@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
|
|
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,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
|
-
//
|
|
46
|
-
const
|
|
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
|
|
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 +
|
|
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.
|
|
574
|
-
*
|
|
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
|
|
585
|
-
for (
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
}
|