@augmentd-labs/canvas-edge 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/node_modules/canvas-stored/README.md +189 -9
  2. package/node_modules/canvas-stored/package.json +2 -2
  3. package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
  4. package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
  5. package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
  6. package/node_modules/canvas-stored/src/backends/canvas/index.js +48 -6
  7. package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
  8. package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
  9. package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
  10. package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
  11. package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
  12. package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
  13. package/node_modules/canvas-stored/src/cache/index.js +39 -5
  14. package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
  15. package/node_modules/canvas-stored/src/index/index.js +5 -3
  16. package/node_modules/canvas-stored/src/index.js +797 -140
  17. package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
  18. package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
  19. package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
  20. package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
  21. package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
  22. package/node_modules/canvas-stored/src/sync/Mirror.js +335 -25
  23. package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
  24. package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
  25. package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
  26. package/node_modules/canvas-stored/src/trash.js +219 -0
  27. package/node_modules/canvas-stored/src/utils/common.js +7 -0
  28. package/package.json +5 -4
  29. package/src/binding.js +33 -0
  30. package/src/control.js +13 -0
  31. package/src/daemon.js +14 -1
  32. 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 = new Stored({ root: './.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 = new 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`, `http` | remote | Skeletons: URL parsing only (`http` has a read-only `get`) |
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 = new Stored({ root: '~/Workspaces/notes/.workspace/db/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: 'prompt', // | 'rename' — inbox upload, or Dropbox-style conflict copy on the hub
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; a directory rename gets 250 ms more per file under the folder. Transfers to and from the hub run outside the storage mutation queue (an upload streams unqueued, a download is staged and only its commit is queued), so the four workers overlap on the network. 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`; 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`).
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` | skeleton | Registered for URL parsing; CRUD not implemented |
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.9.3",
3
+ "version": "1.19.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": ">=20.0.0"
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
- // `s3`/`http` are skeletons (see their index.js). `smb`/`webdav` are reserved
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, s3 skeleton) leave it false.
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
- get capabilities() { return { read: true, write: true, delete: true, canEnumerate: false, remote: this.remote }; }
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(key) { return null; }
61
+ nativeUrl(_key) { return null; }
50
62
 
51
63
  // Required methods - must be implemented by subclasses
52
- async put(key, data) { throw new Error('Not implemented'); }
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(key, srcPath) { throw new Error('Not implemented'); }
56
- async get(key, options = {}) { throw new Error('Not implemented'); }
57
- async delete(key) { throw new Error('Not implemented'); }
58
- async stat(key) { throw new Error('Not implemented'); }
59
- async *list(options = {}) { throw new Error('Not implemented'); }
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(key) { throw new Error('Container ops not supported by this backend'); }
70
- async deleteContainer(key) { throw new Error('Container ops not supported by this backend'); }
71
- async renameContainer(fromKey, toKey) { throw new Error('Container ops not supported by this backend'); }
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
- get capabilities() { return { ...super.capabilities, canEnumerate: true }; }
51
- // Staging dir for the Stored streaming-ingest hash pass (shares a filesystem
52
- // with the store so the temp file is cheap to read back on commit).
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(key) { return null; }
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
- await pipeline(createReadStream(srcPath), cacache.put.stream(this.#root, key, { algorithms: this.#algorithms, ...CACHE_OPTS }));
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
  }
@@ -42,6 +42,8 @@ const RETRY_DELAYS_MS = [500, 1500, 4000];
42
42
  // large file on a slow uplink is never cut short.
43
43
  const REQUEST_TIMEOUT_MS = 60_000;
44
44
  const MIN_UPLOAD_BYTES_PER_SEC = 50_000;
45
+ // Extra time a directory rename is given per file the hub has under it.
46
+ const DIRECTORY_RENAME_MS_PER_FILE = 250;
45
47
  const JSON_HEADERS = { Accept: 'application/json' };
46
48
 
47
49
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -129,7 +131,9 @@ export default class CanvasBackend extends StorageBackend {
129
131
  debug(`CanvasBackend "${name}" initialized (${this.#url} ws=${this.#workspaceId} backend=${this.#backend})`);
130
132
  }
131
133
 
132
- get capabilities() { return { read: true, write: true, delete: true, canEnumerate: true, remote: true }; }
134
+ static getCapabilities(config = {}) {
135
+ return { ...super.getCapabilities(config), canEnumerate: true, remote: true, paths: true };
136
+ }
133
137
  get remote() { return true; }
134
138
  /** Own writes never come back as events (origin-filtered) — Stored need not hold keys. */
135
139
  get suppressEchoes() { return false; }
@@ -194,11 +198,11 @@ export default class CanvasBackend extends StorageBackend {
194
198
  * body is sent once and a retryable status surfaces as `RETRYABLE`.
195
199
  * `okStatuses` widens success (304, 404 for stat-like calls).
196
200
  */
197
- async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null } = {}) {
201
+ async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null, extraMs = 0 } = {}) {
198
202
  const { body: bodySpec, ...rest } = init;
199
203
  const streaming = bodySpec != null && typeof bodySpec !== 'function' && !Buffer.isBuffer(bodySpec) && typeof bodySpec !== 'string';
200
- const timeoutMs = this.#requestTimeoutMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
201
- let last = null;
204
+ const timeoutMs = this.#requestTimeoutMs + extraMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
205
+ let last;
202
206
  for (let attempt = 0; ; attempt += 1) {
203
207
  const body = typeof bodySpec === 'function' ? bodySpec() : bodySpec;
204
208
  const options = { ...rest, headers: { ...this.#authHeaders(), ...(rest.headers || {}) } };
@@ -375,7 +379,12 @@ export default class CanvasBackend extends StorageBackend {
375
379
  if (cursor) u.searchParams.set('cursor', cursor);
376
380
  u.searchParams.set('limit', String(limit));
377
381
  const { payload } = await this.#json(u.toString());
378
- const objects = (payload?.objects || []).map((o) => this.#row(normalizeKey(o.key), o));
382
+ // Anything but a real listing (a proxy error page, a captive portal
383
+ // answering 200) must not read as "the hub is empty".
384
+ if (!Array.isArray(payload?.objects) || !Number.isFinite(Number(payload?.head))) {
385
+ throw Object.assign(new Error('hub listing response is not a listing'), { code: 'BAD_LISTING' });
386
+ }
387
+ const objects = payload.objects.map((o) => this.#row(normalizeKey(o.key), o));
379
388
  const head = num(payload?.head, this.#head) ?? 0;
380
389
  this.#head = Math.max(this.#head, head);
381
390
  return { objects, cursor: payload?.cursor || null, head };
@@ -559,6 +568,39 @@ export default class CanvasBackend extends StorageBackend {
559
568
  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
569
  }
561
570
 
571
+ /**
572
+ * `POST objects/rename` with `directory: true`: the hub moves the folder
573
+ * and everything under it in one operation. `operationId` makes a retry
574
+ * after a lost response return the first result.
575
+ */
576
+ async renameDirectory(from, to, { operationId, origin = null } = {}) {
577
+ const fromKey = normalizeKey(from);
578
+ const toKey = normalizeKey(to);
579
+ if (!fromKey || !toKey || !operationId) throw new CanvasHubError('canvas directory rename: from, to and operationId are required', { code: 'REFUSED' });
580
+ const body = { from: fromKey, to: toKey, directory: true, operationId };
581
+ if (origin || this.#deviceId) body.origin = origin || this.#deviceId;
582
+ // The hub re-indexes every file under the folder before it answers.
583
+ const prefix = `${fromKey}/`;
584
+ let files = 0;
585
+ for (const key of this.#files.keys()) if (key.startsWith(prefix)) files += 1;
586
+ const { payload } = await this.#json(`${this.#backendUrl()}/objects/rename`, {
587
+ method: 'POST',
588
+ headers: { 'Content-Type': 'application/json' },
589
+ body: JSON.stringify(body),
590
+ }, { key: fromKey, extraMs: files * DIRECTORY_RENAME_MS_PER_FILE });
591
+ if (payload?.directory !== true) throw new CanvasHubError('the hub did not acknowledge a directory rename', { code: 'REFUSED', hubCode: 'NOT_IMPLEMENTED', key: fromKey });
592
+ for (const [key, row] of [...this.#files]) {
593
+ if (!key.startsWith(prefix)) continue;
594
+ const next = `${toKey}/${key.slice(prefix.length)}`;
595
+ this.#files.delete(key);
596
+ this.#files.set(next, { ...row, key: next });
597
+ }
598
+ const seq = num(payload?.seq, 0);
599
+ if (seq) this.#head = Math.max(this.#head, seq);
600
+ debug(`RENAME ${fromKey}/ -> ${toKey}/ (seq ${seq})`);
601
+ return { from: fromKey, to: toKey, seq };
602
+ }
603
+
562
604
  // ─────────────────────────────────────────────────────────────────────────
563
605
  // Change feed
564
606
  // ─────────────────────────────────────────────────────────────────────────
@@ -605,7 +647,7 @@ export default class CanvasBackend extends StorageBackend {
605
647
  if (this.#cursor == null) return { ok: false, reason: 'no-cursor' };
606
648
  let since = this.#cursor;
607
649
  let applied = 0;
608
- let head = this.#head;
650
+ let head;
609
651
  try {
610
652
  for (;;) {
611
653
  const page = await this.changes(since, CHANGES_PAGE);