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 last_sync_at INTEGER,19 last_sync_error TEXT20);21-- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's.22CREATE TABLE IF NOT EXISTS release_settings (23 repo TEXT NOT NULL,24 version TEXT NOT NULL,25 hidden INTEGER NOT NULL DEFAULT 0,26 notes TEXT,27 PRIMARY KEY (repo, version)28);29-- §22.2: one count per release file; installs are Nix tarball fetches, per repo.30CREATE TABLE IF NOT EXISTS downloads (31 repo TEXT NOT NULL,32 version TEXT NOT NULL,33 platform TEXT NOT NULL,34 count INTEGER NOT NULL DEFAULT 0,35 PRIMARY KEY (repo, version, platform)36);37CREATE TABLE IF NOT EXISTS installs (38 repo TEXT PRIMARY KEY,39 count INTEGER NOT NULL DEFAULT 040);41-- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact42-- email is the operator's alone; token_hash is the follow-up link's SHA-256 (NULL for imports).43CREATE TABLE IF NOT EXISTS issues (44 id INTEGER PRIMARY KEY,45 repo TEXT NOT NULL,46 number INTEGER NOT NULL,47 title TEXT NOT NULL,48 body TEXT NOT NULL,49 type TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')),50 version TEXT NOT NULL DEFAULT '',51 reporter_name TEXT NOT NULL DEFAULT '',52 contact_email TEXT NOT NULL DEFAULT '',53 filed_at INTEGER NOT NULL,54 status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),55 closed_at INTEGER,56 fixed_in TEXT NOT NULL DEFAULT '',57 hidden INTEGER NOT NULL DEFAULT 0,58 ip_hash TEXT NOT NULL DEFAULT '',59 token_hash TEXT,60 UNIQUE (repo, number)61);62CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);63-- The thread after the first post: the reporter's follow-ups and the operator's replies.64CREATE TABLE IF NOT EXISTS issue_entries (65 id INTEGER PRIMARY KEY,66 issue_id INTEGER NOT NULL REFERENCES issues (id),67 author TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),68 body TEXT NOT NULL,69 created_at INTEGER NOT NULL,70 hidden INTEGER NOT NULL DEFAULT 071);72CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);73-- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.74CREATE TABLE IF NOT EXISTS sessions (75 token_hash TEXT PRIMARY KEY,76 expires_at INTEGER NOT NULL77);78-- June's own words for a repo, edited on its page when signed in; '' falls back to the README.79CREATE TABLE IF NOT EXISTS repo_text (80 repo TEXT PRIMARY KEY,81 description TEXT NOT NULL DEFAULT '',82 details TEXT NOT NULL DEFAULT ''83);84CREATE TABLE IF NOT EXISTS connect_codes (85 code_hash TEXT PRIMARY KEY,86 machine TEXT NOT NULL,87 created_at INTEGER NOT NULL,88 expires_at INTEGER NOT NULL,89 used_at INTEGER90);91-- §24: what Kepr tells the operator in the console, newest 200 kept.92CREATE TABLE IF NOT EXISTS notifications (93 id INTEGER PRIMARY KEY,94 kind TEXT NOT NULL,95 text TEXT NOT NULL,96 href TEXT NOT NULL,97 created_at INTEGER NOT NULL,98 read INTEGER NOT NULL DEFAULT 099);100-- §24: notifications on their way to the operator's inbox; the address is kepr.toml's.101CREATE TABLE IF NOT EXISTS email_queue (102 id INTEGER PRIMARY KEY,103 subject TEXT NOT NULL,104 href TEXT NOT NULL,105 status TEXT NOT NULL DEFAULT 'waiting' CHECK (status IN ('waiting', 'sent', 'failed')),106 attempts INTEGER NOT NULL DEFAULT 0,107 next_at INTEGER NOT NULL,108 last_error TEXT NOT NULL DEFAULT '',109 created_at INTEGER NOT NULL,110 sent_at INTEGER111);112`;113114115export const NOTIFICATION_KINDS = [116 "issue_filed",117 "issue_followup",118 "sync_failed",119 "backup_failed",120] as const;121export type NotificationKind = (typeof NOTIFICATION_KINDS)[number];122const NOTIFICATIONS_KEPT = 200;123124125const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [126 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],127 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],128 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],129];130131const DROPPED_COLUMNS: [table: string, column: string][] = [132 ["repos", "sort_order"],133 ["repos", "install1"],134 ["repos", "install2"],135 ["repos", "install3"],136];137138139export type RepoSettings = {140 name: string;141 category: string;142 homepage: string;143 144 languages: string[];145 146 issuesPaused: boolean;147 description: string;148 details: string;149};150151export type RepoText = "description" | "details";152153type RepoRow = {154 name: string;155 category: string;156 homepage: string;157 languages: string;158 issues_paused: number;159 description: string | null;160 details: string | null;161};162163export function openKeprDb(dataDir: string): Database {164 mkdirSync(dataDir, { recursive: true });165 const db = new Database(join(dataDir, "kepr.db"), { strict: true });166 db.run("PRAGMA journal_mode = WAL");167 db.run("PRAGMA busy_timeout = 5000");168 db.run(SCHEMA);169 170 const has = (table: string, column: string) =>171 db172 .query<{ name: string }, []>(`PRAGMA table_info(${table})`)173 .all()174 .some((existing) => existing.name === column);175 for (const [table, column, definition] of ADDED_COLUMNS) {176 if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);177 }178 for (const [table, column] of DROPPED_COLUMNS) {179 if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);180 }181 return db;182}183184export function setting(db: Database, key: string): string | null {185 const row = db186 .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")187 .get(key);188 return row?.value ?? null;189}190191192export function registerRepo(db: Database, name: string): void {193 db.query("INSERT OR IGNORE INTO repos (name) VALUES (?)").run(name);194}195196197198199200export function notify(db: Database, kind: NotificationKind, text: string, href: string): void {201 const now = Math.floor(Date.now() / 1000);202 db.transaction(() => {203 if (!setting(db, `notify_off_${kind}`)) {204 db.query("INSERT INTO notifications (kind, text, href, created_at) VALUES (?, ?, ?, ?)").run(205 kind,206 text,207 href,208 now,209 );210 }211 if (setting(db, `email_on_${kind}`)) {212 db.query(213 "INSERT INTO email_queue (subject, href, next_at, created_at) VALUES (?, ?, ?, ?)",214 ).run(text, href, now, now);215 }216 for (const table of ["notifications", "email_queue"]) {217 218 db.query(219 `DELETE FROM ${table}220 WHERE id NOT IN (SELECT id FROM ${table} ORDER BY id DESC LIMIT ?)`,221 ).run(NOTIFICATIONS_KEPT);222 }223 })();224}225226227export function recordSync(db: Database, name: string, at: number, error: string | null): void {228 const before = db229 .query<{ last_sync_error: string | null }, [string]>(230 "SELECT last_sync_error FROM repos WHERE name = ?",231 )232 .get(name);233 if (error && !before?.last_sync_error) {234 notify(db, "sync_failed", `Sync failed for ${name}: ${error}`, `/admin/repos?repo=${name}`);235 }236 db.query("UPDATE repos SET last_sync_at = ?, last_sync_error = ? WHERE name = ?").run(237 at,238 error,239 name,240 );241}242243export function publicRepoNames(db: Database): string[] {244 return db245 .query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")246 .all()247 .map((row) => row.name);248}249250251export function publicRepo(db: Database, name: string): RepoSettings | null {252 const row = db253 .query<RepoRow, [string]>(254 `SELECT name, category, homepage, languages, issues_paused, description, details255 FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name256 WHERE name = ? AND public = 1`,257 )258 .get(name);259 if (!row) return null;260 return {261 name: row.name,262 category: row.category,263 homepage: row.homepage,264 languages: row.languages === "" ? [] : row.languages.split(", "),265 issuesPaused: row.issues_paused === 1,266 description: row.description ?? "",267 details: row.details ?? "",268 };269}270271272export function repoText(db: Database, repo: string): Record<RepoText, string> {273 return (274 db275 .query<Record<RepoText, string>, [string]>(276 "SELECT description, details FROM repo_text WHERE repo = ?",277 )278 .get(repo) ?? { description: "", details: "" }279 );280}281282283export function setRepoText(db: Database, repo: string, field: RepoText, value: string): void {284 db.query("INSERT OR IGNORE INTO repo_text (repo) VALUES (?)").run(repo);285 286 db.query(`UPDATE repo_text SET ${field} = ? WHERE repo = ?`).run(value, repo);287}