queries.ts

8.5 KB · raw

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 };2122/** The saves that touched each path up to one save, oldest first; `line` is every one of23 * them in line order. */24export 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}$/;3132// Checkpoints are working marks, not history (§21.4): every list and count below leaves them out.33const NOT_CHECKPOINT = "is_checkpoint = 0";3435export function headId(db: Database): string | null {36	return mirrorState(db, "head");37}3839/** A page of saves, newest first. Rows land oldest first, so rowid is the order of the line. */40export 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}4849/** Every save, oldest first, for the timeline. */50export 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}5657/** The save a unique prefix of 6–12 hex characters names (§6.1), or null when none or58 * several do. */59export function saveByPrefix(db: Database, prefix: string): Save | null {60	if (!ID_PREFIX.test(prefix)) return null;61	// A migrated save's whole six-character ID can also begin a twelve-character one.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}7172/** The nearest ancestor that is not a checkpoint: the parent a visitor can see (§21.4). */73export 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}8182/** The reason a save is flagged, or null when it isn't: its newest flag or clear event wins (§10.1). */83export 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}9293/** How many strip events have landed: events are append-only, so this only ever grows. */94/**95 * What a save's tarball depends on besides the save: the strips landed, and the hidden paths and96 * epoch the mirror was built with. A change to any of them names a new key, so an old tarball is97 * never looked up again, whatever a late write or a failed cleanup leaves behind.98 */99export 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}149150/**151 * Which saves touched each path on the line up to `saveId` (§21.5's detail panel), as recorded when152 * each save landed (`touches` in the mirror).153 */154export function fileTouches(db: Database, saveId: string): Touches {155	const at = { at: saveId };156	// The saves once, the touches as bare pairs: every touch row carrying its save's columns was157	// most of the Atlas's cost.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}188189/** The files at a save with what each says about itself, as recorded when it landed. */190export 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}210211/** The file contents this repo's real saves refer to, as recounted when its saves land. */212export function knownObjects(db: Database): string[] {213	return db214		.query<{ hash: string }, []>("SELECT hash FROM known_objects")215		.all()216		.map((row) => row.hash);217}218219/** The newest real saves, newest first. */220export 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}228229/** Lines per language at the newest real save, most first, vendored code left out as in the header's language. */230export function languageLines(db: Database): [string, number][] {231	return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];232}233234/** Every release record the bucket holds, in no particular order. */235export 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}