@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.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/LICENSE +661 -0
- package/NOTICE +112 -0
- package/README.md +76 -2
- package/bin/canvas-edge +2 -0
- package/bin/healthcheck.mjs +9 -0
- package/node_modules/canvas-stored/LICENSE +661 -0
- package/node_modules/canvas-stored/NOTICE +85 -0
- package/node_modules/canvas-stored/README.md +472 -0
- package/node_modules/canvas-stored/package.json +26 -0
- package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
- package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
- package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
- package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
- package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
- package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
- package/node_modules/canvas-stored/src/cache/index.js +65 -0
- package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
- package/node_modules/canvas-stored/src/extractors/image.js +90 -0
- package/node_modules/canvas-stored/src/extractors/index.js +41 -0
- package/node_modules/canvas-stored/src/extractors/text.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +360 -0
- package/node_modules/canvas-stored/src/index.js +2058 -0
- package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
- package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
- package/node_modules/canvas-stored/src/sync/keys.js +140 -0
- package/node_modules/canvas-stored/src/sync/worker.js +52 -0
- package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
- package/node_modules/canvas-stored/src/utils/common.js +37 -0
- package/node_modules/canvas-stored/src/utils/mime.js +107 -0
- package/node_modules/canvas-stored/src/utils/mount.js +99 -0
- package/package.json +49 -4
- package/src/EdgeClient.js +129 -0
- package/src/control.js +48 -0
- package/src/daemon.js +88 -0
- package/src/env.js +182 -0
- package/src/fuse-runtime.js +173 -0
- package/src/index.js +26 -0
- package/src/mirror-runtime.js +232 -0
- package/src/remote-config.js +69 -0
- package/src/runtime.js +61 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
import { createReadStream, promises as fsp } from 'fs';
|
|
3
|
+
import Debug from 'debug';
|
|
4
|
+
import StorageBackend from '../StorageBackend.js';
|
|
5
|
+
import { normalizeKey, matchesPrefixes } from '../../sync/keys.js';
|
|
6
|
+
|
|
7
|
+
const debug = Debug('stored:backend:canvas');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canvas hub storage backend — one path-addressed backend (`workspace:home`
|
|
11
|
+
* by default) of one workspace on a canvas-server, spoken over the objects
|
|
12
|
+
* protocol (`canvas-server/docs/sync-protocol.md`).
|
|
13
|
+
*
|
|
14
|
+
* Shaped like the gdrive driver: `type:'remote'`, reads stream from the API,
|
|
15
|
+
* `list()/scan()` page the hub's listing, `watch()` polls the change feed and
|
|
16
|
+
* synthesizes the file driver's `file:add|change|unlink` events so Stored
|
|
17
|
+
* indexes the hub's keys like any other backend. Identity on the wire is the
|
|
18
|
+
* sha256 (`ETag`), so rows carry `ino = sha256` and `dev = canvas:<instanceId>`
|
|
19
|
+
* — a hub-side rename (`unlink(from) + add(to)`, same ino) is paired by
|
|
20
|
+
* Stored's rename window into an in-place location rewrite.
|
|
21
|
+
*
|
|
22
|
+
* Writes are direct (`putStream` — streaming `PUT` with the protocol's
|
|
23
|
+
* preconditions and conflict headers); there is no cache/SyncQueue hop. Every
|
|
24
|
+
* mutation is stamped `X-Canvas-Origin: <deviceId>`, and the poller applies
|
|
25
|
+
* the resulting change-log echoes to its own state without re-emitting them.
|
|
26
|
+
*
|
|
27
|
+
* Errors are typed on `err.code`: `PRECONDITION_FAILED` (`err.current`),
|
|
28
|
+
* `CURSOR_TOO_OLD` (`err.oldest`, `err.head`), `TARGET_EXISTS`, `NOT_FOUND`,
|
|
29
|
+
* `UNAUTHORIZED`, `REFUSED` (a 4xx that will not change on retry), `RETRYABLE`
|
|
30
|
+
* (429/5xx after the in-call retries) and `OFFLINE` (transport failure).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const DEV_PREFIX = 'canvas';
|
|
34
|
+
const DEFAULT_POLL_INTERVAL = 30_000;
|
|
35
|
+
const PING_CACHE_MS = 30_000;
|
|
36
|
+
const LIST_PAGE = 1000;
|
|
37
|
+
const CHANGES_PAGE = 1000;
|
|
38
|
+
const RETRY_DELAYS_MS = [500, 1500, 4000];
|
|
39
|
+
// A request that gets no response is aborted, otherwise a connection a proxy
|
|
40
|
+
// silently dropped pins a worker slot until undici's own 5-minute timeout —
|
|
41
|
+
// two of those and a mirror looks dead. Uploads get extra time per byte so a
|
|
42
|
+
// large file on a slow uplink is never cut short.
|
|
43
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
44
|
+
const MIN_UPLOAD_BYTES_PER_SEC = 50_000;
|
|
45
|
+
const JSON_HEADERS = { Accept: 'application/json' };
|
|
46
|
+
|
|
47
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
const encodeSegment = (s) => encodeURIComponent(String(s));
|
|
49
|
+
const encodeKey = (key) => normalizeKey(key).split('/').map(encodeSegment).join('/');
|
|
50
|
+
const quote = (sha) => (String(sha).startsWith('"') ? String(sha) : `"${sha}"`);
|
|
51
|
+
const lower = (v) => (v == null ? null : String(v).toLowerCase());
|
|
52
|
+
const num = (v, fallback = null) => {
|
|
53
|
+
if (v == null || v === '') return fallback;
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
return Number.isFinite(n) ? n : fallback;
|
|
56
|
+
};
|
|
57
|
+
const intOrNull = (v) => { if (v == null || v === '') return null; const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
|
|
58
|
+
const parseMs = (v) => {
|
|
59
|
+
if (v == null || v === '') return null;
|
|
60
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
if (Number.isFinite(n)) return n;
|
|
63
|
+
const t = Date.parse(String(v));
|
|
64
|
+
return Number.isFinite(t) ? t : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export class CanvasHubError extends Error {
|
|
68
|
+
constructor(message, { code = 'OFFLINE', status = null, hubCode = null, current, oldest, head, key } = {}) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'CanvasHubError';
|
|
71
|
+
this.code = code;
|
|
72
|
+
this.status = status;
|
|
73
|
+
this.hubCode = hubCode;
|
|
74
|
+
if (current !== undefined) this.current = current;
|
|
75
|
+
if (oldest !== undefined) this.oldest = oldest;
|
|
76
|
+
if (head !== undefined) this.head = head;
|
|
77
|
+
if (key !== undefined) this.key = key;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get offline() { return this.code === 'OFFLINE'; }
|
|
81
|
+
/** Retrying with the same inputs cannot succeed. */
|
|
82
|
+
get permanent() { return ['REFUSED', 'NOT_FOUND', 'TARGET_EXISTS', 'UNAUTHORIZED'].includes(this.code); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default class CanvasBackend extends StorageBackend {
|
|
86
|
+
#fetch;
|
|
87
|
+
#url;
|
|
88
|
+
#workspaceId;
|
|
89
|
+
#backend;
|
|
90
|
+
#token;
|
|
91
|
+
#deviceId;
|
|
92
|
+
#deviceName;
|
|
93
|
+
#expectedInstanceId;
|
|
94
|
+
#instanceId = null;
|
|
95
|
+
#prefixes;
|
|
96
|
+
#retryDelays;
|
|
97
|
+
#requestTimeoutMs;
|
|
98
|
+
#minUploadBytesPerSec;
|
|
99
|
+
|
|
100
|
+
// key → row { key, size, modified, mimeType, checksums:{sha256}, dev, ino, docId, version }
|
|
101
|
+
// (docId/version = the hub document behind the key and its row version —
|
|
102
|
+
// the pair a replica records as "I hold the current version").
|
|
103
|
+
#files = new Map();
|
|
104
|
+
#cursor = null;
|
|
105
|
+
#head = 0;
|
|
106
|
+
#lastPing = null;
|
|
107
|
+
|
|
108
|
+
#pollTimer = null;
|
|
109
|
+
#polling = null;
|
|
110
|
+
|
|
111
|
+
constructor(name, config = {}) {
|
|
112
|
+
super(name, config);
|
|
113
|
+
this.type = 'remote';
|
|
114
|
+
if (!config.url) throw new Error('canvas backend requires url');
|
|
115
|
+
if (!config.workspaceId) throw new Error('canvas backend requires workspaceId');
|
|
116
|
+
this.#fetch = typeof config.fetch === 'function' ? config.fetch : (...args) => globalThis.fetch(...args);
|
|
117
|
+
this.#url = String(config.url).replace(/\/+$/, '');
|
|
118
|
+
this.#workspaceId = String(config.workspaceId);
|
|
119
|
+
this.#backend = String(config.backend || 'workspace:home');
|
|
120
|
+
this.#token = config.token || null;
|
|
121
|
+
this.#deviceId = config.deviceId || null;
|
|
122
|
+
this.#deviceName = config.deviceName || null;
|
|
123
|
+
this.#expectedInstanceId = config.instanceId || null;
|
|
124
|
+
this.#prefixes = Array.isArray(config.prefixes) ? config.prefixes.filter(Boolean) : [];
|
|
125
|
+
this.#retryDelays = Array.isArray(config.retryDelays) ? config.retryDelays : RETRY_DELAYS_MS;
|
|
126
|
+
this.#requestTimeoutMs = Number(config.requestTimeoutMs) > 0 ? Number(config.requestTimeoutMs) : REQUEST_TIMEOUT_MS;
|
|
127
|
+
this.#minUploadBytesPerSec = Number(config.minUploadBytesPerSec) > 0 ? Number(config.minUploadBytesPerSec) : MIN_UPLOAD_BYTES_PER_SEC;
|
|
128
|
+
this.#cursor = config.cursor == null ? null : Number(config.cursor);
|
|
129
|
+
debug(`CanvasBackend "${name}" initialized (${this.#url} ws=${this.#workspaceId} backend=${this.#backend})`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get capabilities() { return { read: true, write: true, delete: true, canEnumerate: true, remote: true }; }
|
|
133
|
+
get remote() { return true; }
|
|
134
|
+
/** Own writes never come back as events (origin-filtered) — Stored need not hold keys. */
|
|
135
|
+
get suppressEchoes() { return false; }
|
|
136
|
+
get transport() { return DEV_PREFIX; }
|
|
137
|
+
get watching() { return !!this.#pollTimer; }
|
|
138
|
+
get instanceId() { return this.#instanceId; }
|
|
139
|
+
get deviceId() { return this.#deviceId; }
|
|
140
|
+
get deviceName() { return this.#deviceName; }
|
|
141
|
+
get workspaceId() { return this.#workspaceId; }
|
|
142
|
+
get hubBackend() { return this.#backend; }
|
|
143
|
+
get prefixes() { return [...this.#prefixes]; }
|
|
144
|
+
/** Change-feed position the poller will continue from (null = unset). */
|
|
145
|
+
get cursor() { return this.#cursor; }
|
|
146
|
+
set cursor(seq) { this.#cursor = seq == null ? null : Number(seq); }
|
|
147
|
+
/** Hub change-log head as of the last listing/poll. */
|
|
148
|
+
get head() { return this.#head; }
|
|
149
|
+
get dev() { return `${DEV_PREFIX}:${this.#instanceId || this.#expectedInstanceId || 'unknown'}`; }
|
|
150
|
+
|
|
151
|
+
nativeUrl(key) { return this.#objectUrl(key); }
|
|
152
|
+
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
154
|
+
// Transport
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
#backendUrl() {
|
|
158
|
+
return `${this.#url}/rest/v2/workspaces/${encodeSegment(this.#workspaceId)}/backends/file/${encodeSegment(this.#backend)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#objectUrl(key) { return `${this.#backendUrl()}/objects/${encodeKey(key)}`; }
|
|
162
|
+
|
|
163
|
+
#authHeaders() {
|
|
164
|
+
return {
|
|
165
|
+
...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}),
|
|
166
|
+
...(this.#deviceId ? { 'X-Canvas-Origin': this.#deviceId } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Typed error from a non-success response (body consumed here).
|
|
171
|
+
async #classify(res, key = null) {
|
|
172
|
+
const body = await res.json().catch(() => null);
|
|
173
|
+
const hubCode = body?.code || null;
|
|
174
|
+
const message = body?.message || `HTTP ${res.status}`;
|
|
175
|
+
const payload = body?.payload || {};
|
|
176
|
+
const status = res.status;
|
|
177
|
+
const detail = `${message}${key ? ` (${key})` : ''}`;
|
|
178
|
+
if (status === 401) return new CanvasHubError(detail, { code: 'UNAUTHORIZED', status, hubCode, key });
|
|
179
|
+
if (status === 404) return new CanvasHubError(detail, { code: 'NOT_FOUND', status, hubCode, key });
|
|
180
|
+
if (status === 409 && hubCode === 'TARGET_EXISTS') return new CanvasHubError(detail, { code: 'TARGET_EXISTS', status, hubCode, key });
|
|
181
|
+
if (status === 410) return new CanvasHubError(detail, { code: 'CURSOR_TOO_OLD', status, hubCode, oldest: num(payload.oldest, 0), head: num(payload.head, 0) });
|
|
182
|
+
if (status === 412) {
|
|
183
|
+
const c = payload.current;
|
|
184
|
+
const current = c ? { sha256: lower(c.sha256), size: num(c.size, 0), mtime: parseMs(c.mtime) } : null;
|
|
185
|
+
return new CanvasHubError(detail, { code: 'PRECONDITION_FAILED', status, hubCode, current, key });
|
|
186
|
+
}
|
|
187
|
+
if (status === 429 || status >= 500) return new CanvasHubError(detail, { code: 'RETRYABLE', status, hubCode, key });
|
|
188
|
+
return new CanvasHubError(detail, { code: 'REFUSED', status, hubCode, key });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One request with bounded retries on transport failure / 429 / 5xx. A
|
|
193
|
+
* body given as a function is re-created per attempt; a one-shot stream
|
|
194
|
+
* body is sent once and a retryable status surfaces as `RETRYABLE`.
|
|
195
|
+
* `okStatuses` widens success (304, 404 for stat-like calls).
|
|
196
|
+
*/
|
|
197
|
+
async #call(url, init = {}, { okStatuses = [], key = null, uploadBytes = null } = {}) {
|
|
198
|
+
const { body: bodySpec, ...rest } = init;
|
|
199
|
+
const streaming = bodySpec != null && typeof bodySpec !== 'function' && !Buffer.isBuffer(bodySpec) && typeof bodySpec !== 'string';
|
|
200
|
+
const timeoutMs = this.#requestTimeoutMs + (uploadBytes > 0 ? Math.ceil(uploadBytes / this.#minUploadBytesPerSec) * 1000 : 0);
|
|
201
|
+
let last = null;
|
|
202
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
203
|
+
const body = typeof bodySpec === 'function' ? bodySpec() : bodySpec;
|
|
204
|
+
const options = { ...rest, headers: { ...this.#authHeaders(), ...(rest.headers || {}) } };
|
|
205
|
+
if (body != null) {
|
|
206
|
+
options.body = body;
|
|
207
|
+
if (typeof body === 'object' && typeof body.getReader === 'function') options.duplex = 'half';
|
|
208
|
+
}
|
|
209
|
+
const abort = new AbortController();
|
|
210
|
+
const timer = setTimeout(() => abort.abort(new Error(`no response within ${timeoutMs} ms`)), timeoutMs);
|
|
211
|
+
timer.unref?.();
|
|
212
|
+
options.signal = abort.signal;
|
|
213
|
+
let res;
|
|
214
|
+
try {
|
|
215
|
+
res = await this.#fetch(url, options);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
const reason = abort.signal.aborted ? (abort.signal.reason?.message || 'timeout') : (err?.cause?.message || err.message);
|
|
219
|
+
last = new CanvasHubError(`canvas hub unreachable: ${reason}`, { code: 'OFFLINE', key });
|
|
220
|
+
this.#lastPing = null; // a transport failure voids the cached liveness
|
|
221
|
+
if (streaming || attempt >= this.#retryDelays.length) throw last;
|
|
222
|
+
await sleep(this.#retryDelays[attempt]);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
if (res.ok || okStatuses.includes(res.status)) return res;
|
|
227
|
+
if (res.status === 429 || res.status >= 500) {
|
|
228
|
+
last = await this.#classify(res, key);
|
|
229
|
+
if (streaming || attempt >= this.#retryDelays.length) throw last;
|
|
230
|
+
const retryAfter = Number(res.headers?.get?.('retry-after')) * 1000;
|
|
231
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : this.#retryDelays[attempt]);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
throw await this.#classify(res, key);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #json(url, init = {}, extra = {}) {
|
|
239
|
+
const res = await this.#call(url, { ...init, headers: { ...JSON_HEADERS, ...(init.headers || {}) } }, extra);
|
|
240
|
+
if (res.status === 204) return { payload: null, status: 204 };
|
|
241
|
+
const body = await res.json().catch(() => null);
|
|
242
|
+
return { payload: body?.payload ?? null, body, status: res.status };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
246
|
+
// Liveness
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** `GET /rest/v2/ping` → `{ instanceId, ... }` (uncached). */
|
|
250
|
+
async ping() {
|
|
251
|
+
const { payload } = await this.#json(`${this.#url}/rest/v2/ping`);
|
|
252
|
+
const id = payload?.instanceId || null;
|
|
253
|
+
if (id) this.#instanceId = id;
|
|
254
|
+
this.#lastPing = { at: Date.now(), payload };
|
|
255
|
+
return payload || {};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Liveness gate (file-driver contract): the hub answers and is the
|
|
260
|
+
* instance we were configured for. A successful ping is cached 30 s so a
|
|
261
|
+
* burst of transfers does not ping per object.
|
|
262
|
+
*/
|
|
263
|
+
async verifyRoot() {
|
|
264
|
+
try {
|
|
265
|
+
if (!this.#lastPing || Date.now() - this.#lastPing.at > PING_CACHE_MS) await this.ping();
|
|
266
|
+
if (this.#expectedInstanceId && this.#instanceId && this.#instanceId !== this.#expectedInstanceId) {
|
|
267
|
+
return { ok: false, reason: 'instance-mismatch', fsid: null, instanceId: this.#instanceId, expected: this.#expectedInstanceId };
|
|
268
|
+
}
|
|
269
|
+
return { ok: true, fsid: null, instanceId: this.#instanceId };
|
|
270
|
+
} catch (err) {
|
|
271
|
+
this.#lastPing = null;
|
|
272
|
+
const reason = err.code === 'UNAUTHORIZED' ? 'unauthorized' : (err.code === 'OFFLINE' ? 'offline' : 'unreachable');
|
|
273
|
+
return { ok: false, reason, error: err.message, fsid: null };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
278
|
+
// Rows
|
|
279
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
#row(key, { sha256, size, mtime, mimeType = null, docId = null, version = null }) {
|
|
282
|
+
const sha = lower(sha256);
|
|
283
|
+
return {
|
|
284
|
+
key,
|
|
285
|
+
size: num(size, 0),
|
|
286
|
+
modified: parseMs(mtime),
|
|
287
|
+
mimeType: mimeType || null,
|
|
288
|
+
checksums: sha ? { sha256: sha } : null,
|
|
289
|
+
dev: this.dev,
|
|
290
|
+
ino: sha,
|
|
291
|
+
docId: intOrNull(docId),
|
|
292
|
+
version: intOrNull(version),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** `{ docId, version }` the hub last reported for `key` (null when unknown). */
|
|
297
|
+
describe(key) {
|
|
298
|
+
const row = this.#files.get(normalizeKey(key));
|
|
299
|
+
return row ? { docId: row.docId ?? null, version: row.version ?? null } : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#rowFromHeaders(key, headers) {
|
|
303
|
+
const etag = headers.get('x-canvas-sha256') || headers.get('etag');
|
|
304
|
+
const sha256 = etag ? etag.replace(/^W\//, '').replace(/^"|"$/g, '') : null;
|
|
305
|
+
return this.#row(key, {
|
|
306
|
+
sha256,
|
|
307
|
+
size: headers.get('x-canvas-size') ?? headers.get('content-length'),
|
|
308
|
+
mtime: headers.get('x-canvas-mtime') || headers.get('last-modified'),
|
|
309
|
+
mimeType: headers.get('content-type'),
|
|
310
|
+
docId: headers.get('x-canvas-doc-id'),
|
|
311
|
+
version: headers.get('x-canvas-version'),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#inScope(key) { return matchesPrefixes(key, this.#prefixes); }
|
|
316
|
+
|
|
317
|
+
#eventPayload(row) {
|
|
318
|
+
return {
|
|
319
|
+
backend: this.name,
|
|
320
|
+
key: row.key,
|
|
321
|
+
checksums: row.checksums,
|
|
322
|
+
mimeType: row.mimeType,
|
|
323
|
+
size: row.size,
|
|
324
|
+
modified: row.modified,
|
|
325
|
+
dev: row.dev,
|
|
326
|
+
ino: row.ino,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
331
|
+
// Reads
|
|
332
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/** HEAD → row | null. */
|
|
335
|
+
async stat(key) {
|
|
336
|
+
const clean = normalizeKey(key);
|
|
337
|
+
if (!clean) return null;
|
|
338
|
+
const res = await this.#call(this.#objectUrl(clean), { method: 'HEAD' }, { okStatuses: [404], key: clean });
|
|
339
|
+
if (res.status === 404) {
|
|
340
|
+
this.#files.delete(clean);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
const row = this.#rowFromHeaders(clean, res.headers);
|
|
344
|
+
this.#files.set(clean, row);
|
|
345
|
+
return { ...row };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async get(key, options = {}) {
|
|
349
|
+
const clean = normalizeKey(key);
|
|
350
|
+
if (!clean) return null;
|
|
351
|
+
const res = await this.#call(this.#objectUrl(clean), { method: 'GET' }, { okStatuses: [404], key: clean });
|
|
352
|
+
if (res.status === 404) return null;
|
|
353
|
+
this.#files.set(clean, this.#rowFromHeaders(clean, res.headers));
|
|
354
|
+
if (options.stream) return Readable.fromWeb(res.body);
|
|
355
|
+
return Buffer.from(await res.arrayBuffer());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Ranged read; null when the hub ignored the Range (caller falls back). */
|
|
359
|
+
async getRange(key, { start, end }) {
|
|
360
|
+
const clean = normalizeKey(key);
|
|
361
|
+
if (!clean) return null;
|
|
362
|
+
const res = await this.#call(this.#objectUrl(clean), { method: 'GET', headers: { Range: `bytes=${start}-${end}` } }, { okStatuses: [404, 416], key: clean });
|
|
363
|
+
if (res.status === 404 || res.status === 416) return null;
|
|
364
|
+
if (res.status !== 206) {
|
|
365
|
+
await res.arrayBuffer().catch(() => {});
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
return Readable.fromWeb(res.body);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** One page of the hub's listing: `{ objects, cursor, head }`. */
|
|
372
|
+
async listObjects({ prefix = '', cursor = null, limit = LIST_PAGE } = {}) {
|
|
373
|
+
const u = new URL(`${this.#backendUrl()}/objects`);
|
|
374
|
+
if (prefix) u.searchParams.set('prefix', normalizeKey(prefix));
|
|
375
|
+
if (cursor) u.searchParams.set('cursor', cursor);
|
|
376
|
+
u.searchParams.set('limit', String(limit));
|
|
377
|
+
const { payload } = await this.#json(u.toString());
|
|
378
|
+
const objects = (payload?.objects || []).map((o) => this.#row(normalizeKey(o.key), o));
|
|
379
|
+
const head = num(payload?.head, this.#head) ?? 0;
|
|
380
|
+
this.#head = Math.max(this.#head, head);
|
|
381
|
+
return { objects, cursor: payload?.cursor || null, head };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Every object under `prefix` (all pages), scoped to `config.prefixes`. */
|
|
385
|
+
async *list(options = {}) {
|
|
386
|
+
const prefix = normalizeKey(options.prefix || '');
|
|
387
|
+
let cursor = null;
|
|
388
|
+
do {
|
|
389
|
+
const page = await this.listObjects({ prefix, cursor, limit: options.limit || LIST_PAGE });
|
|
390
|
+
for (const row of page.objects) {
|
|
391
|
+
if (!this.#inScope(row.key)) continue;
|
|
392
|
+
yield { ...row };
|
|
393
|
+
}
|
|
394
|
+
cursor = page.cursor;
|
|
395
|
+
} while (cursor);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Full-snapshot scan (file-driver contract): `{ files, complete, errors }`.
|
|
400
|
+
* Rebuilds the row map; when no feed cursor is set yet the listing's head
|
|
401
|
+
* becomes it, so a `watch()` that follows tails from this snapshot.
|
|
402
|
+
*/
|
|
403
|
+
async scan(options = {}) {
|
|
404
|
+
const onFile = typeof options.onFile === 'function' ? options.onFile : null;
|
|
405
|
+
const errors = { root: null, dirs: [], files: [] };
|
|
406
|
+
const files = new Map();
|
|
407
|
+
const results = [];
|
|
408
|
+
this.emit('scan:start', { backend: this.name });
|
|
409
|
+
let head = this.#head;
|
|
410
|
+
try {
|
|
411
|
+
let cursor = null;
|
|
412
|
+
do {
|
|
413
|
+
const page = await this.listObjects({ prefix: options.prefix || '', cursor });
|
|
414
|
+
if (page.head) head = Math.max(head, page.head);
|
|
415
|
+
for (const row of page.objects) {
|
|
416
|
+
if (!this.#inScope(row.key)) continue;
|
|
417
|
+
files.set(row.key, row);
|
|
418
|
+
const out = { ...row, backend: this.name };
|
|
419
|
+
results.push(out);
|
|
420
|
+
if (onFile) await onFile(out);
|
|
421
|
+
}
|
|
422
|
+
cursor = page.cursor;
|
|
423
|
+
} while (cursor);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
errors.root = err.code || 'unreachable';
|
|
426
|
+
debug(`Scan aborted: ${err.message}`);
|
|
427
|
+
this.emit('scan:complete', { backend: this.name, count: 0, complete: false, errors });
|
|
428
|
+
return { files: [], complete: false, errors, fsid: null };
|
|
429
|
+
}
|
|
430
|
+
this.#files = files;
|
|
431
|
+
this.#head = head;
|
|
432
|
+
if (this.#cursor == null) this.#cursor = head;
|
|
433
|
+
this.emit('scan:complete', { backend: this.name, count: results.length, complete: true, errors });
|
|
434
|
+
debug(`Scan complete: ${results.length} objects (head ${head})`);
|
|
435
|
+
return { files: results, complete: true, errors, fsid: null, head };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
439
|
+
// Writes
|
|
440
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Streaming `PUT objects/<key>`. `source` is a Buffer | string | Readable |
|
|
444
|
+
* web ReadableStream | `() => Readable` (retryable). Options map 1:1 onto
|
|
445
|
+
* the protocol headers: `ifMatch`, `ifNoneMatch`, `sha256`, `mtime`,
|
|
446
|
+
* `origin`, `conflictOf`, `conflictMode`, `baseSha256`, `deviceName`,
|
|
447
|
+
* `mimeType`, `size`.
|
|
448
|
+
*
|
|
449
|
+
* Returns `{ key, sha256, size, mtime, seq, docId, unchanged, previous,
|
|
450
|
+
* created }`, or for a conflict upload `{ conflict:true, docId, key,
|
|
451
|
+
* conflictOf, sha256, hubDocId, hubSha256 }`. Throws CanvasHubError.
|
|
452
|
+
*/
|
|
453
|
+
async putStream(key, source, options = {}) {
|
|
454
|
+
const clean = normalizeKey(key);
|
|
455
|
+
if (!clean) throw new CanvasHubError('canvas put: key is required', { code: 'REFUSED', key });
|
|
456
|
+
const headers = {
|
|
457
|
+
'Content-Type': options.mimeType || 'application/octet-stream',
|
|
458
|
+
...JSON_HEADERS,
|
|
459
|
+
};
|
|
460
|
+
if (options.origin) headers['X-Canvas-Origin'] = options.origin;
|
|
461
|
+
if (options.ifMatch != null) headers['If-Match'] = quote(options.ifMatch);
|
|
462
|
+
if (options.ifNoneMatch != null) headers['If-None-Match'] = String(options.ifNoneMatch);
|
|
463
|
+
if (options.sha256) headers['X-Canvas-Sha256'] = String(options.sha256).toLowerCase();
|
|
464
|
+
if (options.mtime != null) headers['X-Canvas-Mtime'] = String(Math.round(Number(options.mtime)));
|
|
465
|
+
if (options.conflictOf) {
|
|
466
|
+
headers['X-Canvas-Conflict-Of'] = normalizeKey(options.conflictOf);
|
|
467
|
+
headers['X-Canvas-Conflict-Mode'] = options.conflictMode === 'rename' ? 'rename' : 'inbox';
|
|
468
|
+
if (options.baseSha256) headers['X-Canvas-Base-Sha256'] = String(options.baseSha256).toLowerCase();
|
|
469
|
+
const deviceName = options.deviceName || this.#deviceName;
|
|
470
|
+
if (deviceName) headers['X-Canvas-Device-Name'] = deviceName;
|
|
471
|
+
}
|
|
472
|
+
let body;
|
|
473
|
+
let size = Number.isFinite(options.size) ? options.size : null;
|
|
474
|
+
if (Buffer.isBuffer(source)) { body = source; size = source.length; }
|
|
475
|
+
else if (typeof source === 'string') { body = Buffer.from(source); size = body.length; }
|
|
476
|
+
else if (typeof source === 'function') body = () => this.#toWeb(source());
|
|
477
|
+
else body = this.#toWeb(source);
|
|
478
|
+
if (size != null) headers['Content-Length'] = String(size);
|
|
479
|
+
|
|
480
|
+
const { payload, status } = await this.#json(this.#objectUrl(clean), { method: 'PUT', headers, body }, { key: clean, uploadBytes: size });
|
|
481
|
+
if (options.conflictOf) {
|
|
482
|
+
debug(`CONFLICT PUT ${clean} (of ${options.conflictOf})`);
|
|
483
|
+
return { conflict: true, ...(payload || {}), key: clean };
|
|
484
|
+
}
|
|
485
|
+
const result = {
|
|
486
|
+
key: clean,
|
|
487
|
+
sha256: lower(payload?.sha256) || (options.sha256 ? String(options.sha256).toLowerCase() : null),
|
|
488
|
+
size: num(payload?.size, size ?? 0),
|
|
489
|
+
mtime: parseMs(payload?.mtime) ?? (options.mtime != null ? Number(options.mtime) : null),
|
|
490
|
+
seq: num(payload?.seq, 0),
|
|
491
|
+
docId: intOrNull(payload?.docId),
|
|
492
|
+
version: intOrNull(payload?.version),
|
|
493
|
+
unchanged: payload?.unchanged === true,
|
|
494
|
+
previous: payload?.previous || null,
|
|
495
|
+
created: status === 201,
|
|
496
|
+
};
|
|
497
|
+
this.#files.set(clean, this.#row(clean, { sha256: result.sha256, size: result.size, mtime: result.mtime, mimeType: options.mimeType, docId: result.docId, version: result.version }));
|
|
498
|
+
if (result.seq) this.#head = Math.max(this.#head, result.seq);
|
|
499
|
+
debug(`PUT ${clean} (${result.size} bytes, seq ${result.seq}${result.unchanged ? ', unchanged' : ''})`);
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#toWeb(source) {
|
|
504
|
+
if (source && typeof source.getReader === 'function') return source;
|
|
505
|
+
if (source && typeof source.pipe === 'function') return Readable.toWeb(source);
|
|
506
|
+
if (source && typeof source[Symbol.asyncIterator] === 'function') return Readable.toWeb(Readable.from(source));
|
|
507
|
+
throw new CanvasHubError('canvas put: unsupported source', { code: 'REFUSED' });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async put(key, data, options = {}) {
|
|
511
|
+
const result = await this.putStream(key, data, options);
|
|
512
|
+
return { key: result.key, size: result.size, sha256: result.sha256, seq: result.seq };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** SyncQueue/commit entry point: stream a file from disk (retryable). */
|
|
516
|
+
async commit(key, srcPath, options = {}) {
|
|
517
|
+
const { size } = await fsp.stat(srcPath);
|
|
518
|
+
return this.putStream(key, () => createReadStream(srcPath), { ...options, size });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** `DELETE objects/<key>` (`If-Match` optional). false when already gone. */
|
|
522
|
+
async delete(key, options = {}) {
|
|
523
|
+
const clean = normalizeKey(key);
|
|
524
|
+
if (!clean) return false;
|
|
525
|
+
const headers = { ...JSON_HEADERS };
|
|
526
|
+
if (options.ifMatch != null) headers['If-Match'] = quote(options.ifMatch);
|
|
527
|
+
if (options.origin) headers['X-Canvas-Origin'] = options.origin;
|
|
528
|
+
const res = await this.#call(this.#objectUrl(clean), { method: 'DELETE', headers }, { okStatuses: [404], key: clean });
|
|
529
|
+
this.#files.delete(clean);
|
|
530
|
+
if (res.status === 404) { await res.arrayBuffer().catch(() => {}); return false; }
|
|
531
|
+
const body = await res.json().catch(() => null);
|
|
532
|
+
if (body?.payload?.seq) this.#head = Math.max(this.#head, Number(body.payload.seq) || 0);
|
|
533
|
+
debug(`DELETE ${clean}`);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** `POST objects/rename` → `{ from, to, sha256, seq, docId }`. */
|
|
538
|
+
async rename(from, to, options = {}) {
|
|
539
|
+
const fromKey = normalizeKey(from);
|
|
540
|
+
const toKey = normalizeKey(to);
|
|
541
|
+
if (!fromKey || !toKey) throw new CanvasHubError('canvas rename: from and to are required', { code: 'REFUSED' });
|
|
542
|
+
const body = { from: fromKey, to: toKey };
|
|
543
|
+
if (options.ifMatch != null) body.ifMatch = String(options.ifMatch).replace(/^W\//, '').replace(/^"|"$/g, '');
|
|
544
|
+
const origin = options.origin || this.#deviceId;
|
|
545
|
+
if (origin) body.origin = origin;
|
|
546
|
+
const { payload } = await this.#json(`${this.#backendUrl()}/objects/rename`, {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
headers: { 'Content-Type': 'application/json' },
|
|
549
|
+
body: JSON.stringify(body),
|
|
550
|
+
}, { key: fromKey });
|
|
551
|
+
const row = this.#files.get(fromKey);
|
|
552
|
+
this.#files.delete(fromKey);
|
|
553
|
+
const docId = intOrNull(payload?.docId) ?? row?.docId ?? null;
|
|
554
|
+
const version = intOrNull(payload?.version) ?? null;
|
|
555
|
+
if (row) this.#files.set(toKey, { ...row, key: toKey, docId, version: version ?? row.version ?? null });
|
|
556
|
+
const seq = num(payload?.seq, 0);
|
|
557
|
+
if (seq) this.#head = Math.max(this.#head, seq);
|
|
558
|
+
debug(`RENAME ${fromKey} -> ${toKey} (seq ${seq})`);
|
|
559
|
+
return { from: fromKey, to: toKey, sha256: lower(payload?.sha256) || row?.checksums?.sha256 || null, seq, docId, version, size: row?.size ?? null, modified: row?.modified ?? null };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
563
|
+
// Change feed
|
|
564
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
/** `GET changes?since=&limit=` → `{ changes, head, oldest, cursor }`; throws CURSOR_TOO_OLD on 410. */
|
|
567
|
+
async changes(since = 0, limit = CHANGES_PAGE) {
|
|
568
|
+
const u = new URL(`${this.#backendUrl()}/changes`);
|
|
569
|
+
u.searchParams.set('since', String(Math.max(0, Number(since) || 0)));
|
|
570
|
+
u.searchParams.set('limit', String(limit));
|
|
571
|
+
const { payload } = await this.#json(u.toString());
|
|
572
|
+
const changes = (payload?.changes || []).map((c) => ({
|
|
573
|
+
seq: num(c.seq, 0),
|
|
574
|
+
ts: num(c.ts, null),
|
|
575
|
+
op: c.op,
|
|
576
|
+
key: normalizeKey(c.key),
|
|
577
|
+
from: c.from != null ? normalizeKey(c.from) : null,
|
|
578
|
+
sha256: lower(c.sha256) || null,
|
|
579
|
+
size: num(c.size, 0),
|
|
580
|
+
mtime: parseMs(c.mtime),
|
|
581
|
+
origin: c.origin ?? null,
|
|
582
|
+
docId: intOrNull(c.docId),
|
|
583
|
+
version: intOrNull(c.version),
|
|
584
|
+
}));
|
|
585
|
+
const head = num(payload?.head, 0);
|
|
586
|
+
this.#head = Math.max(this.#head, head);
|
|
587
|
+
return { changes, head, oldest: num(payload?.oldest, null), cursor: num(payload?.cursor, null) };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Poll the feed from the cursor, synthesizing watcher events. Own-origin
|
|
592
|
+
* entries (our writes echoing back) update the row map but are not
|
|
593
|
+
* emitted. Returns `{ ok, applied, cursor, head }` or
|
|
594
|
+
* `{ ok:false, reason:'cursor-too-old'|'offline'|'no-cursor'|<code> }`;
|
|
595
|
+
* a 410 also emits `backend:state { reason:'cursor-too-old' }` and clears
|
|
596
|
+
* the cursor so the host rebuilds from a listing.
|
|
597
|
+
*/
|
|
598
|
+
async poll() {
|
|
599
|
+
if (this.#polling) return this.#polling;
|
|
600
|
+
this.#polling = this.#pollOnce().finally(() => { this.#polling = null; });
|
|
601
|
+
return this.#polling;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async #pollOnce() {
|
|
605
|
+
if (this.#cursor == null) return { ok: false, reason: 'no-cursor' };
|
|
606
|
+
let since = this.#cursor;
|
|
607
|
+
let applied = 0;
|
|
608
|
+
let head = this.#head;
|
|
609
|
+
try {
|
|
610
|
+
for (;;) {
|
|
611
|
+
const page = await this.changes(since, CHANGES_PAGE);
|
|
612
|
+
head = page.head;
|
|
613
|
+
for (const change of page.changes) {
|
|
614
|
+
this.#applyChange(change);
|
|
615
|
+
applied += 1;
|
|
616
|
+
since = Math.max(since, change.seq);
|
|
617
|
+
}
|
|
618
|
+
if (page.changes.length === 0) since = Math.max(since, page.head);
|
|
619
|
+
else if (page.cursor != null && page.changes.length < CHANGES_PAGE) since = Math.max(since, page.cursor);
|
|
620
|
+
this.#cursor = since;
|
|
621
|
+
if (page.changes.length < CHANGES_PAGE) break;
|
|
622
|
+
}
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (err.code === 'CURSOR_TOO_OLD') {
|
|
625
|
+
debug(`cursor ${since} predates the retained log (oldest ${err.oldest}); rebuild required`);
|
|
626
|
+
this.#cursor = null;
|
|
627
|
+
this.emit('backend:state', { backend: this.name, reason: 'cursor-too-old', oldest: err.oldest, head: err.head, cursor: since });
|
|
628
|
+
return { ok: false, reason: 'cursor-too-old', oldest: err.oldest, head: err.head };
|
|
629
|
+
}
|
|
630
|
+
const reason = err.code === 'OFFLINE' ? 'offline' : (err.code || 'error');
|
|
631
|
+
this.emit('backend:state', { backend: this.name, reason, online: false, error: err.message, cursor: since });
|
|
632
|
+
return { ok: false, reason, error: err.message };
|
|
633
|
+
}
|
|
634
|
+
this.emit('backend:state', { backend: this.name, reason: 'polled', online: true, cursor: this.#cursor, head, applied });
|
|
635
|
+
return { ok: true, applied, cursor: this.#cursor, head };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#applyChange(change) {
|
|
639
|
+
const own = !!this.#deviceId && change.origin === this.#deviceId;
|
|
640
|
+
const key = change.key;
|
|
641
|
+
if (!key) return;
|
|
642
|
+
if (change.op === 'rename' && change.from) {
|
|
643
|
+
const prev = this.#files.get(change.from) || null;
|
|
644
|
+
this.#files.delete(change.from);
|
|
645
|
+
const row = this.#row(key, { sha256: change.sha256 || prev?.checksums?.sha256, size: change.size ?? prev?.size, mtime: change.mtime ?? prev?.modified, mimeType: prev?.mimeType, docId: change.docId ?? prev?.docId, version: change.version ?? prev?.version });
|
|
646
|
+
if (this.#inScope(key)) this.#files.set(key, row);
|
|
647
|
+
if (own) return;
|
|
648
|
+
// unlink(from) + add(to) sharing ino = sha256: Stored pairs them.
|
|
649
|
+
if (this.#inScope(change.from)) this.emit('file:unlink', { backend: this.name, key: change.from, dev: this.dev, ino: row.ino });
|
|
650
|
+
if (this.#inScope(key)) this.emit('file:add', this.#eventPayload(row));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (!this.#inScope(key)) return;
|
|
654
|
+
if (change.op === 'delete') {
|
|
655
|
+
const prev = this.#files.get(key) || null;
|
|
656
|
+
this.#files.delete(key);
|
|
657
|
+
if (own) return;
|
|
658
|
+
this.emit('file:unlink', { backend: this.name, key, dev: this.dev, ino: change.sha256 || prev?.ino || null });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (change.op === 'put') {
|
|
662
|
+
const prev = this.#files.get(key) || null;
|
|
663
|
+
const row = this.#row(key, { sha256: change.sha256, size: change.size, mtime: change.mtime, mimeType: prev?.mimeType, docId: change.docId, version: change.version });
|
|
664
|
+
this.#files.set(key, row);
|
|
665
|
+
if (own || !row.checksums) return;
|
|
666
|
+
if (prev?.checksums?.sha256 === row.checksums.sha256) return;
|
|
667
|
+
this.emit(prev ? 'file:change' : 'file:add', this.#eventPayload(row));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Start polling the change feed every `pollInterval` ms (default 30 s).
|
|
673
|
+
* Needs a cursor: `config.cursor`, a prior `scan()`, or — when neither —
|
|
674
|
+
* the current head (nothing before it is ever replayed).
|
|
675
|
+
*/
|
|
676
|
+
async watch() {
|
|
677
|
+
if (this.#pollTimer) return true;
|
|
678
|
+
if (this.#cursor == null) {
|
|
679
|
+
try {
|
|
680
|
+
const page = await this.changes(0, 1).catch((err) => {
|
|
681
|
+
if (err.code === 'CURSOR_TOO_OLD') return { head: err.head };
|
|
682
|
+
throw err;
|
|
683
|
+
});
|
|
684
|
+
this.#cursor = num(page.head, 0);
|
|
685
|
+
} catch (err) {
|
|
686
|
+
this.emit('backend:state', { backend: this.name, reason: err.code === 'OFFLINE' ? 'offline' : 'error', online: false, error: err.message });
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const interval = Math.max(1_000, Number(this.config.pollInterval) || DEFAULT_POLL_INTERVAL);
|
|
691
|
+
this.#pollTimer = setInterval(() => { this.poll().catch(() => {}); }, interval);
|
|
692
|
+
this.#pollTimer.unref?.();
|
|
693
|
+
debug(`Watching ${this.#backendUrl()} from seq ${this.#cursor} (poll ${interval}ms)`);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async stop() {
|
|
698
|
+
if (this.#pollTimer) {
|
|
699
|
+
clearInterval(this.#pollTimer);
|
|
700
|
+
this.#pollTimer = null;
|
|
701
|
+
debug('Stopped watching');
|
|
702
|
+
}
|
|
703
|
+
if (this.#polling) await this.#polling.catch(() => {});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
707
|
+
// Mirror status
|
|
708
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
/** `POST /workspaces/:id/mirrors/:deviceId/status` → `{ mirror, head }`. */
|
|
711
|
+
async reportStatus(status = {}) {
|
|
712
|
+
if (!this.#deviceId) throw new CanvasHubError('reportStatus requires a deviceId', { code: 'REFUSED' });
|
|
713
|
+
const url = `${this.#url}/rest/v2/workspaces/${encodeSegment(this.#workspaceId)}/mirrors/${encodeSegment(this.#deviceId)}/status`;
|
|
714
|
+
const { payload } = await this.#json(url, {
|
|
715
|
+
method: 'POST',
|
|
716
|
+
headers: { 'Content-Type': 'application/json' },
|
|
717
|
+
body: JSON.stringify({ backend: this.#backend, client: 'daemon', ...status }),
|
|
718
|
+
});
|
|
719
|
+
if (payload?.head) this.#head = Math.max(this.#head, Number(payload.head) || 0);
|
|
720
|
+
return payload || {};
|
|
721
|
+
}
|
|
722
|
+
}
|