@rljson/fs-agent 0.0.8 → 0.0.10

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/README.public.md CHANGED
@@ -747,10 +747,20 @@ const agent = new FsAgent('./my-project', bs, {
747
747
  restore: 15_000, // Filesystem restore (default: 15s)
748
748
  syncCallback: 25_000, // Overall syncFromDb callback (default: 25s)
749
749
  debounceMs: 300, // Coalesce rapid FS events (default: 300ms)
750
+ processRefRetries: 3, // Retry failed refs before dropping (default: 3)
751
+ processRefRetryDelayMs: 5_000, // Base delay between retries (default: 5s)
750
752
  },
751
753
  });
752
754
  ```
753
755
 
756
+ ### processRef Retry Behavior
757
+
758
+ When `syncFromDb` receives a tree ref and fails to process it (e.g. `db.get`
759
+ times out because the IoPeer transport hasn't connected yet), the ref is
760
+ retried up to `processRefRetries` times with increasing delay
761
+ (`attempt * processRefRetryDelayMs`). This prevents a single transient
762
+ timeout from permanently breaking the sync pipeline.
763
+
754
764
  ## Bounce-Back Prevention
755
765
 
756
766
  Bidirectional sync can cause infinite loops when both clients detect each
@@ -747,10 +747,20 @@ const agent = new FsAgent('./my-project', bs, {
747
747
  restore: 15_000, // Filesystem restore (default: 15s)
748
748
  syncCallback: 25_000, // Overall syncFromDb callback (default: 25s)
749
749
  debounceMs: 300, // Coalesce rapid FS events (default: 300ms)
750
+ processRefRetries: 3, // Retry failed refs before dropping (default: 3)
751
+ processRefRetryDelayMs: 5_000, // Base delay between retries (default: 5s)
750
752
  },
751
753
  });
752
754
  ```
753
755
 
