@stowage/adapter-fs 0.0.0 → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Kaufmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @stowage/adapter-fs
2
+
3
+ A storage rooted in one directory of the local file system.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @stowage/adapter-fs
9
+ ```
10
+
11
+ ## Example
12
+
13
+ ```ts
14
+ import { fsStorage } from "@stowage/adapter-fs";
15
+
16
+ const storage = fsStorage({ root: "/var/lib/my-app/storage" });
17
+
18
+ await storage.put("reports/2026/q3.csv", "region,revenue\n");
19
+
20
+ for await (const entry of storage.list({ prefix: "reports/" })) {
21
+ console.log(entry.key, entry.size, entry.lastModified);
22
+ }
23
+ ```
24
+
25
+ ## Runtimes
26
+
27
+ Node 24 and later, Bun and Deno, on Linux and macOS. `workerd` and Windows are not promised. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
28
+
29
+ The bundle measures 5.7 kB minified and gzipped, `@stowage/core` included.
30
+
31
+ ## Limits
32
+
33
+ - `keyBytesPreserved` is not declared: a key comes back Unicode-equivalent to what was written
34
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
35
+ - `presignedUrls` is not declared: `FsStorage` has neither `presignGet` nor `presignPut`
36
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
37
+ - `userMetadata` is not declared: `put` with a non-empty `userMetadata` is `Unsupported`, and reads
38
+ return `{}`
39
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
40
+ - A key segment longer than 255 bytes is `InvalidKey`, and so is a key whose whole path is longer
41
+ than the file system holds. macOS bounds one path at 1024 bytes with the root counted in
42
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)).
43
+ - APFS keeps a name in the Unicode form it was written in, and folds the forms when it looks a name
44
+ up, so a key in NFD reaches the object its NFC form wrote. A case-insensitive file system collides
45
+ keys that differ in case alone. Nothing repairs either
46
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)).
47
+ - The content type is derived from the key's extension, and is `application/octet-stream` where the
48
+ extension is unknown or absent. The `contentType` handed to `put` is not stored, so `stat` may
49
+ report another one
50
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)).
51
+ - No `etag` is set
52
+ ([spec 4.4](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#44-object-descriptions)).
53
+
54
+ ## Notes
55
+
56
+ `put` takes no `Blob`. A caller holding one passes its stream
57
+ ([spec 4.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#42-bodies)):
58
+
59
+ ```ts
60
+ import { fsStorage } from "@stowage/adapter-fs";
61
+
62
+ const storage = fsStorage({ root: "/var/lib/my-app/storage" });
63
+ const blob = new Blob(["region,revenue\n"], { type: "text/csv" });
64
+
65
+ await storage.put("reports/2026/q4.csv", blob.stream());
66
+ ```
67
+
68
+ stowage reports no progress
69
+ ([spec 11](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#11-non-goals)).
70
+ A caller who wants it counts the bytes on their way into `put`:
71
+
72
+ ```ts
73
+ import { fsStorage } from "@stowage/adapter-fs";
74
+
75
+ const storage = fsStorage({ root: "/var/lib/my-app/storage" });
76
+
77
+ function countBytes(report: (bytes: number) => void): TransformStream<Uint8Array, Uint8Array> {
78
+ let bytes = 0;
79
+
80
+ return new TransformStream({
81
+ transform(chunk, controller) {
82
+ bytes += chunk.byteLength;
83
+ report(bytes);
84
+ controller.enqueue(chunk);
85
+ },
86
+ });
87
+ }
88
+
89
+ const response = await fetch("https://example.com/video.mp4");
90
+
91
+ if (response.body === null) throw new Error("The response carries no body");
92
+
93
+ await storage.put("videos/intro.mp4", response.body.pipeThrough(countBytes(console.log)));
94
+ ```
95
+
96
+ ## Specification
97
+
98
+ [`docs/spec.md` at `@stowage/adapter-fs@0.1.0`](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#6-stowageadapter-fs)
99
+ is the contract: a caller may rely on what it states and on nothing else this package happens to
100
+ export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/CONTEXT.md)
101
+ and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/adapter-fs@0.1.0/docs/adr)
102
+ are at the same tag.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,14 @@
1
+ import { Storage } from "@stowage/core";
2
+ //#region src/index.d.ts
3
+ export interface FsAdapterOptions {
4
+ /**
5
+ * An absolute path to an existing directory. Construction performs no I/O, so an operation
6
+ * against a root that does not exist rejects with `NotFound`.
7
+ */
8
+ root: string;
9
+ }
10
+ export interface FsStorage extends Storage {
11
+ readonly provider: "fs";
12
+ }
13
+ export declare function fsStorage(options: FsAdapterOptions): FsStorage;
14
+ //#endregion
package/dist/index.js ADDED
@@ -0,0 +1,1111 @@
1
+ import { copyFile, lstat, mkdir, open, readdir, realpath, rename, rmdir, stat, unlink } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
3
+ import { StorageError, invalidKeyReason, isStorageError } from "@stowage/core";
4
+ //#region src/body.ts
5
+ const utf8$1 = new TextEncoder();
6
+ /**
7
+ * Writes the body into the open file. A stream goes chunk by chunk, so that an upload of
8
+ * any size costs one chunk of memory rather than the object.
9
+ */
10
+ async function writeBody(handle, body, signal) {
11
+ if (isStream(body)) {
12
+ await body.pipeTo(new WritableStream({ async write(chunk) {
13
+ await writeAll(handle, chunk);
14
+ } }), { signal });
15
+ return;
16
+ }
17
+ signal?.throwIfAborted();
18
+ await writeAll(handle, typeof body === "string" ? utf8$1.encode(body) : body);
19
+ }
20
+ /** Spec 4.2 leaves a stream at its end or canceled once `put` settled, read or not. */
21
+ async function cancelBody(body, reason) {
22
+ if (!isStream(body) || body.locked) return;
23
+ await body.cancel(reason).catch(() => {});
24
+ }
25
+ function isStream(body) {
26
+ return typeof body !== "string" && !(body instanceof Uint8Array);
27
+ }
28
+ async function writeAll(handle, bytes) {
29
+ for (let written = 0; written < bytes.byteLength;) {
30
+ const { bytesWritten } = await handle.write(bytes, written, bytes.byteLength - written);
31
+ written += bytesWritten;
32
+ }
33
+ }
34
+ //#endregion
35
+ //#region src/content-type.ts
36
+ const defaultContentType = "application/octet-stream";
37
+ /**
38
+ * The table spec 6 has the adapter derive a content type from, kept to the types a file
39
+ * below a root is likely to carry: nothing here reads the bytes, and no dependency comes
40
+ * into a package that promises a runtime dependency outside `@stowage` for nothing.
41
+ */
42
+ const contentTypes = /* @__PURE__ */ new Map([
43
+ ["avif", "image/avif"],
44
+ ["css", "text/css"],
45
+ ["csv", "text/csv"],
46
+ ["gif", "image/gif"],
47
+ ["gz", "application/gzip"],
48
+ ["htm", "text/html"],
49
+ ["html", "text/html"],
50
+ ["ico", "image/vnd.microsoft.icon"],
51
+ ["jpeg", "image/jpeg"],
52
+ ["jpg", "image/jpeg"],
53
+ ["js", "text/javascript"],
54
+ ["json", "application/json"],
55
+ ["md", "text/markdown"],
56
+ ["mjs", "text/javascript"],
57
+ ["mp3", "audio/mpeg"],
58
+ ["mp4", "video/mp4"],
59
+ ["otf", "font/otf"],
60
+ ["pdf", "application/pdf"],
61
+ ["png", "image/png"],
62
+ ["svg", "image/svg+xml"],
63
+ ["tar", "application/x-tar"],
64
+ ["ttf", "font/ttf"],
65
+ ["txt", "text/plain"],
66
+ ["wasm", "application/wasm"],
67
+ ["wav", "audio/wav"],
68
+ ["webm", "video/webm"],
69
+ ["webp", "image/webp"],
70
+ ["woff", "font/woff"],
71
+ ["woff2", "font/woff2"],
72
+ ["xml", "application/xml"],
73
+ ["yaml", "application/yaml"],
74
+ ["yml", "application/yaml"],
75
+ ["zip", "application/zip"]
76
+ ]);
77
+ /** The type the key's extension names, and `application/octet-stream` for every other key. */
78
+ function contentTypeOf(key) {
79
+ const name = key.slice(key.lastIndexOf("/") + 1);
80
+ const dot = name.lastIndexOf(".");
81
+ if (dot < 1) return defaultContentType;
82
+ return contentTypes.get(name.slice(dot + 1).toLowerCase()) ?? "application/octet-stream";
83
+ }
84
+ //#endregion
85
+ //#region src/cursor.ts
86
+ const cursorTag = "stowage-fs-1:";
87
+ const unitDigits = 4;
88
+ const hexPosition = new RegExp(`^(?:[\\da-f]{${unitDigits}})*$`);
89
+ /** The key a page ended on, as the opaque string that continues from there. */
90
+ function encodeCursor(after) {
91
+ return btoa(`${cursorTag}${hexOf(after)}`);
92
+ }
93
+ /** The key the cursor continues from, or `undefined` for one this adapter did not produce. */
94
+ function decodeCursor(cursor) {
95
+ let decoded;
96
+ try {
97
+ decoded = atob(cursor);
98
+ } catch {
99
+ return;
100
+ }
101
+ if (!decoded.startsWith(cursorTag)) return void 0;
102
+ const position = decoded.slice(13);
103
+ return position.length > 0 && hexPosition.test(position) ? keyOf(position) : void 0;
104
+ }
105
+ function hexOf(key) {
106
+ const units = [];
107
+ for (let index = 0; index < key.length; index += 1) units.push(key.charCodeAt(index).toString(16).padStart(unitDigits, "0"));
108
+ return units.join("");
109
+ }
110
+ function keyOf(position) {
111
+ const units = [];
112
+ for (let index = 0; index < position.length; index += unitDigits) units.push(String.fromCharCode(Number.parseInt(position.slice(index, index + unitDigits), 16)));
113
+ return units.join("");
114
+ }
115
+ //#endregion
116
+ //#region src/storage-error.ts
117
+ /** `bucket` is the root as the storage was given it, which spec 6 names as the bucket. */
118
+ function fsError(root, fields) {
119
+ return new StorageError({
120
+ ...fields,
121
+ bucket: root,
122
+ provider: "fs"
123
+ });
124
+ }
125
+ //#endregion
126
+ //#region src/temporary.ts
127
+ const temporaryName = /^\.stowage-[\da-f-]{36}\.tmp$/;
128
+ /** Where the bytes of a write land before the rename that puts them under the key. */
129
+ function temporaryPathIn(directory) {
130
+ return join(directory, `.stowage-${crypto.randomUUID()}.tmp`);
131
+ }
132
+ function isTemporaryName(name) {
133
+ return temporaryName.test(name);
134
+ }
135
+ //#endregion
136
+ //#region src/key.ts
137
+ /** What a file system takes as one name, which spec 6 has the adapter refuse beyond. */
138
+ const segmentByteLimit = 255;
139
+ const utf8 = new TextEncoder();
140
+ function requireKey(root, key, rule, operation) {
141
+ const reason = invalidKeyReason(key, rule) ?? temporaryNameReason(key, rule) ?? longSegmentReason(key);
142
+ if (reason === void 0) return;
143
+ throw fsError(root, {
144
+ code: "InvalidKey",
145
+ message: `The key ${JSON.stringify(key)} ${reason}`,
146
+ operation,
147
+ key,
148
+ attempts: 0
149
+ });
150
+ }
151
+ function temporaryNameReason(key, rule) {
152
+ if (rule !== "writable" || !isTemporaryName(key.slice(key.lastIndexOf("/") + 1))) return;
153
+ return "uses the reserved .stowage temporary-file name";
154
+ }
155
+ function longSegmentReason(key) {
156
+ for (const segment of key.split("/")) {
157
+ const bytes = utf8.encode(segment).length;
158
+ if (bytes > segmentByteLimit) return `holds a segment of ${bytes} UTF-8 bytes, above the limit of ${segmentByteLimit}`;
159
+ }
160
+ }
161
+ //#endregion
162
+ //#region src/options.ts
163
+ const operationOptionKeys = ["signal"];
164
+ const putOptionKeys = [
165
+ ...operationOptionKeys,
166
+ "contentType",
167
+ "userMetadata"
168
+ ];
169
+ const getOptionKeys = [...operationOptionKeys, "range"];
170
+ const listOptionKeys = [
171
+ ...operationOptionKeys,
172
+ "prefix",
173
+ "delimiter",
174
+ "pageSize",
175
+ "cursor"
176
+ ];
177
+ const adapterOptionKeys = ["root"];
178
+ /**
179
+ * Refuses an option key the spec does not list (spec 4.3), in a call as well as in the
180
+ * configuration a storage is constructed from. TypeScript catches one at the call site;
181
+ * this catches the rest.
182
+ */
183
+ function requireKnownOptions(root, options, known, operation) {
184
+ if (options === void 0) return;
185
+ for (const key of Object.keys(options)) {
186
+ if (known.includes(key)) continue;
187
+ throw optionError(root, key, "is not one this storage takes", operation);
188
+ }
189
+ }
190
+ function optionError(root, option, expectation, operation) {
191
+ return fsError(root, {
192
+ code: "InvalidOption",
193
+ message: `The option \`${option}\` ${expectation}`,
194
+ operation,
195
+ attempts: 0
196
+ });
197
+ }
198
+ //#endregion
199
+ //#region src/listing.ts
200
+ const defaultPageSize = 1e3;
201
+ const maxPageSize = 1e3;
202
+ /** `objectsBelow` walks the tree below the prefix and hands it over sorted by key. */
203
+ function createListing(root, objectsBelow, options) {
204
+ return {
205
+ async page() {
206
+ const request = read(root, options);
207
+ options?.signal?.throwIfAborted();
208
+ const slice = take(await objectsBelow(request.prefix), request);
209
+ return {
210
+ objects: slice.objects,
211
+ prefixes: slice.prefixes,
212
+ cursor: slice.after === void 0 ? void 0 : encodeCursor(slice.after)
213
+ };
214
+ },
215
+ async *[Symbol.asyncIterator]() {
216
+ const request = read(root, options);
217
+ options?.signal?.throwIfAborted();
218
+ const entries = await objectsBelow(request.prefix);
219
+ for (let after = request.after;;) {
220
+ options?.signal?.throwIfAborted();
221
+ const slice = take(entries, {
222
+ ...request,
223
+ after
224
+ });
225
+ yield* slice.objects;
226
+ if (slice.after === void 0) return;
227
+ after = slice.after;
228
+ }
229
+ }
230
+ };
231
+ }
232
+ function read(root, options) {
233
+ requireKnownOptions(root, options, listOptionKeys, "list");
234
+ const prefix = options?.prefix ?? "";
235
+ requireKey(root, prefix, "prefix", "list");
236
+ const pageSize = options?.pageSize ?? defaultPageSize;
237
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > maxPageSize) throw optionError(root, "pageSize", `takes a whole number from 1 to ${maxPageSize}`, "list");
238
+ if (options?.delimiter === "") throw optionError(root, "delimiter", "takes at least one character", "list");
239
+ const after = options?.cursor === void 0 ? void 0 : decodeCursor(options.cursor);
240
+ if (options?.cursor !== void 0 && after === void 0) throw optionError(root, "cursor", "takes a cursor this storage handed out", "list");
241
+ return {
242
+ prefix,
243
+ delimiter: options?.delimiter,
244
+ pageSize,
245
+ after
246
+ };
247
+ }
248
+ function take(entries, request) {
249
+ const objects = [];
250
+ const prefixes = [];
251
+ let after;
252
+ for (const entry of entries) {
253
+ if (request.after !== void 0 && entry.key <= request.after) continue;
254
+ const pseudoDirectory = pseudoDirectoryOf(entry.key, request);
255
+ if (pseudoDirectory !== void 0 && prefixes.at(-1) === pseudoDirectory) {
256
+ after = entry.key;
257
+ continue;
258
+ }
259
+ if (objects.length + prefixes.length === request.pageSize) return {
260
+ objects,
261
+ prefixes,
262
+ after
263
+ };
264
+ if (pseudoDirectory === void 0) objects.push(entry);
265
+ else prefixes.push(pseudoDirectory);
266
+ after = entry.key;
267
+ }
268
+ return {
269
+ objects,
270
+ prefixes
271
+ };
272
+ }
273
+ function pseudoDirectoryOf(key, { prefix, delimiter }) {
274
+ if (delimiter === void 0) return void 0;
275
+ const end = key.indexOf(delimiter, prefix.length);
276
+ return end === -1 ? void 0 : key.slice(0, end + delimiter.length);
277
+ }
278
+ //#endregion
279
+ //#region src/errno.ts
280
+ const errnoMappings = /* @__PURE__ */ new Map([
281
+ ["ENOENT", { code: "NotFound" }],
282
+ ["EISDIR", {
283
+ code: "NotFound",
284
+ onWrite: "InvalidRequest"
285
+ }],
286
+ ["ENOTDIR", {
287
+ code: "NotFound",
288
+ onWrite: "InvalidRequest"
289
+ }],
290
+ ["EACCES", { code: "AccessDenied" }],
291
+ ["EPERM", { code: "AccessDenied" }],
292
+ ["ENAMETOOLONG", { code: "InvalidKey" }],
293
+ ["EMFILE", {
294
+ code: "ProviderError",
295
+ retryable: true
296
+ }],
297
+ ["EBUSY", {
298
+ code: "ProviderError",
299
+ retryable: true
300
+ }],
301
+ ["EAGAIN", {
302
+ code: "ProviderError",
303
+ retryable: true
304
+ }]
305
+ ]);
306
+ /**
307
+ * What the file system threw, as the error spec 6 names for it. The adapter repeats
308
+ * nothing, so the count is the one attempt the syscall made, and `retryable` says that
309
+ * the condition may be gone rather than that anything was sent twice (spec 4.10).
310
+ */
311
+ function fsErrorFrom(thrown, failure) {
312
+ const errno = errnoOf(thrown);
313
+ const mapping = errno === void 0 ? void 0 : errnoMappings.get(errno);
314
+ const onWrite = failure.access === "write" ? mapping?.onWrite : void 0;
315
+ return fsError(failure.root, {
316
+ code: onWrite ?? mapping?.code ?? "ProviderError",
317
+ message: messageOf(thrown),
318
+ operation: failure.operation,
319
+ key: failure.key,
320
+ attempts: 1,
321
+ retryable: mapping?.retryable ?? false,
322
+ providerCode: errno,
323
+ cause: thrown
324
+ });
325
+ }
326
+ /** The `errno` string of a failed syscall, which travels on as the provider code. */
327
+ function errnoOf(thrown) {
328
+ if (typeof thrown !== "object" || thrown === null) return void 0;
329
+ const code = Reflect.get(thrown, "code");
330
+ return typeof code === "string" ? code : void 0;
331
+ }
332
+ /** The key names nothing this storage holds, which is what an absent file amounts to. */
333
+ function isAbsence(thrown) {
334
+ const errno = errnoOf(thrown);
335
+ return errno === "ENOENT" || errno === "ENOTDIR";
336
+ }
337
+ function messageOf(thrown) {
338
+ return thrown instanceof Error ? thrown.message : String(thrown);
339
+ }
340
+ /**
341
+ * What a failed step reaches the caller as: the runtime's `AbortError` and a refusal this
342
+ * adapter already shaped travel as they are, and everything else is read off the table
343
+ * above (spec 4.10).
344
+ */
345
+ function asFailure(thrown, failure) {
346
+ if (isStorageError(thrown) || nameOf(thrown) === "AbortError") return thrown;
347
+ return fsErrorFrom(thrown, failure);
348
+ }
349
+ function nameOf(thrown) {
350
+ if (typeof thrown !== "object" || thrown === null) return void 0;
351
+ const name = Reflect.get(thrown, "name");
352
+ return typeof name === "string" ? name : void 0;
353
+ }
354
+ //#endregion
355
+ //#region src/paths.ts
356
+ /**
357
+ * The root as the file system resolves it, which every path an operation reaches is held
358
+ * against. It is read afresh per operation: construction performs no I/O, and a root
359
+ * that is gone by the time it is used is `NotFound` rather than a path of its own (spec 6).
360
+ */
361
+ async function resolveRoot(root, operation, access) {
362
+ try {
363
+ return await realpath(root);
364
+ } catch (thrown) {
365
+ throw fsErrorFrom(thrown, {
366
+ root,
367
+ operation,
368
+ access
369
+ });
370
+ }
371
+ }
372
+ /** The path the key names below the root, or `undefined` where no file can carry it. */
373
+ function pathOf(realRoot, key) {
374
+ const segments = key.split("/");
375
+ if (segments.at(-1) === "") return void 0;
376
+ return join(realRoot, ...segments);
377
+ }
378
+ function within(realRoot, path) {
379
+ return path === realRoot || path.startsWith(`${realRoot}${sep}`);
380
+ }
381
+ /**
382
+ * The key names nothing this storage holds, which is what a read of it answers. The count
383
+ * is what it cost to find that out: one lookup, or none where the key names no file at
384
+ * all (spec 4.10).
385
+ */
386
+ function absent(context, attempts) {
387
+ return fsError(context.root, {
388
+ code: "NotFound",
389
+ message: `No object under the key ${JSON.stringify(context.key)}`,
390
+ operation: context.operation,
391
+ key: context.key,
392
+ attempts
393
+ });
394
+ }
395
+ /**
396
+ * The real path of the file the key names. Spec 6 has every access resolve it and answer
397
+ * `NotFound` where it lies outside the root, so a symbolic link leaving the root behaves
398
+ * as an absent object rather than as a way out of the storage.
399
+ */
400
+ async function resolveObject(context) {
401
+ const path = pathOf(context.realRoot, context.key);
402
+ if (path === void 0) throw absent(context, 0);
403
+ let resolved;
404
+ try {
405
+ resolved = await realpath(path);
406
+ } catch (thrown) {
407
+ throw fsErrorFrom(thrown, {
408
+ ...context,
409
+ access: "read"
410
+ });
411
+ }
412
+ if (!within(context.realRoot, resolved)) throw absent(context, 1);
413
+ return resolved;
414
+ }
415
+ /**
416
+ * The path a write lands under, with the directories above it in place. The nearest
417
+ * existing directory is resolved before anything is created, so a link leaving the root
418
+ * cannot cause this storage to create directories outside it.
419
+ */
420
+ async function prepareWrite(context) {
421
+ const path = pathOf(context.realRoot, context.key);
422
+ if (path === void 0) throw absent(context, 0);
423
+ const directory = await makeDirectory(dirname(path), context);
424
+ const target = join(directory, basename(path));
425
+ if (!await resolvesWithin(context, target)) throw absent(context, 1);
426
+ return target;
427
+ }
428
+ async function resolvesWithin(context, path) {
429
+ try {
430
+ return within(context.realRoot, await realpath(path));
431
+ } catch (thrown) {
432
+ if (isAbsence(thrown)) return true;
433
+ throw fsErrorFrom(thrown, {
434
+ ...context,
435
+ access: "write"
436
+ });
437
+ }
438
+ }
439
+ async function makeDirectory(directory, context) {
440
+ const missing = [];
441
+ let existing = directory;
442
+ for (;;) try {
443
+ existing = await realpath(existing);
444
+ break;
445
+ } catch (thrown) {
446
+ if (!isAbsence(thrown)) throw fsErrorFrom(thrown, {
447
+ ...context,
448
+ access: "write"
449
+ });
450
+ missing.unshift(basename(existing));
451
+ existing = dirname(existing);
452
+ }
453
+ if (!within(context.realRoot, existing)) throw absent(context, 1);
454
+ for (const segment of missing) {
455
+ const path = join(existing, segment);
456
+ try {
457
+ await mkdir(path);
458
+ } catch (thrown) {
459
+ if (errnoOf(thrown) !== "EEXIST") throw fsErrorFrom(thrown, {
460
+ ...context,
461
+ access: "write"
462
+ });
463
+ }
464
+ try {
465
+ existing = await realpath(path);
466
+ } catch (thrown) {
467
+ throw fsErrorFrom(thrown, {
468
+ ...context,
469
+ access: "write"
470
+ });
471
+ }
472
+ if (!within(context.realRoot, existing)) throw absent(context, 1);
473
+ }
474
+ return existing;
475
+ }
476
+ //#endregion
477
+ //#region src/object-file.ts
478
+ const directoryMutationLocks = /* @__PURE__ */ new Map();
479
+ const doNothing = () => {};
480
+ /**
481
+ * The file below the key, or `undefined` where the key names no object of this storage:
482
+ * nothing at all, a directory, or a path that leaves the root (spec 6). What a listing
483
+ * passes over is what these operations find nothing under.
484
+ */
485
+ async function findObjectFile(context) {
486
+ const release = await holdDirectoryMutations(context.realRoot);
487
+ try {
488
+ const path = pathOf(context.realRoot, context.key);
489
+ if (path === void 0) {
490
+ release();
491
+ return;
492
+ }
493
+ const directory = await resolveWithin(context, dirname(path));
494
+ if (directory === void 0) {
495
+ release();
496
+ return;
497
+ }
498
+ const target = join(directory, basename(path));
499
+ const held = await describe$2(context, target);
500
+ if (held?.isFile() !== true) {
501
+ release();
502
+ return;
503
+ }
504
+ return {
505
+ path: target,
506
+ stats: held,
507
+ release
508
+ };
509
+ } catch (thrown) {
510
+ release();
511
+ throw thrown;
512
+ }
513
+ }
514
+ /** Releases the parent-directory lease after a read-only use of the found object. */
515
+ function releaseObjectFile(file) {
516
+ file.release();
517
+ }
518
+ /**
519
+ * Removes the file and the directories it leaves empty behind it, so that a listing with
520
+ * a delimiter names no pseudo-directory that holds nothing (spec 6).
521
+ */
522
+ async function removeObjectFile(context, file) {
523
+ try {
524
+ try {
525
+ await unlink(file.path);
526
+ } catch (thrown) {
527
+ if (!isAbsence(thrown)) throw fsErrorFrom(thrown, {
528
+ ...context,
529
+ access: "write"
530
+ });
531
+ }
532
+ await pruneEmptyDirectories(context.realRoot, dirname(file.path));
533
+ } finally {
534
+ file.release();
535
+ }
536
+ }
537
+ /**
538
+ * Moves the file to the path, which is the copy and the delete of spec 4.11 as one file
539
+ * system performs them, and removes what the source left empty behind it.
540
+ */
541
+ async function renameObjectFile(context, file, path) {
542
+ try {
543
+ try {
544
+ await rename(file.path, path);
545
+ } catch (thrown) {
546
+ throw fsErrorFrom(thrown, {
547
+ ...context,
548
+ access: "write"
549
+ });
550
+ }
551
+ await pruneEmptyDirectories(context.realRoot, dirname(file.path));
552
+ } finally {
553
+ file.release();
554
+ }
555
+ }
556
+ async function holdDirectoryMutations(realRoot) {
557
+ const lock = directoryMutationLocks.get(realRoot) ?? {
558
+ tail: Promise.resolve(),
559
+ leases: 0
560
+ };
561
+ const previous = lock.tail;
562
+ let releaseNext = doNothing;
563
+ lock.tail = new Promise((resolve) => {
564
+ releaseNext = resolve;
565
+ });
566
+ lock.leases += 1;
567
+ directoryMutationLocks.set(realRoot, lock);
568
+ await previous;
569
+ let held = true;
570
+ return () => {
571
+ if (!held) return;
572
+ held = false;
573
+ lock.leases -= 1;
574
+ releaseNext();
575
+ if (lock.leases === 0 && directoryMutationLocks.get(realRoot) === lock) directoryMutationLocks.delete(realRoot);
576
+ };
577
+ }
578
+ async function pruneEmptyDirectories(realRoot, directory) {
579
+ for (let current = directory; current !== realRoot && within(realRoot, current); current = dirname(current)) try {
580
+ await rmdir(current);
581
+ } catch {
582
+ return;
583
+ }
584
+ }
585
+ /** What the path holds, or `undefined` where nothing is there or a link leaves the root. */
586
+ async function describe$2(context, path) {
587
+ const held = await lookUp(context, async () => await lstat(path));
588
+ if (held === void 0 || !held.isSymbolicLink()) return held;
589
+ const resolved = await resolveWithin(context, path);
590
+ return resolved === void 0 ? void 0 : await lookUp(context, async () => await stat(resolved));
591
+ }
592
+ /** The path as the file system resolves it, where that lies below the root (spec 6). */
593
+ async function resolveWithin(context, path) {
594
+ const resolved = await lookUp(context, async () => await realpath(path));
595
+ return resolved !== void 0 && within(context.realRoot, resolved) ? resolved : void 0;
596
+ }
597
+ /** A lookup whose answer for a path that is not there is that nothing is there. */
598
+ async function lookUp(context, read) {
599
+ try {
600
+ return await read();
601
+ } catch (thrown) {
602
+ if (isAbsence(thrown)) return void 0;
603
+ throw fsErrorFrom(thrown, {
604
+ ...context,
605
+ access: "write"
606
+ });
607
+ }
608
+ }
609
+ //#endregion
610
+ //#region src/range.ts
611
+ /** Refuses bounds the spec does not allow, before the file is opened (spec 4.3). */
612
+ function requireRange(root, range) {
613
+ if (range === void 0) return;
614
+ const { start, end } = range;
615
+ if (!isOffset(start) || end !== void 0 && (!isOffset(end) || end < start)) throw optionError(root, "range", "takes two whole numbers from zero up, `start` at most `end`", "get");
616
+ }
617
+ /** The last byte the range names, both ends inclusive, clipped to what the file holds. */
618
+ function lastByteOf(root, range, size, key) {
619
+ if (range === void 0) return size - 1;
620
+ if (range.start >= size) throw fsError(root, {
621
+ code: "InvalidRequest",
622
+ message: `The range starts beyond the ${size} bytes under the key ${JSON.stringify(key)}`,
623
+ operation: "get",
624
+ key,
625
+ attempts: 1
626
+ });
627
+ return Math.min(range.end ?? size - 1, size - 1);
628
+ }
629
+ function isOffset(value) {
630
+ return Number.isInteger(value) && value >= 0;
631
+ }
632
+ //#endregion
633
+ //#region src/stored-object.ts
634
+ const chunkSize = 65536;
635
+ /**
636
+ * The object `get` hands back. The file is opened where a reader is taken rather than
637
+ * where the object was described, so a caller reading the description alone holds no
638
+ * open file — the rename `put` lands through is what keeps that reader off a half-written
639
+ * object (spec 6).
640
+ */
641
+ function createStoredObject(context, stat, body) {
642
+ let read = false;
643
+ const take = () => {
644
+ if (read) throw fsError(context.root, {
645
+ code: "InvalidRequest",
646
+ message: "The body of this stored object has already been read",
647
+ operation: context.operation,
648
+ key: context.key,
649
+ attempts: 0
650
+ });
651
+ read = true;
652
+ return body;
653
+ };
654
+ const fail = (thrown) => asFailure(thrown, {
655
+ ...context,
656
+ access: "read"
657
+ });
658
+ const decoder = new TextDecoder();
659
+ return {
660
+ stat,
661
+ stream() {
662
+ let taken;
663
+ try {
664
+ taken = take();
665
+ } catch (error) {
666
+ return new ReadableStream({ start(controller) {
667
+ controller.error(error);
668
+ } });
669
+ }
670
+ return readStream(taken, fail);
671
+ },
672
+ async bytes() {
673
+ return await readAll(take(), fail);
674
+ },
675
+ async text() {
676
+ return decoder.decode(await readAll(take(), fail));
677
+ },
678
+ async json() {
679
+ return JSON.parse(decoder.decode(await readAll(take(), fail)));
680
+ }
681
+ };
682
+ }
683
+ function readStream(body, fail) {
684
+ let handle;
685
+ let position = body.start;
686
+ return new ReadableStream({
687
+ async pull(controller) {
688
+ const left = body.end - position + 1;
689
+ if (left <= 0) {
690
+ await close(handle);
691
+ controller.close();
692
+ return;
693
+ }
694
+ const buffer = new Uint8Array(Math.min(left, chunkSize));
695
+ try {
696
+ handle ??= await openFile(body.path, fail);
697
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
698
+ if (bytesRead === 0) {
699
+ await close(handle);
700
+ controller.close();
701
+ return;
702
+ }
703
+ position += bytesRead;
704
+ controller.enqueue(buffer.subarray(0, bytesRead));
705
+ } catch (thrown) {
706
+ await close(handle);
707
+ throw fail(thrown);
708
+ }
709
+ },
710
+ async cancel() {
711
+ await close(handle);
712
+ }
713
+ });
714
+ }
715
+ async function readAll(body, fail) {
716
+ const size = Math.max(body.end - body.start + 1, 0);
717
+ const bytes = new Uint8Array(size);
718
+ const handle = await openFile(body.path, fail);
719
+ let read = 0;
720
+ try {
721
+ while (read < size) {
722
+ const { bytesRead } = await handle.read(bytes, read, size - read, body.start + read);
723
+ if (bytesRead === 0) break;
724
+ read += bytesRead;
725
+ }
726
+ } catch (thrown) {
727
+ throw fail(thrown);
728
+ } finally {
729
+ await close(handle);
730
+ }
731
+ return read === size ? bytes : bytes.subarray(0, read);
732
+ }
733
+ async function openFile(path, fail) {
734
+ try {
735
+ return await open(path, "r");
736
+ } catch (thrown) {
737
+ throw fail(thrown);
738
+ }
739
+ }
740
+ async function close(handle) {
741
+ await handle?.close().catch(() => {});
742
+ }
743
+ //#endregion
744
+ //#region src/walk.ts
745
+ /**
746
+ * Every object below the prefix, sorted by key. A listing pages through one order, so
747
+ * the snapshot it reads is sorted; the order itself is not promised (spec 4.6).
748
+ */
749
+ async function walkObjects(context, prefix) {
750
+ const entries = [];
751
+ const directory = prefix.slice(0, prefix.lastIndexOf("/") + 1);
752
+ await collect(context, entries, join(context.realRoot, ...directory.split("/")), directory);
753
+ return entries.filter((entry) => entry.key.startsWith(prefix)).toSorted(byKey);
754
+ }
755
+ async function collect(context, entries, directory, keyPrefix) {
756
+ let resolved;
757
+ let held;
758
+ try {
759
+ resolved = await realpath(directory);
760
+ if (!within(context.realRoot, resolved)) return;
761
+ held = await readdir(resolved, { withFileTypes: true });
762
+ } catch (thrown) {
763
+ if (isAbsence(thrown)) return;
764
+ throw failure(context, thrown);
765
+ }
766
+ for (const entry of held) {
767
+ const path = join(resolved, entry.name);
768
+ const key = `${keyPrefix}${entry.name}`;
769
+ if (entry.isDirectory()) {
770
+ await collect(context, entries, path, `${key}/`);
771
+ continue;
772
+ }
773
+ if (isTemporaryName(entry.name)) continue;
774
+ const described = await describe$1(context, path);
775
+ if (described !== void 0) entries.push({
776
+ key,
777
+ size: described.size,
778
+ lastModified: described.mtime
779
+ });
780
+ }
781
+ }
782
+ /**
783
+ * What the entry holds, or `undefined` for everything that is no object: a link leaving
784
+ * the root, a link to a directory, a socket, and a file another writer removed between
785
+ * the listing of the level and the reading of it.
786
+ */
787
+ async function describe$1(context, path) {
788
+ try {
789
+ const resolved = await realpath(path);
790
+ if (!within(context.realRoot, resolved)) return void 0;
791
+ const described = await stat(resolved);
792
+ return described.isFile() ? described : void 0;
793
+ } catch (thrown) {
794
+ if (isAbsence(thrown)) return void 0;
795
+ throw failure(context, thrown);
796
+ }
797
+ }
798
+ function failure(context, thrown) {
799
+ return fsErrorFrom(thrown, {
800
+ root: context.root,
801
+ operation: context.operation,
802
+ access: "read"
803
+ });
804
+ }
805
+ function byKey(one, other) {
806
+ return one.key < other.key ? -1 : one.key > other.key ? 1 : 0;
807
+ }
808
+ //#endregion
809
+ //#region src/index.ts
810
+ function fsStorage(options) {
811
+ return new FileSystemStorage(options);
812
+ }
813
+ const fsCapabilities = Object.freeze(["rangeReads"]);
814
+ /** A storage that does not declare `userMetadata` reads back none of it (spec 4.9). */
815
+ const noUserMetadata = Object.freeze({});
816
+ var FileSystemStorage = class {
817
+ provider = "fs";
818
+ bucket;
819
+ capabilities = fsCapabilities;
820
+ #root;
821
+ constructor(options) {
822
+ this.#root = readRoot(options);
823
+ this.bucket = this.#root;
824
+ }
825
+ async put(key, body, options) {
826
+ try {
827
+ return await this.#put(key, body, options);
828
+ } catch (failure) {
829
+ await cancelBody(body, failure);
830
+ throw failure;
831
+ }
832
+ }
833
+ async #put(key, body, options) {
834
+ requireKey(this.#root, key, "writable", "put");
835
+ requireKnownOptions(this.#root, options, putOptionKeys, "put");
836
+ this.#requireContentType(options?.contentType);
837
+ this.#requireNoUserMetadata(options?.userMetadata, key);
838
+ options?.signal?.throwIfAborted();
839
+ const context = await this.#context(key, "put", "write");
840
+ const path = await prepareWrite(context);
841
+ return await this.#write(context, path, body, options?.signal);
842
+ }
843
+ async #write(context, path, body, signal) {
844
+ return await this.#land(context, path, async (temporary) => {
845
+ const handle = await this.#openTemporary(context, temporary);
846
+ try {
847
+ await writeBody(handle, body, signal);
848
+ signal?.throwIfAborted();
849
+ const written = await handle.stat();
850
+ await handle.close();
851
+ return written;
852
+ } catch (thrown) {
853
+ await handle.close().catch(() => {});
854
+ throw thrown;
855
+ }
856
+ }, signal);
857
+ }
858
+ /**
859
+ * Spec 6: the bytes land in a file beside the object and are renamed into place, so a
860
+ * reader sees the object as it was or as it now is and never half of a write. A write
861
+ * that broke takes its file with it, so that no listing names what never became one.
862
+ */
863
+ async #land(context, path, fill, signal) {
864
+ const temporary = temporaryPathIn(dirname(path));
865
+ try {
866
+ const written = await fill(temporary);
867
+ signal?.throwIfAborted();
868
+ await rename(temporary, path);
869
+ return describe(context.key, written.size, written.mtime);
870
+ } catch (thrown) {
871
+ await unlink(temporary).catch(() => {});
872
+ throw asFailure(thrown, {
873
+ ...context,
874
+ access: "write"
875
+ });
876
+ }
877
+ }
878
+ async get(key, options) {
879
+ requireKey(this.#root, key, "addressable", "get");
880
+ requireKnownOptions(this.#root, options, getOptionKeys, "get");
881
+ requireRange(this.#root, options?.range);
882
+ options?.signal?.throwIfAborted();
883
+ const context = await this.#context(key, "get", "read");
884
+ const found = await this.#find(context);
885
+ return createStoredObject(context, found.description, {
886
+ path: found.path,
887
+ start: options?.range?.start ?? 0,
888
+ end: lastByteOf(this.#root, options?.range, found.description.size, key)
889
+ });
890
+ }
891
+ async stat(key, options) {
892
+ requireKey(this.#root, key, "addressable", "stat");
893
+ requireKnownOptions(this.#root, options, operationOptionKeys, "stat");
894
+ options?.signal?.throwIfAborted();
895
+ return (await this.#find(await this.#context(key, "stat", "read"))).description;
896
+ }
897
+ async exists(key, options) {
898
+ requireKey(this.#root, key, "addressable", "exists");
899
+ requireKnownOptions(this.#root, options, operationOptionKeys, "exists");
900
+ options?.signal?.throwIfAborted();
901
+ const context = await this.#context(key, "exists", "read");
902
+ try {
903
+ await this.#find(context);
904
+ return true;
905
+ } catch (thrown) {
906
+ if (isStorageError(thrown) && thrown.code === "NotFound") return false;
907
+ throw thrown;
908
+ }
909
+ }
910
+ list(options) {
911
+ return createListing(this.#root, async (prefix) => await walkObjects({
912
+ root: this.#root,
913
+ realRoot: await resolveRoot(this.#root, "list", "read"),
914
+ operation: "list"
915
+ }, prefix), options);
916
+ }
917
+ async delete(...keys) {
918
+ const context = {
919
+ root: this.#root,
920
+ realRoot: await resolveRoot(this.#root, "delete", "write"),
921
+ operation: "delete"
922
+ };
923
+ const failed = [];
924
+ for (const key of keys) {
925
+ const failure = await this.#remove({
926
+ ...context,
927
+ key
928
+ });
929
+ if (failure !== void 0) failed.push(failure);
930
+ }
931
+ return {
932
+ requested: keys.length,
933
+ failed
934
+ };
935
+ }
936
+ async deleteAll(prefix, options) {
937
+ requireKey(this.#root, prefix, "prefix", "deleteAll");
938
+ requireKnownOptions(this.#root, options, operationOptionKeys, "deleteAll");
939
+ options?.signal?.throwIfAborted();
940
+ const context = {
941
+ root: this.#root,
942
+ realRoot: await resolveRoot(this.#root, "deleteAll", "write"),
943
+ operation: "deleteAll"
944
+ };
945
+ const entries = await walkObjects(context, prefix);
946
+ const failed = [];
947
+ for (const entry of entries) {
948
+ options?.signal?.throwIfAborted();
949
+ const failure = await this.#remove({
950
+ ...context,
951
+ key: entry.key
952
+ });
953
+ if (failure !== void 0) failed.push(failure);
954
+ }
955
+ return {
956
+ requested: entries.length,
957
+ failed
958
+ };
959
+ }
960
+ async #remove(context) {
961
+ try {
962
+ requireKey(this.#root, context.key, "addressable", context.operation);
963
+ const file = await findObjectFile(context);
964
+ if (file !== void 0) await removeObjectFile(context, file);
965
+ return;
966
+ } catch (thrown) {
967
+ if (isStorageError(thrown)) return thrown;
968
+ throw thrown;
969
+ }
970
+ }
971
+ async copy(from, to, options) {
972
+ const ends = await this.#endpoints(from, to, "copy", options);
973
+ const source = await this.#requireFile(ends.from);
974
+ try {
975
+ const path = await prepareWrite(ends.to);
976
+ return await this.#land(ends.to, path, async (temporary) => {
977
+ await this.#read(ends.from, source.path, temporary);
978
+ return await stat(temporary);
979
+ }, options?.signal);
980
+ } finally {
981
+ releaseObjectFile(source);
982
+ }
983
+ }
984
+ /** Reads the source into the file the copy lands through, and names it in a failure. */
985
+ async #read(context, source, temporary) {
986
+ try {
987
+ await copyFile(source, temporary);
988
+ } catch (thrown) {
989
+ throw asFailure(thrown, {
990
+ ...context,
991
+ access: "read"
992
+ });
993
+ }
994
+ }
995
+ async move(from, to, options) {
996
+ const ends = await this.#endpoints(from, to, "move", options);
997
+ const source = await this.#requireFile(ends.from);
998
+ try {
999
+ const path = await prepareWrite(ends.to);
1000
+ options?.signal?.throwIfAborted();
1001
+ await renameObjectFile(ends.from, source, path);
1002
+ return describe(to, source.stats.size, source.stats.mtime);
1003
+ } finally {
1004
+ releaseObjectFile(source);
1005
+ }
1006
+ }
1007
+ /** Both ends of a `copy` or a `move`, once every rule of spec 4.11 has passed. */
1008
+ async #endpoints(from, to, operation, options) {
1009
+ requireKey(this.#root, from, "addressable", operation);
1010
+ requireKey(this.#root, to, "writable", operation);
1011
+ requireKnownOptions(this.#root, options, operationOptionKeys, operation);
1012
+ options?.signal?.throwIfAborted();
1013
+ if (from === to) throw fsError(this.#root, {
1014
+ code: "InvalidRequest",
1015
+ message: `The key ${JSON.stringify(from)} is both the source and the destination`,
1016
+ operation,
1017
+ key: from,
1018
+ attempts: 0
1019
+ });
1020
+ const realRoot = await resolveRoot(this.#root, operation, "write");
1021
+ return {
1022
+ from: {
1023
+ root: this.#root,
1024
+ realRoot,
1025
+ key: from,
1026
+ operation
1027
+ },
1028
+ to: {
1029
+ root: this.#root,
1030
+ realRoot,
1031
+ key: to,
1032
+ operation
1033
+ }
1034
+ };
1035
+ }
1036
+ /** The file the source key names, which `copy` and `move` owe a `NotFound` for. */
1037
+ async #requireFile(context) {
1038
+ const file = await findObjectFile(context);
1039
+ if (file !== void 0) return file;
1040
+ throw absent(context, context.key.endsWith("/") ? 0 : 1);
1041
+ }
1042
+ /** The file the key names, described as spec 4.4 has a read of one describe it. */
1043
+ async #find(context) {
1044
+ const path = await resolveObject(context);
1045
+ try {
1046
+ const described = await stat(path);
1047
+ if (!described.isFile()) throw absent(context, 1);
1048
+ return {
1049
+ path,
1050
+ description: describe(context.key, described.size, described.mtime)
1051
+ };
1052
+ } catch (thrown) {
1053
+ throw asFailure(thrown, {
1054
+ ...context,
1055
+ access: "read"
1056
+ });
1057
+ }
1058
+ }
1059
+ async #context(key, operation, access) {
1060
+ return {
1061
+ root: this.#root,
1062
+ realRoot: await resolveRoot(this.#root, operation, access),
1063
+ key,
1064
+ operation
1065
+ };
1066
+ }
1067
+ async #openTemporary(context, path) {
1068
+ try {
1069
+ return await open(path, "wx");
1070
+ } catch (thrown) {
1071
+ throw asFailure(thrown, {
1072
+ ...context,
1073
+ access: "write"
1074
+ });
1075
+ }
1076
+ }
1077
+ /** Spec 6 validates the type `put` was given and derives the stored one from the key. */
1078
+ #requireContentType(contentType) {
1079
+ if (contentType === void 0 || typeof contentType === "string") return;
1080
+ throw optionError(this.#root, "contentType", "takes a string", "put");
1081
+ }
1082
+ #requireNoUserMetadata(userMetadata, key) {
1083
+ if (userMetadata === void 0 || Object.keys(userMetadata).length === 0) return;
1084
+ throw fsError(this.#root, {
1085
+ code: "Unsupported",
1086
+ message: "This storage holds no user metadata",
1087
+ operation: "put",
1088
+ key,
1089
+ attempts: 0,
1090
+ capability: "userMetadata"
1091
+ });
1092
+ }
1093
+ };
1094
+ function readRoot(options) {
1095
+ const root = options.root;
1096
+ const named = typeof root === "string" ? root : "";
1097
+ requireKnownOptions(named, options, adapterOptionKeys, "fsStorage");
1098
+ if (typeof root !== "string" || !isAbsolute(root)) throw optionError(named, "root", "takes an absolute path to a directory", "fsStorage");
1099
+ return root;
1100
+ }
1101
+ function describe(key, size, lastModified) {
1102
+ return {
1103
+ key,
1104
+ size,
1105
+ lastModified,
1106
+ contentType: contentTypeOf(key),
1107
+ userMetadata: noUserMetadata
1108
+ };
1109
+ }
1110
+ //#endregion
1111
+ export { fsStorage };
package/package.json CHANGED
@@ -1 +1,41 @@
1
- {"name":"@stowage/adapter-fs","version":"0.0.0","description":"Placeholder that reserves the name for trusted publishing; the first release is 0.1.0.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/stowage-js/stowage.git","directory":"packages/adapter-fs"}}
1
+ {
2
+ "name": "@stowage/adapter-fs",
3
+ "version": "0.1.0",
4
+ "description": "stowage adapter rooting a storage in one directory of the local file system.",
5
+ "keywords": [
6
+ "adapter",
7
+ "filesystem",
8
+ "fs",
9
+ "object-storage",
10
+ "storage",
11
+ "stowage"
12
+ ],
13
+ "homepage": "https://github.com/stowage-js/stowage/tree/main/packages/adapter-fs#readme",
14
+ "bugs": "https://github.com/stowage-js/stowage/issues",
15
+ "license": "MIT",
16
+ "author": "Alexander Kaufmann",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/stowage-js/stowage.git",
20
+ "directory": "packages/adapter-fs"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": "./dist/index.js",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "dependencies": {
36
+ "@stowage/core": "^0.1.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=24"
40
+ }
41
+ }