@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/LICENSE +203 -0
- package/dist/schemas/file-meta/v1.ts +43 -0
- package/dist/tsup/index.cjs +859 -0
- package/dist/tsup/index.cjs.map +1 -0
- package/dist/tsup/index.d.cts +115 -0
- package/dist/tsup/index.d.ts +115 -0
- package/dist/tsup/index.js +855 -0
- package/dist/tsup/index.js.map +1 -0
- package/package.json +48 -0
- package/schemas/file-meta/mod.ts +2 -0
- package/schemas/file-meta/v1.bare +7 -0
- package/schemas/file-meta/versioned.ts +25 -0
- package/src/index.ts +2 -0
- package/src/kv.ts +81 -0
- package/src/types.ts +12 -0
- package/src/vfs.ts +1126 -0
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
|
+
}
|