@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,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
|
+
}
|