@rivetkit/sqlite-vfs 2.1.5 → 2.1.6-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 CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  getMetaKey,
37
37
  type SqliteFileTag,
38
38
  } from "./kv";
39
+ import { EMPTY_DB_PAGE } from "./generated/empty-db-page";
39
40
  import {
40
41
  FILE_META_VERSIONED,
41
42
  CURRENT_VERSION,
@@ -43,7 +44,41 @@ import {
43
44
  import type { FileMeta } from "../schemas/file-meta/mod";
44
45
  import type { KvVfsOptions } from "./types";
45
46
 
46
- type SqliteEsmFactory = (config?: { wasmBinary?: ArrayBuffer | Uint8Array }) => Promise<unknown>;
47
+ /**
48
+ * Common interface for database handles returned by ISqliteVfs.open().
49
+ * Both the concrete Database class and the pool's TrackedDatabase wrapper
50
+ * implement this, so consumers can use either interchangeably.
51
+ */
52
+ export interface IDatabase {
53
+ exec(
54
+ sql: string,
55
+ callback?: (row: unknown[], columns: string[]) => void,
56
+ ): Promise<void>;
57
+ run(sql: string, params?: SqliteBindings): Promise<void>;
58
+ query(
59
+ sql: string,
60
+ params?: SqliteBindings,
61
+ ): Promise<{ rows: unknown[][]; columns: string[] }>;
62
+ close(): Promise<void>;
63
+ readonly fileName: string;
64
+ }
65
+
66
+ /**
67
+ * Common interface for SQLite VFS backends. Both standalone SqliteVfs and
68
+ * PooledSqliteHandle implement this so callers can use either interchangeably.
69
+ */
70
+ export interface ISqliteVfs {
71
+ open(fileName: string, options: KvVfsOptions): Promise<IDatabase>;
72
+ destroy(): Promise<void>;
73
+ }
74
+
75
+ type SqliteEsmFactory = (config?: {
76
+ wasmBinary?: ArrayBuffer | Uint8Array;
77
+ instantiateWasm?: (
78
+ imports: WebAssembly.Imports,
79
+ receiveInstance: (instance: WebAssembly.Instance) => void,
80
+ ) => WebAssembly.Exports;
81
+ }) => Promise<unknown>;
47
82
  type SQLite3Api = ReturnType<typeof Factory>;
48
83
  type SqliteBindings = Parameters<SQLite3Api["bind_collection"]>[1];
49
84
  type SqliteVfsRegistration = Parameters<SQLite3Api["vfs_register"]>[0];
@@ -65,6 +100,55 @@ const MAX_FILE_SIZE_BYTES = (MAX_CHUNK_INDEX + 1) * CHUNK_SIZE;
65
100
  const MAX_FILE_SIZE_HI32 = Math.floor(MAX_FILE_SIZE_BYTES / UINT32_SIZE);
66
101
  const MAX_FILE_SIZE_LO32 = MAX_FILE_SIZE_BYTES % UINT32_SIZE;
67
102
 
103
+ // Maximum number of keys the KV backend accepts in a single deleteBatch or putBatch call.
104
+ const KV_MAX_BATCH_KEYS = 128;
105
+
106
+ // -- BATCH_ATOMIC and KV round trip documentation --
107
+ //
108
+ // KV round trips per actor database lifecycle:
109
+ //
110
+ // Open (new database):
111
+ // 1 putBatch -- xOpen pre-writes EMPTY_DB_PAGE + metadata (2 keys)
112
+ // PRAGMAs are in-memory, 0 KV ops
113
+ //
114
+ // Open (existing database / wake from sleep):
115
+ // 1 get -- xOpen reads metadata to determine file size
116
+ // PRAGMAs are in-memory, 0 KV ops
117
+ //
118
+ // First SQL operation (e.g., migration CREATE TABLE):
119
+ // 1 getBatch -- pager reads page 1 (database header)
120
+ // N getBatch -- pager reads additional pages as needed by the schema
121
+ // 1 putBatch -- BATCH_ATOMIC commit (all dirty pages + metadata)
122
+ //
123
+ // Subsequent writes (warm pager cache):
124
+ // 0 reads -- pages served from pager cache
125
+ // 1 putBatch -- BATCH_ATOMIC commit
126
+ //
127
+ // Subsequent reads (warm pager cache):
128
+ // 0 reads -- pages served from pager cache
129
+ // 0 writes -- SELECT-only, no dirty pages
130
+ //
131
+ // Large writes (> 127 dirty pages):
132
+ // BATCH_ATOMIC COMMIT returns SQLITE_IOERR, SQLite falls back to
133
+ // journal mode with multiple putBatch calls (each <= 128 keys).
134
+ //
135
+ // BATCH_ATOMIC requires SQLite's pager to use an in-memory journal.
136
+ // The pager only does this when dbSize > 0. For new databases, xOpen
137
+ // pre-writes a valid empty page (EMPTY_DB_PAGE) so dbSize is 1 from
138
+ // the start. Without this, the first transaction opens a real journal
139
+ // file, and locking_mode=EXCLUSIVE prevents it from ever being closed,
140
+ // permanently disabling BATCH_ATOMIC.
141
+ //
142
+ // See scripts/generate-empty-db-page.ts for how EMPTY_DB_PAGE is built.
143
+
144
+ // BATCH_ATOMIC capability flag returned by xDeviceCharacteristics.
145
+ const SQLITE_IOCAP_BATCH_ATOMIC = 0x4000;
146
+
147
+ // xFileControl opcodes for atomic write bracketing.
148
+ const SQLITE_FCNTL_BEGIN_ATOMIC_WRITE = 31;
149
+ const SQLITE_FCNTL_COMMIT_ATOMIC_WRITE = 32;
150
+ const SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE = 33;
151
+
68
152
  // libvfs captures this async/sync mask at registration time. Any VFS callback
69
153
  // that returns a Promise must be listed here so SQLite uses async relays.
70
154
  const SQLITE_ASYNC_METHODS = new Set([
@@ -77,6 +161,7 @@ const SQLITE_ASYNC_METHODS = new Set([
77
161
  "xFileSize",
78
162
  "xDelete",
79
163
  "xAccess",
164
+ "xFileControl",
80
165
  ]);
81
166
 
82
167
  interface LoadedSqliteRuntime {
@@ -102,30 +187,55 @@ function isSQLiteModule(value: unknown): value is SQLiteModule {
102
187
  );
103
188
  }
104
189
 
105
-
106
190
  /**
107
191
  * Lazily load and instantiate the async SQLite module for this VFS instance.
108
192
  * We do this on first open so actors that do not use SQLite do not pay module
109
193
  * parse and wasm initialization cost at startup, and we pass wasmBinary
110
194
  * explicitly so this works consistently in both ESM and CJS bundles.
111
195
  */
112
- async function loadSqliteRuntime(): Promise<LoadedSqliteRuntime> {
196
+ async function loadSqliteRuntime(
197
+ wasmModule?: WebAssembly.Module,
198
+ ): Promise<LoadedSqliteRuntime> {
113
199
  // Keep the module specifier assembled at runtime so TypeScript declaration
114
200
  // generation does not try to typecheck this deep dist import path.
115
201
  // Uses Array.join() instead of string concatenation to prevent esbuild/tsup
116
202
  // from constant-folding the expression at build time, which would allow
117
203
  // Turbopack to trace into the WASM package.
118
- const specifier = ["@rivetkit/sqlite", "dist", "wa-sqlite-async.mjs"].join("/");
204
+ const specifier = ["@rivetkit/sqlite", "dist", "wa-sqlite-async.mjs"].join(
205
+ "/",
206
+ );
119
207
  const sqliteModule = await import(specifier);
120
208
  if (!isSqliteEsmFactory(sqliteModule.default)) {
121
209
  throw new Error("Invalid SQLite ESM factory export");
122
210
  }
123
211
  const sqliteEsmFactory = sqliteModule.default;
124
- const require = createRequire(import.meta.url);
125
- const sqliteDistPath = "@rivetkit/sqlite/dist/";
126
- const wasmPath = require.resolve(sqliteDistPath + "wa-sqlite-async.wasm");
127
- const wasmBinary = readFileSync(wasmPath);
128
- const module = await sqliteEsmFactory({ wasmBinary });
212
+
213
+ let module: unknown;
214
+ if (wasmModule) {
215
+ // Use the pre-compiled WebAssembly.Module directly, skipping
216
+ // WebAssembly.compile. The Emscripten instantiateWasm callback lets us
217
+ // provide a module that has already been compiled and cached by the pool.
218
+ module = await sqliteEsmFactory({
219
+ instantiateWasm(
220
+ imports: WebAssembly.Imports,
221
+ receiveInstance: (instance: WebAssembly.Instance) => void,
222
+ ) {
223
+ WebAssembly.instantiate(wasmModule, imports).then((instance) => {
224
+ receiveInstance(instance);
225
+ });
226
+ return {} as WebAssembly.Exports;
227
+ },
228
+ });
229
+ } else {
230
+ const require = createRequire(import.meta.url);
231
+ const sqliteDistPath = "@rivetkit/sqlite/dist/";
232
+ const wasmPath = require.resolve(
233
+ sqliteDistPath + "wa-sqlite-async.wasm",
234
+ );
235
+ const wasmBinary = readFileSync(wasmPath);
236
+ module = await sqliteEsmFactory({ wasmBinary });
237
+ }
238
+
129
239
  if (!isSQLiteModule(module)) {
130
240
  throw new Error("Invalid SQLite runtime module");
131
241
  }
@@ -153,6 +263,12 @@ interface OpenFile {
153
263
  flags: number;
154
264
  /** KV options for this file */
155
265
  options: KvVfsOptions;
266
+ /** True while inside a BATCH_ATOMIC write bracket */
267
+ batchMode: boolean;
268
+ /** Buffered dirty pages during batch mode. Key is the chunk index. */
269
+ dirtyBuffer: Map<number, Uint8Array> | null;
270
+ /** File size saved at BEGIN_ATOMIC_WRITE for rollback */
271
+ savedFileSize: number;
156
272
  }
157
273
 
158
274
  interface ResolvedFile {
@@ -180,7 +296,9 @@ function decodeFileMeta(data: Uint8Array): number {
180
296
  }
181
297
 
182
298
  function isValidFileSize(size: number): boolean {
183
- return Number.isSafeInteger(size) && size >= 0 && size <= MAX_FILE_SIZE_BYTES;
299
+ return (
300
+ Number.isSafeInteger(size) && size >= 0 && size <= MAX_FILE_SIZE_BYTES
301
+ );
184
302
  }
185
303
 
186
304
  /**
@@ -219,18 +337,19 @@ class AsyncMutex {
219
337
  /**
220
338
  * Database wrapper that provides a simplified SQLite API
221
339
  */
222
- export class Database {
340
+ export class Database implements IDatabase {
223
341
  readonly #sqlite3: SQLite3Api;
224
342
  readonly #handle: number;
225
343
  readonly #fileName: string;
226
- readonly #onClose: () => void;
344
+ readonly #onClose: () => Promise<void>;
227
345
  readonly #sqliteMutex: AsyncMutex;
346
+ #closed = false;
228
347
 
229
348
  constructor(
230
349
  sqlite3: SQLite3Api,
231
350
  handle: number,
232
351
  fileName: string,
233
- onClose: () => void,
352
+ onClose: () => Promise<void>,
234
353
  sqliteMutex: AsyncMutex,
235
354
  ) {
236
355
  this.#sqlite3 = sqlite3;
@@ -245,7 +364,10 @@ export class Database {
245
364
  * @param sql - SQL statement to execute
246
365
  * @param callback - Called for each result row with (row, columns)
247
366
  */
248
- async exec(sql: string, callback?: (row: unknown[], columns: string[]) => void): Promise<void> {
367
+ async exec(
368
+ sql: string,
369
+ callback?: (row: unknown[], columns: string[]) => void,
370
+ ): Promise<void> {
249
371
  await this.#sqliteMutex.run(async () => {
250
372
  await this.#sqlite3.exec(this.#handle, sql, callback);
251
373
  });
@@ -258,7 +380,10 @@ export class Database {
258
380
  */
259
381
  async run(sql: string, params?: SqliteBindings): Promise<void> {
260
382
  await this.#sqliteMutex.run(async () => {
261
- for await (const stmt of this.#sqlite3.statements(this.#handle, sql)) {
383
+ for await (const stmt of this.#sqlite3.statements(
384
+ this.#handle,
385
+ sql,
386
+ )) {
262
387
  if (params) {
263
388
  this.#sqlite3.bind_collection(stmt, params);
264
389
  }
@@ -275,11 +400,17 @@ export class Database {
275
400
  * @param params - Parameter values to bind
276
401
  * @returns Object with rows (array of arrays) and columns (column names)
277
402
  */
278
- async query(sql: string, params?: SqliteBindings): Promise<{ rows: unknown[][]; columns: string[] }> {
403
+ async query(
404
+ sql: string,
405
+ params?: SqliteBindings,
406
+ ): Promise<{ rows: unknown[][]; columns: string[] }> {
279
407
  return this.#sqliteMutex.run(async () => {
280
408
  const rows: unknown[][] = [];
281
409
  let columns: string[] = [];
282
- for await (const stmt of this.#sqlite3.statements(this.#handle, sql)) {
410
+ for await (const stmt of this.#sqlite3.statements(
411
+ this.#handle,
412
+ sql,
413
+ )) {
283
414
  if (params) {
284
415
  this.#sqlite3.bind_collection(stmt, params);
285
416
  }
@@ -300,10 +431,22 @@ export class Database {
300
431
  * Close the database
301
432
  */
302
433
  async close(): Promise<void> {
434
+ if (this.#closed) {
435
+ return;
436
+ }
437
+ this.#closed = true;
438
+
303
439
  await this.#sqliteMutex.run(async () => {
304
440
  await this.#sqlite3.close(this.#handle);
305
441
  });
306
- this.#onClose();
442
+ await this.#onClose();
443
+ }
444
+
445
+ /**
446
+ * Get the database file name
447
+ */
448
+ get fileName(): string {
449
+ return this.#fileName;
307
450
  }
308
451
 
309
452
  /**
@@ -327,7 +470,7 @@ export class Database {
327
470
  * Each instance is independent and has its own @rivetkit/sqlite WASM module.
328
471
  * This allows multiple instances to operate concurrently without interference.
329
472
  */
330
- export class SqliteVfs {
473
+ export class SqliteVfs implements ISqliteVfs {
331
474
  #sqlite3: SQLite3Api | null = null;
332
475
  #sqliteSystem: SqliteSystem | null = null;
333
476
  #initPromise: Promise<void> | null = null;
@@ -335,10 +478,13 @@ export class SqliteVfs {
335
478
  #sqliteMutex = new AsyncMutex();
336
479
  #instanceId: string;
337
480
  #destroyed = false;
481
+ #openDatabases: Set<Database> = new Set();
482
+ #wasmModule?: WebAssembly.Module;
338
483
 
339
- constructor() {
484
+ constructor(wasmModule?: WebAssembly.Module) {
340
485
  // Generate unique instance ID for VFS name
341
- this.#instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
486
+ this.#instanceId = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
487
+ this.#wasmModule = wasmModule;
342
488
  }
343
489
 
344
490
  /**
@@ -357,7 +503,9 @@ export class SqliteVfs {
357
503
  // Synchronously create the promise if not started
358
504
  if (!this.#initPromise) {
359
505
  this.#initPromise = (async () => {
360
- const { sqlite3, module } = await loadSqliteRuntime();
506
+ const { sqlite3, module } = await loadSqliteRuntime(
507
+ this.#wasmModule,
508
+ );
361
509
  if (this.#destroyed) {
362
510
  return;
363
511
  }
@@ -387,10 +535,7 @@ export class SqliteVfs {
387
535
  * @param options - KV storage operations for this database
388
536
  * @returns A Database instance
389
537
  */
390
- async open(
391
- fileName: string,
392
- options: KvVfsOptions,
393
- ): Promise<Database> {
538
+ async open(fileName: string, options: KvVfsOptions): Promise<IDatabase> {
394
539
  if (this.#destroyed) {
395
540
  throw new Error("SqliteVfs is closed");
396
541
  }
@@ -398,6 +543,17 @@ export class SqliteVfs {
398
543
  // Serialize all open operations within this instance
399
544
  await this.#openMutex.acquire();
400
545
  try {
546
+ // Reject double-open of the same fileName. Two handles to the same
547
+ // file would have separate pager caches and no real locking
548
+ // (xLock/xUnlock are no-ops), causing silent data corruption.
549
+ for (const db of this.#openDatabases) {
550
+ if (db.fileName === fileName) {
551
+ throw new Error(
552
+ `SqliteVfs: fileName "${fileName}" is already open on this instance`,
553
+ );
554
+ }
555
+ }
556
+
401
557
  // Initialize @rivetkit/sqlite and SqliteSystem on first call
402
558
  await this.#ensureInitialized();
403
559
 
@@ -418,27 +574,102 @@ export class SqliteVfs {
418
574
  sqliteSystem.name,
419
575
  ),
420
576
  );
421
- // TODO: Benchmark PRAGMA tuning for KV-backed SQLite after open.
422
- // Start with journal_mode=PERSIST and journal_size_limit to reduce
423
- // journal churn on high-latency KV without introducing WAL.
577
+ // Single-writer optimizations for KV-backed SQLite. Each actor owns
578
+ // its database exclusively. BATCH_ATOMIC batches dirty pages into a
579
+ // single putBatch call instead of 5-7 individual KV round trips per
580
+ // write transaction.
581
+ //
582
+ // BATCH_ATOMIC requires an in-memory journal, which SQLite only uses
583
+ // when dbSize > 0. The xOpen handler pre-writes a valid empty page 1
584
+ // for new databases so this condition is satisfied from the start.
585
+ // See xOpen and scripts/generate-empty-db-page.ts for details.
586
+ await this.#sqliteMutex.run(async () => {
587
+ await sqlite3.exec(db, "PRAGMA page_size = 4096");
588
+ await sqlite3.exec(db, "PRAGMA journal_mode = DELETE");
589
+ await sqlite3.exec(db, "PRAGMA synchronous = NORMAL");
590
+ await sqlite3.exec(db, "PRAGMA temp_store = MEMORY");
591
+ await sqlite3.exec(db, "PRAGMA auto_vacuum = NONE");
592
+ await sqlite3.exec(db, "PRAGMA locking_mode = EXCLUSIVE");
593
+ });
424
594
 
425
- // Create cleanup callback
426
- const onClose = () => {
427
- sqliteSystem.unregisterFile(fileName);
595
+ // Wrap unregistration under #openMutex so it serializes with
596
+ // registerFile and prevents interleaving when short names recycle.
597
+ const onClose = async () => {
598
+ this.#openDatabases.delete(database);
599
+ await this.#openMutex.run(async () => {
600
+ sqliteSystem.unregisterFile(fileName);
601
+ });
428
602
  };
429
603
 
430
- return new Database(
604
+ const database = new Database(
431
605
  sqlite3,
432
606
  db,
433
607
  fileName,
434
608
  onClose,
435
609
  this.#sqliteMutex,
436
610
  );
611
+ this.#openDatabases.add(database);
612
+
613
+ return database;
437
614
  } finally {
438
615
  this.#openMutex.release();
439
616
  }
440
617
  }
441
618
 
619
+ /**
620
+ * Force-close all Database handles whose fileName exactly matches the
621
+ * given name. Snapshots the set to an array before iterating to avoid
622
+ * mutation during async iteration.
623
+ *
624
+ * Uses exact file name match because short names are numeric strings
625
+ * ('0', '1', ..., '10', '11', ...) and a prefix match like
626
+ * startsWith('1') would incorrectly match '10', '11', etc., causing
627
+ * cross-actor corruption. Sidecar files (-journal, -wal, -shm) are not
628
+ * tracked as separate Database handles, so prefix matching for sidecars
629
+ * is not needed.
630
+ */
631
+ async forceCloseByFileName(
632
+ fileName: string,
633
+ ): Promise<{ allSucceeded: boolean }> {
634
+ const snapshot = [...this.#openDatabases];
635
+ let allSucceeded = true;
636
+ for (const db of snapshot) {
637
+ if (db.fileName === fileName) {
638
+ try {
639
+ await db.close();
640
+ } catch {
641
+ allSucceeded = false;
642
+ // When close fails, onClose never fires, leaving orphaned
643
+ // entries in #openDatabases and #registeredFiles. Clean up
644
+ // manually so stale registrations don't accumulate.
645
+ this.#openDatabases.delete(db);
646
+ const sqliteSystem = this.#sqliteSystem;
647
+ if (sqliteSystem) {
648
+ await this.#openMutex.run(async () => {
649
+ sqliteSystem.unregisterFile(db.fileName);
650
+ });
651
+ }
652
+ }
653
+ }
654
+ }
655
+ return { allSucceeded };
656
+ }
657
+
658
+ /**
659
+ * Force-close all open Database handles. Best-effort: errors are
660
+ * swallowed so this is safe to call during instance teardown.
661
+ */
662
+ async forceCloseAll(): Promise<void> {
663
+ const snapshot = [...this.#openDatabases];
664
+ for (const db of snapshot) {
665
+ try {
666
+ await db.close();
667
+ } catch {
668
+ // Best-effort teardown. Swallow errors.
669
+ }
670
+ }
671
+ }
672
+
442
673
  /**
443
674
  * Tears down this VFS instance and releases internal references.
444
675
  */
@@ -481,8 +712,7 @@ class SqliteSystem implements SqliteVfsRegistration {
481
712
  readonly name: string;
482
713
  readonly mxPathName = SQLITE_MAX_PATHNAME_BYTES;
483
714
  readonly mxPathname = SQLITE_MAX_PATHNAME_BYTES;
484
- #mainFileName: string | null = null;
485
- #mainFileOptions: KvVfsOptions | null = null;
715
+ readonly #registeredFiles: Map<string, KvVfsOptions> = new Map();
486
716
  readonly #openFiles: Map<number, OpenFile> = new Map();
487
717
  readonly #sqlite3: SQLite3Api;
488
718
  readonly #module: SQLiteModule;
@@ -499,8 +729,7 @@ class SqliteSystem implements SqliteVfsRegistration {
499
729
 
500
730
  async close(): Promise<void> {
501
731
  this.#openFiles.clear();
502
- this.#mainFileName = null;
503
- this.#mainFileOptions = null;
732
+ this.#registeredFiles.clear();
504
733
  }
505
734
 
506
735
  isReady(): boolean {
@@ -519,53 +748,50 @@ class SqliteSystem implements SqliteVfsRegistration {
519
748
  }
520
749
 
521
750
  /**
522
- * Registers a file with its KV options (before opening)
751
+ * Registers a file with its KV options (before opening).
523
752
  */
524
753
  registerFile(fileName: string, options: KvVfsOptions): void {
525
- if (!this.#mainFileName) {
526
- this.#mainFileName = fileName;
527
- this.#mainFileOptions = options;
528
- return;
529
- }
530
-
531
- if (this.#mainFileName !== fileName) {
532
- throw new Error(
533
- `SqliteSystem is actor-scoped and expects one main file. Got ${fileName}, expected ${this.#mainFileName}.`,
534
- );
535
- }
536
-
537
- this.#mainFileOptions = options;
754
+ this.#registeredFiles.set(fileName, options);
538
755
  }
539
756
 
540
757
  /**
541
- * Unregisters a file's KV options (after closing)
758
+ * Unregisters a file's KV options (after closing).
542
759
  */
543
760
  unregisterFile(fileName: string): void {
544
- if (this.#mainFileName === fileName) {
545
- this.#mainFileName = null;
546
- this.#mainFileOptions = null;
547
- }
761
+ this.#registeredFiles.delete(fileName);
548
762
  }
549
763
 
550
764
  /**
551
- * Resolve file path to the actor's main DB file or known SQLite sidecars.
765
+ * Resolve file path to a registered database file or one of its SQLite
766
+ * sidecars (-journal, -wal, -shm). File tags are reused across files
767
+ * because each file's KvVfsOptions routes to a separate KV namespace.
552
768
  */
553
769
  #resolveFile(path: string): ResolvedFile | null {
554
- if (!this.#mainFileName || !this.#mainFileOptions) {
555
- return null;
770
+ // Direct match: O(1) lookup for main database file.
771
+ const directOptions = this.#registeredFiles.get(path);
772
+ if (directOptions) {
773
+ return { options: directOptions, fileTag: FILE_TAG_MAIN };
556
774
  }
557
775
 
558
- if (path === this.#mainFileName) {
559
- return { options: this.#mainFileOptions, fileTag: FILE_TAG_MAIN };
560
- }
561
- if (path === `${this.#mainFileName}-journal`) {
562
- return { options: this.#mainFileOptions, fileTag: FILE_TAG_JOURNAL };
563
- }
564
- if (path === `${this.#mainFileName}-wal`) {
565
- return { options: this.#mainFileOptions, fileTag: FILE_TAG_WAL };
566
- }
567
- if (path === `${this.#mainFileName}-shm`) {
568
- return { options: this.#mainFileOptions, fileTag: FILE_TAG_SHM };
776
+ // Sidecar match: strip each known suffix and check the base name.
777
+ if (path.endsWith("-journal")) {
778
+ const baseName = path.slice(0, -8);
779
+ const options = this.#registeredFiles.get(baseName);
780
+ if (options) {
781
+ return { options, fileTag: FILE_TAG_JOURNAL };
782
+ }
783
+ } else if (path.endsWith("-wal")) {
784
+ const baseName = path.slice(0, -4);
785
+ const options = this.#registeredFiles.get(baseName);
786
+ if (options) {
787
+ return { options, fileTag: FILE_TAG_WAL };
788
+ }
789
+ } else if (path.endsWith("-shm")) {
790
+ const baseName = path.slice(0, -4);
791
+ const options = this.#registeredFiles.get(baseName);
792
+ if (options) {
793
+ return { options, fileTag: FILE_TAG_SHM };
794
+ }
569
795
  }
570
796
 
571
797
  return null;
@@ -577,12 +803,13 @@ class SqliteSystem implements SqliteVfsRegistration {
577
803
  return resolved;
578
804
  }
579
805
 
580
- if (!this.#mainFileName) {
806
+ if (this.#registeredFiles.size === 0) {
581
807
  throw new Error(`No KV options registered for file: ${path}`);
582
808
  }
583
809
 
810
+ const registered = Array.from(this.#registeredFiles.keys()).join(", ");
584
811
  throw new Error(
585
- `Unsupported SQLite file path ${path}. Expected one of ${this.#mainFileName}, ${this.#mainFileName}-journal, ${this.#mainFileName}-wal, ${this.#mainFileName}-shm.`,
812
+ `Unsupported SQLite file path ${path}. Registered base names: ${registered}.`,
586
813
  );
587
814
  }
588
815
 
@@ -608,7 +835,12 @@ class SqliteSystem implements SqliteVfsRegistration {
608
835
  const metaKey = getMetaKey(fileTag);
609
836
 
610
837
  // Get existing file size if the file exists
611
- const sizeData = await options.get(metaKey);
838
+ let sizeData: Uint8Array | null;
839
+ try {
840
+ sizeData = await options.get(metaKey);
841
+ } catch {
842
+ return VFS.SQLITE_CANTOPEN;
843
+ }
612
844
 
613
845
  let size: number;
614
846
 
@@ -619,9 +851,37 @@ class SqliteSystem implements SqliteVfsRegistration {
619
851
  return VFS.SQLITE_IOERR;
620
852
  }
621
853
  } else if (flags & VFS.SQLITE_OPEN_CREATE) {
622
- // File doesn't exist, create it
623
- size = 0;
624
- await options.put(metaKey, encodeFileMeta(size));
854
+ if (fileTag === FILE_TAG_MAIN) {
855
+ // Pre-write a valid empty database page so SQLite sees
856
+ // dbSize > 0 on first read. This enables BATCH_ATOMIC
857
+ // from the very first write transaction. Without this,
858
+ // SQLite's pager opens a real journal file for the first
859
+ // write (because jrnlBufferSize returns a positive value
860
+ // when dbSize == 0), and with locking_mode=EXCLUSIVE that
861
+ // real journal is never closed, permanently disabling
862
+ // batch atomic writes.
863
+ //
864
+ // The page is generated by scripts/generate-empty-header.ts
865
+ // using the same wa-sqlite WASM binary we ship.
866
+ const chunkKey = getChunkKey(fileTag, 0);
867
+ size = EMPTY_DB_PAGE.length;
868
+ try {
869
+ await options.putBatch([
870
+ [chunkKey, EMPTY_DB_PAGE],
871
+ [metaKey, encodeFileMeta(size)],
872
+ ]);
873
+ } catch {
874
+ return VFS.SQLITE_CANTOPEN;
875
+ }
876
+ } else {
877
+ // Sidecar files (journal, WAL, SHM) start empty.
878
+ size = 0;
879
+ try {
880
+ await options.put(metaKey, encodeFileMeta(size));
881
+ } catch {
882
+ return VFS.SQLITE_CANTOPEN;
883
+ }
884
+ }
625
885
  } else {
626
886
  // File doesn't exist and we're not creating it
627
887
  return VFS.SQLITE_CANTOPEN;
@@ -636,6 +896,9 @@ class SqliteSystem implements SqliteVfsRegistration {
636
896
  metaDirty: false,
637
897
  flags,
638
898
  options,
899
+ batchMode: false,
900
+ dirtyBuffer: null,
901
+ savedFileSize: 0,
639
902
  });
640
903
 
641
904
  // Set output flags to the actual flags used.
@@ -650,17 +913,22 @@ class SqliteSystem implements SqliteVfsRegistration {
650
913
  return VFS.SQLITE_OK;
651
914
  }
652
915
 
653
- // Delete-on-close files should skip metadata flush because the file will
654
- // be removed immediately.
655
- if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
656
- await this.#delete(file.path);
916
+ try {
917
+ // Delete-on-close files should skip metadata flush because the file
918
+ // will be removed immediately.
919
+ if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
920
+ await this.#delete(file.path);
921
+ } else if (file.metaDirty) {
922
+ await file.options.put(
923
+ file.metaKey,
924
+ encodeFileMeta(file.size),
925
+ );
926
+ file.metaDirty = false;
927
+ }
928
+ } catch {
929
+ // Always clean up the file handle even if the KV operation fails.
657
930
  this.#openFiles.delete(fileId);
658
- return VFS.SQLITE_OK;
659
- }
660
-
661
- if (file.metaDirty) {
662
- await file.options.put(file.metaKey, encodeFileMeta(file.size));
663
- file.metaDirty = false;
931
+ return VFS.SQLITE_IOERR;
664
932
  }
665
933
 
666
934
  this.#openFiles.delete(fileId);
@@ -683,7 +951,7 @@ class SqliteSystem implements SqliteVfsRegistration {
683
951
  return VFS.SQLITE_IOERR_READ;
684
952
  }
685
953
 
686
- const data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
954
+ let data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
687
955
  const options = file.options;
688
956
  const requestedLength = iAmt;
689
957
  const iOffset = delegalize(iOffsetLo, iOffsetHi);
@@ -700,19 +968,44 @@ class SqliteSystem implements SqliteVfsRegistration {
700
968
 
701
969
  // Calculate which chunks we need to read
702
970
  const startChunk = Math.floor(iOffset / CHUNK_SIZE);
703
- const endChunk = Math.floor((iOffset + requestedLength - 1) / CHUNK_SIZE);
971
+ const endChunk = Math.floor(
972
+ (iOffset + requestedLength - 1) / CHUNK_SIZE,
973
+ );
704
974
 
705
- // Fetch all needed chunks
975
+ // Fetch needed chunks, checking dirty buffer first in batch mode.
706
976
  const chunkKeys: Uint8Array[] = [];
977
+ const chunkIndexToBuffered: Map<number, Uint8Array> = new Map();
707
978
  for (let i = startChunk; i <= endChunk; i++) {
979
+ // In batch mode, serve from dirty buffer if available.
980
+ if (file.batchMode && file.dirtyBuffer) {
981
+ const buffered = file.dirtyBuffer.get(i);
982
+ if (buffered) {
983
+ chunkIndexToBuffered.set(i, buffered);
984
+ continue;
985
+ }
986
+ }
708
987
  chunkKeys.push(this.#chunkKey(file, i));
709
988
  }
710
989
 
711
- const chunks = await options.getBatch(chunkKeys);
990
+ let kvChunks: (Uint8Array | null)[];
991
+ try {
992
+ kvChunks =
993
+ chunkKeys.length > 0
994
+ ? await options.getBatch(chunkKeys)
995
+ : [];
996
+ } catch {
997
+ return VFS.SQLITE_IOERR_READ;
998
+ }
999
+
1000
+ // Re-read HEAPU8 after await to defend against buffer detachment
1001
+ // from memory.grow() that may have occurred during getBatch.
1002
+ data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
712
1003
 
713
1004
  // Copy data from chunks to output buffer
1005
+ let kvIdx = 0;
714
1006
  for (let i = startChunk; i <= endChunk; i++) {
715
- const chunkData = chunks[i - startChunk];
1007
+ const chunkData =
1008
+ chunkIndexToBuffered.get(i) ?? kvChunks[kvIdx++];
716
1009
  const chunkOffset = i * CHUNK_SIZE;
717
1010
 
718
1011
  // Calculate the range within this chunk
@@ -729,7 +1022,10 @@ class SqliteSystem implements SqliteVfsRegistration {
729
1022
  const destStart = chunkOffset + readStart - iOffset;
730
1023
 
731
1024
  if (sourceEnd > sourceStart) {
732
- data.set(chunkData.subarray(sourceStart, sourceEnd), destStart);
1025
+ data.set(
1026
+ chunkData.subarray(sourceStart, sourceEnd),
1027
+ destStart,
1028
+ );
733
1029
  }
734
1030
 
735
1031
  // Zero-fill if chunk is smaller than expected
@@ -772,7 +1068,7 @@ class SqliteSystem implements SqliteVfsRegistration {
772
1068
  return VFS.SQLITE_IOERR_WRITE;
773
1069
  }
774
1070
 
775
- const data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
1071
+ let data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
776
1072
  const iOffset = delegalize(iOffsetLo, iOffsetHi);
777
1073
  if (iOffset < 0) {
778
1074
  return VFS.SQLITE_IOERR_WRITE;
@@ -788,6 +1084,34 @@ class SqliteSystem implements SqliteVfsRegistration {
788
1084
  const startChunk = Math.floor(iOffset / CHUNK_SIZE);
789
1085
  const endChunk = Math.floor((iOffset + writeLength - 1) / CHUNK_SIZE);
790
1086
 
1087
+ // Batch mode: buffer pages in dirtyBuffer instead of writing to KV.
1088
+ // COMMIT_ATOMIC_WRITE flushes the buffer in a single putBatch.
1089
+ if (file.batchMode && file.dirtyBuffer) {
1090
+ for (let i = startChunk; i <= endChunk; i++) {
1091
+ const chunkOffset = i * CHUNK_SIZE;
1092
+ const sourceStart = Math.max(0, chunkOffset - iOffset);
1093
+ const sourceEnd = Math.min(
1094
+ writeLength,
1095
+ chunkOffset + CHUNK_SIZE - iOffset,
1096
+ );
1097
+ // .slice() creates an independent copy that won't be
1098
+ // invalidated by memory.grow() after an await.
1099
+ file.dirtyBuffer.set(
1100
+ i,
1101
+ data.subarray(sourceStart, sourceEnd).slice(),
1102
+ );
1103
+ }
1104
+
1105
+ // Update file size if write extends the file
1106
+ const newSize = Math.max(file.size, writeEndOffset);
1107
+ if (newSize !== file.size) {
1108
+ file.size = newSize;
1109
+ file.metaDirty = true;
1110
+ }
1111
+
1112
+ return VFS.SQLITE_OK;
1113
+ }
1114
+
791
1115
  interface WritePlan {
792
1116
  chunkKey: Uint8Array;
793
1117
  chunkOffset: number;
@@ -810,7 +1134,8 @@ class SqliteSystem implements SqliteVfsRegistration {
810
1134
  0,
811
1135
  Math.min(CHUNK_SIZE, file.size - chunkOffset),
812
1136
  );
813
- const needsExisting = writeStart > 0 || existingBytesInChunk > writeEnd;
1137
+ const needsExisting =
1138
+ writeStart > 0 || existingBytesInChunk > writeEnd;
814
1139
  const chunkKey = this.#chunkKey(file, i);
815
1140
  let existingChunkIndex = -1;
816
1141
  if (needsExisting) {
@@ -826,9 +1151,19 @@ class SqliteSystem implements SqliteVfsRegistration {
826
1151
  });
827
1152
  }
828
1153
 
829
- const existingChunks = chunkKeysToFetch.length > 0
830
- ? await options.getBatch(chunkKeysToFetch)
831
- : [];
1154
+ let existingChunks: (Uint8Array | null)[];
1155
+ try {
1156
+ existingChunks =
1157
+ chunkKeysToFetch.length > 0
1158
+ ? await options.getBatch(chunkKeysToFetch)
1159
+ : [];
1160
+ } catch {
1161
+ return VFS.SQLITE_IOERR_WRITE;
1162
+ }
1163
+
1164
+ // Re-read HEAPU8 after await to defend against buffer detachment
1165
+ // from memory.grow() that may have occurred during getBatch.
1166
+ data = this.#module.HEAPU8.subarray(pData, pData + iAmt);
832
1167
 
833
1168
  // Prepare new chunk data
834
1169
  const entriesToWrite: [Uint8Array, Uint8Array][] = [];
@@ -841,7 +1176,9 @@ class SqliteSystem implements SqliteVfsRegistration {
841
1176
  // Create new chunk data
842
1177
  let newChunk: Uint8Array;
843
1178
  if (existingChunk) {
844
- newChunk = new Uint8Array(Math.max(existingChunk.length, plan.writeEnd));
1179
+ newChunk = new Uint8Array(
1180
+ Math.max(existingChunk.length, plan.writeEnd),
1181
+ );
845
1182
  newChunk.set(existingChunk);
846
1183
  } else {
847
1184
  newChunk = new Uint8Array(plan.writeEnd);
@@ -850,13 +1187,17 @@ class SqliteSystem implements SqliteVfsRegistration {
850
1187
  // Copy data from input buffer to chunk
851
1188
  const sourceStart = plan.chunkOffset + plan.writeStart - iOffset;
852
1189
  const sourceEnd = sourceStart + (plan.writeEnd - plan.writeStart);
853
- newChunk.set(data.subarray(sourceStart, sourceEnd), plan.writeStart);
1190
+ newChunk.set(
1191
+ data.subarray(sourceStart, sourceEnd),
1192
+ plan.writeStart,
1193
+ );
854
1194
 
855
1195
  entriesToWrite.push([plan.chunkKey, newChunk]);
856
1196
  }
857
1197
 
858
1198
  // Update file size if we wrote past the end
859
1199
  const previousSize = file.size;
1200
+ const previousMetaDirty = file.metaDirty;
860
1201
  const newSize = Math.max(file.size, writeEndOffset);
861
1202
  if (newSize !== previousSize) {
862
1203
  file.size = newSize;
@@ -867,7 +1208,13 @@ class SqliteSystem implements SqliteVfsRegistration {
867
1208
  }
868
1209
 
869
1210
  // Write all chunks and metadata
870
- await options.putBatch(entriesToWrite);
1211
+ try {
1212
+ await options.putBatch(entriesToWrite);
1213
+ } catch {
1214
+ file.size = previousSize;
1215
+ file.metaDirty = previousMetaDirty;
1216
+ return VFS.SQLITE_IOERR_WRITE;
1217
+ }
871
1218
  if (file.metaDirty) {
872
1219
  file.metaDirty = false;
873
1220
  }
@@ -894,9 +1241,17 @@ class SqliteSystem implements SqliteVfsRegistration {
894
1241
  // If truncating to larger size, just update metadata
895
1242
  if (size >= file.size) {
896
1243
  if (size > file.size) {
1244
+ const previousSize = file.size;
1245
+ const previousMetaDirty = file.metaDirty;
897
1246
  file.size = size;
898
1247
  file.metaDirty = true;
899
- await options.put(file.metaKey, encodeFileMeta(file.size));
1248
+ try {
1249
+ await options.put(file.metaKey, encodeFileMeta(file.size));
1250
+ } catch {
1251
+ file.size = previousSize;
1252
+ file.metaDirty = previousMetaDirty;
1253
+ return VFS.SQLITE_IOERR_TRUNCATE;
1254
+ }
900
1255
  file.metaDirty = false;
901
1256
  }
902
1257
  return VFS.SQLITE_OK;
@@ -908,33 +1263,52 @@ class SqliteSystem implements SqliteVfsRegistration {
908
1263
  const lastChunkToKeep = Math.floor((size - 1) / CHUNK_SIZE);
909
1264
  const lastExistingChunk = Math.floor((file.size - 1) / CHUNK_SIZE);
910
1265
 
911
- // Delete chunks beyond the new size
912
- const keysToDelete: Uint8Array[] = [];
913
- for (let i = lastChunkToKeep + 1; i <= lastExistingChunk; i++) {
914
- keysToDelete.push(this.#chunkKey(file, i));
1266
+ // Update metadata first so a crash leaves orphaned chunks (wasted
1267
+ // space) rather than metadata pointing at missing chunks (corruption).
1268
+ const previousSize = file.size;
1269
+ const previousMetaDirty = file.metaDirty;
1270
+ file.size = size;
1271
+ file.metaDirty = true;
1272
+ try {
1273
+ await options.put(file.metaKey, encodeFileMeta(file.size));
1274
+ } catch {
1275
+ file.size = previousSize;
1276
+ file.metaDirty = previousMetaDirty;
1277
+ return VFS.SQLITE_IOERR_TRUNCATE;
915
1278
  }
1279
+ file.metaDirty = false;
916
1280
 
917
- if (keysToDelete.length > 0) {
918
- await options.deleteBatch(keysToDelete);
919
- }
1281
+ // Remaining operations clean up old chunk data. Metadata already
1282
+ // reflects the new size, so failures here leave orphaned/oversized
1283
+ // chunks that are invisible to SQLite (xRead clips to file.size).
1284
+ try {
1285
+ // Truncate the last kept chunk if needed
1286
+ if (size > 0 && size % CHUNK_SIZE !== 0) {
1287
+ const lastChunkKey = this.#chunkKey(file, lastChunkToKeep);
1288
+ const lastChunkData = await options.get(lastChunkKey);
1289
+
1290
+ if (lastChunkData && lastChunkData.length > size % CHUNK_SIZE) {
1291
+ const truncatedChunk = lastChunkData.subarray(
1292
+ 0,
1293
+ size % CHUNK_SIZE,
1294
+ );
1295
+ await options.put(lastChunkKey, truncatedChunk);
1296
+ }
1297
+ }
920
1298
 
921
- // Truncate the last kept chunk if needed
922
- if (size > 0 && size % CHUNK_SIZE !== 0) {
923
- const lastChunkKey = this.#chunkKey(file, lastChunkToKeep);
924
- const lastChunkData = await options.get(lastChunkKey);
1299
+ // Delete chunks beyond the new size
1300
+ const keysToDelete: Uint8Array[] = [];
1301
+ for (let i = lastChunkToKeep + 1; i <= lastExistingChunk; i++) {
1302
+ keysToDelete.push(this.#chunkKey(file, i));
1303
+ }
925
1304
 
926
- if (lastChunkData && lastChunkData.length > size % CHUNK_SIZE) {
927
- const truncatedChunk = lastChunkData.subarray(0, size % CHUNK_SIZE);
928
- await options.put(lastChunkKey, truncatedChunk);
1305
+ for (let b = 0; b < keysToDelete.length; b += KV_MAX_BATCH_KEYS) {
1306
+ await options.deleteBatch(keysToDelete.slice(b, b + KV_MAX_BATCH_KEYS));
929
1307
  }
1308
+ } catch {
1309
+ return VFS.SQLITE_IOERR_TRUNCATE;
930
1310
  }
931
1311
 
932
- // Update file size
933
- file.size = size;
934
- file.metaDirty = true;
935
- await options.put(file.metaKey, encodeFileMeta(file.size));
936
- file.metaDirty = false;
937
-
938
1312
  return VFS.SQLITE_OK;
939
1313
  }
940
1314
 
@@ -944,7 +1318,11 @@ class SqliteSystem implements SqliteVfsRegistration {
944
1318
  return VFS.SQLITE_OK;
945
1319
  }
946
1320
 
947
- await file.options.put(file.metaKey, encodeFileMeta(file.size));
1321
+ try {
1322
+ await file.options.put(file.metaKey, encodeFileMeta(file.size));
1323
+ } catch {
1324
+ return VFS.SQLITE_IOERR_FSYNC;
1325
+ }
948
1326
  file.metaDirty = false;
949
1327
  return VFS.SQLITE_OK;
950
1328
  }
@@ -960,8 +1338,16 @@ class SqliteSystem implements SqliteVfsRegistration {
960
1338
  return VFS.SQLITE_OK;
961
1339
  }
962
1340
 
963
- async xDelete(_pVfs: number, zName: number, _syncDir: number): Promise<number> {
964
- await this.#delete(this.#module.UTF8ToString(zName));
1341
+ async xDelete(
1342
+ _pVfs: number,
1343
+ zName: number,
1344
+ _syncDir: number,
1345
+ ): Promise<number> {
1346
+ try {
1347
+ await this.#delete(this.#module.UTF8ToString(zName));
1348
+ } catch {
1349
+ return VFS.SQLITE_IOERR_DELETE;
1350
+ }
965
1351
  return VFS.SQLITE_OK;
966
1352
  }
967
1353
 
@@ -989,7 +1375,9 @@ class SqliteSystem implements SqliteVfsRegistration {
989
1375
  keysToDelete.push(getChunkKey(fileTag, i));
990
1376
  }
991
1377
 
992
- await options.deleteBatch(keysToDelete);
1378
+ for (let b = 0; b < keysToDelete.length; b += KV_MAX_BATCH_KEYS) {
1379
+ await options.deleteBatch(keysToDelete.slice(b, b + KV_MAX_BATCH_KEYS));
1380
+ }
993
1381
  }
994
1382
 
995
1383
  async xAccess(
@@ -1010,7 +1398,12 @@ class SqliteSystem implements SqliteVfsRegistration {
1010
1398
  }
1011
1399
 
1012
1400
  const compactMetaKey = getMetaKey(resolved.fileTag);
1013
- const metaData = await resolved.options.get(compactMetaKey);
1401
+ let metaData: Uint8Array | null;
1402
+ try {
1403
+ metaData = await resolved.options.get(compactMetaKey);
1404
+ } catch {
1405
+ return VFS.SQLITE_IOERR_ACCESS;
1406
+ }
1014
1407
 
1015
1408
  // Set result: 1 if file exists, 0 otherwise
1016
1409
  this.#writeInt32(pResOut, metaData ? 1 : 0);
@@ -1032,15 +1425,95 @@ class SqliteSystem implements SqliteVfsRegistration {
1032
1425
  return VFS.SQLITE_OK;
1033
1426
  }
1034
1427
 
1035
- xFileControl(_fileId: number, _flags: number, _pArg: number): number {
1036
- return VFS.SQLITE_NOTFOUND;
1428
+ async xFileControl(
1429
+ fileId: number,
1430
+ flags: number,
1431
+ _pArg: number,
1432
+ ): Promise<number> {
1433
+ switch (flags) {
1434
+ case SQLITE_FCNTL_BEGIN_ATOMIC_WRITE: {
1435
+ const file = this.#openFiles.get(fileId);
1436
+ if (!file) return VFS.SQLITE_NOTFOUND;
1437
+ file.savedFileSize = file.size;
1438
+ file.batchMode = true;
1439
+ file.metaDirty = false;
1440
+ file.dirtyBuffer = new Map();
1441
+ return VFS.SQLITE_OK;
1442
+ }
1443
+
1444
+ case SQLITE_FCNTL_COMMIT_ATOMIC_WRITE: {
1445
+ const file = this.#openFiles.get(fileId);
1446
+ if (!file) return VFS.SQLITE_NOTFOUND;
1447
+ const { dirtyBuffer, options } = file;
1448
+
1449
+ // Dynamic limit: if metadata is dirty, we need one slot for it.
1450
+ // If metadata is not dirty (file.size unchanged), all slots are available for pages.
1451
+ const maxDirtyPages = file.metaDirty ? KV_MAX_BATCH_KEYS - 1 : KV_MAX_BATCH_KEYS;
1452
+ if (dirtyBuffer && dirtyBuffer.size > maxDirtyPages) {
1453
+ dirtyBuffer.clear();
1454
+ file.dirtyBuffer = null;
1455
+ file.size = file.savedFileSize;
1456
+ file.metaDirty = false;
1457
+ file.batchMode = false;
1458
+ return VFS.SQLITE_IOERR;
1459
+ }
1460
+
1461
+ // Build entries array from dirty buffer + metadata.
1462
+ const entries: [Uint8Array, Uint8Array][] = [];
1463
+ if (dirtyBuffer) {
1464
+ for (const [chunkIndex, data] of dirtyBuffer) {
1465
+ entries.push([this.#chunkKey(file, chunkIndex), data]);
1466
+ }
1467
+ dirtyBuffer.clear();
1468
+ }
1469
+ if (file.metaDirty) {
1470
+ entries.push([file.metaKey, encodeFileMeta(file.size)]);
1471
+ }
1472
+
1473
+ try {
1474
+ await options.putBatch(entries);
1475
+ } catch {
1476
+ file.dirtyBuffer = null;
1477
+ file.size = file.savedFileSize;
1478
+ file.metaDirty = false;
1479
+ file.batchMode = false;
1480
+ return VFS.SQLITE_IOERR;
1481
+ }
1482
+
1483
+ file.dirtyBuffer = null;
1484
+ file.metaDirty = false;
1485
+ file.batchMode = false;
1486
+ return VFS.SQLITE_OK;
1487
+ }
1488
+
1489
+ case SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE: {
1490
+ const file = this.#openFiles.get(fileId);
1491
+ if (!file || !file.batchMode) return VFS.SQLITE_OK;
1492
+ if (file.dirtyBuffer) {
1493
+ file.dirtyBuffer.clear();
1494
+ file.dirtyBuffer = null;
1495
+ }
1496
+ file.size = file.savedFileSize;
1497
+ file.metaDirty = false;
1498
+ file.batchMode = false;
1499
+ return VFS.SQLITE_OK;
1500
+ }
1501
+
1502
+ default:
1503
+ return VFS.SQLITE_NOTFOUND;
1504
+ }
1037
1505
  }
1038
1506
 
1039
1507
  xDeviceCharacteristics(_fileId: number): number {
1040
- return 0;
1508
+ return SQLITE_IOCAP_BATCH_ATOMIC;
1041
1509
  }
1042
1510
 
1043
- xFullPathname(_pVfs: number, zName: number, nOut: number, zOut: number): number {
1511
+ xFullPathname(
1512
+ _pVfs: number,
1513
+ zName: number,
1514
+ nOut: number,
1515
+ zOut: number,
1516
+ ): number {
1044
1517
  const path = this.#module.UTF8ToString(zName);
1045
1518
  const bytes = TEXT_ENCODER.encode(path);
1046
1519
  const out = this.#module.HEAPU8.subarray(zOut, zOut + nOut);
@@ -1127,5 +1600,5 @@ function delegalize(lo32: number, hi32: number): number {
1127
1600
  if (hi === MAX_FILE_SIZE_HI32 && lo > MAX_FILE_SIZE_LO32) {
1128
1601
  return -1;
1129
1602
  }
1130
- return (hi * UINT32_SIZE) + lo;
1603
+ return hi * UINT32_SIZE + lo;
1131
1604
  }