@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.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, sep } from "path";
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
- path: absolutePath,
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
- this._watcher = watch(
355
- this._rootPath,
356
- { recursive: true },
357
- /* v8 ignore next -- @preserve */
358
- async (eventType, filename) => {
359
- if (!filename) return;
360
- if (this._shouldIgnore(filename)) {
361
- return;
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
- await this._handleFileChange(eventType, filename);
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
- await stat(join(this._rootPath, filename));
375
- const existingTree = this._findTreeByPath(relativePath);
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
- try {
399
- await this.scan();
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 (connector.syncConfig?.requireAck) {
629
- await connector.sendWithAck(ref);
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(target, expectedDirs, expectedFiles);
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 writeFile(filePath, fileBlob.content);
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
- const entries = await readdir(currentDir, { withFileTypes: true });
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 rm(fullPath, { recursive: true, force: true });
938
- continue;
939
- }
940
- await this._pruneExtraneous(fullPath, expectedDirs, expectedFiles);
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
- this.storeInDb(db, treeKey, options),
1447
+ new FsDbAdapter(db, treeKey).storeFsTree(initialTree, {
1448
+ ...options,
1449
+ previous: initialPrevious
1450
+ }),
960
1451
  this._timeouts.fetchTree,
961
- `syncToDb → initial storeInDb(${treeKey})`
1452
+ `syncToDb → initial storeFsTree(${treeKey})`
962
1453
  );
963
1454
  if (initialRef) {
964
1455
  this._lastSentRef = initialRef;
965
- const currentTree = this._scanner.tree;
966
- if (currentTree) {
967
- this._lastSentContentKey = this._contentKeyFromTree(currentTree);
968
- }
969
- await this._sendRef(connector, initialRef);
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 ref = await FsAgent._withTimeout(
985
- dbAdapter.storeFsTree(tree, options),
986
- this._timeouts.fetchTree,
987
- `syncToDb → storeFsTree(${treeKey})`
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(connector, ref);
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
- const processRef = async (treeRef) => {
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
- console.error(
1123
- `[FsAgent] syncFromDb processRef failed after ${maxAttempts} attempts:`,
1124
- err
1125
- );
1126
- this._writeSyncError("syncFromDb/processRef", err);
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: ${err instanceof Error ? err.message : String(err)}`
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 syncCallback = async (treeRef) => {
1142
- if (!treeRef || typeof treeRef !== "string") {
1143
- return;
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 ref = pendingRef;
1851
+ const r = pendingRef;
1852
+ const ra = pendingRecoveryAttempt;
1853
+ const pr = pendingPredecessorRefs;
1150
1854
  pendingRef = null;
1151
- if (ref) {
1152
- await processRef(ref);
1855
+ if (r) {
1856
+ await processRef(r, ra, pr);
1153
1857
  }
1154
- }, this._timeouts.debounceMs);
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
- runClientServerSetup
2004
+ compareTips,
2005
+ conflictCopyName,
2006
+ decideWinner,
2007
+ findCommonAncestor,
2008
+ formatConflictTimestamp,
2009
+ fsTreeToContentMap,
2010
+ runClientServerSetup,
2011
+ threeWayMerge
1292
2012
  };