@davideasden/pi-undo 0.2.2 → 0.2.3

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.
@@ -2,10 +2,20 @@ import { randomUUID } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { dirname, join, resolve } from "node:path";
6
6
 
7
+ import {
8
+ createDurablePack,
9
+ publishCachedDurablePack,
10
+ removeDurablePack,
11
+ type DurableLeafInput,
12
+ type DurablePack,
13
+ type DurablePackEntryInput,
14
+ } from "./durable-pack.ts";
7
15
  import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
8
16
  import { MutationJournal } from "./mutation-journal.ts";
17
+ import { createNativeFileBatch } from "./native-restore.ts";
18
+ import { recoverPackedMutations } from "./packed-recovery.ts";
9
19
  import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
10
20
  import {
11
21
  assertNoSymlinkEscape,
@@ -25,8 +35,8 @@ import {
25
35
  import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
26
36
 
27
37
  const PREPARED_PLAN_CACHE_LIMIT = 16;
28
- const RESTORE_FILE_BATCH_MAX_ENTRIES = 128;
29
- const RESTORE_FILE_BATCH_MAX_BYTES = 32 * 1024 * 1024;
38
+ const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
39
+ const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
30
40
  const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
31
41
 
32
42
  export interface RestorePlan {
@@ -54,6 +64,8 @@ export interface RestoreEngine {
54
64
  export interface RestoreApplyOptions {
55
65
  readonly opId: string;
56
66
  readonly mutationJournal: MutationJournal;
67
+ readonly forceTargetArtifactSync?: boolean;
68
+ readonly deferDurability?: boolean;
57
69
  }
58
70
 
59
71
  export interface RestoreEngineOptions {
@@ -102,6 +114,17 @@ export class RestoreEngine {
102
114
  private readonly discovery: RootDiscovery;
103
115
  private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
104
116
  private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
117
+ private readonly durablePackCache = new Map<string, {
118
+ readonly currentManifestId: ManifestId;
119
+ readonly targetManifestId: ManifestId;
120
+ readonly path: string;
121
+ readonly packChecksum: string;
122
+ readonly pinReason: string;
123
+ }>();
124
+ private readonly durablePackByPair = new Map<string, {
125
+ readonly planDigest: string;
126
+ readonly pack: DurablePack;
127
+ }>();
105
128
 
106
129
  constructor(options: RestoreEngineOptions) {
107
130
  this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
@@ -197,6 +220,118 @@ export class RestoreEngine {
197
220
  return plan;
198
221
  }
199
222
 
223
+ async canReuseDurableSource(
224
+ current: SnapshotManifest,
225
+ target: SnapshotManifest,
226
+ scopePaths: readonly string[],
227
+ ): Promise<boolean> {
228
+ const cached = this.durablePackByPair.get(durablePairKey(current.manifestId, target.manifestId, scopePaths));
229
+ if (cached === undefined) return false;
230
+ const cacheJournal = new MutationJournal(
231
+ join(dirname(cached.pack.storagePath), "mutations.jsonl"),
232
+ `cache-${cached.planDigest}`,
233
+ );
234
+ try {
235
+ if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) return false;
236
+ const topology = await this.discovery.discover(this.workspaceRoot);
237
+ this.assertCurrentTopology(current, target, topology);
238
+ const native = await createNativeFileBatch({
239
+ workspaceRoot: this.workspaceRoot,
240
+ planDigest: cached.planDigest,
241
+ journal: cacheJournal,
242
+ });
243
+ return native !== undefined && await native.verifySource(cached.pack);
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ async prepareDurableRestore(
250
+ current: SnapshotManifest,
251
+ target: SnapshotManifest,
252
+ scopePaths: readonly string[],
253
+ ): Promise<void> {
254
+ const plan = await this.plan(current, target, scopePaths);
255
+ const prepared = this.takePreparedPlan(plan);
256
+ if (prepared === undefined || !this.canUseNativeFilePlan(plan, prepared.targetPaths)) return;
257
+ const cacheRoot = await this.store.durableCacheDirectory();
258
+ const cacheOpId = `cache-${plan.planDigest}`;
259
+ const cacheJournal = new MutationJournal(
260
+ join(cacheRoot, plan.planDigest, "mutations.jsonl"),
261
+ cacheOpId,
262
+ );
263
+ await mkdir(dirname(cacheJournal.storagePath), { recursive: true });
264
+ const pack = await createDurablePack(cacheJournal, {
265
+ opId: cacheOpId,
266
+ planDigest: plan.planDigest,
267
+ entries: await this.durablePackEntries(
268
+ current,
269
+ target,
270
+ prepared.currentPaths,
271
+ prepared.targetPaths,
272
+ plan,
273
+ cacheOpId,
274
+ ),
275
+ });
276
+ const pinReason = `durable-cache:${plan.planDigest}`;
277
+ const pinned: ManifestId[] = [];
278
+ try {
279
+ for (const manifestId of new Set([current.manifestId, target.manifestId])) {
280
+ await this.store.pin(manifestId, pinReason);
281
+ pinned.push(manifestId);
282
+ }
283
+ } catch (error) {
284
+ await Promise.all(pinned.map((manifestId) => this.store.unpin(manifestId, pinReason).catch(() => {})));
285
+ await rm(dirname(pack.storagePath), { recursive: true, force: true }).catch(() => {});
286
+ throw error;
287
+ }
288
+ await this.rememberDurablePack(
289
+ plan,
290
+ current,
291
+ target,
292
+ scopePaths,
293
+ pack,
294
+ pinReason,
295
+ );
296
+ this.rememberPreparedPlan(plan, prepared.currentPaths, prepared.targetPaths);
297
+ }
298
+
299
+ private async rememberDurablePack(
300
+ plan: RestorePlan,
301
+ current: SnapshotManifest,
302
+ target: SnapshotManifest,
303
+ scopePaths: readonly string[],
304
+ pack: DurablePack,
305
+ pinReason: string,
306
+ ): Promise<void> {
307
+ this.durablePackCache.set(plan.planDigest, {
308
+ currentManifestId: current.manifestId,
309
+ targetManifestId: target.manifestId,
310
+ path: pack.storagePath,
311
+ packChecksum: pack.packChecksum,
312
+ pinReason,
313
+ });
314
+ this.durablePackByPair.set(durablePairKey(current.manifestId, target.manifestId, scopePaths), {
315
+ planDigest: plan.planDigest,
316
+ pack,
317
+ });
318
+ while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
319
+ const oldest = this.durablePackCache.entries().next().value as
320
+ | readonly [string, { readonly currentManifestId: ManifestId; readonly targetManifestId: ManifestId; readonly path: string; readonly packChecksum: string; readonly pinReason: string }]
321
+ | undefined;
322
+ if (oldest === undefined) break;
323
+ const [digest, entry] = oldest;
324
+ this.durablePackCache.delete(digest);
325
+ for (const [key, pair] of this.durablePackByPair) {
326
+ if (pair.planDigest === digest) this.durablePackByPair.delete(key);
327
+ }
328
+ await Promise.all([...new Set([entry.currentManifestId, entry.targetManifestId])].map(
329
+ (manifestId) => this.store.unpin(manifestId, entry.pinReason).catch(() => {}),
330
+ ));
331
+ await rm(dirname(entry.path), { recursive: true, force: true }).catch(() => {});
332
+ }
333
+ }
334
+
200
335
  private rememberPreparedPlan(
201
336
  plan: RestorePlan,
202
337
  currentPaths: ReadonlyMap<string, OwnedPath>,
@@ -255,14 +390,18 @@ export class RestoreEngine {
255
390
  if (!hasValidPlanDigest(plan)) {
256
391
  throw new Error("restore plan digest 与语义字段不匹配");
257
392
  }
393
+ const pinsDeferred = effectiveOptions.deferDurability === true &&
394
+ this.durablePackCache.has(plan.planDigest);
258
395
  const recoveryReason = `restore:${plan.planDigest}`;
259
396
  const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
260
397
  const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
261
398
  const acquired: ManifestId[] = [];
262
399
  try {
263
- for (const manifestId of pinned) {
264
- await this.store.pin(manifestId, attemptReason);
265
- acquired.push(manifestId);
400
+ if (!pinsDeferred) {
401
+ for (const manifestId of pinned) {
402
+ await this.store.pin(manifestId, attemptReason);
403
+ acquired.push(manifestId);
404
+ }
266
405
  }
267
406
  } catch (error) {
268
407
  await Promise.all(acquired.map(
@@ -272,10 +411,16 @@ export class RestoreEngine {
272
411
  }
273
412
 
274
413
  try {
275
- const result = await this.applyPinned(plan, target, effectiveOptions, compatibilityMode);
414
+ const result = await this.applyPinned(
415
+ plan,
416
+ target,
417
+ effectiveOptions,
418
+ compatibilityMode,
419
+ pinsDeferred,
420
+ );
276
421
  if (result.code === "partial_restore" || result.code === "recovery_required") {
277
- let recoveryPinned = true;
278
- for (const manifestId of pinned) {
422
+ let recoveryPinned = !pinsDeferred;
423
+ for (const manifestId of recoveryPinned ? pinned : []) {
279
424
  try {
280
425
  await this.store.pin(manifestId, recoveryReason);
281
426
  } catch {
@@ -284,24 +429,24 @@ export class RestoreEngine {
284
429
  }
285
430
  }
286
431
  if (recoveryPinned) {
287
- await Promise.all(pinned.map(
432
+ await Promise.all(acquired.map(
288
433
  (manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
289
434
  ));
290
435
  }
291
436
  return result;
292
437
  }
293
438
 
294
- await Promise.all(pinned.map(
439
+ await Promise.all(acquired.map(
295
440
  (manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
296
441
  ));
297
- if (result.code === "ok" || result.postFingerprint !== undefined) {
442
+ if (!pinsDeferred && (result.code === "ok" || result.postFingerprint !== undefined)) {
298
443
  await Promise.all(pinned.map(
299
444
  (manifestId) => this.store.unpin(manifestId, recoveryReason).catch(() => {}),
300
445
  ));
301
446
  }
302
447
  return result;
303
448
  } catch (error) {
304
- await Promise.all(pinned.map(
449
+ await Promise.all(acquired.map(
305
450
  (manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
306
451
  ));
307
452
  throw error;
@@ -313,6 +458,7 @@ export class RestoreEngine {
313
458
  target: SnapshotManifest,
314
459
  options: RestoreApplyOptions,
315
460
  compatibilityMode: boolean,
461
+ pinsDeferred: boolean,
316
462
  ): Promise<RestoreResult> {
317
463
  const [current, storedTarget] = await Promise.all([
318
464
  this.store.loadManifest(plan.currentManifestId),
@@ -327,10 +473,14 @@ export class RestoreEngine {
327
473
  plan.scopePaths === undefined ? undefined : this.canonicalScope(plan.scopePaths),
328
474
  );
329
475
  let expectedPlan: RestorePlan;
330
- let prepared: PreparedRestorePlan | undefined;
476
+ let prepared = options.deferDurability === true ? this.takePreparedPlan(plan) : undefined;
331
477
  try {
332
- expectedPlan = await this.plan(current, target, plan.scopePaths);
333
- prepared = this.takePreparedPlan(expectedPlan);
478
+ if (prepared === undefined) {
479
+ expectedPlan = await this.plan(current, target, plan.scopePaths);
480
+ prepared = this.takePreparedPlan(expectedPlan);
481
+ } else {
482
+ expectedPlan = prepared.plan;
483
+ }
334
484
  } catch (error) {
335
485
  if (error instanceof SnapshotStoreError && error.code === "object_missing") {
336
486
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
@@ -357,15 +507,15 @@ export class RestoreEngine {
357
507
  this.readOwnedPaths(target, plan.scopePaths),
358
508
  ])
359
509
  : [prepared.currentPaths, prepared.targetPaths];
360
- const quarantine = new QuarantineManager({
361
- workspaceRoot: this.requestedWorkspaceRoot,
362
- journal: options.mutationJournal,
363
- });
364
- if (
365
- compatibilityMode
366
- ? !await this.restorePendingMutations(quarantine, options.mutationJournal)
367
- : (await options.mutationJournal.activeArtifacts()).size > 0
368
- ) {
510
+ if (compatibilityMode) {
511
+ const compatibilityQuarantine = new QuarantineManager({
512
+ workspaceRoot: this.requestedWorkspaceRoot,
513
+ journal: options.mutationJournal,
514
+ });
515
+ if (!await this.restorePendingMutations(compatibilityQuarantine, options.mutationJournal)) {
516
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
517
+ }
518
+ } else if ((await options.mutationJournal.activeArtifacts()).size > 0) {
369
519
  return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
370
520
  }
371
521
  try {
@@ -373,6 +523,71 @@ export class RestoreEngine {
373
523
  } catch {
374
524
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
375
525
  }
526
+ let durablePack: DurablePack | undefined;
527
+ if (
528
+ !compatibilityMode &&
529
+ options.deferDurability === true &&
530
+ options.forceTargetArtifactSync !== true &&
531
+ this.canUseNativeFilePlan(plan, targetPaths)
532
+ ) {
533
+ try {
534
+ const cached = this.durablePackCache.get(plan.planDigest);
535
+ if (
536
+ cached !== undefined &&
537
+ cached.currentManifestId === current.manifestId &&
538
+ cached.targetManifestId === target.manifestId
539
+ ) {
540
+ durablePack = await publishCachedDurablePack(
541
+ cached.path,
542
+ options.mutationJournal,
543
+ plan.planDigest,
544
+ cached.packChecksum,
545
+ );
546
+ } else {
547
+ durablePack = await createDurablePack(options.mutationJournal, {
548
+ opId: options.opId,
549
+ planDigest: plan.planDigest,
550
+ entries: await this.durablePackEntries(current, target, currentPaths, targetPaths, plan, options.opId),
551
+ });
552
+ }
553
+ } catch {
554
+ await removeDurablePack(options.mutationJournal).catch(() => {});
555
+ }
556
+ }
557
+ const nativeFileBatch = durablePack !== undefined && this.beforeMutation === undefined
558
+ ? await createNativeFileBatch({
559
+ workspaceRoot: this.workspaceRoot,
560
+ planDigest: plan.planDigest,
561
+ journal: options.mutationJournal,
562
+ })
563
+ : undefined;
564
+ if (durablePack !== undefined && nativeFileBatch === undefined) {
565
+ await removeDurablePack(options.mutationJournal).catch(() => {});
566
+ durablePack = undefined;
567
+ }
568
+ if (pinsDeferred && durablePack === undefined) {
569
+ return this.applyWithOptions(
570
+ plan,
571
+ target,
572
+ { ...options, deferDurability: false },
573
+ compatibilityMode,
574
+ );
575
+ }
576
+ const durablePackEnabled = durablePack !== undefined;
577
+ if (nativeFileBatch !== undefined && durablePack !== undefined && this.canUseNativeFilePlan(plan, targetPaths)) {
578
+ const result = await this.applyNativeFilePlan(
579
+ plan,
580
+ current,
581
+ target,
582
+ currentPaths,
583
+ targetPaths,
584
+ topologyBefore,
585
+ options,
586
+ durablePack,
587
+ nativeFileBatch.run,
588
+ );
589
+ return result;
590
+ }
376
591
  const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
377
592
  if (!preflight.ok) {
378
593
  return {
@@ -381,6 +596,11 @@ export class RestoreEngine {
381
596
  totalPaths: preflight.totalPaths,
382
597
  };
383
598
  }
599
+ const quarantine = new QuarantineManager({
600
+ workspaceRoot: this.requestedWorkspaceRoot,
601
+ journal: options.mutationJournal,
602
+ syncTargetArtifacts: !durablePackEnabled,
603
+ });
384
604
 
385
605
  const mutationContext: MutationContext = {
386
606
  phase: "apply",
@@ -434,10 +654,137 @@ export class RestoreEngine {
434
654
  targetPaths,
435
655
  options,
436
656
  plan.scopePaths,
657
+ !durablePackEnabled,
658
+ );
659
+ }
660
+ }
661
+
662
+ private canUseNativeFilePlan(
663
+ plan: RestorePlan,
664
+ targetPaths: ReadonlyMap<string, OwnedPath>,
665
+ ): boolean {
666
+ return plan.deletePaths.length === 0 &&
667
+ plan.writePaths.length > 0 &&
668
+ plan.writePaths.every((path) => targetPaths.get(path)?.entry.kind === "file");
669
+ }
670
+
671
+ private async applyNativeFilePlan(
672
+ plan: RestorePlan,
673
+ current: SnapshotManifest,
674
+ target: SnapshotManifest,
675
+ currentPaths: ReadonlyMap<string, OwnedPath>,
676
+ targetPaths: ReadonlyMap<string, OwnedPath>,
677
+ topologyBefore: RootTopology,
678
+ options: RestoreApplyOptions,
679
+ pack: DurablePack,
680
+ nativeRun: (pack: DurablePack) => Promise<void>,
681
+ ): Promise<RestoreResult> {
682
+ try {
683
+ await nativeRun(pack);
684
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot);
685
+ assertUnchangedTopology(topologyBefore, topologyAfter);
686
+ await this.assertCompleteVisibleSubset(
687
+ topologyAfter,
688
+ [target],
689
+ options.mutationJournal,
690
+ pack.paths().flatMap((path) => {
691
+ const artifacts = pack.artifacts(path);
692
+ return artifacts === undefined
693
+ ? []
694
+ : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
695
+ }),
696
+ );
697
+ if ((await options.mutationJournal.load()).length !== 0) {
698
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.writePaths.length };
699
+ }
700
+ return { code: "ok", verifiedPaths: plan.writePaths.length, totalPaths: plan.writePaths.length };
701
+ } catch {
702
+ const packedRecovery = await recoverPackedMutations({
703
+ workspaceRoot: this.workspaceRoot,
704
+ journal: options.mutationJournal,
705
+ planDigest: plan.planDigest,
706
+ decision: "rollback",
707
+ });
708
+ if (packedRecovery.kind !== "clean") {
709
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.writePaths.length };
710
+ }
711
+ return this.rollback(
712
+ current,
713
+ target,
714
+ topologyBefore,
715
+ currentPaths,
716
+ targetPaths,
717
+ options,
718
+ plan.scopePaths,
719
+ false,
437
720
  );
438
721
  }
439
722
  }
440
723
 
724
+ private async durablePackEntries(
725
+ current: SnapshotManifest,
726
+ target: SnapshotManifest,
727
+ currentPaths: ReadonlyMap<string, OwnedPath>,
728
+ targetPaths: ReadonlyMap<string, OwnedPath>,
729
+ plan: RestorePlan,
730
+ opId: string,
731
+ ): Promise<DurablePackEntryInput[]> {
732
+ const paths = [...new Set([...plan.deletePaths, ...plan.writePaths])].sort(comparePaths);
733
+ const result: DurablePackEntryInput[] = [];
734
+ for (const path of paths) {
735
+ const variants = new Map<string, DurableLeafInput>();
736
+ const absent: DurableLeafInput = { kind: "absent", fingerprint: fingerprintAbsent(path) };
737
+ variants.set(absent.fingerprint, absent);
738
+ const currentLeaf = await this.durableLeaf(current.manifestId, currentPaths.get(path));
739
+ if (currentLeaf !== undefined) variants.set(currentLeaf.fingerprint, currentLeaf);
740
+ const targetLeaf = await this.durableLeaf(target.manifestId, targetPaths.get(path));
741
+ if (targetLeaf !== undefined) variants.set(targetLeaf.fingerprint, targetLeaf);
742
+ if (currentLeaf === undefined && targetLeaf === undefined) continue;
743
+ const artifactId = checksum(canonicalJson({ opId, path })).slice(0, 32);
744
+ const parts = path.split("/");
745
+ const parent = parts.slice(0, -1).join("/");
746
+ const artifact = (role: "source" | "target"): string =>
747
+ `${parent === "" ? "" : `${parent}/`}.pi-undo-q2-${artifactId}-${role}`;
748
+ result.push({
749
+ path,
750
+ sourceArtifact: artifact("source"),
751
+ targetArtifact: targetLeaf?.kind === "file" ? artifact("target") : null,
752
+ sourceFingerprint: currentLeaf?.fingerprint ?? absent.fingerprint,
753
+ targetFingerprint: targetLeaf?.fingerprint ?? null,
754
+ variants: [...variants.values()],
755
+ });
756
+ }
757
+ return result;
758
+ }
759
+
760
+ private async durableLeaf(
761
+ manifestId: ManifestId,
762
+ owned: OwnedPath | undefined,
763
+ ): Promise<DurableLeafInput | undefined> {
764
+ if (owned === undefined || owned.entry.kind === "directory") return undefined;
765
+ if (owned.entry.kind === "symlink") {
766
+ return {
767
+ kind: "symlink",
768
+ fingerprint: fingerprintSymlink(owned.absolutePath, owned.entry.linkText!),
769
+ linkText: owned.entry.linkText!,
770
+ };
771
+ }
772
+ if (owned.entry.blobId === null) throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
773
+ const bytes = await this.store.readBlob(
774
+ manifestId,
775
+ owned.root.relativeRoot,
776
+ owned.entry.blobId,
777
+ owned.entry.relativePath,
778
+ );
779
+ const mode = owned.entry.mode & 0o777;
780
+ return {
781
+ kind: "file",
782
+ fingerprint: fingerprintBytes(owned.absolutePath, bytes, mode),
783
+ mode,
784
+ bytes,
785
+ };
786
+ }
787
+
441
788
  private async readOwnedPaths(
442
789
  manifest: SnapshotManifest,
443
790
  scopePaths?: readonly string[],
@@ -729,7 +1076,8 @@ export class RestoreEngine {
729
1076
  currentPaths: ReadonlyMap<string, OwnedPath>,
730
1077
  targetPaths: ReadonlyMap<string, OwnedPath>,
731
1078
  options: RestoreApplyOptions,
732
- scopePaths?: readonly string[],
1079
+ scopePaths: readonly string[] | undefined,
1080
+ syncTargetArtifacts: boolean,
733
1081
  ): Promise<RestoreResult> {
734
1082
  let rollbackPlan: RestorePlan | undefined;
735
1083
  try {
@@ -747,6 +1095,7 @@ export class RestoreEngine {
747
1095
  quarantine: new QuarantineManager({
748
1096
  workspaceRoot: this.requestedWorkspaceRoot,
749
1097
  journal: options.mutationJournal,
1098
+ syncTargetArtifacts,
750
1099
  }),
751
1100
  };
752
1101
  await this.deletePlannedPaths(
@@ -784,6 +1133,7 @@ export class RestoreEngine {
784
1133
  new QuarantineManager({
785
1134
  workspaceRoot: this.requestedWorkspaceRoot,
786
1135
  journal: options.mutationJournal,
1136
+ syncTargetArtifacts,
787
1137
  }),
788
1138
  options.mutationJournal,
789
1139
  );
@@ -940,14 +1290,16 @@ export class RestoreEngine {
940
1290
  targetMode: target.entry.mode & 0o777,
941
1291
  sourceFingerprint: await this.expectedMutationFingerprint(context, target.absolutePath),
942
1292
  targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
943
- beforeInstall: async () => {
944
- await this.beforeMutation?.({
945
- phase: context.phase,
946
- ordinal,
947
- kind: "write",
948
- path: target.absolutePath,
949
- });
950
- },
1293
+ ...(this.beforeMutation === undefined ? {} : {
1294
+ beforeInstall: async () => {
1295
+ await this.beforeMutation?.({
1296
+ phase: context.phase,
1297
+ ordinal,
1298
+ kind: "write",
1299
+ path: target.absolutePath,
1300
+ });
1301
+ },
1302
+ }),
951
1303
  };
952
1304
  }
953
1305
 
@@ -983,6 +1335,7 @@ export class RestoreEngine {
983
1335
  topology: RootTopology,
984
1336
  allowedManifests: readonly SnapshotManifest[],
985
1337
  mutationJournal?: MutationJournal,
1338
+ extraExclusions: readonly string[] = [],
986
1339
  ): Promise<void> {
987
1340
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
988
1341
  return;
@@ -1000,8 +1353,12 @@ export class RestoreEngine {
1000
1353
  }
1001
1354
  }
1002
1355
 
1356
+ const exclusions = new Set(extraExclusions);
1357
+ if (mutationJournal !== undefined) {
1358
+ for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
1359
+ }
1003
1360
  const livePaths = await this.store.listVisibleLeafPaths(topology, {
1004
- excludePaths: mutationJournal === undefined ? undefined : [...await mutationJournal.activeArtifacts()],
1361
+ excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
1005
1362
  });
1006
1363
  for (const path of livePaths) {
1007
1364
  if (!allowedPaths.has(path)) {
@@ -1447,6 +1804,14 @@ async function mapConcurrentOrdered<T, R>(
1447
1804
  return results;
1448
1805
  }
1449
1806
 
1807
+ function durablePairKey(
1808
+ currentManifestId: ManifestId,
1809
+ targetManifestId: ManifestId,
1810
+ scopePaths: readonly string[],
1811
+ ): string {
1812
+ return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
1813
+ }
1814
+
1450
1815
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
1451
1816
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
1452
1817
  }
@@ -125,6 +125,7 @@ export interface SnapshotStore {
125
125
  pin(id: ManifestId, reason: string): Promise<void>;
126
126
  unpin(id: ManifestId, reason: string): Promise<void>;
127
127
  collectGarbage(): Promise<number>;
128
+ durableCacheDirectory(): Promise<string>;
128
129
  }
129
130
 
130
131
  export class SnapshotStore {
@@ -150,6 +151,12 @@ export class SnapshotStore {
150
151
  this.clock = options.clock ?? Date.now;
151
152
  }
152
153
 
154
+ async durableCacheDirectory(): Promise<string> {
155
+ const directory = join(this.storeRoot, "durable-cache");
156
+ await mkdir(directory, { recursive: true });
157
+ return directory;
158
+ }
159
+
153
160
  async capture(
154
161
  topology: RootTopology,
155
162
  scope?: readonly string[],
@@ -116,7 +116,7 @@ export class WorkspaceLock {
116
116
  await rename(candidateDirectory, lockDirectory);
117
117
  published = true;
118
118
  } catch (error) {
119
- if (!await pathExists(lockDirectory)) {
119
+ if (!isPublishCollision(error) && !await pathExists(lockDirectory)) {
120
120
  throw error;
121
121
  }
122
122
  } finally {
@@ -402,6 +402,10 @@ function assertPositive(value: number, name: string): void {
402
402
  }
403
403
  }
404
404
 
405
+ function isPublishCollision(error: unknown): boolean {
406
+ return hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY");
407
+ }
408
+
405
409
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
406
410
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
407
411
  }