taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/idb-bridge/package.json | 4++++
Apackages/idb-bridge/src/browser-sqlite3-impl.test.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/idb-bridge/src/browser-sqlite3-impl.ts | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 9+++++++++
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