@rljson/fs-agent 0.0.39 → 0.0.41

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.
@@ -176,6 +176,15 @@ export declare const TREE_FETCH_CONCURRENCY = 64;
176
176
  * which is the cheap half of the trade.
177
177
  */
178
178
  export declare const RESTORED_BLOB_MEMORY_MAX = 50000;
179
+ /**
180
+ * What an agent concluded about an inbound ref.
181
+ *
182
+ * See `FsAgent._inboundRefVerdict` for why this is one decision rather than
183
+ * several conditions.
184
+ */
185
+ export type InboundRefVerdict = 'apply' | 'own-echo' | 'stale';
186
+ /** How each non-applying verdict reads in a log line. */
187
+ export declare const VERDICT_REASON: Record<Exclude<InboundRefVerdict, 'apply'>, string>;
179
188
  export declare const MASS_DELETE_MIN_FILES = 100;
180
189
  /**
181
190
  * Above this share of the folder, a prune is treated as suspicious rather than
@@ -254,6 +263,16 @@ export declare class FsAgent {
254
263
  * view — and undo a deletion the peer just made.
255
264
  */
256
265
  private _remoteApplyInFlight;
266
+ /**
267
+ * Whether a safety rescan was suppressed while a remote apply was running.
268
+ *
269
+ * The rescan is what covers a watcher that drops or coalesces events, so a
270
+ * suppressed one has to be re-run rather than forgotten — see the deferral in
271
+ * `syncToDb`'s change handler.
272
+ */
273
+ private _rescanDeferred;
274
+ /** Re-runs a deferred rescan once the apply that blocked it has finished. */
275
+ private _flushDeferredRescan;
257
276
  /** Files written vs left alone by the current {@link restore}. */
258
277
  private _restoreWritten;
259
278
  private _restoreSkipped;
