@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.
- package/node_modules/canvas-stored/README.md +1 -1
- package/node_modules/canvas-stored/package.json +1 -1
- package/node_modules/canvas-stored/src/backends/canvas/index.js +18 -8
- package/node_modules/canvas-stored/src/index.js +81 -10
- package/node_modules/canvas-stored/src/sync/Mirror.js +1 -0
- package/package.json +3 -3
|
@@ -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`.
|
|
@@ -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.
|
|
572
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1736
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
51
|
+
"canvasRev": "8758ca3",
|
|
52
52
|
"canvasSource": "runtimes/edge"
|
|
53
53
|
}
|