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; token_hash is the follow-up link's SHA-256 (NULL for imports).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 token_hash TEXT,71 UNIQUE (repo, number)72);73CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);74-- The thread after the first post: the reporter's follow-ups and the operator's replies.75-- §23: addresses blocked from filing and following up, by the hash the rate limit keeps, with the76-- issue they were blocked from.77CREATE TABLE IF NOT EXISTS blocked_reporters (78 id INTEGER PRIMARY KEY,79 ip_hash TEXT NOT NULL UNIQUE,80 repo TEXT NOT NULL,81 number INTEGER NOT NULL,82 blocked_at INTEGER NOT NULL83);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 body TEXT NOT NULL,89 created_at INTEGER NOT NULL,90 hidden INTEGER NOT NULL DEFAULT 091);92CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);93-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.94CREATE TABLE IF NOT EXISTS sessions (95 token_hash TEXT PRIMARY KEY,96 expires_at INTEGER NOT NULL97);98-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.99CREATE TABLE IF NOT EXISTS repo_text (100 repo TEXT PRIMARY KEY,101 description TEXT NOT NULL DEFAULT '',102 details TEXT NOT NULL DEFAULT ''103);104CREATE TABLE IF NOT EXISTS connect_codes (105 code_hash TEXT PRIMARY KEY,106 machine TEXT NOT NULL,107 created_at INTEGER NOT NULL,108 expires_at INTEGER NOT NULL,109 used_at INTEGER110);111-- §24: what Kepr tells the operator in the console, newest 200 kept.112CREATE TABLE IF NOT EXISTS notifications (113 id INTEGER PRIMARY KEY,114 kind TEXT NOT NULL,115 text TEXT NOT NULL,116 href TEXT NOT NULL,117 created_at INTEGER NOT NULL,118 read INTEGER NOT NULL DEFAULT 0119);120-- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.121CREATE TABLE IF NOT EXISTS email_queue (122 id INTEGER PRIMARY KEY,123 subject TEXT NOT NULL,124 href TEXT NOT NULL,125 status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),126 attempts INTEGER NOT NULL DEFAULT 0,127 next_at INTEGER NOT NULL,128 last_error TEXT NOT NULL DEFAULT '',129 created_at INTEGER NOT NULL,130 sent_at INTEGER131);132-- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.133CREATE TABLE IF NOT EXISTS renamed (134 old TEXT PRIMARY KEY,135 new TEXT NOT NULL136);137`;138139140export const NOTIFICATION_KINDS = [141 "issue_filed",142 "issue_followup",143 "sync_failed",144 "backup_failed",145] as const;146export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];147const NOTIFICATIONS_KEPT = 200;148149150const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [151 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],152 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],153 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],154 ["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],155 ["repos", "hidden_handed", "TEXT"],156];157158const DROPPED_COLUMNS: [table: string, column: string][] = [159 ["repos", "sort_order"],160 ["repos", "install1"],161 ["repos", "install2"],162 ["repos", "install3"],163];164165166export type RepoSettings = {167 name: string;168 category: string;169 homepage: string;170 171 languages: string[];172 173 issuesPaused: boolean;174 description: string;175 details: string;176};177178export type RepoText = "description" | "details";179180type RepoRow = {181 name: string;182 category: string;183 homepage: string;184 languages: string;185 issues_paused: number;186 description: string | null;187 details: string | null;188};189190export function openKeprDb(dataDir: string): Database {191 mkdirSync(dataDir, { recursive: true });192 const db = new Database(join(dataDir, "kepr.db"), { strict: true });193 db.run("PRAGMA journal_mode = WAL");194 db.run("PRAGMA busy_timeout = 5000");195 db.run(SCHEMA);196 197 const has = (table: string, column: string) =>198 db199 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)200 .all()201 .some((existing) => existing.name === column);202 for (const [table, column, definition] of ADDED_COLUMNS) {203 if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);204 }205 for (const [table, column] of DROPPED_COLUMNS) {206 if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);207 }208 return db;209}210211export function setting(db: Database, key: string): string | null {212 const row = db213 .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")214 .get(key);215 return row?.value ?? null;216}217218219export function renamedTo(db: Database, name: string): string | null {220 const query = db.query<{ new: string }, [string]>("SELECT new FROM renamed WHERE old = ?");221 let to: string | null = null;222 for (let row = query.get(name), hops = 0; row && hops < 5; row = query.get(row.new), hops++) {223 to = row.new;224 }225 return to;226}227228229export function registerRepo(db: Database, name: string): void {230 db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);231}232233234235236237export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {238 const now = Math.floor(Date.now() / 1000);239 db.transaction(() => {240 if (!setting(db, `notify_off_${kind}`)) {241 db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(242 kind,243 text,244 href,245 now,246 );247 }248 if (setting(db, `email_on_${kind}`)) {249 db.query(250 "INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",251 ).run(text, href, now, now);252 }253 for (const table of ["notifications", "email_queue"]) {254 255 db.query(256 `DELETE FROM ${table}257 WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,258 ).run(NOTIFICATIONS_KEPT);259 }260 })();261}262263264export function recordSync(db: Database, name: string, at: number, error: string | null): void {265 const before = db266 .query<{ last_sync_error: string | null }, [string]>(267 "SELECT last_sync_error FROM repos WHERE name = ?",268 )269 .get(name);270 if (error && !before?.last_sync_error) {271 notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);272 }273 db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(274 at,275 error,276 name,277 );278}279280export function publicRepoNames(db: Database): string[] {281 return db282 .query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")283 .all()284 .map((row) => row.name);285}286287288export function publicRepo(db: Database, name: string): RepoSettings | null {289 const row = db290 .query<RepoRow, [string]>(291 `SELECT name, category, homepage, languages, issues_paused, description, details292 FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name293 WHERE name = ? AND public = 1`,294 )295 .get(name);296 if (!row) return null;297 return {298 name: row.name,299 category: row.category,300 homepage: row.homepage,301 languages: row.languages === "" ? [] : row.languages.split(", "),302 issuesPaused: row.issues_paused === 1,303 description: row.description ?? "",304 details: row.details ?? "",305 };306}307308309310311312export function hiddenPaths(db: Database, repo: string): string[] {313 const row = db314 .query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")315 .get(repo);316 const paths = row?.hidden_paths ? row.hidden_paths.split("\n") : [];317 return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];318}319320321export function keepState(db: Database, key: string, value: string | null): void {322 if (value === null) db.query("DELETE FROM settings WHERE key = ?").run(key);323 else db.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);324}325326327export function hiddenToHand(db: Database, repo: string): string | null {328 const row = db329 .query<{ hidden_paths: string }, [string]>(330 `SELECT hidden_paths FROM repos331 WHERE name = ?332 AND (hidden_handed IS NULL OR hidden_paths != hidden_handed)`,333 )334 .get(repo);335 return row?.hidden_paths ?? null;336}337338339export function recordHiddenHanded(db: Database, repo: string, list: string | null): void {340 db.query("UPDATE repos SET hidden_handed = ? WHERE name = ?").run(list, repo);341}342343344export function ignoredNames(db: Database): string[] {345 return setting(db, "ignored")?.split("\n").filter(Boolean) ?? [];346}347348349export function repoText(db: Database, repo: string): Record<RepoText, string> {350 return (351 db352 .query<Record<RepoText, string>, [string]>(353 "SELECT description, details FROM repo_text WHERE repo = ?",354 )355 .get(repo) ?? { description: "", details: "" }356 );357}358359360export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {361 db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);362 363 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);364}