@@ -653,6 +672,48 @@ export declare class FsAgent {
653
672
  * @returns `true` when the tree carries no entries at all.
654
673
  */
655
674
  private _treeIsEmpty;
675
+ /**
676
+ * Whether an inbound ref is news to this agent, and if not, why.
677
+ *
678
+ * "Is this ref news to me" is the question this subsystem keeps getting
679
+ * wrong. It has been answered in five separate places — the connector's sent
680
+ * and received dedup sets, `_lastSentContentKey`, `_lastSentRef`,
681
+ * `_lastAppliedRef` — and each has been wrong at least once:
682
+ *
683
+ * - an agent applied its OWN last advertisement over its own newer edit,
684
+ * destroying it and then declining to re-send (fixed 0.0.30);
685
+ * - a delete propagated only from the client that had created the file,
686
+ * because the send path never retired the state it left (0.0.31);
687
+ * - a refusal consumed the ref it refused, so the same emptiness arriving
688
+ * twice was silent the second time (0.0.33);
689
+ * - a restarted agent inherited its predecessor's conclusions (0.0.34);
690
+ * - a quiet join announced anyway, because the connector broadcasts on
691
+ * local insert and the gate was on the send (0.0.38).
692
+ *
693
+ * Collecting the pre-fetch gates here does not fix a sixth. It makes the
694
+ * question answerable in one place, in one order, with the reasoning
695
+ * attached — which is what the previous five each lacked.
696
+ *
697
+ * Order matters and is deliberate. The own-echo check comes first because it
698
+ * is the only one that holds regardless of what the sender believes: the two
699
+ * defences that ought to catch an echo both miss it. The origin filter
700
+ * compares the payload's origin to this connector's, and a bootstrap carries
701
+ * the SERVER as origin rather than the client the ref came from; the
702
+ * staleness check then measures the server's sequence, which did advance, so
703
+ * the echo reads as news.
704
+ *
705
+ * KNOWN LIMIT, stated because the next person will meet it: this compares
706
+ * against the LAST ref this agent sent, so an echo of an OLDER
707
+ * self-originated ref still gets through. Widening it to a set would also
708
+ * suppress a peer's legitimate revert to a state this agent once held. The
709
+ * real fix is for the bootstrap to carry the originating client, so the
710
+ * origin filter works and this check stops being needed at all.
711
+ * @param treeRef - The inbound ref.
712
+ * @param isNewestFromSender - Whether the connector judged it the newest
713
+ * thing its sender has advertised. Unknown answers `true`.
714
+ * @returns `apply`, or the reason it is not news.
715
+ */
716
+ private _inboundRefVerdict;
656
717
  private _adoptAppliedRef;
657
718
  /**
658
719
  * Derives a deterministic content key from an FsTree.
package/dist/fs-agent.js CHANGED
@@ -1030,6 +1030,10 @@ const AGENT_STATE_FILE = ".fsagent-state.json";
1030
1030
  const REFUSAL_ANSWER_COOLDOWN_MS = 5e3;
1031
1031
  const TREE_FETCH_CONCURRENCY = 64;
1032
1032
  const RESTORED_BLOB_MEMORY_MAX = 5e4;
1033
+ const VERDICT_REASON = {
1034
+ "own-echo": "is this agent's own last advertisement echoed back",
1035
+ stale: "is not the newest its sender has advertised"
1036
+ };
1033
1037
  const MASS_DELETE_MIN_FILES = 100;
1034
1038
  const MASS_DELETE_MAX_RATIO = 0.3;
1035
1039
  class RestoreIncompleteError extends Error {
@@ -1086,6 +1090,16 @@ class FsAgent {
1086
1090
  * view — and undo a deletion the peer just made.
1087
1091
  */
1088
1092
  _remoteApplyInFlight = false;
1093
+ /**
1094
+ * Whether a safety rescan was suppressed while a remote apply was running.
1095
+ *
1096
+ * The rescan is what covers a watcher that drops or coalesces events, so a
1097
+ * suppressed one has to be re-run rather than forgotten — see the deferral in
1098
+ * `syncToDb`'s change handler.
1099
+ */
1100
+ _rescanDeferred = false;
1101
+ /** Re-runs a deferred rescan once the apply that blocked it has finished. */
1102
+ _flushDeferredRescan;
1089
1103
  /** Files written vs left alone by the current {@link restore}. */
1090
1104
  _restoreWritten = 0;
1091
1105
  _restoreSkipped = 0;
@@ -1928,8 +1942,14 @@ ${err.stack}` : String(err);
1928
1942
  );
1929
1943
  }
1930
1944
  let debounceTimer = null;
1945
+ this._flushDeferredRescan = () => {
1946
+ if (!this._rescanDeferred) return;
1947
+ this._rescanDeferred = false;
1948
+ debouncedSync({ type: "safety-rescan" });
1949
+ };
1931
1950
  const debouncedSync = (change) => {
1932
1951
  if (change?.type === "safety-rescan" && this._remoteApplyInFlight) {
1952
+ this._rescanDeferred = true;
1933
1953
  return;
1934
1954
  }
1935
1955
  if (debounceTimer) clearTimeout(debounceTimer);
@@ -2183,6 +2203,52 @@ ${err.stack}` : String(err);
2183
2203
  _treeIsEmpty(tree) {
2184
2204
  return this._getFileContentMap(tree).size === 0;
2185
2205
  }
2206
+ /**
2207
+ * Whether an inbound ref is news to this agent, and if not, why.
2208
+ *
2209
+ * "Is this ref news to me" is the question this subsystem keeps getting
2210
+ * wrong. It has been answered in five separate places — the connector's sent
2211
+ * and received dedup sets, `_lastSentContentKey`, `_lastSentRef`,
2212
+ * `_lastAppliedRef` — and each has been wrong at least once:
2213
+ *
2214
+ * - an agent applied its OWN last advertisement over its own newer edit,
2215
+ * destroying it and then declining to re-send (fixed 0.0.30);
2216
+ * - a delete propagated only from the client that had created the file,
2217
+ * because the send path never retired the state it left (0.0.31);
2218
+ * - a refusal consumed the ref it refused, so the same emptiness arriving
2219
+ * twice was silent the second time (0.0.33);
2220
+ * - a restarted agent inherited its predecessor's conclusions (0.0.34);
2221
+ * - a quiet join announced anyway, because the connector broadcasts on
2222
+ * local insert and the gate was on the send (0.0.38).
2223
+ *
2224
+ * Collecting the pre-fetch gates here does not fix a sixth. It makes the
2225
+ * question answerable in one place, in one order, with the reasoning
2226
+ * attached — which is what the previous five each lacked.
2227
+ *
2228
+ * Order matters and is deliberate. The own-echo check comes first because it
2229
+ * is the only one that holds regardless of what the sender believes: the two
2230
+ * defences that ought to catch an echo both miss it. The origin filter
2231
+ * compares the payload's origin to this connector's, and a bootstrap carries
2232
+ * the SERVER as origin rather than the client the ref came from; the
2233
+ * staleness check then measures the server's sequence, which did advance, so
2234
+ * the echo reads as news.
2235
+ *
2236
+ * KNOWN LIMIT, stated because the next person will meet it: this compares
2237
+ * against the LAST ref this agent sent, so an echo of an OLDER
2238
+ * self-originated ref still gets through. Widening it to a set would also
2239
+ * suppress a peer's legitimate revert to a state this agent once held. The
2240
+ * real fix is for the bootstrap to carry the originating client, so the
2241
+ * origin filter works and this check stops being needed at all.
2242
+ * @param treeRef - The inbound ref.
2243
+ * @param isNewestFromSender - Whether the connector judged it the newest
2244
+ * thing its sender has advertised. Unknown answers `true`.
2245
+ * @returns `apply`, or the reason it is not news.
2246
+ */
2247
+ _inboundRefVerdict(treeRef, isNewestFromSender) {
2248
+ if (treeRef === this._lastSentRef) return "own-echo";
2249
+ if (!isNewestFromSender) return "stale";
2250
+ return "apply";
2251
+ }
2186
2252
  _adoptAppliedRef(connector, treeRef) {
2187
2253
  if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
2188
2254
  connector.invalidateSent?.(this._lastAppliedRef);
@@ -2278,17 +2344,14 @@ ${err.stack}` : String(err);
2278
2344
  const processRef = async (treeRef, recoveryAttempt = 0, predecessorRefs, isNewestFromSender = true) => {
2279
2345
  const maxAttempts = this._timeouts.processRefRetries + 1;
2280
2346
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2281
- if (treeRef === this._lastSentRef) {
2347
+ const verdict = this._inboundRefVerdict(treeRef, isNewestFromSender);
2348
+ if (verdict !== "apply") {
2282
2349
  console.warn(
2283
- `[FsAgent] ref=${treeRef.slice(0, 8)}… is this agent's own last advertisement echoed back — ignoring it.`
2350
+ `[FsAgent] ref=${treeRef.slice(0, 8)}… ${VERDICT_REASON[verdict]} — ignoring it.`
2284
2351
  );
2285
- return;
2286
- }
2287
- if (!isNewestFromSender) {
2288
- console.warn(
2289
- `[FsAgent] ref=${treeRef.slice(0, 8)}… is not the newest its sender has advertised — ignoring it.`
2290
- );
2291
- connector.invalidateReceived(treeRef);
2352
+ if (verdict === "stale") {
2353
+ connector.invalidateReceived(treeRef);
2354
+ }
2292
2355
  return;
2293
2356
  }
