@stowage/adapter-fs 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -12
  2. package/dist/index.js +252 -95
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -31,30 +31,37 @@ The bundle measures 5.7 kB minified and gzipped, `@stowage/core` included.
31
31
  ## Limits
32
32
 
33
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)).
34
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
35
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)).
36
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
37
37
  - `userMetadata` is not declared: `put` with a non-empty `userMetadata` is `Unsupported`, and reads
38
38
  return `{}`
39
- ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.1.0/docs/spec.md#49-capabilities)).
39
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
40
+ - `userMetadataTokenKeys` is not declared: without `userMetadata`, a key beyond identifiers is
41
+ `Unsupported` naming `userMetadata` like any other
42
+ ([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#49-capabilities)).
40
43
  - A key segment longer than 255 bytes is `InvalidKey`, and so is a key whose whole path is longer
41
44
  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)).
45
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
46
+ - A name the file system refuses to create is `InvalidKey` on `put` and on the destination of
47
+ `copy` and `move`. APFS refuses every noncharacter, so a key holding `U+FFFE` is written on
48
+ Linux and refused on macOS
49
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
43
50
  - APFS keeps a name in the Unicode form it was written in, and folds the forms when it looks a name
44
51
  up, so a key in NFD reaches the object its NFC form wrote. A case-insensitive file system collides
45
52
  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)).
53
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
47
54
  - The content type is derived from the key's extension, and is `application/octet-stream` where the
48
55
  extension is unknown or absent. The `contentType` handed to `put` is not stored, so `stat` may
49
56
  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)).
57
+ ([spec 6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)).
51
58
  - 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)).
