commit 46a937f7ca608840e4648fb45138fb388e9e6026
parent 6fb290a4e8663c7fa72a869d01c51f1ef440bfc2
Author: Florian Dold <dold@taler.net>
Date: Thu, 13 Aug 2026 12:46:46 +0200
idb-bridge: add browser SQLite backend
Diffstat:
4 files changed, 339 insertions(+), 0 deletions(-)
diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json
@@ -23,6 +23,9 @@
},
"./node-helper-sqlite3-impl": {
"default": "./lib/node-helper-sqlite3-impl.js"
+ },
+ "./browser-sqlite3-impl": {
+ "default": "./lib/browser-sqlite3-impl.js"
}
},
"devDependencies": {
@@ -32,6 +35,7 @@
"typescript": "^7.0.2"
},
"dependencies": {
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
"tslib": "^2.6.2"
}
}
diff --git a/packages/idb-bridge/src/browser-sqlite3-impl.test.ts b/packages/idb-bridge/src/browser-sqlite3-impl.test.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
+import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
+import assert from "node:assert";
+import test from "node:test";
+import { createBrowserSqlite3Impl } from "./browser-sqlite3-impl.js";
+import { isSqlite3Error } from "./sqlite3-interface.js";
+
+test("official SQLite WASM implements the shared database contract", async () => {
+ const sqlite3 = await sqlite3InitModule();
+ const impl = createBrowserSqlite3Impl(sqlite3);
+ const db = await impl.open(":memory:");
+ try {
+ await db.exec(`
+ CREATE TABLE entries (
+ entry_id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ payload BLOB,
+ optional_value TEXT,
+ large_value INTEGER
+ );
+ `);
+ const insert = await db.prepare(
+ "INSERT INTO entries" +
+ " (name, payload, optional_value, large_value)" +
+ " VALUES ($name, $payload, $optional_value, $large_value)",
+ );
+ const firstInsert = await insert.run({
+ name: "alpha",
+ payload: new Uint8Array([0, 127, 128, 255]),
+ optional_value: undefined,
+ large_value: 9_007_199_254_740_993n,
+ });
+ const secondInsert = await insert.run({
+ name: "beta",
+ payload: new Uint8Array([4, 2]),
+ optional_value: "present",
+ large_value: 42,
+ });
+ assert.equal(firstInsert.lastInsertRowid, 1n);
+ assert.equal(secondInsert.lastInsertRowid, 2n);
+
+ const byName = await db.prepare("SELECT * FROM entries WHERE name = $name");
+ assert.deepEqual(await byName.getFirst({ name: "alpha" }), {
+ entry_id: 1,
+ name: "alpha",
+ payload: new Uint8Array([0, 127, 128, 255]),
+ optional_value: null,
+ large_value: 9_007_199_254_740_993n,
+ });
+ assert.equal((await byName.getFirst({ name: "beta" }))?.entry_id, 2);
+ assert.equal(await byName.getFirst({ name: "missing" }), undefined);
+
+ const all = await (
+ await db.prepare("SELECT name FROM entries ORDER BY entry_id")
+ ).getAll();
+ assert.deepEqual(all, [{ name: "alpha" }, { name: "beta" }]);
+
+ await assert.rejects(
+ insert.run({
+ name: "alpha",
+ payload: null,
+ optional_value: null,
+ large_value: 0,
+ }),
+ (error: unknown) => {
+ assert.ok(isSqlite3Error(error));
+ assert.equal(error.code, "SQLITE_CONSTRAINT_UNIQUE");
+ assert.match(error.message, /UNIQUE constraint failed/);
+ assert.ok(error.errno > 0);
+ return true;
+ },
+ );
+ } finally {
+ await db.close();
+ }
+});
diff --git a/packages/idb-bridge/src/browser-sqlite3-impl.ts b/packages/idb-bridge/src/browser-sqlite3-impl.ts
@@ -0,0 +1,242 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
+import sqlite3InitModule, {
+ type Database,
+ type PreparedStatement,
+ type SAHPoolUtil,
+ type Sqlite3Static,
+ type SqlValue,
+} from "@sqlite.org/sqlite-wasm";
+import {
+ BindParams,
+ ResultRow,
+ RunResult,
+ Sqlite3Database,
+ Sqlite3Error,
+ Sqlite3Interface,
+ Sqlite3Statement,
+ Sqlite3Value,
+} from "./sqlite3-interface.js";
+
+let sqlite3Module: Promise<Sqlite3Static> | undefined;
+
+type OpenOo1Database = (filename: string) => Database;
+
+export interface BrowserSqlite3Options {
+ /** Override the OO1 database constructor, for example with OpfsSAHPoolDb. */
+ openDatabase?: OpenOo1Database;
+}
+
+export interface OpfsSahPoolOptions {
+ /** A stable, generation-independent directory owned only by this VFS. */
+ directory?: string;
+ /** VFS registration name. */
+ name?: string;
+}
+
+export interface OpfsSahPoolSqlite3 {
+ sqlite3: Sqlite3Interface;
+ pool: SAHPoolUtil;
+}
+
+/** Load the pinned official SQLite WASM module once in this JS context. */
+export function initOfficialSqliteWasm(): Promise<Sqlite3Static> {
+ return (sqlite3Module ??= sqlite3InitModule());
+}
+
+function bindParams(params: BindParams | undefined): Record<string, SqlValue> {
+ const result: Record<string, SqlValue> = {};
+ for (const [key, value] of Object.entries(params ?? {})) {
+ const sqliteKey = /^[\$:@?]/.test(key) ? key : `$${key}`;
+ result[sqliteKey] = value ?? null;
+ }
+ return result;
+}
+
+function resultValue(value: SqlValue): Sqlite3Value {
+ if (value instanceof Uint8Array) {
+ return value;
+ }
+ if (value instanceof Int8Array) {
+ return new Uint8Array(
+ value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength),
+ );
+ }
+ if (value instanceof ArrayBuffer) {
+ return new Uint8Array(value.slice(0));
+ }
+ return value;
+}
+
+function resultRow(row: Record<string, SqlValue>): ResultRow {
+ return Object.fromEntries(
+ Object.entries(row).map(([key, value]) => [key, resultValue(value)]),
+ );
+}
+
+function sqliteError(
+ sqlite3: Sqlite3Static,
+ db: Database,
+ error: unknown,
+): unknown {
+ const resultCode = (error as { resultCode?: unknown } | undefined)
+ ?.resultCode;
+ if (typeof resultCode !== "number") {
+ return error;
+ }
+ const extendedCode = db.pointer
+ ? sqlite3.capi.sqlite3_extended_errcode(db)
+ : resultCode;
+ const errno = extendedCode || resultCode;
+ const message = error instanceof Error ? error.message : String(error);
+ return new Sqlite3Error(
+ message,
+ sqlite3.capi.sqlite3_js_rc_str(errno),
+ errno,
+ );
+}
+
+function prepareStatement(
+ sqlite3: Sqlite3Static,
+ db: Database,
+ statement: PreparedStatement,
+): Sqlite3Statement {
+ const begin = (params: BindParams | undefined): void => {
+ statement.reset(true);
+ const bindings = bindParams(params);
+ if (Object.keys(bindings).length > 0) {
+ statement.bind(bindings);
+ }
+ };
+
+ const reset = (): void => {
+ try {
+ statement.reset(true);
+ } catch {
+ // Preserve the original SQLite error. Closing the database will finalize
+ // a statement which SQLite itself left unusable.
+ }
+ };
+
+ const wrap = async <T>(operation: () => T): Promise<T> => {
+ try {
+ return operation();
+ } catch (error) {
+ throw sqliteError(sqlite3, db, error);
+ } finally {
+ reset();
+ }
+ };
+
+ return {
+ internalStatement: statement,
+ async run(params?: BindParams): Promise<RunResult> {
+ return wrap(() => {
+ begin(params);
+ while (statement.step()) {
+ // Consume RETURNING rows so the statement reaches SQLITE_DONE.
+ }
+ return {
+ lastInsertRowid: sqlite3.capi.sqlite3_last_insert_rowid(db),
+ };
+ });
+ },
+ async getAll(params?: BindParams): Promise<ResultRow[]> {
+ return wrap(() => {
+ begin(params);
+ const rows: ResultRow[] = [];
+ while (statement.step()) {
+ rows.push(resultRow(statement.get({})));
+ }
+ return rows;
+ });
+ },
+ async getFirst(params?: BindParams): Promise<ResultRow | undefined> {
+ return wrap(() => {
+ begin(params);
+ return statement.step() ? resultRow(statement.get({})) : undefined;
+ });
+ },
+ };
+}
+
+/** Adapt the official SQLite WASM OO1 API to Taler's shared SQLite contract. */
+export function createBrowserSqlite3Impl(
+ sqlite3: Sqlite3Static,
+ options: BrowserSqlite3Options = {},
+): Sqlite3Interface {
+ const openDatabase =
+ options.openDatabase ??
+ ((filename: string): Database => new sqlite3.oo1.DB(filename, "c"));
+
+ return {
+ async open(filename: string): Promise<Sqlite3Database> {
+ let db: Database;
+ try {
+ db = openDatabase(filename);
+ } catch (error) {
+ const resultCode = (error as { resultCode?: unknown } | undefined)
+ ?.resultCode;
+ if (typeof resultCode === "number") {
+ throw new Sqlite3Error(
+ error instanceof Error ? error.message : String(error),
+ sqlite3.capi.sqlite3_js_rc_str(resultCode),
+ resultCode,
+ );
+ }
+ throw error;
+ }
+ return {
+ internalDbHandle: db,
+ async close(): Promise<void> {
+ db.close();
+ },
+ async exec(sqlStr: string): Promise<void> {
+ try {
+ db.exec(sqlStr);
+ } catch (error) {
+ throw sqliteError(sqlite3, db, error);
+ }
+ },
+ async prepare(stmtStr: string): Promise<Sqlite3Statement> {
+ try {
+ return prepareStatement(sqlite3, db, db.prepare(stmtStr));
+ } catch (error) {
+ throw sqliteError(sqlite3, db, error);
+ }
+ },
+ };
+ },
+ };
+}
+
+/**
+ * Install the official single-connection OPFS VFS and return its adapter.
+ *
+ * The default SAH-pool capacity is intentionally left to SQLite. Capacity is
+ * increased only when conformance measurements demonstrate that it is needed.
+ */
+export async function createOpfsSahPoolSqlite3Impl(
+ sqlite3: Sqlite3Static,
+ options: OpfsSahPoolOptions = {},
+): Promise<OpfsSahPoolSqlite3> {
+ const pool = await sqlite3.installOpfsSAHPoolVfs(options);
+ return {
+ pool,
+ sqlite3: createBrowserSqlite3Impl(sqlite3, {
+ openDatabase(filename) {
+ if (!filename.startsWith("/")) {
+ throw new Error("opfs-sahpool database names must be absolute");
+ }
+ return new pool.OpfsSAHPoolDb(filename);
+ },
+ }),
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -165,6 +165,9 @@ importers:
packages/idb-bridge:
dependencies:
+ '@sqlite.org/sqlite-wasm':
+ specifier: 3.53.0-build1
+ version: 3.53.0-build1
tslib:
specifier: ^2.6.2
version: 2.8.1
@@ -1209,6 +1212,10 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@sqlite.org/sqlite-wasm@3.53.0-build1':
+ resolution: {integrity: sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==}
+ engines: {node: '>=22'}
+
'@tailwindcss/forms@0.5.3':
resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
peerDependencies:
@@ -3405,6 +3412,8 @@ snapshots:
'@rtsao/scc@1.1.0': {}
+ '@sqlite.org/sqlite-wasm@3.53.0-build1': {}
+
'@tailwindcss/forms@0.5.3(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.19.41)(typescript@7.0.2)))':
dependencies:
mini-svg-data-uri: 1.4.4