@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,111 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import cacache from 'cacache';
|
|
4
|
-
import Debug from 'debug';
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Background sync queue for remote backend writes.
|
|
10
|
-
*
|
|
11
|
-
* Every job is `{ id, cacheRoot, cacheKey, targets[] }` — the bytes already
|
|
12
|
-
* sit in the cache under `cacheKey`; each target gets a copy. Two lanes:
|
|
13
|
-
* - `driver: 'file'` targets are streamed by a lazily-spawned worker thread
|
|
14
|
-
* (disk-to-disk copy is the one thing worth taking off the event loop);
|
|
15
|
-
* - every other driver is committed IN-PROCESS through its live backend
|
|
16
|
-
* (`backend.commit(key, cachePath)`): network uploads are async I/O, and
|
|
17
|
-
* the driver's credentials, token and path cache live on the main thread.
|
|
18
|
-
* One `synced` event per job carries the merged results of both lanes.
|
|
19
|
-
*/
|
|
4
|
+
/** Compatibility adapter: ordinary remote transfers use the same durable
|
|
5
|
+
* executor and verification contract as explicit replica plans. */
|
|
20
6
|
export default class SyncQueue extends EventEmitter {
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
#pending = new Map(); // seq → resolve(results) for in-flight worker jobs
|
|
24
|
-
#resolveBackend;
|
|
25
|
-
|
|
26
|
-
/** @param {{ resolveBackend?: (name:string) => object|undefined }} [options] */
|
|
27
|
-
constructor({ resolveBackend = null } = {}) {
|
|
7
|
+
#service; #listener;
|
|
8
|
+
constructor({ service }) {
|
|
28
9
|
super();
|
|
29
|
-
this.#
|
|
10
|
+
this.#service = service;
|
|
11
|
+
this.#listener = operation => {
|
|
12
|
+
if (!operation.plan.transportJob) return;
|
|
13
|
+
if (!['complete', 'blocked'].includes(operation.state)) return;
|
|
14
|
+
this.emit('synced', { id: operation.plan.contentId, operationId: operation.id,
|
|
15
|
+
results: operation.plan.replicas.map((target, i) => ({
|
|
16
|
+
backend: target.backend, key: operation.outcomes[i]?.key || target.key,
|
|
17
|
+
success: operation.state === 'complete',
|
|
18
|
+
...(operation.error ? { error: operation.error.message } : {}),
|
|
19
|
+
})) });
|
|
20
|
+
};
|
|
21
|
+
service.on('changed', this.#listener);
|
|
30
22
|
}
|
|
31
|
-
|
|
32
23
|
enqueue(job) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (viaWorker.length) lanes.push(this.#viaWorker({ ...job, targets: viaWorker }));
|
|
38
|
-
if (inProcess.length) lanes.push(this.#inProcess(job, inProcess));
|
|
39
|
-
if (!lanes.length) return;
|
|
40
|
-
|
|
41
|
-
Promise.all(lanes)
|
|
42
|
-
.then((parts) => {
|
|
43
|
-
const results = parts.flat();
|
|
44
|
-
debug(`Sync complete: ${String(job.id).slice(0, 19)}... → ${results.map((r) => `${r.backend}:${r.success}`).join(', ')}`);
|
|
45
|
-
this.emit('synced', { id: job.id, results });
|
|
46
|
-
})
|
|
47
|
-
.catch((err) => this.emit('error', err));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async stop() {
|
|
51
|
-
if (this.#worker) {
|
|
52
|
-
await this.#worker.terminate();
|
|
53
|
-
this.#worker = null;
|
|
54
|
-
}
|
|
55
|
-
// Jobs the worker never answered must not hang their `synced` forever.
|
|
56
|
-
for (const [seq, resolve] of this.#pending) {
|
|
57
|
-
this.#pending.delete(seq);
|
|
58
|
-
resolve([]);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
#viaWorker(job) {
|
|
63
|
-
if (!this.#worker) this.#spawnWorker();
|
|
64
|
-
const seq = ++this.#seq;
|
|
65
|
-
return new Promise((resolve) => {
|
|
66
|
-
this.#pending.set(seq, resolve);
|
|
67
|
-
this.#worker.postMessage({ seq, ...job });
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async #inProcess({ cacheRoot, cacheKey }, targets) {
|
|
72
|
-
const info = await cacache.get.info(cacheRoot, cacheKey).catch(() => null);
|
|
73
|
-
if (!info) {
|
|
74
|
-
return targets.map((t) => ({ backend: t.name, key: t.key, success: false, error: 'Cache read failed: entry not found' }));
|
|
75
|
-
}
|
|
76
|
-
const results = [];
|
|
77
|
-
for (const target of targets) {
|
|
78
|
-
const backend = this.#resolveBackend(target.name);
|
|
79
|
-
if (!backend || typeof backend.commit !== 'function') {
|
|
80
|
-
results.push({ backend: target.name, key: target.key, success: false, error: `Backend not registered: ${target.name}` });
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
await backend.commit(target.key, info.path);
|
|
85
|
-
results.push({ backend: target.name, key: target.key, success: true });
|
|
86
|
-
} catch (err) {
|
|
87
|
-
debug(`In-process sync to ${target.name}:${target.key} failed: ${err.message}`);
|
|
88
|
-
results.push({ backend: target.name, key: target.key, success: false, error: err.message });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return results;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
#spawnWorker() {
|
|
95
|
-
this.#worker = new Worker(new URL('./worker.js', import.meta.url));
|
|
96
|
-
this.#worker.on('message', (msg) => {
|
|
97
|
-
const resolve = this.#pending.get(msg.seq);
|
|
98
|
-
if (resolve) {
|
|
99
|
-
this.#pending.delete(msg.seq);
|
|
100
|
-
resolve(msg.results || []);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
// No correlation (foreign/legacy message) — surface as-is.
|
|
104
|
-
this.emit('synced', msg);
|
|
105
|
-
});
|
|
106
|
-
this.#worker.on('error', (err) => {
|
|
107
|
-
debug(`Worker error: ${err.message}`);
|
|
108
|
-
this.emit('error', err);
|
|
24
|
+
return this.#service.submit({
|
|
25
|
+
id: `transfer:${randomUUID()}`, owner: `transfer:${createHash('sha256').update(JSON.stringify([job.id, job.targets])).digest('hex')}`,
|
|
26
|
+
contentId: job.id, sourceCacheKey: job.cacheKey, transportJob: true,
|
|
27
|
+
replicas: job.targets.map(t => ({ backend: t.name, key: t.key, mirror: false, onConflict: 'error' })),
|
|
109
28
|
});
|
|
110
29
|
}
|
|
30
|
+
async stop() { this.#service.off('changed', this.#listener); }
|
|
111
31
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { Readable, Transform } from 'node:stream';
|
|
3
|
+
import { pipeline } from 'node:stream/promises';
|
|
4
|
+
|
|
5
|
+
// Per-deletion provenance, separate from the deduplicated retained blob pool.
|
|
6
|
+
// Restoring one path never consumes the bytes needed by another trash item.
|
|
7
|
+
export default class BackendTrash {
|
|
8
|
+
tasks = new Map();
|
|
9
|
+
constructor(stored) { this.stored = stored; }
|
|
10
|
+
get db() { return this._db ??= this.stored.index.openDB('backend-trash-v1'); }
|
|
11
|
+
get days() { return this.stored.retention?.days || 0; }
|
|
12
|
+
|
|
13
|
+
prepare(backend, key, { retained = null, directory = false, mtime = null, reason = 'delete' } = {}) {
|
|
14
|
+
if (!this.days || (!directory && !retained)) return null;
|
|
15
|
+
const item = {
|
|
16
|
+
id: crypto.randomUUID(), backend, key, type: directory ? 'directory' : 'file',
|
|
17
|
+
sha256: retained?.sha256 ?? null, size: retained?.size ?? null,
|
|
18
|
+
mimeType: retained?.mimeType ?? null, mtime, reason, deletedAt: Date.now(), state: 'pending',
|
|
19
|
+
};
|
|
20
|
+
this.db.putSync(`item:${item.id}`, item);
|
|
21
|
+
return item;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
finish(item) {
|
|
25
|
+
if (item) this.db.putSync(`item:${item.id}`, { ...item, state: 'active' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async remove(backendName, key, { operationId, sha256 } = {}) {
|
|
29
|
+
if (!operationId || !/^[a-f0-9]{64}$/i.test(sha256 || '')) return { ok: false, reason: 'content-identity-required' };
|
|
30
|
+
const backend = this.stored.getBackend(backendName);
|
|
31
|
+
if (!backend?.canDelete || backend.config.readOnly || backend.config.removalPolicy?.acceptPropagatedDeletions !== true) return { ok: false, reason: 'protected-backend' };
|
|
32
|
+
// Remote drivers must implement conditional removal before joining this
|
|
33
|
+
// executor. A stale listing is never authority to erase a newer object.
|
|
34
|
+
if (!['file', 'cacache'].includes(backend.config.driver)) return { ok: false, reason: 'conditional-removal-unsupported' };
|
|
35
|
+
if (backend.config.driver === 'file' && !this.days) return { ok: false, reason: 'file-recovery-unavailable' };
|
|
36
|
+
const digest = crypto.createHash('sha256').update(operationId).digest('hex');
|
|
37
|
+
const id = `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(16, 20)}-${digest.slice(20, 32)}`;
|
|
38
|
+
let item = this.db.get(`item:${id}`);
|
|
39
|
+
if (item && (item.backend !== backendName || item.key !== key || item.sha256 !== sha256)) return { ok: false, reason: 'operation-conflict' };
|
|
40
|
+
if (item && ['active', 'restored', 'discarded'].includes(item.state)) return { ok: true, trashId: id, state: item.state };
|
|
41
|
+
if (typeof backend.verifyRoot === 'function' && !(await backend.verifyRoot()).ok) return { ok: false, reason: 'backend-unavailable' };
|
|
42
|
+
const current = await backend.stat(key);
|
|
43
|
+
if (!current) {
|
|
44
|
+
if (item?.recoveryReady) { this.finish(item); return { ok: true, trashId: id }; }
|
|
45
|
+
return { ok: true, missing: true };
|
|
46
|
+
}
|
|
47
|
+
item ||= { id, backend: backendName, key, type: 'file', sha256, reason: 'propagated-delete', manual: true,
|
|
48
|
+
deletedAt: Date.now(), state: 'pending', cacheKey: `trash:${id}`, size: current.size, mtime: current.modified };
|
|
49
|
+
this.db.putSync(`item:${id}`, item);
|
|
50
|
+
if (!item.recoveryReady) {
|
|
51
|
+
const input = await backend.get(key, { stream: true });
|
|
52
|
+
if (!input) return { ok: false, reason: 'source-unreadable' };
|
|
53
|
+
const hash = crypto.createHash('sha256');
|
|
54
|
+
await pipeline(Buffer.isBuffer(input) ? Readable.from([input]) : input,
|
|
55
|
+
new Transform({ transform(chunk, encoding, done) { hash.update(chunk); done(null, chunk); } }),
|
|
56
|
+
this.stored.cache.putStream(item.cacheKey, { trashId: id }));
|
|
57
|
+
if (hash.digest('hex') !== sha256) { await this.stored.cache.delete(item.cacheKey); return { ok: false, reason: 'source-changed' }; }
|
|
58
|
+
item.recoveryReady = true; this.db.putSync(`item:${id}`, item);
|
|
59
|
+
}
|
|
60
|
+
const result = await this.stored.removeObject(backendName, key, { ifMatch: sha256, origin: `trash:${id}`, trashItem: item });
|
|
61
|
+
if (!result.ok) return result;
|
|
62
|
+
this.finish(item);
|
|
63
|
+
return { ...result, trashId: id };
|
|
64
|
+
}
|
|
65
|
+
quarantine(item) {
|
|
66
|
+
item.quarantined = true;
|
|
67
|
+
this.db.putSync(`item:${item.id}`, item);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async importLegacy(backendName) {
|
|
71
|
+
const backend = this.stored.getBackend(backendName);
|
|
72
|
+
if (!backend || !this.days) return;
|
|
73
|
+
for (const retained of this.stored.listRetained({ backend: backendName, limit: Infinity })) {
|
|
74
|
+
const keys = retained.legacyKeys ?? (retained.trashVersion ? [] : retained.keys || []);
|
|
75
|
+
for (const address of keys) {
|
|
76
|
+
if (!address.startsWith(`${backendName}:`)) continue;
|
|
77
|
+
const key = address.slice(backendName.length + 1);
|
|
78
|
+
const deletedAt = retained.legacyAt ?? retained.lastAt;
|
|
79
|
+
const id = crypto.createHash('sha256').update(JSON.stringify([backendName, key, retained.sha256, deletedAt])).digest('hex');
|
|
80
|
+
if (this.db.get(`legacy:${id}`)) continue;
|
|
81
|
+
// Old retention mixed overwrites and deletes. Import absent
|
|
82
|
+
// original paths only, never old versions of a live file.
|
|
83
|
+
const current = await backend.stat(key);
|
|
84
|
+
if (!current) this.db.putSync(`item:${id}`, {
|
|
85
|
+
id, backend: backendName, key, type: 'file', sha256: retained.sha256,
|
|
86
|
+
size: retained.size, mimeType: retained.mimeType, mtime: null,
|
|
87
|
+
deletedAt, state: 'active', legacy: true,
|
|
88
|
+
});
|
|
89
|
+
this.db.putSync(`legacy:${id}`, true);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async get(backendName, id) {
|
|
95
|
+
let item = this.db.get(`item:${id}`);
|
|
96
|
+
if (!item || item.backend !== backendName || ['restored', 'discarded'].includes(item.state)
|
|
97
|
+
|| (!item.manual && (!this.days || Date.now() - item.deletedAt >= this.days * 86400000))) return null;
|
|
98
|
+
if (item.state === 'pending') {
|
|
99
|
+
if (item.manual && !item.recoveryReady) return null;
|
|
100
|
+
const backend = this.stored.getBackend(backendName);
|
|
101
|
+
const deleted = item.type === 'directory' || item.quarantined
|
|
102
|
+
? await backend.hasTrashedDirectory(item.id)
|
|
103
|
+
: !await backend.stat(item.key);
|
|
104
|
+
if (!deleted) return null;
|
|
105
|
+
this.finish(item);
|
|
106
|
+
item = { ...item, state: 'active' };
|
|
107
|
+
}
|
|
108
|
+
return item;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async list(backendName, { cursor = null, limit = 200 } = {}) {
|
|
112
|
+
limit = Math.max(1, Math.min(1000, Number(limit) || 200));
|
|
113
|
+
await this.importLegacy(backendName);
|
|
114
|
+
const items = [];
|
|
115
|
+
for (const { key, value } of [...this.db.getRange()]) {
|
|
116
|
+
if (!key.startsWith('item:') || value.backend !== backendName
|
|
117
|
+
|| (cursor && value.id.localeCompare(cursor) <= 0)) continue;
|
|
118
|
+
const item = await this.get(backendName, value.id);
|
|
119
|
+
// Overwrite captures back retained versions, not user deletions.
|
|
120
|
+
if (item && item.reason !== 'overwrite') items.push(item);
|
|
121
|
+
}
|
|
122
|
+
items.sort((a, b) => a.id.localeCompare(b.id));
|
|
123
|
+
const page = items.slice(0, limit);
|
|
124
|
+
return { items: page.map(({ state: _state, backend: _backend, ...item }) => item),
|
|
125
|
+
cursor: items.length > limit ? page.at(-1).id : null, retention: this.stored.retention };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
withItemLock(id, fn) {
|
|
129
|
+
const task = (this.tasks.get(id) || Promise.resolve()).catch(() => {}).then(fn);
|
|
130
|
+
this.tasks.set(id, task);
|
|
131
|
+
void task.finally(() => { if (this.tasks.get(id) === task) this.tasks.delete(id); }).catch(() => {});
|
|
132
|
+
return task;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
restore(backendName, id, options = {}) {
|
|
136
|
+
return this.withItemLock(id, () => this.restoreItem(backendName, id, options));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async restoreItem(backendName, id, { origin = null } = {}) {
|
|
140
|
+
const item = await this.get(backendName, id);
|
|
141
|
+
if (!item) return { ok: false, reason: 'not-found', id };
|
|
142
|
+
if (item.state === 'discarding') return { ok: false, reason: 'deletion-pending', id };
|
|
143
|
+
await this.stored.getBackend(backendName).validateTrashTarget?.(item.key);
|
|
144
|
+
const result = item.cacheKey
|
|
145
|
+
? await this.restorePinned(item, origin)
|
|
146
|
+
: item.type === 'directory' || item.quarantined
|
|
147
|
+
? await this.stored.getBackend(backendName).restoreTrashedDirectory(id, item.key)
|
|
148
|
+
: await this.stored.restoreRetained(item.sha256, {
|
|
149
|
+
backend: backendName, key: item.key, ifNoneMatch: '*', mtime: item.mtime, origin,
|
|
150
|
+
});
|
|
151
|
+
if (item.quarantined && result.reason === 'target-exists') result.reason = 'precondition-failed';
|
|
152
|
+
if (result?.ok && item.quarantined) await this.stored.refreshObject(backendName, item.key, origin);
|
|
153
|
+
if (result?.ok) this.db.putSync(`item:${id}`, { ...item, state: 'restored', restoredAt: Date.now() });
|
|
154
|
+
if (result?.ok && item.cacheKey) {
|
|
155
|
+
await this.stored.cache.delete(item.cacheKey);
|
|
156
|
+
if (item.quarantined) await this.stored.getBackend(backendName).dropTrashedDirectory(id);
|
|
157
|
+
}
|
|
158
|
+
return { ...result, id, key: item.key, type: item.type };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async restorePinned(item, origin) {
|
|
162
|
+
const backend = this.stored.getBackend(item.backend);
|
|
163
|
+
if (!backend?.canWrite || backend.config.readOnly) return { ok: false, reason: 'read-only-backend' };
|
|
164
|
+
const hash = crypto.createHash('sha256');
|
|
165
|
+
for await (const chunk of this.stored.cache.getStream(item.cacheKey)) hash.update(chunk);
|
|
166
|
+
if (hash.digest('hex') !== item.sha256) return { ok: false, reason: 'recovery-content-changed' };
|
|
167
|
+
if (backend.config.driver === 'cacache') {
|
|
168
|
+
// Stored's physical mutation lock covers this check and put. The
|
|
169
|
+
// managed blob store has no external writers.
|
|
170
|
+
if (await backend.stat(item.key)) return { ok: false, reason: 'precondition-failed' };
|
|
171
|
+
const info = await this.stored.cache.getInfo(item.cacheKey);
|
|
172
|
+
return this.stored.put(info.path, { backends: [item.backend], key: item.key, origin });
|
|
173
|
+
}
|
|
174
|
+
return this.stored.writeObject(item.backend, item.key, this.stored.cache.getStream(item.cacheKey), { sha256: item.sha256, ifNoneMatch: '*', origin, mtime: item.mtime });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
hasReference(sha256, exceptId, address = null) {
|
|
178
|
+
for (const { key, value } of this.db.getRange()) {
|
|
179
|
+
if (key.startsWith('item:') && value.id !== exceptId && value.sha256 === sha256
|
|
180
|
+
&& !['discarded', 'restored'].includes(value.state)
|
|
181
|
+
&& (!address || `${value.backend}:${value.key}` === address)) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
discard(backendName, id) {
|
|
187
|
+
return this.withItemLock(id, () => this.discardItem(backendName, id));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async discardItem(backendName, id) {
|
|
191
|
+
const item = this.db.get(`item:${id}`);
|
|
192
|
+
if (!item || item.backend !== backendName || item.state === 'restored') return { id, ok: false, reason: 'not-found' };
|
|
193
|
+
const result = { id, key: item.key, type: item.type, ok: true };
|
|
194
|
+
if (item.state === 'discarded') return { ...result, alreadyDeleted: true };
|
|
195
|
+
const backend = this.stored.getBackend(backendName);
|
|
196
|
+
if (!backend?.canDelete) return { id, ok: false, reason: 'read-only-backend' };
|
|
197
|
+
// Persist intent before removing bytes. Interrupted/failed deletion can
|
|
198
|
+
// be retried, but must not race a subsequent attempt to restore it.
|
|
199
|
+
this.db.putSync(`item:${id}`, { ...item, state: 'discarding' });
|
|
200
|
+
const finish = () => this.db.putSync(`item:${id}`, { ...item, state: 'discarded', discardedAt: Date.now() });
|
|
201
|
+
if (item.cacheKey) { if (item.quarantined) await backend.dropTrashedDirectory(id); await this.stored.cache.delete(item.cacheKey); finish(); }
|
|
202
|
+
else if (item.type === 'directory') { await backend.dropTrashedDirectory(id); finish(); }
|
|
203
|
+
else if (item.quarantined) { await backend.dropTrashedDirectory(id); await this.stored.discardTrashBytes(item, finish); }
|
|
204
|
+
else await this.stored.discardTrashBytes(item, finish);
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async sweep(now) {
|
|
209
|
+
for (const { key, value } of [...this.db.getRange()]) {
|
|
210
|
+
if (!key.startsWith('item:') || value.manual || now - value.deletedAt < this.days * 86400000) continue;
|
|
211
|
+
if (value.type === 'directory' || value.quarantined) {
|
|
212
|
+
const backend = this.stored.getBackend(value.backend);
|
|
213
|
+
if (!backend) continue;
|
|
214
|
+
await backend.dropTrashedDirectory(value.id);
|
|
215
|
+
}
|
|
216
|
+
this.db.removeSync(key);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -28,6 +28,13 @@ export function isFile(input) {
|
|
|
28
28
|
catch { return false; }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Ingestion must not synchronously stat paths that may live on a NAS. */
|
|
32
|
+
export async function isFileAsync(input) {
|
|
33
|
+
if (typeof input !== 'string') return false;
|
|
34
|
+
try { return (await fs.promises.stat(input)).isFile(); }
|
|
35
|
+
catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
export function isBuffer(input) {
|
|
32
39
|
return Buffer.isBuffer(input);
|
|
33
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@augmentd-labs/canvas-edge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Canvas device runtime: folder mirrors (canvas-stored Mirror engine) + edge tunnel client (dist artifact)",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@augmentd-labs/canvas-protocol": "^0.5.0",
|
|
24
|
-
"canvas-stored": "1.
|
|
24
|
+
"canvas-stored": "1.18.0",
|
|
25
|
+
"@aws-sdk/client-s3": "^3.1149.0",
|
|
25
26
|
"cacache": "^20.0.4",
|
|
26
27
|
"chokidar": "^5.0.0",
|
|
27
28
|
"debug": "^4.4.3",
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"fs-extra": "^11.3.4",
|
|
32
33
|
"image-size": "^2.0.2",
|
|
33
34
|
"imap": "^0.8.19",
|
|
34
|
-
"lmdb": "
|
|
35
|
+
"lmdb": "3.5.7",
|
|
35
36
|
"music-metadata": "^11.9.0",
|
|
36
37
|
"picomatch": "^2.3.1",
|
|
37
38
|
"fastify": "^4.29.1",
|
|
@@ -47,6 +48,6 @@
|
|
|
47
48
|
"bundleDependencies": [
|
|
48
49
|
"canvas-stored"
|
|
49
50
|
],
|
|
50
|
-
"canvasRev": "
|
|
51
|
+
"canvasRev": "9cc56fa",
|
|
51
52
|
"canvasSource": "runtimes/edge"
|
|
52
53
|
}
|
package/src/binding.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
/** Where the binding id lives inside a mirrored folder. */
|
|
8
|
+
export const BINDING_FILE = '.workspace/binding';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The id shared by a mirrored folder and its sync history (`stateDir`),
|
|
12
|
+
* created on first start. History is refused for a folder that does not carry
|
|
13
|
+
* the id, and for an empty folder when history exists but predates bindings:
|
|
14
|
+
* both are what an unmounted drive looks like.
|
|
15
|
+
*/
|
|
16
|
+
export function bindFolder(folder, stateDir, hadHistory) {
|
|
17
|
+
const read = (file) => { try { return fs.readFileSync(file, 'utf8').trim() || null; } catch { return null; } };
|
|
18
|
+
const marker = path.join(folder, BINDING_FILE);
|
|
19
|
+
const record = path.join(stateDir, 'binding');
|
|
20
|
+
const known = read(record);
|
|
21
|
+
if (known) {
|
|
22
|
+
if (read(marker) !== known) throw new Error(`${folder} is not the folder this mirror's sync history belongs to (drive not mounted, or the folder was replaced); nothing was synced. Mount it, or remove ${stateDir} to start the mirror from scratch`);
|
|
23
|
+
return known;
|
|
24
|
+
}
|
|
25
|
+
const empty = !fs.existsSync(folder) || fs.readdirSync(folder).every((name) => name.startsWith('.'));
|
|
26
|
+
if (hadHistory && empty) throw new Error(`${folder} is empty but ${stateDir} holds sync history for it (drive not mounted?); nothing was synced. Mount it, or remove ${stateDir} to start the mirror from scratch`);
|
|
27
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
28
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
29
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
30
|
+
fs.writeFileSync(marker, `${id}\n`);
|
|
31
|
+
fs.writeFileSync(record, `${id}\n`);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
package/src/control.js
CHANGED
|
@@ -31,6 +31,19 @@ export async function startControl({ runtimes, reload, shutdown, logger }) {
|
|
|
31
31
|
r.nudge();
|
|
32
32
|
return { ok: true };
|
|
33
33
|
});
|
|
34
|
+
// Local deletions held by the mirror's delete guard: list, send, or undo.
|
|
35
|
+
app.get('/mirrors/:id/deletes', async (req, reply) => {
|
|
36
|
+
const r = byId(req.params.id);
|
|
37
|
+
if (typeof r?.heldDeletes !== 'function') return reply.code(404).send({ error: 'no such mirror' });
|
|
38
|
+
return { held: r.heldDeletes() };
|
|
39
|
+
});
|
|
40
|
+
app.post('/mirrors/:id/deletes/:verb', async (req, reply) => {
|
|
41
|
+
const r = byId(req.params.id);
|
|
42
|
+
if (typeof r?.heldDeletes !== 'function') return reply.code(404).send({ error: 'no such mirror' });
|
|
43
|
+
if (req.params.verb === 'approve') return { approved: r.approveDeletes() };
|
|
44
|
+
if (req.params.verb === 'restore') return { restored: r.restoreDeletes() };
|
|
45
|
+
return reply.code(404).send({ error: 'approve or restore' });
|
|
46
|
+
});
|
|
34
47
|
app.post('/reload', async () => { await reload(); return { ok: true, mirrors: runtimes.size }; });
|
|
35
48
|
app.post('/shutdown', async () => { setTimeout(() => shutdown(), 50); return { ok: true }; });
|
|
36
49
|
|
package/src/daemon.js
CHANGED
|
@@ -8,6 +8,8 @@ import { MirrorRuntime } from './mirror-runtime.js';
|
|
|
8
8
|
import { FuseRuntime } from './fuse-runtime.js';
|
|
9
9
|
import { startControl } from './control.js';
|
|
10
10
|
|
|
11
|
+
const START_RETRY_MS = 60_000;
|
|
12
|
+
|
|
11
13
|
/*
|
|
12
14
|
* canvas-edge: the device-side daemon for real-folder mirrors (the non-FUSE
|
|
13
15
|
* client of the sync protocol, for macOS/Windows and anyone who wants a plain
|
|
@@ -32,7 +34,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
32
34
|
logger.error({ err: err?.message }, 'env-driven mirror config failed');
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// A mirror that cannot start (its drive is not mounted yet) is tried again.
|
|
38
|
+
let retry = null;
|
|
39
|
+
let stopping = false;
|
|
35
40
|
const load = async () => {
|
|
41
|
+
clearTimeout(retry);
|
|
42
|
+
retry = null;
|
|
43
|
+
let failed = false;
|
|
36
44
|
const wanted = daemonMirrors();
|
|
37
45
|
const fuse = fuseMirrors();
|
|
38
46
|
const wantedIds = new Set([...wanted, ...fuse].map((m) => m.id));
|
|
@@ -53,6 +61,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
53
61
|
await rt.start();
|
|
54
62
|
runtimes.set(mirror.id, rt);
|
|
55
63
|
} catch (err) {
|
|
64
|
+
failed = true;
|
|
65
|
+
await rt.stop().catch(() => {});
|
|
56
66
|
logger.error({ mirror: mirror.id, err: err?.message }, 'mirror failed to start');
|
|
57
67
|
}
|
|
58
68
|
}
|
|
@@ -67,9 +77,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
67
77
|
logger.error({ unit: mirror.id, err: err?.message }, 'fuse unit failed to start');
|
|
68
78
|
}
|
|
69
79
|
}
|
|
80
|
+
if (failed && !stopping) {
|
|
81
|
+
retry = setTimeout(() => load().catch((err) => logger.error({ err: err?.message }, 'reload failed')), START_RETRY_MS);
|
|
82
|
+
retry.unref?.();
|
|
83
|
+
}
|
|
70
84
|
};
|
|
71
85
|
|
|
72
|
-
let stopping = false;
|
|
73
86
|
const shutdown = async () => {
|
|
74
87
|
if (stopping) return;
|
|
75
88
|
stopping = true;
|
package/src/env.js
CHANGED
|
@@ -99,7 +99,7 @@ export function ensureEnvConfig(env = process.env) {
|
|
|
99
99
|
mountpoint: path.resolve(env.CANVAS_MIRROR_PATH ? String(env.CANVAS_MIRROR_PATH) : (existing.mountpoint || '/data')),
|
|
100
100
|
pins: env.CANVAS_PINS != null ? list(env.CANVAS_PINS) : (existing.pins || []),
|
|
101
101
|
ignore: env.CANVAS_IGNORE != null ? list(env.CANVAS_IGNORE) : (existing.ignore || []),
|
|
102
|
-
conflicts: oneOf(env.CANVAS_CONFLICTS, ['prompt', 'rename'], existing.conflicts || '
|
|
102
|
+
conflicts: oneOf(env.CANVAS_CONFLICTS, ['prompt', 'rename'], existing.conflicts || 'rename'),
|
|
103
103
|
deletes: oneOf(env.CANVAS_DELETES, ['propagate', 'keep'], existing.deletes || 'propagate'),
|
|
104
104
|
direction: oneOf(env.CANVAS_DIRECTION, ['bi', 'pull', 'push'], existing.direction || 'bi'),
|
|
105
105
|
client: 'daemon',
|
package/src/mirror-runtime.js
CHANGED
|
@@ -7,6 +7,7 @@ import { io } from 'socket.io-client';
|
|
|
7
7
|
import Stored, { Mirror } from 'canvas-stored';
|
|
8
8
|
import { MIRROR_IGNORE_DEFAULTS } from 'canvas-stored/src/sync/keys.js';
|
|
9
9
|
import { DEFAULT_SYNC_EXCLUSIONS, WORKSPACE_INTERNAL_EXCLUSIONS } from '@augmentd-labs/canvas-protocol';
|
|
10
|
+
import { BINDING_FILE, bindFolder } from './binding.js';
|
|
10
11
|
|
|
11
12
|
const STATUS_THROTTLE_MS = 5000;
|
|
12
13
|
const STATUS_HEARTBEAT_MS = 60000;
|
|
@@ -19,7 +20,11 @@ const STATUS_HEARTBEAT_MS = 60000;
|
|
|
19
20
|
* `<folder>/.workspace/` (mirror.json, db/stored, cache, tmp, trash,
|
|
20
21
|
* conflicts) — the folder is self-describing and movable — unless the mirror
|
|
21
22
|
* names a `stateDir` (or the daemon a CANVAS_EDGE_STATE_ROOT), in which case
|
|
22
|
-
* the folder holds user files
|
|
23
|
+
* the folder holds user files plus `.workspace/binding` and everything else
|
|
24
|
+
* lives there. The binding is a random id written to both the folder and the
|
|
25
|
+
* state dir: sync history is only ever applied to the folder that carries it,
|
|
26
|
+
* so an unmounted drive or a replaced folder is refused instead of read as
|
|
27
|
+
* "every file was deleted". Staging
|
|
23
28
|
* (temp) must share a filesystem with the folder for atomic placement, so
|
|
24
29
|
* it stays under the state dir only when that is on the same device and
|
|
25
30
|
* falls back to `<folder>/.stored-tmp` otherwise.
|
|
@@ -61,7 +66,9 @@ export class MirrorRuntime {
|
|
|
61
66
|
const folder = this.folder;
|
|
62
67
|
const internal = this.stateDir;
|
|
63
68
|
const external = internal !== path.join(folder, '.workspace');
|
|
69
|
+
const hadHistory = fs.existsSync(path.join(internal, 'db', 'stored'));
|
|
64
70
|
for (const d of ['db/stored', 'cache', 'trash', 'conflicts']) fs.mkdirSync(path.join(internal, d), { recursive: true });
|
|
71
|
+
const binding = bindFolder(folder, internal, hadHistory);
|
|
65
72
|
let tempDir = path.join(internal, 'tmp');
|
|
66
73
|
if (external && !canRenameInto(path.join(internal, 'tmp'), folder)) {
|
|
67
74
|
// st_dev lies for bind mounts (one disk, two mounts, rename refused):
|
|
@@ -84,7 +91,8 @@ export class MirrorRuntime {
|
|
|
84
91
|
this.#stored = new Stored({ root: path.join(internal, 'db', 'stored'), cache: { path: path.join(internal, 'cache') }, checksums: ['sha256'] });
|
|
85
92
|
this.#stored.on('error', (err) => this.#logger.warn({ mirror: this.id, err: err?.message }, 'stored error'));
|
|
86
93
|
this.#stored.addBackend('local', {
|
|
87
|
-
driver: 'file', root: folder,
|
|
94
|
+
driver: 'file', root: folder, createRoot: false, marker: { file: BINDING_FILE, id: binding },
|
|
95
|
+
watch: true, tempDir, followSymlinks: false, stabilityThreshold: 2000,
|
|
88
96
|
ignored: [...new Set([...WORKSPACE_INTERNAL_EXCLUSIONS, ...DEFAULT_SYNC_EXCLUSIONS, ...hubExclusions, ...MIRROR_IGNORE_DEFAULTS, ...(this.#mirror.ignore || [])])],
|
|
89
97
|
});
|
|
90
98
|
this.#stored.addBackend('trash', { driver: 'file', root: path.join(internal, 'trash'), watch: false });
|
|
@@ -98,7 +106,7 @@ export class MirrorRuntime {
|
|
|
98
106
|
this.#engine = new Mirror(this.#stored, {
|
|
99
107
|
id: this.#mirror.id, local: 'local', remote: 'canvas:hub', trash: 'trash', conflicts: 'conflicts',
|
|
100
108
|
prefixes: this.#mirror.pins || [], ignore: this.#mirror.ignore || [],
|
|
101
|
-
deletes: this.#mirror.deletes || 'propagate', conflictMode: this.#mirror.conflicts || '
|
|
109
|
+
deletes: this.#mirror.deletes || 'propagate', conflictMode: this.#mirror.conflicts || 'rename',
|
|
102
110
|
direction: this.#mirror.direction || 'bi',
|
|
103
111
|
deviceId: this.#identity.deviceId, deviceName: this.#identity.deviceName,
|
|
104
112
|
});
|
|
@@ -109,6 +117,7 @@ export class MirrorRuntime {
|
|
|
109
117
|
this.#engine.on('revert', (e) => this.#logger.info({ mirror: this.id, key: e?.key, copy: e?.copy }, 'local edit reverted (pull mirror), copy kept'));
|
|
110
118
|
this.#engine.on('skip', (e) => this.#logger.info({ mirror: this.id, key: e?.key, reason: e?.reason }, 'key skipped'));
|
|
111
119
|
this.#engine.on('conflict', (c) => this.#logger.info({ mirror: this.id, key: c?.key }, 'conflict recorded'));
|
|
120
|
+
this.#engine.on('hold', (h) => this.#logger.warn({ mirror: this.id, files: h?.count }, 'many files are missing locally — deletions held; approve or restore with `canvas remote mirror deletes`'));
|
|
112
121
|
await this.#engine.start();
|
|
113
122
|
await this.#connectSocket();
|
|
114
123
|
this.#reporter = setInterval(() => this.#report().catch(() => {}), STATUS_HEARTBEAT_MS);
|
|
@@ -135,7 +144,9 @@ export class MirrorRuntime {
|
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
nudge() { this.#engine?.nudge?.(); }
|
|
138
|
-
|
|
147
|
+
heldDeletes() { return this.#engine?.heldDeletes() ?? []; }
|
|
148
|
+
approveDeletes() { return this.#engine?.approveDeletes() ?? 0; }
|
|
149
|
+
restoreDeletes() { return this.#engine?.restoreDeletes() ?? 0; }
|
|
139
150
|
|
|
140
151
|
async #fetchHubExclusions() {
|
|
141
152
|
try {
|