Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wa-sqlite-explicit-extension-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@treecrdt/wa-sqlite': patch
---

Initialize the statically linked TreeCRDT extension explicitly after opening SQLite.
11 changes: 4 additions & 7 deletions packages/treecrdt-wa-sqlite-vendor/treecrdt-ext.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
// static library.

#include <sqlite3.h>
#include <emscripten/emscripten.h>

// 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);
}
4 changes: 4 additions & 0 deletions packages/treecrdt-wa-sqlite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
6 changes: 4 additions & 2 deletions packages/treecrdt-wa-sqlite/scripts/bench.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand All @@ -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);
Expand Down
72 changes: 72 additions & 0 deletions packages/treecrdt-wa-sqlite/src/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
type WaSqliteModule = {
cwrap?: (
name: string,
returnType: string,
argTypes: string[],
opts?: { async?: boolean },
) => (...args: unknown[]) => Promise<number> | number;
retryOps?: Promise<unknown>[];
pendingOps?: Promise<unknown>[];
};

const initCache = new WeakMap<object, (handle: number) => Promise<number> | 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> | 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<void> {
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> | 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})`);
}
}
1 change: 1 addition & 0 deletions packages/treecrdt-wa-sqlite/src/index.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export {
export { CLIENT_CLOSED_ERROR, createTreecrdtClient } from './client.js';

export { createWaSqliteApi } from './adapter.js';
export { initializeTreecrdtExtension } from './extension.js';
1 change: 1 addition & 0 deletions packages/treecrdt-wa-sqlite/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
16 changes: 7 additions & 9 deletions packages/treecrdt-wa-sqlite/src/node/open.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand All @@ -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);
}
60 changes: 53 additions & 7 deletions packages/treecrdt-wa-sqlite/src/open-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,33 @@ export type OpenTreecrdtDbResult = {
opfsError?: string;
};

const OPFS_VFS_NAME = 'opfs';

async function closeIgnoringErrors(close: (() => Promise<void> | void) | undefined): Promise<void> {
if (!close) return;
try {
await close();
} catch {
// Preserve the initialization error.
}
}

function closeDatabaseWithVfs(db: Database, vfs: { close?: () => Promise<void> | void }): Database {
if (!vfs.close) return db;
let closePromise: Promise<void> | 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 },
Expand All @@ -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> | 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}`);
}
Expand All @@ -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;
}
}
68 changes: 47 additions & 21 deletions packages/treecrdt-wa-sqlite/src/opfs.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -166,30 +167,55 @@ export async function openWithStorage(
opts: OpenOptions,
): Promise<{ db: Database; close?: () => Promise<void> }> {
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> | 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<void> | 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;
}
}
Loading
Loading