@poe-platform/safe-fs 0.1.807 → 0.1.808

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 (34) hide show
  1. package/README.md +19 -0
  2. package/dist/safe-fs/contracts/filesystem.d.ts +5 -0
  3. package/dist/safe-fs/core.d.ts +2 -0
  4. package/dist/safe-fs/core.js +2 -0
  5. package/dist/safe-fs/fs/capabilities.js +2 -2
  6. package/dist/safe-fs/fs/devices/index.d.ts +1 -0
  7. package/dist/safe-fs/fs/devices/index.js +40 -2
  8. package/dist/safe-fs/fs/memory/atomic-view.d.ts +1 -0
  9. package/dist/safe-fs/fs/memory/index.d.ts +4 -1
  10. package/dist/safe-fs/fs/memory/index.js +56 -9
  11. package/dist/safe-fs/fs/mount/index.d.ts +3 -1
  12. package/dist/safe-fs/fs/mount/index.js +154 -3
  13. package/dist/safe-fs/fs/overlay/index.d.ts +2 -0
  14. package/dist/safe-fs/fs/overlay/index.js +167 -59
  15. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +1 -0
  16. package/dist/safe-fs/fs/overlay/memory-publication.js +5 -0
  17. package/dist/safe-fs/fs/quota/index.js +9 -4
  18. package/dist/safe-fs/fs/read-file-stream.d.ts +7 -0
  19. package/dist/safe-fs/fs/read-file-stream.js +103 -0
  20. package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
  21. package/dist/safe-fs/fs/readonly/index.js +10 -0
  22. package/dist/safe-fs/fs/real/index.d.ts +1 -0
  23. package/dist/safe-fs/fs/real/index.js +27 -0
  24. package/dist/safe-fs/fs/s3/filesystem.d.ts +2 -1
  25. package/dist/safe-fs/fs/s3/filesystem.js +55 -16
  26. package/dist/safe-fs/fs/scoped.js +10 -4
  27. package/dist/safe-fs/index.d.ts +1 -0
  28. package/dist/safe-fs/index.js +1 -0
  29. package/dist/safe-fs/runtime-core.d.ts +1 -0
  30. package/dist/safe-fs/runtime-core.js +1 -0
  31. package/dist/safe-fs/storage.d.ts +49 -0
  32. package/dist/safe-fs/storage.js +265 -0
  33. package/dist/safe-fs/testing/object-io-metrics.js +1 -0
  34. package/package.json +9 -6
