@lightninglabs/pi-402 0.0.0-stage → 0.0.1

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,894 @@
1
+ // A SQLite VFS backed by node:fs, for hosts that run this driver under Node
2
+ // instead of a browser.
3
+ //
4
+ // The browser story is OPFS, which is asynchronous at its core and therefore
5
+ // needs either the async proxy worker or the SAH pool's preallocated
6
+ // synchronous access handles. Node has none of that problem: node:fs exposes
7
+ // genuinely synchronous file operations, which is exactly the shape SQLite's
8
+ // VFS layer wants, so this VFS is a thin translation rather than a pooling
9
+ // scheme. It is modeled on opfs-sahpool for structure only.
10
+ //
11
+ // The one property OPFS gives away for free that a plain filesystem does not
12
+ // is exclusive access: a SyncAccessHandle cannot be held by two tabs at once.
13
+ // A wallet database that two processes write concurrently corrupts silently,
14
+ // so this VFS reconstructs that property with a pid-stamped lock file taken
15
+ // when a main database is opened for writing. Two Node processes pointed at
16
+ // one database fail loudly instead of interleaving writes.
17
+ //
18
+ // That reconstruction is narrower than the thing it replaces, and the limits
19
+ // are worth stating rather than discovering. Node core exposes no flock or
20
+ // fcntl, so the claim rests on a file whose contents name the owning process,
21
+ // and reading it back is only meaningful where process ids mean the same thing
22
+ // to both parties. Concretely:
23
+ //
24
+ // - Writers only. A read-only open takes no lock and is not blocked by one.
25
+ // - One pid namespace. Two containers sharing a bind-mounted data directory
26
+ // have unrelated pid numbering, so each would read the other's lock as
27
+ // belonging to a process that does not exist.
28
+ // - Local filesystems. Over NFS or SMB neither the atomicity this relies on
29
+ // nor the pid check holds.
30
+ //
31
+ // Within a single machine and pid namespace, a recycled pid is handled: the
32
+ // lock records the owner's kernel start time where the platform exposes it, so
33
+ // a reused pid number does not read as the original owner.
34
+ //
35
+ // The lock file is named `<database><LOCK_SUFFIX>`. If a database is stranded
36
+ // behind a lock whose owner is genuinely gone and this VFS declines to reclaim
37
+ // it, deleting that file by hand is the recovery, and is safe exactly when no
38
+ // process is using the database.
39
+ //
40
+ // One method is deliberately absent: xShmMap. Shared memory exists so that
41
+ // several connections can share one WAL index, and this VFS has already ruled
42
+ // that out by holding the database exclusively. WAL is still available, but
43
+ // only by the route SQLite documents for exactly this situation: set
44
+ // locking_mode=EXCLUSIVE before switching journal modes and SQLite keeps the
45
+ // WAL index on the heap instead. Ask for WAL without that and SQLite silently
46
+ // stays on the rollback journal, which is why the worker reads the effective
47
+ // journal mode back and refuses the mismatch.
48
+
49
+ "use strict";
50
+
51
+ const nodeFS = require("node:fs");
52
+ const nodePath = require("node:path");
53
+ const nodeCrypto = require("node:crypto");
54
+
55
+ // DEFAULT_VFS_NAME is the name this VFS registers under, and the name callers
56
+ // pass as `vfs=` in a DSN.
57
+ const DEFAULT_VFS_NAME = "nodefs";
58
+
59
+ // SECTOR_SIZE is what we report to SQLite as the atomic write unit. 4096
60
+ // matches the page size of every filesystem we care about.
61
+ const SECTOR_SIZE = 4096;
62
+
63
+ // LOCK_SUFFIX names the sidecar file carrying the exclusive claim on a main
64
+ // database. It sits next to the database itself, so an operator who needs to
65
+ // clear a lock by hand can find it without knowing this file.
66
+ const LOCK_SUFFIX = "-nodefs-lock";
67
+
68
+ // RECLAIM_STALE_MS bounds how long the reclaim marker is honored. Reclaiming a
69
+ // lock is a handful of syscalls, so a marker older than this was left by a
70
+ // process that died in the middle of one.
71
+ const RECLAIM_STALE_MS = 30_000;
72
+
73
+ // sleepSignal backs xSleep. Atomics.wait needs a shared integer array to wait
74
+ // on; nothing ever notifies this one, so every wait runs to its timeout, which
75
+ // is exactly the blocking sleep SQLite is asking for.
76
+ const sleepSignal = new Int32Array(new SharedArrayBuffer(4));
77
+
78
+ // isNodeHost reports whether we are running under Node rather than a browser
79
+ // or a browser Worker. Callers use it to decide whether installing this VFS is
80
+ // even possible.
81
+ function isNodeHost() {
82
+ return (
83
+ typeof globalThis.process === "object" &&
84
+ !!globalThis.process?.versions?.node &&
85
+ typeof globalThis.importScripts !== "function"
86
+ );
87
+ }
88
+
89
+ // pidIsAlive reports whether a process id still exists. Signal 0 performs the
90
+ // permission and existence checks without delivering anything.
91
+ function pidIsAlive(pid) {
92
+ if (!Number.isInteger(pid) || pid <= 0) {
93
+ return false;
94
+ }
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch (error) {
99
+ // EPERM means the process exists but belongs to another user, which
100
+ // still counts as alive.
101
+ return error.code === "EPERM";
102
+ }
103
+ }
104
+
105
+ // processStartTicks reads the kernel's record of when a process started, in
106
+ // clock ticks since boot. A pid on its own is not an identity: pids are
107
+ // recycled, and a crashed owner whose number has since been reused would read
108
+ // as alive forever, stranding the database behind a lock nothing will reclaim.
109
+ // The pair (pid, start time) is not reused within a boot, so it is one.
110
+ //
111
+ // Only Linux exposes this for an arbitrary pid. Elsewhere this returns null and
112
+ // the caller falls back to the plain liveness check, which is what this code
113
+ // did before.
114
+ function processStartTicks(pid) {
115
+ try {
116
+ const stat = nodeFS.readFileSync(`/proc/${pid}/stat`, "utf8");
117
+
118
+ // The second field is the executable name in parentheses and may
119
+ // itself contain spaces and parentheses, so the fields after it are
120
+ // counted from the last ')' rather than from the start of the line.
121
+ // That puts field 3 at index 0, and starttime is field 22.
122
+ const after = stat.slice(stat.lastIndexOf(")") + 2);
123
+ const ticks = Number(after.split(" ")[19]);
124
+
125
+ return Number.isFinite(ticks) ? ticks : null;
126
+ } catch (_) {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ // ownerIsLive reports whether the process named by a lock file is still
132
+ // running. A pid that exists but started at a different time than the lock
133
+ // records is a recycled number rather than the original owner, so the lock is
134
+ // stale despite the pid being alive.
135
+ function ownerIsLive(owner) {
136
+ if (!owner || !pidIsAlive(owner.pid)) {
137
+ return false;
138
+ }
139
+
140
+ const ticks = processStartTicks(owner.pid);
141
+ if (ticks === null || owner.startTicks === null ||
142
+ owner.startTicks === undefined) {
143
+
144
+ return true;
145
+ }
146
+
147
+ return ticks === owner.startTicks;
148
+ }
149
+
150
+ // installNodeFsVfs registers the node:fs VFS with the given sqlite3 namespace
151
+ // and returns a small utility handle. It is idempotent: a second call with the
152
+ // same name returns the already-registered handle rather than failing, so a
153
+ // worker can call it unconditionally on every open.
154
+ function installNodeFsVfs(sqlite3, options = {}) {
155
+ if (!isNodeHost()) {
156
+ throw new Error(
157
+ "the nodefs SQLite VFS requires a Node host; use OPFS in " +
158
+ "a browser"
159
+ );
160
+ }
161
+
162
+ const vfsName = options.name || DEFAULT_VFS_NAME;
163
+ const capi = sqlite3.capi;
164
+ const wasm = sqlite3.wasm;
165
+
166
+ if (capi.sqlite3_vfs_find(vfsName)) {
167
+ return installNodeFsVfs.installed?.get(vfsName);
168
+ }
169
+
170
+ // rootDir anchors relative database names. Absolute names are used as
171
+ // given, so a host that already knows its data directory can simply pass
172
+ // full paths.
173
+ const rootDir = nodePath.resolve(options.directory || process.cwd());
174
+
175
+ // openFiles maps a sqlite3_file pointer to the state we keep for it. The
176
+ // pointer is the only handle SQLite hands back to the io methods.
177
+ const openFiles = new Map();
178
+
179
+ // heldLocks maps a lock file path to the descriptor holding it, so xClose
180
+ // can release exactly what xOpen took.
181
+ const heldLocks = new Map();
182
+
183
+ // lastError carries the most recent failure so xGetLastError can report
184
+ // something better than a bare result code.
185
+ let lastError = null;
186
+
187
+ function storeErr(error, rc) {
188
+ if (error !== undefined) {
189
+ lastError = error;
190
+ }
191
+ return rc === undefined ? 0 : rc;
192
+ }
193
+
194
+ function popErr() {
195
+ const error = lastError;
196
+ lastError = null;
197
+ return error;
198
+ }
199
+
200
+ function resolvePath(name) {
201
+ return nodePath.resolve(rootDir, name);
202
+ }
203
+
204
+ function fileFor(pFile) {
205
+ const file = openFiles.get(Number(pFile));
206
+ if (!file) {
207
+ throw new Error(`no open node:fs file for handle ${pFile}`);
208
+ }
209
+ return file;
210
+ }
211
+
212
+ // syncDir flushes a directory entry, so that a file's creation or removal
213
+ // is itself durable rather than merely visible. Without it a power loss can
214
+ // undo the unlink of a rollback journal that SQLite treated as the commit
215
+ // point, resurrecting the journal and rolling a committed transaction back.
216
+ //
217
+ // Failure is deliberately swallowed. Not every platform lets a directory be
218
+ // opened for reading, and turning a weaker barrier into a failed delete
219
+ // would be the worse trade.
220
+ function syncDir(dirPath) {
221
+ let dfd;
222
+ try {
223
+ dfd = nodeFS.openSync(dirPath, "r");
224
+ } catch (_) {
225
+ return;
226
+ }
227
+
228
+ try {
229
+ nodeFS.fsyncSync(dfd);
230
+ } catch (_) {
231
+ } finally {
232
+ nodeFS.closeSync(dfd);
233
+ }
234
+ }
235
+
236
+ // ioErrForWrite distinguishes a full disk from a broken one. SQLite's own
237
+ // unix VFS returns SQLITE_FULL for ENOSPC, and the difference is what sends
238
+ // an operator to `df` rather than to smartctl.
239
+ function ioErrForWrite(error, fallback) {
240
+ return error && error.code === "ENOSPC" ? capi.SQLITE_FULL : fallback;
241
+ }
242
+
243
+ // heapView returns a view of the wasm heap for a pointer/length pair. The
244
+ // view is only valid for the duration of one synchronous call, since any
245
+ // wasm memory growth detaches it.
246
+ function heapView(ptr, n) {
247
+ const start = Number(ptr);
248
+ return wasm.heap8u().subarray(start, start + n);
249
+ }
250
+
251
+ // readLockOwner returns the parsed contents of a lock file, or null when it
252
+ // is missing or unreadable. An unparseable lock file is treated as stale,
253
+ // since the only way to produce one is to crash midway through writing it.
254
+ function readLockOwner(lockPath) {
255
+ try {
256
+ return JSON.parse(nodeFS.readFileSync(lockPath, "utf8"));
257
+ } catch (_) {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ // lockedError builds the error for a database another process holds. The
263
+ // holder travels on the error object as well as in the message, because
264
+ // the worker turns it into something the caller can act on rather than the
265
+ // bare SQLITE_CANTOPEN that SQLite would otherwise surface.
266
+ function lockedError(dbPath, lockPath, owner) {
267
+ const pid = owner && owner.pid ? owner.pid : "unknown";
268
+ const error = new Error(
269
+ `database is locked by another process (pid ${pid}): ${dbPath}`
270
+ );
271
+ error.nodefsLockHolderPid = owner ? owner.pid : null;
272
+ error.nodefsLockPath = lockPath;
273
+
274
+ return error;
275
+ }
276
+
277
+ // writeLockFile creates the lock file and stamps it with this process's
278
+ // identity. It reports false when the lock already exists, and throws for
279
+ // every other failure.
280
+ function writeLockFile(lockPath) {
281
+ let fd;
282
+ try {
283
+ fd = nodeFS.openSync(lockPath, "wx", 0o600);
284
+ } catch (error) {
285
+ if (error.code === "EEXIST") {
286
+ return false;
287
+ }
288
+ throw error;
289
+ }
290
+
291
+ try {
292
+ nodeFS.writeSync(
293
+ fd,
294
+ JSON.stringify({
295
+ pid: process.pid,
296
+ startTicks: processStartTicks(process.pid),
297
+ at: Date.now(),
298
+ })
299
+ );
300
+ } finally {
301
+ nodeFS.closeSync(fd);
302
+ }
303
+
304
+ return true;
305
+ }
306
+
307
+ // withReclaimMutex runs body as the only process reclaiming this lock, and
308
+ // reports whether it ran at all.
309
+ //
310
+ // Reclaiming means observing a dead owner and then replacing the file, and
311
+ // those two steps cannot be made one with anything Node exposes. Left
312
+ // unserialized they interleave, and the interleavings all end the same
313
+ // way: while one process has the stale lock momentarily out of the way, a
314
+ // second finds the path empty, creates its own, and two writers now hold
315
+ // one database. Inspecting the file first does not help, because the
316
+ // inspection is itself a window.
317
+ //
318
+ // mkdir is the one exclusion primitive available here that costs nothing:
319
+ // it fails if the directory exists. So a marker directory beside the lock
320
+ // serializes reclamation, and only its holder may touch the lock file.
321
+ function withReclaimMutex(lockPath, body) {
322
+ const mutexPath = `${lockPath}.reclaim`;
323
+
324
+ try {
325
+ nodeFS.mkdirSync(mutexPath);
326
+ } catch (error) {
327
+ if (error.code !== "EEXIST") {
328
+ throw error;
329
+ }
330
+
331
+ // The marker is held across a handful of syscalls, so one older
332
+ // than any reclaim could take belongs to a process that died
333
+ // inside one. Clearing it costs a retry at worst, where leaving it
334
+ // would strand the database for good.
335
+ let age = RECLAIM_STALE_MS;
336
+ try {
337
+ age = Date.now() - nodeFS.statSync(mutexPath).mtimeMs;
338
+ } catch (_) {
339
+ return false;
340
+ }
341
+
342
+ if (age < RECLAIM_STALE_MS) {
343
+ return false;
344
+ }
345
+
346
+ try {
347
+ nodeFS.rmdirSync(mutexPath);
348
+ } catch (_) {}
349
+
350
+ return false;
351
+ }
352
+
353
+ try {
354
+ return body();
355
+ } finally {
356
+ try {
357
+ nodeFS.rmdirSync(mutexPath);
358
+ } catch (_) {}
359
+ }
360
+ }
361
+
362
+ // reclaimStaleLock replaces a lock whose owner is gone with one naming this
363
+ // process, and reports whether it now holds the lock. The replacement
364
+ // happens under the reclaim mutex so that no other process can slip into
365
+ // the moment when the lock file does not exist.
366
+ function reclaimStaleLock(lockPath) {
367
+ return withReclaimMutex(lockPath, () => {
368
+ // Read it again in here. A reclaimer ahead of us may have
369
+ // finished between our staleness check and our getting the
370
+ // mutex, in which case the lock is now held by a live process.
371
+ const owner = readLockOwner(lockPath);
372
+ if (ownerIsLive(owner)) {
373
+ return false;
374
+ }
375
+
376
+ try {
377
+ nodeFS.unlinkSync(lockPath);
378
+ } catch (error) {
379
+ if (error.code !== "ENOENT") {
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ return writeLockFile(lockPath);
385
+ });
386
+ }
387
+
388
+ // acquireLock takes the exclusive open lock for a main database file. It
389
+ // reclaims a lock whose owning process is gone, and otherwise throws so
390
+ // the open fails loudly rather than racing another writer.
391
+ function acquireLock(dbPath) {
392
+ const lockPath = `${dbPath}${LOCK_SUFFIX}`;
393
+
394
+ // Two passes are all this needs: one to find the lock taken and clear
395
+ // a dead owner out of the way, and one to take it. A process that
396
+ // loses the reclaim race does not get a third try, because the winner
397
+ // now holds a live lock and the answer is no.
398
+ for (let attempt = 0; attempt < 2; attempt++) {
399
+ if (writeLockFile(lockPath)) {
400
+ heldLocks.set(dbPath, lockPath);
401
+ return;
402
+ }
403
+
404
+ const owner = readLockOwner(lockPath);
405
+ if (ownerIsLive(owner)) {
406
+ throw lockedError(dbPath, lockPath, owner);
407
+ }
408
+
409
+ if (reclaimStaleLock(lockPath)) {
410
+ heldLocks.set(dbPath, lockPath);
411
+ return;
412
+ }
413
+ }
414
+
415
+ throw lockedError(dbPath, lockPath, readLockOwner(lockPath));
416
+ }
417
+
418
+ // releaseLock drops the claim taken by acquireLock. It checks that the
419
+ // lock still names this process before removing it: if a reclaimer
420
+ // elsewhere concluded we were dead and took the lock for itself, the file
421
+ // on disk is now that process's claim, and deleting it would hand the
422
+ // database to a third.
423
+ function releaseLock(dbPath) {
424
+ const lockPath = heldLocks.get(dbPath);
425
+ if (!lockPath) {
426
+ return;
427
+ }
428
+ heldLocks.delete(dbPath);
429
+
430
+ const owner = readLockOwner(lockPath);
431
+ if (owner && owner.pid !== process.pid) {
432
+ return;
433
+ }
434
+
435
+ try {
436
+ nodeFS.unlinkSync(lockPath);
437
+ } catch (error) {
438
+ if (error.code !== "ENOENT") {
439
+ storeErr(error);
440
+ }
441
+ }
442
+ }
443
+
444
+ const ioMethods = {
445
+ xCheckReservedLock: function (pFile, pOut) {
446
+ // A writer here holds the database exclusively for its lifetime,
447
+ // so no other connection can be holding a reserved lock. The
448
+ // answer is the same, though for a weaker reason, for the one case
449
+ // that escapes the open lock: a read-only opener takes no lock and
450
+ // has no reserved lock of its own to report.
451
+ storeErr();
452
+ wasm.poke32(pOut, 0);
453
+ return 0;
454
+ },
455
+
456
+ xClose: function (pFile) {
457
+ storeErr();
458
+ const key = Number(pFile);
459
+ const file = openFiles.get(key);
460
+ if (!file) {
461
+ return 0;
462
+ }
463
+ openFiles.delete(key);
464
+ try {
465
+ nodeFS.closeSync(file.fd);
466
+ if (file.locked) {
467
+ releaseLock(file.path);
468
+ }
469
+ if (file.flags & capi.SQLITE_OPEN_DELETEONCLOSE) {
470
+ try {
471
+ nodeFS.unlinkSync(file.path);
472
+ } catch (error) {
473
+ if (error.code !== "ENOENT") {
474
+ throw error;
475
+ }
476
+ }
477
+ }
478
+ return 0;
479
+ } catch (error) {
480
+ return storeErr(error, capi.SQLITE_IOERR);
481
+ }
482
+ },
483
+
484
+ xDeviceCharacteristics: function (pFile) {
485
+ // Claim nothing. A wallet database is worth more than the write
486
+ // batching SQLite would enable on the strength of these hints.
487
+ return 0;
488
+ },
489
+
490
+ xFileControl: function (pFile, opId, pArg) {
491
+ return capi.SQLITE_NOTFOUND;
492
+ },
493
+
494
+ xFileSize: function (pFile, pSz64) {
495
+ storeErr();
496
+ try {
497
+ const file = fileFor(pFile);
498
+ const size = nodeFS.fstatSync(file.fd).size;
499
+ wasm.poke64(pSz64, BigInt(size));
500
+ return 0;
501
+ } catch (error) {
502
+ return storeErr(error, capi.SQLITE_IOERR);
503
+ }
504
+ },
505
+
506
+ xLock: function (pFile, lockType) {
507
+ storeErr();
508
+ try {
509
+ fileFor(pFile).lockType = lockType;
510
+ return 0;
511
+ } catch (error) {
512
+ return storeErr(error, capi.SQLITE_IOERR);
513
+ }
514
+ },
515
+
516
+ xRead: function (pFile, pDest, n, offset64) {
517
+ storeErr();
518
+ try {
519
+ const file = fileFor(pFile);
520
+ const view = heapView(pDest, n);
521
+ const nRead = nodeFS.readSync(
522
+ file.fd,
523
+ view,
524
+ 0,
525
+ n,
526
+ Number(offset64)
527
+ );
528
+ if (nRead < n) {
529
+ // SQLite requires the tail of a short read to be zeroed.
530
+ view.fill(0, nRead, n);
531
+ return capi.SQLITE_IOERR_SHORT_READ;
532
+ }
533
+ return 0;
534
+ } catch (error) {
535
+ return storeErr(error, capi.SQLITE_IOERR_READ);
536
+ }
537
+ },
538
+
539
+ xSectorSize: function (pFile) {
540
+ return SECTOR_SIZE;
541
+ },
542
+
543
+ xSync: function (pFile, flags) {
544
+ storeErr();
545
+ try {
546
+ const file = fileFor(pFile);
547
+ // A durability barrier is the whole point of xSync, so this
548
+ // deliberately does not degrade to fdatasync on the DATAONLY
549
+ // hint: file size changes must reach the disk too.
550
+ nodeFS.fsyncSync(file.fd);
551
+
552
+ // The unix VFS syncs the parent directory at the first sync of
553
+ // a file it created, so the file's existence is durable before
554
+ // the database starts depending on it. SQLite cannot ask for
555
+ // that through the VFS interface, so it is done here.
556
+ if (file.needsDirSync) {
557
+ syncDir(nodePath.dirname(file.path));
558
+ file.needsDirSync = false;
559
+ }
560
+
561
+ return 0;
562
+ } catch (error) {
563
+ return storeErr(error, capi.SQLITE_IOERR_FSYNC);
564
+ }
565
+ },
566
+
567
+ xTruncate: function (pFile, sz64) {
568
+ storeErr();
569
+ try {
570
+ nodeFS.ftruncateSync(fileFor(pFile).fd, Number(sz64));
571
+ return 0;
572
+ } catch (error) {
573
+ return storeErr(
574
+ error,
575
+ ioErrForWrite(error, capi.SQLITE_IOERR_TRUNCATE)
576
+ );
577
+ }
578
+ },
579
+
580
+ xUnlock: function (pFile, lockType) {
581
+ storeErr();
582
+ try {
583
+ fileFor(pFile).lockType = lockType;
584
+ return 0;
585
+ } catch (error) {
586
+ return storeErr(error, capi.SQLITE_IOERR);
587
+ }
588
+ },
589
+
590
+ xWrite: function (pFile, pSrc, n, offset64) {
591
+ storeErr();
592
+ try {
593
+ const file = fileFor(pFile);
594
+ const nWrote = nodeFS.writeSync(
595
+ file.fd,
596
+ heapView(pSrc, n),
597
+ 0,
598
+ n,
599
+ Number(offset64)
600
+ );
601
+ if (nWrote !== n) {
602
+ throw new Error(
603
+ `short write: wrote ${nWrote} of ${n} bytes`
604
+ );
605
+ }
606
+ return 0;
607
+ } catch (error) {
608
+ return storeErr(
609
+ error,
610
+ ioErrForWrite(error, capi.SQLITE_IOERR_WRITE)
611
+ );
612
+ }
613
+ },
614
+ };
615
+
616
+ const nodeIoMethods = new capi.sqlite3_io_methods();
617
+ nodeIoMethods.$iVersion = 1;
618
+ sqlite3.vfs.installVfs({
619
+ io: { struct: nodeIoMethods, methods: ioMethods },
620
+ });
621
+
622
+ const vfsMethods = {
623
+ xAccess: function (pVfs, zName, flags, pOut) {
624
+ storeErr();
625
+ try {
626
+ const target = resolvePath(wasm.cstrToJs(zName));
627
+ let mode = nodeFS.constants.F_OK;
628
+ if (flags === capi.SQLITE_ACCESS_READWRITE) {
629
+ mode = nodeFS.constants.R_OK | nodeFS.constants.W_OK;
630
+ } else if (flags === capi.SQLITE_ACCESS_READ) {
631
+ mode = nodeFS.constants.R_OK;
632
+ }
633
+ nodeFS.accessSync(target, mode);
634
+ wasm.poke32(pOut, 1);
635
+ } catch (_) {
636
+ wasm.poke32(pOut, 0);
637
+ }
638
+ return 0;
639
+ },
640
+
641
+ xCurrentTime: function (pVfs, pOut) {
642
+ wasm.poke(
643
+ pOut,
644
+ 2440587.5 + new Date().getTime() / 86400000,
645
+ "double"
646
+ );
647
+ return 0;
648
+ },
649
+
650
+ xCurrentTimeInt64: function (pVfs, pOut) {
651
+ wasm.poke(pOut, 2440587.5 * 86400000 + new Date().getTime(), "i64");
652
+ return 0;
653
+ },
654
+
655
+ xDelete: function (pVfs, zName, doSyncDir) {
656
+ storeErr();
657
+ const target = resolvePath(wasm.cstrToJs(zName));
658
+ try {
659
+ nodeFS.unlinkSync(target);
660
+ } catch (error) {
661
+ if (error.code !== "ENOENT") {
662
+ storeErr(error);
663
+ return capi.SQLITE_IOERR_DELETE;
664
+ }
665
+ }
666
+
667
+ // SQLite sets this under synchronous=EXTRA, where in rollback
668
+ // journal mode the unlink above *is* the commit. Ignoring the flag
669
+ // would quietly give EXTRA no more than FULL, which is the same
670
+ // class of silent substitution the journal-mode readback exists to
671
+ // refuse.
672
+ if (doSyncDir) {
673
+ syncDir(nodePath.dirname(target));
674
+ }
675
+
676
+ return 0;
677
+ },
678
+
679
+ xFullPathname: function (pVfs, zName, nOut, pOut) {
680
+ // SQLite builds the journal and WAL names by appending suffixes to
681
+ // whatever this returns, so it must be the real absolute path.
682
+ // Otherwise a journal would land somewhere the database is not.
683
+ const full = resolvePath(wasm.cstrToJs(zName));
684
+ const scope = wasm.scopedAllocPush();
685
+ try {
686
+ const cFull = wasm.scopedAllocCString(full);
687
+ const written = wasm.cstrncpy(pOut, cFull, nOut);
688
+ return written < nOut ? 0 : capi.SQLITE_CANTOPEN;
689
+ } catch (error) {
690
+ storeErr(error);
691
+ return capi.SQLITE_CANTOPEN;
692
+ } finally {
693
+ wasm.scopedAllocPop(scope);
694
+ }
695
+ },
696
+
697
+ xGetLastError: function (pVfs, nOut, pOut) {
698
+ const error = popErr();
699
+ if (error) {
700
+ const scope = wasm.scopedAllocPush();
701
+ try {
702
+ const [cMsg, n] = wasm.scopedAllocCString(
703
+ error.message,
704
+ true
705
+ );
706
+ wasm.cstrncpy(pOut, cMsg, nOut);
707
+ if (n > nOut) {
708
+ wasm.poke8(wasm.ptr.add(pOut, nOut, -1), 0);
709
+ }
710
+ } catch (_) {
711
+ return capi.SQLITE_NOMEM;
712
+ } finally {
713
+ wasm.scopedAllocPop(scope);
714
+ }
715
+ }
716
+ return error ? error.sqlite3Rc || capi.SQLITE_IOERR : 0;
717
+ },
718
+
719
+ xOpen: function (pVfs, zName, pFile, flags, pOutFlags) {
720
+ storeErr();
721
+ let fd = -1;
722
+ let target = "";
723
+ let took = false;
724
+ let created = false;
725
+ try {
726
+ const isMainDB = !!(flags & capi.SQLITE_OPEN_MAIN_DB);
727
+ const create = !!(flags & capi.SQLITE_OPEN_CREATE);
728
+ const readOnly = !!(flags & capi.SQLITE_OPEN_READONLY);
729
+
730
+ // A null name asks for an anonymous temp file. SQLite deletes
731
+ // it on close, so a random name in the root directory is
732
+ // enough.
733
+ target =
734
+ zName && wasm.peek8(zName)
735
+ ? resolvePath(wasm.cstrToJs(zName))
736
+ : nodePath.join(
737
+ rootDir,
738
+ `etilqs_${nodeCrypto
739
+ .randomBytes(12)
740
+ .toString("hex")}`
741
+ );
742
+
743
+ if (create) {
744
+ // The host may not have created the data directory yet.
745
+ // Creating it here keeps a first run from failing on a
746
+ // missing parent rather than on anything meaningful.
747
+ nodeFS.mkdirSync(nodePath.dirname(target), {
748
+ recursive: true,
749
+ });
750
+ }
751
+
752
+ // The claim comes before the file does. Creating first leaves a
753
+ // window in which a second process racing the same first-time
754
+ // open truncates a database this one has already begun
755
+ // writing, and the loser of the lock race discovers it only
756
+ // afterwards, with the damage done.
757
+ if (isMainDB && !readOnly) {
758
+ acquireLock(target);
759
+ took = true;
760
+ }
761
+
762
+ let openMode;
763
+ if (readOnly) {
764
+ openMode = "r";
765
+ } else {
766
+ if (create) {
767
+ // "a+" would force every write to the end of the
768
+ // file, so create the file separately when it is
769
+ // missing and then open it read/write. The create is
770
+ // "wx" rather than "w" because an existing database
771
+ // must never be truncated on the way in; EEXIST here
772
+ // simply means someone got there first.
773
+ try {
774
+ nodeFS.closeSync(
775
+ nodeFS.openSync(target, "wx", 0o600)
776
+ );
777
+ created = true;
778
+ } catch (error) {
779
+ if (error.code !== "EEXIST") {
780
+ throw error;
781
+ }
782
+ }
783
+ }
784
+ openMode = "r+";
785
+ }
786
+
787
+ fd = nodeFS.openSync(target, openMode);
788
+
789
+ openFiles.set(Number(pFile), {
790
+ fd,
791
+ path: target,
792
+ flags,
793
+ locked: took,
794
+ lockType: capi.SQLITE_LOCK_NONE,
795
+
796
+ // A file this VFS created is not durably present until its
797
+ // parent directory is synced. The unix VFS does that at the
798
+ // first xSync of such a file, and SQLite does not signal it
799
+ // through the VFS interface, so it is tracked here.
800
+ needsDirSync: created,
801
+ });
802
+
803
+ const sq3File = new capi.sqlite3_file(pFile);
804
+ sq3File.$pMethods = nodeIoMethods.pointer;
805
+ sq3File.dispose();
806
+ if (pOutFlags) {
807
+ wasm.poke32(pOutFlags, flags);
808
+ }
809
+ return 0;
810
+ } catch (error) {
811
+ // SQLite leaves pMethods null for a failed xOpen and so never
812
+ // calls xClose for it, which means anything recorded here
813
+ // would outlive the descriptor it describes. A later
814
+ // allocation landing on the same pointer would then find a
815
+ // stale entry naming a closed fd.
816
+ openFiles.delete(Number(pFile));
817
+
818
+ if (fd >= 0) {
819
+ try {
820
+ nodeFS.closeSync(fd);
821
+ } catch (_) {}
822
+ }
823
+ if (took) {
824
+ releaseLock(target);
825
+ }
826
+ storeErr(error);
827
+ return capi.SQLITE_CANTOPEN;
828
+ }
829
+ },
830
+
831
+ xRandomness: function (pVfs, nOut, pOut) {
832
+ const bytes = nodeCrypto.randomBytes(nOut);
833
+ heapView(pOut, nOut).set(bytes);
834
+ return nOut;
835
+ },
836
+
837
+ xSleep: function (pVfs, microseconds) {
838
+ // SQLite calls this from inside a busy handler, so returning
839
+ // without sleeping would turn a busy timeout into a tight spin
840
+ // that burns a core for however long the timeout is. Atomics.wait
841
+ // blocks this thread for real, and it is safe precisely because
842
+ // this VFS only ever runs inside the dedicated SQLite worker,
843
+ // never on a thread anyone else's event loop depends on.
844
+ const ms = Math.max(1, Math.round(Number(microseconds) / 1000));
845
+ Atomics.wait(sleepSignal, 0, 0, ms);
846
+ return 0;
847
+ },
848
+ };
849
+
850
+ const nodeVfs = new capi.sqlite3_vfs();
851
+ nodeVfs.$iVersion = 2;
852
+ nodeVfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof;
853
+ nodeVfs.$mxPathname = 1024;
854
+ nodeVfs.addOnDispose((nodeVfs.$zName = wasm.allocCString(vfsName)));
855
+ sqlite3.vfs.installVfs({
856
+ vfs: { struct: nodeVfs, methods: vfsMethods },
857
+ });
858
+
859
+ const util = {
860
+ vfsName,
861
+ rootDir,
862
+ getVfs: () => nodeVfs,
863
+ openFileCount: () => openFiles.size,
864
+
865
+ // lockHolder names the process currently holding the open lock for a
866
+ // database, or null when nothing does.
867
+ //
868
+ // SQLite's core does not carry xGetLastError's message out to the
869
+ // caller, so a collision reaches the application as a bare
870
+ // SQLITE_CANTOPEN, indistinguishable from a missing directory or a
871
+ // permissions problem. This lets the worker say which process is
872
+ // holding the database instead.
873
+ lockHolder: (dbPath) => {
874
+ const owner = readLockOwner(
875
+ `${resolvePath(dbPath)}${LOCK_SUFFIX}`
876
+ );
877
+
878
+ return owner && ownerIsLive(owner) ? owner : null;
879
+ },
880
+ };
881
+
882
+ if (!installNodeFsVfs.installed) {
883
+ installNodeFsVfs.installed = new Map();
884
+ }
885
+ installNodeFsVfs.installed.set(vfsName, util);
886
+
887
+ return util;
888
+ }
889
+
890
+ module.exports = {
891
+ DEFAULT_VFS_NAME,
892
+ installNodeFsVfs,
893
+ isNodeHost,
894
+ };