@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.
Files changed (34) hide show
  1. package/README.md +5 -0
  2. package/node_modules/canvas-stored/README.md +189 -9
  3. package/node_modules/canvas-stored/package.json +2 -2
  4. package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
  5. package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
  6. package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
  7. package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
  8. package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
  9. package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
  10. package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
  11. package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
  12. package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
  13. package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
  14. package/node_modules/canvas-stored/src/cache/index.js +39 -5
  15. package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
  16. package/node_modules/canvas-stored/src/index/index.js +5 -3
  17. package/node_modules/canvas-stored/src/index.js +733 -132
  18. package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
  19. package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
  20. package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
  21. package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
  22. package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
  23. package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
  24. package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
  25. package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
  26. package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
  27. package/node_modules/canvas-stored/src/trash.js +219 -0
  28. package/node_modules/canvas-stored/src/utils/common.js +7 -0
  29. package/package.json +5 -4
  30. package/src/binding.js +33 -0
  31. package/src/control.js +13 -0
  32. package/src/daemon.js +14 -1
  33. package/src/env.js +1 -1
  34. package/src/mirror-runtime.js +15 -4
@@ -0,0 +1,60 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { setImmediate as yieldToIO } from 'node:timers/promises';
4
+
5
+ /** One physical mutation executor. Nested operations join their owner's scope;
6
+ * observers must detach before scheduling independent work. Diagnostics never
7
+ * abandon a write or release its lock early.
8
+ */
9
+ export default class MutationQueue {
10
+ #context = new AsyncLocalStorage();
11
+ #tail = Promise.resolve();
12
+ #waiting = 0;
13
+ #active = null;
14
+ #closed = false;
15
+ #timer = null;
16
+ #report;
17
+ #threshold;
18
+ #completed = 0;
19
+ constructor({ onStall = null, stallMs = 5000 } = {}) {
20
+ this.#report = onStall;
21
+ this.#threshold = Math.max(1, stallMs);
22
+ }
23
+ get diagnostics() {
24
+ return { queued: this.#waiting, active: this.#active
25
+ ? { operation: this.#active.operation, elapsedMs: Math.round(performance.now() - this.#active.started) } : null };
26
+ }
27
+ detach(action) { return this.#context.exit(action); }
28
+ close() { this.#closed = true; }
29
+ drain() { return this.#tail.catch(() => {}); }
30
+ run(action, operation = 'mutation') {
31
+ if (this.#context.getStore()?.active) return action();
32
+ if (this.#closed) return Promise.reject(Object.assign(new Error('Stored is stopping'), { code: 'STOPPED' }));
33
+ this.#waiting++;
34
+ if (!this.#timer && this.#report) {
35
+ this.#timer = setInterval(() => {
36
+ const state = this.diagnostics;
37
+ if (state.active?.elapsedMs >= this.#threshold) {
38
+ try { this.#report(state); } catch { /* diagnostic only */ }
39
+ }
40
+ }, this.#threshold);
41
+ this.#timer.unref?.();
42
+ }
43
+ const next = this.#tail.catch(() => {}).then(async () => {
44
+ // Make progress on HTTP, cancellation and watcher callbacks even
45
+ // when thousands of queued mutations resolve from memory.
46
+ if (++this.#completed % 32 === 0) await yieldToIO();
47
+ this.#waiting--;
48
+ this.#active = { operation, started: performance.now() };
49
+ const scope = { active: true };
50
+ try { return await this.#context.run(scope, action); }
51
+ finally {
52
+ scope.active = false;
53
+ this.#active = null;
54
+ if (!this.#waiting) { clearInterval(this.#timer); this.#timer = null; }
55
+ }
56
+ });
57
+ this.#tail = next;
58
+ return next;
59
+ }
60
+ }
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { open } from 'lmdb';
6
+
7
+ const VERIFY_WORKER = fileURLToPath(new URL('./verify-worker.js', import.meta.url));
8
+ const VERIFY_TIMEOUT_MS = 30 * 60 * 1000;
9
+
10
+ /** The LMDB environment behind the index and ledgers (compressed, named sub-dbs). */
11
+ export function openKeyValueStore({ path: dbPath, name, compression = true, maxDbs = 64 }) {
12
+ if (!dbPath) throw new Error('LMDB database path not provided');
13
+ return open({ path: dbPath, name, compression, maxDbs, cache: false, strictAsyncOrder: false });
14
+ }
15
+
16
+ /**
17
+ * Check an existing environment in a child process before opening it in this
18
+ * one: a corrupt map can SIGBUS, which try/catch cannot contain. Walks every
19
+ * named database read-only. Does not repair, truncate, replace or create
20
+ * data.mdb. Returns null when there is no database yet.
21
+ */
22
+ export async function verifyDatabase(directory) {
23
+ const databasePath = path.resolve(directory);
24
+ const file = path.join(databasePath, 'data.mdb');
25
+ try {
26
+ let stat;
27
+ try { stat = await fs.stat(file); }
28
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
29
+ if (!stat.isFile()) throw new Error('Expected data.mdb to be a regular file');
30
+ if (stat.size === 0) throw Object.assign(new Error('Empty data.mdb'), { canQuarantine: true });
31
+ return await runVerifyWorker(databasePath);
32
+ } catch (cause) {
33
+ throw Object.assign(new Error(`Database verification failed for ${file}: ${cause.message}. No repair or replacement was attempted.`, { cause }),
34
+ { code: 'DATABASE_INTEGRITY_FAILED', databasePath: file, canQuarantine: cause.canQuarantine === true, statusCode: 500 });
35
+ }
36
+ }
37
+
38
+ function runVerifyWorker(databasePath) {
39
+ return new Promise((resolve, reject) => {
40
+ const child = spawn(process.execPath, [VERIFY_WORKER, databasePath], {
41
+ stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: '' },
42
+ });
43
+ let output = '';
44
+ let errors = '';
45
+ child.stdout.on('data', data => { output += data; });
46
+ child.stderr.on('data', data => { errors = (errors + data).slice(-4096); });
47
+ let timedOut = false;
48
+ const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, VERIFY_TIMEOUT_MS);
49
+ child.on('error', error => { clearTimeout(timeout); reject(error); });
50
+ child.on('close', (code, signal) => {
51
+ clearTimeout(timeout);
52
+ if (timedOut) { reject(new Error('Database verification timed out')); return; }
53
+ if (code !== 0) {
54
+ const canQuarantine = ['SIGABRT', 'SIGBUS', 'SIGSEGV'].includes(signal)
55
+ || /MDB_(CORRUPTED|PAGE_NOTFOUND|INVALID)\b|Unreadable entry in |Incomplete dataset: /.test(errors);
56
+ reject(Object.assign(new Error(`verify worker ${signal || `exit ${code}`}: ${errors.trim()}`), { canQuarantine }));
57
+ return;
58
+ }
59
+ try { resolve(JSON.parse(output)); } catch { reject(new Error('Invalid verify worker response')); }
60
+ });
61
+ });
62
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { verifyDatabase } from './lmdb.js';
4
+
5
+ /** Prepare the Stored index before opening LMDB. Only an unreadable environment
6
+ * is quarantined; infrastructure failures must not discard a usable index.
7
+ * The entire environment is retained, including operational sub-databases.
8
+ */
9
+ export async function prepareIndex(directory, { verify = verifyDatabase } = {}) {
10
+ const databasePath = path.resolve(directory);
11
+ try {
12
+ await verify(databasePath);
13
+ return null;
14
+ } catch (error) {
15
+ if (error.code !== 'DATABASE_INTEGRITY_FAILED' || error.canQuarantine !== true) throw error;
16
+ // Renaming a symlink would leave its actual environment in place.
17
+ if (!(await fs.lstat(databasePath)).isDirectory()) throw error;
18
+ const createdAt = new Date().toISOString();
19
+ const quarantinePath = await fs.mkdtemp(`${databasePath}.quarantine-${createdAt.replaceAll(':', '-')}-`);
20
+ const recovery = {
21
+ databasePath,
22
+ quarantinePath,
23
+ preservedIndexPath: path.join(quarantinePath, 'index'),
24
+ createdAt,
25
+ reason: error.cause?.message || error.message,
26
+ };
27
+ // Write the report before moving anything. A rename failure propagates:
28
+ // never open a fresh index unless the original was preserved successfully.
29
+ await fs.writeFile(path.join(quarantinePath, 'recovery.json'), JSON.stringify(recovery, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
30
+ await fs.rename(databasePath, recovery.preservedIndexPath);
31
+ return recovery;
32
+ }
33
+ }
@@ -0,0 +1,23 @@
1
+ // Native faults stay in this disposable process, never in the caller.
2
+ import { open } from 'lmdb';
3
+
4
+ const db = open({ path: process.argv[2], readOnly: true, create: false, maxDbs: 256,
5
+ compression: false, encoding: 'binary', keyEncoding: 'binary' });
6
+ try {
7
+ const datasets = {};
8
+ // The main database holds the named-database descriptors; discover them
9
+ // rather than miss a store. Finish the root cursor before openDB, which
10
+ // renews the read transaction.
11
+ const names = Array.from(db.getKeys(), key => key.toString('utf8').replace(/\0$/, ''));
12
+ for (const name of names) {
13
+ const store = db.openDB(name, { create: false, encoding: 'binary', keyEncoding: 'binary' });
14
+ if (!store) throw new Error(`Missing dataset: ${name}`);
15
+ let count = 0;
16
+ for (const { key, value } of store.getRange()) { if (key == null || value == null) throw new Error(`Unreadable entry in ${name}`); count++; }
17
+ if (count !== store.getStats().entryCount) throw new Error(`Incomplete dataset: ${name}`);
18
+ datasets[name] = { count };
19
+ }
20
+ process.stdout.write(JSON.stringify({ datasets }));
21
+ } finally {
22
+ await db.close();
23
+ }
@@ -1,4 +1,4 @@
1
- import { open } from 'lmdb';
1
+ import { openKeyValueStore } from '../storage/lmdb.js';
2
2
  import Debug from 'debug';
3
3
 
4
4
  const debug = Debug('stored:sync:ledger');
@@ -29,7 +29,7 @@ export default class Ledger {
29
29
  if (index && typeof index.openDB === 'function') {
30
30
  this.#db = index.openDB(name);
31
31
  } else if (path) {
32
- this.#own = open({ path, name, compression: true });
32
+ this.#own = openKeyValueStore({ path, name, compression: true });
33
33
  this.#db = this.#own;
34
34
  } else {
35
35
  throw new Error('Ledger requires { index } or { path }');
@@ -71,6 +71,24 @@ export default class Ledger {
71
71
  });
72
72
  }
73
73
 
74
+ /**
75
+ * Re-key every base under `from/` to `to/` in one transaction (a folder
76
+ * rename). `patch(toKey)` may add fields. Returns the new keys.
77
+ */
78
+ moveBases(from, to, patch = () => ({})) {
79
+ const rows = [...this.bases(`${from}/`)];
80
+ return this.#db.transactionSync(() => {
81
+ const moved = [];
82
+ for (const [key, base] of rows) {
83
+ const next = `${to}/${key.slice(from.length + 1)}`;
84
+ this.#db.removeSync(this.#k('base', key));
85
+ this.#db.putSync(this.#k('base', next), { ...base, ...patch(next), at: Date.now() });
86
+ moved.push(next);
87
+ }
88
+ return moved;
89
+ });
90
+ }
91
+
74
92
  /** All bases in key order: yields `[key, base]`. */
75
93
  *bases(prefix = '') {
76
94
  const start = this.#k('base', prefix);
@@ -133,6 +151,6 @@ export default class Ledger {
133
151
  }
134
152
 
135
153
  close() {
136
- if (this.#own) { this.#own.close(); this.#own = null; }
154
+ if (this.#own) { const closing = this.#own.close(); this.#own = null; return closing; }
137
155
  }
138
156
  }