@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.
@@ -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
- await this._restoreTree(tree.rootHash, tree.trees, target);
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
- await FsAgent._atomicWriteFile(filePath, fileBlob.content);
1384
- if (meta.mtime) {
1385
- const mtime = new Date(meta.mtime);
1386
- await utimes(filePath, mtime, mtime);
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
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",