0c253117d56c

apply hidden paths in place by setting rows aside in hidden_files instead of rebuilding from the bucket, hand the list to Koh from the sync with retry, and serve only visible objects on the Koh routes (v3.0.193)

time
2026-10-03 00:30 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
lightning quick kepr
parent
0a9f3887db51
10 files package.json+1 −1src/admin/repos.ts+8 −21src/admin/site.ts+6 −8src/bucket/sync.ts+44 −42src/mirror/remote.ts+9 −2src/mirror/store.ts+109 −39src/server/db.ts+20 −0tests/hidden.test.ts+47 −10tests/ignored.test.ts+0 −8tests/sync.test.ts+46 −4
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.192",3+	"version": "3.0.193",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/repos.ts+8 −21
@@ -1,11 +1,11 @@11 import type { Context, Hono } from "hono";2-import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";2+import { BucketWriteError } from "../bucket/reader.ts";33 import { isSafePath } from "../bucket/repo.ts";4-import { rebuildRepo, syncAll } from "../bucket/sync.ts";4+import { syncAll } from "../bucket/sync.ts";55 import { MAX_CHARS } from "../issues/spam.ts";66 import { operatorIssues } from "../issues/store.ts";77 import { languageLines, repoStats } from "../mirror/queries.ts";8-import { openMirror, quarantinedPaths } from "../mirror/store.ts";8+import { applyHidden, openMirror, quarantinedPaths } from "../mirror/store.ts";99 import { html } from "../pages/html.ts";1010 import { formatDate, plural } from "../pages/layout.ts";1111 import {@@ -17,7 +17,7 @@1717 	setRelease,1818 	visibleReleases,1919 } from "../releases/releases.ts";20-import { type RepoText, repoText, setRepoText } from "../server/db.ts";20+import { hiddenPaths, type RepoText, repoText, setRepoText } from "../server/db.ts";2121 import { dropTarballs } from "../tarball/cache.ts";2222 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";2323 import { renameErrors, renameProblem, renameRepo, renames } from "./rename.ts";@@ -100,7 +100,10 @@100100 		if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);101101 		updateRepo(ctx.kepr, repo.name, edit);102102 		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);103-		if (edit.hidden_paths !== repo.hidden_paths) await applyHidden(ctx, repo.name, hidden);103+		// The sync hands the list to Koh (`handHidden`); the mirror has it before the response.104+		if (edit.hidden_paths !== repo.hidden_paths) {105+			applyHidden(openMirror(ctx.mirrorDir, repo.name), hiddenPaths(ctx.kepr, repo.name));106+		}104107 		return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings&saved=1`, 303);105108 	});106109 @@ -205,22 +208,6 @@205208 			303,206209 		);207210 	});208-}209-210-/**211- * Hands a changed hidden list to Koh at `.kepr/hidden/<repo>`, one path per line, for it to add to212- * `.kohignore`; then rebuilds the mirror and drops kept tarballs, so hiding and unhiding both show213- * at once. Without a write key the list stays in Kepr: Koh reads it only from the bucket anyway.214- */215-async function applyHidden(ctx: AdminContext, repo: string, hidden: string[]): Promise<void> {216-	const list = new TextEncoder().encode(hidden.map((path) => `${path}\n`).join(""));217-	try {218-		await ctx.sync.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, list, "text/plain");219-		await dropTarballs(ctx.sync.bucket, repo);220-	} catch (error) {221-		if (!(error instanceof BucketWriteError)) throw error;222-	}223-	await rebuildRepo(ctx.sync, repo);224211 }225212 226213 /** A build of a known repo, as the console lists it, or null. */

view file

