@rljson/fs-agent 0.0.25 → 0.0.27

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.
@@ -128,6 +128,16 @@ export declare const SYNC_ERROR_FILE = ".sync-errors.log";
128
128
  * pollutes the tree or churns the watcher.
129
129
  */
130
130
  export declare const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
131
+ /**
132
+ * Filename for the agent's own state, kept beside the synced folder's content
133
+ * and ignored by the scanner like the other two above.
134
+ *
135
+ * It holds one thing: the ref this folder was last known to be at. That
136
+ * survives a restart, which is the whole point — a process that comes back
137
+ * with no idea what it descends from cannot declare ancestry, and a push with
138
+ * no ancestry is one every peer has to treat as untrustworthy for deletion.
139
+ */
140
+ export declare const AGENT_STATE_FILE = ".fsagent-state.json";
131
141
  /**
132
142
  * Orchestrates filesystem operations with tree structures and blob storage
133
143
  */
@@ -255,6 +265,36 @@ export declare class FsAgent {
255
265
  * Gets the current timeout configuration
256
266
  */
257
267
  get timeouts(): Required<TimeoutConfig>;
268
+ /**
269
+ * Records the ref this folder is now at, so a restart can still say what it
270
+ * descends from.
271
+ *
272
+ * Best-effort on purpose: losing it costs ancestry on the next start, which
273
+ * degrades to an additive-only apply rather than to anything unsafe, so it
274
+ * must never be worth failing a sync over.
275
+ * @param ref - The ref the folder is now at.
276
+ */
277
+ /**
278
+ * Starts the watcher unless it is already running.
279
+ *
280
+ * Both `syncToDb` and `syncFromDb` need a live watcher and either may be
281
+ * started first. `syncToDb` used to call `watch()` unconditionally, so
282
+ * starting them in the order `syncFromDb` then `syncToDb` threw "Already
283
+ * watching" — which quietly forced every caller into push-first, the order
284
+ * that lets a reconnecting client overwrite the network with a stale tree.
285
+ * A crash is a poor reason to choose an unsafe order.
286
+ */
287
+ private _ensureWatching;
288
+ private _persistCurrentRef;
289
+ /**
290
+ * The ref this folder was last recorded at, from a previous run.
291
+ *
292
+ * Absent, unreadable and malformed all mean the same thing — this process
293
+ * cannot vouch for what it descends from — and all answer `undefined`, which
294
+ * the caller treats as "declare no ancestry".
295
+ * @returns The persisted ref, or `undefined`.
296
+ */
297
+ private _loadPersistedRef;
258
298
  /**
259
299
  * Appends a sync error entry to the error log file in the sync folder.
260
300
  * Uses synchronous I/O to guarantee the write completes even in catch blocks.
package/dist/fs-agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BsMem } from "@rljson/bs";
2
2
  import { Route, createTreesTableCfg } from "@rljson/rljson";
3
- import { watch, appendFileSync } from "fs";
3
+ import { watch, writeFileSync, existsSync, readFileSync, appendFileSync } from "fs";
4
4
  import { stat, readFile, mkdir, writeFile, readdir, rename, unlink, utimes, rm } from "fs/promises";
5
5
  import { dirname, join } from "path";
6
6
  import { hip } from "@rljson/hash";
@@ -458,6 +458,10 @@ class FsScanner {
458
458
  get tree() {
459
459
  return this._tree;
460
460
  }
461
+ /** Whether a watcher is currently installed. */
462
+ get isWatching() {
463
+ return this._watcher !== null;
464
+ }
461
465
  get rootPath() {
462
466
  return this._rootPath;
463
467
  }
@@ -1022,6 +1026,7 @@ const DEFAULT_TIMEOUTS = {
1022
1026
  const DISCONNECT_PAUSE_MAX_MS = 3e4;
1023
1027
  const SYNC_ERROR_FILE = ".sync-errors.log";
1024
1028
  const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
1029
+ const AGENT_STATE_FILE = ".fsagent-state.json";
1025
1030
  const MASS_DELETE_MIN_FILES = 100;
1026
1031
  const MASS_DELETE_MAX_RATIO = 0.3;
1027
1032
  class RestoreIncompleteError extends Error {
@@ -1105,7 +1110,12 @@ class FsAgent {
1105
1110
  this._resolveConflicts = options.resolveConflicts ?? false;
1106
1111
  this._scanner = new FsScanner(rootPath, {
1107
1112
  ...options,
1108
- ignore: [...options.ignore || [], SYNC_ERROR_FILE, ATOMIC_TMP_PREFIX],
1113
+ ignore: [
1114
+ ...options.ignore || [],
1115
+ SYNC_ERROR_FILE,
1116
+ ATOMIC_TMP_PREFIX,
1117
+ AGENT_STATE_FILE
1118
+ ],
1109
1119
  bs: this._bs
1110
1120
  });
1111
1121
  this._adapter = new FsBlobAdapter(this._bs);
@@ -1146,6 +1156,60 @@ class FsAgent {
1146
1156
  get timeouts() {
1147
1157
  return this._timeouts;
1148
1158
  }
1159
+ /**
1160
+ * Records the ref this folder is now at, so a restart can still say what it
1161
+ * descends from.
1162
+ *
1163
+ * Best-effort on purpose: losing it costs ancestry on the next start, which
1164
+ * degrades to an additive-only apply rather than to anything unsafe, so it
1165
+ * must never be worth failing a sync over.
1166
+ * @param ref - The ref the folder is now at.
1167
+ */
1168
+ /**
1169
+ * Starts the watcher unless it is already running.
1170
+ *
1171
+ * Both `syncToDb` and `syncFromDb` need a live watcher and either may be
1172
+ * started first. `syncToDb` used to call `watch()` unconditionally, so
1173
+ * starting them in the order `syncFromDb` then `syncToDb` threw "Already
1174
+ * watching" — which quietly forced every caller into push-first, the order
1175
+ * that lets a reconnecting client overwrite the network with a stale tree.
1176
+ * A crash is a poor reason to choose an unsafe order.
1177
+ */
1178
+ async _ensureWatching() {
1179
+ if (!this._scanner.isWatching) {
1180
+ await this._scanner.watch();
1181
+ }
1182
+ }
1183
+ _persistCurrentRef(ref) {
1184
+ try {
1185
+ writeFileSync(
1186
+ join(this._rootPath, AGENT_STATE_FILE),
1187
+ JSON.stringify({ currentRef: ref }),
1188
+ "utf-8"
1189
+ );
1190
+ } catch {
1191
+ }
1192
+ }
1193
+ /**
1194
+ * The ref this folder was last recorded at, from a previous run.
1195
+ *
1196
+ * Absent, unreadable and malformed all mean the same thing — this process
1197
+ * cannot vouch for what it descends from — and all answer `undefined`, which
1198
+ * the caller treats as "declare no ancestry".
1199
+ * @returns The persisted ref, or `undefined`.
1200
+ */
1201
+ _loadPersistedRef() {
1202
+ try {
1203
+ const file = join(this._rootPath, AGENT_STATE_FILE);
1204
+ if (!existsSync(file)) return void 0;
1205
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
1206
+ if (!parsed || typeof parsed !== "object") return void 0;
1207
+ const ref = parsed.currentRef;
1208
+ return typeof ref === "string" && ref.length > 0 ? ref : void 0;
1209
+ } catch {
1210
+ return void 0;
1211
+ }
1212
+ }
1149
1213
  /**
1150
1214
  * Appends a sync error entry to the error log file in the sync folder.
1151
1215
  * Uses synchronous I/O to guarantee the write completes even in catch blocks.
@@ -1791,6 +1855,15 @@ ${err.stack}` : String(err);
1791
1855
  * @returns Function to stop watching
1792
1856
  */
1793
1857
  async syncToDb(db, connector, treeKey, options) {
1858
+ if (this._currentRef === void 0) {
1859
+ const persisted = this._loadPersistedRef();
1860
+ if (persisted !== void 0) {
1861
+ this._currentRef = persisted;
1862
+ console.log(
1863
+ `[FsAgent] resuming from recorded ref ${persisted.slice(0, 8)}… — this folder can declare its ancestry`
1864
+ );
1865
+ }
1866
+ }
1794
1867
  const initialParentRef = this._currentRef;
1795
1868
  const initialTree = await FsAgent._withTimeout(
1796
1869
  this.extract(),
@@ -1810,6 +1883,7 @@ ${err.stack}` : String(err);
1810
1883
  if (initialRef) {
1811
1884
  this._lastSentRef = initialRef;
1812
1885
  this._currentRef = initialRef;
1886
+ this._persistCurrentRef(initialRef);
1813
1887
  this._lastSentContentKey = this._contentKeyFromTree(initialTree);
1814
1888
  await this._sendRef(
1815
1889
  connector,
@@ -1850,6 +1924,8 @@ ${err.stack}` : String(err);
1850
1924
  `syncToDb storeFsTree(${treeKey})`
1851
1925
  );
1852
1926
  this._currentRef = ref;
1927
+ this._persistCurrentRef(ref);
1928
+ this._persistCurrentRef(ref);
1853
1929
  if (ref === this._lastSentRef) {
1854
1930
  return;
1855
1931
  }
@@ -1870,7 +1946,7 @@ ${err.stack}` : String(err);
1870
1946
  }, this._timeouts.debounceMs);
1871
1947
  };
1872
1948
  this._scanner.onChange(debouncedSync);
1873
- await this._scanner.watch();
1949
+ await this._ensureWatching();
1874
1950
  return () => {
1875
1951
  if (debounceTimer) clearTimeout(debounceTimer);
1876
1952
  this._scanner.offChange(debouncedSync);
@@ -2113,9 +2189,7 @@ ${err.stack}` : String(err);
2113
2189
  * @returns Function to stop watching
2114
2190
  */
2115
2191
  async syncFromDb(db, connector, treeKey, restoreOptions) {
2116
- if (!this._scanner["_watcher"]) {
2117
- await this._scanner.watch();
2118
- }
2192
+ await this._ensureWatching();
2119
2193
  let pendingRef = null;
2120
2194
  let fromDbTimer = null;
2121
2195
  let pendingRecoveryAttempt = 0;
@@ -2171,8 +2245,16 @@ ${err.stack}` : String(err);
2171
2245
  return;
2172
2246
  }
2173
2247
  }
2248
+ const ancestryExpected = this._resolveConflicts;
2249
+ const declaresAncestry = (predecessorRefs?.length ?? 0) > 0;
2250
+ const applyOptions = restoreOptions?.cleanTarget && ancestryExpected && !declaresAncestry ? { ...restoreOptions, cleanTarget: false } : restoreOptions;
2251
+ if (applyOptions !== restoreOptions) {
2252
+ console.warn(
2253
+ `[FsAgent] ref=${treeRef.slice(0, 8)}… declares no ancestry — applying additively, not pruning. A sender that cannot say what it descends from must not delete.`
2254
+ );
2255
+ }
2174
2256
  await FsAgent._withTimeout(
2175
- this.restore(incomingTree, void 0, restoreOptions),
2257
+ this.restore(incomingTree, void 0, applyOptions),
2176
2258
  this._timeouts.restore,
2177
2259
  `syncFromDb → restore(${treeKey})`
2178
2260
  );
@@ -2191,6 +2273,7 @@ ${err.stack}` : String(err);
2191
2273
  this._lastSentRef = postRestoreRef;
2192
2274
  this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
2193
2275
  this._currentRef = postRestoreRef;
2276
+ this._persistCurrentRef(postRestoreRef);
2194
2277
  return;
2195
2278
  } catch (err) {
2196
2279
  if (err instanceof MassDeleteRefusedError) {
@@ -121,6 +121,8 @@ export declare class FsScanner {
121
121
  private _vanishedDuringScan;
122
122
  constructor(rootPath: string, options?: FsScanOptions);
123
123
  get tree(): FsTree | null;
124
+ /** Whether a watcher is currently installed. */
125
+ get isWatching(): boolean;
124
126
  get rootPath(): string;
125
127
  get bs(): Bs;
126
128
  scan(): Promise<FsTree>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
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",