@ttsc/metro 0.19.2 → 0.20.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/README.md +1 -1
- package/lib/core/fingerprint.d.ts +12 -9
- package/lib/core/fingerprint.js +183 -36
- package/lib/core/fingerprint.js.map +1 -1
- package/lib/core/fingerprint.mjs +183 -36
- package/lib/core/fingerprint.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/fingerprint.ts +236 -36
package/src/core/fingerprint.ts
CHANGED
|
@@ -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.
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
for (const entry of
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* `withTtsc` compacts the
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 });
|