92240bfbe802

work out the Atlas's touches and file facts and each repo's known objects as saves land (mirror schema 4), so pages read instead of compute and no Atlas or facts cache, warm-up or synced message remains (v3.0.196)

time
2026-10-03 01:54 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
lightning quick kepr
parent
45b702df7e26
15 files package.json+1 −1src/bucket/sync.ts+3 −13src/bucket/worker.ts+1 −1src/describe/describe.ts+47 −0src/main.ts+1 −4src/mirror/queries.ts+68 −42src/mirror/store.ts+153 −23src/pages/atlas.ts+22 −83src/pages/files.ts+1 −15src/pages/front.ts+3 −3src/pages/library.ts+7 −5src/pages/save.ts+1 −1tests/front.test.ts+25 −0tests/sync.test.ts+70 −18tests/worker.test.ts+5 −10
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.195",3+	"version": "3.0.196",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/sync.ts+3 −13
@@ -60,8 +60,6 @@6060 	bucket: Bucket;6161 	kepr: Database;6262 	mirrorDir: string;63-	/** Called after each repo that synced without error. */64-	synced?: (repo: string) => void;6563 };6664 6765 let running: Promise<void> | null = null;@@ -120,20 +118,14 @@120118 	| { kind: "sync" | "hold" | "release"; id: number }121119 	| { kind: "hidden"; id: number; repos: string[] }122120 	| { kind: "bucket"; source: BucketSource };123-export type SyncReply =124-	| { kind: "done"; id: number; error?: string }125-	| { kind: "synced"; repo: string };121+export type SyncReply = { kind: "done"; id: number; error?: string };126122 127123 /**128124  * The sync on its own thread (2026-10-02), so a landing, a recount or a migration never stalls a129- * request. `synced` hears of each repo that synced cleanly. A worker that dies takes Kepr down with125+ * request. A worker that dies takes Kepr down with130126  * it, for the service manager to restart, rather than leaving requests waiting on it.131127  */132-export function startSyncWorker(133-	dataDir: string,134-	source: BucketSource,135-	synced: (repo: string) => void,136-): Syncer {128+export function startSyncWorker(dataDir: string, source: BucketSource): Syncer {137129 	const worker = new Worker(new URL("./worker.ts", import.meta.url));138130 	// The server keeps Kepr running; the worker alone never should.139131 	worker.unref();@@ -144,7 +136,6 @@144136 		process.exit(1);145137 	});146138 	worker.addEventListener("message", ({ data }: MessageEvent<SyncReply>) => {147-		if (data.kind === "synced") return synced(data.repo);148139 		const reply = waiting.get(data.id);149140 		waiting.delete(data.id);150141 		if (data.error === undefined) reply?.resolve();@@ -210,7 +201,6 @@210201 			}211202 		}212203 		recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);213-		if (error === null) target.synced?.(repo);214204 		await handHidden(target, repo);215205 	}216206 }

view file

src/bucket/worker.ts+1 −1
@@ -34,7 +34,7 @@3434 				mirrorDir: join(data.dataDir, "mirror"),3535 			};3636 			syncer = localSyncer(target);37-			startSyncLoop({ ...target, synced: (repo) => reply({ kind: "synced", repo }) });37+			startSyncLoop(target);3838 			return;3939 		}4040 		case "sync":

view file

src/describe/describe.ts+47 −0
@@ -2,6 +2,53 @@22 // Every function returns Markdown source (hard-wrapped lines kept) or null.33 44 import MarkdownIt from "markdown-it";5+import { type Outline, outline } from "./links.ts";6+7+/** A file page shows a file's first megabyte, and that megabyte decides whether it is text. */8+export const SHOWN_BYTES = 1024 * 1024;9+10+// What a file says about itself is worked out only up to this size; a larger file is data.11+const MAX_DESCRIBED = 8 * 1024 * 1024;12+13+/** What a file says about itself, as the Atlas shows it; all null for binary or very large files. */14+export type FileFacts = {15+	lines: number | null;16+	description: string | null;17+	outline: Outline | null;18+};19+20+/** `read` is called only for a file small enough to describe, so a large one is never inflated. */21+export function fileFacts(path: string, size: number, read: () => Uint8Array): FileFacts {22+	const bytes = size > MAX_DESCRIBED ? null : read();23+	if (!bytes || !isText(bytes)) return { lines: null, description: null, outline: null };24+	const text = new TextDecoder().decode(bytes);25+	return {26+		lines: countLines(text),27+		description: describeFile(path, text),28+		outline: outline(path, text),29+	};30+}31+32+export function isText(bytes: Uint8Array): boolean {33+	if (bytes.subarray(0, 8000).includes(0)) return false;34+	try {35+		// Streaming mode tolerates only a character cut at the 1 MB mark; invalid bytes still throw.36+		const cut = bytes.length > SHOWN_BYTES;37+		new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, SHOWN_BYTES), {38+			stream: cut,39+		});40+		return true;41+	} catch {42+		return false;43+	}44+}45+46+function countLines(text: string): number {47+	if (text === "") return 0;48+	let count = 0;49+	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;50+	return text.endsWith("\n") ? count : count + 1;51+}552 653 type Style = "bang" | "slash" | "jvm" | "hash" | "python" | "markdown";754 

view file

src/main.ts+1 −4
@@ -5,7 +5,6 @@55 import { openBucket } from "./bucket/reader.ts";66 import { startSyncWorker } from "./bucket/sync.ts";77 import { openMirrors } from "./mirror/store.ts";8-import { warmAtlas } from "./pages/atlas.ts";98 import { createApp } from "./server/app.ts";109 import { startBackupLoop } from "./server/backup.ts";1110 import { CONFIG_FILE, type Config, ConfigError, loadConfig } from "./server/config.ts";@@ -26,9 +25,7 @@2625 	const mirrorDir = join(config.dataDir, "mirror");2726 	openMirrors(mirrorDir);2827 	// The first visitor after a new save would otherwise wait a second or two on a big repo.29-	const syncer = startSyncWorker(config.dataDir, config.bucket, (repo) =>30-		warmAtlas({ kepr, mirrorDir }, repo),31-	);28+	const syncer = startSyncWorker(config.dataDir, config.bucket);3229 	startStorageLoop({ bucket, kepr, mirrorDir });3330 	return createApp(config, kepr, bucket, CONFIG_FILE, syncer).fetch;3431 }

