@augmentd-labs/canvas-edge 0.6.2 → 0.7.0
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/README.md +5 -0
- package/node_modules/canvas-stored/README.md +189 -9
- package/node_modules/canvas-stored/package.json +2 -2
- package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
- package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
- package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
- package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
- package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
- package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
- package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
- package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
- package/node_modules/canvas-stored/src/cache/index.js +39 -5
- package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +5 -3
- package/node_modules/canvas-stored/src/index.js +733 -132
- package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
- package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
- package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
- package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
- package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
- package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
- package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
- package/node_modules/canvas-stored/src/trash.js +219 -0
- package/node_modules/canvas-stored/src/utils/common.js +7 -0
- package/package.json +5 -4
- package/src/binding.js +33 -0
- package/src/control.js +13 -0
- package/src/daemon.js +14 -1
- package/src/env.js +1 -1
- package/src/mirror-runtime.js +15 -4
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
2
4
|
import Debug from 'debug';
|
|
3
5
|
import JobQueue from './JobQueue.js';
|
|
4
6
|
import Ledger from './Ledger.js';
|
|
@@ -10,12 +12,25 @@ const DEFAULTS = {
|
|
|
10
12
|
prefixes: [],
|
|
11
13
|
ignore: [],
|
|
12
14
|
deletes: 'propagate',
|
|
13
|
-
conflictMode: '
|
|
15
|
+
conflictMode: 'rename',
|
|
14
16
|
direction: 'bi',
|
|
15
17
|
debounceMs: 1500,
|
|
18
|
+
// A folder move reaches the mirror as one rename per file. They are
|
|
19
|
+
// collected until none arrived for this long, so a whole folder goes to
|
|
20
|
+
// the hub as one directory rename.
|
|
21
|
+
moveSettleMs: 1500,
|
|
22
|
+
// An inferred local deletion waits this long before it is sent: the
|
|
23
|
+
// other half of a move (the file appearing under its new name) can
|
|
24
|
+
// arrive later than the removal.
|
|
25
|
+
deleteDelayMs: 5000,
|
|
16
26
|
fullReconcileEvery: 6 * 60 * 60_000,
|
|
17
27
|
concurrency: 4,
|
|
18
28
|
offlineBackoffMs: [5_000, 10_000, 20_000, 40_000, 60_000],
|
|
29
|
+
// Local deletions are inferred from files being absent. When at least
|
|
30
|
+
// `min` of them AND `ratio` of the tracked files go missing within
|
|
31
|
+
// `windowMs`, they are held for `approveDeletes()` / `restoreDeletes()`
|
|
32
|
+
// instead of being sent to the hub. `false` turns the hold off.
|
|
33
|
+
deleteGuard: { min: 20, ratio: 0.25, windowMs: 60_000 },
|
|
19
34
|
};
|
|
20
35
|
|
|
21
36
|
/**
|
|
@@ -52,7 +67,7 @@ export function decide(L, B, R) {
|
|
|
52
67
|
* Every `object:*` event on either backend marks its key dirty; a debounced
|
|
53
68
|
* pass runs the three-way table (`decide`) per dirty key and turns the
|
|
54
69
|
* outcome into durable jobs (`JobQueue`): push, pull, delete-remote,
|
|
55
|
-
* trash-local, rename-remote, rename-local, conflict. A worker (concurrency
|
|
70
|
+
* trash-local, rename-remote, rename-remote-dir, rename-local, conflict. A worker (concurrency
|
|
56
71
|
* 2, one job per key) executes them through Stored's own copy/move/rename
|
|
57
72
|
* primitives so locations, succession and echo suppression behave exactly
|
|
58
73
|
* as for any other transfer. Base is written only after the byte operation
|
|
@@ -74,8 +89,14 @@ export function decide(L, B, R) {
|
|
|
74
89
|
* lists them all. That is what "this replica holds the current version" is
|
|
75
90
|
* computed from on the hub.
|
|
76
91
|
*
|
|
92
|
+
* A burst of local deletions (see `deleteGuard`) is not sent upstream: an
|
|
93
|
+
* unmounted or emptied folder looks exactly like the user deleting
|
|
94
|
+
* everything. The keys wait in `heldDeletes` until `approveDeletes()` sends
|
|
95
|
+
* them or `restoreDeletes()` pulls the hub copies back; files that reappear
|
|
96
|
+
* leave the hold on their own.
|
|
97
|
+
*
|
|
77
98
|
* Events: `status` (snapshot), `job:start|done|failed` (job), `conflict`,
|
|
78
|
-
* `revert`, `skip`, `offline`, `online`, `error`.
|
|
99
|
+
* `revert`, `skip`, `hold`, `offline`, `online`, `error`.
|
|
79
100
|
*/
|
|
80
101
|
export default class Mirror extends EventEmitter {
|
|
81
102
|
#stored;
|
|
@@ -103,6 +124,13 @@ export default class Mirror extends EventEmitter {
|
|
|
103
124
|
#unbind = [];
|
|
104
125
|
#ignorePatterns;
|
|
105
126
|
#reconcilePass = null;
|
|
127
|
+
#held = new Map(); // key → ifMatch: local deletions waiting for approval
|
|
128
|
+
#passDeletes = []; // deletions decided since the last settle
|
|
129
|
+
#recentDeletes = []; // when deletions were last let through (sliding window)
|
|
130
|
+
#moves = new Map(); // to → from: local renames waiting to be grouped
|
|
131
|
+
#moveSources = new Set(); // the `from` side of #moves
|
|
132
|
+
#movedAt = 0; // when the watcher last reported a local rename
|
|
133
|
+
#movingDirs = []; // [from, to] of folder renames the hub has not confirmed
|
|
106
134
|
|
|
107
135
|
constructor(stored, options = {}) {
|
|
108
136
|
super();
|
|
@@ -113,9 +141,10 @@ export default class Mirror extends EventEmitter {
|
|
|
113
141
|
}
|
|
114
142
|
// `conflicts` names the conflicts backend; the policy is `conflictMode`
|
|
115
143
|
// ('prompt' = inbox upload, 'rename' = Dropbox-style copy on the hub).
|
|
116
|
-
if (!['prompt', 'rename'].includes(opts.conflictMode)) opts.conflictMode =
|
|
144
|
+
if (!['prompt', 'rename'].includes(opts.conflictMode)) opts.conflictMode = DEFAULTS.conflictMode;
|
|
117
145
|
if (!['propagate', 'keep'].includes(opts.deletes)) opts.deletes = 'propagate';
|
|
118
146
|
if (!['bi', 'pull', 'push'].includes(opts.direction)) opts.direction = 'bi';
|
|
147
|
+
opts.deleteGuard = opts.deleteGuard ? { ...DEFAULTS.deleteGuard, ...opts.deleteGuard } : null;
|
|
119
148
|
this.#stored = stored;
|
|
120
149
|
this.#opts = opts;
|
|
121
150
|
this.#ignorePatterns = [...MIRROR_IGNORE_DEFAULTS, ...(opts.ignore || [])];
|
|
@@ -186,6 +215,7 @@ export default class Mirror extends EventEmitter {
|
|
|
186
215
|
this.#stopping = false;
|
|
187
216
|
this.#setState('starting');
|
|
188
217
|
this.#bind();
|
|
218
|
+
this.#movingDirs = [...this.#queue.all(this.#opts.id)].filter((j) => j.kind === 'rename-remote-dir').map((j) => [j.payload.from, j.payload.to]);
|
|
189
219
|
try {
|
|
190
220
|
await this.#stored.scan(this.#opts.local);
|
|
191
221
|
await this.#catchUp();
|
|
@@ -287,9 +317,76 @@ export default class Mirror extends EventEmitter {
|
|
|
287
317
|
skipped: skips.length,
|
|
288
318
|
skips: Object.fromEntries(skips.map(([k, v]) => [k, v.reason])),
|
|
289
319
|
dirty: this.#dirty.size,
|
|
320
|
+
heldDeletes: this.#held.size,
|
|
290
321
|
};
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
/** Keys whose local deletion is waiting for approval. */
|
|
325
|
+
heldDeletes() { return [...this.#held.keys()].sort(); }
|
|
326
|
+
|
|
327
|
+
/** The held deletions were meant: send them to the hub. */
|
|
328
|
+
approveDeletes() {
|
|
329
|
+
const held = [...this.#held];
|
|
330
|
+
this.#held.clear();
|
|
331
|
+
let sent = 0;
|
|
332
|
+
for (const [key] of held) {
|
|
333
|
+
const { L, B, R } = this.#local3(key);
|
|
334
|
+
const d = this.#direct(decide(L, B, R), { L, B, R });
|
|
335
|
+
if (d.action !== 'delete-remote') { this.#markDirty(key); continue; }
|
|
336
|
+
this.#enqueue('delete-remote', key, { ifMatch: d.ifMatch });
|
|
337
|
+
sent += 1;
|
|
338
|
+
}
|
|
339
|
+
this.#lastError = null;
|
|
340
|
+
this.emit('status', this.status());
|
|
341
|
+
this.#kick();
|
|
342
|
+
return sent;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The held deletions were not meant: forget the base so the hub copies are pulled back. */
|
|
346
|
+
restoreDeletes() {
|
|
347
|
+
const held = [...this.#held.keys()];
|
|
348
|
+
this.#held.clear();
|
|
349
|
+
for (const key of held) { this.#ledger.removeBase(key); this.#markDirty(key); }
|
|
350
|
+
this.#lastError = null;
|
|
351
|
+
this.emit('status', this.status());
|
|
352
|
+
return held.length;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Let the deletions decided since the last call through, or hold them.
|
|
356
|
+
#settleDeletes() {
|
|
357
|
+
const guard = this.#opts.deleteGuard;
|
|
358
|
+
const candidates = this.#passDeletes.splice(0);
|
|
359
|
+
if (!candidates.length) return;
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
this.#recentDeletes = this.#recentDeletes.filter((t) => now - t < guard.windowMs);
|
|
362
|
+
let hold = this.#held.size > 0;
|
|
363
|
+
if (!hold) {
|
|
364
|
+
const count = candidates.length + this.#recentDeletes.length;
|
|
365
|
+
if (count >= guard.min) {
|
|
366
|
+
let tracked = 0;
|
|
367
|
+
for (const _ of this.#ledger.bases()) tracked += 1;
|
|
368
|
+
hold = count >= guard.ratio * tracked;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!hold) {
|
|
372
|
+
for (const [key, ifMatch] of candidates) { this.#enqueue('delete-remote', key, { ifMatch }, null, now + this.#opts.deleteDelayMs); this.#recentDeletes.push(now); }
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const first = this.#held.size === 0;
|
|
376
|
+
for (const [key, ifMatch] of candidates) this.#held.set(key, ifMatch);
|
|
377
|
+
// Deletions of the same burst that were let through but have not run yet join the hold.
|
|
378
|
+
for (const job of [...this.#queue.pending(Infinity, this.#opts.id)]) {
|
|
379
|
+
if (job.kind !== 'delete-remote' || this.#runningKeys.has(job.key)) continue;
|
|
380
|
+
this.#queue.cancel(job.seq);
|
|
381
|
+
this.#held.set(job.key, job.payload?.ifMatch ?? null);
|
|
382
|
+
}
|
|
383
|
+
this.#recentDeletes = [];
|
|
384
|
+
this.#lastError = `${this.#held.size} files are missing locally; their deletion is held until approved or restored`;
|
|
385
|
+
debug(this.#lastError);
|
|
386
|
+
if (first) this.emit('hold', { count: this.#held.size });
|
|
387
|
+
this.emit('status', this.status());
|
|
388
|
+
}
|
|
389
|
+
|
|
293
390
|
// ─────────────────────────────────────────────────────────────────────────
|
|
294
391
|
// Event wiring
|
|
295
392
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -303,7 +400,7 @@ export default class Mirror extends EventEmitter {
|
|
|
303
400
|
};
|
|
304
401
|
on('object:add', (e) => {
|
|
305
402
|
if (e.backend === local) {
|
|
306
|
-
if (e.renamedFrom) this.#localRenamed(normalizeKey(e.renamedFrom), normalizeKey(e.key));
|
|
403
|
+
if (e.renamedFrom) { this.#movedAt = Date.now(); this.#localRenamed(normalizeKey(e.renamedFrom), normalizeKey(e.key)); }
|
|
307
404
|
else this.#markDirty(e.key);
|
|
308
405
|
} else if (e.backend === remote) {
|
|
309
406
|
if (e.renamedFrom) this.#remoteRenamed(normalizeKey(e.renamedFrom), normalizeKey(e.key));
|
|
@@ -338,11 +435,15 @@ export default class Mirror extends EventEmitter {
|
|
|
338
435
|
const key = normalizeKey(rawKey);
|
|
339
436
|
if (!key) return;
|
|
340
437
|
this.#dirty.add(key);
|
|
341
|
-
|
|
438
|
+
this.#schedule();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#schedule(ms = this.#opts.debounceMs) {
|
|
442
|
+
if (this.#debounceTimer || this.#stopping) return;
|
|
342
443
|
this.#debounceTimer = setTimeout(() => {
|
|
343
444
|
this.#debounceTimer = null;
|
|
344
445
|
this.#reconcileDirty().then(() => this.#kick()).catch((err) => this.#report(err));
|
|
345
|
-
},
|
|
446
|
+
}, ms);
|
|
346
447
|
this.#debounceTimer.unref?.();
|
|
347
448
|
}
|
|
348
449
|
|
|
@@ -403,19 +504,24 @@ export default class Mirror extends EventEmitter {
|
|
|
403
504
|
const wasState = this.#state;
|
|
404
505
|
if (wasState === 'online') this.#setState('syncing');
|
|
405
506
|
try {
|
|
406
|
-
while (this.#dirty.size) {
|
|
507
|
+
while (this.#dirty.size || this.#movesDue()) {
|
|
407
508
|
const batch = [...this.#dirty].sort();
|
|
408
509
|
this.#dirty.clear();
|
|
510
|
+
const moved = this.#pairMoves(batch);
|
|
511
|
+
if (this.#movesDue()) this.#planMoves();
|
|
409
512
|
for (const key of batch) {
|
|
513
|
+
if (moved.has(key) || this.#isMoving(key)) continue;
|
|
410
514
|
try { this.#reconcileKey(key); }
|
|
411
515
|
catch (err) { this.#report(err); }
|
|
412
516
|
}
|
|
517
|
+
this.#settleDeletes();
|
|
413
518
|
}
|
|
414
519
|
// The whole batch reconciled (decisions durable in the queue):
|
|
415
520
|
// the feed position may move.
|
|
416
521
|
if (this.#pendingCursor != null && this.#pendingCursor !== this.#ledger.cursor) {
|
|
417
522
|
this.#ledger.cursor = this.#pendingCursor;
|
|
418
523
|
}
|
|
524
|
+
if (this.#moves.size) this.#schedule(Math.max(50, this.#movedAt + this.#opts.moveSettleMs - Date.now()));
|
|
419
525
|
} finally {
|
|
420
526
|
if (this.#state === 'syncing' || this.#state === 'scanning') this.#setState(this.#stopping ? 'stopping' : 'online');
|
|
421
527
|
}
|
|
@@ -423,6 +529,58 @@ export default class Mirror extends EventEmitter {
|
|
|
423
529
|
await this.#reconcilePass;
|
|
424
530
|
}
|
|
425
531
|
|
|
532
|
+
// A folder scan after downtime, or a hub listing, shows a move as one key
|
|
533
|
+
// that went missing and another that appeared. Pair the two by content
|
|
534
|
+
// and treat them as the rename they are: the hub keeps its document and
|
|
535
|
+
// history, no bytes travel, and nothing counts as a deletion. Returns
|
|
536
|
+
// the keys that were settled this way.
|
|
537
|
+
#pairMoves(batch) {
|
|
538
|
+
const settled = new Set();
|
|
539
|
+
const gone = { local: new Map(), remote: new Map() };
|
|
540
|
+
const fresh = { local: [], remote: [] };
|
|
541
|
+
for (const key of batch) {
|
|
542
|
+
if (!key || !this.#inScope(key) || validateKey(key) || this.#isMoving(key)) continue;
|
|
543
|
+
const { L, B, R } = this.#local3(key);
|
|
544
|
+
const left = (side) => {
|
|
545
|
+
if (!gone[side].has(B)) gone[side].set(B, []);
|
|
546
|
+
gone[side].get(B).push(key);
|
|
547
|
+
};
|
|
548
|
+
if (B && !L && R === B) left('local');
|
|
549
|
+
else if (B && !R && L) left('remote');
|
|
550
|
+
else if (!B && L && !R) fresh.local.push([key, L]);
|
|
551
|
+
else if (!B && R && !L) fresh.remote.push([key, R]);
|
|
552
|
+
}
|
|
553
|
+
// A removal that was noticed in an earlier pass (held, or queued and
|
|
554
|
+
// not sent yet) can still be the first half of a move.
|
|
555
|
+
if (fresh.local.length) {
|
|
556
|
+
const earlier = new Set(this.#held.keys());
|
|
557
|
+
for (const job of this.#queue.all(this.#opts.id)) if (job.state === 'pending' && job.kind === 'delete-remote') earlier.add(job.key);
|
|
558
|
+
const seen = new Set(batch);
|
|
559
|
+
for (const key of earlier) {
|
|
560
|
+
if (seen.has(key) || this.#isMoving(key)) continue;
|
|
561
|
+
const { L, B, R } = this.#local3(key);
|
|
562
|
+
if (!B || L || R !== B) continue;
|
|
563
|
+
if (!gone.local.has(B)) gone.local.set(B, []);
|
|
564
|
+
gone.local.get(B).push(key);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const pair = (side, apply) => {
|
|
568
|
+
for (const [to, sha] of fresh[side]) {
|
|
569
|
+
const sources = gone[side].get(sha);
|
|
570
|
+
if (!sources?.length) continue;
|
|
571
|
+
const leaf = to.slice(to.lastIndexOf('/') + 1);
|
|
572
|
+
const at = Math.max(0, sources.findIndex((k) => k.slice(k.lastIndexOf('/') + 1) === leaf));
|
|
573
|
+
const [from] = sources.splice(at, 1);
|
|
574
|
+
debug(`${from} → ${to}: moved on the ${side === 'local' ? 'device' : 'hub'} while the mirror was not watching`);
|
|
575
|
+
apply(from, to);
|
|
576
|
+
settled.add(from); settled.add(to);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
if (this.#opts.direction !== 'pull') pair('local', (from, to) => this.#localRenamed(from, to));
|
|
580
|
+
if (this.#opts.direction !== 'push') pair('remote', (from, to) => this.#remoteRenamed(from, to));
|
|
581
|
+
return settled;
|
|
582
|
+
}
|
|
583
|
+
|
|
426
584
|
#local3(key) {
|
|
427
585
|
const l = this.#stored.index.get(`${this.#opts.local}:${key}`);
|
|
428
586
|
const r = this.#stored.index.get(`${this.#opts.remote}:${key}`);
|
|
@@ -464,6 +622,10 @@ export default class Mirror extends EventEmitter {
|
|
|
464
622
|
const { L, B, R, Lloc, Rloc, base } = this.#local3(key);
|
|
465
623
|
const d = this.#direct(decide(L, B, R), { L, B, R });
|
|
466
624
|
debug(`reconcile ${key}: L=${L?.slice(0, 8) ?? '∅'} B=${B?.slice(0, 8) ?? '∅'} R=${R?.slice(0, 8) ?? '∅'} → ${d.action}${d.reason ? ` (${d.reason})` : ''}`);
|
|
625
|
+
if (d.action !== 'delete-remote' && this.#held.delete(key) && !this.#held.size) {
|
|
626
|
+
this.#lastError = null;
|
|
627
|
+
this.emit('status', this.status());
|
|
628
|
+
}
|
|
467
629
|
if (d.action === 'skip') { this.#queue.cancelByKey(key, this.#opts.id); this.#skip(key, d.reason); return d; }
|
|
468
630
|
if (this.#ledger.isSkipped(key)) this.#ledger.unskip(key);
|
|
469
631
|
switch (d.action) {
|
|
@@ -477,8 +639,9 @@ export default class Mirror extends EventEmitter {
|
|
|
477
639
|
this.#enqueue('push', key, { sha256: L, ifMatch: d.ifMatch ?? null });
|
|
478
640
|
return d;
|
|
479
641
|
case 'delete-remote':
|
|
480
|
-
if (this.#opts.deletes
|
|
481
|
-
else this.#
|
|
642
|
+
if (this.#opts.deletes !== 'propagate') this.#ledger.removeBase(key);
|
|
643
|
+
else if (!this.#opts.deleteGuard) this.#enqueue('delete-remote', key, { ifMatch: d.ifMatch }, null, Date.now() + this.#opts.deleteDelayMs);
|
|
644
|
+
else if (!this.#held.has(key)) this.#passDeletes.push([key, d.ifMatch]);
|
|
482
645
|
return d;
|
|
483
646
|
case 'pull':
|
|
484
647
|
this.#enqueue('pull', key, { sha256: R, mtime: Rloc?.mtime ?? null, size: Rloc?.size ?? null });
|
|
@@ -519,34 +682,130 @@ export default class Mirror extends EventEmitter {
|
|
|
519
682
|
}
|
|
520
683
|
}
|
|
521
684
|
|
|
522
|
-
#enqueue(kind, key, payload = {}, dedupe = null) {
|
|
685
|
+
#enqueue(kind, key, payload = {}, dedupe = null, notBefore = 0) {
|
|
523
686
|
// A newer decision for the key supersedes the older pending ones.
|
|
524
687
|
for (const job of this.#queue.byKey(key, this.#opts.id)) {
|
|
525
688
|
if (job.state === 'pending' && job.kind !== kind && job.kind !== 'rename-remote' && job.kind !== 'rename-local') this.#queue.cancel(job.seq);
|
|
526
689
|
}
|
|
527
|
-
return this.#queue.append({ kind, mirror: this.#opts.id, key, payload, dedupe });
|
|
690
|
+
return this.#queue.append({ kind, mirror: this.#opts.id, key, payload, dedupe, notBefore });
|
|
528
691
|
}
|
|
529
692
|
|
|
530
|
-
// Local `mv`:
|
|
531
|
-
//
|
|
693
|
+
// Local `mv`: collected first, so the renames of one folder move can be
|
|
694
|
+
// sent as one directory rename (#planMoves).
|
|
532
695
|
#localRenamed(from, to) {
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
696
|
+
const origin = this.#moves.get(from) ?? from;
|
|
697
|
+
this.#moves.delete(from);
|
|
698
|
+
this.#moves.set(to, origin);
|
|
699
|
+
this.#moveSources.add(origin);
|
|
700
|
+
this.#markDirty(to);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#movesDue() {
|
|
704
|
+
return this.#moves.size > 0 && Date.now() - this.#movedAt >= this.#opts.moveSettleMs;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// A key whose rename is still being grouped or is part of a folder rename
|
|
708
|
+
// the hub has not confirmed: deciding it on its own would upload or
|
|
709
|
+
// delete what is only being moved.
|
|
710
|
+
#isMoving(key) {
|
|
711
|
+
if (this.#moves.has(key) || this.#moveSources.has(key)) return true;
|
|
712
|
+
return this.#movingDirs.some(([from, to]) => key.startsWith(`${from}/`) || key.startsWith(`${to}/`));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Turn the collected local renames into jobs. Renames that share a
|
|
716
|
+
// folder pair (`A/x/y` → `B/x/y`) become one directory rename when the
|
|
717
|
+
// whole folder went: A is gone from the device, B is there and the hub
|
|
718
|
+
// has nothing under B. Otherwise the
|
|
719
|
+
// same test is made one level down, and what remains is renamed per file.
|
|
720
|
+
#planMoves() {
|
|
721
|
+
const pairs = [];
|
|
722
|
+
for (const [to, from] of this.#moves) {
|
|
723
|
+
if (this.#opts.direction === 'pull' || !this.#ledger.getBase(from) || this.#ledger.getBase(to) || !this.#inScope(from) || !this.#inScope(to)) {
|
|
724
|
+
this.#dirty.add(from); this.#dirty.add(to);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
pairs.push({ from, to });
|
|
728
|
+
}
|
|
729
|
+
this.#moves.clear();
|
|
730
|
+
this.#moveSources.clear();
|
|
731
|
+
const files = [];
|
|
732
|
+
const groups = new Map();
|
|
733
|
+
for (const pair of pairs) {
|
|
734
|
+
const f = pair.from.split('/');
|
|
735
|
+
const t = pair.to.split('/');
|
|
736
|
+
let n = 0;
|
|
737
|
+
while (n < f.length - 1 && n < t.length - 1 && f[f.length - 1 - n] === t[t.length - 1 - n]) n += 1;
|
|
738
|
+
if (!n) { files.push(pair); continue; }
|
|
739
|
+
const A = f.slice(0, f.length - n).join('/');
|
|
740
|
+
const B = t.slice(0, t.length - n).join('/');
|
|
741
|
+
const id = `${A}\n${B}`;
|
|
742
|
+
if (!groups.has(id)) groups.set(id, { A, B, group: [] });
|
|
743
|
+
groups.get(id).group.push({ ...pair, rest: f.slice(f.length - n) });
|
|
538
744
|
}
|
|
745
|
+
const descend = (A, B, group) => {
|
|
746
|
+
if (this.#wholeFolder(A, B, group.length)) { this.#renameFolder(A, B); return; }
|
|
747
|
+
const below = new Map();
|
|
748
|
+
for (const pair of group) {
|
|
749
|
+
if (pair.rest.length <= 1) { files.push(pair); continue; }
|
|
750
|
+
const [segment, ...rest] = pair.rest;
|
|
751
|
+
if (!below.has(segment)) below.set(segment, []);
|
|
752
|
+
below.get(segment).push({ ...pair, rest });
|
|
753
|
+
}
|
|
754
|
+
for (const [segment, sub] of below) descend(`${A}/${segment}`, `${B}/${segment}`, sub);
|
|
755
|
+
};
|
|
756
|
+
for (const { A, B, group } of groups.values()) descend(A, B, group);
|
|
757
|
+
for (const { from, to } of files) this.#renameFile(from, to);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
#wholeFolder(A, B, count) {
|
|
761
|
+
if (!A || !B || A === B || B.startsWith(`${A}/`) || A.startsWith(`${B}/`)) return false;
|
|
762
|
+
if (typeof this.#remote.renameDirectory !== 'function') return false;
|
|
763
|
+
const empty = (backend, prefix) => this.#stored.listObjects(backend, { prefix, limit: 1 }).objects.length === 0;
|
|
764
|
+
if (!empty(this.#opts.remote, `${B}/`)) return false;
|
|
765
|
+
for (const _ of this.#ledger.bases(`${B}/`)) return false;
|
|
766
|
+
// The folder itself has the last word: once A is gone and B is there,
|
|
767
|
+
// the whole of it moved, whatever part of it the watcher reported so far.
|
|
768
|
+
const local = this.#local;
|
|
769
|
+
if (typeof local.resolveKeyPath === 'function') {
|
|
770
|
+
const directory = (key) => { try { return fs.statSync(local.resolveKeyPath(key)).isDirectory(); } catch { return false; } };
|
|
771
|
+
const present = (key) => { try { fs.lstatSync(local.resolveKeyPath(key)); return true; } catch { return false; } };
|
|
772
|
+
return !present(A) && directory(B);
|
|
773
|
+
}
|
|
774
|
+
if (!empty(this.#opts.local, `${A}/`)) return false;
|
|
775
|
+
let tracked = 0;
|
|
776
|
+
for (const _ of this.#ledger.bases(`${A}/`)) { tracked += 1; if (tracked > count) return false; }
|
|
777
|
+
return tracked === count;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// One job for the folder. The bases stay under `from` until the hub
|
|
781
|
+
// confirmed, so a crash before that replays the same job.
|
|
782
|
+
#renameFolder(from, to) {
|
|
783
|
+
const prefix = `${from}/`;
|
|
784
|
+
for (const job of [...this.#queue.all(this.#opts.id)]) {
|
|
785
|
+
if (job.state === 'pending' && ['push', 'delete-remote'].includes(job.kind) && job.key?.startsWith(prefix)) this.#queue.cancel(job.seq);
|
|
786
|
+
}
|
|
787
|
+
for (const key of [...this.#held.keys()]) if (key.startsWith(prefix)) this.#held.delete(key);
|
|
788
|
+
this.#queue.append({ kind: 'rename-remote-dir', mirror: this.#opts.id, key: to, payload: { from, to, operationId: randomUUID() }, dedupe: `rename-remote-dir|${from}→${to}` });
|
|
789
|
+
this.#movingDirs.push([from, to]);
|
|
790
|
+
debug(`${from}/ → ${to}/: folder moved on the device`);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// The base follows the key; the hub gets a rename when it knew the
|
|
794
|
+
// source. A file never pushed is simply pushed under its new name.
|
|
795
|
+
#renameFile(from, to) {
|
|
539
796
|
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);
|
|
797
|
+
for (const job of this.#queue.byKey(from, this.#opts.id)) if (job.state === 'pending' && (job.kind === 'push' || job.kind === 'delete-remote')) this.#queue.cancel(job.seq);
|
|
798
|
+
this.#held.delete(from);
|
|
541
799
|
this.#queue.append({ kind: 'rename-remote', mirror: this.#opts.id, key: to, payload: { from, to }, dedupe: `rename-remote|${from}→${to}` });
|
|
542
800
|
this.#markDirty(to);
|
|
543
801
|
}
|
|
544
802
|
|
|
545
|
-
// Hub-side rename:
|
|
546
|
-
//
|
|
803
|
+
// Hub-side rename: move the local file too (a job, so it is durable and
|
|
804
|
+
// serialized with the rest). A local edit that was not pushed yet moves
|
|
805
|
+
// with the file and is then pushed against the version the hub renamed.
|
|
547
806
|
#remoteRenamed(from, to) {
|
|
548
807
|
const { L, B } = this.#local3(from);
|
|
549
|
-
if (this.#opts.direction !== 'push' && L &&
|
|
808
|
+
if (this.#opts.direction !== 'push' && L && B && !this.#stored.index.get(`${this.#opts.local}:${to}`) && this.#inScope(from) && this.#inScope(to)) {
|
|
550
809
|
this.#queue.append({ kind: 'rename-local', mirror: this.#opts.id, key: to, payload: { from, to }, dedupe: `rename-local|${from}→${to}` });
|
|
551
810
|
this.#kick();
|
|
552
811
|
return;
|
|
@@ -635,6 +894,7 @@ export default class Mirror extends EventEmitter {
|
|
|
635
894
|
case 'delete-remote': return this.#runDeleteRemote(job);
|
|
636
895
|
case 'trash-local': return this.#runTrashLocal(job);
|
|
637
896
|
case 'rename-remote': return this.#runRenameRemote(job);
|
|
897
|
+
case 'rename-remote-dir': return this.#runRenameRemoteDir(job);
|
|
638
898
|
case 'rename-local': return this.#runRenameLocal(job);
|
|
639
899
|
case 'conflict': return this.#runConflict(job);
|
|
640
900
|
case 'revert': return this.#runRevert(job);
|
|
@@ -711,7 +971,7 @@ export default class Mirror extends EventEmitter {
|
|
|
711
971
|
case 'nothing': return;
|
|
712
972
|
case 'adopt': this.#reconcileKey(key); return;
|
|
713
973
|
case 'push': this.#reconcileKey(key); return;
|
|
714
|
-
case 'delete-remote': this.#reconcileKey(key); return;
|
|
974
|
+
case 'delete-remote': this.#reconcileKey(key); this.#settleDeletes(); return;
|
|
715
975
|
case 'pull': this.#enqueue('pull', key, { sha256: R }); return;
|
|
716
976
|
case 'trash-local': this.#enqueue('trash-local', key, { sha256: L }); return;
|
|
717
977
|
case 'conflict': this.#enqueue('conflict', key, { local: L, base: B, remote: R }); return;
|
|
@@ -757,6 +1017,12 @@ export default class Mirror extends EventEmitter {
|
|
|
757
1017
|
const { L, B } = this.#local3(key);
|
|
758
1018
|
if (!B) return;
|
|
759
1019
|
if (L) { this.#markDirty(key); return; } // it came back
|
|
1020
|
+
if (this.#isMoving(key)) { this.#markDirty(key); return; }
|
|
1021
|
+
// The index says the file is gone; the folder has the last word. An
|
|
1022
|
+
// unavailable root, or a file that is there after all, sends nothing.
|
|
1023
|
+
const live = await this.#local.verifyRoot?.();
|
|
1024
|
+
if (live && !live.ok) throw Object.assign(new Error(`local folder unavailable (${live.reason}); not deleting ${key} on the hub`), { code: 'LOCAL_UNAVAILABLE' });
|
|
1025
|
+
if (await this.#local.stat(key)) { this.#markDirty(key); return; }
|
|
760
1026
|
let res;
|
|
761
1027
|
try {
|
|
762
1028
|
res = await this.#stored.removeObject(this.#opts.remote, key, { ifMatch: B, origin: this.deviceId || undefined });
|
|
@@ -829,10 +1095,52 @@ export default class Mirror extends EventEmitter {
|
|
|
829
1095
|
throw this.#failure(res, to);
|
|
830
1096
|
}
|
|
831
1097
|
|
|
1098
|
+
async #runRenameRemoteDir(job) {
|
|
1099
|
+
const { from, to, operationId } = job.payload;
|
|
1100
|
+
const settle = () => { this.#movingDirs = this.#movingDirs.filter(([a, b]) => a !== from || b !== to); };
|
|
1101
|
+
const touch = (prefix) => {
|
|
1102
|
+
for (const name of [this.#opts.local, this.#opts.remote]) {
|
|
1103
|
+
for (const o of this.#stored.listObjects(name, { prefix, limit: Number.MAX_SAFE_INTEGER }).objects) this.#markDirty(o.key);
|
|
1104
|
+
}
|
|
1105
|
+
for (const [key] of this.#ledger.bases(prefix)) this.#markDirty(key);
|
|
1106
|
+
};
|
|
1107
|
+
let res;
|
|
1108
|
+
try {
|
|
1109
|
+
res = await this.#stored.renameDirectory(this.#opts.remote, from, to, { operationId, origin: this.deviceId || undefined });
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
if (this.#isOffline(err) || err.code === 'RETRYABLE' || err.code === 'UNAUTHORIZED') throw err;
|
|
1112
|
+
res = { ok: false, reason: err.hubCode || err.code || 'refused', error: err.message };
|
|
1113
|
+
}
|
|
1114
|
+
if (res.ok) {
|
|
1115
|
+
// The poll brings the hub's row versions for the moved files.
|
|
1116
|
+
await this.#remote.poll().catch(() => {});
|
|
1117
|
+
const seq = res.seq ?? this.#remote.head ?? 0;
|
|
1118
|
+
const moved = this.#ledger.moveBases(from, to, (key) => ({ remoteSeq: seq, ...(this.#remote.describe?.(key) || {}) }));
|
|
1119
|
+
for (const key of moved) {
|
|
1120
|
+
const base = this.#ledger.getBase(key);
|
|
1121
|
+
if (base?.docId && base?.version) this.#applied.set(base.docId, base.version);
|
|
1122
|
+
}
|
|
1123
|
+
settle();
|
|
1124
|
+
debug(`renamed ${from}/ → ${to}/ on the hub (${moved.length} files, one request)`);
|
|
1125
|
+
touch(`${from}/`); touch(`${to}/`);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
// The hub cannot take the folder as a whole (it changed there, the
|
|
1129
|
+
// target exists, or the hub is older): rename file by file.
|
|
1130
|
+
debug(`${from}/ → ${to}/: directory rename refused (${res.reason}); renaming per file`);
|
|
1131
|
+
settle();
|
|
1132
|
+
for (const [key] of [...this.#ledger.bases(`${from}/`)]) {
|
|
1133
|
+
const target = `${to}/${key.slice(from.length + 1)}`;
|
|
1134
|
+
if (!this.#stored.index.get(`${this.#opts.local}:${key}`) && this.#stored.index.get(`${this.#opts.local}:${target}`) && !this.#ledger.getBase(target)) this.#renameFile(key, target);
|
|
1135
|
+
else this.#markDirty(key);
|
|
1136
|
+
}
|
|
1137
|
+
touch(`${from}/`); touch(`${to}/`);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
832
1140
|
async #runRenameLocal(job) {
|
|
833
1141
|
const { from, to } = job.payload;
|
|
834
1142
|
const src = this.#local3(from);
|
|
835
|
-
if (!src.L || src.
|
|
1143
|
+
if (!src.L || !src.B || this.#stored.index.get(`${this.#opts.local}:${to}`)) {
|
|
836
1144
|
this.#markDirty(from); this.#markDirty(to); return;
|
|
837
1145
|
}
|
|
838
1146
|
const res = await this.#stored.move(`${this.#opts.local}:${from}`, {
|
|
@@ -846,6 +1154,7 @@ export default class Mirror extends EventEmitter {
|
|
|
846
1154
|
const moved = this.#ledger.moveBase(from, to, { remoteSeq: this.#remote.head ?? 0, ...(this.#remote.describe?.(to) || {}) });
|
|
847
1155
|
if (moved?.docId && moved?.version) this.#applied.set(moved.docId, moved.version);
|
|
848
1156
|
debug(`renamed ${from} → ${to} locally (hub rename)`);
|
|
1157
|
+
if (src.L !== src.B) this.#markDirty(to);
|
|
849
1158
|
return;
|
|
850
1159
|
}
|
|
851
1160
|
if (['not-found', 'no-source', 'target-exists'].includes(res.reason)) { this.#markDirty(from); this.#markDirty(to); return; }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Physical replica executor
|
|
2
|
+
|
|
3
|
+
`stored.replicas` owns the durable physical workflow. Callers supply a complete,
|
|
4
|
+
ordered destination set for an opaque owner; no workspace or virtual-tree types
|
|
5
|
+
are imported here. The current worker is in process. On Linux, an OS-held
|
|
6
|
+
exclusive lock refuses a second executor and releases on process death, including
|
|
7
|
+
container restarts that reuse PIDs. `util-linux` (`flock`) is required; the server
|
|
8
|
+
image installs it. The lock file must stay on the workspace's local state volume.
|
|
9
|
+
Other platforms use a conservative PID lease and are outside this pilot contract.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
stored.replicas.submit({
|
|
13
|
+
id: 'unique-request-id',
|
|
14
|
+
owner: 'opaque-logical-file-id',
|
|
15
|
+
contentId: 'sha256:<64 lowercase hex digits>',
|
|
16
|
+
source: { backend: 'data', key: 'existing-staging-key' },
|
|
17
|
+
replicas: [
|
|
18
|
+
{ backend: 'home', key: 'Accounting/2026/invoice.pdf' },
|
|
19
|
+
{ backend: 'backup', key: 'Accounting/2026/invoice.pdf' },
|
|
20
|
+
],
|
|
21
|
+
release: [{ backend: 'data', key: 'existing-staging-key' }],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Identical request IDs return the original receipt. Reusing an ID with different
|
|
26
|
+
input fails. A plan is immutable. `supersede(id, nextPlan)` accepts a newer byte
|
|
27
|
+
version of the same owner, preserving verified partial ownership and keeping old
|
|
28
|
+
pins until the successor completes. It never resolves filename conflicts by
|
|
29
|
+
guessing. Each outcome persists its selected name and
|
|
30
|
+
expected byte version before IO. Placement is create-only by default; managed
|
|
31
|
+
mirror updates can replace their recorded old version. Explicit overwrite is
|
|
32
|
+
accepted only with recovery support. A changed destination blocks the operation.
|
|
33
|
+
|
|
34
|
+
All mutations share one reentrant executor with the public Stored API. Unfinished
|
|
35
|
+
operations serialize by owner; one blocked owner does not stop unrelated work.
|
|
36
|
+
Transient failures retry with backoff, permanent conflicts wait for `retry(id)`.
|
|
37
|
+
`list({ active: true })` lists pending/blocked operations without completed history
|
|
38
|
+
hiding current work. `owner(id)` exposes verified exact-key ownership and content
|
|
39
|
+
versions. `audit(id)` verifies actual physical bytes rather than index presence.
|
|
40
|
+
|
|
41
|
+
The phase order is immutable staging pin, place and verify all destinations,
|
|
42
|
+
retire obsolete owned copies, verify all destinations again, release blob
|
|
43
|
+
locations, commit ownership and completion, release operation pins. Crash replay
|
|
44
|
+
uses the same staged content, names and preconditions. Native file quarantine and
|
|
45
|
+
retained versions use independent inodes; mutable destinations never hardlink
|
|
46
|
+
immutable cache pins. Deletes/overwrites capture the actual old inode so an
|
|
47
|
+
external writer holding it open does not lose its bytes.
|
|
48
|
+
|
|
49
|
+
Only file backends currently implement automatic recoverable retirement and
|
|
50
|
+
overwrite. Remote drivers must supply equivalent conditional-write and recovery
|
|
51
|
+
semantics before those capabilities are enabled. Copy-only remote creates use
|
|
52
|
+
the same executor through the SyncQueue compatibility adapter. Unsupported
|
|
53
|
+
operations fail before destructive execution.
|
|
54
|
+
|
|
55
|
+
Cache verification reclaims unreferenced content after key removal, serialized
|
|
56
|
+
against cache streams. Outstanding operation pins remain until completion.
|
|
57
|
+
Managed API edits pin local ingress before publishing the writable file. With
|
|
58
|
+
`replication.captureAllWrites`, new API files are pinned too; the producer must
|
|
59
|
+
either resolve their complete replica plan or call `finishUnmirroredIngress`
|
|
60
|
+
after deciding that the requested primary is their only destination. Ingress
|
|
61
|
+
and operation pins are separate, so rapid edits cannot mutate accepted bytes.
|
|
62
|
+
Completed receipts are retained for idempotency; metadata history compaction is
|
|
63
|
+
not implemented. A backend file changed by an independent process is observed,
|
|
64
|
+
never silently interpreted as permission to delete copies elsewhere.
|