@pygmalionjs/pygmalion 0.4.0 → 0.5.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.
@@ -1,6 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import {
5
+ normalizeObservedDependencies,
6
+ observedDependenciesUnchanged,
7
+ recordObservedDependencies,
8
+ } from './route-dependency-digest.mjs';
4
9
  import {
5
10
  mergeRoutePreviewArtifactV3,
6
11
  selectRoutePreviewArtifactFrames,
@@ -10,10 +15,34 @@ import {
10
15
  export const PYGMALION_PREVIEW_ARTIFACT_STORE_VERSION = 1;
11
16
 
12
17
  const MANIFEST_ENTRY_LIMIT = 131_072;
18
+ /**
19
+ * Retention is one policy, not a lifecycle.
20
+ *
21
+ * Freshness is decided by content now, so an entry is no longer tied to the
22
+ * revision that produced it and there are no "generations" to prune. What is
23
+ * left is an ordinary cache, and the only thing a cache has to be told is how
24
+ * much room it may use.
25
+ *
26
+ * The budget is in bytes because bytes are what hurt: a measured 66-frame
27
+ * catalog held 444 objects in 375 MB, and object sizes ranged from 0.1 to
28
+ * 1.5 MB, so an entry count bounds nothing. The per-frame and per-namespace
29
+ * counts below survive only as manifest-size guards.
30
+ */
13
31
  export const PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION = Object.freeze({
14
32
  revisionsPerNamespace: 16,
15
33
  variantsPerFrame: 32,
34
+ maxBytes: 256 * 1024 * 1024,
35
+ /**
36
+ * An unreferenced object is unlinked only after it has been unreferenced
37
+ * this long. A reader that read the manifest just before an eviction
38
+ * committed still holds the old hash, and deleting immediately would hand it
39
+ * a miss it did not earn.
40
+ */
41
+ reclaimGraceMs: 60_000,
16
42
  });
43
+ const ACCESS_JOURNAL_VERSION = 1;
44
+ const ACCESS_JOURNAL_LIMIT = 65_536;
45
+ const ACCESS_FLUSH_DEBOUNCE_MS = 2_000;
17
46
  const LOCK_TIMEOUT_MS = 30_000;
18
47
  const LOCK_STALE_MS = 120_000;
19
48
  const LOCK_POLL_MS = 40;
@@ -50,6 +79,16 @@ function validManifestEntry(entry, kind) {
50
79
  );
51
80
  }
52
81
 
82
+ function validPendingEntry(entry) {
83
+ return (
84
+ entry &&
85
+ typeof entry === 'object' &&
86
+ !Array.isArray(entry) &&
87
+ OBJECT_HASH.test(entry.object) &&
88
+ Number.isFinite(entry.since)
89
+ );
90
+ }
91
+
53
92
  function validateManifest(value, namespace) {
54
93
  if (
55
94
  !value ||
@@ -63,7 +102,11 @@ function validateManifest(value, namespace) {
63
102
  !Array.isArray(value.frames) ||
64
103
  value.revisions.length + value.frames.length > MANIFEST_ENTRY_LIMIT ||
65
104
  !value.revisions.every((entry) => validManifestEntry(entry, 'revision')) ||
66
- !value.frames.every((entry) => validManifestEntry(entry, 'frame'))
105
+ !value.frames.every((entry) => validManifestEntry(entry, 'frame')) ||
106
+ // Written by newer stores only, so absence is normal and must not throw —
107
+ // a manifest from before retention became a budget stays readable.
108
+ (value.pending !== undefined &&
109
+ (!Array.isArray(value.pending) || !value.pending.every(validPendingEntry)))
67
110
  ) {
68
111
  throw new Error('Preview artifact store manifest is invalid.');
69
112
  }
@@ -209,6 +252,17 @@ export function createPreviewArtifactStore({
209
252
  lockTimeoutMs,
210
253
  lockStaleMs,
211
254
  onLockAcquired,
255
+ /**
256
+ * Application source root. With it, a frame records the files it actually
257
+ * rendered and freshness becomes a question about their content instead of
258
+ * about the revision that happened to capture them.
259
+ */
260
+ sourceRoot,
261
+ observedAlwaysInclude,
262
+ /** Byte budget for stored objects. See the retention comment above. */
263
+ maxBytes = PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.maxBytes,
264
+ reclaimGraceMs = PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.reclaimGraceMs,
265
+ now = () => Date.now(),
212
266
  } = {}) {
213
267
  if (typeof artifactFile !== 'string' || !artifactFile.trim()) {
214
268
  throw new TypeError('Preview artifact store requires an artifact file.');
@@ -245,6 +299,75 @@ export function createPreviewArtifactStore({
245
299
  return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
246
300
  };
247
301
 
302
+ // ── Access journal ────────────────────────────────────────────────────────
303
+ // Recency has to come from reads, not writes: a frame captured once and
304
+ // opened every day must outlive one captured today and never opened, and
305
+ // the manifest's generation counter only ever knew about writes.
306
+ //
307
+ // The journal is deliberately lock-free. It is a heuristic, so a lost update
308
+ // costs a slightly worse eviction choice and never correctness — whereas
309
+ // taking the namespace lock on the read path would put write contention in
310
+ // front of every frame fetch. Flushes merge with what is on disk, so two
311
+ // processes reading at once do not erase each other.
312
+ const accessJournalPath = path.join(root, 'accesses.json');
313
+ const pendingAccess = new Map();
314
+ let accessFlushTimer = null;
315
+ let accessFlushChain = Promise.resolve();
316
+
317
+ const readAccessJournal = async () => {
318
+ try {
319
+ const value = await readJson(accessJournalPath);
320
+ if (
321
+ !value ||
322
+ typeof value !== 'object' ||
323
+ value.version !== ACCESS_JOURNAL_VERSION ||
324
+ !value.entries ||
325
+ typeof value.entries !== 'object'
326
+ ) {
327
+ return new Map();
328
+ }
329
+ const entries = new Map();
330
+ for (const [hash, at] of Object.entries(value.entries)) {
331
+ if (OBJECT_HASH.test(hash) && Number.isFinite(at)) entries.set(hash, at);
332
+ }
333
+ return entries;
334
+ } catch {
335
+ return new Map();
336
+ }
337
+ };
338
+
339
+ const flushAccessJournal = async () => {
340
+ if (pendingAccess.size === 0) return;
341
+ const mine = new Map(pendingAccess);
342
+ pendingAccess.clear();
343
+ accessFlushChain = accessFlushChain.then(async () => {
344
+ const stored = await readAccessJournal();
345
+ for (const [hash, at] of mine) {
346
+ if ((stored.get(hash) ?? 0) < at) stored.set(hash, at);
347
+ }
348
+ // Bounded so a long-lived store cannot grow a journal without end. The
349
+ // oldest accesses are the ones eviction cares least about.
350
+ const kept = [...stored.entries()]
351
+ .sort((left, right) => right[1] - left[1])
352
+ .slice(0, ACCESS_JOURNAL_LIMIT);
353
+ await writeJsonAtomic(accessJournalPath, {
354
+ version: ACCESS_JOURNAL_VERSION,
355
+ entries: Object.fromEntries(kept),
356
+ }).catch(() => undefined);
357
+ });
358
+ return accessFlushChain;
359
+ };
360
+
361
+ const touchObject = (hash) => {
362
+ pendingAccess.set(hash, now());
363
+ if (accessFlushTimer) return;
364
+ accessFlushTimer = setTimeout(() => {
365
+ accessFlushTimer = null;
366
+ void flushAccessJournal();
367
+ }, ACCESS_FLUSH_DEBOUNCE_MS);
368
+ accessFlushTimer.unref?.();
369
+ };
370
+
248
371
  const namespaceHash = (namespace) => sha256(namespace);
249
372
  const manifestPath = (namespace) =>
250
373
  path.join(root, 'namespaces', `${namespaceHash(namespace)}.json`);
@@ -286,6 +409,7 @@ export function createPreviewArtifactStore({
286
409
  if (cached) {
287
410
  objectCache.delete(hash);
288
411
  objectCache.set(hash, cached);
412
+ touchObject(hash);
289
413
  return cached.artifact;
290
414
  }
291
415
  try {
@@ -295,12 +419,181 @@ export function createPreviewArtifactStore({
295
419
  if (sha256(serialized) !== hash) return null;
296
420
  if (!validateRoutePreviewArtifactBundle(artifact).valid) return null;
297
421
  rememberObject(hash, artifact, Buffer.byteLength(serialized));
422
+ touchObject(hash);
298
423
  return artifact;
299
424
  } catch {
300
425
  return null;
301
426
  }
302
427
  };
303
428
 
429
+ const objectByteSize = async (hash) => {
430
+ try {
431
+ return (await fs.stat(objectPath(hash))).size;
432
+ } catch {
433
+ return 0;
434
+ }
435
+ };
436
+
437
+ /**
438
+ * Ranks a frame entry by how much keeping it is worth.
439
+ *
440
+ * Validity comes first: an entry whose observed files still hash the same is
441
+ * a cache hit waiting to happen, while an entry whose files moved is garbage
442
+ * that only survives as provenance. Recency only breaks ties inside a rank,
443
+ * so a busy but invalid entry never outranks a quiet valid one.
444
+ */
445
+ const EVICTION_RANK = Object.freeze({
446
+ invalid: 0,
447
+ unknown: 1,
448
+ valid: 2,
449
+ });
450
+
451
+ const classifyFrameEntry = async (entry) => {
452
+ const observed = normalizeObservedDependencies(entry.sourceFiles);
453
+ if (!observed) return 'unknown';
454
+ return (await observedDependenciesUnchanged({ recorded: observed, sourceRoot }))
455
+ ? 'valid'
456
+ : 'invalid';
457
+ };
458
+
459
+ /**
460
+ * Trims the namespace to the byte budget.
461
+ *
462
+ * Runs under the namespace lock, so no other writer can be mutating the
463
+ * manifest. Objects are not unlinked here: their hashes move to `pending`
464
+ * and are reclaimed by a later pass once the grace window has passed, which
465
+ * is what keeps a concurrent reader from losing a bundle it is about to
466
+ * fetch.
467
+ */
468
+ const evictToBudget = async (manifest, { at }) => {
469
+ const referenced = new Map();
470
+ const remember = async (hash) => {
471
+ if (!referenced.has(hash)) referenced.set(hash, await objectByteSize(hash));
472
+ return referenced.get(hash);
473
+ };
474
+ for (const entry of [...manifest.revisions, ...manifest.frames]) {
475
+ await remember(entry.object);
476
+ }
477
+ let bytes = [...referenced.values()].reduce((total, size) => total + size, 0);
478
+ if (bytes <= maxBytes) return { manifest, dropped: null, bytes };
479
+
480
+ const journal = await readAccessJournal();
481
+ for (const [hash, seen] of pendingAccess) {
482
+ if ((journal.get(hash) ?? 0) < seen) journal.set(hash, seen);
483
+ }
484
+ const accessOf = (entry) => journal.get(entry.object) ?? 0;
485
+
486
+ const perFrame = new Map();
487
+ for (const entry of manifest.frames) {
488
+ perFrame.set(entry.id, (perFrame.get(entry.id) ?? 0) + 1);
489
+ }
490
+ const newestRevision = [...manifest.revisions].sort(
491
+ (left, right) => right.generation - left.generation,
492
+ )[0];
493
+
494
+ const candidates = [];
495
+ for (const entry of manifest.frames) {
496
+ candidates.push({
497
+ kind: 'frame',
498
+ entry,
499
+ rank: EVICTION_RANK[await classifyFrameEntry(entry)],
500
+ // Protection within a rank, never across it: the last copy of a frame
501
+ // is worth more than a surplus copy, but an invalid sole entry is still
502
+ // garbage and goes before a valid one.
503
+ sole: perFrame.get(entry.id) === 1 ? 1 : 0,
504
+ });
505
+ }
506
+ for (const entry of manifest.revisions) {
507
+ candidates.push({
508
+ kind: 'revision',
509
+ entry,
510
+ // A whole-revision object is a full bundle kept for provenance and the
511
+ // compatibility file; its content is never verified, so it can only be
512
+ // unknown. Only the newest earns within-rank protection.
513
+ rank: EVICTION_RANK.unknown,
514
+ sole: entry === newestRevision ? 1 : 0,
515
+ });
516
+ }
517
+ candidates.sort(
518
+ (left, right) =>
519
+ left.rank - right.rank ||
520
+ left.sole - right.sole ||
521
+ accessOf(left.entry) - accessOf(right.entry) ||
522
+ left.entry.generation - right.entry.generation,
523
+ );
524
+
525
+ const evicted = new Set();
526
+ let droppedBytes = 0;
527
+ for (const candidate of candidates) {
528
+ if (bytes <= maxBytes) break;
529
+ // Within a rank the last copy of a frame is reached only after every
530
+ // surplus copy: giving it up turns a recapture-and-keep into a recapture
531
+ // that also loses the frame's provenance.
532
+ evicted.add(candidate.entry);
533
+ const stillReferenced = [...manifest.revisions, ...manifest.frames].some(
534
+ (entry) => !evicted.has(entry) && entry.object === candidate.entry.object,
535
+ );
536
+ if (!stillReferenced) {
537
+ const size = referenced.get(candidate.entry.object) ?? 0;
538
+ bytes -= size;
539
+ droppedBytes += size;
540
+ }
541
+ }
542
+ if (evicted.size === 0) return { manifest, dropped: null, bytes };
543
+
544
+ const keptFrames = manifest.frames.filter((entry) => !evicted.has(entry));
545
+ const keptRevisions = manifest.revisions.filter((entry) => !evicted.has(entry));
546
+ const stillReferencedObjects = new Set(
547
+ [...keptFrames, ...keptRevisions].map((entry) => entry.object),
548
+ );
549
+ const unreferenced = [...evicted]
550
+ .map((entry) => entry.object)
551
+ .filter((hash) => !stillReferencedObjects.has(hash));
552
+ return {
553
+ manifest: {
554
+ ...manifest,
555
+ frames: keptFrames,
556
+ revisions: keptRevisions,
557
+ pending: [
558
+ ...(manifest.pending ?? []),
559
+ ...[...new Set(unreferenced)].map((object) => ({ object, since: at })),
560
+ ],
561
+ },
562
+ dropped: {
563
+ at: new Date(at).toISOString(),
564
+ entries: evicted.size,
565
+ bytes: droppedBytes,
566
+ reason: 'byte-budget',
567
+ },
568
+ bytes,
569
+ };
570
+ };
571
+
572
+ /** Unlinks objects whose grace window has passed and that nothing references. */
573
+ const reclaimPending = async (manifest, { at }) => {
574
+ const pending = manifest.pending ?? [];
575
+ if (pending.length === 0) return manifest;
576
+ const live = new Set(
577
+ [...manifest.revisions, ...manifest.frames].map((entry) => entry.object),
578
+ );
579
+ const keep = [];
580
+ const remove = [];
581
+ for (const entry of pending) {
582
+ if (live.has(entry.object)) continue;
583
+ if (at - entry.since >= reclaimGraceMs) remove.push(entry.object);
584
+ else keep.push(entry);
585
+ }
586
+ await Promise.all(
587
+ [...new Set(remove)].map(async (hash) => {
588
+ await fs.rm(objectPath(hash), { force: true }).catch(() => undefined);
589
+ forgetObject(hash);
590
+ }),
591
+ );
592
+ if (keep.length === pending.length) return manifest;
593
+ const { pending: _dropped, ...rest } = manifest;
594
+ return keep.length ? { ...rest, pending: keep } : rest;
595
+ };
596
+
304
597
  const materializeManifest = async (manifest, sourceRevision) => {
305
598
  const latestById = new Map();
306
599
  for (const entry of manifest.frames) {
@@ -391,6 +684,17 @@ export function createPreviewArtifactStore({
391
684
  for (const [id, frame] of Object.entries(artifact.frames)) {
392
685
  const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
393
686
  const object = await writeObject(selected.bundle);
687
+ // Recorded from the stored snapshot, so the set describes exactly
688
+ // the artifact it travels with. Null keeps today's behaviour.
689
+ const observed =
690
+ normalizeObservedDependencies(frame.sourceFiles) ??
691
+ (await recordObservedDependencies({
692
+ snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
693
+ sourceRoot,
694
+ ...(observedAlwaysInclude
695
+ ? { alwaysInclude: observedAlwaysInclude }
696
+ : {}),
697
+ }));
394
698
  const nextFrame = {
395
699
  id,
396
700
  fingerprint:
@@ -398,6 +702,7 @@ export function createPreviewArtifactStore({
398
702
  sourceRevision: sourceRevision ?? null,
399
703
  object,
400
704
  generation,
705
+ ...(observed ? { sourceFiles: observed } : {}),
401
706
  };
402
707
  const alreadyRecorded = frames.some(
403
708
  (entry) =>
@@ -411,6 +716,37 @@ export function createPreviewArtifactStore({
411
716
  manifest = { ...manifest, frames };
412
717
  }
413
718
  manifest = { ...manifest, generation };
719
+ // One policy, applied on every write: trim to the byte budget, then
720
+ // reclaim whatever has been unreferenced long enough to be safe. No
721
+ // human is asked when the store should be cleaned.
722
+ const at = now();
723
+ const trimmed = await evictToBudget(manifest, { at });
724
+ manifest = trimmed.dropped
725
+ ? { ...trimmed.manifest, lastEviction: trimmed.dropped }
726
+ : trimmed.manifest;
727
+ // Objects the structural upserts just made unreachable join the same
728
+ // queue rather than being unlinked here. Immediate deletion is what
729
+ // could pull a bundle out from under a reader that read the previous
730
+ // manifest and has not fetched yet.
731
+ const retainedObjectReferences = new Set(
732
+ [...manifest.revisions, ...manifest.frames].map((entry) => entry.object),
733
+ );
734
+ const orphanedByUpsert = [...previousObjectReferences].filter(
735
+ (hash) => !retainedObjectReferences.has(hash),
736
+ );
737
+ if (orphanedByUpsert.length) {
738
+ const queued = new Set((manifest.pending ?? []).map((entry) => entry.object));
739
+ manifest = {
740
+ ...manifest,
741
+ pending: [
742
+ ...(manifest.pending ?? []),
743
+ ...orphanedByUpsert
744
+ .filter((hash) => !queued.has(hash))
745
+ .map((object) => ({ object, since: at })),
746
+ ],
747
+ };
748
+ }
749
+ manifest = await reclaimPending(manifest, { at });
414
750
  if (
415
751
  manifest.revisions.length + manifest.frames.length >
416
752
  MANIFEST_ENTRY_LIMIT
@@ -419,23 +755,6 @@ export function createPreviewArtifactStore({
419
755
  }
420
756
  await writeJsonAtomic(manifestPath(namespace), manifest);
421
757
 
422
- // Objects contain the namespace, so an identical hash cannot be owned
423
- // by another namespace. Under this namespace lock it is safe to remove
424
- // entries retention just made unreachable, after the manifest commit.
425
- const retainedObjectReferences = new Set(
426
- [...manifest.revisions, ...manifest.frames].map(
427
- (entry) => entry.object,
428
- ),
429
- );
430
- await Promise.all(
431
- [...previousObjectReferences]
432
- .filter((hash) => !retainedObjectReferences.has(hash))
433
- .map(async (hash) => {
434
- await fs.rm(objectPath(hash), { force: true });
435
- forgetObject(hash);
436
- }),
437
- );
438
-
439
758
  if (materializeLegacy) {
440
759
  // Never stamp a cross-revision frame aggregate with the current
441
760
  // revision. The compatibility file remains an honest exact snapshot;
@@ -561,6 +880,31 @@ export function createPreviewArtifactStore({
561
880
  return candidatesById;
562
881
  };
563
882
 
883
+ /**
884
+ * A stored frame is fresh when its content identity still holds.
885
+ *
886
+ * The revision is provenance, not identity: gating on revision equality
887
+ * retired every frame on every commit even when the fingerprint proved the
888
+ * frame could not have changed. A recorded observed set is stronger still —
889
+ * it survives an edit to a file the frame never rendered, which a route-wide
890
+ * digest cannot express in a single-page application.
891
+ */
892
+ const frameIsFresh = async (entry, request, sourceRevision) => {
893
+ if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
894
+ return true;
895
+ }
896
+ const observed = normalizeObservedDependencies(entry.sourceFiles);
897
+ if (observed) {
898
+ return observedDependenciesUnchanged({ recorded: observed, sourceRoot });
899
+ }
900
+ // Nothing content-based to go on: fall back to the old rule so a frame is
901
+ // never treated as fresher than it was before.
902
+ return (
903
+ entry.sourceRevision === sourceRevision &&
904
+ (request.fingerprint == null || entry.fingerprint === request.fingerprint)
905
+ );
906
+ };
907
+
564
908
  /** Resolves freshness from the compact manifest without loading frame payloads. */
565
909
  const resolveFrameSelection = async (namespace, sourceRevision, wanted) => {
566
910
  const manifest = await readManifest(namespace);
@@ -574,12 +918,13 @@ export function createPreviewArtifactStore({
574
918
  missing.push(request.id);
575
919
  continue;
576
920
  }
577
- const current = candidates.some(
578
- (entry) =>
579
- entry.sourceRevision === sourceRevision &&
580
- (request.fingerprint == null ||
581
- entry.fingerprint === request.fingerprint),
582
- );
921
+ let current = false;
922
+ for (const entry of candidates) {
923
+ if (await frameIsFresh(entry, request, sourceRevision)) {
924
+ current = true;
925
+ break;
926
+ }
927
+ }
583
928
  (current ? exact : stale).push(request.id);
584
929
  }
585
930
  return { exact, missing, stale };
@@ -594,11 +939,18 @@ export function createPreviewArtifactStore({
594
939
  const candidatesById = indexFrameCandidates(manifest, wanted);
595
940
  for (const request of wanted) {
596
941
  const candidates = candidatesById.get(request.id) ?? [];
597
- const exact = candidates.filter((entry) =>
598
- entry.sourceRevision === sourceRevision &&
599
- (request.fingerprint == null ||
600
- entry.fingerprint === request.fingerprint),
601
- );
942
+ const fresh = [];
943
+ for (const entry of candidates) {
944
+ if (await frameIsFresh(entry, request, sourceRevision)) fresh.push(entry);
945
+ }
946
+ // Several revisions can now be fresh at once, so provenance decides
947
+ // which one is served: the requested revision's own capture first, then
948
+ // the newest. Without this a caller asking about revision A would be
949
+ // handed revision B's bundle merely because it was captured later.
950
+ const exact = [
951
+ ...fresh.filter((entry) => entry.sourceRevision === sourceRevision),
952
+ ...fresh.filter((entry) => entry.sourceRevision !== sourceRevision),
953
+ ];
602
954
  const exactSet = new Set(exact);
603
955
  const ordered = [
604
956
  ...exact,
@@ -640,6 +992,105 @@ export function createPreviewArtifactStore({
640
992
  );
641
993
  };
642
994
 
995
+ /**
996
+ * What the cache is holding, in the terms the policy decides by.
997
+ *
998
+ * A cache nobody can look at becomes a cache nobody trusts, and "valid vs
999
+ * provenance-only" is the split that explains both the size and the next
1000
+ * eviction — so it is reported rather than left to be inferred.
1001
+ */
1002
+ const inspect = async (namespace) => {
1003
+ const manifest = await readManifest(namespace);
1004
+ if (!manifest) {
1005
+ return {
1006
+ namespace,
1007
+ bytes: 0,
1008
+ maxBytes,
1009
+ objects: 0,
1010
+ frames: { entries: 0, ids: 0, valid: 0, unknown: 0, invalid: 0 },
1011
+ revisions: 0,
1012
+ pending: 0,
1013
+ lastEviction: null,
1014
+ };
1015
+ }
1016
+ const sizes = new Map();
1017
+ for (const entry of [...manifest.revisions, ...manifest.frames]) {
1018
+ if (!sizes.has(entry.object)) {
1019
+ sizes.set(entry.object, await objectByteSize(entry.object));
1020
+ }
1021
+ }
1022
+ const counts = { valid: 0, unknown: 0, invalid: 0 };
1023
+ for (const entry of manifest.frames) {
1024
+ counts[await classifyFrameEntry(entry)] += 1;
1025
+ }
1026
+ return {
1027
+ namespace,
1028
+ bytes: [...sizes.values()].reduce((total, size) => total + size, 0),
1029
+ maxBytes,
1030
+ objects: sizes.size,
1031
+ frames: {
1032
+ entries: manifest.frames.length,
1033
+ ids: new Set(manifest.frames.map((entry) => entry.id)).size,
1034
+ ...counts,
1035
+ },
1036
+ revisions: manifest.revisions.length,
1037
+ pending: (manifest.pending ?? []).length,
1038
+ lastEviction: manifest.lastEviction ?? null,
1039
+ };
1040
+ };
1041
+
1042
+ /**
1043
+ * Applies retention without publishing anything.
1044
+ *
1045
+ * Eviction otherwise only runs on a write, so a store that is over budget and
1046
+ * merely being read would stay over budget until the next capture. Callers
1047
+ * run this when the server starts.
1048
+ */
1049
+ const applyRetention = async (namespace) => {
1050
+ // No namespace means every namespace: at server start the caller has not
1051
+ // seen a request yet and so cannot name one.
1052
+ if (namespace === undefined) {
1053
+ const dropped = [];
1054
+ let names = [];
1055
+ try {
1056
+ names = await fs.readdir(path.join(root, 'namespaces'));
1057
+ } catch {
1058
+ return dropped;
1059
+ }
1060
+ for (const file of names) {
1061
+ if (!file.endsWith('.json')) continue;
1062
+ const stored = await readJson(path.join(root, 'namespaces', file)).catch(
1063
+ () => null,
1064
+ );
1065
+ if (typeof stored?.namespace !== 'string') continue;
1066
+ const result = await applyRetention(stored.namespace).catch(() => null);
1067
+ if (result) dropped.push({ namespace: stored.namespace, ...result });
1068
+ }
1069
+ return dropped;
1070
+ }
1071
+ return withFilesystemLock(
1072
+ lockPath(namespace),
1073
+ async () => {
1074
+ const stored = await readManifest(namespace);
1075
+ if (!stored) return null;
1076
+ const at = now();
1077
+ const trimmed = await evictToBudget(stored, { at });
1078
+ let manifest = trimmed.dropped
1079
+ ? { ...trimmed.manifest, lastEviction: trimmed.dropped }
1080
+ : trimmed.manifest;
1081
+ manifest = await reclaimPending(manifest, { at });
1082
+ if (manifest !== stored) {
1083
+ await writeJsonAtomic(manifestPath(namespace), {
1084
+ ...manifest,
1085
+ generation: stored.generation + 1,
1086
+ });
1087
+ }
1088
+ return trimmed.dropped;
1089
+ },
1090
+ { lockTimeoutMs, lockStaleMs, onLockAcquired },
1091
+ );
1092
+ };
1093
+
643
1094
  return {
644
1095
  artifactFile: legacyFile,
645
1096
  storeDirectory: root,
@@ -649,5 +1100,8 @@ export function createPreviewArtifactStore({
649
1100
  readFrameSelection,
650
1101
  readLatestArtifact,
651
1102
  resolveFrameSelection,
1103
+ inspect,
1104
+ applyRetention,
1105
+ flushAccessJournal,
652
1106
  };
653
1107
  }