@rljson/fs-agent 0.0.23 → 0.0.25

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.
@@ -131,6 +131,28 @@ export declare const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
131
131
  /**
132
132
  * Orchestrates filesystem operations with tree structures and blob storage
133
133
  */
134
+ /**
135
+ * A prune smaller than this many files is always allowed through.
136
+ *
137
+ * Below it, "most of the folder" is not a meaningful statement: emptying a
138
+ * three-file folder is an ordinary edit, and a guard that blocked it would
139
+ * fire constantly on small trees and be turned off.
140
+ */
141
+ export declare const MASS_DELETE_MIN_FILES = 100;
142
+ /**
143
+ * Above this share of the folder, a prune is treated as suspicious rather than
144
+ * intentional.
145
+ */
146
+ export declare const MASS_DELETE_MAX_RATIO = 0.3;
147
+ /**
148
+ * A restore that could not put the folder into the state the tree describes.
149
+ *
150
+ * The distinction that matters to callers is not why. It is that the folder
151
+ * does NOT match the tree afterwards, so the ref must not be recorded as
152
+ * applied and the resulting state must not be advertised to peers.
153
+ */
154
+ export declare class RestoreIncompleteError extends Error {
155
+ }
134
156
  /**
135
157
  * Thrown when a restore wrote everything it could but at least one file was
136
158
  * held open by another process.
@@ -142,10 +164,28 @@ export declare const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
142
164
  * ref as applied — would make one locked file look like an edit that everyone
143
165
  * else must adopt.
144
166
  */
145
- export declare class PartialRestoreError extends Error {
167
+ export declare class PartialRestoreError extends RestoreIncompleteError {
146
168
  readonly lockedPaths: string[];
147
169
  constructor(lockedPaths: string[]);
148
170
  }
171
+ /**
172
+ * Thrown when `cleanTarget` would have deleted most of the folder.
173
+ *
174
+ * The dangerous direction of sync is a POPULATED node receiving a tree that
175
+ * lacks its files: a peer that comes up empty — a fresh clone, a folder not
176
+ * yet mounted, a bootstrap that raced its own first scan — advertises an empty
177
+ * tree, and every other node faithfully deletes everything it has.
178
+ *
179
+ * Nothing downstream can tell that apart from a genuine bulk deletion, so the
180
+ * judgement has to be made here, and it is deliberately biased: refusing a
181
+ * real mass delete costs one manual step, applying a false one costs the data.
182
+ */
183
+ export declare class MassDeleteRefusedError extends RestoreIncompleteError {
184
+ readonly wouldPrune: number;
185
+ readonly totalFiles: number;
186
+ readonly incomingFiles: number;
187
+ constructor(wouldPrune: number, totalFiles: number, incomingFiles: number);
188
+ }
149
189
  export declare class FsAgent {
150
190
  private _scanner;
151
191
  private _adapter;
package/dist/fs-agent.js CHANGED
@@ -1022,7 +1022,11 @@ const DEFAULT_TIMEOUTS = {
1022
1022
  const DISCONNECT_PAUSE_MAX_MS = 3e4;
1023
1023
  const SYNC_ERROR_FILE = ".sync-errors.log";
1024
1024
  const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
1025
- class PartialRestoreError extends Error {
1025
+ const MASS_DELETE_MIN_FILES = 100;
1026
+ const MASS_DELETE_MAX_RATIO = 0.3;
1027
+ class RestoreIncompleteError extends Error {
1028
+ }
1029
+ class PartialRestoreError extends RestoreIncompleteError {
1026
1030
  constructor(lockedPaths) {
1027
1031
  super(
1028
1032
  `restore could not write ${lockedPaths.length} locked file${lockedPaths.length === 1 ? "" : "s"}: ${lockedPaths.join(", ")}`
@@ -1031,6 +1035,19 @@ class PartialRestoreError extends Error {
1031
1035
  this.name = "PartialRestoreError";
1032
1036
  }
1033
1037
  }
1038
+ class MassDeleteRefusedError extends RestoreIncompleteError {
1039
+ constructor(wouldPrune, totalFiles, incomingFiles) {
1040
+ super(
1041
+ // No pluralisation: the guard only fires above MASS_DELETE_MIN_FILES,
1042
+ // so this is never one file.
1043
+ `refusing to prune ${wouldPrune} of ${totalFiles} local files: the incoming tree has ${incomingFiles === 0 ? "NO files at all" : `only ${incomingFiles}`}, which looks like a peer that came up empty rather than a deletion. Nothing was deleted.`
1044
+ );
1045
+ this.wouldPrune = wouldPrune;
1046
+ this.totalFiles = totalFiles;
1047
+ this.incomingFiles = incomingFiles;
1048
+ this.name = "MassDeleteRefusedError";
1049
+ }
1050
+ }
1034
1051
  class FsAgent {
1035
1052
  _scanner;
1036
1053
  _adapter;
@@ -1347,6 +1364,26 @@ ${err.stack}` : String(err);
1347
1364
  );
1348
1365
  }
1349
1366
  if (options?.cleanTarget) {
1367
+ let wouldPrune = 0;
1368
+ for (const existing of preRestore) {
1369
+ if (!expectedFiles.has(existing)) wouldPrune++;
1370
+ }
1371
+ if (wouldPrune > MASS_DELETE_MIN_FILES && (expectedFiles.size === 0 || wouldPrune / preRestore.size > MASS_DELETE_MAX_RATIO)) {
1372
+ console.error(
1373
+ `[FsAgent] MASS DELETE REFUSED on ${target}: the incoming tree would remove ${wouldPrune} of ${preRestore.size} files (incoming tree has ${expectedFiles.size}). Nothing was deleted. If this deletion is real, it has to be applied deliberately.`
1374
+ );
1375
+ this._writeSyncError(
1376
+ "restore/massDeleteGuard",
1377
+ new Error(
1378
+ `refused to prune ${wouldPrune}/${preRestore.size} files; incoming tree had ${expectedFiles.size}`
1379
+ )
1380
+ );
1381
+ throw new MassDeleteRefusedError(
1382
+ wouldPrune,
1383
+ preRestore.size,
1384
+ expectedFiles.size
1385
+ );
1386
+ }
1350
1387
  await this._pruneExtraneous(
1351
1388
  target,
1352
1389
  expectedDirs,
@@ -2156,6 +2193,9 @@ ${err.stack}` : String(err);
2156
2193
  this._currentRef = postRestoreRef;
2157
2194
  return;
2158
2195
  } catch (err) {
2196
+ if (err instanceof MassDeleteRefusedError) {
2197
+ return;
2198
+ }
2159
2199
  if (err instanceof PartialRestoreError) {
2160
2200
  try {
2161
2201
  const halfApplied = await this._scanner.scan();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
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",