@poe-platform/safe-fs 0.1.677 → 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
|
-
|
|
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(
|
|
410
|
+
await finishCleanup(retireStaging, failed);
|
|
411
|
+
retirementFailed = false;
|
|
289
412
|
}
|
|
290
413
|
finally {
|
|
291
|
-
|
|
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);
|