queries.ts

7.2 KB · raw

1import type { Database } from "bun:sqlite";2import { inflateSync } from "node:zlib";3import type { ReleaseRecord } from "../bucket/repo.ts";4import { mirrorState } from "./store.ts";56export type Save = {7	id: string;8	parent: string;9	message: string;10	created_at: number;11	machine: string;12	agent: string;13	session: string;14	is_checkpoint: boolean;15};1617export type FileEntry = { path: string; hash: string; executable: boolean; size: number };1819/** The saves that touched each path up to one save, oldest first; `line` is every one of20 * them in line order. */21export type Touches = { line: Save[]; byPath: Map<string, Save[]> };2223export type RepoStats = { saves: number; lines: number; language: string; updated: number | null };2425const SAVE_COLUMNS = `id, COALESCE(parent, '') AS parent, message, created_at, machine,26	COALESCE(agent, '') AS agent, COALESCE(session, '') AS session, is_checkpoint = 1 AS is_checkpoint`;27const LINE_COLUMNS =28	"line.id, line.parent, line.message, line.created_at, line.machine, line.agent, line.session, line.is_checkpoint, line.n";29const ID_PREFIX = /^[0-9a-f]{6,12}$/;3031// Checkpoints are working marks, not history (§21.4): every list and count below leaves them out.32const NOT_CHECKPOINT = "is_checkpoint = 0";3334export function headId(db: Database): string | null {35	return mirrorState(db, "head");36}3738/** A page of saves, newest first. Rows land oldest first, so rowid is the order of the line. */39export function history(db: Database, limit: number, offset: number): Save[] {40	return db41		.query<Save, [number, number]>(42			`SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid DESC LIMIT ? OFFSET ?`,43		)44		.all(limit, offset)45		.map(asSave);46}4748/** Every save, oldest first, for the timeline. */49export function timeline(db: Database): Save[] {50	return db51		.query<Save, []>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE ${NOT_CHECKPOINT} ORDER BY rowid`)52		.all()53		.map(asSave);54}5556/** The save a unique prefix of 6–12 hex characters names (§6.1), or null when none or57 * several do. */58export function saveByPrefix(db: Database, prefix: string): Save | null {59	if (!ID_PREFIX.test(prefix)) return null;60	const rows = db61		.query<Save, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id LIKE ? || '%' LIMIT 2`)62		.all(prefix);63	return rows.length === 1 ? asSave(rows[0] as Save) : null;64}6566/** The nearest ancestor that is not a checkpoint: the parent a visitor can see (§21.4). */67export function visibleParent(db: Database, save: Save): Save | null {68	for (let id = save.parent; id !== ""; ) {69		const parent = saveByPrefix(db, id);70		if (!parent?.is_checkpoint) return parent;71		id = parent.parent;72	}73	return null;74}7576/** The reason a save is flagged, or null when it isn't: its newest flag or clear event wins (§10.1). */77export function flagReason(db: Database, saveId: string): string | null {78	const row = db79		.query<{ kind: string; reason: string }, [string]>(80			`SELECT kind, COALESCE(reason, '') AS reason FROM events81			WHERE save_id = ? AND kind IN ('flag', 'clear') ORDER BY created_at DESC, id DESC LIMIT 1`,82		)83		.get(saveId);84	return row?.kind === "flag" ? row.reason : null;85}8687/** How many strip events have landed: events are append-only, so this only ever grows. */88export function stripCount(db: Database): number {89	return db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM events WHERE kind = 'strip'").get()90		?.n as number;91}9293export function filesAt(db: Database, saveId: string): FileEntry[] {94	return db95		.query<FileEntry, [string]>(96			`SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f97			JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? ORDER BY f.path`,98		)99		.all(saveId)100		.map((file) => ({ ...file, executable: Boolean(file.executable) }));101}102103export function fileAt(db: Database, saveId: string, path: string): FileEntry | null {104	const file = db105		.query<FileEntry, [string, string]>(106			`SELECT f.path, f.hash, f.executable = 1 AS executable, o.size FROM save_files f107			JOIN objects o ON o.hash = f.hash WHERE f.save_id = ? AND f.path = ?`,108		)109		.get(saveId, path);110	return file ? { ...file, executable: Boolean(file.executable) } : null;111}112113export function objectBytes(db: Database, hash: string): Uint8Array<ArrayBuffer> | null {114	const row = db115		.query<{ data: Uint8Array }, [string]>("SELECT data FROM objects WHERE hash = ?")116		.get(hash);117	return row ? new Uint8Array(inflateSync(row.data)) : null;118}119120export function repoStats(db: Database): RepoStats {121	const row = db122		.query<{ saves: number; updated: number | null }, []>(123			`SELECT COUNT(*) AS saves, MAX(created_at) AS updated FROM saves WHERE ${NOT_CHECKPOINT}`,124		)125		.get() ?? { saves: 0, updated: null };126	return {127		saves: row.saves,128		updated: row.updated,129		lines: Number(mirrorState(db, "lines") ?? 0),130		language: mirrorState(db, "language") ?? "",131	};132}133134/**135 * Which saves touched each path on the line up to `saveId` (§21.5's detail panel). A save touched a path when136 * the path was added, changed or removed against the save before it; checkpoints are skipped, so a137 * change first recorded by a checkpoint counts at the next real save.138 */139export function fileTouches(db: Database, saveId: string): Touches {140	const rows = db141		.query<Save & { path: string; n: number }, { at: string }>(142			`WITH line AS (143				SELECT ${SAVE_COLUMNS}, LAG(id) OVER (ORDER BY rowid) AS prev, rowid AS n FROM saves144				WHERE ${NOT_CHECKPOINT} AND rowid <= (SELECT rowid FROM saves WHERE id = $at)145			)146			SELECT ${LINE_COLUMNS}, f.path FROM line JOIN save_files f ON f.save_id = line.id147			LEFT JOIN save_files p ON p.save_id = line.prev AND p.path = f.path148			WHERE p.hash IS NOT f.hash OR p.executable IS NOT f.executable149			UNION ALL150			SELECT ${LINE_COLUMNS}, p.path FROM line JOIN save_files p ON p.save_id = line.prev151			WHERE NOT EXISTS (SELECT 1 FROM save_files f WHERE f.save_id = line.id AND f.path = p.path)152			ORDER BY n`,153		)154		.all({ at: saveId });155	const saves = new Map<string, Save>();156	const byPath = new Map<string, Save[]>();157	for (const { path, n: _, ...row } of rows) {158		const save = saves.get(row.id) ?? asSave(row);159		saves.set(save.id, save);160		const touched = byPath.get(path);161		if (touched) touched.push(save);162		else byPath.set(path, [save]);163	}164	return { line: [...saves.values()], byPath };165}166167/** Lines per language at the newest real save, most first, vendored code left out as in the header's language. */168export function languageLines(db: Database): [string, number][] {169	return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];170}171172/** Every distinct object (unique file contents) a real save refers to, at any point in history. */173export function objectHashes(db: Database): string[] {174	return db175		.query<{ hash: string }, []>(176			`SELECT DISTINCT f.hash177			 FROM save_files f178			 JOIN saves s ON s.id = f.save_id179			 WHERE s.${NOT_CHECKPOINT}`,180		)181		.all()182		.map((row) => row.hash);183}184185/** Every release record the bucket holds, in no particular order. */186export function releaseRecords(db: Database): ReleaseRecord[] {187	return db188		.query<{ record: string }, []>("SELECT record FROM releases")189		.all()190		.map((row) => JSON.parse(row.record) as ReleaseRecord);191}192193function asSave(row: Save): Save {194	return { ...row, is_checkpoint: Boolean(row.is_checkpoint) };195}