import type { Database } from "bun:sqlite"; import { createHash } from "node:crypto"; import { inflateSync } from "node:zlib"; import type { ReleaseRecord } from "../bucket/repo.ts"; import { mirrorState } from "./store.ts"; export type Save = { id: string; parent: string; message: string; created_at: number; machine: string; agent: string; session: string; is_checkpoint: boolean; }; export type FileEntry = { path: string; hash: string; executable: boolean; size: number }; /** The saves that touched each path up to one save, oldest first; `line` is every one of * them in line order. */ export type Touches = { line: Save[]; byPath: Map }; export type RepoStats = { saves: number; lines: number; language: string; updated: number | null }; const SAVE_COLUMNS = `id, COALESCE(parent, '') AS parent, message, created_at, machine, COALESCE(agent, '') AS agent, COALESCE(session, '') AS session, is_checkpoint = 1 AS is_checkpoint`; const LINE_COLUMNS = "line.id, line.parent, line.message, line.created_at, line.machine, line.agent, line.session, line.is_checkpoint, line.n"; const ID_PREFIX = /^[0-9a-f]{6,12}$/; // Checkpoints are working marks, not history (§21.4): every list and count below leaves them out. const NOT_CHECKPOINT = "is_checkpoint = 0"; export function headId(db: Database): string | null { return mirrorState(db, "head"); } /** A page of saves, newest first. Rows land oldest first, so rowid is the order of the line. */ export function history(db: Database, limit: number, offset: number): Save[] { return db .query( `SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid DESC LIMIT ? OFFSET ?`, ) .all(limit, offset) .map(asSave); } /** Every save, oldest first, for the timeline. */ export function timeline(db: Database): Save[] { return db .query(`SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid`) .all() .map(asSave); } /** The save a unique prefix of 6–12 hex characters names (§6.1), or null when none or * several do. */ export function saveByPrefix(db: Database, prefix: string): Save | null { if (!ID_PREFIX.test(prefix)) return null; // A migrated save's whole six-character ID can also begin a twelve-character one. const exact = db .query(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`) .get(prefix); if (exact) return asSave(exact); const rows = db .query(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id LIKE ? || '%' LIMIT 2`) .all(prefix); return rows.length === 1 ? asSave(rows[0] as Save) : null; } /** The nearest ancestor that is not a checkpoint: the parent a visitor can see (§21.4). */ export function visibleParent(db: Database, save: Save): Save | null { for (let id = save.parent; id !== ""; ) { const parent = saveByPrefix(db, id); if (!parent?.is_checkpoint) return parent; id = parent.parent; } return null; } /** The reason a save is flagged, or null when it isn't: its newest flag or clear event wins (§10.1). */ export function flagReason(db: Database, saveId: string): string | null { const row = db .query<{ kind: string; reason: string }, [string]>( `SELECT kind, COALESCE(reason, '') AS reason FROM events WHERE save_id = ? AND kind IN ('flag', 'clear') ORDER BY created_at DESC, id DESC LIMIT 1`, ) .get(saveId); return row?.kind === "flag" ? row.reason : null; } /** How many strip events have landed: events are append-only, so this only ever grows. */ /** * What a save's tarball depends on besides the save: the strips landed, and the hidden paths and * epoch the mirror was built with. A change to any of them names a new key, so an old tarball is * never looked up again, whatever a late write or a failed cleanup leaves behind. */ export function tarballGeneration(db: Database): string { const built = `${mirrorState(db, "hidden") ?? ""}\n${mirrorState(db, "epoch") ?? ""}`; return `${stripCount(db)}-${createHash("sha256").update(built).digest("hex").slice(0, 12)}`; } function stripCount(db: Database): number { return db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM events WHERE kind = 'strip'").get() ?.n as number; } export function filesAt(db: Database, saveId: string): FileEntry[] { return db .query( `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? ORDER BY f.path`, ) .all(saveId) .map((file) => ({ ...file, executable: Boolean(file.executable) })); } export function fileAt(db: Database, saveId: string, path: string): FileEntry | null { const file = db .query( `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? AND f.path = ?`, ) .get(saveId, path); return file ? { ...file, executable: Boolean(file.executable) } : null; } export function objectBytes(db: Database, hash: string): Uint8Array | null { const row = db .query<{ data: Uint8Array }, [string]>("SELECT data FROM objects WHERE hash = ?") .get(hash); return row ? new Uint8Array(inflateSync(row.data)) : null; } export function repoStats(db: Database): RepoStats { const row = db .query<{ saves: number; updated: number | null }, []>( `SELECT COUNT(*) AS saves, MAX(created_at) AS updated FROM saves WHERE ${NOT_CHECKPOINT}`, ) .get() ?? { saves: 0, updated: null }; return { saves: row.saves, updated: row.updated, lines: Number(mirrorState(db, "lines") ?? 0), language: mirrorState(db, "language") ?? "", }; } /** * Which saves touched each path on the line up to `saveId` (§21.5's detail panel). A save touched a path when * the path was added, changed or removed against the save before it; checkpoints are skipped, so a * change first recorded by a checkpoint counts at the next real save. */ export function fileTouches(db: Database, saveId: string): Touches { const rows = db .query( `WITH line AS ( SELECT ${SAVE_COLUMNS}, LAG(id) OVER (ORDER BY rowid) AS prev, rowid AS n FROM saves WHERE ${NOT_CHECKPOINT} AND rowid <= (SELECT rowid FROM saves WHERE id = $at) ) SELECT ${LINE_COLUMNS}, f.path FROM line JOIN save_files f ON f.save_id = line.id LEFT JOIN save_files p ON p.save_id = line.prev AND p.path = f.path WHERE p.hash IS NOT f.hash OR p.executable IS NOT f.executable UNION ALL SELECT ${LINE_COLUMNS}, p.path FROM line JOIN save_files p ON p.save_id = line.prev WHERE NOT EXISTS (SELECT 1 FROM save_files f WHERE f.save_id = line.id AND f.path = p.path) ORDER BY n`, ) .all({ at: saveId }); const saves = new Map(); const byPath = new Map(); for (const { path, n: _, ...row } of rows) { const save = saves.get(row.id) ?? asSave(row); saves.set(save.id, save); const touched = byPath.get(path); if (touched) touched.push(save); else byPath.set(path, [save]); } return { line: [...saves.values()], byPath }; } /** Lines per language at the newest real save, most first, vendored code left out as in the header's language. */ export function languageLines(db: Database): [string, number][] { return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][]; } /** Every distinct object (unique file contents) a real save refers to, at any point in history. */ export function objectHashes(db: Database): string[] { // From `objects`, probing save_files_hash: every save lists its whole tree, so a DISTINCT over // save_files reads saves × files rows (1.4 s for 1,083 saves on Tylee). return db .query<{ hash: string }, []>( `SELECT o.hash FROM objects o WHERE EXISTS ( SELECT 1 FROM save_files f JOIN saves s ON s.id = f.save_id WHERE f.hash = o.hash AND s.${NOT_CHECKPOINT} )`, ) .all() .map((row) => row.hash); } /** Every release record the bucket holds, in no particular order. */ export function releaseRecords(db: Database): ReleaseRecord[] { return db .query<{ record: string }, []>("SELECT record FROM releases") .all() .map((row) => JSON.parse(row.record) as ReleaseRecord); } function asSave(row: Save): Save { return { ...row, is_checkpoint: Boolean(row.is_checkpoint) }; }