@rljson/fs-agent 0.0.22 → 0.0.24
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/dist/fs-agent.d.ts +68 -0
- package/dist/fs-agent.js +93 -12
- package/package.json +1 -1
package/dist/fs-agent.d.ts
CHANGED
|
@@ -131,6 +131,61 @@ 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
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Thrown when a restore wrote everything it could but at least one file was
|
|
158
|
+
* held open by another process.
|
|
159
|
+
*
|
|
160
|
+
* Not a failure of the restore so much as a "not yet": the bytes are still
|
|
161
|
+
* available, the file is simply busy. It is an error rather than a silent
|
|
162
|
+
* partial success because the folder does NOT match the tree afterwards, and
|
|
163
|
+
* anything that treats it as if it did — advertising the state, recording the
|
|
164
|
+
* ref as applied — would make one locked file look like an edit that everyone
|
|
165
|
+
* else must adopt.
|
|
166
|
+
*/
|
|
167
|
+
export declare class PartialRestoreError extends RestoreIncompleteError {
|
|
168
|
+
readonly lockedPaths: string[];
|
|
169
|
+
constructor(lockedPaths: string[]);
|
|
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
|
+
}
|
|
134
189
|
export declare class FsAgent {
|
|
135
190
|
private _scanner;
|
|
136
191
|
private _adapter;
|
|
@@ -162,6 +217,8 @@ export declare class FsAgent {
|
|
|
162
217
|
/** Files written vs left alone by the current {@link restore}. */
|
|
163
218
|
private _restoreWritten;
|
|
164
219
|
private _restoreSkipped;
|
|
220
|
+
/** Paths the current {@link restore} could not write because they were held open. */
|
|
221
|
+
private _restoreLocked;
|
|
165
222
|
/**
|
|
166
223
|
* What this agent last wrote to each absolute path, so a repeat restore can
|
|
167
224
|
* recognise its own work without re-reading the file.
|
|
@@ -304,6 +361,17 @@ export declare class FsAgent {
|
|
|
304
361
|
* is the only case where the scanner's view describes these files
|
|
305
362
|
*/
|
|
306
363
|
private _restoreTree;
|
|
364
|
+
/**
|
|
365
|
+
* Whether a caught value means "another process is holding this file".
|
|
366
|
+
*
|
|
367
|
+
* Windows reports a locked file as EPERM or EBUSY; EACCES covers the
|
|
368
|
+
* permission-denied shape. Deliberately narrow — anything else is a real
|
|
369
|
+
* write failure and must still abort, because a restore that shrugged off
|
|
370
|
+
* every error would report success while leaving the folder wrong.
|
|
371
|
+
* @param err - The caught value.
|
|
372
|
+
* @returns `true` for a lock-shaped error.
|
|
373
|
+
*/
|
|
374
|
+
private static _isLocked;
|
|
307
375
|
/**
|
|
308
376
|
* The content identity this agent believes is on disk at `filePath`, or
|
|
309
377
|
* `undefined` when it has no basis for an opinion.
|
package/dist/fs-agent.js
CHANGED
|
@@ -1022,6 +1022,32 @@ 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
|
+
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 {
|
|
1030
|
+
constructor(lockedPaths) {
|
|
1031
|
+
super(
|
|
1032
|
+
`restore could not write ${lockedPaths.length} locked file${lockedPaths.length === 1 ? "" : "s"}: ${lockedPaths.join(", ")}`
|
|
1033
|
+
);
|
|
1034
|
+
this.lockedPaths = lockedPaths;
|
|
1035
|
+
this.name = "PartialRestoreError";
|
|
1036
|
+
}
|
|
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
|
+
}
|
|
1025
1051
|
class FsAgent {
|
|
1026
1052
|
_scanner;
|
|
1027
1053
|
_adapter;
|
|
@@ -1053,6 +1079,8 @@ class FsAgent {
|
|
|
1053
1079
|
/** Files written vs left alone by the current {@link restore}. */
|
|
1054
1080
|
_restoreWritten = 0;
|
|
1055
1081
|
_restoreSkipped = 0;
|
|
1082
|
+
/** Paths the current {@link restore} could not write because they were held open. */
|
|
1083
|
+
_restoreLocked = [];
|
|
1056
1084
|
/**
|
|
1057
1085
|
* What this agent last wrote to each absolute path, so a repeat restore can
|
|
1058
1086
|
* recognise its own work without re-reading the file.
|
|
@@ -1323,6 +1351,7 @@ ${err.stack}` : String(err);
|
|
|
1323
1351
|
const preRestore = options?.cleanTarget ? await this._collectAllFiles(target) : /* @__PURE__ */ new Set();
|
|
1324
1352
|
this._restoreWritten = 0;
|
|
1325
1353
|
this._restoreSkipped = 0;
|
|
1354
|
+
this._restoreLocked = [];
|
|
1326
1355
|
await this._restoreTree(
|
|
1327
1356
|
tree.rootHash,
|
|
1328
1357
|
tree.trees,
|
|
@@ -1335,6 +1364,26 @@ ${err.stack}` : String(err);
|
|
|
1335
1364
|
);
|
|
1336
1365
|
}
|
|
1337
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
|
+
}
|
|
1338
1387
|
await this._pruneExtraneous(
|
|
1339
1388
|
target,
|
|
1340
1389
|
expectedDirs,
|
|
@@ -1342,6 +1391,9 @@ ${err.stack}` : String(err);
|
|
|
1342
1391
|
preRestore
|
|
1343
1392
|
);
|
|
1344
1393
|
}
|
|
1394
|
+
if (this._restoreLocked.length > 0) {
|
|
1395
|
+
throw new PartialRestoreError([...this._restoreLocked]);
|
|
1396
|
+
}
|
|
1345
1397
|
}
|
|
1346
1398
|
/**
|
|
1347
1399
|
* Recursively collects the absolute paths of all files under `currentDir`.
|
|
@@ -1406,18 +1458,26 @@ ${err.stack}` : String(err);
|
|
|
1406
1458
|
);
|
|
1407
1459
|
}
|
|
1408
1460
|
await mkdir(dirname(filePath), { recursive: true });
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1461
|
+
try {
|
|
1462
|
+
await FsAgent._atomicWriteFile(filePath, fileBlob.content);
|
|
1463
|
+
this._restoreWritten++;
|
|
1464
|
+
if (meta.mtime) {
|
|
1465
|
+
const mtime = new Date(meta.mtime);
|
|
1466
|
+
await utimes(filePath, mtime, mtime);
|
|
1467
|
+
}
|
|
1468
|
+
if (meta.size !== void 0 && meta.mtime !== void 0) {
|
|
1469
|
+
this._restoredBlobs.set(filePath, {
|
|
1470
|
+
blobId: meta.blobId,
|
|
1471
|
+
size: meta.size,
|
|
1472
|
+
mtime: meta.mtime
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
if (!FsAgent._isLocked(error)) throw error;
|
|
1477
|
+
console.warn(
|
|
1478
|
+
`[FsAgent] restore: "${meta.relativePath}" is held open by another process (${error.code}) — skipped, will retry`
|
|
1479
|
+
);
|
|
1480
|
+
this._restoreLocked.push(meta.relativePath);
|
|
1421
1481
|
}
|
|
1422
1482
|
}
|
|
1423
1483
|
} else if (meta.type === "directory") {
|
|
@@ -1430,6 +1490,20 @@ ${err.stack}` : String(err);
|
|
|
1430
1490
|
}
|
|
1431
1491
|
}
|
|
1432
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Whether a caught value means "another process is holding this file".
|
|
1495
|
+
*
|
|
1496
|
+
* Windows reports a locked file as EPERM or EBUSY; EACCES covers the
|
|
1497
|
+
* permission-denied shape. Deliberately narrow — anything else is a real
|
|
1498
|
+
* write failure and must still abort, because a restore that shrugged off
|
|
1499
|
+
* every error would report success while leaving the folder wrong.
|
|
1500
|
+
* @param err - The caught value.
|
|
1501
|
+
* @returns `true` for a lock-shaped error.
|
|
1502
|
+
*/
|
|
1503
|
+
static _isLocked(err) {
|
|
1504
|
+
const code = err?.code;
|
|
1505
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
1506
|
+
}
|
|
1433
1507
|
/**
|
|
1434
1508
|
* The content identity this agent believes is on disk at `filePath`, or
|
|
1435
1509
|
* `undefined` when it has no basis for an opinion.
|
|
@@ -2119,6 +2193,13 @@ ${err.stack}` : String(err);
|
|
|
2119
2193
|
this._currentRef = postRestoreRef;
|
|
2120
2194
|
return;
|
|
2121
2195
|
} catch (err) {
|
|
2196
|
+
if (err instanceof RestoreIncompleteError) {
|
|
2197
|
+
try {
|
|
2198
|
+
const halfApplied = await this._scanner.scan();
|
|
2199
|
+
this._lastSentContentKey = this._contentKeyFromTree(halfApplied);
|
|
2200
|
+
} catch {
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2122
2203
|
if (attempt === maxAttempts) {
|
|
2123
2204
|
if (recoveryAttempt >= this._timeouts.recoveryRetries) {
|
|
2124
2205
|
console.error(
|