@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.1
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/LICENSE +661 -0
- package/NOTICE +112 -0
- package/README.md +76 -2
- package/bin/canvas-edge +2 -0
- package/bin/healthcheck.mjs +9 -0
- package/node_modules/canvas-stored/LICENSE +661 -0
- package/node_modules/canvas-stored/NOTICE +85 -0
- package/node_modules/canvas-stored/README.md +472 -0
- package/node_modules/canvas-stored/package.json +26 -0
- package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
- package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
- package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
- package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
- package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
- package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
- package/node_modules/canvas-stored/src/cache/index.js +65 -0
- package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
- package/node_modules/canvas-stored/src/extractors/image.js +90 -0
- package/node_modules/canvas-stored/src/extractors/index.js +41 -0
- package/node_modules/canvas-stored/src/extractors/text.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +360 -0
- package/node_modules/canvas-stored/src/index.js +2058 -0
- package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
- package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
- package/node_modules/canvas-stored/src/sync/keys.js +140 -0
- package/node_modules/canvas-stored/src/sync/worker.js +52 -0
- package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
- package/node_modules/canvas-stored/src/utils/common.js +37 -0
- package/node_modules/canvas-stored/src/utils/mime.js +107 -0
- package/node_modules/canvas-stored/src/utils/mount.js +99 -0
- package/package.json +49 -4
- package/src/EdgeClient.js +129 -0
- package/src/control.js +48 -0
- package/src/daemon.js +88 -0
- package/src/env.js +182 -0
- package/src/fuse-runtime.js +173 -0
- package/src/index.js +26 -0
- package/src/mirror-runtime.js +232 -0
- package/src/remote-config.js +69 -0
- package/src/runtime.js +61 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import Debug from 'debug';
|
|
3
|
+
import JobQueue from './JobQueue.js';
|
|
4
|
+
import Ledger from './Ledger.js';
|
|
5
|
+
import { normalizeKey, validateKey, isIgnored, matchesPrefixes, conflictKey, MIRROR_IGNORE_DEFAULTS } from './keys.js';
|
|
6
|
+
|
|
7
|
+
const debug = Debug('stored:sync:mirror');
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
prefixes: [],
|
|
11
|
+
ignore: [],
|
|
12
|
+
deletes: 'propagate',
|
|
13
|
+
conflictMode: 'prompt',
|
|
14
|
+
direction: 'bi',
|
|
15
|
+
debounceMs: 1500,
|
|
16
|
+
fullReconcileEvery: 6 * 60 * 60_000,
|
|
17
|
+
concurrency: 4,
|
|
18
|
+
offlineBackoffMs: [5_000, 10_000, 20_000, 40_000, 60_000],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The three-way decision per key (protocol doc, "Client algorithm"). Pure.
|
|
23
|
+
* `L` = local sha, `B` = base ledger sha, `R` = hub sha; null = absent.
|
|
24
|
+
*/
|
|
25
|
+
export function decide(L, B, R) {
|
|
26
|
+
const localChanged = L !== B;
|
|
27
|
+
const remoteChanged = R !== B;
|
|
28
|
+
if (!localChanged && !remoteChanged) return { action: 'nothing' };
|
|
29
|
+
if (localChanged && !remoteChanged) {
|
|
30
|
+
if (L) return { action: 'push', ifMatch: B };
|
|
31
|
+
return B ? { action: 'delete-remote', ifMatch: B } : { action: 'nothing' };
|
|
32
|
+
}
|
|
33
|
+
if (!localChanged && remoteChanged) {
|
|
34
|
+
if (R) return { action: 'pull', sha256: R };
|
|
35
|
+
return L ? { action: 'trash-local' } : { action: 'nothing' };
|
|
36
|
+
}
|
|
37
|
+
// Both changed.
|
|
38
|
+
if (L === R) return { action: 'adopt' };
|
|
39
|
+
if (!L && R) return { action: 'pull', sha256: R }; // edit beats delete
|
|
40
|
+
if (L && !R) return { action: 'push', ifMatch: null }; // edit beats delete: push as new
|
|
41
|
+
return { action: 'conflict', remote: R };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Device-mirror sync engine: keeps a local folder (a `file` backend) equal
|
|
46
|
+
* to one backend of a workspace on a canvas hub (a `canvas` backend), both
|
|
47
|
+
* registered on the same `Stored`.
|
|
48
|
+
*
|
|
49
|
+
* Stored's index is the view of both sides — local keys via the file
|
|
50
|
+
* watcher/scan, hub keys via the canvas driver's change-feed poller — and
|
|
51
|
+
* the ledger (LMDB sub-db) holds the base: what the two last agreed on.
|
|
52
|
+
* Every `object:*` event on either backend marks its key dirty; a debounced
|
|
53
|
+
* pass runs the three-way table (`decide`) per dirty key and turns the
|
|
54
|
+
* outcome into durable jobs (`JobQueue`): push, pull, delete-remote,
|
|
55
|
+
* trash-local, rename-remote, rename-local, conflict. A worker (concurrency
|
|
56
|
+
* 2, one job per key) executes them through Stored's own copy/move/rename
|
|
57
|
+
* primitives so locations, succession and echo suppression behave exactly
|
|
58
|
+
* as for any other transfer. Base is written only after the byte operation
|
|
59
|
+
* succeeded; the feed cursor only after a whole batch reconciled.
|
|
60
|
+
*
|
|
61
|
+
* `direction` (rclone vocabulary) narrows the table AFTER it decided:
|
|
62
|
+
* - `bi` both ways (default);
|
|
63
|
+
* - `pull` the hub is the only writer — a local edit of a tracked file is
|
|
64
|
+
* preserved in the conflicts folder and the hub's bytes put back
|
|
65
|
+
* (`revert`), a local delete pulls the hub copy back, a local-only
|
|
66
|
+
* file is left alone and reported as a skip; nothing is ever sent
|
|
67
|
+
* upstream (a backup target);
|
|
68
|
+
* - `push` local is the only writer — hub-side additions/edits are reported
|
|
69
|
+
* as skips and never applied locally (a one-shot import).
|
|
70
|
+
*
|
|
71
|
+
* Every base row also records the hub document behind the key and its row
|
|
72
|
+
* version when the hub told us (`docId`/`version`): `takeApplied()` hands the
|
|
73
|
+
* pairs agreed since the last call to the status reporter, `appliedSnapshot()`
|
|
74
|
+
* lists them all. That is what "this replica holds the current version" is
|
|
75
|
+
* computed from on the hub.
|
|
76
|
+
*
|
|
77
|
+
* Events: `status` (snapshot), `job:start|done|failed` (job), `conflict`,
|
|
78
|
+
* `revert`, `skip`, `offline`, `online`, `error`.
|
|
79
|
+
*/
|
|
80
|
+
export default class Mirror extends EventEmitter {
|
|
81
|
+
#stored;
|
|
82
|
+
#opts;
|
|
83
|
+
#ledger;
|
|
84
|
+
#queue;
|
|
85
|
+
#state = 'idle';
|
|
86
|
+
#dirty = new Set();
|
|
87
|
+
#debounceTimer = null;
|
|
88
|
+
#fullTimer = null;
|
|
89
|
+
#offlineTimer = null;
|
|
90
|
+
#offlineAttempts = 0;
|
|
91
|
+
#retryTimer = null;
|
|
92
|
+
#running = new Map(); // seq → promise
|
|
93
|
+
#runningKeys = new Set();
|
|
94
|
+
#drainLoop = null;
|
|
95
|
+
#pendingCursor = null;
|
|
96
|
+
#lastSyncAt = null;
|
|
97
|
+
#lastError = null;
|
|
98
|
+
#conflicts = 0;
|
|
99
|
+
#reverted = 0;
|
|
100
|
+
#applied = new Map(); // docId → version agreed since the last takeApplied()
|
|
101
|
+
#stopping = false;
|
|
102
|
+
#started = false;
|
|
103
|
+
#unbind = [];
|
|
104
|
+
#ignorePatterns;
|
|
105
|
+
#reconcilePass = null;
|
|
106
|
+
|
|
107
|
+
constructor(stored, options = {}) {
|
|
108
|
+
super();
|
|
109
|
+
if (!stored) throw new Error('Mirror requires a Stored instance');
|
|
110
|
+
const opts = { ...DEFAULTS, ...options };
|
|
111
|
+
for (const k of ['id', 'local', 'remote', 'trash', 'conflicts']) {
|
|
112
|
+
if (!opts[k]) throw new Error(`Mirror requires option "${k}"`);
|
|
113
|
+
}
|
|
114
|
+
// `conflicts` names the conflicts backend; the policy is `conflictMode`
|
|
115
|
+
// ('prompt' = inbox upload, 'rename' = Dropbox-style copy on the hub).
|
|
116
|
+
if (!['prompt', 'rename'].includes(opts.conflictMode)) opts.conflictMode = 'prompt';
|
|
117
|
+
if (!['propagate', 'keep'].includes(opts.deletes)) opts.deletes = 'propagate';
|
|
118
|
+
if (!['bi', 'pull', 'push'].includes(opts.direction)) opts.direction = 'bi';
|
|
119
|
+
this.#stored = stored;
|
|
120
|
+
this.#opts = opts;
|
|
121
|
+
this.#ignorePatterns = [...MIRROR_IGNORE_DEFAULTS, ...(opts.ignore || [])];
|
|
122
|
+
this.#ledger = new Ledger({ index: stored.index, mirror: opts.id });
|
|
123
|
+
this.#queue = new JobQueue({ index: stored.index });
|
|
124
|
+
const st = this.#ledger.getState();
|
|
125
|
+
this.#conflicts = Number(st.conflicts) || 0;
|
|
126
|
+
this.#reverted = Number(st.reverted) || 0;
|
|
127
|
+
this.#lastSyncAt = st.lastSyncAt ?? null;
|
|
128
|
+
debug(`Mirror ${opts.id}: ${opts.local} ⇄ ${opts.remote} (${opts.direction}, trash ${opts.trash}, conflicts ${opts.conflicts}, ${opts.conflictMode}, deletes ${opts.deletes})`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get id() { return this.#opts.id; }
|
|
132
|
+
get state() { return this.#state; }
|
|
133
|
+
get ledger() { return this.#ledger; }
|
|
134
|
+
get queue() { return this.#queue; }
|
|
135
|
+
get options() { return { ...this.#opts }; }
|
|
136
|
+
get deviceId() { return this.#opts.deviceId || this.#remote?.deviceId || null; }
|
|
137
|
+
get deviceName() { return this.#opts.deviceName || this.#remote?.deviceName || this.deviceId || 'device'; }
|
|
138
|
+
get direction() { return this.#opts.direction; }
|
|
139
|
+
|
|
140
|
+
/** `[[docId, version], …]` agreed since the last call (cleared). Merge it back with `restoreApplied` when a report fails. */
|
|
141
|
+
takeApplied() {
|
|
142
|
+
const out = [...this.#applied.entries()];
|
|
143
|
+
this.#applied.clear();
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
restoreApplied(pairs = []) {
|
|
148
|
+
for (const [docId, version] of pairs) {
|
|
149
|
+
if (!(this.#applied.get(docId) > version)) this.#applied.set(docId, version);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every (docId, version) this replica currently holds, from the ledger. */
|
|
154
|
+
appliedSnapshot() {
|
|
155
|
+
const best = new Map();
|
|
156
|
+
for (const [, base] of this.#ledger.bases()) {
|
|
157
|
+
if (!base?.docId || !base?.version) continue;
|
|
158
|
+
if (!(best.get(base.docId) > base.version)) best.set(base.docId, base.version);
|
|
159
|
+
}
|
|
160
|
+
return [...best.entries()];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Base write + protection bookkeeping in one place.
|
|
164
|
+
#agree(key, base) {
|
|
165
|
+
const row = this.#ledger.setBase(key, base);
|
|
166
|
+
if (row.docId && row.version) this.#applied.set(row.docId, row.version);
|
|
167
|
+
return row;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get #remote() { return this.#stored.getBackend(this.#opts.remote); }
|
|
171
|
+
get #local() { return this.#stored.getBackend(this.#opts.local); }
|
|
172
|
+
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
174
|
+
// Lifecycle
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async start() {
|
|
178
|
+
if (this.#started) return this.status();
|
|
179
|
+
for (const name of [this.#opts.local, this.#opts.remote, this.#opts.trash, this.#opts.conflicts]) {
|
|
180
|
+
if (!this.#stored.getBackend(name)) throw new Error(`Mirror ${this.#opts.id}: backend "${name}" is not registered`);
|
|
181
|
+
}
|
|
182
|
+
if (typeof this.#remote.putStream !== 'function' || typeof this.#remote.poll !== 'function') {
|
|
183
|
+
throw new Error(`Mirror ${this.#opts.id}: remote backend "${this.#opts.remote}" must be a canvas driver`);
|
|
184
|
+
}
|
|
185
|
+
this.#started = true;
|
|
186
|
+
this.#stopping = false;
|
|
187
|
+
this.#setState('starting');
|
|
188
|
+
this.#bind();
|
|
189
|
+
try {
|
|
190
|
+
await this.#stored.scan(this.#opts.local);
|
|
191
|
+
await this.#catchUp();
|
|
192
|
+
await this.reconcileAll();
|
|
193
|
+
await this.#remote.watch();
|
|
194
|
+
this.#setState('online');
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (this.#isOffline(err)) this.#goOffline(err);
|
|
197
|
+
else { this.#lastError = err.message; this.emit('error', err); this.#setState('online'); }
|
|
198
|
+
}
|
|
199
|
+
if (this.#opts.fullReconcileEvery > 0) {
|
|
200
|
+
this.#fullTimer = setInterval(() => { this.fullReconcile().catch(() => {}); }, this.#opts.fullReconcileEvery);
|
|
201
|
+
this.#fullTimer.unref?.();
|
|
202
|
+
}
|
|
203
|
+
this.#kick();
|
|
204
|
+
return this.status();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Drain gracefully: no new jobs are started, running ones finish. */
|
|
208
|
+
async stop() {
|
|
209
|
+
if (!this.#started) return;
|
|
210
|
+
this.#stopping = true;
|
|
211
|
+
this.#setState('stopping');
|
|
212
|
+
for (const t of [this.#debounceTimer, this.#fullTimer, this.#offlineTimer, this.#retryTimer]) if (t) clearTimeout(t);
|
|
213
|
+
this.#debounceTimer = this.#fullTimer = this.#offlineTimer = this.#retryTimer = null;
|
|
214
|
+
for (const off of this.#unbind.splice(0)) off();
|
|
215
|
+
await this.#remote?.stop?.().catch(() => {});
|
|
216
|
+
if (this.#drainLoop) await this.#drainLoop.catch(() => {});
|
|
217
|
+
await Promise.allSettled([...this.#running.values()]);
|
|
218
|
+
if (this.#reconcilePass) await this.#reconcilePass.catch(() => {});
|
|
219
|
+
this.#ledger.setState({ lastSyncAt: this.#lastSyncAt, conflicts: this.#conflicts, reverted: this.#reverted });
|
|
220
|
+
this.#started = false;
|
|
221
|
+
this.#setState('stopped');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Poll the hub now, reconcile what changed, run the queue. */
|
|
225
|
+
async nudge() {
|
|
226
|
+
if (!this.#started || this.#stopping) return this.status();
|
|
227
|
+
if (this.#state === 'offline') { await this.#tryOnline(); return this.status(); }
|
|
228
|
+
const r = await this.#remote.poll();
|
|
229
|
+
if (!r.ok && r.reason === 'cursor-too-old') await this.#rebuild();
|
|
230
|
+
else if (!r.ok && r.reason === 'offline') { this.#goOffline(new Error(r.error || 'offline')); return this.status(); }
|
|
231
|
+
await this.#reconcileDirty();
|
|
232
|
+
this.#kick();
|
|
233
|
+
return this.status();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Full pass: rescan the local folder, reconcile every known key. */
|
|
237
|
+
async fullReconcile() {
|
|
238
|
+
if (!this.#started || this.#stopping || this.#state === 'offline') return this.status();
|
|
239
|
+
await this.#stored.scan(this.#opts.local);
|
|
240
|
+
await this.reconcileAll();
|
|
241
|
+
this.#kick();
|
|
242
|
+
return this.status();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Rebuild the hub view from a full listing (as after a 410), then reconcile. */
|
|
246
|
+
async resync() {
|
|
247
|
+
if (!this.#started || this.#stopping) return this.status();
|
|
248
|
+
await this.#stored.scan(this.#opts.local);
|
|
249
|
+
await this.#rebuild();
|
|
250
|
+
this.#kick();
|
|
251
|
+
return this.status();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Wait until the queue is empty and no reconcile pass is running (tests, CLI `sync now`). */
|
|
255
|
+
async idle({ timeout = 30_000 } = {}) {
|
|
256
|
+
const until = Date.now() + timeout;
|
|
257
|
+
for (;;) {
|
|
258
|
+
if (this.#reconcilePass) await this.#reconcilePass.catch(() => {});
|
|
259
|
+
if (this.#drainLoop) await this.#drainLoop.catch(() => {});
|
|
260
|
+
const c = this.#queue.counters(this.#opts.id);
|
|
261
|
+
const due = [...this.#queue.pending(Date.now(), this.#opts.id)].length;
|
|
262
|
+
if (!this.#dirty.size && !this.#running.size && due === 0 && !this.#debounceTimer && !this.#reconcilePass) {
|
|
263
|
+
return { ...c, drained: c.pending === 0 };
|
|
264
|
+
}
|
|
265
|
+
if (Date.now() > until) return { ...c, drained: false, timeout: true };
|
|
266
|
+
if (this.#state === 'offline') return { ...c, drained: false, offline: true };
|
|
267
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
status() {
|
|
272
|
+
const counters = this.#queue.counters(this.#opts.id);
|
|
273
|
+
const skips = [...this.#ledger.skips()];
|
|
274
|
+
return {
|
|
275
|
+
id: this.#opts.id,
|
|
276
|
+
direction: this.#opts.direction,
|
|
277
|
+
state: this.#state,
|
|
278
|
+
cursor: this.#ledger.cursor,
|
|
279
|
+
head: this.#remote?.head ?? null,
|
|
280
|
+
pending: counters.pending + counters.running,
|
|
281
|
+
running: counters.running,
|
|
282
|
+
failed: counters.failed,
|
|
283
|
+
conflicts: this.#conflicts,
|
|
284
|
+
reverted: this.#reverted,
|
|
285
|
+
lastSyncAt: this.#lastSyncAt,
|
|
286
|
+
lastError: this.#lastError,
|
|
287
|
+
skipped: skips.length,
|
|
288
|
+
skips: Object.fromEntries(skips.map(([k, v]) => [k, v.reason])),
|
|
289
|
+
dirty: this.#dirty.size,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
294
|
+
// Event wiring
|
|
295
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
#bind() {
|
|
298
|
+
const local = this.#opts.local;
|
|
299
|
+
const remote = this.#opts.remote;
|
|
300
|
+
const on = (event, handler) => {
|
|
301
|
+
this.#stored.on(event, handler);
|
|
302
|
+
this.#unbind.push(() => this.#stored.off(event, handler));
|
|
303
|
+
};
|
|
304
|
+
on('object:add', (e) => {
|
|
305
|
+
if (e.backend === local) {
|
|
306
|
+
if (e.renamedFrom) this.#localRenamed(normalizeKey(e.renamedFrom), normalizeKey(e.key));
|
|
307
|
+
else this.#markDirty(e.key);
|
|
308
|
+
} else if (e.backend === remote) {
|
|
309
|
+
if (e.renamedFrom) this.#remoteRenamed(normalizeKey(e.renamedFrom), normalizeKey(e.key));
|
|
310
|
+
else this.#markDirty(e.key);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
on('object:unlink', (e) => { if (e.backend === local || e.backend === remote) this.#markDirty(e.key); });
|
|
314
|
+
on('object:location:add', (e) => { if (e.backend === local || e.backend === remote) this.#markDirty(e.key); });
|
|
315
|
+
on('object:location:remove', (e) => { if (e.backend === local || e.backend === remote) this.#markDirty(e.key); });
|
|
316
|
+
on('object:move', (e) => {
|
|
317
|
+
for (const end of [e.from, e.to]) {
|
|
318
|
+
if (end && (end.backend === local || end.backend === remote)) this.#markDirty(end.key);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
on('backend:state', (e) => {
|
|
322
|
+
if (e.backend !== remote) return;
|
|
323
|
+
if (e.reason === 'polled') {
|
|
324
|
+
this.#pendingCursor = e.cursor;
|
|
325
|
+
if (this.#state === 'offline') this.#tryOnline().catch(() => {});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (e.reason === 'cursor-too-old') {
|
|
329
|
+
this.#rebuild().then(() => this.#kick()).catch((err) => { if (this.#isOffline(err)) this.#goOffline(err); });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (e.online === false) this.#goOffline(new Error(e.error || e.reason || 'offline'));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#markDirty(rawKey) {
|
|
337
|
+
if (this.#stopping) return;
|
|
338
|
+
const key = normalizeKey(rawKey);
|
|
339
|
+
if (!key) return;
|
|
340
|
+
this.#dirty.add(key);
|
|
341
|
+
if (this.#debounceTimer) return;
|
|
342
|
+
this.#debounceTimer = setTimeout(() => {
|
|
343
|
+
this.#debounceTimer = null;
|
|
344
|
+
this.#reconcileDirty().then(() => this.#kick()).catch((err) => this.#report(err));
|
|
345
|
+
}, this.#opts.debounceMs);
|
|
346
|
+
this.#debounceTimer.unref?.();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
350
|
+
// Catch-up / reconcile
|
|
351
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
async #catchUp() {
|
|
354
|
+
const remote = this.#remote;
|
|
355
|
+
const cursor = this.#ledger.cursor;
|
|
356
|
+
if (cursor == null) return this.#rebuild({ reconcile: false });
|
|
357
|
+
if (remote.cursor == null) remote.cursor = cursor;
|
|
358
|
+
const r = await remote.poll();
|
|
359
|
+
if (r.ok) { this.#pendingCursor = r.cursor; return; }
|
|
360
|
+
if (r.reason === 'cursor-too-old') return this.#rebuild({ reconcile: false });
|
|
361
|
+
if (r.reason === 'offline') throw Object.assign(new Error(r.error || 'hub offline'), { code: 'OFFLINE' });
|
|
362
|
+
throw Object.assign(new Error(r.error || `poll failed: ${r.reason}`), { code: r.reason });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Full listing → Stored index for the remote backend (stale remote
|
|
366
|
+
// locations are dropped by scan), cursor := listing head.
|
|
367
|
+
async #rebuild({ reconcile = true } = {}) {
|
|
368
|
+
this.#setState('scanning');
|
|
369
|
+
const remote = this.#remote;
|
|
370
|
+
remote.cursor = null;
|
|
371
|
+
const result = await this.#stored.scan(this.#opts.remote);
|
|
372
|
+
if (!result.ok || result.complete === false) {
|
|
373
|
+
const reason = result.errors?.[this.#opts.remote]?.root || result.errors?.root || 'unreachable';
|
|
374
|
+
const err = Object.assign(new Error(`hub listing failed: ${reason}`), { code: reason === 'OFFLINE' || reason === 'offline' ? 'OFFLINE' : reason });
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
this.#pendingCursor = remote.cursor;
|
|
378
|
+
debug(`rebuilt hub view: ${result.count} objects, cursor ${remote.cursor}`);
|
|
379
|
+
if (reconcile) await this.reconcileAll();
|
|
380
|
+
else if (this.#state === 'scanning') this.#setState('syncing');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Reconcile the union of local keys, hub keys and ledger bases. */
|
|
384
|
+
async reconcileAll() {
|
|
385
|
+
const keys = new Set();
|
|
386
|
+
for (const name of [this.#opts.local, this.#opts.remote]) {
|
|
387
|
+
let after = null;
|
|
388
|
+
do {
|
|
389
|
+
const page = this.#stored.listObjects(name, { after, limit: 5000 });
|
|
390
|
+
for (const o of page.objects) keys.add(normalizeKey(o.key));
|
|
391
|
+
after = page.cursor;
|
|
392
|
+
} while (after);
|
|
393
|
+
}
|
|
394
|
+
for (const [key] of this.#ledger.bases()) keys.add(key);
|
|
395
|
+
for (const key of keys) this.#dirty.add(key);
|
|
396
|
+
await this.#reconcileDirty();
|
|
397
|
+
return keys.size;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async #reconcileDirty() {
|
|
401
|
+
if (this.#reconcilePass) { await this.#reconcilePass; if (!this.#dirty.size) return; }
|
|
402
|
+
this.#reconcilePass = (async () => {
|
|
403
|
+
const wasState = this.#state;
|
|
404
|
+
if (wasState === 'online') this.#setState('syncing');
|
|
405
|
+
try {
|
|
406
|
+
while (this.#dirty.size) {
|
|
407
|
+
const batch = [...this.#dirty].sort();
|
|
408
|
+
this.#dirty.clear();
|
|
409
|
+
for (const key of batch) {
|
|
410
|
+
try { this.#reconcileKey(key); }
|
|
411
|
+
catch (err) { this.#report(err); }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// The whole batch reconciled (decisions durable in the queue):
|
|
415
|
+
// the feed position may move.
|
|
416
|
+
if (this.#pendingCursor != null && this.#pendingCursor !== this.#ledger.cursor) {
|
|
417
|
+
this.#ledger.cursor = this.#pendingCursor;
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
if (this.#state === 'syncing' || this.#state === 'scanning') this.#setState(this.#stopping ? 'stopping' : 'online');
|
|
421
|
+
}
|
|
422
|
+
})().finally(() => { this.#reconcilePass = null; });
|
|
423
|
+
await this.#reconcilePass;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#local3(key) {
|
|
427
|
+
const l = this.#stored.index.get(`${this.#opts.local}:${key}`);
|
|
428
|
+
const r = this.#stored.index.get(`${this.#opts.remote}:${key}`);
|
|
429
|
+
const loc = (meta, backend) => meta?.locations?.find((x) => x.backend === backend && x.key === key) || null;
|
|
430
|
+
return {
|
|
431
|
+
L: l?.checksums?.sha256 ?? null,
|
|
432
|
+
Lmeta: l,
|
|
433
|
+
Lloc: loc(l, this.#opts.local),
|
|
434
|
+
B: this.#ledger.getBase(key)?.sha256 ?? null,
|
|
435
|
+
base: this.#ledger.getBase(key),
|
|
436
|
+
R: r?.checksums?.sha256 ?? null,
|
|
437
|
+
Rmeta: r,
|
|
438
|
+
Rloc: loc(r, this.#opts.remote),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#inScope(key) {
|
|
443
|
+
if (!matchesPrefixes(key, this.#opts.prefixes)) return false;
|
|
444
|
+
if (isIgnored(key, this.#ignorePatterns)) return false;
|
|
445
|
+
const local = this.#local;
|
|
446
|
+
if (typeof local?.isIgnored === 'function' && local.isIgnored(key)) return false;
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#skip(key, reason) {
|
|
451
|
+
if (!this.#ledger.isSkipped(key)) {
|
|
452
|
+
this.#ledger.skip(key, reason);
|
|
453
|
+
debug(`skip ${key}: ${reason}`);
|
|
454
|
+
this.emit('skip', { key, reason });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Decide for one key and queue the outcome. Synchronous: reads only the
|
|
459
|
+
// index and the ledger. Runtime confirmation (HEAD) happens in the job.
|
|
460
|
+
#reconcileKey(key) {
|
|
461
|
+
if (!key || !this.#inScope(key)) return null;
|
|
462
|
+
const invalid = validateKey(key);
|
|
463
|
+
if (invalid) { this.#skip(key, invalid); return null; }
|
|
464
|
+
const { L, B, R, Lloc, Rloc, base } = this.#local3(key);
|
|
465
|
+
const d = this.#direct(decide(L, B, R), { L, B, R });
|
|
466
|
+
debug(`reconcile ${key}: L=${L?.slice(0, 8) ?? '∅'} B=${B?.slice(0, 8) ?? '∅'} R=${R?.slice(0, 8) ?? '∅'} → ${d.action}${d.reason ? ` (${d.reason})` : ''}`);
|
|
467
|
+
if (d.action === 'skip') { this.#queue.cancelByKey(key, this.#opts.id); this.#skip(key, d.reason); return d; }
|
|
468
|
+
if (this.#ledger.isSkipped(key)) this.#ledger.unskip(key);
|
|
469
|
+
switch (d.action) {
|
|
470
|
+
case 'nothing':
|
|
471
|
+
return d;
|
|
472
|
+
case 'revert':
|
|
473
|
+
this.#enqueue('revert', key, { local: L, base: B, remote: R });
|
|
474
|
+
return d;
|
|
475
|
+
case 'push':
|
|
476
|
+
if (!d.ifMatch && base) this.#ledger.removeBase(key); // hub deleted it: the base is void
|
|
477
|
+
this.#enqueue('push', key, { sha256: L, ifMatch: d.ifMatch ?? null });
|
|
478
|
+
return d;
|
|
479
|
+
case 'delete-remote':
|
|
480
|
+
if (this.#opts.deletes === 'propagate') this.#enqueue('delete-remote', key, { ifMatch: d.ifMatch });
|
|
481
|
+
else this.#ledger.removeBase(key);
|
|
482
|
+
return d;
|
|
483
|
+
case 'pull':
|
|
484
|
+
this.#enqueue('pull', key, { sha256: R, mtime: Rloc?.mtime ?? null, size: Rloc?.size ?? null });
|
|
485
|
+
return d;
|
|
486
|
+
case 'trash-local':
|
|
487
|
+
this.#enqueue('trash-local', key, { sha256: L });
|
|
488
|
+
return d;
|
|
489
|
+
case 'adopt':
|
|
490
|
+
this.#queue.cancelByKey(key, this.#opts.id);
|
|
491
|
+
if (L) this.#agree(key, { sha256: L, size: Lloc?.size ?? Rloc?.size ?? null, mtime: Rloc?.mtime ?? Lloc?.mtime ?? null, remoteSeq: this.#remote?.head ?? 0, ...(this.#remote?.describe?.(key) || {}) });
|
|
492
|
+
else this.#ledger.removeBase(key);
|
|
493
|
+
return d;
|
|
494
|
+
case 'conflict':
|
|
495
|
+
this.#enqueue('conflict', key, { local: L, base: B, remote: R });
|
|
496
|
+
return d;
|
|
497
|
+
default:
|
|
498
|
+
return d;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Narrow a decision to the mirror's direction (see the class comment).
|
|
503
|
+
#direct(d, { B, R }) {
|
|
504
|
+
const dir = this.#opts.direction;
|
|
505
|
+
if (dir === 'bi') return d;
|
|
506
|
+
if (dir === 'pull') {
|
|
507
|
+
switch (d.action) {
|
|
508
|
+
case 'push': return R ? { action: 'revert', remote: R } : { action: 'skip', reason: 'local-only' };
|
|
509
|
+
case 'delete-remote': return R ? { action: 'pull', sha256: R } : { action: 'nothing' };
|
|
510
|
+
case 'conflict': return { action: 'revert', remote: R };
|
|
511
|
+
default: return d;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
switch (d.action) { // push
|
|
515
|
+
case 'pull': return { action: 'skip', reason: B ? 'remote-changed' : 'remote-only' };
|
|
516
|
+
case 'trash-local': return { action: 'nothing' };
|
|
517
|
+
case 'conflict': return { action: 'skip', reason: 'conflict' };
|
|
518
|
+
default: return d;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#enqueue(kind, key, payload = {}, dedupe = null) {
|
|
523
|
+
// A newer decision for the key supersedes the older pending ones.
|
|
524
|
+
for (const job of this.#queue.byKey(key, this.#opts.id)) {
|
|
525
|
+
if (job.state === 'pending' && job.kind !== kind && job.kind !== 'rename-remote' && job.kind !== 'rename-local') this.#queue.cancel(job.seq);
|
|
526
|
+
}
|
|
527
|
+
return this.#queue.append({ kind, mirror: this.#opts.id, key, payload, dedupe });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Local `mv`: the base follows the key; the hub gets a rename when it knew
|
|
531
|
+
// the source. A file never pushed is simply pushed under its new name.
|
|
532
|
+
#localRenamed(from, to) {
|
|
533
|
+
const base = this.#ledger.getBase(from);
|
|
534
|
+
if (this.#opts.direction === 'pull' || !base || this.#ledger.getBase(to) || !this.#inScope(from) || !this.#inScope(to)) {
|
|
535
|
+
this.#markDirty(from);
|
|
536
|
+
this.#markDirty(to);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
this.#ledger.moveBase(from, to);
|
|
540
|
+
for (const job of this.#queue.byKey(from, this.#opts.id)) if (job.state === 'pending' && job.kind === 'push') this.#queue.cancel(job.seq);
|
|
541
|
+
this.#queue.append({ kind: 'rename-remote', mirror: this.#opts.id, key: to, payload: { from, to }, dedupe: `rename-remote|${from}→${to}` });
|
|
542
|
+
this.#markDirty(to);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Hub-side rename: re-key locally when our copy is clean (a job, so it is
|
|
546
|
+
// durable and serialized with the rest); a dirty source stays put.
|
|
547
|
+
#remoteRenamed(from, to) {
|
|
548
|
+
const { L, B } = this.#local3(from);
|
|
549
|
+
if (this.#opts.direction !== 'push' && L && L === B && !this.#stored.index.get(`${this.#opts.local}:${to}`) && this.#inScope(from) && this.#inScope(to)) {
|
|
550
|
+
this.#queue.append({ kind: 'rename-local', mirror: this.#opts.id, key: to, payload: { from, to }, dedupe: `rename-local|${from}→${to}` });
|
|
551
|
+
this.#kick();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.#markDirty(from);
|
|
555
|
+
this.#markDirty(to);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
559
|
+
// Worker
|
|
560
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
#jobKeys(job) {
|
|
563
|
+
const keys = new Set([job.key]);
|
|
564
|
+
if (job.payload?.from) keys.add(job.payload.from);
|
|
565
|
+
if (job.payload?.to) keys.add(job.payload.to);
|
|
566
|
+
keys.delete(null); keys.delete(undefined);
|
|
567
|
+
return [...keys];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#kick() {
|
|
571
|
+
if (!this.#started || this.#stopping || this.#state === 'offline' || this.#state === 'stopped') return;
|
|
572
|
+
if (this.#drainLoop) return;
|
|
573
|
+
this.#drainLoop = this.#drain().catch((err) => this.#report(err)).finally(() => {
|
|
574
|
+
this.#drainLoop = null;
|
|
575
|
+
const c = this.#queue.counters(this.#opts.id);
|
|
576
|
+
if (c.pending > 0 && c.nextAt != null && !this.#retryTimer && !this.#stopping) {
|
|
577
|
+
this.#retryTimer = setTimeout(() => { this.#retryTimer = null; this.#kick(); }, Math.max(50, c.nextAt - Date.now()));
|
|
578
|
+
this.#retryTimer.unref?.();
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async #drain() {
|
|
584
|
+
for (;;) {
|
|
585
|
+
if (this.#stopping || this.#state === 'offline') break;
|
|
586
|
+
let started = 0;
|
|
587
|
+
for (const job of [...this.#queue.pending(Date.now(), this.#opts.id)]) {
|
|
588
|
+
if (this.#running.size >= this.#opts.concurrency) break;
|
|
589
|
+
const keys = this.#jobKeys(job);
|
|
590
|
+
if (keys.some((k) => this.#runningKeys.has(k))) continue;
|
|
591
|
+
const taken = this.#queue.take(job.seq);
|
|
592
|
+
if (!taken) continue;
|
|
593
|
+
started += 1;
|
|
594
|
+
for (const k of keys) this.#runningKeys.add(k);
|
|
595
|
+
const p = this.#execute(taken).finally(() => {
|
|
596
|
+
this.#running.delete(taken.seq);
|
|
597
|
+
for (const k of keys) this.#runningKeys.delete(k);
|
|
598
|
+
});
|
|
599
|
+
this.#running.set(taken.seq, p);
|
|
600
|
+
}
|
|
601
|
+
if (!this.#running.size) break;
|
|
602
|
+
await Promise.race([...this.#running.values()]);
|
|
603
|
+
if (!started && !this.#running.size) break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async #execute(job) {
|
|
608
|
+
this.emit('job:start', job);
|
|
609
|
+
try {
|
|
610
|
+
await this.#runJob(job);
|
|
611
|
+
this.#queue.complete(job.seq);
|
|
612
|
+
this.#lastSyncAt = Date.now();
|
|
613
|
+
this.#lastError = null;
|
|
614
|
+
this.emit('job:done', job);
|
|
615
|
+
this.emit(`job:${job.kind}`, job);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (this.#isOffline(err)) {
|
|
618
|
+
this.#queue.retry(job.seq);
|
|
619
|
+
this.#goOffline(err);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const permanent = !!err?.permanent || ['REFUSED', 'UNAUTHORIZED'].includes(err?.code);
|
|
623
|
+
const failed = this.#queue.fail(job.seq, err, { permanent });
|
|
624
|
+
this.#lastError = `${job.kind} ${job.key}: ${err.message}`;
|
|
625
|
+
if (permanent && job.key) this.#skip(job.key, err.hubCode || err.code || 'refused');
|
|
626
|
+
debug(`job #${job.seq} ${job.kind} ${job.key} failed: ${err.message}`);
|
|
627
|
+
this.emit('job:failed', { ...failed, error: err });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async #runJob(job) {
|
|
632
|
+
switch (job.kind) {
|
|
633
|
+
case 'push': return this.#runPush(job);
|
|
634
|
+
case 'pull': return this.#runPull(job);
|
|
635
|
+
case 'delete-remote': return this.#runDeleteRemote(job);
|
|
636
|
+
case 'trash-local': return this.#runTrashLocal(job);
|
|
637
|
+
case 'rename-remote': return this.#runRenameRemote(job);
|
|
638
|
+
case 'rename-local': return this.#runRenameLocal(job);
|
|
639
|
+
case 'conflict': return this.#runConflict(job);
|
|
640
|
+
case 'revert': return this.#runRevert(job);
|
|
641
|
+
default: throw Object.assign(new Error(`unknown job kind ${job.kind}`), { code: 'REFUSED' });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// A driver call that failed with a typed `{ ok:false }` from Stored.
|
|
646
|
+
#failure(result, key) {
|
|
647
|
+
const err = new Error(result.error || `${result.reason}${key ? ` (${key})` : ''}`);
|
|
648
|
+
err.code = result.code || (result.reason === 'target-offline' || result.reason === 'source-offline' ? 'OFFLINE' : null);
|
|
649
|
+
err.reason = result.reason;
|
|
650
|
+
err.current = result.current;
|
|
651
|
+
return err;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Make sure the remote view of `key` is current before reading bytes from it.
|
|
655
|
+
async #freshRemote(key, sha256 = null) {
|
|
656
|
+
let meta = this.#stored.index.get(`${this.#opts.remote}:${key}`);
|
|
657
|
+
if (meta && (!sha256 || meta.checksums?.sha256 === sha256)) return meta;
|
|
658
|
+
const r = await this.#remote.poll();
|
|
659
|
+
if (!r.ok && r.reason === 'offline') throw Object.assign(new Error(r.error || 'offline'), { code: 'OFFLINE' });
|
|
660
|
+
if (!r.ok && r.reason === 'cursor-too-old') await this.#rebuild({ reconcile: false });
|
|
661
|
+
meta = this.#stored.index.get(`${this.#opts.remote}:${key}`);
|
|
662
|
+
return meta || null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async #runPush(job) {
|
|
666
|
+
const key = job.key;
|
|
667
|
+
const { L, B, Lloc, Lmeta } = this.#local3(key);
|
|
668
|
+
if (!L || !Lmeta) { this.#markDirty(key); return; }
|
|
669
|
+
// A brand-new key (no base, nothing in the hub view) skips the HEAD:
|
|
670
|
+
// the PUT carries If-None-Match:* and a 412 re-decides below, so the
|
|
671
|
+
// extra round trip bought nothing — and on a slow link it was half the
|
|
672
|
+
// per-file cost of a bulk upload.
|
|
673
|
+
const fresh = !B && !this.#stored.index.get(`${this.#opts.remote}:${key}`);
|
|
674
|
+
const stat = fresh ? null : await this.#remote.stat(key); // confirm R
|
|
675
|
+
const R = stat?.checksums?.sha256 ?? null;
|
|
676
|
+
if (R !== B) {
|
|
677
|
+
// The hub moved since the decision — decide again with what it has.
|
|
678
|
+
await this.#redecide(key, R);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const res = await this.#stored.copy(`${this.#opts.local}:${key}`, {
|
|
682
|
+
to: this.#opts.remote,
|
|
683
|
+
key,
|
|
684
|
+
from: this.#opts.local,
|
|
685
|
+
mtime: Lloc?.mtime ?? undefined,
|
|
686
|
+
origin: this.deviceId || undefined,
|
|
687
|
+
...(B ? { ifMatch: B } : { ifNoneMatch: '*' }),
|
|
688
|
+
onConflict: 'overwrite',
|
|
689
|
+
});
|
|
690
|
+
if (res.ok) {
|
|
691
|
+
const seq = res.remote?.seq ?? this.#remote.head ?? 0;
|
|
692
|
+
const mtime = res.remote?.mtime ?? Lloc?.mtime ?? null;
|
|
693
|
+
this.#agree(key, { sha256: L, size: Lloc?.size ?? Lmeta.size ?? null, mtime, remoteSeq: seq, docId: res.remote?.docId ?? null, version: res.remote?.version ?? null });
|
|
694
|
+
debug(`pushed ${key} (${Lloc?.size ?? '?'} bytes, seq ${seq})`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (res.reason === 'precondition-failed') { await this.#redecide(key, res.current?.sha256 ?? null); return; }
|
|
698
|
+
if (res.reason === 'not-found' || res.reason === 'no-source') { this.#markDirty(key); return; }
|
|
699
|
+
throw this.#failure(res, key);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Re-run the table with a confirmed R (after a HEAD or a 412). A pull needs
|
|
703
|
+
// the remote index fresh, so the feed is polled first.
|
|
704
|
+
async #redecide(key, R) {
|
|
705
|
+
if (R) await this.#freshRemote(key, R);
|
|
706
|
+
else if (this.#stored.index.get(`${this.#opts.remote}:${key}`)) await this.#freshRemote(key, '∅');
|
|
707
|
+
const { L, B } = this.#local3(key);
|
|
708
|
+
const d = decide(L, B, R);
|
|
709
|
+
debug(`redecide ${key}: L=${L?.slice(0, 8) ?? '∅'} B=${B?.slice(0, 8) ?? '∅'} R=${R?.slice(0, 8) ?? '∅'} → ${d.action}`);
|
|
710
|
+
switch (d.action) {
|
|
711
|
+
case 'nothing': return;
|
|
712
|
+
case 'adopt': this.#reconcileKey(key); return;
|
|
713
|
+
case 'push': this.#reconcileKey(key); return;
|
|
714
|
+
case 'delete-remote': this.#reconcileKey(key); return;
|
|
715
|
+
case 'pull': this.#enqueue('pull', key, { sha256: R }); return;
|
|
716
|
+
case 'trash-local': this.#enqueue('trash-local', key, { sha256: L }); return;
|
|
717
|
+
case 'conflict': this.#enqueue('conflict', key, { local: L, base: B, remote: R }); return;
|
|
718
|
+
default: return;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async #runPull(job) {
|
|
723
|
+
const key = job.key;
|
|
724
|
+
const want = job.payload?.sha256 ?? null;
|
|
725
|
+
const Rmeta = await this.#freshRemote(key, want);
|
|
726
|
+
const R = Rmeta?.checksums?.sha256 ?? null;
|
|
727
|
+
if (!R) { this.#markDirty(key); return; }
|
|
728
|
+
const { L, B, Lloc } = this.#local3(key);
|
|
729
|
+
if (L === R) { this.#reconcileKey(key); return; } // adopt
|
|
730
|
+
// Local changed since the decision → the table decides again
|
|
731
|
+
// (conflict). A local delete still pulls: edit beats delete.
|
|
732
|
+
if (L != null && L !== B) { this.#reconcileKey(key); return; }
|
|
733
|
+
const Rloc = Rmeta.locations.find((x) => x.backend === this.#opts.remote && x.key === key) || null;
|
|
734
|
+
const res = await this.#stored.copy(`${this.#opts.remote}:${key}`, {
|
|
735
|
+
to: this.#opts.local,
|
|
736
|
+
key,
|
|
737
|
+
from: this.#opts.remote,
|
|
738
|
+
onConflict: 'overwrite',
|
|
739
|
+
mtime: Rloc?.mtime ?? undefined,
|
|
740
|
+
origin: this.deviceId || undefined,
|
|
741
|
+
});
|
|
742
|
+
if (res.ok) {
|
|
743
|
+
this.#agree(key, { sha256: R, size: Rloc?.size ?? Rmeta.size ?? null, mtime: Rloc?.mtime ?? null, remoteSeq: this.#remote.head ?? 0, ...(this.#remote.describe?.(key) || {}) });
|
|
744
|
+
debug(`pulled ${key} (${Rloc?.size ?? '?'} bytes)${Lloc ? ' over local' : ''}`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (res.reason === 'not-found' || res.reason === 'no-source' || res.reason === 'source-unreadable') {
|
|
748
|
+
await this.#freshRemote(key, '∅');
|
|
749
|
+
this.#markDirty(key);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
throw this.#failure(res, key);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async #runDeleteRemote(job) {
|
|
756
|
+
const key = job.key;
|
|
757
|
+
const { L, B } = this.#local3(key);
|
|
758
|
+
if (!B) return;
|
|
759
|
+
if (L) { this.#markDirty(key); return; } // it came back
|
|
760
|
+
let res;
|
|
761
|
+
try {
|
|
762
|
+
res = await this.#stored.removeObject(this.#opts.remote, key, { ifMatch: B, origin: this.deviceId || undefined });
|
|
763
|
+
} catch (err) {
|
|
764
|
+
if (err.code === 'PRECONDITION_FAILED') { await this.#redecide(key, err.current?.sha256 ?? null); return; }
|
|
765
|
+
if (err.code === 'NOT_FOUND') { this.#ledger.removeBase(key); return; }
|
|
766
|
+
throw err;
|
|
767
|
+
}
|
|
768
|
+
if (res.ok || res.reason === 'not-found') {
|
|
769
|
+
this.#ledger.removeBase(key);
|
|
770
|
+
debug(`deleted ${key} on the hub`);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (res.reason === 'precondition-failed') { await this.#redecide(key, res.current?.sha256 ?? null); return; }
|
|
774
|
+
throw this.#failure(res, key);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async #runTrashLocal(job) {
|
|
778
|
+
const key = job.key;
|
|
779
|
+
const { L, B, R } = this.#local3(key);
|
|
780
|
+
if (!L) { this.#ledger.removeBase(key); return; }
|
|
781
|
+
if (L !== B || R) { this.#markDirty(key); return; }
|
|
782
|
+
const res = await this.#stored.move(`${this.#opts.local}:${key}`, {
|
|
783
|
+
to: this.#opts.trash,
|
|
784
|
+
key,
|
|
785
|
+
from: this.#opts.local,
|
|
786
|
+
onConflict: 'rename',
|
|
787
|
+
origin: this.deviceId || undefined,
|
|
788
|
+
});
|
|
789
|
+
if (res.ok) {
|
|
790
|
+
this.#ledger.removeBase(key);
|
|
791
|
+
debug(`${key}: deleted on the hub; local copy moved to ${this.#opts.trash}`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (res.reason === 'not-found' || res.reason === 'no-source') { this.#ledger.removeBase(key); return; }
|
|
795
|
+
throw this.#failure(res, key);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async #runRenameRemote(job) {
|
|
799
|
+
const { from, to } = job.payload;
|
|
800
|
+
const base = this.#ledger.getBase(to);
|
|
801
|
+
if (!base) { this.#markDirty(to); return; }
|
|
802
|
+
let res;
|
|
803
|
+
try {
|
|
804
|
+
res = await this.#stored.renameObject(this.#opts.remote, from, to, { ifMatch: base.sha256, origin: this.deviceId || undefined });
|
|
805
|
+
} catch (err) {
|
|
806
|
+
res = { ok: false, reason: err.code === 'PRECONDITION_FAILED' ? 'precondition-failed' : (err.code === 'NOT_FOUND' ? 'not-found' : (err.code === 'TARGET_EXISTS' ? 'target-exists' : 'transfer-failed')), error: err.message, code: err.code, current: err.current };
|
|
807
|
+
}
|
|
808
|
+
if (res.ok) {
|
|
809
|
+
this.#agree(to, { ...base, remoteSeq: res.seq ?? base.remoteSeq, docId: res.docId ?? base.docId ?? null, version: res.version ?? null });
|
|
810
|
+
debug(`renamed ${from} → ${to} on the hub`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (res.reason === 'not-found') {
|
|
814
|
+
// Source gone on the hub (index stale or really gone): our bytes at
|
|
815
|
+
// `to` are a new file there.
|
|
816
|
+
const meta = await this.#freshRemote(from, base.sha256);
|
|
817
|
+
if (meta) { this.#queue.append({ kind: 'rename-remote', mirror: this.#opts.id, key: to, payload: { from, to }, dedupe: `rename-remote|${from}→${to}` }); return; }
|
|
818
|
+
this.#ledger.removeBase(to);
|
|
819
|
+
this.#markDirty(to);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (res.reason === 'target-exists' || res.reason === 'precondition-failed') {
|
|
823
|
+
await this.#freshRemote(to, '∅');
|
|
824
|
+
this.#ledger.removeBase(to);
|
|
825
|
+
this.#markDirty(from);
|
|
826
|
+
this.#markDirty(to);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
throw this.#failure(res, to);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async #runRenameLocal(job) {
|
|
833
|
+
const { from, to } = job.payload;
|
|
834
|
+
const src = this.#local3(from);
|
|
835
|
+
if (!src.L || src.L !== src.B || this.#stored.index.get(`${this.#opts.local}:${to}`)) {
|
|
836
|
+
this.#markDirty(from); this.#markDirty(to); return;
|
|
837
|
+
}
|
|
838
|
+
const res = await this.#stored.move(`${this.#opts.local}:${from}`, {
|
|
839
|
+
to: this.#opts.local,
|
|
840
|
+
key: to,
|
|
841
|
+
from: this.#opts.local,
|
|
842
|
+
onConflict: 'error',
|
|
843
|
+
origin: this.deviceId || undefined,
|
|
844
|
+
});
|
|
845
|
+
if (res.ok) {
|
|
846
|
+
const moved = this.#ledger.moveBase(from, to, { remoteSeq: this.#remote.head ?? 0, ...(this.#remote.describe?.(to) || {}) });
|
|
847
|
+
if (moved?.docId && moved?.version) this.#applied.set(moved.docId, moved.version);
|
|
848
|
+
debug(`renamed ${from} → ${to} locally (hub rename)`);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (['not-found', 'no-source', 'target-exists'].includes(res.reason)) { this.#markDirty(from); this.#markDirty(to); return; }
|
|
852
|
+
throw this.#failure(res, to);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async #runConflict(job) {
|
|
856
|
+
const key = job.key;
|
|
857
|
+
const { L, B, Lloc, Lmeta } = this.#local3(key);
|
|
858
|
+
if (!L || !Lmeta) { this.#markDirty(key); return; }
|
|
859
|
+
const stat = await this.#remote.stat(key);
|
|
860
|
+
const R = stat?.checksums?.sha256 ?? null;
|
|
861
|
+
if (!R) { this.#ledger.removeBase(key); this.#markDirty(key); return; } // hub deleted it after all → push as new
|
|
862
|
+
if (R === L) { this.#reconcileKey(key); return; } // adopt
|
|
863
|
+
if (B && R === B) { this.#reconcileKey(key); return; } // plain push again
|
|
864
|
+
const now = new Date();
|
|
865
|
+
const copyKey = conflictKey(key, this.deviceName, now);
|
|
866
|
+
const mode = this.#opts.conflictMode === 'rename' ? 'rename' : 'inbox';
|
|
867
|
+
|
|
868
|
+
// (a) keep the device version next to the folder.
|
|
869
|
+
const kept = await this.#stored.copy(`${this.#opts.local}:${key}`, {
|
|
870
|
+
to: this.#opts.conflicts, key: copyKey, from: this.#opts.local, onConflict: 'rename', mtime: Lloc?.mtime ?? undefined,
|
|
871
|
+
});
|
|
872
|
+
if (!kept.ok && kept.reason !== 'unchanged') throw this.#failure(kept, copyKey);
|
|
873
|
+
|
|
874
|
+
// (b) upload it: inbox document, or an ordinary object at the conflict key.
|
|
875
|
+
let upload;
|
|
876
|
+
if (mode === 'inbox') {
|
|
877
|
+
const stream = await this.#stored.getStreamByUrl(`stored://${this.#opts.local}/${key}`);
|
|
878
|
+
if (!stream) { this.#markDirty(key); return; }
|
|
879
|
+
upload = await this.#remote.putStream(key, stream, {
|
|
880
|
+
conflictOf: key, conflictMode: 'inbox', baseSha256: B || undefined, sha256: L,
|
|
881
|
+
mtime: Lloc?.mtime ?? undefined, origin: this.deviceId || undefined, deviceName: this.deviceName,
|
|
882
|
+
mimeType: Lmeta.mimeType || undefined, size: Lloc?.size ?? Lmeta.size ?? null,
|
|
883
|
+
});
|
|
884
|
+
} else {
|
|
885
|
+
const res = await this.#stored.copy(`${this.#opts.local}:${key}`, {
|
|
886
|
+
to: this.#opts.remote, key: copyKey, from: this.#opts.local, mtime: Lloc?.mtime ?? undefined,
|
|
887
|
+
origin: this.deviceId || undefined, ifNoneMatch: '*', onConflict: 'error',
|
|
888
|
+
conflictOf: key, conflictMode: 'rename', baseSha256: B || undefined, deviceName: this.deviceName,
|
|
889
|
+
});
|
|
890
|
+
if (!res.ok && res.reason !== 'target-exists') throw this.#failure(res, copyKey);
|
|
891
|
+
upload = res.conflict || res.remote || { key: copyKey };
|
|
892
|
+
// No base for the copy key: it has no local file yet, so the next
|
|
893
|
+
// pass pulls it like any other hub object (the copy shows up on
|
|
894
|
+
// every device, Dropbox-style).
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// (c) the hub version keeps the name.
|
|
898
|
+
const Rmeta = await this.#freshRemote(key, R);
|
|
899
|
+
if (!Rmeta) { this.#markDirty(key); return; }
|
|
900
|
+
const Rloc = Rmeta.locations.find((x) => x.backend === this.#opts.remote && x.key === key) || null;
|
|
901
|
+
const pulled = await this.#stored.copy(`${this.#opts.remote}:${key}`, {
|
|
902
|
+
to: this.#opts.local, key, from: this.#opts.remote, onConflict: 'overwrite', mtime: Rloc?.mtime ?? undefined, origin: this.deviceId || undefined,
|
|
903
|
+
});
|
|
904
|
+
if (!pulled.ok) throw this.#failure(pulled, key);
|
|
905
|
+
this.#agree(key, { sha256: R, size: Rloc?.size ?? null, mtime: Rloc?.mtime ?? null, remoteSeq: this.#remote.head ?? 0, ...(this.#remote.describe?.(key) || {}) });
|
|
906
|
+
this.#conflicts += 1;
|
|
907
|
+
this.#ledger.setState({ conflicts: this.#conflicts });
|
|
908
|
+
const event = { key, mode, local: L, base: B, remote: R, copy: kept.added?.[0] ?? `stored://${this.#opts.conflicts}/${copyKey}`, conflictKey: mode === 'rename' ? copyKey : null, upload, at: now.getTime() };
|
|
909
|
+
debug(`conflict ${key}: kept ${copyKey}, uploaded (${mode}), hub version in place`);
|
|
910
|
+
this.emit('conflict', event);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// `pull` direction: a tracked file was edited here. Keep the edit next to
|
|
914
|
+
// the folder (conflicts backend, conflict-copy name), put the hub's bytes
|
|
915
|
+
// back and agree on them. Nothing goes upstream.
|
|
916
|
+
async #runRevert(job) {
|
|
917
|
+
const key = job.key;
|
|
918
|
+
const { L, B, Lloc } = this.#local3(key);
|
|
919
|
+
if (!L) { this.#markDirty(key); return; }
|
|
920
|
+
const Rmeta = await this.#freshRemote(key, job.payload?.remote ?? null);
|
|
921
|
+
const R = Rmeta?.checksums?.sha256 ?? null;
|
|
922
|
+
if (!R || R === L) { this.#reconcileKey(key); return; } // hub gone → local-only skip; equal → adopt
|
|
923
|
+
const now = new Date();
|
|
924
|
+
const copyKey = conflictKey(key, this.deviceName, now);
|
|
925
|
+
const kept = await this.#stored.copy(`${this.#opts.local}:${key}`, {
|
|
926
|
+
to: this.#opts.conflicts, key: copyKey, from: this.#opts.local, onConflict: 'rename', mtime: Lloc?.mtime ?? undefined,
|
|
927
|
+
});
|
|
928
|
+
if (!kept.ok && kept.reason !== 'unchanged') throw this.#failure(kept, copyKey);
|
|
929
|
+
const Rloc = Rmeta.locations.find((x) => x.backend === this.#opts.remote && x.key === key) || null;
|
|
930
|
+
const pulled = await this.#stored.copy(`${this.#opts.remote}:${key}`, {
|
|
931
|
+
to: this.#opts.local, key, from: this.#opts.remote, onConflict: 'overwrite', mtime: Rloc?.mtime ?? undefined, origin: this.deviceId || undefined,
|
|
932
|
+
});
|
|
933
|
+
if (!pulled.ok) throw this.#failure(pulled, key);
|
|
934
|
+
this.#agree(key, { sha256: R, size: Rloc?.size ?? null, mtime: Rloc?.mtime ?? null, remoteSeq: this.#remote.head ?? 0, ...(this.#remote.describe?.(key) || {}) });
|
|
935
|
+
this.#reverted += 1;
|
|
936
|
+
this.#ledger.setState({ reverted: this.#reverted });
|
|
937
|
+
const event = { key, local: L, base: B, remote: R, copy: kept.added?.[0] ?? `stored://${this.#opts.conflicts}/${copyKey}`, at: now.getTime() };
|
|
938
|
+
debug(`revert ${key}: local edit kept as ${copyKey}, hub version back in place`);
|
|
939
|
+
this.emit('revert', event);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
943
|
+
// Offline / state
|
|
944
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
945
|
+
|
|
946
|
+
#isOffline(err) {
|
|
947
|
+
return err?.code === 'OFFLINE' || err?.reason === 'target-offline' || err?.reason === 'source-offline' || err?.offline === true;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
#goOffline(err) {
|
|
951
|
+
this.#lastError = err?.message || 'offline';
|
|
952
|
+
if (this.#state === 'offline' || this.#stopping) return;
|
|
953
|
+
this.#setState('offline');
|
|
954
|
+
this.emit('offline', { error: this.#lastError });
|
|
955
|
+
this.#scheduleOnline();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#scheduleOnline() {
|
|
959
|
+
if (this.#offlineTimer || this.#stopping) return;
|
|
960
|
+
const delays = this.#opts.offlineBackoffMs;
|
|
961
|
+
const delay = delays[Math.min(this.#offlineAttempts, delays.length - 1)];
|
|
962
|
+
this.#offlineAttempts += 1;
|
|
963
|
+
this.#offlineTimer = setTimeout(() => { this.#offlineTimer = null; this.#tryOnline().catch(() => {}); }, delay);
|
|
964
|
+
this.#offlineTimer.unref?.();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async #tryOnline() {
|
|
968
|
+
if (this.#stopping || this.#state !== 'offline') return;
|
|
969
|
+
const live = await this.#remote.verifyRoot();
|
|
970
|
+
if (!live.ok) { this.#scheduleOnline(); return; }
|
|
971
|
+
this.#offlineAttempts = 0;
|
|
972
|
+
this.#setState('online');
|
|
973
|
+
this.emit('online', {});
|
|
974
|
+
try {
|
|
975
|
+
await this.#catchUp();
|
|
976
|
+
await this.#reconcileDirty();
|
|
977
|
+
} catch (err) {
|
|
978
|
+
if (this.#isOffline(err)) { this.#goOffline(err); return; }
|
|
979
|
+
this.#report(err);
|
|
980
|
+
}
|
|
981
|
+
this.#kick();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
#setState(state) {
|
|
985
|
+
if (this.#state === state) return;
|
|
986
|
+
this.#state = state;
|
|
987
|
+
this.emit('status', this.status());
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
#report(err) {
|
|
991
|
+
this.#lastError = err?.message || String(err);
|
|
992
|
+
debug(`error: ${this.#lastError}`);
|
|
993
|
+
if (this.listenerCount('error')) this.emit('error', err);
|
|
994
|
+
}
|
|
995
|
+
}
|