@rljson/fs-agent 0.0.14 → 0.0.16
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 +43 -1
- package/dist/fs-agent.js +273 -53
- package/dist/fs-scanner.d.ts +28 -1
- package/package.json +2 -2
package/dist/fs-agent.d.ts
CHANGED
|
@@ -107,6 +107,12 @@ export interface TimeoutConfig {
|
|
|
107
107
|
}
|
|
108
108
|
/** Filename for sync error log written to the sync folder */
|
|
109
109
|
export declare const SYNC_ERROR_FILE = ".sync-errors.log";
|
|
110
|
+
/**
|
|
111
|
+
* Filename prefix for the staging files used by atomic writes. The scanner
|
|
112
|
+
* ignores anything starting with this so the transient temp + rename never
|
|
113
|
+
* pollutes the tree or churns the watcher.
|
|
114
|
+
*/
|
|
115
|
+
export declare const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
|
|
110
116
|
/**
|
|
111
117
|
* Orchestrates filesystem operations with tree structures and blob storage
|
|
112
118
|
*/
|
|
@@ -167,6 +173,31 @@ export declare class FsAgent {
|
|
|
167
173
|
* @returns A message string.
|
|
168
174
|
*/
|
|
169
175
|
private static _errMessage;
|
|
176
|
+
/**
|
|
177
|
+
* Retries an async operation up to `attempts` times with exponential backoff
|
|
178
|
+
* (each delay doubles from `baseDelayMs`). For transient failures — a file
|
|
179
|
+
* briefly locked by antivirus or a save-and-rename editor, a peer briefly
|
|
180
|
+
* unreachable. Non-final failures are logged once at warn level so retry
|
|
181
|
+
* pressure is visible without log-spam.
|
|
182
|
+
* @param fn - The operation to run
|
|
183
|
+
* @param attempts - Maximum number of attempts
|
|
184
|
+
* @param baseDelayMs - Initial backoff delay (doubles each retry)
|
|
185
|
+
* @param label - Human-readable label for log messages
|
|
186
|
+
* @returns The operation's resolved value
|
|
187
|
+
*/
|
|
188
|
+
private static _withRetry;
|
|
189
|
+
/**
|
|
190
|
+
* Atomically writes a file: stages the content in a sibling `.<rand>.tmp`,
|
|
191
|
+
* then renames over the target. The rename is atomic, so a crash mid-write
|
|
192
|
+
* leaves only the temp behind — never a half-written target file. (We do not
|
|
193
|
+
* `fsync` the temp: it adds significant per-file latency under bursty
|
|
194
|
+
* restores, and durability-on-power-loss is secondary here since the content
|
|
195
|
+
* is replicated and re-synced.) The random suffix keeps concurrent restores
|
|
196
|
+
* of the same path from trampling each other.
|
|
197
|
+
* @param filePath - Destination path
|
|
198
|
+
* @param content - Bytes to write
|
|
199
|
+
*/
|
|
200
|
+
private static _atomicWriteFile;
|
|
170
201
|
/**
|
|
171
202
|
* Wraps a promise with a timeout.
|
|
172
203
|
* Rejects with a descriptive error if the promise does not settle
|
|
@@ -217,6 +248,14 @@ export declare class FsAgent {
|
|
|
217
248
|
* @param options - Restore options
|
|
218
249
|
*/
|
|
219
250
|
restore(tree: FsTree, targetPath?: string, options?: RestoreOptions): Promise<void>;
|
|
251
|
+
/**
|
|
252
|
+
* Recursively collects the absolute paths of all files under `currentDir`.
|
|
253
|
+
* Used to snapshot the pre-restore file set for prune race-protection.
|
|
254
|
+
* @param currentDir - Directory to walk
|
|
255
|
+
* @param out - Accumulator set (created if omitted)
|
|
256
|
+
* @returns The set of absolute file paths
|
|
257
|
+
*/
|
|
258
|
+
private _collectAllFiles;
|
|
220
259
|
/**
|
|
221
260
|
* Recursively restores a tree node and its children
|
|
222
261
|
* @param treeHash - Hash of the tree node to restore
|
|
@@ -284,10 +323,13 @@ export declare class FsAgent {
|
|
|
284
323
|
*/
|
|
285
324
|
private _collectExpectedPaths;
|
|
286
325
|
/**
|
|
287
|
-
* Remove files/dirs not present in the expected sets
|
|
326
|
+
* Remove files/dirs not present in the expected sets, preserving any file
|
|
327
|
+
* that appeared *during* the restore (not in `preRestore`) — a fresh user
|
|
328
|
+
* write that must not be clobbered.
|
|
288
329
|
* @param currentDir - Directory currently being inspected
|
|
289
330
|
* @param expectedDirs - Allowed directory paths
|
|
290
331
|
* @param expectedFiles - Allowed file paths
|
|
332
|
+
* @param preRestore - Files present before the restore (prune candidates)
|
|
291
333
|
*/
|
|
292
334
|
private _pruneExtraneous;
|
|
293
335
|
/**
|
package/dist/fs-agent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BsMem } from "@rljson/bs";
|
|
2
2
|
import { Route, createTreesTableCfg } from "@rljson/rljson";
|
|
3
3
|
import { watch, appendFileSync } from "fs";
|
|
4
|
-
import { stat, readFile, mkdir, writeFile, readdir, utimes, rm } from "fs/promises";
|
|
4
|
+
import { stat, readFile, mkdir, writeFile, readdir, rename, unlink, utimes, rm } from "fs/promises";
|
|
5
5
|
import { dirname, join } from "path";
|
|
6
6
|
import { hip } from "@rljson/hash";
|
|
7
7
|
import { Db } from "@rljson/db";
|
|
@@ -422,6 +422,10 @@ class FsScanner {
|
|
|
422
422
|
_bs;
|
|
423
423
|
_paused = false;
|
|
424
424
|
_missedChangesDuringPause = false;
|
|
425
|
+
/** Periodic full-rescan timer that catches events the native watcher drops. */
|
|
426
|
+
_safetyTimer = null;
|
|
427
|
+
/** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
|
|
428
|
+
_stopRequested = false;
|
|
425
429
|
constructor(rootPath, options = {}) {
|
|
426
430
|
this._rootPath = rootPath;
|
|
427
431
|
this._options = {
|
|
@@ -591,18 +595,102 @@ class FsScanner {
|
|
|
591
595
|
if (!this._tree) {
|
|
592
596
|
await this.scan();
|
|
593
597
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
598
|
+
const onEvent = async (eventType, filename) => {
|
|
599
|
+
if (!filename) return;
|
|
600
|
+
if (this._shouldIgnore(filename)) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
await this._handleFileChange(eventType, filename);
|
|
604
|
+
};
|
|
605
|
+
const onError = (err) => {
|
|
606
|
+
console.warn(
|
|
607
|
+
`[fs-scanner] watcher error: ${FsScanner._errMessage(err)} — reinstalling`
|
|
608
|
+
);
|
|
609
|
+
try {
|
|
610
|
+
this._watcher?.close();
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
this._watcher = null;
|
|
614
|
+
setTimeout(() => {
|
|
615
|
+
if (this._stopRequested) return;
|
|
616
|
+
try {
|
|
617
|
+
this._watcher = watch(this._rootPath, { recursive: true }, onEvent);
|
|
618
|
+
if (FsScanner._isWindows) this._watcher.on("error", onError);
|
|
619
|
+
} catch (e) {
|
|
620
|
+
console.warn(
|
|
621
|
+
`[fs-scanner] watcher reinstall failed: ${FsScanner._errMessage(e)}`
|
|
622
|
+
);
|
|
602
623
|
}
|
|
603
|
-
|
|
624
|
+
}, 500);
|
|
625
|
+
};
|
|
626
|
+
this._stopRequested = false;
|
|
627
|
+
this._watcher = watch(this._rootPath, { recursive: true }, onEvent);
|
|
628
|
+
if (FsScanner._isWindows) this._watcher.on("error", onError);
|
|
629
|
+
if (!this._safetyTimer) {
|
|
630
|
+
this._safetyTimer = setInterval(() => {
|
|
631
|
+
void this._runSafetyRescan();
|
|
632
|
+
}, 3e4);
|
|
633
|
+
this._safetyTimer.unref?.();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* One safety-rescan pass: rescans the tree and, if its content differs from
|
|
638
|
+
* the previous scan (the native watcher dropped an event), emits a sync
|
|
639
|
+
* notification so syncToDb reconciles the drift. Paused/stopped scanners and
|
|
640
|
+
* scan failures are no-ops.
|
|
641
|
+
*/
|
|
642
|
+
async _runSafetyRescan() {
|
|
643
|
+
if (this._paused || this._stopRequested) return;
|
|
644
|
+
const prevKey = this._tree ? this._safetyContentKey(this._tree) : null;
|
|
645
|
+
try {
|
|
646
|
+
await this.scan();
|
|
647
|
+
} catch (err) {
|
|
648
|
+
console.warn(
|
|
649
|
+
`[fs-scanner] safety rescan failed: ${FsScanner._errMessage(err)}`
|
|
650
|
+
);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (this._paused || this._stopRequested) return;
|
|
654
|
+
const nextKey = this._tree ? this._safetyContentKey(this._tree) : null;
|
|
655
|
+
if (prevKey !== nextKey) {
|
|
656
|
+
console.warn(
|
|
657
|
+
`[fs-scanner] safety rescan detected drift on ${this._rootPath} — notifying`
|
|
658
|
+
);
|
|
659
|
+
await this._notifyChange({ type: "safety-rescan", path: "." });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Path+blobId content fingerprint used by the safety rescan to detect drift
|
|
664
|
+
* the native watcher missed (mtime-independent, same idea as the agent's
|
|
665
|
+
* content key but local to the scanner).
|
|
666
|
+
* @param tree - The tree to fingerprint
|
|
667
|
+
* @returns A stable content key
|
|
668
|
+
*/
|
|
669
|
+
_safetyContentKey(tree) {
|
|
670
|
+
const parts = [];
|
|
671
|
+
for (const [, node] of tree.trees) {
|
|
672
|
+
const meta = node.meta;
|
|
673
|
+
if (!meta) continue;
|
|
674
|
+
if (meta.type === "file") {
|
|
675
|
+
parts.push(`${meta.relativePath}:${meta.blobId ?? ""}`);
|
|
676
|
+
} else if (meta.type === "directory" && meta.relativePath !== ".") {
|
|
677
|
+
parts.push(`d:${meta.relativePath}`);
|
|
604
678
|
}
|
|
605
|
-
|
|
679
|
+
}
|
|
680
|
+
parts.sort();
|
|
681
|
+
return parts.join("\n");
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Extracts a readable message from a thrown value.
|
|
685
|
+
* @param err - The caught value
|
|
686
|
+
* @returns A message string
|
|
687
|
+
*/
|
|
688
|
+
static _errMessage(err) {
|
|
689
|
+
return err instanceof Error ? err.message : String(err);
|
|
690
|
+
}
|
|
691
|
+
/** Whether the host is Windows — gates Windows-specific watcher hardening. */
|
|
692
|
+
static get _isWindows() {
|
|
693
|
+
return process.platform === "win32";
|
|
606
694
|
}
|
|
607
695
|
async _handleFileChange(_eventType, filename) {
|
|
608
696
|
if (this._paused) {
|
|
@@ -610,23 +698,35 @@ class FsScanner {
|
|
|
610
698
|
return;
|
|
611
699
|
}
|
|
612
700
|
const relativePath = filename.replace(/\\/g, "/");
|
|
701
|
+
const fullPath = join(this._rootPath, filename);
|
|
702
|
+
let exists = false;
|
|
703
|
+
if (FsScanner._isWindows) {
|
|
704
|
+
for (let i = 0; i < 4; i++) {
|
|
705
|
+
try {
|
|
706
|
+
await stat(fullPath);
|
|
707
|
+
exists = true;
|
|
708
|
+
break;
|
|
709
|
+
} catch {
|
|
710
|
+
if (i < 3) await new Promise((r) => setTimeout(r, 80 + i * 80));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
try {
|
|
715
|
+
await stat(fullPath);
|
|
716
|
+
exists = true;
|
|
717
|
+
} catch {
|
|
718
|
+
}
|
|
719
|
+
}
|
|
613
720
|
try {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (!existingTree) {
|
|
721
|
+
if (exists) {
|
|
722
|
+
const existingTree = this._findTreeByPath(relativePath);
|
|
617
723
|
await this.scan();
|
|
618
724
|
await this._notifyChange({
|
|
619
|
-
type: "added",
|
|
620
|
-
path: relativePath
|
|
621
|
-
});
|
|
622
|
-
} else {
|
|
623
|
-
await this.scan();
|
|
624
|
-
await this._notifyChange({
|
|
625
|
-
type: "modified",
|
|
725
|
+
type: existingTree ? "modified" : "added",
|
|
626
726
|
path: relativePath
|
|
627
727
|
});
|
|
728
|
+
return;
|
|
628
729
|
}
|
|
629
|
-
} catch {
|
|
630
730
|
let rootExists = false;
|
|
631
731
|
try {
|
|
632
732
|
await stat(this._rootPath);
|
|
@@ -635,15 +735,10 @@ class FsScanner {
|
|
|
635
735
|
this.stopWatch();
|
|
636
736
|
}
|
|
637
737
|
if (rootExists) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
await this._notifyChange({
|
|
641
|
-
type: "deleted",
|
|
642
|
-
path: relativePath
|
|
643
|
-
});
|
|
644
|
-
} catch {
|
|
645
|
-
}
|
|
738
|
+
await this.scan();
|
|
739
|
+
await this._notifyChange({ type: "deleted", path: relativePath });
|
|
646
740
|
}
|
|
741
|
+
} catch {
|
|
647
742
|
}
|
|
648
743
|
}
|
|
649
744
|
_findTreeByPath(relativePath) {
|
|
@@ -673,6 +768,11 @@ class FsScanner {
|
|
|
673
768
|
);
|
|
674
769
|
}
|
|
675
770
|
stopWatch() {
|
|
771
|
+
this._stopRequested = true;
|
|
772
|
+
if (this._safetyTimer) {
|
|
773
|
+
clearInterval(this._safetyTimer);
|
|
774
|
+
this._safetyTimer = null;
|
|
775
|
+
}
|
|
676
776
|
if (this._watcher) {
|
|
677
777
|
this._watcher.close();
|
|
678
778
|
this._watcher = null;
|
|
@@ -754,6 +854,7 @@ const DEFAULT_TIMEOUTS = {
|
|
|
754
854
|
recoveryRetries: 10
|
|
755
855
|
};
|
|
756
856
|
const SYNC_ERROR_FILE = ".sync-errors.log";
|
|
857
|
+
const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
|
|
757
858
|
class FsAgent {
|
|
758
859
|
_scanner;
|
|
759
860
|
_adapter;
|
|
@@ -785,7 +886,7 @@ class FsAgent {
|
|
|
785
886
|
this._resolveConflicts = options.resolveConflicts ?? false;
|
|
786
887
|
this._scanner = new FsScanner(rootPath, {
|
|
787
888
|
...options,
|
|
788
|
-
ignore: [...options.ignore || [], SYNC_ERROR_FILE],
|
|
889
|
+
ignore: [...options.ignore || [], SYNC_ERROR_FILE, ATOMIC_TMP_PREFIX],
|
|
789
890
|
bs: this._bs
|
|
790
891
|
});
|
|
791
892
|
this._adapter = new FsBlobAdapter(this._bs);
|
|
@@ -852,6 +953,68 @@ ${err.stack}` : String(err);
|
|
|
852
953
|
static _errMessage(err) {
|
|
853
954
|
return err instanceof Error ? err.message : String(err);
|
|
854
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* Retries an async operation up to `attempts` times with exponential backoff
|
|
958
|
+
* (each delay doubles from `baseDelayMs`). For transient failures — a file
|
|
959
|
+
* briefly locked by antivirus or a save-and-rename editor, a peer briefly
|
|
960
|
+
* unreachable. Non-final failures are logged once at warn level so retry
|
|
961
|
+
* pressure is visible without log-spam.
|
|
962
|
+
* @param fn - The operation to run
|
|
963
|
+
* @param attempts - Maximum number of attempts
|
|
964
|
+
* @param baseDelayMs - Initial backoff delay (doubles each retry)
|
|
965
|
+
* @param label - Human-readable label for log messages
|
|
966
|
+
* @returns The operation's resolved value
|
|
967
|
+
*/
|
|
968
|
+
static async _withRetry(fn, attempts, baseDelayMs, label) {
|
|
969
|
+
let lastErr;
|
|
970
|
+
for (let i = 0; i < attempts; i++) {
|
|
971
|
+
try {
|
|
972
|
+
return await fn();
|
|
973
|
+
} catch (err) {
|
|
974
|
+
lastErr = err;
|
|
975
|
+
if (i === attempts - 1) {
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
const delay = baseDelayMs * Math.pow(2, i);
|
|
979
|
+
console.warn(
|
|
980
|
+
`[FsAgent] ${label} attempt ${i + 1}/${attempts} failed: ${FsAgent._errMessage(err)} — retry in ${delay}ms`
|
|
981
|
+
);
|
|
982
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
throw lastErr;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Atomically writes a file: stages the content in a sibling `.<rand>.tmp`,
|
|
989
|
+
* then renames over the target. The rename is atomic, so a crash mid-write
|
|
990
|
+
* leaves only the temp behind — never a half-written target file. (We do not
|
|
991
|
+
* `fsync` the temp: it adds significant per-file latency under bursty
|
|
992
|
+
* restores, and durability-on-power-loss is secondary here since the content
|
|
993
|
+
* is replicated and re-synced.) The random suffix keeps concurrent restores
|
|
994
|
+
* of the same path from trampling each other.
|
|
995
|
+
* @param filePath - Destination path
|
|
996
|
+
* @param content - Bytes to write
|
|
997
|
+
*/
|
|
998
|
+
static async _atomicWriteFile(filePath, content) {
|
|
999
|
+
if (process.platform !== "win32") {
|
|
1000
|
+
await writeFile(filePath, content);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const rnd = `${Date.now().toString(36)}-${Math.floor(
|
|
1004
|
+
Math.random() * 1e9
|
|
1005
|
+
).toString(36)}`;
|
|
1006
|
+
const tmp = join(dirname(filePath), `${ATOMIC_TMP_PREFIX}${rnd}`);
|
|
1007
|
+
try {
|
|
1008
|
+
await writeFile(tmp, content);
|
|
1009
|
+
await rename(tmp, filePath);
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
try {
|
|
1012
|
+
await unlink(tmp);
|
|
1013
|
+
} catch {
|
|
1014
|
+
}
|
|
1015
|
+
throw err;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
855
1018
|
/**
|
|
856
1019
|
* Wraps a promise with a timeout.
|
|
857
1020
|
* Rejects with a descriptive error if the promise does not settle
|
|
@@ -891,11 +1054,18 @@ ${err.stack}` : String(err);
|
|
|
891
1054
|
if (this._resolveConflicts) {
|
|
892
1055
|
connector.setPredecessors(predecessorRefs ?? []);
|
|
893
1056
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1057
|
+
await FsAgent._withRetry(
|
|
1058
|
+
async () => {
|
|
1059
|
+
if (connector.syncConfig?.requireAck) {
|
|
1060
|
+
await connector.sendWithAck(ref);
|
|
1061
|
+
} else {
|
|
1062
|
+
connector.send(ref);
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
3,
|
|
1066
|
+
100,
|
|
1067
|
+
`sendRef(${ref.slice(0, 12)}…)`
|
|
1068
|
+
);
|
|
899
1069
|
}
|
|
900
1070
|
/**
|
|
901
1071
|
* Starts automatic syncing to database
|
|
@@ -958,10 +1128,41 @@ ${err.stack}` : String(err);
|
|
|
958
1128
|
tree,
|
|
959
1129
|
target
|
|
960
1130
|
);
|
|
1131
|
+
const preRestore = options?.cleanTarget ? await this._collectAllFiles(target) : /* @__PURE__ */ new Set();
|
|
961
1132
|
await this._restoreTree(tree.rootHash, tree.trees, target);
|
|
962
1133
|
if (options?.cleanTarget) {
|
|
963
|
-
await this._pruneExtraneous(
|
|
1134
|
+
await this._pruneExtraneous(
|
|
1135
|
+
target,
|
|
1136
|
+
expectedDirs,
|
|
1137
|
+
expectedFiles,
|
|
1138
|
+
preRestore
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Recursively collects the absolute paths of all files under `currentDir`.
|
|
1144
|
+
* Used to snapshot the pre-restore file set for prune race-protection.
|
|
1145
|
+
* @param currentDir - Directory to walk
|
|
1146
|
+
* @param out - Accumulator set (created if omitted)
|
|
1147
|
+
* @returns The set of absolute file paths
|
|
1148
|
+
*/
|
|
1149
|
+
async _collectAllFiles(currentDir, out) {
|
|
1150
|
+
const result = out ?? /* @__PURE__ */ new Set();
|
|
1151
|
+
let entries;
|
|
1152
|
+
try {
|
|
1153
|
+
entries = await readdir(currentDir, { withFileTypes: true });
|
|
1154
|
+
} catch {
|
|
1155
|
+
return result;
|
|
1156
|
+
}
|
|
1157
|
+
for (const entry of entries) {
|
|
1158
|
+
const fullPath = join(currentDir, entry.name);
|
|
1159
|
+
if (entry.isDirectory()) {
|
|
1160
|
+
await this._collectAllFiles(fullPath, result);
|
|
1161
|
+
} else {
|
|
1162
|
+
result.add(fullPath);
|
|
1163
|
+
}
|
|
964
1164
|
}
|
|
1165
|
+
return result;
|
|
965
1166
|
}
|
|
966
1167
|
/**
|
|
967
1168
|
* Recursively restores a tree node and its children
|
|
@@ -995,7 +1196,7 @@ ${err.stack}` : String(err);
|
|
|
995
1196
|
);
|
|
996
1197
|
}
|
|
997
1198
|
await mkdir(dirname(filePath), { recursive: true });
|
|
998
|
-
await
|
|
1199
|
+
await FsAgent._atomicWriteFile(filePath, fileBlob.content);
|
|
999
1200
|
if (meta.mtime) {
|
|
1000
1201
|
const mtime = new Date(meta.mtime);
|
|
1001
1202
|
await utimes(filePath, mtime, mtime);
|
|
@@ -1189,25 +1390,38 @@ ${err.stack}` : String(err);
|
|
|
1189
1390
|
return { expectedDirs, expectedFiles };
|
|
1190
1391
|
}
|
|
1191
1392
|
/**
|
|
1192
|
-
* Remove files/dirs not present in the expected sets
|
|
1393
|
+
* Remove files/dirs not present in the expected sets, preserving any file
|
|
1394
|
+
* that appeared *during* the restore (not in `preRestore`) — a fresh user
|
|
1395
|
+
* write that must not be clobbered.
|
|
1193
1396
|
* @param currentDir - Directory currently being inspected
|
|
1194
1397
|
* @param expectedDirs - Allowed directory paths
|
|
1195
1398
|
* @param expectedFiles - Allowed file paths
|
|
1399
|
+
* @param preRestore - Files present before the restore (prune candidates)
|
|
1196
1400
|
*/
|
|
1197
|
-
async _pruneExtraneous(currentDir, expectedDirs, expectedFiles) {
|
|
1198
|
-
|
|
1401
|
+
async _pruneExtraneous(currentDir, expectedDirs, expectedFiles, preRestore) {
|
|
1402
|
+
let entries;
|
|
1403
|
+
try {
|
|
1404
|
+
entries = await readdir(currentDir, { withFileTypes: true });
|
|
1405
|
+
} catch {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1199
1408
|
for (const entry of entries) {
|
|
1200
1409
|
const fullPath = join(currentDir, entry.name);
|
|
1201
1410
|
if (entry.isDirectory()) {
|
|
1411
|
+
await this._pruneExtraneous(
|
|
1412
|
+
fullPath,
|
|
1413
|
+
expectedDirs,
|
|
1414
|
+
expectedFiles,
|
|
1415
|
+
preRestore
|
|
1416
|
+
);
|
|
1202
1417
|
if (!expectedDirs.has(fullPath)) {
|
|
1203
|
-
await
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
} else {
|
|
1208
|
-
if (!expectedFiles.has(fullPath)) {
|
|
1209
|
-
await rm(fullPath, { force: true });
|
|
1418
|
+
const remaining = await readdir(fullPath);
|
|
1419
|
+
if (remaining.length === 0) {
|
|
1420
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
1421
|
+
}
|
|
1210
1422
|
}
|
|
1423
|
+
} else if (!expectedFiles.has(fullPath) && preRestore.has(fullPath)) {
|
|
1424
|
+
await rm(fullPath, { force: true });
|
|
1211
1425
|
}
|
|
1212
1426
|
}
|
|
1213
1427
|
}
|
|
@@ -1266,10 +1480,15 @@ ${err.stack}` : String(err);
|
|
|
1266
1480
|
treeKey,
|
|
1267
1481
|
parentRef ? [parentRef] : void 0
|
|
1268
1482
|
);
|
|
1269
|
-
const ref = await FsAgent.
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1483
|
+
const ref = await FsAgent._withRetry(
|
|
1484
|
+
() => FsAgent._withTimeout(
|
|
1485
|
+
dbAdapter.storeFsTree(tree, { ...options, previous }),
|
|
1486
|
+
this._timeouts.fetchTree,
|
|
1487
|
+
`syncToDb → storeFsTree(${treeKey})`
|
|
1488
|
+
),
|
|
1489
|
+
3,
|
|
1490
|
+
200,
|
|
1491
|
+
`syncToDb storeFsTree(${treeKey})`
|
|
1273
1492
|
);
|
|
1274
1493
|
this._currentRef = ref;
|
|
1275
1494
|
if (ref === this._lastSentRef) {
|
|
@@ -1481,7 +1700,7 @@ ${err.stack}` : String(err);
|
|
|
1481
1700
|
writeFileAt: async (relativePath, content) => {
|
|
1482
1701
|
const filePath = join(this._rootPath, relativePath);
|
|
1483
1702
|
await mkdir(dirname(filePath), { recursive: true });
|
|
1484
|
-
await
|
|
1703
|
+
await FsAgent._atomicWriteFile(filePath, content);
|
|
1485
1704
|
},
|
|
1486
1705
|
deleteFileAt: async (relativePath) => {
|
|
1487
1706
|
await rm(join(this._rootPath, relativePath), {
|
|
@@ -1595,6 +1814,7 @@ ${err.stack}` : String(err);
|
|
|
1595
1814
|
err
|
|
1596
1815
|
);
|
|
1597
1816
|
this._writeSyncError("syncFromDb/processRef", err);
|
|
1817
|
+
connector.invalidateReceived(treeRef);
|
|
1598
1818
|
} else {
|
|
1599
1819
|
if (pendingRef === null) {
|
|
1600
1820
|
console.warn(
|
package/dist/fs-scanner.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface FsTree {
|
|
|
39
39
|
/**
|
|
40
40
|
* Type of file system change
|
|
41
41
|
*/
|
|
42
|
-
export type FsChangeType = 'added' | 'modified' | 'deleted';
|
|
42
|
+
export type FsChangeType = 'added' | 'modified' | 'deleted' | 'safety-rescan';
|
|
43
43
|
/**
|
|
44
44
|
* File system change event
|
|
45
45
|
*/
|
|
@@ -80,6 +80,10 @@ export declare class FsScanner {
|
|
|
80
80
|
private _bs;
|
|
81
81
|
private _paused;
|
|
82
82
|
private _missedChangesDuringPause;
|
|
83
|
+
/** Periodic full-rescan timer that catches events the native watcher drops. */
|
|
84
|
+
private _safetyTimer;
|
|
85
|
+
/** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
|
|
86
|
+
private _stopRequested;
|
|
83
87
|
constructor(rootPath: string, options?: FsScanOptions);
|
|
84
88
|
get tree(): FsTree | null;
|
|
85
89
|
get rootPath(): string;
|
|
@@ -88,6 +92,29 @@ export declare class FsScanner {
|
|
|
88
92
|
private _scanDirectory;
|
|
89
93
|
private _shouldIgnore;
|
|
90
94
|
watch(): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* One safety-rescan pass: rescans the tree and, if its content differs from
|
|
97
|
+
* the previous scan (the native watcher dropped an event), emits a sync
|
|
98
|
+
* notification so syncToDb reconciles the drift. Paused/stopped scanners and
|
|
99
|
+
* scan failures are no-ops.
|
|
100
|
+
*/
|
|
101
|
+
private _runSafetyRescan;
|
|
102
|
+
/**
|
|
103
|
+
* Path+blobId content fingerprint used by the safety rescan to detect drift
|
|
104
|
+
* the native watcher missed (mtime-independent, same idea as the agent's
|
|
105
|
+
* content key but local to the scanner).
|
|
106
|
+
* @param tree - The tree to fingerprint
|
|
107
|
+
* @returns A stable content key
|
|
108
|
+
*/
|
|
109
|
+
private _safetyContentKey;
|
|
110
|
+
/**
|
|
111
|
+
* Extracts a readable message from a thrown value.
|
|
112
|
+
* @param err - The caught value
|
|
113
|
+
* @returns A message string
|
|
114
|
+
*/
|
|
115
|
+
private static _errMessage;
|
|
116
|
+
/** Whether the host is Windows — gates Windows-specific watcher hardening. */
|
|
117
|
+
private static get _isWindows();
|
|
91
118
|
private _handleFileChange;
|
|
92
119
|
private _findTreeByPath;
|
|
93
120
|
private _notifyChange;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rljson/fs-agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
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",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@rljson/bs": "^0.0.21",
|
|
54
|
-
"@rljson/db": "^0.0.
|
|
54
|
+
"@rljson/db": "^0.0.23",
|
|
55
55
|
"@rljson/hash": "^0.0.18",
|
|
56
56
|
"@rljson/io": "^0.0.66",
|
|
57
57
|
"@rljson/json": "^0.0.23",
|