src/admin/site.ts+6 −8
@@ -3,12 +3,12 @@33 import { join } from "node:path";44 import type { Context, Hono } from "hono";55 import { isSafePath } from "../bucket/repo.ts";6-import { reapplyHidden } from "../bucket/sync.ts";6+import { applyHidden, openMirror } from "../mirror/store.ts";77 import { supportText } from "../pages/docs.ts";88 import { html } from "../pages/html.ts";9-import { ignoredNames, setting } from "../server/db.ts";9+import { hiddenPaths, ignoredNames, setting } from "../server/db.ts";1010 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";11-import { saveSettings } from "./store.ts";11+import { allRepos, saveSettings } from "./store.ts";1212 1313 // The settings Phase 6's pages read, with their labels and length caps.1414 const FIELDS: [key: string, label: string, max: number][] = [@@ -117,11 +117,9 @@117117 			rmSync(join(assets, previous), { force: true });118118 		}119119 		if (values.ignored !== undefined && values.ignored !== previousIgnored.join("\n")) {120-			const rebuild = previousIgnored.some((name) => !ignored.includes(name));121-			// Not awaited: it waits for any sync under way, which can take minutes on a big repo.122-			reapplyHidden(ctx.sync, rebuild).catch((error) =>123-				console.error(`kepr: applying ignored names failed: ${error}`),124-			);120+			for (const { name } of allRepos(ctx.kepr)) {121+				applyHidden(openMirror(ctx.mirrorDir, name), hiddenPaths(ctx.kepr, name));122+			}125123 		}126124 		return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);127125 	});

view file

