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
↓ kepr-0c253117.tar.gzatlas at this save
files changed10 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 −4package.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": {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. */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 });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 }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);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());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. */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 () => {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");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 });