@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.0

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.
Files changed (44) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +114 -0
  3. package/README.md +76 -2
  4. package/bin/canvas-edge +2 -0
  5. package/bin/healthcheck.mjs +9 -0
  6. package/node_modules/canvas-stored/LICENSE +661 -0
  7. package/node_modules/canvas-stored/NOTICE +85 -0
  8. package/node_modules/canvas-stored/README.md +472 -0
  9. package/node_modules/canvas-stored/package.json +26 -0
  10. package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
  11. package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
  12. package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
  13. package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
  14. package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
  15. package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
  16. package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
  17. package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
  18. package/node_modules/canvas-stored/src/cache/index.js +65 -0
  19. package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
  20. package/node_modules/canvas-stored/src/extractors/image.js +90 -0
  21. package/node_modules/canvas-stored/src/extractors/index.js +41 -0
  22. package/node_modules/canvas-stored/src/extractors/text.js +65 -0
  23. package/node_modules/canvas-stored/src/index/index.js +360 -0
  24. package/node_modules/canvas-stored/src/index.js +2058 -0
  25. package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
  26. package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
  27. package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
  28. package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
  29. package/node_modules/canvas-stored/src/sync/keys.js +140 -0
  30. package/node_modules/canvas-stored/src/sync/worker.js +52 -0
  31. package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
  32. package/node_modules/canvas-stored/src/utils/common.js +37 -0
  33. package/node_modules/canvas-stored/src/utils/mime.js +107 -0
  34. package/node_modules/canvas-stored/src/utils/mount.js +99 -0
  35. package/package.json +49 -4
  36. package/src/EdgeClient.js +129 -0
  37. package/src/control.js +48 -0
  38. package/src/daemon.js +88 -0
  39. package/src/env.js +182 -0
  40. package/src/fuse-runtime.js +173 -0
  41. package/src/index.js +26 -0
  42. package/src/mirror-runtime.js +232 -0
  43. package/src/remote-config.js +69 -0
  44. package/src/runtime.js +61 -0
