@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +114 -0
  3. package/README.md +76 -2
  4. package/bin/canvas-edge +2 -0
  5. package/bin/healthcheck.mjs +9 -0
  6. package/node_modules/canvas-stored/LICENSE +661 -0
  7. package/node_modules/canvas-stored/NOTICE +85 -0
  8. package/node_modules/canvas-stored/README.md +472 -0
  9. package/node_modules/canvas-stored/package.json +26 -0
  10. package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
  11. package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
  12. package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
  13. package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
  14. package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
  15. package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
  16. package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
  17. package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
  18. package/node_modules/canvas-stored/src/cache/index.js +65 -0
  19. package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
  20. package/node_modules/canvas-stored/src/extractors/image.js +90 -0
  21. package/node_modules/canvas-stored/src/extractors/index.js +41 -0
  22. package/node_modules/canvas-stored/src/extractors/text.js +65 -0
  23. package/node_modules/canvas-stored/src/index/index.js +360 -0
  24. package/node_modules/canvas-stored/src/index.js +2058 -0
  25. package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
  26. package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
  27. package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
  28. package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
  29. package/node_modules/canvas-stored/src/sync/keys.js +140 -0
  30. package/node_modules/canvas-stored/src/sync/worker.js +52 -0
  31. package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
  32. package/node_modules/canvas-stored/src/utils/common.js +37 -0
  33. package/node_modules/canvas-stored/src/utils/mime.js +107 -0
  34. package/node_modules/canvas-stored/src/utils/mount.js +99 -0
  35. package/package.json +49 -4
  36. package/src/EdgeClient.js +129 -0
  37. package/src/control.js +48 -0
  38. package/src/daemon.js +88 -0
  39. package/src/env.js +182 -0
  40. package/src/fuse-runtime.js +173 -0
  41. package/src/index.js +26 -0
  42. package/src/mirror-runtime.js +232 -0
  43. package/src/remote-config.js +69 -0
  44. 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
+ }