@rljson/fs-agent 0.0.16 → 0.0.17

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 */
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;
@@ -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.17",
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",