@davideasden/pi-undo 0.2.17 → 0.2.18

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,7 +1,8 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "keywords": [
7
8
  "pi-package",
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { copyFile, link, lstat, open, readFile, rename, rm, type FileHandle } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
@@ -194,7 +194,12 @@ export async function createDurablePack(
194
194
  await rm(temporary, { force: true }).catch(() => {});
195
195
  throw error;
196
196
  }
197
- const packChecksum = checksum(Buffer.concat([MAGIC, lengthBytes, headerBytes, ...payloads]));
197
+ const digest = createHash("sha256");
198
+ digest.update(MAGIC);
199
+ digest.update(lengthBytes);
200
+ digest.update(headerBytes);
201
+ for (const payload of payloads) digest.update(payload);
202
+ const packChecksum = digest.digest("hex");
198
203
  return durablePackFromInput(input.opId, input.planDigest, packPath, packChecksum, entries);
199
204
  }
200
205
 
@@ -38,6 +38,7 @@ const PREPARED_PLAN_CACHE_LIMIT = 16;
38
38
  const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
39
39
  const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
40
40
  const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
41
+ const RESTORE_FILE_VERIFY_CONCURRENCY = 32;
41
42
 
42
43
  export interface RestorePlan {
43
44
  currentManifestId: ManifestId;
@@ -519,7 +520,13 @@ export class RestoreEngine {
519
520
  return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
520
521
  }
521
522
  try {
522
- await this.assertCompleteVisibleSubset(topologyBefore, [current, target], options.mutationJournal);
523
+ await this.assertCompleteVisibleSubset(
524
+ topologyBefore,
525
+ [current, target],
526
+ options.mutationJournal,
527
+ [],
528
+ this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
529
+ );
523
530
  } catch {
524
531
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
525
532
  }
@@ -588,6 +595,11 @@ export class RestoreEngine {
588
595
  );
589
596
  return result;
590
597
  }
598
+ try {
599
+ await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
600
+ } catch {
601
+ // 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
602
+ }
591
603
  const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