756
+ ### processRef Retry Behavior
757
+
758
+ When `syncFromDb` receives a tree ref and fails to process it (e.g. `db.get`
759
+ times out because the IoPeer transport hasn't connected yet), the ref is
760
+ retried up to `processRefRetries` times with increasing delay
761
+ (`attempt * processRefRetryDelayMs`). This prevents a single transient
762
+ timeout from permanently breaking the sync pipeline.
763
+
754
764
  ## Bounce-Back Prevention
755
765
 
756
766
  Bidirectional sync can cause infinite loops when both clients detect each
@@ -73,7 +73,21 @@ export interface TimeoutConfig {
73
73
  * Also applies to incoming database refs in syncFromDb.
74
74
  */
75
75
  debounceMs?: number;
76
+ /**
77
+ * Number of retries for processRef in syncFromDb. Default: 3.
78
+ * When a ref fails to process (e.g. db.get timeout because the IoPeer
79
+ * transport hasn't connected yet), the ref is retried this many times
80
+ * with increasing delay before being dropped.
81
+ */
82
+ processRefRetries?: number;
83
+ /**
84
+ * Base delay between processRef retries (milliseconds). Default: 5 000 ms.
85
+ * Each retry waits `attempt * processRefRetryDelayMs` (i.e. 5s, 10s, 15s).
86
+ */
87
+ processRefRetryDelayMs?: number;
76
88
  }
89
+ /** Filename for sync error log written to the sync folder */
90
+ export declare const SYNC_ERROR_FILE = ".sync-errors.log";
77
91
  /**
78
92
  * Orchestrates filesystem operations with tree structures and blob storage
79
93
  */
@@ -111,6 +125,13 @@ export declare class FsAgent {
111
125
  * Gets the current timeout configuration
112
126
  */
113
127
  get timeouts(): Required<TimeoutConfig>;
128
+ /**
129
+ * Appends a sync error entry to the error log file in the sync folder.
130
+ * Uses synchronous I/O to guarantee the write completes even in catch blocks.
131
+ * @param context - Label identifying where the error occurred
132
+ * @param err - The error value caught
133
+ */
134
+ _writeSyncError(context: string, err: unknown): void;
114
135
  /**
115
136
  * Wraps a promise with a timeout.
116
137
  * Rejects with a descriptive error if the promise does not settle
package/dist/fs-agent.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { BsMem } from "@rljson/bs";
2
2
  import { Route, createTreesTableCfg } from "@rljson/rljson";
3
+ import { watch, appendFileSync } from "fs";
3
4
  import { stat, readFile, mkdir, writeFile, readdir, utimes, rm } from "fs/promises";
4
5
  import { dirname, join, sep } from "path";
5
6
  import { hip } from "@rljson/hash";
6
- import { watch } from "fs";
7
7
  import { Db } from "@rljson/db";
8
8
  import { IoMem, createSocketPair } from "@rljson/io";
9
9
  import { Server, Client } from "@rljson/server";
@@ -484,8 +484,11 @@ const DEFAULT_TIMEOUTS = {
484
484
  extract: 15e3,
485
485
  restore: 15e3,
486
486
  syncCallback: 25e3,
487
- debounceMs: 300
487
+ debounceMs: 300,
488
+ processRefRetries: 3,
489
+ processRefRetryDelayMs: 5e3
488
490
  };
491
+ const SYNC_ERROR_FILE = ".sync-errors.log";
489
492
  class FsAgent {
490
493
  _scanner;
491
494
  _adapter;
@@ -505,7 +508,11 @@ class FsAgent {
505
508
  this._db = options.db;
506
509
  this._treeKey = options.treeKey;
507
510
  this._timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
508
- this._scanner = new FsScanner(rootPath, { ...options, bs: this._bs });
511
+ this._scanner = new FsScanner(rootPath, {
512
+ ...options,
513
+ ignore: [...options.ignore || [], SYNC_ERROR_FILE],
514
+ bs: this._bs
515
+ });
509
516
  this._adapter = new FsBlobAdapter(this._bs);
510
517
  if (this._db && this._treeKey) {
511
518
  this._startAutoSync().catch(() => {
@@ -544,6 +551,23 @@ class FsAgent {
544
551
  get timeouts() {
545
552
  return this._timeouts;
546
553
  }
554
+ /**
555
+ * Appends a sync error entry to the error log file in the sync folder.
556
+ * Uses synchronous I/O to guarantee the write completes even in catch blocks.
557
+ * @param context - Label identifying where the error occurred
558
+ * @param err - The error value caught
559
+ */
560
+ _writeSyncError(context, err) {
561
+ try {
562
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
563
+ const msg = err instanceof Error ? `${err.message}
564
+ ${err.stack}` : String(err);
565
+ const entry = `[${ts}] ${context}: ${msg}
566
+ `;
567
+ appendFileSync(join(this._rootPath, SYNC_ERROR_FILE), entry);
568
+ } catch {
569
+ }
570
+ }
547
571
  /**
548
572
  * Wraps a promise with a timeout.
549
573
  * Rejects with a descriptive error if the promise does not settle
@@ -772,6 +796,14 @@ class FsAgent {
772
796
  if (error instanceof Error && error.message.startsWith("Timeout")) {
773
797
  throw error;
774
798
  }
799
+ const errMsg = error instanceof Error ? error.message : String(error);
800
+ console.warn(
801
+ `[FsAgent] _fetchTreeRecursively: db.get failed for hash=${currentHash.slice(0, 8)}…: ${errMsg}`
802
+ );
803
+ this._writeSyncError(
804
+ `fetchTree/db.get(${currentHash.slice(0, 8)}…)`,
805
+ error
806
+ );
775
807
  continue;
776
808
  }
777
809
  const treeData = result?.rljson?.[treeKey];
@@ -938,7 +970,9 @@ class FsAgent {
938
970
  if (ref) {
939
971
  await this._sendRef(connector, ref);
940
972
  }
941
- } catch {
973
+ } catch (err) {
974
+ console.error("[FsAgent] syncToDb failed:", err);
975
+ this._writeSyncError("syncToDb", err);
942
976
  }
943
977
  }
944
978
  }, this._timeouts.debounceMs);
@@ -1020,36 +1054,64 @@ class FsAgent {
1020
1054
  let pendingRef = null;
1021
1055
  let fromDbTimer = null;
1022
1056
  const processRef = async (treeRef) => {
1023
- this._scanner.pauseWatch();
1024
- try {
1025
- const incomingTree = await FsAgent._withTimeout(
1026
- this._fetchTreeFromDb(db, treeKey, treeRef),
1027
- this._timeouts.fetchTree,
1028
- `syncFromDb → fetchTree(${treeKey}@${treeRef.slice(0, 8)}…)`
1029
- );
1030
- const currentTree = await FsAgent._withTimeout(
1031
- this.extract(),
1032
- this._timeouts.extract,
1033
- `syncFromDb → extract(${this._rootPath})`
1034
- );
1035
- if (this._treesHaveEquivalentContent(currentTree, incomingTree)) {
1057
+ const maxAttempts = this._timeouts.processRefRetries + 1;
1058
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1059
+ this._scanner.pauseWatch();
1060
+ try {
1061
+ const incomingTree = await FsAgent._withTimeout(
1062
+ this._fetchTreeFromDb(db, treeKey, treeRef),
1063
+ this._timeouts.fetchTree,
1064
+ `syncFromDb fetchTree(${treeKey}@${treeRef.slice(0, 8)}…)`
1065
+ );
1066
+ const incomingNodeCount = incomingTree.trees.size;
1067
+ console.log(
1068
+ `[FsAgent] syncFromDb: fetched tree with ${incomingNodeCount} nodes for ref=${treeRef.slice(0, 8)}…`
1069
+ );
1070
+ const currentTree = await FsAgent._withTimeout(
1071
+ this.extract(),
1072
+ this._timeouts.extract,
1073
+ `syncFromDb → extract(${this._rootPath})`
1074
+ );
1075
+ if (this._treesHaveEquivalentContent(currentTree, incomingTree)) {
1076
+ const incomingFiles = this._getFileContentMap(incomingTree);
1077
+ const currentFiles = this._getFileContentMap(currentTree);
1078
+ console.log(
1079
+ `[FsAgent] syncFromDb: equivalent content, skipping restore (incoming=${incomingFiles.size} entries, current=${currentFiles.size} entries, ref=${treeRef.slice(0, 8)}…)`
1080
+ );
1081
+ return;
1082
+ }
1083
+ await FsAgent._withTimeout(
1084
+ this.restore(incomingTree, void 0, restoreOptions),
1085
+ this._timeouts.restore,
1086
+ `syncFromDb → restore(${treeKey})`
1087
+ );
1088
+ const postRestoreTree = await this._scanner.scan();
1089
+ const dbAdapter = new FsDbAdapter(db, treeKey);
1090
+ const postRestoreRef = await dbAdapter.storeFsTree(postRestoreTree, {
1091
+ skipNotification: true
1092
+ });
1093
+ this._lastSentRef = postRestoreRef;
1094
+ this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
1036
1095
  return;
1096
+ } catch (err) {
1097
+ if (attempt === maxAttempts) {
1098
+ console.error(
1099
+ `[FsAgent] syncFromDb processRef failed after ${maxAttempts} attempts:`,
1100
+ err
1101
+ );
1102
+ this._writeSyncError("syncFromDb/processRef", err);
1103
+ } else {
1104
+ const delaySec = attempt * this._timeouts.processRefRetryDelayMs / 1e3;
1105
+ console.warn(
1106
+ `[FsAgent] syncFromDb: attempt ${attempt}/${maxAttempts} failed for ref=${treeRef.slice(0, 8)}…, retrying in ${delaySec}s: ${err instanceof Error ? err.message : String(err)}`
1107
+ );
1108
+ }
1109
+ } finally {
1110
+ this._scanner.resumeWatch();
1037
1111
  }
1038
- await FsAgent._withTimeout(
1039
- this.restore(incomingTree, void 0, restoreOptions),
1040
- this._timeouts.restore,
1041
- `syncFromDb → restore(${treeKey})`
1112
+ await new Promise(
1113
+ (r) => setTimeout(r, attempt * this._timeouts.processRefRetryDelayMs)
1042
1114
  );
1043
- const postRestoreTree = await this._scanner.scan();
1044
- const dbAdapter = new FsDbAdapter(db, treeKey);
1045
- const postRestoreRef = await dbAdapter.storeFsTree(postRestoreTree, {
1046
- skipNotification: true
1047
- });
1048
- this._lastSentRef = postRestoreRef;
1049
- this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
1050
- } catch {
1051
- } finally {
1052
- this._scanner.resumeWatch();
1053
1115
  }
1054
1116
  };
1055
1117
  const syncCallback = async (treeRef) => {
@@ -1201,5 +1263,6 @@ export {
1201
1263
  FsBlobAdapter,
1202
1264
  FsDbAdapter,
1203
1265
  FsScanner,
1266
+ SYNC_ERROR_FILE,
1204
1267
  runClientServerSetup
1205
1268
  };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { FsAgent, type FsAgentOptions, type RestoreOptions, type TimeoutConfig, } from './fs-agent.ts';
1
+ export { FsAgent, SYNC_ERROR_FILE, type FsAgentOptions, type RestoreOptions, type TimeoutConfig, } from './fs-agent.ts';
2
2
  export { FsBlobAdapter, type BlobToFileOptions, type FileBlobMeta, type FileToBlobOptions, } from './fs-blob-adapter.ts';
3
3
  export { FsDbAdapter, type StoreFsTreeOptions } from './fs-db-adapter.ts';
4
4
  export { FsScanner, type FsChange, type FsChangeCallback, type FsChangeType, type FsNodeMeta, type FsScanOptions, type FsTree, } from './fs-scanner.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",