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 languages TEXT NOT NULL DEFAULT '',17 issues_paused INTEGER NOT NULL DEFAULT 0,18 hidden_paths TEXT NOT NULL DEFAULT '',19 last_sync_at INTEGER,20 last_sync_error TEXT21);22-- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's.23CREATE TABLE IF NOT EXISTS release_settings (24 repo TEXT NOT NULL,25 version TEXT NOT NULL,26 hidden INTEGER NOT NULL DEFAULT 0,27 notes TEXT,28 PRIMARY KEY (repo, version)29);30-- §22.2: one count per release file; installs are Nix tarball fetches, per repo.31CREATE TABLE IF NOT EXISTS downloads (32 repo TEXT NOT NULL,33 version TEXT NOT NULL,34 platform TEXT NOT NULL,35 count INTEGER NOT NULL DEFAULT 0,36 PRIMARY KEY (repo, version, platform)37);38CREATE TABLE IF NOT EXISTS installs (39 repo TEXT PRIMARY KEY,40 count INTEGER NOT NULL DEFAULT 041);42-- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact43-- email is the operator's alone; token_hash is the follow-up link's SHA-256 (NULL for imports).44CREATE TABLE IF NOT EXISTS issues (45 id INTEGER PRIMARY KEY,46 repo TEXT NOT NULL,47 number INTEGER NOT NULL,48 title TEXT NOT NULL,49 body TEXT NOT NULL,50 type TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')),51 version TEXT NOT NULL DEFAULT '',52 reporter_name TEXT NOT NULL DEFAULT '',53 contact_email TEXT NOT NULL DEFAULT '',54 filed_at INTEGER NOT NULL,55 status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),56 closed_at INTEGER,57 fixed_in TEXT NOT NULL DEFAULT '',58 hidden INTEGER NOT NULL DEFAULT 0,59 ip_hash TEXT NOT NULL DEFAULT '',60 token_hash TEXT,61 UNIQUE (repo, number)62);63CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);64-- The thread after the first post: the reporter's follow-ups and the operator's replies.65CREATE TABLE IF NOT EXISTS issue_entries (66 id INTEGER PRIMARY KEY,67 issue_id INTEGER NOT NULL REFERENCES issues (id),68 author TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),69 body TEXT NOT NULL,70 created_at INTEGER NOT NULL,71 hidden INTEGER NOT NULL DEFAULT 072);73CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);74-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.75CREATE TABLE IF NOT EXISTS sessions (76 token_hash TEXT PRIMARY KEY,77 expires_at INTEGER NOT NULL78);79-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.80CREATE TABLE IF NOT EXISTS repo_text (81 repo TEXT PRIMARY KEY,82 description TEXT NOT NULL DEFAULT '',83 details TEXT NOT NULL DEFAULT ''84);85CREATE TABLE IF NOT EXISTS connect_codes (86 code_hash TEXT PRIMARY KEY,87 machine TEXT NOT NULL,88 created_at INTEGER NOT NULL,89 expires_at INTEGER NOT NULL,90 used_at INTEGER91);92-- §24: what Kepr tells the operator in the console, newest 200 kept.93CREATE TABLE IF NOT EXISTS notifications (94 id INTEGER PRIMARY KEY,95 kind TEXT NOT NULL,96 text TEXT NOT NULL,97 href TEXT NOT NULL,98 created_at INTEGER NOT NULL,99 read INTEGER NOT NULL DEFAULT 0100);101-- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.102CREATE TABLE IF NOT EXISTS email_queue (103 id INTEGER PRIMARY KEY,104 subject TEXT NOT NULL,105 href TEXT NOT NULL,106 status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),107 attempts INTEGER NOT NULL DEFAULT 0,108 next_at INTEGER NOT NULL,109 last_error TEXT NOT NULL DEFAULT '',110 created_at INTEGER NOT NULL,111 sent_at INTEGER112);113`;114115116export const NOTIFICATION_KINDS = [117 "issue_filed",118 "issue_followup",119 "sync_failed",120 "backup_failed",121] as const;122export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];123const NOTIFICATIONS_KEPT = 200;124125126const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [127 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],128 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],129 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],130 ["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],131];132133const DROPPED_COLUMNS: [table: string, column: string][] = [134 ["repos", "sort_order"],135 ["repos", "install1"],136 ["repos", "install2"],137 ["repos", "install3"],138];139140141export type RepoSettings = {142 name: string;143 category: string;144 homepage: string;145 146 languages: string[];147 148 issuesPaused: boolean;149 description: string;150 details: string;151};152153export type RepoText = "description" | "details";154155type RepoRow = {156 name: string;157 category: string;158 homepage: string;159 languages: string;160 issues_paused: number;161 description: string | null;162 details: string | null;163};164165export function openKeprDb(dataDir: string): Database {166 mkdirSync(dataDir, { recursive: true });167 const db = new Database(join(dataDir, "kepr.db"), { strict: true });168 db.run("PRAGMA journal_mode = WAL");169 db.run("PRAGMA busy_timeout = 5000");170 db.run(SCHEMA);171 172 const has = (table: string, column: string) =>173 db174 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)175 .all()176 .some((existing) => existing.name === column);177 for (const [table, column, definition] of ADDED_COLUMNS) {178 if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);179 }180 for (const [table, column] of DROPPED_COLUMNS) {181 if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);182 }183 return db;184}185186export function setting(db: Database, key: string): string | null {187 const row = db188 .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")189 .get(key);190 return row?.value ?? null;191}192193194export function registerRepo(db: Database, name: string): void {195 db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);196}197198199200201202export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {203 const now = Math.floor(Date.now() / 1000);204 db.transaction(() => {205 if (!setting(db, `notify_off_${kind}`)) {206 db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(207 kind,208 text,209 href,210 now,211 );212 }213 if (setting(db, `email_on_${kind}`)) {214 db.query(215 "INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",216 ).run(text, href, now, now);217 }218 for (const table of ["notifications", "email_queue"]) {219 220 db.query(221 `DELETE FROM ${table}222 WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,223 ).run(NOTIFICATIONS_KEPT);224 }225 })();226}227228229export function recordSync(db: Database, name: string, at: number, error: string | null): void {230 const before = db231 .query<{ last_sync_error: string | null }, [string]>(232 "SELECT last_sync_error FROM repos WHERE name = ?",233 )234 .get(name);235 if (error && !before?.last_sync_error) {236 notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);237 }238 db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(239 at,240 error,241 name,242 );243}244245export function publicRepoNames(db: Database): string[] {246 return db247 .query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")248 .all()249 .map((row) => row.name);250}251252253export function publicRepo(db: Database, name: string): RepoSettings | null {254 const row = db255 .query<RepoRow, [string]>(256 `SELECT name, category, homepage, languages, issues_paused, description, details257 FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name258 WHERE name = ? AND public = 1`,259 )260 .get(name);261 if (!row) return null;262 return {263 name: row.name,264 category: row.category,265 homepage: row.homepage,266 languages: row.languages === "" ? [] : row.languages.split(", "),267 issuesPaused: row.issues_paused === 1,268 description: row.description ?? "",269 details: row.details ?? "",270 };271}272273274export function hiddenPaths(db: Database, repo: string): string[] {275 const row = db276 .query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")277 .get(repo);278 return row?.hidden_paths ? row.hidden_paths.split("\n") : [];279}280281282export function repoText(db: Database, repo: string): Record<RepoText, string> {283 return (284 db285 .query<Record<RepoText, string>, [string]>(286 "SELECT description, details FROM repo_text WHERE repo = ?",287 )288 .get(repo) ?? { description: "", details: "" }289 );290}291292293export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {294 db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);295 296 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);297}