queries.ts

5.2 KB · raw

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 };1718/** A file's story up to one save: first and last save that changed it, how many did, newest five. */19export type FileHistory = { first: Save; last: Save; touches: number; recent: 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}$/;2829// Checkpoints are working marks, not history (§21.4): every list and count below leaves them out.30const NOT_CHECKPOINT = "is_checkpoint = 0";3132export function headId(db: Database): string | null {33	return mirrorState(db, "head");34}3536/** A page of saves, newest first. Rows land oldest first, so rowid is the order of the line. */37export 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}4546/** Every save, oldest first, for the timeline. */47export 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}5354/** The save a unique prefix of 6–12 hex characters names (§6.1), or null when none or several do. */55export 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}103104/**105 * Each path's history on the line up to `saveId` (§21.5's detail panel). A save touched a path when106 * the path was added, changed or removed against the save before it; checkpoints are skipped, so a107 * change first recorded by a checkpoint counts at the next real save.108 */109export function fileHistories(db: Database, saveId: string): Map<string, FileHistory> {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 histories = new Map<string, FileHistory>();126	for (const { path, n: _, ...row } of rows) {127		const save = asSave(row);128		const known = histories.get(path);129		if (!known) {130			histories.set(path, { first: save, last: save, touches: 1, recent: [save] });131			continue;132		}133		known.last = save;134		known.touches++;135		known.recent = [save, ...known.recent.slice(0, 4)];136	}137	return histories;138}139140function asSave(row: Save): Save {141	return { ...row, is_checkpoint: Boolean(row.is_checkpoint) };142}