@augmentd-labs/canvas-edge 0.7.0 → 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. 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.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -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
+ // Pause between two requests for a directory rename the hub is still running.
46
+ const DIRECTORY_RENAME_POLL_MS = [500, 1000, 2000, 5000];
45
47
  const JSON_HEADERS = { Accept: 'application/json' };
46
48
 
47
49
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -568,8 +570,10 @@ export default class CanvasBackend extends StorageBackend {
568
570
 
569
571
  /**
570
572
  * `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
+ * 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.
573
577
  */
574
578
  async renameDirectory(from, to, { operationId, origin = null } = {}) {
575
579
  const fromKey = normalizeKey(from);
@@ -577,13 +581,19 @@ export default class CanvasBackend extends StorageBackend {
577
581
  if (!fromKey || !toKey || !operationId) throw new CanvasHubError('canvas directory rename: from, to and operationId are required', { code: 'REFUSED' });
578
582
  const body = { from: fromKey, to: toKey, directory: true, operationId };
579
583
  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
584
  const prefix = `${fromKey}/`;
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
+ }
596
+ if (payload?.directory !== true) throw new CanvasHubError('the hub did not acknowledge a directory rename', { code: 'REFUSED', hubCode: 'NOT_IMPLEMENTED', key: fromKey });
587
597
  for (const [key, row] of [...this.#files]) {
588
598
  if (!key.startsWith(prefix)) continue;
589
599
  const next = `${toKey}/${key.slice(prefix.length)}`;
@@ -27,6 +27,8 @@ import { detectMimeType, detectMimeFromHead } from './utils/mime.js';
27
27
  const debug = Debug('stored');
28
28
  // Source retirement belongs to the final move event, never a source-loss unlink.
29
29
  const MOVE_SOURCE = Symbol('move-source');
30
+ // A remote source already downloaded for this copy: { path, checksums, size, backend, key }.
31
+ const STAGED = Symbol('staged-source');
30
32
 
31
33
  const HEAD_BYTES = 4096;
32
34
 
@@ -101,6 +103,7 @@ export default class Stored extends EventEmitter2 {
101
103
  #sweepTimer = null;
102
104
  #directoryMovesDb = null;
103
105
  #directoryMoveTasks = new Map();
106
+ #moveUnqueued;
104
107
  #replicas;
105
108
 
106
109
  constructor(config = {}) {
@@ -112,7 +115,15 @@ export default class Stored extends EventEmitter2 {
112
115
  for (const name of ['put', 'copy', 'move', 'writeObject', 'removeObject', 'removeToTrash', 'renameObject', 'renameDirectory', 'trashDirectory', 'removeDirectory',
113
116
  'ensureDirectory', 'removeEmptyDirectory', 'restoreTrash', 'discardTrash', 'restoreRetained', 'deleteByUrl', 'delete']) {
114
117
  const method = this[name].bind(this);
115
- this[name] = (...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);
116
127
  }
117
128
  this.#paths = resolveStoredPaths(config);
118
129
  this.#config = {
@@ -983,7 +994,8 @@ export default class Stored extends EventEmitter2 {
983
994
  return { ok: false, reason: 'target-exists', key: toKey };
984
995
  }
985
996
 
986
- const result = await this.move(`${backendName}:${fromKey}`, {
997
+ const move = this.#streamsRemote(backendName) ? this.#moveUnqueued : this.move;
998
+ const result = await move(`${backendName}:${fromKey}`, {
987
999
  to: backendName,
988
1000
  key: toKey,
989
1001
  from: `stored://${backendName}/${fromKey}`,
@@ -1196,6 +1208,58 @@ export default class Stored extends EventEmitter2 {
1196
1208
  * pick the next free `name-1.ext`, or replace it.
1197
1209
  * @returns {Promise<{ ok:true, id, added:string[], locations } | { ok:false, reason:string }>}
1198
1210
  */
1211
+ /**
1212
+ * Network time stays out of the mutation executor, so transfers to and
1213
+ * from a hub run side by side. An upload to a streaming remote touches no
1214
+ * local bytes and records its result in one index transaction, so it runs
1215
+ * unqueued. A download is staged to a temp file first; only the commit
1216
+ * onto the local target is queued.
1217
+ */
1218
+ async #routeCopy(method, idOrKey, options) {
1219
+ const names = (Array.isArray(options.to) ? options.to : [options.to]).filter(Boolean).map(String);
1220
+ const targets = names.map((name) => this.#backends.get(name));
1221
+ if (names.length === 1 && this.#streamsRemote(names[0])) {
1222
+ return method(idOrKey, options);
1223
+ }
1224
+ const staged = this.#closing ? null : await this.#stageRemoteSource(idOrKey, options, targets);
1225
+ if (!staged) return this.withStorageMutation(() => method(idOrKey, options), 'copy');
1226
+ try {
1227
+ return await this.withStorageMutation(() => method(idOrKey, { ...options, [STAGED]: staged }), 'copy');
1228
+ } finally {
1229
+ await fsp.rm(staged.path, { force: true }).catch(() => {});
1230
+ }
1231
+ }
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
+
1240
+ // Download a remote source next to its first local target. Null when the
1241
+ // source is not remote or cannot be read; the queued copy then reports it.
1242
+ async #stageRemoteSource(idOrKey, options, targets) {
1243
+ const meta = this.#index.get(idOrKey);
1244
+ const location = meta ? this.#resolveSourceLocation(meta, options.from) : null;
1245
+ const backend = location ? this.#backends.get(location.backend) : null;
1246
+ if (backend?.type !== 'remote') return null;
1247
+ const firstLocal = targets.find((t) => t?.type === 'local');
1248
+ const tempDir = firstLocal ? firstLocal.tempDir : path.join(this.#paths.cache, '.tmp');
1249
+ const tempPath = path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
1250
+ try {
1251
+ const source = await backend.get(location.key, { stream: true });
1252
+ if (!source) return null;
1253
+ await fsp.mkdir(tempDir, { recursive: true });
1254
+ const { checksums, size } = await this.#hashToFile(source, tempPath);
1255
+ return { path: tempPath, checksums, size, backend: location.backend, key: location.key };
1256
+ } catch (err) {
1257
+ debug(`Staging ${location.backend}:${location.key} failed: ${err.message}`);
1258
+ await fsp.rm(tempPath, { force: true }).catch(() => {});
1259
+ return null;
1260
+ }
1261
+ }
1262
+
1199
1263
  async copy(idOrKey, options = {}) {
1200
1264
  const plan = await this.#planTransfer(idOrKey, options);
1201
1265
  if (!plan.ok) return plan;
@@ -1207,7 +1271,7 @@ export default class Stored extends EventEmitter2 {
1207
1271
 
1208
1272
  const release = this.#holdKeys(targets.map(t => `${t.name}:${t.key || destKey}`));
1209
1273
  try {
1210
- const transferred = await this.#streamToTargets(meta, sourceLocation, targets, destKey, this.#transferOptions(options));
1274
+ const transferred = await this.#streamToTargets(meta, sourceLocation, targets, destKey, this.#transferOptions(options), options[STAGED]);
1211
1275
  if (!transferred.ok) return transferred;
1212
1276
  // A conflict-inbox upload placed nothing at the key: no location.
1213
1277
  if (transferred.conflict && !transferred.locations.length) {
@@ -1632,7 +1696,13 @@ export default class Stored extends EventEmitter2 {
1632
1696
  // an unmounted share, a failed sync) must not turn a copy into a
1633
1697
  // silent no-op that reports success.
1634
1698
  const ours = (meta.locations || []).some(l => l.backend === name && l.key === destKey);
1635
- const present = await backend.stat(destKey).catch(() => null);
1699
+ // A streaming remote enforces If-Match / If-None-Match itself, so
1700
+ // the indexed view of the key stands in for a round trip.
1701
+ const preconditioned = backend.type === 'remote' && typeof backend.putStream === 'function'
1702
+ && (options.ifMatch != null || options.ifNoneMatch != null);
1703
+ const present = preconditioned
1704
+ ? (this.#index.get(`${name}:${destKey}`) ? { key: destKey } : null)
1705
+ : await backend.stat(destKey).catch(() => null);
1636
1706
  if (ours && present) continue;
1637
1707
  if (ours) debug(`Stale location ${name}:${destKey} (indexed, no bytes) — re-transferring`);
1638
1708
 
@@ -1724,7 +1794,7 @@ export default class Stored extends EventEmitter2 {
1724
1794
  * content identity before committing. The temp file is staged on the first
1725
1795
  * local target's filesystem so `#commit` can hardlink instead of copying.
1726
1796
  */
1727
- async #streamToTargets(meta, sourceLocation, targets, destKey, options = {}) {
1797
+ async #streamToTargets(meta, sourceLocation, targets, destKey, options = {}, staged = null) {
1728
1798
  const sourceBackend = this.#backends.get(sourceLocation.backend);
1729
1799
  // One remote target that speaks `putStream`: stream straight to it —
1730
1800
  // no temp file, no cache entry, no SyncQueue. The hub verifies the
@@ -1732,16 +1802,17 @@ export default class Stored extends EventEmitter2 {
1732
1802
  if (targets.length === 1 && targets[0].backend.type === 'remote' && typeof targets[0].backend.putStream === 'function') {
1733
1803
  return this.#streamToRemote(meta, sourceBackend, sourceLocation, targets[0], destKey, options);
1734
1804
  }
1735
- const source = await sourceBackend.get(sourceLocation.key, { stream: true });
1736
- if (!source) return { ok: false, reason: 'source-unreadable', backend: sourceLocation.backend, key: sourceLocation.key };
1805
+ const prefetched = staged && staged.backend === sourceLocation.backend && staged.key === sourceLocation.key ? staged : null;
1806
+ const source = prefetched ? null : await sourceBackend.get(sourceLocation.key, { stream: true });
1807
+ if (!prefetched && !source) return { ok: false, reason: 'source-unreadable', backend: sourceLocation.backend, key: sourceLocation.key };
1737
1808
 
1738
1809
  const firstLocal = targets.find(t => t.backend.type === 'local')?.backend;
1739
1810
  const tempDir = firstLocal ? firstLocal.tempDir : path.join(this.#paths.cache, '.tmp');
1740
- const tempPath = path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
1741
- await fsp.mkdir(tempDir, { recursive: true });
1811
+ const tempPath = prefetched ? prefetched.path : path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
1812
+ if (!prefetched) await fsp.mkdir(tempDir, { recursive: true });
1742
1813
 
1743
1814
  try {
1744
- const { checksums, size } = await this.#hashToFile(source, tempPath);
1815
+ const { checksums, size } = prefetched || await this.#hashToFile(source, tempPath);
1745
1816
  const id = formatId(checksums, this.#config.primaryChecksum);
1746
1817
  if (id !== meta.id) {
1747
1818
  debug(`Transfer aborted: ${sourceLocation.backend}:${sourceLocation.key} hashes to ${id}, indexed as ${meta.id}`);
@@ -926,6 +926,7 @@ export default class Mirror extends EventEmitter {
926
926
  const key = job.key;
927
927
  const { L, B, Lloc, Lmeta } = this.#local3(key);
928
928
  if (!L || !Lmeta) { this.#markDirty(key); return; }
929
+ if (L === B) return; // an earlier job already pushed these bytes
929
930
  // A brand-new key (no base, nothing in the hub view) skips the HEAD:
930
931
  // the PUT carries If-None-Match:* and a 412 re-decides below, so the
931
932
  // extra round trip bought nothing — and on a slow link it was half the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmentd-labs/canvas-edge",
3
- "version": "0.7.0",
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.18.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": "9cc56fa",
51
+ "canvasRev": "8758ca3",
52
52
  "canvasSource": "runtimes/edge"
53
53
  }