src/bucket/sync.ts+44 −42
@@ -1,6 +1,6 @@11 import type { Database } from "bun:sqlite";22 import {3-	clearMirror,3+	applyHidden,44 	knownEventIds,55 	knownObject,66 	knownReleases,@@ -11,10 +11,16 @@1111 	type StoredObject,1212 	storeObjects,1313 } from "../mirror/store.ts";14-import { hiddenPaths, recordSync, registerRepo } from "../server/db.ts";14+import {15+	hiddenPaths,16+	hiddenToHand,17+	recordHiddenHanded,18+	recordSync,19+	registerRepo,20+} from "../server/db.ts";1521 import { RESERVED_NAMES } from "../server/reserved.ts";1622 import { dropTarballs } from "../tarball/cache.ts";17-import type { Bucket } from "./reader.ts";23+import { type Bucket, BucketWriteError, KEPR_PREFIX } from "./reader.ts";1824 import {1925 	BucketError,2026 	checkFormat,@@ -69,41 +75,6 @@6975 	return mine;7076 }7177 72-/** Rebuilds one repo's mirror from the bucket, once any sync under way has finished. */73-export async function rebuildRepo(target: SyncTarget, repo: string): Promise<void> {74-	await syncAll(target);75-	clearMirror(openMirror(target.mirrorDir, repo));76-	await syncAll(target);77-}78-79-/**80- * Applies a changed list of names ignored in every repo. Once any sync under way has finished (its81- * later batches would land with the old list), a longer list is applied to each mirror in place; a82- * name taken off needs its files back, so every mirror is rebuilt.83- */84-export async function reapplyHidden(target: SyncTarget, rebuild: boolean): Promise<void> {85-	const repos = await listRepos(target.bucket);86-	await syncAll(target);87-	for (const repo of repos) {88-		const mirror = openMirror(target.mirrorDir, repo);89-		const head = mirrorState(mirror, "head");90-		if (rebuild) clearMirror(mirror);91-		else if (head) {92-			land(mirror, {93-				head,94-				releasesLatest: mirrorState(mirror, "releases_latest") ?? "",95-				saves: [],96-				objects: new Map(),97-				events: [],98-				releases: [],99-				quarantined: [],100-				hidden: hiddenPaths(target.kepr, repo),101-			});102-		}103-	}104-	if (rebuild) await syncAll(target);105-}106-10778 export function startSyncLoop(target: SyncTarget): void {10879 	const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));10980 	tick();@@ -127,16 +98,48 @@12798 		}12899 		recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);129100 		if (error === null) target.synced?.(repo);101+		await handHidden(target, repo);130102 	}131103 }132104 133105 /**106+ * Hands a repo's hidden paths to Koh at `.kepr/hidden/<repo>`, one per line, for it to add to107+ * `.kohignore`, unless they are known to be there already, then drops the tarballs kept under the108+ * old list. The sync does it rather than the console, so a failed write or a restart is retried at109+ * the next pass, and it never overlaps a rename. Without a write key the list stays in Kepr: Koh110+ * reads it only from the bucket anyway.111+ */112+async function handHidden(target: SyncTarget, repo: string): Promise<void> {113+	const list = hiddenToHand(target.kepr, repo);114+	if (list === null) return;115+	const paths = list.split("\n").filter(Boolean);116+	const bytes = new TextEncoder().encode(paths.map((path) => `${path}\n`).join(""));117+	// Unknown until the write is confirmed, so a restart or failure mid-write is written again118+	// even if the list changes back to the one recorded before.119+	recordHiddenHanded(target.kepr, repo, null);120+	try {121+		await target.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, bytes, "text/plain");122+	} catch (error) {123+		if (!(error instanceof BucketWriteError)) {124+			console.error(`kepr: handing ${repo}'s hidden paths to Koh failed: ${error}`);125+		}126+		return;127+	}128+	recordHiddenHanded(target.kepr, repo, list);129+	// Only storage: a kept tarball is never served once its generation's hidden list has changed.130+	await dropTarballs(target.bucket, repo).catch((error) =>131+		console.error(`kepr: dropping ${repo}'s kept tarballs failed: ${error}`),132+	);133+}134+135+/**134136  * Koh's steal flow (§9.4) against a mirror: walk back from the remote head to a save the mirror has,135137  * fetch what is missing, and land it, in batches oldest first. Events are listed every time, since a136138  * flag or a strip arrives without moving `head`; releases only when `head` or `releases/latest`137139  * moved (§18), since publishing one moves both. A path §5.5 refuses, or one whose object inflates138- * past Koh's file limit, is quarantined: the save lands without it, as if stripped. Returns the139- * events it landed.140+ * past Koh's file limit, is quarantined: the save lands without it, as if stripped. `hidden` is141+ * applied first, so a list changed since the last sync takes effect even with nothing new. Returns142+ * the events it landed.140143  */141144 export async function syncRepo(142145 	bucket: Bucket,@@ -145,6 +148,7 @@145148 	hidden: string[] = [],146149 	landingBytes = LANDING_BYTES,147150 ): Promise<EventRecord[]> {151+	applyHidden(mirror, hidden);148152 	await checkFormat(bucket, repo);149153 	const head = await readHead(bucket, repo);150154 	if (head === null) return [];@@ -172,7 +176,6 @@172176 			events: [],173177 			releases: [],174178 			quarantined,175-			hidden,176179 		});177180 		saves = [];178181 		objects = new Map();@@ -241,7 +244,6 @@241244 		events,242245 		releases,243246 		quarantined,244-		hidden,245247 	});246248 	return events;247249 }

view file

src/mirror/remote.ts+9 −2
@@ -113,13 +113,16 @@113113 	);114114 115115 	// The object's stored bytes, zlib and all: `koh steal` inflates them itself and checks the hash.116+	// Only objects a visible file refers to: the mirror keeps hidden files' objects for unhiding.116117 	app.get("/:repo/koh/objects/:hash", (c) =>117118 		serve(c, c.req.param("repo"), (mirror) => {118119 			const hash = c.req.param("hash");119120 			if (!OBJECT_HASH.test(hash)) return miss(c);120121 			const row = mirror121122 				.query<{ data: Uint8Array<ArrayBuffer> }, [string]>(122-					"SELECT data FROM objects WHERE hash = ?",123+					`SELECT data FROM objects o124+					WHERE hash = ?125+					AND EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`,123126 				)124127 				.get(hash);125128 			return row@@ -147,7 +150,11 @@147150 	app.get("/:repo/koh/objects", (c) =>148151 		serve(c, c.req.param("repo"), (mirror) => {149152 			const hashes = mirror150-				.query<{ hash: string }, []>("SELECT hash FROM objects ORDER BY hash")153+				.query<{ hash: string }, []>(154+					`SELECT hash FROM objects o155+					WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)156+					ORDER BY hash`,157+				)151158 				.all()152159 				.map((row) => row.hash);153160 			return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);

view file