59
+ ([spec 4.4](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#44-object-descriptions)).
53
60
 
54
61
  ## Notes
55
62
 
56
63
  `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)):
64
+ ([spec 4.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#42-bodies)):
58
65
 
59
66
  ```ts
60
67
  import { fsStorage } from "@stowage/adapter-fs";
@@ -66,7 +73,7 @@ await storage.put("reports/2026/q4.csv", blob.stream());
66
73
  ```
67
74
 
68
75
  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)).
76
+ ([spec 12](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#12-non-goals)).
70
77
  A caller who wants it counts the bytes on their way into `put`:
71
78
 
72
79
  ```ts
@@ -95,10 +102,10 @@ await storage.put("videos/intro.mp4", response.body.pipeThrough(countBytes(conso
95
102
 
96
103
  ## Specification
97
104
 
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)
105
+ [`docs/spec.md` at `@stowage/adapter-fs@0.2.0`](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/docs/spec.md#6-stowageadapter-fs)
99
106
  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)
107
+ export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/adapter-fs@0.2.0/CONTEXT.md)
108
+ and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/adapter-fs@0.2.0/docs/adr)
102
109
  are at the same tag.
103
110
 
104
111
  ## License
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { copyFile, lstat, mkdir, open, readdir, realpath, rename, rmdir, stat, unlink } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join, sep } from "node:path";
3
- import { StorageError, invalidKeyReason, isStorageError } from "@stowage/core";
3
+ import { StorageError, invalidKeyReason, isStorageError, lastByteOf, rangeBoundsRefusal, rangeStartRefusal } from "@stowage/core";
4
4
  //#region src/body.ts
5
- const utf8$1 = new TextEncoder();
5
+ const utf8$2 = new TextEncoder();
6
6
  /**
7
7
  * Writes the body into the open file. A stream goes chunk by chunk, so that an upload of
8
8
  * any size costs one chunk of memory rather than the object.
@@ -15,7 +15,7 @@ async function writeBody(handle, body, signal) {
15
15
  return;
16
16
  }
17
17
  signal?.throwIfAborted();
18
- await writeAll(handle, typeof body === "string" ? utf8$1.encode(body) : body);
18
+ await writeAll(handle, typeof body === "string" ? utf8$2.encode(body) : body);
19
19
  }
20
20
  /** Spec 4.2 leaves a stream at its end or canceled once `put` settled, read or not. */
21
21
  async function cancelBody(body, reason) {
@@ -82,6 +82,41 @@ function contentTypeOf(key) {
82
82
  return contentTypes.get(name.slice(dot + 1).toLowerCase()) ?? "application/octet-stream";
83
83
  }
84
84
  //#endregion
85
+ //#region src/directory-lock.ts
86
+ const directoryMutationLocks = /* @__PURE__ */ new Map();
87
+ const doNothing = () => {};
88
+ async function holdDirectoryMutations(realRoot) {
89
+ const lock = directoryMutationLocks.get(realRoot) ?? {
90
+ tail: Promise.resolve(),
91
+ leases: 0
92
+ };
93
+ const previous = lock.tail;
94
+ let releaseNext = doNothing;
95
+ lock.tail = new Promise((resolve) => {
96
+ releaseNext = resolve;
97
+ });
98
+ lock.leases += 1;
99
+ directoryMutationLocks.set(realRoot, lock);
100
+ await previous;
101
+ let held = true;
102
+ return () => {
103
+ if (!held) return;
104
+ held = false;
105
+ lock.leases -= 1;
106
+ releaseNext();
107
+ if (lock.leases === 0 && directoryMutationLocks.get(realRoot) === lock) directoryMutationLocks.delete(realRoot);
108
+ };
109
+ }
110
+ /** Runs the step with no directory below the root created or removed by anyone else. */
111
+ async function withDirectoryMutations(realRoot, step) {
112
+ const release = await holdDirectoryMutations(realRoot);
113
+ try {
114
+ return await step();
115
+ } finally {
116
+ release();
117
+ }
118
+ }
119
+ //#endregion
85
120
  //#region src/cursor.ts
86
121
  const cursorTag = "stowage-fs-1:";
87
122
  const unitDigits = 4;
@@ -136,7 +171,7 @@ function isTemporaryName(name) {
136
171
  //#region src/key.ts
137
172
  /** What a file system takes as one name, which spec 6 has the adapter refuse beyond. */
138
173
  const segmentByteLimit = 255;
139
- const utf8 = new TextEncoder();
174
+ const utf8$1 = new TextEncoder();
140
175
  function requireKey(root, key, rule, operation) {
141
176
  const reason = invalidKeyReason(key, rule) ?? temporaryNameReason(key, rule) ?? longSegmentReason(key);
142
177
  if (reason === void 0) return;
@@ -154,7 +189,7 @@ function temporaryNameReason(key, rule) {
154
189
  }
155
190
  function longSegmentReason(key) {
156
191
  for (const segment of key.split("/")) {
157
- const bytes = utf8.encode(segment).length;
192
+ const bytes = utf8$1.encode(segment).length;
158
193
  if (bytes > segmentByteLimit) return `holds a segment of ${bytes} UTF-8 bytes, above the limit of ${segmentByteLimit}`;
159
194
  }
160
195
  }
@@ -290,6 +325,10 @@ const errnoMappings = /* @__PURE__ */ new Map([
290
325
  ["EACCES", { code: "AccessDenied" }],
291
326
  ["EPERM", { code: "AccessDenied" }],
292
327
  ["ENAMETOOLONG", { code: "InvalidKey" }],
328
+ ["EILSEQ", {
329
+ code: "NotFound",
330
+ onWrite: "InvalidKey"
331
+ }],
293
332
  ["EMFILE", {
294
333
  code: "ProviderError",
295
334
  retryable: true
@@ -329,10 +368,17 @@ function errnoOf(thrown) {
329
368
  const code = Reflect.get(thrown, "code");
330
369
  return typeof code === "string" ? code : void 0;
331
370
  }
332
- /** The key names nothing this storage holds, which is what an absent file amounts to. */
371
+ /** The file system refuses to create the name, as APFS refuses a noncharacter (spec 6). */
372
+ function refusesName(thrown) {
373
+ return errnoOf(thrown) === "EILSEQ";
374
+ }
375
+ /**
376
+ * The key names nothing this storage holds, which is what an absent file amounts to, and
377
+ * so is a name a file system refuses on a lookup: no file can be held under it.
378
+ */
333
379
  function isAbsence(thrown) {
334
380
  const errno = errnoOf(thrown);
335
- return errno === "ENOENT" || errno === "ENOTDIR";
381
+ return errno === "ENOENT" || errno === "ENOTDIR" || errno === "EILSEQ";
336
382
  }
337
383
  function messageOf(thrown) {
338
384
  return thrown instanceof Error ? thrown.message : String(thrown);
@@ -415,15 +461,33 @@ async function resolveObject(context) {
415
461
  /**
416
462
  * The path a write lands under, with the directories above it in place. The nearest
417
463
  * existing directory is resolved before anything is created, so a link leaving the root
418
- * cannot cause this storage to create directories outside it.
464
+ * cannot cause this storage to create directories outside it. The caller holds the
465
+ * directory lock until the write's file is in place, since a prune may otherwise remove a
466
+ * directory this found in place before anything lies in it.
419
467
  */
420
468
  async function prepareWrite(context) {
421
469
  const path = pathOf(context.realRoot, context.key);
422
470
  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;
471
+ const { directory, created } = await makeDirectory(dirname(path), context);
472
+ const abandon = async () => await removeCreated(created);
473
+ try {
474
+ const target = join(directory, basename(path));
475
+ if (!await resolvesWithin(context, target)) throw absent(context, 1);
476
+ return {
477
+ path: target,
478
+ abandon
479
+ };
480
+ } catch (thrown) {
481
+ await abandon();
482
+ throw thrown;
483
+ }
484
+ }
485
+ async function removeCreated(created) {
486
+ for (const directory of created.toReversed()) try {
487
+ await rmdir(directory);
488
+ } catch {
489
+ return;
490
+ }
427
491
  }
428
492
  async function resolvesWithin(context, path) {
429
493
  try {
@@ -451,32 +515,40 @@ async function makeDirectory(directory, context) {
451
515
  existing = dirname(existing);
452
516
  }
453
517
  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
- });
518
+ const created = [];
519
+ try {
520
+ for (const segment of missing) {
521
+ const path = join(existing, segment);
522
+ try {
523
+ await mkdir(path);
524
+ created.push(path);
525
+ } catch (thrown) {
526
+ if (errnoOf(thrown) !== "EEXIST") throw fsErrorFrom(thrown, {
527
+ ...context,
528
+ access: "write"
529
+ });
530
+ }
531
+ try {
532
+ existing = await realpath(path);
533
+ } catch (thrown) {
534
+ throw fsErrorFrom(thrown, {
535
+ ...context,
536
+ access: "write"
537
+ });
538
+ }
539
+ if (!within(context.realRoot, existing)) throw absent(context, 1);
471
540
  }
472
- if (!within(context.realRoot, existing)) throw absent(context, 1);
541
+ } catch (thrown) {
542
+ await removeCreated(created);
543
+ throw thrown;
473
544
  }
474
- return existing;
545
+ return {
546
+ directory: existing,
547
+ created
548
+ };
475
549
  }
476
550
  //#endregion
477
551
  //#region src/object-file.ts
478
- const directoryMutationLocks = /* @__PURE__ */ new Map();
479
- const doNothing = () => {};
480
552
  /**
481
553
  * The file below the key, or `undefined` where the key names no object of this storage:
482
554
  * nothing at all, a directory, or a path that leaves the root (spec 6). What a listing
@@ -538,43 +610,21 @@ async function removeObjectFile(context, file) {
538
610
  * Moves the file to the path, which is the copy and the delete of spec 4.11 as one file
539
611
  * system performs them, and removes what the source left empty behind it.
540
612
  */
541
- async function renameObjectFile(context, file, path) {
613
+ async function renameObjectFile(context, file, destination) {
614
+ try {
615
+ await rename(file.path, destination.path);
616
+ } catch (thrown) {
617
+ throw fsErrorFrom(thrown, {
618
+ ...refusesName(thrown) ? destination.context : context,
619
+ access: "write"
620
+ });
621
+ }
542
622
  try {
543
- try {
544
- await rename(file.path, path);
545
- } catch (thrown) {
546
- throw fsErrorFrom(thrown, {
547
- ...context,
548
- access: "write"
549
- });
550
- }
551
623
  await pruneEmptyDirectories(context.realRoot, dirname(file.path));
552
624
  } finally {
553
625
  file.release();
554
626
  }
555
627
  }
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
628
  async function pruneEmptyDirectories(realRoot, directory) {
579
629
  for (let current = directory; current !== realRoot && within(realRoot, current); current = dirname(current)) try {
580
630
  await rmdir(current);
@@ -610,24 +660,24 @@ async function lookUp(context, read) {
610
660
  //#region src/range.ts
611
661
  /** Refuses bounds the spec does not allow, before the file is opened (spec 4.3). */
612
662
  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");
663
+ const refusal = rangeBoundsRefusal(range);
664
+ if (refusal !== void 0) throw fsError(root, {
665
+ ...refusal,
666
+ operation: "get",
667
+ attempts: 0
668
+ });
616
669
  }
617
670
  /** The last byte the range names, both ends inclusive, clipped to what the file holds. */
618
- function lastByteOf(root, range, size, key) {
671
+ function lastByteToRead(root, range, size, key) {
619
672
  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)}`,
673
+ const refusal = rangeStartRefusal(range, size, key);
674
+ if (refusal !== void 0) throw fsError(root, {
675
+ ...refusal,
623
676
  operation: "get",
624
677
  key,
625
678
  attempts: 1
626
679
  });
627
- return Math.min(range.end ?? size - 1, size - 1);
628
- }
629
- function isOffset(value) {
630
- return Number.isInteger(value) && value >= 0;
680
+ return lastByteOf(range, size);
631
681
  }
632
682
  //#endregion
633
683
  //#region src/stored-object.ts
@@ -742,6 +792,8 @@ async function close(handle) {
742
792
  }
743
793
  //#endregion
744
794
  //#region src/walk.ts
795
+ const utf8 = new TextEncoder();
796
+ const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
745
797
  /**
746
798
  * Every object below the prefix, sorted by key. A listing pages through one order, so
747
799
  * the snapshot it reads is sorted; the order itself is not promised (spec 4.6).
@@ -749,25 +801,34 @@ async function close(handle) {
749
801
  async function walkObjects(context, prefix) {
750
802
  const entries = [];
751
803
  const directory = prefix.slice(0, prefix.lastIndexOf("/") + 1);
752
- await collect(context, entries, join(context.realRoot, ...directory.split("/")), directory);
804
+ await collect({
805
+ context,
806
+ entries,
807
+ prefix: utf8.encode(prefix)
808
+ }, join(context.realRoot, ...directory.split("/")), directory);
753
809
  return entries.filter((entry) => entry.key.startsWith(prefix)).toSorted(byKey);
754
810
  }
755
- async function collect(context, entries, directory, keyPrefix) {
811
+ async function collect(walk, directory, keyPrefix) {
812
+ const { context, entries } = walk;
756
813
  let resolved;
757
814
  let held;
758
815
  try {
759
816
  resolved = await realpath(directory);
760
817
  if (!within(context.realRoot, resolved)) return;
761
- held = await readdir(resolved, { withFileTypes: true });
818
+ held = await entriesOf(resolved);
762
819
  } catch (thrown) {
763
820
  if (isAbsence(thrown)) return;
764
821
  throw failure(context, thrown);
765
822
  }
766
823
  for (const entry of held) {
824
+ if ("bytes" in entry) {
825
+ refuseUndecodableName(walk, keyPrefix, entry.bytes);
826
+ continue;
827
+ }
767
828
  const path = join(resolved, entry.name);
768
829
  const key = `${keyPrefix}${entry.name}`;
769
- if (entry.isDirectory()) {
770
- await collect(context, entries, path, `${key}/`);
830
+ if (entry.directory) {
831
+ await collect(walk, path, `${key}/`);
771
832
  continue;
772
833
  }
773
834
  if (isTemporaryName(entry.name)) continue;
@@ -780,6 +841,71 @@ async function collect(context, entries, directory, keyPrefix) {
780
841
  }
781
842
  }
782
843
  /**
844
+ * The names of one level. The runtime decodes a name that is no UTF-8 with U+FFFD in
845
+ * place of what it cannot read, so a level holding that character reads its names again
846
+ * as bytes to tell such a name from one that holds U+FFFD as written.
847
+ */
848
+ async function entriesOf(directory) {
849
+ const held = await readdir(directory, { withFileTypes: true });
850
+ if (!held.some((entry) => entry.name.includes("�"))) return held.map((entry) => ({
851
+ name: entry.name,
852
+ directory: entry.isDirectory()
853
+ }));
854
+ const names = await readdir(directory, { encoding: "buffer" });
855
+ const entries = [];
856
+ for (const bytes of names) {
857
+ const name = utf8NameOf(bytes);
858
+ if (name === void 0) {
859
+ entries.push({ bytes });
860
+ continue;
861
+ }
862
+ try {
863
+ entries.push({
864
+ name,
865
+ directory: (await lstat(join(directory, name))).isDirectory()
866
+ });
867
+ } catch (thrown) {
868
+ if (isAbsence(thrown)) continue;
869
+ throw thrown;
870
+ }
871
+ }
872
+ return entries;
873
+ }
874
+ function utf8NameOf(bytes) {
875
+ try {
876
+ return strictUtf8.decode(bytes);
877
+ } catch {
878
+ return;
879
+ }
880
+ }
881
+ /**
882
+ * A name that is no UTF-8 has no key, and a key with U+FFFD in its place would reach
883
+ * another object or none. Passing over it would let a move of the prefix lose the file
884
+ * in silence, so a listing it falls below fails as one whose entry arrived without a key
885
+ * (spec 4.6); a listing beside it goes on.
886
+ */
887
+ function refuseUndecodableName(walk, keyPrefix, bytes) {
888
+ const path = new Uint8Array([...utf8.encode(keyPrefix), ...bytes]);
889
+ if (!startsWith(path, walk.prefix)) return;
890
+ throw fsError(walk.context.root, {
891
+ code: "ProviderError",
892
+ message: `The name ${escapedBytesOf(path)} is no UTF-8, so no key names it`,
893
+ operation: walk.context.operation,
894
+ attempts: 1,
895
+ retryable: false
896
+ });
897
+ }
898
+ function startsWith(bytes, start) {
899
+ return start.length <= bytes.length && start.every((byte, index) => bytes[index] === byte);
900
+ }
901
+ function escapedBytesOf(bytes) {
902
+ return `"${Array.from(bytes, (byte) => standsAsItIs(byte) ? String.fromCharCode(byte) : `\\x${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("")}"`;
903
+ }
904
+ /** Printable ASCII but for the quote and the backslash, which would read as an escape. */
905
+ function standsAsItIs(byte) {
906
+ return byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92;
907
+ }
908
+ /**
783
909
  * What the entry holds, or `undefined` for everything that is no object: a link leaving
784
910
  * the root, a link to a directory, a socket, and a file another writer removed between
785
911
  * the listing of the level and the reading of it.
@@ -837,12 +963,30 @@ var FileSystemStorage = class {
837
963
  this.#requireNoUserMetadata(options?.userMetadata, key);
838
964
  options?.signal?.throwIfAborted();
839
965
  const context = await this.#context(key, "put", "write");
840
- const path = await prepareWrite(context);
841
- return await this.#write(context, path, body, options?.signal);
966
+ const reserved = await withDirectoryMutations(context.realRoot, async () => await this.#reserve(context));
967
+ try {
968
+ return await this.#write(context, reserved, body, options?.signal);
969
+ } catch (thrown) {
970
+ await withDirectoryMutations(context.realRoot, reserved.prepared.abandon);
971
+ throw thrown;
972
+ }
973
+ }
974
+ /**
975
+ * The directories a write needs and the file its bytes land in, created while the caller
976
+ * holds the directory lock: once that file is there, no prune removes the directory it
977
+ * lies in, so the body streams in without the lock.
978
+ */
979
+ async #reserve(context) {
980
+ const prepared = await prepareWrite(context);
981
+ const temporary = temporaryPathIn(dirname(prepared.path));
982
+ return {
983
+ prepared,
984
+ temporary,
985
+ handle: await abandoningOnFailure(prepared, async () => await this.#openTemporary(context, temporary))
986
+ };
842
987
  }
843
- async #write(context, path, body, signal) {
844
- return await this.#land(context, path, async (temporary) => {
845
- const handle = await this.#openTemporary(context, temporary);
988
+ async #write(context, { prepared, temporary, handle }, body, signal) {
989
+ return await this.#land(context, prepared.path, temporary, async () => {
846
990
  try {
847
991
  await writeBody(handle, body, signal);
848
992
  signal?.throwIfAborted();
@@ -860,10 +1004,9 @@ var FileSystemStorage = class {
860
1004
  * reader sees the object as it was or as it now is and never half of a write. A write
861
1005
  * that broke takes its file with it, so that no listing names what never became one.
862
1006
  */
863
- async #land(context, path, fill, signal) {
864
- const temporary = temporaryPathIn(dirname(path));
1007
+ async #land(context, path, temporary, fill, signal) {
865
1008
  try {
866
- const written = await fill(temporary);
1009
+ const written = await fill();
867
1010
  signal?.throwIfAborted();
868
1011
  await rename(temporary, path);
869
1012
  return describe(context.key, written.size, written.mtime);
@@ -885,7 +1028,7 @@ var FileSystemStorage = class {
885
1028
  return createStoredObject(context, found.description, {
886
1029
  path: found.path,
887
1030
  start: options?.range?.start ?? 0,
888
- end: lastByteOf(this.#root, options?.range, found.description.size, key)
1031
+ end: lastByteToRead(this.#root, options?.range, found.description.size, key)
889
1032
  });
890
1033
  }
891
1034
  async stat(key, options) {
@@ -972,11 +1115,12 @@ var FileSystemStorage = class {
972
1115
  const ends = await this.#endpoints(from, to, "copy", options);
973
1116
  const source = await this.#requireFile(ends.from);
974
1117
  try {
975
- const path = await prepareWrite(ends.to);
976
- return await this.#land(ends.to, path, async (temporary) => {
1118
+ const prepared = await prepareWrite(ends.to);
1119
+ const temporary = temporaryPathIn(dirname(prepared.path));
1120
+ return await abandoningOnFailure(prepared, async () => await this.#land(ends.to, prepared.path, temporary, async () => {
977
1121
  await this.#read(ends.from, source.path, temporary);
978
1122
  return await stat(temporary);
979
- }, options?.signal);
1123
+ }, options?.signal));
980
1124
  } finally {
981
1125
  releaseObjectFile(source);
982
1126
  }
@@ -996,9 +1140,14 @@ var FileSystemStorage = class {
996
1140
  const ends = await this.#endpoints(from, to, "move", options);
997
1141
  const source = await this.#requireFile(ends.from);
998
1142
  try {
999
- const path = await prepareWrite(ends.to);
1000
- options?.signal?.throwIfAborted();
1001
- await renameObjectFile(ends.from, source, path);
1143
+ const prepared = await prepareWrite(ends.to);
1144
+ await abandoningOnFailure(prepared, async () => {
1145
+ options?.signal?.throwIfAborted();
1146
+ await renameObjectFile(ends.from, source, {
1147
+ context: ends.to,
1148
+ path: prepared.path
1149
+ });
1150
+ });
1002
1151
  return describe(to, source.stats.size, source.stats.mtime);
1003
1152
  } finally {
1004
1153
  releaseObjectFile(source);
@@ -1107,5 +1256,13 @@ function describe(key, size, lastModified) {
1107
1256
  userMetadata: noUserMetadata
1108
1257
  };
1109
1258
  }
1259
+ async function abandoningOnFailure(prepared, write) {
1260
+ try {
1261
+ return await write();
1262
+ } catch (thrown) {
1263
+ await prepared.abandon();
1264
+ throw thrown;
1265
+ }
1266
+ }
1110
1267
  //#endregion
1111
1268
  export { fsStorage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stowage/adapter-fs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "stowage adapter rooting a storage in one directory of the local file system.",
5
5
  "keywords": [
6
6
  "adapter",
@@ -33,7 +33,7 @@
33
33
  "provenance": true
34
34
  },
35
35
  "dependencies": {
36
- "@stowage/core": "^0.1.0"
36
+ "@stowage/core": "^0.2.0"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=24"