From 0a2f290fecfeb36c86630afe136f5d0092f05d22 Mon Sep 17 00:00:00 2001 From: Marcus Pousette Date: Wed, 15 Jul 2026 09:06:20 +0200 Subject: [PATCH] refactor(wa-sqlite): initialize extension after opening --- .../wa-sqlite-explicit-extension-init.md | 5 + .../treecrdt-wa-sqlite-vendor/treecrdt-ext.c | 11 +- packages/treecrdt-wa-sqlite-vendor/wa-sqlite | 2 +- packages/treecrdt-wa-sqlite/README.md | 4 + packages/treecrdt-wa-sqlite/scripts/bench.ts | 6 +- packages/treecrdt-wa-sqlite/src/extension.ts | 72 +++++++++ .../treecrdt-wa-sqlite/src/index.browser.ts | 1 + packages/treecrdt-wa-sqlite/src/index.node.ts | 1 + packages/treecrdt-wa-sqlite/src/node/open.ts | 16 +- packages/treecrdt-wa-sqlite/src/open-core.ts | 60 ++++++- packages/treecrdt-wa-sqlite/src/opfs.ts | 68 +++++--- .../tests/extension.test.ts | 146 ++++++++++++++++++ .../tests/open-extension.test.ts | 96 ++++++++++++ 13 files changed, 441 insertions(+), 47 deletions(-) create mode 100644 .changeset/wa-sqlite-explicit-extension-init.md create mode 100644 packages/treecrdt-wa-sqlite/src/extension.ts create mode 100644 packages/treecrdt-wa-sqlite/tests/extension.test.ts create mode 100644 packages/treecrdt-wa-sqlite/tests/open-extension.test.ts diff --git a/.changeset/wa-sqlite-explicit-extension-init.md b/.changeset/wa-sqlite-explicit-extension-init.md new file mode 100644 index 00000000..76380d4f --- /dev/null +++ b/.changeset/wa-sqlite-explicit-extension-init.md @@ -0,0 +1,5 @@ +--- +'@treecrdt/wa-sqlite': patch +--- + +Initialize the statically linked TreeCRDT extension explicitly after opening SQLite. diff --git a/packages/treecrdt-wa-sqlite-vendor/treecrdt-ext.c b/packages/treecrdt-wa-sqlite-vendor/treecrdt-ext.c index 2aa232ef..69a43b25 100644 --- a/packages/treecrdt-wa-sqlite-vendor/treecrdt-ext.c +++ b/packages/treecrdt-wa-sqlite-vendor/treecrdt-ext.c @@ -4,15 +4,12 @@ // static library. #include +#include // The Rust extension entrypoint (static-link build ignores the sqlite3_api_routines pointer). extern int sqlite3_treecrdt_init(sqlite3 *db, char **pzErrMsg, const void *pApi); -__attribute__((used, constructor)) static void treecrdt_register_auto(void) { - // wa-sqlite builds SQLite with SQLITE_OMIT_AUTOINIT, so ensure initialization. - sqlite3_initialize(); - - // SQLite calls the registered function with (db, err, api); cast to silence - // the prototype mismatch on platforms that declare xEntryPoint as void(*)(void). - sqlite3_auto_extension((void (*)(void))sqlite3_treecrdt_init); +EMSCRIPTEN_KEEPALIVE +int treecrdt_sqlite_init(sqlite3 *db) { + return sqlite3_treecrdt_init(db, 0, 0); } diff --git a/packages/treecrdt-wa-sqlite-vendor/wa-sqlite b/packages/treecrdt-wa-sqlite-vendor/wa-sqlite index 3b3fe0ca..5bae8bde 160000 --- a/packages/treecrdt-wa-sqlite-vendor/wa-sqlite +++ b/packages/treecrdt-wa-sqlite-vendor/wa-sqlite @@ -1 +1 @@ -Subproject commit 3b3fe0cabf7e8edb60adfb6eac04a855561516e2 +Subproject commit 5bae8bde68faf851a3b60f3bb6b3875623ae6f9f diff --git a/packages/treecrdt-wa-sqlite/README.md b/packages/treecrdt-wa-sqlite/README.md index 51872667..2f5b253a 100644 --- a/packages/treecrdt-wa-sqlite/README.md +++ b/packages/treecrdt-wa-sqlite/README.md @@ -13,6 +13,10 @@ pnpm --filter @treecrdt/wa-sqlite build The build copies wa-sqlite WASM/JS assets into `dist/wa-sqlite/` for Node and packages them for browser apps via the Vite plugin. +Low-level callers that open a wa-sqlite handle themselves must call +`initializeTreecrdtExtension(module, handle)` before constructing an adapter with +`createWaSqliteApi`. `createTreecrdtClient()` does this automatically. + ## Browser usage Use `createTreecrdtClient()` with OPFS or in-memory storage. Browser apps should use `@treecrdt/wa-sqlite/vite-plugin` to copy assets into `public/wa-sqlite/`. diff --git a/packages/treecrdt-wa-sqlite/scripts/bench.ts b/packages/treecrdt-wa-sqlite/scripts/bench.ts index ad935495..23976a03 100644 --- a/packages/treecrdt-wa-sqlite/scripts/bench.ts +++ b/packages/treecrdt-wa-sqlite/scripts/bench.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { buildWorkloads, runWorkloads } from '@treecrdt/benchmark'; import { parseBenchCliArgs, repoRootFromImportMeta, writeResult } from '@treecrdt/benchmark/node'; -import { createWaSqliteApi } from '../dist/index.js'; +import { createWaSqliteApi, initializeTreecrdtExtension } from '../dist/index.js'; import { makeDbAdapter } from '../dist/db.js'; import { loadWaSqliteNode } from '../dist/node/load-wa-sqlite.js'; @@ -13,12 +13,13 @@ async function main() { const workloadDefs = buildWorkloads(opts.workloads, opts.sizes); // wa-sqlite is browser-first; in Node we only exercise the in-memory runtime. - const { sqlite3 } = await loadWaSqliteNode(); + const { sqlite3, module } = await loadWaSqliteNode(); const docId = 'treecrdt-wa-sqlite-bench'; // Probe extension registration once so benchmark timing isn't dominated by setup errors. const probeHandle = await sqlite3.open_v2(':memory:'); try { + await initializeTreecrdtExtension(module, probeHandle); await sqlite3.exec(probeHandle, 'SELECT treecrdt_ops_since(0)'); } catch (err) { const msg = sqlite3.errmsg ? sqlite3.errmsg(probeHandle) : String(err); @@ -29,6 +30,7 @@ async function main() { const adapterFactory = async () => { const handle = await sqlite3.open_v2(':memory:'); + await initializeTreecrdtExtension(module, handle); const db = makeDbAdapter(sqlite3, handle); const api = createWaSqliteApi(db); await api.setDocId(docId); diff --git a/packages/treecrdt-wa-sqlite/src/extension.ts b/packages/treecrdt-wa-sqlite/src/extension.ts new file mode 100644 index 00000000..2967d921 --- /dev/null +++ b/packages/treecrdt-wa-sqlite/src/extension.ts @@ -0,0 +1,72 @@ +type WaSqliteModule = { + cwrap?: ( + name: string, + returnType: string, + argTypes: string[], + opts?: { async?: boolean }, + ) => (...args: unknown[]) => Promise | number; + retryOps?: Promise[]; + pendingOps?: Promise[]; +}; + +const initCache = new WeakMap Promise | number>(); +const SQLITE_OK = 0; +const SQLITE_ERROR = 1; + +function pendingErrorCode(error: unknown): number { + if (typeof error !== 'object' || error === null || !('code' in error)) return SQLITE_ERROR; + const code = (error as { code?: unknown }).code; + return typeof code === 'number' && code !== SQLITE_OK ? code : SQLITE_ERROR; +} + +async function runWithWaSqliteRetries(run: () => Promise | number, module: WaSqliteModule) { + while (true) { + if (module.retryOps?.length) { + try { + await Promise.all(module.retryOps); + } finally { + module.retryOps = []; + } + } + + const rc = await run(); + if (rc === SQLITE_OK || !module.retryOps?.length) { + if (module.pendingOps?.length) { + try { + await Promise.all(module.pendingOps); + } catch (error) { + return pendingErrorCode(error); + } finally { + module.pendingOps = []; + } + } + return rc; + } + + // Unlike wa-sqlite's generic retry cap, this idempotent schema initializer can safely + // continue on the same open handle while each failed attempt queues real VFS work. + } +} + +/** Initialize the statically linked TreeCRDT extension on an open wa-sqlite handle. */ +export async function initializeTreecrdtExtension( + module: WaSqliteModule, + handle: number, +): Promise { + if (!module || typeof module.cwrap !== 'function') { + throw new Error('wa-sqlite module does not expose cwrap'); + } + + let init = initCache.get(module as object); + if (!init) { + init = module.cwrap('treecrdt_sqlite_init', 'number', ['number'], { async: true }) as ( + handle: number, + ) => Promise | number; + initCache.set(module as object, init); + } + + const rc = await runWithWaSqliteRetries(() => init(handle), module); + if (rc !== 0) { + throw new Error(`TreeCRDT SQLite extension init failed (rc=${rc})`); + } +} diff --git a/packages/treecrdt-wa-sqlite/src/index.browser.ts b/packages/treecrdt-wa-sqlite/src/index.browser.ts index b7b7ac05..518f1d0f 100644 --- a/packages/treecrdt-wa-sqlite/src/index.browser.ts +++ b/packages/treecrdt-wa-sqlite/src/index.browser.ts @@ -22,3 +22,4 @@ export { export { CLIENT_CLOSED_ERROR, createTreecrdtClient } from './client.js'; export { createWaSqliteApi } from './adapter.js'; +export { initializeTreecrdtExtension } from './extension.js'; diff --git a/packages/treecrdt-wa-sqlite/src/index.node.ts b/packages/treecrdt-wa-sqlite/src/index.node.ts index c48c2889..917a3840 100644 --- a/packages/treecrdt-wa-sqlite/src/index.node.ts +++ b/packages/treecrdt-wa-sqlite/src/index.node.ts @@ -14,6 +14,7 @@ export { createTreecrdtClient } from './node/client.js'; export { CLIENT_CLOSED_ERROR } from './client.js'; export { createWaSqliteApi } from './adapter.js'; +export { initializeTreecrdtExtension } from './extension.js'; export { loadWaSqliteNode } from './node/load-wa-sqlite.js'; export { openTreecrdtDbNode } from './node/open.js'; diff --git a/packages/treecrdt-wa-sqlite/src/node/open.ts b/packages/treecrdt-wa-sqlite/src/node/open.ts index 8e8c2637..80e1dfba 100644 --- a/packages/treecrdt-wa-sqlite/src/node/open.ts +++ b/packages/treecrdt-wa-sqlite/src/node/open.ts @@ -1,6 +1,8 @@ -import { createWaSqliteApi } from '../adapter.js'; -import { makeDbAdapter } from '../db.js'; -import type { OpenTreecrdtDbOptions, OpenTreecrdtDbResult } from '../open-core.js'; +import { + openTreecrdtDbFromLoaded, + type OpenTreecrdtDbOptions, + type OpenTreecrdtDbResult, +} from '../open-core.js'; import { loadWaSqliteNode } from './load-wa-sqlite.js'; /** Node entry: loads wa-sqlite WASM from the filesystem (in-memory only). */ @@ -10,10 +12,6 @@ export async function openTreecrdtDbNode( if (opts.storage === 'opfs' && opts.requireOpfs) { throw new Error('OPFS is not supported in Node'); } - const { sqlite3 } = await loadWaSqliteNode(opts.baseUrl); - const handle = await sqlite3.open_v2(':memory:'); - const db = makeDbAdapter(sqlite3, handle); - const api = createWaSqliteApi(db, { onMaterialized: opts.onMaterialized }); - await api.setDocId(opts.docId); - return { db, api, storage: 'memory', filename: ':memory:' }; + const loaded = await loadWaSqliteNode(opts.baseUrl); + return openTreecrdtDbFromLoaded({ ...opts, storage: 'memory' }, loaded); } diff --git a/packages/treecrdt-wa-sqlite/src/open-core.ts b/packages/treecrdt-wa-sqlite/src/open-core.ts index c403ce26..a1d91127 100644 --- a/packages/treecrdt-wa-sqlite/src/open-core.ts +++ b/packages/treecrdt-wa-sqlite/src/open-core.ts @@ -4,6 +4,7 @@ import type { Database } from './types.js'; import { makeDbAdapter } from './db.js'; import type { TreecrdtAdapter } from '@treecrdt/interface'; import type { MaterializationEvent } from '@treecrdt/interface/engine'; +import { initializeTreecrdtExtension } from './extension.js'; export type OpenTreecrdtDbOptions = { baseUrl?: string; @@ -23,6 +24,33 @@ export type OpenTreecrdtDbResult = { opfsError?: string; }; +const OPFS_VFS_NAME = 'opfs'; + +async function closeIgnoringErrors(close: (() => Promise | void) | undefined): Promise { + if (!close) return; + try { + await close(); + } catch { + // Preserve the initialization error. + } +} + +function closeDatabaseWithVfs(db: Database, vfs: { close?: () => Promise | void }): Database { + if (!vfs.close) return db; + let closePromise: Promise | undefined; + return { + ...db, + close: () => + (closePromise ??= (async () => { + try { + await db.close?.(); + } finally { + await vfs.close?.(); + } + })()), + }; +} + export async function openTreecrdtDbFromLoaded( opts: OpenTreecrdtDbOptions, loaded: { sqlite3: any; module: any }, @@ -31,13 +59,17 @@ export async function openTreecrdtDbFromLoaded( let storage: 'memory' | 'opfs' = opts.storage === 'opfs' ? 'opfs' : 'memory'; let opfsError: string | undefined; + let vfs: { close?: () => Promise | void } | undefined; if (storage === 'opfs') { try { - const vfs = await createOpfsVfs(module, { name: 'opfs', kind: opts.opfsVfs }); - sqlite3.vfs_register(vfs, true); + vfs = await createOpfsVfs(module, { name: OPFS_VFS_NAME, kind: opts.opfsVfs }); + // Keep SQLite's default VFS unchanged; open_v2 selects OPFS explicitly by name. + sqlite3.vfs_register(vfs, false); } catch (err) { opfsError = err instanceof Error ? err.message : String(err); + await closeIgnoringErrors(vfs?.close ? () => vfs!.close!() : undefined); + vfs = undefined; if (opts.requireOpfs) { throw new Error(`OPFS requested but could not be initialized: ${opfsError}`); } @@ -46,10 +78,24 @@ export async function openTreecrdtDbFromLoaded( } const filename = storage === 'opfs' ? (opts.filename ?? '/treecrdt.db') : ':memory:'; - const handle = await sqlite3.open_v2(filename); - const db = makeDbAdapter(sqlite3, handle); - const api = createWaSqliteApi(db, { onMaterialized: opts.onMaterialized }); - await api.setDocId(opts.docId); + let db: Database | undefined; + try { + const handle = + storage === 'opfs' + ? await sqlite3.open_v2(filename, undefined, OPFS_VFS_NAME) + : await sqlite3.open_v2(filename); + db = makeDbAdapter(sqlite3, handle); + await initializeTreecrdtExtension(module, handle); + const api = createWaSqliteApi(db, { onMaterialized: opts.onMaterialized }); + await api.setDocId(opts.docId); + const resultDb = vfs ? closeDatabaseWithVfs(db, vfs) : db; - return opfsError ? { db, api, storage, filename, opfsError } : { db, api, storage, filename }; + return opfsError + ? { db: resultDb, api, storage, filename, opfsError } + : { db: resultDb, api, storage, filename }; + } catch (err) { + await closeIgnoringErrors(db?.close ? () => db!.close!() : undefined); + await closeIgnoringErrors(vfs?.close ? () => vfs!.close!() : undefined); + throw err; + } } diff --git a/packages/treecrdt-wa-sqlite/src/opfs.ts b/packages/treecrdt-wa-sqlite/src/opfs.ts index ebe81ac6..7c63c9f4 100644 --- a/packages/treecrdt-wa-sqlite/src/opfs.ts +++ b/packages/treecrdt-wa-sqlite/src/opfs.ts @@ -1,5 +1,6 @@ import type { Database } from './types.js'; import { makeDbAdapter } from './db.js'; +import { initializeTreecrdtExtension } from './extension.js'; export type OpfsSupport = { available: boolean; @@ -166,30 +167,55 @@ export async function openWithStorage( opts: OpenOptions, ): Promise<{ db: Database; close?: () => Promise }> { const { moduleFactory, filename = ':memory:', sqliteApi, storage } = opts; - let module = await moduleFactory(); + const module = await moduleFactory(); const sqlite3 = sqliteApi.Factory(module); let file = filename; - if (storage === 'opfs') { - const support = detectOpfsSupport(); - if (!support.available) { - throw new Error(`OPFS unsupported: ${support.reason ?? 'unknown reason'}`); + let vfs: { close?: () => Promise | void } | undefined; + let vfsName: string | undefined; + let handle: number | undefined; + try { + if (storage === 'opfs') { + const support = detectOpfsSupport(); + if (!support.available) { + throw new Error(`OPFS unsupported: ${support.reason ?? 'unknown reason'}`); + } + vfsName = 'opfs'; + vfs = await createOpfsVfs(module, { name: vfsName, kind: opts.opfsVfs }); + // Keep SQLite's default VFS unchanged; open_v2 selects OPFS explicitly by name. + sqlite3.vfs_register(vfs, false); + file = filename === ':memory:' ? '/treecrdt.db' : filename; } - const vfs = await createOpfsVfs(module, { name: 'opfs', kind: opts.opfsVfs }); - sqlite3.vfs_register(vfs, true); - file = filename === ':memory:' ? '/treecrdt.db' : filename; - } - const handle = await sqlite3.open_v2(file); - const db = makeDbAdapter(sqlite3, handle); - return { - db, - close: async () => { - try { - await sqlite3.close(handle); - } catch { - /* ignore */ - } - }, - }; + const openedHandle = vfsName + ? await sqlite3.open_v2(file, undefined, vfsName) + : await sqlite3.open_v2(file); + handle = openedHandle; + const db = makeDbAdapter(sqlite3, openedHandle); + await initializeTreecrdtExtension(module, openedHandle); + let closePromise: Promise | undefined; + return { + db, + close: () => + (closePromise ??= (async () => { + try { + await db.close?.(); + } finally { + await vfs?.close?.(); + } + })()), + }; + } catch (err) { + try { + if (handle !== undefined) await sqlite3.close(handle); + } catch { + // Preserve the initialization error. + } + try { + await vfs?.close?.(); + } catch { + // Preserve the initialization error. + } + throw err; + } } diff --git a/packages/treecrdt-wa-sqlite/tests/extension.test.ts b/packages/treecrdt-wa-sqlite/tests/extension.test.ts new file mode 100644 index 00000000..ad56c1ed --- /dev/null +++ b/packages/treecrdt-wa-sqlite/tests/extension.test.ts @@ -0,0 +1,146 @@ +import { expect, test, vi } from 'vitest'; +import { initializeTreecrdtExtension } from '../src/extension.js'; + +test('initializes each database handle and caches the module wrapper', async () => { + const init = vi.fn(async () => 0); + const module = { + cwrap: vi.fn(() => init), + retryOps: [] as Promise[], + }; + + await initializeTreecrdtExtension(module, 11); + await initializeTreecrdtExtension(module, 12); + + expect(module.cwrap).toHaveBeenCalledOnce(); + expect(module.cwrap).toHaveBeenCalledWith('treecrdt_sqlite_init', 'number', ['number'], { + async: true, + }); + expect(init).toHaveBeenNthCalledWith(1, 11); + expect(init).toHaveBeenNthCalledWith(2, 12); +}); + +test('waits and retries when an async VFS operation requests it', async () => { + let attempt = 0; + const module = { + cwrap: vi.fn(() => + vi.fn(async () => { + attempt += 1; + if (attempt === 1) { + module.retryOps.push(Promise.resolve()); + return 5; + } + return 0; + }), + ), + retryOps: [] as Promise[], + }; + + await initializeTreecrdtExtension(module, 21); + expect(attempt).toBe(2); + expect(module.retryOps).toEqual([]); +}); + +test('clears a rejected retry operation without calling the initializer', async () => { + const retryFailure = new Error('retry failed'); + const init = vi.fn(async () => 0); + const module = { + cwrap: vi.fn(() => init), + retryOps: [Promise.reject(retryFailure)], + }; + + await expect(initializeTreecrdtExtension(module, 22)).rejects.toBe(retryFailure); + expect(module.retryOps).toEqual([]); + expect(init).not.toHaveBeenCalled(); +}); + +test('continues through multiple queued retry phases before succeeding', async () => { + const phases: string[] = []; + let attempt = 0; + const init = vi.fn(async () => { + attempt += 1; + phases.push(`init-${attempt}`); + if (attempt <= 3) { + const phase = attempt; + module.retryOps.push( + Promise.resolve().then(() => { + phases.push(`retry-${phase}`); + }), + ); + return 5; + } + return 0; + }); + const module = { + cwrap: vi.fn(() => init), + retryOps: [] as Promise[], + }; + + await initializeTreecrdtExtension(module, 23); + + expect(init).toHaveBeenCalledTimes(4); + expect(phases).toEqual(['init-1', 'retry-1', 'init-2', 'retry-2', 'init-3', 'retry-3', 'init-4']); + expect(module.retryOps).toEqual([]); +}); + +test('waits for pending VFS work before reporting successful initialization', async () => { + let releasePending!: () => void; + const pending = new Promise((resolve) => { + releasePending = resolve; + }); + const module = { + cwrap: vi.fn(() => + vi.fn(async () => { + module.pendingOps.push(pending); + return 0; + }), + ), + retryOps: [] as Promise[], + pendingOps: [] as Promise[], + }; + + let initialized = false; + const initialization = initializeTreecrdtExtension(module, 24).then(() => { + initialized = true; + }); + await Promise.resolve(); + expect(initialized).toBe(false); + + releasePending(); + await initialization; + expect(module.pendingOps).toEqual([]); +}); + +test.each([ + { failure: Object.assign(new Error('checkpoint failed'), { code: 10 }), expectedCode: 10 }, + { failure: new Error('checkpoint failed'), expectedCode: 1 }, +])( + 'maps a rejected pending operation to SQLite code $expectedCode', + async ({ failure, expectedCode }) => { + const module = { + cwrap: vi.fn(() => + vi.fn(async () => { + module.pendingOps.push(Promise.reject(failure)); + return 0; + }), + ), + retryOps: [] as Promise[], + pendingOps: [] as Promise[], + }; + + await expect(initializeTreecrdtExtension(module, 25)).rejects.toThrow( + `TreeCRDT SQLite extension init failed (rc=${expectedCode})`, + ); + expect(module.pendingOps).toEqual([]); + }, +); + +test('fails clearly when initialization returns an SQLite error code', async () => { + const module = { + cwrap: vi.fn(() => vi.fn(async () => 10)), + retryOps: [] as Promise[], + }; + + await expect(initializeTreecrdtExtension(module, 31)).rejects.toThrow( + 'TreeCRDT SQLite extension init failed (rc=10)', + ); +}); diff --git a/packages/treecrdt-wa-sqlite/tests/open-extension.test.ts b/packages/treecrdt-wa-sqlite/tests/open-extension.test.ts new file mode 100644 index 00000000..f45e6e97 --- /dev/null +++ b/packages/treecrdt-wa-sqlite/tests/open-extension.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('../src/opfs.js', () => ({ createOpfsVfs: vi.fn() })); + +import { createOpfsVfs } from '../src/opfs.js'; +import { openTreecrdtDbFromLoaded } from '../src/open-core.js'; + +function createFakeModule(initResult = 0) { + const init = vi.fn(async () => initResult); + return { + cwrap: vi.fn(() => init), + init, + retryOps: [] as Promise[], + pendingOps: [] as Promise[], + }; +} + +function createFakeSqlite() { + let nextStatement = 100; + return { + vfs_register: vi.fn(), + open_v2: vi.fn(async () => 1), + statements: vi.fn(() => { + const statement = nextStatement++; + return { + next: async () => ({ value: statement }), + return: async () => undefined, + }; + }), + bind: vi.fn(), + step: vi.fn(async () => 101), + column_text: vi.fn(), + finalize: vi.fn(), + exec: vi.fn(), + close: vi.fn(), + }; +} + +beforeEach(() => { + vi.mocked(createOpfsVfs).mockReset(); +}); + +test('initializes the extension after opening a memory database', async () => { + const sqlite3 = createFakeSqlite(); + const module = createFakeModule(); + + const opened = await openTreecrdtDbFromLoaded( + { storage: 'memory', docId: 'memory-explicit-init' }, + { sqlite3, module }, + ); + + expect(sqlite3.open_v2).toHaveBeenCalledWith(':memory:'); + expect(module.init).toHaveBeenCalledWith(1); + expect(module.init.mock.invocationCallOrder[0]).toBeLessThan( + sqlite3.statements.mock.invocationCallOrder[0]!, + ); + await opened.db.close?.(); + expect(sqlite3.close).toHaveBeenCalledWith(1); +}); + +test('closes the database when explicit extension initialization fails', async () => { + const sqlite3 = createFakeSqlite(); + const module = createFakeModule(10); + + await expect( + openTreecrdtDbFromLoaded( + { storage: 'memory', docId: 'memory-explicit-init-failure' }, + { sqlite3, module }, + ), + ).rejects.toThrow('TreeCRDT SQLite extension init failed (rc=10)'); + + expect(sqlite3.close).toHaveBeenCalledWith(1); +}); + +test('uses the named OPFS VFS and closes partial resources when initialization fails', async () => { + const sqlite3 = createFakeSqlite(); + const module = createFakeModule(10); + const vfs = { close: vi.fn() }; + vi.mocked(createOpfsVfs).mockResolvedValue(vfs); + + await expect( + openTreecrdtDbFromLoaded( + { + storage: 'opfs', + filename: '/explicit-init-failure.db', + docId: 'opfs-explicit-init-failure', + }, + { sqlite3, module }, + ), + ).rejects.toThrow('TreeCRDT SQLite extension init failed (rc=10)'); + + expect(sqlite3.vfs_register).toHaveBeenCalledWith(vfs, false); + expect(sqlite3.open_v2).toHaveBeenCalledWith('/explicit-init-failure.db', undefined, 'opfs'); + expect(sqlite3.close).toHaveBeenCalledWith(1); + expect(vfs.close).toHaveBeenCalledOnce(); +});