@augmentd-labs/canvas-edge 0.7.0 → 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.
- 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 +9 -4
- package/node_modules/canvas-stored/src/index.js +65 -9
- 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 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.
|
|
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
|
+
// 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));
|
|
@@ -196,10 +198,10 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
196
198
|
* body is sent once and a retryable status surfaces as `RETRYABLE`.
|
|
197
199
|
* `okStatuses` widens success (304, 404 for stat-like calls).
|
|
198
200
|
*/
|
|
199
|
-
async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null } = {}) {
|
|
201
|
+
async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null, extraMs = 0 } = {}) {
|
|
200
202
|
const { body: bodySpec, ...rest } = init;
|
|
201
203
|
const streaming = bodySpec != null && typeof bodySpec !== 'function' && !Buffer.isBuffer(bodySpec) && typeof bodySpec !== 'string';
|
|
202
|
-
const timeoutMs = this.#requestTimeoutMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
|
|
204
|
+
const timeoutMs = this.#requestTimeoutMs + extraMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
|
|
203
205
|
let last;
|
|
204
206
|
for (let attempt = 0; ; attempt += 1) {
|
|
205
207
|
const body = typeof bodySpec === 'function' ? bodySpec() : bodySpec;
|
|
@@ -577,13 +579,16 @@ export default class CanvasBackend extends StorageBackend {
|
|
|
577
579
|
if (!fromKey || !toKey || !operationId) throw new CanvasHubError('canvas directory rename: from, to and operationId are required', { code: 'REFUSED' });
|
|
578
580
|
const body = { from: fromKey, to: toKey, directory: true, operationId };
|
|
579
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;
|
|
580
586
|
const { payload } = await this.#json(`${this.#backendUrl()}/objects/rename`, {
|
|
581
587
|
method: 'POST',
|
|
582
588
|
headers: { 'Content-Type': 'application/json' },
|
|
583
589
|
body: JSON.stringify(body),
|
|
584
|
-
}, { key: fromKey });
|
|
590
|
+
}, { key: fromKey, extraMs: files * DIRECTORY_RENAME_MS_PER_FILE });
|
|
585
591
|
if (payload?.directory !== true) throw new CanvasHubError('the hub did not acknowledge a directory rename', { code: 'REFUSED', hubCode: 'NOT_IMPLEMENTED', key: fromKey });
|
|
586
|
-
const prefix = `${fromKey}/`;
|
|
587
592
|
for (const [key, row] of [...this.#files]) {
|
|
588
593
|
if (!key.startsWith(prefix)) continue;
|
|
589
594
|
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
|
|
|
@@ -112,7 +114,9 @@ export default class Stored extends EventEmitter2 {
|
|
|
112
114
|
for (const name of ['put', 'copy', 'move', 'writeObject', 'removeObject', 'removeToTrash', 'renameObject', 'renameDirectory', 'trashDirectory', 'removeDirectory',
|
|
113
115
|
'ensureDirectory', 'removeEmptyDirectory', 'restoreTrash', 'discardTrash', 'restoreRetained', 'deleteByUrl', 'delete']) {
|
|
114
116
|
const method = this[name].bind(this);
|
|
115
|
-
this[name] =
|
|
117
|
+
this[name] = name === 'copy'
|
|
118
|
+
? (idOrKey, options = {}) => this.#routeCopy(method, idOrKey, options)
|
|
119
|
+
: (...args) => this.withStorageMutation(() => method(...args), name);
|
|
116
120
|
}
|
|
117
121
|
this.#paths = resolveStoredPaths(config);
|
|
118
122
|
this.#config = {
|
|
@@ -1196,6 +1200,51 @@ export default class Stored extends EventEmitter2 {
|
|
|
1196
1200
|
* pick the next free `name-1.ext`, or replace it.
|
|
1197
1201
|
* @returns {Promise<{ ok:true, id, added:string[], locations } | { ok:false, reason:string }>}
|
|
1198
1202
|
*/
|
|
1203
|
+
/**
|
|
1204
|
+
* Network time stays out of the mutation executor, so transfers to and
|
|
1205
|
+
* from a hub run side by side. An upload to a streaming remote touches no
|
|
1206
|
+
* local bytes and records its result in one index transaction, so it runs
|
|
1207
|
+
* unqueued. A download is staged to a temp file first; only the commit
|
|
1208
|
+
* onto the local target is queued.
|
|
1209
|
+
*/
|
|
1210
|
+
async #routeCopy(method, idOrKey, options) {
|
|
1211
|
+
const names = (Array.isArray(options.to) ? options.to : [options.to]).filter(Boolean).map(String);
|
|
1212
|
+
const targets = names.map((name) => this.#backends.get(name));
|
|
1213
|
+
if (targets.length === 1 && targets[0]?.type === 'remote' && typeof targets[0].putStream === 'function') {
|
|
1214
|
+
return method(idOrKey, options);
|
|
1215
|
+
}
|
|
1216
|
+
const staged = this.#closing ? null : await this.#stageRemoteSource(idOrKey, options, targets);
|
|
1217
|
+
if (!staged) return this.withStorageMutation(() => method(idOrKey, options), 'copy');
|
|
1218
|
+
try {
|
|
1219
|
+
return await this.withStorageMutation(() => method(idOrKey, { ...options, [STAGED]: staged }), 'copy');
|
|
1220
|
+
} finally {
|
|
1221
|
+
await fsp.rm(staged.path, { force: true }).catch(() => {});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Download a remote source next to its first local target. Null when the
|
|
1226
|
+
// source is not remote or cannot be read; the queued copy then reports it.
|
|
1227
|
+
async #stageRemoteSource(idOrKey, options, targets) {
|
|
1228
|
+
const meta = this.#index.get(idOrKey);
|
|
1229
|
+
const location = meta ? this.#resolveSourceLocation(meta, options.from) : null;
|
|
1230
|
+
const backend = location ? this.#backends.get(location.backend) : null;
|
|
1231
|
+
if (backend?.type !== 'remote') return null;
|
|
1232
|
+
const firstLocal = targets.find((t) => t?.type === 'local');
|
|
1233
|
+
const tempDir = firstLocal ? firstLocal.tempDir : path.join(this.#paths.cache, '.tmp');
|
|
1234
|
+
const tempPath = path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
|
|
1235
|
+
try {
|
|
1236
|
+
const source = await backend.get(location.key, { stream: true });
|
|
1237
|
+
if (!source) return null;
|
|
1238
|
+
await fsp.mkdir(tempDir, { recursive: true });
|
|
1239
|
+
const { checksums, size } = await this.#hashToFile(source, tempPath);
|
|
1240
|
+
return { path: tempPath, checksums, size, backend: location.backend, key: location.key };
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
debug(`Staging ${location.backend}:${location.key} failed: ${err.message}`);
|
|
1243
|
+
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1199
1248
|
async copy(idOrKey, options = {}) {
|
|
1200
1249
|
const plan = await this.#planTransfer(idOrKey, options);
|
|
1201
1250
|
if (!plan.ok) return plan;
|
|
@@ -1207,7 +1256,7 @@ export default class Stored extends EventEmitter2 {
|
|
|
1207
1256
|
|
|
1208
1257
|
const release = this.#holdKeys(targets.map(t => `${t.name}:${t.key || destKey}`));
|
|
1209
1258
|
try {
|
|
1210
|
-
const transferred = await this.#streamToTargets(meta, sourceLocation, targets, destKey, this.#transferOptions(options));
|
|
1259
|
+
const transferred = await this.#streamToTargets(meta, sourceLocation, targets, destKey, this.#transferOptions(options), options[STAGED]);
|
|
1211
1260
|
if (!transferred.ok) return transferred;
|
|
1212
1261
|
// A conflict-inbox upload placed nothing at the key: no location.
|
|
1213
1262
|
if (transferred.conflict && !transferred.locations.length) {
|
|
@@ -1632,7 +1681,13 @@ export default class Stored extends EventEmitter2 {
|
|
|
1632
1681
|
// an unmounted share, a failed sync) must not turn a copy into a
|
|
1633
1682
|
// silent no-op that reports success.
|
|
1634
1683
|
const ours = (meta.locations || []).some(l => l.backend === name && l.key === destKey);
|
|
1635
|
-
|
|
1684
|
+
// A streaming remote enforces If-Match / If-None-Match itself, so
|
|
1685
|
+
// the indexed view of the key stands in for a round trip.
|
|
1686
|
+
const preconditioned = backend.type === 'remote' && typeof backend.putStream === 'function'
|
|
1687
|
+
&& (options.ifMatch != null || options.ifNoneMatch != null);
|
|
1688
|
+
const present = preconditioned
|
|
1689
|
+
? (this.#index.get(`${name}:${destKey}`) ? { key: destKey } : null)
|
|
1690
|
+
: await backend.stat(destKey).catch(() => null);
|
|
1636
1691
|
if (ours && present) continue;
|
|
1637
1692
|
if (ours) debug(`Stale location ${name}:${destKey} (indexed, no bytes) — re-transferring`);
|
|
1638
1693
|
|
|
@@ -1724,7 +1779,7 @@ export default class Stored extends EventEmitter2 {
|
|
|
1724
1779
|
* content identity before committing. The temp file is staged on the first
|
|
1725
1780
|
* local target's filesystem so `#commit` can hardlink instead of copying.
|
|
1726
1781
|
*/
|
|
1727
|
-
async #streamToTargets(meta, sourceLocation, targets, destKey, options = {}) {
|
|
1782
|
+
async #streamToTargets(meta, sourceLocation, targets, destKey, options = {}, staged = null) {
|
|
1728
1783
|
const sourceBackend = this.#backends.get(sourceLocation.backend);
|
|
1729
1784
|
// One remote target that speaks `putStream`: stream straight to it —
|
|
1730
1785
|
// no temp file, no cache entry, no SyncQueue. The hub verifies the
|
|
@@ -1732,16 +1787,17 @@ export default class Stored extends EventEmitter2 {
|
|
|
1732
1787
|
if (targets.length === 1 && targets[0].backend.type === 'remote' && typeof targets[0].backend.putStream === 'function') {
|
|
1733
1788
|
return this.#streamToRemote(meta, sourceBackend, sourceLocation, targets[0], destKey, options);
|
|
1734
1789
|
}
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1790
|
+
const prefetched = staged && staged.backend === sourceLocation.backend && staged.key === sourceLocation.key ? staged : null;
|
|
1791
|
+
const source = prefetched ? null : await sourceBackend.get(sourceLocation.key, { stream: true });
|
|
1792
|
+
if (!prefetched && !source) return { ok: false, reason: 'source-unreadable', backend: sourceLocation.backend, key: sourceLocation.key };
|
|
1737
1793
|
|
|
1738
1794
|
const firstLocal = targets.find(t => t.backend.type === 'local')?.backend;
|
|
1739
1795
|
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 });
|
|
1796
|
+
const tempPath = prefetched ? prefetched.path : path.join(tempDir, `${Date.now()}-${crypto.randomUUID()}`);
|
|
1797
|
+
if (!prefetched) await fsp.mkdir(tempDir, { recursive: true });
|
|
1742
1798
|
|
|
1743
1799
|
try {
|
|
1744
|
-
const { checksums, size } = await this.#hashToFile(source, tempPath);
|
|
1800
|
+
const { checksums, size } = prefetched || await this.#hashToFile(source, tempPath);
|
|
1745
1801
|
const id = formatId(checksums, this.#config.primaryChecksum);
|
|
1746
1802
|
if (id !== meta.id) {
|
|
1747
1803
|
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.1",
|
|
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.19.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": "a499f3e",
|
|
52
52
|
"canvasSource": "runtimes/edge"
|
|
53
53
|
}
|