@augmentd-labs/canvas-edge 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/node_modules/canvas-stored/README.md +189 -9
- package/node_modules/canvas-stored/package.json +2 -2
- package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
- package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
- package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
- package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
- package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
- package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
- package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
- package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
- package/node_modules/canvas-stored/src/cache/index.js +39 -5
- package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +5 -3
- package/node_modules/canvas-stored/src/index.js +733 -132
- package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
- package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
- package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
- package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
- package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
- package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
- package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
- package/node_modules/canvas-stored/src/trash.js +219 -0
- package/node_modules/canvas-stored/src/utils/common.js +7 -0
- package/package.json +5 -4
- package/src/binding.js +33 -0
- package/src/control.js +13 -0
- package/src/daemon.js +14 -1
- package/src/env.js +1 -1
- package/src/mirror-runtime.js +15 -4
|
@@ -1,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
|
|
17
|
-
import
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.#
|
|
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',
|
|
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
|
-
|
|
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' && !
|
|
352
|
+
const ingest = (isBuffer(blob) || (typeof blob === 'string' && !await isFileAsync(blob)))
|
|
252
353
|
? await this.#ingestMemory(blob, targets, key, options.mimeType, ingestOptions)
|
|
253
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
-
|
|
552
|
+
const retained = current ? await this.#retain(backendName, destKey, current) : null;
|
|
419
553
|
let placed = null;
|
|
420
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1387
|
-
if (!
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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.
|
|
1863
|
-
|
|
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
|
-
|
|
2006
|
-
|
|
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;
|