2294
2357
  this._scanner.pauseWatch();
@@ -2412,6 +2475,7 @@ ${err.stack}` : String(err);
2412
2475
  } finally {
2413
2476
  this._remoteApplyInFlight = false;
2414
2477
  this._scanner.resumeWatch();
2478
+ this._flushDeferredRescan?.();
2415
2479
  }
2416
2480
  await new Promise(
2417
2481
  (r) => setTimeout(r, attempt * this._timeouts.processRefRetryDelayMs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
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",
@@ -19,6 +19,14 @@
19
19
  "dist"
20
20
  ],
21
21
  "type": "module",
22
+ "scripts": {
23
+ "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
24
+ "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
25
+ "prebuild": "npm run test",
26
+ "prepublishOnly": "npm run build",
27
+ "lint": "pnpm exec eslint .",
28
+ "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
29
+ },
22
30
  "devDependencies": {
23
31
  "@rljson/server": "^0.0.43",
24
32
  "@types/node": "^25.3.1",
@@ -51,11 +59,13 @@
51
59
  "socket.io": "^4.8.3",
52
60
  "socket.io-client": "^4.8.3"
53
61
  },
54
- "scripts": {
55
- "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
56
- "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
57
- "prebuild": "npm run test",
58
- "lint": "pnpm exec eslint .",
59
- "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
60
- }
61
- }
62
+ "pnpm": {
63
+ "onlyBuiltDependencies": [
64
+ "esbuild"
65
+ ],
66
+ "overrides": {
67
+ "@rljson/rljson": "^0.0.78"
68
+ }
69
+ },
70
+ "packageManager": "pnpm@10.11.0"
71
+ }