@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,26 +1,44 @@
1
1
  import EventEmitter2 from 'eventemitter2';
2
2
  import path from 'path';
3
3
  import crypto from 'crypto';
4
+ import MutationQueue from './runtime/MutationQueue.js';
4
5
  import { createReadStream, createWriteStream, promises as fsp } from 'fs';
5
6
  import { once } from 'events';
6
7
  import { Readable } from 'stream';
7
8
  import { pipeline } from 'stream/promises';
9
+ import { setImmediate as yieldToIO } from 'node:timers/promises';
10
+ import { isDeepStrictEqual } from 'node:util';
8
11
  import Debug from 'debug';
9
12
  import Cache from './cache/index.js';
10
13
  import BackendManager from './backends/BackendManager.js';
14
+ export { getBackendCapabilities } from './backends/BackendManager.js';
11
15
  import Index from './index/index.js';
16
+ import { prepareIndex } from './storage/recover-index.js';
12
17
  import SyncQueue from './sync/SyncQueue.js';
13
18
  import JobQueue from './sync/JobQueue.js';
14
19
  import Ledger from './sync/Ledger.js';
15
20
  import Mirror from './sync/Mirror.js';
16
- import { isBuffer, isFile, isStream, resolveStoredPaths } from './utils/common.js';
17
- import { checksumBuffer, formatId } from './utils/checksum.js';
21
+ import BackendTrash from './trash.js';
22
+ import ReplicaService from './sync/ReplicaService.js';
23
+ import { isBuffer, isFileAsync, isStream, resolveStoredPaths } from './utils/common.js';
24
+ import { checksumBuffer, checksumStream, formatId } from './utils/checksum.js';
18
25
  import { detectMimeType, detectMimeFromHead } from './utils/mime.js';
19
26
 
20
27
  const debug = Debug('stored');
28
+ // Source retirement belongs to the final move event, never a source-loss unlink.
29
+ const MOVE_SOURCE = Symbol('move-source');
21
30
 
22
31
  const HEAD_BYTES = 4096;
23
32
 
33
+ async function readHead(file) {
34
+ const handle = await fsp.open(file, 'r');
35
+ try {
36
+ const buffer = Buffer.alloc(HEAD_BYTES);
37
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
38
+ return buffer.subarray(0, bytesRead);
39
+ } finally { await handle.close(); }
40
+ }
41
+
24
42
  // A filesystem move reaches the watcher as unlink(old) + add(new). Unlink
25
43
  // processing is held back this long so an add carrying the same inode can
26
44
  // claim it as a rename (in-place URL rewrite, no orphan churn). Expired
@@ -36,6 +54,29 @@ const RENAME_WINDOW_MS = 1000;
36
54
  const MOVE_SUPPRESS_MS = 3000;
37
55
 