592
604
  if (!preflight.ok) {
593
605
  return {
@@ -625,7 +637,13 @@ export class RestoreEngine {
625
637
 
626
638
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
627
639
  assertUnchangedTopology(topologyBefore, topologyAfter);
628
- await this.assertCompleteVisibleSubset(topologyAfter, [target], options.mutationJournal);
640
+ await this.assertCompleteVisibleSubset(
641
+ topologyAfter,
642
+ [target],
643
+ options.mutationJournal,
644
+ [],
645
+ this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
646
+ );
629
647
  const verification = await this.verifyTarget(
630
648
  target,
631
649
  currentPaths,
@@ -702,6 +720,7 @@ export class RestoreEngine {
702
720
  ? []
703
721
  : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
704
722
  }),
723
+ this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
705
724
  );
706
725
  const totalPaths = plan.deletePaths.length + plan.writePaths.length;
707
726
  if ((await options.mutationJournal.load()).length !== 0) {
@@ -887,6 +906,41 @@ export class RestoreEngine {
887
906
  return result;
888
907
  }
889
908
 
909
+ private completeCoverageOwnedPaths(
910
+ scopePaths: readonly string[] | undefined,
911
+ ownedPaths: readonly ReadonlyMap<string, OwnedPath>[],
912
+ ): readonly ReadonlyMap<string, OwnedPath>[] | undefined {
913
+ return scopePaths === undefined ? ownedPaths : undefined;
914
+ }
915
+
916
+ private async prefetchCompleteRestoreBlobs(
917
+ plan: RestorePlan,
918
+ current: SnapshotManifest,
919
+ target: SnapshotManifest,
920
+ currentPaths: ReadonlyMap<string, OwnedPath>,
921
+ targetPaths: ReadonlyMap<string, OwnedPath>,
922
+ ): Promise<void> {
923
+ if (plan.scopePaths !== undefined || !SnapshotStore.supportsValidatedBlobBatch(this.store)) return;
924
+ const requestsFor = (paths: ReadonlyMap<string, OwnedPath>, extraPaths: readonly string[] = []) => {
925
+ const requests = [];
926
+ const requested = new Set([...plan.writePaths, ...extraPaths]);
927
+ for (const path of requested) {
928
+ const owned = paths.get(path);
929
+ if (owned === undefined || owned.entry.kind !== "file" || owned.entry.blobId === null) continue;
930
+ requests.push({
931
+ rootPath: owned.root.relativeRoot,
932
+ blobId: owned.entry.blobId,
933
+ relativePath: owned.entry.relativePath,
934
+ });
935
+ }
936
+ return requests;
937
+ };
938
+ await Promise.all([
939
+ this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
940
+ this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
941
+ ]);
942
+ }
943
+
890
944
  private assertCurrentTopology(
891
945
  current: SnapshotManifest,
892
946
  target: SnapshotManifest,
@@ -926,12 +980,13 @@ export class RestoreEngine {
926
980
  const paths = [...new Set([...currentPaths.keys(), ...targetPaths.keys()])]
927
981
  .filter((path) => scope === undefined || scope.has(path))
928
982
  .sort(comparePaths);
929
- let verifiedPaths = 0;
930
- for (const path of paths) {
931
- if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) {
932
- verifiedPaths += 1;
933
- continue;
934
- }
983
+ const results: Array<boolean | undefined> = new Array(paths.length);
984
+ let nextIndex = 0;
985
+ let stop = false;
986
+ let failure: unknown;
987
+ let failureIndex: number | undefined;
988
+ const verifyPath = async (path: string): Promise<boolean> => {
989
+ if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) return true;
935
990
  const currentPath = currentPaths.get(path);
936
991
  const targetPath = targetPaths.get(path);
937
992
  const matchesCurrent = currentPath !== undefined &&
@@ -941,7 +996,37 @@ export class RestoreEngine {
941
996
  const matchesAbsentSide = !matchesCurrent && !matchesTarget &&
942
997
  (currentPath === undefined || targetPath === undefined) &&
943
998
  await this.pathIsAbsent(path);
944
- if (!matchesCurrent && !matchesTarget && !matchesAbsentSide) {
999
+ return matchesCurrent || matchesTarget || matchesAbsentSide;
1000
+ };
1001
+ const worker = async (): Promise<void> => {
1002
+ while (!stop && nextIndex < paths.length) {
1003
+ const index = nextIndex;
1004
+ nextIndex += 1;
1005
+ try {
1006
+ const ok = await verifyPath(paths[index]!);
1007
+ results[index] = ok;
1008
+ if (!ok) stop = true;
1009
+ } catch (error) {
1010
+ if (failureIndex === undefined || index < failureIndex) {
1011
+ failure = error;
1012
+ failureIndex = index;
1013
+ }
1014
+ stop = true;
1015
+ }
1016
+ }
1017
+ };
1018
+ if (paths.length > 0) {
1019
+ await Promise.all(Array.from(
1020
+ { length: Math.min(RESTORE_FILE_VERIFY_CONCURRENCY, paths.length) },
1021
+ () => worker(),
1022
+ ));
1023
+ }
1024
+ let verifiedPaths = 0;
1025
+ for (let index = 0; index < results.length; index += 1) {
1026
+ const ok = results[index];
1027
+ if (ok === false) return { ok: false, verifiedPaths, totalPaths: paths.length };
1028
+ if (ok === undefined) {
1029
+ if (failureIndex === index && failure !== undefined) throw failure;
945
1030
  return { ok: false, verifiedPaths, totalPaths: paths.length };
946
1031
  }
947
1032
  verifiedPaths += 1;
@@ -1168,7 +1253,13 @@ export class RestoreEngine {
1168
1253
  await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
1169
1254
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1170
1255
  assertUnchangedTopology(topologyBefore, topologyAfter);
1171
- await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
1256
+ await this.assertCompleteVisibleSubset(
1257
+ topologyAfter,
1258
+ [current],
1259
+ options.mutationJournal,
1260
+ [],
1261
+ this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1262
+ );
1172
1263
  const verification = await this.verifyTarget(
1173
1264
  current,
1174
1265
  targetPaths,
@@ -1205,7 +1296,13 @@ export class RestoreEngine {
1205
1296
  try {
1206
1297
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1207
1298
  assertUnchangedTopology(topologyBefore, topologyAfter);
1208
- await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
1299
+ await this.assertCompleteVisibleSubset(
1300
+ topologyAfter,
1301
+ [current],
1302
+ options.mutationJournal,
1303
+ [],
1304
+ this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1305
+ );
1209
1306
  const verification = await this.verifyTarget(
1210
1307
  current,
1211
1308
  targetPaths,
@@ -1397,16 +1494,17 @@ export class RestoreEngine {
1397
1494
  allowedManifests: readonly SnapshotManifest[],
1398
1495
  mutationJournal?: MutationJournal,
1399
1496
  extraExclusions: readonly string[] = [],
1497
+ ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
1400
1498
  ): Promise<void> {
1401
1499
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
1402
1500
  return;
1403
1501
  }
1404
1502
  const allowedPaths = new Set<string>();
1405
- for (const manifest of allowedManifests) {
1503
+ for (const [index, manifest] of allowedManifests.entries()) {
1406
1504
  for (const path of ignoredWorkspacePaths(manifest)) {
1407
1505
  allowedPaths.add(path);
1408
1506
  }
1409
- const paths = await this.readOwnedPaths(manifest);
1507
+ const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1410
1508
  for (const [path, owned] of paths) {
1411
1509
  if (owned.entry.kind !== "directory") {
1412
1510
  allowedPaths.add(path);
@@ -1435,23 +1533,18 @@ export class RestoreEngine {
1435
1533
  deletePaths: readonly string[],
1436
1534
  scopePaths?: readonly string[],
1437
1535
  ): Promise<{ verifiedPaths: number; totalPaths: number; pathFingerprints: string[] }> {
1438
- const pathFingerprints: string[] = [];
1439
1536
  const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
1440
- for (const [path, owned] of targetPaths) {
1441
- if (scope !== undefined && !scope.has(path)) continue;
1442
- pathFingerprints.push(await this.verifyEntry(target.manifestId, owned));
1443
- }
1444
- let verifiedPaths = pathFingerprints.length;
1445
- let totalPaths = pathFingerprints.length;
1446
- for (const path of deletePaths) {
1447
- if (
1448
- targetPaths.has(path) ||
1449
- currentPaths.get(path)?.entry.kind === "directory" ||
1450
- hasNonDirectoryAncestor(path, targetPaths)
1451
- ) {
1452
- continue;
1453
- }
1454
- totalPaths += 1;
1537
+ const scopedTargets = [...targetPaths].filter(([path]) => scope === undefined || scope.has(path));
1538
+ const pathFingerprints = await mapConcurrentOrdered(
1539
+ scopedTargets,
1540
+ RESTORE_FILE_VERIFY_CONCURRENCY,
1541
+ ([, owned]) => this.verifyEntry(target.manifestId, owned),
1542
+ );
1543
+ const remainingDeletes = deletePaths.filter((path) =>
1544
+ !targetPaths.has(path) &&
1545
+ currentPaths.get(path)?.entry.kind !== "directory" &&
1546
+ !hasNonDirectoryAncestor(path, targetPaths));
1547
+ await mapConcurrentOrdered(remainingDeletes, RESTORE_FILE_VERIFY_CONCURRENCY, async (path) => {
1455
1548
  try {
1456
1549
  await lstat(this.absolutePath(path));
1457
1550
  throw new Error(`目标应删除的路径仍然存在:${path}`);
@@ -1460,11 +1553,10 @@ export class RestoreEngine {
1460
1553
  throw error;
1461
1554
  }
1462
1555
  }
1463
- verifiedPaths += 1;
1464
- }
1556
+ });
1465
1557
  return {
1466
- verifiedPaths,
1467
- totalPaths,
1558
+ verifiedPaths: pathFingerprints.length + remainingDeletes.length,
1559
+ totalPaths: pathFingerprints.length + remainingDeletes.length,
1468
1560
  pathFingerprints,
1469
1561
  };
1470
1562
  }
@@ -210,6 +210,8 @@ export class SnapshotStore {
210
210
  private readonly blobCache = new Map<string, CachedBlob>();
211
211
  private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
212
212
  private readonly leafCacheDirectoriesLoaded = new Set<string>();
213
+ private readonly configuredPrivateRepositories = new Set<string>();
214
+ private readonly leafCacheDirtyDirectories = new Set<string>();
213
215
  private blobCacheBytes = 0;
214
216
 
215
217
  constructor(options: SnapshotStoreOptions = {}) {
@@ -741,6 +743,61 @@ export class SnapshotStore {
741
743
  return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
742
744
  }
743
745
 
746
+ /** 按 root 批量预取普通文件 blob;membership 与 manifest 校验仍走只读路径。 */
747
+ async prefetchBlobs(id: ManifestId, requests: readonly SnapshotBlobRequest[]): Promise<void> {
748
+ if (requests.length === 0) return;
749
+ for (const request of requests) {
750
+ relativeSafePath("/", request.rootPath);
751
+ if (!isObjectId(request.blobId)) {
752
+ throw new SnapshotStoreError("object_missing", "blob ID 无效");
753
+ }
754
+ if (request.relativePath === undefined) {
755
+ throw new SnapshotStoreError("object_missing", "blob 预取必须提供 root-relative path");
756
+ }
757
+ }
758
+ const manifestPath = await this.findManifestPath(id);
759
+ const manifest = await this.loadManifest(id);
760
+ const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
761
+ const storeDirectory = dirname(dirname(manifestPath));
762
+ const byRoot = new Map<string, SnapshotBlobRequest[]>();
763
+ for (const request of requests) {
764
+ const grouped = byRoot.get(request.rootPath) ?? [];
765
+ grouped.push(request);
766
+ byRoot.set(request.rootPath, grouped);
767
+ }
768
+ try {
769
+ const grouped = new Map<string, Map<string, CapturedTreeEntry>>();
770
+ for (const [rootPath, rootRequests] of byRoot) {
771
+ const root = roots.get(rootPath);
772
+ if (root === undefined) {
773
+ throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
774
+ }
775
+ if (root.state !== "active" || root.treeId === null) {
776
+ throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
777
+ }
778
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
779
+ const entries = await this.readTreeEntries(gitDirectory, root.treeId);
780
+ const byPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
781
+ const unique = grouped.get(gitDirectory) ?? new Map<string, CapturedTreeEntry>();
782
+ for (const request of rootRequests) {
783
+ const safeRelativePath = relativeSafePath("/", request.relativePath!);
784
+ const entry = byPath.get(safeRelativePath);
785
+ if (entry === undefined || entry.objectId !== request.blobId) {
786
+ throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
787
+ }
788
+ unique.set(entry.objectId, entry);
789
+ }
790
+ grouped.set(gitDirectory, unique);
791
+ }
792
+ for (const [gitDirectory, unique] of grouped) {
793
+ await this.preloadBlobBytes(gitDirectory, [...unique.values()]);
794
+ }
795
+ } catch (error) {
796
+ if (error instanceof SnapshotStoreError) throw error;
797
+ throw new SnapshotStoreError("object_missing", "blob 无法预取", { cause: error });
798
+ }
799
+ }
800
+
744
801
  private readBlobsValidated(
745
802
  id: ManifestId,
746
803
  requests: readonly SnapshotBlobRequest[],
@@ -1155,9 +1212,10 @@ export class SnapshotStore {
1155
1212
 
1156
1213
  private rememberVisibleLeaves(update: VisibleLeafCacheUpdate): void {
1157
1214
  const { gitDirectory, staged, inclusions } = update;
1215
+ const previous = this.visibleLeafCache.get(gitDirectory);
1158
1216
  const cache = inclusions !== null && inclusions.length === 0
1159
1217
  ? new Map<string, CachedVisibleLeaf>()
1160
- : new Map(this.visibleLeafCache.get(gitDirectory));
1218
+ : new Map(previous);
1161
1219
  if (inclusions !== null && inclusions.length > 0) {
1162
1220
  for (const relativePath of cache.keys()) {
1163
1221
  if (inclusions.some((inclusion) => isPathAtOrBelow(inclusion, relativePath))) {
@@ -1174,6 +1232,9 @@ export class SnapshotStore {
1174
1232
  }
1175
1233
  cache.set(leaf.relativePath, { ...leaf, objectId, verifiedAtNs: staged.verifiedAtNs });
1176
1234
  }
1235
+ if (!samePersistedLeafCache(previous, cache)) {
1236
+ this.leafCacheDirtyDirectories.add(storeDirectoryForGitDirectory(gitDirectory));
1237
+ }
1177
1238
  this.visibleLeafCache.set(gitDirectory, cache);
1178
1239
  }
1179
1240
 
@@ -1210,6 +1271,7 @@ export class SnapshotStore {
1210
1271
 
1211
1272
  /** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
1212
1273
  private async persistLeafCache(storeDirectory: string): Promise<void> {
1274
+ if (!this.leafCacheDirtyDirectories.has(storeDirectory)) return;
1213
1275
  const prefix = `${storeDirectory}${sep}`;
1214
1276
  const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
1215
1277
  for (const [gitDirectory, cache] of this.visibleLeafCache) {
@@ -1235,6 +1297,7 @@ export class SnapshotStore {
1235
1297
  Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
1236
1298
  0o600,
1237
1299
  );
1300
+ this.leafCacheDirtyDirectories.delete(storeDirectory);
1238
1301
  } catch {
1239
1302
  // 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
1240
1303
  }
@@ -1428,9 +1491,11 @@ export class SnapshotStore {
1428
1491
  }
1429
1492
 
1430
1493
  private async configurePrivateRepository(gitDirectory: string): Promise<void> {
1494
+ if (this.configuredPrivateRepositories.has(gitDirectory)) return;
1431
1495
  const environment = cleanGitEnvironment();
1432
1496
  await this.runGit(["--git-dir", gitDirectory, "config", "gc.auto", "0"], { env: environment });
1433
1497
  await this.runGit(["--git-dir", gitDirectory, "config", "maintenance.auto", "false"], { env: environment });
1498
+ this.configuredPrivateRepositories.add(gitDirectory);
1434
1499
  }
1435
1500
 
1436
1501
  private async assertNoAlternates(gitDirectory: string): Promise<void> {
@@ -1982,6 +2047,35 @@ function blobCacheKey(gitDirectory: string, objectId: string): string {
1982
2047
  return `${gitDirectory}\0${objectId}`;
1983
2048
  }
1984
2049
 
2050
+ function storeDirectoryForGitDirectory(gitDirectory: string): string {
2051
+ return dirname(dirname(dirname(gitDirectory)));
2052
+ }
2053
+
2054
+ function samePersistedLeafCache(
2055
+ left: ReadonlyMap<string, CachedVisibleLeaf> | undefined,
2056
+ right: ReadonlyMap<string, CachedVisibleLeaf>,
2057
+ ): boolean {
2058
+ if (left === undefined) return right.size === 0;
2059
+ if (left.size !== right.size) return false;
2060
+ for (const [path, entry] of right) {
2061
+ const existing = left.get(path);
2062
+ const existingTrusted = existing !== undefined &&
2063
+ existing.verifiedAtNs > existing.changedAtNs + RACY_CLEAN_WINDOW_NS;
2064
+ const entryTrusted = entry.verifiedAtNs > entry.changedAtNs + RACY_CLEAN_WINDOW_NS;
2065
+ if (
2066
+ existing === undefined ||
2067
+ existing.kind !== entry.kind ||
2068
+ existing.mode !== entry.mode ||
2069
+ existing.fingerprint !== entry.fingerprint ||
2070
+ existing.cacheable !== entry.cacheable ||
2071
+ existing.objectId !== entry.objectId ||
2072
+ existing.changedAtNs !== entry.changedAtNs ||
2073
+ existingTrusted !== entryTrusted
2074
+ ) return false;
2075
+ }
2076
+ return true;
2077
+ }
2078
+
1985
2079
  function blobReadBatches(entries: readonly CapturedTreeEntry[]): CapturedTreeEntry[][] {
1986
2080
  const result: CapturedTreeEntry[][] = [];
1987
2081
  let batch: CapturedTreeEntry[] = [];