@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.
- package/dist/fs-agent.d.ts +87 -0
- package/dist/fs-agent.js +541 -40
- package/dist/fs-conflict-resolver.d.ts +181 -0
- package/dist/fs-db-adapter.d.ts +8 -0
- package/dist/fs-scanner.d.ts +12 -5
- package/dist/index.d.ts +1 -0
- package/package.json +22 -12
package/dist/fs-agent.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1233
|
+
new FsDbAdapter(db, treeKey).storeFsTree(initialTree, {
|
|
1234
|
+
...options,
|
|
1235
|
+
previous: initialPrevious
|
|
1236
|
+
}),
|
|
960
1237
|
this._timeouts.fetchTree,
|
|
961
|
-
`syncToDb → initial
|
|
1238
|
+
`syncToDb → initial storeFsTree(${treeKey})`
|
|
962
1239
|
);
|
|
963
1240
|
if (initialRef) {
|
|
964
1241
|
this._lastSentRef = initialRef;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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: ${
|
|
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
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
1632
|
+
const r = pendingRef;
|
|
1633
|
+
const ra = pendingRecoveryAttempt;
|
|
1634
|
+
const pr = pendingPredecessorRefs;
|
|
1150
1635
|
pendingRef = null;
|
|
1151
|
-
if (
|
|
1152
|
-
await processRef(
|
|
1636
|
+
if (r) {
|
|
1637
|
+
await processRef(r, ra, pr);
|
|
1153
1638
|
}
|
|
1154
|
-
},
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/fs-db-adapter.d.ts
CHANGED
|
@@ -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.
|
package/dist/fs-scanner.d.ts
CHANGED
|
@@ -9,16 +9,23 @@ export interface FsNodeMeta extends Json {
|
|
|
9
9
|
name: string;
|
|
10
10
|
/** Type of node */
|
|
11
11
|
type: 'file' | 'directory';
|
|
12
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
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
|
+
}
|