///
import sqlite3InitModule, { type Database, type SAHPoolUtil, type Sqlite3Static } from '@aztec/sqlite3mc-wasm';
import { SqliteEncryptionError, type SqliteEncryptionErrorCode, isDecryptFailureMessage } from './errors.js';
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS data (
slot TEXT NOT NULL PRIMARY KEY,
container TEXT NOT NULL,
key BLOB NOT NULL,
key_count INTEGER NOT NULL,
hash TEXT NOT NULL,
value BLOB
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_container_key ON data(container, key);
CREATE UNIQUE INDEX IF NOT EXISTS idx_container_key_count ON data(container, key, key_count);
CREATE UNIQUE INDEX IF NOT EXISTS idx_container_key_hash ON data(container, key, hash);
`;
const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';
const SAH_POOL_VFS_NAME = 'aztec-kv-opfs';
const MC_SAH_POOL_VFS_NAME = `multipleciphers-${SAH_POOL_VFS_NAME}`;
let sqlite3: Sqlite3Static | undefined;
let pool: SAHPoolUtil | undefined;
let poolDirectory: string | undefined;
let db: Database | undefined;
let dbPath: string | undefined;
async function ensurePool(directory: string): Promise {
sqlite3 ??= await sqlite3InitModule();
const s = sqlite3;
if (!pool) {
poolDirectory = directory;
pool = await s.installOpfsSAHPoolVfs({
name: SAH_POOL_VFS_NAME,
directory,
initialCapacity: 8,
});
// Register a sqlite3mc-wrapped VFS pointing at our SAH Pool VFS.
// Encrypted DBs must be opened through this wrapper so sqlite3mc can
// intercept file I/O; plain DBs continue using the SAH Pool VFS directly.
// The wrapper name is `multipleciphers-`.
(s.capi as unknown as { sqlite3mc_vfs_create(name: string, makeDefault: number): number }).sqlite3mc_vfs_create(
SAH_POOL_VFS_NAME,
0,
);
}
return pool!;
}
/**
* Applies sqlite3mc's ChaCha20 page cipher using a pre-derived 32-byte key.
* The PRAGMAs must run before any schema DDL so sqlite3mc can decrypt existing
* pages and encrypt new ones. Zeroes the caller-held key array after the PRAGMA
* completes to minimize residency of the raw key bytes outside sqlite3mc's heap.
*/
function applyEncryptionKey(conn: Database, key: Uint8Array): void {
const hex = Array.from(key, b => b.toString(16).padStart(2, '0')).join('');
conn.exec(`PRAGMA cipher = 'chacha20'`);
conn.exec(`PRAGMA key = "x'${hex}'"`);
key.fill(0);
}
async function handleInit(
dbName: string,
ephemeral: boolean,
directory?: string,
encryptionKey?: Uint8Array,
): Promise {
sqlite3 ??= await sqlite3InitModule();
const s = sqlite3;
if (encryptionKey !== undefined && ephemeral) {
throw new SqliteEncryptionError(
'encryption_not_supported_for_ephemeral',
'encryptionKey is not supported for ephemeral (:memory:) stores',
);
}
if (ephemeral) {
db = new s.oo1.DB(':memory:', 'c');
} else {
await ensurePool(directory ?? DEFAULT_SAH_POOL_DIRECTORY);
dbPath = normalizeDbPath(dbName);
if (encryptionKey !== undefined) {
db = new s.oo1.DB({ filename: dbPath, flags: 'c', vfs: MC_SAH_POOL_VFS_NAME });
applyEncryptionKey(db, encryptionKey);
} else {
db = new pool!.OpfsSAHPoolDb(dbPath);
}
}
runSql(SCHEMA_SQL);
}
function handleClose(): void {
db?.close();
db = undefined;
dbPath = undefined;
}
async function handleExport(): Promise {
if (!db || !dbPath) {
throw new Error('SQLite worker: no database open to export');
}
if (!pool) {
throw new Error('SQLite worker: no SAH Pool available (ephemeral DBs cannot be exported)');
}
return await pool.exportFile(dbPath);
}
async function handleDeleteDb(dbName: string): Promise {
const path = normalizeDbPath(dbName);
if (db && dbPath === path) {
db.close();
db = undefined;
dbPath = undefined;
}
const p = await ensurePool(poolDirectory ?? DEFAULT_SAH_POOL_DIRECTORY);
try {
p.unlink(path);
} catch {
// File may not exist; ignore.
}
}
function requireDb(): Database {
if (!db) {
throw new Error('SQLite worker: no database open');
}
return db;
}
function runSql(sql: string, bind?: SqlValue[]): { changes: number } {
const conn = requireDb();
conn.exec({ sql, bind });
return { changes: conn.changes() };
}
function selectAll(sql: string, bind?: SqlValue[]): ResultRow[] {
const conn = requireDb();
const rows: ResultRow[] = [];
conn.exec({ sql, bind, rowMode: 'array', resultRows: rows });
return rows;
}
function normalizeDbPath(dbName: string): string {
return dbName.startsWith('/') ? dbName : `/${dbName}`;
}
function respond(msg: WorkerResponse): void {
(self as DedicatedWorkerGlobalScope).postMessage(msg);
}
(self as DedicatedWorkerGlobalScope).onmessage = async (ev: MessageEvent) => {
const req = ev.data;
try {
switch (req.type) {
case 'init':
await handleInit(req.dbName, req.ephemeral, req.poolDirectory, req.encryptionKey);
return respond({ type: 'ok', id: req.id });
case 'close':
handleClose();
return respond({ type: 'ok', id: req.id });
case 'deleteDb':
await handleDeleteDb(req.dbName);
return respond({ type: 'ok', id: req.id });
case 'run': {
const { changes } = runSql(req.sql, req.bind);
return respond({ type: 'ok', id: req.id, changes });
}
case 'all': {
const rows = selectAll(req.sql, req.bind);
return respond({ type: 'ok', id: req.id, rows });
}
case 'export': {
const bytes = await handleExport();
return respond({ type: 'ok', id: req.id, bytes });
}
case 'begin':
runSql('BEGIN');
return respond({ type: 'ok', id: req.id });
case 'commit':
runSql('COMMIT');
return respond({ type: 'ok', id: req.id });
case 'rollback':
runSql('ROLLBACK');
return respond({ type: 'ok', id: req.id });
default: {
const _exhaustive: never = req;
throw new Error(`Unknown request: ${JSON.stringify(_exhaustive)}`);
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
respond({ type: 'err', id: req.id, message, encryptionCode: detectEncryptionCode(req, err, message) });
}
};
/**
* Maps a thrown error during request handling to a typed encryption code, so the
* main thread can re-hydrate it as a {@link SqliteEncryptionError}. Returns
* `undefined` for non-encryption errors (preserves the existing untyped path).
*
* Two sources:
* - The error was already a `SqliteEncryptionError` (pre-flight throws inside
* this worker — e.g. ephemeral + encryptionKey). Forward its code as-is.
* - The error came from SQLite/sqlite3mc with a known decrypt-failure message
* during an `init` request. Both "wrong key supplied" and "no key supplied
* to an encrypted DB" surface as SQLITE_NOTADB ("file is not a database") —
* we don't constrain on `req.encryptionKey` because the no-key-on-encrypted-DB
* case is exactly when the caller most needs the typed signal.
*/
function detectEncryptionCode(
req: WorkerRequest,
err: unknown,
message: string,
): SqliteEncryptionErrorCode | undefined {
if (err instanceof SqliteEncryptionError) {
return err.code;
}
if (req.type === 'init' && isDecryptFailureMessage(message)) {
return 'decrypt_failed';
}
return undefined;
}