@ttsc/metro 0.19.3 → 0.20.1

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.
@@ -33,6 +33,9 @@
33
33
  *
34
34
  * - No readable snapshot (first run, wiped cache dir, unwritable filesystem)
35
35
  * folds a random nonce: that run shares no cache entries with any other run.
36
+ * - A failed snapshot write persists the pending observation beside the snapshot
37
+ * directory. While any such recovery document exists, readers fold a nonce;
38
+ * successful compaction merges it and mints a fresh epoch.
36
39
  * - A recreated snapshot carries a fresh epoch id, so it can never alias a key
37
40
  * from an older epoch whose recorded set is unknown.
38
41
  * - A plugin-declared volatile output (non-file inputs; unrepresentable in any
@@ -56,12 +59,18 @@ const SNAPSHOT_VERSION = 1;
56
59
  /** Snapshot directory segments under the fingerprint base directory. */
57
60
  const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
58
61
 
62
+ /** Recovery-document prefix in the parent cache directory. */
63
+ const UNHEALTHY_SNAPSHOT_PREFIX = "ttsc-metro.unhealthy-";
64
+
59
65
  /** Main snapshot file name (epoch id + compacted recorded inputs). */
60
66
  const MAIN_SNAPSHOT = "graph-inputs.json";
61
67
 
62
68
  /** Worker snapshot file prefix; each worker appends a unique suffix. */
63
69
  const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
64
70
 
71
+ /** Prefix used after a compactor atomically claims an immutable worker file. */
72
+ const CLAIMED_WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-claimed-";
73
+
65
74
  /** Union of the snapshot state readable on disk. */
66
75
  interface SnapshotState {
67
76
  /** Random epoch id minted when the main snapshot was created. */
@@ -80,6 +89,17 @@ interface SnapshotDocument {
80
89
  volatile: boolean;
81
90
  }
82
91
 
92
+ /** Snapshot documents discovered during one directory scan. */
93
+ interface SnapshotDocuments {
94
+ corruptPaths: string[];
95
+ entries: SnapshotDocument[];
96
+ paths: string[];
97
+ readable: boolean;
98
+ }
99
+
100
+ /** Bases whose latest observation is not yet durable in the main snapshot. */
101
+ const unhealthySnapshots = new Set<string>();
102
+
83
103
  /**
84
104
  * Resolve the base directory both fingerprint sides agree on: Metro's
85
105
  * `projectRoot` when known (`withTtsc` reads it from the config, `getCacheKey`
@@ -193,12 +213,20 @@ function nonce(): string {
193
213
  * unparseable worker file's recordings are unrecoverable, so its removal mints
194
214
  * a fresh epoch id — every key that might have depended on the lost recordings
195
215
  * is soundly orphaned, and later runs stabilize instead of degrading to a nonce
196
- * forever. Never throws an unwritable cache directory leaves the snapshot
197
- * unreadable and `getCacheKey` degrades to a nonce.
216
+ * forever. A failed rewrite leaves a recovery document outside the snapshot
217
+ * directory so `getCacheKey` degrades to a nonce until a later compaction
218
+ * succeeds. If an older readable main exists and neither location is writable,
219
+ * preparation throws instead of authorizing stale reuse.
198
220
  */
199
221
  export function prepareSnapshot(projectRoot: string | undefined): void {
222
+ const base = resolveFingerprintBase(projectRoot);
223
+ let hadReadableMain = false;
224
+ let pending: SnapshotDocument = {
225
+ files: [],
226
+ version: SNAPSHOT_VERSION,
227
+ volatile: false,
228
+ };
200
229
  try {
201
- const base = resolveFingerprintBase(projectRoot);
202
230
  // A nonexistent base can never be a working Metro setup (Metro verifies
203
231
  // the project root exists), so preparing a snapshot there would only
204
232
  // materialize directory trees at arbitrary paths.
@@ -211,33 +239,47 @@ export function prepareSnapshot(projectRoot: string | undefined): void {
211
239
  // comment): a concurrent compactor deletes a worker file only after the
212
240
  // merged main is renamed into place, so whatever this enumeration misses
213
241
  // is already inside the main read below.
242
+ claimWorkerFiles(directory);
243
+ const recovery = readUnhealthySnapshots(base);
214
244
  const workers = readWorkerFiles(directory);
245
+ if (!recovery.readable || !workers.readable) {
246
+ throw new Error("Unable to enumerate Metro snapshot state.");
247
+ }
215
248
  const main = readMainDocument(directory);
249
+ hadReadableMain = main !== undefined && typeof main.id === "string";
216
250
  const files = new Set(main?.files ?? []);
251
+ const observations = [...recovery.entries, ...workers.entries];
217
252
  const volatile =
218
- workers.entries.length === 0
253
+ observations.length === 0
219
254
  ? (main?.volatile ?? false)
220
255
  : // Worker files carry the previous run's fresh observations, so they
221
256
  // own the volatile verdict: a removed volatile declaration must be
222
257
  // able to clear the sticky flag.
223
- workers.entries.some((entry) => entry.volatile);
224
- for (const entry of workers.entries) {
258
+ observations.some((entry) => entry.volatile);
259
+ for (const entry of observations) {
225
260
  for (const file of entry.files) {
226
261
  files.add(file);
227
262
  }
228
263
  }
229
- writeSnapshotDocument(path.join(directory, MAIN_SNAPSHOT), {
264
+ const recovering =
265
+ unhealthySnapshots.has(base) ||
266
+ recovery.paths.length !== 0 ||
267
+ recovery.corruptPaths.length !== 0;
268
+ pending = {
230
269
  files: [...files].sort(),
231
270
  id:
232
- workers.corruptPaths.length === 0
271
+ !recovering && workers.corruptPaths.length === 0
233
272
  ? (main?.id ?? randomBytes(16).toString("hex"))
234
273
  : randomBytes(16).toString("hex"),
235
274
  version: SNAPSHOT_VERSION,
236
275
  volatile,
237
- });
276
+ };
277
+ writeSnapshotDocument(path.join(directory, MAIN_SNAPSHOT), pending);
238
278
  for (const file of [
239
- ...workers.paths,
240
- ...workers.corruptPaths,
279
+ ...workers.paths.filter(isClaimedWorkerSnapshot),
280
+ ...workers.corruptPaths.filter(isClaimedWorkerSnapshot),
281
+ ...recovery.paths,
282
+ ...recovery.corruptPaths,
241
283
  ...listTemporaryFiles(directory),
242
284
  ]) {
243
285
  try {
@@ -247,8 +289,25 @@ export function prepareSnapshot(projectRoot: string | undefined): void {
247
289
  // lost, and the next compaction retries.
248
290
  }
249
291
  }
250
- } catch {
251
- // Snapshot maintenance must never break the Metro config process.
292
+ const remainingRecovery = readUnhealthySnapshots(base);
293
+ if (
294
+ remainingRecovery.readable &&
295
+ remainingRecovery.paths.length === 0 &&
296
+ remainingRecovery.corruptPaths.length === 0
297
+ ) {
298
+ unhealthySnapshots.delete(base);
299
+ }
300
+ } catch (snapshotError) {
301
+ try {
302
+ persistUnhealthySnapshot(base, pending);
303
+ } catch (recoveryError) {
304
+ if (hadReadableMain || hasReadableMainSnapshot(base)) {
305
+ throw new AggregateError(
306
+ [snapshotError, recoveryError],
307
+ "Unable to persist Metro snapshot state or its recovery record.",
308
+ );
309
+ }
310
+ }
252
311
  }
253
312
  }
254
313
 
@@ -283,10 +342,21 @@ function listTemporaryFiles(directory: string): string[] {
283
342
  * recorded set cannot be trusted, so the caller degrades to a nonce).
284
343
  */
285
344
  export function readSnapshotState(base: string): SnapshotState | undefined {
345
+ if (unhealthySnapshots.has(base)) {
346
+ return undefined;
347
+ }
348
+ const recovery = readUnhealthySnapshots(base);
349
+ if (
350
+ !recovery.readable ||
351
+ recovery.paths.length !== 0 ||
352
+ recovery.corruptPaths.length !== 0
353
+ ) {
354
+ return undefined;
355
+ }
286
356
  const directory = snapshotDirectory(base);
287
357
  // Worker files strictly before the main file — see the module doc comment.
288
358
  const workers = readWorkerFiles(directory);
289
- if (workers.corruptPaths.length !== 0) {
359
+ if (!workers.readable || workers.corruptPaths.length !== 0) {
290
360
  return undefined;
291
361
  }
292
362
  const main = readMainDocument(directory);
@@ -305,13 +375,14 @@ export function readSnapshotState(base: string): SnapshotState | undefined {
305
375
  }
306
376
 
307
377
  /**
308
- * Recorder held by each Metro worker. Collects the out-of-walk inputs derived
309
- * for every transformed file (the plugin-reported dependencies unioned with the
310
- * reference graph's reach, globals, and configs delivered through the
311
- * transform core's `addWatchFile` hook) plus any volatile declaration, and
312
- * persists them to this worker's uniquely named snapshot file whenever the
313
- * observed state grows. The unique name makes worker writes race-free;
314
- * `withTtsc` compacts the files on the next run.
378
+ * Recorder held by each Metro worker. It persists out-of-walk watch inputs and
379
+ * missing in-walk paths delivered through the transform core's `addWatchFile`
380
+ * hook, plus any volatile declaration. Existing in-walk files stay covered by
381
+ * the project walk; a missing path must be retained because its creation is a
382
+ * state change that the initial walk could not hash. A clean in-walk transform
383
+ * also writes a document so it can clear a volatile declaration from an earlier
384
+ * run. The unique name makes worker writes race-free; `withTtsc` compacts the
385
+ * files on the next run.
315
386
  */
316
387
  export function createSnapshotRecorder(): {
317
388
  record: (props: {
@@ -328,6 +399,7 @@ export function createSnapshotRecorder(): {
328
399
  interface BaseState {
329
400
  dirty: boolean;
330
401
  files: Set<string>;
402
+ observed: boolean;
331
403
  roots: string[];
332
404
  volatile: boolean;
333
405
  }
@@ -343,6 +415,7 @@ export function createSnapshotRecorder(): {
343
415
  state = {
344
416
  dirty: false,
345
417
  files: new Set(),
418
+ observed: false,
346
419
  roots: fingerprintRoots(base, explicitProject),
347
420
  volatile: false,
348
421
  };
@@ -355,23 +428,32 @@ export function createSnapshotRecorder(): {
355
428
  if (!state.dirty) {
356
429
  return;
357
430
  }
431
+ const document: SnapshotDocument = {
432
+ files: [...state.files].sort(),
433
+ version: SNAPSHOT_VERSION,
434
+ volatile: state.volatile,
435
+ };
358
436
  try {
359
437
  const directory = snapshotDirectory(base);
360
438
  fs.mkdirSync(directory, { recursive: true });
361
439
  writeSnapshotDocument(
362
440
  path.join(directory, `${WORKER_SNAPSHOT_PREFIX}${suffix}.json`),
363
- {
364
- files: [...state.files].sort(),
365
- version: SNAPSHOT_VERSION,
366
- volatile: state.volatile,
367
- },
441
+ document,
368
442
  );
369
443
  // Cleared only on success so a transient write failure retries on the
370
444
  // next recording instead of silently dropping the observed state.
371
445
  state.dirty = false;
372
- } catch {
373
- // An unwritable snapshot leaves the main snapshot unreadable or stale;
374
- // getCacheKey degrades to a nonce rather than serving stale output.
446
+ } catch (snapshotError) {
447
+ try {
448
+ persistUnhealthySnapshot(base, document);
449
+ } catch (recoveryError) {
450
+ if (hasReadableMainSnapshot(base)) {
451
+ throw new AggregateError(
452
+ [snapshotError, recoveryError],
453
+ "Unable to persist a Metro snapshot observation or its recovery record.",
454
+ );
455
+ }
456
+ }
375
457
  }
376
458
  }
377
459
 
@@ -380,10 +462,20 @@ export function createSnapshotRecorder(): {
380
462
  const base = resolveFingerprintBase(props.projectRoot);
381
463
  const state = stateFor(props.projectRoot, props.explicitProject);
382
464
  const input = path.resolve(props.input);
465
+ const firstObservation = !state.observed;
466
+ state.observed = true;
383
467
  if (
384
468
  state.files.has(input) ||
385
- state.roots.some((root) => isProjectWalkPath(root, input))
469
+ (fs.existsSync(input) &&
470
+ state.roots.some((root) => isProjectWalkPath(root, input)))
386
471
  ) {
472
+ // Even when every input belongs to the project walk, the worker must
473
+ // publish that it performed a clean transform. Otherwise an old main
474
+ // snapshot with `volatile: true` remains sticky forever.
475
+ if (firstObservation || state.dirty) {
476
+ state.dirty = true;
477
+ flush(base, state);
478
+ }
387
479
  return;
388
480
  }
389
481
  state.files.add(input);
@@ -394,6 +486,7 @@ export function createSnapshotRecorder(): {
394
486
  const base = resolveFingerprintBase(props.projectRoot);
395
487
  const state = stateFor(props.projectRoot, props.explicitProject);
396
488
  if (state.volatile) {
489
+ flush(base, state);
397
490
  return;
398
491
  }
399
492
  state.volatile = true;
@@ -407,6 +500,88 @@ function snapshotDirectory(base: string): string {
407
500
  return path.join(base, ...SNAPSHOT_DIRECTORY);
408
501
  }
409
502
 
503
+ function snapshotCacheDirectory(base: string): string {
504
+ return path.dirname(snapshotDirectory(base));
505
+ }
506
+
507
+ function hasReadableMainSnapshot(base: string): boolean {
508
+ const main = readMainDocument(snapshotDirectory(base));
509
+ return main !== undefined && typeof main.id === "string";
510
+ }
511
+
512
+ /**
513
+ * Persist a failed observation where a read-only snapshot directory cannot hide
514
+ * it.
515
+ */
516
+ function persistUnhealthySnapshot(
517
+ base: string,
518
+ document: SnapshotDocument,
519
+ ): void {
520
+ unhealthySnapshots.add(base);
521
+ const directory = snapshotCacheDirectory(base);
522
+ fs.mkdirSync(directory, { recursive: true });
523
+ writeSnapshotDocument(
524
+ path.join(
525
+ directory,
526
+ `${UNHEALTHY_SNAPSHOT_PREFIX}${process.pid.toString(36)}-${randomBytes(8).toString("hex")}.json`,
527
+ ),
528
+ document,
529
+ );
530
+ }
531
+
532
+ function readUnhealthySnapshots(base: string): SnapshotDocuments {
533
+ return readSnapshotFiles(
534
+ snapshotCacheDirectory(base),
535
+ UNHEALTHY_SNAPSHOT_PREFIX,
536
+ );
537
+ }
538
+
539
+ /**
540
+ * Move each live worker document to a unique immutable name before reading it.
541
+ * A concurrent worker publishes its next cumulative document at the original
542
+ * name, so deleting the claimed copy after the main write can never erase a
543
+ * newer observation. Claimed names still match the reader prefix, keeping the
544
+ * worker-before-main visibility invariant during compaction.
545
+ */
546
+ function claimWorkerFiles(directory: string): void {
547
+ let names: string[];
548
+ try {
549
+ names = fs.readdirSync(directory);
550
+ } catch (error) {
551
+ if (isMissingFileError(error)) {
552
+ return;
553
+ }
554
+ throw error;
555
+ }
556
+ const claim = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
557
+ for (const name of names) {
558
+ if (
559
+ !name.startsWith(WORKER_SNAPSHOT_PREFIX) ||
560
+ name.startsWith(CLAIMED_WORKER_SNAPSHOT_PREFIX) ||
561
+ !name.endsWith(".json")
562
+ ) {
563
+ continue;
564
+ }
565
+ try {
566
+ fs.renameSync(
567
+ path.join(directory, name),
568
+ path.join(
569
+ directory,
570
+ `${CLAIMED_WORKER_SNAPSHOT_PREFIX}${claim}-${name.slice(WORKER_SNAPSHOT_PREFIX.length)}`,
571
+ ),
572
+ );
573
+ } catch (error) {
574
+ if (!isMissingFileError(error)) {
575
+ throw error;
576
+ }
577
+ }
578
+ }
579
+ }
580
+
581
+ function isClaimedWorkerSnapshot(file: string): boolean {
582
+ return path.basename(file).startsWith(CLAIMED_WORKER_SNAPSHOT_PREFIX);
583
+ }
584
+
410
585
  /**
411
586
  * Read every worker snapshot file in `directory`. A file that disappears
412
587
  * mid-read was compacted (merged into the main snapshot first) and is skipped;
@@ -417,25 +592,41 @@ function readWorkerFiles(directory: string): {
417
592
  corruptPaths: string[];
418
593
  entries: SnapshotDocument[];
419
594
  paths: string[];
595
+ readable: boolean;
420
596
  } {
597
+ return readSnapshotFiles(directory, WORKER_SNAPSHOT_PREFIX);
598
+ }
599
+
600
+ function readSnapshotFiles(
601
+ directory: string,
602
+ prefix: string,
603
+ ): SnapshotDocuments {
421
604
  let names: string[];
422
605
  try {
423
606
  names = fs.readdirSync(directory);
424
- } catch {
425
- return { corruptPaths: [], entries: [], paths: [] };
607
+ } catch (error) {
608
+ return {
609
+ corruptPaths: [],
610
+ entries: [],
611
+ paths: [],
612
+ readable: isMissingFileError(error),
613
+ };
426
614
  }
427
615
  const entries: SnapshotDocument[] = [];
428
616
  const paths: string[] = [];
429
617
  const corruptPaths: string[] = [];
430
618
  for (const name of names) {
431
- if (!name.startsWith(WORKER_SNAPSHOT_PREFIX) || !name.endsWith(".json")) {
619
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) {
432
620
  continue;
433
621
  }
434
622
  const file = path.join(directory, name);
435
623
  let text: string;
436
624
  try {
437
625
  text = fs.readFileSync(file, "utf8");
438
- } catch {
626
+ } catch (error) {
627
+ if (!isMissingFileError(error)) {
628
+ corruptPaths.push(file);
629
+ }
439
630
  continue;
440
631
  }
441
632
  const parsed = parseSnapshotDocument(text);
@@ -446,7 +637,16 @@ function readWorkerFiles(directory: string): {
446
637
  entries.push(parsed);
447
638
  paths.push(file);
448
639
  }
449
- return { corruptPaths, entries, paths };
640
+ return { corruptPaths, entries, paths, readable: true };
641
+ }
642
+
643
+ function isMissingFileError(error: unknown): boolean {
644
+ return (
645
+ typeof error === "object" &&
646
+ error !== null &&
647
+ "code" in error &&
648
+ (error as { code?: unknown }).code === "ENOENT"
649
+ );
450
650
  }
451
651
 
452
652
  function readMainDocument(directory: string): SnapshotDocument | undefined {
@@ -486,8 +686,8 @@ function parseSnapshotDocument(text: string): SnapshotDocument | undefined {
486
686
  /** Write a snapshot document atomically (unique temp file, then rename). */
487
687
  function writeSnapshotDocument(file: string, document: SnapshotDocument): void {
488
688
  const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
489
- fs.writeFileSync(temp, JSON.stringify(document), "utf8");
490
689
  try {
690
+ fs.writeFileSync(temp, JSON.stringify(document), "utf8");
491
691
  fs.renameSync(temp, file);
492
692
  } catch (error) {
493
693
  fs.rmSync(temp, { force: true });