import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; // §19: only what the pages, the sync loop and issues use so far; later phases add their tables. const SCHEMA = ` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS repos ( name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0, category TEXT NOT NULL DEFAULT '', homepage TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, install1 TEXT NOT NULL DEFAULT '', install2 TEXT NOT NULL DEFAULT '', install3 TEXT NOT NULL DEFAULT '', last_sync_at INTEGER, last_sync_error TEXT ); -- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's. CREATE TABLE IF NOT EXISTS release_settings ( repo TEXT NOT NULL, version TEXT NOT NULL, hidden INTEGER NOT NULL DEFAULT 0, notes TEXT, PRIMARY KEY (repo, version) ); -- §22.2: one count per release file; installs are Nix tarball fetches, per repo. CREATE TABLE IF NOT EXISTS downloads ( repo TEXT NOT NULL, version TEXT NOT NULL, platform TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (repo, version, platform) ); CREATE TABLE IF NOT EXISTS installs ( repo TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0 ); -- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact -- email is the operator's alone; token_hash is the follow-up link's SHA-256 (NULL for imports). CREATE TABLE IF NOT EXISTS issues ( id INTEGER PRIMARY KEY, repo TEXT NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')), version TEXT NOT NULL DEFAULT '', reporter_name TEXT NOT NULL DEFAULT '', contact_email TEXT NOT NULL DEFAULT '', filed_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), closed_at INTEGER, fixed_in TEXT NOT NULL DEFAULT '', hidden INTEGER NOT NULL DEFAULT 0, ip_hash TEXT NOT NULL DEFAULT '', token_hash TEXT, UNIQUE (repo, number) ); CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at); -- The thread after the first post: the reporter's follow-ups and the operator's replies. CREATE TABLE IF NOT EXISTS issue_entries ( id INTEGER PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues (id), author TEXT NOT NULL CHECK (author IN ('reporter', 'operator')), body TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id); -- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept. CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL ); `; export type RepoSettings = { name: string; category: string; homepage: string; installLines: string[]; }; type RepoRow = { name: string; category: string; homepage: string; install1: string; install2: string; install3: string; }; export function openKeprDb(dataDir: string): Database { mkdirSync(dataDir, { recursive: true }); const db = new Database(join(dataDir, "kepr.db"), { strict: true }); db.run("PRAGMA journal_mode = WAL"); db.run("PRAGMA busy_timeout = 5000"); db.run(SCHEMA); return db; } export function setting(db: Database, key: string): string | null { const row = db .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?") .get(key); return row?.value ?? null; } /** Every repo starts private (§20); registering a known repo changes nothing. */ export function registerRepo(db: Database, name: string): void { db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name); } export function recordSync(db: Database, name: string, at: number, error: string | null): void { db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run( at, error, name, ); } export function publicRepoNames(db: Database): string[] { return db .query<{ name: string }, []>( "SELECT name FROM repos WHERE public = 1 ORDER BY sort_order, name", ) .all() .map((row) => row.name); } /** The settings of a public repo, or null when it is private or unknown: both must look missing. */ export function publicRepo(db: Database, name: string): RepoSettings | null { const row = db .query( "SELECT name, category, homepage, install1, install2, install3 FROM repos WHERE name = ? AND public = 1", ) .get(name); if (!row) return null; const installLines = [row.install1, row.install2, row.install3].filter((line) => line !== ""); return { name: row.name, category: row.category, homepage: row.homepage, installLines }; }