@@ -152,8 +152,8 @@ export class S3FileSystem {
152
152
  atomicRenameNoReplace: false,
153
153
  rename: this.allowRename && options.transport.capabilities?.conditionalDelete === true
154
154
  && (options.transport.capabilities?.conditionalCopy === true || options.transport.capabilities?.conditionalPut === true),
155
- streamingAppend: options.transport.capabilities?.streamingWrite === true
156
- && options.transport.capabilities?.conditionalPut === true && typeof options.transport.putObjectStream === "function",
155
+ // Conditional append currently collects the old and incoming bodies.
156
+ streamingAppend: false,
157
157
  randomAccessWrite: false,
158
158
  readOnly: options.readOnly ?? false,
159
159
  symlinks: false, hardlinks: false, permissions: false,
@@ -341,25 +341,33 @@ export class S3FileSystem {
341
341
  if (key.endsWith("/") && object.Size !== 0)
342
342
  fail("ENOTSUP", "listObjectsV2", path, "nonempty slash-suffixed objects cannot be treated as directories");
343
343
  }
344
- async *pages(prefix, path, options, delimiter, maxKeys = this.pageSize) {
345
- const tokens = new Set();
346
- let token;
347
- let entries = 0;
344
+ async *pages(prefix, path, options, delimiter, maxKeys = this.pageSize, rememberTokens = true) {
345
+ // Buffered callers preserve exact repeated-token rejection. Lazy traversal
346
+ // detects continuation cycles without retaining the whole token history.
347
+ const tokens = rememberTokens ? new Set() : undefined;
348
+ let token, checkpoint;
349
+ let entries = 0, pageCount = 0, span = 0, power = 1;
348
350
  do {
349
351
  const page = await this.page(prefix, path, options, delimiter, token, maxKeys);
350
352
  entries += (page.Contents?.length ?? 0) + (page.CommonPrefixes?.length ?? 0);
351
- if (entries > this.maxListEntries || tokens.size >= this.maxListEntries)
353
+ if (entries > this.maxListEntries || ++pageCount > this.maxListEntries)
352
354
  fail("EFBIG", "listObjectsV2", path, "listing exceeds maxListEntries");
355
+ const truncated = page.IsTruncated, next = page.NextContinuationToken;
353
356
  yield page;
354
- if (!page.IsTruncated)
357
+ if (!truncated)
355
358
  return;
356
- token = page.NextContinuationToken;
357
- if (typeof token !== "string" || !token || tokens.has(token))
359
+ if (typeof next !== "string" || !next || next === token || next === checkpoint || tokens?.has(next))
358
360
  fail("EIO", "listObjectsV2", path, "missing or repeated continuation token");
359
- tokens.add(token);
361
+ if (++span === power) {
362
+ checkpoint = next;
363
+ span = 0;
364
+ power = Math.min(Number.MAX_SAFE_INTEGER, power * 2);
365
+ }
366
+ tokens?.add(next);
367
+ token = next;
360
368
  } while (true);
361
369
  }
362
- async inspect(path, options) {
370
+ async inspect(path, options, rememberTokens = true) {
363
371
  if (path === "/") {
364
372
  const listing = await this.page(this.prefix, path, options, undefined, undefined, 1);
365
373
  const marker = this.prefix && listing.Contents?.some(object => object.Key === this.prefix)
@@ -375,7 +383,7 @@ export class S3FileSystem {
375
383
  const directoryKey = this.directoryKey(path);
376
384
  let directory = false;
377
385
  let hasMarker = false;
378
- for await (const children of this.pages(directoryKey, path, options, undefined, 1)) {
386
+ for await (const children of this.pages(directoryKey, path, options, undefined, 1, rememberTokens)) {
379
387
  if ((children.Contents?.length ?? 0) > 0) {
380
388
  directory = true;
381
389
  hasMarker = children.Contents.some(object => object.Key === directoryKey);
@@ -393,7 +401,7 @@ export class S3FileSystem {
393
401
  return { stat: this.makeStat("directory", marker), ...(marker ? { metadata: marker } : {}) };
394
402
  return undefined;
395
403
  }
396
- async lookup(path, options) {
404
+ async lookup(path, options, rememberTokens = true) {
397
405
  const ancestors = [];
398
406
  for (let parent = posix.dirname(path); path !== "/"; parent = posix.dirname(parent)) {
399
407
  ancestors.unshift(parent);
@@ -403,13 +411,13 @@ export class S3FileSystem {
403
411
  for (const parent of ancestors) {
404
412
  if (parent === "/" && this.prefix === "")
405
413
  continue;
406
- const ancestor = await this.inspect(parent, options);
414
+ const ancestor = await this.inspect(parent, options, rememberTokens);
407
415
  if (!ancestor)
408
416
  fail("ENOENT", "stat", parent);
409
417
  if (ancestor.stat.type !== "directory")
410
418
  fail("ENOTDIR", "stat", parent);
411
419
  }
412
- return this.inspect(path, options);
420
+ return this.inspect(path, options, rememberTokens);
413
421
  }
414
422
  requireDirectorySuffix(input, info) {
415
423
  if (input.endsWith("/") && info?.stat.type === "file")
@@ -596,6 +604,37 @@ export class S3FileSystem {
596
604
  }
597
605
  return [...entries.values()].sort((left, right) => compareKeys(left.name, right.name));
598
606
  }
607
+ async *iterateDirectory(input, options = {}) {
608
+ const path = this.path(input);
609
+ const directory = await this.lookup(path, options, false);
610
+ if (!directory)
611
+ fail("ENOENT", "iterateDirectory", path);
612
+ if (directory.stat.type !== "directory")
613
+ fail("ENOTDIR", "iterateDirectory", path);
614
+ const prefix = this.directoryKey(path);
615
+ for await (const page of this.pages(prefix, path, options, "/", this.pageSize, false)) {
616
+ // Snapshot one bounded service page before yielding to caller code.
617
+ if ((page.Contents?.length ?? 0) + (page.CommonPrefixes?.length ?? 0) > this.pageSize)
618
+ fail("EIO", "iterateDirectory", path, "transport exceeded requested page size");
619
+ const entries = [];
620
+ for (const item of page.Contents ?? [])
621
+ if (item.Key !== prefix)
622
+ entries.push({ name: item.Key.slice(prefix.length), type: "file" });
623
+ for (const item of page.CommonPrefixes ?? [])
624
+ entries.push({ name: item.Prefix.slice(prefix.length, -1), type: "directory" });
625
+ for (const entry of entries) {
626
+ this.checkAbort(options, "iterateDirectory", path);
627
+ if (!entry.name || entry.name.includes("/"))
628
+ fail("EIO", "iterateDirectory", path, "invalid direct child in delimited listing");
629
+ // Revalidation preserves file/prefix collision detection across pages
630
+ // and follows the backend's live namespace without retaining all names.
631
+ const child = await this.lookup(path === "/" ? `/${entry.name}` : `${path}/${entry.name}`, options, false);
632
+ this.checkAbort(options, "iterateDirectory", path);
633
+ if (child)
634
+ yield { name: entry.name, type: child.stat.type };
635
+ }
636
+ }
637
+ }
599
638
  async tree(path, options) {
600
639
  const objects = new Map();
601
640
  for await (const page of this.pages(this.directoryKey(path), path, options)) {
@@ -21,7 +21,7 @@ const operations = new Set([
21
21
  "prepareDirectoryAncestry", "prepareStagingResolution", "publishStagedFileSet",
22
22
  "publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
23
23
  "confineExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
24
- "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir",
24
+ "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir", "iterateDirectory",
25
25
  "readlink", "realpath", "rename", "resizeFile", "rm", "rmdir", "unlink", "stat", "symlink", "truncate", "utimes",
26
26
  "writeFile", "writeStream",
27
27
  ]);
@@ -188,7 +188,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
188
188
  close: (options = {}) => closing ??= Promise.resolve().then(() => descriptor.close(options)),
189
189
  };
190
190
  };
191
- const wrapStream = (source, options) => {
191
+ const wrapStream = (source, options, chargeNext = false) => {
192
192
  const budgetFrames = getScopedTransportBudget();
193
193
  return {
194
194
  [Symbol.asyncIterator]() {
@@ -203,6 +203,8 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
203
203
  await closing;
204
204
  return { done: true, value: undefined };
205
205
  }
206
+ if (chargeNext)
207
+ admit(options);
206
208
  const result = await withScopedTransportBudget(budgetFrames, operation);
207
209
  assertOpen(options);
208
210
  if (closing) {
@@ -430,7 +432,11 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
430
432
  });
431
433
  return Reflect.apply(method, original, args);
432
434
  })();
435
+ if (property === "iterateDirectory")
436
+ args[1] = { ...args[1], signal: combineSignal(args[1]?.signal) };
433
437
  const result = Reflect.apply(method, original, args);
438
+ if (property === "iterateDirectory")
439
+ return wrapStream(result, args[1], true);
434
440
  return property === "readStream" ? wrapStream(result, args[1]) : result;
435
441
  };
436
442
  const dispatch = (...args) => {
@@ -438,7 +444,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
438
444
  return executeDispatch(args);
439
445
  if (operations.has(property)) {
440
446
  const optionIndex = ["compareEntry", "createStagedFile", "utimes"].includes(String(property)) ? 3
441
- : ["writeFile", "appendFile", "writeStream", "rename", "copyFile", "link", "symlink", "access", "chmod", "truncate", "resizeFile", "publishFileConditional", "writeFileConditional", "publishStagedFile"].includes(String(property)) ? 2 : 1;
447
+ : ["writeFile", "appendFile", "writeStream", "rename", "copyFile", "link", "symlink", "access", "chmod", "truncate", "resizeFile", "publishFileConditional", "writeFileConditional", "publishStagedFile", "publishStagedFileSet"].includes(String(property)) ? 2 : 1;
442
448
  args[optionIndex] = scopeTransportOptions((args[optionIndex] ?? {}), admit, credit);
443
449
  }
444
450
  return runScopedTransportBudget(admit, () => executeDispatch(args), credit);
@@ -528,7 +534,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
528
534
  ? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
529
535
  : property === "openReadFile"
530
536
  ? async (...args) => wrapHandle(await dispatch(...args))
531
- : operations.has(property) && property !== "canonicalizeMissingTarget" && property !== "readStream"
537
+ : operations.has(property) && property !== "canonicalizeMissingTarget" && property !== "readStream" && property !== "iterateDirectory"
532
538
  ? (...args) => {
533
539
  try {
534
540
  const res = dispatch(...args);
@@ -23,3 +23,4 @@ export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./
23
23
  export type { FileSystemConfig, FileSystemAdapterDescriptor, FileSystemAdapterRegistry } from "./config.js";
24
24
  export { createNodeFileSystemAdapterRegistry } from "./config.node.js";
25
25
  export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
26
+ export { readFileStream } from "./fs/read-file-stream.js";
@@ -19,3 +19,4 @@ export * from "./node/index.js";
19
19
  export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
20
20
  export { createNodeFileSystemAdapterRegistry } from "./config.node.js";
21
21
  export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
22
+ export { readFileStream } from "./fs/read-file-stream.js";
@@ -25,3 +25,4 @@ export declare function tryWriteMemoryFileSync(fs: FileSystem, path: string, dat
25
25
  export declare function tryMkdirMemorySync(fs: FileSystem, path: string, recursive: boolean, mode: number, signal?: AbortSignal, dirPrefixHint?: string): boolean;
26
26
  export declare function tryRmRfMemorySync(fs: FileSystem, path: string, signal?: AbortSignal): boolean;
27
27
  export declare function tryWriteMemoryFileInDirSync(fs: FileSystem, dirPrefix: string, name: string, data: Uint8Array, append: boolean, mode: number, signal?: AbortSignal): boolean;
28
+ export { readFileStream } from "./fs/read-file-stream.js";
@@ -55,3 +55,4 @@ export function tryRmRfMemorySync(fs, path, signal) {
55
55
  export function tryWriteMemoryFileInDirSync(fs, dirPrefix, name, data, append, mode, signal) {
56
56
  return Boolean((fs && _getMemHooks()?.byProto?.get(Object.getPrototypeOf(fs))?.tryWriteMemoryFileInDirSync)?.(fs, dirPrefix, name, data, append, mode, signal));
57
57
  }
58
+ export { readFileStream } from "./fs/read-file-stream.js";
@@ -0,0 +1,49 @@
1
+ import type { FileSystem } from "./contracts/filesystem.js";
2
+ export interface PagedStorageContext {
3
+ readonly fs: FileSystem;
4
+ readonly cwd: string;
5
+ readonly env: Readonly<Record<string, string | undefined>>;
6
+ readonly signal: AbortSignal;
7
+ }
8
+ /** A bounded page cache backed only by the caller filesystem. The default is
9
+ * one MiB; callers own close() on every outcome. Memory filesystems still retain
10
+ * spilled data in RAM, so large workloads require an external backing provider. */
11
+ export declare class PagedStorage {
12
+ private readonly context;
13
+ private readonly maxPages;
14
+ private readonly pages;
15
+ private readonly controller;
16
+ private readonly signal;
17
+ private descriptor;
18
+ private parent;
19
+ private initial;
20
+ private path;
21
+ private diskLength;
22
+ private unlinked;
23
+ private end;
24
+ private active;
25
+ private closing;
26
+ constructor(context: PagedStorageContext, maxPages?: number);
27
+ allocate(length: number): number;
28
+ private operation;
29
+ private spill;
30
+ private flush;
31
+ private page;
32
+ write(position: number, bytes: Uint8Array): Promise<void>;
33
+ append(bytes: Uint8Array): Promise<number>;
34
+ read(position: number, length: number): Promise<Uint8Array>;
35
+ close(): Promise<void>;
36
+ }
37
+ /** Fixed-depth radix index: both dense and sparse 64-bit keys use bounded RAM. */
38
+ export declare class IntegerTable {
39
+ private readonly storage;
40
+ private readonly maxEntries;
41
+ private root;
42
+ private readonly cache;
43
+ constructor(storage: PagedStorage, maxEntries?: number);
44
+ private retain;
45
+ get(key: bigint): Promise<bigint | undefined>;
46
+ set(key: bigint, value: bigint): Promise<void>;
47
+ private lookup;
48
+ private persist;
49
+ }
@@ -0,0 +1,265 @@
1
+ import { FsError, isFsError } from "./contracts/errors.js";
2
+ const pageBytes = 16 * 1024;
3
+ let serial = 0;
4
+ /** A bounded page cache backed only by the caller filesystem. The default is
5
+ * one MiB; callers own close() on every outcome. Memory filesystems still retain
6
+ * spilled data in RAM, so large workloads require an external backing provider. */
7
+ export class PagedStorage {
8
+ context;
9
+ maxPages;
10
+ pages = new Map();
11
+ controller = new AbortController();
12
+ signal;
13
+ descriptor;
14
+ parent;
15
+ initial;
16
+ path;
17
+ diskLength = 0;
18
+ unlinked = false;
19
+ end = 8; // Zero is the absent-pointer sentinel.
20
+ active = Promise.resolve();
21
+ closing;
22
+ constructor(context, maxPages = 64) {
23
+ this.context = context;
24
+ this.maxPages = maxPages;
25
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1)
26
+ throw new RangeError("Invalid storage page cache size");
27
+ this.signal = AbortSignal.any([context.signal, this.controller.signal]);
28
+ }
29
+ allocate(length) {
30
+ this.signal.throwIfAborted();
31
+ if (!Number.isSafeInteger(length) || length < 0 || length > Number.MAX_SAFE_INTEGER - this.end)
32
+ throw new FsError("EFBIG");
33
+ const offset = this.end;
34
+ this.end += length;
35
+ return offset;
36
+ }
37
+ operation(action) {
38
+ this.signal.throwIfAborted();
39
+ const work = this.active.then(() => {
40
+ this.signal.throwIfAborted();
41
+ return action();
42
+ });
43
+ this.active = work.then(() => undefined, () => undefined);
44
+ return work;
45
+ }
46
+ async spill() {
47
+ const { fs, cwd, env } = this.context;
48
+ if (!fs.open || !fs.removeFileConditional)
49
+ throw new FsError("ENOTSUP", { message: "spill storage requires retained read/write handles and conditional removal" });
50
+ const selected = env.TMPDIR || cwd;
51
+ const directory = selected.startsWith("/") ? selected : `${cwd}/${selected}`;
52
+ this.parent = await fs.stat(directory, { signal: this.signal });
53
+ this.signal.throwIfAborted();
54
+ if (this.parent.type !== "directory")
55
+ throw new FsError("ENOTDIR", { path: directory });
56
+ for (let attempt = 0; attempt < 128; attempt++) {
57
+ const path = `${directory.endsWith("/") ? directory : directory + "/"}.storage-${++serial}`;
58
+ try {
59
+ this.descriptor = await fs.open(path, { access: "readwrite", creation: "exclusive", mode: 0o600, signal: this.signal });
60
+ this.path = path;
61
+ break;
62
+ }
63
+ catch (error) {
64
+ this.signal.throwIfAborted();
65
+ if (!isFsError(error) || error.code !== "EEXIST")
66
+ throw error;
67
+ }
68
+ }
69
+ if (!this.descriptor)
70
+ throw new FsError("EEXIST", { path: directory });
71
+ // Retain the acquisition receipt even if cancellation wins immediately after open.
72
+ this.initial = await this.descriptor.stat();
73
+ this.signal.throwIfAborted();
74
+ if (!this.descriptor.capabilities.positionedRead || !this.descriptor.capabilities.positionedWrite)
75
+ throw new FsError("ENOTSUP");
76
+ // Detach the name before storing input. A later output or random-source path
77
+ // cannot alias our scratch file; the retained descriptor owns its lifetime.
78
+ await fs.removeFileConditional(this.path, { parent: this.parent, expected: this.initial, signal: this.signal });
79
+ this.unlinked = true;
80
+ for (const [number, page] of this.pages)
81
+ await this.flush(number, page);
82
+ }
83
+ async flush(number, page) {
84
+ if (!page.dirty)
85
+ return;
86
+ let offset = 0;
87
+ while (offset < pageBytes) {
88
+ this.signal.throwIfAborted();
89
+ const count = await this.descriptor.write(page.bytes.subarray(offset), number * pageBytes + offset, { signal: this.signal });
90
+ this.signal.throwIfAborted();
91
+ if (!Number.isSafeInteger(count) || count <= 0 || count > pageBytes - offset)
92
+ throw new FsError("EIO");
93
+ offset += count;
94
+ }
95
+ this.diskLength = Math.max(this.diskLength, (number + 1) * pageBytes);
96
+ page.dirty = false;
97
+ }
98
+ async page(number) {
99
+ this.signal.throwIfAborted();
100
+ const cached = this.pages.get(number);
101
+ if (cached) {
102
+ this.pages.delete(number);
103
+ this.pages.set(number, cached);
104
+ return cached;
105
+ }
106
+ if (this.pages.size === this.maxPages) {
107
+ if (!this.descriptor)
108
+ await this.spill();
109
+ const [oldNumber, oldPage] = this.pages.entries().next().value;
110
+ await this.flush(oldNumber, oldPage);
111
+ this.pages.delete(oldNumber);
112
+ }
113
+ const page = { bytes: new Uint8Array(pageBytes), dirty: false };
114
+ if (number * pageBytes < this.diskLength) {
115
+ let offset = 0;
116
+ while (offset < pageBytes) {
117
+ const count = await this.descriptor.read(page.bytes.subarray(offset), number * pageBytes + offset, { signal: this.signal });
118
+ this.signal.throwIfAborted();
119
+ if (!Number.isSafeInteger(count) || count <= 0 || count > pageBytes - offset)
120
+ throw new FsError("EIO");
121
+ offset += count;
122
+ }
123
+ }
124
+ this.pages.set(number, page);
125
+ return page;
126
+ }
127
+ write(position, bytes) {
128
+ if (!Number.isSafeInteger(position) || position < 8 || position + bytes.length > this.end)
129
+ throw new RangeError("Invalid paged storage range");
130
+ return this.operation(async () => {
131
+ for (let offset = 0; offset < bytes.length;) {
132
+ const target = position + offset;
133
+ const page = await this.page(Math.floor(target / pageBytes));
134
+ const count = Math.min(bytes.length - offset, pageBytes - target % pageBytes);
135
+ page.bytes.set(bytes.subarray(offset, offset + count), target % pageBytes);
136
+ page.dirty = true;
137
+ offset += count;
138
+ }
139
+ });
140
+ }
141
+ async append(bytes) {
142
+ const position = this.allocate(bytes.length);
143
+ await this.write(position, bytes);
144
+ return position;
145
+ }
146
+ read(position, length) {
147
+ if (!Number.isSafeInteger(position) || position < 8 || !Number.isSafeInteger(length) || length < 0 || length > pageBytes || position + length > this.end)
148
+ throw new RangeError("Invalid paged storage range");
149
+ return this.operation(async () => {
150
+ const bytes = new Uint8Array(length);
151
+ for (let offset = 0; offset < length;) {
152
+ const source = position + offset;
153
+ const page = await this.page(Math.floor(source / pageBytes));
154
+ const count = Math.min(length - offset, pageBytes - source % pageBytes);
155
+ bytes.set(page.bytes.subarray(source % pageBytes, source % pageBytes + count), offset);
156
+ offset += count;
157
+ }
158
+ return bytes;
159
+ });
160
+ }
161
+ close() {
162
+ this.controller.abort(new FsError("ECANCELED"));
163
+ return this.closing ??= (async () => {
164
+ await this.active.catch(() => { });
165
+ this.pages.clear();
166
+ const descriptor = this.descriptor;
167
+ if (!descriptor)
168
+ return;
169
+ const cancellation = new FsError("ECANCELED");
170
+ try {
171
+ if (!this.unlinked) {
172
+ const expected = this.initial ?? await descriptor.stat();
173
+ await this.context.fs.removeFileConditional(this.path, { parent: this.parent, expected }).catch(error => {
174
+ if (!isFsError(error) || error.code !== "ENOENT")
175
+ throw error;
176
+ });
177
+ }
178
+ }
179
+ finally {
180
+ // Conditional descriptors must discard their private staged writes, not
181
+ // publish the scratch data when the command retires its handle.
182
+ await descriptor.close({ signal: AbortSignal.abort(cancellation) }).catch(error => {
183
+ if (error !== cancellation)
184
+ throw error;
185
+ });
186
+ }
187
+ })();
188
+ }
189
+ }
190
+ /** Fixed-depth radix index: both dense and sparse 64-bit keys use bounded RAM. */
191
+ export class IntegerTable {
192
+ storage;
193
+ maxEntries;
194
+ root = 0;
195
+ cache = new Map();
196
+ constructor(storage, maxEntries = 4096) {
197
+ this.storage = storage;
198
+ this.maxEntries = maxEntries;
199
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
200
+ throw new RangeError("Invalid integer table cache size");
201
+ }
202
+ async retain(key, value, dirty) {
203
+ if (this.cache.size === this.maxEntries && !this.cache.has(key)) {
204
+ const [oldKey, old] = this.cache.entries().next().value;
205
+ if (old.dirty)
206
+ await this.persist(oldKey, old.value);
207
+ this.cache.delete(oldKey);
208
+ }
209
+ this.cache.delete(key);
210
+ this.cache.set(key, { value, dirty });
211
+ }
212
+ async get(key) {
213
+ const cached = this.cache.get(key);
214
+ if (cached) {
215
+ this.cache.delete(key);
216
+ this.cache.set(key, cached);
217
+ return cached.value;
218
+ }
219
+ const value = await this.lookup(key);
220
+ if (value !== undefined)
221
+ await this.retain(key, value, false);
222
+ return value;
223
+ }
224
+ async set(key, value) {
225
+ if (key < 0n || key > 0xffffffffffffffffn || value < 0n || value >= 0xffffffffffffffffn)
226
+ throw new RangeError("Invalid storage integer table value");
227
+ await this.retain(key, value, true);
228
+ }
229
+ async lookup(key) {
230
+ let node = this.root;
231
+ for (let shift = 60n; shift >= 0n; shift -= 4n) {
232
+ if (!node)
233
+ return undefined;
234
+ const slot = node + Number((key >> shift) & 15n) * 8;
235
+ const bytes = await this.storage.read(slot, 8);
236
+ const value = new DataView(bytes.buffer, bytes.byteOffset, 8).getBigUint64(0, true);
237
+ if (shift === 0n)
238
+ return value === 0n ? undefined : value - 1n;
239
+ node = Number(value);
240
+ }
241
+ return undefined;
242
+ }
243
+ async persist(key, value) {
244
+ if (!this.root)
245
+ this.root = this.storage.allocate(128);
246
+ let node = this.root;
247
+ for (let shift = 60n; shift >= 0n; shift -= 4n) {
248
+ const slot = node + Number((key >> shift) & 15n) * 8;
249
+ const bytes = await this.storage.read(slot, 8);
250
+ const view = new DataView(bytes.buffer, bytes.byteOffset, 8);
251
+ if (shift === 0n) {
252
+ view.setBigUint64(0, value + 1n, true);
253
+ await this.storage.write(slot, bytes);
254
+ return;
255
+ }
256
+ let child = Number(view.getBigUint64(0, true));
257
+ if (!child) {
258
+ child = this.storage.allocate(128);
259
+ view.setBigUint64(0, BigInt(child), true);
260
+ await this.storage.write(slot, bytes);
261
+ }
262
+ node = child;
263
+ }
264
+ }
265
+ }
@@ -48,6 +48,7 @@ export function measureObjectIoStore(store, metrics) {
48
48
  const publish = store.publish;
49
49
  const createStaging = store.createStaging;
50
50
  return {
51
+ ...(store.noFollow === undefined ? {} : { noFollow: store.noFollow }),
51
52
  async acquire(...args) {
52
53
  const head = await metrics.measure("store", "acquire", () => store.acquire(...args));
53
54
  return head && version(head);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.807",
3
+ "version": "0.1.808",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -88,11 +88,7 @@
88
88
  "import": "./dist/safe-fs/fs/real/index.js"
89
89
  },
90
90
  "./fs/s3/http": {
91
- "types": {
92
- "browser": "./dist/safe-fs/node-unavailable.d.ts",
93
- "default": "./dist/safe-fs/fs/s3/http/index.d.ts"
94
- },
95
- "browser": null,
91
+ "types": "./dist/safe-fs/fs/s3/http/index.d.ts",
96
92
  "import": "./dist/safe-fs/fs/s3/http/index.js"
97
93
  },
98
94
  "./fs/s3": {
@@ -134,6 +130,13 @@
134
130
  "./bridge": {
135
131
  "types": "./dist/safe-fs/bridge/index.d.ts",
136
132
  "import": "./dist/safe-fs/bridge/index.js"
133
+ },
134
+ "./storage": {
135
+ "types": "./dist/safe-fs/storage.d.ts",
136
+ "workerd": "./dist/safe-fs/storage.js",
137
+ "browser": "./dist/safe-fs/storage.js",
138
+ "worker": "./dist/safe-fs/storage.js",
139
+ "import": "./dist/safe-fs/storage.js"
137
140
  }
138
141
  },
139
142
  "repository": {