@augmentd-labs/canvas-edge 0.7.1 → 0.7.2

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.
@@ -317,7 +317,7 @@ await mirror.stop(); // drains running jobs
317
317
 
318
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.
319
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.
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.
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 the hub answers `202` (still running) is requested again with the same `operationId` until the hub reports the result. Requests to the hub run outside the storage mutation queue (an upload streams unqueued, a download is staged and only its commit is queued, a delete or a single-file rename is one request and one index transaction), 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.
321
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.
322
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`).
323
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`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-stored",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -42,8 +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
+ // Pause between two requests for a directory rename the hub is still running.
46
+ const DIRECTORY_RENAME_POLL_MS = [500, 1000, 2000, 5000];
47
47
  const JSON_HEADERS = { Accept: 'application/json' };
48
48
 
49
49
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -198,10 +198,10 @@ export default class CanvasBackend extends StorageBackend {
198
198
  * body is sent once and a retryable status surfaces as `RETRYABLE`.
199
199
  * `okStatuses` widens success (304, 404 for stat-like calls).
200
200
  */
201
- async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null, extraMs = 0 } = {}) {
201
+ async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null } = {}) {
202
202
  const { body: bodySpec, ...rest } = init;
203
203
  const streaming = bodySpec != null && typeof bodySpec !== 'function' && !Buffer.isBuffer(bodySpec) && typeof bodySpec !== 'string';
204
- const timeoutMs = this.#requestTimeoutMs + extraMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
204
+ const timeoutMs = this.#requestTimeoutMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
205
205
  let last;
206
206
  for (let attempt = 0; ; attempt += 1) {
207
207
  const body = typeof bodySpec === 'function' ? bodySpec() : bodySpec;
@@ -570,8 +570,10 @@ export default class CanvasBackend extends StorageBackend {
570
570
 
571
571
  /**
572
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.
573
+ * and everything under it in one operation. A large folder is answered
574
+ * `202` while the hub works; the same request is repeated until it
575
+ * reports the result. `operationId` makes every repeat, and a retry
576
+ * after a lost response, return the first result.
575
577
  */
576
578
  async renameDirectory(from, to, { operationId, origin = null } = {}) {
577
579
  const fromKey = normalizeKey(from);
@@ -579,15 +581,18 @@ export default class CanvasBackend extends StorageBackend {
579
581
  if (!fromKey || !toKey || !operationId) throw new CanvasHubError('canvas directory rename: from, to and operationId are required', { code: 'REFUSED' });
580
582
  const body = { from: fromKey, to: toKey, directory: true, operationId };
581
583
  if (origin || this.#deviceId) body.origin = origin || this.#deviceId;
582
- // The hub re-indexes every file under the folder before it answers.
583
584
  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 });
585
+ let payload;
586
+ for (let attempt = 0; ; attempt += 1) {
587
+ const answer = await this.#json(`${this.#backendUrl()}/objects/rename`, {
588
+ method: 'POST',
589
+ headers: { 'Content-Type': 'application/json' },
590
+ body: JSON.stringify(body),
591
+ }, { key: fromKey });
592
+ payload = answer.payload;
593
+ if (answer.status !== 202) break;
594
+ await sleep(DIRECTORY_RENAME_POLL_MS[Math.min(attempt, DIRECTORY_RENAME_POLL_MS.length - 1)]);
595
+ }
591
596
  if (payload?.directory !== true) throw new CanvasHubError('the hub did not acknowledge a directory rename', { code: 'REFUSED', hubCode: 'NOT_IMPLEMENTED', key: fromKey });
592
597
  for (const [key, row] of [...this.#files]) {
593
598
  if (!key.startsWith(prefix)) continue;
@@ -103,6 +103,7 @@ export default class Stored extends EventEmitter2 {
103
103
  #sweepTimer = null;
104
104
  #directoryMovesDb = null;
105
105
  #directoryMoveTasks = new Map();
106
+ #moveUnqueued;
106
107
  #replicas;
107
108
 
108
109
  constructor(config = {}) {
@@ -114,9 +115,15 @@ export default class Stored extends EventEmitter2 {
114
115
  for (const name of ['put', 'copy', 'move', 'writeObject', 'removeObject', 'removeToTrash', 'renameObject', 'renameDirectory', 'trashDirectory', 'removeDirectory',
115
116
  'ensureDirectory', 'removeEmptyDirectory', 'restoreTrash', 'discardTrash', 'restoreRetained', 'deleteByUrl', 'delete']) {
116
117
  const method = this[name].bind(this);
117
- this[name] = name === 'copy'
118
- ? (idOrKey, options = {}) => this.#routeCopy(method, idOrKey, options)
119
- : (...args) => this.withStorageMutation(() => method(...args), name);
118
+ if (name === 'move') this.#moveUnqueued = method;
119
+ if (name === 'copy') this[name] = (idOrKey, options = {}) => this.#routeCopy(method, idOrKey, options);
120
+ // A delete or rename on a streaming remote is one request and one
121
+ // index transaction, so several run side by side.
122
+ else if (name === 'removeObject' || name === 'renameObject') {
123
+ this[name] = (backendName, ...rest) => (this.#streamsRemote(backendName)
124
+ ? method(backendName, ...rest)
125
+ : this.withStorageMutation(() => method(backendName, ...rest), name));
126
+ } else this[name] = (...args) => this.withStorageMutation(() => method(...args), name);
120
127
  }
121
128
  this.#paths = resolveStoredPaths(config);
122
129
  this.#config = {
@@ -987,7 +994,8 @@ export default class Stored extends EventEmitter2 {
987
994
  return { ok: false, reason: 'target-exists', key: toKey };
988
995
  }
989
996
 
990
- const result = await this.move(`${backendName}:${fromKey}`, {
997
+ const move = this.#streamsRemote(backendName) ? this.#moveUnqueued : this.move;
998
+ const result = await move(`${backendName}:${fromKey}`, {
991
999
  to: backendName,
992
1000
  key: toKey,
993
1001
  from: `stored://${backendName}/${fromKey}`,
@@ -1210,7 +1218,7 @@ export default class Stored extends EventEmitter2 {
1210
1218
  async #routeCopy(method, idOrKey, options) {
1211
1219
  const names = (Array.isArray(options.to) ? options.to : [options.to]).filter(Boolean).map(String);
1212
1220
  const targets = names.map((name) => this.#backends.get(name));
1213
- if (targets.length === 1 && targets[0]?.type === 'remote' && typeof targets[0].putStream === 'function') {
1221
+ if (names.length === 1 && this.#streamsRemote(names[0])) {
1214
1222
  return method(idOrKey, options);
1215
1223
  }
1216
1224
  const staged = this.#closing ? null : await this.#stageRemoteSource(idOrKey, options, targets);
@@ -1222,6 +1230,13 @@ export default class Stored extends EventEmitter2 {
1222
1230
  }
1223
1231
  }
1224
1232
 
1233
+ // A remote that takes writes directly (the canvas driver): it holds no
1234
+ // local bytes, so its requests need no place in the mutation queue.
1235
+ #streamsRemote(backendName) {
1236
+ const backend = this.#backends.get(String(backendName));
1237
+ return backend?.type === 'remote' && typeof backend.putStream === 'function';
1238
+ }
1239
+
1225
1240
  // Download a remote source next to its first local target. Null when the
1226
1241
  // source is not remote or cannot be read; the queued copy then reports it.
1227
1242
  async #stageRemoteSource(idOrKey, options, targets) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmentd-labs/canvas-edge",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Canvas device runtime: folder mirrors (canvas-stored Mirror engine) + edge tunnel client (dist artifact)",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@augmentd-labs/canvas-protocol": "^0.5.0",
24
- "canvas-stored": "1.19.0",
24
+ "canvas-stored": "1.20.0",
25
25
  "@aws-sdk/client-s3": "^3.1149.0",
26
26
  "cacache": "^20.0.4",
27
27
  "chokidar": "^5.0.0",
@@ -48,6 +48,6 @@
48
48
  "bundleDependencies": [
49
49
  "canvas-stored"
50
50
  ],
51
- "canvasRev": "a499f3e",
51
+ "canvasRev": "8758ca3",
52
52
  "canvasSource": "runtimes/edge"
53
53
  }