@poe-platform/safe-fs 0.1.678 → 0.1.679

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
@@ -71,6 +71,16 @@ All required methods must exist, but a backend may reject an operation with `FsE
71
71
 
72
72
  `createDeviceFileSystem(fs)` adds portable `/dev/null` whole-file, stream, and descriptor I/O. Descriptor reads return EOF and writes discard bytes; stat remains a zero-size character device and descriptor position remains zero. Truncating opens are accepted, exclusive creation fails with `EEXIST`, and descriptor resizing and synchronization are unsupported. Access modes, cancellation, and closed handles use the normal descriptor checks. With an authoritative object store, use `createDeviceFileSystem(withObjectFileDescriptors(fs, store))` so null-device I/O never acquires or publishes an object version; ordinary files retain conditional publication. The device wrapper must be outermost for this composition.
73
73
 
74
+ `withObjectFileDescriptors(fs, store)` supports large shell and Python descriptor
75
+ writes when the host supplies `store.createStaging`: private externally backed
76
+ pages keep working memory bounded without publishing the growing file after
77
+ every write. Conditional publication still occurs at sync/close, and retained
78
+ readers keep their old versions. Without that optional backend primitive, the
79
+ default 8 MiB dirty-page budget still limits unflushed output. See the
80
+ [object descriptor and spill contract](src/contracts/object-publication.md) for
81
+ backend methods, failure semantics and qualification; no provider storage is
82
+ configured automatically.
83
+
74
84
  Immutable flat stores can supply `identityScope`, `opaqueIdentity` and an ABA-safe
75
85
  `opaqueVersion` on file stats, then expose `atomicFilePublication: true` with
76
86
  `publishFileConditional(path, source, { expected, parent, maxBytes, signal })`.
@@ -13,9 +13,20 @@ export interface ObjectFilePublicationOptions extends FsOptions {
13
13
  export interface ObjectFileAcquireOptions extends FsOptions {
14
14
  readonly access: OpenFileOptions["access"];
15
15
  }
16
+ export interface ObjectFileStaging {
17
+ readPage(index: number, options?: FsOptions): Promise<Uint8Array | undefined>;
18
+ writePage(index: number, bytes: Uint8Array, options?: FsOptions): Promise<void>;
19
+ truncate(size: number, options?: FsOptions): Promise<void>;
20
+ close(): Promise<void>;
21
+ }
22
+ export interface ObjectFileStagingOptions extends FsOptions {
23
+ readonly chunkBytes: number;
24
+ readonly maxFileBytes: number;
25
+ }
16
26
  export interface ObjectFilePublicationStore {
17
27
  acquire(path: string, options: ObjectFileAcquireOptions): Promise<ObjectFileVersion | undefined>;
18
28
  publish?(path: string, expectedRevision: string | null, source: ByteSource, options: ObjectFilePublicationOptions): Promise<ObjectFileVersion>;
29
+ createStaging?(path: string, options: ObjectFileStagingOptions): Promise<ObjectFileStaging>;
19
30
  }
20
31
  export interface ObjectFileDescriptorOptions {
21
32
  readonly chunkBytes?: number;
@@ -11,10 +11,43 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
11
11
  const maxOpenFiles = options.maxOpenFiles ?? 64;
12
12
  if (![chunkBytes, maxStagedBytes, maxStagedPages, maxFileBytes, maxOpenFiles].every(value => Number.isSafeInteger(value) && value > 0)
13
13
  || chunkBytes > 1048576 || typeof store.acquire !== "function"
14
- || store.publish !== undefined && typeof store.publish !== "function")
14
+ || store.publish !== undefined && typeof store.publish !== "function"
15
+ || store.createStaging !== undefined && typeof store.createStaging !== "function")
15
16
  throw new TypeError("Invalid object descriptor configuration");
17
+ const createStaging = store.createStaging?.bind(store);
16
18
  let stagedBytes = 0;
17
19
  let openFiles = 0;
20
+ const stagingWaiters = new Set();
21
+ const reservePage = async (path, forwarded) => {
22
+ if (maxStagedBytes < chunkBytes)
23
+ throw new FsError("ENOSPC", { path, message: "Object staging requires at least one page of working memory" });
24
+ while (stagedBytes + chunkBytes > maxStagedBytes || stagedBytes / chunkBytes >= maxStagedPages) {
25
+ forwarded.signal?.throwIfAborted();
26
+ await new Promise((resolve, reject) => {
27
+ const wake = () => {
28
+ stagingWaiters.delete(wake);
29
+ forwarded.signal?.removeEventListener("abort", abort);
30
+ resolve();
31
+ };
32
+ const abort = () => {
33
+ stagingWaiters.delete(wake);
34
+ forwarded.signal?.removeEventListener("abort", abort);
35
+ reject(forwarded.signal?.reason);
36
+ };
37
+ stagingWaiters.add(wake);
38
+ forwarded.signal?.addEventListener("abort", abort, { once: true });
39
+ if (forwarded.signal?.aborted)
40
+ abort();
41
+ });
42
+ }
43
+ forwarded.signal?.throwIfAborted();
44
+ stagedBytes += chunkBytes;
45
+ };
46
+ const releasePage = () => {
47
+ stagedBytes -= chunkBytes;
48
+ for (const wake of stagingWaiters)
49
+ wake();
50
+ };
18
51
  const version = (value) => {
19
52
  if (!value || typeof value.revision !== "string" || value.revision.length === 0 || value.revision.length > 4096
20
53
  || !value.stat || value.stat.type !== "file" || !Number.isSafeInteger(value.stat.size) || value.stat.size < 0
@@ -41,7 +74,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
41
74
  throw new FsError("EMFILE", { path });
42
75
  openFiles++;
43
76
  let acquiring = true;
44
- const state = { head: undefined, position: 0, size: 0, inheritedSize: 0, modifiedAt: Date.now(), dirty: false, pages: new Map(), failure: undefined };
77
+ const state = { head: undefined, position: 0, size: 0, inheritedSize: 0, modifiedAt: Date.now(), dirty: false, pages: new Map(), staging: undefined, failure: undefined };
45
78
  const check = (forwarded) => {
46
79
  if (acquiring)
47
80
  admitted.signal?.throwIfAborted();
@@ -74,6 +107,18 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
74
107
  throw new FsError("EIO", { path, message: "Immutable range read returned an invalid byte count" });
75
108
  return Uint8Array.from(data);
76
109
  };
110
+ const readStagedPage = async (page, forwarded) => {
111
+ const bytes = await perform(forwarded, selected => state.staging.readPage(page, selected));
112
+ if (bytes !== undefined && (!(bytes instanceof Uint8Array) || bytes.byteLength !== chunkBytes))
113
+ throw new FsError("EIO", { path, message: "Invalid object staging page" });
114
+ return bytes;
115
+ };
116
+ const retireStaging = async () => {
117
+ const staging = state.staging;
118
+ state.staging = undefined;
119
+ if (typeof staging?.close === "function")
120
+ await staging.close();
121
+ };
77
122
  const read = async (buffer, position, forwarded) => {
78
123
  check(forwarded);
79
124
  const count = Math.min(buffer.byteLength, Math.max(0, state.size - position));
@@ -86,6 +131,21 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
86
131
  const staged = state.pages.get(page);
87
132
  if (staged)
88
133
  buffer.set(staged.subarray(within, within + length), copied);
134
+ else if (state.staging) {
135
+ await reservePage(path, forwarded);
136
+ try {
137
+ const spilled = await readStagedPage(page, forwarded);
138
+ if (spilled)
139
+ buffer.set(spilled.subarray(within, within + length), copied);
140
+ else {
141
+ buffer.fill(0, copied, copied + length);
142
+ buffer.set(await readBase(offset, length, forwarded), copied);
143
+ }
144
+ }
145
+ finally {
146
+ releasePage();
147
+ }
148
+ }
89
149
  else {
90
150
  buffer.fill(0, copied, copied + length);
91
151
  buffer.set(await readBase(offset, length, forwarded), copied);
@@ -133,7 +193,14 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
133
193
  state.modifiedAt = published.stat.mtimeMs;
134
194
  state.dirty = false;
135
195
  clearPages(state);
136
- await previous?.close();
196
+ let retirementFailed = true;
197
+ try {
198
+ await retireStaging();
199
+ retirementFailed = false;
200
+ }
201
+ finally {
202
+ await finishCleanup(() => previous?.close(), retirementFailed);
203
+ }
137
204
  }
138
205
  catch (reason) {
139
206
  state.failure = { reason };
@@ -207,6 +274,51 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
207
274
  const end = start + buffer.byteLength;
208
275
  if (!Number.isSafeInteger(end) || end > maxFileBytes)
209
276
  throw new FsError("EFBIG", { path, message: "Object descriptor file limit exceeded" });
277
+ if (createStaging) {
278
+ try {
279
+ if (!state.staging) {
280
+ await perform(forwarded, async (selected) => {
281
+ state.staging = await createStaging(path, { ...selected, chunkBytes, maxFileBytes });
282
+ if (!state.staging || ![state.staging.readPage, state.staging.writePage, state.staging.truncate, state.staging.close].every(method => typeof method === "function"))
283
+ throw new FsError("EIO", { path, message: "Invalid object staging handle" });
284
+ });
285
+ }
286
+ let copied = 0;
287
+ while (copied < buffer.byteLength) {
288
+ await reservePage(path, forwarded);
289
+ try {
290
+ const offset = start + copied;
291
+ const page = Math.floor(offset / chunkBytes);
292
+ const within = offset % chunkBytes;
293
+ const length = Math.min(buffer.byteLength - copied, chunkBytes - within);
294
+ let bytes = within === 0 && length === chunkBytes ? undefined : await readStagedPage(page, forwarded);
295
+ if (!bytes) {
296
+ bytes = new Uint8Array(chunkBytes);
297
+ if (within !== 0 || length !== chunkBytes)
298
+ bytes.set(await readBase(page * chunkBytes, chunkBytes, forwarded));
299
+ }
300
+ bytes.set(buffer.subarray(copied, copied + length), within);
301
+ await perform(forwarded, selected => state.staging.writePage(page, bytes, selected));
302
+ copied += length;
303
+ }
304
+ finally {
305
+ releasePage();
306
+ }
307
+ }
308
+ }
309
+ catch (reason) {
310
+ state.failure = { reason };
311
+ throw reason;
312
+ }
313
+ state.size = Math.max(state.size, end);
314
+ state.modifiedAt = Date.now();
315
+ state.dirty = true;
316
+ if (position === null)
317
+ state.position = end;
318
+ if (admitted.synchronization !== undefined)
319
+ await flush(forwarded);
320
+ return buffer.byteLength;
321
+ }
210
322
  const first = Math.floor(start / chunkBytes);
211
323
  const last = Math.floor((end - 1) / chunkBytes);
212
324
  const missing = [];
@@ -254,6 +366,15 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
254
366
  check(forwarded);
255
367
  if (length > maxFileBytes)
256
368
  throw new FsError("EFBIG", { path });
369
+ if (state.staging) {
370
+ try {
371
+ await perform(forwarded, selected => state.staging.truncate(length, selected));
372
+ }
373
+ catch (reason) {
374
+ state.failure = { reason };
375
+ throw reason;
376
+ }
377
+ }
257
378
  for (const [page, bytes] of state.pages) {
258
379
  if (page * chunkBytes >= length) {
259
380
  state.pages.delete(page);
@@ -284,11 +405,18 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
284
405
  clearPages(state);
285
406
  const retained = state.head;
286
407
  state.head = undefined;
408
+ let retirementFailed = true;
287
409
  try {
288
- await finishCleanup(() => retained?.close(), failed);
410
+ await finishCleanup(retireStaging, failed);
411
+ retirementFailed = false;
289
412
  }
290
413
  finally {
291
- openFiles--;
414
+ try {
415
+ await finishCleanup(() => retained?.close(), failed || retirementFailed);
416
+ }
417
+ finally {
418
+ openFiles--;
419
+ }
292
420
  }
293
421
  }
294
422
  },
@@ -8,6 +8,7 @@ export interface ObjectFilePublicationConformanceFixture {
8
8
  }
9
9
  export interface ObjectFilePublicationConformanceOptions {
10
10
  readonly createFixture: () => ObjectFilePublicationConformanceFixture | Promise<ObjectFilePublicationConformanceFixture>;
11
+ readonly requireStaging?: boolean;
11
12
  }
12
13
  export interface ObjectFilePublicationConformanceCase {
13
14
  readonly name: string;
@@ -106,6 +106,68 @@ export function createObjectFilePublicationConformanceCases(options) {
106
106
  },
107
107
  },
108
108
  ];
109
+ if (options.requireStaging)
110
+ cases.push({
111
+ name: "object staging: private pages have owned reads and truncation semantics",
112
+ async run({ fixture, path, track }) {
113
+ const name = path("staged");
114
+ const staging = track(await fixture.store.createStaging(name, { chunkBytes: 4, maxFileBytes: 16 }));
115
+ const sibling = track(await fixture.store.createStaging(name, { chunkBytes: 4, maxFileBytes: 16 }));
116
+ check(await staging.readPage(0) === undefined, "new staging contains an unowned page");
117
+ const input = new Uint8Array([1, 2, 3, 4]);
118
+ await staging.writePage(0, input);
119
+ input.fill(0);
120
+ const retained = await staging.readPage(0);
121
+ check(retained !== undefined, "staging lost an acknowledged page");
122
+ bytes(retained, new Uint8Array([1, 2, 3, 4]));
123
+ retained.fill(0);
124
+ bytes((await staging.readPage(0)), new Uint8Array([1, 2, 3, 4]));
125
+ check(await sibling.readPage(0) === undefined, "staging leaked across descriptors");
126
+ await staging.writePage(2, new Uint8Array([5, 6, 7, 8]));
127
+ await staging.truncate(2);
128
+ bytes((await staging.readPage(0)), new Uint8Array([1, 2, 0, 0]));
129
+ check(await staging.readPage(2) === undefined, "truncate retained a removed page");
130
+ const visible = await fixture.store.acquire(name, { access: "read" });
131
+ if (visible)
132
+ track(visible);
133
+ check(visible === undefined, "private staging published a namespace entry");
134
+ },
135
+ }, {
136
+ name: "object staging: cancelled writes preserve acknowledged pages",
137
+ async run({ fixture, path, track }) {
138
+ const staging = track(await fixture.store.createStaging(path("stage-cancelled"), { chunkBytes: 4, maxFileBytes: 16 }));
139
+ await staging.writePage(0, new Uint8Array([1, 2, 3, 4]));
140
+ const controller = new AbortController();
141
+ const reason = new Error("staging cancellation");
142
+ controller.abort(reason);
143
+ let rejected = false;
144
+ try {
145
+ await staging.writePage(0, new Uint8Array(4), { signal: controller.signal });
146
+ }
147
+ catch (error) {
148
+ check(error === reason, "staging must preserve cancellation identity");
149
+ rejected = true;
150
+ }
151
+ check(rejected, "cancelled staging write succeeded");
152
+ bytes((await staging.readPage(0)), new Uint8Array([1, 2, 3, 4]));
153
+ },
154
+ }, {
155
+ name: "object staging: writes larger than memory stay private until conditional sync",
156
+ async run({ fixture, path, track, publish }) {
157
+ const name = path("stage-descriptor");
158
+ await publish(name, null, new Uint8Array([1, 2, 3, 4]));
159
+ const fs = withObjectFileDescriptors(fixture.fs, fixture.store, { chunkBytes: 4, maxStagedBytes: 4, maxStagedPages: 1, maxFileBytes: 16 });
160
+ const descriptor = track(await fs.open(name, { access: "readwrite" }));
161
+ const content = new Uint8Array([5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
162
+ await descriptor.write(content, 0);
163
+ bytes(await fixture.fs.readFile(name), new Uint8Array([1, 2, 3, 4]));
164
+ const buffer = new Uint8Array(content.length);
165
+ check(await descriptor.read(buffer, 0) === buffer.length, "staging read was incomplete");
166
+ bytes(buffer, content);
167
+ await descriptor.sync(false);
168
+ bytes(await fixture.fs.readFile(name), content);
169
+ },
170
+ });
109
171
  return cases.map(entry => ({
110
172
  name: entry.name,
111
173
  async run() {
@@ -116,6 +178,8 @@ export function createObjectFilePublicationConformanceCases(options) {
116
178
  validatePath(fixture.root);
117
179
  check(fixture.root.startsWith("/"), "conformance fixture root must be absolute");
118
180
  check(typeof fixture.store.publish === "function", "conformance requires authoritative conditional publication");
181
+ if (options.requireStaging)
182
+ check(typeof fixture.store.createStaging === "function", "conformance requires private object staging");
119
183
  const root = fixture.root.endsWith("/") ? fixture.root.slice(0, -1) : fixture.root;
120
184
  const track = (value) => {
121
185
  resources.push(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.678",
3
+ "version": "0.1.679",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",