@pygmalionjs/pygmalion 0.6.31 → 0.6.33
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/README.md +75 -2
- package/dist-lib/{FrozenRoutePreview-D9J35QsM.js → FrozenRoutePreview-BbF5Ov5K.js} +4045 -3819
- package/dist-lib/pygmalion.js +10976 -9740
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/editor/documentSync.d.ts +15 -1
- package/dist-lib/types/editor/host.d.ts +9 -0
- package/dist-lib/types/editor/previewEnvironmentControls.d.ts +2 -0
- package/dist-lib/types/editor/revisionCatalog.d.ts +135 -0
- package/dist-lib/types/editor/revisionCatalogInstall.d.ts +62 -0
- package/dist-lib/types/editor/routePreview.d.ts +1 -1
- package/dist-lib/types/editor/store.d.ts +54 -37
- package/dist-lib/types/editor/tokens.d.ts +9 -0
- package/dist-lib/types/lib.d.ts +19 -3
- package/node/dev-mirror.mjs +564 -112
- package/node/preview-artifact-plugin.mjs +506 -68
- package/node/preview-artifact-store.mjs +358 -77
- package/node/vite.mjs +48 -2
- package/package.json +1 -1
- package/vite.d.ts +61 -4
|
@@ -141,30 +141,164 @@ function processIsAlive(pid) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
async function acquireFilesystemReclaimMutex(lockPath, deadline) {
|
|
145
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
146
|
+
const token = randomUUID();
|
|
147
|
+
const payload = `${JSON.stringify({
|
|
148
|
+
token,
|
|
149
|
+
pid: process.pid,
|
|
150
|
+
createdAt: new Date().toISOString(),
|
|
151
|
+
})}\n`;
|
|
152
|
+
while (true) {
|
|
153
|
+
const candidate = `${reclaimPath}.candidate-${randomUUID()}`;
|
|
154
|
+
try {
|
|
155
|
+
await fs.writeFile(candidate, payload, { encoding: 'utf8', flag: 'wx' });
|
|
156
|
+
await fs.link(candidate, reclaimPath);
|
|
157
|
+
return async () => {
|
|
158
|
+
const current = await readJson(reclaimPath)
|
|
159
|
+
.then((owner) => owner?.token)
|
|
160
|
+
.catch(() => null);
|
|
161
|
+
if (current === token) {
|
|
162
|
+
await fs.unlink(reclaimPath).catch(() => undefined);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
167
|
+
// Recovery of this guard would recreate the same ABA race one level down.
|
|
168
|
+
// A dead guard therefore fails closed until it is removed manually.
|
|
169
|
+
if (Date.now() >= deadline) {
|
|
170
|
+
throw new Error('Timed out waiting for the preview artifact store lock.');
|
|
171
|
+
}
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
173
|
+
} finally {
|
|
174
|
+
await fs.rm(candidate, { force: true }).catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function reclaimStaleFilesystemLock({
|
|
180
|
+
lockPath,
|
|
181
|
+
deadline,
|
|
182
|
+
staleMs,
|
|
183
|
+
onStaleLockConfirmed,
|
|
184
|
+
}) {
|
|
185
|
+
const releaseReclaim = await acquireFilesystemReclaimMutex(lockPath, deadline);
|
|
186
|
+
try {
|
|
187
|
+
const staleDeadOwner = async () => {
|
|
188
|
+
const stat = await fs.stat(lockPath).catch(() => null);
|
|
189
|
+
if (!stat || Date.now() - stat.mtimeMs <= staleMs) return false;
|
|
190
|
+
const owner = await readJson(path.join(lockPath, 'owner.json')).catch(
|
|
191
|
+
() => null,
|
|
192
|
+
);
|
|
193
|
+
// An absent or malformed owner cannot prove that its initializer died.
|
|
194
|
+
// New locks are atomically published complete, so keep legacy/corrupt
|
|
195
|
+
// shapes fail-closed instead of guessing and deleting a live directory.
|
|
196
|
+
if (
|
|
197
|
+
!owner ||
|
|
198
|
+
typeof owner.token !== 'string' ||
|
|
199
|
+
!owner.token ||
|
|
200
|
+
!Number.isSafeInteger(owner.pid) ||
|
|
201
|
+
owner.pid <= 0
|
|
202
|
+
) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
return processIsAlive(owner.pid) === false;
|
|
206
|
+
};
|
|
207
|
+
if (!(await staleDeadOwner())) return false;
|
|
208
|
+
await onStaleLockConfirmed?.({ lockPath });
|
|
209
|
+
// Re-read after any delay while every other reclaimer is excluded. The
|
|
210
|
+
// fixed path may now name a new live generation, which must be preserved.
|
|
211
|
+
if (!(await staleDeadOwner())) return false;
|
|
212
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
213
|
+
return true;
|
|
214
|
+
} finally {
|
|
215
|
+
await releaseReclaim();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function filesystemLockPathExists(lockPath) {
|
|
220
|
+
try {
|
|
221
|
+
await fs.lstat(lockPath);
|
|
222
|
+
return true;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code === 'ENOENT') return false;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function filesystemLockPublishConflict(error, lockPath, candidate) {
|
|
230
|
+
if (['EEXIST', 'ENOTEMPTY'].includes(error?.code)) return true;
|
|
231
|
+
if (!['EACCES', 'EPERM'].includes(error?.code)) return false;
|
|
232
|
+
// Windows can report an existing destination directory as a permission
|
|
233
|
+
// failure. Treat that shape as contention, but propagate a genuine parent or
|
|
234
|
+
// candidate permission failure when no competing fixed directory exists.
|
|
235
|
+
const [fixedExists, candidateExists] = await Promise.all([
|
|
236
|
+
filesystemLockPathExists(lockPath),
|
|
237
|
+
fs
|
|
238
|
+
.lstat(candidate)
|
|
239
|
+
.then((stat) => stat.isDirectory())
|
|
240
|
+
.catch(() => false),
|
|
241
|
+
]);
|
|
242
|
+
return fixedExists && candidateExists;
|
|
243
|
+
}
|
|
244
|
+
|
|
144
245
|
async function withFilesystemLock(lockPath, task, options = {}) {
|
|
145
246
|
const timeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
|
|
146
247
|
const staleMs = options.lockStaleMs ?? LOCK_STALE_MS;
|
|
147
248
|
const deadline = Date.now() + timeoutMs;
|
|
249
|
+
const ownerToken = randomUUID();
|
|
250
|
+
const ownerPayload = `${JSON.stringify({
|
|
251
|
+
token: ownerToken,
|
|
252
|
+
pid: process.pid,
|
|
253
|
+
createdAt: new Date().toISOString(),
|
|
254
|
+
})}\n`;
|
|
255
|
+
let owner;
|
|
148
256
|
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
149
|
-
while (
|
|
257
|
+
while (!owner) {
|
|
258
|
+
const candidate = `${lockPath}.candidate-${randomUUID()}`;
|
|
259
|
+
let publicationConflict = false;
|
|
260
|
+
let publicationError;
|
|
150
261
|
try {
|
|
151
|
-
await fs.mkdir(
|
|
152
|
-
|
|
262
|
+
await fs.mkdir(candidate);
|
|
263
|
+
await fs.writeFile(path.join(candidate, 'owner.json'), ownerPayload, 'utf8');
|
|
264
|
+
await options.onLockOwnerReady?.({ candidate, lockPath });
|
|
265
|
+
if (await filesystemLockPathExists(lockPath)) {
|
|
266
|
+
publicationConflict = true;
|
|
267
|
+
} else {
|
|
268
|
+
try {
|
|
269
|
+
await fs.rename(candidate, lockPath);
|
|
270
|
+
owner = path.join(lockPath, 'owner.json');
|
|
271
|
+
} catch (error) {
|
|
272
|
+
publicationConflict = await filesystemLockPublishConflict(
|
|
273
|
+
error,
|
|
274
|
+
lockPath,
|
|
275
|
+
candidate,
|
|
276
|
+
);
|
|
277
|
+
if (!publicationConflict) publicationError = error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
153
280
|
} catch (error) {
|
|
154
|
-
|
|
281
|
+
publicationError = error;
|
|
282
|
+
} finally {
|
|
283
|
+
await fs.rm(candidate, { recursive: true, force: true }).catch(
|
|
284
|
+
() => undefined,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (owner) continue;
|
|
288
|
+
if (publicationError) throw publicationError;
|
|
289
|
+
if (publicationConflict) {
|
|
155
290
|
const stale = await fs
|
|
156
291
|
.stat(lockPath)
|
|
157
292
|
.then((stat) => Date.now() - stat.mtimeMs > staleMs)
|
|
158
293
|
.catch(() => false);
|
|
159
294
|
if (stale) {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
295
|
+
const reclaimed = await reclaimStaleFilesystemLock({
|
|
296
|
+
lockPath,
|
|
297
|
+
deadline,
|
|
298
|
+
staleMs,
|
|
299
|
+
onStaleLockConfirmed: options.onStaleLockConfirmed,
|
|
300
|
+
});
|
|
301
|
+
if (reclaimed) {
|
|
168
302
|
continue;
|
|
169
303
|
}
|
|
170
304
|
}
|
|
@@ -172,24 +306,9 @@ async function withFilesystemLock(lockPath, task, options = {}) {
|
|
|
172
306
|
throw new Error('Timed out waiting for the preview artifact store lock.');
|
|
173
307
|
}
|
|
174
308
|
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
309
|
+
continue;
|
|
175
310
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const owner = path.join(lockPath, 'owner.json');
|
|
179
|
-
const ownerToken = randomUUID();
|
|
180
|
-
try {
|
|
181
|
-
await fs.writeFile(
|
|
182
|
-
owner,
|
|
183
|
-
`${JSON.stringify({
|
|
184
|
-
token: ownerToken,
|
|
185
|
-
pid: process.pid,
|
|
186
|
-
createdAt: new Date().toISOString(),
|
|
187
|
-
})}\n`,
|
|
188
|
-
'utf8',
|
|
189
|
-
);
|
|
190
|
-
} catch (error) {
|
|
191
|
-
await fs.rm(lockPath, { recursive: true, force: true });
|
|
192
|
-
throw error;
|
|
311
|
+
throw new Error('Preview artifact store lock publication did not settle.');
|
|
193
312
|
}
|
|
194
313
|
const heartbeat = setInterval(() => {
|
|
195
314
|
const now = new Date();
|
|
@@ -254,12 +373,8 @@ export function createPreviewArtifactStore({
|
|
|
254
373
|
lockTimeoutMs,
|
|
255
374
|
lockStaleMs,
|
|
256
375
|
onLockAcquired,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
* rendered and freshness becomes a question about their content instead of
|
|
260
|
-
* about the revision that happened to capture them.
|
|
261
|
-
*/
|
|
262
|
-
sourceRoot,
|
|
376
|
+
onLockOwnerReady,
|
|
377
|
+
onStaleLockConfirmed,
|
|
263
378
|
observedAlwaysInclude,
|
|
264
379
|
/** Byte budget for stored objects. See the retention comment above. */
|
|
265
380
|
maxBytes = PYGMALION_PREVIEW_ARTIFACT_STORE_RETENTION.maxBytes,
|
|
@@ -271,13 +386,12 @@ export function createPreviewArtifactStore({
|
|
|
271
386
|
}
|
|
272
387
|
const legacyFile = path.resolve(artifactFile);
|
|
273
388
|
const root = path.resolve(storeDirectory);
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
? path.resolve(
|
|
389
|
+
const sourceRootForRevision = (sourceContext, sourceRevision) =>
|
|
390
|
+
sourceContext?.sourceRevision === sourceRevision &&
|
|
391
|
+
typeof sourceContext?.sourceRoot === 'string' &&
|
|
392
|
+
sourceContext.sourceRoot
|
|
393
|
+
? path.resolve(sourceContext.sourceRoot)
|
|
279
394
|
: undefined;
|
|
280
|
-
};
|
|
281
395
|
let legacyCache = null;
|
|
282
396
|
const objectCache = new Map();
|
|
283
397
|
let objectCacheBytes = 0;
|
|
@@ -457,12 +571,17 @@ export function createPreviewArtifactStore({
|
|
|
457
571
|
valid: 2,
|
|
458
572
|
});
|
|
459
573
|
|
|
460
|
-
const classifyFrameEntry = async (entry) => {
|
|
574
|
+
const classifyFrameEntry = async (entry, sourceContext) => {
|
|
461
575
|
const observed = normalizeObservedDependencies(entry.sourceFiles);
|
|
462
576
|
if (!observed) return 'unknown';
|
|
577
|
+
const exactSourceRoot = sourceRootForRevision(
|
|
578
|
+
sourceContext,
|
|
579
|
+
sourceContext?.sourceRevision,
|
|
580
|
+
);
|
|
581
|
+
if (!exactSourceRoot) return 'unknown';
|
|
463
582
|
return (await observedDependenciesUnchanged({
|
|
464
583
|
recorded: observed,
|
|
465
|
-
sourceRoot:
|
|
584
|
+
sourceRoot: exactSourceRoot,
|
|
466
585
|
}))
|
|
467
586
|
? 'valid'
|
|
468
587
|
: 'invalid';
|
|
@@ -477,7 +596,7 @@ export function createPreviewArtifactStore({
|
|
|
477
596
|
* is what keeps a concurrent reader from losing a bundle it is about to
|
|
478
597
|
* fetch.
|
|
479
598
|
*/
|
|
480
|
-
const evictToBudget = async (manifest, { at }) => {
|
|
599
|
+
const evictToBudget = async (manifest, { at, sourceContext } = {}) => {
|
|
481
600
|
const referenced = new Map();
|
|
482
601
|
const remember = async (hash) => {
|
|
483
602
|
if (!referenced.has(hash)) referenced.set(hash, await objectByteSize(hash));
|
|
@@ -508,7 +627,7 @@ export function createPreviewArtifactStore({
|
|
|
508
627
|
candidates.push({
|
|
509
628
|
kind: 'frame',
|
|
510
629
|
entry,
|
|
511
|
-
rank: EVICTION_RANK[await classifyFrameEntry(entry)],
|
|
630
|
+
rank: EVICTION_RANK[await classifyFrameEntry(entry, sourceContext)],
|
|
512
631
|
// Protection within a rank, never across it: the last copy of a frame
|
|
513
632
|
// is worth more than a surplus copy, but an invalid sole entry is still
|
|
514
633
|
// garbage and goes before a valid one.
|
|
@@ -632,6 +751,40 @@ export function createPreviewArtifactStore({
|
|
|
632
751
|
};
|
|
633
752
|
};
|
|
634
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Records every frame's observed source set before publication can wait on the
|
|
756
|
+
* namespace lock. A Map entry is present even when observation is unavailable,
|
|
757
|
+
* so `null` is an explicit pin rather than permission to inspect another root.
|
|
758
|
+
*/
|
|
759
|
+
const observeArtifactDependencies = async (artifact, explicitSourceRoot) => {
|
|
760
|
+
const validation = validateRoutePreviewArtifactBundle(artifact);
|
|
761
|
+
if (!validation.valid) {
|
|
762
|
+
throw new Error('Preview artifact store refused an invalid artifact.');
|
|
763
|
+
}
|
|
764
|
+
const observedByFrame = new Map();
|
|
765
|
+
if (artifact.version !== 3) return observedByFrame;
|
|
766
|
+
const pinnedSourceRoot =
|
|
767
|
+
typeof explicitSourceRoot === 'string' && explicitSourceRoot
|
|
768
|
+
? path.resolve(explicitSourceRoot)
|
|
769
|
+
: undefined;
|
|
770
|
+
for (const [id, frame] of Object.entries(artifact.frames)) {
|
|
771
|
+
const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
|
|
772
|
+
// Artifact frames are an open record in the v3 wire format, so an
|
|
773
|
+
// unvalidated `sourceFiles` property supplied by a generator cannot be a
|
|
774
|
+
// freshness proof. Recompute it from the serialized snapshot and the
|
|
775
|
+
// explicitly pinned root every time.
|
|
776
|
+
const observed = await recordObservedDependencies({
|
|
777
|
+
snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
|
|
778
|
+
sourceRoot: pinnedSourceRoot,
|
|
779
|
+
...(observedAlwaysInclude
|
|
780
|
+
? { alwaysInclude: observedAlwaysInclude }
|
|
781
|
+
: {}),
|
|
782
|
+
});
|
|
783
|
+
observedByFrame.set(id, observed);
|
|
784
|
+
}
|
|
785
|
+
return observedByFrame;
|
|
786
|
+
};
|
|
787
|
+
|
|
635
788
|
const publishArtifact = async (
|
|
636
789
|
artifact,
|
|
637
790
|
{
|
|
@@ -639,6 +792,8 @@ export function createPreviewArtifactStore({
|
|
|
639
792
|
sourceRevision = artifact?.sourceRevision,
|
|
640
793
|
materializeLegacy = true,
|
|
641
794
|
frameRequests = [],
|
|
795
|
+
observedDependenciesByFrame,
|
|
796
|
+
sourceContext,
|
|
642
797
|
} = {},
|
|
643
798
|
) => {
|
|
644
799
|
const validation = validateRoutePreviewArtifactBundle(artifact);
|
|
@@ -652,7 +807,51 @@ export function createPreviewArtifactStore({
|
|
|
652
807
|
) {
|
|
653
808
|
throw new Error('Preview artifact store refused a mismatched revision.');
|
|
654
809
|
}
|
|
810
|
+
if (
|
|
811
|
+
observedDependenciesByFrame != null &&
|
|
812
|
+
!(observedDependenciesByFrame instanceof Map)
|
|
813
|
+
) {
|
|
814
|
+
throw new TypeError(
|
|
815
|
+
'Preview artifact observed dependencies must be provided as a Map.',
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
let pinnedObservedDependenciesByFrame;
|
|
819
|
+
if (observedDependenciesByFrame instanceof Map && artifact.version === 3) {
|
|
820
|
+
const frameIds = new Set(Object.keys(artifact.frames));
|
|
821
|
+
if (
|
|
822
|
+
observedDependenciesByFrame.size !== frameIds.size ||
|
|
823
|
+
[...frameIds].some((id) => !observedDependenciesByFrame.has(id)) ||
|
|
824
|
+
[...observedDependenciesByFrame.keys()].some((id) => !frameIds.has(id))
|
|
825
|
+
) {
|
|
826
|
+
throw new TypeError(
|
|
827
|
+
'Preview artifact observed dependencies must cover every frame exactly.',
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
pinnedObservedDependenciesByFrame = new Map();
|
|
831
|
+
for (const observed of observedDependenciesByFrame.values()) {
|
|
832
|
+
if (observed !== null && normalizeObservedDependencies(observed) == null) {
|
|
833
|
+
throw new TypeError(
|
|
834
|
+
'Preview artifact observed dependencies contain an invalid entry.',
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
for (const id of frameIds) {
|
|
839
|
+
const normalized = normalizeObservedDependencies(
|
|
840
|
+
observedDependenciesByFrame.get(id),
|
|
841
|
+
);
|
|
842
|
+
pinnedObservedDependenciesByFrame.set(
|
|
843
|
+
id,
|
|
844
|
+
normalized?.map(([file, hash]) => [file, hash]) ?? null,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
655
848
|
const namespace = artifact.namespace;
|
|
849
|
+
const exactSourceContext = sourceRootForRevision(
|
|
850
|
+
sourceContext,
|
|
851
|
+
sourceRevision,
|
|
852
|
+
)
|
|
853
|
+
? sourceContext
|
|
854
|
+
: undefined;
|
|
656
855
|
return withFilesystemLock(
|
|
657
856
|
lockPath(namespace),
|
|
658
857
|
async () => {
|
|
@@ -701,16 +900,13 @@ export function createPreviewArtifactStore({
|
|
|
701
900
|
const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
|
|
702
901
|
const object = await writeObject(selected.bundle);
|
|
703
902
|
// Recorded from the stored snapshot, so the set describes exactly
|
|
704
|
-
// the artifact it travels with. Null keeps
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
? { alwaysInclude: observedAlwaysInclude }
|
|
712
|
-
: {}),
|
|
713
|
-
}));
|
|
903
|
+
// the artifact it travels with. Null keeps the entry on the
|
|
904
|
+
// revision/fingerprint-only fallback.
|
|
905
|
+
const observed = pinnedObservedDependenciesByFrame
|
|
906
|
+
? normalizeObservedDependencies(
|
|
907
|
+
pinnedObservedDependenciesByFrame.get(id),
|
|
908
|
+
)
|
|
909
|
+
: null;
|
|
714
910
|
const nextFrame = {
|
|
715
911
|
id,
|
|
716
912
|
fingerprint:
|
|
@@ -742,7 +938,10 @@ export function createPreviewArtifactStore({
|
|
|
742
938
|
// reclaim whatever has been unreferenced long enough to be safe. No
|
|
743
939
|
// human is asked when the store should be cleaned.
|
|
744
940
|
const at = now();
|
|
745
|
-
const trimmed = await evictToBudget(manifest, {
|
|
941
|
+
const trimmed = await evictToBudget(manifest, {
|
|
942
|
+
at,
|
|
943
|
+
sourceContext: exactSourceContext,
|
|
944
|
+
});
|
|
746
945
|
manifest = trimmed.dropped
|
|
747
946
|
? { ...trimmed.manifest, lastEviction: trimmed.dropped }
|
|
748
947
|
: trimmed.manifest;
|
|
@@ -808,7 +1007,13 @@ export function createPreviewArtifactStore({
|
|
|
808
1007
|
}
|
|
809
1008
|
return artifact;
|
|
810
1009
|
},
|
|
811
|
-
{
|
|
1010
|
+
{
|
|
1011
|
+
lockTimeoutMs,
|
|
1012
|
+
lockStaleMs,
|
|
1013
|
+
onLockAcquired,
|
|
1014
|
+
onLockOwnerReady,
|
|
1015
|
+
onStaleLockConfirmed,
|
|
1016
|
+
},
|
|
812
1017
|
);
|
|
813
1018
|
};
|
|
814
1019
|
|
|
@@ -911,9 +1116,17 @@ export function createPreviewArtifactStore({
|
|
|
911
1116
|
* it survives an edit to a file the frame never rendered, which a route-wide
|
|
912
1117
|
* digest cannot express in a single-page application.
|
|
913
1118
|
*/
|
|
914
|
-
const
|
|
1119
|
+
const frameFreshness = async (
|
|
1120
|
+
entry,
|
|
1121
|
+
request,
|
|
1122
|
+
sourceRevision,
|
|
1123
|
+
sourceContext,
|
|
1124
|
+
) => {
|
|
915
1125
|
if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
|
|
916
|
-
return
|
|
1126
|
+
return 'fresh';
|
|
1127
|
+
}
|
|
1128
|
+
if (request.fingerprint == null) {
|
|
1129
|
+
return entry.sourceRevision === sourceRevision ? 'fresh' : 'stale';
|
|
917
1130
|
}
|
|
918
1131
|
// Source hashes cannot prove that viewport, environment, or interaction
|
|
919
1132
|
// inputs stayed the same. They may bridge a changed source fingerprint only
|
|
@@ -924,30 +1137,45 @@ export function createPreviewArtifactStore({
|
|
|
924
1137
|
entry.recipeFingerprint == null ||
|
|
925
1138
|
entry.recipeFingerprint !== request.recipeFingerprint)
|
|
926
1139
|
) {
|
|
927
|
-
return
|
|
1140
|
+
return 'stale';
|
|
928
1141
|
}
|
|
929
1142
|
const observed = normalizeObservedDependencies(entry.sourceFiles);
|
|
930
1143
|
if (observed) {
|
|
931
|
-
|
|
1144
|
+
const exactSourceRoot = sourceRootForRevision(
|
|
1145
|
+
sourceContext,
|
|
1146
|
+
sourceRevision,
|
|
1147
|
+
);
|
|
1148
|
+
if (!exactSourceRoot) return 'source-pending';
|
|
1149
|
+
return (await observedDependenciesUnchanged({
|
|
932
1150
|
recorded: observed,
|
|
933
|
-
sourceRoot:
|
|
934
|
-
})
|
|
1151
|
+
sourceRoot: exactSourceRoot,
|
|
1152
|
+
}))
|
|
1153
|
+
? 'fresh'
|
|
1154
|
+
: 'stale';
|
|
935
1155
|
}
|
|
936
1156
|
// Nothing content-based to go on: fall back to the old rule so a frame is
|
|
937
1157
|
// never treated as fresher than it was before.
|
|
938
1158
|
return (
|
|
939
1159
|
entry.sourceRevision === sourceRevision &&
|
|
940
|
-
|
|
941
|
-
)
|
|
1160
|
+
entry.fingerprint === request.fingerprint
|
|
1161
|
+
)
|
|
1162
|
+
? 'fresh'
|
|
1163
|
+
: 'stale';
|
|
942
1164
|
};
|
|
943
1165
|
|
|
944
1166
|
/** Resolves freshness from the compact manifest without loading frame payloads. */
|
|
945
|
-
const resolveFrameSelection = async (
|
|
1167
|
+
const resolveFrameSelection = async (
|
|
1168
|
+
namespace,
|
|
1169
|
+
sourceRevision,
|
|
1170
|
+
wanted,
|
|
1171
|
+
{ sourceContext } = {},
|
|
1172
|
+
) => {
|
|
946
1173
|
const manifest = await readManifest(namespace);
|
|
947
1174
|
const candidatesById = indexFrameCandidates(manifest, wanted);
|
|
948
1175
|
const exact = [];
|
|
949
1176
|
const missing = [];
|
|
950
1177
|
const stale = [];
|
|
1178
|
+
const sourcePending = [];
|
|
951
1179
|
for (const request of wanted) {
|
|
952
1180
|
const candidates = candidatesById.get(request.id) ?? [];
|
|
953
1181
|
if (!candidates.length) {
|
|
@@ -955,20 +1183,41 @@ export function createPreviewArtifactStore({
|
|
|
955
1183
|
continue;
|
|
956
1184
|
}
|
|
957
1185
|
let current = false;
|
|
1186
|
+
let mayBecomeCurrent = false;
|
|
958
1187
|
for (const entry of candidates) {
|
|
959
|
-
|
|
1188
|
+
const freshness = await frameFreshness(
|
|
1189
|
+
entry,
|
|
1190
|
+
request,
|
|
1191
|
+
sourceRevision,
|
|
1192
|
+
sourceContext,
|
|
1193
|
+
);
|
|
1194
|
+
if (freshness === 'fresh') {
|
|
960
1195
|
current = true;
|
|
961
1196
|
break;
|
|
962
1197
|
}
|
|
1198
|
+
if (freshness === 'source-pending') mayBecomeCurrent = true;
|
|
963
1199
|
}
|
|
1200
|
+
if (!current && mayBecomeCurrent) sourcePending.push(request.id);
|
|
964
1201
|
(current ? exact : stale).push(request.id);
|
|
965
1202
|
}
|
|
966
|
-
return {
|
|
1203
|
+
return {
|
|
1204
|
+
exact,
|
|
1205
|
+
missing,
|
|
1206
|
+
stale,
|
|
1207
|
+
...(sourcePending.length ? { sourcePending } : {}),
|
|
1208
|
+
};
|
|
967
1209
|
};
|
|
968
1210
|
|
|
969
|
-
const readFrameSelection = async (
|
|
1211
|
+
const readFrameSelection = async (
|
|
1212
|
+
namespace,
|
|
1213
|
+
sourceRevision,
|
|
1214
|
+
wanted,
|
|
1215
|
+
{ sourceContext } = {},
|
|
1216
|
+
) => {
|
|
970
1217
|
const manifest = await readManifest(namespace);
|
|
971
1218
|
let aggregate = null;
|
|
1219
|
+
const contentFreshIds = new Set();
|
|
1220
|
+
const sourcePendingIds = new Set();
|
|
972
1221
|
if (manifest) {
|
|
973
1222
|
// Index once per read. The earlier path filtered the complete manifest for
|
|
974
1223
|
// every wanted frame, which made a storyboard probe O(wanted × entries).
|
|
@@ -977,7 +1226,16 @@ export function createPreviewArtifactStore({
|
|
|
977
1226
|
const candidates = candidatesById.get(request.id) ?? [];
|
|
978
1227
|
const fresh = [];
|
|
979
1228
|
for (const entry of candidates) {
|
|
980
|
-
|
|
1229
|
+
const freshness = await frameFreshness(
|
|
1230
|
+
entry,
|
|
1231
|
+
request,
|
|
1232
|
+
sourceRevision,
|
|
1233
|
+
sourceContext,
|
|
1234
|
+
);
|
|
1235
|
+
if (freshness === 'fresh') fresh.push(entry);
|
|
1236
|
+
if (freshness === 'source-pending') {
|
|
1237
|
+
sourcePendingIds.add(request.id);
|
|
1238
|
+
}
|
|
981
1239
|
}
|
|
982
1240
|
// Several revisions can now be fresh at once, so provenance decides
|
|
983
1241
|
// which one is served: the requested revision's own capture first, then
|
|
@@ -1016,16 +1274,30 @@ export function createPreviewArtifactStore({
|
|
|
1016
1274
|
aggregate,
|
|
1017
1275
|
withProvenance,
|
|
1018
1276
|
);
|
|
1277
|
+
if (exactSet.has(entry)) {
|
|
1278
|
+
contentFreshIds.add(request.id);
|
|
1279
|
+
sourcePendingIds.delete(request.id);
|
|
1280
|
+
}
|
|
1019
1281
|
break;
|
|
1020
1282
|
}
|
|
1021
1283
|
}
|
|
1022
1284
|
}
|
|
1023
1285
|
}
|
|
1024
|
-
|
|
1286
|
+
const selection = selectRoutePreviewArtifactFrames(
|
|
1025
1287
|
aggregate ?? emptyBundle(namespace, sourceRevision),
|
|
1026
1288
|
wanted,
|
|
1027
1289
|
{ includeStale: true, sourceRevision },
|
|
1028
1290
|
);
|
|
1291
|
+
return {
|
|
1292
|
+
...selection,
|
|
1293
|
+
// The wire-level selector only knows fingerprints. A frame whose recipe
|
|
1294
|
+
// and observed files were verified against the requested source context is
|
|
1295
|
+
// also current, even when its coarse source fingerprint changed.
|
|
1296
|
+
stale: selection.stale.filter((id) => !contentFreshIds.has(id)),
|
|
1297
|
+
...(sourcePendingIds.size
|
|
1298
|
+
? { sourcePending: [...sourcePendingIds] }
|
|
1299
|
+
: {}),
|
|
1300
|
+
};
|
|
1029
1301
|
};
|
|
1030
1302
|
|
|
1031
1303
|
/**
|
|
@@ -1035,7 +1307,7 @@ export function createPreviewArtifactStore({
|
|
|
1035
1307
|
* provenance-only" is the split that explains both the size and the next
|
|
1036
1308
|
* eviction — so it is reported rather than left to be inferred.
|
|
1037
1309
|
*/
|
|
1038
|
-
const inspect = async (namespace) => {
|
|
1310
|
+
const inspect = async (namespace, { sourceContext } = {}) => {
|
|
1039
1311
|
const manifest = await readManifest(namespace);
|
|
1040
1312
|
if (!manifest) {
|
|
1041
1313
|
return {
|
|
@@ -1057,7 +1329,7 @@ export function createPreviewArtifactStore({
|
|
|
1057
1329
|
}
|
|
1058
1330
|
const counts = { valid: 0, unknown: 0, invalid: 0 };
|
|
1059
1331
|
for (const entry of manifest.frames) {
|
|
1060
|
-
counts[await classifyFrameEntry(entry)] += 1;
|
|
1332
|
+
counts[await classifyFrameEntry(entry, sourceContext)] += 1;
|
|
1061
1333
|
}
|
|
1062
1334
|
return {
|
|
1063
1335
|
namespace,
|
|
@@ -1082,7 +1354,7 @@ export function createPreviewArtifactStore({
|
|
|
1082
1354
|
* merely being read would stay over budget until the next capture. Callers
|
|
1083
1355
|
* run this when the server starts.
|
|
1084
1356
|
*/
|
|
1085
|
-
const applyRetention = async (namespace) => {
|
|
1357
|
+
const applyRetention = async (namespace, { sourceContext } = {}) => {
|
|
1086
1358
|
// No namespace means every namespace: at server start the caller has not
|
|
1087
1359
|
// seen a request yet and so cannot name one.
|
|
1088
1360
|
if (namespace === undefined) {
|
|
@@ -1099,7 +1371,9 @@ export function createPreviewArtifactStore({
|
|
|
1099
1371
|
() => null,
|
|
1100
1372
|
);
|
|
1101
1373
|
if (typeof stored?.namespace !== 'string') continue;
|
|
1102
|
-
const result = await applyRetention(stored.namespace
|
|
1374
|
+
const result = await applyRetention(stored.namespace, {
|
|
1375
|
+
sourceContext,
|
|
1376
|
+
}).catch(() => null);
|
|
1103
1377
|
if (result) dropped.push({ namespace: stored.namespace, ...result });
|
|
1104
1378
|
}
|
|
1105
1379
|
return dropped;
|
|
@@ -1110,7 +1384,7 @@ export function createPreviewArtifactStore({
|
|
|
1110
1384
|
const stored = await readManifest(namespace);
|
|
1111
1385
|
if (!stored) return null;
|
|
1112
1386
|
const at = now();
|
|
1113
|
-
const trimmed = await evictToBudget(stored, { at });
|
|
1387
|
+
const trimmed = await evictToBudget(stored, { at, sourceContext });
|
|
1114
1388
|
let manifest = trimmed.dropped
|
|
1115
1389
|
? { ...trimmed.manifest, lastEviction: trimmed.dropped }
|
|
1116
1390
|
: trimmed.manifest;
|
|
@@ -1123,7 +1397,13 @@ export function createPreviewArtifactStore({
|
|
|
1123
1397
|
}
|
|
1124
1398
|
return trimmed.dropped;
|
|
1125
1399
|
},
|
|
1126
|
-
{
|
|
1400
|
+
{
|
|
1401
|
+
lockTimeoutMs,
|
|
1402
|
+
lockStaleMs,
|
|
1403
|
+
onLockAcquired,
|
|
1404
|
+
onLockOwnerReady,
|
|
1405
|
+
onStaleLockConfirmed,
|
|
1406
|
+
},
|
|
1127
1407
|
);
|
|
1128
1408
|
};
|
|
1129
1409
|
|
|
@@ -1131,6 +1411,7 @@ export function createPreviewArtifactStore({
|
|
|
1131
1411
|
artifactFile: legacyFile,
|
|
1132
1412
|
storeDirectory: root,
|
|
1133
1413
|
importLegacyArtifact,
|
|
1414
|
+
observeArtifactDependencies,
|
|
1134
1415
|
publishArtifact,
|
|
1135
1416
|
readExactArtifact,
|
|
1136
1417
|
readFrameSelection,
|