@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.
Files changed (34) hide show
  1. package/README.md +5 -0
  2. package/node_modules/canvas-stored/README.md +189 -9
  3. package/node_modules/canvas-stored/package.json +2 -2
  4. package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
  5. package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
  6. package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
  7. package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
  8. package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
  9. package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
  10. package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
  11. package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
  12. package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
  13. package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
  14. package/node_modules/canvas-stored/src/cache/index.js +39 -5
  15. package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
  16. package/node_modules/canvas-stored/src/index/index.js +5 -3
  17. package/node_modules/canvas-stored/src/index.js +733 -132
  18. package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
  19. package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
  20. package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
  21. package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
  22. package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
  23. package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
  24. package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
  25. package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
  26. package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
  27. package/node_modules/canvas-stored/src/trash.js +219 -0
  28. package/node_modules/canvas-stored/src/utils/common.js +7 -0
  29. package/package.json +5 -4
  30. package/src/binding.js +33 -0
  31. package/src/control.js +13 -0
  32. package/src/daemon.js +14 -1
  33. package/src/env.js +1 -1
  34. 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: 'prompt',
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 = 'prompt';
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
- if (this.#debounceTimer) return;
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
- }, this.#opts.debounceMs);
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 === 'propagate') this.#enqueue('delete-remote', key, { ifMatch: d.ifMatch });
481
- else this.#ledger.removeBase(key);
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`: 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.
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 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;
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: 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.
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 && L === B && !this.#stored.index.get(`${this.#opts.local}:${to}`) && this.#inScope(from) && this.#inScope(to)) {
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.L !== src.B || this.#stored.index.get(`${this.#opts.local}:${to}`)) {
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.