@rljson/fs-agent 0.0.59 → 0.0.61

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.
@@ -190,6 +190,16 @@ export declare const RESTORED_BLOB_MEMORY_MAX = 50000;
190
190
  * high concurrency is how a client runs out of heap.
191
191
  */
192
192
  export declare const RESTORE_FETCH_CONCURRENCY = 16;
193
+ /**
194
+ * How many of its own past states a node remembers.
195
+ *
196
+ * Used to recognise a sender that is BEHIND this node — one that declares a
197
+ * state this node has held and left. A burst on a large folder produces a few
198
+ * dozen states, so this is generous; forgetting the oldest costs only the
199
+ * ability to spot a very stale sender, which the mass-delete guard still
200
+ * catches.
201
+ */
202
+ export declare const STATE_HISTORY_MAX = 1000;
193
203
  /**
194
204
  * What an agent concluded about an inbound ref.
195
205
  *
@@ -290,6 +300,25 @@ export declare class FsAgent {
290
300
  /** Files written vs left alone by the current {@link restore}. */
291
301
  private _restoreWritten;
292
302
  private _restoreSkipped;
303
+ /**
304
+ * Absolute paths deleted here since the last announcement.
305
+ *
306
+ * The mirror of {@link _announcedFiles}. Between a local unlink and the push
307
+ * that announces it, this node's advertised state still CONTAINS the file —
308
+ * so a peer pushing in that window descends from a state the file is in, and
309
+ * an apply re-creates it. The local scan then sees the file present, and the
310
+ * deletion is never announced at all: silently undone, everywhere.
311
+ */
312
+ private readonly _pendingDeletes;
313
+ /**
314
+ * The states this node has held, oldest first.
315
+ *
316
+ * A push declaring one of these — but not the current one — comes from a
317
+ * sender that is behind this node. Applying such a tree is what puts a
318
+ * deleted file back, so it is ignored outright rather than applied
319
+ * additively.
320
+ */
321
+ private readonly _stateHistory;
293
322
  /**
294
323
  * Absolute paths of the files this agent has actually TOLD anyone about.
295
324
  *
@@ -753,6 +782,27 @@ export declare class FsAgent {
753
782
  * its own pushes, and the states it adopts from theirs.
754
783
  * @param tree - The tree that just went out, or was adopted as ours.
755
784
  */
785
+ /**
786
+ * Records a state this node is now in.
787
+ * @param ref - The content ref of that state.
788
+ */
789
+ private _rememberState;
790
+ /**
791
+ * Whether a push comes from a sender this node has already moved past.
792
+ * @param predecessorRefs - What the push declares it descends from.
793
+ * @returns True when every declared parent is a state this node has left.
794
+ */
795
+ private _senderIsBehind;
796
+ /**
797
+ * Marks an incoming tree's files as known to the network.
798
+ *
799
+ * A union, not a replacement: {@link _rememberAnnounced} answers "what this
800
+ * node has told the network", and this answers "what the network has told
801
+ * this node". Both make a file prunable; neither alone is the whole set, and
802
+ * treating the first as the whole set is what made deletions vanish.
803
+ * @param tree - The tree just applied.
804
+ */
805
+ private _rememberReceived;
756
806
  private _rememberAnnounced;
757
807
  private _adoptAppliedRef;
758
808
  /**
package/dist/fs-agent.js CHANGED
@@ -1080,6 +1080,7 @@ const REFUSAL_ANSWER_COOLDOWN_MS = 5e3;
1080
1080
  const TREE_FETCH_CONCURRENCY = 64;
1081
1081
  const RESTORED_BLOB_MEMORY_MAX = 5e4;
1082
1082
  const RESTORE_FETCH_CONCURRENCY = 16;
1083
+ const STATE_HISTORY_MAX = 1e3;
1083
1084
  const VERDICT_REASON = {
1084
1085
  "own-echo": "is this agent's own last advertisement echoed back",
1085
1086
  stale: "is not the newest its sender has advertised"
@@ -1153,6 +1154,25 @@ class FsAgent {
1153
1154
  /** Files written vs left alone by the current {@link restore}. */
1154
1155
  _restoreWritten = 0;
1155
1156
  _restoreSkipped = 0;
