@davideasden/pi-undo 0.1.2 → 0.2.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.
@@ -19,10 +19,15 @@ import {
19
19
  fingerprintAbsent,
20
20
  fingerprintBytes,
21
21
  fingerprintSymlink,
22
+ type DeleteLeafRequest,
23
+ type ReplaceFileRequest,
22
24
  } from "./quarantine.ts";
23
25
  import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
24
26
 
25
27
  const PREPARED_PLAN_CACHE_LIMIT = 16;
28
+ const RESTORE_FILE_BATCH_MAX_ENTRIES = 128;
29
+ const RESTORE_FILE_BATCH_MAX_BYTES = 32 * 1024 * 1024;
30
+ const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
26
31
 
27
32
  export interface RestorePlan {
28
33
  currentManifestId: ManifestId;
@@ -128,17 +133,18 @@ export class RestoreEngine {
128
133
  ): Promise<RestorePlan> {
129
134
  assertManifest(current);
130
135
  assertManifest(target);
131
- assertCompatibleManifests(current, target);
132
136
  const scope = scopePaths === undefined ? undefined : this.canonicalScope(scopePaths);
137
+ const canonicalScopePaths = scope === undefined ? undefined : [...scope];
138
+ assertCompatibleManifests(current, target, scope);
133
139
  const isScopedPath = (path: string): boolean => scope === undefined || scope.has(path);
134
140
  await Promise.all([
135
- this.store.assertComplete(current.manifestId),
136
- this.store.assertComplete(target.manifestId),
141
+ this.store.assertComplete(current.manifestId, canonicalScopePaths),
142
+ this.store.assertComplete(target.manifestId, canonicalScopePaths),
137
143
  ]);
138
144
 
139
145
  const [currentPaths, targetPaths] = await Promise.all([
140
- this.readOwnedPaths(current),
141
- this.readOwnedPaths(target),
146
+ this.readOwnedPaths(current, canonicalScopePaths),
147
+ this.readOwnedPaths(target, canonicalScopePaths),
142
148
  ]);
143
149
  const targetIgnoredPaths = ignoredWorkspacePaths(target);
144
150
  const deleteByRoot = new Map<string, string[]>();
@@ -315,7 +321,11 @@ export class RestoreEngine {
315
321
  if (canonicalJson(storedTarget) !== canonicalJson(target)) {
316
322
  throw new Error("target manifest 与 store 内容不一致");
317
323
  }
318
- assertCompatibleManifests(current, target);
324
+ assertCompatibleManifests(
325
+ current,
326
+ target,
327
+ plan.scopePaths === undefined ? undefined : this.canonicalScope(plan.scopePaths),
328
+ );
319
329
  let expectedPlan: RestorePlan;
320
330
  let prepared: PreparedRestorePlan | undefined;
321
331
  try {
@@ -342,7 +352,10 @@ export class RestoreEngine {
342
352
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
343
353
  }
344
354
  const [currentPaths, targetPaths] = prepared === undefined
345
- ? await Promise.all([this.readOwnedPaths(current), this.readOwnedPaths(target)])
355
+ ? await Promise.all([
356
+ this.readOwnedPaths(current, plan.scopePaths),
357
+ this.readOwnedPaths(target, plan.scopePaths),
358
+ ])
346
359
  : [prepared.currentPaths, prepared.targetPaths];
347
360
  const quarantine = new QuarantineManager({
348
361
  workspaceRoot: this.requestedWorkspaceRoot,
@@ -382,12 +395,12 @@ export class RestoreEngine {
382
395
  quarantine,
383
396
  };
384
397
  try {
385
- for (const path of plan.deletePaths) {
386
- if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) {
387
- continue;
388
- }
389
- await this.deletePath(path, mutationContext);
390
- }
398
+ await this.deletePlannedPaths(
399
+ target.manifestId,
400
+ targetPaths,
401
+ plan.deletePaths,
402
+ mutationContext,
403
+ );
391
404
  await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
392
405
 
393
406
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
@@ -425,11 +438,16 @@ export class RestoreEngine {
425
438
  }
426
439
  }
427
440
 
428
- private async readOwnedPaths(manifest: SnapshotManifest): Promise<Map<string, OwnedPath>> {
441
+ private async readOwnedPaths(
442
+ manifest: SnapshotManifest,
443
+ scopePaths?: readonly string[],
444
+ ): Promise<Map<string, OwnedPath>> {
429
445
  const result = new Map<string, OwnedPath>();
430
446
  for (const root of manifest.roots) {
431
447
  assertNotGitMetadata(root.relativeRoot);
432
- const entries = await this.store.listTree(manifest.manifestId, root.relativeRoot);
448
+ const rootScope = rootRelativeScopePaths(root.relativeRoot, scopePaths);
449
+ if (rootScope !== undefined && rootScope.length === 0) continue;
450
+ const entries = await this.store.listTree(manifest.manifestId, root.relativeRoot, rootScope);
433
451
  if (entries.length > 0) {
434
452
  for (const boundaryPath of rootBoundaryDirectories(root.relativeRoot)) {
435
453
  if (!result.has(boundaryPath)) {
@@ -523,6 +541,63 @@ export class RestoreEngine {
523
541
  return { ok: true, verifiedPaths, totalPaths: paths.length };
524
542
  }
525
543
 
544
+ private async deletePlannedPaths(
545
+ targetManifestId: ManifestId,
546
+ targetPaths: ReadonlyMap<string, OwnedPath>,
547
+ deletePaths: readonly string[],
548
+ context: MutationContext,
549
+ ): Promise<void> {
550
+ let fileBatch: OwnedPath[] = [];
551
+ let fileBatchRoot: string | undefined;
552
+ const flushFiles = async (): Promise<void> => {
553
+ if (fileBatch.length === 0) return;
554
+ const requests: DeleteLeafRequest[] = [];
555
+ for (const source of fileBatch) {
556
+ context.ordinal += 1;
557
+ const ordinal = context.ordinal;
558
+ await this.beforeMutation?.({
559
+ phase: context.phase,
560
+ ordinal,
561
+ kind: "delete",
562
+ path: source.absolutePath,
563
+ });
564
+ await this.assertMutationPath(source.absolutePath);
565
+ await this.assertMutationState(context, "delete", source.absolutePath);
566
+ requests.push({
567
+ path: source.absolutePath,
568
+ sourceFingerprint: await this.expectedMutationFingerprint(context, source.absolutePath),
569
+ targetFingerprint: fingerprintAbsent(source.absolutePath),
570
+ });
571
+ }
572
+ await context.quarantine.deleteFiles(requests);
573
+ fileBatch = [];
574
+ fileBatchRoot = undefined;
575
+ };
576
+ for (const path of deletePaths) {
577
+ if (await this.pathIsShadowedByTarget(targetManifestId, path, targetPaths)) continue;
578
+ const source = context.sourcePaths.get(path);
579
+ const live = await lstat(this.absolutePath(path)).catch((error) => {
580
+ if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return null;
581
+ throw error;
582
+ });
583
+ if (live === null) continue;
584
+ if (source?.entry.kind !== "file" || live.isSymbolicLink() || !live.isFile()) {
585
+ await flushFiles();
586
+ await this.deletePath(path, context);
587
+ continue;
588
+ }
589
+ if (
590
+ fileBatch.length > 0 &&
591
+ (fileBatchRoot !== source.root.relativeRoot || fileBatch.length >= RESTORE_FILE_BATCH_MAX_ENTRIES)
592
+ ) {
593
+ await flushFiles();
594
+ }
595
+ fileBatch.push(source);
596
+ fileBatchRoot = source.root.relativeRoot;
597
+ }
598
+ await flushFiles();
599
+ }
600
+
526
601
  private async deletePath(path: string, context: MutationContext): Promise<void> {
527
602
  const absolutePath = this.absolutePath(path);
528
603
  try {
@@ -557,12 +632,12 @@ export class RestoreEngine {
557
632
  return;
558
633
  }
559
634
  await this.mutate(context, "delete", path, async () => {
560
- await context.quarantine.deleteLeaf({
635
+ const record = await context.quarantine.deleteLeaf({
561
636
  path,
562
637
  sourceFingerprint: await this.expectedMutationFingerprint(context, path),
563
638
  targetFingerprint: fingerprintAbsent(path),
564
639
  });
565
- await this.cleanupLatestMutation(context);
640
+ await context.quarantine.cleanupMutation(record);
566
641
  });
567
642
  }
568
643
 
@@ -604,14 +679,14 @@ export class RestoreEngine {
604
679
  throw new Error(`symlink 存在类型或内容冲突:${path}`);
605
680
  }
606
681
  await this.mutate(context, "symlink", path, async (beforeInstall) => {
607
- await context.quarantine.replaceSymlink({
682
+ const record = await context.quarantine.replaceSymlink({
608
683
  path,
609
684
  targetLinkText: linkText,
610
685
  sourceFingerprint: await this.expectedMutationFingerprint(context, path),
611
686
  targetFingerprint: fingerprintSymlink(path, linkText),
612
687
  beforeInstall,
613
688
  });
614
- await this.cleanupLatestMutation(context);
689
+ await context.quarantine.cleanupMutation(record);
615
690
  }, true);
616
691
  return;
617
692
  }
@@ -619,7 +694,12 @@ export class RestoreEngine {
619
694
  if (target.entry.blobId === null) {
620
695
  throw new Error(`普通文件缺少 blob:${path}`);
621
696
  }
622
- const bytes = await this.store.readBlob(manifestId, target.root.relativeRoot, target.entry.blobId);
697
+ const bytes = await this.store.readBlob(
698
+ manifestId,
699
+ target.root.relativeRoot,
700
+ target.entry.blobId,
701
+ target.entry.relativePath,
702
+ );
623
703
  if (bytes.byteLength !== target.entry.size) {
624
704
  throw new Error(`普通文件 blob 大小不匹配:${path}`);
625
705
  }
@@ -628,7 +708,7 @@ export class RestoreEngine {
628
708
  "write",
629
709
  path,
630
710
  async (beforeInstall) => {
631
- await context.quarantine.replaceFile({
711
+ const record = await context.quarantine.replaceFile({
632
712
  path,
633
713
  targetBytes: bytes,
634
714
  targetMode: target.entry.mode & 0o777,
@@ -636,7 +716,7 @@ export class RestoreEngine {
636
716
  targetFingerprint: fingerprintBytes(path, bytes, target.entry.mode),
637
717
  beforeInstall,
638
718
  });
639
- await this.cleanupLatestMutation(context);
719
+ await context.quarantine.cleanupMutation(record);
640
720
  },
641
721
  true,
642
722
  );
@@ -669,12 +749,12 @@ export class RestoreEngine {
669
749
  journal: options.mutationJournal,
670
750
  }),
671
751
  };
672
- for (const path of rollbackPlan.deletePaths) {
673
- if (await this.pathIsShadowedByTarget(current.manifestId, path, currentPaths)) {
674
- continue;
675
- }
676
- await this.deletePath(path, context);
677
- }
752
+ await this.deletePlannedPaths(
753
+ current.manifestId,
754
+ currentPaths,
755
+ rollbackPlan.deletePaths,
756
+ context,
757
+ );
678
758
  await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
679
759
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
680
760
  assertUnchangedTopology(topologyBefore, topologyAfter);
@@ -766,8 +846,9 @@ export class RestoreEngine {
766
846
  deferHook = false,
767
847
  ): Promise<void> {
768
848
  context.ordinal += 1;
849
+ const ordinal = context.ordinal;
769
850
  const beforeInstall = async (): Promise<void> => {
770
- await this.beforeMutation?.({ phase: context.phase, ordinal: context.ordinal, kind, path });
851
+ await this.beforeMutation?.({ phase: context.phase, ordinal, kind, path });
771
852
  };
772
853
  if (!deferHook) await beforeInstall();
773
854
  await this.assertMutationPath(path);
@@ -781,26 +862,95 @@ export class RestoreEngine {
781
862
  writePaths: readonly string[],
782
863
  context: MutationContext,
783
864
  ): Promise<void> {
865
+ let fileBatch: OwnedPath[] = [];
866
+ let fileBatchBytes = 0;
867
+ let fileBatchRoot: string | undefined;
868
+ const flushFiles = async (): Promise<void> => {
869
+ if (fileBatch.length === 0) return;
870
+ const pending = fileBatch.map((target) => {
871
+ context.ordinal += 1;
872
+ return { target, ordinal: context.ordinal };
873
+ });
874
+ const requests = await mapConcurrentOrdered(
875
+ pending,
876
+ RESTORE_FILE_PREPARE_CONCURRENCY,
877
+ ({ target, ordinal }) => this.prepareFileReplacement(manifestId, target, context, ordinal),
878
+ );
879
+ await context.quarantine.replaceFiles(requests);
880
+ fileBatch = [];
881
+ fileBatchBytes = 0;
882
+ fileBatchRoot = undefined;
883
+ };
784
884
  for (const kind of ["directory", "leaf"] as const) {
785
885
  for (const path of writePaths) {
786
886
  const target = targetPaths.get(path);
787
887
  if (target === undefined) {
788
888
  throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
789
889
  }
790
- if ((target.entry.kind === "directory") !== (kind === "directory")) {
791
- continue;
792
- }
890
+ if ((target.entry.kind === "directory") !== (kind === "directory")) continue;
793
891
  if (context.sourceIgnoredPaths.has(path)) {
794
- if (await this.entryMatches(manifestId, target)) {
795
- continue;
796
- }
892
+ await flushFiles();
893
+ if (await this.entryMatches(manifestId, target)) continue;
797
894
  throw new Error(`${context.phase} 的 ignored-present 路径与目标内容冲突:${path}`);
798
895
  }
799
- await this.writePath(manifestId, target, context);
896
+ if (target.entry.kind !== "file") {
897
+ await flushFiles();
898
+ await this.writePath(manifestId, target, context);
899
+ continue;
900
+ }
901
+ if (
902
+ fileBatch.length > 0 &&
903
+ (fileBatchRoot !== target.root.relativeRoot ||
904
+ fileBatch.length >= RESTORE_FILE_BATCH_MAX_ENTRIES ||
905
+ fileBatchBytes + target.entry.size > RESTORE_FILE_BATCH_MAX_BYTES)
906
+ ) {
907
+ await flushFiles();
908
+ }
909
+ fileBatch.push(target);
910
+ fileBatchBytes += target.entry.size;
911
+ fileBatchRoot = target.root.relativeRoot;
800
912
  }
913
+ await flushFiles();
801
914
  }
802
915
  }
803
916
 
917
+ private async prepareFileReplacement(
918
+ manifestId: ManifestId,
919
+ target: OwnedPath,
920
+ context: MutationContext,
921
+ ordinal: number,
922
+ ): Promise<ReplaceFileRequest> {
923
+ if (target.entry.kind !== "file" || target.entry.blobId === null) {
924
+ throw new Error(`批量普通文件缺少 blob:${target.absolutePath}`);
925
+ }
926
+ const bytes = await this.store.readBlob(
927
+ manifestId,
928
+ target.root.relativeRoot,
929
+ target.entry.blobId,
930
+ target.entry.relativePath,
931
+ );
932
+ if (bytes.byteLength !== target.entry.size) {
933
+ throw new Error(`普通文件 blob 大小不匹配:${target.absolutePath}`);
934
+ }
935
+ await this.assertMutationPath(target.absolutePath);
936
+ await this.assertMutationState(context, "write", target.absolutePath);
937
+ return {
938
+ path: target.absolutePath,
939
+ targetBytes: bytes,
940
+ targetMode: target.entry.mode & 0o777,
941
+ sourceFingerprint: await this.expectedMutationFingerprint(context, target.absolutePath),
942
+ 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
+ },
951
+ };
952
+ }
953
+
804
954
  private async assertMutationState(
805
955
  context: MutationContext,
806
956
  kind: RestoreMutation["kind"],
@@ -933,7 +1083,12 @@ export class RestoreEngine {
933
1083
  return fingerprintSymlink(path, owned.entry.linkText!);
934
1084
  }
935
1085
  if (owned.entry.blobId === null) throw new Error(`普通文件缺少 blob:${path}`);
936
- const bytes = await this.store.readBlob(manifestId, owned.root.relativeRoot, owned.entry.blobId);
1086
+ const bytes = await this.store.readBlob(
1087
+ manifestId,
1088
+ owned.root.relativeRoot,
1089
+ owned.entry.blobId,
1090
+ owned.entry.relativePath,
1091
+ );
937
1092
  return fingerprintBytes(path, bytes, owned.entry.mode);
938
1093
  }
939
1094
  if (await this.pathIsAbsent(path)) return fingerprintAbsent(path);
@@ -949,13 +1104,6 @@ export class RestoreEngine {
949
1104
  }
950
1105
  }
951
1106
 
952
- private async cleanupLatestMutation(context: MutationContext): Promise<void> {
953
- const records = await context.mutationJournal.load();
954
- const record = records.at(-1);
955
- if (record === undefined) throw new Error("quarantine mutation 记录缺失");
956
- await context.quarantine.cleanupMutation(record);
957
- }
958
-
959
1107
  private async restorePendingMutations(
960
1108
  quarantine: QuarantineManager,
961
1109
  journal: MutationJournal,
@@ -1012,7 +1160,12 @@ export class RestoreEngine {
1012
1160
  }
1013
1161
  const [actual, expected] = await Promise.all([
1014
1162
  readFile(this.absolutePath(path)),
1015
- this.store.readBlob(manifestId, owned.root.relativeRoot, owned.entry.blobId),
1163
+ this.store.readBlob(
1164
+ manifestId,
1165
+ owned.root.relativeRoot,
1166
+ owned.entry.blobId,
1167
+ owned.entry.relativePath,
1168
+ ),
1016
1169
  ]);
1017
1170
  if (!actual.equals(Buffer.from(expected))) {
1018
1171
  throw new Error(`普通文件内容校验失败:${path}`);
@@ -1081,16 +1234,37 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
1081
1234
  left.linkText === right.linkText;
1082
1235
  }
1083
1236
 
1084
- function assertCompatibleManifests(current: SnapshotManifest, target: SnapshotManifest): void {
1237
+ function assertCompatibleManifests(
1238
+ current: SnapshotManifest,
1239
+ target: SnapshotManifest,
1240
+ scope?: ReadonlySet<string>,
1241
+ ): void {
1085
1242
  if (current.workspaceIdentity !== target.workspaceIdentity) {
1086
1243
  throw new Error("restore manifest 不属于同一 workspace");
1087
1244
  }
1088
- if (current.coverage !== target.coverage) {
1089
- throw new Error("restore manifest coverage 不一致,不能推断缺失路径");
1090
- }
1091
1245
  if (current.roots.some((root) => root.state === "broken") || target.roots.some((root) => root.state === "broken")) {
1092
1246
  throw new Error("broken root 不能用于 restore");
1093
1247
  }
1248
+ const scopedCoverage = scope === undefined
1249
+ ? undefined
1250
+ : `paths:${checksum(canonicalJson([...scope]))}`;
1251
+ if (current.coverage === target.coverage) {
1252
+ if (
1253
+ scopedCoverage !== undefined && current.coverage !== "complete" &&
1254
+ current.coverage !== scopedCoverage
1255
+ ) {
1256
+ throw new Error("restore manifest coverage 与 scope 不匹配");
1257
+ }
1258
+ return;
1259
+ }
1260
+ if (
1261
+ scopedCoverage === undefined ||
1262
+ ![current.coverage, target.coverage].every(
1263
+ (coverage) => coverage === "complete" || coverage === scopedCoverage,
1264
+ )
1265
+ ) {
1266
+ throw new Error("restore manifest coverage 不一致,不能推断缺失路径");
1267
+ }
1094
1268
  }
1095
1269
 
1096
1270
  function orderedWritePaths(
@@ -1120,6 +1294,26 @@ function appendPath(paths: Map<string, string[]>, root: string, path: string): v
1120
1294
  owned.push(path);
1121
1295
  }
1122
1296
 
1297
+ function rootRelativeScopePaths(
1298
+ root: string,
1299
+ scopePaths: readonly string[] | undefined,
1300
+ ): string[] | undefined {
1301
+ if (scopePaths === undefined) return undefined;
1302
+ if (scopePaths.length === 0) return [];
1303
+ const result = new Set<string>();
1304
+ for (const path of scopePaths) {
1305
+ if (path === "." || path === root || isStrictWorkspaceAncestor(path, root)) return undefined;
1306
+ if (isStrictWorkspaceAncestor(root, path)) {
1307
+ result.add(root === "." ? path : path.slice(root.length + 1));
1308
+ }
1309
+ }
1310
+ return [...result].sort(comparePaths);
1311
+ }
1312
+
1313
+ function isStrictWorkspaceAncestor(parent: string, child: string): boolean {
1314
+ return parent === "." ? child !== "." : child.startsWith(`${parent}/`);
1315
+ }
1316
+
1123
1317
  function workspacePath(root: string, path: string): string {
1124
1318
  return root === "." ? path : path === "." ? root : `${root}/${path}`;
1125
1319
  }
@@ -1227,6 +1421,32 @@ function hasValidPlanDigest(plan: RestorePlan): boolean {
1227
1421
  }
1228
1422
  }
1229
1423
 
1424
+ async function mapConcurrentOrdered<T, R>(
1425
+ values: readonly T[],
1426
+ concurrency: number,
1427
+ operation: (value: T) => Promise<R>,
1428
+ ): Promise<R[]> {
1429
+ const results: R[] = new Array(values.length);
1430
+ let nextIndex = 0;
1431
+ let failed = false;
1432
+ let failure: unknown;
1433
+ async function worker(): Promise<void> {
1434
+ while (!failed && nextIndex < values.length) {
1435
+ const index = nextIndex;
1436
+ nextIndex += 1;
1437
+ try {
1438
+ results[index] = await operation(values[index]!);
1439
+ } catch (error) {
1440
+ if (!failed) failure = error;
1441
+ failed = true;
1442
+ }
1443
+ }
1444
+ }
1445
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
1446
+ if (failed) throw failure;
1447
+ return results;
1448
+ }
1449
+
1230
1450
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
1231
1451
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
1232
1452
  }