1import { Database } from "bun:sqlite";2import { mkdirSync } from "node:fs";3import { join } from "node:path";456const 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`;949596export type RepoSettings = {97 name: string;98 category: string;99 homepage: string;100 installLines: string[];101 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 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}141142143export 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}163164165export 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}186187188export 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}197198199export 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 202 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);203}