1import { Database } from "bun:sqlite";2import { mkdirSync } from "node:fs";3import { join } from "node:path";4import { ANY_DEPTH } from "../mirror/store.ts";567const SCHEMA = `8CREATE TABLE IF NOT EXISTS settings (9 key TEXT PRIMARY KEY,10 value TEXT NOT NULL11);12CREATE TABLE IF NOT EXISTS repos (13 name TEXT PRIMARY KEY,14 public INTEGER NOT NULL DEFAULT 0,15 category TEXT NOT NULL DEFAULT '',16 homepage TEXT NOT NULL DEFAULT '',17 languages TEXT NOT NULL DEFAULT '',18 issues_paused INTEGER NOT NULL DEFAULT 0,19 hidden_paths TEXT NOT NULL DEFAULT '',20 -- The hidden list last written to .kepr/hidden/<repo>; NULL while unknown.21 hidden_handed TEXT,22 last_sync_at INTEGER,23 last_sync_error TEXT24);25-- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's.26CREATE TABLE IF NOT EXISTS release_settings (27 repo TEXT NOT NULL,28 version TEXT NOT NULL,29 hidden INTEGER NOT NULL DEFAULT 0,30 notes TEXT,31 PRIMARY KEY (repo, version)32);33-- §22.2: one count per release file; installs are Nix tarball fetches, per repo.34CREATE TABLE IF NOT EXISTS downloads (35 repo TEXT NOT NULL,36 version TEXT NOT NULL,37 platform TEXT NOT NULL,38 count INTEGER NOT NULL DEFAULT 0,39 PRIMARY KEY (repo, version, platform)40);41-- §24's Downloads: the same downloads per repo and UTC day (YYYY-MM-DD), the newest 400 days.42CREATE TABLE IF NOT EXISTS download_days (43 repo TEXT NOT NULL,44 day TEXT NOT NULL,45 count INTEGER NOT NULL DEFAULT 0,46 PRIMARY KEY (repo, day)47);48CREATE TABLE IF NOT EXISTS installs (49 repo TEXT PRIMARY KEY,50 count INTEGER NOT NULL DEFAULT 051);52-- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact53-- email is the operator's alone.54CREATE TABLE IF NOT EXISTS issues (55 id INTEGER PRIMARY KEY,56 repo TEXT NOT NULL,57 number INTEGER NOT NULL,58 title TEXT NOT NULL,59 body TEXT NOT NULL,60 type TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')),61 version TEXT NOT NULL DEFAULT '',62 reporter_name TEXT NOT NULL DEFAULT '',63 contact_email TEXT NOT NULL DEFAULT '',64 filed_at INTEGER NOT NULL,65 status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),66 closed_at INTEGER,67 fixed_in TEXT NOT NULL DEFAULT '',68 hidden INTEGER NOT NULL DEFAULT 0,69 ip_hash TEXT NOT NULL DEFAULT '',70 UNIQUE (repo, number)71);72CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);73-- §23: addresses blocked from filing, replying and following, by the hash the rate limit keeps,74-- with the issue they were blocked from.75CREATE TABLE IF NOT EXISTS blocked_reporters (76 id INTEGER PRIMARY KEY,77 ip_hash TEXT NOT NULL UNIQUE,78 repo TEXT NOT NULL,79 number INTEGER NOT NULL,80 blocked_at INTEGER NOT NULL81);82-- The thread after the first post. 'reporter' is anyone from the public, the issue's own reporter83-- or not, under the name they chose; reply_to is the reply this one answers.84CREATE TABLE IF NOT EXISTS issue_entries (85 id INTEGER PRIMARY KEY,86 issue_id INTEGER NOT NULL REFERENCES issues (id),87 author TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),88 name TEXT NOT NULL DEFAULT '',89 body TEXT NOT NULL,90 created_at INTEGER NOT NULL,91 hidden INTEGER NOT NULL DEFAULT 0,92 ip_hash TEXT NOT NULL DEFAULT '',93 reply_to INTEGER94);95CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);96-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.97CREATE TABLE IF NOT EXISTS sessions (98 token_hash TEXT PRIMARY KEY,99 expires_at INTEGER NOT NULL100);101-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.102CREATE TABLE IF NOT EXISTS repo_text (103 repo TEXT PRIMARY KEY,104 description TEXT NOT NULL DEFAULT '',105 details TEXT NOT NULL DEFAULT ''106);107CREATE TABLE IF NOT EXISTS connect_codes (108 code_hash TEXT PRIMARY KEY,109 machine TEXT NOT NULL,110 created_at INTEGER NOT NULL,111 expires_at INTEGER NOT NULL,112 used_at INTEGER113);114-- §24: what Kepr tells the operator in the console, newest 200 kept.115CREATE TABLE IF NOT EXISTS notifications (116 id INTEGER PRIMARY KEY,117 kind TEXT NOT NULL,118 text TEXT NOT NULL,119 href TEXT NOT NULL,120 created_at INTEGER NOT NULL,121 read INTEGER NOT NULL DEFAULT 0122);123-- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.124CREATE TABLE IF NOT EXISTS email_queue (125 id INTEGER PRIMARY KEY,126 subject TEXT NOT NULL,127 href TEXT NOT NULL,128 status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),129 attempts INTEGER NOT NULL DEFAULT 0,130 next_at INTEGER NOT NULL,131 last_error TEXT NOT NULL DEFAULT '',132 created_at INTEGER NOT NULL,133 sent_at INTEGER134);135-- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.136CREATE TABLE IF NOT EXISTS renamed (137 old TEXT PRIMARY KEY,138 new TEXT NOT NULL139);140`;141142143export const NOTIFICATION_KINDS = [144 "issue_filed",145 "issue_followup",146 "sync_failed",147 "backup_failed",148] as const;149export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];150const NOTIFICATIONS_KEPT = 200;151152153const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [154 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],155 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],156 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],157 ["issue_entries", "name", "TEXT NOT NULL DEFAULT ''"],158 ["issue_entries", "ip_hash", "TEXT NOT NULL DEFAULT ''"],159 ["issue_entries", "reply_to", "INTEGER"],160 ["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],161 ["repos", "hidden_handed", "TEXT"],162];163164const DROPPED_COLUMNS: [table: string, column: string][] = [165 ["repos", "sort_order"],166 ["repos", "install1"],167 ["repos", "install2"],168 ["repos", "install3"],169 170 ["issues", "token_hash"],171];172173174export type RepoSettings = {175 name: string;176 category: string;177 homepage: string;178 179 languages: string[];180 181 issuesPaused: boolean;182 description: string;183 details: string;184};185186export type RepoText = "description" | "details";187188type RepoRow = {189 name: string;190 category: string;191 homepage: string;192 languages: string;193 issues_paused: number;194 description: string | null;195 details: string | null;196};197198export function openKeprDb(dataDir: string): Database {199 mkdirSync(dataDir, { recursive: true });200 const db = new Database(join(dataDir, "kepr.db"), { strict: true });201 db.run("PRAGMA journal_mode = WAL");202 db.run("PRAGMA busy_timeout = 5000");203 db.run(SCHEMA);204 205 const has = (table: string, column: string) =>206 db207 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)208 .all()209 .some((existing) => existing.name === column);210 const named = has("issue_entries", "name");211 for (const [table, column, definition] of ADDED_COLUMNS) {212 if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);213 }214 215 if (!named) {216 db.run(`UPDATE issue_entries SET name = (217 SELECT reporter_name FROM issues WHERE issues.id = issue_entries.issue_id218) WHERE author = 'reporter'`);219 }220 for (const [table, column] of DROPPED_COLUMNS) {221 if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);222 }223 db.run("CREATE INDEX IF NOT EXISTS issue_entries_ip ON issue_entries (ip_hash, created_at)");224 return db;225}226227export function setting(db: Database, key: string): string | null {228 const row = db229 .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")230 .get(key);231 return row?.value ?? null;232}233234235export function renamedTo(db: Database, name: string): string | null {236 const query = db.query<{ new: string }, [string]>("SELECT new FROM renamed WHERE old = ?");237 let to: string | null = null;238 for (let row = query.get(name), hops = 0; row && hops < 5; row = query.get(row.new), hops++) {239 to = row.new;240 }241 return to;242}243244export function isRegistered(db: Database, name: string): boolean {245 return db.query("SELECT 1 FROM repos WHERE name = ?").get(name) !== null;246}247248249export function registerRepo(db: Database, name: string): void {250 db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);251}252253254255256257export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {258 const now = Math.floor(Date.now() / 1000);259 260 db.transaction(() => {261 if (!setting(db, `notify_off_${kind}`)) {262 db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(263 kind,264 text,265 href,266 now,267 );268 }269 if (setting(db, `email_on_${kind}`)) {270 db.query(271 "INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",272 ).run(text, href, now, now);273 }274 for (const table of ["notifications", "email_queue"]) {275 276 db.query(277 `DELETE FROM ${table}278 WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,279 ).run(NOTIFICATIONS_KEPT);280 }281 }).immediate();282}283284285export function recordSync(db: Database, name: string, at: number, error: string | null): void {286 const before = db287 .query<{ last_sync_error: string | null }, [string]>(288 "SELECT last_sync_error FROM repos WHERE name = ?",289 )290 .get(name);291 if (error && !before?.last_sync_error) {292 notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);293 }294 db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(295 at,296 error,297 name,298 );299}300301export function publicRepoNames(db: Database): string[] {302 return db303 .query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")304 .all()305 .map((row) => row.name);306}307308309export function publicRepo(db: Database, name: string): RepoSettings | null {310 const row = db311 .query<RepoRow, [string]>(312 `SELECT name, category, homepage, languages, issues_paused, description, details313 FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name314 WHERE name = ? AND public = 1`,315 )316 .get(name);317 if (!row) return null;318 return {319 name: row.name,320 category: row.category,321 homepage: row.homepage,322 languages: row.languages === "" ? [] : row.languages.split(", "),323 issuesPaused: row.issues_paused === 1,324 description: row.description ?? "",325 details: row.details ?? "",326 };327}328329330331332333export function hiddenPaths(db: Database, repo: string): string[] {334 const row = db335 .query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")336 .get(repo);337 const paths = row?.hidden_paths ? row.hidden_paths.split("\n") : [];338 return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];339}340341342export function keepState(db: Database, key: string, value: string | null): void {343 if (value === null) db.query("DELETE FROM settings WHERE key = ?").run(key);344 else db.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);345}346347348export function hiddenToHand(db: Database, repo: string): string | null {349 const row = db350 .query<{ hidden_paths: string }, [string]>(351 `SELECT hidden_paths FROM repos352 WHERE name = ?353 AND (hidden_handed IS NULL OR hidden_paths != hidden_handed)`,354 )355 .get(repo);356 return row?.hidden_paths ?? null;357}358359360export function recordHiddenHanded(db: Database, repo: string, list: string | null): void {361 db.query("UPDATE repos SET hidden_handed = ? WHERE name = ?").run(list, repo);362}363364365export function ignoredNames(db: Database): string[] {366 return setting(db, "ignored")?.split("\n").filter(Boolean) ?? [];367}368369370export function repoText(db: Database, repo: string): Record<RepoText, string> {371 return (372 db373 .query<Record<RepoText, string>, [string]>(374 "SELECT description, details FROM repo_text WHERE repo = ?",375 )376 .get(repo) ?? { description: "", details: "" }377 );378}379380381export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {382 db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);383 384 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);385}