@rljson/fs-agent 0.0.11 → 0.0.14

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.
@@ -44,6 +44,14 @@ export interface FsAgentOptions {
44
44
  * auto-generates its own identity.
45
45
  */
46
46
  clientIdentity?: ClientId;
47
+ /**
48
+ * Enable Nextcloud-style conflict resolution. When true, {@link syncFromDb}
49
+ * registers a DAG-branch conflict observer that resolves forks into a single
50
+ * merge revision (winner keeps the path, loser is renamed). This is a
51
+ * **client-only** behaviour — hubs are dumb relays and must leave it off
52
+ * (the default). See `doc/conflict-resolution-design.md`.
53
+ */
54
+ resolveConflicts?: boolean;
47
55
  }
48
56
  /** Restore options */
49
57
  export interface RestoreOptions {
@@ -85,6 +93,17 @@ export interface TimeoutConfig {
85
93
  * Each retry waits `attempt * processRefRetryDelayMs` (i.e. 5s, 10s, 15s).
86
94
  */
87
95
  processRefRetryDelayMs?: number;
96
+ /**
97
+ * Number of **recovery re-queues** for a ref whose per-cycle retries were
98
+ * all exhausted (e.g. `db.get` kept timing out because the transport was
99
+ * disconnected/contended during a hub crash, reconnect or restart). Instead
100
+ * of permanently dropping the ref — which loses the file written in that
101
+ * window — it is re-queued up to this many times so it is eventually applied
102
+ * once the transport recovers. A newer incoming ref supersedes a pending
103
+ * recovery; `tearDown()` stops it. Default: 10. Set 0 to restore the old
104
+ * drop-on-exhaustion behaviour.
105
+ */
106
+ recoveryRetries?: number;
88
107
  }
89
108
  /** Filename for sync error log written to the sync folder */
90
109
  export declare const SYNC_ERROR_FILE = ".sync-errors.log";
@@ -104,6 +123,15 @@ export declare class FsAgent {
104
123
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
105
124
  private _lastSentContentKey?;
106
125
  private _timeouts;
126
+ /** Client-only: resolve DAG-branch conflicts into merge revisions. */
127
+ private _resolveConflicts;
128
+ /**
129
+ * Ancestry head: the content ref of the revision currently representing the
130
+ * filesystem state. New local revisions descend from it; received revisions
131
+ * advance it. Only tracked when `resolveConflicts` is enabled, so the
132
+ * InsertHistory predecessor DAG forms only where conflict resolution is on.
133
+ */
134
+ private _currentRef?;
107
135
  constructor(rootPath: string, bs?: Bs, options?: FsAgentOptions);
108
136
  /**
109
137
  * Gets the root path
@@ -132,6 +160,13 @@ export declare class FsAgent {
132
160
  * @param err - The error value caught
133
161
  */
134
162
  _writeSyncError(context: string, err: unknown): void;
163
+ /**
164
+ * Extracts a human-readable message from a thrown value. The non-`Error`
165
+ * branch is defensive (the DB/transport always throw `Error`s).
166
+ * @param err - The caught value.
167
+ * @returns A message string.
168
+ */
169
+ private static _errMessage;
135
170
  /**
136
171
  * Wraps a promise with a timeout.
137
172
  * Rejects with a descriptive error if the promise does not settle
@@ -147,6 +182,9 @@ export declare class FsAgent {
147
182
  * otherwise falls back to fire-and-forget `send()`.
148
183
  * @param connector - The Connector to send through
149
184
  * @param ref - The ref to broadcast
185
+ * @param predecessorRefs - Causal predecessor content refs to attach (for
186
+ * conflict ancestry); set explicitly here because the FsAgent broadcasts
187
+ * via an explicit send, which pre-empts the Connector's db-observer path.
150
188
  */
151
189
  private _sendRef;
152
190
  /**
@@ -262,6 +300,44 @@ export declare class FsAgent {
262
300
  * @returns Function to stop watching
263
301
  */
264
302
  syncToDb(db: Db, connector: Connector, treeKey: string, options?: StoreFsTreeOptions): Promise<() => void>;
303
+ /**
304
+ * Resolves the `previous` (InsertHistory predecessor timeIds) for a new
305
+ * revision from the parent's shared content refs. timeIds are per-db, so we
306
+ * map each shared parent ref to *this* db's local timeId(s). Returns undefined
307
+ * when ancestry tracking is off (default) or no parent is known — in which
308
+ * case the store behaves exactly as before.
309
+ * @param db - Database instance
310
+ * @param treeKey - Tree table key
311
+ * @param parentRefs - Parent content refs (local head, or received predecessors)
312
+ */
313
+ private _ancestryPrevious;
314
+ /**
315
+ * Classifies an incoming revision relative to our current head using the
316
+ * local InsertHistory DAG (keyed on shared content refs):
317
+ * - `behind` → incoming descends from our head → fast-forward (restore).
318
+ * - `ahead` → our head descends from incoming (e.g. a reconnect bootstrap
319
+ * re-sending an older ancestor) → ignore; we are newer.
320
+ * - `diverged` → siblings produced by concurrent edits → resolve the fork.
321
+ * @param db - Database instance
322
+ * @param treeKey - Tree table key
323
+ * @param currentRef - Our current head's content ref
324
+ * @param incomingRef - The incoming revision's content ref
325
+ * @param incomingPredecessorRefs - The incoming revision's predecessor refs
326
+ */
327
+ private _ancestryRelation;
328
+ /**
329
+ * Resolves a divergent incoming revision inline (called from `processRef`
330
+ * with the watcher paused, so resolution cannot race the sync loop). Records
331
+ * the incoming revision as a fork tip without clobbering local content, then
332
+ * merges our head and the incoming tip into a single merge revision D that is
333
+ * materialised to disk and broadcast.
334
+ * @param db - Database instance
335
+ * @param treeKey - Tree table key
336
+ * @param incomingRef - The incoming revision's content ref
337
+ * @param incomingTree - The fetched incoming tree
338
+ * @param predecessorRefs - The incoming revision's predecessor content refs
339
+ */
340
+ private _resolveConflictInline;
265
341
  /**
266
342
  * Builds a map of relativePath → blobId for all files in a tree.
267
343
  * Used to compare trees by content rather than by hash (which includes mtime).
@@ -289,6 +365,17 @@ export declare class FsAgent {
289
365
  * @param b - Second tree to compare
290
366
  */
291
367
  private _treesHaveEquivalentContent;
368
+ /**
369
+ * Builds the dependency surface a {@link FsConflictResolver} needs, wiring it
370
+ * to this agent's db, blob store, scanner, and working directory.
371
+ *
372
+ * The merge store records the merged ref/content key as the last-sent state,
373
+ * so the watcher-driven re-scan that follows the on-disk materialisation
374
+ * settles to a no-op instead of re-broadcasting.
375
+ * @param db - Database instance
376
+ * @param treeKey - Tree table key
377
+ */
378
+ private _buildConflictResolverDeps;
292
379
  /**
293
380
  * Watches database for tree changes and syncs to filesystem
294
381
  * Uses Connector for socket-based notifications
package/dist/fs-agent.js CHANGED
@@ -2,7 +2,7 @@ import { BsMem } from "@rljson/bs";
2
2
  import { Route, createTreesTableCfg } from "@rljson/rljson";
3
3
  import { watch, appendFileSync } from "fs";
4
4
  import { stat, readFile, mkdir, writeFile, readdir, utimes, rm } from "fs/promises";
5
- import { dirname, join, sep } from "path";
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
  }
@@ -240,7 +483,6 @@ class FsScanner {
240
483
  return this._tree;
241
484
  }
242
485
  async _scanDirectory(absolutePath, relativePath, depth, trees) {
243
- const stats = await stat(absolutePath);
244
486
  const entries = await readdir(absolutePath, { withFileTypes: true });
245
487
  const childRefs = [];
246
488
  for (const entry of entries) {
@@ -292,9 +534,12 @@ class FsScanner {
292
534
  const fileMeta = {
293
535
  name: entry.name,
294
536
  type: "file",
295
- path: childPath,
296
537
  relativePath: childRelPath,
297
538
  size: childStats.size,
539
+ // mtime is kept for files (restore preserves it, so it round-trips to
540
+ // the same ref on every client) but NOT for directories (a folder's
541
+ // mtime is per-machine and does not round-trip). The absolute `path`
542
+ // is excluded everywhere — it is folder-specific.
298
543
  mtime: childStats.mtime.getTime(),
299
544
  blobId: blobProps.blobId
300
545
  // Link to content in Bs
@@ -311,19 +556,14 @@ class FsScanner {
311
556
  childRefs.push(fileTreeHashStr);
312
557
  }
313
558
  }
314
- const dirName = relativePath === "." ? (
315
- /* v8 ignore next -- @preserve */
316
- this._rootPath.split(sep).pop() || ""
317
- ) : (
559
+ const dirName = relativePath === "." ? "." : (
318
560
  /* v8 ignore next -- @preserve */
319
561
  relativePath.split("/").pop() || ""
320
562
  );
321
563
  const dirMeta = {
322
564
  name: dirName,
323
565
  type: "directory",
324
- path: absolutePath,
325
- relativePath,
326
- mtime: stats.mtime.getTime()
566
+ relativePath
327
567
  };
328
568
  const dirTree = {
329
569
  id: dirName,
@@ -510,7 +750,8 @@ const DEFAULT_TIMEOUTS = {
510
750
  syncCallback: 25e3,
511
751
  debounceMs: 300,
512
752
  processRefRetries: 3,
513
- processRefRetryDelayMs: 5e3
753
+ processRefRetryDelayMs: 5e3,
754
+ recoveryRetries: 10
514
755
  };
515
756
  const SYNC_ERROR_FILE = ".sync-errors.log";
516
757
  class FsAgent {
@@ -526,12 +767,22 @@ class FsAgent {
526
767
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
527
768
  _lastSentContentKey;
528
769
  _timeouts;
770
+ /** Client-only: resolve DAG-branch conflicts into merge revisions. */
771
+ _resolveConflicts;
772
+ /**
773
+ * Ancestry head: the content ref of the revision currently representing the
774
+ * filesystem state. New local revisions descend from it; received revisions
775
+ * advance it. Only tracked when `resolveConflicts` is enabled, so the
776
+ * InsertHistory predecessor DAG forms only where conflict resolution is on.
777
+ */
778
+ _currentRef;
529
779
  constructor(rootPath, bs, options = {}) {
530
780
  this._rootPath = rootPath;
531
781
  this._bs = bs || new BsMem();
532
782
  this._db = options.db;
533
783
  this._treeKey = options.treeKey;
534
784
  this._timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
785
+ this._resolveConflicts = options.resolveConflicts ?? false;
535
786
  this._scanner = new FsScanner(rootPath, {
536
787
  ...options,
537
788
  ignore: [...options.ignore || [], SYNC_ERROR_FILE],
@@ -592,6 +843,15 @@ ${err.stack}` : String(err);
592
843
  } catch {
593
844
  }
594
845
  }
846
+ /**
847
+ * Extracts a human-readable message from a thrown value. The non-`Error`
848
+ * branch is defensive (the DB/transport always throw `Error`s).
849
+ * @param err - The caught value.
850
+ * @returns A message string.
851
+ */
852
+ static _errMessage(err) {
853
+ return err instanceof Error ? err.message : String(err);
854
+ }
595
855
  /**
596
856
  * Wraps a promise with a timeout.
597
857
  * Rejects with a descriptive error if the promise does not settle
@@ -623,8 +883,14 @@ ${err.stack}` : String(err);
623
883
  * otherwise falls back to fire-and-forget `send()`.
624
884
  * @param connector - The Connector to send through
625
885
  * @param ref - The ref to broadcast
886
+ * @param predecessorRefs - Causal predecessor content refs to attach (for
887
+ * conflict ancestry); set explicitly here because the FsAgent broadcasts
888
+ * via an explicit send, which pre-empts the Connector's db-observer path.
626
889
  */
627
- async _sendRef(connector, ref) {
890
+ async _sendRef(connector, ref, predecessorRefs) {
891
+ if (this._resolveConflicts) {
892
+ connector.setPredecessors(predecessorRefs ?? []);
893
+ }
628
894
  if (connector.syncConfig?.requireAck) {
629
895
  await connector.sendWithAck(ref);
630
896
  } else {
@@ -955,18 +1221,31 @@ ${err.stack}` : String(err);
955
1221
  * @returns Function to stop watching
956
1222
  */
957
1223
  async syncToDb(db, connector, treeKey, options) {
1224
+ const initialParentRef = this._currentRef;
1225
+ const initialTree = await FsAgent._withTimeout(
1226
+ this.extract(),
1227
+ this._timeouts.extract,
1228
+ `syncToDb → initial extract(${treeKey})`
1229
+ );
1230
+ const initialIsNew = initialParentRef !== void 0 && initialTree.rootHash !== initialParentRef;
1231
+ const initialPrevious = initialIsNew ? await this._ancestryPrevious(db, treeKey, [initialParentRef]) : void 0;
958
1232
  const initialRef = await FsAgent._withTimeout(
959
- this.storeInDb(db, treeKey, options),
1233
+ new FsDbAdapter(db, treeKey).storeFsTree(initialTree, {
1234
+ ...options,
1235
+ previous: initialPrevious
1236
+ }),
960
1237
  this._timeouts.fetchTree,
961
- `syncToDb → initial storeInDb(${treeKey})`
1238
+ `syncToDb → initial storeFsTree(${treeKey})`
962
1239
  );
963
1240
  if (initialRef) {
964
1241
  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);
1242
+ this._currentRef = initialRef;
1243
+ this._lastSentContentKey = this._contentKeyFromTree(initialTree);
1244
+ await this._sendRef(
1245
+ connector,
1246
+ initialRef,
1247
+ initialIsNew ? [initialParentRef] : void 0
1248
+ );
970
1249
  }
971
1250
  let debounceTimer = null;
972
1251
  const debouncedSync = () => {
@@ -981,18 +1260,29 @@ ${err.stack}` : String(err);
981
1260
  return;
982
1261
  }
983
1262
  const dbAdapter = new FsDbAdapter(db, treeKey);
1263
+ const parentRef = this._currentRef;
1264
+ const previous = await this._ancestryPrevious(
1265
+ db,
1266
+ treeKey,
1267
+ parentRef ? [parentRef] : void 0
1268
+ );
984
1269
  const ref = await FsAgent._withTimeout(
985
- dbAdapter.storeFsTree(tree, options),
1270
+ dbAdapter.storeFsTree(tree, { ...options, previous }),
986
1271
  this._timeouts.fetchTree,
987
1272
  `syncToDb → storeFsTree(${treeKey})`
988
1273
  );
1274
+ this._currentRef = ref;
989
1275
  if (ref === this._lastSentRef) {
990
1276
  return;
991
1277
  }
992
1278
  this._lastSentRef = ref;
993
1279
  this._lastSentContentKey = contentKey;
994
1280
  if (ref) {
995
- await this._sendRef(connector, ref);
1281
+ await this._sendRef(
1282
+ connector,
1283
+ ref,
1284
+ parentRef ? [parentRef] : void 0
1285
+ );
996
1286
  }
997
1287
  } catch (err) {
998
1288
  console.error("[FsAgent] syncToDb failed:", err);
@@ -1009,6 +1299,110 @@ ${err.stack}` : String(err);
1009
1299
  this._scanner.stopWatch();
1010
1300
  };
1011
1301
  }
1302
+ /**
1303
+ * Resolves the `previous` (InsertHistory predecessor timeIds) for a new
1304
+ * revision from the parent's shared content refs. timeIds are per-db, so we
1305
+ * map each shared parent ref to *this* db's local timeId(s). Returns undefined
1306
+ * when ancestry tracking is off (default) or no parent is known — in which
1307
+ * case the store behaves exactly as before.
1308
+ * @param db - Database instance
1309
+ * @param treeKey - Tree table key
1310
+ * @param parentRefs - Parent content refs (local head, or received predecessors)
1311
+ */
1312
+ async _ancestryPrevious(db, treeKey, parentRefs) {
1313
+ if (!this._resolveConflicts || !parentRefs || parentRefs.length === 0) {
1314
+ return void 0;
1315
+ }
1316
+ const timeIds = [];
1317
+ for (const ref of parentRefs) {
1318
+ timeIds.push(...await db.getTimeIdsForRef(treeKey, ref));
1319
+ }
1320
+ return timeIds.length > 0 ? timeIds : void 0;
1321
+ }
1322
+ /**
1323
+ * Classifies an incoming revision relative to our current head using the
1324
+ * local InsertHistory DAG (keyed on shared content refs):
1325
+ * - `behind` → incoming descends from our head → fast-forward (restore).
1326
+ * - `ahead` → our head descends from incoming (e.g. a reconnect bootstrap
1327
+ * re-sending an older ancestor) → ignore; we are newer.
1328
+ * - `diverged` → siblings produced by concurrent edits → resolve the fork.
1329
+ * @param db - Database instance
1330
+ * @param treeKey - Tree table key
1331
+ * @param currentRef - Our current head's content ref
1332
+ * @param incomingRef - The incoming revision's content ref
1333
+ * @param incomingPredecessorRefs - The incoming revision's predecessor refs
1334
+ */
1335
+ async _ancestryRelation(db, treeKey, currentRef, incomingRef, incomingPredecessorRefs) {
1336
+ const dump = await db.getInsertHistory(treeKey);
1337
+ const rows = dump[`${treeKey}InsertHistory`]?._data ?? [];
1338
+ const refKey = `${treeKey}Ref`;
1339
+ const refOfTimeId = /* @__PURE__ */ new Map();
1340
+ for (const r of rows) {
1341
+ refOfTimeId.set(r.timeId, r[refKey]);
1342
+ }
1343
+ const prevRefsOf = /* @__PURE__ */ new Map();
1344
+ for (const r of rows) {
1345
+ const prev = (r.previous ?? []).map((t) => refOfTimeId.get(t)).filter((x) => x !== void 0);
1346
+ prevRefsOf.set(r[refKey], prev);
1347
+ }
1348
+ const ancestorsOf = (startRefs) => {
1349
+ const seen = /* @__PURE__ */ new Set();
1350
+ const stack = [...startRefs];
1351
+ while (stack.length > 0) {
1352
+ const ref = stack.pop();
1353
+ if (seen.has(ref)) {
1354
+ continue;
1355
+ }
1356
+ seen.add(ref);
1357
+ for (const p of prevRefsOf.get(ref) ?? []) {
1358
+ stack.push(p);
1359
+ }
1360
+ }
1361
+ return seen;
1362
+ };
1363
+ if (ancestorsOf(incomingPredecessorRefs).has(currentRef)) {
1364
+ return "behind";
1365
+ }
1366
+ if (ancestorsOf([currentRef]).has(incomingRef)) {
1367
+ return "ahead";
1368
+ }
1369
+ return "diverged";
1370
+ }
1371
+ /**
1372
+ * Resolves a divergent incoming revision inline (called from `processRef`
1373
+ * with the watcher paused, so resolution cannot race the sync loop). Records
1374
+ * the incoming revision as a fork tip without clobbering local content, then
1375
+ * merges our head and the incoming tip into a single merge revision D that is
1376
+ * materialised to disk and broadcast.
1377
+ * @param db - Database instance
1378
+ * @param treeKey - Tree table key
1379
+ * @param incomingRef - The incoming revision's content ref
1380
+ * @param incomingTree - The fetched incoming tree
1381
+ * @param predecessorRefs - The incoming revision's predecessor content refs
1382
+ */
1383
+ async _resolveConflictInline(db, treeKey, incomingRef, incomingTree, predecessorRefs) {
1384
+ const dbAdapter = new FsDbAdapter(db, treeKey);
1385
+ const incomingPrevious = await this._ancestryPrevious(
1386
+ db,
1387
+ treeKey,
1388
+ predecessorRefs
1389
+ );
1390
+ await dbAdapter.storeFsTree(incomingTree, {
1391
+ skipNotification: true,
1392
+ previous: incomingPrevious
1393
+ });
1394
+ const headTimeIds = await db.getTimeIdsForRef(treeKey, this._currentRef);
1395
+ const incomingTimeIds = await db.getTimeIdsForRef(treeKey, incomingRef);
1396
+ const resolver = new FsConflictResolver(
1397
+ this._buildConflictResolverDeps(db, treeKey)
1398
+ );
1399
+ await resolver.resolve({
1400
+ table: treeKey,
1401
+ type: "dagBranch",
1402
+ detectedAt: Date.now(),
1403
+ branches: [...headTimeIds, ...incomingTimeIds]
1404
+ });
1405
+ }
1012
1406
  /**
1013
1407
  * Builds a map of relativePath → blobId for all files in a tree.
1014
1408
  * Used to compare trees by content rather than by hash (which includes mtime).
@@ -1062,6 +1456,51 @@ ${err.stack}` : String(err);
1062
1456
  }
1063
1457
  return true;
1064
1458
  }
1459
+ /**
1460
+ * Builds the dependency surface a {@link FsConflictResolver} needs, wiring it
1461
+ * to this agent's db, blob store, scanner, and working directory.
1462
+ *
1463
+ * The merge store records the merged ref/content key as the last-sent state,
1464
+ * so the watcher-driven re-scan that follows the on-disk materialisation
1465
+ * settles to a no-op instead of re-broadcasting.
1466
+ * @param db - Database instance
1467
+ * @param treeKey - Tree table key
1468
+ */
1469
+ _buildConflictResolverDeps(db, treeKey) {
1470
+ return {
1471
+ treeKey,
1472
+ getInsertHistory: async (table) => {
1473
+ const dump = await db.getInsertHistory(table);
1474
+ const rows = dump[`${table}InsertHistory`]?._data ?? [];
1475
+ return rows;
1476
+ },
1477
+ getRefOfTimeId: (table, timeId) => db.getRefOfTimeId(table, timeId),
1478
+ fetchTree: (rootRef) => this._fetchTreeFromDb(db, treeKey, rootRef),
1479
+ getBlobContent: (blobId) => this._adapter.getFileContent(blobId),
1480
+ restoreTree: (tree) => this.restore(tree, void 0, { cleanTarget: true }),
1481
+ writeFileAt: async (relativePath, content) => {
1482
+ const filePath = join(this._rootPath, relativePath);
1483
+ await mkdir(dirname(filePath), { recursive: true });
1484
+ await writeFile(filePath, content);
1485
+ },
1486
+ deleteFileAt: async (relativePath) => {
1487
+ await rm(join(this._rootPath, relativePath), {
1488
+ force: true,
1489
+ recursive: true
1490
+ });
1491
+ },
1492
+ scan: () => this._scanner.scan(),
1493
+ storeMerge: async (tree, previous) => {
1494
+ const dbAdapter = new FsDbAdapter(db, treeKey);
1495
+ const ref = await dbAdapter.storeFsTree(tree, { previous });
1496
+ this._lastSentRef = ref;
1497
+ this._lastSentContentKey = this._contentKeyFromTree(tree);
1498
+ this._currentRef = ref;
1499
+ return ref;
1500
+ }
1501
+ // Resolution failures are surfaced by `_onConflict`; success is silent.
1502
+ };
1503
+ }
1065
1504
  /**
1066
1505
  * Watches database for tree changes and syncs to filesystem
1067
1506
  * Uses Connector for socket-based notifications
@@ -1077,7 +1516,9 @@ ${err.stack}` : String(err);
1077
1516
  }
1078
1517
  let pendingRef = null;
1079
1518
  let fromDbTimer = null;
1080
- const processRef = async (treeRef) => {
1519
+ let pendingRecoveryAttempt = 0;
1520
+ let pendingPredecessorRefs;
1521
+ const processRef = async (treeRef, recoveryAttempt = 0, predecessorRefs) => {
1081
1522
  const maxAttempts = this._timeouts.processRefRetries + 1;
1082
1523
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1083
1524
  this._scanner.pauseWatch();
@@ -1104,6 +1545,28 @@ ${err.stack}` : String(err);
1104
1545
  );
1105
1546
  return;
1106
1547
  }
1548
+ if (this._resolveConflicts && this._currentRef && predecessorRefs && predecessorRefs.length > 0) {
1549
+ const relation = await this._ancestryRelation(
1550
+ db,
1551
+ treeKey,
1552
+ this._currentRef,
1553
+ treeRef,
1554
+ predecessorRefs
1555
+ );
1556
+ if (relation === "ahead") {
1557
+ return;
1558
+ }
1559
+ if (relation === "diverged") {
1560
+ await this._resolveConflictInline(
1561
+ db,
1562
+ treeKey,
1563
+ treeRef,
1564
+ incomingTree,
1565
+ predecessorRefs
1566
+ );
1567
+ return;
1568
+ }
1569
+ }
1107
1570
  await FsAgent._withTimeout(
1108
1571
  this.restore(incomingTree, void 0, restoreOptions),
1109
1572
  this._timeouts.restore,
@@ -1111,23 +1574,44 @@ ${err.stack}` : String(err);
1111
1574
  );
1112
1575
  const postRestoreTree = await this._scanner.scan();
1113
1576
  const dbAdapter = new FsDbAdapter(db, treeKey);
1577
+ const previous = await this._ancestryPrevious(
1578
+ db,
1579
+ treeKey,
1580
+ predecessorRefs
1581
+ );
1114
1582
  const postRestoreRef = await dbAdapter.storeFsTree(postRestoreTree, {
1115
- skipNotification: true
1583
+ skipNotification: true,
1584
+ previous
1116
1585
  });
1117
1586
  this._lastSentRef = postRestoreRef;
1118
1587
  this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
1588
+ this._currentRef = postRestoreRef;
1119
1589
  return;
1120
1590
  } catch (err) {
1121
1591
  if (attempt === maxAttempts) {
1122
- console.error(
1123
- `[FsAgent] syncFromDb processRef failed after ${maxAttempts} attempts:`,
1124
- err
1125
- );
1126
- this._writeSyncError("syncFromDb/processRef", err);
1592
+ if (recoveryAttempt >= this._timeouts.recoveryRetries) {
1593
+ console.error(
1594
+ `[FsAgent] syncFromDb processRef failed after ${maxAttempts} attempts and ${recoveryAttempt} recoveries:`,
1595
+ err
1596
+ );
1597
+ this._writeSyncError("syncFromDb/processRef", err);
1598
+ } else {
1599
+ if (pendingRef === null) {
1600
+ console.warn(
1601
+ `[FsAgent] syncFromDb: ref=${treeRef.slice(0, 8)}… not yet fetchable after ${maxAttempts} attempts, re-queueing (recovery ${recoveryAttempt + 1}/${this._timeouts.recoveryRetries}): ${FsAgent._errMessage(err)}`
1602
+ );
1603
+ scheduleProcess(
1604
+ treeRef,
1605
+ this._timeouts.processRefRetryDelayMs * maxAttempts,
1606
+ recoveryAttempt + 1,
1607
+ predecessorRefs
1608
+ );
1609
+ }
1610
+ }
1127
1611
  } else {
1128
1612
  const delaySec = attempt * this._timeouts.processRefRetryDelayMs / 1e3;
1129
1613
  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)}`
1614
+ `[FsAgent] syncFromDb: attempt ${attempt}/${maxAttempts} failed for ref=${treeRef.slice(0, 8)}…, retrying in ${delaySec}s: ${FsAgent._errMessage(err)}`
1131
1615
  );
1132
1616
  }
1133
1617
  } finally {
@@ -1138,20 +1622,28 @@ ${err.stack}` : String(err);
1138
1622
  );
1139
1623
  }
1140
1624
  };
1141
- const syncCallback = async (treeRef) => {
1142
- if (!treeRef || typeof treeRef !== "string") {
1143
- return;
1144
- }
1145
- pendingRef = treeRef;
1625
+ const scheduleProcess = (ref, delayMs, recoveryAttempt, predecessorRefs) => {
1626
+ pendingRef = ref;
1627
+ pendingRecoveryAttempt = recoveryAttempt;
1628
+ pendingPredecessorRefs = predecessorRefs;
1146
1629
  if (fromDbTimer) clearTimeout(fromDbTimer);
1147
1630
  fromDbTimer = setTimeout(async () => {
1148
1631
  fromDbTimer = null;
1149
- const ref = pendingRef;
1632
+ const r = pendingRef;
1633
+ const ra = pendingRecoveryAttempt;
1634
+ const pr = pendingPredecessorRefs;
1150
1635
  pendingRef = null;
1151
- if (ref) {
1152
- await processRef(ref);
1636
+ if (r) {
1637
+ await processRef(r, ra, pr);
1153
1638
  }
1154
- }, this._timeouts.debounceMs);
1639
+ }, delayMs);
1640
+ };
1641
+ const syncCallback = (treeRef, predecessorRefs) => {
1642
+ if (!treeRef || typeof treeRef !== "string") {
1643
+ return Promise.resolve();
1644
+ }
1645
+ scheduleProcess(treeRef, this._timeouts.debounceMs, 0, predecessorRefs);
1646
+ return Promise.resolve();
1155
1647
  };
1156
1648
  connector.listen(syncCallback);
1157
1649
  return () => {
@@ -1283,10 +1775,19 @@ async function runClientServerSetup(opts = {}) {
1283
1775
  return { baseDir, folderA, folderB, contentB, cleanup };
1284
1776
  }
1285
1777
  export {
1778
+ DIR_MARKER,
1286
1779
  FsAgent,
1287
1780
  FsBlobAdapter,
1781
+ FsConflictResolver,
1288
1782
  FsDbAdapter,
1289
1783
  FsScanner,
1290
1784
  SYNC_ERROR_FILE,
1291
- runClientServerSetup
1785
+ compareTips,
1786
+ conflictCopyName,
1787
+ decideWinner,
1788
+ findCommonAncestor,
1789
+ formatConflictTimestamp,
1790
+ fsTreeToContentMap,
1791
+ runClientServerSetup,
1792
+ threeWayMerge
1292
1793
  };
@@ -0,0 +1,181 @@
1
+ import { Conflict } from '@rljson/db';
2
+ import { InsertHistoryRow } from '@rljson/rljson';
3
+ import { FsTree } from './fs-scanner.js';
4
+ /**
5
+ * Nextcloud-style conflict resolution for forked FS-tree DAGs.
6
+ *
7
+ * When two peers edit a shared tree while one is offline, the InsertHistory
8
+ * DAG forks into two tips (B and C, both descending from a common ancestor A).
9
+ * `@rljson/db` fires a `dagBranch` conflict. This module resolves it on the
10
+ * **client** by performing a deterministic three-way file-level merge and
11
+ * writing a single **merge revision D** whose `InsertHistory.previous`
12
+ * references *both* tips — collapsing the fork back to one tip.
13
+ *
14
+ * The pure functions (merge, naming, ancestor walk, winner selection) are
15
+ * deterministic: every peer resolving the same fork produces the identical D,
16
+ * so resolution converges instead of forking again.
17
+ *
18
+ * See `doc/conflict-resolution-design.md`.
19
+ */
20
+ /** relativePath → blobId. Directories are recorded as {@link DIR_MARKER}. */
21
+ export type ContentMap = Map<string, string>;
22
+ /** Sentinel blobId used for directory entries in a {@link ContentMap}. */
23
+ export declare const DIR_MARKER = "<dir>";
24
+ /**
25
+ * Builds a {@link ContentMap} (relativePath → blobId) from an FsTree, ignoring
26
+ * mtime so two trees with identical content compare equal regardless of when
27
+ * they were written.
28
+ * @param tree - The FsTree to flatten
29
+ * @returns A map of relativePath → blobId (directories use {@link DIR_MARKER})
30
+ */
31
+ export declare function fsTreeToContentMap(tree: FsTree): ContentMap;
32
+ /** A branch tip's identity, used for deterministic winner selection. */
33
+ export interface BranchTip {
34
+ /** InsertHistory timeId of the tip (per-db; used only for local lookups). */
35
+ timeId: string;
36
+ /** Shared content ref of the tip — the cross-client deterministic tiebreak. */
37
+ ref: string;
38
+ /** Originating client id (InsertHistory `origin`). Empty string if unknown. */
39
+ clientId: string;
40
+ /** InsertHistory client timestamp (ms). 0 if unknown. */
41
+ timestamp: number;
42
+ }
43
+ /**
44
+ * Total, deterministic order over two tips. Returns a positive number when `a`
45
+ * outranks `b`. Greater timestamp wins; ties broken by greater clientId, then
46
+ * greater **content ref**. The final tiebreak is the ref (not the timeId)
47
+ * because timeIds are per-db — using them would make different peers pick
48
+ * different winners and never converge; the ref is shared, so every peer agrees.
49
+ * @param a - First tip
50
+ * @param b - Second tip
51
+ * @returns Positive if `a` outranks `b`, negative if `b` outranks `a`, else 0
52
+ */
53
+ export declare function compareTips(a: BranchTip, b: BranchTip): number;
54
+ /**
55
+ * Picks the path-owning winner of a conflict. Per design decision §11.1 the
56
+ * revision with the greater InsertHistory timestamp keeps the original path;
57
+ * the loser's content is preserved under a renamed conflict copy.
58
+ * @param a - First tip
59
+ * @param b - Second tip
60
+ * @returns The winning and losing tips
61
+ */
62
+ export declare function decideWinner(a: BranchTip, b: BranchTip): {
63
+ winner: BranchTip;
64
+ loser: BranchTip;
65
+ };
66
+ /**
67
+ * Finds the nearest common ancestor timeId of two tips by walking the
68
+ * InsertHistory `previous` chains. Returns null when the tips share no
69
+ * ancestor (treat as an empty ancestor — everything is an add/add).
70
+ * @param rows - All InsertHistory rows for the table
71
+ * @param tipA - First tip timeId
72
+ * @param tipB - Second tip timeId
73
+ * @returns The nearest common ancestor timeId, or null if none
74
+ */
75
+ export declare function findCommonAncestor(rows: InsertHistoryRow<string>[], tipA: string, tipB: string): string | null;
76
+ /**
77
+ * Formats a timestamp as a stable UTC `YYYY-MM-DD HHMMSS` string. UTC keeps the
78
+ * conflict-copy name identical across peers in different timezones.
79
+ * @param ms - Milliseconds since the epoch
80
+ * @returns The formatted UTC timestamp
81
+ */
82
+ export declare function formatConflictTimestamp(ms: number): string;
83
+ /**
84
+ * Derives a Nextcloud-style conflict-copy path:
85
+ * `document.txt` → `document (conflicted copy <clientId> <ts>).txt`.
86
+ *
87
+ * - The suffix is inserted before the final extension; dotfiles / extensionless
88
+ * names get it appended.
89
+ * - Identity + timestamp come from the *losing* revision, so every peer derives
90
+ * the same name (determinism).
91
+ * - If the candidate name is already taken, a numeric ` (n)` is appended; the
92
+ * chosen name is added to `taken`.
93
+ * @param relativePath - The original conflicting path
94
+ * @param clientId - The losing revision's client id
95
+ * @param timestamp - The losing revision's InsertHistory timestamp (ms)
96
+ * @param taken - Set of already-used paths; the chosen name is added to it
97
+ * @returns A unique conflict-copy path
98
+ */
99
+ export declare function conflictCopyName(relativePath: string, clientId: string, timestamp: number, taken: Set<string>): string;
100
+ /** A conflict copy to materialise: the losing content under a renamed path. */
101
+ export interface ConflictCopy {
102
+ /** The renamed path the losing content is written to. */
103
+ path: string;
104
+ /** The losing blobId (content preserved, nothing lost). */
105
+ blobId: string;
106
+ }
107
+ /** The result of a three-way merge. */
108
+ export interface MergePlan {
109
+ /** Final tree content (winner-resolved): relativePath → blobId. */
110
+ merged: ContentMap;
111
+ /** Extra files to write beside the merged set (the renamed losers). */
112
+ copies: ConflictCopy[];
113
+ /** Original paths that genuinely conflicted (both sides changed differently). */
114
+ conflictPaths: string[];
115
+ }
116
+ /**
117
+ * Three-way file-level merge of ancestor `o`, branch `ours`, branch `theirs`.
118
+ * `winnerSide` decides who keeps the path on a real conflict; the loser's
119
+ * content is preserved under a {@link conflictCopyName}. Pure & deterministic.
120
+ *
121
+ * Per relative path (see design §4.4):
122
+ * - both sides equal → keep it (covers unchanged + add-same + delete-both)
123
+ * - only theirs changed (`ours === o`) → take theirs
124
+ * - only ours changed (`theirs === o`) → take ours
125
+ * - both changed differently → CONFLICT: winner keeps path, loser renamed
126
+ * @param o - Ancestor content map
127
+ * @param ours - Our branch content map
128
+ * @param theirs - Their branch content map
129
+ * @param winnerSide - Which side keeps the path on a real conflict
130
+ * @param loserClientId - The losing revision's client id (for copy names)
131
+ * @param loserTimestamp - The losing revision's timestamp (for copy names)
132
+ * @returns The merge plan (merged set, conflict copies, conflicting paths)
133
+ */
134
+ export declare function threeWayMerge(o: ContentMap, ours: ContentMap, theirs: ContentMap, winnerSide: 'ours' | 'theirs', loserClientId: string, loserTimestamp: number): MergePlan;
135
+ /**
136
+ * The capabilities the resolver needs from its host FsAgent + Db. Injected so
137
+ * the orchestration is unit-testable with in-memory fakes (no real db/fs).
138
+ */
139
+ export interface ConflictResolverDeps {
140
+ /** The tree table key (route is `/${treeKey}`). */
141
+ treeKey: string;
142
+ /** All InsertHistory rows for `treeKey`. */
143
+ getInsertHistory: (table: string) => Promise<InsertHistoryRow<string>[]>;
144
+ /** Resolve a tip timeId to its tree root ref. */
145
+ getRefOfTimeId: (table: string, timeId: string) => Promise<string | null>;
146
+ /** Fetch a full FsTree by its root ref. */
147
+ fetchTree: (rootRef: string) => Promise<FsTree>;
148
+ /** Read a blob's bytes by blobId. */
149
+ getBlobContent: (blobId: string) => Promise<Buffer>;
150
+ /** Restore an FsTree onto the working dir, pruning extraneous entries. */
151
+ restoreTree: (tree: FsTree) => Promise<void>;
152
+ /** Write bytes to a relative path under the working dir (mkdir -p). */
153
+ writeFileAt: (relativePath: string, content: Buffer) => Promise<void>;
154
+ /** Remove a relative path under the working dir (best effort). */
155
+ deleteFileAt: (relativePath: string) => Promise<void>;
156
+ /** Re-scan the working dir into a fresh, hashed FsTree. */
157
+ scan: () => Promise<FsTree>;
158
+ /** Store the merge revision with explicit predecessors; returns its root ref. */
159
+ storeMerge: (tree: FsTree, previous: string[]) => Promise<string>;
160
+ /** Notified with the stored merge ref so the host can suppress the echo. */
161
+ onMergeStored?: (ref: string) => void;
162
+ /** Optional structured logger. */
163
+ log?: (level: 'info' | 'warn' | 'error', msg: string) => void;
164
+ }
165
+ /**
166
+ * Resolves a single `dagBranch` conflict into a merge revision. For more than
167
+ * two tips it merges the two lowest-identity tips per call; the resulting
168
+ * smaller fork re-fires the observer and converges in further rounds.
169
+ */
170
+ export declare class FsConflictResolver {
171
+ private readonly deps;
172
+ constructor(deps: ConflictResolverDeps);
173
+ private _log;
174
+ /**
175
+ * Resolves the conflict, returning the stored merge ref, or null when the
176
+ * conflict is not ours / not actionable.
177
+ * @param conflict - The detected DAG-branch conflict
178
+ * @returns The stored merge revision's root ref, or null
179
+ */
180
+ resolve(conflict: Conflict): Promise<string | null>;
181
+ }
@@ -1,4 +1,5 @@
1
1
  import { Db } from '@rljson/db';
2
+ import { InsertHistoryTimeId } from '@rljson/rljson';
2
3
  import { FsTree } from './fs-scanner.js';
3
4
  /**
4
5
  * Options for storing filesystem trees in database
@@ -10,6 +11,13 @@ export interface StoreFsTreeOptions {
10
11
  * via the standard db.insertTrees() pipeline.
11
12
  */
12
13
  skipNotification?: boolean;
14
+ /**
15
+ * Explicit predecessor override for the InsertHistory row. When set, the
16
+ * stored revision's `previous` references exactly these timeIds instead of
17
+ * the controller-derived predecessor. Used to write a **merge revision**
18
+ * whose parents are both tips of a forked DAG, collapsing the fork.
19
+ */
20
+ previous?: InsertHistoryTimeId[];
13
21
  }
14
22
  /**
15
23
  * Adapter for storing filesystem trees in a database.
@@ -9,16 +9,23 @@ export interface FsNodeMeta extends Json {
9
9
  name: string;
10
10
  /** Type of node */
11
11
  type: 'file' | 'directory';
12
- /** Absolute path */
13
- path: string;
14
- /** Relative path from scan root */
12
+ /** Relative path from scan root (the cross-client-stable content identity) */
15
13
  relativePath: string;
16
14
  /** File size in bytes (for files) */
17
15
  size?: number;
18
- /** Last modified timestamp (milliseconds since epoch) */
19
- mtime: number;
20
16
  /** Blob ID for file content (files only) */
21
17
  blobId?: string;
18
+ /**
19
+ * Absolute path — informational only, NOT part of the content identity.
20
+ * Excluded from stored meta so tree refs are folder-independent (shared
21
+ * across clients). Retained in the type for back-compat.
22
+ */
23
+ path?: string;
24
+ /**
25
+ * Last modified timestamp — NOT part of the content identity (environment-
26
+ * specific). Excluded from stored meta so refs are mtime-independent.
27
+ */
28
+ mtime?: number;
22
29
  }
23
30
  /**
24
31
  * Tree structure with hash mapping
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { FsAgent, SYNC_ERROR_FILE, type FsAgentOptions, type RestoreOptions, type TimeoutConfig, } from './fs-agent.ts';
2
2
  export { FsBlobAdapter, type BlobToFileOptions, type FileBlobMeta, type FileToBlobOptions, } from './fs-blob-adapter.ts';
3
3
  export { FsDbAdapter, type StoreFsTreeOptions } from './fs-db-adapter.ts';
4
+ export { compareTips, conflictCopyName, decideWinner, DIR_MARKER, findCommonAncestor, formatConflictTimestamp, FsConflictResolver, fsTreeToContentMap, threeWayMerge, type BranchTip, type ConflictCopy, type ConflictResolverDeps, type ContentMap, type MergePlan, } from './fs-conflict-resolver.ts';
4
5
  export { FsScanner, type FsChange, type FsChangeCallback, type FsChangeType, type FsNodeMeta, type FsScanOptions, type FsTree, } from './fs-scanner.ts';
5
6
  export { runClientServerSetup, type ClientServerSetupOptions, type ClientServerSetupResult, } from './client-server/client-server-setup.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.11",
3
+ "version": "0.0.14",
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",
@@ -19,12 +19,20 @@
19
19
  "dist"
20
20
  ],
21
21
  "type": "module",
22
+ "scripts": {
23
+ "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
24
+ "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
25
+ "prebuild": "npm run test",
26
+ "prepublishOnly": "npm run build",
27
+ "lint": "pnpm exec eslint .",
28
+ "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
29
+ },
22
30
  "devDependencies": {
23
31
  "@rljson/server": "^0.0.10",
24
32
  "@types/node": "^25.3.1",
25
33
  "@typescript-eslint/eslint-plugin": "^8.56.1",
26
34
  "@typescript-eslint/parser": "^8.56.1",
27
- "@vitest/coverage-v8": "^4.0.18",
35
+ "@vitest/coverage-v8": "^4.1.8",
28
36
  "cross-env": "^10.1.0",
29
37
  "eslint": "~9.39.3",
30
38
  "eslint-plugin-jsdoc": "^62.7.1",
@@ -38,12 +46,12 @@
38
46
  "vite-node": "^5.3.0",
39
47
  "vite-plugin-dts": "^4.5.4",
40
48
  "vite-tsconfig-paths": "^6.1.1",
41
- "vitest": "^4.0.18",
49
+ "vitest": "^4.1.8",
42
50
  "vitest-dom": "^0.1.1"
43
51
  },
44
52
  "dependencies": {
45
53
  "@rljson/bs": "^0.0.21",
46
- "@rljson/db": "^0.0.15",
54
+ "@rljson/db": "^0.0.22",
47
55
  "@rljson/hash": "^0.0.18",
48
56
  "@rljson/io": "^0.0.66",
49
57
  "@rljson/json": "^0.0.23",
@@ -51,11 +59,13 @@
51
59
  "socket.io": "^4.8.3",
52
60
  "socket.io-client": "^4.8.3"
53
61
  },
54
- "scripts": {
55
- "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
56
- "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
57
- "prebuild": "npm run test",
58
- "lint": "pnpm exec eslint .",
59
- "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
60
- }
61
- }
62
+ "pnpm": {
63
+ "onlyBuiltDependencies": [
64
+ "esbuild"
65
+ ],
66
+ "overrides": {
67
+ "@rljson/rljson": "^0.0.78"
68
+ }
69
+ },
70
+ "packageManager": "pnpm@10.11.0"
71
+ }