@augmentd-labs/canvas-edge 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/canvas-stored/README.md +189 -9
- package/node_modules/canvas-stored/package.json +2 -2
- package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
- package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
- package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
- package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
- package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
- package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
- package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
- package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
- package/node_modules/canvas-stored/src/cache/index.js +39 -5
- package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +5 -3
- package/node_modules/canvas-stored/src/index.js +733 -132
- package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
- package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
- package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
- package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
- package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
- package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
- package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
- package/node_modules/canvas-stored/src/trash.js +219 -0
- package/node_modules/canvas-stored/src/utils/common.js +7 -0
- package/package.json +5 -4
- package/src/binding.js +33 -0
- package/src/control.js +13 -0
- package/src/daemon.js +14 -1
- package/src/mirror-runtime.js +14 -3
|
@@ -4,6 +4,71 @@
|
|
|
4
4
|
|
|
5
5
|
# StoreD
|
|
6
6
|
|
|
7
|
+
## Managed blob uploads and maintenance
|
|
8
|
+
|
|
9
|
+
Stream/path uploads through `put()` with a cacache destination (or only remote
|
|
10
|
+
destinations, using the transient cache) write directly through cacache's atomic
|
|
11
|
+
content writer. Checksums and MIME sniffing run during
|
|
12
|
+
that stream; extraction reads the completed blob. There is no separate Stored
|
|
13
|
+
staging copy. Cacache still uses its own temporary file until the digest is
|
|
14
|
+
known, then publishes it under the content hash. Additional destinations require
|
|
15
|
+
their own copies; keyed filesystem edits and transfers retain their staging and
|
|
16
|
+
precondition checks.
|
|
17
|
+
|
|
18
|
+
Deleting a cache key releases its reference; unused bytes remain until explicit
|
|
19
|
+
maintenance. Neither the managed backend nor the transient cache scans on a
|
|
20
|
+
timer or during shutdown. `collectGarbage()` on either object walks the index
|
|
21
|
+
and removes unreferenced blobs without reading payloads, rewriting the index,
|
|
22
|
+
or clearing temporary files. It still holds live digest paths in memory and
|
|
23
|
+
excludes writes for that root: schedule it as maintenance, not per deletion.
|
|
24
|
+
Only one writer/maintenance owner may operate on a cache root at a time.
|
|
25
|
+
|
|
26
|
+
`compact()` trims obsolete index history for each live key, preserving its newest
|
|
27
|
+
digest and metadata. It neither reads blob contents nor reclaims unused blobs.
|
|
28
|
+
|
|
29
|
+
`stored.cache.verify()` remains an explicitly requested, expensive integrity
|
|
30
|
+
scrub. It reads all retained bytes and can discard corrupted content; it is
|
|
31
|
+
not routine garbage collection. Preserve recovery copies before scrubbing.
|
|
32
|
+
|
|
33
|
+
## File backend readiness
|
|
34
|
+
|
|
35
|
+
`addBackend()` returns the backend immediately. Since 1.13, managed file roots
|
|
36
|
+
are prepared asynchronously so a slow NAS `mkdir`/`stat` cannot block Node's
|
|
37
|
+
event loop. Backend operations await this preparation. Code that accesses a
|
|
38
|
+
new root directly through `fs` must first `await backend.ready()`. Initialization
|
|
39
|
+
errors reject that promise. `createRoot: false` never creates a missing mount.
|
|
40
|
+
|
|
41
|
+
`mutationDiagnostics` reports the active physical operation and queue length.
|
|
42
|
+
An optional `onStall(report)` callback receives reports while an operation is
|
|
43
|
+
still pending; it never releases a lock or abandons a write. Cancellation of
|
|
44
|
+
`scan(name, { isCancelled })` throws `RESYNC_CANCELLED` before absence cleanup,
|
|
45
|
+
including scans that return no files.
|
|
46
|
+
|
|
47
|
+
## Backend Trash
|
|
48
|
+
|
|
49
|
+
With `retention: { days: 30 }`, file backend deletions record each original path
|
|
50
|
+
in durable Trash metadata. `listTrash(backend, { cursor, limit })` returns
|
|
51
|
+
`{ items, cursor, retention }`; `restoreTrash(backend, id)` restores the recorded
|
|
52
|
+
path without overwriting an occupied destination. Identical bytes can back
|
|
53
|
+
multiple independent trash items, and overwritten versions are not listed as
|
|
54
|
+
new deletions. Legacy retained entries with missing original paths are imported
|
|
55
|
+
when listing Trash; recovery is limited to the path metadata still available.
|
|
56
|
+
|
|
57
|
+
`trashDirectory(backend, key)` moves an entire folder to the backend staging
|
|
58
|
+
directory's `trash/<id>`, preserving hidden/unindexed files and empty folders.
|
|
59
|
+
After folder restoration, callers must scan and project that backend into their
|
|
60
|
+
index (canvas-server does this automatically). Retention expiry removes Trash
|
|
61
|
+
records and their recovery data. Retention errors stop destructive mutations.
|
|
62
|
+
|
|
63
|
+
`discardTrash(backend, id)` permanently removes one Trash item without touching
|
|
64
|
+
its original live path. Directory contents are removed from private trash;
|
|
65
|
+
file blobs are released only after their last Trash/retained-version reference.
|
|
66
|
+
Deletion intent is durable, retries are idempotent, and discard serializes with
|
|
67
|
+
restore. A failed discard remains listed for retry and cannot be restored after
|
|
68
|
+
deletion has started.
|
|
69
|
+
|
|
70
|
+
## Overview
|
|
71
|
+
|
|
7
72
|
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
73
|
|
|
9
74
|
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.
|
|
@@ -15,19 +80,38 @@ get(id) → cacache → synced backend location → cache on read (buffers on
|
|
|
15
80
|
|
|
16
81
|
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
82
|
|
|
83
|
+
## Index recovery
|
|
84
|
+
|
|
85
|
+
`Stored.open()` verifies an existing LMDB index before opening it. An unreadable
|
|
86
|
+
index or a native verifier crash quarantines the complete environment in a unique
|
|
87
|
+
sibling directory, `<index>.quarantine-<timestamp>-<suffix>/index`. The sibling
|
|
88
|
+
also contains `recovery.json` recording the original path, time and failure.
|
|
89
|
+
Startup logs the quarantine location and creates a fresh index. No quarantined
|
|
90
|
+
files are deleted automatically. Backend files and the separate cache stay in place.
|
|
91
|
+
|
|
92
|
+
Rescan each backend to rebuild byte metadata and location mappings. Verification
|
|
93
|
+
timeouts, permission errors and verifier-launch failures do not trigger quarantine.
|
|
94
|
+
If preserving the original environment fails, startup fails instead of replacing it.
|
|
95
|
+
|
|
96
|
+
The environment also holds operational sub-databases (pending moves, replica
|
|
97
|
+
ownership, sync state and trash records). These remain in quarantine; scanning
|
|
98
|
+
reconstructs the byte inventory, not pending operations or their history. Retain
|
|
99
|
+
the quarantined directory for recovery. Do not merge or replay its operations
|
|
100
|
+
against the fresh index automatically.
|
|
101
|
+
|
|
18
102
|
## Quick start
|
|
19
103
|
|
|
20
104
|
### Simple example
|
|
21
105
|
```js
|
|
22
106
|
import Stored from './src/index.js';
|
|
23
107
|
|
|
24
|
-
const stored =
|
|
108
|
+
const stored = await Stored.open({ root: './.stored' });
|
|
25
109
|
stored.addBackend('fs:data', { driver: 'file', root: stored.dataPath });
|
|
26
110
|
```
|
|
27
111
|
|
|
28
112
|
### Backend configuration
|
|
29
113
|
```js
|
|
30
|
-
const stored =
|
|
114
|
+
const stored = await Stored.open({
|
|
31
115
|
root: './.stored', // → ./.stored/{index,cache,data}
|
|
32
116
|
checksums: ['sha256'], // sha256 is the default and the id/cache algorithm
|
|
33
117
|
primaryChecksum: 'sha256',
|
|
@@ -59,10 +143,25 @@ await stored.stop();
|
|
|
59
143
|
| `cacache` | local | Content-addressable blob store (managed; opaque keys) |
|
|
60
144
|
| `gdrive` | remote | Google Drive folder subtree over OAuth refresh token — see below |
|
|
61
145
|
| `canvas` | remote | One backend of a workspace on a canvas hub, over the objects/sync protocol — see *Sync engine* below |
|
|
62
|
-
| `s3
|
|
146
|
+
| `s3` | remote | S3-compatible bucket/prefix: reads, indexing, polling and conditional create-only uploads |
|
|
147
|
+
| `http` | remote | Read-only `get` / `stat` |
|
|
63
148
|
|
|
64
149
|
Register additional drivers at runtime with `BackendManager.register(name, Class)` (`import BackendManager from 'canvas-stored/src/backends/BackendManager.js'`).
|
|
65
150
|
|
|
151
|
+
Drivers declare their features through `static getCapabilities(config)`. Use
|
|
152
|
+
`getBackendCapabilities(config)` (a named export from `canvas-stored`) to inspect
|
|
153
|
+
a stopped/unconfigured backend without opening it, or
|
|
154
|
+
`stored.getBackendCapabilities(name)` for the live instance, including runtime
|
|
155
|
+
mount detection. Unknown drivers report no supported operations.
|
|
156
|
+
|
|
157
|
+
`paths` means objects can keep a chosen folder/filename, including sub-folder
|
|
158
|
+
paths: file, Google Drive, S3 key prefixes and Canvas hub backends support it;
|
|
159
|
+
the content-addressed blob store and HTTP source do not. `mutableContainers`
|
|
160
|
+
separately reports real folder creation/rename/deletion, including empty folders
|
|
161
|
+
(file and Google Drive). `upload` reports create-only keyed uploads. Write
|
|
162
|
+
features honor `readOnly`. These are driver features; safe replication/retirement
|
|
163
|
+
capabilities remain available through `stored.replicas.capabilities(name)`.
|
|
164
|
+
|
|
66
165
|
### Google Drive backend
|
|
67
166
|
|
|
68
167
|
```js
|
|
@@ -83,6 +182,76 @@ stored.addBackend('gdrive:work', {
|
|
|
83
182
|
- `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
183
|
- `verifyRoot()` → `{ ok, reason: 'unauthorized' | 'root-missing' | 'unreachable' }` is the liveness gate (credentials + root folder).
|
|
85
184
|
|
|
185
|
+
### S3-compatible backend
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
stored.addBackend('s3:accounting', {
|
|
189
|
+
driver: 's3',
|
|
190
|
+
bucket: 'accounting', // existing bucket; never created by the driver
|
|
191
|
+
prefix: 'Augmentd/Accounting', // optional scope within the bucket
|
|
192
|
+
endpoint: 'https://s3.example.com', // omit for Amazon S3
|
|
193
|
+
region: 'us-east-1', // use the region configured by your provider
|
|
194
|
+
forcePathStyle: true, // default true with a custom endpoint
|
|
195
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
|
196
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
|
197
|
+
// sessionToken: process.env.S3_SESSION_TOKEN, // optional temporary credentials
|
|
198
|
+
readOnly: false,
|
|
199
|
+
watch: false, // opt in to full-list polling
|
|
200
|
+
pollInterval: 60000, // milliseconds, minimum 5000
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Uses the AWS SDK v3 with Signature V4. Amazon S3 uses its regional endpoint;
|
|
205
|
+
MinIO, Garage, RustFS and Ceph RGW use their HTTP(S) endpoint, configured region
|
|
206
|
+
and usually path-style addressing. The driver requires explicit credentials;
|
|
207
|
+
it never falls back to the server's ambient IAM credentials. Canvas workspace
|
|
208
|
+
settings expose the same connection fields and redact stored secrets. Rotating
|
|
209
|
+
credentials is supported; changing a workspace backend's bucket, prefix or
|
|
210
|
+
endpoint requires a new backend to preserve existing locations and pending plans.
|
|
211
|
+
|
|
212
|
+
- `get`, streaming/ranged reads, `stat`, paginated `list`, `shape`, `scan`, and
|
|
213
|
+
optional `watch`/`poll` are implemented. Directory nodes come from object keys;
|
|
214
|
+
empty directory markers and folder mutations are not supported.
|
|
215
|
+
- `put` and streaming `createFrom`/`commit` use conditional `If-None-Match: *`
|
|
216
|
+
writes with a content MD5. Existing keys are never overwritten. Single PUT
|
|
217
|
+
uploads are limited to **5 GiB**; larger uploads fail before publication.
|
|
218
|
+
Multipart/resumable upload is deferred.
|
|
219
|
+
- Before allowing writes, each new driver instance tests conditional creation
|
|
220
|
+
on a random key under `<prefix>/.canvas-probe/`, checks the original bytes,
|
|
221
|
+
and deletes its probe. The key needs ListBucket scoped to the prefix, GetObject
|
|
222
|
+
and PutObject, plus DeleteObject on the probe prefix (DeleteObjectVersion on
|
|
223
|
+
versioned buckets). An endpoint that ignores preconditions is rejected before
|
|
224
|
+
writing user keys. Read-only connections do not probe writes.
|
|
225
|
+
- ETags are observation tokens, **never content checksums or rename identity**.
|
|
226
|
+
Initial scans download bytes to hash them; subsequent scans reuse only hashes
|
|
227
|
+
matching the current ETag/version/size/time. Polling lists the prefix and checks
|
|
228
|
+
metadata, so budget remote requests and downloads. Failed/incomplete scans
|
|
229
|
+
never infer deletions. Remote renames are not inferred as user move intents.
|
|
230
|
+
- Workspace `kind: "filing"` policies support automated copies with `error` or
|
|
231
|
+
`rename` conflicts. The existing durable replica executor verifies each exact
|
|
232
|
+
destination before releasing staging. Full mirrors, overwrite and automated
|
|
233
|
+
removal are refused. `delete()` is disabled unless `permanentDelete: true` is
|
|
234
|
+
explicitly configured; this manual opt-in does not enable mirror capabilities.
|
|
235
|
+
|
|
236
|
+
`tests/s3.test.js` covers collision protection, incompatible endpoints, failed
|
|
237
|
+
enumeration, mid-read changes and replica recovery. Run the live test against an
|
|
238
|
+
isolated service with test credentials and permission to create/delete buckets:
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
CANVAS_S3_TEST_ENDPOINT=http://127.0.0.1:32768 node --test tests/s3-live.test.js
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
It creates and removes only a uniquely named test bucket. Optional variables are
|
|
245
|
+
`CANVAS_S3_TEST_REGION`, `CANVAS_S3_TEST_ACCESS_KEY`, and
|
|
246
|
+
`CANVAS_S3_TEST_SECRET_KEY`; the default test keys are `canvas-test` and
|
|
247
|
+
`canvas-test-password`. Host port 32768 is an example, not a required port.
|
|
248
|
+
Live validation used RustFS image digest
|
|
249
|
+
`sha256:1803faef57627e2d9c2e7d89d655d712ddded5389040054987163043fecb6a3c`;
|
|
250
|
+
other providers/versions still need qualification. See [TODO.md](TODO.md).
|
|
251
|
+
|
|
252
|
+
Protocol references: [AWS conditional writes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html)
|
|
253
|
+
and [SDK checksum behavior](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/s3-checksums.html).
|
|
254
|
+
|
|
86
255
|
### Canvas hub backend
|
|
87
256
|
|
|
88
257
|
```js
|
|
@@ -105,6 +274,17 @@ Speaks `canvas-server/docs/sync-protocol.md`: `stat` = `HEAD`, `get`/`getRange`
|
|
|
105
274
|
|
|
106
275
|
`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
276
|
|
|
277
|
+
`renameDirectory(backend, from, to, { operationId, origin })` moves a complete
|
|
278
|
+
directory on a writable local file backend with one native rename and an
|
|
279
|
+
atomic index update. It preserves content identities and inodes, carries
|
|
280
|
+
empty subdirectories, and does not stream or rehash the files. The destination
|
|
281
|
+
must be absent; cross-filesystem moves fail without copying. Persist the
|
|
282
|
+
unique `operationId` before the first request and reuse it on retry: a durable
|
|
283
|
+
receipt recovers an interrupted index commit or acknowledges an already
|
|
284
|
+
completed move without moving a recreated source. Different directory
|
|
285
|
+
operations on the same backend are serialized; API callers must also
|
|
286
|
+
serialize writes to either subtree (WorkspaceStoredIndex does this).
|
|
287
|
+
|
|
108
288
|
---
|
|
109
289
|
|
|
110
290
|
## Sync engine (device mirror)
|
|
@@ -112,7 +292,7 @@ Speaks `canvas-server/docs/sync-protocol.md`: `stat` = `HEAD`, `get`/`getRange`
|
|
|
112
292
|
```js
|
|
113
293
|
import Stored, { Mirror } from 'canvas-stored';
|
|
114
294
|
|
|
115
|
-
const stored =
|
|
295
|
+
const stored = await Stored.open({ root: '~/Workspaces/notes/.workspace/db/stored' });
|
|
116
296
|
stored.addBackend('local', { driver: 'file', root: '~/Workspaces/notes', watch: true, ignored: ['.workspace/**'] });
|
|
117
297
|
stored.addBackend('trash', { driver: 'file', root: '~/Workspaces/notes/.workspace/trash' });
|
|
118
298
|
stored.addBackend('conflicts', { driver: 'file', root: '~/Workspaces/notes/.workspace/conflicts' });
|
|
@@ -124,7 +304,7 @@ const mirror = new Mirror(stored, {
|
|
|
124
304
|
prefixes: [], // selective sync (prefixes / globs), applied on both sides
|
|
125
305
|
ignore: [], // on top of MIRROR_IGNORE_DEFAULTS (+ the hub's exclusions you pass here)
|
|
126
306
|
deletes: 'propagate', // | 'keep' — local deletes reach the hub, or only drop the base
|
|
127
|
-
conflictMode: '
|
|
307
|
+
conflictMode: 'rename', // default: conflict copy on the hub; 'prompt' opts into the inbox
|
|
128
308
|
debounceMs: 1500, fullReconcileEvery: 6 * 3600e3,
|
|
129
309
|
});
|
|
130
310
|
await mirror.start(); // scan local → list/poll hub → reconcile → watch → worker
|
|
@@ -137,9 +317,9 @@ await mirror.stop(); // drains running jobs
|
|
|
137
317
|
|
|
138
318
|
- **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
319
|
- **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.
|
|
320
|
+
- **Jobs** (`JobQueue`, LMDB sub-dbs `jobs` + `jobs.dedupe`): `push`, `pull`, `delete-remote`, `trash-local`, `rename-remote`, `rename-remote-dir`, `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
321
|
- **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
|
|
322
|
+
- **Renames**: a local `mv` (paired by Stored's inode window, or by content after downtime) → `POST objects/rename`. Renames are collected for `moveSettleMs` (1.5 s); when a whole folder went (the source folder is gone from the device, the target is new to the hub) they are sent as one request with `directory: true`, whatever the number of files. An inferred local deletion waits `deleteDelayMs` (5 s) before it is sent, so the second half of a move can still claim it. 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
323
|
- **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
324
|
|
|
145
325
|
`JobQueue`, `Ledger`, `Mirror`, `BackendManager` and `Index` are named exports next to the default `Stored`.
|
|
@@ -179,7 +359,7 @@ The constructor extends **eventemitter2** with `wildcard: true` and `delimiter:
|
|
|
179
359
|
| `index` | `Index` | LMDB metadata store |
|
|
180
360
|
| `backends` | `BackendManager` | Registered backend instances |
|
|
181
361
|
|
|
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()`.
|
|
362
|
+
The `cache` wrapper exposes cacache maintenance helpers directly: `stats()` (entry count + total size, streamed so large stores are never held in memory), `clear()`, `collectGarbage()`, `compact()`, `verify()`, and `list()` / `listStream()`.
|
|
183
363
|
|
|
184
364
|
---
|
|
185
365
|
|
|
@@ -356,7 +536,7 @@ Registered in `BackendManager` via `DRIVERS`:
|
|
|
356
536
|
| `file` | `local` | complete | CRUD, chokidar `watch`, `scan` |
|
|
357
537
|
| `cacache` | `local` | complete | Content-addressable blob store (cacache); CRUD, no `watch`/`scan` |
|
|
358
538
|
| `http` | `remote` | partial | Read-only `get` / `stat` via `fetch`; `config.baseUrl`, `headers` |
|
|
359
|
-
| `s3` | `remote` |
|
|
539
|
+
| `s3` | `remote` | filing/read support | Conditional creates, streaming reads, scan/poll; mirrors and overwrite disabled |
|
|
360
540
|
|
|
361
541
|
Each backend implements `StorageBackend`: `put`, `get`, `delete`, `stat`, `list`; optional `commit` (local, for streaming put), `watch`, `scan`, `stop`.
|
|
362
542
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-stored",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"license": "AGPL-3.0-or-later",
|
|
9
9
|
"dependencies": {},
|
|
10
10
|
"engines": {
|
|
11
|
-
"node": ">=
|
|
11
|
+
"node": ">=22.0.0"
|
|
12
12
|
}
|
|
13
13
|
}
|
|
@@ -8,7 +8,7 @@ import CanvasBackend from './canvas/index.js';
|
|
|
8
8
|
|
|
9
9
|
const debug = Debug('stored:backends');
|
|
10
10
|
|
|
11
|
-
// `
|
|
11
|
+
// `http` is a skeleton (see its index.js). S3 supports conditional filing copies. `smb`/`webdav` are reserved
|
|
12
12
|
// scheme names with no driver yet — register a driver class here (or at
|
|
13
13
|
// runtime via BackendManager.register) when implemented. `gdrive` is the first
|
|
14
14
|
// real remote driver; `canvas` mirrors one backend of a workspace on another
|
|
@@ -27,6 +27,14 @@ export default class BackendManager {
|
|
|
27
27
|
|
|
28
28
|
static drivers() { return Object.keys(DRIVERS); }
|
|
29
29
|
|
|
30
|
+
/** Driver-owned features, without credentials, I/O or starting a backend. */
|
|
31
|
+
static capabilities(config = {}) {
|
|
32
|
+
return DRIVERS[config.driver || 'file']?.getCapabilities?.(config) || {
|
|
33
|
+
read: false, write: false, delete: false, canEnumerate: false,
|
|
34
|
+
remote: false, paths: false, mutableContainers: false, upload: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
get(name) { return this.#backends.get(name); }
|
|
31
39
|
has(name) { return this.#backends.has(name); }
|
|
32
40
|
list() { return [...this.#backends.keys()]; }
|
|
@@ -58,3 +66,5 @@ export default class BackendManager {
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
}
|
|
69
|
+
|
|
70
|
+
export const getBackendCapabilities = (config) => BackendManager.capabilities(config);
|
|
@@ -20,11 +20,23 @@ export default class StorageBackend extends EventEmitter {
|
|
|
20
20
|
* reference — its bytes cannot be removed by us.
|
|
21
21
|
* `canEnumerate:true` means the backend implements the async-generator
|
|
22
22
|
* `list()` (and usually `scan()`) so callers can mirror its whole tree;
|
|
23
|
-
* drivers without real enumeration (http
|
|
23
|
+
* drivers without real enumeration (http) leave it false.
|
|
24
24
|
* `remote` mirrors the getter below — carried here so a single capabilities
|
|
25
25
|
* snapshot is enough for consumers deciding how expensive an op will be.
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
// Available without opening a backend (settings also list stopped mounts).
|
|
28
|
+
// paths: accepts a chosen folder/filename, including nested path segments.
|
|
29
|
+
// mutableContainers: can create/rename/delete actual folders, including
|
|
30
|
+
// empty ones. S3 prefixes support paths without supporting container ops.
|
|
31
|
+
// upload: supports create-only keyed uploads via createFrom().
|
|
32
|
+
static getCapabilities(config = {}) {
|
|
33
|
+
return {
|
|
34
|
+
read: true, write: config.readOnly !== true, delete: config.readOnly !== true,
|
|
35
|
+
canEnumerate: false, remote: config.remote === true,
|
|
36
|
+
paths: false, mutableContainers: false, upload: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
get capabilities() { return { ...this.constructor.getCapabilities(this.config), remote: this.remote }; }
|
|
28
40
|
get canDelete() { return this.capabilities.delete !== false; }
|
|
29
41
|
get canWrite() { return this.capabilities.write !== false; }
|
|
30
42
|
get canRead() { return this.capabilities.read !== false; }
|
|
@@ -46,17 +58,17 @@ export default class StorageBackend extends EventEmitter {
|
|
|
46
58
|
// The real, protocol-native URL for `key` (e.g. https://…, s3://…, smb://…,
|
|
47
59
|
// file://…), used for provenance/UI. `stored://<backend>/<key>` remains the
|
|
48
60
|
// canonical fetch form. Returns null when the backend has no meaningful one.
|
|
49
|
-
nativeUrl(
|
|
61
|
+
nativeUrl(_key) { return null; }
|
|
50
62
|
|
|
51
63
|
// Required methods - must be implemented by subclasses
|
|
52
|
-
async put(
|
|
64
|
+
async put(_key, _data) { throw new Error('Not implemented'); }
|
|
53
65
|
// Place an already-written file at `key` (streaming put commit). Local
|
|
54
66
|
// backends implement this; remote backends are fed via the cache + SyncQueue.
|
|
55
|
-
async commit(
|
|
56
|
-
async get(
|
|
57
|
-
async delete(
|
|
58
|
-
async stat(
|
|
59
|
-
|
|
67
|
+
async commit(_key, _srcPath) { throw new Error('Not implemented'); }
|
|
68
|
+
async get(_key, _options = {}) { throw new Error('Not implemented'); }
|
|
69
|
+
async delete(_key) { throw new Error('Not implemented'); }
|
|
70
|
+
async stat(_key) { throw new Error('Not implemented'); }
|
|
71
|
+
list(_options = {}) { throw new Error('Not implemented'); }
|
|
60
72
|
|
|
61
73
|
// Optional methods
|
|
62
74
|
async watch() { return false; }
|
|
@@ -66,7 +78,7 @@ export default class StorageBackend extends EventEmitter {
|
|
|
66
78
|
// Optional container (directory/folder) mutation — only backends with a real
|
|
67
79
|
// hierarchical namespace (the file driver) implement these. Advertised via
|
|
68
80
|
// the Workspace `mutableContainers` capability; callers must gate on it.
|
|
69
|
-
async createContainer(
|
|
70
|
-
async deleteContainer(
|
|
71
|
-
async renameContainer(
|
|
81
|
+
async createContainer(_key) { throw new Error('Container ops not supported by this backend'); }
|
|
82
|
+
async deleteContainer(_key) { throw new Error('Container ops not supported by this backend'); }
|
|
83
|
+
async renameContainer(_fromKey, _toKey) { throw new Error('Container ops not supported by this backend'); }
|
|
72
84
|
}
|
|
@@ -4,6 +4,9 @@ import { createReadStream } from 'fs';
|
|
|
4
4
|
import { pipeline } from 'stream/promises';
|
|
5
5
|
import cacache from 'cacache';
|
|
6
6
|
import contentPath from 'cacache/lib/content/path.js';
|
|
7
|
+
import collectGarbage from '../../cache/collect-garbage.js';
|
|
8
|
+
import ingestStream, { adoptFile } from '../../cache/ingest-stream.js';
|
|
9
|
+
import compactIndex from '../../cache/compact-index.js';
|
|
7
10
|
import Debug from 'debug';
|
|
8
11
|
import StorageBackend from '../StorageBackend.js';
|
|
9
12
|
|
|
@@ -33,6 +36,7 @@ const CACHE_OPTS = { memoize: false };
|
|
|
33
36
|
export default class CacacheBackend extends StorageBackend {
|
|
34
37
|
#root;
|
|
35
38
|
#algorithms;
|
|
39
|
+
#mutations = Promise.resolve();
|
|
36
40
|
|
|
37
41
|
constructor(name, config = {}) {
|
|
38
42
|
super(name, config);
|
|
@@ -41,19 +45,25 @@ export default class CacacheBackend extends StorageBackend {
|
|
|
41
45
|
this.#algorithms = config.algorithms || ['sha256'];
|
|
42
46
|
this.type = 'local';
|
|
43
47
|
fs.ensureDirSync(this.#root);
|
|
48
|
+
// Explicit collection must never race an unpublished content write.
|
|
49
|
+
// All mutations and maintenance share this queue.
|
|
50
|
+
for (const method of ['put', 'commit', 'ingestStream', 'delete', 'clear']) {
|
|
51
|
+
const run = this[method].bind(this);
|
|
52
|
+
this[method] = (...args) => this.#serialize(() => run(...args));
|
|
53
|
+
}
|
|
44
54
|
debug(`CacacheBackend "${name}" initialized at ${this.#root}`);
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
get root() { return this.#root; }
|
|
48
58
|
// list() streams the cacache index, so full-key enumeration works even
|
|
49
59
|
// though the store is opaque byte-wise.
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
get tempDir() { return path.join(this.#root, 'tmp'); }
|
|
60
|
+
static getCapabilities(config = {}) { return { ...super.getCapabilities(config), canEnumerate: true }; }
|
|
61
|
+
// Legacy keyed writes/transfers still stage before checking preconditions.
|
|
62
|
+
// Keep these outside cacache's tmp, which explicit verify() can remove.
|
|
63
|
+
get tempDir() { return path.join(this.#root, '.stored-tmp'); }
|
|
54
64
|
|
|
55
65
|
// Internal blob store: no protocol-native URL to surface.
|
|
56
|
-
nativeUrl(
|
|
66
|
+
nativeUrl(_key) { return null; }
|
|
57
67
|
|
|
58
68
|
// ─────────────────────────────────────────────────────────────────────────
|
|
59
69
|
// CRUD Operations
|
|
@@ -69,13 +79,28 @@ export default class CacacheBackend extends StorageBackend {
|
|
|
69
79
|
// Commit an already-written file (the Stored streaming-ingest temp) at `key`
|
|
70
80
|
// by streaming it into the content store.
|
|
71
81
|
async commit(key, srcPath) {
|
|
72
|
-
|
|
82
|
+
const output = cacache.put.stream(this.#root, key, { algorithms: this.#algorithms, ...CACHE_OPTS });
|
|
83
|
+
// An empty file emits no chunks; initialize cacache's content writer.
|
|
84
|
+
output.write(Buffer.alloc(0));
|
|
85
|
+
await pipeline(createReadStream(srcPath), output);
|
|
73
86
|
const info = await cacache.get.info(this.#root, key);
|
|
74
87
|
const size = info?.size ?? 0;
|
|
75
88
|
debug(`COMMIT ${key} (${size} bytes)`);
|
|
76
89
|
return { key, size };
|
|
77
90
|
}
|
|
78
91
|
|
|
92
|
+
// Write bytes once using cacache's atomic content writer. A generated key
|
|
93
|
+
// is only known after EOF; resolveKey can inspect the completed blob before
|
|
94
|
+
// publication. Failed/aborted uploads never replace an existing key.
|
|
95
|
+
async ingestStream(source, resolveKey) {
|
|
96
|
+
return ingestStream(this.#root, source, async content => ({ key: await resolveKey(content) }), this.#algorithms);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Move a finished file in instead of copying it (see adoptFile).
|
|
100
|
+
async adoptFile(file, digest, resolveKey) {
|
|
101
|
+
return adoptFile(this.#root, file, digest, async content => ({ key: await resolveKey(content) }), this.#algorithms);
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
// Ranged read for HTTP Range/streaming: cacache has no ranged API, so read a
|
|
80
105
|
// byte window straight off the content-addressed blob file. `end` is
|
|
81
106
|
// inclusive (matches HTTP Range and fs.createReadStream). Returns null on a
|
|
@@ -125,6 +150,24 @@ export default class CacacheBackend extends StorageBackend {
|
|
|
125
150
|
}
|
|
126
151
|
}
|
|
127
152
|
|
|
153
|
+
#serialize(fn) {
|
|
154
|
+
const next = this.#mutations.catch(() => {}).then(fn);
|
|
155
|
+
this.#mutations = next;
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
collectGarbage() {
|
|
160
|
+
return this.#serialize(() => collectGarbage(this.#root));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Explicit integrity scrub only: reads all bytes and may discard corruption.
|
|
164
|
+
verify() { return this.#serialize(() => cacache.verify(this.#root)); }
|
|
165
|
+
compact() { return this.#serialize(() => compactIndex(this.#root)); }
|
|
166
|
+
|
|
167
|
+
async stop() {
|
|
168
|
+
await this.#mutations;
|
|
169
|
+
}
|
|
170
|
+
|
|
128
171
|
// Drop the whole content store (index + content). Used by Destroy/reset paths.
|
|
129
172
|
async clear() { return cacache.rm.all(this.#root); }
|
|
130
173
|
}
|
|
@@ -129,7 +129,9 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
129
129
|
debug(`CanvasBackend "${name}" initialized (${this.#url} ws=${this.#workspaceId} backend=${this.#backend})`);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
static getCapabilities(config = {}) {
|
|
133
|
+
return { ...super.getCapabilities(config), canEnumerate: true, remote: true, paths: true };
|
|
134
|
+
}
|
|
133
135
|
get remote() { return true; }
|
|
134
136
|
/** Own writes never come back as events (origin-filtered) — Stored need not hold keys. */
|
|
135
137
|
get suppressEchoes() { return false; }
|
|
@@ -198,7 +200,7 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
198
200
|
const { body: bodySpec, ...rest } = init;
|
|
199
201
|
const streaming = bodySpec != null && typeof bodySpec !== 'function' && !Buffer.isBuffer(bodySpec) && typeof bodySpec !== 'string';
|
|
200
202
|
const timeoutMs = this.#requestTimeoutMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
|
|
201
|
-
let last
|
|
203
|
+
let last;
|
|
202
204
|
for (let attempt = 0; ; attempt += 1) {
|
|
203
205
|
const body = typeof bodySpec === 'function' ? bodySpec() : bodySpec;
|
|
204
206
|
const options = { ...rest, headers: { ...this.#authHeaders(), ...(rest.headers || {}) } };
|
|
@@ -375,7 +377,12 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
375
377
|
if (cursor) u.searchParams.set('cursor', cursor);
|
|
376
378
|
u.searchParams.set('limit', String(limit));
|
|
377
379
|
const { payload } = await this.#json(u.toString());
|
|
378
|
-
|
|
380
|
+
// Anything but a real listing (a proxy error page, a captive portal
|
|
381
|
+
// answering 200) must not read as "the hub is empty".
|
|
382
|
+
if (!Array.isArray(payload?.objects) || !Number.isFinite(Number(payload?.head))) {
|
|
383
|
+
throw Object.assign(new Error('hub listing response is not a listing'), { code: 'BAD_LISTING' });
|
|
384
|
+
}
|
|
385
|
+
const objects = payload.objects.map((o) => this.#row(normalizeKey(o.key), o));
|
|
379
386
|
const head = num(payload?.head, this.#head) ?? 0;
|
|
380
387
|
this.#head = Math.max(this.#head, head);
|
|
381
388
|
return { objects, cursor: payload?.cursor || null, head };
|
|
@@ -559,6 +566,36 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
559
566
|
return { from: fromKey, to: toKey, sha256: lower(payload?.sha256) || row?.checksums?.sha256 || null, seq, docId, version, size: row?.size ?? null, modified: row?.modified ?? null };
|
|
560
567
|
}
|
|
561
568
|
|
|
569
|
+
/**
|
|
570
|
+
* `POST objects/rename` with `directory: true`: the hub moves the folder
|
|
571
|
+
* and everything under it in one operation. `operationId` makes a retry
|
|
572
|
+
* after a lost response return the first result.
|
|
573
|
+
*/
|
|
574
|
+
async renameDirectory(from, to, { operationId, origin = null } = {}) {
|
|
575
|
+
const fromKey = normalizeKey(from);
|
|
576
|
+
const toKey = normalizeKey(to);
|
|
577
|
+
if (!fromKey || !toKey || !operationId) throw new CanvasHubError('canvas directory rename: from, to and operationId are required', { code: 'REFUSED' });
|
|
578
|
+
const body = { from: fromKey, to: toKey, directory: true, operationId };
|
|
579
|
+
if (origin || this.#deviceId) body.origin = origin || this.#deviceId;
|
|
580
|
+
const { payload } = await this.#json(`${this.#backendUrl()}/objects/rename`, {
|
|
581
|
+
method: 'POST',
|
|
582
|
+
headers: { 'Content-Type': 'application/json' },
|
|
583
|
+
body: JSON.stringify(body),
|
|
584
|
+
}, { key: fromKey });
|
|
585
|
+
if (payload?.directory !== true) throw new CanvasHubError('the hub did not acknowledge a directory rename', { code: 'REFUSED', hubCode: 'NOT_IMPLEMENTED', key: fromKey });
|
|
586
|
+
const prefix = `${fromKey}/`;
|
|
587
|
+
for (const [key, row] of [...this.#files]) {
|
|
588
|
+
if (!key.startsWith(prefix)) continue;
|
|
589
|
+
const next = `${toKey}/${key.slice(prefix.length)}`;
|
|
590
|
+
this.#files.delete(key);
|
|
591
|
+
this.#files.set(next, { ...row, key: next });
|
|
592
|
+
}
|
|
593
|
+
const seq = num(payload?.seq, 0);
|
|
594
|
+
if (seq) this.#head = Math.max(this.#head, seq);
|
|
595
|
+
debug(`RENAME ${fromKey}/ -> ${toKey}/ (seq ${seq})`);
|
|
596
|
+
return { from: fromKey, to: toKey, seq };
|
|
597
|
+
}
|
|
598
|
+
|
|
562
599
|
// ─────────────────────────────────────────────────────────────────────────
|
|
563
600
|
// Change feed
|
|
564
601
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -605,7 +642,7 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
605
642
|
if (this.#cursor == null) return { ok: false, reason: 'no-cursor' };
|
|
606
643
|
let since = this.#cursor;
|
|
607
644
|
let applied = 0;
|
|
608
|
-
let head
|
|
645
|
+
let head;
|
|
609
646
|
try {
|
|
610
647
|
for (;;) {
|
|
611
648
|
const page = await this.changes(since, CHANGES_PAGE);
|