@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,85 @@
1
+ Canvas StoreD
2
+ Copyright (C) 2025-2026 Jozef Melich
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of the GNU Affero General Public License as published by the Free
6
+ Software Foundation, either version 3 of the License, or (at your option) any
7
+ later version.
8
+
9
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY
10
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11
+ PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
12
+
13
+ You should have received a copy of the GNU Affero General Public License along
14
+ with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ Contact:
17
+ lic@augmentd.eu licensing
18
+ contrib@augmentd.eu contributions
19
+ security@augmentd.eu security
20
+
21
+ PART OF CANVAS
22
+ --------------
23
+
24
+ StoreD is the blob storage layer behind Canvas. It provides
25
+ content-addressed object storage across local and remote backends.
26
+
27
+ This repository is one component of the Canvas project. It is developed and
28
+ released as part of Canvas, but it lives in its own repository:
29
+
30
+ this component https://github.com/canvas-ui/canvas-stored
31
+ the project https://github.com/canvas-ui/canvas-server
32
+
33
+ Canvas components are not all licensed the same way. The server runtime and the
34
+ services it embeds are dual-licensed. The standalone clients are AGPL-only. The
35
+ COMPONENTS section of the project NOTICE lists which is which.
36
+
37
+ NETWORK USE
38
+ -----------
39
+
40
+ This component is a library embedded in canvas-server rather than a network
41
+ service of its own, but it forms part of a program that is made available to
42
+ users over a network. Section 13 of the AGPL therefore applies to the combined
43
+ work. If you run a modified Canvas and let users interact with it over a
44
+ network, those users must be able to obtain the corresponding source of your
45
+ modifications, including any you make to this component.
46
+
47
+ canvas-server implements its side of that obligation. Every HTTP response
48
+ carries an `X-Source-Code` header, and `GET /rest/v2/ping` (no authentication
49
+ required) reports the running version, revision and source URL:
50
+
51
+ curl -sI https://your-host/ | grep -i x-source-code
52
+ curl -s https://your-host/rest/v2/ping
53
+
54
+ If you deploy a modified version, repoint these at the repository that publishes
55
+ your changes. Set `CANVAS_SOURCE_URL`, and set `CANVAS_SOURCE_COMMIT` where the
56
+ build has no git metadata. Removing the notice instead of repointing it does not
57
+ satisfy the licence.
58
+
59
+ COPYRIGHT AND COMMERCIAL LICENSING
60
+ ----------------------------------
61
+
62
+ Copyright in this codebase is held by Jozef Melich. Augmentd s.r.o. holds an
63
+ exclusive licence to it, including the right to grant sublicences, and is the
64
+ entity authorised to issue commercial licences for Canvas.
65
+
66
+ The AGPL is not the only option for this component. If its terms do not work for
67
+ you, most often because you want to build a commercial or hosted product on
68
+ Canvas without publishing your own changes, a separate commercial licence is
69
+ available and no AGPL obligation applies to it. See COMMERCIAL.md.
70
+
71
+ Enquiries: lic@augmentd.eu
72
+
73
+ Augmentd s.r.o.
74
+ DUETT Business Center
75
+ Námestie Osloboditeľov 3/A
76
+ 040 01 Košice, Slovakia
77
+ Company ID: 45331936
78
+ VAT ID: SK2022968717
79
+ https://augmentd.eu
80
+
81
+ THIRD-PARTY CODE
82
+ ----------------
83
+
84
+ Third-party dependencies keep their own licences. See the dependency manifests
85
+ and lockfiles for their terms. This notice covers Canvas code only.
@@ -0,0 +1,472 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/canvas-ai/.github/main/banners/canvas-banner_1200x480.jpg" alt="Canvas" width="100%" />
3
+ </p>
4
+
5
+ # StoreD
6
+
7
+ Cache-first blob storage with a content-addressable local cache, LMDB metadata index and pluggable backends. Local backends write immediately; remote backends are fed from the cache by a sync queue (disk copies on a worker thread, network drivers such as Google Drive committed in-process).
8
+
9
+ Used with canvas-server/synapsd to build virtual context trees over indexed data: checksum-based identity, multi-location replication, and `stored://` URLs as the canonical fetch form.
10
+
11
+ ```
12
+ put(blob) → stream hash → local backends (hardlink/rename) + remote (cacache → SyncQueue → backend.commit)
13
+ get(id) → cacache → synced backend location → cache on read (buffers only)
14
+ ```
15
+
16
+ Paths and streams are ingested without buffering the whole blob in memory (safe for 10GB+ files). Local-only puts never touch the cache; the cache + sync queue are used only when a remote backend is targeted.
17
+
18
+ ## Quick start
19
+
20
+ ### Simple example
21
+ ```js
22
+ import Stored from './src/index.js';
23
+
24
+ const stored = new Stored({ root: './.stored' });
25
+ stored.addBackend('fs:data', { driver: 'file', root: stored.dataPath });
26
+ ```
27
+
28
+ ### Backend configuration
29
+ ```js
30
+ const stored = new Stored({
31
+ root: './.stored', // → ./.stored/{index,cache,data}
32
+ checksums: ['sha256'], // sha256 is the default and the id/cache algorithm
33
+ primaryChecksum: 'sha256',
34
+ });
35
+
36
+ stored.addBackend('fs:home', {
37
+ driver: 'file',
38
+ root: './home', // any path - not tied to .stored/data
39
+ watch: true,
40
+ ignored: /node_modules/,
41
+ });
42
+
43
+ stored.on('object:add', ({ kind, id, key }) => { /* index in synapsd */ });
44
+ stored.on('synced', ({ id, results }) => { /* remote write finished */ });
45
+
46
+ // `backends` is required - there is no implicit fan-out.
47
+ const res = await stored.put(Buffer.from('hello'), { key: 'docs/hello.txt', backends: ['fs:home'] });
48
+ const data = await stored.get(res.id);
49
+ await stored.getByUrl('stored://fs:home/docs/hello.txt');
50
+
51
+ await stored.stop();
52
+ ```
53
+
54
+ ### Drivers
55
+
56
+ | driver | type | notes |
57
+ |---|---|---|
58
+ | `file` | local | Any directory; chokidar watch, inode-based rename matching, container (folder) ops |
59
+ | `cacache` | local | Content-addressable blob store (managed; opaque keys) |
60
+ | `gdrive` | remote | Google Drive folder subtree over OAuth refresh token — see below |
61
+ | `canvas` | remote | One backend of a workspace on a canvas hub, over the objects/sync protocol — see *Sync engine* below |
62
+ | `s3`, `http` | remote | Skeletons: URL parsing only (`http` has a read-only `get`) |
63
+
64
+ Register additional drivers at runtime with `BackendManager.register(name, Class)` (`import BackendManager from 'canvas-stored/src/backends/BackendManager.js'`).
65
+
66
+ ### Google Drive backend
67
+
68
+ ```js
69
+ stored.addBackend('gdrive:work', {
70
+ driver: 'gdrive',
71
+ clientId: '…apps.googleusercontent.com', // OAuth client (Desktop/Web app) with the drive scope
72
+ clientSecret: '…',
73
+ refreshToken: '1//…', // offline-access grant (access_type=offline, prompt=consent)
74
+ folderId: 'root', // subtree root; default = My Drive
75
+ watch: true, // poll changes.list (pollInterval, default 60000 ms)
76
+ permanentDelete: false, // default: delete() trashes instead of purging
77
+ });
78
+ ```
79
+
80
+ - Keys are `/`-joined Drive *names* below the root folder. Duplicate siblings get a ` (<id-prefix>)` suffix, `/` in a name becomes `∕`. Native Google Docs/Sheets/Slides (no bytes) are skipped.
81
+ - `scan()` takes sha256/sha1/md5 straight from file metadata — no downloads unless you ask for an algorithm Drive doesn't serve.
82
+ - Writes go cache → SyncQueue → `commit(key, path)` (resumable upload, 8 MiB chunks, in-place update when the key exists). Reads (`get`, `getRange`) stream from the API.
83
+ - `watch()` emits `file:add|change|unlink` from the changes feed; renames/moves carry `ino = fileId`, so Stored's rename window rewrites the location in place instead of orphaning the document.
84
+ - `verifyRoot()` → `{ ok, reason: 'unauthorized' | 'root-missing' | 'unreachable' }` is the liveness gate (credentials + root folder).
85
+
86
+ ### Canvas hub backend
87
+
88
+ ```js
89
+ stored.addBackend('hub', {
90
+ driver: 'canvas',
91
+ url: 'https://canvas.example', // hub base URL
92
+ workspaceId: 'ws-id',
93
+ backend: 'workspace:home', // hub backend address (default)
94
+ token: '<device or api token>',
95
+ deviceId: 'dev-…', // stamped as X-Canvas-Origin on every mutation
96
+ deviceName: 'laptop',
97
+ instanceId: '…', // optional: refuse a hub whose /ping instanceId differs
98
+ prefixes: ['Docs'], // optional selective scope (prefixes or globs)
99
+ pollInterval: 30000, // change-feed poll (watch)
100
+ cursor: null, // feed position to resume from
101
+ });
102
+ ```
103
+
104
+ Speaks `canvas-server/docs/sync-protocol.md`: `stat` = `HEAD`, `get`/`getRange` = `GET` (Range), `list`/`scan` page the listing, `putStream(key, source, { ifMatch, ifNoneMatch, sha256, mtime, origin, conflictOf, conflictMode, baseSha256, deviceName, mimeType })` is a streaming `PUT`, `delete(key, { ifMatch })`, `rename(from, to, { ifMatch })`, `changes(since, limit)`, `poll()`, `watch()`. Rows carry `checksums.sha256`, `ino = sha256`, `dev = canvas:<instanceId>`, so a hub-side rename (`unlink(from) + add(to)`, same ino) is paired by Stored into an in-place location rewrite. Own-origin feed entries are applied but not emitted; a `410` clears the cursor and emits `backend:state { reason:'cursor-too-old' }`. Errors are typed on `err.code`: `PRECONDITION_FAILED` (`err.current`), `CURSOR_TOO_OLD`, `TARGET_EXISTS`, `NOT_FOUND`, `UNAUTHORIZED`, `REFUSED`, `RETRYABLE` (429/5xx, retried in-call first), `OFFLINE`.
105
+
106
+ `copy()`/`move()` onto a `canvas` target stream straight to the hub (no temp file, cache or SyncQueue) and forward `ifMatch`/`ifNoneMatch`/`origin`/`mtime`/conflict headers; a `412` comes back as `{ ok:false, reason:'precondition-failed', current }`. A same-backend `move()`/`renameObject()` on the hub is one server-side rename.
107
+
108
+ ---
109
+
110
+ ## Sync engine (device mirror)
111
+
112
+ ```js
113
+ import Stored, { Mirror } from 'canvas-stored';
114
+
115
+ const stored = new Stored({ root: '~/Workspaces/notes/.workspace/db/stored' });
116
+ stored.addBackend('local', { driver: 'file', root: '~/Workspaces/notes', watch: true, ignored: ['.workspace/**'] });
117
+ stored.addBackend('trash', { driver: 'file', root: '~/Workspaces/notes/.workspace/trash' });
118
+ stored.addBackend('conflicts', { driver: 'file', root: '~/Workspaces/notes/.workspace/conflicts' });
119
+ stored.addBackend('hub', { driver: 'canvas', url, workspaceId, token, deviceId, deviceName });
120
+
121
+ const mirror = new Mirror(stored, {
122
+ id: 'notes', local: 'local', remote: 'hub', trash: 'trash', conflicts: 'conflicts',
123
+ deviceId, deviceName,
124
+ prefixes: [], // selective sync (prefixes / globs), applied on both sides
125
+ ignore: [], // on top of MIRROR_IGNORE_DEFAULTS (+ the hub's exclusions you pass here)
126
+ deletes: 'propagate', // | 'keep' — local deletes reach the hub, or only drop the base
127
+ conflictMode: 'prompt', // | 'rename' — inbox upload, or Dropbox-style conflict copy on the hub
128
+ debounceMs: 1500, fullReconcileEvery: 6 * 3600e3,
129
+ });
130
+ await mirror.start(); // scan local → list/poll hub → reconcile → watch → worker
131
+ mirror.on('conflict', (c) => …); mirror.on('skip', ({ key, reason }) => …);
132
+ mirror.on('offline', …); mirror.on('online', …); mirror.on('status', (s) => …);
133
+ await mirror.nudge(); // "poll now" (socket nudge)
134
+ mirror.status(); // { state, cursor, head, pending, running, failed, conflicts, lastSyncAt, lastError, skipped, skips }
135
+ await mirror.stop(); // drains running jobs
136
+ ```
137
+
138
+ - **View**: Stored's index holds both sides — local keys through the file watcher/scan, hub keys through the `canvas` driver's feed poller. **Base ledger** (`Ledger`, LMDB sub-db `mirror`): `base/<mirror>/<key>` → `{ sha256, size, mtime, remoteSeq }`, `cursor/<mirror>`, `skip/<mirror>/<key>`, `state/<mirror>`. Keys + digests are the identity; hub document ids are never stored.
139
+ - **Decision**: per dirty key, the protocol's three-way table (`decide(L, B, R)`, exported from `src/sync/Mirror.js`); R is confirmed with `HEAD` before a push or a conflict.
140
+ - **Jobs** (`JobQueue`, LMDB sub-dbs `jobs` + `jobs.dedupe`): `push`, `pull`, `delete-remote`, `trash-local`, `rename-remote`, `rename-local`, `conflict`; dedupe per `<kind>|<key>`, backoff `min(60s·2^n, 1h)`, running→pending recovery on open, one job per key in flight, concurrency 4 (a brand-new key skips the pre-push HEAD; the PUT's If-None-Match:* guards it). Hub requests abort after `requestTimeoutMs` (60 s) plus an upload allowance (`minUploadBytesPerSec`, 50 kB/s) so a dropped connection never pins a worker. Every byte move goes through `stored.copy`/`move`/`renameObject`/`removeObject`, so locations, succession and echo suppression are the ordinary ones. Base is written only after the operation succeeded; the feed cursor after a whole batch reconciled.
141
+ - **Conflicts** (`prompt`): the device version is copied to the `conflicts` backend as `<stem> (conflict from <device> <YYYY-MM-DD HHmm>).<ext>`, uploaded with `X-Canvas-Conflict-Of` (inbox), the hub version is pulled into place, base := R. `rename`: the upload is an ordinary object at the conflict-copy key (the hub tags it); the copy then arrives on every device like any other object.
142
+ - **Renames**: a local `mv` (paired by Stored's inode window) → `POST objects/rename`; a hub rename → local `rename(2)` when the local copy is clean. **Hub delete** → local copy to the `trash` backend. **Offline** is a state: jobs stay queued, the engine retries with backoff and catches up (`poll`, or a full listing after `410`).
143
+ - **Keys**: `src/sync/keys.js` — `normalizeKey` (NFC, `/`), `validateKey` (traversal, NUL, Windows reserved names, `<>:"|?*`, trailing dot/space, length) → skipped visibly (`skip` event, `status().skips`), `isIgnored(key, patterns)` (picomatch, `dot:true`), `matchesPrefixes`, `conflictKey`, `MIRROR_IGNORE_DEFAULTS`.
144
+
145
+ `JobQueue`, `Ledger`, `Mirror`, `BackendManager` and `Index` are named exports next to the default `Stored`.
146
+
147
+ ---
148
+
149
+ ## Configuration options
150
+
151
+ All paths hang off a single **`root`** (default `./.stored`):
152
+
153
+ ```
154
+ .stored/
155
+ index/ LMDB metadata + path index
156
+ cache/ cacache (content-addressable)
157
+ data/ default root for an optional local file backend
158
+ ```
159
+
160
+ | Option | Default | Description |
161
+ |--------|---------|-------------|
162
+ | `root` | `./.stored` | Base directory for index, cache, and data |
163
+ | `index.path` | `<root>/index` | Override index location |
164
+ | `cache.path` | `<root>/cache` | Override cacache location |
165
+ | `data.path` | `<root>/data` | Override local data directory |
166
+ | `checksums` | `['sha256']` | Algorithms computed on `put` / `scan`. sha256 is mandatory (id + cache); extra algorithms are optional |
167
+ | `primaryChecksum` | `'sha256'` | Algorithm used for content `id` (`<algo>:<hex>`) |
168
+
169
+ The constructor extends **eventemitter2** with `wildcard: true` and `delimiter: ':'`, so listeners can bind patterns like `object:*` or `scan:*`.
170
+
171
+ ### Properties
172
+
173
+ | Property | Type | Description |
174
+ |----------|------|-------------|
175
+ | `root` | `string` | Resolved store root |
176
+ | `paths` | `{ root, index, cache, data }` | All resolved paths |
177
+ | `dataPath` | `string` | `<root>/data` (convenience) |
178
+ | `cache` | `Cache` | cacache wrapper |
179
+ | `index` | `Index` | LMDB metadata store |
180
+ | `backends` | `BackendManager` | Registered backend instances |
181
+
182
+ The `cache` wrapper exposes cacache maintenance helpers directly: `stats()` (entry count + total size, streamed so large stores are never held in memory), `clear()`, `verify()`, and `list()` / `listStream()`.
183
+
184
+ ---
185
+
186
+ ## Metadata
187
+
188
+ `put()`, `scan()`, and file watchers populate the index. `stat()` / `has()` / `get()` accept either a content **id** or a path key **`{backend}:{key}`**.
189
+
190
+ ```js
191
+ {
192
+ id: 'sha256:abc...', // primaryChecksum:hex
193
+ checksums: { sha256: '...', md5: '...' },
194
+ size: 1234,
195
+ mimeType: 'text/plain',
196
+ created: 1710000000000,
197
+ modified: 1710000000000,
198
+ custom: { /* from put(..., { metadata }) */ },
199
+ locations: [
200
+ {
201
+ backend: 'fs:home',
202
+ driver: 'file',
203
+ key: 'docs/hello.txt',
204
+ synced: true, // false until remote SyncQueue completes
205
+ source: { // provenance descriptor for synapsd / UI
206
+ provider: 'fs',
207
+ account: 'home',
208
+ container: 'home', // basename of root, or bucket/share/folder from config
209
+ path: 'docs/hello.txt',
210
+ },
211
+ },
212
+ ],
213
+ }
214
+ ```
215
+
216
+ Auto-generated keys (when `put` has no `key`) use a sharded hash path: `ab/cd/<full-hex>`.
217
+
218
+ ---
219
+
220
+ ## `stored://` URLs
221
+
222
+ Canonical fetch/delete form: `stored://<backend>/<key>`. Backend names may contain colons (`fs:data:email`); only the **first** `/` after the scheme separates backend from key.
223
+
224
+ | Method | Description |
225
+ |--------|-------------|
226
+ | `getByUrl(url)` | `backend.get(key)` - bypasses the content index. Returns `Buffer \| null` (never throws on bad input). |
227
+ | `getStreamByUrl(url)` | Same as `getByUrl` but returns a `Readable \| null`. |
228
+ | `deleteByUrl(url)` | Deletes bytes on the backend and drops that location from the index (the consumer's document index is still the caller's to trim). Emits `object:location:remove` when the content survives elsewhere. Returns `{ ok: boolean, reason?: string }` where `reason` is `malformed-url`, `unknown-backend`, or `read-only-backend`. |
229
+
230
+ ---
231
+
232
+ ## API
233
+
234
+ ### Backend management
235
+
236
+ | Method | Returns | Description |
237
+ |--------|---------|-------------|
238
+ | `addBackend(name, config)` | `StorageBackend` | Register driver; wire events; start `watch()` if `config.watch` |
239
+ | `removeBackend(name)` | `Promise<boolean>` | `stop()` backend and unregister |
240
+ | `listBackends()` | `string[]` | Registered backend names |
241
+ | `getBackend(name)` | `StorageBackend \| undefined` | Instance by name |
242
+
243
+ **`addBackend` config** (common):
244
+
245
+ | Field | Description |
246
+ |-------|-------------|
247
+ | `driver` | `'file'` \| `'cacache'` \| `'s3'` \| `'http'` |
248
+ | `watch` | Start backend watcher when true (file: chokidar) |
249
+ | `root` | File backend root directory (required for `file`) |
250
+ | `ignored` | chokidar ignore pattern (file backend) |
251
+ | `provider`, `account`, `container`, `bucket`, `share`, `folder` | Override `location.source` fields (else derived from backend name + config) |
252
+
253
+ Throws if the name exists or the driver is unknown.
254
+
255
+ ### Core
256
+
257
+ #### `put(blob, options?) → Promise<{ ok, id, key, size, mimeType, checksums, locations, … }>`
258
+
259
+ Accepts `Buffer`, `string` (content), filesystem path (`string` path readable as file), or `Readable` stream. Paths and streams are hashed and written in a single streaming pass - the whole blob is never held in memory.
260
+
261
+ | Option | Default | Description |
262
+ |--------|---------|-------------|
263
+ | `key` | auto from checksum | Storage key on backends |
264
+ | `backends` | **required** | Target backend names. Empty/missing → `{ ok:false, reason:'no-targets' }` |
265
+ | `metadata` | `{}` | Stored as `custom` on the index entry |
266
+ | `mimeType` | sniffed | Override the detected mime type |
267
+
268
+ Flow: stream → hash (+ head peek for mime) → local backends (hardlink/rename from a same-fs temp) + remote placeholders (streamed into cacache) → index → enqueue remote sync. Returns `{ ok:false, reason }` on failure (`no-targets`, `unknown-backend`).
269
+
270
+ Emits `put` with `{ id, key, metadata }`.
271
+
272
+ #### `get(idOrKey) → Promise<Buffer \| null>` / `getStream(idOrKey) → Promise<Readable \| null>`
273
+
274
+ Lookup order: path index (`backend:key`) or id → cacache → first **synced** location on a backend. On a backend buffer hit, bytes are written back to cache asynchronously.
275
+
276
+ #### `delete(idOrKey, options?) → Promise<{ ok, deleted, kept } \| { ok:false, reason:'not-found' }>`
277
+
278
+ Removes from cache and backends. The index entry is removed only when no locations remain.
279
+
280
+ | Option | Description |
281
+ |--------|-------------|
282
+ | `backends` | Only delete locations on these backends |
283
+ | `urls` | Only delete the listed `stored://<backend>/<key>` locations (precise multi-name targeting) |
284
+
285
+ Emits `delete` with `{ id, backends }` (names deleted).
286
+
287
+ #### `copy(idOrKey, options) → Promise<{ ok, id, added, locations } | { ok:false, reason }>`
288
+
289
+ Copy bytes onto other backends. Content identity is unchanged — the same index entry gains locations.
290
+
291
+ | Option | Default | Description |
292
+ |--------|---------|-------------|
293
+ | `to` | **required** | Target backend name, or an array of them |
294
+ | `key` | source key | Key on the targets |
295
+ | `from` | cheapest readable location | Source backend name or `stored://` URL |
296
+ | `onConflict` | `error` | Other content already on the destination key: `error` (refuse), `rename` (next free `name-1.ext`), `overwrite` |
297
+
298
+ Bytes stream through one hashing pass and the result is checked against the indexed id **before** anything is committed, so a source that was rewritten behind stored's back fails with `checksum-mismatch` instead of propagating wrong bytes under the old id. Targets that already hold the key are skipped (`unchanged:true`, not an error).
299
+
300
+ Destination keys are normalized (`Fotky//2019` → `Fotky/2019`) so the index always spells a key the way the filesystem reports it back.
301
+
302
+ Reasons: `not-found`, `no-targets`, `no-source`, `unknown-backend`, `read-only-target`, `source-offline`, `target-offline`, `source-unreadable`, `checksum-mismatch`, `target-exists`, `invalid-key`, `transfer-failed`.
303
+
304
+ Emits `object:location:add` per new location.
305
+
306
+ #### `move(idOrKey, options) → Promise<{ ok, id, state, from, to, locations } | { ok:false, reason }>`
307
+
308
+ Same options as `copy`, but exactly one target (`move-single-target` otherwise) and the source location is dropped afterwards.
309
+
310
+ - Within one filesystem the transfer is a single `rename(2)` — no bytes cross userspace and no re-hash (the inode carries the identity).
311
+ - Across filesystems/drivers it is a verified stream copy, then a source delete.
312
+ - Onto a `type:'remote'` backend it returns `state:'pending'` and completes on the `synced` event: **the source is never released before the destination write is durable.** A failed sync leaves the object as a copy, never lost.
313
+ - If the destination lands but the source delete fails, the result is `{ ok:false, reason:'source-delete-failed', degradedToCopy:true }` — a cleanup problem, not data loss.
314
+
315
+ Emits `object:move` on completion. Consumers must patch `locations[]` **in place**; treating it as unlink + add loses the document's identity and curated placements.
316
+
317
+ #### `stat(idOrKey) → Promise<Metadata | null>`
318
+
319
+ #### `has(idOrKey) → Promise<boolean>`
320
+
321
+ #### `locations(idOrKey) → Promise<Location[]>`
322
+
323
+ Where the content actually lives: `{ url, nativeUrl, backend, key, driver, synced, size, source }` per location.
324
+
325
+ - `url` - canonical `stored://<backend>/<key>`, the fetch form (`getByUrl`). Single source of truth for the grammar.
326
+ - `nativeUrl` - the real protocol URL for provenance/UI: `https://…`, `s3://bucket/…`, `imap://account/…`, `file://…` (local), or `null` when the backend has none.
327
+
328
+ Returns `[]` when unknown. Backend drivers render `nativeUrl(key)`; remote backends are the interesting case (a blob can live under several names across backends).
329
+
330
+ #### `list() → AsyncIterable<Metadata>`
331
+
332
+ Yields all index metadata entries.
333
+
334
+ #### `listBackend(name, options?) → AsyncIterable`
335
+
336
+ Yields `{ key, size, … }` from that backend’s native `list()`. `options` (`prefix`, `recursive`) are passed through.
337
+
338
+ #### `scan(backendName?, options?) → Promise<{ ok, backend, count, files }>`
339
+
340
+ Index existing objects from one backend or all. `options` is forwarded verbatim to each backend's `scan()` (per-driver knobs). File backends skip re-hashing files whose size+mtime are unchanged. Updates locations, prunes index entries for keys removed on disk; `files` are per-file scan rows `{ key, size, checksums, mimeType, backend, … }`.
341
+
342
+ ### Lifecycle
343
+
344
+ #### `stop() → Promise<void>`
345
+
346
+ Stops sync queue, all backend watchers, and closes the LMDB index.
347
+
348
+ ---
349
+
350
+ ## Backends
351
+
352
+ Registered in `BackendManager` via `DRIVERS`:
353
+
354
+ | Driver | Type | Status | Notes |
355
+ |--------|------|--------|-------|
356
+ | `file` | `local` | complete | CRUD, chokidar `watch`, `scan` |
357
+ | `cacache` | `local` | complete | Content-addressable blob store (cacache); CRUD, no `watch`/`scan` |
358
+ | `http` | `remote` | partial | Read-only `get` / `stat` via `fetch`; `config.baseUrl`, `headers` |
359
+ | `s3` | `remote` | skeleton | Registered for URL parsing; CRUD not implemented |
360
+
361
+ Each backend implements `StorageBackend`: `put`, `get`, `delete`, `stat`, `list`; optional `commit` (local, for streaming put), `watch`, `scan`, `stop`.
362
+
363
+ > StoreD abstracts **blob** backends only. Non-blob connectors (mail/IMAP, git, …) are not drivers here - they live in separate consumer services and use StoreD only to persist the blobs they produce. The generic `object:*` / `backend:state` event surface still lets such a connector emit through a host that wraps StoreD, but the IMAP protocol itself is no longer bundled.
364
+
365
+ | Capability | Meaning |
366
+ |------------|---------|
367
+ | `capabilities.delete === false` | `deleteByUrl` returns `read-only-backend` (e.g. HTTP) |
368
+ | `type === 'local'` | Written synchronously on `put` |
369
+ | `type === 'remote'` | Written via `SyncQueue` after cache write |
370
+
371
+ ### `file` driver config
372
+
373
+ ```js
374
+ { driver: 'file', root: '/path/to/root', watch: true, ignored: /pattern/, algorithms: ['sha256'] }
375
+ ```
376
+
377
+ #### Network mounts (`remote`)
378
+
379
+ Remote shares are consumed as **OS-level mounts** (kernel cifs/nfs, `rclone mount`, …) rather than protocol drivers: the file driver already carries the whole vocabulary a share needs (`createRoot:false`, `verifyRoot()` fsid liveness, partial-scan semantics, ranged reads, `stat.dev/ino` for rename matching and skip-hash), and the kernel client caches far better than a userspace one.
380
+
381
+ ```js
382
+ { driver: 'file', root: '/mnt/nas/photos', remote: true, transport: 'cifs' }
383
+ ```
384
+
385
+ Detected from the kernel mount table (`/proc/mounts`, Linux) at registration; explicit config always wins. `remote` is a **separate axis from `type`**: `type: 'local'|'remote'` decides the write path (synchronous put vs cache + SyncQueue), and a CIFS mount is still a synchronous POSIX write target — `type:'local'`, `remote:true`.
386
+
387
+ What it changes:
388
+
389
+ - **`watch()` refuses to start** without `usePolling: true`. inotify only reports changes made through *this* client, so a watched share silently misses everything another machine writes — worse than not watching, because it looks live. A `backend:state` event with `reason: 'remote-watch-requires-polling'` is emitted instead; use `scan()`/resync. With `usePolling` the watcher polls at `pollInterval` (default 30s).
390
+ - **Reads prefer local locations.** `get()`/`getStream()` order candidates cache → local disk → network mount → protocol backend, so a LAN round-trip never wins over a local copy that was merely indexed later.
391
+ - **`locations()` carries `remote` + `transport`**, and the location's `source` descriptor records them too (sparsely — only when remote), so a location stays self-describing after its backend is unmounted or dropped from config.
392
+ - Liveness is unchanged and load-bearing: an unmounted share either vanishes or exposes a different `dev`, and `verifyRoot()` fails the scan with `complete:false` and **zero rows**. An absent mount is never read as an empty one.
393
+
394
+ ### `cacache` driver config
395
+
396
+ ```js
397
+ { driver: 'cacache', root: '/path/to/blobstore', algorithms: ['sha256'] }
398
+ ```
399
+
400
+ Content-addressable local blob store backed by [cacache](https://www.npmjs.com/package/cacache): bytes are sha-keyed, deduped, and integrity-checked. Same key→value CRUD surface as `file` (`put`/`commit`/`get`/`delete`/`stat`/`list`), so it is a drop-in `type: 'local'` write target - bytes land synchronously on `put`, no `SyncQueue`. `config.root` is the only required field ("the data route"); `nativeUrl` is `null` (the store is internal - `stored://<backend>/<key>` is the sole address) and the store is **not** watched/scanned (a managed write target, not an external source). canvas-server wires it as the opt-in `workspace:data` backend (disabled by default).
401
+
402
+ ### `http` driver config
403
+
404
+ ```js
405
+ { driver: 'http', baseUrl: 'https://cdn.example/', account: 'cdn', headers: {} }
406
+ ```
407
+
408
+ ---
409
+
410
+ ## Events
411
+
412
+ Stored re-emits backend events and adds its own. File watcher events are duplicated as **`file:*`** and **`object:*`** (`kind: 'file'`) so consumers can bind either `file:add` or `object:add`.
413
+
414
+ ### File / object (from file backend + Stored index updates)
415
+
416
+ | Event | Payload (typical) |
417
+ |-------|-------------------|
418
+ | `file:add` | `{ backend, key, path, checksums, mimeType, size, id, locations }` |
419
+ | `file:change` | Same shape on modify (emits `object:unlink` for old content, then `object:add` for new) |
420
+ | `file:unlink` | `{ backend, key, path?, id?, checksums?, locations? }` |
421
+ | `object:add` | `{ kind: 'file', … }` (or `{ kind, backend, key, … }` from a non-file connector emitting through stored) |
422
+ | `object:change` | Forwarded from backends that emit it |
423
+ | `object:unlink` | Forwarded or synthesized on file delete |
424
+
425
+ ### Location events (identity-preserving)
426
+
427
+ Same content, different places. Consumers **patch `locations[]` in place** — routing these through unlink + add rebuilds the document and loses its identity and curated placements. Every payload carries the full post-mutation `locations` array.
428
+
429
+ | Event | Payload | Meaning |
430
+ |-------|---------|---------|
431
+ | `object:move` | `{ kind, id, checksums, from:{backend,key,url}, to:{…}, location, locations }` | One location replaced another (`move()`), destination already durable |
432
+ | `object:location:add` | `{ kind, id, checksums, location, locations }` | Content gained a location (`copy()`), keeping the existing ones |
433
+ | `object:location:remove` | `{ …, reason }` | One location dropped while others survive. `reason`: `deleted` (today) — `evicted` / `backend-removed` land with the cache policy |
434
+
435
+ Note the three-segment names: an `object:*` wildcard binding does **not** match them (eventemitter2 `*` is single-level); bind them explicitly or use `object:**`.
436
+
437
+ Watcher echoes from stored's own transfers are suppressed, so a move never also arrives as an `unlink` of the source key.
438
+
439
+ Wildcard: `object:*`, `file:*`, `scan:*`.
440
+
441
+ ### API / sync / scan
442
+
443
+ | Event | Payload |
444
+ |-------|---------|
445
+ | `put` | `{ id, key, metadata }` |
446
+ | `delete` | `{ id, backends }` |
447
+ | `synced` | `{ id, results }` - `results[]` has `{ backend, success, … }`; successful rows set `location.synced = true` |
448
+ | `scan:start` | `{ backend }` |
449
+ | `scan:complete` | `{ backend, count }` |
450
+ | `backend:state` | Backend-specific cursor/state `{ backend, … }` (forwarded for any connector that emits it) |
451
+ | `error` | From backends or sync queue |
452
+
453
+ ---
454
+
455
+ ## Workspace integration
456
+
457
+ canvas-server owns a `Stored` instance per workspace at `{WORKSPACE_ROOT}/.stored` (index + cacache). Workspace blobs still live under `data/`, `home/`, etc. as separate file backends. `WorkspaceStoredIndex` listens on `object:*`, builds synapsd documents with `stored://` locations, and resolves blobs via `getByUrl`. See `STORAGE-URL-SCHEME.md` at the repo root for the full URL grammar.
458
+
459
+ ## Licence
460
+
461
+ Copyright (C) 2025-2026 Jozef Melich. Canvas StoreD is dual-licensed:
462
+
463
+ - **[AGPL-3.0-or-later](LICENSE)**, free for everyone. Run it, modify it, build
464
+ on it. If you distribute a modified version, or expose one to users over a
465
+ network, they are entitled to your changes (AGPL section 13).
466
+ - **[Commercial licence](COMMERCIAL.md)**, the same code without the copyleft
467
+ obligations, for hosted products and proprietary distribution. Issued by
468
+ Augmentd s.r.o., lic@augmentd.eu.
469
+
470
+ Same software either way. There is no cut-down community edition. See
471
+ [NOTICE](NOTICE) for the full position, and [CONTRIBUTING.md](CONTRIBUTING.md)
472
+ before opening a pull request.
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "canvas-stored",
3
+ "version": "1.9.1",
4
+ "description": "",
5
+ "main": "./src/index.js",
6
+ "type": "module",
7
+ "author": "Jozef Melich <me@idnc.sk>",
8
+ "license": "AGPL-3.0-or-later",
9
+ "dependencies": {
10
+ "cacache": "^20.0.4",
11
+ "chokidar": "^5.0.0",
12
+ "debug": "^4.4.3",
13
+ "eventemitter2": "^6.4.9",
14
+ "exifr": "^7.1.3",
15
+ "file-type": "^21.3.3",
16
+ "fs-extra": "^11.3.4",
17
+ "image-size": "^2.0.2",
18
+ "imap": "^0.8.19",
19
+ "lmdb": "^3.5.2",
20
+ "music-metadata": "^11.9.0",
21
+ "picomatch": "^2.3.1"
22
+ },
23
+ "engines": {
24
+ "node": ">=20.0.0"
25
+ }
26
+ }
@@ -0,0 +1,60 @@
1
+ import Debug from 'debug';
2
+ import FileBackend from './file/index.js';
3
+ import CacacheBackend from './cacache/index.js';
4
+ import S3Backend from './s3/index.js';
5
+ import HttpBackend from './http/index.js';
6
+ import GdriveBackend from './gdrive/index.js';
7
+ import CanvasBackend from './canvas/index.js';
8
+
9
+ const debug = Debug('stored:backends');
10
+
11
+ // `s3`/`http` are skeletons (see their index.js). `smb`/`webdav` are reserved
12
+ // scheme names with no driver yet — register a driver class here (or at
13
+ // runtime via BackendManager.register) when implemented. `gdrive` is the first
14
+ // real remote driver; `canvas` mirrors one backend of a workspace on another
15
+ // canvas-server over the objects protocol (docs/sync-protocol.md). Non-blob connectors (mail/git/…) are NOT stored drivers;
16
+ // they live in separate workspace services and only use stored to persist blobs.
17
+ const DRIVERS = { file: FileBackend, cacache: CacacheBackend, s3: S3Backend, http: HttpBackend, gdrive: GdriveBackend, canvas: CanvasBackend };
18
+
19
+ export default class BackendManager {
20
+ #backends = new Map();
21
+
22
+ /** Register (or override) a driver class under `driver` — plugin/test seam. */
23
+ static register(driver, Driver) {
24
+ if (!driver || typeof Driver !== 'function') throw new Error('register(driver, DriverClass) required');
25
+ DRIVERS[driver] = Driver;
26
+ }
27
+
28
+ static drivers() { return Object.keys(DRIVERS); }
29
+
30
+ get(name) { return this.#backends.get(name); }
31
+ has(name) { return this.#backends.has(name); }
32
+ list() { return [...this.#backends.keys()]; }
33
+ all() { return [...this.#backends.values()]; }
34
+
35
+ add(name, config) {
36
+ if (this.#backends.has(name)) throw new Error(`Backend "${name}" already exists`);
37
+ const Driver = DRIVERS[config.driver];
38
+ if (!Driver) throw new Error(`Unknown driver: ${config.driver}`);
39
+
40
+ const backend = new Driver(name, config);
41
+ this.#backends.set(name, backend);
42
+ debug(`Added backend "${name}" (${config.driver})`);
43
+ return backend;
44
+ }
45
+
46
+ async remove(name) {
47
+ const backend = this.#backends.get(name);
48
+ if (!backend) return false;
49
+ await backend.stop();
50
+ this.#backends.delete(name);
51
+ debug(`Removed backend "${name}"`);
52
+ return true;
53
+ }
54
+
55
+ async stopAll() {
56
+ for (const backend of this.#backends.values()) {
57
+ await backend.stop();
58
+ }
59
+ }
60
+ }