@motujs/host 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.mjs ADDED
@@ -0,0 +1,680 @@
1
+ // The lagoon store: content-addressed blobs, mutable aliases on top, and a retention cap that can
2
+ // never break a URL someone is holding.
3
+ //
4
+ // WHAT IS STORED is what `motu lagoon publish` produces — a self-contained HTML FRAGMENT (assets
5
+ // inlined, mock transport, no doctype/html/head/body: see `inlineToArtifact` in the CLI), or, by
6
+ // default with --remote, a MANIFEST naming content-addressed chunks stored beside it (manifest.mjs).
7
+ // The document skeleton is supplied at serve time, so the same bytes publish to a hosted page here and
8
+ // to an Artifact, and neither host has to know about the other.
9
+ //
10
+ // TWO AXES, deliberately:
11
+ // /<repo>/<sha>/<slug> immutable — the reviewable artifact, still renders in six months
12
+ // /<repo>/latest/<slug> mutable alias — the bookmark that keeps working
13
+ // Content addressing does the deduplication: republishing an unchanged lagoon costs zero new bytes.
14
+ //
15
+ // RETENTION counts publish RECORDS per repo, not blobs, and never evicts a record an alias or a
16
+ // composed manifest points at. That rule is the whole point — a cap that can quietly delete what a
17
+ // link resolves to is worse than no cap, because the failure lands on whoever you sent the link to,
18
+ // not on you. Eviction orders by LAST ACCESS rather than publish date: the six-week-old lagoon
19
+ // somebody bookmarked is exactly the one that must survive ten builds from this morning.
20
+ import { createHash } from 'node:crypto';
21
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, rmSync, statSync } from 'node:fs';
22
+ // THE FOLD IS NOT REIMPLEMENTED HERE. Merging two corpora is arithmetic on motu's own format, and the
23
+ // moment a second copy of it exists the two stop agreeing — which is the whole reason `mergeCorpora`
24
+ // lives in @motujs/coverage instead of in every backend that stores one. The package compiles to plain
25
+ // ESM, so bare node reads it exactly as the browser does.
26
+ import { mergeCorpora, fingerprintId } from '@motujs/coverage';
27
+ import { homedir } from 'node:os';
28
+ import { resolve, dirname } from 'node:path';
29
+
30
+ /** Default cap: publish records kept PER REPO. */
31
+ export const DEFAULT_MAX_RECORDS = 1000;
32
+
33
+ /**
34
+ * Second cap, on BYTES per repo — because records are not a proxy for size.
35
+ *
36
+ * A typical lagoon is ~430 kB, which is where 1000 records came from. Twenty's record page inlines
37
+ * its whole front-end and publishes at 19.2 MB; a thousand of those is 19 GB, from a cap that reads
38
+ * as conservative. Whichever limit binds first wins, so the count keeps history shallow on ordinary
39
+ * repos and the byte ceiling keeps an outlier from quietly filling the disk.
40
+ */
41
+ export const DEFAULT_MAX_BYTES = 4 * 1024 * 1024 * 1024;
42
+
43
+ const INDEX_VERSION = 1;
44
+
45
+ /**
46
+ * How long a CHUNK nothing references yet is kept. Chunks arrive before the manifest that names them
47
+ * (a publish asks, sends, then commits), and a draft's chunks are named by no record at all — so a
48
+ * sweep that ran in between would delete what is about to be, or is being, served. Every time a
49
+ * publisher asks about a chunk its clock restarts, so a draft that keeps saving keeps its chunks.
50
+ */
51
+ export const CHUNK_GRACE_MS = 6 * 60 * 60 * 1000;
52
+
53
+ /**
54
+ * One index file for the whole store, rewritten atomically.
55
+ *
56
+ * Not many small files: referential integrity here is a cross-cutting question ("is this blob still
57
+ * pointed at by ANY repo's alias or ANY manifest?") and answering it by walking a directory tree
58
+ * would make every sweep an O(files) stat storm. The server is a single process, so there is one
59
+ * writer; a few MB of JSON at a thousand records per repo is nothing.
60
+ */
61
+ function emptyIndex() {
62
+ return { version: INDEX_VERSION, blobs: {}, repos: {}, baselines: {} };
63
+ }
64
+
65
+ /** A path segment that is safe to join: no separators, no dots-only, no surprises. */
66
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
67
+
68
+ /** Repo ids carry one optional owner segment (`acme/web`), which is how git remotes name things. */
69
+ export function normalizeRepo(raw) {
70
+ const parts = String(raw ?? '').split('/').filter(Boolean);
71
+ if (parts.length < 1 || parts.length > 2) return null;
72
+ if (!parts.every((p) => SEGMENT.test(p) && p.length <= 64)) return null;
73
+ return parts.join('/');
74
+ }
75
+
76
+ export function normalizeSegment(raw, max = 128) {
77
+ const s = String(raw ?? '');
78
+ return SEGMENT.test(s) && s.length <= max ? s : null;
79
+ }
80
+
81
+ function nowIso() {
82
+ return new Date().toISOString();
83
+ }
84
+
85
+ export function hashBytes(buf) {
86
+ return createHash('sha256').update(buf).digest('hex');
87
+ }
88
+
89
+ /**
90
+ * Where the store lives when nobody says otherwise.
91
+ *
92
+ * Exported because `motu-host access` edits a file INSIDE it and must land in the same place the
93
+ * server will read from. Two copies of this expression is two directories, and the symptom is a
94
+ * policy that was written and appears to do nothing.
95
+ */
96
+ export function storeDir(dir) {
97
+ return resolve(dir || process.env.MOTU_HOST_DIR || resolve(homedir(), '.motu/host'));
98
+ }
99
+
100
+ export function openStore({ dir, maxRecords = DEFAULT_MAX_RECORDS, maxBytes = DEFAULT_MAX_BYTES } = {}) {
101
+ const root = storeDir(dir);
102
+ const indexPath = resolve(root, 'index.json');
103
+ mkdirSync(resolve(root, 'objects'), { recursive: true });
104
+
105
+ let index = emptyIndex();
106
+ if (existsSync(indexPath)) {
107
+ try {
108
+ const loaded = JSON.parse(readFileSync(indexPath, 'utf8'));
109
+ if (loaded && loaded.version === INDEX_VERSION) index = { ...emptyIndex(), ...loaded };
110
+ } catch {
111
+ // A corrupt index must not take the blobs with it: keep them on disk, start the index over.
112
+ // Anything unreferenced is then sweepable, and nothing already served silently changes bytes.
113
+ index = emptyIndex();
114
+ }
115
+ }
116
+
117
+ function save() {
118
+ const tmp = `${indexPath}.tmp`;
119
+ writeFileSync(tmp, JSON.stringify(index), 'utf8');
120
+ renameSync(tmp, indexPath); // atomic: a killed process leaves the previous index, never a half one
121
+ }
122
+
123
+ function blobPath(hash) {
124
+ return resolve(root, 'objects', hash.slice(0, 2), hash);
125
+ }
126
+
127
+ function repoEntry(repo) {
128
+ if (!index.repos[repo]) index.repos[repo] = { history: [], aliases: {}, nextId: 1 };
129
+ return index.repos[repo];
130
+ }
131
+
132
+ /** Record ids this repo's mutable aliases resolve to — `latest`, branch heads, anything named. */
133
+ function aliasPinned(entry) {
134
+ const pinned = new Set();
135
+ for (const slugs of Object.values(entry.aliases)) for (const id of Object.values(slugs)) pinned.add(id);
136
+ return pinned;
137
+ }
138
+
139
+ /**
140
+ * Store a fragment and point `latest` (and the branch alias, and the commit) at it.
141
+ *
142
+ * Returns `deduped: true` when the bytes were already here — the common case for a republish with
143
+ * no UI change, and worth surfacing so the CLI can say so instead of implying work happened.
144
+ */
145
+ function publish(repo, slug, bytes, { title = slug, sha = null, branch = null, brand = null, refs = null } = {}) {
146
+ const entry = repoEntry(repo);
147
+ // THE PROJECT'S OWN COLOUR, on the repo rather than on the record: it is a property of the
148
+ // project, not of one build, and a tool listing repositories should not have to fetch a record to
149
+ // know what colour to be. Only overwritten when a publish actually carries one, so a project that
150
+ // stops declaring it keeps the last thing it said rather than silently reverting to motu teal.
151
+ if (brand) entry.brand = brand;
152
+ const hash = hashBytes(bytes);
153
+ const file = blobPath(hash);
154
+ const deduped = existsSync(file);
155
+ if (!deduped) {
156
+ mkdirSync(dirname(file), { recursive: true });
157
+ writeFileSync(file, bytes);
158
+ }
159
+ const at = nowIso();
160
+ index.blobs[hash] = { bytes: bytes.length, createdAt: index.blobs[hash]?.createdAt ?? at };
161
+
162
+ const record = {
163
+ id: entry.nextId++,
164
+ slug,
165
+ hash,
166
+ // A CHUNKED lagoon's record is its manifest; these are the chunks it names, which must live
167
+ // exactly as long as the record does.
168
+ ...(refs?.length ? { refs: [...new Set(refs)] } : {}),
169
+ title,
170
+ sha: sha || hash.slice(0, 12), // no git? the content hash IS a usable immutable ref
171
+ branch,
172
+ publishedAt: at,
173
+ lastAccess: at,
174
+ };
175
+ entry.history.push(record);
176
+
177
+ // Aliases always move on publish. `latest` is the bookmark; the branch alias is what a PR link
178
+ // wants. The commit is NOT an alias — it is found in history, so it cannot be repointed later.
179
+ setAlias(entry, 'latest', slug, record.id);
180
+ if (branch && normalizeSegment(branch)) setAlias(entry, branch, slug, record.id);
181
+
182
+ sweepRepo(repo);
183
+ save();
184
+ return { hash, bytes: bytes.length, deduped, sha: record.sha, record };
185
+ }
186
+
187
+ function setAlias(entry, ref, slug, id) {
188
+ if (!entry.aliases[ref]) entry.aliases[ref] = {};
189
+ entry.aliases[ref][slug] = id;
190
+ }
191
+
192
+ /** Resolve `<ref>/<slug>` — an alias name first, then a commit (prefix-matched) in history. */
193
+ function resolveRef(repo, ref, slug) {
194
+ const entry = index.repos[repo];
195
+ if (!entry) return null;
196
+ const aliased = entry.aliases[ref]?.[slug];
197
+ if (aliased != null) {
198
+ const rec = entry.history.find((r) => r.id === aliased);
199
+ if (rec) return { ...rec, mutable: true };
200
+ }
201
+ // Newest first: a short sha that matches two records should give the recent one, not the ancient.
202
+ for (let i = entry.history.length - 1; i >= 0; i--) {
203
+ const rec = entry.history[i];
204
+ if (rec.slug === slug && (rec.sha === ref || rec.sha.startsWith(ref) || rec.hash.startsWith(ref)))
205
+ return { ...rec, mutable: false };
206
+ }
207
+ return null;
208
+ }
209
+
210
+ /** Read a blob and stamp the access time that eviction orders by. */
211
+ function read(repo, recordId, hash) {
212
+ const file = blobPath(hash);
213
+ if (!existsSync(file)) return null;
214
+ const entry = index.repos[repo];
215
+ const rec = entry?.history.find((r) => r.id === recordId);
216
+ if (rec) {
217
+ rec.lastAccess = nowIso();
218
+ save();
219
+ }
220
+ return readFileSync(file);
221
+ }
222
+
223
+ /**
224
+ * A chunk, stored by its content. Refused unless the bytes ARE that content: the hash is the
225
+ * address every later reader trusts, so it is checked on the way in, never assumed.
226
+ */
227
+ function putBlob(bytes, expected) {
228
+ const hash = hashBytes(bytes);
229
+ if (expected && hash !== expected) return { ok: false, hash };
230
+ const file = blobPath(hash);
231
+ if (!existsSync(file)) {
232
+ mkdirSync(dirname(file), { recursive: true });
233
+ writeFileSync(file, bytes);
234
+ }
235
+ const at = nowIso();
236
+ index.blobs[hash] = { bytes: bytes.length, createdAt: index.blobs[hash]?.createdAt ?? at, seenAt: at };
237
+ save();
238
+ return { ok: true, hash };
239
+ }
240
+
241
+ /** Which of these chunks are not here — and the ones that are have just been mentioned again. */
242
+ function missingBlobs(hashes) {
243
+ const at = nowIso();
244
+ const missing = [];
245
+ for (const h of hashes) {
246
+ if (typeof h !== 'string' || !/^[0-9a-f]{64}$/.test(h)) continue;
247
+ if (index.blobs[h] && existsSync(blobPath(h))) index.blobs[h].seenAt = at;
248
+ else missing.push(h);
249
+ }
250
+ save();
251
+ return missing;
252
+ }
253
+
254
+ /** Read a blob by hash alone — what a composed manifest serves, since members span repos. */
255
+ function readHash(hash) {
256
+ const file = blobPath(hash);
257
+ return existsSync(file) ? readFileSync(file) : null;
258
+ }
259
+
260
+ /**
261
+ * Trim one repo to the cap, then collect blobs nothing points at any more.
262
+ *
263
+ * Pinned = targeted by a mutable alias. Everything else is history, and history is what the cap is
264
+ * allowed to forget. (It used to also pin every member of a stored group MANIFEST — a composed view
265
+ * could be pinned forever, so its members had to survive the cap. Groups are gone and so is that
266
+ * class of pin; nothing else could hold a record alive past its repo's own aliases.)
267
+ */
268
+ function sweepRepo(repo) {
269
+ const entry = index.repos[repo];
270
+ if (!entry) return { removed: 0 };
271
+ const pinnedIds = aliasPinned(entry);
272
+
273
+ const evictable = entry.history
274
+ .filter((r) => !pinnedIds.has(r.id))
275
+ .sort((a, b) => (a.lastAccess < b.lastAccess ? -1 : a.lastAccess > b.lastAccess ? 1 : a.id - b.id));
276
+
277
+ // Distinct blobs, not the sum of records: two records sharing content occupy one blob, and
278
+ // charging the repo twice for it would evict history that costs nothing to keep.
279
+ // A chunked record costs its manifest AND its chunks — shared chunks once, which is the saving.
280
+ const bytesOf = (recs) => {
281
+ const seen = new Set();
282
+ let total = 0;
283
+ for (const r of recs) {
284
+ for (const h of [r.hash, ...(r.refs ?? [])]) {
285
+ if (seen.has(h)) continue;
286
+ seen.add(h);
287
+ total += index.blobs[h]?.bytes ?? 0;
288
+ }
289
+ }
290
+ return total;
291
+ };
292
+
293
+ let over = entry.history.length - maxRecords;
294
+ let overBytes = bytesOf(entry.history) - maxBytes;
295
+ const drop = new Set();
296
+ for (const rec of evictable) {
297
+ if (over <= 0 && overBytes <= 0) break;
298
+ drop.add(rec.id);
299
+ over--;
300
+ // Only credit the bytes back if no SURVIVING record still points at that blob.
301
+ for (const h of [rec.hash, ...(rec.refs ?? [])]) {
302
+ const stillUsed = entry.history.some((r) => !drop.has(r.id) && (r.hash === h || r.refs?.includes(h)));
303
+ if (!stillUsed) overBytes -= index.blobs[h]?.bytes ?? 0;
304
+ }
305
+ }
306
+ if (!drop.size) return { removed: 0, blobs: gcBlobs() };
307
+ entry.history = entry.history.filter((r) => !drop.has(r.id));
308
+ return { removed: drop.size, blobs: gcBlobs() };
309
+ }
310
+
311
+ /** Delete blobs no surviving record references. Runs after any history change. */
312
+ function gcBlobs() {
313
+ const live = new Set();
314
+ for (const entry of Object.values(index.repos)) {
315
+ for (const r of entry.history) {
316
+ live.add(r.hash);
317
+ for (const h of r.refs ?? []) live.add(h);
318
+ }
319
+ }
320
+ // Chunks mentioned recently are on their way into a manifest, or serving a draft.
321
+ const graceSince = Date.now() - CHUNK_GRACE_MS;
322
+ for (const [hash, b] of Object.entries(index.blobs)) {
323
+ if (b.seenAt && Date.parse(b.seenAt) > graceSince) live.add(hash);
324
+ }
325
+ // A baseline is only meaningful while its bytes exist. ACCEPTED is pinned forever; the recent
326
+ // history is pinned so "what did it look like before" survives long enough to be looked at.
327
+ for (const islands of Object.values(index.baselines)) {
328
+ for (const shots of Object.values(islands)) {
329
+ for (const shot of Object.values(shots)) {
330
+ if (shot.accepted) live.add(shot.accepted);
331
+ for (const h of shot.history ?? []) live.add(h);
332
+ }
333
+ }
334
+ }
335
+ let removed = 0;
336
+ for (const hash of Object.keys(index.blobs)) {
337
+ if (live.has(hash)) continue;
338
+ try {
339
+ rmSync(blobPath(hash), { force: true });
340
+ } catch {
341
+ /* a blob already gone is the state we wanted */
342
+ }
343
+ delete index.blobs[hash];
344
+ removed++;
345
+ }
346
+ return removed;
347
+ }
348
+
349
+ // --- visual baselines ---------------------------------------------------------------------
350
+ //
351
+ // WHY THE HOST AND NOT THE REPOSITORY. Committed PNGs are what makes a visual tier unmaintainable:
352
+ // re-recording is a binary diff nobody reviews, so baselines drift, the check goes permanently red,
353
+ // and people stop looking — which is exactly the state acme was in, with one island baselined and
354
+ // its baselines stale. Every hosted tool in this space (Chromatic, Percy) keeps them server-side for
355
+ // the same reason.
356
+ //
357
+ // Content addressing is what makes it affordable: a shot that does not change costs zero new bytes,
358
+ // forever. Storage grows with CHANGE, not with island count, so a thousand snapshot runs over a
359
+ // hundred unchanged islands add nothing.
360
+ //
361
+ // ACCEPTED IS A DECISION, not a file write. `--update` overwriting everything is why "the baseline is
362
+ // stale" and "you broke something" are the same red today; an accepted pointer that someone moved
363
+ // deliberately separates them.
364
+
365
+ /** `<island>/<scenario>@<viewport>` — one shot's identity within a repo. */
366
+ function shotEntry(repo, island, shot) {
367
+ const byRepo = (index.baselines[repo] ??= {});
368
+ const byIsland = (byRepo[island] ??= {});
369
+ return (byIsland[shot] ??= { accepted: null, acceptedAt: null, history: [], lastSeen: null });
370
+ }
371
+
372
+ /**
373
+ * Record what a run rendered. This does NOT move the baseline — it stores the bytes and reports how
374
+ * they compare, leaving the decision to a human or to an explicit accept.
375
+ */
376
+ function putShot(repo, island, shot, bytes, { sha = null, branch = null } = {}) {
377
+ const hash = hashBytes(bytes);
378
+ const file = blobPath(hash);
379
+ const deduped = existsSync(file);
380
+ if (!deduped) {
381
+ mkdirSync(dirname(file), { recursive: true });
382
+ writeFileSync(file, bytes);
383
+ }
384
+ index.blobs[hash] = { bytes: bytes.length, createdAt: index.blobs[hash]?.createdAt ?? nowIso() };
385
+
386
+ const entry = shotEntry(repo, island, shot);
387
+ entry.lastSeen = { hash, at: nowIso(), sha, branch };
388
+ if (!entry.history.includes(hash)) entry.history.unshift(hash);
389
+ // Bounded: enough to look back at what it used to be, not a full archive.
390
+ entry.history = entry.history.slice(0, 10);
391
+
392
+ const status = !entry.accepted ? 'new' : entry.accepted === hash ? 'match' : 'changed';
393
+ save();
394
+ return { hash, bytes: bytes.length, deduped, status, accepted: entry.accepted };
395
+ }
396
+
397
+ /** Move the accepted pointer to what was last seen (or to an explicit hash). */
398
+ function acceptShot(repo, island, shot, hash = null) {
399
+ const entry = index.baselines[repo]?.[island]?.[shot];
400
+ if (!entry) return null;
401
+ const target = hash ?? entry.lastSeen?.hash;
402
+ if (!target || !index.blobs[target]) return null;
403
+ entry.accepted = target;
404
+ entry.acceptedAt = nowIso();
405
+ save();
406
+ return { island, shot, accepted: target };
407
+ }
408
+
409
+ /** Every shot of a repo, with its status — what a review page and the CLI both read. */
410
+ function listShots(repo, island = null) {
411
+ const byIsland = index.baselines[repo] ?? {};
412
+ const out = [];
413
+ for (const [isl, shots] of Object.entries(byIsland)) {
414
+ if (island && isl !== island) continue;
415
+ for (const [shot, e] of Object.entries(shots)) {
416
+ out.push({
417
+ island: isl,
418
+ shot,
419
+ accepted: e.accepted,
420
+ acceptedAt: e.acceptedAt,
421
+ last: e.lastSeen,
422
+ status: !e.accepted ? 'new' : e.lastSeen?.hash === e.accepted ? 'match' : 'changed',
423
+ });
424
+ }
425
+ }
426
+ return out.sort((a, b) => a.island.localeCompare(b.island) || a.shot.localeCompare(b.shot));
427
+ }
428
+
429
+ // --- the rail ---------------------------------------------------------------------------------
430
+ //
431
+ // WHAT EVERY LAGOON'S RAIL LISTS, resolved for now.
432
+ //
433
+ // This was `resolveGroup(name)` and a group was a curated set — a name, a member list, an `all`
434
+ // switch, an exclusion list, plus an immutable manifest so a composed URL could be pinned. All of it
435
+ // is gone, and the reason is the one `b1719dd` already wrote down and then only half-acted on: a
436
+ // group was never a thing to BROWSE, it was a way of LOOKING at lagoons. Once the rail belongs to
437
+ // every lagoon, the curated set has nothing left to curate — the honest list is everything this
438
+ // viewer may see, with the one they opened selected, and that needs no definition to maintain, no
439
+ // `/g/<name>` to bookmark and no manifest to pin.
440
+ //
441
+ // A repo with no `all` switcher entry contributes its first slug instead, so a project that only
442
+ // ever publishes one focused archipelago is still reachable from everyone else's rail.
443
+
444
+ /**
445
+ * Every lagoon this host can serve right now, in repo order.
446
+ *
447
+ * Each carries its latest published hash if it has one, and a `live` endpoint if something is
448
+ * currently serving it with `motu lagoon serve --watch`. Live-but-never-published is still a member:
449
+ * that is the whole point of the live axis, and looking needs no build.
450
+ *
451
+ * FILTERING IS THE CALLER'S, because only the server knows who is asking.
452
+ */
453
+ function resolveAll(endpointFor) {
454
+ const out = [];
455
+ for (const r of listRepos()) {
456
+ const slug = r.slugs.includes('all') ? 'all' : r.slugs[0];
457
+ if (!slug) continue;
458
+ const rec = resolveRef(r.repo, 'latest', slug);
459
+ const liveUrl = endpointFor ? endpointFor(r.repo, slug) : null;
460
+ if (!rec && !liveUrl) continue;
461
+ out.push({
462
+ repo: r.repo,
463
+ slug,
464
+ hash: rec?.hash ?? null,
465
+ title: rec?.title ?? slug,
466
+ sha: rec?.sha ?? null,
467
+ live: liveUrl,
468
+ // The same declared colour a direct lagoon view carries, so the rail and the page it frames
469
+ // cannot disagree about what colour a project is.
470
+ brand: index.repos[r.repo]?.brand ?? null,
471
+ });
472
+ }
473
+ return out;
474
+ }
475
+
476
+ function listRepos() {
477
+ return Object.entries(index.repos).map(([repo, entry]) => ({
478
+ repo,
479
+ records: entry.history.length,
480
+ slugs: [...new Set(Object.keys(entry.aliases.latest ?? {}))].sort(),
481
+ brand: entry.brand ?? null,
482
+ }));
483
+ }
484
+
485
+ function listRepo(repo) {
486
+ const entry = index.repos[repo];
487
+ if (!entry) return null;
488
+ return {
489
+ repo,
490
+ aliases: entry.aliases,
491
+ history: entry.history.slice().reverse(),
492
+ };
493
+ }
494
+
495
+ function stats() {
496
+ const bytes = Object.values(index.blobs).reduce((n, b) => n + b.bytes, 0);
497
+ return {
498
+ root,
499
+ maxRecords,
500
+ maxBytes,
501
+ repos: Object.keys(index.repos).length,
502
+ blobs: Object.keys(index.blobs).length,
503
+ bytes,
504
+ };
505
+ }
506
+
507
+ // --- coverage ------------------------------------------------------------------------------
508
+ //
509
+ // Small, sparse and per repo: motu's own review console reaches 7 states out of ~2,900 possible
510
+ // ones. Kept as files beside the objects rather than in the index, so a corpus cannot bloat the one
511
+ // document every request reads, and so a stale one is a file to delete.
512
+
513
+ // `root`, NEVER `dir`. `dir` is openStore's raw parameter and is undefined whenever the directory
514
+ // came from MOTU_HOST_DIR — which is how the service runs. `resolve(undefined, …)` throws, so these
515
+ // three lines answered every coverage request with a 500 while the pages carried on working. The
516
+ // same mistake, in the same shape, as the access loader: only `root` has been resolved.
517
+ /** Where one declaration's corpus lives. Nested so a repo id with an owner segment stays a path. */
518
+ function coveragePath(repo, region, keysHash) {
519
+ return resolve(root, 'coverage', repo, region, `${keysHash}.json`);
520
+ }
521
+
522
+ /**
523
+ * Fold an incoming corpus into the stored one for its declaration.
524
+ *
525
+ * BUCKETED BY `keysHash`, never mixed across it: a corpus recorded against a different key list is
526
+ * not comparable to this one, and folding the two would produce counts that mean nothing. Add a key
527
+ * to a region and its old rows simply stop being written to — visible, and one file to delete.
528
+ */
529
+ function mergeCoverage(repo, region, incoming) {
530
+ const keysHash = String(incoming.keysHash ?? '').trim() || 'unstamped';
531
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(keysHash)) throw new Error('keysHash must be [A-Za-z0-9_-]');
532
+ const file = coveragePath(repo, region, keysHash);
533
+ let stored = null;
534
+ if (existsSync(file)) {
535
+ try {
536
+ stored = JSON.parse(readFileSync(file, 'utf8'));
537
+ } catch {
538
+ // A corrupt corpus is a worklist nobody can read, not data anyone is owed. Start it again
539
+ // rather than refuse every write from now on.
540
+ stored = null;
541
+ }
542
+ }
543
+ const merged = stored ? mergeCorpora([stored, incoming]) : incoming;
544
+ mkdirSync(dirname(file), { recursive: true });
545
+ const tmp = `${file}.tmp`;
546
+ writeFileSync(tmp, JSON.stringify(merged), 'utf8');
547
+ renameSync(tmp, file);
548
+ return { states: merged.entries.length, keysHash };
549
+ }
550
+
551
+ /** Where a declaration's ACCEPTED set lives, beside the corpus it is about. */
552
+ function acceptedPath(repo, region, keysHash) {
553
+ return resolve(root, 'coverage', repo, region, `${keysHash}.accepted.json`);
554
+ }
555
+
556
+ /**
557
+ * The fingerprints somebody has looked at and chosen not to preview.
558
+ *
559
+ * A SEPARATE FILE FROM THE CORPUS, and separate on purpose. "This happened" and "we decided this
560
+ * needs no scenario" are different claims by different authors — one is written by browsers, the
561
+ * other by a person — and folding them into one document would let an ingest write promote its own
562
+ * states to accepted. The corpus is a worklist; this is the part of it somebody has closed.
563
+ */
564
+ function readAccepted(repo, region, keysHash) {
565
+ const file = acceptedPath(repo, region, keysHash);
566
+ if (!existsSync(file)) return [];
567
+ try {
568
+ const ids = JSON.parse(readFileSync(file, 'utf8'));
569
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
570
+ } catch {
571
+ // Unreadable means "nobody has accepted anything", which costs one extra beacon and never a
572
+ // wrong silence — the failure that matters here is claiming a state is known when it is not.
573
+ return [];
574
+ }
575
+ }
576
+
577
+ /** Add to the accepted set. Idempotent, and never removes — see `forgetAccepted`. */
578
+ function acceptCoverage(repo, region, keysHash, ids) {
579
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(keysHash))) throw new Error('keysHash must be [A-Za-z0-9_-]');
580
+ const merged = [...new Set([...readAccepted(repo, region, keysHash), ...ids.filter((i) => typeof i === 'string')])];
581
+ const file = acceptedPath(repo, region, keysHash);
582
+ mkdirSync(dirname(file), { recursive: true });
583
+ const tmp = `${file}.tmp`;
584
+ writeFileSync(tmp, JSON.stringify(merged), 'utf8');
585
+ renameSync(tmp, file);
586
+ return { accepted: merged.length };
587
+ }
588
+
589
+ /**
590
+ * Remove recorded states — the corpus is a worklist, and a worklist you cannot cross things off is
591
+ * a worklist people stop reading.
592
+ *
593
+ * THE CASE THIS EXISTS FOR is not a mistake in the data but a mistake in the INSTRUMENT. acme's
594
+ * corpus recorded `isCurrentWeek:true isOtherWeek:true`, where the second is defined as the negation
595
+ * of the first: a state the page cannot compute, recorded because the region was published one key
596
+ * at a time. Fixing the publishing does not un-record it, and a report that keeps offering an
597
+ * impossible state to triage will eventually be answered with a scenario for something that cannot
598
+ * happen — fake evidence, which is worse than none.
599
+ *
600
+ * DELETING IS NOT ACCEPTING, and they are separate acts for the same reason accepting is separate
601
+ * from previewing. Accepting says "we looked and chose not to"; deleting says "this was never true".
602
+ * Only the second is appropriate for an artifact, and only the second loses information.
603
+ */
604
+ function forgetCoverage(repo, region, keysHash, ids) {
605
+ const file = coveragePath(repo, region, keysHash);
606
+ if (!existsSync(file)) return { removed: 0, remaining: 0 };
607
+ let corpus;
608
+ try {
609
+ corpus = JSON.parse(readFileSync(file, 'utf8'));
610
+ } catch {
611
+ return { removed: 0, remaining: 0 };
612
+ }
613
+ const before = corpus.entries.length;
614
+ // No ids: the whole declaration. One recorded by a client that has since been fixed is not worth
615
+ // triaging entry by entry, and re-recording it costs nothing but a few page loads.
616
+ if (!ids || !ids.length) {
617
+ rmSync(file, { force: true });
618
+ return { removed: before, remaining: 0 };
619
+ }
620
+ const drop = new Set(ids);
621
+ corpus.entries = corpus.entries.filter((e) => !drop.has(fingerprintId(e.fingerprint)));
622
+ const tmp = `${file}.tmp`;
623
+ writeFileSync(tmp, JSON.stringify(corpus), 'utf8');
624
+ renameSync(tmp, file);
625
+ return { removed: before - corpus.entries.length, remaining: corpus.entries.length };
626
+ }
627
+
628
+ /**
629
+ * Every declaration's corpus for a region, newest declaration first.
630
+ *
631
+ * Returns them separately rather than merged: they are not comparable, and a reader deciding which
632
+ * one is current needs to see that there is more than one.
633
+ */
634
+ function readCoverage(repo, region) {
635
+ const base = resolve(root, 'coverage', repo, region);
636
+ if (!existsSync(base)) return [];
637
+ const out = [];
638
+ for (const name of readdirSync(base)) {
639
+ // A CORPUS FILE IS `<keysHash>.json` AND NOTHING ELSE. The accepted set lives in the same
640
+ // directory as `<keysHash>.accepted.json`, which also ends in `.json` — so a bare suffix test
641
+ // read it back as a corpus and served an array of fingerprint ids where a caller expected
642
+ // `{ keys, entries }`. It typechecks nowhere and fails nothing; it just answers the wrong shape.
643
+ // An exact pattern rather than an exclusion, so the next file added beside these is ignored by
644
+ // default instead of silently joining the corpus list.
645
+ if (!/^[A-Za-z0-9_-]+\.json$/.test(name)) continue;
646
+ try {
647
+ out.push(JSON.parse(readFileSync(resolve(base, name), 'utf8')));
648
+ } catch {
649
+ // skipped: see mergeCoverage
650
+ }
651
+ }
652
+ return out.sort((a, b) => (b.entries?.length ?? 0) - (a.entries?.length ?? 0));
653
+ }
654
+
655
+ return {
656
+ root,
657
+ maxRecords,
658
+ maxBytes,
659
+ mergeCoverage,
660
+ readCoverage,
661
+ readAccepted,
662
+ acceptCoverage,
663
+ forgetCoverage,
664
+ publish,
665
+ resolveRef,
666
+ read,
667
+ readHash,
668
+ putBlob,
669
+ missingBlobs,
670
+ putShot,
671
+ acceptShot,
672
+ listShots,
673
+ listRepos,
674
+ listRepo,
675
+ resolveAll,
676
+ stats,
677
+ sweepRepo,
678
+ save,
679
+ };
680
+ }