1157
+ /**
1158
+ * Absolute paths deleted here since the last announcement.
1159
+ *
1160
+ * The mirror of {@link _announcedFiles}. Between a local unlink and the push
1161
+ * that announces it, this node's advertised state still CONTAINS the file —
1162
+ * so a peer pushing in that window descends from a state the file is in, and
1163
+ * an apply re-creates it. The local scan then sees the file present, and the
1164
+ * deletion is never announced at all: silently undone, everywhere.
1165
+ */
1166
+ _pendingDeletes = /* @__PURE__ */ new Set();
1167
+ /**
1168
+ * The states this node has held, oldest first.
1169
+ *
1170
+ * A push declaring one of these — but not the current one — comes from a
1171
+ * sender that is behind this node. Applying such a tree is what puts a
1172
+ * deleted file back, so it is ignored outright rather than applied
1173
+ * additively.
1174
+ */
1175
+ _stateHistory = [];
1156
1176
  /**
1157
1177
  * Absolute paths of the files this agent has actually TOLD anyone about.
1158
1178
  *
@@ -1268,6 +1288,7 @@ class FsAgent {
1268
1288
  }
1269
1289
  }
1270
1290
  _persistCurrentRef(ref) {
1291
+ this._rememberState(ref);
1271
1292
  try {
1272
1293
  writeFileSync(
1273
1294
  join(this._rootPath, AGENT_STATE_FILE),
@@ -1594,6 +1615,10 @@ ${err.stack}` : String(err);
1594
1615
  this._restoreSkipped++;
1595
1616
  return;
1596
1617
  }
1618
+ if (this._pendingDeletes.has(filePath)) {
1619
+ this._restoreSkipped++;
1620
+ return;
1621
+ }
1597
1622
  let fileBlob;
1598
1623
  try {
1599
1624
  fileBlob = await this._bs.getBlob(meta.blobId);
@@ -2035,9 +2060,12 @@ ${err.stack}` : String(err);
2035
2060
  this._flushDeferredRescan = () => {
2036
2061
  if (!this._rescanDeferred) return;
2037
2062
  this._rescanDeferred = false;
2038
- debouncedSync({ type: "safety-rescan" });
2063
+ debouncedSync({ type: "safety-rescan", path: "." });
2039
2064
  };
2040
2065
  const debouncedSync = (change) => {
2066
+ if (change?.type === "deleted" && change.path !== ".") {
2067
+ this._pendingDeletes.add(join(this._rootPath, change.path));
2068
+ }
2041
2069
  if (change?.type === "safety-rescan" && this._remoteApplyInFlight) {
2042
2070
  this._rescanDeferred = true;
2043
2071
  return;
@@ -2378,11 +2406,48 @@ ${err.stack}` : String(err);
2378
2406
  * its own pushes, and the states it adopts from theirs.
2379
2407
  * @param tree - The tree that just went out, or was adopted as ours.
2380
2408
  */
2409
+ /**
2410
+ * Records a state this node is now in.
2411
+ * @param ref - The content ref of that state.
2412
+ */
2413
+ _rememberState(ref) {
2414
+ if (this._stateHistory.includes(ref)) return;
2415
+ this._stateHistory.push(ref);
2416
+ if (this._stateHistory.length > STATE_HISTORY_MAX) {
2417
+ this._stateHistory.shift();
2418
+ }
2419
+ }
2420
+ /**
2421
+ * Whether a push comes from a sender this node has already moved past.
2422
+ * @param predecessorRefs - What the push declares it descends from.
2423
+ * @returns True when every declared parent is a state this node has left.
2424
+ */
2425
+ _senderIsBehind(predecessorRefs) {
2426
+ if (predecessorRefs.length === 0) return false;
2427
+ return predecessorRefs.every(
2428
+ (ref) => ref !== this._currentRef && ref !== this._lastAppliedRef && this._stateHistory.includes(ref)
2429
+ );
2430
+ }
2431
+ /**
2432
+ * Marks an incoming tree's files as known to the network.
2433
+ *
2434
+ * A union, not a replacement: {@link _rememberAnnounced} answers "what this
2435
+ * node has told the network", and this answers "what the network has told
2436
+ * this node". Both make a file prunable; neither alone is the whole set, and
2437
+ * treating the first as the whole set is what made deletions vanish.
2438
+ * @param tree - The tree just applied.
2439
+ */
2440
+ _rememberReceived(tree) {
2441
+ for (const path of this._getFileContentMap(tree).keys()) {
2442
+ this._announcedFiles.add(join(this._rootPath, path));
2443
+ }
2444
+ }
2381
2445
  _rememberAnnounced(tree) {
2382
2446
  this._announcedFiles.clear();
2383
2447
  for (const path of this._getFileContentMap(tree).keys()) {
2384
2448
  this._announcedFiles.add(join(this._rootPath, path));
2385
2449
  }
2450
+ this._pendingDeletes.clear();
2386
2451
  }
2387
2452
  _adoptAppliedRef(connector, treeRef) {
2388
2453
  if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
@@ -2549,6 +2614,12 @@ ${err.stack}` : String(err);
2549
2614
  (r) => r !== void 0
2550
2615
  );
2551
2616
  const senderSawMyState = !ancestryIsCarried || !declaresAncestry || statesIAmIn.some((r) => predecessorRefs.includes(r));
2617
+ if (ancestryIsCarried && declaresAncestry && this._senderIsBehind(predecessorRefs)) {
2618
+ console.warn(
2619
+ `[FsAgent] ref=${treeRef.slice(0, 8)}… descends from a state this node has already left — ignoring, the sender will catch up and re-push.`
2620
+ );
2621
+ return;
2622
+ }
2552
2623
  const withholdPrune = restoreOptions?.cleanTarget && (ancestryExpected && !declaresAncestry || !senderSawMyState);
2553
2624
  const applyOptions = withholdPrune ? { ...restoreOptions, cleanTarget: false } : restoreOptions;
2554
2625
  if (applyOptions !== restoreOptions) {
@@ -2580,6 +2651,7 @@ ${err.stack}` : String(err);
2580
2651
  this._adoptAppliedRef(connector, treeRef);
2581
2652
  this._currentRef = postRestoreRef;
2582
2653
  this._persistCurrentRef(postRestoreRef);
2654
+ this._rememberReceived(incomingTree);
2583
2655
  const postRestoreFiles = this._getFileContentMap(postRestoreTree);
2584
2656
  const incomingFiles = this._getFileContentMap(incomingTree);
2585
2657
  let hasNewsOfOurOwn = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
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",