@pygmalionjs/pygmalion 0.6.32 → 0.6.34
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-BwkyYvUV.js → FrozenRoutePreview-BbF5Ov5K.js} +2901 -2740
- package/dist-lib/pygmalion.js +11165 -9856
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/editor/captureSupply.d.ts +2 -0
- 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/projectBootGate.d.ts +15 -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/routePreviewStatus.d.ts +2 -1
- package/dist-lib/types/editor/store.d.ts +36 -37
- package/dist-lib/types/editor/tokens.d.ts +9 -0
- package/dist-lib/types/lib.d.ts +12 -3
- package/node/dev-mirror.mjs +567 -112
- package/node/preview-artifact-plugin.mjs +527 -68
- package/node/preview-artifact-store.mjs +370 -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,10 @@ 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 (
|
|
599
|
+
const evictToBudget = async (
|
|
600
|
+
manifest,
|
|
601
|
+
{ at, sourceContext, admittedFrameObjects = new Set() } = {},
|
|
602
|
+
) => {
|
|
481
603
|
const referenced = new Map();
|
|
482
604
|
const remember = async (hash) => {
|
|
483
605
|
if (!referenced.has(hash)) referenced.set(hash, await objectByteSize(hash));
|
|
@@ -508,7 +630,13 @@ export function createPreviewArtifactStore({
|
|
|
508
630
|
candidates.push({
|
|
509
631
|
kind: 'frame',
|
|
510
632
|
entry,
|
|
511
|
-
|
|
633
|
+
// A caller that supplied normalized observations recorded from the
|
|
634
|
+
// artifact's own snapshot has already paid to produce this object.
|
|
635
|
+
// Protect that admission from older unknown entries without rereading
|
|
636
|
+
// a source root that may not be physically pinned.
|
|
637
|
+
rank: admittedFrameObjects.has(entry.object)
|
|
638
|
+
? EVICTION_RANK.valid
|
|
639
|
+
: EVICTION_RANK[await classifyFrameEntry(entry, sourceContext)],
|
|
512
640
|
// Protection within a rank, never across it: the last copy of a frame
|
|
513
641
|
// is worth more than a surplus copy, but an invalid sole entry is still
|
|
514
642
|
// garbage and goes before a valid one.
|
|
@@ -632,6 +760,40 @@ export function createPreviewArtifactStore({
|
|
|
632
760
|
};
|
|
633
761
|
};
|
|
634
762
|
|
|
763
|
+
/**
|
|
764
|
+
* Records every frame's observed source set before publication can wait on the
|
|
765
|
+
* namespace lock. A Map entry is present even when observation is unavailable,
|
|
766
|
+
* so `null` is an explicit pin rather than permission to inspect another root.
|
|
767
|
+
*/
|
|
768
|
+
const observeArtifactDependencies = async (artifact, explicitSourceRoot) => {
|
|
769
|
+
const validation = validateRoutePreviewArtifactBundle(artifact);
|
|
770
|
+
if (!validation.valid) {
|
|
771
|
+
throw new Error('Preview artifact store refused an invalid artifact.');
|
|
772
|
+
}
|
|
773
|
+
const observedByFrame = new Map();
|
|
774
|
+
if (artifact.version !== 3) return observedByFrame;
|
|
775
|
+
const pinnedSourceRoot =
|
|
776
|
+
typeof explicitSourceRoot === 'string' && explicitSourceRoot
|
|
777
|
+
? path.resolve(explicitSourceRoot)
|
|
778
|
+
: undefined;
|
|
779
|
+
for (const [id, frame] of Object.entries(artifact.frames)) {
|
|
780
|
+
const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
|
|
781
|
+
// Artifact frames are an open record in the v3 wire format, so an
|
|
782
|
+
// unvalidated `sourceFiles` property supplied by a generator cannot be a
|
|
783
|
+
// freshness proof. Recompute it from the serialized snapshot and the
|
|
784
|
+
// explicitly pinned root every time.
|
|
785
|
+
const observed = await recordObservedDependencies({
|
|
786
|
+
snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
|
|
787
|
+
sourceRoot: pinnedSourceRoot,
|
|
788
|
+
...(observedAlwaysInclude
|
|
789
|
+
? { alwaysInclude: observedAlwaysInclude }
|
|
790
|
+
: {}),
|
|
791
|
+
});
|
|
792
|
+
observedByFrame.set(id, observed);
|
|
793
|
+
}
|
|
794
|
+
return observedByFrame;
|
|
795
|
+
};
|
|
796
|
+
|
|
635
797
|
const publishArtifact = async (
|
|
636
798
|
artifact,
|
|
637
799
|
{
|
|
@@ -639,6 +801,8 @@ export function createPreviewArtifactStore({
|
|
|
639
801
|
sourceRevision = artifact?.sourceRevision,
|
|
640
802
|
materializeLegacy = true,
|
|
641
803
|
frameRequests = [],
|
|
804
|
+
observedDependenciesByFrame,
|
|
805
|
+
sourceContext,
|
|
642
806
|
} = {},
|
|
643
807
|
) => {
|
|
644
808
|
const validation = validateRoutePreviewArtifactBundle(artifact);
|
|
@@ -652,7 +816,51 @@ export function createPreviewArtifactStore({
|
|
|
652
816
|
) {
|
|
653
817
|
throw new Error('Preview artifact store refused a mismatched revision.');
|
|
654
818
|
}
|
|
819
|
+
if (
|
|
820
|
+
observedDependenciesByFrame != null &&
|
|
821
|
+
!(observedDependenciesByFrame instanceof Map)
|
|
822
|
+
) {
|
|
823
|
+
throw new TypeError(
|
|
824
|
+
'Preview artifact observed dependencies must be provided as a Map.',
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
let pinnedObservedDependenciesByFrame;
|
|
828
|
+
if (observedDependenciesByFrame instanceof Map && artifact.version === 3) {
|
|
829
|
+
const frameIds = new Set(Object.keys(artifact.frames));
|
|
830
|
+
if (
|
|
831
|
+
observedDependenciesByFrame.size !== frameIds.size ||
|
|
832
|
+
[...frameIds].some((id) => !observedDependenciesByFrame.has(id)) ||
|
|
833
|
+
[...observedDependenciesByFrame.keys()].some((id) => !frameIds.has(id))
|
|
834
|
+
) {
|
|
835
|
+
throw new TypeError(
|
|
836
|
+
'Preview artifact observed dependencies must cover every frame exactly.',
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
pinnedObservedDependenciesByFrame = new Map();
|
|
840
|
+
for (const observed of observedDependenciesByFrame.values()) {
|
|
841
|
+
if (observed !== null && normalizeObservedDependencies(observed) == null) {
|
|
842
|
+
throw new TypeError(
|
|
843
|
+
'Preview artifact observed dependencies contain an invalid entry.',
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
for (const id of frameIds) {
|
|
848
|
+
const normalized = normalizeObservedDependencies(
|
|
849
|
+
observedDependenciesByFrame.get(id),
|
|
850
|
+
);
|
|
851
|
+
pinnedObservedDependenciesByFrame.set(
|
|
852
|
+
id,
|
|
853
|
+
normalized?.map(([file, hash]) => [file, hash]) ?? null,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
655
857
|
const namespace = artifact.namespace;
|
|
858
|
+
const exactSourceContext = sourceRootForRevision(
|
|
859
|
+
sourceContext,
|
|
860
|
+
sourceRevision,
|
|
861
|
+
)
|
|
862
|
+
? sourceContext
|
|
863
|
+
: undefined;
|
|
656
864
|
return withFilesystemLock(
|
|
657
865
|
lockPath(namespace),
|
|
658
866
|
async () => {
|
|
@@ -673,6 +881,7 @@ export function createPreviewArtifactStore({
|
|
|
673
881
|
...(stored?.frames ?? []),
|
|
674
882
|
].map((entry) => entry.object),
|
|
675
883
|
);
|
|
884
|
+
const admittedFrameObjects = new Set();
|
|
676
885
|
if (recordRevision && sourceRevision) {
|
|
677
886
|
const wholeObject = await writeObject(artifact);
|
|
678
887
|
const nextRevision = {
|
|
@@ -701,16 +910,13 @@ export function createPreviewArtifactStore({
|
|
|
701
910
|
const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
|
|
702
911
|
const object = await writeObject(selected.bundle);
|
|
703
912
|
// 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
|
-
}));
|
|
913
|
+
// the artifact it travels with. Null keeps the entry on the
|
|
914
|
+
// revision/fingerprint-only fallback.
|
|
915
|
+
const observed = pinnedObservedDependenciesByFrame
|
|
916
|
+
? normalizeObservedDependencies(
|
|
917
|
+
pinnedObservedDependenciesByFrame.get(id),
|
|
918
|
+
)
|
|
919
|
+
: null;
|
|
714
920
|
const nextFrame = {
|
|
715
921
|
id,
|
|
716
922
|
fingerprint:
|
|
@@ -726,6 +932,7 @@ export function createPreviewArtifactStore({
|
|
|
726
932
|
generation,
|
|
727
933
|
...(observed ? { sourceFiles: observed } : {}),
|
|
728
934
|
};
|
|
935
|
+
if (observed) admittedFrameObjects.add(object);
|
|
729
936
|
const alreadyRecorded = frames.some(
|
|
730
937
|
(entry) =>
|
|
731
938
|
entry.id === nextFrame.id &&
|
|
@@ -742,7 +949,11 @@ export function createPreviewArtifactStore({
|
|
|
742
949
|
// reclaim whatever has been unreferenced long enough to be safe. No
|
|
743
950
|
// human is asked when the store should be cleaned.
|
|
744
951
|
const at = now();
|
|
745
|
-
const trimmed = await evictToBudget(manifest, {
|
|
952
|
+
const trimmed = await evictToBudget(manifest, {
|
|
953
|
+
at,
|
|
954
|
+
sourceContext: exactSourceContext,
|
|
955
|
+
admittedFrameObjects,
|
|
956
|
+
});
|
|
746
957
|
manifest = trimmed.dropped
|
|
747
958
|
? { ...trimmed.manifest, lastEviction: trimmed.dropped }
|
|
748
959
|
: trimmed.manifest;
|
|
@@ -808,7 +1019,13 @@ export function createPreviewArtifactStore({
|
|
|
808
1019
|
}
|
|
809
1020
|
return artifact;
|
|
810
1021
|
},
|
|
811
|
-
{
|
|
1022
|
+
{
|
|
1023
|
+
lockTimeoutMs,
|
|
1024
|
+
lockStaleMs,
|
|
1025
|
+
onLockAcquired,
|
|
1026
|
+
onLockOwnerReady,
|
|
1027
|
+
onStaleLockConfirmed,
|
|
1028
|
+
},
|
|
812
1029
|
);
|
|
813
1030
|
};
|
|
814
1031
|
|
|
@@ -911,9 +1128,17 @@ export function createPreviewArtifactStore({
|
|
|
911
1128
|
* it survives an edit to a file the frame never rendered, which a route-wide
|
|
912
1129
|
* digest cannot express in a single-page application.
|
|
913
1130
|
*/
|
|
914
|
-
const
|
|
1131
|
+
const frameFreshness = async (
|
|
1132
|
+
entry,
|
|
1133
|
+
request,
|
|
1134
|
+
sourceRevision,
|
|
1135
|
+
sourceContext,
|
|
1136
|
+
) => {
|
|
915
1137
|
if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
|
|
916
|
-
return
|
|
1138
|
+
return 'fresh';
|
|
1139
|
+
}
|
|
1140
|
+
if (request.fingerprint == null) {
|
|
1141
|
+
return entry.sourceRevision === sourceRevision ? 'fresh' : 'stale';
|
|
917
1142
|
}
|
|
918
1143
|
// Source hashes cannot prove that viewport, environment, or interaction
|
|
919
1144
|
// inputs stayed the same. They may bridge a changed source fingerprint only
|
|
@@ -924,30 +1149,45 @@ export function createPreviewArtifactStore({
|
|
|
924
1149
|
entry.recipeFingerprint == null ||
|
|
925
1150
|
entry.recipeFingerprint !== request.recipeFingerprint)
|
|
926
1151
|
) {
|
|
927
|
-
return
|
|
1152
|
+
return 'stale';
|
|
928
1153
|
}
|
|
929
1154
|
const observed = normalizeObservedDependencies(entry.sourceFiles);
|
|
930
1155
|
if (observed) {
|
|
931
|
-
|
|
1156
|
+
const exactSourceRoot = sourceRootForRevision(
|
|
1157
|
+
sourceContext,
|
|
1158
|
+
sourceRevision,
|
|
1159
|
+
);
|
|
1160
|
+
if (!exactSourceRoot) return 'source-pending';
|
|
1161
|
+
return (await observedDependenciesUnchanged({
|
|
932
1162
|
recorded: observed,
|
|
933
|
-
sourceRoot:
|
|
934
|
-
})
|
|
1163
|
+
sourceRoot: exactSourceRoot,
|
|
1164
|
+
}))
|
|
1165
|
+
? 'fresh'
|
|
1166
|
+
: 'stale';
|
|
935
1167
|
}
|
|
936
1168
|
// Nothing content-based to go on: fall back to the old rule so a frame is
|
|
937
1169
|
// never treated as fresher than it was before.
|
|
938
1170
|
return (
|
|
939
1171
|
entry.sourceRevision === sourceRevision &&
|
|
940
|
-
|
|
941
|
-
)
|
|
1172
|
+
entry.fingerprint === request.fingerprint
|
|
1173
|
+
)
|
|
1174
|
+
? 'fresh'
|
|
1175
|
+
: 'stale';
|
|
942
1176
|
};
|
|
943
1177
|
|
|
944
1178
|
/** Resolves freshness from the compact manifest without loading frame payloads. */
|
|
945
|
-
const resolveFrameSelection = async (
|
|
1179
|
+
const resolveFrameSelection = async (
|
|
1180
|
+
namespace,
|
|
1181
|
+
sourceRevision,
|
|
1182
|
+
wanted,
|
|
1183
|
+
{ sourceContext } = {},
|
|
1184
|
+
) => {
|
|
946
1185
|
const manifest = await readManifest(namespace);
|
|
947
1186
|
const candidatesById = indexFrameCandidates(manifest, wanted);
|
|
948
1187
|
const exact = [];
|
|
949
1188
|
const missing = [];
|
|
950
1189
|
const stale = [];
|
|
1190
|
+
const sourcePending = [];
|
|
951
1191
|
for (const request of wanted) {
|
|
952
1192
|
const candidates = candidatesById.get(request.id) ?? [];
|
|
953
1193
|
if (!candidates.length) {
|
|
@@ -955,20 +1195,41 @@ export function createPreviewArtifactStore({
|
|
|
955
1195
|
continue;
|
|
956
1196
|
}
|
|
957
1197
|
let current = false;
|
|
1198
|
+
let mayBecomeCurrent = false;
|
|
958
1199
|
for (const entry of candidates) {
|
|
959
|
-
|
|
1200
|
+
const freshness = await frameFreshness(
|
|
1201
|
+
entry,
|
|
1202
|
+
request,
|
|
1203
|
+
sourceRevision,
|
|
1204
|
+
sourceContext,
|
|
1205
|
+
);
|
|
1206
|
+
if (freshness === 'fresh') {
|
|
960
1207
|
current = true;
|
|
961
1208
|
break;
|
|
962
1209
|
}
|
|
1210
|
+
if (freshness === 'source-pending') mayBecomeCurrent = true;
|
|
963
1211
|
}
|
|
1212
|
+
if (!current && mayBecomeCurrent) sourcePending.push(request.id);
|
|
964
1213
|
(current ? exact : stale).push(request.id);
|
|
965
1214
|
}
|
|
966
|
-
return {
|
|
1215
|
+
return {
|
|
1216
|
+
exact,
|
|
1217
|
+
missing,
|
|
1218
|
+
stale,
|
|
1219
|
+
...(sourcePending.length ? { sourcePending } : {}),
|
|
1220
|
+
};
|
|
967
1221
|
};
|
|
968
1222
|
|
|
969
|
-
const readFrameSelection = async (
|
|
1223
|
+
const readFrameSelection = async (
|
|
1224
|
+
namespace,
|
|
1225
|
+
sourceRevision,
|
|
1226
|
+
wanted,
|
|
1227
|
+
{ sourceContext } = {},
|
|
1228
|
+
) => {
|
|
970
1229
|
const manifest = await readManifest(namespace);
|
|
971
1230
|
let aggregate = null;
|
|
1231
|
+
const contentFreshIds = new Set();
|
|
1232
|
+
const sourcePendingIds = new Set();
|
|
972
1233
|
if (manifest) {
|
|
973
1234
|
// Index once per read. The earlier path filtered the complete manifest for
|
|
974
1235
|
// every wanted frame, which made a storyboard probe O(wanted × entries).
|
|
@@ -977,7 +1238,16 @@ export function createPreviewArtifactStore({
|
|
|
977
1238
|
const candidates = candidatesById.get(request.id) ?? [];
|
|
978
1239
|
const fresh = [];
|
|
979
1240
|
for (const entry of candidates) {
|
|
980
|
-
|
|
1241
|
+
const freshness = await frameFreshness(
|
|
1242
|
+
entry,
|
|
1243
|
+
request,
|
|
1244
|
+
sourceRevision,
|
|
1245
|
+
sourceContext,
|
|
1246
|
+
);
|
|
1247
|
+
if (freshness === 'fresh') fresh.push(entry);
|
|
1248
|
+
if (freshness === 'source-pending') {
|
|
1249
|
+
sourcePendingIds.add(request.id);
|
|
1250
|
+
}
|
|
981
1251
|
}
|
|
982
1252
|
// Several revisions can now be fresh at once, so provenance decides
|
|
983
1253
|
// which one is served: the requested revision's own capture first, then
|
|
@@ -1016,16 +1286,30 @@ export function createPreviewArtifactStore({
|
|
|
1016
1286
|
aggregate,
|
|
1017
1287
|
withProvenance,
|
|
1018
1288
|
);
|
|
1289
|
+
if (exactSet.has(entry)) {
|
|
1290
|
+
contentFreshIds.add(request.id);
|
|
1291
|
+
sourcePendingIds.delete(request.id);
|
|
1292
|
+
}
|
|
1019
1293
|
break;
|
|
1020
1294
|
}
|
|
1021
1295
|
}
|
|
1022
1296
|
}
|
|
1023
1297
|
}
|
|
1024
|
-
|
|
1298
|
+
const selection = selectRoutePreviewArtifactFrames(
|
|
1025
1299
|
aggregate ?? emptyBundle(namespace, sourceRevision),
|
|
1026
1300
|
wanted,
|
|
1027
1301
|
{ includeStale: true, sourceRevision },
|
|
1028
1302
|
);
|
|
1303
|
+
return {
|
|
1304
|
+
...selection,
|
|
1305
|
+
// The wire-level selector only knows fingerprints. A frame whose recipe
|
|
1306
|
+
// and observed files were verified against the requested source context is
|
|
1307
|
+
// also current, even when its coarse source fingerprint changed.
|
|
1308
|
+
stale: selection.stale.filter((id) => !contentFreshIds.has(id)),
|
|
1309
|
+
...(sourcePendingIds.size
|
|
1310
|
+
? { sourcePending: [...sourcePendingIds] }
|
|
1311
|
+
: {}),
|
|
1312
|
+
};
|
|
1029
1313
|
};
|
|
1030
1314
|
|
|
1031
1315
|
/**
|
|
@@ -1035,7 +1319,7 @@ export function createPreviewArtifactStore({
|
|
|
1035
1319
|
* provenance-only" is the split that explains both the size and the next
|
|
1036
1320
|
* eviction — so it is reported rather than left to be inferred.
|
|
1037
1321
|
*/
|
|
1038
|
-
const inspect = async (namespace) => {
|
|
1322
|
+
const inspect = async (namespace, { sourceContext } = {}) => {
|
|
1039
1323
|
const manifest = await readManifest(namespace);
|
|
1040
1324
|
if (!manifest) {
|
|
1041
1325
|
return {
|
|
@@ -1057,7 +1341,7 @@ export function createPreviewArtifactStore({
|
|
|
1057
1341
|
}
|
|
1058
1342
|
const counts = { valid: 0, unknown: 0, invalid: 0 };
|
|
1059
1343
|
for (const entry of manifest.frames) {
|
|
1060
|
-
counts[await classifyFrameEntry(entry)] += 1;
|
|
1344
|
+
counts[await classifyFrameEntry(entry, sourceContext)] += 1;
|
|
1061
1345
|
}
|
|
1062
1346
|
return {
|
|
1063
1347
|
namespace,
|
|
@@ -1082,7 +1366,7 @@ export function createPreviewArtifactStore({
|
|
|
1082
1366
|
* merely being read would stay over budget until the next capture. Callers
|
|
1083
1367
|
* run this when the server starts.
|
|
1084
1368
|
*/
|
|
1085
|
-
const applyRetention = async (namespace) => {
|
|
1369
|
+
const applyRetention = async (namespace, { sourceContext } = {}) => {
|
|
1086
1370
|
// No namespace means every namespace: at server start the caller has not
|
|
1087
1371
|
// seen a request yet and so cannot name one.
|
|
1088
1372
|
if (namespace === undefined) {
|
|
@@ -1099,7 +1383,9 @@ export function createPreviewArtifactStore({
|
|
|
1099
1383
|
() => null,
|
|
1100
1384
|
);
|
|
1101
1385
|
if (typeof stored?.namespace !== 'string') continue;
|
|
1102
|
-
const result = await applyRetention(stored.namespace
|
|
1386
|
+
const result = await applyRetention(stored.namespace, {
|
|
1387
|
+
sourceContext,
|
|
1388
|
+
}).catch(() => null);
|
|
1103
1389
|
if (result) dropped.push({ namespace: stored.namespace, ...result });
|
|
1104
1390
|
}
|
|
1105
1391
|
return dropped;
|
|
@@ -1110,7 +1396,7 @@ export function createPreviewArtifactStore({
|
|
|
1110
1396
|
const stored = await readManifest(namespace);
|
|
1111
1397
|
if (!stored) return null;
|
|
1112
1398
|
const at = now();
|
|
1113
|
-
const trimmed = await evictToBudget(stored, { at });
|
|
1399
|
+
const trimmed = await evictToBudget(stored, { at, sourceContext });
|
|
1114
1400
|
let manifest = trimmed.dropped
|
|
1115
1401
|
? { ...trimmed.manifest, lastEviction: trimmed.dropped }
|
|
1116
1402
|
: trimmed.manifest;
|
|
@@ -1123,7 +1409,13 @@ export function createPreviewArtifactStore({
|
|
|
1123
1409
|
}
|
|
1124
1410
|
return trimmed.dropped;
|
|
1125
1411
|
},
|
|
1126
|
-
{
|
|
1412
|
+
{
|
|
1413
|
+
lockTimeoutMs,
|
|
1414
|
+
lockStaleMs,
|
|
1415
|
+
onLockAcquired,
|
|
1416
|
+
onLockOwnerReady,
|
|
1417
|
+
onStaleLockConfirmed,
|
|
1418
|
+
},
|
|
1127
1419
|
);
|
|
1128
1420
|
};
|
|
1129
1421
|
|
|
@@ -1131,6 +1423,7 @@ export function createPreviewArtifactStore({
|
|
|
1131
1423
|
artifactFile: legacyFile,
|
|
1132
1424
|
storeDirectory: root,
|
|
1133
1425
|
importLegacyArtifact,
|
|
1426
|
+
observeArtifactDependencies,
|
|
1134
1427
|
publishArtifact,
|
|
1135
1428
|
readExactArtifact,
|
|
1136
1429
|
readFrameSelection,
|