src/mirror/store.ts+109 −39
@@ -7,8 +7,19 @@77 88 // Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs,99 // 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.11-const SCHEMA_VERSION = 2;10+// data too and pages and feeds need it on every request, and `hidden_files`.11+const SCHEMA_VERSION = 3;12+// The operator's hidden rows, moved out of `save_files` rather than deleted, so a changed hidden13+// list is applied in place, with nothing fetched again (2026-10-02).14+const HIDDEN_FILES = `15+CREATE TABLE hidden_files (16+	save_id    TEXT NOT NULL REFERENCES saves(id),17+	path       TEXT NOT NULL,18+	hash       TEXT NOT NULL REFERENCES objects(hash),19+	executable INTEGER NOT NULL DEFAULT 0,20+	PRIMARY KEY (save_id, path)21+);22+`;1223 const SCHEMA = `1324 CREATE TABLE objects (1425 	hash TEXT PRIMARY KEY,@@ -54,7 +65,7 @@5465 );5566 CREATE INDEX saves_parent ON saves(parent);5667 CREATE INDEX save_files_hash ON save_files(hash);57-`;68+${HIDDEN_FILES}`;5869 5970 /** An object as the bucket stores it: zlib bytes, already checked against its hash. */6071 export type StoredObject = { compressed: Uint8Array; size: number };@@ -69,11 +80,6 @@6980 	releases: ReleaseRecord[];7081 	/** Escaped names of the paths these saves arrived without (§5.5, A3). */7182 	quarantined: string[];72-	/**73-	 * The operator's hidden paths (June, 2026-09-30), removed from every save like a strip, and the74-	 * names ignored in every repo (June, 2026-10-01), each as `ANY_DEPTH` + name.75-	 */76-	hidden: string[];7783 };7884 7985 /** Marks a hidden entry as a name matched at any depth rather than a path from the root. */@@ -136,7 +142,7 @@136142 	const { user_version } = db.query<{ user_version: number }, []>("PRAGMA user_version").get() ?? {137143 		user_version: 0,138144 	};139-	if (user_version !== 0 && user_version !== SCHEMA_VERSION) {145+	if (user_version !== 0 && user_version !== 2 && user_version !== SCHEMA_VERSION) {140146 		db.close();141147 		throw new Error(142148 			`${path} has mirror schema version ${user_version}; this Kepr reads version ${SCHEMA_VERSION}. ` +@@ -149,6 +155,14 @@149155 			db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);150156 		})();151157 	}158+	if (user_version === 2) {159+		db.transaction(() => {160+			db.run(HIDDEN_FILES);161+			// Version 2 deleted hidden rows, so only the bucket can bring them back on unhide.162+			if (mirrorState(db, "hidden")) clearMirror(db);163+			db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);164+		})();165+	}152166 	// Mirrors counted under an older rule are recounted now, not at the next save.153167 	if (mirrorState(db, "head") && mirrorState(db, "stats_rule") !== STATS_RULE) refreshStats(db);154168 	open.set(path, db);@@ -193,7 +207,6 @@193207 	return new Set(rows.map((row) => row.version));194208 }195209 196-/** Lands a sync like Koh's steal: objects, events, saves, then every strip over every save. */197210 /** Stores objects on their own, ahead of the save that refers to them. */198211 export function storeObjects(db: Database, objects: Map<string, StoredObject>): void {199212 	const put = db.query("INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)");@@ -202,6 +215,10 @@202215 	})();203216 }204217 218+/**219+ * Lands a sync like Koh's steal: objects, events, saves, then every strip over every save. The new220+ * saves are hidden by the list the mirror holds; `applyHidden` changes that list.221+ */205222 export function land(db: Database, landing: Landing): void {206223 	const putEvent = db.query(207224 		"INSERT OR IGNORE INTO events (id, kind, save_id, path, reason, created_at, machine) VALUES " +@@ -239,12 +256,11 @@239256 		}240257 		for (const r of landing.releases) putRelease.run(r.version, JSON.stringify(r));241258 		applyStrips(db);242-		let hidden = 0;243-		for (const path of landing.hidden) hidden += removePath(db, path, "");244-		if (hidden > 0) db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");245-		// In the same transaction as the removal, so the tarball key never names a list the rows246-		// don't reflect yet.247-		setState.run("hidden", landing.hidden.join("\n"));259+		hideRows(260+			db,261+			hiddenList(db),262+			landing.saves.map((save) => save.id),263+		);248264 		setState.run("head", landing.head);249265 		setState.run("releases_latest", landing.releasesLatest);250266 		const quarantined = new Set([...quarantinedPaths(db), ...landing.quarantined]);@@ -254,6 +270,46 @@254270 	refreshStats(db);255271 }256272 273+/**274+ * Makes `hidden` the mirror's hidden list: the operator's hidden paths (June, 2026-09-30) and the275+ * names ignored in every repo (June, 2026-10-01), each as `ANY_DEPTH` + name. Rows move between276+ * `save_files` and `hidden_files`, so it takes milliseconds and fetches nothing.277+ */278+export function applyHidden(db: Database, hidden: string[]): void {279+	if (hiddenList(db).join("\n") === hidden.join("\n")) return;280+	db.transaction(() => {281+		db.run(282+			`INSERT OR IGNORE INTO save_files (save_id, path, hash, executable)283+			SELECT save_id, path, hash, executable FROM hidden_files`,284+		);285+		db.run("DELETE FROM hidden_files");286+		hideRows(db, hidden, null);287+	})();288+	refreshStats(db);289+}290+291+function hiddenList(db: Database): string[] {292+	return mirrorState(db, "hidden")?.split("\n").filter(Boolean) ?? [];293+}294+295+// Moves the rows `hidden` matches, in the `saves` given or (null) every save, into `hidden_files`.296+// The list is recorded in the same transaction, so the tarball key never names a list the rows297+// don't reflect yet.298+function hideRows(db: Database, hidden: string[], saves: string[] | null): void {299+	for (const entry of hidden) {300+		const { where, params } = pathMatch(entry);301+		const scope = { ...params, all: saves === null ? 1 : 0, saves: JSON.stringify(saves ?? []) };302+		const chosen = `${where} AND ($all = 1 OR save_id IN (SELECT value FROM json_each($saves)))`;303+		db.query(304+			`INSERT OR IGNORE INTO hidden_files (save_id, path, hash, executable)305+			SELECT save_id, path, hash, executable FROM save_files306+			WHERE ${chosen}`,307+		).run(scope);308+		db.query(`DELETE FROM save_files WHERE ${chosen}`).run(scope);309+	}310+	db.query("INSERT OR REPLACE INTO state (key, value) VALUES ('hidden', ?)").run(hidden.join("\n"));311+}312+257313 /** Escaped names of every path this mirror's saves arrived without. */258314 export function quarantinedPaths(db: Database): string[] {259315 	return JSON.parse(mirrorState(db, "quarantined") ?? "[]");@@ -269,42 +325,56 @@269325 		)270326 		.all();271327 	if (strips.length === 0) return;272-	for (const strip of strips) removePath(db, strip.path, strip.save_id);273-	db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");328+	for (const strip of strips) {329+		const { where, params } = pathMatch(strip.path);330+		// Hidden rows too, so unhiding never brings back what a strip removed.331+		for (const table of ["save_files", "hidden_files"]) {332+			db.query(`DELETE FROM ${table} WHERE ${where} AND ($save = '' OR save_id = $save)`).run({333+				...params,334+				save: strip.save_id,335+			});336+		}337+	}338+	db.run(339+		`DELETE FROM objects340+		WHERE hash NOT IN (SELECT hash FROM save_files)341+		AND hash NOT IN (SELECT hash FROM hidden_files)`,342+	);274343 }275344 276-/** Removes a path, file or folder, from one save or ('') every save; returns the rows removed. */277-function removePath(db: Database, path: string, save: string): number {345+/** The SQL condition matching a path, file or folder, or an `ANY_DEPTH` name at any depth. */346+function pathMatch(path: string): { where: string; params: Record<string, string> } {278347 	if (path.startsWith(ANY_DEPTH)) {279-		// A name ignored in every repo: any path with a segment of exactly that name.280-		return db281-			.query(282-				`DELETE FROM save_files283-				WHERE instr('/' || path || '/', $segment) > 0284-				AND ($save = '' OR save_id = $save)`,285-			)286-			.run({ segment: `/${path.slice(ANY_DEPTH.length)}/`, save }).changes;348+		return {349+			where: "instr('/' || path || '/', $segment) > 0",350+			params: { segment: `/${path.slice(ANY_DEPTH.length)}/` },351+		};287352 	}288353 	// Koh compares the prefix exactly, never with LIKE, so `_` and `%` in a path match only themselves.289-	const dir = `${path.replace(/\/+$/, "")}/`;290-	return db291-		.query(292-			`DELETE FROM save_files293-			WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)294-			AND ($save = '' OR save_id = $save)`,295-		)296-		.run({ path, dir, save }).changes;354+	return {355+		where: "(path = $path OR substr(path, 1, length($dir)) = $dir)",356+		params: { path, dir: `${path.replace(/\/+$/, "")}/` },357+	};297358 }298359 299360 /**300- * Empties a mirror in place so the next sync rebuilds it from the bucket, as a change to the301- * hidden paths needs. The file and its open handles stay, so a request holding one reads an empty361+ * Empties a mirror in place so the next sync rebuilds it from the bucket, as the move to schema362+ * version 3 needs when hidden rows were deleted. The file and its open handles stay, so a request holding one reads an empty302363  * repo for a moment rather than failing. A new epoch keeps tarballs kept before it from matching.303364  */304365 export function clearMirror(db: Database): void {305366 	db.transaction(() => {306367 		// Children before parents, for the foreign keys; every name is a table of SCHEMA's.307-		for (const table of ["save_files", "events", "saves", "objects", "releases", "state"]) {368+		const tables = [369+			"save_files",370+			"hidden_files",371+			"events",372+			"saves",373+			"objects",374+			"releases",375+			"state",376+		];377+		for (const table of tables) {308378 			db.run(`DELETE FROM ${table}`);309379 		}310380 		db.query("INSERT INTO state (key, value) VALUES ('epoch', ?)").run(crypto.randomUUID());

