import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { inflateSync } from "node:zlib"; import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts"; // Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs, // plus `releases`: each version's release.json (§11.2) as read from the bucket, since that is bucket // data too and pages and feeds need it on every request. const SCHEMA_VERSION = 2; const SCHEMA = ` CREATE TABLE objects ( hash TEXT PRIMARY KEY, size INTEGER NOT NULL, data BLOB NOT NULL ); CREATE TABLE saves ( id TEXT PRIMARY KEY, parent TEXT REFERENCES saves(id), message TEXT NOT NULL, created_at INTEGER NOT NULL, machine TEXT NOT NULL, is_checkpoint INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0, agent TEXT, session TEXT, restores TEXT, replayed_from TEXT ); CREATE TABLE save_files ( save_id TEXT NOT NULL REFERENCES saves(id), path TEXT NOT NULL, hash TEXT NOT NULL REFERENCES objects(hash), executable INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (save_id, path) ); CREATE TABLE events ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, save_id TEXT, path TEXT, reason TEXT, created_at INTEGER NOT NULL, machine TEXT NOT NULL ); CREATE TABLE state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE releases ( version TEXT PRIMARY KEY, record TEXT NOT NULL ); CREATE INDEX saves_parent ON saves(parent); CREATE INDEX save_files_hash ON save_files(hash); `; /** An object as the bucket stores it: zlib bytes, already checked against its hash. */ export type StoredObject = { compressed: Uint8Array; size: number }; /** What one sync brings in, landed in one transaction. Saves are oldest first. */ export type Landing = { head: string; releasesLatest: string; saves: SaveRecord[]; objects: Map; events: EventRecord[]; releases: ReleaseRecord[]; }; // Display names for the header's language, by extension; data and docs formats never count. const LANGUAGES: Record = { ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", mjs: "JavaScript", zig: "Zig", v: "V", rs: "Rust", go: "Go", py: "Python", c: "C", h: "C", cpp: "C++", cc: "C++", hpp: "C++", swift: "Swift", kt: "Kotlin", java: "Java", rb: "Ruby", lua: "Lua", sh: "Shell", nix: "Nix", css: "CSS", html: "HTML", }; // Vendored code is someone else's: counting Koh 3's bundled SQLite made it read as 290k lines of C. const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//; const open = new Map(); /** The mirror of one repo at `/.db`, created on first use and kept open. */ export function openMirror(dir: string, repo: string): Database { const path = join(dir, `${repo}.db`); const cached = open.get(path); if (cached) return cached; mkdirSync(dir, { recursive: true }); const db = new Database(path, { strict: true }); db.run("PRAGMA journal_mode = WAL"); db.run("PRAGMA foreign_keys = ON"); db.run("PRAGMA busy_timeout = 5000"); const { user_version } = db.query<{ user_version: number }, []>("PRAGMA user_version").get() ?? { user_version: 0, }; if (user_version !== 0 && user_version !== SCHEMA_VERSION) { db.close(); throw new Error( `${path} has mirror schema version ${user_version}; this Kepr reads version ${SCHEMA_VERSION}. The mirror is disposable: delete it and the next sync rebuilds it from the bucket.`, ); } if (user_version === 0) { db.transaction(() => { db.run(SCHEMA); db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`); })(); } // Mirrors synced before the per-language lines existed get them now, not at the next save. const head = mirrorState(db, "head"); if (head && mirrorState(db, "languages") === null) refreshStats(db, head); open.set(path, db); return db; } export function mirrorState(db: Database, key: string): string | null { const row = db .query<{ value: string }, [string]>("SELECT value FROM state WHERE key = ?") .get(key); return row?.value ?? null; } export function knownSave(db: Database, id: string): boolean { return db.query("SELECT 1 FROM saves WHERE id = ?").get(id) !== null; } export function knownObject(db: Database, hash: string): boolean { return db.query("SELECT 1 FROM objects WHERE hash = ?").get(hash) !== null; } export function knownEventIds(db: Database): Set { const rows = db.query<{ id: string }, []>("SELECT id FROM events").all(); return new Set(rows.map((row) => row.id)); } export function knownReleases(db: Database): Set { const rows = db.query<{ version: string }, []>("SELECT version FROM releases").all(); return new Set(rows.map((row) => row.version)); } /** Lands a sync like Koh's steal: objects, then events, then saves, then strips, all or nothing. */ export function land(db: Database, landing: Landing): void { const putObject = db.query("INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)"); const putEvent = db.query( "INSERT OR IGNORE INTO events (id, kind, save_id, path, reason, created_at, machine) VALUES (?, ?, ?, ?, ?, ?, ?)", ); const putSave = db.query( `INSERT OR IGNORE INTO saves (id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent, session, restores, replayed_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const putFile = db.query( "INSERT OR IGNORE INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)", ); const putRelease = db.query("INSERT OR IGNORE INTO releases (version, record) VALUES (?, ?)"); const setState = db.query("INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)"); db.transaction(() => { for (const [hash, object] of landing.objects) putObject.run(hash, object.size, object.compressed); for (const e of landing.events) { putEvent.run(e.id, e.kind, e.save_id, e.path, e.reason, e.created_at, e.machine); } for (const s of landing.saves) { putSave.run( s.id, s.parent === "" ? null : s.parent, s.message, s.created_at, s.machine, s.is_checkpoint ? 1 : 0, s.size_bytes, s.agent, s.session, s.restores, s.replayed_from, ); for (const f of s.files) putFile.run(s.id, f.path, f.hash, f.executable ? 1 : 0); } for (const r of landing.releases) putRelease.run(r.version, JSON.stringify(r)); applyStrips(db); setState.run("head", landing.head); setState.run("releases_latest", landing.releasesLatest); refreshStats(db, landing.head); })(); } // §10.2: a strip of one save reaches that save; a strip of every save reaches the saves made up to // and including its own time, so a file saved again after the strip survives. Re-applying every // strip is harmless, which lets a save that arrives after its strip event be covered too. function applyStrips(db: Database): void { const strips = db .query<{ save_id: string; path: string; created_at: number }, []>( "SELECT save_id, path, created_at FROM events WHERE kind = 'strip'", ) .all(); if (strips.length === 0) return; const remove = db.query( `DELETE FROM save_files WHERE (path = $path OR substr(path, 1, length($dir)) = $dir) AND save_id IN (SELECT id FROM saves WHERE CASE WHEN $save = '' THEN created_at <= $at ELSE id = $save END)`, ); for (const strip of strips) { // Koh compares the prefix exactly, never with LIKE, so `_` and `%` in a path match only themselves. const dir = `${strip.path.replace(/\/+$/, "")}/`; remove.run({ path: strip.path, dir, save: strip.save_id, at: strip.created_at }); } db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)"); } // Lines and language at `head` never change between syncs, so they are counted once here, not per view. function refreshStats(db: Database, head: string): void { const files = db .query<{ path: string; data: Uint8Array }, [string]>( "SELECT f.path, o.data FROM save_files f JOIN objects o ON o.hash = f.hash WHERE f.save_id = ?", ) .all(head); let lines = 0; const byLanguage = new Map(); for (const file of files) { if (VENDORED.test(file.path)) continue; const bytes = inflateSync(file.data); if (bytes.includes(0)) continue; let count = 0; for (const byte of bytes) if (byte === 10) count++; if (bytes.length > 0 && bytes[bytes.length - 1] !== 10) count++; lines += count; const language = LANGUAGES[file.path.split(".").pop() ?? ""]; if (language) byLanguage.set(language, (byLanguage.get(language) ?? 0) + count); } const languages = [...byLanguage].sort((a, b) => b[1] - a[1]); const setState = db.query("INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)"); setState.run("lines", String(lines)); setState.run("language", languages[0]?.[0] ?? ""); setState.run("languages", JSON.stringify(languages)); }