@rljson/fs-agent 0.0.21 → 0.0.23
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 +76 -0
- package/dist/fs-agent.js +149 -7
- package/package.json +1 -1
package/dist/fs-agent.d.ts
CHANGED
|
@@ -131,6 +131,21 @@ 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
|
+
* Thrown when a restore wrote everything it could but at least one file was
|
|
136
|
+
* held open by another process.
|
|
137
|
+
*
|
|
138
|
+
* Not a failure of the restore so much as a "not yet": the bytes are still
|
|
139
|
+
* available, the file is simply busy. It is an error rather than a silent
|
|
140
|
+
* partial success because the folder does NOT match the tree afterwards, and
|
|
141
|
+
* anything that treats it as if it did — advertising the state, recording the
|
|
142
|
+
* ref as applied — would make one locked file look like an edit that everyone
|
|
143
|
+
* else must adopt.
|
|
144
|
+
*/
|
|
145
|
+
export declare class PartialRestoreError extends Error {
|
|
146
|
+
readonly lockedPaths: string[];
|
|
147
|
+
constructor(lockedPaths: string[]);
|
|
148
|
+
}
|
|
134
149
|
export declare class FsAgent {
|
|
135
150
|
private _scanner;
|
|
136
151
|
private _adapter;
|
|
@@ -159,6 +174,16 @@ export declare class FsAgent {
|
|
|
159
174
|
* view — and undo a deletion the peer just made.
|
|
160
175
|
*/
|
|
161
176
|
private _remoteApplyInFlight;
|
|
177
|
+
/** Files written vs left alone by the current {@link restore}. */
|
|
178
|
+
private _restoreWritten;
|
|
179
|
+
private _restoreSkipped;
|
|
180
|
+
/** Paths the current {@link restore} could not write because they were held open. */
|
|
181
|
+
private _restoreLocked;
|
|
182
|
+
/**
|
|
183
|
+
* What this agent last wrote to each absolute path, so a repeat restore can
|
|
184
|
+
* recognise its own work without re-reading the file.
|
|
185
|
+
*/
|
|
186
|
+
private _restoredBlobs;
|
|
162
187
|
private _timeouts;
|
|
163
188
|
/** Client-only: resolve DAG-branch conflicts into merge revisions. */
|
|
164
189
|
private _resolveConflicts;
|
|
@@ -292,8 +317,59 @@ export declare class FsAgent {
|
|
|
292
317
|
* @param treeHash - Hash of the tree node to restore
|
|
293
318
|
* @param trees - Map of all tree nodes
|
|
294
319
|
* @param targetPath - Target directory path
|
|
320
|
+
* @param isOwnRoot - Whether `targetPath` is this agent's own folder, which
|
|
321
|
+
* is the only case where the scanner's view describes these files
|
|
295
322
|
*/
|
|
296
323
|
private _restoreTree;
|
|
324
|
+
/**
|
|
325
|
+
* Whether a caught value means "another process is holding this file".
|
|
326
|
+
*
|
|
327
|
+
* Windows reports a locked file as EPERM or EBUSY; EACCES covers the
|
|
328
|
+
* permission-denied shape. Deliberately narrow — anything else is a real
|
|
329
|
+
* write failure and must still abort, because a restore that shrugged off
|
|
330
|
+
* every error would report success while leaving the folder wrong.
|
|
331
|
+
* @param err - The caught value.
|
|
332
|
+
* @returns `true` for a lock-shaped error.
|
|
333
|
+
*/
|
|
334
|
+
private static _isLocked;
|
|
335
|
+
/**
|
|
336
|
+
* The content identity this agent believes is on disk at `filePath`, or
|
|
337
|
+
* `undefined` when it has no basis for an opinion.
|
|
338
|
+
*
|
|
339
|
+
* Two sources, both anchored on a real blobId rather than a guess:
|
|
340
|
+
* what this agent last wrote there, and what the scanner hashed at its last
|
|
341
|
+
* scan of the folder (which survives a restart, so a fresh process still
|
|
342
|
+
* skips an unchanged 80 GB catalogue).
|
|
343
|
+
* @param filePath - Absolute path of the file.
|
|
344
|
+
* @param relativePath - Its path within the tree.
|
|
345
|
+
* @param isOwnRoot - Whether the restore target is this agent's own folder,
|
|
346
|
+
* which is the only case where the scanner's view describes this file.
|
|
347
|
+
* @returns The believed content identity, or `undefined`.
|
|
348
|
+
*/
|
|
349
|
+
private _knownOnDisk;
|
|
350
|
+
/**
|
|
351
|
+
* Whether the file at `filePath` is already the content `meta` describes.
|
|
352
|
+
*
|
|
353
|
+
* The decision is anchored on the blobId: a different blobId is always
|
|
354
|
+
* rewritten, whatever the timestamps say. Hashing the file instead would
|
|
355
|
+
* mean reading 80 GB to avoid writing 80 GB, which saves nothing — so the
|
|
356
|
+
* known blobId is verified against a `stat`, which catches a file edited
|
|
357
|
+
* since this agent last had an opinion about it.
|
|
358
|
+
*
|
|
359
|
+
* Deliberately one-directional in its uncertainty: every unclear case
|
|
360
|
+
* answers `false` and the file is rewritten. A needless write costs time; a
|
|
361
|
+
* wrongly skipped write leaves the wrong bytes on disk indefinitely.
|
|
362
|
+
*
|
|
363
|
+
* Anchoring on the blobId is not belt-and-braces. Size and mtime alone
|
|
364
|
+
* cannot see a same-size edit made inside the same millisecond — the scan
|
|
365
|
+
* cache tolerates that, but a restore must not: there the cost is not a
|
|
366
|
+
* stale cache entry, it is the wrong file contents left in place.
|
|
367
|
+
* @param filePath - Absolute path of the file to check.
|
|
368
|
+
* @param meta - The metadata describing the content that should be there.
|
|
369
|
+
* @param isOwnRoot - Whether the target is this agent's own folder.
|
|
370
|
+
* @returns `true` only when the file is certainly already correct.
|
|
371
|
+
*/
|
|
372
|
+
private _alreadyOnDisk;
|
|
297
373
|
/**
|
|
298
374
|
* Gets the current tree structure
|
|
299
375
|
*/
|
package/dist/fs-agent.js
CHANGED
|
@@ -1022,6 +1022,15 @@ 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 {
|
|
1026
|
+
constructor(lockedPaths) {
|
|
1027
|
+
super(
|
|
1028
|
+
`restore could not write ${lockedPaths.length} locked file${lockedPaths.length === 1 ? "" : "s"}: ${lockedPaths.join(", ")}`
|
|
1029
|
+
);
|
|
1030
|
+
this.lockedPaths = lockedPaths;
|
|
1031
|
+
this.name = "PartialRestoreError";
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1025
1034
|
class FsAgent {
|
|
1026
1035
|
_scanner;
|
|
1027
1036
|
_adapter;
|
|
@@ -1050,6 +1059,16 @@ class FsAgent {
|
|
|
1050
1059
|
* view — and undo a deletion the peer just made.
|
|
1051
1060
|
*/
|
|
1052
1061
|
_remoteApplyInFlight = false;
|
|
1062
|
+
/** Files written vs left alone by the current {@link restore}. */
|
|
1063
|
+
_restoreWritten = 0;
|
|
1064
|
+
_restoreSkipped = 0;
|
|
1065
|
+
/** Paths the current {@link restore} could not write because they were held open. */
|
|
1066
|
+
_restoreLocked = [];
|
|
1067
|
+
/**
|
|
1068
|
+
* What this agent last wrote to each absolute path, so a repeat restore can
|
|
1069
|
+
* recognise its own work without re-reading the file.
|
|
1070
|
+
*/
|
|
1071
|
+
_restoredBlobs = /* @__PURE__ */ new Map();
|
|
1053
1072
|
_timeouts;
|
|
1054
1073
|
/** Client-only: resolve DAG-branch conflicts into merge revisions. */
|
|
1055
1074
|
_resolveConflicts;
|
|
@@ -1313,7 +1332,20 @@ ${err.stack}` : String(err);
|
|
|
1313
1332
|
target
|
|
1314
1333
|
);
|
|
1315
1334
|
const preRestore = options?.cleanTarget ? await this._collectAllFiles(target) : /* @__PURE__ */ new Set();
|
|
1316
|
-
|
|
1335
|
+
this._restoreWritten = 0;
|
|
1336
|
+
this._restoreSkipped = 0;
|
|
1337
|
+
this._restoreLocked = [];
|
|
1338
|
+
await this._restoreTree(
|
|
1339
|
+
tree.rootHash,
|
|
1340
|
+
tree.trees,
|
|
1341
|
+
target,
|
|
1342
|
+
target === this._rootPath
|
|
1343
|
+
);
|
|
1344
|
+
if (this._restoreSkipped > 0) {
|
|
1345
|
+
console.log(
|
|
1346
|
+
`[FsAgent] restore: wrote ${this._restoreWritten}, left ${this._restoreSkipped} already-correct file${this._restoreSkipped === 1 ? "" : "s"} untouched`
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1317
1349
|
if (options?.cleanTarget) {
|
|
1318
1350
|
await this._pruneExtraneous(
|
|
1319
1351
|
target,
|
|
@@ -1322,6 +1354,9 @@ ${err.stack}` : String(err);
|
|
|
1322
1354
|
preRestore
|
|
1323
1355
|
);
|
|
1324
1356
|
}
|
|
1357
|
+
if (this._restoreLocked.length > 0) {
|
|
1358
|
+
throw new PartialRestoreError([...this._restoreLocked]);
|
|
1359
|
+
}
|
|
1325
1360
|
}
|
|
1326
1361
|
/**
|
|
1327
1362
|
* Recursively collects the absolute paths of all files under `currentDir`.
|
|
@@ -1353,8 +1388,10 @@ ${err.stack}` : String(err);
|
|
|
1353
1388
|
* @param treeHash - Hash of the tree node to restore
|
|
1354
1389
|
* @param trees - Map of all tree nodes
|
|
1355
1390
|
* @param targetPath - Target directory path
|
|
1391
|
+
* @param isOwnRoot - Whether `targetPath` is this agent's own folder, which
|
|
1392
|
+
* is the only case where the scanner's view describes these files
|
|
1356
1393
|
*/
|
|
1357
|
-
async _restoreTree(treeHash, trees, targetPath) {
|
|
1394
|
+
async _restoreTree(treeHash, trees, targetPath, isOwnRoot) {
|
|
1358
1395
|
const treeNode = trees.get(treeHash);
|
|
1359
1396
|
if (!treeNode) {
|
|
1360
1397
|
throw new Error(`Tree node not found: ${treeHash}`);
|
|
@@ -1366,6 +1403,10 @@ ${err.stack}` : String(err);
|
|
|
1366
1403
|
if (meta.type === "file") {
|
|
1367
1404
|
const filePath = join(targetPath, meta.relativePath);
|
|
1368
1405
|
if (meta.blobId) {
|
|
1406
|
+
if (await this._alreadyOnDisk(filePath, meta, isOwnRoot)) {
|
|
1407
|
+
this._restoreSkipped++;
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1369
1410
|
let fileBlob;
|
|
1370
1411
|
try {
|
|
1371
1412
|
fileBlob = await this._bs.getBlob(meta.blobId);
|
|
@@ -1380,10 +1421,26 @@ ${err.stack}` : String(err);
|
|
|
1380
1421
|
);
|
|
1381
1422
|
}
|
|
1382
1423
|
await mkdir(dirname(filePath), { recursive: true });
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1424
|
+
try {
|
|
1425
|
+
await FsAgent._atomicWriteFile(filePath, fileBlob.content);
|
|
1426
|
+
this._restoreWritten++;
|
|
1427
|
+
if (meta.mtime) {
|
|
1428
|
+
const mtime = new Date(meta.mtime);
|
|
1429
|
+
await utimes(filePath, mtime, mtime);
|
|
1430
|
+
}
|
|
1431
|
+
if (meta.size !== void 0 && meta.mtime !== void 0) {
|
|
1432
|
+
this._restoredBlobs.set(filePath, {
|
|
1433
|
+
blobId: meta.blobId,
|
|
1434
|
+
size: meta.size,
|
|
1435
|
+
mtime: meta.mtime
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
if (!FsAgent._isLocked(error)) throw error;
|
|
1440
|
+
console.warn(
|
|
1441
|
+
`[FsAgent] restore: "${meta.relativePath}" is held open by another process (${error.code}) — skipped, will retry`
|
|
1442
|
+
);
|
|
1443
|
+
this._restoreLocked.push(meta.relativePath);
|
|
1387
1444
|
}
|
|
1388
1445
|
}
|
|
1389
1446
|
} else if (meta.type === "directory") {
|
|
@@ -1391,11 +1448,89 @@ ${err.stack}` : String(err);
|
|
|
1391
1448
|
await mkdir(dirPath, { recursive: true });
|
|
1392
1449
|
if (treeNode.children && Array.isArray(treeNode.children)) {
|
|
1393
1450
|
for (const childHash of treeNode.children) {
|
|
1394
|
-
await this._restoreTree(childHash, trees, targetPath);
|
|
1451
|
+
await this._restoreTree(childHash, trees, targetPath, isOwnRoot);
|
|
1395
1452
|
}
|
|
1396
1453
|
}
|
|
1397
1454
|
}
|
|
1398
1455
|
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Whether a caught value means "another process is holding this file".
|
|
1458
|
+
*
|
|
1459
|
+
* Windows reports a locked file as EPERM or EBUSY; EACCES covers the
|
|
1460
|
+
* permission-denied shape. Deliberately narrow — anything else is a real
|
|
1461
|
+
* write failure and must still abort, because a restore that shrugged off
|
|
1462
|
+
* every error would report success while leaving the folder wrong.
|
|
1463
|
+
* @param err - The caught value.
|
|
1464
|
+
* @returns `true` for a lock-shaped error.
|
|
1465
|
+
*/
|
|
1466
|
+
static _isLocked(err) {
|
|
1467
|
+
const code = err?.code;
|
|
1468
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* The content identity this agent believes is on disk at `filePath`, or
|
|
1472
|
+
* `undefined` when it has no basis for an opinion.
|
|
1473
|
+
*
|
|
1474
|
+
* Two sources, both anchored on a real blobId rather than a guess:
|
|
1475
|
+
* what this agent last wrote there, and what the scanner hashed at its last
|
|
1476
|
+
* scan of the folder (which survives a restart, so a fresh process still
|
|
1477
|
+
* skips an unchanged 80 GB catalogue).
|
|
1478
|
+
* @param filePath - Absolute path of the file.
|
|
1479
|
+
* @param relativePath - Its path within the tree.
|
|
1480
|
+
* @param isOwnRoot - Whether the restore target is this agent's own folder,
|
|
1481
|
+
* which is the only case where the scanner's view describes this file.
|
|
1482
|
+
* @returns The believed content identity, or `undefined`.
|
|
1483
|
+
*/
|
|
1484
|
+
_knownOnDisk(filePath, relativePath, isOwnRoot) {
|
|
1485
|
+
const written = this._restoredBlobs.get(filePath);
|
|
1486
|
+
if (written) return written;
|
|
1487
|
+
if (!isOwnRoot) return void 0;
|
|
1488
|
+
const scanned = this._scanner.getTreeByPath(relativePath)?.meta;
|
|
1489
|
+
if (scanned?.blobId === void 0 || scanned.size === void 0 || scanned.mtime === void 0) {
|
|
1490
|
+
return void 0;
|
|
1491
|
+
}
|
|
1492
|
+
return {
|
|
1493
|
+
blobId: scanned.blobId,
|
|
1494
|
+
size: scanned.size,
|
|
1495
|
+
mtime: scanned.mtime
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Whether the file at `filePath` is already the content `meta` describes.
|
|
1500
|
+
*
|
|
1501
|
+
* The decision is anchored on the blobId: a different blobId is always
|
|
1502
|
+
* rewritten, whatever the timestamps say. Hashing the file instead would
|
|
1503
|
+
* mean reading 80 GB to avoid writing 80 GB, which saves nothing — so the
|
|
1504
|
+
* known blobId is verified against a `stat`, which catches a file edited
|
|
1505
|
+
* since this agent last had an opinion about it.
|
|
1506
|
+
*
|
|
1507
|
+
* Deliberately one-directional in its uncertainty: every unclear case
|
|
1508
|
+
* answers `false` and the file is rewritten. A needless write costs time; a
|
|
1509
|
+
* wrongly skipped write leaves the wrong bytes on disk indefinitely.
|
|
1510
|
+
*
|
|
1511
|
+
* Anchoring on the blobId is not belt-and-braces. Size and mtime alone
|
|
1512
|
+
* cannot see a same-size edit made inside the same millisecond — the scan
|
|
1513
|
+
* cache tolerates that, but a restore must not: there the cost is not a
|
|
1514
|
+
* stale cache entry, it is the wrong file contents left in place.
|
|
1515
|
+
* @param filePath - Absolute path of the file to check.
|
|
1516
|
+
* @param meta - The metadata describing the content that should be there.
|
|
1517
|
+
* @param isOwnRoot - Whether the target is this agent's own folder.
|
|
1518
|
+
* @returns `true` only when the file is certainly already correct.
|
|
1519
|
+
*/
|
|
1520
|
+
async _alreadyOnDisk(filePath, meta, isOwnRoot) {
|
|
1521
|
+
const known = this._knownOnDisk(
|
|
1522
|
+
filePath,
|
|
1523
|
+
meta.relativePath,
|
|
1524
|
+
isOwnRoot
|
|
1525
|
+
);
|
|
1526
|
+
if (!known || known.blobId !== meta.blobId) return false;
|
|
1527
|
+
try {
|
|
1528
|
+
const st = await stat(filePath);
|
|
1529
|
+
return st.size === known.size && Math.abs(st.mtimeMs - known.mtime) < 1;
|
|
1530
|
+
} catch {
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1399
1534
|
/**
|
|
1400
1535
|
* Gets the current tree structure
|
|
1401
1536
|
*/
|
|
@@ -2021,6 +2156,13 @@ ${err.stack}` : String(err);
|
|
|
2021
2156
|
this._currentRef = postRestoreRef;
|
|
2022
2157
|
return;
|
|
2023
2158
|
} catch (err) {
|
|
2159
|
+
if (err instanceof PartialRestoreError) {
|
|
2160
|
+
try {
|
|
2161
|
+
const halfApplied = await this._scanner.scan();
|
|
2162
|
+
this._lastSentContentKey = this._contentKeyFromTree(halfApplied);
|
|
2163
|
+
} catch {
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2024
2166
|
if (attempt === maxAttempts) {
|
|
2025
2167
|
if (recoveryAttempt >= this._timeouts.recoveryRetries) {
|
|
2026
2168
|
console.error(
|