view file

src/mirror/queries.ts+68 −42
@@ -2,6 +2,8 @@22 import { createHash } from "node:crypto";33 import { inflateSync } from "node:zlib";44 import type { ReleaseRecord } from "../bucket/repo.ts";5+import type { FileFacts } from "../describe/describe.ts";6+import type { Outline } from "../describe/links.ts";57 import { mirrorState } from "./store.ts";68 79 export type Save = {@@ -25,8 +27,6 @@2527 2628 const SAVE_COLUMNS = `id, COALESCE(parent, '') AS parent, message, created_at, machine,2729 	COALESCE(agent, '') AS agent, COALESCE(session, '') AS session, is_checkpoint = 1 AS is_checkpoint`;28-const LINE_COLUMNS =29-	"line.id, line.parent, line.message, line.created_at, line.machine, line.agent, line.session, line.is_checkpoint, line.n";3030 const ID_PREFIX = /^[0-9a-f]{6,12}$/;3131 3232 // Checkpoints are working marks, not history (§21.4): every list and count below leaves them out.@@ -148,61 +148,87 @@148148 }149149 150150 /**151- * Which saves touched each path on the line up to `saveId` (§21.5's detail panel). A save touched a path when152- * the path was added, changed or removed against the save before it; checkpoints are skipped, so a153- * change first recorded by a checkpoint counts at the next real save.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).154153  */155154 export function fileTouches(db: Database, saveId: string): Touches {156-	const rows = db157-		.query<Save & { path: string; n: number }, { at: string }>(158-			`WITH line AS (159-				SELECT ${SAVE_COLUMNS}, LAG(id) OVER (ORDER BY rowid) AS prev, rowid AS n FROM saves160-				WHERE ${NOT_CHECKPOINT} AND rowid <= (SELECT rowid FROM saves WHERE id = $at)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)`,161165 			)162-			SELECT ${LINE_COLUMNS}, f.path FROM line JOIN save_files f ON f.save_id = line.id163-			LEFT JOIN save_files p ON p.save_id = line.prev AND p.path = f.path164-			WHERE p.hash IS NOT f.hash OR p.executable IS NOT f.executable165-			UNION ALL166-			SELECT ${LINE_COLUMNS}, p.path FROM line JOIN save_files p ON p.save_id = line.prev167-			WHERE NOT EXISTS (SELECT 1 FROM save_files f WHERE f.save_id = line.id AND f.path = p.path)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)168174 			ORDER BY n`,169175 		)170-		.all({ at: saveId });171-	const saves = new Map<string, Save>();176+		.all(at);177+	const line = new Set<Save>();172178 	const byPath = new Map<string, Save[]>();173-	for (const { path, n: _, ...row } of rows) {174-		const save = saves.get(row.id) ?? asSave(row);175-		saves.set(save.id, save);176-		const touched = byPath.get(path);177-		if (touched) touched.push(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);178184 		else byPath.set(path, [save]);179185 	}180-	return { line: [...saves.values()], byPath };186+	return { line: [...line], byPath };181187 }182188 183-/** Lines per language at the newest real save, most first, vendored code left out as in the header's language. */184-export function languageLines(db: Database): [string, number][] {185-	return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];189+/** The files at a save with what each says about itself, as recorded when it landed. */190+export 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+		}));186209 }187210 188-/** Every distinct object (unique file contents) a real save refers to, at any point in history. */189-export function objectHashes(db: Database): string[] {190-	// From `objects`, probing save_files_hash: every save lists its whole tree, so a DISTINCT over191-	// save_files reads saves × files rows (1.4 s for 1,083 saves on Tylee).211+/** The file contents this repo's real saves refer to, as recounted when its saves land. */212+export function knownObjects(db: Database): string[] {192213 	return db193-		.query<{ hash: string }, []>(194-			`SELECT o.hash195-			 FROM objects o196-			 WHERE EXISTS (197-				SELECT 1198-				FROM save_files f199-				JOIN saves s ON s.id = f.save_id200-				WHERE f.hash = o.hash201-				AND s.${NOT_CHECKPOINT}202-			 )`,203-		)214+		.query<{ hash: string }, []>("SELECT hash FROM known_objects")204215 		.all()205216 		.map((row) => row.hash);217+}218+219+/** The newest real saves, newest first. */220+export 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+}228+229+/** Lines per language at the newest real save, most first, vendored code left out as in the header's language. */230+export function languageLines(db: Database): [string, number][] {231+	return JSON.parse(mirrorState(db, "languages") ?? "[]") as [string, number][];206232 }207233 208234 /** Every release record the bucket holds, in no particular order. */

view file

src/mirror/store.ts+153 −23
@@ -3,12 +3,13 @@33 import { join } from "node:path";44 import { inflateSync } from "node:zlib";55 import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts";6+import { fileFacts } from "../describe/describe.ts";67 import { isTestPath } from "../describe/links.ts";78 89 // Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs,910 // plus `releases`: each version's release.json (§11.2) as read from the bucket, since that is bucket10-// data too and pages and feeds need it on every request, and `hidden_files`.11-const SCHEMA_VERSION = 3;11+// data too and pages and feeds need it on every request, `hidden_files`, and what the Atlas reads.12+const SCHEMA_VERSION = 4;1213 // The operator's hidden rows, moved out of `save_files` rather than deleted, so a changed hidden1314 // list is applied in place, with nothing fetched again (2026-10-02).1415 const HIDDEN_FILES = `@@ -20,6 +21,25 @@2021 	PRIMARY KEY (save_id, path)2122 );2223 `;24+// What pages read, worked out as each save lands rather than on every view (2026-10-02): which25+// real saves touched each path (added, changed or removed against the real save before; `n` is the26+// touching save's rowid), what each file says about itself, and the objects real saves refer to.27+const DERIVED = `28+CREATE TABLE known_objects (hash TEXT PRIMARY KEY) WITHOUT ROWID;29+CREATE TABLE touches (30+	path TEXT NOT NULL,31+	n    INTEGER NOT NULL32+);33+CREATE INDEX touches_n ON touches(n);34+CREATE TABLE facts (35+	hash        TEXT NOT NULL,36+	path        TEXT NOT NULL,37+	lines       INTEGER,38+	description TEXT,39+	outline     TEXT,40+	PRIMARY KEY (hash, path)41+);42+`;2343 const SCHEMA = `2444 CREATE TABLE objects (2545 	hash TEXT PRIMARY KEY,@@ -65,7 +85,8 @@6585 );6686 CREATE INDEX saves_parent ON saves(parent);6787 CREATE INDEX save_files_hash ON save_files(hash);68-${HIDDEN_FILES}`;88+${HIDDEN_FILES}89+${DERIVED}`;6990 7091 /** An object as the bucket stores it: zlib bytes, already checked against its hash. */7192 export type StoredObject = { compressed: Uint8Array; size: number };@@ -117,8 +138,8 @@117138 118139 // Vendored code is someone else's: counting Koh 3's bundled SQLite made it read as 290k lines of C.119140 const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//;120-// Bumped when what counts as a line changes, so every mirror recounts once when it opens.121-const STATS_RULE = "source-only";141+// Bumped when what the recount records changes, so every mirror recounts once when it opens.142+const STATS_RULE = "source-only, known objects";122143 123144 const open = new Map<string, Database>();124145 @@ -156,7 +177,7 @@156177 	db.run("PRAGMA busy_timeout = 5000");157178 	// A current mirror needs no lock; only creating or migrating one takes the write lock.158179 	const found = schemaVersion(db) === SCHEMA_VERSION ? SCHEMA_VERSION : upgrade(db);159-	if (found !== 0 && found !== 2 && found !== SCHEMA_VERSION) {180+	if (found !== 0 && found !== 2 && found !== 3 && found !== SCHEMA_VERSION) {160181 		db.close();161182 		throw new Error(162183 			`${path} has mirror schema version ${found}; this Kepr reads version ${SCHEMA_VERSION}. ` +@@ -181,13 +202,17 @@181202 	return db182203 		.transaction(() => {183204 			const found = schemaVersion(db);205+			if (found === SCHEMA_VERSION || ![0, 2, 3].includes(found)) return found;184206 			if (found === 0) db.run(SCHEMA);185-			if (found === 2) {186-				db.run(HIDDEN_FILES);187-				// Version 2 deleted hidden rows, so only the bucket can bring them back on unhide.188-				if (mirrorState(db, "hidden")) clearMirror(db);207+			if (found === 2) db.run(HIDDEN_FILES);208+			if (found === 2 || found === 3) db.run(DERIVED);209+			// Version 2 deleted hidden rows, so only the bucket can bring them back on unhide.210+			if (found === 2 && mirrorState(db, "hidden")) clearMirror(db);211+			else if (found !== 0) {212+				recordTouches(db, 0);213+				recordFacts(db, null);189214 			}190-			if (found === 0 || found === 2) db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);215+			db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);191216 			return found;192217 		})193218 		.immediate();@@ -281,16 +306,17 @@281306 			for (const f of s.files) putFile.run(s.id, f.path, f.hash, f.executable ? 1 : 0);282307 		}283308 		for (const r of landing.releases) putRelease.run(r.version, JSON.stringify(r));284-		applyStrips(db);285-		hideRows(286-			db,287-			hiddenList(db),288-			landing.saves.map((save) => save.id),289-		);309+		const stripped = applyStrips(db);310+		const landed = landing.saves.map((save) => save.id);311+		hideRows(db, hiddenList(db), landed);312+		// A strip can reach any save, so every save's touches are worked out again after one.313+		recordTouches(db, stripped ? 0 : Number(mirrorState(db, "touches_to") ?? 0));314+		recordFacts(db, landed);290315 		setState.run("head", landing.head);291316 		setState.run("releases_latest", landing.releasesLatest);292317 		const quarantined = new Set([...quarantinedPaths(db), ...landing.quarantined]);293318 		setState.run("quarantined", JSON.stringify([...quarantined].sort()));319+		staleStats(db);294320 	}).immediate();295321 	// Outside the transaction: the recount can inflate megabytes and needs no lock to be correct.296322 	refreshStats(db);@@ -310,10 +336,85 @@310336 		);311337 		db.run("DELETE FROM hidden_files");312338 		hideRows(db, hidden, null);339+		recordTouches(db, 0);340+		recordFacts(db, null);341+		staleStats(db);313342 	})();314343 	refreshStats(db);315344 }316345 346+// Marks the counts out of date in the change's own transaction: if Kepr stops before the recount347+// that follows, the mirror's next open does it.348+function staleStats(db: Database): void {349+	db.run("DELETE FROM state WHERE key = 'stats_rule'");350+}351+352+/**353+ * Records which real saves touched each path, for the saves after rowid `from`; 0 starts again.354+ * A save touched a path when it was added, changed or removed against the real save before it;355+ * checkpoints are skipped, so a change first recorded by a checkpoint counts at the next real save.356+ */357+function recordTouches(db: Database, from: number): void {358+	if (from === 0) db.run("DELETE FROM touches");359+	db.query(360+		`WITH line AS (361+			SELECT id, rowid AS n, LAG(id) OVER (ORDER BY rowid) AS prev362+			FROM saves363+			WHERE is_checkpoint = 0364+		)365+		INSERT INTO touches (path, n)366+		SELECT f.path, line.n367+		FROM line368+		JOIN save_files f ON f.save_id = line.id369+		LEFT JOIN save_files p ON p.save_id = line.prev AND p.path = f.path370+		WHERE line.n > $from371+		AND (p.hash IS NOT f.hash OR p.executable IS NOT f.executable)372+		UNION ALL373+		SELECT p.path, line.n374+		FROM line375+		JOIN save_files p ON p.save_id = line.prev376+		WHERE line.n > $from377+		AND NOT EXISTS (SELECT 1 FROM save_files f WHERE f.save_id = line.id AND f.path = p.path)`,378+	).run({ from });379+	const last = db.query<{ n: number | null }, []>("SELECT MAX(rowid) AS n FROM saves").get()?.n;380+	db.query("INSERT OR REPLACE INTO state (key, value) VALUES ('touches_to', ?)").run(381+		String(last ?? 0),382+	);383+}384+385+/** Records what each file of the given saves, or (null) every save, says about itself, once per version and path. */386+function recordFacts(db: Database, saves: string[] | null): void {387+	const wanted = db388+		.query<{ hash: string; path: string }, { all: number; saves: string }>(389+			`SELECT DISTINCT f.hash, f.path390+			FROM save_files f391+			WHERE ($all = 1 OR f.save_id IN (SELECT value FROM json_each($saves)))392+			AND NOT EXISTS (SELECT 1 FROM facts x WHERE x.hash = f.hash AND x.path = f.path)`,393+		)394+		.all({ all: saves === null ? 1 : 0, saves: JSON.stringify(saves ?? []) });395+	const sizeOf = db.query<{ size: number }, [string]>("SELECT size FROM objects WHERE hash = ?");396+	const dataOf = db.query<{ data: Uint8Array }, [string]>(397+		"SELECT data FROM objects WHERE hash = ?",398+	);399+	const put = db.query(400+		"INSERT OR IGNORE INTO facts (hash, path, lines, description, outline) VALUES (?, ?, ?, ?, ?)",401+	);402+	for (const { hash, path } of wanted) {403+		const size = sizeOf.get(hash)?.size;404+		if (size === undefined) continue;405+		// The stored bytes are read only for a file small enough to describe.406+		const read = () => inflateSync((dataOf.get(hash) as { data: Uint8Array }).data);407+		const facts = fileFacts(path, size, read);408+		put.run(409+			hash,410+			path,411+			facts.lines,412+			facts.description,413+			facts.outline && JSON.stringify(facts.outline),414+		);415+	}416+}417+317418 function hiddenList(db: Database): string[] {318419 	return mirrorState(db, "hidden")?.split("\n").filter(Boolean) ?? [];319420 }@@ -344,21 +445,22 @@344445 // §10.2: a strip of one save reaches that save; a strip of every save bans its path from all of345446 // history, whenever a save was made or arrives. Re-applying every strip is harmless, which is how a346447 // save that arrives after its strip event is covered too.347-function applyStrips(db: Database): void {448+// Returns whether any save lost a file.449+function applyStrips(db: Database): boolean {348450 	const strips = db349451 		.query<{ save_id: string; path: string }, []>(350452 			"SELECT save_id, path FROM events WHERE kind = 'strip'",351453 		)352454 		.all();353-	if (strips.length === 0) return;455+	if (strips.length === 0) return false;456+	let removed = 0;354457 	for (const strip of strips) {355458 		const { where, params } = pathMatch(strip.path);356459 		// Hidden rows too, so unhiding never brings back what a strip removed.357460 		for (const table of ["save_files", "hidden_files"]) {358-			db.query(`DELETE FROM ${table} WHERE ${where} AND ($save = '' OR save_id = $save)`).run({359-				...params,360-				save: strip.save_id,361-			});461+			removed += db462+				.query(`DELETE FROM ${table} WHERE ${where} AND ($save = '' OR save_id = $save)`)463+				.run({ ...params, save: strip.save_id }).changes;362464 		}363465 	}364466 	db.run(@@ -366,6 +468,8 @@366468 		WHERE hash NOT IN (SELECT hash FROM save_files)367469 		AND hash NOT IN (SELECT hash FROM hidden_files)`,368470 	);471+	db.run("DELETE FROM facts WHERE hash NOT IN (SELECT hash FROM objects)");472+	return removed > 0;369473 }370474 371475 /** The SQL condition matching a path, file or folder, or an `ANY_DEPTH` name at any depth. */@@ -394,6 +498,9 @@394498 		const tables = [395499 			"save_files",396500 			"hidden_files",501+			"touches",502+			"facts",503+			"known_objects",397504 			"events",398505 			"saves",399506 			"objects",@@ -410,7 +517,13 @@410517 // Lines and languages never change between syncs, so they are counted once here, not per view. They411518 // come from the newest real save, not `head`: a checkpoint's files are not public history (§21.4).412519 // Lines are source lines: files in a known language, tests left out; docs and data never count.520+// Read and written under one write lock: both threads recount, and a recount must never publish521+// what it read before another thread's newer one.413522 function refreshStats(db: Database): void {523+	db.transaction(() => recount(db)).immediate();524+}525+526+function recount(db: Database): void {414527 	const save =415528 		db416529 			.query<{ id: string }, []>(@@ -443,5 +556,22 @@443556 	setState.run("language", languages[0]?.[0] ?? "");444557 	setState.run("languages", JSON.stringify(languages));445558 	setState.run("stats_save", save);559+	// §21.1's "the library knows N things": the file contents any real save refers to, which the560+	// front page unites across public repos. From `objects`, probing save_files_hash: a DISTINCT561+	// over save_files reads saves × files rows.562+	db.run("DELETE FROM known_objects");563+	db.run(564+		`INSERT INTO known_objects (hash)565+		SELECT o.hash566+		FROM objects o567+		WHERE EXISTS (568+			SELECT 1569+			FROM save_files f570+			JOIN saves s ON s.id = f.save_id571+			WHERE f.hash = o.hash572+			AND s.is_checkpoint = 0573+		)`,574+	);575+	// Last, with the recount it names, so one cut short is done again at the next open.446576 	setState.run("stats_rule", STATS_RULE);447577 }

view file

src/pages/atlas.ts+22 −83
@@ -1,21 +1,10 @@1-import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2-import { isTestPath, type Outline, outline, repoIndex, resolveImports } from "../describe/links.ts";3-import {4-	filesAt,5-	fileTouches,6-	headId,7-	objectBytes,8-	type Save,9-	type Touches,10-	tarballGeneration,11-} from "../mirror/queries.ts";12-import { isText } from "./files.ts";1+import { describeFolder, firstSentence } from "../describe/describe.ts";2+import { isTestPath, type Outline, repoIndex, resolveImports } from "../describe/links.ts";3+import { filesWithFacts, fileTouches, type Save, type Touches } from "../mirror/queries.ts";134 import { type Html, html } from "./html.ts";145 import {156 	formatDate,167 	formatSize,17-	openPublicRepo,18-	type PageContext,198 	type PublicRepo,209 	pathUrl,2110 	plural,@@ -48,61 +37,27 @@4837 4938 type View = { repo: string; at: string | null; links: Links };5039 51-type Facts = { lines: number | null; description: string | null; outline: Outline | null };52-5340 // A panel lists this many names or links, then says how many more.5441 const LISTED = 12;5542 56-// Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An57-// object never changes, so what it says about itself is kept for the life of the process, keyed by58-// hash and file name (the name picks the comment syntax).59-// ponytail: unbounded, one small entry per object viewed; an LRU if a bucket reaches millions.60-const known = new Map<string, Facts>();61-62-function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {63-	const key = `${hash} ${name}`;64-	const cached = known.get(key);65-	if (cached) return cached;66-	const bytes = objectBytes(db, hash);67-	const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;68-	const facts =69-		text === null70-			? { lines: null, description: null, outline: null }71-			: {72-					lines: countLines(text),73-					description: describeFile(name, text),74-					outline: outline(name, text),75-				};76-	known.set(key, facts);77-	return facts;78-}79-8043 /** The tree at one save with each file's facts and the links between files. */8144 type AtlasModel = { root: AtlasFolder; links: Links; count: number };8245 83-// Building a model reads every file's facts and every save that touched each path: about 165 ms84-// warm for a 550-file repo on Tylee. Panels are fetched one at a time on hover, so the model is85-// kept, keyed by everything that changes what it holds: the save, and the strips and hidden paths86-// (`tarballGeneration`).87-// ponytail: the newest MODELS models in insertion order, not an LRU; enough for a few repos at once.88-const MODELS = 16;89-const models = new Map<string, AtlasModel>();90-91-// In one read snapshot: the sync's thread can apply a hidden list between any two statements, and92-// a model kept under the old generation must never hold a file the new one hides.46+// Built per request from what each save recorded as it landed (touches and facts), so nothing is47+// kept between requests. In one read snapshot: the sync's thread can change the mirror between any48+// two statements.9349 function atlasModel(repo: PublicRepo, saveId: string): AtlasModel {9450 	return repo.mirror.transaction(() => buildAtlasModel(repo, saveId))();9551 }9652 9753 function buildAtlasModel(repo: PublicRepo, saveId: string): AtlasModel {98-	const key = `${repo.settings.name} ${saveId} ${tarballGeneration(repo.mirror)}`;99-	const cached = models.get(key);100-	if (cached) return cached;10154 	const touches = fileTouches(repo.mirror, saveId);10255 	const foldersTouches = folderTouches(touches);10356 	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };10457 	// Koh's own rules file is bookkeeping, not part of the project; Koh never saves what it names.105-	const entries = filesAt(repo.mirror, saveId).filter((entry) => entry.path !== ".kohignore");58+	const entries = filesWithFacts(repo.mirror, saveId).filter(59+		(entry) => entry.path !== ".kohignore",60+	);10661 	for (const entry of entries) {10762 		const parts = entry.path.split("/");10863 		let folder = root;@@ -115,28 +70,19 @@11570 			}11671 			folder = child;11772 		}118-		const name = parts[parts.length - 1] as string;11973 		folder.files.push({120-			name,74+			name: parts[parts.length - 1] as string,12175 			path: entry.path,12276 			size: entry.size,123-			...factsOf(repo.mirror, entry.hash, name),77+			lines: entry.lines,78+			description: entry.description,79+			outline: entry.outline,12480 			saves: touches.byPath.get(entry.path) ?? [],12581 		});12682 	}127-	const model = { root, links: linksOf(allFiles(root)), count: entries.length };128-	if (models.size >= MODELS) models.delete(models.keys().next().value as string);129-	models.set(key, model);130-	return model;83+	return { root, links: linksOf(allFiles(root)), count: entries.length };13184 }13285 133-/** Builds a public repo's Atlas at its head ahead of the first visitor, as the sync calls it. */134-export function warmAtlas(ctx: Pick<PageContext, "kepr" | "mirrorDir">, name: string): void {135-	const repo = openPublicRepo(ctx, name);136-	const head = repo && headId(repo.mirror);137-	if (repo && head) atlasModel(repo, head);138-}139-14086 /**14187  * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without14288  * JavaScript. A row's detail panel is fetched from `atlasPanel` on hover or select (kepr.js), so@@ -398,19 +344,19 @@398344 399345 // Each folder's saves are the line's saves that touched any path under it, in line order.400346 function folderTouches(touches: Touches): Map<string, Save[]> {401-	const ids = new Map<string, Set<string>>();402-	for (const [path, saves] of touches.byPath) {347+	const order = new Map(touches.line.map((save, i) => [save, i]));348+	const saves = new Map<string, Set<Save>>();349+	for (const [path, touched] of touches.byPath) {403350 		const parts = path.split("/");404351 		for (let depth = 1; depth < parts.length; depth++) {405352 			const folder = parts.slice(0, depth).join("/");406-			const set = ids.get(folder) ?? new Set<string>();407-			for (const save of saves) set.add(save.id);408-			ids.set(folder, set);353+			const set = saves.get(folder) ?? new Set<Save>();354+			for (const save of touched) set.add(save);355+			saves.set(folder, set);409356 		}410357 	}411-	return new Map(412-		[...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),413-	);358+	const byLine = (a: Save, b: Save) => (order.get(a) ?? 0) - (order.get(b) ?? 0);359+	return new Map([...saves].map(([folder, set]) => [folder, [...set].sort(byLine)]));414360 }415361 416362 function allFiles(folder: AtlasFolder): AtlasFile[] {@@ -420,11 +366,4 @@420366 // Relative links in a description resolve from the file's own folder, as in a README.421367 function markdownBase(view: View, path: string): MarkdownBase {422368 	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };423-}424-425-function countLines(text: string): number {426-	if (text === "") return 0;427-	let count = 0;428-	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;429-	return text.endsWith("\n") ? count : count + 1;430369 }

view file

src/pages/files.ts+1 −15
@@ -1,5 +1,6 @@11 import type { Context, Hono } from "hono";22 import { isSafePath } from "../bucket/repo.ts";3+import { isText, SHOWN_BYTES } from "../describe/describe.ts";34 import { type FileEntry, fileAt, headId, objectBytes, saveByPrefix } from "../mirror/queries.ts";45 import { highlightLines } from "./highlight.ts";56 import { type Html, html } from "./html.ts";@@ -14,7 +15,6 @@1415 } from "./layout.ts";1516 import { renderMarkdown } from "./markdown.ts";1617 17-const SHOWN_BYTES = 1024 * 1024;1818 const IMAGE = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i;1919 const MARKDOWN = /\.(md|markdown)$/i;2020 // Binary types a browser can show or save as they are; anything else textual stays@@ -69,20 +69,6 @@6969 	const file = saveId ? fileAt(repo.mirror, saveId, path) : null;7070 	const bytes = file && objectBytes(repo.mirror, file.hash);7171 	return file && bytes ? { repo: name, path, at: at && saveId, file, bytes } : null;72-}73-74-export function isText(bytes: Uint8Array): boolean {75-	if (bytes.subarray(0, 8000).includes(0)) return false;76-	try {77-		// Streaming mode tolerates only a character cut at the 1 MB mark; invalid bytes still throw.78-		const cut = bytes.length > SHOWN_BYTES;79-		new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, SHOWN_BYTES), {80-			stream: cut,81-		});82-		return true;83-	} catch {84-		return false;85-	}8672 }8773 8874 function contentType(path: string, bytes: Uint8Array): string {

view file

src/pages/front.ts+3 −3
@@ -1,5 +1,5 @@11 import type { Hono } from "hono";2-import { languageLines, objectHashes } from "../mirror/queries.ts";2+import { knownObjects, languageLines } from "../mirror/queries.ts";33 import { setting } from "../server/db.ts";44 import { type Html, html } from "./html.ts";55 import { cacheFor, type PageContext, page, plural } from "./layout.ts";@@ -14,8 +14,8 @@1414 	app.get("/", (c) => {1515 		const library = publicLibrary(ctx);1616 		const tagline = setting(ctx.kepr, "tagline") ?? "";17-		// §21.1: distinct objects across every public repo's saves, as Kepr 2 counted object refs.18-		const known = new Set(library.flatMap((repo) => objectHashes(repo.mirror))).size;17+		// §21.1: distinct objects across every public repo's real saves.18+		const known = new Set(library.flatMap((repo) => knownObjects(repo.mirror))).size;1919 		const latest = library2020 			.filter((repo) => repo.stats.updated !== null)2121 			.sort((a, b) => (b.stats.updated ?? 0) - (a.stats.updated ?? 0))

view file

src/pages/library.ts+7 −5
@@ -1,11 +1,11 @@1-import { type RepoStats, repoStats, type Save, timeline } from "../mirror/queries.ts";1+import { newestSaves, type RepoStats, repoStats, type Save } from "../mirror/queries.ts";22 import { publicRepoNames } from "../server/db.ts";33 import { type Html, html } from "./html.ts";44 import { openPublicRepo, type PageContext, type PublicRepo, plural } from "./layout.ts";55 import { repoIntro } from "./repo.ts";66 7-/** A public repo as the front page, browse and the profile show it. `saves` are oldest first. */8-export type LibraryRepo = PublicRepo & { stats: RepoStats; description: string; saves: Save[] };7+/** A public repo as the front page, browse and the profile show it. */8+export type LibraryRepo = PublicRepo & { stats: RepoStats; description: string };99 1010 export type RecentSave = { repo: string; save: Save };1111 @@ -15,14 +15,16 @@1515 		const repo = openPublicRepo(ctx, name);1616 		if (!repo) return [];1717 		const description = repoIntro(repo)?.text ?? "";18-		return [{ ...repo, stats: repoStats(repo.mirror), description, saves: timeline(repo.mirror) }];18+		return [{ ...repo, stats: repoStats(repo.mirror), description }];1919 	});2020 }2121 2222 /** The newest saves across the library, checkpoints never among them. */2323 export function recentSaves(library: LibraryRepo[], limit: number): RecentSave[] {2424 	return library25-		.flatMap((repo) => repo.saves.slice(-limit).map((save) => ({ repo: repo.settings.name, save })))25+		.flatMap((repo) =>26+			newestSaves(repo.mirror, limit).map((save) => ({ repo: repo.settings.name, save })),27+		)2628 		.sort((a, b) => b.save.created_at - a.save.created_at)2729 		.slice(0, limit);2830 }