38
56
  export default class Stored extends EventEmitter2 {
57
+ /** Safe startup for persistent indexes. The sync constructor remains for
58
+ * callers that already verified their environment (or create a new one).
59
+ */
60
+ static async open(config = {}) {
61
+ const databasePath = resolveStoredPaths(config).index;
62
+ config.onStartupStage?.({ stage: 'stored.index.verify', databasePath });
63
+ const recovery = await prepareIndex(databasePath);
64
+ if (recovery) {
65
+ config.onStartupStage?.({ stage: 'stored.index.quarantined', ...recovery });
66
+ console.warn(`Stored index verification failed; preserved at ${recovery.preservedIndexPath}. Opening an empty index; rescan backends to rebuild. Operational records remain in quarantine. Reason: ${recovery.reason}`);
67
+ }
68
+ config.onStartupStage?.({ stage: 'stored.index.open', databasePath });
69
+ return new this(config);
70
+ }
71
+
72
+ supportsScopedFolderDeletion = true;
73
+ #mutations;
74
+ #closing = false;
75
+ #stopPromise = null;
76
+
77
+ withStorageMutation(action, operation) { return this.#mutations.run(action, operation); }
78
+ detachedStorageWork(action) { return this.#mutations.detach(action); }
79
+ get mutationDiagnostics() { return this.#mutations.diagnostics; }
39
80
  #cache;
40
81
  #backends;
41
82
  #index;
@@ -55,11 +96,24 @@ export default class Stored extends EventEmitter2 {
55
96
  #pendingMoves = new Map();
56
97
  #retention = null;
57
98
  #retainedDb = null;
99
+ #retentionTasks = new Map();
100
+ #trash;
58
101
  #sweepTimer = null;
102
+ #directoryMovesDb = null;
103
+ #directoryMoveTasks = new Map();
104
+ #replicas;
59
105
 
60
106
  constructor(config = {}) {
61
107
  // Wildcards (':' delimiter) let consumers bind `object:*` across backends.
62
108
  super({ wildcard: true, delimiter: ':', maxListeners: 100, verboseMemoryLeak: false });
109
+ this.#mutations = new MutationQueue({ onStall: config.onStall || (state => debug('Mutation still pending: %o', state)) });
110
+ // API/FUSE mutations and replication share one physical executor.
111
+ // Reentrant calls (rename -> move, replica -> write) keep the lock.
112
+ for (const name of ['put', 'copy', 'move', 'writeObject', 'removeObject', 'removeToTrash', 'renameObject', 'renameDirectory', 'trashDirectory', 'removeDirectory',
113
+ 'ensureDirectory', 'removeEmptyDirectory', 'restoreTrash', 'discardTrash', 'restoreRetained', 'deleteByUrl', 'delete']) {
114
+ const method = this[name].bind(this);
115
+ this[name] = (...args) => this.withStorageMutation(() => method(...args), name);
116
+ }
63
117
  this.#paths = resolveStoredPaths(config);
64
118
  this.#config = {
65
119
  checksums: config.checksums || ['sha256'],
@@ -79,16 +133,19 @@ export default class Stored extends EventEmitter2 {
79
133
  // entry, after the transaction that produced them committed).
80
134
  this.#index = new Index(this.#paths.index, {
81
135
  changes: config.changes,
82
- onChange: (entries) => { for (const entry of entries) this.emit('change', entry); },
136
+ onChange: (entries) => this.detachedStorageWork(() => { for (const entry of entries) this.emit('change', entry); }),
83
137
  });
84
138
 
85
- // Background sync queue for remote backends: file targets are copied by
86
- // a worker thread, network drivers (gdrive, …) are committed in-process
87
- // through their live backend (async I/O — nothing to offload, and the
88
- // driver's credentials/path cache stay on the main thread).
89
- this.#syncQueue = new SyncQueue({ resolveBackend: (name) => this.#backends.get(name) });
139
+ this.#trash = new BackendTrash(this);
140
+
141
+ try { this.#replicas = new ReplicaService(this, config.replication || {}); }
142
+ catch (error) { this.#index.close(); void this.#cache.stop(); throw error; }
143
+ this.#replicas.on('changed', operation => this.detachedStorageWork(() => this.emit('replica:changed', operation)));
144
+ this.#replicas.on('fault', error => this.emit('error', error));
145
+ this.#syncQueue = new SyncQueue({ service: this.#replicas });
90
146
  this.#syncQueue.on('synced', ({ id, results }) => this.#handleSyncResult(id, results));
91
- this.#syncQueue.on('error', (err) => this.emit('error', err));
147
+ this.#syncQueue.on('error', error => this.emit('error', error));
148
+ this.#pendingMoves = this.#index.openDB('pending-moves.v1');
92
149
 
93
150
  debug('Stored initialized');
94
151
  }
@@ -103,6 +160,36 @@ export default class Stored extends EventEmitter2 {
103
160
  get cache() { return this.#cache; }
104
161
  get index() { return this.#index; }
105
162
  get backends() { return this.#backends; }
163
+ get replicas() { return this.#replicas; }
164
+
165
+ // Called only after the executor has verified the exact target bytes.
166
+ async acknowledgeReplica(backendName, key, id, origin) {
167
+ const meta = this.#index.get(id);
168
+ if (!meta) throw new Error('Replica content is not indexed');
169
+ const backend = this.#backends.get(backendName);
170
+ const stat = await backend.stat(key);
171
+ if (!stat) throw new Error('Verified replica disappeared');
172
+ const location = this.#buildLocation(backendName, key, true, {
173
+ size: stat.size, mtime: stat.modified,
174
+ ...(stat.ino != null ? { ino: stat.ino, dev: stat.dev } : {}),
175
+ });
176
+ const saved = this.#index.put(id, { ...meta, locations: this.#mergeLocations(meta, [location]) }, { origin });
177
+ this.#emitLocationEvent('location:add', saved, location, { origin });
178
+ }
179
+
180
+ async ensureDirectory(backendName, key) {
181
+ const backend = this.#backends.get(backendName);
182
+ if (!this.#replicas.capabilities(backendName).directories || !this.#isSafeKey(key)) throw new Error('Safe directory creation is unsupported');
183
+ if (!(await this.#verifyBackendRoot(backend)).ok) throw Object.assign(new Error('Backend is offline'), { code: 'BACKEND_UNAVAILABLE', retryable: true });
184
+ return backend.createContainer(key);
185
+ }
186
+
187
+ async removeEmptyDirectory(backendName, key) {
188
+ const backend = this.#backends.get(backendName);
189
+ if (!this.#replicas.capabilities(backendName).directories || !this.#isSafeKey(key) || typeof backend.removeEmptyContainer !== 'function') throw new Error('Safe directory removal is unsupported');
190
+ if (!(await this.#verifyBackendRoot(backend)).ok) throw Object.assign(new Error('Backend is offline'), { code: 'BACKEND_UNAVAILABLE', retryable: true });
191
+ return backend.removeEmptyContainer(key);
192
+ }
106
193
 
107
194
  // ─────────────────────────────────────────────────────────────────────────
108
195
  // Backend Management
@@ -124,7 +211,13 @@ export default class Stored extends EventEmitter2 {
124
211
  backend.on('scan:complete', e => this.emit('scan:complete', e));
125
212
  backend.on('error', e => this.emit('error', e));
126
213
 
127
- if (config.watch) backend.watch();
214
+ backend.isDirectoryProtected = key => this.#replicas.owners().some(owner => owner.replicas.some(r =>
215
+ r.kind === 'directory' && r.backend === name && (r.key === key || r.key.startsWith(`${key}/`))));
216
+ if (config.watch) backend.watch().catch(error => {
217
+ debug('Backend %s watcher startup failed: %s', name, error.message);
218
+ this.emit('backend:state', { backend: name, watching: false, error: error.message });
219
+ if (this.listenerCount('error')) this.emit('error', error);
220
+ });
128
221
  return backend;
129
222
  }
130
223
 
@@ -139,6 +232,7 @@ export default class Stored extends EventEmitter2 {
139
232
 
140
233
  listBackends() { return this.#backends.list(); }
141
234
  getBackend(name) { return this.#backends.get(name); }
235
+ getBackendCapabilities(name) { return this.#backends.get(name)?.capabilities || null; }
142
236
 
143
237
  /**
144
238
  * Parse a canonical `stored://<backend>/<key>` URL. The backend name may
@@ -203,8 +297,10 @@ export default class Stored extends EventEmitter2 {
203
297
  const backend = this.#backends.get(p.backend);
204
298
  if (!backend) return { ok: false, reason: 'unknown-backend' };
205
299
  if (!backend.canDelete) return { ok: false, reason: 'read-only-backend' };
206
- await this.#retain(p.backend, p.key, this.#index.get(`${p.backend}:${p.key}`));
300
+ const retained = await this.#retain(p.backend, p.key, this.#index.get(`${p.backend}:${p.key}`), 'delete');
301
+ const trashed = retained?.trashItem;
207
302
  const deleted = !!(await backend.delete(p.key));
303
+ if (deleted) this.#trash.finish(trashed);
208
304
  if (deleted) this.#dropLocation(p.backend, p.key);
209
305
  return { ok: deleted };
210
306
  }
@@ -235,6 +331,11 @@ export default class Stored extends EventEmitter2 {
235
331
  * Store a blob. Accepts Buffer | string (content) | filesystem path |
236
332
  * Readable. Paths and streams are ingested without ever materializing the
237
333
  * whole blob in memory. `backends` is required (no implicit fan-out).
334
+ * `adopt: true` with a path hands the file over (writeObject takes the
335
+ * same option): a blob-store target moves
336
+ * it in rather than copying it, and the file is gone afterwards. With
337
+ * `checksums` (hex per configured algorithm, computed by the caller as the
338
+ * bytes arrived) the file is not read again to hash it.
238
339
  * Returns { ok:true, id, key, size, mimeType, checksums, locations, ... }
239
340
  * or { ok:false, reason:'no-targets'|'unknown-backend' }.
240
341
  */
@@ -248,18 +349,22 @@ export default class Stored extends EventEmitter2 {
248
349
  if (!targets.length) return { ok: false, reason: 'unknown-backend' };
249
350
 
250
351
  const ingestOptions = { mtime: options.mtime ?? null };
251
- const ingest = (isBuffer(blob) || (typeof blob === 'string' && !isFile(blob)))
352
+ const ingest = (isBuffer(blob) || (typeof blob === 'string' && !await isFileAsync(blob)))
252
353
  ? await this.#ingestMemory(blob, targets, key, options.mimeType, ingestOptions)
253
- : await this.#ingestStream(blob, targets, key, options.mimeType, ingestOptions);
354
+ : (options.adopt && typeof blob === 'string')
355
+ ? await this.#ingestAdopted(blob, targets, key, options.mimeType, { ...ingestOptions, checksums: options.checksums })
356
+ : await this.#ingestStream(blob, targets, key, options.mimeType, ingestOptions);
254
357
 
255
358
  // Caller-supplied metadata + inline-extracted (EXIF/GPS/dimensions/media)
256
359
  // → persisted under `custom`, returned to caller + on the 'put' event.
257
360
  const extracted = ingest.extracted && Object.keys(ingest.extracted).length ? ingest.extracted : null;
258
- const meta = this.#index.put(ingest.id, {
361
+ let meta;
362
+ this.#index.transaction(() => {
363
+ meta = this.#index.put(ingest.id, {
259
364
  checksums: ingest.checksums,
260
365
  size: ingest.size,
261
366
  mimeType: ingest.mimeType,
262
- locations: ingest.locations,
367
+ locations: this.#mergeLocations(this.#index.get(ingest.id) || {}, ingest.locations),
263
368
  custom: extracted ? { ...metadata, ...extracted } : metadata,
264
369
  }, { origin: options.origin });
265
370
 
@@ -267,6 +372,8 @@ export default class Stored extends EventEmitter2 {
267
372
  this.#syncQueue.enqueue({ id: ingest.id, cacheRoot: this.#cache.root, cacheKey: ingest.id, targets: ingest.remoteTargets });
268
373
  }
269
374
 
375
+ });
376
+
270
377
  this.emit('put', { id: ingest.id, key: ingest.finalKey, metadata: meta });
271
378
  debug(`PUT ${ingest.id.slice(0, 19)}... → ${targets.map(t => t.name).join(', ')}`);
272
379
  return { ok: true, key: ingest.finalKey, ...meta };
@@ -293,8 +400,10 @@ export default class Stored extends EventEmitter2 {
293
400
  const removed = new Set();
294
401
  for (const loc of targets) {
295
402
  const backend = this.#backends.get(loc.backend);
296
- if (backend) await this.#retain(loc.backend, loc.key, meta);
403
+ const retained = backend && await this.#retain(loc.backend, loc.key, meta, 'delete');
404
+ const trashed = retained?.trashItem;
297
405
  if (backend && await backend.delete(loc.key)) {
406
+ this.#trash.finish(trashed);
298
407
  deleted.push(loc.backend);
299
408
  removed.add(loc);
300
409
  }
@@ -366,7 +475,9 @@ export default class Stored extends EventEmitter2 {
366
475
  const backend = this.#backends.get(backendName);
367
476
  if (!backend) return { ok: false, reason: 'unknown-backend', backend: backendName };
368
477
  if (!backend.canWrite) return { ok: false, reason: 'read-only-target', backend: backendName };
369
- if (backend.type !== 'local' || typeof backend.commit !== 'function') {
478
+ const remoteCreate = backend.type === 'remote' && typeof backend.createFrom === 'function'
479
+ && String(options.ifNoneMatch || '').trim() === '*';
480
+ if ((!remoteCreate && backend.type !== 'local') || typeof backend.commit !== 'function') {
370
481
  return { ok: false, reason: 'unsupported-backend', backend: backendName };
371
482
  }
372
483
  const destKey = this.#normalizeKey(key, { nfc: true });
@@ -375,22 +486,44 @@ export default class Stored extends EventEmitter2 {
375
486
  if (!live.ok) return { ok: false, reason: 'target-offline', backend: backendName, detail: live.reason };
376
487
 
377
488
  const pathKey = `${backendName}:${destKey}`;
489
+ // `adopt`: the source path is a finished file the caller hands over
490
+ // (a resumable upload's part). It is placed itself, not copied, and
491
+ // stays where it is if the write does not happen.
492
+ const adopted = options.adopt && typeof source === 'string' && await isFileAsync(source);
378
493
  const tempDir = backend.tempDir || path.join(this.#paths.cache, '.tmp');
379
- const tempPath = path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
380
- await fsp.mkdir(tempDir, { recursive: true });
494
+ const tempPath = adopted ? source : path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
495
+ if (!adopted) await fsp.mkdir(tempDir, { recursive: true });
496
+ let ingress = null;
497
+ let accepted = false;
381
498
 
382
499
  try {
383
- const stream = isBuffer(source) ? Readable.from([source])
384
- : (typeof source === 'string' && !isFile(source)) ? Readable.from([Buffer.from(source)])
385
- : (isStream(source) ? source : createReadStream(source));
386
- const { checksums, size, head } = await this.#hashToFile(stream, tempPath);
500
+ const known = adopted && options.checksums && this.#config.checksums.every(algo => /^[0-9a-f]+$/.test(options.checksums[algo] || ''));
501
+ const { checksums, size, head } = known
502
+ ? { checksums: Object.fromEntries(this.#config.checksums.map(algo => [algo, options.checksums[algo]])), size: (await fsp.stat(tempPath)).size, head: await readHead(tempPath) }
503
+ : adopted
504
+ ? await this.#hashFile(tempPath)
505
+ : await this.#hashToFile(isBuffer(source) ? Readable.from([source])
506
+ : (typeof source === 'string' && !await isFileAsync(source)) ? Readable.from([Buffer.from(source)])
507
+ : (isStream(source) ? source : createReadStream(source)), tempPath);
387
508
  const id = formatId(checksums, this.#config.primaryChecksum);
388
509
  const sha256 = checksums.sha256 ?? null;
389
510
  if (options.sha256 && sha256 && String(options.sha256).toLowerCase() !== sha256) {
390
511
  return { ok: false, reason: 'checksum-mismatch', expected: String(options.sha256).toLowerCase(), actual: sha256 };
391
512
  }
392
513
 
393
- const current = this.#index.get(pathKey);
514
+ const indexed = this.#index.get(pathKey);
515
+ const physical = await backend.stat(destKey);
516
+ let current = physical && indexed?.locations?.some(l => l.backend === backendName && l.key === destKey && l.synced !== false) ? indexed : null;
517
+ if (physical && backend.config.driver === 'file') {
518
+ // A watcher/index can lag behind an external writer. Check
519
+ // actual bytes before preconditions or the unchanged shortcut,
520
+ // and index previously unknown files so overwrites retain them.
521
+ const input = await backend.get(destKey, { stream: true });
522
+ const actual = await checksumStream(Buffer.isBuffer(input) ? Readable.from([input]) : input);
523
+ const after = await backend.stat(destKey);
524
+ if (!after || ['size', 'modified', 'ino', 'dev'].some(k => physical[k] !== after[k])) return { ok: false, reason: 'source-changed' };
525
+ if (!current || current.checksums?.sha256 !== actual.sha256) current = await this.refreshObject(backendName, destKey);
526
+ }
394
527
  const currentLoc = current?.locations?.find(l => l.backend === backendName && l.key === destKey) || null;
395
528
  const failed = this.#checkPrecondition(current, currentLoc, options);
396
529
  if (failed) return failed;
@@ -413,13 +546,32 @@ export default class Stored extends EventEmitter2 {
413
546
  }
414
547
 
415
548
  const mimeType = options.mimeType || await detectMimeFromHead(head, destKey);
549
+ if (!String(options.origin || '').startsWith('replica:')) ingress = await this.#replicas.captureIngress(backendName, destKey, id, tempPath);
416
550
  const release = this.#holdKeys([pathKey]);
417
551
  try {
418
- if (current) await this.#retain(backendName, destKey, current);
552
+ const retained = current ? await this.#retain(backendName, destKey, current) : null;
419
553
  let placed = null;
420
- if (String(options.ifNoneMatch || '').trim() === '*') {
554
+ if (current && retained && typeof backend.trashFile === 'function') {
555
+ // Capture the actual inode before publishing. A writer
556
+ // outside Stored can race a precondition/retention read;
557
+ // its bytes must survive even if it keeps the inode open.
558
+ const captured = this.#trash.prepare(backendName, destKey, { retained, reason: 'overwrite' });
559
+ this.#trash.quarantine(captured);
560
+ const moved = await backend.trashFile(destKey, captured.id);
561
+ this.#trash.finish(captured);
562
+ if (moved.checksums.sha256 !== current.checksums?.sha256) {
563
+ await this.#trash.restore(backendName, captured.id).catch(() => {});
564
+ return { ok: false, reason: 'source-changed', trashId: captured.id };
565
+ }
566
+ try { placed = await backend.createFrom(destKey, tempPath, { owned: true }); }
567
+ catch (error) {
568
+ await this.#trash.restore(backendName, captured.id).catch(() => {});
569
+ if (error.code === 'EEXIST') return { ok: false, reason: 'precondition-failed', trashId: captured.id };
570
+ throw error;
571
+ }
572
+ } else if (String(options.ifNoneMatch || '').trim() === '*') {
421
573
  if (typeof backend.createFrom !== 'function') return { ok: false, reason: 'unsupported-backend' };
422
- try { placed = await backend.createFrom(destKey, tempPath); }
574
+ try { placed = await backend.createFrom(destKey, tempPath, { owned: true }); }
423
575
  catch (error) {
424
576
  if (error.code === 'EEXIST') return { ok: false, reason: 'precondition-failed', current: null };
425
577
  throw error;
@@ -453,6 +605,8 @@ export default class Stored extends EventEmitter2 {
453
605
  const saved = this.#index.get(id);
454
606
  const loc = saved?.locations?.find(l => l.backend === backendName && l.key === destKey) || null;
455
607
  debug(`WRITE ${pathKey} ← ${id.slice(0, 19)}...${current ? ` (replaces ${current.id.slice(0, 19)}...)` : ''}`);
608
+ this.#replicas.commitIngress(ingress);
609
+ accepted = true;
456
610
  return {
457
611
  ok: true,
458
612
  id,
@@ -468,7 +622,8 @@ export default class Stored extends EventEmitter2 {
468
622
  release();
469
623
  }
470
624
  } finally {
471
- await fsp.rm(tempPath, { force: true }).catch(() => {});
625
+ if (!accepted) await this.#replicas.discardIngress(ingress);
626
+ if (accepted || !adopted) await fsp.rm(tempPath, { force: true }).catch(() => {});
472
627
  }
473
628
  }
474
629
 
@@ -491,36 +646,93 @@ export default class Stored extends EventEmitter2 {
491
646
  return this.#retainedDb;
492
647
  }
493
648
 
494
- async #retain(backendName, key, meta) {
649
+ async #retain(backendName, key, meta, reason = 'version') {
495
650
  if (!this.#retention || !meta) return null;
496
651
  const backend = this.#backends.get(backendName);
497
652
  if (!backend || backend.type !== 'local' || typeof backend.retain !== 'function') return null;
498
653
  const sha256 = meta.checksums?.sha256 ? String(meta.checksums.sha256).toLowerCase() : null;
499
654
  if (!sha256) return null;
500
- try {
501
- const kept = await backend.retain(key, sha256);
502
- if (!kept) return null;
503
- const now = Date.now();
504
- const prev = this.#retained.get(sha256) || null;
505
- const keys = [...new Set([...(prev?.keys || []), `${backendName}:${key}`])].slice(-20);
506
- const entry = {
507
- sha256,
508
- backend: prev?.backend || backendName,
509
- size: kept.size ?? prev?.size ?? meta.size ?? null,
510
- mimeType: meta.mimeType ?? prev?.mimeType ?? null,
511
- id: meta.id ?? prev?.id ?? null,
512
- keys,
513
- firstAt: prev?.firstAt ?? now,
514
- lastAt: now,
515
- };
516
- this.#retained.putSync(sha256, entry);
517
- this.#scheduleSweep();
518
- return entry;
519
- } catch (err) {
520
- debug(`retain ${backendName}:${key} failed: ${err.message}`);
521
- this.emit('error', Object.assign(new Error(`retention failed for ${backendName}:${key}: ${err.message}`), { code: 'RETAIN_FAILED' }));
522
- return null;
523
- }
655
+ return this.#withRetainedLock(sha256, async () => {
656
+ try {
657
+ const kept = await backend.retain(key, sha256);
658
+ if (!kept) return null;
659
+ const now = Date.now();
660
+ const prev = this.#retained.get(sha256) || null;
661
+ const keys = [...new Set([...(prev?.keys || []), `${backendName}:${key}`])];
662
+ const entry = {
663
+ sha256,
664
+ backend: prev?.backend || backendName,
665
+ size: kept.size ?? prev?.size ?? meta.size ?? null,
666
+ mimeType: meta.mimeType ?? prev?.mimeType ?? null,
667
+ id: meta.id ?? prev?.id ?? null,
668
+ keys,
669
+ backends: [...new Set([...(prev?.backends || this.#retainedBackendNames(prev)), backendName])],
670
+ versionKeys: [...new Set([...(prev?.versionKeys || []), ...(reason === 'version' ? [`${backendName}:${key}`] : [])])],
671
+ firstAt: prev?.firstAt ?? now,
672
+ lastAt: now,
673
+ trashVersion: 1,
674
+ legacyKeys: prev?.legacyKeys ?? (prev && !prev.trashVersion ? prev.keys : []),
675
+ legacyAt: prev?.legacyAt ?? prev?.lastAt ?? null,
676
+ };
677
+ this.#retained.putSync(sha256, entry);
678
+ this.#scheduleSweep();
679
+ if (reason === 'delete') {
680
+ // Publish the pending deletion while still holding the
681
+ // digest lock: discard must see this new reference even
682
+ // if the physical unlink has not happened yet.
683
+ const mtime = meta.locations?.find(loc => loc.backend === backendName && loc.key === key)?.mtime ?? null;
684
+ return { ...entry, trashItem: this.#trash.prepare(backendName, key, { retained: entry, mtime }) };
685
+ }
686
+ return entry;
687
+ } catch (err) {
688
+ debug(`retain ${backendName}:${key} failed: ${err.message}`);
689
+ // A failed backup must never turn a recoverable delete/overwrite
690
+ // into permanent data loss (for example when the disk is full).
691
+ throw Object.assign(new Error(`retention failed for ${backendName}:${key}: ${err.message}`, { cause: err }), { code: 'RETAIN_FAILED' });
692
+ }
693
+ });
694
+ }
695
+
696
+ #withRetainedLock(sha256, fn) {
697
+ const previous = this.#retentionTasks.get(sha256) || Promise.resolve();
698
+ const task = previous.catch(() => {}).then(fn);
699
+ this.#retentionTasks.set(sha256, task);
700
+ void task.finally(() => {
701
+ if (this.#retentionTasks.get(sha256) === task) this.#retentionTasks.delete(sha256);
702
+ }).catch(() => {});
703
+ return task;
704
+ }
705
+
706
+ #retainedBackendNames(entry) {
707
+ return [...new Set([...(entry?.backends || []), ...(entry?.backend ? [entry.backend] : []),
708
+ ...this.#backends.list().filter(name => entry?.keys?.some(key => key.startsWith(`${name}:`)))])];
709
+ }
710
+
711
+ // Called only for a recorded Trash deletion. Other locations and retained
712
+ // overwrite versions keep their shared bytes. Never touch the original key.
713
+ async discardTrashBytes(item, finish) {
714
+ return this.#withRetainedLock(item.sha256, async () => {
715
+ const entry = this.getRetained(item.sha256);
716
+ if (!entry) { finish(); return; }
717
+ const address = `${item.backend}:${item.key}`;
718
+ const shared = this.#trash.hasReference(item.sha256, item.id, address);
719
+ const keys = (entry.keys || []).filter(key => key !== address || shared || entry.versionKeys?.includes(key));
720
+ const backends = this.#retainedBackendNames(entry);
721
+ if (keys.length || this.#trash.hasReference(item.sha256, item.id)) {
722
+ this.#retained.putSync(item.sha256, { ...entry, keys, backends });
723
+ finish();
724
+ return;
725
+ }
726
+ // Leave metadata intact on failure so the user can retry; drops
727
+ // are idempotent if an earlier attempt removed some copies.
728
+ for (const name of backends) {
729
+ const backend = this.#backends.get(name);
730
+ if (!backend?.dropRetained) throw new Error(`Recovery backend unavailable: ${name}`);
731
+ await backend.dropRetained(item.sha256);
732
+ }
733
+ this.#retained.removeSync(item.sha256);
734
+ finish();
735
+ });
524
736
  }
525
737
 
526
738
  #scheduleSweep() {
@@ -549,13 +761,21 @@ export default class Stored extends EventEmitter2 {
549
761
  }
550
762
 
551
763
  /** Readable stream of retained bytes, or null. */
552
- async retainedStream(sha256) {
764
+ async retainedStream(sha256, preferredBackend = null) {
553
765
  const entry = this.getRetained(sha256);
554
- const backend = entry && this.#backends.get(entry.backend);
555
- if (!backend || typeof backend.retainedPath !== 'function') return null;
556
- const p = backend.retainedPath(entry.sha256);
557
- if (!await fsp.stat(p).catch(() => null)) return null;
558
- return createReadStream(p);
766
+ if (!entry) return null;
767
+ // Equal contents can have retained copies on several backends. A
768
+ // missing first backend must not hide another backend's recovery copy.
769
+ const candidates = this.#backends.list().filter(name => name === entry.backend
770
+ || entry.keys?.some(key => key.startsWith(`${name}:`)));
771
+ if (preferredBackend && candidates.includes(preferredBackend)) candidates.unshift(preferredBackend);
772
+ for (const name of new Set(candidates)) {
773
+ const backend = this.#backends.get(name);
774
+ if (typeof backend.retainedPath !== 'function') continue;
775
+ const p = backend.retainedPath(entry.sha256);
776
+ if (await fsp.stat(p).catch(() => null)) return createReadStream(p);
777
+ }
778
+ return null;
559
779
  }
560
780
 
561
781
  /**
@@ -566,7 +786,7 @@ export default class Stored extends EventEmitter2 {
566
786
  async restoreRetained(sha256, { backend, key, ifMatch = null, ifNoneMatch = null, origin = null, mtime = null } = {}) {
567
787
  const entry = this.getRetained(sha256);
568
788
  if (!entry) return { ok: false, reason: 'not-found', sha256 };
569
- const stream = await this.retainedStream(entry.sha256);
789
+ const stream = await this.retainedStream(entry.sha256, backend);
570
790
  if (!stream) return { ok: false, reason: 'not-found', sha256, detail: 'retained bytes missing on disk' };
571
791
  const target = backend || entry.backend;
572
792
  const targetKey = key || entry.keys?.at(-1)?.slice(target.length + 1) || null;
@@ -582,16 +802,22 @@ export default class Stored extends EventEmitter2 {
582
802
  /** Drop entries (and their bytes) displaced longer than the window ago. Returns `{ swept, kept }`. */
583
803
  async sweepRetained({ olderThanMs = null, now = Date.now() } = {}) {
584
804
  if (!this.#retention) return { swept: 0, kept: 0 };
805
+ await this.#trash.sweep(now);
585
806
  const window = olderThanMs ?? this.#retention.days * 86_400_000;
586
807
  let swept = 0;
587
808
  let kept = 0;
588
- for (const { key: sha, value } of [...this.#retained.getRange()]) {
589
- if (!value?.sha256) continue;
590
- if ((now - (value.lastAt ?? 0)) < window) { kept += 1; continue; }
591
- const backend = this.#backends.get(value.backend);
592
- if (backend && typeof backend.dropRetained === 'function') await backend.dropRetained(value.sha256).catch(() => {});
593
- this.#retained.removeSync(sha);
594
- swept += 1;
809
+ for (const { key: sha } of [...this.#retained.getRange()]) {
810
+ await this.#withRetainedLock(sha, async () => {
811
+ const value = this.#retained.get(sha);
812
+ if (!value?.sha256) return;
813
+ if ((now - (value.lastAt ?? 0)) < window) { kept += 1; return; }
814
+ for (const name of this.#retainedBackendNames(value)) {
815
+ const backend = this.#backends.get(name);
816
+ if (backend?.dropRetained) await backend.dropRetained(value.sha256);
817
+ }
818
+ this.#retained.removeSync(sha);
819
+ swept += 1;
820
+ });
595
821
  }
596
822
  if (swept) debug(`retention sweep: ${swept} dropped, ${kept} kept`);
597
823
  return { swept, kept };
@@ -606,6 +832,70 @@ export default class Stored extends EventEmitter2 {
606
832
  return true;
607
833
  }
608
834
 
835
+ listTrash(backend, options = {}) { return this.#trash.list(backend, options); }
836
+ removeToTrash(backend, key, options = {}) { return this.#trash.remove(backend, key, options); }
837
+ getTrashItem(backend, id) { return this.#trash.get(backend, id); }
838
+ restoreTrash(backend, id, options = {}) { return this.#trash.restore(backend, id, options); }
839
+ discardTrash(backend, id) { return this.#trash.discard(backend, id); }
840
+
841
+ /** Delete a native remote folder and unlink only its indexed descendants.
842
+ * Local folders retain the recoverable, whole-directory Trash operation.
843
+ * The driver owns remote deletion policy (Drive uses its native Trash).
844
+ */
845
+ async removeDirectory(backendName, key, { origin = null } = {}) {
846
+ const backend = this.#backends.get(backendName);
847
+ if (!backend?.canDelete || backend.config.readOnly === true) throw new Error('Backend is read-only or unavailable');
848
+ if (typeof backend.trashContainer === 'function') return this.trashDirectory(backendName, key, { origin });
849
+ if (backend.transport !== 'gdrive') throw new Error('Backend does not support native folder deletion');
850
+ const normalized = this.#normalizeKey(key, { nfc: true });
851
+ if (!normalized || !this.#isSafeKey(normalized)) throw new Error('Invalid folder key');
852
+ this.#replicas.assertIdleEndpoint(backendName, normalized, { directory: true });
853
+ const keys = new Set();
854
+ for (const [, meta] of this.#index.entries()) {
855
+ for (const location of meta.locations || []) {
856
+ if (location.backend === backendName && location.key.startsWith(`${normalized}/`)) keys.add(location.key);
857
+ }
858
+ }
859
+ const release = this.#holdKeys([...keys].map(k => `${backendName}:${k}`));
860
+ try {
861
+ // A missing folder is an idempotent success; a failed remote write
862
+ // must leave every local location intact for retry.
863
+ await backend.deleteContainer(normalized);
864
+ for (const key of keys) this.#processUnlink({ backend: backendName, key, ...(origin ? { origin } : {}) });
865
+ await this.#replicas.abandonUnownedIngress(backendName, normalized, { directory: true });
866
+ return { ok: true, key: normalized, type: 'directory' };
867
+ } finally { release(); }
868
+ }
869
+
870
+ /** Move the whole directory into private trash in one filesystem rename.
871
+ * This preserves unindexed/hidden files, empty folders and timestamps too.
872
+ * No recursive rm is permitted when recoverable deletion is enabled.
873
+ */
874
+ async trashDirectory(backendName, key, { origin = null } = {}) {
875
+ const backend = this.#backends.get(backendName);
876
+ if (!backend?.canDelete || typeof backend.trashContainer !== 'function') throw new Error('Backend does not support recoverable folder deletion');
877
+ const normalized = this.#normalizeKey(key, { nfc: true });
878
+ if (!normalized || !this.#isSafeKey(normalized)) throw new Error('Invalid trash folder key');
879
+ this.#replicas.assertIdleEndpoint(backendName, normalized, { directory: true });
880
+ if (!this.#retention) throw new Error('Enable backend retention before moving folders to Trash');
881
+ const keys = [];
882
+ for (const [, meta] of this.#index.entries()) {
883
+ for (const location of meta.locations || []) {
884
+ if (location.backend === backendName && location.key.startsWith(`${normalized}/`)) keys.push(location.key);
885
+ }
886
+ }
887
+ const item = this.#trash.prepare(backendName, normalized, { directory: true });
888
+ const release = this.#holdKeys(keys.map(k => `${backendName}:${k}`));
889
+ try {
890
+ await backend.trashContainer(normalized, item.id);
891
+ this.#trash.finish(item);
892
+ this.#scheduleSweep();
893
+ for (const key of keys) this.#processUnlink({ backend: backendName, key, ...(origin ? { origin } : {}) });
894
+ await this.#replicas.abandonUnownedIngress(backendName, normalized, { directory: true });
895
+ return { ok: true, id: item.id, key: normalized, type: 'directory' };
896
+ } finally { release(); }
897
+ }
898
+
609
899
  /**
610
900
  * Delete the bytes at `backend:key` (precondition-checked like
611
901
  * writeObject) and process it as the genuine unlink it is: consumers get
@@ -617,6 +907,7 @@ export default class Stored extends EventEmitter2 {
617
907
  if (!backend.canDelete) return { ok: false, reason: 'read-only-backend', backend: backendName };
618
908
  const destKey = this.#normalizeKey(key, { nfc: true });
619
909
  if (!destKey || !this.#isSafeKey(destKey)) return { ok: false, reason: 'invalid-key', key };
910
+ this.#replicas.assertIdleEndpoint(backendName, destKey, { except: String(options.origin || '').startsWith('replica:') ? options.origin.slice(8) : null });
620
911
 
621
912
  const pathKey = `${backendName}:${destKey}`;
622
913
  const current = this.#index.get(pathKey);
@@ -630,9 +921,22 @@ export default class Stored extends EventEmitter2 {
630
921
  // false = bytes already gone; the location still goes. A remote
631
922
  // driver re-evaluates the precondition at the other end (412 →
632
923
  // typed error, nothing is unlinked here).
633
- await this.#retain(backendName, destKey, current);
634
- await backend.delete(destKey, { ifMatch: options.ifMatch, origin: options.origin });
635
- this.#processUnlink({ backend: backendName, key: destKey, ...(options.origin ? { origin: options.origin } : {}) });
924
+ const retained = await this.#retain(backendName, destKey, current, options.trashItem ? 'version' : 'delete');
925
+ const trashed = options.trashItem || retained?.trashItem;
926
+ if (trashed && typeof backend.trashFile === 'function') {
927
+ this.#trash.quarantine(trashed);
928
+ const moved = await backend.trashFile(destKey, trashed.id);
929
+ this.#trash.finish(trashed);
930
+ if (moved.checksums.sha256 !== current.checksums?.sha256) {
931
+ // A filesystem writer won the race. The captured inode is
932
+ // recoverable even if it is still open in that process.
933
+ await this.#trash.restore(backendName, trashed.id).catch(() => {});
934
+ return { ok: false, reason: 'source-changed', trashId: trashed.id };
935
+ }
936
+ } else await backend.delete(destKey, { ifMatch: options.ifMatch, origin: options.origin });
937
+ this.#trash.finish(trashed);
938
+ if (!options[MOVE_SOURCE]) this.#processUnlink({ backend: backendName, key: destKey, ...(options.origin ? { origin: options.origin } : {}) });
939
+ await this.#replicas.abandonUnownedIngress(backendName, destKey);
636
940
  debug(`REMOVE ${pathKey}`);
637
941
  return { ok: true, id: current.id, sha256: current.checksums?.sha256 ?? null, seq: this.#index.head() };
638
942
  } finally {
@@ -640,6 +944,19 @@ export default class Stored extends EventEmitter2 {
640
944
  }
641
945
  }
642
946
 
947
+ async refreshObject(backendName, key, origin = null) {
948
+ const backend = this.#backends.get(backendName);
949
+ const before = await backend.stat(key);
950
+ if (!before) return null;
951
+ const input = await backend.get(key, { stream: true });
952
+ const checksums = await checksumStream(Buffer.isBuffer(input) ? Readable.from([input]) : input);
953
+ const after = await backend.stat(key);
954
+ if (!after || ['size', 'modified', 'ino', 'dev'].some(k => before[k] !== after[k])) throw new Error('File changed during refresh');
955
+ this.#handleFileEvent('file:add', { backend: backendName, key, checksums, ...after, origin,
956
+ path: backend.resolveKeyPath?.(key), mimeType: await detectMimeType(backend.resolveKeyPath(key)) }, { force: true });
957
+ return this.#index.get(`${backendName}:${key}`);
958
+ }
959
+
643
960
  /**
644
961
  * Rename `backend:from` to `backend:to` on the same backend: one
645
962
  * `rename(2)` where the driver supports it, identity and inode intact,
@@ -654,6 +971,8 @@ export default class Stored extends EventEmitter2 {
654
971
  if (!fromKey || !this.#isSafeKey(fromKey)) return { ok: false, reason: 'invalid-key', key: from };
655
972
  if (!toKey || !this.#isSafeKey(toKey)) return { ok: false, reason: 'invalid-key', key: to };
656
973
  if (fromKey === toKey) return { ok: false, reason: 'same-key', key: fromKey };
974
+ this.#replicas.assertIdleEndpoint(backendName, fromKey);
975
+ this.#replicas.assertIdleEndpoint(backendName, toKey);
657
976
 
658
977
  const current = this.#index.get(`${backendName}:${fromKey}`);
659
978
  const currentLoc = current?.locations?.find(l => l.backend === backendName && l.key === fromKey) || null;
@@ -684,6 +1003,133 @@ export default class Stored extends EventEmitter2 {
684
1003
  };
685
1004
  }
686
1005
 
1006
+ /** One filesystem rename, then an atomic re-key of indexed locations.
1007
+ * The durable operation receipt makes a lost HTTP response safe to retry.
1008
+ * No object streams, checksums or per-file filesystem moves are needed.
1009
+ */
1010
+ async renameDirectory(backendName, from, to, { operationId, origin = null } = {}) {
1011
+ const backend = this.#backends.get(backendName);
1012
+ if (!backend) return { ok: false, reason: 'unknown-backend' };
1013
+ if (!backend.canWrite) return { ok: false, reason: 'read-only-target' };
1014
+ const remote = typeof backend.renameDirectory === 'function';
1015
+ if (!remote && (!backend.root || typeof backend.resolveKeyPath !== 'function')) return { ok: false, reason: 'unsupported-backend' };
1016
+ const fromKey = this.#normalizeKey(from, { nfc: true });
1017
+ const toKey = this.#normalizeKey(to, { nfc: true });
1018
+ if (!fromKey || !toKey || !this.#isSafeKey(fromKey) || !this.#isSafeKey(toKey)
1019
+ || backend.isIgnored?.(fromKey) || backend.isIgnored?.(toKey)
1020
+ || fromKey === toKey || toKey.startsWith(`${fromKey}/`) || fromKey.startsWith(`${toKey}/`)
1021
+ || typeof operationId !== 'string' || !operationId || operationId.length > 128) {
1022
+ return { ok: false, reason: 'invalid-key' };
1023
+ }
1024
+ const receiptKey = `${backendName}:${operationId}`;
1025
+ this.#replicas.assertIdleEndpoint(backendName, fromKey, { directory: true });
1026
+ this.#replicas.assertIdleEndpoint(backendName, toKey, { directory: true });
1027
+ if (remote) return this.#renameRemoteDirectory(backend, fromKey, toKey, { operationId, origin });
1028
+ const prior = this.#directoryMoveTasks.get(backendName) || Promise.resolve();
1029
+ const task = prior.catch(() => {}).then(async () => {
1030
+ const db = this.#directoryMovesDb ||= this.#index.openDB('directory-moves');
1031
+ let receipt = db.get(receiptKey);
1032
+ if (receipt && (receipt.from !== fromKey || receipt.to !== toKey)) return { ok: false, reason: 'invalid-key' };
1033
+ const result = () => ({ ok: true, directory: true, from: fromKey, to: toKey, state: 'complete', seq: this.#index.head() });
1034
+ if (receipt?.done) return result();
1035
+ const source = backend.resolveKeyPath(fromKey);
1036
+ const target = backend.resolveKeyPath(toKey);
1037
+ const root = await fsp.realpath(backend.root);
1038
+ const contained = (p) => { const rel = path.relative(root, p); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); };
1039
+ const stat = async (p) => { try { return await fsp.lstat(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
1040
+ const sourceStat = await stat(source);
1041
+ if (!receipt) {
1042
+ if (!sourceStat) return { ok: false, reason: 'not-found' };
1043
+ if (!sourceStat.isDirectory() || !contained(await fsp.realpath(source))) return { ok: false, reason: 'invalid-key' };
1044
+ if (await stat(target)) return { ok: false, reason: 'target-exists' };
1045
+ const rows = this.#index.locationsByBackend(backendName, { prefix: `${fromKey}/`, limit: Number.MAX_SAFE_INTEGER }).objects;
1046
+ if (rows.some(row => backend.isIgnored?.(`${toKey}/${row.key.slice(fromKey.length + 1)}`))) return { ok: false, reason: 'invalid-key' };
1047
+ receipt = { from: fromKey, to: toKey, dev: sourceStat.dev, ino: sourceStat.ino, keys: rows.map(row => row.key) };
1048
+ db.putSync(receiptKey, receipt);
1049
+ }
1050
+ const movedKey = (key) => `${toKey}/${key.slice(fromKey.length + 1)}`;
1051
+ const release = this.#holdKeys(receipt.keys.flatMap(key => [`${backendName}:${key}`, `${backendName}:${movedKey(key)}`]));
1052
+ try {
1053
+ if (sourceStat?.dev === receipt.dev && sourceStat.ino === receipt.ino) {
1054
+ if (!contained(await fsp.realpath(source))) return { ok: false, reason: 'invalid-key' };
1055
+ if (await stat(target)) return { ok: false, reason: 'target-exists' };
1056
+ // Check existing parents before mkdir: a symlink must not create
1057
+ // directories outside this backend, even if the rename fails.
1058
+ let parent = path.dirname(target);
1059
+ while (!await stat(parent)) parent = path.dirname(parent);
1060
+ if (!contained(await fsp.realpath(parent))) return { ok: false, reason: 'invalid-key' };
1061
+ await fsp.mkdir(path.dirname(target), { recursive: true });
1062
+ if (!contained(await fsp.realpath(path.dirname(target)))) return { ok: false, reason: 'invalid-key' };
1063
+ await fsp.rename(source, target); // EXDEV is an error, never a copy fallback.
1064
+ } else {
1065
+ // A recreated source may now have different indexed data.
1066
+ // Leave both trees intact instead of re-keying that data.
1067
+ if (sourceStat) return { ok: false, reason: 'target-exists' };
1068
+ const targetStat = await stat(target);
1069
+ if (targetStat?.dev !== receipt.dev || targetStat.ino !== receipt.ino) return { ok: false, reason: 'target-exists' };
1070
+ }
1071
+ const events = [];
1072
+ this.#index.transaction(() => {
1073
+ for (const key of receipt.keys) {
1074
+ const current = this.#index.get(`${backendName}:${key}`);
1075
+ const old = current?.locations?.find(l => l.backend === backendName && l.key === key);
1076
+ if (!old) continue;
1077
+ const nextKey = movedKey(key);
1078
+ const location = this.#buildLocation(backendName, nextKey, old.synced, { ...old, key: nextKey });
1079
+ const saved = this.#index.put(current.id, {
1080
+ ...current,
1081
+ locations: current.locations.map(l => l === old ? location : l),
1082
+ }, { origin, rename: { backend: backendName, from: key, to: nextKey } });
1083
+ events.push({ id: current.id, checksums: saved.checksums,
1084
+ from: this.#endpoint(old), to: this.#endpoint(location),
1085
+ location: this.#describeLocation(location),
1086
+ locations: saved.locations.map(l => this.#describeLocation(l)), origin });
1087
+ }
1088
+ db.putSync(receiptKey, { from: fromKey, to: toKey, done: true });
1089
+ this.#replicas.moveIngress(backendName, fromKey, backendName, toKey, { directory: true });
1090
+ });
1091
+ for (const event of events) this.#emitObject('move', event);
1092
+ return result();
1093
+ } finally { release(); }
1094
+ });
1095
+ this.#directoryMoveTasks.set(backendName, task);
1096
+ try { return await task; }
1097
+ finally { if (this.#directoryMoveTasks.get(backendName) === task) this.#directoryMoveTasks.delete(backendName); }
1098
+ }
1099
+
1100
+ // A backend that renames a directory itself (the canvas driver: one hub
1101
+ // request). The index follows with one re-key of the locations under it.
1102
+ async #renameRemoteDirectory(backend, fromKey, toKey, { operationId, origin }) {
1103
+ const name = backend.name;
1104
+ const prefix = `${fromKey}/`;
1105
+ const rows = this.#index.locationsByBackend(name, { prefix, limit: Number.MAX_SAFE_INTEGER }).objects;
1106
+ const movedKey = (key) => `${toKey}/${key.slice(prefix.length)}`;
1107
+ const release = this.#holdKeys(rows.flatMap(row => [`${name}:${row.key}`, `${name}:${movedKey(row.key)}`]));
1108
+ try {
1109
+ const moved = await backend.renameDirectory(fromKey, toKey, { operationId, origin });
1110
+ const events = [];
1111
+ this.#index.transaction(() => {
1112
+ for (const { key } of rows) {
1113
+ const current = this.#index.get(`${name}:${key}`);
1114
+ const old = current?.locations?.find(l => l.backend === name && l.key === key);
1115
+ if (!old) continue;
1116
+ const nextKey = movedKey(key);
1117
+ const location = this.#buildLocation(name, nextKey, old.synced, { ...old, key: nextKey });
1118
+ const saved = this.#index.put(current.id, {
1119
+ ...current,
1120
+ locations: current.locations.map(l => l === old ? location : l),
1121
+ }, { origin, rename: { backend: name, from: key, to: nextKey } });
1122
+ events.push({ id: current.id, checksums: saved.checksums,
1123
+ from: this.#endpoint(old), to: this.#endpoint(location),
1124
+ location: this.#describeLocation(location),
1125
+ locations: saved.locations.map(l => this.#describeLocation(l)), origin });
1126
+ }
1127
+ });
1128
+ for (const event of events) this.#emitObject('move', event);
1129
+ return { ok: true, directory: true, from: fromKey, to: toKey, state: 'complete', seq: moved.seq ?? null, count: events.length };
1130
+ } finally { release(); }
1131
+ }
1132
+
687
1133
  // `If-Match` / `If-None-Match` semantics over the index. Returns the typed
688
1134
  // failure (with what is actually there) or null when the write may go on.
689
1135
  #checkPrecondition(current, currentLoc, { ifMatch = null, ifNoneMatch = null } = {}) {
@@ -774,11 +1220,9 @@ export default class Stored extends EventEmitter2 {
774
1220
  const previous = this.#displaceTargets(targets, destKey, meta.id, options);
775
1221
  const current = this.#index.get(meta.id) || meta;
776
1222
  const saved = this.#index.put(meta.id, { ...current, locations: this.#mergeLocations(current, transferred.locations) }, { origin: options.origin });
1223
+ if (transferred.remoteTargets.length) this.#syncQueue.enqueue({ id: meta.id, cacheRoot: this.#cache.root, cacheKey: meta.id, targets: transferred.remoteTargets });
777
1224
  return { saved, previous };
778
1225
  });
779
- if (transferred.remoteTargets.length) {
780
- this.#syncQueue.enqueue({ id: meta.id, cacheRoot: this.#cache.root, cacheKey: meta.id, targets: transferred.remoteTargets });
781
- }
782
1226
 
783
1227
  for (const loc of transferred.locations) {
784
1228
  this.#emitLocationEvent('location:add', saved, loc, previous ? { previous } : {});
@@ -819,6 +1263,8 @@ export default class Stored extends EventEmitter2 {
819
1263
  if (!plan.ok) return plan;
820
1264
 
821
1265
  const { meta, sourceLocation, sourceBackend, targets, destKey } = plan;
1266
+ this.#replicas.assertIdleEndpoint(sourceLocation.backend, sourceLocation.key);
1267
+ for (const target of targets) this.#replicas.assertIdleEndpoint(target.name, destKey);
822
1268
  if (!targets.length) {
823
1269
  return { ok: true, id: meta.id, state: 'complete', unchanged: true, locations: await this.locations(meta.id) };
824
1270
  }
@@ -874,15 +1320,17 @@ export default class Stored extends EventEmitter2 {
874
1320
  const previous = this.#displaceTargets(targets, destKey, meta.id, options);
875
1321
  const current = this.#index.get(meta.id) || meta;
876
1322
  const saved = this.#index.put(meta.id, { ...current, locations: this.#mergeLocations(current, transferred.locations) }, { origin: options.origin });
1323
+ if (transferred.remoteTargets.length) {
1324
+ const pending = this.#pendingMoves.get(meta.id) || [];
1325
+ pending.push({ sourceLocation, target: target.name, destKey: targetKey });
1326
+ this.#pendingMoves.putSync(meta.id, pending);
1327
+ this.#syncQueue.enqueue({ id: meta.id, cacheRoot: this.#cache.root, cacheKey: meta.id, targets: transferred.remoteTargets });
1328
+ }
877
1329
  return { saved, previous };
878
1330
  });
879
1331
  this.#emitLocationEvent('location:add', saved, location, previous ? { previous } : {});
880
1332
 
881
1333
  if (transferred.remoteTargets.length) {
882
- const pending = this.#pendingMoves.get(meta.id) || [];
883
- pending.push({ sourceLocation, target: target.name, destKey: targetKey });
884
- this.#pendingMoves.set(meta.id, pending);
885
- this.#syncQueue.enqueue({ id: meta.id, cacheRoot: this.#cache.root, cacheKey: meta.id, targets: transferred.remoteTargets });
886
1334
  debug(`MOVE (pending sync) ${sourceLocation.backend}:${sourceLocation.key} → ${target.name}:${targetKey}`);
887
1335
  return {
888
1336
  ok: true,
@@ -953,7 +1401,10 @@ export default class Stored extends EventEmitter2 {
953
1401
  * re-hashed. Returns { ok:true, backend, count, files } | { ok:false, reason }.
954
1402
  */
955
1403
  async scan(backendName, options = {}) {
956
- const { onFile, ...backendOptions } = options;
1404
+ const { onFile, isCancelled, ...backendOptions } = options;
1405
+ const checkCancelled = () => {
1406
+ if (isCancelled?.()) throw Object.assign(new Error('Scan cancelled'), { code: 'RESYNC_CANCELLED' });
1407
+ };
957
1408
  const list = backendName
958
1409
  ? [this.#backends.get(backendName)].filter(Boolean)
959
1410
  : this.#backends.all();
@@ -963,6 +1414,7 @@ export default class Stored extends EventEmitter2 {
963
1414
  let complete = true;
964
1415
  let errors = null;
965
1416
  for (const backend of list) {
1417
+ checkCancelled();
966
1418
  // Per-file ingest into the stored index — idempotent, guarded so the
967
1419
  // streaming path (backend onFile) and the returned-rows path don't
968
1420
  // double-process a key.
@@ -972,12 +1424,19 @@ export default class Stored extends EventEmitter2 {
972
1424
  processed.add(file.key);
973
1425
  if (!file.checksums) return;
974
1426
  const id = formatId(file.checksums, this.#config.primaryChecksum);
1427
+ const occupant = this.#index.get(`${file.backend}:${file.key}`);
1428
+ // Scans must report the same edit provenance as watcher/API
1429
+ // writes. Otherwise consumers see new content as a new file,
1430
+ // lose the document ID, and later mistake the old row for a
1431
+ // deletion. Keep the hint on both streamed and returned rows.
1432
+ const previous = occupant && occupant.id !== id ? occupant : null;
1433
+ if (previous) file.previous = { id: previous.id, checksums: previous.checksums };
975
1434
  const existing = this.#index.get(id);
976
1435
  const locations = existing?.locations || [];
977
1436
  const match = locations.find(l => l.backend === file.backend && l.key === file.key);
978
1437
  // Avoid LMDB/index/change-feed churn on warm scans. Still emit
979
1438
  // the row to consumers: their document index may need repair.
980
- if (match && match.synced === true && match.size === file.size && match.mtime === file.modified
1439
+ if (!previous && match && match.synced === true && match.size === file.size && match.mtime === file.modified
981
1440
  && (file.ino == null || (match.ino === file.ino && match.dev === file.dev))
982
1441
  && existing.size === file.size && existing.mimeType === file.mimeType) return;
983
1442
  if (match) {
@@ -993,14 +1452,22 @@ export default class Stored extends EventEmitter2 {
993
1452
  ...(file.ino != null ? { ino: file.ino, dev: file.dev } : {}),
994
1453
  }));
995
1454
  }
996
- this.#index.put(id, { checksums: file.checksums, size: file.size, mimeType: file.mimeType, locations });
1455
+ this.#index.transaction(() => {
1456
+ if (previous) {
1457
+ const remaining = previous.locations.filter(l => !(l.backend === file.backend && l.key === file.key));
1458
+ if (remaining.length) this.#index.put(previous.id, { ...previous, locations: remaining });
1459
+ else this.#index.delete(previous.id);
1460
+ }
1461
+ this.#index.put(id, { checksums: file.checksums, size: file.size, mimeType: file.mimeType, locations });
1462
+ });
997
1463
  };
998
1464
 
999
1465
  // Inode identity snapshot for rename matching: same (dev, ino) at a
1000
1466
  // NEW path with unchanged size+mtime is a move — reuse the indexed
1001
1467
  // checksums instead of rehashing, so a folder rename (thousands of
1002
1468
  // "moves") collapses into cheap URL rewrites.
1003
- const inodeMap = this.#buildInodeMap(backend.name);
1469
+ const inodeMap = await this.#buildInodeMap(backend.name);
1470
+ checkCancelled();
1004
1471
 
1005
1472
  const result = await backend.scan({
1006
1473
  algorithms: this.#config.checksums,
@@ -1010,10 +1477,13 @@ export default class Stored extends EventEmitter2 {
1010
1477
  // Stream-through: index the file, then hand it to the caller so
1011
1478
  // consumers (workspace doc upserts) see results as the walk runs.
1012
1479
  onFile: async (file) => {
1480
+ checkCancelled();
1013
1481
  ingest(file);
1014
1482
  if (typeof onFile === 'function') await onFile(file);
1483
+ if (processed.size % 32 === 0) await yieldToIO();
1015
1484
  },
1016
1485
  });
1486
+ checkCancelled();
1017
1487
  // Snapshot shape { files, complete, errors } (file driver) or a bare
1018
1488
  // rows array (legacy/simple drivers — treated as a complete walk).
1019
1489
  // Backends whose scan() reports via events instead (non-array, no
@@ -1021,13 +1491,24 @@ export default class Stored extends EventEmitter2 {
1021
1491
  const snapshot = Array.isArray(result)
1022
1492
  ? { files: result, complete: true, errors: null }
1023
1493
  : (Array.isArray(result?.files) ? result : null);
1024
- if (!snapshot) continue;
1494
+ if (!snapshot || result?.ok === false) {
1495
+ complete = false;
1496
+ errors = { ...(errors || {}), [backend.name]: { root: 'scan returned no usable snapshot' } };
1497
+ continue;
1498
+ }
1025
1499
 
1026
1500
  if (snapshot.complete === false) complete = false;
1027
1501
  if (snapshot.errors) errors = { ...(errors || {}), [backend.name]: snapshot.errors };
1028
1502
 
1029
1503
  // Backends that ignore onFile still get their rows indexed here.
1030
- for (const file of snapshot.files) ingest(file);
1504
+ let visited = 0;
1505
+ for (const file of snapshot.files) {
1506
+ if (++visited % 128 === 0) { await yieldToIO(); checkCancelled(); }
1507
+ ingest(file);
1508
+ }
1509
+ // Index change listeners may request cancellation synchronously on
1510
+ // the last ingested row, without another file callback or I/O turn.
1511
+ checkCancelled();
1031
1512
 
1032
1513
  // Removal only ever runs against a usable snapshot: a dead/absent
1033
1514
  // root (errors.root) means the backend is offline — nothing is
@@ -1036,7 +1517,7 @@ export default class Stored extends EventEmitter2 {
1036
1517
  if (snapshot.errors?.root) continue;
1037
1518
  const presentKeys = new Set(snapshot.files.map(file => file.key));
1038
1519
  const erroredPrefixes = (snapshot.errors?.dirs || []).map(d => d.prefix).filter(Boolean);
1039
- this.#removeMissingLocations(backend.name, presentKeys, erroredPrefixes);
1520
+ if (snapshot.complete !== false) this.#removeMissingLocations(backend.name, presentKeys, erroredPrefixes);
1040
1521
  files.push(...snapshot.files);
1041
1522
  }
1042
1523
  return { ok: true, backend: backendName ?? null, count: files.length, files, complete, errors };
@@ -1059,8 +1540,14 @@ export default class Stored extends EventEmitter2 {
1059
1540
  // Lifecycle
1060
1541
  // ─────────────────────────────────────────────────────────────────────────
1061
1542
 
1062
- async stop() {
1543
+ stop() {
1544
+ if (!this.#stopPromise) { this.#closing = true; this.#mutations.close(); this.#stopPromise = this.#stop(); }
1545
+ return this.#stopPromise;
1546
+ }
1547
+ async #stop() {
1063
1548
  if (this.#sweepTimer) { clearInterval(this.#sweepTimer); this.#sweepTimer = null; }
1549
+ await this.#replicas.stop();
1550
+ await this.#mutations.drain();
1064
1551
  await this.#syncQueue.stop();
1065
1552
  await this.#backends.stopAll();
1066
1553
  // Flush held unlinks (watchers are stopped — no add can claim them now)
@@ -1070,7 +1557,11 @@ export default class Stored extends EventEmitter2 {
1070
1557
  this.#pendingUnlinks.delete(pendingKey);
1071
1558
  this.#processUnlink(data);
1072
1559
  }
1073
- this.#index.close();
1560
+ // No stream writers remain at shutdown. Verify reclaims content whose
1561
+ // staging keys were released without racing an ingress stream.
1562
+ await this.#cache.stop();
1563
+ this.#replicas.releaseExecutor();
1564
+ await this.#index.close();
1074
1565
  debug('Stopped');
1075
1566
  }
1076
1567
 
@@ -1084,13 +1575,14 @@ export default class Stored extends EventEmitter2 {
1084
1575
 
1085
1576
  for (const r of results) {
1086
1577
  if (!r.success) continue;
1087
- const loc = meta.locations.find(l => l.backend === r.backend);
1578
+ const loc = meta.locations.find(l => l.backend === r.backend && l.key === r.key);
1088
1579
  if (loc) loc.synced = true;
1089
1580
  }
1090
1581
 
1091
1582
  this.#index.put(meta.id, meta);
1092
1583
  this.emit('synced', { id, results });
1093
- this.#resolvePendingMoves(id, results);
1584
+ // Source retirement is replayed by resumePendingMoves, under the same
1585
+ // executor lock, after the durable destination receipt commits.
1094
1586
  }
1095
1587
 
1096
1588
  // ─────────────────────────────────────────────────────────────────────────
@@ -1383,16 +1875,21 @@ export default class Stored extends EventEmitter2 {
1383
1875
  try {
1384
1876
  if (removeSourceBytes) {
1385
1877
  const sourceBackend = this.#backends.get(sourceLocation.backend);
1386
- const deleted = await sourceBackend.delete(sourceLocation.key).catch(() => false);
1387
- if (!deleted) {
1388
- return {
1389
- ok: false,
1390
- reason: 'source-delete-failed',
1391
- degradedToCopy: true,
1392
- id,
1393
- from: this.#endpoint(sourceLocation),
1394
- to: this.#endpoint(newLocation),
1395
- };
1878
+ const targetBackend = this.#backends.get(newLocation.backend);
1879
+ if (!sourceBackend || !targetBackend) return { ok: false, reason: 'backend-offline' };
1880
+ const bytes = await targetBackend.get(newLocation.key, { stream: true });
1881
+ if (!bytes || `sha256:${(await checksumStream(Buffer.isBuffer(bytes) ? Readable.from([bytes]) : bytes)).sha256}` !== id) return { ok: false, reason: 'target-changed' };
1882
+ const sourceStat = await sourceBackend.stat(sourceLocation.key);
1883
+ if (sourceStat) {
1884
+ const source = await sourceBackend.get(sourceLocation.key, { stream: true });
1885
+ if (!source || `sha256:${(await checksumStream(Buffer.isBuffer(source) ? Readable.from([source]) : source)).sha256}` !== id) return { ok: false, reason: 'source-changed' };
1886
+ this.#replicas.moveIngress(sourceLocation.backend, sourceLocation.key, newLocation.backend, newLocation.key);
1887
+ const result = sourceBackend.config.driver === 'cacache'
1888
+ ? await this.deleteByUrl(`stored://${sourceLocation.backend}/${sourceLocation.key}`)
1889
+ : (this.#retention && sourceBackend.config.driver === 'file')
1890
+ ? await this.removeObject(sourceLocation.backend, sourceLocation.key, { ifMatch: id.slice(7), origin, [MOVE_SOURCE]: true })
1891
+ : { ok: false, reason: 'recovery-unavailable' };
1892
+ if (!result.ok) return { ...result, degradedToCopy: true };
1396
1893
  }
1397
1894
  }
1398
1895
 
@@ -1408,6 +1905,7 @@ export default class Stored extends EventEmitter2 {
1408
1905
  ? { backend: newLocation.backend, from: sourceLocation.key, to: newLocation.key }
1409
1906
  : null;
1410
1907
  const saved = this.#index.put(id, { ...current, locations }, { origin, rename });
1908
+ this.#replicas.moveIngress(sourceLocation.backend, sourceLocation.key, newLocation.backend, newLocation.key);
1411
1909
 
1412
1910
  this.#emitObject('move', {
1413
1911
  id,
@@ -1434,36 +1932,35 @@ export default class Stored extends EventEmitter2 {
1434
1932
  }
1435
1933
 
1436
1934
  /** Finish moves that were waiting on a remote destination write. */
1437
- #resolvePendingMoves(id, results = []) {
1438
- const pending = this.#pendingMoves.get(id);
1439
- if (!pending) return;
1440
-
1441
- const succeeded = new Set(results.filter(r => r.success).map(r => r.backend));
1442
- const failed = new Set(results.filter(r => !r.success).map(r => r.backend));
1443
- const remaining = [];
1444
-
1445
- for (const entry of pending) {
1446
- if (succeeded.has(entry.target)) {
1447
- const meta = this.#index.get(id);
1448
- const location = (meta?.locations || []).find(l => l.backend === entry.target && l.key === entry.destKey);
1449
- if (location) {
1450
- this.#finalizeMove(id, entry.sourceLocation, location, { removeSourceBytes: true })
1451
- .catch(err => this.emit('error', err));
1935
+ async resumePendingMoves() {
1936
+ // Snapshot keys only; each record is re-read under the mutation lock.
1937
+ // Holding the lock over the whole backlog delayed interactive byte
1938
+ // operations behind every remote verification. Awaiting resolved
1939
+ // promises also never lets HTTP/timer callbacks run.
1940
+ let processed = 0;
1941
+ for (const id of [...this.#pendingMoves.getKeys()]) {
1942
+ if (this.#closing) break;
1943
+ await this.withStorageMutation(async () => {
1944
+ const pending = this.#pendingMoves.get(id);
1945
+ if (!pending) return;
1946
+ const remaining = [];
1947
+ for (const entry of pending) {
1948
+ try {
1949
+ const meta = this.#index.get(id);
1950
+ const location = meta?.locations?.find(l => l.backend === entry.target && l.key === entry.destKey && l.synced !== false);
1951
+ if (!location) { remaining.push(entry); continue; }
1952
+ const result = await this.#finalizeMove(id, entry.sourceLocation, location, { removeSourceBytes: true });
1953
+ if (!result.ok) remaining.push({ ...entry, error: result.reason });
1954
+ } catch (error) { remaining.push({ ...entry, error: error.message }); }
1452
1955
  }
1453
- } else if (failed.has(entry.target)) {
1454
- // Destination write failed — the source stays exactly where it
1455
- // is. The object is unharmed; the move simply did not happen.
1456
- this.emit('error', Object.assign(
1457
- new Error(`Move to ${entry.target} failed; source retained at ${entry.sourceLocation.backend}:${entry.sourceLocation.key}`),
1458
- { id, code: 'MOVE_SYNC_FAILED' },
1459
- ));
1460
- } else {
1461
- remaining.push(entry);
1462
- }
1956
+ // Disabled/offline destinations can leave thousands of moves
1957
+ // pending. Rewriting identical records on every retry caused
1958
+ // one synchronous LMDB commit per item, twice each queue tick.
1959
+ if (!remaining.length) this.#pendingMoves.removeSync(id);
1960
+ else if (!isDeepStrictEqual(pending, remaining)) this.#pendingMoves.putSync(id, remaining);
1961
+ });
1962
+ if (++processed % 32 === 0) await yieldToIO();
1463
1963
  }
1464
-
1465
- if (remaining.length) this.#pendingMoves.set(id, remaining);
1466
- else this.#pendingMoves.delete(id);
1467
1964
  }
1468
1965
 
1469
1966
  /**
@@ -1640,6 +2137,10 @@ export default class Stored extends EventEmitter2 {
1640
2137
  // local backend's filesystem, then commit (hardlink/rename) to targets.
1641
2138
  async #ingestStream(blob, targets, key, mimeHint, options = {}) {
1642
2139
  const source = isStream(blob) ? blob : createReadStream(blob);
2140
+ const blobTarget = targets.find(t => typeof t.backend.ingestStream === 'function');
2141
+ if (blobTarget || targets.every(t => t.backend.type !== 'local')) {
2142
+ return this.#ingestBlobStream(source, targets, blobTarget, key, mimeHint, options);
2143
+ }
1643
2144
  const firstLocal = targets.find(t => t.backend.type === 'local')?.backend;
1644
2145
  const tempDir = firstLocal ? firstLocal.tempDir : path.join(this.#paths.cache, '.tmp');
1645
2146
  const tempPath = path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
@@ -1663,6 +2164,94 @@ export default class Stored extends EventEmitter2 {
1663
2164
  return result;
1664
2165
  }
1665
2166
 
2167
+ // Upload directly to the managed blob store. Hash and sniff in-flight;
2168
+ // extraction and additional destinations read the completed immutable blob.
2169
+ // Cacache owns the only temporary payload and atomically publishes it.
2170
+ async #ingestBlobStream(source, targets, target, key, mimeHint, options) {
2171
+ const hashes = this.#config.checksums.map(algo => ({ algo, hash: crypto.createHash(algo) }));
2172
+ const checksums = {};
2173
+ const headChunks = [];
2174
+ let headLen = 0;
2175
+ async function* measured() {
2176
+ for await (const chunk of source) {
2177
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2178
+ for (const { hash } of hashes) hash.update(buf);
2179
+ if (headLen < HEAD_BYTES) {
2180
+ const slice = buf.subarray(0, HEAD_BYTES - headLen);
2181
+ headChunks.push(Buffer.from(slice));
2182
+ headLen += slice.length;
2183
+ }
2184
+ yield buf;
2185
+ }
2186
+ for (const { algo, hash } of hashes) checksums[algo] = hash.digest('hex');
2187
+ }
2188
+ let id, finalKey, mimeType, extracted;
2189
+ const sink = target?.backend || this.#cache;
2190
+ const { file, size } = await sink.ingestStream(measured(), async content => {
2191
+ id = formatId(checksums, this.#config.primaryChecksum);
2192
+ finalKey = key || this.#generateKey(checksums);
2193
+ mimeType = mimeHint || await detectMimeFromHead(Buffer.concat(headChunks), finalKey);
2194
+ extracted = await this.#maybeExtract({ file: content.file }, mimeType, finalKey);
2195
+ return target ? finalKey : { key: id, metadata: { key: finalKey, checksums, size: content.size, mimeType } };
2196
+ });
2197
+ const rest = targets.filter(t => t !== target);
2198
+ const { locations, remoteTargets } = await this.#commit(rest, finalKey, id, { file, cached: !target }, { checksums, size, mimeType }, options);
2199
+ if (target) {
2200
+ const stat = await target.backend.stat(finalKey);
2201
+ const location = this.#buildLocation(target.name, finalKey, true, { size, mtime: stat.modified });
2202
+ locations.splice(targets.indexOf(target), 0, location);
2203
+ }
2204
+ return { id, finalKey, checksums, size, mimeType, locations, remoteTargets, extracted };
2205
+ }
2206
+
2207
+ // A file handed over by the caller: moved into the blob store when a
2208
+ // target can adopt it (one rename, no copy); otherwise ingested as a
2209
+ // stream and removed. Hashing reads the file only without caller checksums.
2210
+ async #ingestAdopted(file, targets, key, mimeHint, options) {
2211
+ const target = targets.find(t => typeof t.backend.adoptFile === 'function');
2212
+ if (!target) {
2213
+ try { return await this.#ingestStream(file, targets, key, mimeHint, options); }
2214
+ finally { await fsp.rm(file, { force: true }); }
2215
+ }
2216
+ const known = options.checksums && this.#config.checksums.every(algo => /^[0-9a-f]+$/.test(options.checksums[algo] || ''));
2217
+ const { checksums, size, head } = known
2218
+ ? { checksums: Object.fromEntries(this.#config.checksums.map(algo => [algo, options.checksums[algo]])), size: (await fsp.stat(file)).size, head: await readHead(file) }
2219
+ : await this.#hashFile(file);
2220
+ const id = formatId(checksums, this.#config.primaryChecksum);
2221
+ const finalKey = key || this.#generateKey(checksums);
2222
+ const mimeType = mimeHint || await detectMimeFromHead(head, finalKey);
2223
+ let extracted;
2224
+ const adopted = await target.backend.adoptFile(file, { sha256: checksums.sha256, size }, async content => {
2225
+ extracted = await this.#maybeExtract({ file: content.file }, mimeType, finalKey);
2226
+ return finalKey;
2227
+ });
2228
+ const rest = targets.filter(t => t !== target);
2229
+ const { locations, remoteTargets } = await this.#commit(rest, finalKey, id, { file: adopted.file, cached: false }, { checksums, size, mimeType }, options);
2230
+ const stat = await target.backend.stat(finalKey);
2231
+ locations.splice(targets.indexOf(target), 0, this.#buildLocation(target.name, finalKey, true, { size, mtime: stat.modified }));
2232
+ return { id, finalKey, checksums, size, mimeType, locations, remoteTargets, extracted };
2233
+ }
2234
+
2235
+ // One read pass over a file: checksums (all algorithms), size and head.
2236
+ async #hashFile(file) {
2237
+ const hashes = this.#config.checksums.map(algo => ({ algo, hash: crypto.createHash(algo) }));
2238
+ let size = 0;
2239
+ const headChunks = [];
2240
+ let headLen = 0;
2241
+ for await (const chunk of createReadStream(file)) {
2242
+ for (const { hash } of hashes) hash.update(chunk);
2243
+ size += chunk.length;
2244
+ if (headLen < HEAD_BYTES) {
2245
+ const slice = chunk.subarray(0, HEAD_BYTES - headLen);
2246
+ headChunks.push(slice);
2247
+ headLen += slice.length;
2248
+ }
2249
+ }
2250
+ const checksums = {};
2251
+ for (const { algo, hash } of hashes) checksums[algo] = hash.digest('hex');
2252
+ return { checksums, size, head: Buffer.concat(headChunks) };
2253
+ }
2254
+
1666
2255
  // Single streaming pass: hash (all algorithms), measure size, peek the head
1667
2256
  // for mime sniffing, and write to `tempPath`. Never buffers the whole blob.
1668
2257
  async #hashToFile(source, tempPath) {
@@ -1686,6 +2275,8 @@ export default class Stored extends EventEmitter2 {
1686
2275
  }
1687
2276
  ws.end();
1688
2277
  await once(ws, 'finish');
2278
+ const handle = await fsp.open(tempPath, 'r');
2279
+ try { await handle.sync(); } finally { await handle.close(); }
1689
2280
  } catch (err) {
1690
2281
  ws.destroy();
1691
2282
  throw err;
@@ -1730,7 +2321,7 @@ export default class Stored extends EventEmitter2 {
1730
2321
  }
1731
2322
  }
1732
2323
 
1733
- if (remoteTargets.length) {
2324
+ if (remoteTargets.length && !source.cached) {
1734
2325
  const cacheMeta = { key: finalKey, checksums: meta.checksums, size: meta.size, mimeType: meta.mimeType };
1735
2326
  if (source.data) await this.#cache.put(id, source.data, cacheMeta);
1736
2327
  else await pipeline(createReadStream(source.file), this.#cache.putStream(id, cacheMeta));
@@ -1751,9 +2342,11 @@ export default class Stored extends EventEmitter2 {
1751
2342
  // One-shot snapshot of a backend's indexed locations keyed by inode
1752
2343
  // identity, built per scan (the index is LMDB — a per-file reverse lookup
1753
2344
  // would be O(N²)).
1754
- #buildInodeMap(backendName) {
2345
+ async #buildInodeMap(backendName) {
1755
2346
  const map = new Map();
2347
+ let visited = 0;
1756
2348
  for (const [, meta] of this.#index.entries()) {
2349
+ if (++visited % 128 === 0) await yieldToIO();
1757
2350
  for (const loc of meta.locations || []) {
1758
2351
  if (loc.backend !== backendName || loc.ino == null) continue;
1759
2352
  map.set(`${loc.dev}:${loc.ino}`, {
@@ -1859,8 +2452,10 @@ export default class Stored extends EventEmitter2 {
1859
2452
  // Emit a file event under both its typed name (`file:<suffix>`) and the
1860
2453
  // generic `object:<suffix>` (kind:'file') so consumers can bind either.
1861
2454
  #emitObject(suffix, payload) {
1862
- this.emit(`file:${suffix}`, payload);
1863
- this.emit(`object:${suffix}`, { kind: 'file', ...payload });
2455
+ this.detachedStorageWork(() => {
2456
+ this.emit(`file:${suffix}`, payload);
2457
+ this.emit(`object:${suffix}`, { kind: 'file', ...payload });
2458
+ });
1864
2459
  }
1865
2460
 
1866
2461
  // `options.force` bypasses the echo suppression — used when stored itself
@@ -2002,8 +2597,13 @@ export default class Stored extends EventEmitter2 {
2002
2597
  // deletion, and consumers never see the transient absence.
2003
2598
  const meta = this.#index.get(pathKey);
2004
2599
  const loc = meta?.locations?.find(l => l.backend === data.backend && l.key === data.key);
2005
- if (loc?.ino != null) {
2006
- const pendingKey = `${data.backend}|${loc.dev}:${loc.ino}`;
2600
+ // A driver that names the inode in the event (the canvas driver:
2601
+ // the content digest) is believed over the indexed location, which
2602
+ // a put() created without one.
2603
+ const ino = data.ino ?? loc?.ino ?? null;
2604
+ const dev = data.ino != null ? data.dev : loc?.dev;
2605
+ if (loc && ino != null) {
2606
+ const pendingKey = `${data.backend}|${dev}:${ino}`;
2007
2607
  // A second unlink for the same inode (hardlink twin) processes
2008
2608
  // the held one immediately — one hold per inode.
2009
2609
  const prior = this.#pendingUnlinks.get(pendingKey);
@@ -2032,6 +2632,7 @@ export default class Stored extends EventEmitter2 {
2032
2632
  #recheckUnlink(data) {
2033
2633
  const pathKey = `${data.backend}:${data.key}`;
2034
2634
  const timer = setTimeout(async () => {
2635
+ if (this.#closing) return;
2035
2636
  if (this.#suppressedKeys.has(pathKey)) { this.#recheckUnlink(data); return; }
2036
2637
  const meta = this.#index.get(pathKey);
2037
2638
  if (!meta) return;