@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.
Files changed (44) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +112 -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,47 @@
1
+ import Debug from 'debug';
2
+ import StorageBackend from '../StorageBackend.js';
3
+
4
+ const debug = Debug('stored:backend:http');
5
+
6
+ /**
7
+ * HTTP(S) storage backend — SKELETON.
8
+ *
9
+ * Registered so `stored://http:<account>/<key>` URLs parse and dispatch.
10
+ * `get` has a minimal read-only fetch implementation (HTTP sources are
11
+ * commonly read-only); `put`/`delete`/`list` inherit `Not implemented`.
12
+ * `config` carries { baseUrl, account, headers? }.
13
+ */
14
+ export default class HttpBackend extends StorageBackend {
15
+ #baseUrl;
16
+
17
+ constructor(name, config = {}) {
18
+ super(name, config);
19
+ this.type = 'remote';
20
+ this.#baseUrl = config.baseUrl || '';
21
+ debug(`HttpBackend "${name}" registered (skeleton; baseUrl=${this.#baseUrl || '?'})`);
22
+ }
23
+
24
+ // Read-only source: bytes can't be removed remotely, only the reference dropped.
25
+ get capabilities() { return { read: true, write: false, delete: false, canEnumerate: false }; }
26
+
27
+ nativeUrl(key) {
28
+ try { return this.#baseUrl ? new URL(key, this.#baseUrl).href : key; }
29
+ catch { return null; }
30
+ }
31
+
32
+ async get(key, options = {}) {
33
+ const url = this.#baseUrl ? new URL(key, this.#baseUrl).toString() : key;
34
+ const res = await fetch(url, { headers: this.config.headers || {} });
35
+ if (!res.ok) return null;
36
+ if (options.stream) return res.body;
37
+ return Buffer.from(await res.arrayBuffer());
38
+ }
39
+
40
+ async stat(key) {
41
+ const url = this.#baseUrl ? new URL(key, this.#baseUrl).toString() : key;
42
+ const res = await fetch(url, { method: 'HEAD', headers: this.config.headers || {} });
43
+ if (!res.ok) return null;
44
+ const size = Number(res.headers.get('content-length'));
45
+ return { key, size: Number.isFinite(size) ? size : null, modified: null, created: null };
46
+ }
47
+ }
@@ -0,0 +1,25 @@
1
+ import Debug from 'debug';
2
+ import StorageBackend from '../StorageBackend.js';
3
+
4
+ const debug = Debug('stored:backend:s3');
5
+
6
+ /**
7
+ * S3 storage backend — SKELETON.
8
+ *
9
+ * Registered so `stored://s3:<account>/<key>` URLs parse and dispatch, but the
10
+ * CRUD methods are not yet implemented (they inherit `Not implemented` throws
11
+ * from StorageBackend). Wire up an S3 client (`@aws-sdk/client-s3`) in a
12
+ * follow-up: `config` carries { bucket, region, account, prefix?, credentials? }.
13
+ */
14
+ export default class S3Backend extends StorageBackend {
15
+ constructor(name, config = {}) {
16
+ super(name, config);
17
+ this.type = 'remote';
18
+ debug(`S3Backend "${name}" registered (skeleton; bucket=${config.bucket ?? '?'})`);
19
+ }
20
+
21
+ nativeUrl(key) {
22
+ const { bucket, prefix = '' } = this.config;
23
+ return bucket ? `s3://${bucket}/${prefix}${key}` : null;
24
+ }
25
+ }
@@ -0,0 +1,65 @@
1
+ import cacache from 'cacache';
2
+ import Debug from 'debug';
3
+
4
+ const debug = Debug('stored:cache');
5
+
6
+ // memoize:false on every read/write — the cache fronts blobs up to ~20GB and is
7
+ // hit by 1M+ entries; cacache's in-memory LRU memoizer would defeat the point of
8
+ // a disk-backed store and risk OOM. Bytes always flow through the content store.
9
+ const CACHE_OPTS = { memoize: false };
10
+
11
+ export default class Cache {
12
+ #root;
13
+ #algorithms;
14
+
15
+ constructor(config) {
16
+ if (!config?.path) throw new Error('Cache path required');
17
+ this.#root = config.path;
18
+ this.#algorithms = config.algorithms || ['sha256'];
19
+ debug(`Cache initialized at "${this.#root}"`);
20
+ }
21
+
22
+ get root() { return this.#root; }
23
+
24
+ // Materializes the entire index into one object — fine for small stores, but
25
+ // prefer listStream() at scale (1M+ entries) to avoid the in-RAM map.
26
+ list() { return cacache.ls(this.#root); }
27
+
28
+ // Streaming index walk: yields one entry at a time, never building the whole
29
+ // index in memory. Use this for large stores.
30
+ listStream() { return cacache.ls.stream(this.#root); }
31
+
32
+ has(key) { return cacache.get.info(this.#root, key); }
33
+
34
+ put(key, data, metadata = {}) {
35
+ return cacache.put(this.#root, key, data, { algorithms: this.#algorithms, metadata, ...CACHE_OPTS });
36
+ }
37
+
38
+ putStream(key, metadata = {}) {
39
+ return cacache.put.stream(this.#root, key, { algorithms: this.#algorithms, metadata, ...CACHE_OPTS });
40
+ }
41
+
42
+ get(key) { return cacache.get(this.#root, key, CACHE_OPTS); }
43
+
44
+ getStream(key) { return cacache.get.stream(this.#root, key, CACHE_OPTS); }
45
+
46
+ getInfo(key) { return cacache.get.info(this.#root, key); }
47
+
48
+ delete(key) { return cacache.rm.entry(this.#root, key, { removeFully: true }); }
49
+
50
+ clear() { return cacache.rm.all(this.#root); }
51
+
52
+ verify() { return cacache.verify(this.#root); }
53
+
54
+ // Stream the index so a 1M+ entry store is tallied without ever holding the
55
+ // full key set in memory.
56
+ async stats() {
57
+ let entries = 0;
58
+ let size = 0;
59
+ for await (const entry of cacache.ls.stream(this.#root)) {
60
+ entries += 1;
61
+ size += entry.size || 0;
62
+ }
63
+ return { entries, size };
64
+ }
65
+ }
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ import debugInstance from 'debug';
4
+ const debug = debugInstance('canvas:stored:extract:audio');
5
+
6
+ let _mm;
7
+ let _loaded = false;
8
+ async function load() {
9
+ if (_loaded) { return; }
10
+ _loaded = true;
11
+ try { _mm = await import('music-metadata'); } catch { debug('music-metadata not installed — skipping audio tags'); }
12
+ }
13
+
14
+ function clean(obj) {
15
+ const out = {};
16
+ for (const [k, v] of Object.entries(obj)) {
17
+ if (v !== undefined && v !== null && v !== '') { out[k] = v; }
18
+ }
19
+ return out;
20
+ }
21
+
22
+ /**
23
+ * Extract audio tags (ID3/Vorbis) + format info → { media }. Never throws.
24
+ */
25
+ export async function extractAudio(source, mimeType) {
26
+ await load();
27
+ if (!_mm) { return {}; }
28
+ try {
29
+ const meta = source.data
30
+ ? await _mm.parseBuffer(source.data, { mimeType })
31
+ : await _mm.parseFile(source.file);
32
+ const c = meta.common || {};
33
+ const f = meta.format || {};
34
+ const media = clean({
35
+ title: c.title,
36
+ artist: c.artist,
37
+ album: c.album,
38
+ year: c.year,
39
+ genre: Array.isArray(c.genre) ? c.genre[0] : c.genre,
40
+ track: c.track?.no ?? undefined,
41
+ duration: f.duration,
42
+ bitrate: f.bitrate,
43
+ sampleRate: f.sampleRate,
44
+ codec: f.codec,
45
+ });
46
+ return Object.keys(media).length ? { media } : {};
47
+ } catch (e) {
48
+ debug(`audio parse: ${e.message}`);
49
+ return {};
50
+ }
51
+ }
52
+
53
+ export default extractAudio;
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ import { readFile } from 'fs/promises';
4
+ import debugInstance from 'debug';
5
+ const debug = debugInstance('canvas:stored:extract:image');
6
+
7
+ // Lazy, optional deps — resolved once. If absent, that sub-extraction no-ops.
8
+ let _exifr;
9
+ let _imageSize;
10
+ let _loaded = false;
11
+ async function load() {
12
+ if (_loaded) { return; }
13
+ _loaded = true;
14
+ try { _exifr = (await import('exifr')).default; } catch { debug('exifr not installed — skipping EXIF/GPS'); }
15
+ try {
16
+ const m = await import('image-size');
17
+ _imageSize = m.imageSize || m.default; // v2 named export / v1 default
18
+ } catch { debug('image-size not installed — skipping dimensions'); }
19
+ }
20
+
21
+ function clean(obj) {
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(obj)) {
24
+ if (v !== undefined && v !== null && v !== '') { out[k] = v; }
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /**
30
+ * Extract image dimensions + EXIF (incl. GPS geo + capture date). Returns
31
+ * { dimensions?, geo?, exif? }. Never throws.
32
+ */
33
+ export async function extractImage(source, _mimeType) {
34
+ await load();
35
+ let buf = source.data;
36
+ if (!buf && source.file) {
37
+ try { buf = await readFile(source.file); } catch (e) { debug(`read failed: ${e.message}`); return {}; }
38
+ }
39
+ if (!buf) { return {}; }
40
+
41
+ const out = {};
42
+
43
+ if (_imageSize) {
44
+ try {
45
+ const d = _imageSize(buf);
46
+ if (d?.width && d?.height) {
47
+ out.dimensions = clean({ width: d.width, height: d.height, type: d.type, orientation: d.orientation });
48
+ }
49
+ } catch (e) { debug(`dimensions: ${e.message}`); }
50
+ }
51
+
52
+ if (_exifr) {
53
+ try {
54
+ const ex = await _exifr.parse(buf, { gps: true, tiff: true, exif: true, ifd0: true });
55
+ if (ex) {
56
+ if (typeof ex.latitude === 'number' && typeof ex.longitude === 'number') {
57
+ // `source` marks provenance so consumers can reconcile this
58
+ // against device/manual geo instead of last-writer-wins.
59
+ // `accuracy` = GPSHPositioningError, the camera's own
60
+ // horizontal error radius in metres — the thing that explains
61
+ // a pin sitting a block away from where the photo was taken.
62
+ out.geo = clean({
63
+ lat: ex.latitude,
64
+ lon: ex.longitude,
65
+ alt: typeof ex.GPSAltitude === 'number' ? ex.GPSAltitude : undefined,
66
+ accuracy: typeof ex.GPSHPositioningError === 'number' ? ex.GPSHPositioningError : undefined,
67
+ source: 'exif',
68
+ });
69
+ }
70
+ const date = ex.DateTimeOriginal || ex.CreateDate || ex.ModifyDate;
71
+ const exif = clean({
72
+ make: ex.Make,
73
+ model: ex.Model,
74
+ lensModel: ex.LensModel,
75
+ orientation: ex.Orientation,
76
+ iso: ex.ISO,
77
+ fNumber: ex.FNumber,
78
+ exposureTime: ex.ExposureTime,
79
+ focalLength: ex.FocalLength,
80
+ capturedAt: date instanceof Date ? date.toISOString() : (typeof date === 'string' ? date : undefined),
81
+ });
82
+ if (Object.keys(exif).length) { out.exif = exif; }
83
+ }
84
+ } catch (e) { debug(`exif: ${e.message}`); }
85
+ }
86
+
87
+ return out;
88
+ }
89
+
90
+ export default extractImage;
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ import debugInstance from 'debug';
4
+ const debug = debugInstance('canvas:stored:extract');
5
+
6
+ import { extractImage } from './image.js';
7
+ import { extractAudio } from './audio.js';
8
+ import { extractText } from './text.js';
9
+
10
+ /**
11
+ * Best-effort metadata extraction from blob bytes at ingest, dispatched by mime.
12
+ *
13
+ * Contract: NEVER throws — extraction failure must never fail a store. Parser
14
+ * libs (exifr, image-size, music-metadata) are lazy + OPTIONAL; if one isn't
15
+ * installed, that modality silently returns {}. Returns a flat metadata object
16
+ * ({ geo, exif, dimensions, media, text }) to be merged into the blob's `custom`
17
+ * record — never file bytes.
18
+ *
19
+ * `text` is the one derived FIELD rather than a fact about the container: a
20
+ * bounded head of a text blob, so the bytes are findable by what they SAY. A
21
+ * File is otherwise indexed by its name alone, which makes every note-shaped
22
+ * thing that arrived as a file invisible to a keyword search.
23
+ *
24
+ * @param {{file?:string, data?:Buffer}} source temp-file path OR in-memory buffer
25
+ * @param {{mimeType?:string, key?:string}} [opts]
26
+ * @returns {Promise<object>} extracted metadata ({} when nothing/unsupported)
27
+ */
28
+ export async function extract(source, { mimeType = '', key } = {}) {
29
+ try {
30
+ if (!source || (!source.file && !source.data)) { return {}; }
31
+ if (/^image\//.test(mimeType)) { return (await extractImage(source, mimeType)) || {}; }
32
+ if (/^audio\//.test(mimeType)) { return (await extractAudio(source, mimeType)) || {}; }
33
+ if (/^text\//.test(mimeType)) { return (await extractText(source, mimeType)) || {}; }
34
+ return {};
35
+ } catch (e) {
36
+ debug(`extract failed (${mimeType}, ${key}): ${e.message}`);
37
+ return {};
38
+ }
39
+ }
40
+
41
+ export default extract;
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+
5
+ /**
6
+ * How much of a text blob becomes searchable text.
7
+ *
8
+ * This is a search excerpt, not the file: it rides in the document's metadata,
9
+ * which every listing and every mount carries, so it has to stay small enough
10
+ * that a folder of source files is still cheap to list. 8 KB covers a note, a
11
+ * config, or a source file whole; a long log is indexed by its head and says so
12
+ * (`truncated`), which is honest about what a search can and cannot find.
13
+ */
14
+ const MAX_TEXT_BYTES = 8192;
15
+
16
+ // Text that is really bytes: a NUL in the first block means someone's mime says
17
+ // text/* and means it (a mislabelled binary, a UTF-16 file). Indexing that
18
+ // yields noise, so it is skipped rather than guessed at.
19
+ function looksBinary(buffer) {
20
+ return buffer.includes(0);
21
+ }
22
+
23
+ /**
24
+ * The head of a text blob, as searchable text.
25
+ *
26
+ * Reads at most MAX_TEXT_BYTES — a temp file is opened and partially read
27
+ * rather than slurped, so extracting from a 2 GB log costs one block.
28
+ */
29
+ export async function extractText(source, mimeType) {
30
+ let head;
31
+ if (source.data) {
32
+ head = source.data.subarray(0, MAX_TEXT_BYTES);
33
+ } else {
34
+ const handle = await fs.promises.open(source.file, 'r');
35
+ try {
36
+ const buffer = Buffer.allocUnsafe(MAX_TEXT_BYTES);
37
+ const { bytesRead } = await handle.read(buffer, 0, MAX_TEXT_BYTES, 0);
38
+ head = buffer.subarray(0, bytesRead);
39
+ } finally {
40
+ await handle.close();
41
+ }
42
+ }
43
+
44
+ if (head.length === 0 || looksBinary(head)) { return {}; }
45
+
46
+ // `fatal: false` + stream:true so a multi-byte character split by the read
47
+ // boundary is dropped rather than becoming a replacement char in the index.
48
+ const decoded = new TextDecoder('utf-8', { fatal: false }).decode(head, { stream: true });
49
+ const content = decoded.replace(/�/g, '').trim();
50
+ if (!content) { return {}; }
51
+
52
+ const size = source.data
53
+ ? source.data.length
54
+ : await fs.promises.stat(source.file).then((s) => s.size).catch(() => head.length);
55
+
56
+ return {
57
+ text: {
58
+ content,
59
+ truncated: size > head.length,
60
+ mimeType: mimeType || 'text/plain',
61
+ },
62
+ };
63
+ }
64
+
65
+ export default extractText;