view file

src/pages/save.ts+1 −1
@@ -1,5 +1,6 @@11 import type { Database } from "bun:sqlite";22 import type { Hono } from "hono";3+import { isText } from "../describe/describe.ts";34 import {45 	type FileEntry,56 	filesAt,@@ -10,7 +11,6 @@1011 	visibleParent,1112 } from "../mirror/queries.ts";1213 import { diffLines, type Edit, hunks } from "./diff.ts";13-import { isText } from "./files.ts";1414 import { highlightLines } from "./highlight.ts";1515 import { type Html, html } from "./html.ts";1616 import {

view file

tests/front.test.ts+25 −0
@@ -250,3 +250,28 @@250250 	}251251 	expect(blocks).toContain("animation: none;");252252 });253+254+test("a file two public repos share is one thing the library knows", async () => {255+	const repos = await makeRepos(["one", "two"]);256+	for (const repo of ["one", "two"]) {257+		await writeFile(join(repos.bucketDir, "..", "work", repo, "LICENSE"), "the same licence\n");258+		repos.koh(repo, "save", "add the licence");259+		repos.koh(repo, "offer");260+	}261+	const dir = await mkdtemp(join(tmpdir(), "kepr-front-"));262+	const db = openKeprDb(dir);263+	const bucket = openBucket({ kind: "folder", path: repos.bucketDir });264+	await syncAll({ bucket, kepr: db, mirrorDir: join(dir, "mirror") });265+	db.run("UPDATE repos SET public = 1");266+	const shared = createApp(267+		{268+			domain: "kepr.example",269+			dataDir: dir,270+			adminPasswordHash: "$argon2id$x",271+			bucket: { kind: "folder", path: repos.bucketDir },272+		},273+		db,274+	);275+	// Two READMEs that differ; koh's default .kohignore and the licence, each shared by both.276+	expect(await (await shared.request("/")).text()).toContain("The library knows<br>4 things.");277+});

view file

tests/sync.test.ts+70 −18
@@ -155,17 +155,6 @@155155 	expect(saveByPrefix(mirror, "af96c81")?.message).toBe("made by Koh 3");156156 });157157 158-test("tells its target about each repo that synced cleanly, and not one that failed", async () => {159-	const fx = await makeKohBucket("told");160-	const t = await syncTarget(fx);161-	const synced: string[] = [];162-	await syncAll({ ...t, synced: (repo) => synced.push(repo) });163-	expect(synced).toEqual(["told"]);164-	await writeFile(join(fx.bucketDir, fx.repo, "format"), "koh-remote 2");165-	await syncAll({ ...t, synced: (repo) => synced.push(repo) });166-	expect(synced).toEqual(["told"]);167-});168-169158 test("a sync lands history in batches, so one cut short resumes where it stopped", async () => {170159 	const fx = await makeKohBucket("batched");171160 	const bucket = openBucket({ kind: "folder", path: fx.bucketDir });@@ -343,16 +332,16 @@343332 });344333 345334 describe("the mirror's schema version", () => {346-	test("a version 3 mirror reopens and any other version is refused as disposable", async () => {335+	test("a version 4 mirror reopens and any other version is refused as disposable", async () => {347336 		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));348337 		const created = openMirror(dir, "one");349338 		created.run("PRAGMA wal_checkpoint(TRUNCATE)");350339 		await Bun.write(join(dir, "copy.db"), Bun.file(join(dir, "one.db")));351340 		expect(openMirror(dir, "copy").query("PRAGMA user_version").get()).toEqual({352-			user_version: 3,341+			user_version: 4,353342 		});354343 355-		for (const version of [1, 4]) {344+		for (const version of [1, 5]) {356345 			const other = new Database(join(dir, `v${version}.db`));357346 			other.run(`PRAGMA user_version = ${version}`);358347 			other.close();@@ -360,14 +349,16 @@360349 		}361350 	});362351 363-	test("a version 2 mirror moves to 3 in place, emptied only when it deleted hidden rows", async () => {352+	test("a version 2 mirror moves to 4 in place, emptied only when it deleted hidden rows", async () => {364353 		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));365354 		for (const [name, hidden] of [366355 			["plain", ""],367356 			["hiding", "secret.txt"],368357 		] as const) {369358 			const db = openMirror(dir, name);370-			db.run("DROP TABLE hidden_files");359+			for (const table of ["hidden_files", "touches", "facts", "known_objects"]) {360+				db.run(`DROP TABLE ${table}`);361+			}371362 			db.run("PRAGMA user_version = 2");372363 			db.query("INSERT INTO state (key, value) VALUES ('head', 'abcdef123456'), ('hidden', ?)").run(373364 				hidden,@@ -379,10 +370,68 @@379370 		expect(head("hiding")).toBeNull();380371 		for (const name of ["plain", "hiding"]) {381372 			const db = openMirror(dir, name);382-			expect(db.query("PRAGMA user_version").get()).toEqual({ user_version: 3 });373+			expect(db.query("PRAGMA user_version").get()).toEqual({ user_version: 4 });383374 			expect(db.query("SELECT COUNT(*) AS n FROM hidden_files").get()).toEqual({ n: 0 });384375 		}385376 	});377+378+	test("touches recorded save by save match touches worked out all at once", async () => {379+		const fx = await makeKohBucket("stepwise");380+		const t = await syncTarget(fx);381+		await syncAll(t);382+		await writeFile(join(fx.repoDir, "src/main.ts"), "// Entry point.\nconsole.log('again');\n");383+		await writeFile(join(fx.repoDir, "added.txt"), "new\n");384+		Bun.spawnSync(["koh3", "save", "change main, add a file"], {385+			cwd: fx.repoDir,386+			env: { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" },387+		});388+		Bun.spawnSync(["koh3", "offer"], {389+			cwd: fx.repoDir,390+			env: { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" },391+		});392+		await syncAll(t);393+		const touches = () => t.mirror().query("SELECT path, n FROM touches ORDER BY n, path").all();394+		const stepwise = touches();395+		expect(stepwise).toContainEqual(expect.objectContaining({ path: "added.txt" }));396+		// Reopened as version 3, the mirror works every save's touches out again in one go.397+		for (const table of ["touches", "facts", "known_objects"]) {398+			t.mirror().run(`DROP TABLE ${table}`);399+		}400+		t.mirror().run("PRAGMA user_version = 3");401+		closeMirror(t.mirrorDir, fx.repo);402+		expect(touches()).toEqual(stepwise);403+	});404+405+	test("a recount cut short is done when the mirror next opens", async () => {406+		const fx = await makeKohBucket("recount");407+		const t = await syncTarget(fx);408+		await syncAll(t);409+		const known = () => t.mirror().query("SELECT COUNT(*) AS n FROM known_objects").get();410+		const counted = known();411+		// What a landing leaves if Kepr stops before its recount commits.412+		t.mirror().run("DELETE FROM known_objects");413+		t.mirror().run("DELETE FROM state WHERE key = 'stats_rule'");414+		closeMirror(t.mirrorDir, fx.repo);415+		expect(known()).toEqual(counted);416+	});417+418+	test("a version 3 mirror gains the touches and facts a sync would have recorded", async () => {419+		const fx = await makeKohBucket("derived");420+		const t = await syncTarget(fx);421+		await syncAll(t);422+		const derived = () => [423+			t.mirror().query("SELECT path, n FROM touches ORDER BY n, path").all(),424+			t.mirror().query("SELECT * FROM facts ORDER BY path").all(),425+		];426+		const recorded = derived();427+		expect((recorded[0] as unknown[]).length).toBeGreaterThan(0);428+		for (const table of ["touches", "facts", "known_objects"]) {429+			t.mirror().run(`DROP TABLE ${table}`);430+		}431+		t.mirror().run("PRAGMA user_version = 3");432+		closeMirror(t.mirrorDir, fx.repo);433+		expect(derived()).toEqual(recorded);434+	});386435 });387436 388437 test("a strip that arrives older than the saves already mirrored still strips them (§10.2, R6)", async () => {@@ -488,7 +537,7 @@488537 	expect(JSON.parse(mirrorState(t.mirror(), "quarantined") ?? "[]")).toEqual(["huge.bin"]);489538 });490539 491-test("the line count skips a file over 8 MiB (A3)", async () => {540+test("the line count and the Atlas's facts skip a file over 8 MiB (A3)", async () => {492541 	const fx = await makeKohBucket("large-text");493542 	const t = await syncTarget(fx);494543 	await syncAll(t);@@ -500,4 +549,7 @@500549 	await syncAll(t);501550 	expect(history(t.mirror(), 1, 0)[0]?.message).toBe("add a big text file");502551 	expect(repoStats(t.mirror()).lines).toBe(before);552+	expect(553+		t.mirror().query("SELECT lines, description FROM facts WHERE path = 'big.txt'").get(),554+	).toEqual({ lines: null, description: null });503555 });

view file

tests/worker.test.ts+5 −10
@@ -1,5 +1,5 @@1-// Covers the sync on its own thread (2026-10-02): a real worker syncs a koh3-made bucket, tells the2-// app which repos synced, holds syncs off while the app works, and applies hidden lists.1+// Covers the sync on its own thread (2026-10-02): a real worker syncs a koh3-made bucket, holds2+// syncs off while the app works, and applies hidden lists.33 44 import { expect, test } from "bun:test";55 import { existsSync, renameSync } from "node:fs";@@ -12,20 +12,15 @@1212 import { openKeprDb } from "../src/server/db.ts";1313 import { makeKohBucket } from "./fixture.ts";1414 15-test("the sync worker syncs, reports, holds and hides", async () => {15+test("the sync worker syncs, holds and hides", async () => {1616 	const fx = await makeKohBucket("worked");1717 	const dataDir = await mkdtemp(join(tmpdir(), "kepr-worker-"));1818 	const kepr = openKeprDb(dataDir);19-	const synced: string[] = [];20-	const syncer = startSyncWorker(dataDir, { kind: "folder", path: fx.bucketDir }, (repo) =>21-		synced.push(repo),22-	);19+	const syncer = startSyncWorker(dataDir, { kind: "folder", path: fx.bucketDir });2320 	await syncer.syncAll();2421 	const mirror = () => openMirror(join(dataDir, "mirror"), fx.repo);2522 	const head = fx.saveIds[0] as string;2623 	expect(headId(mirror())).toBe(head);27-	for (let i = 0; i < 100 && !synced.includes(fx.repo); i++) await Bun.sleep(10);28-	expect(synced).toContain(fx.repo);2924 3025 	const order: string[] = [];3126 	let release = () => {};@@ -55,7 +50,7 @@5550 	const dataDir = await mkdtemp(join(tmpdir(), "kepr-worker-"));5651 	const mirrorDir = join(dataDir, "mirror");5752 	const kepr = openKeprDb(dataDir);58-	const syncer = startSyncWorker(dataDir, { kind: "folder", path: fx.bucketDir }, () => {});53+	const syncer = startSyncWorker(dataDir, { kind: "folder", path: fx.bucketDir });5954 	await syncer.syncAll();6055 	const head = fx.saveIds[0] as string;6156 	let edited: Promise<void> = Promise.resolve();

view file