@davideasden/pi-undo 0.2.2 → 0.2.4

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,
@@ -22,11 +32,11 @@ import {
22
32
  type DeleteLeafRequest,
23
33
  type ReplaceFileRequest,
24
34
  } from "./quarantine.ts";
25
- import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
35
+ import { SnapshotStore, SnapshotStoreError } 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,184 @@ 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 useValidatedBatch = SnapshotStore.supportsValidatedBlobBatch(this.store);
734
+ const [currentBlobBytes, targetBlobBytes] = useValidatedBatch
735
+ ? await Promise.all([
736
+ this.readDurableBlobBytes(current.manifestId, paths, currentPaths),
737
+ this.readDurableBlobBytes(target.manifestId, paths, targetPaths),
738
+ ])
739
+ : [new Map<string, Uint8Array>(), new Map<string, Uint8Array>()];
740
+ const result: DurablePackEntryInput[] = [];
741
+ for (const path of paths) {
742
+ const variants = new Map<string, DurableLeafInput>();
743
+ const absent: DurableLeafInput = { kind: "absent", fingerprint: fingerprintAbsent(path) };
744
+ variants.set(absent.fingerprint, absent);
745
+ const currentLeaf = useValidatedBatch
746
+ ? this.durableLeaf(currentPaths.get(path), currentBlobBytes.get(path))
747
+ : await this.durableLeafCompatible(current.manifestId, currentPaths.get(path));
748
+ if (currentLeaf !== undefined) variants.set(currentLeaf.fingerprint, currentLeaf);
749
+ const targetLeaf = useValidatedBatch
750
+ ? this.durableLeaf(targetPaths.get(path), targetBlobBytes.get(path))
751
+ : await this.durableLeafCompatible(target.manifestId, targetPaths.get(path));
752
+ if (targetLeaf !== undefined) variants.set(targetLeaf.fingerprint, targetLeaf);
753
+ if (currentLeaf === undefined && targetLeaf === undefined) continue;
754
+ const artifactId = checksum(canonicalJson({ opId, path })).slice(0, 32);
755
+ const parts = path.split("/");
756
+ const parent = parts.slice(0, -1).join("/");
757
+ const artifact = (role: "source" | "target"): string =>
758
+ `${parent === "" ? "" : `${parent}/`}.pi-undo-q2-${artifactId}-${role}`;
759
+ result.push({
760
+ path,
761
+ sourceArtifact: artifact("source"),
762
+ targetArtifact: targetLeaf?.kind === "file" ? artifact("target") : null,
763
+ sourceFingerprint: currentLeaf?.fingerprint ?? absent.fingerprint,
764
+ targetFingerprint: targetLeaf?.fingerprint ?? null,
765
+ variants: [...variants.values()],
766
+ });
767
+ }
768
+ return result;
769
+ }
770
+
771
+ private async readDurableBlobBytes(
772
+ manifestId: ManifestId,
773
+ paths: readonly string[],
774
+ ownedPaths: ReadonlyMap<string, OwnedPath>,
775
+ ): Promise<ReadonlyMap<string, Uint8Array>> {
776
+ const files = paths.flatMap((path) => {
777
+ const owned = ownedPaths.get(path);
778
+ if (owned?.entry.kind !== "file") return [];
779
+ if (owned.entry.blobId === null) {
780
+ throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
781
+ }
782
+ return [{ path, owned, blobId: owned.entry.blobId }];
783
+ });
784
+ if (files.length === 0) return new Map();
785
+ const requests = files.map(({ owned, blobId }) => ({
786
+ rootPath: owned.root.relativeRoot,
787
+ blobId,
788
+ relativePath: owned.entry.relativePath,
789
+ }));
790
+ const blobs = await SnapshotStore.readBlobs(this.store, manifestId, requests);
791
+ if (blobs.length !== files.length) throw new Error("durable pack blob batch 数量不匹配");
792
+ return new Map(files.map(({ path }, index) => [path, blobs[index]!]));
793
+ }
794
+
795
+ private async durableLeafCompatible(
796
+ manifestId: ManifestId,
797
+ owned: OwnedPath | undefined,
798
+ ): Promise<DurableLeafInput | undefined> {
799
+ if (owned?.entry.kind !== "file") return this.durableLeaf(owned, undefined);
800
+ if (owned.entry.blobId === null) {
801
+ throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
802
+ }
803
+ return this.durableLeaf(owned, await this.store.readBlob(
804
+ manifestId,
805
+ owned.root.relativeRoot,
806
+ owned.entry.blobId,
807
+ owned.entry.relativePath,
808
+ ));
809
+ }
810
+
811
+ private durableLeaf(
812
+ owned: OwnedPath | undefined,
813
+ bytes: Uint8Array | undefined,
814
+ ): DurableLeafInput | undefined {
815
+ if (owned === undefined || owned.entry.kind === "directory") return undefined;
816
+ if (owned.entry.kind === "symlink") {
817
+ return {
818
+ kind: "symlink",
819
+ fingerprint: fingerprintSymlink(owned.absolutePath, owned.entry.linkText!),
820
+ linkText: owned.entry.linkText!,
821
+ };
822
+ }
823
+ if (owned.entry.blobId === null || bytes === undefined) {
824
+ throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
825
+ }
826
+ const mode = owned.entry.mode & 0o777;
827
+ return {
828
+ kind: "file",
829
+ fingerprint: fingerprintBytes(owned.absolutePath, bytes, mode),
830
+ mode,
831
+ bytes,
832
+ };
833
+ }
834
+
441
835
  private async readOwnedPaths(
442
836
  manifest: SnapshotManifest,
443
837
  scopePaths?: readonly string[],
@@ -729,7 +1123,8 @@ export class RestoreEngine {
729
1123
  currentPaths: ReadonlyMap<string, OwnedPath>,
730
1124
  targetPaths: ReadonlyMap<string, OwnedPath>,
731
1125
  options: RestoreApplyOptions,
732
- scopePaths?: readonly string[],
1126
+ scopePaths: readonly string[] | undefined,
1127
+ syncTargetArtifacts: boolean,
733
1128
  ): Promise<RestoreResult> {
734
1129
  let rollbackPlan: RestorePlan | undefined;
735
1130
  try {
@@ -747,6 +1142,7 @@ export class RestoreEngine {
747
1142
  quarantine: new QuarantineManager({
748
1143
  workspaceRoot: this.requestedWorkspaceRoot,
749
1144
  journal: options.mutationJournal,
1145
+ syncTargetArtifacts,
750
1146
  }),
751
1147
  };
752
1148
  await this.deletePlannedPaths(
@@ -784,6 +1180,7 @@ export class RestoreEngine {
784
1180
  new QuarantineManager({
785
1181
  workspaceRoot: this.requestedWorkspaceRoot,
786
1182
  journal: options.mutationJournal,
1183
+ syncTargetArtifacts,
787
1184
  }),
788
1185
  options.mutationJournal,
789
1186
  );
@@ -940,14 +1337,16 @@ export class RestoreEngine {
940
1337
  targetMode: target.entry.mode & 0o777,
941
1338
  sourceFingerprint: await this.expectedMutationFingerprint(context, target.absolutePath),
942
1339
  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
- },
1340
+ ...(this.beforeMutation === undefined ? {} : {
1341
+ beforeInstall: async () => {
1342
+ await this.beforeMutation?.({
1343
+ phase: context.phase,
1344
+ ordinal,
1345
+ kind: "write",
1346
+ path: target.absolutePath,
1347
+ });
1348
+ },
1349
+ }),
951
1350
  };
952
1351
  }
953
1352
 
@@ -983,6 +1382,7 @@ export class RestoreEngine {
983
1382
  topology: RootTopology,
984
1383
  allowedManifests: readonly SnapshotManifest[],
985
1384
  mutationJournal?: MutationJournal,
1385
+ extraExclusions: readonly string[] = [],
986
1386
  ): Promise<void> {
987
1387
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
988
1388
  return;
@@ -1000,8 +1400,12 @@ export class RestoreEngine {
1000
1400
  }
1001
1401
  }
1002
1402
 
1403
+ const exclusions = new Set(extraExclusions);
1404
+ if (mutationJournal !== undefined) {
1405
+ for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
1406
+ }
1003
1407
  const livePaths = await this.store.listVisibleLeafPaths(topology, {
1004
- excludePaths: mutationJournal === undefined ? undefined : [...await mutationJournal.activeArtifacts()],
1408
+ excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
1005
1409
  });
1006
1410
  for (const path of livePaths) {
1007
1411
  if (!allowedPaths.has(path)) {
@@ -1447,6 +1851,14 @@ async function mapConcurrentOrdered<T, R>(
1447
1851
  return results;
1448
1852
  }
1449
1853
 
1854
+ function durablePairKey(
1855
+ currentManifestId: ManifestId,
1856
+ targetManifestId: ManifestId,
1857
+ scopePaths: readonly string[],
1858
+ ): string {
1859
+ return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
1860
+ }
1861
+
1450
1862
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
1451
1863
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
1452
1864
  }