@augmentd-labs/canvas-edge 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/node_modules/canvas-stored/README.md +189 -9
  2. package/node_modules/canvas-stored/package.json +2 -2
  3. package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
  4. package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
  5. package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
  6. package/node_modules/canvas-stored/src/backends/canvas/index.js +48 -6
  7. package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
  8. package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
  9. package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
  10. package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
  11. package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
  12. package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
  13. package/node_modules/canvas-stored/src/cache/index.js +39 -5
  14. package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
  15. package/node_modules/canvas-stored/src/index/index.js +5 -3
  16. package/node_modules/canvas-stored/src/index.js +797 -140
  17. package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
  18. package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
  19. package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
  20. package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
  21. package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
  22. package/node_modules/canvas-stored/src/sync/Mirror.js +335 -25
  23. package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
  24. package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
  25. package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
  26. package/node_modules/canvas-stored/src/trash.js +219 -0
  27. package/node_modules/canvas-stored/src/utils/common.js +7 -0
  28. package/package.json +5 -4
  29. package/src/binding.js +33 -0
  30. package/src/control.js +13 -0
  31. package/src/daemon.js +14 -1
  32. package/src/mirror-runtime.js +14 -3
@@ -0,0 +1,623 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { Readable, Transform } from 'node:stream';
5
+ import { openSync, closeSync, createReadStream } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import path from 'node:path';
8
+ import { setImmediate as yieldToIO } from 'node:timers/promises';
9
+ import { backoffMs } from './JobQueue.js';
10
+
11
+ const clone = value => structuredClone(value);
12
+ const canonical = value => Array.isArray(value) ? value.map(canonical)
13
+ : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
14
+ const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
15
+ const address = value => JSON.stringify([value.backend, value.key]);
16
+ const terminal = state => state === 'complete' || state === 'cancelled';
17
+ const failure = (code, message, retryable = false) => Object.assign(new Error(message), { code, retryable });
18
+ const transient = error => error.retryable || ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENETUNREACH', 'EAI_AGAIN', 'target-offline'].includes(error.code)
19
+ || [429, 502, 503, 504].includes(error.status ?? error.statusCode);
20
+ const safeKey = key => typeof key === 'string' && key.length > 0 && !key.includes('\0')
21
+ && !key.includes('\\') && !key.startsWith('/') && !/^[A-Za-z]:/.test(key)
22
+ && key.split('/').every(part => part && part !== '.' && part !== '..');
23
+
24
+ /**
25
+ * Physical replica execution. No documents, virtual trees, or rule evaluation.
26
+ * A caller supplies the complete ordered destination set for one opaque owner.
27
+ * The journal, owner ledger and receipts share Stored's LMDB environment.
28
+ * Accepted plans are immutable; retries reuse their resolved keys and outcomes.
29
+ */
30
+ export default class ReplicaService extends EventEmitter {
31
+ #stored; #ops; #owners; #meta; #running = null; #timer = null; #stopped = false; #requested = false;
32
+ #lease = { pid: process.pid, token: randomUUID() };
33
+ #lockFd = null;
34
+ #ingress;
35
+ #captureAll;
36
+ #ownerGuard = null;
37
+ #ownerEndpoints = null;
38
+ #latestOperations = null;
39
+
40
+ // Derived, process-local lookups. The durable ledgers remain authoritative.
41
+ // Invalidate on BOTH sides of an owner transaction, including rollback;
42
+ // callbacks inside the transaction must not leave a speculative cache.
43
+ #updateOwners(action, { operationsChanged = false } = {}) {
44
+ this.#ownerEndpoints = null;
45
+ if (operationsChanged) this.#latestOperations = null;
46
+ try { return this.#stored.index.transaction(action); }
47
+ finally {
48
+ this.#ownerEndpoints = null;
49
+ if (operationsChanged) this.#latestOperations = null;
50
+ }
51
+ }
52
+ ownersAt(backend, key, prefix = '') {
53
+ if (!this.#ownerEndpoints) {
54
+ const endpoints = new Map();
55
+ for (const { value } of this.#owners.getRange()) for (const replica of value.replicas) {
56
+ const endpoint = address(replica);
57
+ if (!endpoints.has(endpoint)) endpoints.set(endpoint, new Set());
58
+ endpoints.get(endpoint).add(value.owner);
59
+ }
60
+ this.#ownerEndpoints = endpoints;
61
+ }
62
+ return [...(this.#ownerEndpoints.get(address({ backend, key })) || [])]
63
+ .filter(owner => owner.startsWith(prefix)).map(owner => this.owner(owner)).filter(Boolean);
64
+ }
65
+ latest(owner) {
66
+ if (!this.#latestOperations) {
67
+ const latest = new Map();
68
+ for (const { value } of this.#ops.getRange()) {
69
+ if ((latest.get(value.owner)?.seq ?? -1) < value.seq) latest.set(value.owner, { id: value.id, seq: value.seq });
70
+ }
71
+ this.#latestOperations = latest;
72
+ }
73
+ const id = this.#latestOperations.get(owner)?.id;
74
+ return id ? this.get(id) : null;
75
+ }
76
+ setOwnerGuard(guard) { this.#ownerGuard = guard; }
77
+ #assertOwnerActive(op) {
78
+ if (this.#ownerGuard?.(op.owner) === false) throw failure('OWNER_HELD', 'The owner is awaiting a removal decision', true);
79
+ }
80
+ // Retire metadata only after the workspace explicitly purges a locationless
81
+ // document. History keeps its receipts under a non-reusable owner identity.
82
+ async retireOwner(owner) {
83
+ return this.#stored.withStorageMutation(() => {
84
+ const rows = [...this.#ops.getRange()].map(r => r.value).filter(op => op.owner === owner);
85
+ if (rows.some(op => !terminal(op.state))) throw failure('OWNER_BUSY', 'Finish or resolve pending storage operations before purging this item');
86
+ const archived = `retired:${owner}:${randomUUID()}`;
87
+ this.#updateOwners(() => {
88
+ for (const op of rows) this.#ops.putSync(op.id, { ...op, owner: archived });
89
+ this.#owners.removeSync(owner);
90
+ }, { operationsChanged: true });
91
+ });
92
+ }
93
+ constructor(stored, { retryIntervalMs = 1000, autoStart = true, captureAllWrites = false, ownerGuard = null } = {}) {
94
+ super();
95
+ this.#stored = stored;
96
+ this.#captureAll = captureAllWrites;
97
+ this.#ownerGuard = ownerGuard;
98
+ this.#ops = stored.index.openDB('replica.operations.v1');
99
+ this.#owners = stored.index.openDB('replica.owners.v1');
100
+ this.#meta = stored.index.openDB('replica.meta.v1');
101
+ this.#ingress = stored.index.openDB('replica.ingress.v1');
102
+ if (process.platform === 'linux') {
103
+ // flock is attached to the shared open-file description. The
104
+ // helper acquires it on fd 3; our descriptor keeps it alive after
105
+ // the helper exits. Kernel release on death survives PID reuse
106
+ // and container namespaces without stale-lock deletion races.
107
+ const fd = openSync(path.join(stored.paths.index, '.executor.lock'), 'a', 0o600);
108
+ const result = spawnSync('flock', ['--nonblock', '3'], { stdio: ['ignore', 'ignore', 'pipe', fd] });
109
+ if (result.status !== 0) {
110
+ closeSync(fd);
111
+ throw failure(result.status === 1 ? 'EXECUTOR_ACTIVE' : 'EXECUTOR_LOCK_UNAVAILABLE',
112
+ result.status === 1 ? 'Stored already has an active executor' : 'Stored requires util-linux flock for its executor lock');
113
+ }
114
+ this.#lockFd = fd;
115
+ }
116
+ stored.index.transaction(() => {
117
+ const held = this.#meta.get('executor');
118
+ if (held && this.#lockFd == null) {
119
+ let alive = true;
120
+ try { process.kill(held.pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
121
+ if (alive) throw failure('EXECUTOR_ACTIVE', `Stored already has an active executor (PID ${held.pid})`);
122
+ }
123
+ this.#meta.putSync('executor', this.#lease);
124
+ });
125
+ for (const { key, value } of this.#ops.getRange()) {
126
+ if (value.state === 'running') this.#ops.putSync(key, { ...value, state: 'pending' });
127
+ }
128
+ if (autoStart) {
129
+ this.#timer = setInterval(() => { this.drain().catch(error => this.emit('fault', error)); }, retryIntervalMs);
130
+ this.#timer.unref?.();
131
+ }
132
+ }
133
+
134
+ capabilities(backendName) {
135
+ const backend = this.#stored.getBackend(backendName);
136
+ const driver = backend?.config?.driver;
137
+ return {
138
+ available: Boolean(backend),
139
+ create: Boolean(backend?.canWrite && (driver === 'cacache' || typeof backend.createFrom === 'function')),
140
+ // Destructive automatic operations need recovery AND conditional
141
+ // execution. Remote drivers must explicitly implement that contract.
142
+ retire: Boolean(driver === 'file' && backend.canDelete && this.#stored.retention),
143
+ directories: Boolean(driver === 'file' && backend?.canWrite),
144
+ overwrite: Boolean(driver === 'file' && backend.canWrite && this.#stored.retention),
145
+ };
146
+ }
147
+
148
+ validate(input) {
149
+ if (!input || typeof input !== 'object') throw failure('INVALID_PLAN', 'A replica plan is required');
150
+ for (const key of ['id', 'owner']) {
151
+ if (typeof input[key] !== 'string' || !input[key] || input[key].length > 512) throw failure('INVALID_PLAN', `${key} is required`);
152
+ }
153
+ if (!Array.isArray(input.replicas)) throw failure('INVALID_PLAN', 'replicas must be an array');
154
+ const plan = clone(input);
155
+ const replicas = new Map();
156
+ for (const r of plan.replicas) {
157
+ if (!r.backend || !safeKey(r.key)) throw failure('INVALID_KEY', 'Invalid replica destination');
158
+ const cap = this.capabilities(r.backend);
159
+ if (!cap.available) throw failure('BACKEND_UNAVAILABLE', `Backend ${r.backend} is unavailable`, true);
160
+ if (r.kind === 'directory' ? !cap.directories : !cap.create) throw failure('UNSUPPORTED_BACKEND', `Backend ${r.backend} cannot safely place this replica`);
161
+ if (r.onConflict === 'overwrite' && !cap.overwrite) throw failure('UNSUPPORTED_OVERWRITE', `Backend ${r.backend} cannot safely overwrite replicas`);
162
+ if (r.mirror !== false && r.kind !== 'directory' && !cap.retire) throw failure('UNSUPPORTED_RETIRE', `Backend ${r.backend} supports copies but not recoverable automatic removal`);
163
+ if (r.onConflict && !['error', 'rename', 'overwrite'].includes(r.onConflict)) throw failure('INVALID_PLAN', 'Invalid conflict behavior');
164
+ const prior = replicas.get(address(r));
165
+ if (prior && (prior.kind !== r.kind || prior.onConflict !== r.onConflict || prior.mirror !== r.mirror)) throw failure('CONFLICTING_TARGETS', 'Two targets request incompatible behavior at the same key');
166
+ replicas.set(address(r), r);
167
+ }
168
+ plan.replicas = [...replicas.values()];
169
+ for (const endpoint of plan.preserve || []) if (!endpoint.backend || !safeKey(endpoint.key)) throw failure('INVALID_KEY', 'Invalid preserved endpoint');
170
+ if (plan.source && (!plan.source.backend || !safeKey(plan.source.key))) throw failure('INVALID_KEY', 'Invalid source');
171
+ if (plan.replicas.some(r => r.kind !== 'directory') && !/^sha256:[a-f0-9]{64}$/.test(plan.contentId || '')) throw failure('INVALID_PLAN', 'File replicas require a SHA-256 content identity');
172
+ for (const r of plan.release || []) {
173
+ if (!r.backend || !safeKey(r.key) || this.#stored.getBackend(r.backend)?.config.driver !== 'cacache') throw failure('INVALID_PLAN', 'Only managed blob staging can be released');
174
+ if (r.contentId && !/^sha256:[a-f0-9]{64}$/.test(r.contentId)) throw failure('INVALID_PLAN', 'Invalid staging content identity');
175
+ }
176
+ return plan;
177
+ }
178
+
179
+ submit(input) {
180
+ if (this.#stopped) throw failure('STOPPED', 'Storage is stopping');
181
+ // Validate identical retries against the receipt before consulting live
182
+ // backend config: an offline backend cannot erase an accepted result.
183
+ const prior = this.#ops.get(input?.id);
184
+ if (prior) {
185
+ if (prior.requestHash !== digest(input)) throw failure('IDEMPOTENCY_CONFLICT', 'Operation ID was reused with different input');
186
+ return clone(prior);
187
+ }
188
+ const plan = this.validate(input);
189
+ let op;
190
+ try {
191
+ op = this.#stored.index.transaction(() => {
192
+ const seq = Number(this.#meta.get('head') || 0) + 1;
193
+ const entry = { id: plan.id, owner: plan.owner, seq, requestHash: digest(input), plan,
194
+ state: 'pending', phase: 'prepare', attempts: 0, nextAt: 0,
195
+ createdAt: Date.now(), updatedAt: Date.now(), outcomes: [] };
196
+ this.#ops.putSync(entry.id, entry);
197
+ this.#meta.putSync('head', seq);
198
+ return entry;
199
+ });
200
+ } catch (error) {
201
+ // A callback inside the transaction may have warmed a speculative
202
+ // lookup. Never retain it when the transaction rolls back.
203
+ this.#latestOperations = null;
204
+ throw error;
205
+ }
206
+ // Receipt state is always read from LMDB. Only membership/order lives
207
+ // in this lookup: completing an operation or adding one new receipt
208
+ // must not force another full-ledger decode during reconciliation.
209
+ this.#latestOperations?.set(op.owner, { id: op.id, seq: op.seq });
210
+ this.emit('changed', clone(op));
211
+ this.#requested = true;
212
+ queueMicrotask(() => this.#stored.detachedStorageWork(() => { if (!this.#stopped) this.drain().catch(error => this.emit('fault', error)); }));
213
+ return clone(op);
214
+ }
215
+
216
+ get(id) { const op = this.#ops.get(id); return op ? clone(op) : null; }
217
+ owner(id) { const owner = this.#owners.get(id); return owner ? clone(owner) : null; }
218
+ async captureIngress(backend, key, contentId, file) {
219
+ const owners = this.ownersAt(backend, key).filter(o => o.replicas.some(r => r.mirror !== false && r.kind !== 'directory' && r.backend === backend && r.key === key));
220
+ if (!owners.length && !this.#captureAll) return null;
221
+ if (owners.length > 1) throw failure('SHARED_REPLICA', 'An edit cannot choose between multiple logical owners of this file');
222
+ const entry = this.#stored.index.transaction(() => {
223
+ const seq = Number(this.#meta.get('ingress-head') || 0) + 1;
224
+ const value = { cacheKey: `replica-ingress:${randomUUID()}`, owner: owners[0]?.owner || null, contentId,
225
+ backend, key, seq, state: 'preparing', createdAt: Date.now() };
226
+ this.#meta.putSync('ingress-head', seq); this.#ingress.putSync(value.cacheKey, value);
227
+ return value;
228
+ });
229
+ try { await pipeline(createReadStream(file), this.#stored.cache.putStream(entry.cacheKey, { owner: entry.owner, contentId })); }
230
+ catch (error) { await this.discardIngress(entry); throw error; }
231
+ return entry;
232
+ }
233
+ commitIngress(entry) { if (entry) this.#ingress.putSync(entry.cacheKey, { ...entry, state: 'committed' }); }
234
+ async discardIngress(entry) {
235
+ if (!entry || [...this.#ops.getRange()].some(({ value }) => !terminal(value.state) && value.plan.sourceCacheKey === entry.cacheKey)) return;
236
+ await this.#stored.cache.delete(entry.cacheKey);
237
+ this.#ingress.removeSync(entry.cacheKey);
238
+ }
239
+ get hasIngress() { return this.#ingress.getKeysCount() > 0; }
240
+ assertIdleEndpoint(backend, key, { directory = false, except = null } = {}) {
241
+ const matches = r => r.backend === backend && (r.key === key || (directory && r.key.startsWith(`${key}/`)));
242
+ for (const { value } of this.#ops.getRange()) {
243
+ if (terminal(value.state) || value.id === except) continue;
244
+ if ([...value.plan.replicas, ...value.outcomes, ...(value.base?.replicas || []), ...(value.plan.source ? [value.plan.source] : [])].some(matches)) {
245
+ throw failure('OPERATION_PENDING', `Finish storage operation ${value.id} before changing this path`);
246
+ }
247
+ }
248
+ }
249
+ moveIngress(backend, from, toBackend, to, { directory = false } = {}) {
250
+ for (const { key, value } of this.#ingress.getRange()) {
251
+ if (value.backend === backend && (value.key === from || (directory && value.key.startsWith(`${from}/`)))) {
252
+ this.#ingress.putSync(key, { ...value, backend: toBackend, key: to + value.key.slice(from.length) });
253
+ }
254
+ }
255
+ }
256
+ async abandonUnownedIngress(backend, key, { directory = false } = {}) {
257
+ for (const { value } of this.#ingress.getRange()) {
258
+ if (value.owner == null && value.backend === backend && (value.key === key || (directory && value.key.startsWith(`${key}/`)))) await this.discardIngress(value);
259
+ }
260
+ }
261
+ sourceFor(owner, contentId, endpoints = []) {
262
+ return [...this.#ingress.getRange()].map(r => r.value).filter(r => r.contentId === contentId
263
+ && (r.owner === owner || (r.owner == null && endpoints.some(e => address(e) === address(r))))).sort((a, b) => b.seq - a.seq)[0] || null;
264
+ }
265
+ async finishUnmirroredIngress(entry) {
266
+ return this.#stored.withStorageMutation(async () => {
267
+ if ((await this.#inspect(entry))?.id !== entry.contentId) throw failure('SOURCE_CHANGED', 'The primary write has not been verified');
268
+ for (const { value } of this.#ingress.getRange()) {
269
+ if (value.owner == null && address(value) === address(entry) && value.seq <= entry.seq) await this.discardIngress(value);
270
+ }
271
+ });
272
+ }
273
+ async supersede(id, input) {
274
+ const plan = this.validate(input);
275
+ return this.#stored.withStorageMutation(async () => {
276
+ const previous = this.get(id);
277
+ if (!previous || previous.owner !== plan.owner || previous.plan.contentId === plan.contentId) throw failure('INVALID_SUPERSESSION', 'Only a newer content version of the same owner may supersede unfinished work');
278
+ if (previous.state === 'complete') return this.submit(plan);
279
+ if (previous.state === 'cancelled') {
280
+ if (previous.supersededBy !== plan.id) throw failure('INVALID_SUPERSESSION', 'Operation already has a successor');
281
+ return this.submit(plan);
282
+ }
283
+ const base = this.owner(plan.owner) || previous.base || { owner: plan.owner, replicas: [] };
284
+ // Preserve exact ownership of any partial effects. Never assume a
285
+ // pending outcome landed, or claim bytes changed by another writer.
286
+ const replicas = new Map(base.replicas.map(r => [address(r), r]));
287
+ for (const outcome of previous.outcomes) {
288
+ const actual = await this.#inspect(outcome);
289
+ if (actual && (outcome.kind === 'directory' || actual.id === outcome.contentId)) replicas.set(address(outcome), { ...outcome, state: 'verified' });
290
+ }
291
+ let accepted;
292
+ this.#updateOwners(() => {
293
+ this.#owners.putSync(plan.owner, { ...base, replicas: [...replicas.values()] });
294
+ this.#save(previous, { state: 'cancelled', supersededBy: plan.id });
295
+ accepted = this.submit(plan);
296
+ }, { operationsChanged: true });
297
+ return accepted;
298
+ });
299
+ }
300
+ async adopt(ownerId, endpoint) {
301
+ return this.#stored.withStorageMutation(async () => {
302
+ const owner = this.owner(ownerId);
303
+ const replica = owner?.replicas.find(r => address(r) === address(endpoint));
304
+ if (!replica || (await this.#inspect(replica))?.id !== replica.contentId) throw failure('TARGET_CHANGED', 'Cannot adopt an unknown or changed replica');
305
+ replica.managed = true;
306
+ this.#updateOwners(() => this.#owners.putSync(ownerId, owner));
307
+ });
308
+ }
309
+ async audit(id) {
310
+ const owner = this.owner(id);
311
+ const differences = [];
312
+ for (const replica of owner?.replicas || []) {
313
+ // Copy-only targets no longer in the current plan are historical
314
+ // exports, not a continuing promise to overwrite external edits.
315
+ if (replica.mirror === false) continue;
316
+ const current = await this.#inspect(replica);
317
+ if (!current || (replica.kind !== 'directory' && current.id !== replica.contentId)) {
318
+ differences.push({ backend: replica.backend, key: replica.key, reason: current ? 'changed' : 'missing' });
319
+ }
320
+ }
321
+ return differences;
322
+ }
323
+ owners(prefix = '') { return [...this.#owners.getRange()].map(r => clone(r.value)).filter(r => r.owner.startsWith(prefix)); }
324
+ async recordRelocation(backend, from, to, { directory = false } = {}) {
325
+ return this.#stored.withStorageMutation(async () => {
326
+ const affected = r => r.backend === backend && (r.key === from || (directory && r.key.startsWith(`${from}/`)));
327
+ const updates = [];
328
+ for (const owner of this.owners()) {
329
+ if (!owner.replicas.some(affected)) continue;
330
+ if (this.list({ owner: owner.owner, active: true, limit: 1 }).operations.length) throw failure('OPERATION_PENDING', 'Finish the current replica operation before relocating its ownership');
331
+ for (const replica of owner.replicas.filter(affected)) {
332
+ const key = to + replica.key.slice(from.length);
333
+ const current = await this.#inspect({ ...replica, key });
334
+ if (!current || (replica.kind !== 'directory' && current.id !== replica.contentId)) throw failure('TARGET_CHANGED', 'Relocation cannot be verified against the recorded byte version');
335
+ replica.key = key; replica.requestedKey = key;
336
+ }
337
+ updates.push(owner);
338
+ }
339
+ this.#updateOwners(() => { for (const owner of updates) this.#owners.putSync(owner.owner, owner); });
340
+ this.moveIngress(backend, from, backend, to, { directory });
341
+ });
342
+ }
343
+ list({ owner, limit = 100, after = 0, active = false } = {}) {
344
+ const all = [...this.#ops.getRange()].map(r => r.value).filter(r => (!owner || r.owner === owner) && r.seq > after && (!active || !terminal(r.state))).sort((a, b) => a.seq - b.seq);
345
+ const operations = all.slice(0, Math.min(1000, Math.max(1, limit))).map(clone);
346
+ return { operations, next: all.length > operations.length ? operations.at(-1).seq : null, head: this.#meta.get('head') || 0 };
347
+ }
348
+ retry(id) {
349
+ const op = this.get(id);
350
+ if (!op) throw failure('NOT_FOUND', 'Operation not found');
351
+ if (!terminal(op.state)) this.#save(op, { state: 'pending', nextAt: 0, error: null });
352
+ this.#requested = true;
353
+ return this.drain().then(() => this.get(id));
354
+ }
355
+ async stop() {
356
+ this.#stopped = true;
357
+ clearInterval(this.#timer);
358
+ await this.#running;
359
+ }
360
+ releaseExecutor() {
361
+ this.#stored.index.transaction(() => {
362
+ if (this.#meta.get('executor')?.token === this.#lease.token) this.#meta.removeSync('executor');
363
+ });
364
+ if (this.#lockFd != null) { closeSync(this.#lockFd); this.#lockFd = null; }
365
+ }
366
+ async drain() {
367
+ if (this.#stopped) return;
368
+ if (this.#running) return this.#running;
369
+ this.#running = (async () => {
370
+ do { this.#requested = false; await this.#drain(); } while (this.#requested && !this.#stopped);
371
+ })();
372
+ try { await this.#running; } finally { this.#running = null; }
373
+ }
374
+ async #drain() {
375
+ if (this.#stopped) return;
376
+ // A crash before a physical write may leave an unacknowledged ingress
377
+ // pin. Retain uncertainty while the backend is unavailable; otherwise
378
+ // reclaim only an old preparation that did not become the live file.
379
+ await this.#stored.withStorageMutation(async () => {
380
+ for (const { value } of this.#ingress.getRange()) {
381
+ if (value.state !== 'preparing' || Date.now() - value.createdAt < 3600_000) continue;
382
+ try {
383
+ if ((await this.#inspect(value))?.id === value.contentId) this.commitIngress(value);
384
+ else await this.discardIngress(value);
385
+ } catch { /* An unavailable or changing source is not proof of absence. */ }
386
+ }
387
+ });
388
+ if (!this.#stopped) await this.#stored.resumePendingMoves();
389
+ const blockedOwners = new Set();
390
+ const rows = [...this.#ops.getRange()].map(r => r.value).sort((a, b) => a.seq - b.seq);
391
+ let visited = 0;
392
+ for (const row of rows) {
393
+ // Awaiting an already-resolved promise only drains microtasks. A
394
+ // large receipt/backoff backlog must still let sockets and timers run.
395
+ if (++visited % 32 === 0) await yieldToIO();
396
+ if (this.#stopped) break;
397
+ const op = this.get(row.id);
398
+ if (terminal(op.state)) { await this.#cleanup(op); continue; }
399
+ if (blockedOwners.has(op.owner)) continue;
400
+ if (op.state === 'blocked' || op.nextAt > Date.now()) { blockedOwners.add(op.owner); continue; }
401
+ try {
402
+ this.#save(op, { state: 'running', error: null });
403
+ await this.#stored.withStorageMutation(() => this.#apply(op));
404
+ } catch (error) {
405
+ const attempts = op.attempts + 1;
406
+ this.#save(op, { state: transient(error) ? 'pending' : 'blocked', attempts,
407
+ nextAt: transient(error) ? Date.now() + backoffMs(attempts) : 0,
408
+ error: { code: error.code || 'IO_ERROR', message: error.message } });
409
+ blockedOwners.add(op.owner);
410
+ }
411
+ }
412
+ if (!this.#stopped) await this.#stored.resumePendingMoves();
413
+ }
414
+ #save(op, patch) {
415
+ Object.assign(op, patch, { updatedAt: Date.now() });
416
+ this.#ops.putSync(op.id, op);
417
+ this.emit('changed', clone(op));
418
+ }
419
+ async #cleanup(op) {
420
+ if (op.state === 'complete' && op.plan.ingressSeq) {
421
+ const source = op.plan.ingressSource;
422
+ for (const { value } of this.#ingress.getRange()) {
423
+ if ((value.owner === op.owner || (value.owner == null && source && address(value) === address(source))) && value.seq <= op.plan.ingressSeq) await this.discardIngress(value);
424
+ }
425
+ }
426
+ if (op.cleaned) return;
427
+ let successor = op;
428
+ const visited = new Set();
429
+ while (successor?.state === 'cancelled' && successor.supersededBy && !visited.has(successor.id)) {
430
+ visited.add(successor.id); successor = this.get(successor.supersededBy);
431
+ }
432
+ if (successor?.state !== 'complete') return;
433
+ if (op.stageKey) await this.#stored.cache.delete(op.stageKey);
434
+ if (op.plan.sourceCacheKey) {
435
+ if ([...this.#ops.getRange()].some(({ value }) => !terminal(value.state) && value.plan.sourceCacheKey === op.plan.sourceCacheKey)) return;
436
+ await this.#stored.cache.delete(op.plan.sourceCacheKey);
437
+ }
438
+ this.#ops.putSync(op.id, { ...op, cleaned: true });
439
+ }
440
+ #backend(name) {
441
+ const backend = this.#stored.getBackend(name);
442
+ if (!backend) throw failure('BACKEND_UNAVAILABLE', `Backend ${name} is unavailable`, true);
443
+ return backend;
444
+ }
445
+ async #inspect(endpoint) {
446
+ const backend = this.#backend(endpoint.backend);
447
+ if (typeof backend.verifyRoot === 'function' && !(await backend.verifyRoot()).ok) throw failure('BACKEND_UNAVAILABLE', `Backend ${endpoint.backend} is offline`, true);
448
+ const stat = endpoint.kind === 'directory' ? await backend.statContainer(endpoint.key) : await backend.stat(endpoint.key);
449
+ if (!stat) return null;
450
+ if (endpoint.kind === 'directory') return { kind: 'directory' };
451
+ const input = await backend.get(endpoint.key, { stream: true });
452
+ if (!input) throw failure('SOURCE_CHANGED', `Cannot read ${endpoint.backend}:${endpoint.key}`, true);
453
+ const hash = createHash('sha256');
454
+ for await (const chunk of Buffer.isBuffer(input) ? Readable.from([input]) : input) hash.update(chunk);
455
+ const after = await backend.stat(endpoint.key);
456
+ if (!after || ['size', 'modified', 'ino', 'dev', 'id'].some(k => stat[k] !== after[k])) throw failure('SOURCE_CHANGED', `File changed while reading ${endpoint.key}`, true);
457
+ return { id: `sha256:${hash.digest('hex')}`, stat: after };
458
+ }
459
+ async #stage(op) {
460
+ const stageKey = `replica-operation:${op.id}`;
461
+ let info = await this.#stored.cache.getInfo(stageKey);
462
+ if (info) {
463
+ const hash = createHash('sha256');
464
+ for await (const chunk of this.#stored.cache.getStream(stageKey)) hash.update(chunk);
465
+ if (`sha256:${hash.digest('hex')}` !== (op.plan.contentId || op.base?.contentId)) throw failure('STAGING_CORRUPT', 'Recovery content failed verification');
466
+ this.#save(op, { stageKey });
467
+ return info;
468
+ }
469
+ const source = op.plan.source || op.base?.replicas?.find(r => r.kind !== 'directory');
470
+ if (op.plan.sourceCacheKey) {
471
+ const hash = createHash('sha256');
472
+ const tap = new Transform({ transform(chunk, encoding, done) { hash.update(chunk); done(null, chunk); } });
473
+ await pipeline(this.#stored.cache.getStream(op.plan.sourceCacheKey), tap, this.#stored.cache.putStream(stageKey, { operation: op.id }));
474
+ if (`sha256:${hash.digest('hex')}` !== op.plan.contentId) {
475
+ await this.#stored.cache.delete(stageKey);
476
+ throw failure('STAGING_CORRUPT', 'Transfer source failed checksum verification');
477
+ }
478
+ this.#save(op, { stageKey, phase: 'place' });
479
+ return this.#stored.cache.getInfo(stageKey);
480
+ }
481
+ if (!source) throw failure('NO_SOURCE', 'No recoverable byte source was supplied');
482
+ const backend = this.#backend(source.backend);
483
+ if (typeof backend.verifyRoot === 'function' && !(await backend.verifyRoot()).ok) throw failure('BACKEND_UNAVAILABLE', `Backend ${source.backend} is offline`, true);
484
+ const before = await backend.stat(source.key);
485
+ if (!before) throw failure('SOURCE_MISSING', `Source ${source.backend}:${source.key} is missing`);
486
+ const input = await backend.get(source.key, { stream: true });
487
+ if (!input) throw failure('SOURCE_MISSING', 'Source is unreadable');
488
+ const hash = createHash('sha256');
489
+ const tap = new Transform({ transform(chunk, encoding, done) { hash.update(chunk); done(null, chunk); } });
490
+ await pipeline(Buffer.isBuffer(input) ? Readable.from([input]) : input, tap, this.#stored.cache.putStream(stageKey, { operation: op.id }));
491
+ const actual = `sha256:${hash.digest('hex')}`;
492
+ const expected = op.plan.contentId || op.base?.contentId;
493
+ const after = await backend.stat(source.key);
494
+ if (actual !== expected || !after || ['size', 'modified', 'ino', 'dev', 'id'].some(k => before[k] !== after[k])) {
495
+ await this.#stored.cache.delete(stageKey);
496
+ throw failure('SOURCE_CHANGED', 'Source no longer contains the accepted content version');
497
+ }
498
+ info = await this.#stored.cache.getInfo(stageKey);
499
+ this.#save(op, { stageKey, phase: 'place' });
500
+ return info;
501
+ }
502
+ #otherClaims(op, endpoint) {
503
+ const wanted = address(endpoint);
504
+ for (const value of this.ownersAt(endpoint.backend, endpoint.key)) {
505
+ if (value.owner !== op.owner && value.replicas.some(r => address(r) === wanted)) return true;
506
+ }
507
+ for (const { value } of this.#ops.getRange()) {
508
+ if (value.id !== op.id && !terminal(value.state)
509
+ && [...value.plan.replicas, ...(value.plan.source ? [value.plan.source] : [])].some(r => address(r) === wanted)) return true;
510
+ }
511
+ return false;
512
+ }
513
+ async #apply(op) {
514
+ this.#assertOwnerActive(op);
515
+ if (!op.base) this.#save(op, { base: this.owner(op.owner) || { owner: op.owner, replicas: [] } });
516
+ const files = op.plan.replicas.some(r => r.kind !== 'directory');
517
+ // An immutable staging pin is retained for the entire operation, even
518
+ // when workspace:data is released or the original is moved externally.
519
+ const info = (files || op.base.replicas.some(r => r.kind !== 'directory')) ? await this.#stage(op) : null;
520
+ for (let i = 0; i < op.plan.replicas.length; i++) {
521
+ this.#assertOwnerActive(op);
522
+ const target = op.plan.replicas[i];
523
+ let outcome = op.outcomes[i];
524
+ const backend = this.#backend(target.backend);
525
+ if (!outcome) {
526
+ const previous = op.base.replicas.find(r => r.backend === target.backend && (r.requestedKey || r.key) === target.key);
527
+ const resolved = previous && target.onConflict === 'rename' ? { ...target, key: previous.key } : target;
528
+ const current = await this.#inspect(resolved);
529
+ if (previous?.managed && current?.id && current.id !== previous.contentId && current.id !== op.plan.contentId) {
530
+ throw failure('TARGET_CHANGED', 'A managed replica changed since its last verified version');
531
+ }
532
+ outcome = { ...resolved, requestedKey: target.key, contentId: op.plan.contentId || null, state: 'pending',
533
+ managed: Boolean(previous?.managed || !current || target.adopt === true
534
+ || this.ownersAt(target.backend, target.key).some(owner => owner.replicas.some(r => r.managed && address(r) === address(target)))),
535
+ expected: current?.id || null,
536
+ // An owned, verified old version is an update, not a name
537
+ // collision. Persist its version before any replacement.
538
+ replaces: previous?.managed && previous.mirror !== false && this.capabilities(target.backend).overwrite
539
+ ? previous.contentId : null };
540
+ if (current?.id && current.id !== op.plan.contentId && current.id !== outcome.replaces && target.onConflict === 'rename') {
541
+ let n = 1;
542
+ const dot = target.key.lastIndexOf('.');
543
+ const split = dot > target.key.lastIndexOf('/') ? dot : target.key.length;
544
+ do { outcome.key = `${target.key.slice(0, split)}-${n++}${target.key.slice(split)}`; } while (await backend.stat(outcome.key));
545
+ outcome.expected = null;
546
+ outcome.managed = true;
547
+ }
548
+ op.outcomes[i] = outcome;
549
+ this.#save(op, { outcomes: op.outcomes });
550
+ }
551
+ if (target.kind === 'directory') {
552
+ this.#assertOwnerActive(op);
553
+ await this.#stored.ensureDirectory(target.backend, outcome.key);
554
+ } else {
555
+ const found = await this.#inspect(outcome);
556
+ if (found?.id !== op.plan.contentId) {
557
+ if (found && found.id !== outcome.replaces && target.onConflict !== 'overwrite') throw failure('TARGET_CONFLICT', `Destination ${outcome.backend}:${outcome.key} contains different bytes`);
558
+ if (found && this.#otherClaims(op, outcome) && found.id !== op.plan.contentId) throw failure('SHARED_REPLICA', 'Another owner or pending operation still needs this byte version');
559
+ if (found && found.id !== outcome.expected) throw failure('TARGET_CHANGED', 'Destination changed after the plan was accepted');
560
+ if (!found && outcome.expected && outcome.state !== 'pending') throw failure('TARGET_CHANGED', 'A previously completed destination disappeared');
561
+ this.#assertOwnerActive(op);
562
+ const res = backend.config.driver === 'cacache'
563
+ ? await this.#stored.put(info.path, { backends: [outcome.backend], key: outcome.key, origin: `replica:${op.id}` })
564
+ : await this.#stored.writeObject(outcome.backend, outcome.key, info.path, {
565
+ sha256: op.plan.contentId.slice(7),
566
+ ...(found ? { ifMatch: found.id.slice(7) } : { ifNoneMatch: '*' }),
567
+ origin: `replica:${op.id}`,
568
+ });
569
+ if (!res?.ok) throw failure(res?.reason || 'WRITE_FAILED', `Replica write failed: ${res?.reason || 'unknown'}`);
570
+ const verified = await this.#inspect(outcome);
571
+ if (verified?.id !== op.plan.contentId) throw failure('VERIFY_FAILED', 'Destination verification failed');
572
+ }
573
+ }
574
+ if (target.kind !== 'directory') await this.#stored.acknowledgeReplica(outcome.backend, outcome.key, op.plan.contentId, `replica:${op.id}`);
575
+ outcome.state = 'verified';
576
+ this.#save(op, { outcomes: op.outcomes });
577
+ }
578
+ this.#save(op, { phase: 'retire' });
579
+ for (const old of op.base.replicas) {
580
+ this.#assertOwnerActive(op);
581
+ if (op.outcomes.some(r => address(r) === address(old)) || (op.plan.preserve || []).some(r => address(r) === address(old)) || old.mirror === false || !old.managed || this.#otherClaims(op, old)) continue;
582
+ if (old.kind === 'directory') {
583
+ // Only empty owned directories may be retired. Unknown children
584
+ // are never swept into a policy's recursive deletion.
585
+ await this.#stored.removeEmptyDirectory(old.backend, old.key);
586
+ continue;
587
+ }
588
+ const found = await this.#inspect(old);
589
+ if (!found) continue;
590
+ if (found.id !== old.contentId) throw failure('TARGET_CHANGED', 'The replica to remove has been edited');
591
+ if (!this.capabilities(old.backend).retire) throw failure('RECOVERY_UNAVAILABLE', 'Recoverable removal is unavailable');
592
+ const res = await this.#stored.removeObject(old.backend, old.key, { ifMatch: old.contentId.slice(7), origin: `replica:${op.id}` });
593
+ if (!res?.ok) throw failure(res?.reason || 'REMOVE_FAILED', 'Replica retirement failed');
594
+ }
595
+ // Verify ALL destinations again before releasing a single staging
596
+ // location. Presence in the index, or a queued upload, is insufficient.
597
+ const fileOutcomes = op.outcomes.filter(r => r.kind !== 'directory');
598
+ for (const target of fileOutcomes) {
599
+ if ((await this.#inspect(target))?.id !== op.plan.contentId) throw failure('VERIFY_FAILED', 'A destination changed before completion');
600
+ }
601
+ if (fileOutcomes.length) for (const loc of op.plan.release || []) {
602
+ this.#assertOwnerActive(op);
603
+ if (this.#otherClaims(op, loc)) continue;
604
+ const current = await this.#stored.stat(`${loc.backend}:${loc.key}`);
605
+ if (current && current.id !== (loc.contentId || op.plan.contentId)) throw failure('SOURCE_CHANGED', 'Staging identity changed');
606
+ if (current) {
607
+ const result = await this.#stored.deleteByUrl(`stored://${loc.backend}/${loc.key}`);
608
+ if (!result.ok) throw failure('RELEASE_FAILED', 'Staging release failed', true);
609
+ }
610
+ }
611
+ const preserved = op.base.replicas.filter(r => (r.mirror === false || (op.plan.preserve || []).some(p => address(p) === address(r)))
612
+ && !op.outcomes.some(n => address(n) === address(r)) && !(op.plan.release || []).some(n => address(n) === address(r))).map(r => ({ ...r, mirror: false }));
613
+ const owner = { owner: op.owner, operationId: op.id, contentId: op.plan.contentId || op.base.contentId,
614
+ replicas: [...preserved, ...op.outcomes], updatedAt: Date.now() };
615
+ this.#updateOwners(() => {
616
+ this.#owners.putSync(op.owner, owner);
617
+ Object.assign(op, { state: 'complete', phase: 'complete', updatedAt: Date.now() });
618
+ this.#ops.putSync(op.id, op);
619
+ });
620
+ this.emit('changed', clone(op));
621
+ await this.#cleanup(op);
622
+ }
623
+ }