@dbx-tools/shared-fs 0.6.10

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/src/base-fs.ts ADDED
@@ -0,0 +1,882 @@
1
+ /**
2
+ * Abstract {@link FileSystem} base: lifecycle, rooted POSIX paths, encoding, and
3
+ * portable fallbacks so a concrete backend only implements low-level I/O.
4
+ *
5
+ * A new adapter typically overrides:
6
+ * - {@link onInit} / {@link onClose} (optional)
7
+ * - {@link createRootDirectory} when {@link BaseFileSystemOptions.createRoot} is set
8
+ * - {@link toBackendPath} when the backend needs non-POSIX separators
9
+ * - {@link preparePath} for post-resolve checks (e.g. symlink containment)
10
+ * - the `*At` primitives and {@link isNotFoundError}
11
+ * - optional `try*` hooks for native append / copy / move
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import { error, functionModule, hash, object, type OneOrMany } from "@dbx-tools/shared-core";
17
+ import type {
18
+ CopyOptions,
19
+ FileContent,
20
+ FileEntry,
21
+ FileStat,
22
+ FileSystem,
23
+ ListOptions,
24
+ MakeDirectoryOptions,
25
+ ReadFileOptions,
26
+ RemoveOptions,
27
+ WriteFileOptions,
28
+ } from "./fs.ts";
29
+ import * as posixPath from "./posix-path.ts";
30
+
31
+ /**
32
+ * One root path segment. Strings are split on `/` and sanitized; numbers /
33
+ * booleans / bigints stringify then sanitize; objects and arrays are FNV-hashed
34
+ * as a single segment.
35
+ */
36
+ export type FileSystemRootSegment = string | number | boolean | bigint | object;
37
+
38
+ /**
39
+ * A single {@link FileSystemRootSegment} or a non-empty list of them. Nested
40
+ * arrays/objects inside the list are one hashed segment each (not flattened).
41
+ */
42
+ export type FileSystemRootInput = FileSystemRootSegment | OneOrMany<FileSystemRootSegment>;
43
+
44
+ /**
45
+ * Characters no backend accepts inside a single path component: a separator
46
+ * (which would silently deepen the path) or a NUL / control character.
47
+ *
48
+ * This is a DENY-list on purpose. Spaces, `@`, `&`, `#`, parentheses and
49
+ * non-ASCII are all legal directory names on POSIX and in a Databricks
50
+ * workspace, and replacing one with a hash points the filesystem at a
51
+ * directory that does not exist - a failure that is silent and very hard to
52
+ * trace back. Only reject what genuinely cannot be a component.
53
+ */
54
+ const UNSAFE_PATH_SEGMENT = /[\\/\u0000-\u001F\u007F]/;
55
+
56
+ /**
57
+ * Turn {@link root} into a POSIX filesystem root:
58
+ *
59
+ * 1. Expand one-or-many input segments
60
+ * 2. Strings split on `/` (and `\`); objects/arrays FNV-hash as one piece
61
+ * 3. Each resulting component that cannot be a path component - see
62
+ * {@link UNSAFE_PATH_SEGMENT} - is replaced with {@link hash.fnvHash}
63
+ * 4. Join with `/` and run {@link posixPath.normalizeRoot}
64
+ *
65
+ * Defaults to `/`. A leading `/` on the first string segment is preserved.
66
+ *
67
+ * @example
68
+ * normalizeFileSystemRoot("/cool/wow"); // "/cool/wow"
69
+ * normalizeFileSystemRoot("/Users/me@corp.com/My Notes"); // unchanged
70
+ * normalizeFileSystemRoot(["/path", { user: 1 }, true]); // "/path/<hash>/true"
71
+ */
72
+ export function normalizeFileSystemRoot(root?: FileSystemRootInput): string {
73
+ if (root === undefined) return "/";
74
+ const segments = object.toOneOrMany(root);
75
+ const absolute = isAbsoluteRootStart(segments[0]);
76
+ const parts: string[] = [];
77
+ for (const segment of segments) {
78
+ if (segment === null || segment === undefined) {
79
+ throw new TypeError("Filesystem root segments must be non-null");
80
+ }
81
+ appendRootParts(parts, segment);
82
+ }
83
+ if (parts.length === 0) return "/";
84
+ const joined = parts.join("/");
85
+ return posixPath.normalizeRoot(absolute ? `/${joined}` : joined);
86
+ }
87
+
88
+ function isAbsoluteRootStart(segment: FileSystemRootSegment): boolean {
89
+ return typeof segment === "string" && posixPath.isAbsolute(segment.trim());
90
+ }
91
+
92
+ function appendRootParts(parts: string[], segment: FileSystemRootSegment): void {
93
+ switch (typeof segment) {
94
+ case "string":
95
+ for (const piece of splitPathPieces(segment)) {
96
+ parts.push(sanitizePathSegment(piece));
97
+ }
98
+ return;
99
+ case "number":
100
+ case "boolean":
101
+ case "bigint":
102
+ parts.push(sanitizePathSegment(String(segment)));
103
+ return;
104
+ default:
105
+ parts.push(hash.fnvHash(segment));
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Split on `/` (after `\` → `/`); drop empty pieces from
111
+ * leading/trailing/double slashes and no-op `.` pieces.
112
+ */
113
+ function splitPathPieces(input: string): string[] {
114
+ return posixPath
115
+ .toPosix(input.trim())
116
+ .split("/")
117
+ .filter((piece) => piece.length > 0 && piece !== ".");
118
+ }
119
+
120
+ /**
121
+ * Keep a usable path component verbatim; FNV-hash one that cannot be used.
122
+ *
123
+ * `..` is hashed rather than dropped so a root can never traverse above
124
+ * itself while the offending segment stays visible in the resolved root.
125
+ */
126
+ function sanitizePathSegment(segment: string): string {
127
+ if (segment === ".." || UNSAFE_PATH_SEGMENT.test(segment)) {
128
+ return hash.fnvHash(segment);
129
+ }
130
+ return segment;
131
+ }
132
+
133
+ export type FileSystemErrorCode =
134
+ | "NOT_FOUND"
135
+ | "ALREADY_EXISTS"
136
+ | "NOT_DIRECTORY"
137
+ | "IS_DIRECTORY"
138
+ | "DIRECTORY_NOT_EMPTY"
139
+ | "PERMISSION_DENIED"
140
+ | "READ_ONLY"
141
+ | "INVALID_PATH"
142
+ | "NOT_SUPPORTED"
143
+ | "IO_ERROR";
144
+
145
+ export class FileSystemError extends Error {
146
+ readonly name = "FileSystemError";
147
+
148
+ constructor(
149
+ readonly code: FileSystemErrorCode,
150
+ message: string,
151
+ readonly path?: string,
152
+ options?: { cause?: unknown },
153
+ ) {
154
+ super(
155
+ message,
156
+ options?.cause !== undefined ? { cause: error.toError(options.cause) } : undefined,
157
+ );
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Infer a {@link FileSystemErrorCode} from HTTP status / message tokens on an
163
+ * unknown thrown value (via {@link error.errorContext}).
164
+ *
165
+ * Covers common SDK / REST wording so adapters do not reimplement the same
166
+ * "not found" / "already exists" checks. Returns undefined when nothing matches.
167
+ */
168
+ export function inferFileSystemErrorCode(err: unknown): FileSystemErrorCode | undefined {
169
+ const ctx = error.errorContext(err);
170
+ if (ctx.hasStatusCode(404) || ctx.hasMessage("not", "found") || ctx.hasMessage("not", "exist")) {
171
+ return "NOT_FOUND";
172
+ }
173
+ if (ctx.hasStatusCode(409) || ctx.hasMessage("already", "exists")) {
174
+ return "ALREADY_EXISTS";
175
+ }
176
+ // Before "not"+"directory": "directory not empty" contains both of those tokens.
177
+ if (ctx.hasMessage("not", "empty")) {
178
+ return "DIRECTORY_NOT_EMPTY";
179
+ }
180
+ if (ctx.hasMessage("not", "directory")) {
181
+ return "NOT_DIRECTORY";
182
+ }
183
+ if (ctx.hasMessage("is", "directory") || ctx.hasMessage("not", "file")) {
184
+ return "IS_DIRECTORY";
185
+ }
186
+ if (
187
+ ctx.hasStatusCode(401, 403) ||
188
+ ctx.hasMessage("permission", "denied") ||
189
+ ctx.hasMessage("access", "denied")
190
+ ) {
191
+ return "PERMISSION_DENIED";
192
+ }
193
+ if (ctx.hasMessage("read", "only")) {
194
+ return "READ_ONLY";
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ /**
200
+ * Map an unknown backend failure into a {@link FileSystemError}.
201
+ *
202
+ * Prefer a backend-specific {@link codeOf} classifier (errno, SDK code). When
203
+ * it returns undefined, falls back to {@link inferFileSystemErrorCode}. Message
204
+ * and cause always go through `@dbx-tools/shared-core` {@link error} helpers.
205
+ */
206
+ export function mapFileSystemError(
207
+ err: unknown,
208
+ filePath: string,
209
+ codeOf?: (err: unknown) => FileSystemErrorCode | undefined,
210
+ ): FileSystemError {
211
+ if (err instanceof FileSystemError) return err;
212
+ const message = error.errorMessage(err);
213
+ return new FileSystemError(
214
+ codeOf?.(err) ?? inferFileSystemErrorCode(err) ?? "IO_ERROR",
215
+ message || `Filesystem operation failed: ${filePath}`,
216
+ filePath,
217
+ { cause: error.toError(err) },
218
+ );
219
+ }
220
+
221
+ export interface BaseFileSystemOptions<TBackend extends string = string> {
222
+ id: string;
223
+ backend: TBackend;
224
+ /**
225
+ * Filesystem root. One segment or a list of segments ({@link FileSystemRootInput}):
226
+ * primitives stringify, objects/arrays are FNV-hashed, then joined with `/`
227
+ * and normalized via {@link posixPath.normalizeRoot}. Defaults to `/`.
228
+ */
229
+ root?: FileSystemRootInput;
230
+ readOnly?: boolean;
231
+ /**
232
+ * Ensure {@link root} exists during init by calling {@link createRootDirectory}.
233
+ * Defaults to false (remote roots usually already exist). Local disk adapters
234
+ * typically pass true and override {@link createRootDirectory}.
235
+ */
236
+ createRoot?: boolean;
237
+ }
238
+
239
+ /**
240
+ * Base implementation for local, remote, and virtual filesystems.
241
+ *
242
+ * Subclasses implement the low-level `*At` primitives. This class provides:
243
+ *
244
+ * - Memoized {@link _init} (so an explicit {@link init} call is optional)
245
+ * - Optional root creation via {@link createRootDirectory}
246
+ * - POSIX-only rooted path normalization and traversal protection
247
+ * - {@link toBackendPath} for host/separator conversion at the boundary
248
+ * - Text encoding and decoding
249
+ * - {@link exists}
250
+ * - Parent-directory creation on write / append / copy / move
251
+ * - Append / copy / move fallbacks (override `try*` for native ops)
252
+ * - Recursive mkdir, readdir, and rmdir
253
+ * - Extension filtering for {@link readdir}
254
+ *
255
+ * Namespace paths always use `/`. Host adapters convert with
256
+ * {@link posixPath.toPosix} / {@link posixPath.toHost} in {@link toBackendPath}.
257
+ */
258
+ export abstract class BaseFileSystem<
259
+ TBackend extends string = string,
260
+ > implements FileSystem<TBackend> {
261
+ readonly id: string;
262
+ readonly backend: TBackend;
263
+ /** POSIX-normalized root (see {@link posixPath.normalizeRoot}). */
264
+ readonly root: string;
265
+ readonly readOnly: boolean;
266
+ protected readonly createRoot: boolean;
267
+
268
+ /**
269
+ * Memoized initialization. Every operation that needs a ready backend awaits
270
+ * this, so callers (e.g. Mastra) may call {@link init} every time or never;
271
+ * both are fine.
272
+ */
273
+ protected _init: () => Promise<void>;
274
+
275
+ private initStarted = false;
276
+
277
+ protected constructor(options: BaseFileSystemOptions<TBackend>) {
278
+ this.id = options.id;
279
+ this.backend = options.backend;
280
+ this.root = normalizeFileSystemRoot(options.root);
281
+ this.readOnly = options.readOnly ?? false;
282
+ this.createRoot = options.createRoot ?? false;
283
+ this._init = this.createInit();
284
+ }
285
+
286
+ /* ------------------------------------------------------------------ */
287
+ /* Lifecycle */
288
+ /* ------------------------------------------------------------------ */
289
+
290
+ private createInit(): () => Promise<void> {
291
+ return functionModule.memoize(async () => {
292
+ this.initStarted = true;
293
+ if (this.createRoot) {
294
+ await this.guard(this.root, () => this.createRootDirectory());
295
+ }
296
+ await this.onInit();
297
+ });
298
+ }
299
+
300
+ async init(): Promise<void> {
301
+ await this._init();
302
+ }
303
+
304
+ async close(): Promise<void> {
305
+ if (!this.initStarted) return;
306
+ try {
307
+ await this._init();
308
+ } catch {
309
+ this.initStarted = false;
310
+ this._init = this.createInit();
311
+ return;
312
+ }
313
+ await this.onClose();
314
+ this.initStarted = false;
315
+ this._init = this.createInit();
316
+ }
317
+
318
+ /**
319
+ * Ensure {@link root} exists when {@link createRoot} is true.
320
+ *
321
+ * Default is a no-op. Local adapters typically `mkdir -p`; remote adapters
322
+ * leave the default when the root is provisioned out of band.
323
+ */
324
+ protected async createRootDirectory(): Promise<void> {}
325
+
326
+ /** Override when the backend requires connection or validation work. */
327
+ protected async onInit(): Promise<void> {}
328
+
329
+ /** Override when the backend owns connections or other resources. */
330
+ protected async onClose(): Promise<void> {}
331
+
332
+ protected assertWritable(operation: string): void {
333
+ if (this.readOnly) {
334
+ throw new FileSystemError("READ_ONLY", `Cannot ${operation}: filesystem is read-only`);
335
+ }
336
+ }
337
+
338
+ /* ------------------------------------------------------------------ */
339
+ /* Paths (POSIX only) */
340
+ /* ------------------------------------------------------------------ */
341
+
342
+ /**
343
+ * Normalize an input path into an absolute POSIX path inside the virtual
344
+ * filesystem namespace (`/a/b`). Backslashes are converted; `..` escaping
345
+ * the root throws {@link FileSystemError} `PERMISSION_DENIED`.
346
+ */
347
+ protected normalizePath(inputPath: string): string {
348
+ if (inputPath.includes("\0")) {
349
+ throw new FileSystemError("INVALID_PATH", "Paths cannot contain null characters", inputPath);
350
+ }
351
+
352
+ const result = posixPath.normalize(inputPath);
353
+ if (!result.ok) {
354
+ throw new FileSystemError("PERMISSION_DENIED", "Path escapes the filesystem root", inputPath);
355
+ }
356
+ return result.path;
357
+ }
358
+
359
+ /**
360
+ * Convert a POSIX backend path (under {@link root}) into the form the
361
+ * underlying API expects.
362
+ *
363
+ * Default is identity. Local disk overrides with {@link posixPath.toHost}.
364
+ * Databricks / object-store adapters usually leave the default.
365
+ */
366
+ protected toBackendPath(posixBackendPath: string): string {
367
+ return posixBackendPath;
368
+ }
369
+
370
+ /**
371
+ * Convert a normalized namespace path (`/a/b`) into a backend path.
372
+ *
373
+ * Joins {@link root} with the namespace using POSIX `/`, then applies
374
+ * {@link toBackendPath}. Override {@link toBackendPath} instead of this
375
+ * method unless the join itself must change.
376
+ */
377
+ protected resolveBackendPath(namespacePath: string): string {
378
+ const posix =
379
+ namespacePath === "/" ? this.root : posixPath.join(this.root, namespacePath.slice(1));
380
+ return this.toBackendPath(posix);
381
+ }
382
+
383
+ resolvePath(inputPath: string): string {
384
+ return this.resolveBackendPath(this.normalizePath(inputPath));
385
+ }
386
+
387
+ /**
388
+ * Resolve {@link inputPath}, ensure init, and run {@link preparePath}.
389
+ */
390
+ protected async resolveFor(
391
+ inputPath: string,
392
+ options?: { allowMissing?: boolean },
393
+ ): Promise<string> {
394
+ await this._init();
395
+ return this.resolveNamespaceFor(this.normalizePath(inputPath), options);
396
+ }
397
+
398
+ /**
399
+ * {@link resolveFor} for a path that is ALREADY a normalized namespace path
400
+ * (`/a/b`). The single spelling for "namespace path to prepared backend
401
+ * path", so no call site has to re-derive the chain by hand.
402
+ */
403
+ private resolveNamespaceFor(
404
+ namespacePath: string,
405
+ options?: { allowMissing?: boolean },
406
+ ): Promise<string> {
407
+ return this.preparePath(this.resolveBackendPath(namespacePath), options);
408
+ }
409
+
410
+ /**
411
+ * Hook after lexical resolution. Override for realpath containment or
412
+ * similar backend-specific checks. Default is a no-op.
413
+ */
414
+ protected async preparePath(
415
+ resolvedPath: string,
416
+ _options?: { allowMissing?: boolean },
417
+ ): Promise<string> {
418
+ return resolvedPath;
419
+ }
420
+
421
+ protected joinNamespace(parent: string, child: string): string {
422
+ return this.normalizePath(posixPath.join(parent, child));
423
+ }
424
+
425
+ /** Namespace path without a leading slash (`.` for the root). */
426
+ protected toRelativePath(namespacePath: string): string {
427
+ return namespacePath === "/" ? "." : namespacePath.slice(1);
428
+ }
429
+
430
+ protected toBytes(content: FileContent): Uint8Array {
431
+ return typeof content === "string" ? new TextEncoder().encode(content) : content;
432
+ }
433
+
434
+ /** Create parent directories for {@link inputPath} when it is nested. */
435
+ protected async ensureParentDirectory(inputPath: string): Promise<void> {
436
+ const namespacePath = this.normalizePath(inputPath);
437
+ const parent = posixPath.dirname(namespacePath);
438
+ if (parent === "/" || parent === namespacePath) return;
439
+ await this.mkdir(parent, { recursive: true });
440
+ }
441
+
442
+ /* ------------------------------------------------------------------ */
443
+ /* Backend primitives */
444
+ /* ------------------------------------------------------------------ */
445
+
446
+ protected abstract readBytesAt(resolvedPath: string): Promise<Uint8Array>;
447
+
448
+ protected abstract writeBytesAt(
449
+ resolvedPath: string,
450
+ content: Uint8Array,
451
+ options: Required<WriteFileOptions>,
452
+ ): Promise<void>;
453
+
454
+ protected abstract deleteFileAt(resolvedPath: string): Promise<void>;
455
+
456
+ protected abstract createDirectoryAt(resolvedPath: string): Promise<void>;
457
+
458
+ /**
459
+ * Remove an empty directory.
460
+ *
461
+ * Recursive deletion is implemented by {@link BaseFileSystem}.
462
+ */
463
+ protected abstract removeDirectoryAt(resolvedPath: string): Promise<void>;
464
+
465
+ /** Return only the direct children of a directory (`name` is the basename). */
466
+ protected abstract listDirectoryAt(resolvedPath: string): Promise<FileEntry[]>;
467
+
468
+ protected abstract statAt(resolvedPath: string): Promise<Omit<FileStat, "path">>;
469
+
470
+ /**
471
+ * Recognize the backend's not-found error.
472
+ *
473
+ * Default accepts {@link FileSystemError} `NOT_FOUND` plus common SDK / HTTP
474
+ * "not found" shapes via {@link inferFileSystemErrorCode}. Override for
475
+ * backend-specific codes (e.g. Node `ENOENT`) that do not carry a message.
476
+ */
477
+ protected isNotFoundError(err: unknown): boolean {
478
+ if (err instanceof FileSystemError) return err.code === "NOT_FOUND";
479
+ return inferFileSystemErrorCode(err) === "NOT_FOUND";
480
+ }
481
+
482
+ /**
483
+ * Normalize a backend failure into a {@link FileSystemError}.
484
+ *
485
+ * Every `*At` / `try*` primitive is invoked through {@link guard}, so an
486
+ * adapter never writes its own try/catch and cannot forget to normalize.
487
+ * Override only to classify backend-specific codes (e.g. Node errno).
488
+ */
489
+ protected mapError(err: unknown, filePath: string): FileSystemError {
490
+ return mapFileSystemError(err, filePath);
491
+ }
492
+
493
+ /** Run a backend primitive, routing any failure through {@link mapError}. */
494
+ private async guard<T>(resolvedPath: string, operation: () => Promise<T>): Promise<T> {
495
+ try {
496
+ return await operation();
497
+ } catch (err) {
498
+ throw this.mapError(err, resolvedPath);
499
+ }
500
+ }
501
+
502
+ /* ------------------------------------------------------------------ */
503
+ /* Optional native-operation hooks */
504
+ /* ------------------------------------------------------------------ */
505
+
506
+ /**
507
+ * Override when the backend supports native append.
508
+ *
509
+ * Parent directories are already created by {@link appendFile}. Return true
510
+ * when the operation was performed. The default causes {@link BaseFileSystem}
511
+ * to use read-concatenate-write.
512
+ */
513
+ protected async tryAppendFileAt(_resolvedPath: string, _content: Uint8Array): Promise<boolean> {
514
+ return false;
515
+ }
516
+
517
+ /**
518
+ * Override when the backend supports native server-side copying.
519
+ *
520
+ * Parent directories of the destination are already created by {@link copyFile}.
521
+ */
522
+ protected async tryCopyFileAt(
523
+ _sourcePath: string,
524
+ _destinationPath: string,
525
+ _options: Required<CopyOptions>,
526
+ ): Promise<boolean> {
527
+ return false;
528
+ }
529
+
530
+ /**
531
+ * Override for native rename or move support.
532
+ *
533
+ * Parent directories of the destination are already created by {@link moveFile}.
534
+ */
535
+ protected async tryMoveFileAt(
536
+ _sourcePath: string,
537
+ _destinationPath: string,
538
+ _options: Required<CopyOptions>,
539
+ ): Promise<boolean> {
540
+ return false;
541
+ }
542
+
543
+ /* ------------------------------------------------------------------ */
544
+ /* File operations */
545
+ /* ------------------------------------------------------------------ */
546
+
547
+ async readFile(inputPath: string): Promise<Uint8Array>;
548
+ async readFile(
549
+ inputPath: string,
550
+ options: ReadFileOptions & { encoding: string },
551
+ ): Promise<string>;
552
+ async readFile(inputPath: string, options?: ReadFileOptions): Promise<string | Uint8Array> {
553
+ const resolvedPath = await this.resolveFor(inputPath);
554
+ const content = await this.guard(resolvedPath, () => this.readBytesAt(resolvedPath));
555
+ if (options?.encoding) {
556
+ return new TextDecoder(options.encoding).decode(content);
557
+ }
558
+ return content;
559
+ }
560
+
561
+ async writeFile(
562
+ inputPath: string,
563
+ content: FileContent,
564
+ options: WriteFileOptions = {},
565
+ ): Promise<void> {
566
+ await this._init();
567
+ this.assertWritable("write file");
568
+
569
+ const overwrite = await this.resolveOverwrite(inputPath, options, "File");
570
+ await this.ensureParentDirectory(inputPath);
571
+ const resolvedPath = await this.resolveFor(inputPath, { allowMissing: true });
572
+ await this.guard(resolvedPath, () =>
573
+ this.writeBytesAt(resolvedPath, this.toBytes(content), { overwrite }),
574
+ );
575
+ }
576
+
577
+ async appendFile(inputPath: string, content: FileContent): Promise<void> {
578
+ await this._init();
579
+ this.assertWritable("append file");
580
+
581
+ const bytes = this.toBytes(content);
582
+ await this.ensureParentDirectory(inputPath);
583
+ const resolvedPath = await this.resolveFor(inputPath, { allowMissing: true });
584
+
585
+ if (await this.guard(resolvedPath, () => this.tryAppendFileAt(resolvedPath, bytes))) {
586
+ return;
587
+ }
588
+
589
+ const existing = (await this.exists(inputPath))
590
+ ? await this.readFile(inputPath)
591
+ : new Uint8Array();
592
+ const combined = new Uint8Array(existing.byteLength + bytes.byteLength);
593
+ combined.set(existing);
594
+ combined.set(bytes, existing.byteLength);
595
+ await this.writeFile(inputPath, combined, { overwrite: true });
596
+ }
597
+
598
+ async deleteFile(inputPath: string, options: RemoveOptions = {}): Promise<void> {
599
+ await this._init();
600
+ this.assertWritable("delete file");
601
+
602
+ await this.ignoringMissing(options, async () => {
603
+ const entry = await this.stat(inputPath);
604
+ if (entry.type === "directory") {
605
+ throw new FileSystemError("IS_DIRECTORY", `Path is a directory: ${inputPath}`, inputPath);
606
+ }
607
+ const resolvedPath = await this.resolveFor(inputPath);
608
+ await this.guard(resolvedPath, () => this.deleteFileAt(resolvedPath));
609
+ });
610
+ }
611
+
612
+ async copyFile(
613
+ sourcePath: string,
614
+ destinationPath: string,
615
+ options: CopyOptions = {},
616
+ ): Promise<void> {
617
+ const { source, destination, resolved } = await this.prepareTransfer(
618
+ "copy file",
619
+ sourcePath,
620
+ destinationPath,
621
+ options,
622
+ );
623
+
624
+ if (await this.guard(destination, () => this.tryCopyFileAt(source, destination, resolved))) {
625
+ return;
626
+ }
627
+
628
+ await this.writeFile(destinationPath, await this.readFile(sourcePath), resolved);
629
+ }
630
+
631
+ async moveFile(
632
+ sourcePath: string,
633
+ destinationPath: string,
634
+ options: CopyOptions = {},
635
+ ): Promise<void> {
636
+ const { source, destination, resolved } = await this.prepareTransfer(
637
+ "move file",
638
+ sourcePath,
639
+ destinationPath,
640
+ options,
641
+ );
642
+
643
+ if (await this.guard(destination, () => this.tryMoveFileAt(source, destination, resolved))) {
644
+ return;
645
+ }
646
+
647
+ await this.copyFile(sourcePath, destinationPath, resolved);
648
+ const sourceStat = await this.stat(sourcePath);
649
+ if (sourceStat.type === "directory") {
650
+ await this.rmdir(sourcePath, { recursive: true });
651
+ } else {
652
+ await this.deleteFile(sourcePath);
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Resolve the effective `overwrite` flag, rejecting when the target exists
658
+ * and overwriting was refused. Shared by write / copy / move so the three
659
+ * cannot disagree about what `overwrite: false` means.
660
+ */
661
+ private async resolveOverwrite(
662
+ inputPath: string,
663
+ options: { overwrite?: boolean },
664
+ label: string,
665
+ ): Promise<boolean> {
666
+ const overwrite = options.overwrite ?? true;
667
+ if (!overwrite && (await this.exists(inputPath))) {
668
+ throw new FileSystemError(
669
+ "ALREADY_EXISTS",
670
+ `${label} already exists: ${inputPath}`,
671
+ inputPath,
672
+ );
673
+ }
674
+ return overwrite;
675
+ }
676
+
677
+ /** Shared copy / move prologue: writability, overwrite policy, both ends resolved. */
678
+ private async prepareTransfer(
679
+ operation: string,
680
+ sourcePath: string,
681
+ destinationPath: string,
682
+ options: CopyOptions,
683
+ ): Promise<{ source: string; destination: string; resolved: Required<CopyOptions> }> {
684
+ await this._init();
685
+ this.assertWritable(operation);
686
+
687
+ const overwrite = await this.resolveOverwrite(destinationPath, options, "Destination");
688
+ await this.ensureParentDirectory(destinationPath);
689
+
690
+ return {
691
+ source: await this.resolveFor(sourcePath),
692
+ destination: await this.resolveFor(destinationPath, { allowMissing: true }),
693
+ resolved: { overwrite },
694
+ };
695
+ }
696
+
697
+ /** Run a removal, swallowing a not-found failure when `force` is set. */
698
+ private async ignoringMissing(
699
+ options: RemoveOptions,
700
+ operation: () => Promise<void>,
701
+ ): Promise<void> {
702
+ try {
703
+ await operation();
704
+ } catch (err) {
705
+ if (options.force && this.isNotFoundError(err)) return;
706
+ throw err;
707
+ }
708
+ }
709
+
710
+ /* ------------------------------------------------------------------ */
711
+ /* Directory operations */
712
+ /* ------------------------------------------------------------------ */
713
+
714
+ async mkdir(inputPath: string, options: MakeDirectoryOptions = {}): Promise<void> {
715
+ await this._init();
716
+ this.assertWritable("create directory");
717
+
718
+ const namespacePath = this.normalizePath(inputPath);
719
+
720
+ if (!options.recursive) {
721
+ await this.createDirectory(namespacePath);
722
+ return;
723
+ }
724
+
725
+ let currentPath = "";
726
+ for (const segment of namespacePath.split("/").filter(Boolean)) {
727
+ currentPath = `${currentPath}/${segment}`;
728
+ try {
729
+ const entry = await this.stat(currentPath);
730
+ if (entry.type !== "directory") {
731
+ throw new FileSystemError(
732
+ "NOT_DIRECTORY",
733
+ `Path component is not a directory: ${currentPath}`,
734
+ currentPath,
735
+ );
736
+ }
737
+ } catch (error) {
738
+ if (!this.isNotFoundError(error)) throw error;
739
+ await this.createDirectory(currentPath);
740
+ }
741
+ }
742
+ }
743
+
744
+ async rmdir(inputPath: string, options: RemoveOptions = {}): Promise<void> {
745
+ await this._init();
746
+ this.assertWritable("remove directory");
747
+
748
+ await this.ignoringMissing(options, async () => {
749
+ const entry = await this.stat(inputPath);
750
+ if (entry.type !== "directory") {
751
+ throw new FileSystemError(
752
+ "NOT_DIRECTORY",
753
+ `Path is not a directory: ${inputPath}`,
754
+ inputPath,
755
+ );
756
+ }
757
+
758
+ const namespacePath = this.normalizePath(inputPath);
759
+ if (options.recursive) {
760
+ await this.removeDirectoryContents(namespacePath);
761
+ }
762
+ await this.removeDirectory(namespacePath);
763
+ });
764
+ }
765
+
766
+ private async removeDirectoryContents(namespacePath: string): Promise<void> {
767
+ for (const entry of await this.listDirectory(namespacePath)) {
768
+ const childPath = this.joinNamespace(namespacePath, entry.name);
769
+ if (entry.type === "directory") {
770
+ await this.removeDirectoryContents(childPath);
771
+ await this.removeDirectory(childPath);
772
+ } else {
773
+ const resolvedPath = await this.resolveNamespaceFor(childPath);
774
+ await this.guard(resolvedPath, () => this.deleteFileAt(resolvedPath));
775
+ }
776
+ }
777
+ }
778
+
779
+ /** {@link createDirectoryAt} for a namespace path, resolved and guarded. */
780
+ private async createDirectory(namespacePath: string): Promise<void> {
781
+ const resolvedPath = await this.resolveNamespaceFor(namespacePath, { allowMissing: true });
782
+ await this.guard(resolvedPath, () => this.createDirectoryAt(resolvedPath));
783
+ }
784
+
785
+ /** {@link removeDirectoryAt} for a namespace path, resolved and guarded. */
786
+ private async removeDirectory(namespacePath: string): Promise<void> {
787
+ const resolvedPath = await this.resolveNamespaceFor(namespacePath);
788
+ await this.guard(resolvedPath, () => this.removeDirectoryAt(resolvedPath));
789
+ }
790
+
791
+ /** {@link listDirectoryAt} for a namespace path, resolved and guarded. */
792
+ private async listDirectory(namespacePath: string): Promise<FileEntry[]> {
793
+ const resolvedPath = await this.resolveNamespaceFor(namespacePath);
794
+ return this.guard(resolvedPath, () => this.listDirectoryAt(resolvedPath));
795
+ }
796
+
797
+ async readdir(inputPath: string, options: ListOptions = {}): Promise<FileEntry[]> {
798
+ await this._init();
799
+
800
+ const namespacePath = this.normalizePath(inputPath);
801
+
802
+ if (!options.recursive) {
803
+ return this.filterEntries(await this.listDirectory(namespacePath), options);
804
+ }
805
+
806
+ return this.listDirectoryRecursive(namespacePath, options, 0, "");
807
+ }
808
+
809
+ private async listDirectoryRecursive(
810
+ namespacePath: string,
811
+ options: ListOptions,
812
+ depth: number,
813
+ prefix: string,
814
+ ): Promise<FileEntry[]> {
815
+ const maximumDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
816
+ const entries = this.filterEntries(await this.listDirectory(namespacePath), options);
817
+
818
+ const results: FileEntry[] = [];
819
+
820
+ for (const entry of entries) {
821
+ const relativeName = prefix ? `${prefix}/${entry.name}` : entry.name;
822
+ results.push({ ...entry, name: relativeName });
823
+
824
+ if (entry.type === "directory" && depth < maximumDepth) {
825
+ results.push(
826
+ ...(await this.listDirectoryRecursive(
827
+ this.joinNamespace(namespacePath, entry.name),
828
+ options,
829
+ depth + 1,
830
+ relativeName,
831
+ )),
832
+ );
833
+ }
834
+ }
835
+
836
+ return results;
837
+ }
838
+
839
+ private filterEntries(entries: FileEntry[], options: ListOptions): FileEntry[] {
840
+ if (!options.extension) {
841
+ return entries;
842
+ }
843
+
844
+ const extensions = (
845
+ Array.isArray(options.extension) ? options.extension : [options.extension]
846
+ ).map((extension) => {
847
+ const normalized = extension.toLowerCase();
848
+ return normalized.startsWith(".") ? normalized : `.${normalized}`;
849
+ });
850
+
851
+ return entries.filter((entry) => {
852
+ if (entry.type !== "file") return true;
853
+ const lower = entry.name.toLowerCase();
854
+ return extensions.some((ext) => lower.endsWith(ext));
855
+ });
856
+ }
857
+
858
+ async exists(inputPath: string): Promise<boolean> {
859
+ await this._init();
860
+ try {
861
+ await this.stat(inputPath);
862
+ return true;
863
+ } catch (error) {
864
+ if (this.isNotFoundError(error)) return false;
865
+ if (error instanceof FileSystemError && error.code === "PERMISSION_DENIED") {
866
+ return false;
867
+ }
868
+ throw error;
869
+ }
870
+ }
871
+
872
+ async stat(inputPath: string): Promise<FileStat> {
873
+ await this._init();
874
+ const namespacePath = this.normalizePath(inputPath);
875
+ const resolvedPath = await this.resolveNamespaceFor(namespacePath);
876
+ const entry = await this.guard(resolvedPath, () => this.statAt(resolvedPath));
877
+ return {
878
+ ...entry,
879
+ path: this.toRelativePath(namespacePath),
880
+ };
881
+ }
882
+ }