@pygmalionjs/pygmalion 0.2.44 → 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.
- package/dist-lib/FrozenRoutePreview-efexOSqR.js +9416 -0
- package/dist-lib/pygmalion.js +17853 -1374
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/component-branches.mjs +108 -0
- package/node/dev-view.vite.mjs +45 -1
- package/node/preview-artifact-plugin.mjs +20 -1
- package/node/preview-artifact-store.mjs +483 -29
- package/node/preview-capture-worker.mjs +529 -0
- package/node/route-dependency-digest.mjs +151 -0
- package/node/route-preview-artifact-v3.mjs +10 -5
- package/node/source-graph.mjs +258 -0
- package/node/storyboard-capture-runtime.mjs +213 -5
- package/node/storyboard-environment.mjs +97 -10
- package/node/vite.mjs +53 -2
- package/package.json +5 -2
- package/types.d.ts +495 -0
- package/vite.d.ts +68 -0
- package/dist-lib/App-CCaXx_KA.js +0 -20441
|
@@ -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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
598
|
-
|
|
599
|
-
(request
|
|
600
|
-
|
|
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
|
}
|