@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/dist/tsup/index.cjs +739 -101
- package/dist/tsup/index.cjs.map +1 -1
- package/dist/tsup/index.d.cts +136 -6
- package/dist/tsup/index.d.ts +136 -6
- package/dist/tsup/index.js +737 -99
- package/dist/tsup/index.js.map +1 -1
- package/package.json +4 -3
- package/src/generated/empty-db-page.ts +23 -0
- package/src/index.ts +3 -0
- package/src/kv.ts +18 -3
- package/src/pool.ts +495 -0
- package/src/vfs.ts +604 -131
- package/src/wasm.d.ts +60 -0
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
|
-
|
|
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(
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
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
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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 (
|
|
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}.
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
971
|
+
const endChunk = Math.floor(
|
|
972
|
+
(iOffset + requestedLength - 1) / CHUNK_SIZE,
|
|
973
|
+
);
|
|
704
974
|
|
|
705
|
-
// Fetch
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
830
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
918
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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(
|
|
964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1036
|
-
|
|
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
|
|
1508
|
+
return SQLITE_IOCAP_BATCH_ATOMIC;
|
|
1041
1509
|
}
|
|
1042
1510
|
|
|
1043
|
-
xFullPathname(
|
|
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
|
|
1603
|
+
return hi * UINT32_SIZE + lo;
|
|
1131
1604
|
}
|