@poe-platform/safe-fs 0.1.810 → 0.1.812

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/README.md CHANGED
@@ -10,6 +10,11 @@ working data. Supply `{fs, cwd, env, signal}` and an optional page-cache count
10
10
  `env.TMPDIR` or `cwd`, with conditional unlink and cleanup through `close()`.
11
11
  Providers must support those capabilities; there is no host fallback. Use an
12
12
  external backend for large data because memory filesystems retain spilled bytes.
13
+ To bound aggregate pages across multiple live stores, pass the same
14
+ `new PagedStorageCache(16)` as the third argument to each `PagedStorage` constructor.
15
+ This shares a 256 KiB resident-page budget; `cache.residentBytes` reports current
16
+ page retention. Eviction uses each store's filesystem, and each store still owns
17
+ its `close()` cleanup. The budget excludes caller buffers and backend storage.
13
18
  `IntegerTable.entries()` replays records in unsigned key order with a fixed
14
19
  16-level node stack and batches of at most 128 records. It accepts zero-filled
15
20
  caller storage with `allocate`, `read` and `write`; mutation during traversal
@@ -18,4 +18,4 @@ export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./
18
18
  export type { FileSystemConfig, FileSystemAdapterDescriptor, FileSystemAdapterRegistry } from "./config.js";
19
19
  export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
20
20
  export { readFileStream } from "./fs/read-file-stream.js";
21
- export { PagedStorage, IntegerTable, type PagedStorageContext } from "./storage.js";
21
+ export { PagedStorage, PagedStorageCache, IntegerTable, type PagedStorageContext } from "./storage.js";
@@ -16,4 +16,4 @@ export * from "./fs/s3/http/index.js";
16
16
  export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
17
17
  export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
18
18
  export { readFileStream } from "./fs/read-file-stream.js";
19
- export { PagedStorage, IntegerTable } from "./storage.js";
19
+ export { PagedStorage, PagedStorageCache, IntegerTable } from "./storage.js";
@@ -5,12 +5,31 @@ export interface PagedStorageContext {
5
5
  readonly env: Readonly<Record<string, string | undefined>>;
6
6
  readonly signal: AbortSignal;
7
7
  }
8
+ type Page = {
9
+ bytes: Uint8Array;
10
+ dirty: boolean;
11
+ };
12
+ /** Shares one resident-page limit across stores, retaining each store's caller
13
+ * filesystem and cleanup ownership. Serializes cache users through eviction IO. */
14
+ export declare class PagedStorageCache {
15
+ private readonly maxPages;
16
+ private readonly pages;
17
+ private active;
18
+ constructor(maxPages: number);
19
+ get residentBytes(): number;
20
+ run<T>(action: () => Promise<T>): Promise<T>;
21
+ acquire(): Promise<Page>;
22
+ retain(page: Page, release: () => Promise<void>): void;
23
+ touch(page: Page): void;
24
+ forget(page: Page): void;
25
+ }
8
26
  /** A bounded page cache backed only by the caller filesystem. The default is
9
27
  * one MiB; callers own close() on every outcome. Memory filesystems still retain
10
28
  * spilled data in RAM, so large workloads require an external backing provider. */
