@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +112 -0
- package/README.md +76 -2
- package/bin/canvas-edge +2 -0
- package/bin/healthcheck.mjs +9 -0
- package/node_modules/canvas-stored/LICENSE +661 -0
- package/node_modules/canvas-stored/NOTICE +85 -0
- package/node_modules/canvas-stored/README.md +472 -0
- package/node_modules/canvas-stored/package.json +26 -0
- package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
- package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
- package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
- package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
- package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
- package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
- package/node_modules/canvas-stored/src/cache/index.js +65 -0
- package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
- package/node_modules/canvas-stored/src/extractors/image.js +90 -0
- package/node_modules/canvas-stored/src/extractors/index.js +41 -0
- package/node_modules/canvas-stored/src/extractors/text.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +360 -0
- package/node_modules/canvas-stored/src/index.js +2058 -0
- package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
- package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
- package/node_modules/canvas-stored/src/sync/keys.js +140 -0
- package/node_modules/canvas-stored/src/sync/worker.js +52 -0
- package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
- package/node_modules/canvas-stored/src/utils/common.js +37 -0
- package/node_modules/canvas-stored/src/utils/mime.js +107 -0
- package/node_modules/canvas-stored/src/utils/mount.js +99 -0
- package/package.json +49 -4
- package/src/EdgeClient.js +129 -0
- package/src/control.js +48 -0
- package/src/daemon.js +88 -0
- package/src/env.js +182 -0
- package/src/fuse-runtime.js +173 -0
- package/src/index.js +26 -0
- package/src/mirror-runtime.js +232 -0
- package/src/remote-config.js +69 -0
- package/src/runtime.js +61 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { open } from 'lmdb';
|
|
2
|
+
import Debug from 'debug';
|
|
3
|
+
|
|
4
|
+
const debug = Debug('stored:index');
|
|
5
|
+
|
|
6
|
+
// Durable change log — one entry per location mutation, in commit order.
|
|
7
|
+
// `seq` is a monotonically increasing integer (LMDB ordered-binary keys sort
|
|
8
|
+
// numbers numerically), persisted alongside the entries so it survives restarts.
|
|
9
|
+
const CHANGES_SEQ_KEY = 'changes.seq';
|
|
10
|
+
const DEFAULT_TRIM = { keep: 200000, maxAgeMs: 30 * 24 * 60 * 60 * 1000 };
|
|
11
|
+
const TRIM_EVERY = 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* LMDB-backed index mapping id (sha256:xxx) to metadata, plus a path index
|
|
15
|
+
* (`${backend}:${key}` → id) and a durable change log.
|
|
16
|
+
*
|
|
17
|
+
* The change log is the feed remote mirrors tail: every time a location is
|
|
18
|
+
* added, removed or renamed it gets an entry `{ seq, ts, backend, op, key,
|
|
19
|
+
* from?, id, size, mtime, origin? }` written in the SAME transaction as the
|
|
20
|
+
* index mutation, so a reader can never observe an index state the log does
|
|
21
|
+
* not explain. Entries are dirty-key notifications carrying the last known
|
|
22
|
+
* state — consumers re-stat the key rather than replaying ops blindly, which
|
|
23
|
+
* is what makes coalescing and trimming safe.
|
|
24
|
+
*/
|
|
25
|
+
export default class Index {
|
|
26
|
+
#db;
|
|
27
|
+
#pathDb;
|
|
28
|
+
#changesDb;
|
|
29
|
+
#metaDb;
|
|
30
|
+
#seq = 0;
|
|
31
|
+
#onChange = null;
|
|
32
|
+
#trim;
|
|
33
|
+
// Transaction nesting: the outermost `transaction()` owns the LMDB txn and
|
|
34
|
+
// flushes `#pending` (the entries appended inside it) to `onChange` after
|
|
35
|
+
// commit, so listeners never see an entry that later rolled back.
|
|
36
|
+
#txDepth = 0;
|
|
37
|
+
#pending = [];
|
|
38
|
+
#appendsSinceTrim = 0;
|
|
39
|
+
|
|
40
|
+
constructor(path = './.stored/index', options = {}) {
|
|
41
|
+
this.#db = open({ path, name: 'metadata', compression: true });
|
|
42
|
+
this.#pathDb = this.#db.openDB('paths');
|
|
43
|
+
this.#changesDb = this.#db.openDB('changes');
|
|
44
|
+
this.#metaDb = this.#db.openDB('index-meta');
|
|
45
|
+
this.#seq = Number(this.#metaDb.get(CHANGES_SEQ_KEY)) || 0;
|
|
46
|
+
this.#onChange = typeof options.onChange === 'function' ? options.onChange : null;
|
|
47
|
+
this.#trim = { ...DEFAULT_TRIM, ...(options.changes || {}) };
|
|
48
|
+
debug(`Index opened at ${path} (changes head ${this.#seq})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get size() { return this.#db.getKeysCount(); }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Open (or reuse) a named sub-database in the index environment. The
|
|
55
|
+
* sync engine keeps its ledger and job queue here so a device's whole
|
|
56
|
+
* state lives in one LMDB env with one lifecycle.
|
|
57
|
+
*/
|
|
58
|
+
openDB(name, options = {}) { return this.#db.openDB({ name, ...options }); }
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// Transactions
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run `fn` inside one LMDB write transaction. Nested calls join the outer
|
|
66
|
+
* transaction; `put()`/`delete()` use this internally, so a caller that
|
|
67
|
+
* wraps several of them makes the whole group atomic — and the change log
|
|
68
|
+
* coalesces per key inside the group (a delete + put of the same key in
|
|
69
|
+
* one transaction is logged as a single put).
|
|
70
|
+
*/
|
|
71
|
+
transaction(fn) {
|
|
72
|
+
if (this.#txDepth > 0) return fn();
|
|
73
|
+
this.#txDepth = 1;
|
|
74
|
+
const seqAtStart = this.#seq;
|
|
75
|
+
let ok = false;
|
|
76
|
+
try {
|
|
77
|
+
const result = this.#db.transactionSync(fn);
|
|
78
|
+
ok = true;
|
|
79
|
+
return result;
|
|
80
|
+
} finally {
|
|
81
|
+
this.#txDepth = 0;
|
|
82
|
+
const pending = this.#pending;
|
|
83
|
+
this.#pending = [];
|
|
84
|
+
if (!ok) {
|
|
85
|
+
this.#seq = seqAtStart;
|
|
86
|
+
} else {
|
|
87
|
+
if (pending.length && this.#onChange) {
|
|
88
|
+
try { this.#onChange(pending); } catch (err) { debug(`onChange listener failed: ${err.message}`); }
|
|
89
|
+
}
|
|
90
|
+
if (this.#appendsSinceTrim >= TRIM_EVERY) {
|
|
91
|
+
this.#appendsSinceTrim = 0;
|
|
92
|
+
try { this.trimChanges(); } catch (err) { debug(`trimChanges failed: ${err.message}`); }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
99
|
+
// Core Operations
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Upsert an entry. `options.origin` stamps the resulting change-log
|
|
104
|
+
* entries (who caused the mutation — a device id, a replication peer);
|
|
105
|
+
* `options.rename = { backend, from, to }` folds a removed+added pair on
|
|
106
|
+
* one backend into a single `rename` entry instead of delete + put.
|
|
107
|
+
*/
|
|
108
|
+
put(id, metadata, options = {}) {
|
|
109
|
+
// Both the metadata write and the path-index writes commit atomically:
|
|
110
|
+
// a crash can never leave the path index pointing at a missing id.
|
|
111
|
+
return this.transaction(() => {
|
|
112
|
+
const existing = this.#db.get(id);
|
|
113
|
+
const meta = {
|
|
114
|
+
...existing,
|
|
115
|
+
...metadata,
|
|
116
|
+
id,
|
|
117
|
+
modified: Date.now(),
|
|
118
|
+
created: existing?.created || Date.now(),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const before = new Map((existing?.locations || []).map(loc => [`${loc.backend}:${loc.key}`, loc]));
|
|
122
|
+
const after = new Map((meta.locations || []).map(loc => [`${loc.backend}:${loc.key}`, loc]));
|
|
123
|
+
for (const pathKey of before.keys()) {
|
|
124
|
+
if (!after.has(pathKey)) this.#pathDb.removeSync(pathKey);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.#db.putSync(id, meta);
|
|
128
|
+
|
|
129
|
+
// Index by path for each location
|
|
130
|
+
for (const pathKey of after.keys()) {
|
|
131
|
+
this.#pathDb.putSync(pathKey, id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.#logLocationDiff(meta, before, after, options);
|
|
135
|
+
debug(`Indexed ${id.slice(0, 19)}...`);
|
|
136
|
+
return meta;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get(idOrPath) {
|
|
141
|
+
// Try direct id lookup
|
|
142
|
+
const direct = this.#db.get(idOrPath);
|
|
143
|
+
if (direct) return direct;
|
|
144
|
+
|
|
145
|
+
// Try path lookup
|
|
146
|
+
const id = this.#pathDb.get(idOrPath);
|
|
147
|
+
return id ? this.#db.get(id) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
has(idOrPath) {
|
|
151
|
+
return this.#db.doesExist(idOrPath) || this.#pathDb.doesExist(idOrPath);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
delete(id, options = {}) {
|
|
155
|
+
return this.transaction(() => {
|
|
156
|
+
const meta = this.#db.get(id);
|
|
157
|
+
if (!meta) return false;
|
|
158
|
+
|
|
159
|
+
for (const loc of meta.locations || []) {
|
|
160
|
+
this.#pathDb.removeSync(`${loc.backend}:${loc.key}`);
|
|
161
|
+
this.#append({
|
|
162
|
+
backend: loc.backend, op: 'delete', key: loc.key, id,
|
|
163
|
+
size: loc.size ?? meta.size ?? null, mtime: loc.mtime ?? null, origin: options.origin,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.#db.removeSync(id);
|
|
168
|
+
debug(`Removed ${id.slice(0, 19)}...`);
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
174
|
+
// Change log
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
#logLocationDiff(meta, before, after, { origin = null, rename = null } = {}) {
|
|
178
|
+
const removed = [...before.keys()].filter(k => !after.has(k));
|
|
179
|
+
const added = [...after.keys()].filter(k => !before.has(k));
|
|
180
|
+
if (!removed.length && !added.length) return;
|
|
181
|
+
|
|
182
|
+
let paired = null;
|
|
183
|
+
if (rename?.backend && rename.from != null && rename.to != null) {
|
|
184
|
+
const fromKey = `${rename.backend}:${rename.from}`;
|
|
185
|
+
const toKey = `${rename.backend}:${rename.to}`;
|
|
186
|
+
if (removed.includes(fromKey) && added.includes(toKey)) paired = { fromKey, toKey };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const pathKey of removed) {
|
|
190
|
+
if (paired && pathKey === paired.fromKey) continue;
|
|
191
|
+
const loc = before.get(pathKey);
|
|
192
|
+
this.#append({
|
|
193
|
+
backend: loc.backend, op: 'delete', key: loc.key, id: meta.id,
|
|
194
|
+
size: loc.size ?? meta.size ?? null, mtime: loc.mtime ?? null, origin,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const pathKey of added) {
|
|
198
|
+
const loc = after.get(pathKey);
|
|
199
|
+
const entry = {
|
|
200
|
+
backend: loc.backend, op: 'put', key: loc.key, id: meta.id,
|
|
201
|
+
size: loc.size ?? meta.size ?? null, mtime: loc.mtime ?? null, origin,
|
|
202
|
+
};
|
|
203
|
+
if (paired && pathKey === paired.toKey) { entry.op = 'rename'; entry.from = rename.from; }
|
|
204
|
+
this.#append(entry);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Append one entry, coalescing per (backend, key) within the current
|
|
209
|
+
// transaction: the final state of a key is what matters to a reader, and
|
|
210
|
+
// a delete immediately followed by a put (content succession) must not
|
|
211
|
+
// read as "gone" halfway through.
|
|
212
|
+
#append(entry) {
|
|
213
|
+
const clean = { ...entry };
|
|
214
|
+
if (clean.origin == null) delete clean.origin;
|
|
215
|
+
const prior = this.#pending.find(p => p.backend === clean.backend && p.key === clean.key);
|
|
216
|
+
if (prior) {
|
|
217
|
+
const merged = { ...clean, seq: prior.seq, ts: Date.now() };
|
|
218
|
+
// A rename's `from` survives a later put of the same key in the
|
|
219
|
+
// same transaction (rename then content refresh) — it is still
|
|
220
|
+
// where the bytes came from.
|
|
221
|
+
if (prior.op === 'rename' && merged.op === 'put') { merged.op = 'rename'; merged.from = prior.from; }
|
|
222
|
+
this.#changesDb.putSync(prior.seq, merged);
|
|
223
|
+
Object.assign(prior, merged);
|
|
224
|
+
return prior;
|
|
225
|
+
}
|
|
226
|
+
this.#seq += 1;
|
|
227
|
+
const full = { seq: this.#seq, ts: Date.now(), ...clean };
|
|
228
|
+
this.#changesDb.putSync(this.#seq, full);
|
|
229
|
+
this.#metaDb.putSync(CHANGES_SEQ_KEY, this.#seq);
|
|
230
|
+
this.#pending.push(full);
|
|
231
|
+
this.#appendsSinceTrim += 1;
|
|
232
|
+
return full;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Sequence number of the newest entry (0 when nothing was ever logged). */
|
|
236
|
+
head() { return this.#seq; }
|
|
237
|
+
|
|
238
|
+
/** Sequence number of the oldest retained entry, or null when the log is empty. */
|
|
239
|
+
oldest() {
|
|
240
|
+
for (const { key } of this.#changesDb.getRange({ limit: 1 })) return key;
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Entries after `since` (exclusive), oldest first, optionally filtered by
|
|
246
|
+
* backend. `cursor` is the last examined seq — pass it back as `since` to
|
|
247
|
+
* continue; when it equals `head` the reader is caught up. `cursorTooOld`
|
|
248
|
+
* means entries the reader never saw have been trimmed: rebuild from a
|
|
249
|
+
* full listing instead of tailing.
|
|
250
|
+
*/
|
|
251
|
+
changes({ backend = null, since = 0, limit = 1000 } = {}) {
|
|
252
|
+
const from = Math.max(0, Math.floor(Number(since) || 0));
|
|
253
|
+
const max = Math.max(1, Math.floor(Number(limit) || 1000));
|
|
254
|
+
const head = this.#seq;
|
|
255
|
+
const oldest = this.oldest();
|
|
256
|
+
const floor = oldest ?? (head + 1);
|
|
257
|
+
const cursorTooOld = from + 1 < floor;
|
|
258
|
+
|
|
259
|
+
const changes = [];
|
|
260
|
+
let cursor = from;
|
|
261
|
+
if (!cursorTooOld) {
|
|
262
|
+
for (const { key, value } of this.#changesDb.getRange({ start: from + 1 })) {
|
|
263
|
+
cursor = key;
|
|
264
|
+
if (backend && value.backend !== backend) continue;
|
|
265
|
+
changes.push(value);
|
|
266
|
+
if (changes.length >= max) break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { changes, head, oldest, cursor, cursorTooOld };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Drop entries beyond the retention window: keep at most `keep` entries
|
|
274
|
+
* and nothing older than `maxAgeMs`. Returns the number removed.
|
|
275
|
+
*/
|
|
276
|
+
trimChanges(options = {}) {
|
|
277
|
+
const keep = Math.max(0, Number(options.keep ?? this.#trim.keep) || 0);
|
|
278
|
+
const maxAgeMs = Math.max(0, Number(options.maxAgeMs ?? this.#trim.maxAgeMs) || 0);
|
|
279
|
+
const minSeq = this.#seq - keep + 1;
|
|
280
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
281
|
+
|
|
282
|
+
const doomed = [];
|
|
283
|
+
for (const { key, value } of this.#changesDb.getRange({})) {
|
|
284
|
+
if (key < minSeq || (value?.ts ?? 0) < cutoff) doomed.push(key);
|
|
285
|
+
else break;
|
|
286
|
+
}
|
|
287
|
+
if (!doomed.length) return 0;
|
|
288
|
+
this.transaction(() => {
|
|
289
|
+
for (const key of doomed) this.#changesDb.removeSync(key);
|
|
290
|
+
});
|
|
291
|
+
debug(`Trimmed ${doomed.length} change-log entries`);
|
|
292
|
+
return doomed.length;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
296
|
+
// Query
|
|
297
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
findByBackend(backendName) {
|
|
300
|
+
const results = [];
|
|
301
|
+
for (const { value } of this.#db.getRange()) {
|
|
302
|
+
if (value.locations?.some(l => l.backend === backendName)) {
|
|
303
|
+
results.push(value);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return results;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Page through one backend's locations in key order via the path index
|
|
311
|
+
* (a range scan, no full-table walk). Returns `{ objects, cursor }`; pass
|
|
312
|
+
* `cursor` back as `after` for the next page, a null cursor means done.
|
|
313
|
+
*/
|
|
314
|
+
locationsByBackend(backendName, { prefix = '', after = null, limit = 1000 } = {}) {
|
|
315
|
+
const base = `${backendName}:`;
|
|
316
|
+
const max = Math.max(1, Math.floor(Number(limit) || 1000));
|
|
317
|
+
const start = `${base}${after != null && after >= prefix ? after : prefix}`;
|
|
318
|
+
const end = `${base}${prefix}`;
|
|
319
|
+
const objects = [];
|
|
320
|
+
let cursor = null;
|
|
321
|
+
for (const { key: pathKey, value: id } of this.#pathDb.getRange({ start, end })) {
|
|
322
|
+
if (typeof pathKey !== 'string' || !pathKey.startsWith(base)) continue;
|
|
323
|
+
const key = pathKey.slice(base.length);
|
|
324
|
+
if (prefix && !key.startsWith(prefix)) continue;
|
|
325
|
+
if (after != null && key <= after) continue;
|
|
326
|
+
const meta = this.#db.get(id);
|
|
327
|
+
const loc = meta?.locations?.find(l => l.backend === backendName && l.key === key) || null;
|
|
328
|
+
objects.push({
|
|
329
|
+
key,
|
|
330
|
+
id,
|
|
331
|
+
checksums: meta?.checksums || null,
|
|
332
|
+
size: loc?.size ?? meta?.size ?? null,
|
|
333
|
+
mtime: loc?.mtime ?? null,
|
|
334
|
+
mimeType: meta?.mimeType ?? null,
|
|
335
|
+
synced: loc?.synced ?? null,
|
|
336
|
+
});
|
|
337
|
+
cursor = key;
|
|
338
|
+
if (objects.length >= max) break;
|
|
339
|
+
}
|
|
340
|
+
return { objects, cursor: objects.length >= max ? cursor : null };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
*entries() {
|
|
344
|
+
for (const { key, value } of this.#db.getRange()) {
|
|
345
|
+
yield [key, value];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
clear() {
|
|
350
|
+
this.#db.clearSync();
|
|
351
|
+
this.#pathDb.clearSync();
|
|
352
|
+
this.#changesDb.clearSync();
|
|
353
|
+
this.#metaDb.clearSync();
|
|
354
|
+
this.#seq = 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
close() {
|
|
358
|
+
this.#db.close();
|
|
359
|
+
}
|
|
360
|
+
}
|