1import type { Database } from "bun:sqlite";2import { inflateSync } from "node:zlib";3import { mirrorState } from "./store.ts";45export type Save = {6 id: string;7 parent: string;8 message: string;9 created_at: number;10 machine: string;11 agent: string;12 session: string;13 is_checkpoint: boolean;14};1516export type FileEntry = { path: string; hash: string; executable: boolean; size: number };171819export type Touches = { line: Save[]; byPath: Map<string, Save[]> };2021export type RepoStats = { saves: number; lines: number; language: string; updated: number | null };2223const SAVE_COLUMNS = `id, COALESCE(parent, '') AS parent, message, created_at, machine,24 COALESCE(agent, '') AS agent, COALESCE(session, '') AS session, is_checkpoint = 1 AS is_checkpoint`;25const LINE_COLUMNS =26 "line.id, line.parent, line.message, line.created_at, line.machine, line.agent, line.session, line.is_checkpoint, line.n";27const ID_PREFIX = /^[0-9a-f]{6,12}$/;282930const NOT_CHECKPOINT = "is_checkpoint = 0";3132export function headId(db: Database): string | null {33 return mirrorState(db, "head");34}353637export function history(db: Database, limit: number, offset: number): Save[] {38 return db39 .query<Save, [number, number]>(40 `SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid DESC LIMIT ? OFFSET ?`,41 )42 .all(limit, offset)43 .map(asSave);44}454647export function timeline(db: Database): Save[] {48 return db49 .query<Save, []>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid`)50 .all()51 .map(asSave);52}535455export function saveByPrefix(db: Database, prefix: string): Save | null {56 if (!ID_PREFIX.test(prefix)) return null;57 const rows = db58 .query<Save, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id LIKE ? || '%' LIMIT 2`)59 .all(prefix);60 return rows.length === 1 ? asSave(rows[0] as Save) : null;61}6263export function filesAt(db: Database, saveId: string): FileEntry[] {64 return db65 .query<FileEntry, [string]>(66 `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f67 JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? ORDER BY f.path`,68 )69 .all(saveId)70 .map((file) => ({ ...file, executable: Boolean(file.executable) }));71}7273export function fileAt(db: Database, saveId: string, path: string): FileEntry | null {74 const file = db75 .query<FileEntry, [string, string]>(76 `SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f77 JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? AND f.path = ?`,78 )79 .get(saveId, path);80 return file ? { ...file, executable: Boolean(file.executable) } : null;81}8283export function objectBytes(db: Database, hash: string): Uint8Array<ArrayBuffer> | null {84 const row = db85 .query<{ data: Uint8Array }, [string]>("SELECT data FROM objects WHERE hash = ?")86 .get(hash);87 return row ? new Uint8Array(inflateSync(row.data)) : null;88}8990export function repoStats(db: Database): RepoStats {91 const row = db92 .query<{ saves: number; updated: number | null }, []>(93 `SELECT COUNT(*) AS saves, MAX(created_at) AS updated FROM saves WHERE ${NOT_CHECKPOINT}`,94 )95 .get() ?? { saves: 0, updated: null };96 return {97 saves: row.saves,98 updated: row.updated,99 lines: Number(mirrorState(db, "lines") ?? 0),100 language: mirrorState(db, "language") ?? "",101 };102}103104105106107108109export function fileTouches(db: Database, saveId: string): Touches {110 const rows = db111 .query<Save & { path: string; n: number }, { at: string }>(112 `WITH line AS (113 SELECT ${SAVE_COLUMNS}, LAG(id) OVER (ORDER BY rowid) AS prev, rowid AS n FROM saves114 WHERE ${NOT_CHECKPOINT} AND rowid <= (SELECT rowid FROM saves WHERE id = $at)115 )116 SELECT ${LINE_COLUMNS}, f.path FROM line JOIN save_files f ON f.save_id = line.id117 LEFT JOIN save_files p ON p.save_id = line.prev AND p.path = f.path118 WHERE p.hash IS NOT f.hash OR p.executable IS NOT f.executable119 UNION ALL120 SELECT ${LINE_COLUMNS}, p.path FROM line JOIN save_files p ON p.save_id = line.prev121 WHERE NOT EXISTS (SELECT 1 FROM save_files f WHERE f.save_id = line.id AND f.path = p.path)122 ORDER BY n`,123 )124 .all({ at: saveId });125 const saves = new Map<string, Save>();126 const byPath = new Map<string, Save[]>();127 for (const { path, n: _, ...row } of rows) {128 const save = saves.get(row.id) ?? asSave(row);129 saves.set(save.id, save);130 const touched = byPath.get(path);131 if (touched) touched.push(save);132 else byPath.set(path, [save]);133 }134 return { line: [...saves.values()], byPath };135}136137function asSave(row: Save): Save {138 return { ...row, is_checkpoint: Boolean(row.is_checkpoint) };139}