@davideasden/pi-undo 0.2.14 → 0.2.15

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.14",
3
+ "version": "0.2.15",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -3,7 +3,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
 
6
- import { fsyncDirectory, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
6
+ import { fsyncDirectory, writeBytesAtomic, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
7
7
  import {
8
8
  assertManifest,
9
9
  canonicalJson,
@@ -47,6 +47,7 @@ const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
47
47
  const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
48
48
  const BLOB_BATCH_MAX_ENTRIES = process.platform === "win32" ? 256 : 2_048;
49
49
  const RACY_CLEAN_WINDOW_NS = 2_000_000_000n;
50
+ const LEAF_CACHE_FILE = "leaf-cache.json";
50
51
 
51
52
  interface PinRecord {
52
53
  readonly schemaVersion: 1;
@@ -119,6 +120,22 @@ interface CachedBlob {
119
120
  size: number;
120
121
  }
121
122
 
123
+ /** 持久化叶子缓存文件(storeDirectory/leaf-cache.json),schema 不匹配时整体忽略。 */
124
+ interface PersistedLeafCacheFile {
125
+ readonly schemaVersion: 1;
126
+ readonly entries: Readonly<Record<string, Readonly<Record<string, PersistedLeafCacheEntry>>>>;
127
+ }
128
+
129
+ interface PersistedLeafCacheEntry {
130
+ readonly kind: "file" | "symlink";
131
+ readonly mode: number;
132
+ readonly fingerprint: string;
133
+ readonly cacheable: boolean;
134
+ readonly objectId: string;
135
+ readonly changedAtNs: string;
136
+ readonly verifiedAtNs: string;
137
+ }
138
+
122
139
  export interface SnapshotStoreOptions {
123
140
  readonly storeRoot?: string;
124
141
  readonly git?: GitRunner;
@@ -184,6 +201,7 @@ export class SnapshotStore {
184
201
  private readonly treeBlobMembership = new Map<string, string>();
185
202
  private readonly blobCache = new Map<string, CachedBlob>();
186
203
  private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
204
+ private readonly leafCacheDirectoriesLoaded = new Set<string>();
187
205
  private blobCacheBytes = 0;
188
206
 
189
207
  constructor(options: SnapshotStoreOptions = {}) {
@@ -258,6 +276,8 @@ export class SnapshotStore {
258
276
  }
259
277
 
260
278
  const storeDirectory = this.storeDirectory(topology);
279
+ // 新进程首次 capture 时从磁盘加载叶子指纹缓存,避免全量重新 hash。
280
+ await this.loadPersistedLeafCache(storeDirectory);
261
281
  const transactionsRoot = join(storeDirectory, "transactions");
262
282
  await mkdir(transactionsRoot, { recursive: true });
263
283
  transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
@@ -315,6 +335,8 @@ export class SnapshotStore {
315
335
  await writeContentAddressed(manifestPath, Buffer.from(canonicalJson(manifest), "utf8"));
316
336
  this.manifestLocations.set(manifestId, manifestPath);
317
337
  for (const update of cacheUpdates) this.rememberVisibleLeaves(update);
338
+ // 指纹缓存落盘:让下一个进程(新会话)的首次 capture 跳过全量内容 hash。
339
+ await this.persistLeafCache(storeDirectory);
318
340
  return manifest;
319
341
  } catch (error) {
320
342
  if (error instanceof SnapshotStoreError) {
@@ -960,6 +982,69 @@ export class SnapshotStore {
960
982
  this.visibleLeafCache.set(gitDirectory, cache);
961
983
  }
962
984
 
985
+ /** 从 storeDirectory 读取持久化叶子缓存并合并进内存;进程内已有条目优先。 */
986
+ private async loadPersistedLeafCache(storeDirectory: string): Promise<void> {
987
+ if (this.leafCacheDirectoriesLoaded.has(storeDirectory)) return;
988
+ this.leafCacheDirectoriesLoaded.add(storeDirectory);
989
+ let file: unknown;
990
+ try {
991
+ file = JSON.parse(await readFile(join(storeDirectory, LEAF_CACHE_FILE), "utf8"));
992
+ } catch {
993
+ return; // 缺失或损坏:忽略,本次 capture 走冷路径并重建缓存。
994
+ }
995
+ if (!isPersistedLeafCacheFile(file)) return;
996
+ const prefix = `${storeDirectory}${sep}`;
997
+ for (const [gitDirectory, entries] of Object.entries(file.entries)) {
998
+ if (!gitDirectory.startsWith(prefix) || this.visibleLeafCache.has(gitDirectory)) continue;
999
+ const cache = new Map<string, CachedVisibleLeaf>();
1000
+ for (const [relativePath, entry] of Object.entries(entries)) {
1001
+ cache.set(relativePath, {
1002
+ relativePath,
1003
+ kind: entry.kind,
1004
+ mode: entry.mode,
1005
+ fingerprint: entry.fingerprint,
1006
+ cacheable: entry.cacheable,
1007
+ changedAtNs: BigInt(entry.changedAtNs),
1008
+ objectId: entry.objectId,
1009
+ verifiedAtNs: BigInt(entry.verifiedAtNs),
1010
+ });
1011
+ }
1012
+ this.visibleLeafCache.set(gitDirectory, cache);
1013
+ }
1014
+ }
1015
+
1016
+ /** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
1017
+ private async persistLeafCache(storeDirectory: string): Promise<void> {
1018
+ const prefix = `${storeDirectory}${sep}`;
1019
+ const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
1020
+ for (const [gitDirectory, cache] of this.visibleLeafCache) {
1021
+ if (!gitDirectory.startsWith(prefix)) continue;
1022
+ const rootEntries: Record<string, PersistedLeafCacheEntry> = {};
1023
+ for (const [relativePath, leaf] of cache) {
1024
+ rootEntries[relativePath] = {
1025
+ kind: leaf.kind,
1026
+ mode: leaf.mode,
1027
+ fingerprint: leaf.fingerprint,
1028
+ cacheable: leaf.cacheable,
1029
+ objectId: leaf.objectId,
1030
+ changedAtNs: leaf.changedAtNs.toString(),
1031
+ verifiedAtNs: leaf.verifiedAtNs.toString(),
1032
+ };
1033
+ }
1034
+ entries[gitDirectory] = rootEntries;
1035
+ }
1036
+ try {
1037
+ // 用普通 JSON 序列化(非 canonicalJson):缓存只在本机消费,避免大规模排序开销。
1038
+ await writeBytesAtomic(
1039
+ join(storeDirectory, LEAF_CACHE_FILE),
1040
+ Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
1041
+ 0o600,
1042
+ );
1043
+ } catch {
1044
+ // 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
1045
+ }
1046
+ }
1047
+
963
1048
  private async assertVisibleLeavesUnchanged(
964
1049
  cwd: string,
965
1050
  leaves: readonly VisibleLeaf[],
@@ -1792,6 +1877,33 @@ function nativeVisibleLeafMetadata(entry: NativeMetadataEntry): VisibleLeafMetad
1792
1877
  };
1793
1878
  }
1794
1879
 
1880
+ function isPersistedLeafCacheFile(value: unknown): value is PersistedLeafCacheFile {
1881
+ if (typeof value !== "object" || value === null) return false;
1882
+ const file = value as { schemaVersion?: unknown; entries?: unknown };
1883
+ if (file.schemaVersion !== 1 || typeof file.entries !== "object" || file.entries === null) return false;
1884
+ for (const entries of Object.values(file.entries as Record<string, unknown>)) {
1885
+ if (typeof entries !== "object" || entries === null) return false;
1886
+ for (const entry of Object.values(entries as Record<string, unknown>)) {
1887
+ if (typeof entry !== "object" || entry === null) return false;
1888
+ const candidate = entry as Partial<PersistedLeafCacheEntry>;
1889
+ if (
1890
+ (candidate.kind !== "file" && candidate.kind !== "symlink") ||
1891
+ typeof candidate.mode !== "number" ||
1892
+ typeof candidate.fingerprint !== "string" ||
1893
+ typeof candidate.cacheable !== "boolean" ||
1894
+ typeof candidate.objectId !== "string" ||
1895
+ typeof candidate.changedAtNs !== "string" ||
1896
+ typeof candidate.verifiedAtNs !== "string" ||
1897
+ !/^[0-9]+$/.test(candidate.changedAtNs) ||
1898
+ !/^[0-9]+$/.test(candidate.verifiedAtNs)
1899
+ ) {
1900
+ return false;
1901
+ }
1902
+ }
1903
+ }
1904
+ return true;
1905
+ }
1906
+
1795
1907
  function visibleLeafFingerprint(metadata: VisibleLeafMetadata): string {
1796
1908
  return checksum(canonicalJson({
1797
1909
  kind: metadata.kind,