view file

src/server/db.ts+20 −0
@@ -17,6 +17,8 @@1717 	languages       TEXT NOT NULL DEFAULT '',1818 	issues_paused   INTEGER NOT NULL DEFAULT 0,1919 	hidden_paths    TEXT NOT NULL DEFAULT '',20+	-- The hidden list last written to .kepr/hidden/<repo>; NULL while unknown.21+	hidden_handed   TEXT,2022 	last_sync_at    INTEGER,2123 	last_sync_error TEXT2224 );@@ -150,6 +152,7 @@150152 	["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],151153 	["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],152154 	["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],155+	["repos", "hidden_handed", "TEXT"],153156 ];154157 // Columns an older kepr.db still has; card order and install lines were removed (June, 2026-09-30).155158 const DROPPED_COLUMNS: [table: string, column: string][] = [@@ -312,6 +315,23 @@312315 		.get(repo);313316 	const paths = row?.hidden_paths ? row.hidden_paths.split("\n") : [];314317 	return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];318+}319+320+/** A repo's hidden paths unless they are known to be the list last handed to Koh, else null. */321+export function hiddenToHand(db: Database, repo: string): string | null {322+	const row = db323+		.query<{ hidden_paths: string }, [string]>(324+			`SELECT hidden_paths FROM repos325+			WHERE name = ?326+			AND (hidden_handed IS NULL OR hidden_paths != hidden_handed)`,327+		)328+		.get(repo);329+	return row?.hidden_paths ?? null;330+}331+332+/** Records the list in the bucket, or null while a write is under way and its outcome unknown. */333+export function recordHiddenHanded(db: Database, repo: string, list: string | null): void {334+	db.query("UPDATE repos SET hidden_handed = ? WHERE name = ?").run(list, repo);315335 }316336 317337 /** Names of files and folders hidden at any depth in every repo. */

view file

tests/hidden.test.ts+47 −10
@@ -19,12 +19,22 @@1919 	const text = async (path: string) => (await inst.app.request(path)).text();2020 	const hide = (hidden_paths: string) =>2121 		browser.post("/admin/repos/app", { public: "1", hidden_paths });22-	const sync = () =>23-		syncAll({24-			bucket: openBucket({ kind: "folder", path: repos.bucketDir }),25-			kepr: inst.kepr,26-			mirrorDir: join(inst.dataDir, "mirror"),27-		});22+	// `fail` makes calls under that key prefix fail, as an unreachable bucket would.23+	const sync = (fail = "\0") => {24+		const bucket = openBucket({ kind: "folder", path: repos.bucketDir });25+		const { put, list } = bucket;26+		const reach = (key: string) =>27+			key.startsWith(fail) ? Promise.reject(new Error("bucket unreachable")) : Promise.resolve();28+		bucket.put = (key, ...rest) => reach(key).then(() => put(key, ...rest));29+		bucket.list = (prefix) => reach(prefix).then(() => list(prefix));30+		return syncAll({ bucket, kepr: inst.kepr, mirrorDir: join(inst.dataDir, "mirror") });31+	};32+	// The mirror has a saved list before the response; the next sync hands it to Koh.33+	const handed = async () => {34+		await sync();35+		const file = Bun.file(join(repos.bucketDir, ".kepr/hidden/app"));36+		return (await file.exists()) ? await file.text() : null;37+	};2838 2939 	beforeAll(async () => {3040 		repos = await makeRepos(["app"]);@@ -64,9 +74,7 @@6474 		const listed = Bun.spawnSync(["tar", "-tzf", "-"], { stdin: tarball }).stdout.toString();6575 		expect(listed).toContain("main.ts");6676 		expect(listed).not.toContain("tool.py");67-		expect(await Bun.file(join(repos.bucketDir, ".kepr/hidden/app")).text()).toBe(68-			"tool.py\nprivate\n",69-		);77+		expect(await handed()).toBe("tool.py\nprivate\n");7078 		expect(await (await browser.get("/admin/repos?repo=app&tab=settings")).text()).toContain(7179 			">tool.py\nprivate</textarea>",7280 		);@@ -86,7 +94,7 @@8694 		expect(await status("/app/files/tool.py")).toBe(200);8795 		expect(await text("/app/raw/tool.py")).toBe("print(2)\n");8896 		expect(await status("/app/raw/private/notes.md")).toBe(200);89-		expect(await Bun.file(join(repos.bucketDir, ".kepr/hidden/app")).text()).toBe("");97+		expect(await handed()).toBe("");9098 	});9199 92100 	test("an old kept tarball is never served once a path is hidden, even if cleanup failed", async () => {@@ -105,6 +113,35 @@105113 		expect(listed).toContain("main.ts");106114 		expect(listed).not.toContain("tool.py");107115 		await hide("");116+	});117+118+	test("a list the bucket refused is handed at a later sync", async () => {119+		await hide("tool.py");120+		expect(await status("/app/files/tool.py")).toBe(404);121+		await sync(".kepr/hidden/");122+		expect(await handed()).toBe("tool.py\n");123+		await hide("");124+		await sync();125+	});126+127+	test("a list written before a failed cleanup is not undone by changing back", async () => {128+		await hide("tool.py");129+		await sync(".kepr/tarballs/");130+		await hide("");131+		expect(await handed()).toBe("");132+	});133+134+	test("a hidden file's object is neither listed nor served on the Koh routes", async () => {135+		const mirror = openMirror(join(inst.dataDir, "mirror"), "app");136+		const hash = mirror137+			.query<{ hash: string }, []>("SELECT hash FROM save_files WHERE path = 'private/notes.md'")138+			.get()?.hash as string;139+		await hide("private");140+		expect(await text("/app/koh/objects")).not.toContain(hash);141+		expect(await status(`/app/koh/objects/${hash}`)).toBe(404);142+		await hide("");143+		expect(await text("/app/koh/objects")).toContain(hash);144+		expect(await status(`/app/koh/objects/${hash}`)).toBe(200);108145 	});109146 110147 	test("rebuilding a mirror keeps handles that requests already hold usable", async () => {

view file

tests/ignored.test.ts+0 −8
@@ -22,12 +22,6 @@2222 			kepr: inst.kepr,2323 			mirrorDir: join(inst.dataDir, "mirror"),2424 		});25-	// The console applies a change after its response, once any sync under way has finished.26-	const settled = async (path: string, want: number) => {27-		for (let i = 0; i < 100 && (await inst.app.request(path)).status !== want; i++) {28-			await Bun.sleep(50);29-		}30-	};3125 	const gone = ["/app/raw/AGENTS.md", "/app/raw/.agent/notes.md", "/lib/raw/src/AGENTS.md"];3226 	const kept = ["/app/raw/AGENTS.md.bak", "/app/raw/.agents/x.md", "/lib/raw/src/main.ts"];3327 @@ -67,7 +61,6 @@6761 6862 	test("an ignored name is gone from every repo at any depth, and only that name", async () => {6963 		expect((await ignore(".agent\r\nAGENTS.md\n")).status).toBe(303);70-		await settled(gone[2] as string, 404);7164 		for (const path of gone) expect(await status(path)).toBe(`${path} 404`);7265 		for (const path of kept) expect(await status(path)).toBe(`${path} 200`);7366 		expect(await (await browser.get("/admin/site")).text()).toContain(@@ -86,7 +79,6 @@8679 8780 	test("taking a name off the list brings its files back", async () => {8881 		expect((await ignore(".agent")).status).toBe(303);89-		await settled("/lib/raw/src/AGENTS.md", 200);9082 		expect(await status("/lib/raw/src/AGENTS.md")).toBe("/lib/raw/src/AGENTS.md 200");9183 		expect(await (await inst.app.request("/app/raw/AGENTS.md")).text()).toBe("new rules\n");9284 		expect(await status("/app/raw/.agent/notes.md")).toBe("/app/raw/.agent/notes.md 404");