11
29
  export declare class PagedStorage {
12
30
  private readonly context;
13
31
  private readonly maxPages;
32
+ private readonly cache?;
14
33
  private readonly pages;
15
34
  private readonly controller;
16
35
  private readonly signal;
@@ -23,7 +42,7 @@ export declare class PagedStorage {
23
42
  private end;
24
43
  private active;
25
44
  private closing;
26
- constructor(context: PagedStorageContext, maxPages?: number);
45
+ constructor(context: PagedStorageContext, maxPages?: number, cache?: PagedStorageCache | undefined);
27
46
  allocate(length: number): number;
28
47
  private operation;
29
48
  private spill;
@@ -55,3 +74,4 @@ export declare class IntegerTable {
55
74
  private lookup;
56
75
  private persist;
57
76
  }
77
+ export {};
@@ -1,12 +1,50 @@
1
1
  import { FsError, isFsError } from "./contracts/errors.js";
2
2
  const pageBytes = 16 * 1024;
3
3
  let serial = 0;
4
+ /** Shares one resident-page limit across stores, retaining each store's caller
5
+ * filesystem and cleanup ownership. Serializes cache users through eviction IO. */
6
+ export class PagedStorageCache {
7
+ maxPages;
8
+ pages = new Map();
9
+ active = Promise.resolve();
10
+ constructor(maxPages) {
11
+ this.maxPages = maxPages;
12
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1)
13
+ throw new RangeError("Invalid storage page cache size");
14
+ }
15
+ get residentBytes() { return this.pages.size * pageBytes; }
16
+ run(action) {
17
+ const work = this.active.then(action);
18
+ this.active = work.then(() => undefined, () => undefined);
19
+ return work;
20
+ }
21
+ async acquire() {
22
+ if (this.pages.size < this.maxPages)
23
+ return { bytes: new Uint8Array(pageBytes), dirty: false };
24
+ const [page, release] = this.pages.entries().next().value;
25
+ await release();
26
+ this.pages.delete(page);
27
+ page.bytes.fill(0);
28
+ return page;
29
+ }
30
+ retain(page, release) {
31
+ this.pages.delete(page);
32
+ this.pages.set(page, release);
33
+ }
34
+ touch(page) {
35
+ const release = this.pages.get(page);
36
+ this.pages.delete(page);
37
+ this.pages.set(page, release);
38
+ }
39
+ forget(page) { this.pages.delete(page); }
40
+ }
4
41
  /** A bounded page cache backed only by the caller filesystem. The default is
5
42
  * one MiB; callers own close() on every outcome. Memory filesystems still retain
6
43
  * spilled data in RAM, so large workloads require an external backing provider. */
7
44
  export class PagedStorage {
8
45
  context;
9
46
  maxPages;
47
+ cache;
10
48
  pages = new Map();
11
49
  controller = new AbortController();
12
50
  signal;
@@ -19,9 +57,10 @@ export class PagedStorage {
19
57
  end = 8; // Zero is the absent-pointer sentinel.
20
58
  active = Promise.resolve();
21
59
  closing;
22
- constructor(context, maxPages = 64) {
60
+ constructor(context, maxPages = 64, cache) {
23
61
  this.context = context;
24
62
  this.maxPages = maxPages;
63
+ this.cache = cache;
25
64
  if (!Number.isSafeInteger(maxPages) || maxPages < 1)
26
65
  throw new RangeError("Invalid storage page cache size");
27
66
  this.signal = AbortSignal.any([context.signal, this.controller.signal]);
@@ -36,10 +75,11 @@ export class PagedStorage {
36
75
  }
37
76
  operation(action) {
38
77
  this.signal.throwIfAborted();
39
- const work = this.active.then(() => {
78
+ const execute = () => {
40
79
  this.signal.throwIfAborted();
41
80
  return action();
42
- });
81
+ };
82
+ const work = this.active.then(() => this.cache ? this.cache.run(execute) : execute());
43
83
  this.active = work.then(() => undefined, () => undefined);
44
84
  return work;
45
85
  }
@@ -101,6 +141,7 @@ export class PagedStorage {
101
141
  if (cached) {
102
142
  this.pages.delete(number);
103
143
  this.pages.set(number, cached);
144
+ this.cache?.touch(cached);
104
145
  return cached;
105
146
  }
106
147
  let reusable;
@@ -110,11 +151,14 @@ export class PagedStorage {
110
151
  const [oldNumber, oldPage] = this.pages.entries().next().value;
111
152
  await this.flush(oldNumber, oldPage);
112
153
  this.pages.delete(oldNumber);
154
+ this.cache?.forget(oldPage);
113
155
  reusable = oldPage;
114
156
  }
115
157
  // Flush owns the bytes until its awaited write completes. Reuse only then;
116
158
  // zero new/unwritten ranges so the previous page cannot leak into them.
117
- const page = reusable ?? { bytes: new Uint8Array(pageBytes), dirty: false };
159
+ const page = reusable ?? (this.cache ? await this.cache.acquire() : { bytes: new Uint8Array(pageBytes), dirty: false });
160
+ // Shared eviction may await IO owned by a different, still-live signal.
161
+ this.signal.throwIfAborted();
118
162
  if (reusable)
119
163
  page.bytes.fill(0);
120
164
  if (number * pageBytes < this.diskLength) {
@@ -128,6 +172,12 @@ export class PagedStorage {
128
172
  }
129
173
  }
130
174
  this.pages.set(number, page);
175
+ this.cache?.retain(page, async () => {
176
+ if (!this.descriptor)
177
+ await this.spill();
178
+ await this.flush(number, page);
179
+ this.pages.delete(number);
180
+ });
131
181
  return page;
132
182
  }
133
183
  write(position, bytes) {
@@ -168,28 +218,33 @@ export class PagedStorage {
168
218
  this.controller.abort(new FsError("ECANCELED"));
169
219
  return this.closing ??= (async () => {
170
220
  await this.active.catch(() => { });
171
- this.pages.clear();
172
- const descriptor = this.descriptor;
173
- if (!descriptor)
174
- return;
175
- const cancellation = new FsError("ECANCELED");
176
- try {
177
- if (!this.unlinked) {
178
- const expected = this.initial ?? await descriptor.stat();
179
- await this.context.fs.removeFileConditional(this.path, { parent: this.parent, expected }).catch(error => {
180
- if (!isFsError(error) || error.code !== "ENOENT")
221
+ const retire = async () => {
222
+ for (const page of this.pages.values())
223
+ this.cache?.forget(page);
224
+ this.pages.clear();
225
+ const descriptor = this.descriptor;
226
+ if (!descriptor)
227
+ return;
228
+ const cancellation = new FsError("ECANCELED");
229
+ try {
230
+ if (!this.unlinked) {
231
+ const expected = this.initial ?? await descriptor.stat();
232
+ await this.context.fs.removeFileConditional(this.path, { parent: this.parent, expected }).catch(error => {
233
+ if (!isFsError(error) || error.code !== "ENOENT")
234
+ throw error;
235
+ });
236
+ }
237
+ }
238
+ finally {
239
+ // Conditional descriptors must discard their private staged writes, not
240
+ // publish the scratch data when the command retires its handle.
241
+ await descriptor.close({ signal: AbortSignal.abort(cancellation) }).catch(error => {
242
+ if (error !== cancellation)
181
243
  throw error;
182
244
  });
183
245
  }
184
- }
185
- finally {
186
- // Conditional descriptors must discard their private staged writes, not
187
- // publish the scratch data when the command retires its handle.
188
- await descriptor.close({ signal: AbortSignal.abort(cancellation) }).catch(error => {
189
- if (error !== cancellation)
190
- throw error;
191
- });
192
- }
246
+ };
247
+ await (this.cache ? this.cache.run(retire) : retire());
193
248
  })();
194
249
  }
195
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.810",
3
+ "version": "0.1.812",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",