1import type { Database } from "bun:sqlite";2import { createHash } from "node:crypto";3import { inflateSync } from "node:zlib";4import type { ReleaseRecord } from "../bucket/repo.ts";5import type { FileFacts } from "../describe/describe.ts";6import type { Outline } from "../describe/links.ts";7import { mirrorState } from "./store.ts";89export type Save = {10 id: string;11 parent: string;12 message: string;13 created_at: number;14 machine: string;15 agent: string;16 session: string;17 is_checkpoint: boolean;18};1920export type FileEntry = { path: string; hash: string; executable: boolean; size: number };21222324export type Touches = { line: Save[]; byPath: Map<string, Save[]> };2526export type RepoStats = { saves: number; lines: number; language: string; updated: number | null };2728const SAVE_COLUMNS = `id, COALESCE(parent, '') AS parent, message, created_at, machine,29 COALESCE(agent, '') AS agent, COALESCE(session, '') AS session, is_checkpoint = 1 AS is_checkpoint`;30const ID_PREFIX = /^[0-9a-f]{6,12}$/;313233const NOT_CHECKPOINT = "is_checkpoint = 0";3435export function headId(db: Database): string | null {36 return mirrorState(db, "head");37}383940export function history(db: Database, limit: number, offset: number): Save[] {41 return db42 .query<Save, [number, number]>(43 `SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid DESC LIMIT ? OFFSET ?`,44 )45 .all(limit, offset)46 .map(asSave);47}484950export function timeline(db: Database): Save[] {51 return db52 .query<Save, []>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid`)53 .all()54 .map(asSave);55}56575859export function saveByPrefix(db: Database, prefix: string): Save | null {60 if (!ID_PREFIX.test(prefix)) return null;61 62 const exact = db63 .query<Save, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`)64 .get(prefix);65 if (exact) return asSave(exact);66 const rows = db67 .query<Save, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id LIKE ? || '%' LIMIT 2`)68 .all(prefix);69 return rows.length === 1 ? asSave(rows[0] as Save) : null;70}717273export function visibleParent(db: Database, save: Save): Save | null {74 for (let id = save.parent; id !== ""; ) {75 const parent = saveByPrefix(db, id);76 if (!parent?.is_checkpoint) return parent;77 id = parent.parent;78 }79 return null;80}818283export function flagReason(db: Database, saveId: string): string | null {84 const row = db85 .query<{ kind: string; reason: string }, [string]>(86 `SELECT kind, COALESCE(reason, '') AS reason FROM events87 WHERE save_id = ? AND kind IN ('flag', 'clear') ORDER BY created_at DESC, id DESC LIMIT 1`,88 )89 .get(saveId);90 return row?.kind === "flag" ? row.reason : null;91}9293949596979899export function tarballGeneration(db: Database): string {100 const built = `${mirrorState(db, "hidden") ?? ""}\n${mirrorState(db, "epoch") ?? ""}`;101 return `${stripCount(db)}-${createHash("sha256").update(built).digest("hex").slice(0, 12)}`;102}103104function stripCount(db: Database): number {105 return db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM events WHERE kind = 'strip'").get()106 ?.n as number;107}108109export function filesAt(db: Database, saveId: string): FileEntry[] {110 return db111 .query<FileEntry, [string]>(112 `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f113 JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? ORDER BY f.path`,114 )115 .all(saveId)116 .map((file) => ({ ...file, executable: Boolean(file.executable) }));117}118119export function fileAt(db: Database, saveId: string, path: string): FileEntry | null {120 const file = db121 .query<FileEntry, [string, string]>(122 `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f123 JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? AND f.path = ?`,124 )125 .get(saveId, path);126 return file ? { ...file, executable: Boolean(file.executable) } : null;127}128129export function objectBytes(db: Database, hash: string): Uint8Array<ArrayBuffer> | null {130 const row = db131 .query<{ data: Uint8Array }, [string]>("SELECT data FROM objects WHERE hash = ?")132 .get(hash);133 return row ? new Uint8Array(inflateSync(row.data)) : null;134}135136export function repoStats(db: Database): RepoStats {137 const row = db138 .query<{ saves: number; updated: number | null }, []>(139 `SELECT COUNT(*) AS saves, MAX(created_at) AS updated FROM saves WHERE ${NOT_CHECKPOINT}`,140 )141 .get() ?? { saves: 0, updated: null };142 return {143 saves: row.saves,144 updated: row.updated,145 lines: Number(mirrorState(db, "lines") ?? 0),146 language: mirrorState(db, "language") ?? "",147 };148}149150151152153154export function fileTouches(db: Database, saveId: string): Touches {155 const at = { at: saveId };156 157 158 const byRowid = new Map(159 db160 .query<Save & { n: number }, { at: string }>(161 `SELECT rowid AS n, ${SAVE_COLUMNS}162 FROM saves163 WHERE ${NOT_CHECKPOINT}164 AND rowid <= (SELECT rowid FROM saves WHERE id = $at)`,165 )166 .all(at)167 .map(({ n, ...row }) => [n, asSave(row)]),168 );169 const touched = db170 .query<{ path: string; n: number }, { at: string }>(171 `SELECT path, n172 FROM touches173 WHERE n <= (SELECT rowid FROM saves WHERE id = $at)174 ORDER BY n`,175 )176 .all(at);177 const line = new Set<Save>();178 const byPath = new Map<string, Save[]>();179 for (const { path, n } of touched) {180 const save = byRowid.get(n) as Save;181 line.add(save);182 const saves = byPath.get(path);183 if (saves) saves.push(save);184 else byPath.set(path, [save]);185 }186 return { line: [...line], byPath };187}188189190export function filesWithFacts(db: Database, saveId: string): (FileEntry & FileFacts)[] {191 return db192 .query<193 FileEntry & { lines: number | null; description: string | null; outline: string | null },194 [string]195 >(196 `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size, x.lines, x.description, x.outline197 FROM save_files f198 JOIN objects o ON o.hash = f.hash199 LEFT JOIN facts x ON x.hash = f.hash AND x.path = f.path200 WHERE f.save_id = ?201 ORDER BY f.path`,202 )203 .all(saveId)204 .map((row) => ({205 ...row,206 executable: Boolean(row.executable),207 outline: row.outline === null ? null : (JSON.parse(row.outline) as Outline),208 }));209}210211212export function knownObjects(db: Database): string[] {213 return db214 .query<{ hash: string }, []>("SELECT hash FROM known_objects")215 .all()216 .map((row) => row.hash);217}218219220export function newestSaves(db: Database, limit: number): Save[] {221 return db222 .query<Save, [number]>(223 `SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid DESC LIMIT ?`,224 )225 .all(limit)226 .map(asSave);227}228229230export function languageLines(db: Database): [string, number][] {231 return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];232}233234235export function releaseRecords(db: Database): ReleaseRecord[] {236 return db237 .query<{ record: string }, []>("SELECT record FROM releases")238 .all()239 .map((row) => JSON.parse(row.record) as ReleaseRecord);240}241242function asSave(row: Save): Save {243 return { ...row, is_checkpoint: Boolean(row.is_checkpoint) };244}