@davideasden/pi-undo 0.2.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -64,6 +64,12 @@ export class MutationJournal {
64
64
  return (await this.readRecords()).latest;
65
65
  }
66
66
 
67
+ async loadOrdinal(ordinal: number): Promise<MutationRecord | undefined> {
68
+ if (!Number.isSafeInteger(ordinal) || ordinal < 1) return undefined;
69
+ const record = (await this.readRecords()).latest[ordinal - 1];
70
+ return record?.ordinal === ordinal ? record : undefined;
71
+ }
72
+
67
73
  begin(intent: MutationIntent): Promise<MutationRecord> {
68
74
  return this.enqueueMutation(async () => (await this.beginManyMutation([intent]))[0]!);
69
75
  }
@@ -64,6 +64,23 @@ export async function assertNoSymlinkEscape(root: string, relativePath: string):
64
64
  }
65
65
  }
66
66
 
67
+ export function pathSetsOverlap(leftPaths: readonly string[], rightPaths: readonly string[]): boolean {
68
+ for (const path of leftPaths) assertRelativeCandidate(path);
69
+ for (const path of rightPaths) assertRelativeCandidate(path);
70
+ const rightSet = new Set(rightPaths);
71
+ const sortedRight = [...rightSet].sort(comparePaths);
72
+ for (const path of leftPaths) {
73
+ if (rightSet.has(path)) return true;
74
+ for (let separator = path.lastIndexOf("/"); separator >= 0; separator = path.lastIndexOf("/", separator - 1)) {
75
+ if (rightSet.has(path.slice(0, separator))) return true;
76
+ }
77
+ const descendantPrefix = `${path}/`;
78
+ const candidate = sortedRight[lowerBound(sortedRight, descendantPrefix)];
79
+ if (candidate?.startsWith(descendantPrefix)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
67
84
  export function sortDeletePaths(paths: readonly string[]): string[] {
68
85
  return sortPaths(paths, -1);
69
86
  }
@@ -108,6 +125,20 @@ function pathDepth(path: string): number {
108
125
  return path === "." ? 0 : path.split("/").length;
109
126
  }
110
127
 
128
+ function lowerBound(paths: readonly string[], target: string): number {
129
+ let low = 0;
130
+ let high = paths.length;
131
+ while (low < high) {
132
+ const middle = low + Math.floor((high - low) / 2);
133
+ if (comparePaths(paths[middle]!, target) < 0) {
134
+ low = middle + 1;
135
+ } else {
136
+ high = middle;
137
+ }
138
+ }
139
+ return low;
140
+ }
141
+
111
142
  function comparePaths(left: string, right: string): number {
112
143
  return left < right ? -1 : left > right ? 1 : 0;
113
144
  }
package/src/pi-runtime.ts CHANGED
@@ -99,7 +99,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
99
99
  continue;
100
100
  }
101
101
  await quarantine.rollForwardMutation(record);
102
- const latest = (await mutationJournal.load()).find((candidate) => candidate.ordinal === record.ordinal);
102
+ const latest = await mutationJournal.loadOrdinal(record.ordinal);
103
103
  if (latest === undefined) throw new Error("mutation ordinal 在恢复期间丢失");
104
104
  await quarantine.cleanupMutation(latest);
105
105
  }
package/src/quarantine.ts CHANGED
@@ -771,7 +771,7 @@ export class QuarantineManager {
771
771
 
772
772
  private async assertOwnedRecord(record: MutationRecord): Promise<MutationRecord> {
773
773
  await this.assertWorkspaceIdentity();
774
- const owned = (await this.journal.load()).find((candidate) => candidate.ordinal === record.ordinal);
774
+ const owned = await this.journal.loadOrdinal(record.ordinal);
775
775
  if (owned === undefined || immutableMutation(owned) !== immutableMutation(record)) {
776
776
  throw new QuarantineError("unsafe_artifact", "mutation record 未被当前 journal 精确登记");
777
777
  }
@@ -32,7 +32,7 @@ import {
32
32
  type DeleteLeafRequest,
33
33
  type ReplaceFileRequest,
34
34
  } from "./quarantine.ts";
35
- import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
35
+ import { SnapshotStore, SnapshotStoreError } from "./snapshot-store.ts";
36
36
 
37
37
  const PREPARED_PLAN_CACHE_LIMIT = 16;
38
38
  const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
@@ -730,14 +730,25 @@ export class RestoreEngine {
730
730
  opId: string,
731
731
  ): Promise<DurablePackEntryInput[]> {
732
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>()];
733
740
  const result: DurablePackEntryInput[] = [];
734
741
  for (const path of paths) {
735
742
  const variants = new Map<string, DurableLeafInput>();
736
743
  const absent: DurableLeafInput = { kind: "absent", fingerprint: fingerprintAbsent(path) };
737
744
  variants.set(absent.fingerprint, absent);
738
- const currentLeaf = await this.durableLeaf(current.manifestId, currentPaths.get(path));
745
+ const currentLeaf = useValidatedBatch
746
+ ? this.durableLeaf(currentPaths.get(path), currentBlobBytes.get(path))
747
+ : await this.durableLeafCompatible(current.manifestId, currentPaths.get(path));
739
748
  if (currentLeaf !== undefined) variants.set(currentLeaf.fingerprint, currentLeaf);
740
- const targetLeaf = await this.durableLeaf(target.manifestId, targetPaths.get(path));
749
+ const targetLeaf = useValidatedBatch
750
+ ? this.durableLeaf(targetPaths.get(path), targetBlobBytes.get(path))
751
+ : await this.durableLeafCompatible(target.manifestId, targetPaths.get(path));
741
752
  if (targetLeaf !== undefined) variants.set(targetLeaf.fingerprint, targetLeaf);
742
753
  if (currentLeaf === undefined && targetLeaf === undefined) continue;
743
754
  const artifactId = checksum(canonicalJson({ opId, path })).slice(0, 32);
@@ -757,10 +768,50 @@ export class RestoreEngine {
757
768
  return result;
758
769
  }
759
770
 
760
- private async durableLeaf(
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(
761
796
  manifestId: ManifestId,
762
797
  owned: OwnedPath | undefined,
763
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 {
764
815
  if (owned === undefined || owned.entry.kind === "directory") return undefined;
765
816
  if (owned.entry.kind === "symlink") {
766
817
  return {
@@ -769,13 +820,9 @@ export class RestoreEngine {
769
820
  linkText: owned.entry.linkText!,
770
821
  };
771
822
  }
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
- );
823
+ if (owned.entry.blobId === null || bytes === undefined) {
824
+ throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
825
+ }
779
826
  const mode = owned.entry.mode & 0o777;
780
827
  return {
781
828
  kind: "file",
@@ -20,7 +20,7 @@ import type {
20
20
  SnapshotManifest,
21
21
  SnapshotRoot,
22
22
  } from "./model.ts";
23
- import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
23
+ import { assertNoSymlinkEscape, pathSetsOverlap, relativeSafePath } from "./path-safety.ts";
24
24
  import { RootDiscovery, type RootTopology } from "./root-discovery.ts";
25
25
  import { WorkspaceLock } from "./workspace-lock.ts";
26
26
 
@@ -96,6 +96,12 @@ export interface CaptureOptions {
96
96
  readonly excludePaths?: readonly string[];
97
97
  }
98
98
 
99
+ export interface SnapshotBlobRequest {
100
+ readonly rootPath: string;
101
+ readonly blobId: string;
102
+ readonly relativePath?: string;
103
+ }
104
+
99
105
  export type SnapshotStoreErrorCode =
100
106
  | "capture_failed"
101
107
  | "invalid_manifest_id"
@@ -151,6 +157,33 @@ export class SnapshotStore {
151
157
  this.clock = options.clock ?? Date.now;
152
158
  }
153
159
 
160
+ static supportsValidatedBlobBatch(store: SnapshotStore): boolean {
161
+ return store instanceof SnapshotStore &&
162
+ store.readBlob === SnapshotStore.prototype.readBlob &&
163
+ store.loadManifest === SnapshotStore.prototype.loadManifest;
164
+ }
165
+
166
+ static async readBlobs(
167
+ store: SnapshotStore,
168
+ id: ManifestId,
169
+ requests: readonly SnapshotBlobRequest[],
170
+ ): Promise<readonly Uint8Array[]> {
171
+ if (requests.length === 0) return [];
172
+ if (SnapshotStore.supportsValidatedBlobBatch(store)) {
173
+ return (store as SnapshotStore).readBlobsValidated(id, requests);
174
+ }
175
+ const result: Uint8Array[] = [];
176
+ for (const request of requests) {
177
+ result.push(await store.readBlob(
178
+ id,
179
+ request.rootPath,
180
+ request.blobId,
181
+ request.relativePath,
182
+ ));
183
+ }
184
+ return result;
185
+ }
186
+
154
187
  async durableCacheDirectory(): Promise<string> {
155
188
  const directory = join(this.storeRoot, "durable-cache");
156
189
  await mkdir(directory, { recursive: true });
@@ -309,7 +342,10 @@ export class SnapshotStore {
309
342
  }
310
343
 
311
344
  async loadManifest(id: ManifestId): Promise<SnapshotManifest> {
312
- const manifestPath = await this.findManifestPath(id);
345
+ return this.loadManifestFromPath(id, await this.findManifestPath(id));
346
+ }
347
+
348
+ private async loadManifestFromPath(id: ManifestId, manifestPath: string): Promise<SnapshotManifest> {
313
349
  try {
314
350
  const value: unknown = JSON.parse(await readFile(manifestPath, "utf8"));
315
351
  const manifest = assertManifest(value);
@@ -349,10 +385,10 @@ export class SnapshotStore {
349
385
  const gitDirectory = this.rootGitDirectory(storeDirectory, root);
350
386
  await this.assertNoAlternates(gitDirectory);
351
387
  const entries = await this.readTreeEntries(gitDirectory, root.treeId, rootScope);
352
- if (root.ignoredPresentPaths.some((ignoredPath) => entries.some(
353
- (entry) => isPathAtOrBelow(ignoredPath, entry.relativePath) ||
354
- isPathAtOrBelow(entry.relativePath, ignoredPath),
355
- ))) {
388
+ if (pathSetsOverlap(
389
+ root.ignoredPresentPaths,
390
+ entries.map((entry) => entry.relativePath),
391
+ )) {
356
392
  throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
357
393
  }
358
394
  await this.assertObjectsComplete(gitDirectory, root.treeId, entries);
@@ -432,41 +468,67 @@ export class SnapshotStore {
432
468
  blobId: string,
433
469
  relativePath?: string,
434
470
  ): Promise<Uint8Array> {
435
- relativeSafePath("/", rootPath);
436
- if (!isObjectId(blobId)) {
437
- throw new SnapshotStoreError("object_missing", "blob ID 无效");
471
+ return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
472
+ }
473
+
474
+ private readBlobsValidated(
475
+ id: ManifestId,
476
+ requests: readonly SnapshotBlobRequest[],
477
+ ): Promise<readonly Uint8Array[]> {
478
+ return this.readBlobOperation(id, requests, true);
479
+ }
480
+
481
+ private async readBlobOperation(
482
+ id: ManifestId,
483
+ requests: readonly SnapshotBlobRequest[],
484
+ revalidateManifest: boolean,
485
+ ): Promise<readonly Uint8Array[]> {
486
+ for (const request of requests) {
487
+ relativeSafePath("/", request.rootPath);
488
+ if (!isObjectId(request.blobId)) {
489
+ throw new SnapshotStoreError("object_missing", "blob ID 无效");
490
+ }
438
491
  }
439
492
  const manifestPath = await this.findManifestPath(id);
440
- const manifest = await this.loadManifest(id);
441
- const root = manifest.roots.find((candidate) => candidate.relativeRoot === rootPath);
442
- if (root === undefined) {
443
- throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
444
- }
445
- if (root.state !== "active" || root.treeId === null) {
446
- throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
447
- }
448
-
493
+ const manifest = revalidateManifest
494
+ ? await this.loadManifestFromPath(id, manifestPath)
495
+ : await this.loadManifest(id);
496
+ const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
449
497
  const storeDirectory = dirname(dirname(manifestPath));
450
- const gitDirectory = this.rootGitDirectory(storeDirectory, root);
451
498
  try {
452
- const safeRelativePath = relativePath === undefined ? undefined : relativeSafePath("/", relativePath);
453
- if (safeRelativePath === undefined) {
454
- const entries = await this.readTreeEntries(gitDirectory, root.treeId);
455
- if (!entries.some((entry) => entry.objectId === blobId)) {
456
- throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree");
499
+ const result: Uint8Array[] = [];
500
+ for (const request of requests) {
501
+ const root = roots.get(request.rootPath);
502
+ if (root === undefined) {
503
+ throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
457
504
  }
458
- } else {
459
- const membershipKey = treeBlobMembershipKey(gitDirectory, root.treeId, safeRelativePath);
460
- let ownedBlobId = this.treeBlobMembership.get(membershipKey);
461
- if (ownedBlobId === undefined) {
462
- await this.readTreeEntries(gitDirectory, root.treeId, [safeRelativePath]);
463
- ownedBlobId = this.treeBlobMembership.get(membershipKey);
505
+ if (root.state !== "active" || root.treeId === null) {
506
+ throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
464
507
  }
465
- if (ownedBlobId !== blobId) {
466
- throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
508
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
509
+ const safeRelativePath = request.relativePath === undefined
510
+ ? undefined
511
+ : relativeSafePath("/", request.relativePath);
512
+ if (safeRelativePath === undefined) {
513
+ const entries = await this.readTreeEntries(gitDirectory, root.treeId);
514
+ if (!entries.some((entry) => entry.objectId === request.blobId)) {
515
+ throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree");
516
+ }
517
+ } else {
518
+ const membershipKey = treeBlobMembershipKey(gitDirectory, root.treeId, safeRelativePath);
519
+ let ownedBlobId = this.treeBlobMembership.get(membershipKey);
520
+ if (ownedBlobId === undefined) {
521
+ await this.readTreeEntries(gitDirectory, root.treeId, [safeRelativePath]);
522
+ ownedBlobId = this.treeBlobMembership.get(membershipKey);
523
+ }
524
+ if (ownedBlobId !== request.blobId) {
525
+ throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
526
+ }
467
527
  }
528
+ result.push(new Uint8Array(await this.readBlobBytes(gitDirectory, request.blobId)));
468
529
  }
469
- return new Uint8Array(await this.readBlobBytes(gitDirectory, blobId));
530
+ if (revalidateManifest) await this.loadManifestFromPath(id, manifestPath);
531
+ return result;
470
532
  } catch (error) {
471
533
  if (error instanceof SnapshotStoreError) {
472
534
  throw error;
@@ -634,7 +696,7 @@ export class SnapshotStore {
634
696
  gitBacked: boolean,
635
697
  inclusions: readonly string[] | null,
636
698
  exclusions: readonly string[],
637
- exactExclusions: readonly string[],
699
+ exactExclusions: ReadonlySet<string>,
638
700
  ): Promise<string[]> {
639
701
  if (inclusions === null) {
640
702
  return [];
@@ -657,7 +719,7 @@ export class SnapshotStore {
657
719
  for (const relativePath of parseNulPaths(output)) {
658
720
  if (
659
721
  exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
660
- exactExclusions.includes(relativePath)
722
+ exactExclusions.has(relativePath)
661
723
  ) {
662
724
  continue;
663
725
  }
@@ -686,7 +748,7 @@ export class SnapshotStore {
686
748
  gitBacked: boolean,
687
749
  inclusions: readonly string[] | null,
688
750
  exclusions: readonly string[],
689
- exactExclusions: readonly string[],
751
+ exactExclusions: ReadonlySet<string>,
690
752
  ): Promise<void> {
691
753
  const leaves = await this.collectVisibleLeaves(
692
754
  cwd,
@@ -758,7 +820,7 @@ export class SnapshotStore {
758
820
  gitBacked: boolean,
759
821
  inclusions: readonly string[] | null,
760
822
  exclusions: readonly string[],
761
- exactExclusions: readonly string[],
823
+ exactExclusions: ReadonlySet<string>,
762
824
  excludeDeleted = false,
763
825
  ): Promise<string[]> {
764
826
  if (inclusions === null) return [];
@@ -792,7 +854,7 @@ export class SnapshotStore {
792
854
  return parseNulPaths(output).filter((relativePath) =>
793
855
  !deletedPaths.has(relativePath) &&
794
856
  !exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) &&
795
- !exactExclusions.includes(relativePath));
857
+ !exactExclusions.has(relativePath));
796
858
  }
797
859
 
798
860
  private async collectVisibleLeaves(
@@ -801,7 +863,7 @@ export class SnapshotStore {
801
863
  gitBacked: boolean,
802
864
  inclusions: readonly string[] | null,
803
865
  exclusions: readonly string[],
804
- exactExclusions: readonly string[],
866
+ exactExclusions: ReadonlySet<string>,
805
867
  ): Promise<VisibleLeaf[]> {
806
868
  const paths = await this.queryVisibleLeafPaths(
807
869
  cwd,
@@ -1196,7 +1258,7 @@ function ownedArtifactExclusions(
1196
1258
  roots: readonly DiscoveryRoot[],
1197
1259
  rootPath: string,
1198
1260
  exclusions: readonly string[],
1199
- ): string[] {
1261
+ ): ReadonlySet<string> {
1200
1262
  const result: string[] = [];
1201
1263
  for (const exclusion of exclusions) {
1202
1264
  const owner = roots
@@ -1211,7 +1273,7 @@ function ownedArtifactExclusions(
1211
1273
  }
1212
1274
  result.push(rootRelativePath(rootPath, exclusion));
1213
1275
  }
1214
- return result;
1276
+ return new Set(result);
1215
1277
  }
1216
1278
 
1217
1279
  function rootRelativeScope(rootPath: string, scope: readonly string[] | undefined): string[] | undefined {