@rivetkit/sqlite-vfs 2.1.0-rc.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.
package/src/vfs.ts ADDED
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * SQLite raw database with KV storage backend
3
+ *
4
+ * This module provides a SQLite API that uses a KV-backed VFS
5
+ * for storage. Each SqliteVfs instance is independent and can be
6
+ * used concurrently with other instances.
7
+ *
8
+ * Keep this VFS on direct VFS.Base callbacks for minimal wrapper overhead.
9
+ * Use @rivetkit/sqlite/src/FacadeVFS.js as the reference implementation for
10
+ * callback ABI and pointer/data conversion behavior.
11
+ * This implementation is optimized for single-writer semantics because each
12
+ * actor owns one SQLite database.
13
+ * SQLite invokes this VFS with byte-range file operations. This VFS maps those
14
+ * ranges onto fixed-size KV chunks keyed by file tag and chunk index.
15
+ * We intentionally rely on SQLite's pager cache for hot page reuse and do not
16
+ * add a second cache in this VFS. This avoids duplicate cache invalidation
17
+ * logic and keeps memory usage predictable for each actor.
18
+ */
19
+
20
+ import * as VFS from "@rivetkit/sqlite/src/VFS.js";
21
+ import {
22
+ Factory,
23
+ SQLITE_OPEN_CREATE,
24
+ SQLITE_OPEN_READWRITE,
25
+ SQLITE_ROW,
26
+ } from "@rivetkit/sqlite";
27
+ import { readFileSync } from "node:fs";
28
+ import { createRequire } from "node:module";
29
+ import {
30
+ CHUNK_SIZE,
31
+ FILE_TAG_JOURNAL,
32
+ FILE_TAG_MAIN,
33
+ FILE_TAG_SHM,
34
+ FILE_TAG_WAL,
35
+ getChunkKey,
36
+ getMetaKey,
37
+ type SqliteFileTag,
38
+ } from "./kv";
39
+ import {
40
+ FILE_META_VERSIONED,
41
+ CURRENT_VERSION,
42
+ } from "../schemas/file-meta/versioned";
43
+ import type { FileMeta } from "../schemas/file-meta/mod";
44
+ import type { KvVfsOptions } from "./types";
45
+
46
+ type SqliteEsmFactory = (config?: { wasmBinary?: ArrayBuffer | Uint8Array }) => Promise<unknown>;
47
+ type SQLite3Api = ReturnType<typeof Factory>;
48
+ type SqliteBindings = Parameters<SQLite3Api["bind_collection"]>[1];
49
+ type SqliteVfsRegistration = Parameters<SQLite3Api["vfs_register"]>[0];
50
+
51
+ interface SQLiteModule {
52
+ UTF8ToString: (ptr: number) => string;
53
+ HEAPU8: Uint8Array;
54
+ }
55
+
56
+ const TEXT_ENCODER = new TextEncoder();
57
+ const TEXT_DECODER = new TextDecoder();
58
+ const SQLITE_MAX_PATHNAME_BYTES = 64;
59
+
60
+ // Chunk keys encode the chunk index in 32 bits, so a file can span at most
61
+ // 2^32 chunks. At 4 KiB/chunk this yields a hard limit of 16 TiB.
62
+ const UINT32_SIZE = 0x100000000;
63
+ const MAX_CHUNK_INDEX = 0xffffffff;
64
+ const MAX_FILE_SIZE_BYTES = (MAX_CHUNK_INDEX + 1) * CHUNK_SIZE;
65
+ const MAX_FILE_SIZE_HI32 = Math.floor(MAX_FILE_SIZE_BYTES / UINT32_SIZE);
66
+ const MAX_FILE_SIZE_LO32 = MAX_FILE_SIZE_BYTES % UINT32_SIZE;
67
+
68
+ // libvfs captures this async/sync mask at registration time. Any VFS callback
69
+ // that returns a Promise must be listed here so SQLite uses async relays.
70
+ const SQLITE_ASYNC_METHODS = new Set([
71
+ "xOpen",
72
+ "xClose",
73
+ "xRead",
74
+ "xWrite",
75
+ "xTruncate",
76
+ "xSync",
77
+ "xFileSize",
78
+ "xDelete",
79
+ "xAccess",
80
+ ]);
81
+
82
+ interface LoadedSqliteRuntime {
83
+ sqlite3: SQLite3Api;
84
+ module: SQLiteModule;
85
+ }
86
+
87
+ function isSqliteEsmFactory(value: unknown): value is SqliteEsmFactory {
88
+ return typeof value === "function";
89
+ }
90
+
91
+ function isSQLiteModule(value: unknown): value is SQLiteModule {
92
+ if (!value || typeof value !== "object") {
93
+ return false;
94
+ }
95
+ const candidate = value as {
96
+ UTF8ToString?: unknown;
97
+ HEAPU8?: unknown;
98
+ };
99
+ return (
100
+ typeof candidate.UTF8ToString === "function" &&
101
+ candidate.HEAPU8 instanceof Uint8Array
102
+ );
103
+ }
104
+
105
+
106
+ /**
107
+ * Lazily load and instantiate the async SQLite module for this VFS instance.
108
+ * We do this on first open so actors that do not use SQLite do not pay module
109
+ * parse and wasm initialization cost at startup, and we pass wasmBinary
110
+ * explicitly so this works consistently in both ESM and CJS bundles.
111
+ */
112
+ async function loadSqliteRuntime(): Promise<LoadedSqliteRuntime> {
113
+ // Keep the module specifier assembled at runtime so TypeScript declaration
114
+ // generation does not try to typecheck this deep dist import path.
115
+ const sqliteModule = await import("@rivetkit/sqlite/dist/" + "wa-sqlite-async.mjs");
116
+ if (!isSqliteEsmFactory(sqliteModule.default)) {
117
+ throw new Error("Invalid SQLite ESM factory export");
118
+ }
119
+ const sqliteEsmFactory = sqliteModule.default;
120
+ const require = createRequire(import.meta.url);
121
+ const wasmPath = require.resolve("@rivetkit/sqlite/dist/wa-sqlite-async.wasm");
122
+ const wasmBinary = readFileSync(wasmPath);
123
+ const module = await sqliteEsmFactory({ wasmBinary });
124
+ if (!isSQLiteModule(module)) {
125
+ throw new Error("Invalid SQLite runtime module");
126
+ }
127
+ return {
128
+ sqlite3: Factory(module),
129
+ module,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Represents an open file
135
+ */
136
+ interface OpenFile {
137
+ /** File path */
138
+ path: string;
139
+ /** File kind tag used by compact key layout */
140
+ fileTag: SqliteFileTag;
141
+ /** Precomputed metadata key */
142
+ metaKey: Uint8Array;
143
+ /** File size in bytes */
144
+ size: number;
145
+ /** True when in-memory size has not been persisted yet */
146
+ metaDirty: boolean;
147
+ /** Open flags */
148
+ flags: number;
149
+ /** KV options for this file */
150
+ options: KvVfsOptions;
151
+ }
152
+
153
+ interface ResolvedFile {
154
+ options: KvVfsOptions;
155
+ fileTag: SqliteFileTag;
156
+ }
157
+
158
+ /**
159
+ * Encodes file metadata to a Uint8Array using BARE schema
160
+ */
161
+ function encodeFileMeta(size: number): Uint8Array {
162
+ const meta: FileMeta = { size: BigInt(size) };
163
+ return FILE_META_VERSIONED.serializeWithEmbeddedVersion(
164
+ meta,
165
+ CURRENT_VERSION,
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Decodes file metadata from a Uint8Array using BARE schema
171
+ */
172
+ function decodeFileMeta(data: Uint8Array): number {
173
+ const meta = FILE_META_VERSIONED.deserializeWithEmbeddedVersion(data);
174
+ return Number(meta.size);
175
+ }
176
+
177
+ function isValidFileSize(size: number): boolean {
178
+ return Number.isSafeInteger(size) && size >= 0 && size <= MAX_FILE_SIZE_BYTES;
179
+ }
180
+
181
+ /**
182
+ * Simple async mutex for serializing database operations
183
+ * @rivetkit/sqlite calls are not safe to run concurrently on one module instance
184
+ */
185
+ class AsyncMutex {
186
+ #locked = false;
187
+ #waiting: (() => void)[] = [];
188
+
189
+ async acquire(): Promise<void> {
190
+ while (this.#locked) {
191
+ await new Promise<void>((resolve) => this.#waiting.push(resolve));
192
+ }
193
+ this.#locked = true;
194
+ }
195
+
196
+ release(): void {
197
+ this.#locked = false;
198
+ const next = this.#waiting.shift();
199
+ if (next) {
200
+ next();
201
+ }
202
+ }
203
+
204
+ async run<T>(fn: () => Promise<T>): Promise<T> {
205
+ await this.acquire();
206
+ try {
207
+ return await fn();
208
+ } finally {
209
+ this.release();
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Database wrapper that provides a simplified SQLite API
216
+ */
217
+ export class Database {
218
+ readonly #sqlite3: SQLite3Api;
219
+ readonly #handle: number;
220
+ readonly #fileName: string;
221
+ readonly #onClose: () => void;
222
+ readonly #sqliteMutex: AsyncMutex;
223
+
224
+ constructor(
225
+ sqlite3: SQLite3Api,
226
+ handle: number,
227
+ fileName: string,
228
+ onClose: () => void,
229
+ sqliteMutex: AsyncMutex,
230
+ ) {
231
+ this.#sqlite3 = sqlite3;
232
+ this.#handle = handle;
233
+ this.#fileName = fileName;
234
+ this.#onClose = onClose;
235
+ this.#sqliteMutex = sqliteMutex;
236
+ }
237
+
238
+ /**
239
+ * Execute SQL with optional row callback
240
+ * @param sql - SQL statement to execute
241
+ * @param callback - Called for each result row with (row, columns)
242
+ */
243
+ async exec(sql: string, callback?: (row: unknown[], columns: string[]) => void): Promise<void> {
244
+ await this.#sqliteMutex.run(async () => {
245
+ await this.#sqlite3.exec(this.#handle, sql, callback);
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Execute a parameterized SQL statement (no result rows)
251
+ * @param sql - SQL statement with ? placeholders
252
+ * @param params - Parameter values to bind
253
+ */
254
+ async run(sql: string, params?: SqliteBindings): Promise<void> {
255
+ await this.#sqliteMutex.run(async () => {
256
+ for await (const stmt of this.#sqlite3.statements(this.#handle, sql)) {
257
+ if (params) {
258
+ this.#sqlite3.bind_collection(stmt, params);
259
+ }
260
+ while ((await this.#sqlite3.step(stmt)) === SQLITE_ROW) {
261
+ // Consume rows for statements that return results.
262
+ }
263
+ }
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Execute a parameterized SQL query and return results
269
+ * @param sql - SQL query with ? placeholders
270
+ * @param params - Parameter values to bind
271
+ * @returns Object with rows (array of arrays) and columns (column names)
272
+ */
273
+ async query(sql: string, params?: SqliteBindings): Promise<{ rows: unknown[][]; columns: string[] }> {
274
+ return this.#sqliteMutex.run(async () => {
275
+ const rows: unknown[][] = [];
276
+ let columns: string[] = [];
277
+ for await (const stmt of this.#sqlite3.statements(this.#handle, sql)) {
278
+ if (params) {
279
+ this.#sqlite3.bind_collection(stmt, params);
280
+ }
281
+
282
+ while ((await this.#sqlite3.step(stmt)) === SQLITE_ROW) {
283
+ if (columns.length === 0) {
284
+ columns = this.#sqlite3.column_names(stmt);
285
+ }
286
+ rows.push(this.#sqlite3.row(stmt));
287
+ }
288
+ }
289
+
290
+ return { rows, columns };
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Close the database
296
+ */
297
+ async close(): Promise<void> {
298
+ await this.#sqliteMutex.run(async () => {
299
+ await this.#sqlite3.close(this.#handle);
300
+ });
301
+ this.#onClose();
302
+ }
303
+
304
+ /**
305
+ * Get the raw @rivetkit/sqlite API (for advanced usage)
306
+ */
307
+ get sqlite3(): SQLite3Api {
308
+ return this.#sqlite3;
309
+ }
310
+
311
+ /**
312
+ * Get the raw database handle (for advanced usage)
313
+ */
314
+ get handle(): number {
315
+ return this.#handle;
316
+ }
317
+ }
318
+
319
+ /**
320
+ * SQLite VFS backed by KV storage.
321
+ *
322
+ * Each instance is independent and has its own @rivetkit/sqlite WASM module.
323
+ * This allows multiple instances to operate concurrently without interference.
324
+ */
325
+ export class SqliteVfs {
326
+ #sqlite3: SQLite3Api | null = null;
327
+ #sqliteSystem: SqliteSystem | null = null;
328
+ #initPromise: Promise<void> | null = null;
329
+ #openMutex = new AsyncMutex();
330
+ #sqliteMutex = new AsyncMutex();
331
+ #instanceId: string;
332
+ #destroyed = false;
333
+
334
+ constructor() {
335
+ // Generate unique instance ID for VFS name
336
+ this.#instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
337
+ }
338
+
339
+ /**
340
+ * Initialize @rivetkit/sqlite and VFS (called once per instance)
341
+ */
342
+ async #ensureInitialized(): Promise<void> {
343
+ if (this.#destroyed) {
344
+ throw new Error("SqliteVfs is closed");
345
+ }
346
+
347
+ // Fast path: already initialized
348
+ if (this.#sqlite3 && this.#sqliteSystem) {
349
+ return;
350
+ }
351
+
352
+ // Synchronously create the promise if not started
353
+ if (!this.#initPromise) {
354
+ this.#initPromise = (async () => {
355
+ const { sqlite3, module } = await loadSqliteRuntime();
356
+ if (this.#destroyed) {
357
+ return;
358
+ }
359
+ this.#sqlite3 = sqlite3;
360
+ this.#sqliteSystem = new SqliteSystem(
361
+ sqlite3,
362
+ module,
363
+ `kv-vfs-${this.#instanceId}`,
364
+ );
365
+ this.#sqliteSystem.register();
366
+ })();
367
+ }
368
+
369
+ // Wait for initialization
370
+ try {
371
+ await this.#initPromise;
372
+ } catch (error) {
373
+ this.#initPromise = null;
374
+ throw error;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Open a SQLite database using KV storage backend
380
+ *
381
+ * @param fileName - The database file name (typically the actor ID)
382
+ * @param options - KV storage operations for this database
383
+ * @returns A Database instance
384
+ */
385
+ async open(
386
+ fileName: string,
387
+ options: KvVfsOptions,
388
+ ): Promise<Database> {
389
+ if (this.#destroyed) {
390
+ throw new Error("SqliteVfs is closed");
391
+ }
392
+
393
+ // Serialize all open operations within this instance
394
+ await this.#openMutex.acquire();
395
+ try {
396
+ // Initialize @rivetkit/sqlite and SqliteSystem on first call
397
+ await this.#ensureInitialized();
398
+
399
+ if (!this.#sqlite3 || !this.#sqliteSystem) {
400
+ throw new Error("Failed to initialize SQLite");
401
+ }
402
+ const sqlite3 = this.#sqlite3;
403
+ const sqliteSystem = this.#sqliteSystem;
404
+
405
+ // Register this filename with its KV options
406
+ sqliteSystem.registerFile(fileName, options);
407
+
408
+ // Open database
409
+ const db = await this.#sqliteMutex.run(async () =>
410
+ sqlite3.open_v2(
411
+ fileName,
412
+ SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
413
+ sqliteSystem.name,
414
+ ),
415
+ );
416
+ // TODO: Benchmark PRAGMA tuning for KV-backed SQLite after open.
417
+ // Start with journal_mode=PERSIST and journal_size_limit to reduce
418
+ // journal churn on high-latency KV without introducing WAL.
419
+
420
+ // Create cleanup callback
421
+ const onClose = () => {
422
+ sqliteSystem.unregisterFile(fileName);
423
+ };
424
+
425
+ return new Database(
426
+ sqlite3,
427
+ db,
428
+ fileName,
429
+ onClose,
430
+ this.#sqliteMutex,
431
+ );
432
+ } finally {
433
+ this.#openMutex.release();
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Tears down this VFS instance and releases internal references.
439
+ */
440
+ async destroy(): Promise<void> {
441
+ if (this.#destroyed) {
442
+ return;
443
+ }
444
+ this.#destroyed = true;
445
+
446
+ const initPromise = this.#initPromise;
447
+ if (initPromise) {
448
+ try {
449
+ await initPromise;
450
+ } catch {
451
+ // Initialization failure already surfaced to caller.
452
+ }
453
+ }
454
+
455
+ if (this.#sqliteSystem) {
456
+ await this.#sqliteSystem.close();
457
+ }
458
+
459
+ this.#sqliteSystem = null;
460
+ this.#sqlite3 = null;
461
+ this.#initPromise = null;
462
+ }
463
+
464
+ /**
465
+ * Alias for destroy to align with DB-style lifecycle naming.
466
+ */
467
+ async close(): Promise<void> {
468
+ await this.destroy();
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Internal VFS implementation
474
+ */
475
+ class SqliteSystem implements SqliteVfsRegistration {
476
+ readonly name: string;
477
+ readonly mxPathName = SQLITE_MAX_PATHNAME_BYTES;
478
+ readonly mxPathname = SQLITE_MAX_PATHNAME_BYTES;
479
+ #mainFileName: string | null = null;
480
+ #mainFileOptions: KvVfsOptions | null = null;
481
+ readonly #openFiles: Map<number, OpenFile> = new Map();
482
+ readonly #sqlite3: SQLite3Api;
483
+ readonly #module: SQLiteModule;
484
+ #heapDataView: DataView;
485
+ #heapDataViewBuffer: ArrayBufferLike;
486
+
487
+ constructor(sqlite3: SQLite3Api, module: SQLiteModule, name: string) {
488
+ this.name = name;
489
+ this.#sqlite3 = sqlite3;
490
+ this.#module = module;
491
+ this.#heapDataViewBuffer = module.HEAPU8.buffer;
492
+ this.#heapDataView = new DataView(this.#heapDataViewBuffer);
493
+ }
494
+
495
+ async close(): Promise<void> {
496
+ this.#openFiles.clear();
497
+ this.#mainFileName = null;
498
+ this.#mainFileOptions = null;
499
+ }
500
+
501
+ isReady(): boolean {
502
+ return true;
503
+ }
504
+
505
+ hasAsyncMethod(methodName: string): boolean {
506
+ return SQLITE_ASYNC_METHODS.has(methodName);
507
+ }
508
+
509
+ /**
510
+ * Registers the VFS with SQLite
511
+ */
512
+ register(): void {
513
+ this.#sqlite3.vfs_register(this, false);
514
+ }
515
+
516
+ /**
517
+ * Registers a file with its KV options (before opening)
518
+ */
519
+ registerFile(fileName: string, options: KvVfsOptions): void {
520
+ if (!this.#mainFileName) {
521
+ this.#mainFileName = fileName;
522
+ this.#mainFileOptions = options;
523
+ return;
524
+ }
525
+
526
+ if (this.#mainFileName !== fileName) {
527
+ throw new Error(
528
+ `SqliteSystem is actor-scoped and expects one main file. Got ${fileName}, expected ${this.#mainFileName}.`,
529
+ );
530
+ }
531
+
532
+ this.#mainFileOptions = options;
533
+ }
534
+
535
+ /**
536
+ * Unregisters a file's KV options (after closing)
537
+ */
538
+ unregisterFile(fileName: string): void {
539
+ if (this.#mainFileName === fileName) {
540
+ this.#mainFileName = null;
541
+ this.#mainFileOptions = null;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Resolve file path to the actor's main DB file or known SQLite sidecars.
547
+ */
548
+ #resolveFile(path: string): ResolvedFile | null {
549
+ if (!this.#mainFileName || !this.#mainFileOptions) {
550
+ return null;
551
+ }
552
+
553
+ if (path === this.#mainFileName) {
554
+ return { options: this.#mainFileOptions, fileTag: FILE_TAG_MAIN };
555
+ }
556
+ if (path === `${this.#mainFileName}-journal`) {
557
+ return { options: this.#mainFileOptions, fileTag: FILE_TAG_JOURNAL };
558
+ }
559
+ if (path === `${this.#mainFileName}-wal`) {
560
+ return { options: this.#mainFileOptions, fileTag: FILE_TAG_WAL };
561
+ }
562
+ if (path === `${this.#mainFileName}-shm`) {
563
+ return { options: this.#mainFileOptions, fileTag: FILE_TAG_SHM };
564
+ }
565
+
566
+ return null;
567
+ }
568
+
569
+ #resolveFileOrThrow(path: string): ResolvedFile {
570
+ const resolved = this.#resolveFile(path);
571
+ if (resolved) {
572
+ return resolved;
573
+ }
574
+
575
+ if (!this.#mainFileName) {
576
+ throw new Error(`No KV options registered for file: ${path}`);
577
+ }
578
+
579
+ throw new Error(
580
+ `Unsupported SQLite file path ${path}. Expected one of ${this.#mainFileName}, ${this.#mainFileName}-journal, ${this.#mainFileName}-wal, ${this.#mainFileName}-shm.`,
581
+ );
582
+ }
583
+
584
+ #chunkKey(file: OpenFile, chunkIndex: number): Uint8Array {
585
+ return getChunkKey(file.fileTag, chunkIndex);
586
+ }
587
+
588
+ async xOpen(
589
+ _pVfs: number,
590
+ zName: number,
591
+ fileId: number,
592
+ flags: number,
593
+ pOutFlags: number,
594
+ ): Promise<number> {
595
+ const path = this.#decodeFilename(zName, flags);
596
+ if (!path) {
597
+ return VFS.SQLITE_CANTOPEN;
598
+ }
599
+
600
+ // Get the registered KV options for this file
601
+ // For journal/wal files, use the main database's options
602
+ const { options, fileTag } = this.#resolveFileOrThrow(path);
603
+ const metaKey = getMetaKey(fileTag);
604
+
605
+ // Get existing file size if the file exists
606
+ const sizeData = await options.get(metaKey);
607
+
608
+ let size: number;
609
+
610
+ if (sizeData) {
611
+ // File exists, use existing size
612
+ size = decodeFileMeta(sizeData);
613
+ if (!isValidFileSize(size)) {
614
+ return VFS.SQLITE_IOERR;
615
+ }
616
+ } else if (flags & VFS.SQLITE_OPEN_CREATE) {
617
+ // File doesn't exist, create it
618
+ size = 0;
619
+ await options.put(metaKey, encodeFileMeta(size));
620
+ } else {
621
+ // File doesn't exist and we're not creating it
622
+ return VFS.SQLITE_CANTOPEN;
623
+ }
624
+
625
+ // Store open file info with options
626
+ this.#openFiles.set(fileId, {
627
+ path,
628
+ fileTag,
629
+ metaKey,
630
+ size,
631
+ metaDirty: false,
632
+ flags,
633
+ options,
634
+ });
635
+
636
+ // Set output flags to the actual flags used.
637
+ this.#writeInt32(pOutFlags, flags);
638
+
639
+ return VFS.SQLITE_OK;
640
+ }
641
+
642
+ async xClose(fileId: number): Promise<number> {
643
+ const file = this.#openFiles.get(fileId);
644
+ if (!file) {
645
+ return VFS.SQLITE_OK;
646
+ }
647
+
648
+ // Delete-on-close files should skip metadata flush because the file will
649
+ // be removed immediately.
650
+ if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
651
+ await this.#delete(file.path);
652
+ this.#openFiles.delete(fileId);
653
+ return VFS.SQLITE_OK;
654
+ }
655
+
656
+ if (file.metaDirty) {
657
+ await file.options.put(file.metaKey, encodeFileMeta(file.size));
658
+ file.metaDirty = false;
659
+ }
660
+
661
+ this.#openFiles.delete(fileId);
662
+ return VFS.SQLITE_OK;
663
+ }
664
+
665
+ async xRead(
666
+ fileId: number,
667
+ pData: number,
668
+ iAmt: number,
669
+ iOffsetLo: number,
670
+ iOffsetHi: number,
671
+ ): Promise<number> {
672
+ if (iAmt === 0) {
673
+ return VFS.SQLITE_OK;
674
+ }
675
+
676
+ const file = this.#openFiles.get(fileId);
677
+ if (!file) {
678
+ return VFS.SQLITE_IOERR_READ;
679
+ }
680
+
681
+ const data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
682
+ const options = file.options;
683
+ const requestedLength = iAmt;
684
+ const iOffset = delegalize(iOffsetLo, iOffsetHi);
685
+ if (iOffset < 0) {
686
+ return VFS.SQLITE_IOERR_READ;
687
+ }
688
+ const fileSize = file.size;
689
+
690
+ // If offset is beyond file size, return short read with zeroed buffer
691
+ if (iOffset >= fileSize) {
692
+ data.fill(0);
693
+ return VFS.SQLITE_IOERR_SHORT_READ;
694
+ }
695
+
696
+ // Calculate which chunks we need to read
697
+ const startChunk = Math.floor(iOffset / CHUNK_SIZE);
698
+ const endChunk = Math.floor((iOffset + requestedLength - 1) / CHUNK_SIZE);
699
+
700
+ // Fetch all needed chunks
701
+ const chunkKeys: Uint8Array[] = [];
702
+ for (let i = startChunk; i <= endChunk; i++) {
703
+ chunkKeys.push(this.#chunkKey(file, i));
704
+ }
705
+
706
+ const chunks = await options.getBatch(chunkKeys);
707
+
708
+ // Copy data from chunks to output buffer
709
+ for (let i = startChunk; i <= endChunk; i++) {
710
+ const chunkData = chunks[i - startChunk];
711
+ const chunkOffset = i * CHUNK_SIZE;
712
+
713
+ // Calculate the range within this chunk
714
+ const readStart = Math.max(0, iOffset - chunkOffset);
715
+ const readEnd = Math.min(
716
+ CHUNK_SIZE,
717
+ iOffset + requestedLength - chunkOffset,
718
+ );
719
+
720
+ if (chunkData) {
721
+ // Copy available data
722
+ const sourceStart = readStart;
723
+ const sourceEnd = Math.min(readEnd, chunkData.length);
724
+ const destStart = chunkOffset + readStart - iOffset;
725
+
726
+ if (sourceEnd > sourceStart) {
727
+ data.set(chunkData.subarray(sourceStart, sourceEnd), destStart);
728
+ }
729
+
730
+ // Zero-fill if chunk is smaller than expected
731
+ if (sourceEnd < readEnd) {
732
+ const zeroStart = destStart + (sourceEnd - sourceStart);
733
+ const zeroEnd = destStart + (readEnd - readStart);
734
+ data.fill(0, zeroStart, zeroEnd);
735
+ }
736
+ } else {
737
+ // Chunk doesn't exist, zero-fill
738
+ const destStart = chunkOffset + readStart - iOffset;
739
+ const destEnd = destStart + (readEnd - readStart);
740
+ data.fill(0, destStart, destEnd);
741
+ }
742
+ }
743
+
744
+ // If we read less than requested (past EOF), return short read
745
+ const actualBytes = Math.min(requestedLength, fileSize - iOffset);
746
+ if (actualBytes < requestedLength) {
747
+ data.fill(0, actualBytes);
748
+ return VFS.SQLITE_IOERR_SHORT_READ;
749
+ }
750
+
751
+ return VFS.SQLITE_OK;
752
+ }
753
+
754
+ async xWrite(
755
+ fileId: number,
756
+ pData: number,
757
+ iAmt: number,
758
+ iOffsetLo: number,
759
+ iOffsetHi: number,
760
+ ): Promise<number> {
761
+ if (iAmt === 0) {
762
+ return VFS.SQLITE_OK;
763
+ }
764
+
765
+ const file = this.#openFiles.get(fileId);
766
+ if (!file) {
767
+ return VFS.SQLITE_IOERR_WRITE;
768
+ }
769
+
770
+ const data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
771
+ const iOffset = delegalize(iOffsetLo, iOffsetHi);
772
+ if (iOffset < 0) {
773
+ return VFS.SQLITE_IOERR_WRITE;
774
+ }
775
+ const options = file.options;
776
+ const writeLength = iAmt;
777
+ const writeEndOffset = iOffset + writeLength;
778
+ if (writeEndOffset > MAX_FILE_SIZE_BYTES) {
779
+ return VFS.SQLITE_IOERR_WRITE;
780
+ }
781
+
782
+ // Calculate which chunks we need to modify
783
+ const startChunk = Math.floor(iOffset / CHUNK_SIZE);
784
+ const endChunk = Math.floor((iOffset + writeLength - 1) / CHUNK_SIZE);
785
+
786
+ interface WritePlan {
787
+ chunkKey: Uint8Array;
788
+ chunkOffset: number;
789
+ writeStart: number;
790
+ writeEnd: number;
791
+ existingChunkIndex: number;
792
+ }
793
+
794
+ // Only fetch chunks where we must preserve existing prefix/suffix bytes.
795
+ const plans: WritePlan[] = [];
796
+ const chunkKeysToFetch: Uint8Array[] = [];
797
+ for (let i = startChunk; i <= endChunk; i++) {
798
+ const chunkOffset = i * CHUNK_SIZE;
799
+ const writeStart = Math.max(0, iOffset - chunkOffset);
800
+ const writeEnd = Math.min(
801
+ CHUNK_SIZE,
802
+ iOffset + writeLength - chunkOffset,
803
+ );
804
+ const existingBytesInChunk = Math.max(
805
+ 0,
806
+ Math.min(CHUNK_SIZE, file.size - chunkOffset),
807
+ );
808
+ const needsExisting = writeStart > 0 || existingBytesInChunk > writeEnd;
809
+ const chunkKey = this.#chunkKey(file, i);
810
+ let existingChunkIndex = -1;
811
+ if (needsExisting) {
812
+ existingChunkIndex = chunkKeysToFetch.length;
813
+ chunkKeysToFetch.push(chunkKey);
814
+ }
815
+ plans.push({
816
+ chunkKey,
817
+ chunkOffset,
818
+ writeStart,
819
+ writeEnd,
820
+ existingChunkIndex,
821
+ });
822
+ }
823
+
824
+ const existingChunks = chunkKeysToFetch.length > 0
825
+ ? await options.getBatch(chunkKeysToFetch)
826
+ : [];
827
+
828
+ // Prepare new chunk data
829
+ const entriesToWrite: [Uint8Array, Uint8Array][] = [];
830
+
831
+ for (const plan of plans) {
832
+ const existingChunk =
833
+ plan.existingChunkIndex >= 0
834
+ ? existingChunks[plan.existingChunkIndex]
835
+ : null;
836
+ // Create new chunk data
837
+ let newChunk: Uint8Array;
838
+ if (existingChunk) {
839
+ newChunk = new Uint8Array(Math.max(existingChunk.length, plan.writeEnd));
840
+ newChunk.set(existingChunk);
841
+ } else {
842
+ newChunk = new Uint8Array(plan.writeEnd);
843
+ }
844
+
845
+ // Copy data from input buffer to chunk
846
+ const sourceStart = plan.chunkOffset + plan.writeStart - iOffset;
847
+ const sourceEnd = sourceStart + (plan.writeEnd - plan.writeStart);
848
+ newChunk.set(data.subarray(sourceStart, sourceEnd), plan.writeStart);
849
+
850
+ entriesToWrite.push([plan.chunkKey, newChunk]);
851
+ }
852
+
853
+ // Update file size if we wrote past the end
854
+ const previousSize = file.size;
855
+ const newSize = Math.max(file.size, writeEndOffset);
856
+ if (newSize !== previousSize) {
857
+ file.size = newSize;
858
+ file.metaDirty = true;
859
+ }
860
+ if (file.metaDirty) {
861
+ entriesToWrite.push([file.metaKey, encodeFileMeta(file.size)]);
862
+ }
863
+
864
+ // Write all chunks and metadata
865
+ await options.putBatch(entriesToWrite);
866
+ if (file.metaDirty) {
867
+ file.metaDirty = false;
868
+ }
869
+
870
+ return VFS.SQLITE_OK;
871
+ }
872
+
873
+ async xTruncate(
874
+ fileId: number,
875
+ sizeLo: number,
876
+ sizeHi: number,
877
+ ): Promise<number> {
878
+ const file = this.#openFiles.get(fileId);
879
+ if (!file) {
880
+ return VFS.SQLITE_IOERR_TRUNCATE;
881
+ }
882
+
883
+ const size = delegalize(sizeLo, sizeHi);
884
+ if (size < 0 || size > MAX_FILE_SIZE_BYTES) {
885
+ return VFS.SQLITE_IOERR_TRUNCATE;
886
+ }
887
+ const options = file.options;
888
+
889
+ // If truncating to larger size, just update metadata
890
+ if (size >= file.size) {
891
+ if (size > file.size) {
892
+ file.size = size;
893
+ file.metaDirty = true;
894
+ await options.put(file.metaKey, encodeFileMeta(file.size));
895
+ file.metaDirty = false;
896
+ }
897
+ return VFS.SQLITE_OK;
898
+ }
899
+
900
+ // Calculate which chunks to delete
901
+ // Note: When size=0, lastChunkToKeep = floor(-1/4096) = -1, which means
902
+ // all chunks (starting from index 0) will be deleted in the loop below.
903
+ const lastChunkToKeep = Math.floor((size - 1) / CHUNK_SIZE);
904
+ const lastExistingChunk = Math.floor((file.size - 1) / CHUNK_SIZE);
905
+
906
+ // Delete chunks beyond the new size
907
+ const keysToDelete: Uint8Array[] = [];
908
+ for (let i = lastChunkToKeep + 1; i <= lastExistingChunk; i++) {
909
+ keysToDelete.push(this.#chunkKey(file, i));
910
+ }
911
+
912
+ if (keysToDelete.length > 0) {
913
+ await options.deleteBatch(keysToDelete);
914
+ }
915
+
916
+ // Truncate the last kept chunk if needed
917
+ if (size > 0 && size % CHUNK_SIZE !== 0) {
918
+ const lastChunkKey = this.#chunkKey(file, lastChunkToKeep);
919
+ const lastChunkData = await options.get(lastChunkKey);
920
+
921
+ if (lastChunkData && lastChunkData.length > size % CHUNK_SIZE) {
922
+ const truncatedChunk = lastChunkData.subarray(0, size % CHUNK_SIZE);
923
+ await options.put(lastChunkKey, truncatedChunk);
924
+ }
925
+ }
926
+
927
+ // Update file size
928
+ file.size = size;
929
+ file.metaDirty = true;
930
+ await options.put(file.metaKey, encodeFileMeta(file.size));
931
+ file.metaDirty = false;
932
+
933
+ return VFS.SQLITE_OK;
934
+ }
935
+
936
+ async xSync(fileId: number, _flags: number): Promise<number> {
937
+ const file = this.#openFiles.get(fileId);
938
+ if (!file || !file.metaDirty) {
939
+ return VFS.SQLITE_OK;
940
+ }
941
+
942
+ await file.options.put(file.metaKey, encodeFileMeta(file.size));
943
+ file.metaDirty = false;
944
+ return VFS.SQLITE_OK;
945
+ }
946
+
947
+ async xFileSize(fileId: number, pSize: number): Promise<number> {
948
+ const file = this.#openFiles.get(fileId);
949
+ if (!file) {
950
+ return VFS.SQLITE_IOERR_FSTAT;
951
+ }
952
+
953
+ // Set size as 64-bit integer.
954
+ this.#writeBigInt64(pSize, BigInt(file.size));
955
+ return VFS.SQLITE_OK;
956
+ }
957
+
958
+ async xDelete(_pVfs: number, zName: number, _syncDir: number): Promise<number> {
959
+ await this.#delete(this.#module.UTF8ToString(zName));
960
+ return VFS.SQLITE_OK;
961
+ }
962
+
963
+ /**
964
+ * Internal delete implementation
965
+ */
966
+ async #delete(path: string): Promise<void> {
967
+ const { options, fileTag } = this.#resolveFileOrThrow(path);
968
+ const metaKey = getMetaKey(fileTag);
969
+
970
+ // Get file size to find out how many chunks to delete
971
+ const sizeData = await options.get(metaKey);
972
+
973
+ if (!sizeData) {
974
+ // File doesn't exist, that's OK
975
+ return;
976
+ }
977
+
978
+ const size = decodeFileMeta(sizeData);
979
+
980
+ // Delete all chunks
981
+ const keysToDelete: Uint8Array[] = [metaKey];
982
+ const numChunks = Math.ceil(size / CHUNK_SIZE);
983
+ for (let i = 0; i < numChunks; i++) {
984
+ keysToDelete.push(getChunkKey(fileTag, i));
985
+ }
986
+
987
+ await options.deleteBatch(keysToDelete);
988
+ }
989
+
990
+ async xAccess(
991
+ _pVfs: number,
992
+ zName: number,
993
+ _flags: number,
994
+ pResOut: number,
995
+ ): Promise<number> {
996
+ // TODO: Measure how often xAccess runs during open and whether these
997
+ // existence checks add meaningful KV round-trip overhead. If they do,
998
+ // consider serving file existence from in-memory state.
999
+ const path = this.#module.UTF8ToString(zName);
1000
+ const resolved = this.#resolveFile(path);
1001
+ if (!resolved) {
1002
+ // File not registered, doesn't exist
1003
+ this.#writeInt32(pResOut, 0);
1004
+ return VFS.SQLITE_OK;
1005
+ }
1006
+
1007
+ const compactMetaKey = getMetaKey(resolved.fileTag);
1008
+ const metaData = await resolved.options.get(compactMetaKey);
1009
+
1010
+ // Set result: 1 if file exists, 0 otherwise
1011
+ this.#writeInt32(pResOut, metaData ? 1 : 0);
1012
+ return VFS.SQLITE_OK;
1013
+ }
1014
+
1015
+ xCheckReservedLock(_fileId: number, pResOut: number): number {
1016
+ // This VFS is actor-scoped with one writer, so there is no external
1017
+ // reserved lock state to report.
1018
+ this.#writeInt32(pResOut, 0);
1019
+ return VFS.SQLITE_OK;
1020
+ }
1021
+
1022
+ xLock(_fileId: number, _flags: number): number {
1023
+ return VFS.SQLITE_OK;
1024
+ }
1025
+
1026
+ xUnlock(_fileId: number, _flags: number): number {
1027
+ return VFS.SQLITE_OK;
1028
+ }
1029
+
1030
+ xFileControl(_fileId: number, _flags: number, _pArg: number): number {
1031
+ return VFS.SQLITE_NOTFOUND;
1032
+ }
1033
+
1034
+ xDeviceCharacteristics(_fileId: number): number {
1035
+ return 0;
1036
+ }
1037
+
1038
+ xFullPathname(_pVfs: number, zName: number, nOut: number, zOut: number): number {
1039
+ const path = this.#module.UTF8ToString(zName);
1040
+ const bytes = TEXT_ENCODER.encode(path);
1041
+ const out = this.#module.HEAPU8.subarray(zOut, zOut + nOut);
1042
+ if (bytes.length >= out.length) {
1043
+ return VFS.SQLITE_IOERR;
1044
+ }
1045
+ out.set(bytes, 0);
1046
+ out[bytes.length] = 0;
1047
+ return VFS.SQLITE_OK;
1048
+ }
1049
+
1050
+ #decodeFilename(zName: number, flags: number): string | null {
1051
+ if (!zName) {
1052
+ return null;
1053
+ }
1054
+
1055
+ if (flags & VFS.SQLITE_OPEN_URI) {
1056
+ // Decode SQLite URI filename layout: path\0key\0value\0...\0
1057
+ let pName = zName;
1058
+ let state: 1 | 2 | 3 | null = 1;
1059
+ const charCodes: number[] = [];
1060
+ while (state) {
1061
+ const charCode = this.#module.HEAPU8[pName++];
1062
+ if (charCode) {
1063
+ charCodes.push(charCode);
1064
+ continue;
1065
+ }
1066
+
1067
+ if (!this.#module.HEAPU8[pName]) {
1068
+ state = null;
1069
+ }
1070
+ switch (state) {
1071
+ case 1:
1072
+ charCodes.push("?".charCodeAt(0));
1073
+ state = 2;
1074
+ break;
1075
+ case 2:
1076
+ charCodes.push("=".charCodeAt(0));
1077
+ state = 3;
1078
+ break;
1079
+ case 3:
1080
+ charCodes.push("&".charCodeAt(0));
1081
+ state = 2;
1082
+ break;
1083
+ }
1084
+ }
1085
+ return TEXT_DECODER.decode(new Uint8Array(charCodes));
1086
+ }
1087
+
1088
+ return this.#module.UTF8ToString(zName);
1089
+ }
1090
+
1091
+ #heapView(): DataView {
1092
+ const heapBuffer = this.#module.HEAPU8.buffer;
1093
+ if (heapBuffer !== this.#heapDataViewBuffer) {
1094
+ this.#heapDataViewBuffer = heapBuffer;
1095
+ this.#heapDataView = new DataView(heapBuffer);
1096
+ }
1097
+ return this.#heapDataView;
1098
+ }
1099
+
1100
+ #writeInt32(pointer: number, value: number): void {
1101
+ const heapByteOffset = this.#module.HEAPU8.byteOffset + pointer;
1102
+ this.#heapView().setInt32(heapByteOffset, value, true);
1103
+ }
1104
+
1105
+ #writeBigInt64(pointer: number, value: bigint): void {
1106
+ const heapByteOffset = this.#module.HEAPU8.byteOffset + pointer;
1107
+ this.#heapView().setBigInt64(heapByteOffset, value, true);
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * Rebuild an i64 from Emscripten's legalized (lo32, hi32) pair.
1113
+ * SQLite passes file offsets and sizes this way. We decode into unsigned words
1114
+ * and reject values above the VFS max file size.
1115
+ */
1116
+ function delegalize(lo32: number, hi32: number): number {
1117
+ const hi = hi32 >>> 0;
1118
+ const lo = lo32 >>> 0;
1119
+ if (hi > MAX_FILE_SIZE_HI32) {
1120
+ return -1;
1121
+ }
1122
+ if (hi === MAX_FILE_SIZE_HI32 && lo > MAX_FILE_SIZE_LO32) {
1123
+ return -1;
1124
+ }
1125
+ return (hi * UINT32_SIZE) + lo;
1126
+ }