1import { Database } from "bun:sqlite";2import { mkdirSync } from "node:fs";3import { join } from "node:path";4import { inflateSync } from "node:zlib";5import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts";6import { isTestPath } from "../describe/links.ts";7891011const SCHEMA_VERSION = 2;12const SCHEMA = `13CREATE TABLE objects (14 hash TEXT PRIMARY KEY,15 size INTEGER NOT NULL,16 data BLOB NOT NULL17);18CREATE TABLE saves (19 id TEXT PRIMARY KEY,20 parent TEXT REFERENCES saves(id),21 message TEXT NOT NULL,22 created_at INTEGER NOT NULL,23 machine TEXT NOT NULL,24 is_checkpoint INTEGER NOT NULL DEFAULT 0,25 size_bytes INTEGER NOT NULL DEFAULT 0,26 agent TEXT,27 session TEXT,28 restores TEXT,29 replayed_from TEXT30);31CREATE TABLE save_files (32 save_id TEXT NOT NULL REFERENCES saves(id),33 path TEXT NOT NULL,34 hash TEXT NOT NULL REFERENCES objects(hash),35 executable INTEGER NOT NULL DEFAULT 0,36 PRIMARY KEY (save_id, path)37);38CREATE TABLE events (39 id TEXT PRIMARY KEY,40 kind TEXT NOT NULL,41 save_id TEXT,42 path TEXT,43 reason TEXT,44 created_at INTEGER NOT NULL,45 machine TEXT NOT NULL46);47CREATE TABLE state (48 key TEXT PRIMARY KEY,49 value TEXT NOT NULL50);51CREATE TABLE releases (52 version TEXT PRIMARY KEY,53 record TEXT NOT NULL54);55CREATE INDEX saves_parent ON saves(parent);56CREATE INDEX save_files_hash ON save_files(hash);57`;585960export type StoredObject = { compressed: Uint8Array; size: number };616263export type Landing = {64 head: string;65 releasesLatest: string;66 saves: SaveRecord[];67 objects: Map<string, StoredObject>;68 events: EventRecord[];69 releases: ReleaseRecord[];70 71 quarantined: string[];72 73747576 hidden: string[];77};787980export const ANY_DEPTH = "**/";818283const MAX_COUNTED = 8 * 1024 * 1024;848586const LANGUAGES: Record<string, string> = {87 ts: "TypeScript",88 tsx: "TypeScript",89 js: "JavaScript",90 mjs: "JavaScript",91 zig: "Zig",92 v: "V",93 rs: "Rust",94 go: "Go",95 py: "Python",96 c: "C",97 h: "C",98 cpp: "C++",99 cc: "C++",100 hpp: "C++",101 swift: "Swift",102 kt: "Kotlin",103 java: "Java",104 rb: "Ruby",105 lua: "Lua",106 sh: "Shell",107 nix: "Nix",108 css: "CSS",109 html: "HTML",110};111112113const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//;114115const STATS_RULE = "source-only";116117const open = new Map<string, Database>();118119120export function closeMirror(dir: string, repo: string): void {121 const path = join(dir, `${repo}.db`);122 open.get(path)?.close();123 open.delete(path);124}125126127export function openMirror(dir: string, repo: string): Database {128 const path = join(dir, `${repo}.db`);129 const cached = open.get(path);130 if (cached) return cached;131 mkdirSync(dir, { recursive: true });132 const db = new Database(path, { strict: true });133 db.run("PRAGMA journal_mode = WAL");134 db.run("PRAGMA foreign_keys = ON");135 db.run("PRAGMA busy_timeout = 5000");136 const { user_version } = db.query<{ user_version: number }, []>("PRAGMA user_version").get() ?? {137 user_version: 0,138 };139 if (user_version !== 0 && user_version !== SCHEMA_VERSION) {140 db.close();141 throw new Error(142 `${path} has mirror schema version ${user_version}; this Kepr reads version ${SCHEMA_VERSION}. ` +143 "The mirror is disposable: delete it and the next sync rebuilds it from the bucket.",144 );145 }146 if (user_version === 0) {147 db.transaction(() => {148 db.run(SCHEMA);149 db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);150 })();151 }152 153 if (mirrorState(db, "head") && mirrorState(db, "stats_rule") !== STATS_RULE) refreshStats(db);154 open.set(path, db);155 return db;156}157158export function mirrorState(db: Database, key: string): string | null {159 const row = db160 .query<{ value: string }, [string]>("SELECT value FROM state WHERE key = ?")161 .get(key);162 return row?.value ?? null;163}164165export function knownSave(db: Database, id: string): boolean {166 return db.query("SELECT 1 FROM saves WHERE id = ?").get(id) !== null;167}168169export function knownObject(db: Database, hash: string): boolean {170 return db.query("SELECT 1 FROM objects WHERE hash = ?").get(hash) !== null;171}172173export function knownEventIds(db: Database): Set<string> {174 const rows = db.query<{ id: string }, []>("SELECT id FROM events").all();175 return new Set(rows.map((row) => row.id));176}177178179export function forgetRelease(db: Database, version: string): void {180 db.query("DELETE FROM releases WHERE version = ?").run(version);181}182183184export function replaceRelease(db: Database, record: ReleaseRecord): void {185 db.query("UPDATE releases SET record = ? WHERE version = ?").run(186 JSON.stringify(record),187 record.version,188 );189}190191export function knownReleases(db: Database): Set<string> {192 const rows = db.query<{ version: string }, []>("SELECT version FROM releases").all();193 return new Set(rows.map((row) => row.version));194}195196197198export function storeObjects(db: Database, objects: Map<string, StoredObject>): void {199 const put = db.query("INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)");200 db.transaction(() => {201 for (const [hash, object] of objects) put.run(hash, object.size, object.compressed);202 })();203}204205export function land(db: Database, landing: Landing): void {206 const putEvent = db.query(207 "INSERT OR IGNORE INTO events (id, kind, save_id, path, reason, created_at, machine) VALUES " +208 "(?, ?, ?, ?, ?, ?, ?)",209 );210 const putSave = db.query(211 `INSERT OR IGNORE INTO saves (id, parent, message, created_at, machine, is_checkpoint, size_bytes,212 agent, session, restores, replayed_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,213 );214 const putFile = db.query(215 "INSERT OR IGNORE INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)",216 );217 const putRelease = db.query("INSERT OR IGNORE INTO releases (version, record) VALUES (?, ?)");218 const setState = db.query("INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)");219 db.transaction(() => {220 storeObjects(db, landing.objects);221 for (const e of landing.events) {222 putEvent.run(e.id, e.kind, e.save_id, e.path, e.reason, e.created_at, e.machine);223 }224 for (const s of landing.saves) {225 putSave.run(226 s.id,227 s.parent === "" ? null : s.parent,228 s.message,229 s.created_at,230 s.machine,231 s.is_checkpoint ? 1 : 0,232 s.size_bytes,233 s.agent,234 s.session,235 s.restores,236 s.replayed_from,237 );238 for (const f of s.files) putFile.run(s.id, f.path, f.hash, f.executable ? 1 : 0);239 }240 for (const r of landing.releases) putRelease.run(r.version, JSON.stringify(r));241 applyStrips(db);242 let hidden = 0;243 for (const path of landing.hidden) hidden += removePath(db, path, "");244 if (hidden > 0) db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");245 246 247 setState.run("hidden", landing.hidden.join("\n"));248 setState.run("head", landing.head);249 setState.run("releases_latest", landing.releasesLatest);250 const quarantined = new Set([...quarantinedPaths(db), ...landing.quarantined]);251 setState.run("quarantined", JSON.stringify([...quarantined].sort()));252 })();253 254 refreshStats(db);255}256257258export function quarantinedPaths(db: Database): string[] {259 return JSON.parse(mirrorState(db, "quarantined") ?? "[]");260}261262263264265function applyStrips(db: Database): void {266 const strips = db267 .query<{ save_id: string; path: string }, []>(268 "SELECT save_id, path FROM events WHERE kind = 'strip'",269 )270 .all();271 if (strips.length === 0) return;272 for (const strip of strips) removePath(db, strip.path, strip.save_id);273 db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");274}275276277function removePath(db: Database, path: string, save: string): number {278 if (path.startsWith(ANY_DEPTH)) {279 280 return db281 .query(282 `DELETE FROM save_files283 WHERE instr('/' || path || '/', $segment) > 0284 AND ($save = '' OR save_id = $save)`,285 )286 .run({ segment: `/${path.slice(ANY_DEPTH.length)}/`, save }).changes;287 }288 289 const dir = `${path.replace(/\/+$/, "")}/`;290 return db291 .query(292 `DELETE FROM save_files293 WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)294 AND ($save = '' OR save_id = $save)`,295 )296 .run({ path, dir, save }).changes;297}298299300301302303304export function clearMirror(db: Database): void {305 db.transaction(() => {306 307 for (const table of ["save_files", "events", "saves", "objects", "releases", "state"]) {308 db.run(`DELETE FROM ${table}`);309 }310 db.query("INSERT INTO state (key, value) VALUES ('epoch', ?)").run(crypto.randomUUID());311 })();312}313314315316317function refreshStats(db: Database): void {318 const save =319 db320 .query<{ id: string }, []>(321 "SELECT id FROM saves WHERE is_checkpoint = 0 ORDER BY rowid DESC LIMIT 1",322 )323 .get()?.id ?? "";324 const files = db325 .query<{ path: string; size: number; data: Uint8Array }, [string]>(326 "SELECT f.path, o.size, o.data FROM save_files f JOIN objects o ON o.hash = f.hash WHERE f.save_id = ?",327 )328 .all(save);329 let lines = 0;330 const byLanguage = new Map<string, number>();331 for (const file of files) {332 const language = LANGUAGES[file.path.split(".").pop() ?? ""];333 334 if (!language || VENDORED.test(file.path) || isTestPath(file.path)) continue;335 if (file.size > MAX_COUNTED) continue;336 const bytes = inflateSync(file.data);337 if (bytes.includes(0)) continue;338 let count = 0;339 for (const byte of bytes) if (byte === 10) count++;340 if (bytes.length > 0 && bytes[bytes.length - 1] !== 10) count++;341 lines += count;342 byLanguage.set(language, (byLanguage.get(language) ?? 0) + count);343 }344 const languages = [...byLanguage].sort((a, b) => b[1] - a[1]);345 const setState = db.query("INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)");346 setState.run("lines", String(lines));347 setState.run("language", languages[0]?.[0] ?? "");348 setState.run("languages", JSON.stringify(languages));349 setState.run("stats_save", save);350 setState.run("stats_rule", STATS_RULE);351}