@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.
- package/LICENSE +661 -0
- package/NOTICE +114 -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,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
|
+
}
|