@@ -0,0 +1,658 @@
1
+ import fs from 'fs-extra';
2
+ import { promises as fsp } from 'fs';
3
+ import path from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import chokidar from 'chokidar';
6
+ import picomatch from 'picomatch';
7
+ import Debug from 'debug';
8
+ import StorageBackend from '../StorageBackend.js';
9
+ import { checksumFile } from '../../utils/checksum.js';
10
+ import { detectMimeType } from '../../utils/mime.js';
11
+ import { detectMountSync } from '../../utils/mount.js';
12
+
13
+ const debug = Debug('stored:backend:file');
14
+
15
+ // Per-backend staging dir for streaming puts. Lives inside the backend root so
16
+ // temp files share a filesystem with their final destination (cheap rename /
17
+ // hardlink), and is always excluded from the watcher. `config.tempDir`
18
+ // (relative to the root, or absolute) relocates it — a mirror keeps all of
19
+ // its state under one hidden directory.
20
+ const TMP_DIR = '.stored-tmp';
21
+ // The staging dir, and the `.stored-tmp-<rand>` siblings placement falls back
22
+ // to when the staging dir is on another mount (see #finish).
23
+ const TMP_IGNORE = /(^|[/\\])\.stored-tmp(-[^/\\]*)?([/\\]|$)/;
24
+
25
+ export default class FileBackend extends StorageBackend {
26
+ #root;
27
+ #tempDir;
28
+ #tempRel = null;
29
+ #watcher = null;
30
+ #watchEnabled;
31
+ #ignored;
32
+ #ignoreMatcher = null;
33
+ #defaultAlgorithms = ['sha256'];
34
+ #remote = false;
35
+ #transport = null;
36
+ #mountSource = null;
37
+
38
+ constructor(name, config = {}) {
39
+ super(name, config);
40
+ if (!config.root) throw new Error('FileBackend requires root path');
41
+ this.#root = path.resolve(config.root);
42
+ this.#tempDir = config.tempDir ? path.resolve(this.#root, String(config.tempDir)) : path.join(this.#root, TMP_DIR);
43
+ const tempRel = path.relative(this.#root, this.#tempDir).split(path.sep).join('/');
44
+ this.#tempRel = tempRel && !tempRel.startsWith('..') && !path.isAbsolute(tempRel) ? tempRel : null;
45
+ this.#watchEnabled = config.watch ?? false;
46
+ this.#ignored = config.ignored || null;
47
+ this.#ignoreMatcher = this.#buildIgnoreMatcher(this.#ignored);
48
+ this.#defaultAlgorithms = config.algorithms || ['sha256'];
49
+ this.type = 'local';
50
+ // Managed stores (workspace:home/data) own their directory and may
51
+ // create it. External mounts (createRoot:false) must NOT: silently
52
+ // creating an empty dir at an unmounted mountpoint makes an absent
53
+ // drive indistinguishable from an empty one — verifyRoot() + the
54
+ // resync liveness gate rely on the root being genuinely missing.
55
+ if (config.createRoot !== false) fs.ensureDirSync(this.#root);
56
+
57
+ // Network mount detection. A CIFS/NFS share reached through a POSIX path
58
+ // is indistinguishable from local disk to every fs call we make, so the
59
+ // kernel mount table is the only honest source. Explicit config always
60
+ // wins — detection only fills the gaps (and is a no-op off Linux).
61
+ const detected = detectMountSync(this.#root);
62
+ this.#remote = config.remote ?? detected.remote;
63
+ this.#transport = config.transport ?? detected.transport;
64
+ this.#mountSource = config.mountSource ?? detected.source;
65
+
66
+ debug(`FileBackend "${name}" initialized at ${this.#root}${this.#remote ? ` (remote, ${this.#transport || 'unknown transport'})` : ''}`);
67
+ }
68
+
69
+ get root() { return this.#root; }
70
+ get tempDir() { return this.#tempDir; }
71
+ get watching() { return !!this.#watcher; }
72
+ get capabilities() { return { ...super.capabilities, canEnumerate: true }; }
73
+ get remote() { return this.#remote; }
74
+ get transport() { return this.#transport; }
75
+ get mountSource() { return this.#mountSource; }
76
+
77
+ /**
78
+ * Absolute on-disk path for `key`. Public because cross-backend copy/move
79
+ * needs it to detect a shared filesystem (same `dev` ⇒ rename instead of a
80
+ * byte-for-byte stream copy) — callers outside stored should still address
81
+ * bytes via `stored://<backend>/<key>`.
82
+ */
83
+ resolveKeyPath(key) { return this.#resolvePath(key); }
84
+
85
+ // One matcher shared by watch(), list() and (via list) scan(), so exclusion
86
+ // patterns behave identically live and on resync. Accepts glob strings,
87
+ // RegExps and predicate functions (rel-path based, '/'-separated). Chokidar
88
+ // v4+ dropped glob support in `ignored`, hence the picomatch bridge.
89
+ #buildIgnoreMatcher(patterns) {
90
+ if (!patterns) return null;
91
+ const list = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
92
+ if (list.length === 0) return null;
93
+
94
+ const globs = [];
95
+ const regexes = [];
96
+ const fns = [];
97
+ for (const pattern of list) {
98
+ if (typeof pattern === 'string') {
99
+ globs.push(pattern);
100
+ // "**/node_modules/**" should also prune the directory itself so
101
+ // enumeration/watch never descends into it.
102
+ if (pattern.endsWith('/**')) globs.push(pattern.slice(0, -3));
103
+ } else if (pattern instanceof RegExp) {
104
+ regexes.push(pattern);
105
+ } else if (typeof pattern === 'function') {
106
+ fns.push(pattern);
107
+ }
108
+ }
109
+ const globMatch = globs.length > 0 ? picomatch(globs, { dot: true }) : null;
110
+ return (relPath) => {
111
+ const rel = String(relPath).split(path.sep).join('/');
112
+ if (rel === '' || rel === '.') return false;
113
+ if (globMatch && globMatch(rel)) return true;
114
+ if (regexes.some((re) => re.test(rel))) return true;
115
+ if (fns.some((fn) => fn(rel))) return true;
116
+ return false;
117
+ };
118
+ }
119
+
120
+ #isIgnored(relPath) {
121
+ if (this.#isTempPath(relPath)) return true;
122
+ return this.#ignoreMatcher ? this.#ignoreMatcher(relPath) : false;
123
+ }
124
+
125
+ /**
126
+ * Would this relative key be excluded from watch/list/scan? Public so a
127
+ * write API can refuse a key the index would never see (a dotfile pushed
128
+ * by a device would otherwise land on disk invisibly).
129
+ */
130
+ isIgnored(relPath) { return this.#isIgnored(relPath); }
131
+
132
+ // The staging dir (default or configured) never counts as content.
133
+ #isTempPath(relPath) {
134
+ const rel = String(relPath).split(path.sep).join('/');
135
+ if (TMP_IGNORE.test(rel)) return true;
136
+ return !!this.#tempRel && (rel === this.#tempRel || rel.startsWith(`${this.#tempRel}/`));
137
+ }
138
+
139
+ // Truthful server-side location. Consumers decide whether to surface a local
140
+ // path to clients (it leaks the server fs layout); stored:// is the address.
141
+ nativeUrl(key) { return pathToFileURL(this.#resolvePath(key)).href; }
142
+
143
+ /**
144
+ * Liveness gate for resync: is the root present, a directory, and the same
145
+ * filesystem it was when the mount was configured? `expected` is the fsid
146
+ * snapshot ({ dev, ino }) recorded at mount creation (falls back to
147
+ * config.fsid). An absent mountpoint or a dev mismatch means the backend
148
+ * must go offline — a scan would read "empty", not "deleted".
149
+ * @returns {Promise<{ok: boolean, reason?: string, fsid: {dev:number, ino:number}|null}>}
150
+ */
151
+ async verifyRoot(expected = null) {
152
+ const snapshot = expected || this.config.fsid || null;
153
+ let stats;
154
+ try {
155
+ stats = await fs.stat(this.#root);
156
+ } catch {
157
+ return { ok: false, reason: 'root-missing', fsid: null };
158
+ }
159
+ if (!stats.isDirectory()) return { ok: false, reason: 'root-not-directory', fsid: null };
160
+ const fsid = { dev: stats.dev, ino: stats.ino };
161
+ if (snapshot?.dev != null && Number(snapshot.dev) !== stats.dev) {
162
+ return { ok: false, reason: 'filesystem-changed', fsid };
163
+ }
164
+ if (snapshot?.ino != null && Number(snapshot.ino) !== stats.ino) {
165
+ return { ok: false, reason: 'root-replaced', fsid };
166
+ }
167
+ return { ok: true, fsid };
168
+ }
169
+
170
+ // ─────────────────────────────────────────────────────────────────────────
171
+ // CRUD Operations
172
+ // ─────────────────────────────────────────────────────────────────────────
173
+
174
+ #resolvePath(key) { return path.join(this.#root, key); }
175
+
176
+ // Resolve a key to an absolute path, refusing anything that escapes the
177
+ // backend root (path traversal) or targets the root itself. Used by the
178
+ // container (directory) mutation ops.
179
+ #safeResolve(key) {
180
+ const target = path.resolve(this.#root, String(key || ''));
181
+ const rel = path.relative(this.#root, target);
182
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
183
+ throw new Error(`Path escapes backend root: ${key}`);
184
+ }
185
+ return target;
186
+ }
187
+
188
+ // ── Container (directory) ops — only meaningful for a real filesystem, so
189
+ // they live on the file driver (StorageBackend throws by default). Watched
190
+ // backends reflect the byte changes; empty directories are surfaced by the
191
+ // caller (Workspace) inserting a tree node.
192
+ async createContainer(key) {
193
+ const dir = this.#safeResolve(key);
194
+ await fs.ensureDir(dir);
195
+ debug(`MKDIR ${key}`);
196
+ return { key };
197
+ }
198
+
199
+ async deleteContainer(key) {
200
+ const dir = this.#safeResolve(key);
201
+ if (!await fs.pathExists(dir)) return false;
202
+ await fs.remove(dir);
203
+ debug(`RMDIR ${key}`);
204
+ return true;
205
+ }
206
+
207
+ async renameContainer(fromKey, toKey) {
208
+ const from = this.#safeResolve(fromKey);
209
+ const to = this.#safeResolve(toKey);
210
+ if (!await fs.pathExists(from)) throw new Error(`Container not found: ${fromKey}`);
211
+ await fs.ensureDir(path.dirname(to));
212
+ await fs.move(from, to, { overwrite: false });
213
+ debug(`MV ${fromKey} -> ${toKey}`);
214
+ return { from: fromKey, to: toKey };
215
+ }
216
+
217
+ // A fresh staging path inside the backend root (same filesystem as every
218
+ // destination, so the final step is always one rename(2)).
219
+ async #stagingPath() {
220
+ await fs.ensureDir(this.#tempDir);
221
+ return path.join(this.#tempDir, `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`);
222
+ }
223
+
224
+ // Every placement ends with rename(2) over the destination: a reader sees
225
+ // either the previous bytes or the complete new ones, never a partial
226
+ // file, and a crash mid-write leaves only a staging file behind. This is
227
+ // also what makes hardlink-based retention safe (see retain()): the old
228
+ // inode is never written to in place, it is displaced.
229
+ //
230
+ // The staging dir normally shares the destination's filesystem, but a
231
+ // configured `tempDir` can sit on another mount (a container's /state next
232
+ // to /data; bind mounts of one disk still refuse rename across them). Then
233
+ // the bytes take one more hop: a `.stored-tmp-<rand>` sibling of the
234
+ // destination — same directory, same mount by construction — and the
235
+ // rename from there. Siblings are ignored by the watcher/scan like the dir.
236
+ async #finish(tmp, dest) {
237
+ try {
238
+ await fs.rename(tmp, dest);
239
+ return;
240
+ } catch (err) {
241
+ if (err.code !== 'EXDEV') throw err;
242
+ }
243
+ const sibling = path.join(path.dirname(dest), `${TMP_DIR}-${process.pid}-${Math.random().toString(16).slice(2)}`);
244
+ try {
245
+ await fs.copyFile(tmp, sibling);
246
+ await fs.rename(sibling, dest);
247
+ } finally {
248
+ await fs.remove(sibling).catch(() => {});
249
+ }
250
+ }
251
+
252
+ async put(key, data) {
253
+ const filePath = this.#resolvePath(key);
254
+ await fs.ensureDir(path.dirname(filePath));
255
+ const tmp = await this.#stagingPath();
256
+ try {
257
+ const fh = await fsp.open(tmp, 'w');
258
+ try { await fh.writeFile(data); await fh.sync(); } finally { await fh.close(); }
259
+ await this.#finish(tmp, filePath);
260
+ } finally {
261
+ await fs.remove(tmp).catch(() => {});
262
+ }
263
+ const stats = await fs.stat(filePath);
264
+ debug(`PUT ${key} (${stats.size} bytes)`);
265
+ return { key, size: stats.size };
266
+ }
267
+
268
+ // Place an already-written file (e.g. a streamed temp) at `key`: hardlink
269
+ // it to a staging name (zero copy on the same filesystem; a byte copy
270
+ // across filesystems), then rename over the destination.
271
+ async commit(key, srcPath) {
272
+ const dest = this.#resolvePath(key);
273
+ await fs.ensureDir(path.dirname(dest));
274
+ const tmp = await this.#stagingPath();
275
+ try {
276
+ try {
277
+ await fs.link(srcPath, tmp);
278
+ } catch (err) {
279
+ if (!['EXDEV', 'EPERM', 'EMLINK', 'ENOTSUP', 'EOPNOTSUPP'].includes(err.code)) throw err;
280
+ await fs.copyFile(srcPath, tmp);
281
+ }
282
+ await this.#finish(tmp, dest);
283
+ } finally {
284
+ await fs.remove(tmp).catch(() => {});
285
+ }
286
+ const stats = await fs.stat(dest);
287
+ debug(`COMMIT ${key} (${stats.size} bytes)`);
288
+ return { key, size: stats.size };
289
+ }
290
+
291
+ // ── Retention ─────────────────────────────────────────────────────────
292
+ //
293
+ // Bytes an overwrite or delete is about to displace are kept for a while
294
+ // under `<tempDir>/retained/<sha256>` (Stored decides when; the window is
295
+ // its config). A hardlink shares the inode with the visible file only
296
+ // until the placement above renames a new inode over it — placements are
297
+ // never in-place writes, so the retained copy stays what it was. On
298
+ // filesystems without hardlinks the bytes are copied.
299
+
300
+ get retainDir() { return path.join(this.#tempDir, 'retained'); }
301
+ retainedPath(sha256) { return path.join(this.retainDir, String(sha256).toLowerCase()); }
302
+
303
+ async retain(key, sha256) {
304
+ const src = this.#resolvePath(key);
305
+ if (!sha256 || !await fs.pathExists(src)) return null;
306
+ const dest = this.retainedPath(sha256);
307
+ const existing = await fs.stat(dest).catch(() => null);
308
+ if (existing) return { path: dest, size: existing.size, existing: true };
309
+ await fs.ensureDir(this.retainDir);
310
+ try {
311
+ await fs.link(src, dest);
312
+ } catch (err) {
313
+ if (!['EXDEV', 'EPERM', 'EMLINK', 'ENOTSUP', 'EOPNOTSUPP'].includes(err.code)) throw err;
314
+ const tmp = await this.#stagingPath();
315
+ try { await fs.copyFile(src, tmp); await fs.rename(tmp, dest); }
316
+ finally { await fs.remove(tmp).catch(() => {}); }
317
+ }
318
+ const stats = await fs.stat(dest);
319
+ debug(`RETAIN ${key} → ${sha256.slice(0, 12)} (${stats.size} bytes)`);
320
+ return { path: dest, size: stats.size, existing: false };
321
+ }
322
+
323
+ async dropRetained(sha256) {
324
+ const dest = this.retainedPath(sha256);
325
+ if (!await fs.pathExists(dest)) return false;
326
+ await fs.remove(dest);
327
+ return true;
328
+ }
329
+
330
+ /**
331
+ * Move an already-existing file at `srcPath` to `key` on this backend via a
332
+ * single `rename(2)` — no bytes cross userspace, so a 10GB file moves in
333
+ * microseconds instead of minutes. Only valid within one filesystem: throws
334
+ * EXDEV otherwise, which callers treat as "fall back to a stream copy".
335
+ */
336
+ async renameFrom(key, srcPath) {
337
+ const dest = this.#resolvePath(key);
338
+ await fs.ensureDir(path.dirname(dest));
339
+ await fs.rename(srcPath, dest);
340
+ const stats = await fs.stat(dest);
341
+ debug(`RENAME ${srcPath} -> ${key} (${stats.size} bytes)`);
342
+ return { key, size: stats.size, modified: stats.mtimeMs, dev: stats.dev, ino: stats.ino };
343
+ }
344
+
345
+ async get(key, options = {}) {
346
+ const filePath = this.#resolvePath(key);
347
+ if (!await fs.pathExists(filePath)) return null;
348
+ return options.stream ? fs.createReadStream(filePath) : fs.readFile(filePath);
349
+ }
350
+
351
+ // Ranged read for HTTP Range/streaming. `end` is inclusive (matches HTTP
352
+ // Range and createReadStream). Null on miss → caller falls back to full read.
353
+ async getRange(key, { start, end }) {
354
+ const filePath = this.#resolvePath(key);
355
+ if (!await fs.pathExists(filePath)) return null;
356
+ return fs.createReadStream(filePath, { start, end });
357
+ }
358
+
359
+ async delete(key) {
360
+ const filePath = this.#resolvePath(key);
361
+ if (!await fs.pathExists(filePath)) return false;
362
+ await fs.remove(filePath);
363
+ debug(`DELETE ${key}`);
364
+ await this.pruneEmptyParents(key);
365
+ return true;
366
+ }
367
+
368
+ /**
369
+ * Remove the now-empty directories above `key`, stopping at the first one
370
+ * that still has entries and never touching the root or the staging dir.
371
+ * Directories are not objects — a mirror deletes a folder file by file —
372
+ * so without this a hub keeps every emptied folder as a shell forever.
373
+ * `rmdir(2)` refuses a non-empty dir, so a concurrent write is safe.
374
+ */
375
+ async pruneEmptyParents(key) {
376
+ let rel = path.posix.dirname(String(key).split(path.sep).join('/'));
377
+ while (rel && rel !== '.' && rel !== '/') {
378
+ if (this.#isTempPath(rel) || (this.#tempRel && this.#tempRel.startsWith(`${rel}/`))) return;
379
+ const dir = this.#resolvePath(rel);
380
+ if (dir === this.#root) return;
381
+ try { await fs.rmdir(dir); } catch { return; }
382
+ debug(`RMDIR (empty) ${rel}`);
383
+ rel = path.posix.dirname(rel);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Set the modification time of `key` (ms since epoch, Date, or ISO
389
+ * string). A mirror pulls a file with the hub's mtime so both sides agree
390
+ * on "when" without a second hash.
391
+ */
392
+ async utimes(key, mtime) {
393
+ const filePath = this.#resolvePath(key);
394
+ const when = mtime instanceof Date ? mtime : new Date(typeof mtime === 'string' ? Date.parse(mtime) : Number(mtime));
395
+ if (Number.isNaN(when.getTime())) throw new Error(`Invalid mtime: ${mtime}`);
396
+ await fs.utimes(filePath, when, when);
397
+ return { key, modified: when.getTime() };
398
+ }
399
+
400
+ async stat(key) {
401
+ const filePath = this.#resolvePath(key);
402
+ if (!await fs.pathExists(filePath)) return null;
403
+ const stats = await fs.stat(filePath);
404
+ if (!stats.isFile()) return null;
405
+ // dev/ino feed rename matching (same inode at a new path) and the
406
+ // liveness checks — stored keeps them in location metadata.
407
+ return { key, size: stats.size, modified: stats.mtimeMs, created: stats.birthtimeMs, dev: stats.dev, ino: stats.ino };
408
+ }
409
+
410
+ // `onError(relPath, err)` (optional): an unreadable directory (EACCES, I/O)
411
+ // is reported and skipped instead of aborting the walk — one denied subtree
412
+ // must not kill (or worse, truncate) a whole-mount enumeration.
413
+ async *list(options = {}) {
414
+ const { prefix = '', recursive = true, onError = null } = options;
415
+ const searchPath = this.#resolvePath(prefix);
416
+ if (!await fs.pathExists(searchPath)) return;
417
+
418
+ let entries;
419
+ try {
420
+ entries = await fs.readdir(searchPath, { withFileTypes: true });
421
+ } catch (err) {
422
+ if (typeof onError === 'function') { onError(prefix, err); return; }
423
+ throw err;
424
+ }
425
+ for (const entry of entries) {
426
+ const relativePath = path.join(prefix, entry.name);
427
+ if (this.#isIgnored(relativePath)) continue;
428
+ if (entry.isFile()) {
429
+ yield { key: relativePath, ...(await this.stat(relativePath)) };
430
+ } else if (entry.isDirectory() && recursive) {
431
+ yield* this.list({ ...options, prefix: relativePath });
432
+ }
433
+ }
434
+ }
435
+
436
+ // ─────────────────────────────────────────────────────────────────────────
437
+ // Watch & Scan
438
+ // ─────────────────────────────────────────────────────────────────────────
439
+
440
+ async watch() {
441
+ if (this.#watcher) return true;
442
+
443
+ // inotify does not cross the wire: on cifs/nfs the kernel only reports
444
+ // changes made through THIS client, so a watched share silently misses
445
+ // everything another machine writes — worse than not watching, because
446
+ // it looks live. Polling is the only correct option and it is expensive
447
+ // on a large share, so it must be asked for explicitly.
448
+ if (this.#remote && this.config.usePolling !== true) {
449
+ debug(`Refusing to watch remote backend "${this.name}" (${this.#transport || 'unknown transport'}) without usePolling — use scan()/resync instead`);
450
+ this.emit('backend:state', {
451
+ backend: this.name,
452
+ watching: false,
453
+ remote: true,
454
+ transport: this.#transport,
455
+ reason: 'remote-watch-requires-polling',
456
+ });
457
+ return false;
458
+ }
459
+
460
+ // `stabilityThreshold`: how long a file must stop growing before it is
461
+ // hashed (a mirror bumps it — a multi-GB copy from a USB stick must
462
+ // not be hashed mid-write). `followSymlinks`: chokidar's default is
463
+ // true; a mirror turns it off so a symlink into /etc is never synced.
464
+ const stabilityMs = Number(this.config.stabilityThreshold) > 0 ? Number(this.config.stabilityThreshold) : 200;
465
+ const watchOpts = {
466
+ persistent: true,
467
+ ignoreInitial: true,
468
+ awaitWriteFinish: {
469
+ stabilityThreshold: stabilityMs,
470
+ pollInterval: 50,
471
+ },
472
+ ...(this.config.followSymlinks != null ? { followSymlinks: this.config.followSymlinks !== false } : {}),
473
+ };
474
+ // Opt-in polling (the only way to see other clients' writes on a network
475
+ // mount). Deliberately slow by default: each tick stats every watched
476
+ // file, which on a NAS is real traffic.
477
+ if (this.config.usePolling === true) {
478
+ watchOpts.usePolling = true;
479
+ watchOpts.interval = this.config.pollInterval ?? 30000;
480
+ watchOpts.binaryInterval = this.config.pollInterval ?? 30000;
481
+ }
482
+ // Chokidar hands us absolute paths; route them through the shared
483
+ // rel-path matcher so watch and list/scan agree on exclusions.
484
+ watchOpts.ignored = (p) => {
485
+ if (TMP_IGNORE.test(p)) return true;
486
+ const rel = path.relative(this.#root, p);
487
+ if (!rel || rel.startsWith('..')) return false;
488
+ return this.#isIgnored(rel);
489
+ };
490
+
491
+ this.#watcher = chokidar.watch(this.#root, watchOpts);
492
+
493
+ const toKey = p => path.relative(this.#root, p);
494
+
495
+ this.#watcher
496
+ .on('add', p => this.#emitFileEvent('file:add', p))
497
+ .on('change', p => this.#emitFileEvent('file:change', p))
498
+ .on('unlink', p => {
499
+ this.emit('file:unlink', { backend: this.name, key: toKey(p), path: p });
500
+ })
501
+ // chokidar (v4 and v5) with followSymlinks:false loses a directory
502
+ // that is created and populated before its own watch is set up
503
+ // (`cp -r`, unzip, a clone): no add for the files, no watch on the
504
+ // dir, so later edits and deletes in it are invisible too. Every
505
+ // addDir schedules a walk that adopts what chokidar missed.
506
+ .on('addDir', p => this.#scheduleHeal(p, stabilityMs))
507
+ .on('error', err => this.emit('error', err));
508
+
509
+ debug(`Watching ${this.#root}`);
510
+ return true;
511
+ }
512
+
513
+ async #emitFileEvent(event, p) {
514
+ const key = path.relative(this.#root, p);
515
+ const [checksums, mimeType, stats] = await Promise.all([
516
+ checksumFile(p, this.#defaultAlgorithms).catch(() => null),
517
+ detectMimeType(p).catch(() => null),
518
+ fs.stat(p).catch(() => null),
519
+ ]);
520
+ if (!stats) return; // gone again before we could hash it; unlink follows
521
+ this.emit(event, { backend: this.name, key, path: p, checksums, mimeType, size: stats.size, modified: stats.mtimeMs, dev: stats.dev, ino: stats.ino });
522
+ }
523
+
524
+ // Walk `dir` once the burst that created it has settled and compare the
525
+ // disk against what chokidar tracks: an untracked subdirectory is handed
526
+ // to the watcher (it then watches it, silently — ignoreInitial), an
527
+ // untracked file is announced as if the watcher had seen it. A file still
528
+ // being written (mtime inside the stability window) defers one more round.
529
+ #scheduleHeal(dir, stabilityMs, attempt = 0) {
530
+ const timer = setTimeout(() => {
531
+ this.#healSubtree(dir, stabilityMs, attempt).catch((err) => debug(`heal ${dir} failed: ${err.message}`));
532
+ }, stabilityMs + 250);
533
+ timer.unref?.();
534
+ }
535
+
536
+ async #healSubtree(dir, stabilityMs, attempt) {
537
+ const watcher = this.#watcher;
538
+ if (!watcher) return;
539
+ const watched = watcher.getWatched();
540
+ const tracked = (parent, name) => Array.isArray(watched[parent]) && watched[parent].includes(name);
541
+ let unsettled = false;
542
+ const walk = async (d) => {
543
+ let entries;
544
+ try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
545
+ for (const entry of entries) {
546
+ const full = path.join(d, entry.name);
547
+ const rel = path.relative(this.#root, full);
548
+ if (this.#isIgnored(rel)) continue;
549
+ if (entry.isDirectory()) {
550
+ if (!tracked(d, entry.name)) { debug(`heal: adopting directory ${rel}`); watcher.add(full); }
551
+ await walk(full);
552
+ } else if (entry.isFile() && !tracked(d, entry.name)) {
553
+ const stats = await fs.stat(full).catch(() => null);
554
+ if (!stats) continue;
555
+ if (Date.now() - stats.mtimeMs < stabilityMs) { unsettled = true; continue; }
556
+ debug(`heal: adopting file ${rel}`);
557
+ await this.#emitFileEvent('file:add', full);
558
+ }
559
+ }
560
+ };
561
+ await walk(dir);
562
+ if (unsettled && attempt < 5) this.#scheduleHeal(dir, stabilityMs, attempt + 1);
563
+ }
564
+
565
+ /**
566
+ * Full-snapshot scan. Returns { files, complete, errors } — a diff against
567
+ * the previous state may only run on a completed snapshot:
568
+ * - root liveness is verified first; a missing/replaced root yields
569
+ * complete:false and NO rows (absent mount ≠ empty mount)
570
+ * - unreadable directories are recorded in errors.dirs and skipped; their
571
+ * prior entries must be carried forward by the differ, not deleted
572
+ * - files that fail to hash keep a row (checksums:null) and are recorded
573
+ * in errors.files: present-but-unverified, never "deleted"
574
+ */
575
+ async scan(options = {}) {
576
+ const algorithms = options.algorithms || this.#defaultAlgorithms;
577
+ // Optional skip-hash predicate: (key, { size, mtime }) => { checksums, mimeType } | null.
578
+ // When it returns a cached descriptor the (expensive) file read is skipped.
579
+ const known = typeof options.knownChecksums === 'function' ? options.knownChecksums : null;
580
+ // Optional streaming consumer: awaited per file as soon as its checksums
581
+ // are ready, so large scans (a whole photo archive on a network mount)
582
+ // surface results incrementally instead of after the full walk.
583
+ const onFile = typeof options.onFile === 'function' ? options.onFile : null;
584
+ const results = [];
585
+ const errors = { root: null, dirs: [], files: [] };
586
+ debug(`Scanning ${this.#root}...`);
587
+ this.emit('scan:start', { backend: this.name });
588
+
589
+ const liveness = await this.verifyRoot(options.fsid || null);
590
+ if (!liveness.ok) {
591
+ errors.root = liveness.reason;
592
+ debug(`Scan aborted: root ${liveness.reason}`);
593
+ this.emit('scan:complete', { backend: this.name, count: 0, complete: false, errors });
594
+ return { files: [], complete: false, errors, fsid: liveness.fsid };
595
+ }
596
+
597
+ const listOptions = {
598
+ ...options,
599
+ onError: (relPath, err) => errors.dirs.push({ prefix: relPath, code: err?.code || 'EUNKNOWN' }),
600
+ };
601
+ for await (const entry of this.list(listOptions)) {
602
+ const cached = known ? known(entry.key, { size: entry.size, mtime: entry.modified, dev: entry.dev, ino: entry.ino }) : null;
603
+ let checksums, mimeType;
604
+ if (cached) {
605
+ checksums = cached.checksums;
606
+ mimeType = cached.mimeType ?? null;
607
+ } else {
608
+ const filePath = this.#resolvePath(entry.key);
609
+ [checksums, mimeType] = await Promise.all([
610
+ checksumFile(filePath, algorithms).catch(() => null),
611
+ detectMimeType(filePath).catch(() => null),
612
+ ]);
613
+ if (!checksums) errors.files.push({ key: entry.key });
614
+ }
615
+ const row = { ...entry, checksums, mimeType, backend: this.name };
616
+ results.push(row);
617
+ if (onFile) await onFile(row);
618
+ }
619
+
620
+ const complete = errors.dirs.length === 0;
621
+ this.emit('scan:complete', { backend: this.name, count: results.length, complete, errors });
622
+ debug(`Scan complete: ${results.length} files (${complete ? 'full' : `partial — ${errors.dirs.length} unreadable dirs`})`);
623
+ return { files: results, complete, errors, fsid: liveness.fsid };
624
+ }
625
+
626
+ /**
627
+ * Cheap structural walk: directory keys + file count, honoring the shared
628
+ * ignore matcher — readdir only, no stat/hash. Lets callers mirror the
629
+ * folder skeleton (and size a progress bar) before the expensive scan.
630
+ */
631
+ async shape(prefix = '') {
632
+ const dirs = [];
633
+ let files = 0;
634
+ const walk = async (rel) => {
635
+ const entries = await fs.readdir(this.#resolvePath(rel), { withFileTypes: true }).catch(() => []);
636
+ for (const entry of entries) {
637
+ const relativePath = path.join(rel, entry.name);
638
+ if (this.#isIgnored(relativePath)) continue;
639
+ if (entry.isDirectory()) {
640
+ dirs.push(relativePath.split(path.sep).join('/'));
641
+ await walk(relativePath);
642
+ } else if (entry.isFile()) {
643
+ files += 1;
644
+ }
645
+ }
646
+ };
647
+ await walk(prefix);
648
+ return { dirs, files };
649
+ }
650
+
651
+ async stop() {
652
+ if (this.#watcher) {
653
+ await this.#watcher.close();
654
+ this.#watcher = null;
655
+ debug(`Stopped watching ${this.#root}`);
656
+ }
657
+ }
658
+ }