@rljson/fs-agent 0.0.16 → 0.0.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.
@@ -14,6 +14,13 @@ export interface FsAgentOptions {
14
14
  maxDepth?: number;
15
15
  /** Follow symlinks (default: false) */
16
16
  followSymlinks?: boolean;
17
+ /**
18
+ * Persist a path→(mtime, size, blobId) scan cache at this file path so a RESTART
19
+ * does not re-read + re-hash the whole folder (a cold scan of an 80 GB catalog
20
+ * is ~48 min). Forwarded to the {@link FsScanner}. Requires a PERSISTENT blob
21
+ * store (e.g. `@rljson/bs-fs`). See {@link FsScanOptions.scanCachePath}.
22
+ */
23
+ scanCachePath?: string;
17
24
  /** Database instance for automatic syncing */
18
25
  db?: Db;
19
26
  /** Tree key for database storage */
@@ -126,6 +133,12 @@ export declare class FsAgent {
126
133
  private _stopSync?;
127
134
  private _stopSyncFromDb?;
128
135
  private _lastSentRef?;
136
+ /**
137
+ * The incoming ref most recently applied. Retired from the connector's dedup
138
+ * sets when the next one supersedes it, so a peer returning the tree to that
139
+ * state can still reach this agent.
140
+ */
141
+ private _lastAppliedRef?;
129
142
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
130
143
  private _lastSentContentKey?;
131
144
  private _timeouts;
package/dist/fs-agent.js CHANGED
@@ -426,15 +426,25 @@ class FsScanner {
426
426
  _safetyTimer = null;
427
427
  /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
428
428
  _stopRequested = false;
429
+ /** Path→content cache backing {@link FsScanOptions.scanCachePath} (unused when unset). */
430
+ _scanCachePath;
431
+ /** Last-scan cache (loaded from disk); consulted to skip re-read/re-hash. */
432
+ _blobCache = /* @__PURE__ */ new Map();
433
+ /** Cache rebuilt during the CURRENT scan; becomes `_blobCache` at the end (self-pruning). */
434
+ _nextBlobCache = /* @__PURE__ */ new Map();
435
+ /** Ensures the persisted cache is read from disk only once. */
436
+ _cacheLoaded = false;
429
437
  constructor(rootPath, options = {}) {
430
438
  this._rootPath = rootPath;
431
439
  this._options = {
432
440
  ignore: options.ignore || ["node_modules", ".git", "dist", "coverage"],
433
441
  maxDepth: options.maxDepth,
434
442
  followSymlinks: options.followSymlinks ?? false,
435
- bs: options.bs
443
+ bs: options.bs,
444
+ scanCachePath: options.scanCachePath
436
445
  };
437
446
  this._bs = options.bs || new BsMem();
447
+ this._scanCachePath = options.scanCachePath;
438
448
  }
439
449
  get tree() {
440
450
  return this._tree;
@@ -463,6 +473,10 @@ class FsScanner {
463
473
  `Cannot access root path "${this._rootPath}": ${error instanceof Error ? error.message : String(error)}`
464
474
  );
465
475
  }
476
+ if (this._scanCachePath) {
477
+ await this._loadPersistedCache();
478
+ this._nextBlobCache = /* @__PURE__ */ new Map();
479
+ }
466
480
  const trees = /* @__PURE__ */ new Map();
467
481
  let rootTree;
468
482
  try {
@@ -484,6 +498,10 @@ class FsScanner {
484
498
  rootHash: rootHashStr,
485
499
  trees
486
500
  };
501
+ if (this._scanCachePath) {
502
+ this._blobCache = this._nextBlobCache;
503
+ await this._persistCache();
504
+ }
487
505
  return this._tree;
488
506
  }
489
507
  async _scanDirectory(absolutePath, relativePath, depth, trees) {
@@ -514,26 +532,41 @@ class FsScanner {
514
532
  trees.set(childHashStr, childTree);
515
533
  childRefs.push(childHashStr);
516
534
  } else if (entry.isFile()) {
517
- let fileContent;
518
- try {
519
- fileContent = await readFile(childPath);
520
- } catch (error) {
521
- throw new Error(
522
- `Failed to read file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
523
- );
524
- }
525
- let blobProps;
526
- try {
527
- blobProps = await this._bs.setBlob(fileContent);
528
- } catch (error) {
529
- throw new Error(
530
- `Failed to store blob for file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
531
- );
535
+ const mtimeMs = childStats.mtime.getTime();
536
+ const cached = this._scanCachePath ? this._blobCache.get(childRelPath) : void 0;
537
+ let blobId;
538
+ if (cached && cached.mtime === mtimeMs && cached.size === childStats.size) {
539
+ blobId = cached.blobId;
540
+ } else {
541
+ let fileContent;
542
+ try {
543
+ fileContent = await readFile(childPath);
544
+ } catch (error) {
545
+ throw new Error(
546
+ `Failed to read file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
547
+ );
548
+ }
549
+ let blobProps;
550
+ try {
551
+ blobProps = await this._bs.setBlob(fileContent);
552
+ } catch (error) {
553
+ throw new Error(
554
+ `Failed to store blob for file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
555
+ );
556
+ }
557
+ if (!blobProps || !blobProps.blobId) {
558
+ throw new Error(
559
+ `Blob storage returned invalid blobId for file "${childRelPath}"`
560
+ );
561
+ }
562
+ blobId = blobProps.blobId;
532
563
  }
533
- if (!blobProps || !blobProps.blobId) {
534
- throw new Error(
535
- `Blob storage returned invalid blobId for file "${childRelPath}"`
536
- );
564
+ if (this._scanCachePath) {
565
+ this._nextBlobCache.set(childRelPath, {
566
+ mtime: mtimeMs,
567
+ size: childStats.size,
568
+ blobId
569
+ });
537
570
  }
538
571
  const fileMeta = {
539
572
  name: entry.name,
@@ -544,8 +577,8 @@ class FsScanner {
544
577
  // the same ref on every client) but NOT for directories (a folder's
545
578
  // mtime is per-machine and does not round-trip). The absolute `path`
546
579
  // is excluded everywhere — it is folder-specific.
547
- mtime: childStats.mtime.getTime(),
548
- blobId: blobProps.blobId
580
+ mtime: mtimeMs,
581
+ blobId
549
582
  // Link to content in Bs
550
583
  };
551
584
  const fileTree = {
@@ -577,6 +610,41 @@ class FsScanner {
577
610
  };
578
611
  return dirTree;
579
612
  }
613
+ /**
614
+ * Load the persisted scan cache from {@link FsScanOptions.scanCachePath} once.
615
+ * A missing or corrupt cache file is tolerated (the scan just starts cold).
616
+ */
617
+ async _loadPersistedCache() {
618
+ if (this._cacheLoaded) return;
619
+ this._cacheLoaded = true;
620
+ if (!this._scanCachePath) return;
621
+ try {
622
+ const raw = await readFile(this._scanCachePath, "utf8");
623
+ const parsed = JSON.parse(raw);
624
+ if (Array.isArray(parsed.entries)) {
625
+ this._blobCache = new Map(parsed.entries);
626
+ }
627
+ } catch {
628
+ }
629
+ }
630
+ /**
631
+ * Write the current scan cache to {@link FsScanOptions.scanCachePath} atomically
632
+ * (temp + rename). Best-effort: a failed cache write never fails the scan.
633
+ */
634
+ async _persistCache() {
635
+ if (!this._scanCachePath) return;
636
+ const body = JSON.stringify({
637
+ version: 1,
638
+ entries: [...this._blobCache.entries()]
639
+ });
640
+ const tmp = `${this._scanCachePath}.${Date.now().toString(36)}.tmp`;
641
+ try {
642
+ await mkdir(dirname(this._scanCachePath), { recursive: true });
643
+ await writeFile(tmp, body);
644
+ await rename(tmp, this._scanCachePath);
645
+ } catch {
646
+ }
647
+ }
580
648
  _shouldIgnore(name) {
581
649
  if (!this._options.ignore) {
582
650
  return false;
@@ -865,6 +933,12 @@ class FsAgent {
865
933
  _stopSync;
866
934
  _stopSyncFromDb;
867
935
  _lastSentRef;
936
+ /**
937
+ * The incoming ref most recently applied. Retired from the connector's dedup
938
+ * sets when the next one supersedes it, so a peer returning the tree to that
939
+ * state can still reach this agent.
940
+ */
941
+ _lastAppliedRef;
868
942
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
869
943
  _lastSentContentKey;
870
944
  _timeouts;
@@ -1051,6 +1125,7 @@ ${err.stack}` : String(err);
1051
1125
  * via an explicit send, which pre-empts the Connector's db-observer path.
1052
1126
  */
1053
1127
  async _sendRef(connector, ref, predecessorRefs) {
1128
+ connector.invalidateSent?.(ref);
1054
1129
  if (this._resolveConflicts) {
1055
1130
  connector.setPredecessors(predecessorRefs ?? []);
1056
1131
  }
@@ -1802,6 +1877,10 @@ ${err.stack}` : String(err);
1802
1877
  skipNotification: true,
1803
1878
  previous
1804
1879
  });
1880
+ if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
1881
+ connector.invalidateSent?.(this._lastAppliedRef);
1882
+ }
1883
+ this._lastAppliedRef = treeRef;
1805
1884
  this._lastSentRef = postRestoreRef;
1806
1885
  this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
1807
1886
  this._currentRef = postRestoreRef;
@@ -67,6 +67,17 @@ export interface FsScanOptions {
67
67
  followSymlinks?: boolean;
68
68
  /** Blob storage implementation (defaults to BsMem) */
69
69
  bs?: Bs;
70
+ /**
71
+ * Persist a path→(mtime, size, blobId) scan cache at this file path. When set,
72
+ * a file whose mtime AND size are unchanged since the last scan is NOT re-read
73
+ * or re-hashed — its cached `blobId` is reused. The cache is loaded once on the
74
+ * first {@link FsScanner.scan} and re-written after every scan, so a RESTART
75
+ * does not re-read the whole folder (a cold scan of an 80 GB catalog is ~48
76
+ * min) and the periodic safety-rescan stays cheap. Requires a PERSISTENT blob
77
+ * store (e.g. `@rljson/bs-fs`) — with an in-RAM store the cached blob is gone
78
+ * after a restart. Omit to disable (default: full read+hash every scan).
79
+ */
80
+ scanCachePath?: string;
70
81
  }
71
82
  /**
72
83
  * Scans and watches file system changes, extracting RLJSON tree structure
@@ -84,12 +95,30 @@ export declare class FsScanner {
84
95
  private _safetyTimer;
85
96
  /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
86
97
  private _stopRequested;
98
+ /** Path→content cache backing {@link FsScanOptions.scanCachePath} (unused when unset). */
99
+ private _scanCachePath?;
100
+ /** Last-scan cache (loaded from disk); consulted to skip re-read/re-hash. */
101
+ private _blobCache;
102
+ /** Cache rebuilt during the CURRENT scan; becomes `_blobCache` at the end (self-pruning). */
103
+ private _nextBlobCache;
104
+ /** Ensures the persisted cache is read from disk only once. */
105
+ private _cacheLoaded;
87
106
  constructor(rootPath: string, options?: FsScanOptions);
88
107
  get tree(): FsTree | null;
89
108
  get rootPath(): string;
90
109
  get bs(): Bs;
91
110
  scan(): Promise<FsTree>;
92
111
  private _scanDirectory;
112
+ /**
113
+ * Load the persisted scan cache from {@link FsScanOptions.scanCachePath} once.
114
+ * A missing or corrupt cache file is tolerated (the scan just starts cold).
115
+ */
116
+ private _loadPersistedCache;
117
+ /**
118
+ * Write the current scan cache to {@link FsScanOptions.scanCachePath} atomically
119
+ * (temp + rename). Best-effort: a failed cache write never fails the scan.
120
+ */
121
+ private _persistCache;
93
122
  private _shouldIgnore;
94
123
  watch(): Promise<void>;
95
124
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Rljson fs-agent description",
5
5
  "homepage": "https://github.com/rljson/fs-agent",
6
6
  "bugs": "https://github.com/rljson/fs-agent/issues",
@@ -28,7 +28,7 @@
28
28
  "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
29
29
  },
30
30
  "devDependencies": {
31
- "@rljson/server": "^0.0.10",
31
+ "@rljson/server": "^0.0.43",
32
32
  "@types/node": "^25.3.1",
33
33
  "@typescript-eslint/eslint-plugin": "^8.56.1",
34
34
  "@typescript-eslint/parser": "^8.56.1",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@rljson/bs": "^0.0.21",
54
- "@rljson/db": "^0.0.23",
54
+ "@rljson/db": "^0.0.30",
55
55
  "@rljson/hash": "^0.0.18",
56
56
  "@rljson/io": "^0.0.66",
57
57
  "@rljson/json": "^0.0.23",