@rljson/fs-agent 0.0.12 → 0.0.15
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 +130 -1
- package/dist/fs-agent.js +810 -90
- package/dist/fs-conflict-resolver.d.ts +181 -0
- package/dist/fs-db-adapter.d.ts +8 -0
- package/dist/fs-scanner.d.ts +40 -6
- package/dist/index.d.ts +1 -0
- package/package.json +20 -10
package/dist/fs-agent.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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";
|
|
5
|
-
import { dirname, join
|
|
4
|
+
import { stat, readFile, mkdir, writeFile, readdir, rename, unlink, utimes, rm } from "fs/promises";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
6
|
import { hip } from "@rljson/hash";
|
|
7
7
|
import { Db } from "@rljson/db";
|
|
8
8
|
import { IoMem, createSocketPair } from "@rljson/io";
|
|
@@ -112,6 +112,248 @@ class FsBlobAdapter {
|
|
|
112
112
|
return new FsBlobAdapter();
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
const DIR_MARKER = "<dir>";
|
|
116
|
+
function fsTreeToContentMap(tree) {
|
|
117
|
+
const map = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const [, node] of tree.trees) {
|
|
119
|
+
const meta = node.meta;
|
|
120
|
+
if (meta?.type === "file") {
|
|
121
|
+
map.set(
|
|
122
|
+
meta.relativePath,
|
|
123
|
+
meta.blobId ?? ""
|
|
124
|
+
);
|
|
125
|
+
} else if (meta?.type === "directory" && meta.relativePath !== ".") {
|
|
126
|
+
map.set(meta.relativePath, DIR_MARKER);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return map;
|
|
130
|
+
}
|
|
131
|
+
function compareTips(a, b) {
|
|
132
|
+
if (a.timestamp !== b.timestamp) {
|
|
133
|
+
return a.timestamp - b.timestamp;
|
|
134
|
+
}
|
|
135
|
+
if (a.clientId !== b.clientId) {
|
|
136
|
+
return a.clientId > b.clientId ? 1 : -1;
|
|
137
|
+
}
|
|
138
|
+
if (a.ref !== b.ref) {
|
|
139
|
+
return a.ref > b.ref ? 1 : -1;
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
function decideWinner(a, b) {
|
|
144
|
+
return compareTips(a, b) >= 0 ? { winner: a, loser: b } : { winner: b, loser: a };
|
|
145
|
+
}
|
|
146
|
+
function findCommonAncestor(rows, tipA, tipB) {
|
|
147
|
+
const prevOf = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
prevOf.set(row.timeId, row.previous ?? []);
|
|
150
|
+
}
|
|
151
|
+
const ancestorsOfA = /* @__PURE__ */ new Set();
|
|
152
|
+
const stack = [tipA];
|
|
153
|
+
while (stack.length > 0) {
|
|
154
|
+
const id = stack.pop();
|
|
155
|
+
if (ancestorsOfA.has(id)) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
ancestorsOfA.add(id);
|
|
159
|
+
for (const p of prevOf.get(id) ?? []) {
|
|
160
|
+
stack.push(p);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const visited = /* @__PURE__ */ new Set();
|
|
164
|
+
let frontier = [tipB];
|
|
165
|
+
while (frontier.length > 0) {
|
|
166
|
+
const next = [];
|
|
167
|
+
for (const id of frontier) {
|
|
168
|
+
if (ancestorsOfA.has(id)) {
|
|
169
|
+
return id;
|
|
170
|
+
}
|
|
171
|
+
if (visited.has(id)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
visited.add(id);
|
|
175
|
+
for (const p of prevOf.get(id) ?? []) {
|
|
176
|
+
next.push(p);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
frontier = next;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function formatConflictTimestamp(ms) {
|
|
184
|
+
const d = new Date(ms);
|
|
185
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
186
|
+
const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
187
|
+
const time = `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
|
|
188
|
+
return `${date} ${time}`;
|
|
189
|
+
}
|
|
190
|
+
function conflictCopyName(relativePath, clientId, timestamp, taken) {
|
|
191
|
+
const slash = relativePath.lastIndexOf("/");
|
|
192
|
+
const dir = slash >= 0 ? relativePath.slice(0, slash + 1) : "";
|
|
193
|
+
const base = slash >= 0 ? relativePath.slice(slash + 1) : relativePath;
|
|
194
|
+
const dot = base.lastIndexOf(".");
|
|
195
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
196
|
+
const ext = dot > 0 ? base.slice(dot) : "";
|
|
197
|
+
const ts = formatConflictTimestamp(timestamp);
|
|
198
|
+
const marker = `(conflicted copy ${clientId} ${ts})`;
|
|
199
|
+
let candidate = `${dir}${stem} ${marker}${ext}`;
|
|
200
|
+
let n = 1;
|
|
201
|
+
while (taken.has(candidate)) {
|
|
202
|
+
candidate = `${dir}${stem} ${marker} (${n})${ext}`;
|
|
203
|
+
n++;
|
|
204
|
+
}
|
|
205
|
+
taken.add(candidate);
|
|
206
|
+
return candidate;
|
|
207
|
+
}
|
|
208
|
+
function threeWayMerge(o, ours, theirs, winnerSide, loserClientId, loserTimestamp) {
|
|
209
|
+
const merged = /* @__PURE__ */ new Map();
|
|
210
|
+
const copies = [];
|
|
211
|
+
const conflictPaths = [];
|
|
212
|
+
const taken = /* @__PURE__ */ new Set([...o.keys(), ...ours.keys(), ...theirs.keys()]);
|
|
213
|
+
const allPaths = new Set(taken);
|
|
214
|
+
for (const path of [...allPaths].sort()) {
|
|
215
|
+
const a = o.get(path);
|
|
216
|
+
const b = ours.get(path);
|
|
217
|
+
const c = theirs.get(path);
|
|
218
|
+
if (b === c) {
|
|
219
|
+
if (b !== void 0) {
|
|
220
|
+
merged.set(path, b);
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (b === a) {
|
|
225
|
+
if (c !== void 0) {
|
|
226
|
+
merged.set(path, c);
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (c === a) {
|
|
231
|
+
if (b !== void 0) {
|
|
232
|
+
merged.set(path, b);
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
conflictPaths.push(path);
|
|
237
|
+
const winnerVal = winnerSide === "ours" ? b : c;
|
|
238
|
+
const loserVal = winnerSide === "ours" ? c : b;
|
|
239
|
+
if (winnerVal !== void 0) {
|
|
240
|
+
merged.set(path, winnerVal);
|
|
241
|
+
}
|
|
242
|
+
if (loserVal !== void 0 && loserVal !== DIR_MARKER) {
|
|
243
|
+
const copyPath = conflictCopyName(
|
|
244
|
+
path,
|
|
245
|
+
loserClientId,
|
|
246
|
+
loserTimestamp,
|
|
247
|
+
taken
|
|
248
|
+
);
|
|
249
|
+
copies.push({ path: copyPath, blobId: loserVal });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { merged, copies, conflictPaths };
|
|
253
|
+
}
|
|
254
|
+
class FsConflictResolver {
|
|
255
|
+
constructor(deps) {
|
|
256
|
+
this.deps = deps;
|
|
257
|
+
}
|
|
258
|
+
_log(level, msg) {
|
|
259
|
+
this.deps.log?.(level, `[FsConflictResolver] ${msg}`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolves the conflict, returning the stored merge ref, or null when the
|
|
263
|
+
* conflict is not ours / not actionable.
|
|
264
|
+
* @param conflict - The detected DAG-branch conflict
|
|
265
|
+
* @returns The stored merge revision's root ref, or null
|
|
266
|
+
*/
|
|
267
|
+
async resolve(conflict) {
|
|
268
|
+
const { treeKey } = this.deps;
|
|
269
|
+
if (conflict.table !== treeKey) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const tips = conflict.branches ?? [];
|
|
273
|
+
if (tips.length < 2) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const rows = await this.deps.getInsertHistory(treeKey);
|
|
277
|
+
const rowByTimeId = new Map(
|
|
278
|
+
rows.map((r) => [r.timeId, r])
|
|
279
|
+
);
|
|
280
|
+
const branchTips = [];
|
|
281
|
+
for (const timeId of tips) {
|
|
282
|
+
const row = rowByTimeId.get(timeId);
|
|
283
|
+
const ref2 = await this.deps.getRefOfTimeId(treeKey, timeId);
|
|
284
|
+
branchTips.push({
|
|
285
|
+
timeId,
|
|
286
|
+
ref: ref2 ?? "",
|
|
287
|
+
clientId: row?.origin ?? "",
|
|
288
|
+
timestamp: row?.clientTimestamp ?? 0
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const ordered = [...branchTips].sort((x, y) => compareTips(x, y));
|
|
292
|
+
const loserTip = ordered[0];
|
|
293
|
+
const winnerTip = ordered[1];
|
|
294
|
+
if (!loserTip.ref || !winnerTip.ref) {
|
|
295
|
+
this._log(
|
|
296
|
+
"warn",
|
|
297
|
+
`missing tree ref for a tip (loser=${loserTip.ref}, winner=${winnerTip.ref})`
|
|
298
|
+
);
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const loserTipId = loserTip.timeId;
|
|
302
|
+
const winnerTipId = winnerTip.timeId;
|
|
303
|
+
const loserTree = await this.deps.fetchTree(loserTip.ref);
|
|
304
|
+
const winnerTree = await this.deps.fetchTree(winnerTip.ref);
|
|
305
|
+
const loserMap = fsTreeToContentMap(loserTree);
|
|
306
|
+
const winnerMap = fsTreeToContentMap(winnerTree);
|
|
307
|
+
const ancestorTimeId = findCommonAncestor(rows, loserTipId, winnerTipId);
|
|
308
|
+
let ancestorMap = /* @__PURE__ */ new Map();
|
|
309
|
+
if (ancestorTimeId) {
|
|
310
|
+
const ancRef = await this.deps.getRefOfTimeId(treeKey, ancestorTimeId);
|
|
311
|
+
if (ancRef) {
|
|
312
|
+
ancestorMap = fsTreeToContentMap(await this.deps.fetchTree(ancRef));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const plan = threeWayMerge(
|
|
316
|
+
ancestorMap,
|
|
317
|
+
loserMap,
|
|
318
|
+
winnerMap,
|
|
319
|
+
"theirs",
|
|
320
|
+
loserTip.clientId,
|
|
321
|
+
loserTip.timestamp
|
|
322
|
+
);
|
|
323
|
+
await this.deps.restoreTree(winnerTree);
|
|
324
|
+
for (const [path, blobId] of plan.merged) {
|
|
325
|
+
if (blobId === DIR_MARKER) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (winnerMap.get(path) === blobId) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
await this.deps.writeFileAt(path, await this.deps.getBlobContent(blobId));
|
|
332
|
+
}
|
|
333
|
+
for (const [path, blobId] of winnerMap) {
|
|
334
|
+
if (blobId === DIR_MARKER) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!plan.merged.has(path)) {
|
|
338
|
+
await this.deps.deleteFileAt(path);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const copy of plan.copies) {
|
|
342
|
+
await this.deps.writeFileAt(
|
|
343
|
+
copy.path,
|
|
344
|
+
await this.deps.getBlobContent(copy.blobId)
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
const mergedTree = await this.deps.scan();
|
|
348
|
+
const ref = await this.deps.storeMerge(mergedTree, [loserTipId, winnerTipId]);
|
|
349
|
+
this.deps.onMergeStored?.(ref);
|
|
350
|
+
this._log(
|
|
351
|
+
"info",
|
|
352
|
+
`resolved fork ${loserTipId.slice(0, 6)}…/${winnerTipId.slice(0, 6)}… → ${ref.slice(0, 8)}… (${plan.conflictPaths.length} conflict(s), ${plan.copies.length} copy/ies)`
|
|
353
|
+
);
|
|
354
|
+
return ref;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
115
357
|
class FsDbAdapter {
|
|
116
358
|
constructor(db, treeKey) {
|
|
117
359
|
this.db = db;
|
|
@@ -153,7 +395,8 @@ class FsDbAdapter {
|
|
|
153
395
|
);
|
|
154
396
|
trees.push(rootTree);
|
|
155
397
|
const results = await this.db.insertTrees(this.treeKey, trees, {
|
|
156
|
-
skipNotification: options.skipNotification
|
|
398
|
+
skipNotification: options.skipNotification,
|
|
399
|
+
previous: options.previous
|
|
157
400
|
});
|
|
158
401
|
return results[0][`${this.treeKey}Ref`];
|
|
159
402
|
}
|
|
@@ -179,6 +422,10 @@ class FsScanner {
|
|
|
179
422
|
_bs;
|
|
180
423
|
_paused = false;
|
|
181
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;
|
|
182
429
|
constructor(rootPath, options = {}) {
|
|
183
430
|
this._rootPath = rootPath;
|
|
184
431
|
this._options = {
|
|
@@ -240,7 +487,6 @@ class FsScanner {
|
|
|
240
487
|
return this._tree;
|
|
241
488
|
}
|
|
242
489
|
async _scanDirectory(absolutePath, relativePath, depth, trees) {
|
|
243
|
-
const stats = await stat(absolutePath);
|
|
244
490
|
const entries = await readdir(absolutePath, { withFileTypes: true });
|
|
245
491
|
const childRefs = [];
|
|
246
492
|
for (const entry of entries) {
|
|
@@ -292,9 +538,12 @@ class FsScanner {
|
|
|
292
538
|
const fileMeta = {
|
|
293
539
|
name: entry.name,
|
|
294
540
|
type: "file",
|
|
295
|
-
path: childPath,
|
|
296
541
|
relativePath: childRelPath,
|
|
297
542
|
size: childStats.size,
|
|
543
|
+
// mtime is kept for files (restore preserves it, so it round-trips to
|
|
544
|
+
// the same ref on every client) but NOT for directories (a folder's
|
|
545
|
+
// mtime is per-machine and does not round-trip). The absolute `path`
|
|
546
|
+
// is excluded everywhere — it is folder-specific.
|
|
298
547
|
mtime: childStats.mtime.getTime(),
|
|
299
548
|
blobId: blobProps.blobId
|
|
300
549
|
// Link to content in Bs
|
|
@@ -311,19 +560,14 @@ class FsScanner {
|
|
|
311
560
|
childRefs.push(fileTreeHashStr);
|
|
312
561
|
}
|
|
313
562
|
}
|
|
314
|
-
const dirName = relativePath === "." ? (
|
|
315
|
-
/* v8 ignore next -- @preserve */
|
|
316
|
-
this._rootPath.split(sep).pop() || ""
|
|
317
|
-
) : (
|
|
563
|
+
const dirName = relativePath === "." ? "." : (
|
|
318
564
|
/* v8 ignore next -- @preserve */
|
|
319
565
|
relativePath.split("/").pop() || ""
|
|
320
566
|
);
|
|
321
567
|
const dirMeta = {
|
|
322
568
|
name: dirName,
|
|
323
569
|
type: "directory",
|
|
324
|
-
|
|
325
|
-
relativePath,
|
|
326
|
-
mtime: stats.mtime.getTime()
|
|
570
|
+
relativePath
|
|
327
571
|
};
|
|
328
572
|
const dirTree = {
|
|
329
573
|
id: dirName,
|
|
@@ -351,18 +595,102 @@ class FsScanner {
|
|
|
351
595
|
if (!this._tree) {
|
|
352
596
|
await this.scan();
|
|
353
597
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
+
);
|
|
362
623
|
}
|
|
363
|
-
|
|
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}`);
|
|
364
678
|
}
|
|
365
|
-
|
|
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";
|
|
366
694
|
}
|
|
367
695
|
async _handleFileChange(_eventType, filename) {
|
|
368
696
|
if (this._paused) {
|
|
@@ -370,23 +698,35 @@ class FsScanner {
|
|
|
370
698
|
return;
|
|
371
699
|
}
|
|
372
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
|
+
}
|
|
373
720
|
try {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (!existingTree) {
|
|
377
|
-
await this.scan();
|
|
378
|
-
await this._notifyChange({
|
|
379
|
-
type: "added",
|
|
380
|
-
path: relativePath
|
|
381
|
-
});
|
|
382
|
-
} else {
|
|
721
|
+
if (exists) {
|
|
722
|
+
const existingTree = this._findTreeByPath(relativePath);
|
|
383
723
|
await this.scan();
|
|
384
724
|
await this._notifyChange({
|
|
385
|
-
type: "modified",
|
|
725
|
+
type: existingTree ? "modified" : "added",
|
|
386
726
|
path: relativePath
|
|
387
727
|
});
|
|
728
|
+
return;
|
|
388
729
|
}
|
|
389
|
-
} catch {
|
|
390
730
|
let rootExists = false;
|
|
391
731
|
try {
|
|
392
732
|
await stat(this._rootPath);
|
|
@@ -395,15 +735,10 @@ class FsScanner {
|
|
|
395
735
|
this.stopWatch();
|
|
396
736
|
}
|
|
397
737
|
if (rootExists) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
await this._notifyChange({
|
|
401
|
-
type: "deleted",
|
|
402
|
-
path: relativePath
|
|
403
|
-
});
|
|
404
|
-
} catch {
|
|
405
|
-
}
|
|
738
|
+
await this.scan();
|
|
739
|
+
await this._notifyChange({ type: "deleted", path: relativePath });
|
|
406
740
|
}
|
|
741
|
+
} catch {
|
|
407
742
|
}
|
|
408
743
|
}
|
|
409
744
|
_findTreeByPath(relativePath) {
|
|
@@ -433,6 +768,11 @@ class FsScanner {
|
|
|
433
768
|
);
|
|
434
769
|
}
|
|
435
770
|
stopWatch() {
|
|
771
|
+
this._stopRequested = true;
|
|
772
|
+
if (this._safetyTimer) {
|
|
773
|
+
clearInterval(this._safetyTimer);
|
|
774
|
+
this._safetyTimer = null;
|
|
775
|
+
}
|
|
436
776
|
if (this._watcher) {
|
|
437
777
|
this._watcher.close();
|
|
438
778
|
this._watcher = null;
|
|
@@ -510,9 +850,11 @@ const DEFAULT_TIMEOUTS = {
|
|
|
510
850
|
syncCallback: 25e3,
|
|
511
851
|
debounceMs: 300,
|
|
512
852
|
processRefRetries: 3,
|
|
513
|
-
processRefRetryDelayMs: 5e3
|
|
853
|
+
processRefRetryDelayMs: 5e3,
|
|
854
|
+
recoveryRetries: 10
|
|
514
855
|
};
|
|
515
856
|
const SYNC_ERROR_FILE = ".sync-errors.log";
|
|
857
|
+
const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
|
|
516
858
|
class FsAgent {
|
|
517
859
|
_scanner;
|
|
518
860
|
_adapter;
|
|
@@ -526,15 +868,25 @@ class FsAgent {
|
|
|
526
868
|
/** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
|
|
527
869
|
_lastSentContentKey;
|
|
528
870
|
_timeouts;
|
|
871
|
+
/** Client-only: resolve DAG-branch conflicts into merge revisions. */
|
|
872
|
+
_resolveConflicts;
|
|
873
|
+
/**
|
|
874
|
+
* Ancestry head: the content ref of the revision currently representing the
|
|
875
|
+
* filesystem state. New local revisions descend from it; received revisions
|
|
876
|
+
* advance it. Only tracked when `resolveConflicts` is enabled, so the
|
|
877
|
+
* InsertHistory predecessor DAG forms only where conflict resolution is on.
|
|
878
|
+
*/
|
|
879
|
+
_currentRef;
|
|
529
880
|
constructor(rootPath, bs, options = {}) {
|
|
530
881
|
this._rootPath = rootPath;
|
|
531
882
|
this._bs = bs || new BsMem();
|
|
532
883
|
this._db = options.db;
|
|
533
884
|
this._treeKey = options.treeKey;
|
|
534
885
|
this._timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
|
|
886
|
+
this._resolveConflicts = options.resolveConflicts ?? false;
|
|
535
887
|
this._scanner = new FsScanner(rootPath, {
|
|
536
888
|
...options,
|
|
537
|
-
ignore: [...options.ignore || [], SYNC_ERROR_FILE],
|
|
889
|
+
ignore: [...options.ignore || [], SYNC_ERROR_FILE, ATOMIC_TMP_PREFIX],
|
|
538
890
|
bs: this._bs
|
|
539
891
|
});
|
|
540
892
|
this._adapter = new FsBlobAdapter(this._bs);
|
|
@@ -592,6 +944,77 @@ ${err.stack}` : String(err);
|
|
|
592
944
|
} catch {
|
|
593
945
|
}
|
|
594
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Extracts a human-readable message from a thrown value. The non-`Error`
|
|
949
|
+
* branch is defensive (the DB/transport always throw `Error`s).
|
|
950
|
+
* @param err - The caught value.
|
|
951
|
+
* @returns A message string.
|
|
952
|
+
*/
|
|
953
|
+
static _errMessage(err) {
|
|
954
|
+
return err instanceof Error ? err.message : String(err);
|
|
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
|
+
}
|
|
595
1018
|
/**
|
|
596
1019
|
* Wraps a promise with a timeout.
|
|
597
1020
|
* Rejects with a descriptive error if the promise does not settle
|
|
@@ -623,13 +1046,26 @@ ${err.stack}` : String(err);
|
|
|
623
1046
|
* otherwise falls back to fire-and-forget `send()`.
|
|
624
1047
|
* @param connector - The Connector to send through
|
|
625
1048
|
* @param ref - The ref to broadcast
|
|
1049
|
+
* @param predecessorRefs - Causal predecessor content refs to attach (for
|
|
1050
|
+
* conflict ancestry); set explicitly here because the FsAgent broadcasts
|
|
1051
|
+
* via an explicit send, which pre-empts the Connector's db-observer path.
|
|
626
1052
|
*/
|
|
627
|
-
async _sendRef(connector, ref) {
|
|
628
|
-
if (
|
|
629
|
-
|
|
630
|
-
} else {
|
|
631
|
-
connector.send(ref);
|
|
1053
|
+
async _sendRef(connector, ref, predecessorRefs) {
|
|
1054
|
+
if (this._resolveConflicts) {
|
|
1055
|
+
connector.setPredecessors(predecessorRefs ?? []);
|
|
632
1056
|
}
|
|
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
|
+
);
|
|
633
1069
|
}
|
|
634
1070
|
/**
|
|
635
1071
|
* Starts automatic syncing to database
|
|
@@ -692,10 +1128,41 @@ ${err.stack}` : String(err);
|
|
|
692
1128
|
tree,
|
|
693
1129
|
target
|
|
694
1130
|
);
|
|
1131
|
+
const preRestore = options?.cleanTarget ? await this._collectAllFiles(target) : /* @__PURE__ */ new Set();
|
|
695
1132
|
await this._restoreTree(tree.rootHash, tree.trees, target);
|
|
696
1133
|
if (options?.cleanTarget) {
|
|
697
|
-
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
|
+
}
|
|
698
1164
|
}
|
|
1165
|
+
return result;
|
|
699
1166
|
}
|
|
700
1167
|
/**
|
|
701
1168
|
* Recursively restores a tree node and its children
|
|
@@ -729,7 +1196,7 @@ ${err.stack}` : String(err);
|
|
|
729
1196
|
);
|
|
730
1197
|
}
|
|
731
1198
|
await mkdir(dirname(filePath), { recursive: true });
|
|
732
|
-
await
|
|
1199
|
+
await FsAgent._atomicWriteFile(filePath, fileBlob.content);
|
|
733
1200
|
if (meta.mtime) {
|
|
734
1201
|
const mtime = new Date(meta.mtime);
|
|
735
1202
|
await utimes(filePath, mtime, mtime);
|
|
@@ -923,25 +1390,38 @@ ${err.stack}` : String(err);
|
|
|
923
1390
|
return { expectedDirs, expectedFiles };
|
|
924
1391
|
}
|
|
925
1392
|
/**
|
|
926
|
-
* 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.
|
|
927
1396
|
* @param currentDir - Directory currently being inspected
|
|
928
1397
|
* @param expectedDirs - Allowed directory paths
|
|
929
1398
|
* @param expectedFiles - Allowed file paths
|
|
1399
|
+
* @param preRestore - Files present before the restore (prune candidates)
|
|
930
1400
|
*/
|
|
931
|
-
async _pruneExtraneous(currentDir, expectedDirs, expectedFiles) {
|
|
932
|
-
|
|
1401
|
+
async _pruneExtraneous(currentDir, expectedDirs, expectedFiles, preRestore) {
|
|
1402
|
+
let entries;
|
|
1403
|
+
try {
|
|
1404
|
+
entries = await readdir(currentDir, { withFileTypes: true });
|
|
1405
|
+
} catch {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
933
1408
|
for (const entry of entries) {
|
|
934
1409
|
const fullPath = join(currentDir, entry.name);
|
|
935
1410
|
if (entry.isDirectory()) {
|
|
1411
|
+
await this._pruneExtraneous(
|
|
1412
|
+
fullPath,
|
|
1413
|
+
expectedDirs,
|
|
1414
|
+
expectedFiles,
|
|
1415
|
+
preRestore
|
|
1416
|
+
);
|
|
936
1417
|
if (!expectedDirs.has(fullPath)) {
|
|
937
|
-
await
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
} else {
|
|
942
|
-
if (!expectedFiles.has(fullPath)) {
|
|
943
|
-
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
|
+
}
|
|
944
1422
|
}
|
|
1423
|
+
} else if (!expectedFiles.has(fullPath) && preRestore.has(fullPath)) {
|
|
1424
|
+
await rm(fullPath, { force: true });
|
|
945
1425
|
}
|
|
946
1426
|
}
|
|
947
1427
|
}
|
|
@@ -955,18 +1435,31 @@ ${err.stack}` : String(err);
|
|
|
955
1435
|
* @returns Function to stop watching
|
|
956
1436
|
*/
|
|
957
1437
|
async syncToDb(db, connector, treeKey, options) {
|
|
1438
|
+
const initialParentRef = this._currentRef;
|
|
1439
|
+
const initialTree = await FsAgent._withTimeout(
|
|
1440
|
+
this.extract(),
|
|
1441
|
+
this._timeouts.extract,
|
|
1442
|
+
`syncToDb → initial extract(${treeKey})`
|
|
1443
|
+
);
|
|
1444
|
+
const initialIsNew = initialParentRef !== void 0 && initialTree.rootHash !== initialParentRef;
|
|
1445
|
+
const initialPrevious = initialIsNew ? await this._ancestryPrevious(db, treeKey, [initialParentRef]) : void 0;
|
|
958
1446
|
const initialRef = await FsAgent._withTimeout(
|
|
959
|
-
|
|
1447
|
+
new FsDbAdapter(db, treeKey).storeFsTree(initialTree, {
|
|
1448
|
+
...options,
|
|
1449
|
+
previous: initialPrevious
|
|
1450
|
+
}),
|
|
960
1451
|
this._timeouts.fetchTree,
|
|
961
|
-
`syncToDb → initial
|
|
1452
|
+
`syncToDb → initial storeFsTree(${treeKey})`
|
|
962
1453
|
);
|
|
963
1454
|
if (initialRef) {
|
|
964
1455
|
this._lastSentRef = initialRef;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1456
|
+
this._currentRef = initialRef;
|
|
1457
|
+
this._lastSentContentKey = this._contentKeyFromTree(initialTree);
|
|
1458
|
+
await this._sendRef(
|
|
1459
|
+
connector,
|
|
1460
|
+
initialRef,
|
|
1461
|
+
initialIsNew ? [initialParentRef] : void 0
|
|
1462
|
+
);
|
|
970
1463
|
}
|
|
971
1464
|
let debounceTimer = null;
|
|
972
1465
|
const debouncedSync = () => {
|
|
@@ -981,18 +1474,34 @@ ${err.stack}` : String(err);
|
|
|
981
1474
|
return;
|
|
982
1475
|
}
|
|
983
1476
|
const dbAdapter = new FsDbAdapter(db, treeKey);
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1477
|
+
const parentRef = this._currentRef;
|
|
1478
|
+
const previous = await this._ancestryPrevious(
|
|
1479
|
+
db,
|
|
1480
|
+
treeKey,
|
|
1481
|
+
parentRef ? [parentRef] : void 0
|
|
1482
|
+
);
|
|
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})`
|
|
988
1492
|
);
|
|
1493
|
+
this._currentRef = ref;
|
|
989
1494
|
if (ref === this._lastSentRef) {
|
|
990
1495
|
return;
|
|
991
1496
|
}
|
|
992
1497
|
this._lastSentRef = ref;
|
|
993
1498
|
this._lastSentContentKey = contentKey;
|
|
994
1499
|
if (ref) {
|
|
995
|
-
await this._sendRef(
|
|
1500
|
+
await this._sendRef(
|
|
1501
|
+
connector,
|
|
1502
|
+
ref,
|
|
1503
|
+
parentRef ? [parentRef] : void 0
|
|
1504
|
+
);
|
|
996
1505
|
}
|
|
997
1506
|
} catch (err) {
|
|
998
1507
|
console.error("[FsAgent] syncToDb failed:", err);
|
|
@@ -1009,6 +1518,110 @@ ${err.stack}` : String(err);
|
|
|
1009
1518
|
this._scanner.stopWatch();
|
|
1010
1519
|
};
|
|
1011
1520
|
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Resolves the `previous` (InsertHistory predecessor timeIds) for a new
|
|
1523
|
+
* revision from the parent's shared content refs. timeIds are per-db, so we
|
|
1524
|
+
* map each shared parent ref to *this* db's local timeId(s). Returns undefined
|
|
1525
|
+
* when ancestry tracking is off (default) or no parent is known — in which
|
|
1526
|
+
* case the store behaves exactly as before.
|
|
1527
|
+
* @param db - Database instance
|
|
1528
|
+
* @param treeKey - Tree table key
|
|
1529
|
+
* @param parentRefs - Parent content refs (local head, or received predecessors)
|
|
1530
|
+
*/
|
|
1531
|
+
async _ancestryPrevious(db, treeKey, parentRefs) {
|
|
1532
|
+
if (!this._resolveConflicts || !parentRefs || parentRefs.length === 0) {
|
|
1533
|
+
return void 0;
|
|
1534
|
+
}
|
|
1535
|
+
const timeIds = [];
|
|
1536
|
+
for (const ref of parentRefs) {
|
|
1537
|
+
timeIds.push(...await db.getTimeIdsForRef(treeKey, ref));
|
|
1538
|
+
}
|
|
1539
|
+
return timeIds.length > 0 ? timeIds : void 0;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Classifies an incoming revision relative to our current head using the
|
|
1543
|
+
* local InsertHistory DAG (keyed on shared content refs):
|
|
1544
|
+
* - `behind` → incoming descends from our head → fast-forward (restore).
|
|
1545
|
+
* - `ahead` → our head descends from incoming (e.g. a reconnect bootstrap
|
|
1546
|
+
* re-sending an older ancestor) → ignore; we are newer.
|
|
1547
|
+
* - `diverged` → siblings produced by concurrent edits → resolve the fork.
|
|
1548
|
+
* @param db - Database instance
|
|
1549
|
+
* @param treeKey - Tree table key
|
|
1550
|
+
* @param currentRef - Our current head's content ref
|
|
1551
|
+
* @param incomingRef - The incoming revision's content ref
|
|
1552
|
+
* @param incomingPredecessorRefs - The incoming revision's predecessor refs
|
|
1553
|
+
*/
|
|
1554
|
+
async _ancestryRelation(db, treeKey, currentRef, incomingRef, incomingPredecessorRefs) {
|
|
1555
|
+
const dump = await db.getInsertHistory(treeKey);
|
|
1556
|
+
const rows = dump[`${treeKey}InsertHistory`]?._data ?? [];
|
|
1557
|
+
const refKey = `${treeKey}Ref`;
|
|
1558
|
+
const refOfTimeId = /* @__PURE__ */ new Map();
|
|
1559
|
+
for (const r of rows) {
|
|
1560
|
+
refOfTimeId.set(r.timeId, r[refKey]);
|
|
1561
|
+
}
|
|
1562
|
+
const prevRefsOf = /* @__PURE__ */ new Map();
|
|
1563
|
+
for (const r of rows) {
|
|
1564
|
+
const prev = (r.previous ?? []).map((t) => refOfTimeId.get(t)).filter((x) => x !== void 0);
|
|
1565
|
+
prevRefsOf.set(r[refKey], prev);
|
|
1566
|
+
}
|
|
1567
|
+
const ancestorsOf = (startRefs) => {
|
|
1568
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1569
|
+
const stack = [...startRefs];
|
|
1570
|
+
while (stack.length > 0) {
|
|
1571
|
+
const ref = stack.pop();
|
|
1572
|
+
if (seen.has(ref)) {
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
seen.add(ref);
|
|
1576
|
+
for (const p of prevRefsOf.get(ref) ?? []) {
|
|
1577
|
+
stack.push(p);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return seen;
|
|
1581
|
+
};
|
|
1582
|
+
if (ancestorsOf(incomingPredecessorRefs).has(currentRef)) {
|
|
1583
|
+
return "behind";
|
|
1584
|
+
}
|
|
1585
|
+
if (ancestorsOf([currentRef]).has(incomingRef)) {
|
|
1586
|
+
return "ahead";
|
|
1587
|
+
}
|
|
1588
|
+
return "diverged";
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Resolves a divergent incoming revision inline (called from `processRef`
|
|
1592
|
+
* with the watcher paused, so resolution cannot race the sync loop). Records
|
|
1593
|
+
* the incoming revision as a fork tip without clobbering local content, then
|
|
1594
|
+
* merges our head and the incoming tip into a single merge revision D that is
|
|
1595
|
+
* materialised to disk and broadcast.
|
|
1596
|
+
* @param db - Database instance
|
|
1597
|
+
* @param treeKey - Tree table key
|
|
1598
|
+
* @param incomingRef - The incoming revision's content ref
|
|
1599
|
+
* @param incomingTree - The fetched incoming tree
|
|
1600
|
+
* @param predecessorRefs - The incoming revision's predecessor content refs
|
|
1601
|
+
*/
|
|
1602
|
+
async _resolveConflictInline(db, treeKey, incomingRef, incomingTree, predecessorRefs) {
|
|
1603
|
+
const dbAdapter = new FsDbAdapter(db, treeKey);
|
|
1604
|
+
const incomingPrevious = await this._ancestryPrevious(
|
|
1605
|
+
db,
|
|
1606
|
+
treeKey,
|
|
1607
|
+
predecessorRefs
|
|
1608
|
+
);
|
|
1609
|
+
await dbAdapter.storeFsTree(incomingTree, {
|
|
1610
|
+
skipNotification: true,
|
|
1611
|
+
previous: incomingPrevious
|
|
1612
|
+
});
|
|
1613
|
+
const headTimeIds = await db.getTimeIdsForRef(treeKey, this._currentRef);
|
|
1614
|
+
const incomingTimeIds = await db.getTimeIdsForRef(treeKey, incomingRef);
|
|
1615
|
+
const resolver = new FsConflictResolver(
|
|
1616
|
+
this._buildConflictResolverDeps(db, treeKey)
|
|
1617
|
+
);
|
|
1618
|
+
await resolver.resolve({
|
|
1619
|
+
table: treeKey,
|
|
1620
|
+
type: "dagBranch",
|
|
1621
|
+
detectedAt: Date.now(),
|
|
1622
|
+
branches: [...headTimeIds, ...incomingTimeIds]
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1012
1625
|
/**
|
|
1013
1626
|
* Builds a map of relativePath → blobId for all files in a tree.
|
|
1014
1627
|
* Used to compare trees by content rather than by hash (which includes mtime).
|
|
@@ -1062,6 +1675,51 @@ ${err.stack}` : String(err);
|
|
|
1062
1675
|
}
|
|
1063
1676
|
return true;
|
|
1064
1677
|
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Builds the dependency surface a {@link FsConflictResolver} needs, wiring it
|
|
1680
|
+
* to this agent's db, blob store, scanner, and working directory.
|
|
1681
|
+
*
|
|
1682
|
+
* The merge store records the merged ref/content key as the last-sent state,
|
|
1683
|
+
* so the watcher-driven re-scan that follows the on-disk materialisation
|
|
1684
|
+
* settles to a no-op instead of re-broadcasting.
|
|
1685
|
+
* @param db - Database instance
|
|
1686
|
+
* @param treeKey - Tree table key
|
|
1687
|
+
*/
|
|
1688
|
+
_buildConflictResolverDeps(db, treeKey) {
|
|
1689
|
+
return {
|
|
1690
|
+
treeKey,
|
|
1691
|
+
getInsertHistory: async (table) => {
|
|
1692
|
+
const dump = await db.getInsertHistory(table);
|
|
1693
|
+
const rows = dump[`${table}InsertHistory`]?._data ?? [];
|
|
1694
|
+
return rows;
|
|
1695
|
+
},
|
|
1696
|
+
getRefOfTimeId: (table, timeId) => db.getRefOfTimeId(table, timeId),
|
|
1697
|
+
fetchTree: (rootRef) => this._fetchTreeFromDb(db, treeKey, rootRef),
|
|
1698
|
+
getBlobContent: (blobId) => this._adapter.getFileContent(blobId),
|
|
1699
|
+
restoreTree: (tree) => this.restore(tree, void 0, { cleanTarget: true }),
|
|
1700
|
+
writeFileAt: async (relativePath, content) => {
|
|
1701
|
+
const filePath = join(this._rootPath, relativePath);
|
|
1702
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1703
|
+
await FsAgent._atomicWriteFile(filePath, content);
|
|
1704
|
+
},
|
|
1705
|
+
deleteFileAt: async (relativePath) => {
|
|
1706
|
+
await rm(join(this._rootPath, relativePath), {
|
|
1707
|
+
force: true,
|
|
1708
|
+
recursive: true
|
|
1709
|
+
});
|
|
1710
|
+
},
|
|
1711
|
+
scan: () => this._scanner.scan(),
|
|
1712
|
+
storeMerge: async (tree, previous) => {
|
|
1713
|
+
const dbAdapter = new FsDbAdapter(db, treeKey);
|
|
1714
|
+
const ref = await dbAdapter.storeFsTree(tree, { previous });
|
|
1715
|
+
this._lastSentRef = ref;
|
|
1716
|
+
this._lastSentContentKey = this._contentKeyFromTree(tree);
|
|
1717
|
+
this._currentRef = ref;
|
|
1718
|
+
return ref;
|
|
1719
|
+
}
|
|
1720
|
+
// Resolution failures are surfaced by `_onConflict`; success is silent.
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1065
1723
|
/**
|
|
1066
1724
|
* Watches database for tree changes and syncs to filesystem
|
|
1067
1725
|
* Uses Connector for socket-based notifications
|
|
@@ -1077,7 +1735,9 @@ ${err.stack}` : String(err);
|
|
|
1077
1735
|
}
|
|
1078
1736
|
let pendingRef = null;
|
|
1079
1737
|
let fromDbTimer = null;
|
|
1080
|
-
|
|
1738
|
+
let pendingRecoveryAttempt = 0;
|
|
1739
|
+
let pendingPredecessorRefs;
|
|
1740
|
+
const processRef = async (treeRef, recoveryAttempt = 0, predecessorRefs) => {
|
|
1081
1741
|
const maxAttempts = this._timeouts.processRefRetries + 1;
|
|
1082
1742
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1083
1743
|
this._scanner.pauseWatch();
|
|
@@ -1104,6 +1764,28 @@ ${err.stack}` : String(err);
|
|
|
1104
1764
|
);
|
|
1105
1765
|
return;
|
|
1106
1766
|
}
|
|
1767
|
+
if (this._resolveConflicts && this._currentRef && predecessorRefs && predecessorRefs.length > 0) {
|
|
1768
|
+
const relation = await this._ancestryRelation(
|
|
1769
|
+
db,
|
|
1770
|
+
treeKey,
|
|
1771
|
+
this._currentRef,
|
|
1772
|
+
treeRef,
|
|
1773
|
+
predecessorRefs
|
|
1774
|
+
);
|
|
1775
|
+
if (relation === "ahead") {
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
if (relation === "diverged") {
|
|
1779
|
+
await this._resolveConflictInline(
|
|
1780
|
+
db,
|
|
1781
|
+
treeKey,
|
|
1782
|
+
treeRef,
|
|
1783
|
+
incomingTree,
|
|
1784
|
+
predecessorRefs
|
|
1785
|
+
);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1107
1789
|
await FsAgent._withTimeout(
|
|
1108
1790
|
this.restore(incomingTree, void 0, restoreOptions),
|
|
1109
1791
|
this._timeouts.restore,
|
|
@@ -1111,23 +1793,44 @@ ${err.stack}` : String(err);
|
|
|
1111
1793
|
);
|
|
1112
1794
|
const postRestoreTree = await this._scanner.scan();
|
|
1113
1795
|
const dbAdapter = new FsDbAdapter(db, treeKey);
|
|
1796
|
+
const previous = await this._ancestryPrevious(
|
|
1797
|
+
db,
|
|
1798
|
+
treeKey,
|
|
1799
|
+
predecessorRefs
|
|
1800
|
+
);
|
|
1114
1801
|
const postRestoreRef = await dbAdapter.storeFsTree(postRestoreTree, {
|
|
1115
|
-
skipNotification: true
|
|
1802
|
+
skipNotification: true,
|
|
1803
|
+
previous
|
|
1116
1804
|
});
|
|
1117
1805
|
this._lastSentRef = postRestoreRef;
|
|
1118
1806
|
this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
|
|
1807
|
+
this._currentRef = postRestoreRef;
|
|
1119
1808
|
return;
|
|
1120
1809
|
} catch (err) {
|
|
1121
1810
|
if (attempt === maxAttempts) {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1811
|
+
if (recoveryAttempt >= this._timeouts.recoveryRetries) {
|
|
1812
|
+
console.error(
|
|
1813
|
+
`[FsAgent] syncFromDb processRef failed after ${maxAttempts} attempts and ${recoveryAttempt} recoveries:`,
|
|
1814
|
+
err
|
|
1815
|
+
);
|
|
1816
|
+
this._writeSyncError("syncFromDb/processRef", err);
|
|
1817
|
+
} else {
|
|
1818
|
+
if (pendingRef === null) {
|
|
1819
|
+
console.warn(
|
|
1820
|
+
`[FsAgent] syncFromDb: ref=${treeRef.slice(0, 8)}… not yet fetchable after ${maxAttempts} attempts, re-queueing (recovery ${recoveryAttempt + 1}/${this._timeouts.recoveryRetries}): ${FsAgent._errMessage(err)}`
|
|
1821
|
+
);
|
|
1822
|
+
scheduleProcess(
|
|
1823
|
+
treeRef,
|
|
1824
|
+
this._timeouts.processRefRetryDelayMs * maxAttempts,
|
|
1825
|
+
recoveryAttempt + 1,
|
|
1826
|
+
predecessorRefs
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1127
1830
|
} else {
|
|
1128
1831
|
const delaySec = attempt * this._timeouts.processRefRetryDelayMs / 1e3;
|
|
1129
1832
|
console.warn(
|
|
1130
|
-
`[FsAgent] syncFromDb: attempt ${attempt}/${maxAttempts} failed for ref=${treeRef.slice(0, 8)}…, retrying in ${delaySec}s: ${
|
|
1833
|
+
`[FsAgent] syncFromDb: attempt ${attempt}/${maxAttempts} failed for ref=${treeRef.slice(0, 8)}…, retrying in ${delaySec}s: ${FsAgent._errMessage(err)}`
|
|
1131
1834
|
);
|
|
1132
1835
|
}
|
|
1133
1836
|
} finally {
|
|
@@ -1138,20 +1841,28 @@ ${err.stack}` : String(err);
|
|
|
1138
1841
|
);
|
|
1139
1842
|
}
|
|
1140
1843
|
};
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
pendingRef = treeRef;
|
|
1844
|
+
const scheduleProcess = (ref, delayMs, recoveryAttempt, predecessorRefs) => {
|
|
1845
|
+
pendingRef = ref;
|
|
1846
|
+
pendingRecoveryAttempt = recoveryAttempt;
|
|
1847
|
+
pendingPredecessorRefs = predecessorRefs;
|
|
1146
1848
|
if (fromDbTimer) clearTimeout(fromDbTimer);
|
|
1147
1849
|
fromDbTimer = setTimeout(async () => {
|
|
1148
1850
|
fromDbTimer = null;
|
|
1149
|
-
const
|
|
1851
|
+
const r = pendingRef;
|
|
1852
|
+
const ra = pendingRecoveryAttempt;
|
|
1853
|
+
const pr = pendingPredecessorRefs;
|
|
1150
1854
|
pendingRef = null;
|
|
1151
|
-
if (
|
|
1152
|
-
await processRef(
|
|
1855
|
+
if (r) {
|
|
1856
|
+
await processRef(r, ra, pr);
|
|
1153
1857
|
}
|
|
1154
|
-
},
|
|
1858
|
+
}, delayMs);
|
|
1859
|
+
};
|
|
1860
|
+
const syncCallback = (treeRef, predecessorRefs) => {
|
|
1861
|
+
if (!treeRef || typeof treeRef !== "string") {
|
|
1862
|
+
return Promise.resolve();
|
|
1863
|
+
}
|
|
1864
|
+
scheduleProcess(treeRef, this._timeouts.debounceMs, 0, predecessorRefs);
|
|
1865
|
+
return Promise.resolve();
|
|
1155
1866
|
};
|
|
1156
1867
|
connector.listen(syncCallback);
|
|
1157
1868
|
return () => {
|
|
@@ -1283,10 +1994,19 @@ async function runClientServerSetup(opts = {}) {
|
|
|
1283
1994
|
return { baseDir, folderA, folderB, contentB, cleanup };
|
|
1284
1995
|
}
|
|
1285
1996
|
export {
|
|
1997
|
+
DIR_MARKER,
|
|
1286
1998
|
FsAgent,
|
|
1287
1999
|
FsBlobAdapter,
|
|
2000
|
+
FsConflictResolver,
|
|
1288
2001
|
FsDbAdapter,
|
|
1289
2002
|
FsScanner,
|
|
1290
2003
|
SYNC_ERROR_FILE,
|
|
1291
|
-
|
|
2004
|
+
compareTips,
|
|
2005
|
+
conflictCopyName,
|
|
2006
|
+
decideWinner,
|
|
2007
|
+
findCommonAncestor,
|
|
2008
|
+
formatConflictTimestamp,
|
|
2009
|
+
fsTreeToContentMap,
|
|
2010
|
+
runClientServerSetup,
|
|
2011
|
+
threeWayMerge
|
|
1292
2012
|
};
|