space-data-module-sdk 0.8.21 → 0.8.22

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.
@@ -0,0 +1,633 @@
1
+ // Node synchronous-fs `flatsql_io` provider (docs/architecture/flatsql-partition-store.md
2
+ // §5.6, 22.3a-6/7).
3
+ //
4
+ // "flatsql_io is implemented with synchronous fs calls in each worker, over a
5
+ // shared virtual-handle table in a SAB. Each worker opens its own fds lazily."
6
+ //
7
+ // A handle is `(slot << 8) | gen`, the same virtual-handle shape as the SDN C
8
+ // host module (§5.4). The table in a SharedArrayBuffer records, per slot, the
9
+ // path (relative to the root), the open flags, the opening instance and a
10
+ // generation. Any guest thread can use any handle: the first time a worker
11
+ // touches a slot it opens its own fd for the recorded path, without CREATE,
12
+ // TRUNC or EXCL. Closing bumps the generation, so other workers' cached fds for
13
+ // that slot are detected as stale and closed on their next use or sweep.
14
+ //
15
+ // Semantics match the Go HostIO and the C host:
16
+ // - paths are confined below `root` (".." and escaping symlinked parents are
17
+ // ACCESS);
18
+ // - O_RDONLY for no WRITE, O_WRONLY for WRITE without READ, else O_RDWR;
19
+ // - EEXIST maps to GENERIC;
20
+ // - CREATE_PARENTS makes missing directories, syncing each new directory's
21
+ // parent, and syncs the parent when the file itself is new;
22
+ // - UNLINK_IF_UNUSED is BUSY while any live slot names the path;
23
+ // - OPEN_DEFERRED is a plain synchronous open (A38);
24
+ // - sync is fdatasync (libuv issues F_FULLFSYNC on darwin).
25
+ //
26
+ // Revocation follows A23: a call increments its instance's in-flight counter
27
+ // and then checks the revoked flag; revokeNodeSyncFsIoInstance sets the flag and
28
+ // waits for the counter to reach zero, so no byte of a revoked instance is
29
+ // written after it returns.
30
+ //
31
+ // Fault injection (§19, 22.3a-7) belongs to the FlatSQL Node host (T4); this
32
+ // provider exposes `interpose` so that host can wrap every syscall.
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+
37
+ import {
38
+ FLATSQL_IO_CREATE,
39
+ FLATSQL_IO_CREATE_PARENTS,
40
+ FLATSQL_IO_DELETE_ON_CLOSE,
41
+ FLATSQL_IO_ERR_ACCESS,
42
+ FLATSQL_IO_ERR_BADHANDLE,
43
+ FLATSQL_IO_ERR_BUSY,
44
+ FLATSQL_IO_ERR_GENERIC,
45
+ FLATSQL_IO_ERR_IO,
46
+ FLATSQL_IO_ERR_NOENT,
47
+ FLATSQL_IO_EXCL,
48
+ FLATSQL_IO_PROBE,
49
+ FLATSQL_IO_READ,
50
+ FLATSQL_IO_TRUNC,
51
+ FLATSQL_IO_UNLINK,
52
+ FLATSQL_IO_UNLINK_IF_UNUSED,
53
+ FLATSQL_IO_WRITE,
54
+ decodeFlatsqlIoPath,
55
+ splitFlatsqlIoPath,
56
+ flatsqlIoStatusOf,
57
+ } from "./flatsqlIoContract.js";
58
+ import { createFlatsqlIoImports } from "./flatsqlIoImports.js";
59
+
60
+ const TABLE_MAGIC = 0x464e4453; // "SDNF"
61
+ const TABLE_VERSION = 1;
62
+ const HEADER_WORDS = 16;
63
+ const SLOT_WORDS = 8;
64
+ const S_STATE = 0;
65
+ const S_GEN = 1;
66
+ const S_FLAGS = 2;
67
+ const S_PATH_LEN = 3;
68
+ const S_OWNER = 4;
69
+
70
+ const SLOT_FREE = 0;
71
+ const SLOT_CLAIMING = 1;
72
+ const SLOT_LIVE = 2;
73
+ const SLOT_CLOSING = 3;
74
+
75
+ const INSTANCE_LIVE = 0;
76
+ const INSTANCE_REVOKED = 1;
77
+ /** Instance id for callers that are not a revocable instance. */
78
+ export const NODE_SYNC_FS_ANONYMOUS_INSTANCE = -1;
79
+
80
+ export const NODE_SYNC_FS_PROVIDER_DESCRIPTOR = "flatsql-io-node";
81
+
82
+ /**
83
+ * Allocate the shared virtual-handle table.
84
+ * @param {object} [options]
85
+ * @param {number} [options.maxHandles=4096] the design's process fd cap.
86
+ * @param {number} [options.maxPathBytes=1024] longest root-relative path.
87
+ * @param {number} [options.maxInstances=64]
88
+ */
89
+ export function createNodeSyncFsIoTable(options = {}) {
90
+ const maxHandles = options.maxHandles ?? 4096;
91
+ const maxPathBytes = options.maxPathBytes ?? 1024;
92
+ const maxInstances = options.maxInstances ?? 64;
93
+ if (!Number.isInteger(maxHandles) || maxHandles < 1 || maxHandles > 1 << 23) {
94
+ throw new RangeError("maxHandles must be in 1..2^23.");
95
+ }
96
+ const bytes =
97
+ HEADER_WORDS * 4 + maxInstances * 8 + maxHandles * SLOT_WORDS * 4 + maxHandles * maxPathBytes;
98
+ const buffer = new SharedArrayBuffer(bytes);
99
+ const header = new Int32Array(buffer, 0, HEADER_WORDS);
100
+ header[0] = TABLE_MAGIC;
101
+ header[1] = TABLE_VERSION;
102
+ header[2] = maxHandles;
103
+ header[3] = maxPathBytes;
104
+ header[4] = maxInstances;
105
+ return buffer;
106
+ }
107
+
108
+ function tableLayout(buffer) {
109
+ const header = new Int32Array(buffer, 0, HEADER_WORDS);
110
+ if (header[0] !== TABLE_MAGIC || header[1] !== TABLE_VERSION) {
111
+ throw new TypeError("Not a Node sync-fs flatsql_io handle table.");
112
+ }
113
+ const maxHandles = header[2];
114
+ const maxPathBytes = header[3];
115
+ const maxInstances = header[4];
116
+ let offset = HEADER_WORDS * 4;
117
+ // Two words per instance: [revoked, inFlight].
118
+ const instances = new Int32Array(buffer, offset, maxInstances * 2);
119
+ offset += maxInstances * 8;
120
+ const slots = new Int32Array(buffer, offset, maxHandles * SLOT_WORDS);
121
+ offset += maxHandles * SLOT_WORDS * 4;
122
+ const paths = new Uint8Array(buffer, offset, maxHandles * maxPathBytes);
123
+ return { header, maxHandles, maxPathBytes, maxInstances, instances, slots, paths };
124
+ }
125
+
126
+ function syncDirectory(dir) {
127
+ let fd = -1;
128
+ try {
129
+ fd = fs.openSync(dir, "r");
130
+ fs.fsyncSync(fd);
131
+ } catch (error) {
132
+ // Some filesystems refuse fsync on a directory fd; that is not a failure
133
+ // of the operation that asked for it (SQLite makes the same call).
134
+ if (!["EINVAL", "ENOTSUP", "EISDIR", "EBADF", "EPERM", "EACCES"].includes(error?.code)) {
135
+ throw error;
136
+ }
137
+ } finally {
138
+ if (fd >= 0) fs.closeSync(fd);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Revoke an instance (A23): later calls from it return ACCESS; returns after
144
+ * every call of it that was in flight has finished.
145
+ */
146
+ export function revokeNodeSyncFsIoInstance(table, instanceId) {
147
+ const layout = tableLayout(table);
148
+ if (instanceId < 0 || instanceId >= layout.maxInstances) {
149
+ throw new RangeError(`instanceId ${instanceId} is outside 0..${layout.maxInstances - 1}.`);
150
+ }
151
+ Atomics.store(layout.instances, instanceId * 2, INSTANCE_REVOKED);
152
+ for (;;) {
153
+ const inFlight = Atomics.load(layout.instances, instanceId * 2 + 1);
154
+ if (inFlight === 0) return;
155
+ Atomics.wait(layout.instances, instanceId * 2 + 1, inFlight, 5);
156
+ }
157
+ }
158
+
159
+ /** Clear a revocation so a replacement instance may reuse the id. */
160
+ export function resetNodeSyncFsIoInstance(table, instanceId) {
161
+ const layout = tableLayout(table);
162
+ Atomics.store(layout.instances, instanceId * 2, INSTANCE_LIVE);
163
+ }
164
+
165
+ /**
166
+ * Create the provider for one thread.
167
+ *
168
+ * @param {object} options
169
+ * @param {string} options.root store root; every path is confined below it.
170
+ * @param {SharedArrayBuffer} options.table createNodeSyncFsIoTable() result,
171
+ * shared by every thread that uses the same handles.
172
+ * @param {number} [options.instanceId] revocation domain (A23).
173
+ * @param {() => WebAssembly.Memory} [options.getMemory] memory that `ptr`
174
+ * arguments address.
175
+ * @param {(op: string, fn: Function, args: any[]) => any} [options.interpose]
176
+ * wraps every syscall (fault injection, instrumentation).
177
+ */
178
+ export function createNodeSyncFsIo(options = {}) {
179
+ if (typeof options.root !== "string" || options.root.length === 0) {
180
+ throw new TypeError("createNodeSyncFsIo requires a root directory.");
181
+ }
182
+ const layout = tableLayout(options.table);
183
+ const rawRoot = path.resolve(options.root);
184
+ let realRoot = rawRoot;
185
+ try {
186
+ realRoot = fs.realpathSync(rawRoot);
187
+ } catch {
188
+ // created later by the caller; containment still checks rawRoot
189
+ }
190
+ const instanceId = Number.isInteger(options.instanceId)
191
+ ? options.instanceId
192
+ : NODE_SYNC_FS_ANONYMOUS_INSTANCE;
193
+ const getMemory = typeof options.getMemory === "function" ? options.getMemory : () => null;
194
+ const interpose =
195
+ typeof options.interpose === "function" ? options.interpose : (_op, fn, args) => fn(...args);
196
+ const localFds = new Map(); // slot -> { gen, fd }
197
+ const encoder = new TextEncoder();
198
+
199
+ const call = (op, fn, ...args) => interpose(op, fn, args);
200
+
201
+ function within(candidate, base) {
202
+ return candidate === base || candidate.startsWith(base + path.sep);
203
+ }
204
+
205
+ function resolvePath(relative) {
206
+ const full = path.join(rawRoot, relative);
207
+ if (!within(full, rawRoot)) return FLATSQL_IO_ERR_ACCESS;
208
+ // The deepest existing ancestor must really live inside the root, so a
209
+ // symlink planted in the store cannot point outside it.
210
+ let probe = path.dirname(full);
211
+ for (;;) {
212
+ try {
213
+ const real = fs.realpathSync(probe);
214
+ if (!within(real, realRoot) && !within(real, rawRoot)) return FLATSQL_IO_ERR_ACCESS;
215
+ break;
216
+ } catch {
217
+ const parent = path.dirname(probe);
218
+ if (parent === probe) break;
219
+ probe = parent;
220
+ }
221
+ }
222
+ return full;
223
+ }
224
+
225
+ function enter() {
226
+ if (instanceId < 0) return true;
227
+ Atomics.add(layout.instances, instanceId * 2 + 1, 1);
228
+ if (Atomics.load(layout.instances, instanceId * 2) === INSTANCE_REVOKED) {
229
+ leave();
230
+ return false;
231
+ }
232
+ return true;
233
+ }
234
+
235
+ function leave() {
236
+ if (instanceId < 0) return;
237
+ Atomics.sub(layout.instances, instanceId * 2 + 1, 1);
238
+ Atomics.notify(layout.instances, instanceId * 2 + 1);
239
+ }
240
+
241
+ function slotBase(slot) {
242
+ return slot * SLOT_WORDS;
243
+ }
244
+
245
+ function slotPath(slot) {
246
+ const len = Atomics.load(layout.slots, slotBase(slot) + S_PATH_LEN);
247
+ const start = slot * layout.maxPathBytes;
248
+ return new TextDecoder().decode(layout.paths.slice(start, start + len));
249
+ }
250
+
251
+ function samePath(slot, bytes) {
252
+ if (Atomics.load(layout.slots, slotBase(slot) + S_PATH_LEN) !== bytes.length) return false;
253
+ const start = slot * layout.maxPathBytes;
254
+ for (let i = 0; i < bytes.length; i += 1) {
255
+ if (layout.paths[start + i] !== bytes[i]) return false;
256
+ }
257
+ return true;
258
+ }
259
+
260
+ function pathInUse(bytes) {
261
+ for (let slot = 0; slot < layout.maxHandles; slot += 1) {
262
+ const state = Atomics.load(layout.slots, slotBase(slot) + S_STATE);
263
+ if ((state === SLOT_LIVE || state === SLOT_CLAIMING) && samePath(slot, bytes)) return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ function claimSlot(bytes, flags) {
269
+ const start = (Atomics.add(layout.header, 5, 1) >>> 0) % layout.maxHandles;
270
+ for (let step = 0; step < layout.maxHandles; step += 1) {
271
+ const slot = (start + step) % layout.maxHandles;
272
+ const base = slotBase(slot);
273
+ if (
274
+ Atomics.compareExchange(layout.slots, base + S_STATE, SLOT_FREE, SLOT_CLAIMING) ===
275
+ SLOT_FREE
276
+ ) {
277
+ const gen = (Atomics.load(layout.slots, base + S_GEN) + 1) & 0xff;
278
+ layout.paths.set(bytes, slot * layout.maxPathBytes);
279
+ Atomics.store(layout.slots, base + S_PATH_LEN, bytes.length);
280
+ Atomics.store(layout.slots, base + S_FLAGS, flags);
281
+ Atomics.store(layout.slots, base + S_OWNER, instanceId);
282
+ Atomics.store(layout.slots, base + S_GEN, gen);
283
+ return { slot, gen };
284
+ }
285
+ }
286
+ return null;
287
+ }
288
+
289
+ function sweep() {
290
+ for (const [slot, cached] of localFds) {
291
+ const base = slotBase(slot);
292
+ if (
293
+ Atomics.load(layout.slots, base + S_STATE) !== SLOT_LIVE ||
294
+ Atomics.load(layout.slots, base + S_GEN) !== cached.gen
295
+ ) {
296
+ try {
297
+ fs.closeSync(cached.fd);
298
+ } catch {
299
+ // already closed
300
+ }
301
+ localFds.delete(slot);
302
+ }
303
+ }
304
+ }
305
+
306
+ function openFlagsFor(flags, forReopen) {
307
+ const c = fs.constants;
308
+ let mode = c.O_RDONLY;
309
+ if (flags & FLATSQL_IO_WRITE) {
310
+ mode = flags & FLATSQL_IO_READ ? c.O_RDWR : c.O_WRONLY;
311
+ }
312
+ if (!forReopen) {
313
+ if (flags & FLATSQL_IO_CREATE) mode |= c.O_CREAT;
314
+ if (flags & FLATSQL_IO_EXCL) mode |= c.O_EXCL;
315
+ if (flags & FLATSQL_IO_TRUNC) mode |= c.O_TRUNC;
316
+ }
317
+ return mode;
318
+ }
319
+
320
+ /** The fd this thread uses for `handle`, or a negative status. */
321
+ function fdFor(handle) {
322
+ if (!Number.isInteger(handle) || handle < 0) return FLATSQL_IO_ERR_BADHANDLE;
323
+ const slot = handle >>> 8;
324
+ const gen = handle & 0xff;
325
+ if (slot >= layout.maxHandles) return FLATSQL_IO_ERR_BADHANDLE;
326
+ const base = slotBase(slot);
327
+ const cached = localFds.get(slot);
328
+ if (
329
+ Atomics.load(layout.slots, base + S_STATE) !== SLOT_LIVE ||
330
+ Atomics.load(layout.slots, base + S_GEN) !== gen
331
+ ) {
332
+ if (cached && cached.gen === gen) {
333
+ // The slot was closed elsewhere: this thread's fd for it is stale.
334
+ try {
335
+ fs.closeSync(cached.fd);
336
+ } catch {
337
+ // already closed
338
+ }
339
+ localFds.delete(slot);
340
+ }
341
+ return FLATSQL_IO_ERR_BADHANDLE;
342
+ }
343
+ if (cached && cached.gen === gen) return cached.fd;
344
+ if (cached) {
345
+ try {
346
+ fs.closeSync(cached.fd);
347
+ } catch {
348
+ // stale
349
+ }
350
+ localFds.delete(slot);
351
+ }
352
+ const full = path.join(rawRoot, slotPath(slot));
353
+ const flags = Atomics.load(layout.slots, base + S_FLAGS);
354
+ try {
355
+ const fd = call("open", fs.openSync, full, openFlagsFor(flags, true), 0o600);
356
+ localFds.set(slot, { gen, fd });
357
+ return fd;
358
+ } catch (error) {
359
+ return flatsqlIoStatusOf(error);
360
+ }
361
+ }
362
+
363
+ function slotFlags(handle) {
364
+ return Atomics.load(layout.slots, slotBase(handle >>> 8) + S_FLAGS);
365
+ }
366
+
367
+ function ensureParents(full) {
368
+ const parent = path.dirname(full);
369
+ const missing = [];
370
+ let probe = parent;
371
+ while (!fs.existsSync(probe)) {
372
+ missing.unshift(probe);
373
+ const up = path.dirname(probe);
374
+ if (up === probe) break;
375
+ probe = up;
376
+ }
377
+ for (const dir of missing) {
378
+ if (!within(dir, rawRoot)) throw Object.assign(new Error("escape"), { code: "EACCES" });
379
+ try {
380
+ call("mkdir", fs.mkdirSync, dir, 0o700);
381
+ } catch (error) {
382
+ if (error?.code !== "EEXIST") throw error;
383
+ }
384
+ call("syncdir", syncDirectory, path.dirname(dir));
385
+ }
386
+ }
387
+
388
+ function openInternal(bytes, flags) {
389
+ const decoded = decodeFlatsqlIoPath(bytes);
390
+ if (typeof decoded !== "string") return decoded;
391
+ const components = splitFlatsqlIoPath(decoded);
392
+ if (!Array.isArray(components)) return components;
393
+ const relative = components.join("/");
394
+ const relativeBytes = encoder.encode(relative);
395
+ if (relativeBytes.length > layout.maxPathBytes) return FLATSQL_IO_ERR_GENERIC;
396
+ const full = resolvePath(relative);
397
+ if (typeof full !== "string") return full;
398
+
399
+ if (flags & FLATSQL_IO_PROBE) {
400
+ try {
401
+ call("stat", fs.statSync, full);
402
+ return 0;
403
+ } catch {
404
+ return FLATSQL_IO_ERR_NOENT;
405
+ }
406
+ }
407
+ if (flags & (FLATSQL_IO_UNLINK | FLATSQL_IO_UNLINK_IF_UNUSED)) {
408
+ if (flags & FLATSQL_IO_UNLINK_IF_UNUSED && pathInUse(relativeBytes)) {
409
+ return FLATSQL_IO_ERR_BUSY;
410
+ }
411
+ try {
412
+ call("unlink", fs.unlinkSync, full);
413
+ call("syncdir", syncDirectory, path.dirname(full));
414
+ return 0;
415
+ } catch (error) {
416
+ return flatsqlIoStatusOf(error);
417
+ }
418
+ }
419
+
420
+ // Close this thread's fds for slots other threads have closed since.
421
+ sweep();
422
+ let createdFile = false;
423
+ try {
424
+ if (flags & FLATSQL_IO_CREATE && flags & FLATSQL_IO_CREATE_PARENTS) {
425
+ ensureParents(full);
426
+ }
427
+ if (flags & FLATSQL_IO_CREATE && flags & FLATSQL_IO_CREATE_PARENTS) {
428
+ createdFile = !fs.existsSync(full);
429
+ }
430
+ } catch (error) {
431
+ return flatsqlIoStatusOf(error);
432
+ }
433
+ const claimed = claimSlot(relativeBytes, flags);
434
+ if (!claimed) return FLATSQL_IO_ERR_GENERIC; // handle table exhausted
435
+ let fd;
436
+ try {
437
+ fd = call("open", fs.openSync, full, openFlagsFor(flags, false), 0o600);
438
+ if (createdFile) call("syncdir", syncDirectory, path.dirname(full));
439
+ } catch (error) {
440
+ if (fd !== undefined) fs.closeSync(fd);
441
+ Atomics.store(layout.slots, slotBase(claimed.slot) + S_STATE, SLOT_FREE);
442
+ return flatsqlIoStatusOf(error);
443
+ }
444
+ localFds.set(claimed.slot, { gen: claimed.gen, fd });
445
+ Atomics.store(layout.slots, slotBase(claimed.slot) + S_STATE, SLOT_LIVE);
446
+ return (claimed.slot << 8) | claimed.gen;
447
+ }
448
+
449
+ function guarded(fn) {
450
+ if (!enter()) return FLATSQL_IO_ERR_ACCESS;
451
+ try {
452
+ return fn();
453
+ } catch (error) {
454
+ return flatsqlIoStatusOf(error);
455
+ } finally {
456
+ leave();
457
+ }
458
+ }
459
+
460
+ function memoryView(ptr, len) {
461
+ const memory = getMemory();
462
+ const buffer = memory?.buffer ?? memory;
463
+ if (!buffer || ptr < 0 || len < 0 || ptr + len > buffer.byteLength) return null;
464
+ return new Uint8Array(buffer, ptr, len);
465
+ }
466
+
467
+ function readView(handle, view, offset) {
468
+ const fd = fdFor(handle);
469
+ if (fd < 0) return fd;
470
+ let done = 0;
471
+ while (done < view.length) {
472
+ const n = call("read", fs.readSync, fd, view, done, view.length - done, offset + done);
473
+ if (n === 0) break;
474
+ done += n;
475
+ }
476
+ return done;
477
+ }
478
+
479
+ function writeView(handle, view, offset) {
480
+ const fd = fdFor(handle);
481
+ if (fd < 0) return fd;
482
+ if (!(slotFlags(handle) & FLATSQL_IO_WRITE)) return FLATSQL_IO_ERR_IO;
483
+ let done = 0;
484
+ while (done < view.length) {
485
+ done += call("write", fs.writeSync, fd, view, done, view.length - done, offset + done);
486
+ }
487
+ return done;
488
+ }
489
+
490
+ return {
491
+ kind: "node-sync-fs",
492
+ instanceId,
493
+ root: rawRoot,
494
+ open(pathBytes, flags) {
495
+ const bytes = typeof pathBytes === "string" ? encoder.encode(pathBytes) : pathBytes;
496
+ return guarded(() => openInternal(bytes, flags | 0));
497
+ },
498
+ read(handle, ptr, len, offset) {
499
+ return guarded(() => {
500
+ if (len === 0) return 0;
501
+ const view = memoryView(ptr, len);
502
+ return view ? readView(handle, view, offset) : FLATSQL_IO_ERR_GENERIC;
503
+ });
504
+ },
505
+ write(handle, ptr, len, offset) {
506
+ return guarded(() => {
507
+ if (len === 0) return 0;
508
+ const view = memoryView(ptr, len);
509
+ return view ? writeView(handle, view, offset) : FLATSQL_IO_ERR_GENERIC;
510
+ });
511
+ },
512
+ readInto(handle, view, offset) {
513
+ return guarded(() => readView(handle, view, offset));
514
+ },
515
+ writeFrom(handle, view, offset) {
516
+ return guarded(() => writeView(handle, view, offset));
517
+ },
518
+ truncate(handle, size) {
519
+ return guarded(() => {
520
+ const fd = fdFor(handle);
521
+ if (fd < 0) return fd;
522
+ if (!(slotFlags(handle) & FLATSQL_IO_WRITE)) return FLATSQL_IO_ERR_IO;
523
+ call("truncate", fs.ftruncateSync, fd, size);
524
+ return 0;
525
+ });
526
+ },
527
+ sync(handle) {
528
+ return guarded(() => {
529
+ const fd = fdFor(handle);
530
+ if (fd < 0) return fd;
531
+ call("sync", fs.fdatasyncSync, fd);
532
+ return 0;
533
+ });
534
+ },
535
+ size(handle) {
536
+ return guarded(() => {
537
+ const fd = fdFor(handle);
538
+ if (fd < 0) return fd;
539
+ return call("size", fs.fstatSync, fd).size;
540
+ });
541
+ },
542
+ close(handle) {
543
+ // Close is allowed for a revoked instance's handles too, so a supervisor
544
+ // can release them; it writes nothing.
545
+ try {
546
+ if (!Number.isInteger(handle) || handle < 0) return FLATSQL_IO_ERR_BADHANDLE;
547
+ const slot = handle >>> 8;
548
+ const gen = handle & 0xff;
549
+ if (slot >= layout.maxHandles) return FLATSQL_IO_ERR_BADHANDLE;
550
+ const base = slotBase(slot);
551
+ if (Atomics.load(layout.slots, base + S_GEN) !== gen) return FLATSQL_IO_ERR_BADHANDLE;
552
+ if (
553
+ Atomics.compareExchange(layout.slots, base + S_STATE, SLOT_LIVE, SLOT_CLOSING) !==
554
+ SLOT_LIVE
555
+ ) {
556
+ return FLATSQL_IO_ERR_BADHANDLE;
557
+ }
558
+ const flags = Atomics.load(layout.slots, base + S_FLAGS);
559
+ const relative = slotPath(slot);
560
+ const cached = localFds.get(slot);
561
+ if (cached) {
562
+ localFds.delete(slot);
563
+ try {
564
+ call("close", fs.closeSync, cached.fd);
565
+ } catch {
566
+ // already closed
567
+ }
568
+ }
569
+ Atomics.store(layout.slots, base + S_STATE, SLOT_FREE);
570
+ if (flags & FLATSQL_IO_DELETE_ON_CLOSE) {
571
+ try {
572
+ call("unlink", fs.unlinkSync, path.join(rawRoot, relative));
573
+ } catch {
574
+ // advisory
575
+ }
576
+ }
577
+ return 0;
578
+ } catch (error) {
579
+ return flatsqlIoStatusOf(error);
580
+ }
581
+ },
582
+ /** Close this thread's cached fds (thread exit). Handles stay valid. */
583
+ closeLocalFds() {
584
+ for (const cached of localFds.values()) {
585
+ try {
586
+ fs.closeSync(cached.fd);
587
+ } catch {
588
+ // ignore
589
+ }
590
+ }
591
+ localFds.clear();
592
+ },
593
+ /** Live handles in the shared table (diagnostics, tests). */
594
+ liveHandles() {
595
+ let live = 0;
596
+ for (let slot = 0; slot < layout.maxHandles; slot += 1) {
597
+ if (Atomics.load(layout.slots, slotBase(slot) + S_STATE) === SLOT_LIVE) live += 1;
598
+ }
599
+ return live;
600
+ },
601
+ localFdCount() {
602
+ return localFds.size;
603
+ },
604
+ };
605
+ }
606
+
607
+ /**
608
+ * Replace `{ provider: "flatsql-io-node", root, table, instanceId, mirror?,
609
+ * trace? }` extraImports descriptors with factories, for Node pool workers.
610
+ * Other entries pass through.
611
+ */
612
+ export function resolveNodeFlatsqlIoDescriptors(entries) {
613
+ return (entries ?? []).map((entry) => {
614
+ if (!entry || entry.provider !== NODE_SYNC_FS_PROVIDER_DESCRIPTOR) return entry;
615
+ return (ctx) => {
616
+ const provider = createNodeSyncFsIo({
617
+ root: entry.root,
618
+ table: entry.table,
619
+ instanceId: entry.instanceId,
620
+ getMemory: ctx.getMemory,
621
+ });
622
+ return {
623
+ imports: createFlatsqlIoImports({
624
+ getMemory: ctx.getMemory,
625
+ provider,
626
+ mirror: entry.mirror,
627
+ trace: entry.trace,
628
+ }),
629
+ close: () => provider.closeLocalFds(),
630
+ };
631
+ };
632
+ });
633
+ }