db.ts

6.8 KB · raw

1import { Database } from "bun:sqlite";2import { mkdirSync } from "node:fs";3import { join } from "node:path";45// §19: only what the pages, the sync loop and issues use so far; later phases add their tables.6const SCHEMA = `7CREATE TABLE IF NOT EXISTS settings (8	key   TEXT PRIMARY KEY,9	value TEXT NOT NULL10);11CREATE TABLE IF NOT EXISTS repos (12	name            TEXT PRIMARY KEY,13	public          INTEGER NOT NULL DEFAULT 0,14	category        TEXT NOT NULL DEFAULT '',15	homepage        TEXT NOT NULL DEFAULT '',16	sort_order      INTEGER NOT NULL DEFAULT 0,17	install1        TEXT NOT NULL DEFAULT '',18	install2        TEXT NOT NULL DEFAULT '',19	install3        TEXT NOT NULL DEFAULT '',20	languages       TEXT NOT NULL DEFAULT '',21	last_sync_at    INTEGER,22	last_sync_error TEXT23);24-- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's.25CREATE TABLE IF NOT EXISTS release_settings (26	repo    TEXT NOT NULL,27	version TEXT NOT NULL,28	hidden  INTEGER NOT NULL DEFAULT 0,29	notes   TEXT,30	PRIMARY KEY (repo, version)31);32-- §22.2: one count per release file; installs are Nix tarball fetches, per repo.33CREATE TABLE IF NOT EXISTS downloads (34	repo     TEXT NOT NULL,35	version  TEXT NOT NULL,36	platform TEXT NOT NULL,37	count    INTEGER NOT NULL DEFAULT 0,38	PRIMARY KEY (repo, version, platform)39);40CREATE TABLE IF NOT EXISTS installs (41	repo  TEXT PRIMARY KEY,42	count INTEGER NOT NULL DEFAULT 043);44-- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact45-- email is the operator's alone; token_hash is the follow-up link's SHA-256 (NULL for imports).46CREATE TABLE IF NOT EXISTS issues (47	id            INTEGER PRIMARY KEY,48	repo          TEXT NOT NULL,49	number        INTEGER NOT NULL,50	title         TEXT NOT NULL,51	body          TEXT NOT NULL,52	type          TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')),53	version       TEXT NOT NULL DEFAULT '',54	reporter_name TEXT NOT NULL DEFAULT '',55	contact_email TEXT NOT NULL DEFAULT '',56	filed_at      INTEGER NOT NULL,57	status        TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),58	closed_at     INTEGER,59	fixed_in      TEXT NOT NULL DEFAULT '',60	hidden        INTEGER NOT NULL DEFAULT 0,61	ip_hash       TEXT NOT NULL DEFAULT '',62	token_hash    TEXT,63	UNIQUE (repo, number)64);65CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);66-- The thread after the first post: the reporter's follow-ups and the operator's replies.67CREATE TABLE IF NOT EXISTS issue_entries (68	id         INTEGER PRIMARY KEY,69	issue_id   INTEGER NOT NULL REFERENCES issues (id),70	author     TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),71	body       TEXT NOT NULL,72	created_at INTEGER NOT NULL73);74CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);75-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.76CREATE TABLE IF NOT EXISTS sessions (77	token_hash TEXT PRIMARY KEY,78	expires_at INTEGER NOT NULL79);80-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.81CREATE TABLE IF NOT EXISTS repo_text (82	repo        TEXT PRIMARY KEY,83	description TEXT NOT NULL DEFAULT '',84	details     TEXT NOT NULL DEFAULT ''85);86CREATE TABLE IF NOT EXISTS connect_codes (87	code_hash   TEXT PRIMARY KEY,88	machine     TEXT NOT NULL,89	created_at  INTEGER NOT NULL,90	expires_at  INTEGER NOT NULL,91	used_at     INTEGER92);93`;9495/** `description` and `details` are the operator's Markdown, or '' for the README's. */96export type RepoSettings = {97	name: string;98	category: string;99	homepage: string;100	installLines: string[];101	/** The operator's shown languages (§24), or none for the detected one. */102	languages: string[];103	description: string;104	details: string;105};106107export type RepoText = "description" | "details";108109type RepoRow = {110	name: string;111	category: string;112	homepage: string;113	install1: string;114	install2: string;115	install3: string;116	languages: string;117	description: string | null;118	details: string | null;119};120121export function openKeprDb(dataDir: string): Database {122	mkdirSync(dataDir, { recursive: true });123	const db = new Database(join(dataDir, "kepr.db"), { strict: true });124	db.run("PRAGMA journal_mode = WAL");125	db.run("PRAGMA busy_timeout = 5000");126	db.run(SCHEMA);127	// Added after the first kepr.db was made; SQLite has no ADD COLUMN IF NOT EXISTS.128	const columns = db.query<{ name: string }, []>("PRAGMA table_info(repos)").all();129	if (!columns.some((column) => column.name === "languages")) {130		db.run("ALTER TABLE repos ADD COLUMN languages TEXT NOT NULL DEFAULT ''");131	}132	return db;133}134135export function setting(db: Database, key: string): string | null {136	const row = db137		.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")138		.get(key);139	return row?.value ?? null;140}141142/** Every repo starts private (§20); registering a known repo changes nothing. */143export function registerRepo(db: Database, name: string): void {144	db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);145}146147export function recordSync(db: Database, name: string, at: number, error: string | null): void {148	db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(149		at,150		error,151		name,152	);153}154155export function publicRepoNames(db: Database): string[] {156	return db157		.query<{ name: string }, []>(158			"SELECT name FROM repos WHERE public = 1 ORDER BY sort_order, name",159		)160		.all()161		.map((row) => row.name);162}163164/** The settings of a public repo, or null when it is private or unknown: both must look missing. */165export function publicRepo(db: Database, name: string): RepoSettings | null {166	const row = db167		.query<RepoRow, [string]>(168			`SELECT name, category, homepage, install1, install2, install3, languages, description,169			details170			FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name171			WHERE name = ? AND public = 1`,172		)173		.get(name);174	if (!row) return null;175	const installLines = [row.install1, row.install2, row.install3].filter((line) => line !== "");176	return {177		name: row.name,178		category: row.category,179		homepage: row.homepage,180		installLines,181		languages: row.languages === "" ? [] : row.languages.split(", "),182		description: row.description ?? "",183		details: row.details ?? "",184	};185}186187/** A repo's own texts, public or not; '' where the README speaks. */188export function repoText(db: Database, repo: string): Record<RepoText, string> {189	return (190		db191			.query<Record<RepoText, string>, [string]>(192				"SELECT description, details FROM repo_text WHERE repo = ?",193			)194			.get(repo) ?? { description: "", details: "" }195	);196}197198/** Sets one of a repo's texts; '' hands it back to the README. */199export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {200	db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);201	// `field` is one of two column names, never input.202	db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);203}