view file

tests/sync.test.ts+46 −4
@@ -17,7 +17,7 @@1717 	saveByPrefix,1818 	timeline,1919 } from "../src/mirror/queries.ts";20-import { mirrorState, openMirror } from "../src/mirror/store.ts";20+import { applyHidden, closeMirror, mirrorState, openMirror } from "../src/mirror/store.ts";2121 import { openKeprDb } from "../src/server/db.ts";2222 import { Browser, instance } from "./browser.ts";2323 import { type Fixture, makeKohBucket } from "./fixture.ts";@@ -125,6 +125,24 @@125125 	});126126 });127127 128+test("a hidden path that is then stripped stays gone when it is unhidden", async () => {129+	const fx = await makeKohBucket("hidden-strip");130+	const t = await syncTarget(fx);131+	await syncAll(t);132+	const secret = fileAt(t.mirror(), fx.saveIds[0] as string, "secret.txt")?.hash as string;133+	t.kepr.query("UPDATE repos SET hidden_paths = 'secret.txt' WHERE name = ?").run(fx.repo);134+	await syncAll(t);135+	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();136+	expect(objectBytes(t.mirror(), secret)).not.toBeNull();137+138+	await fx.strip("secret.txt");139+	await syncAll(t);140+	applyHidden(t.mirror(), []);141+	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();142+	expect(objectBytes(t.mirror(), secret)).toBeNull();143+	expect(fileAt(t.mirror(), fx.saveIds[0] as string, "src/main.ts")).not.toBeNull();144+});145+128146 test("a whole six-character ID finds its own save, not a longer one it begins", async () => {129147 	const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-mirror-")), "ids");130148 	const add = mirror.query(@@ -253,20 +271,44 @@253271 });254272 255273 describe("the mirror's schema version", () => {256-	test("a version 2 mirror reopens and any other version is refused as disposable", async () => {274+	test("a version 3 mirror reopens and any other version is refused as disposable", async () => {257275 		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));258276 		const created = openMirror(dir, "one");259277 		created.run("PRAGMA wal_checkpoint(TRUNCATE)");260278 		await Bun.write(join(dir, "copy.db"), Bun.file(join(dir, "one.db")));261279 		expect(openMirror(dir, "copy").query("PRAGMA user_version").get()).toEqual({262-			user_version: 2,280+			user_version: 3,263281 		});264282 265-		for (const version of [1, 3]) {283+		for (const version of [1, 4]) {266284 			const other = new Database(join(dir, `v${version}.db`));267285 			other.run(`PRAGMA user_version = ${version}`);268286 			other.close();269287 			expect(() => openMirror(dir, `v${version}`)).toThrow("disposable");288+		}289+	});290+291+	test("a version 2 mirror moves to 3 in place, emptied only when it deleted hidden rows", async () => {292+		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));293+		for (const [name, hidden] of [294+			["plain", ""],295+			["hiding", "secret.txt"],296+		] as const) {297+			const db = openMirror(dir, name);298+			db.run("DROP TABLE hidden_files");299+			db.run("PRAGMA user_version = 2");300+			db.query("INSERT INTO state (key, value) VALUES ('head', 'abcdef123456'), ('hidden', ?)").run(301+				hidden,302+			);303+			closeMirror(dir, name);304+		}305+		const head = (name: string) => mirrorState(openMirror(dir, name), "head");306+		expect(head("plain")).toBe("abcdef123456");307+		expect(head("hiding")).toBeNull();308+		for (const name of ["plain", "hiding"]) {309+			const db = openMirror(dir, name);310+			expect(db.query("PRAGMA user_version").get()).toEqual({ user_version: 3 });311+			expect(db.query("SELECT COUNT(*) AS n FROM hidden_files").get()).toEqual({ n: 0 });270312 		}271313 	});272314 });

view file