// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import assert from 'node:assert'; import { runInThisContext } from 'node:vm'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import bindings from 'node-gyp-build'; /** @internal */ type NativeDatabase = Readonly<{ __native_db: never }>; /** @internal */ type NativeStatement = Readonly<{ __native_stmt: never }>; // esbuild is configured to replace: // - `import.meta.url` => `undefined` for CJS // - `__dirname` => `undefined` for ESM const ROOT_DIR = import.meta.url ? fileURLToPath(new URL('..', import.meta.url)) : join(__dirname, '..'); const addon = bindings<{ statementNew( db: NativeDatabase, query: string, persistent: boolean, pluck: boolean, bigint: boolean, paramNames: Array, ): NativeStatement; statementRun( stmt: NativeStatement, params: NativeParameters | undefined, result: [number, number], ): void; statementStep( stmt: NativeStatement, params: NativeParameters | null | undefined, cache: Array> | undefined, isGet: boolean, ): Array>; statementScanStats(stmt: NativeStatement): Array; statementClose(stmt: NativeStatement): void; databaseOpen(path: string): NativeDatabase; databaseInitTokenizer(db: NativeDatabase): void; databaseExec(db: NativeDatabase, query: string): void; databaseClose(db: NativeDatabase): void; databaseCreateFunction( db: NativeDatabase, name: string, fn: (...args: ReadonlyArray) => void, bigint: boolean, ): void; databaseSetWalHook( db: NativeDatabase, fn: (dbName: string, pageCount: number) => void, ): void; signalTokenize(value: string): Array; setLogger(fn: (code: string, message: string) => void): void; }>(ROOT_DIR); export type RunResult = { /** Total number of affected rows */ changes: number; /** Rowid of the last inserted row */ lastInsertRowid: number; }; export type StatementOptions = Readonly<{ /** * If `true` - the statement is assumed to be long-lived and some otherwise * costly optimizations are enabled. * * The default value is controlled by DatabaseOptions. * * @see {@link DatabaseOptions} */ persistent?: boolean; /** * If `true` - `.get()` returns a single column and `.all()` returns a list * of column values. * * Note: the statement must not result in multi-column rows. */ pluck?: true; /** * If `true` - all integers returned by query will be returned as big * integers instead of regular (floating-point) numbers. */ bigint?: true; }>; export type NativeParameters = ReadonlyArray< SqliteValue >; /** * Parameters accepted by `.run()`/`.get()`/`.all()` methods of the statement. */ export type StatementParameters = | NativeParameters | Readonly>>; /** * Possible SQL values given statement options. */ export type SqliteValue = | string | Uint8Array | number | null | (Options extends { bigint: true } ? bigint : never); /** * Return value type of `.get()` and an element type of `.all()` */ export type RowType = Options extends { pluck: true; } ? SqliteValue : Record>; export type FunctionOptions = Readonly<{ /** * If `true` - all integers passed to the fucntion will be big * integers instead of regular (floating-point) numbers. */ bigint?: boolean; }>; /** * A compiled SQL statement class. */ class Statement { readonly #needsTranslation: boolean; #cache: Array> | undefined; #createRow: undefined | ((result: unknown) => RowType); #translateParams: ( params: StatementParameters, ) => NativeParameters; #native: NativeStatement | undefined; #onClose: (() => void) | undefined; /** @internal */ constructor( db: NativeDatabase, query: string, { persistent, pluck, bigint }: Options, onClose?: () => void, ) { this.#needsTranslation = persistent === true && !pluck; const paramNames = new Array(); this.#native = addon.statementNew( db, query, persistent === true, pluck === true, bigint === true, paramNames, ); const isArrayParams = paramNames.every((name) => name === null); const isObjectParams = !isArrayParams && paramNames.every((name) => typeof name === 'string'); if (!isArrayParams && !isObjectParams) { throw new TypeError('Cannot mix named and anonymous params in query'); } if (isArrayParams) { this.#translateParams = (params) => { if (!Array.isArray(params)) { throw new TypeError('Query requires an array of anonymous params'); } return params; }; } else { this.#translateParams = runInThisContext(` (function translateParams(params) { if (Array.isArray(params)) { throw new TypeError('Query requires an object of named params'); } return [ ${paramNames .map((name) => `params[${JSON.stringify(name)}]`) .join(',\n')} ]; }) `); } this.#onClose = onClose; } /** * Run the statement's query without returning any rows. * * @param params - Parameters to be bound to query placeholders before * executing the statement. * @returns An object with `changes` and `lastInsertedRowid` integers. */ public run(params?: StatementParameters): RunResult { if (this.#native === undefined) { throw new Error('Statement closed'); } const result: [number, number] = [0, 0]; const nativeParams = this.#checkParams(params); addon.statementRun(this.#native, nativeParams, result); return { changes: result[0], lastInsertRowid: result[1] }; } /** * Run the statement's query and return the first row of the result or * `undefined` if no rows matched. * * @param params - Parameters to be bound to query placeholders before * executing the statement. * @returns A row object or a single column if `pluck: true` is set in the * statement options. */ public get = RowType>( params?: StatementParameters, ): Row | undefined { if (this.#native === undefined) { throw new Error('Statement closed'); } const nativeParams = this.#checkParams(params); const result = addon.statementStep( this.#native, nativeParams, this.#cache, true, ); if (result === undefined) { return undefined; } if (!this.#needsTranslation) { return result as unknown as Row | undefined; } const createRow = this.#updateCache(result); return createRow(result) as Row; } /** * Run the statement's query and return the all rows of the result or * `undefined` if no rows matched. * * @param params - Parameters to be bound to query placeholders before * executing the statement. * @returns A list of row objects or single columns if `pluck: true` is set in * the statement options. */ public all = RowType>( params?: StatementParameters, ): Array { if (this.#native === undefined) { throw new Error('Statement closed'); } const result = []; const nativeParams = this.#checkParams(params); let singleUseParams: typeof nativeParams | undefined | null = nativeParams; while (true) { const single = addon.statementStep( this.#native, singleUseParams, this.#cache, false, ); singleUseParams = null; if (single === undefined) { break; } if (!this.#needsTranslation) { result.push(single); continue; } const createRow = this.#updateCache(single); result.push(createRow(single)); } return result as unknown as Array; } /** * Report collected performance statics for the statement. * * @returns A list of objects describing the performance of the query. * * @see {@link https://www.sqlite.org/profile.html} */ public scanStats(): Array { if (this.#native === undefined) { throw new Error('Statement closed'); } return addon.statementScanStats(this.#native); } /** * Close the statement and release the used memory. */ public close(): void { if (this.#native === undefined) { throw new Error('Statement already closed'); } addon.statementClose(this.#native); this.#native = undefined; this.#onClose?.(); } /** @internal */ #updateCache( result: Array>, ): (result: unknown) => RowType { if (this.#cache === result) { assert(this.#createRow !== undefined); return this.#createRow; } // eslint-disable-next-line no-bitwise const half = result.length >>> 1; const lines = []; for (let i = 0; i < half; i += 1) { lines.push(`${JSON.stringify(result[i])}: value[${half} + ${i}],`); } this.#cache = result; const createRow = runInThisContext(`(function createRow(value) { return { ${lines.join('\n')} }; })`); this.#createRow = createRow; return createRow; } /** @internal */ #checkParams( params: StatementParameters | undefined, ): NativeParameters | undefined { if (params === undefined) { return undefined; } if (typeof params !== 'object') { throw new TypeError('Params must be either object or array'); } if (params === null) { throw new TypeError('Params cannot be null'); } return this.#translateParams(params); } } export { type Statement }; /** * Options for `db.pragma()` method. * * If `simple` is `true` - pragma returns the first column of the first row of * the result. */ export type PragmaOptions = Readonly<{ simple?: true; }>; /** * Result of `db.pragma()` method. * * Either a list of rows a single column from the first row depending on the * options. */ export type PragmaResult = Options extends { simple: true; } ? RowType<{ pluck: true }> | undefined : Array>; /** * An entry of result array of `stmt.scanStats()` method. * * Value of `-1` indicates that the field is not available for a given entry. */ export type ScanStats = Readonly<{ id: number; parent: number; cycles: number; loops: number; rows: number; explain: string | null; }>; /** @internal */ type TransactionStatement = Statement<{ persistent: true; pluck: true }>; export type DatabaseOptions = Readonly<{ /** * If `true` - all statements are persistent by default (unless * `persistent` is set to `false` in `StatementOptions`, and persistent * statements are automatically cached and reused until closed. * * @see {@link StatementOptions} */ cacheStatements?: boolean; }>; /** * @param dbName - The name of the database that was written to. * @param pageCount - The number of pages currently in the write-ahead log file, * including those that were just committed. */ export type WalHook = (dbName: string, pageCount: number) => void; /** * A sqlite database class. */ export default class Database { #native: NativeDatabase | undefined; #transactionDepth = 0; #isCacheEnabled: boolean; #statementCache = new Map(); #transactionStmts: | Readonly<{ begin: TransactionStatement; rollback: TransactionStatement; commit: TransactionStatement; savepoint: TransactionStatement; rollbackTo: TransactionStatement; release: TransactionStatement; }> | undefined; /** * Constructor * * @param path - The path to the database file or ':memory:'/'' for opening * the in-memory database. */ constructor(path = ':memory:', { cacheStatements }: DatabaseOptions = {}) { if (typeof path !== 'string') { throw new TypeError('Invalid database path'); } this.#native = addon.databaseOpen(path); this.#isCacheEnabled = cacheStatements === true; } public initTokenizer(): void { if (this.#native === undefined) { throw new Error('Database closed'); } addon.databaseInitTokenizer(this.#native); } /** * Execute one or multiple SQL statements in a given `sql` string. * * @param sql - one or multiple SQL statements */ public exec(sql: string): void { if (this.#native === undefined) { throw new Error('Database closed'); } if (typeof sql !== 'string') { throw new TypeError('Invalid sql argument'); } addon.databaseExec(this.#native, sql); } /** * Create custom SQL function with a given `name`. * * @param name - name of the function * @param fn - function implementation * @param options - function options. */ public createFunction( name: string, fn: (...args: ReadonlyArray) => void, options: FunctionOptions = {}, ): void { if (this.#native === undefined) { throw new Error('Database closed'); } if (typeof name !== 'string') { throw new TypeError('Invalid name argument'); } if (typeof fn !== 'function') { throw new TypeError('Invalid fn argument'); } addon.databaseCreateFunction( this.#native, name, fn, options.bigint === true, ); } /** * Register a callback to be invoked each time data is commited to a database * in WAL mode. * * @param fn - function implementation */ public setWalHook(fn: WalHook): void { if (this.#native === undefined) { throw new Error('Database closed'); } if (typeof fn !== 'function') { throw new TypeError('Invalid fn argument'); } addon.databaseSetWalHook(this.#native, fn); } /** * Compile a single SQL statement. * * @param query - a single SQL statement. * @param options - statement options. * @returns Statement instance. * * @see {@link StatementOptions} */ public prepare( query: string, options: Options, ): Statement; /** * Compile a single SQL statement. * * @param query - a single SQL statement. * @returns Statement instance. */ public prepare(query: string): Statement; public prepare( query: string, options: Options = {} as Options, ): Statement { if (this.#native === undefined) { throw new Error('Database closed'); } if (typeof query !== 'string') { throw new TypeError('Invalid query argument'); } if (!this.#isCacheEnabled || options.persistent === false) { return new Statement(this.#native, query, options); } // Persistent statements are cached until closed. const cacheKey = `${options.pluck}:${options.bigint}:${query}`; const cached = this.#statementCache.get(cacheKey); if (cached !== undefined) { return cached; } const stmt = new Statement( this.#native, query, { persistent: true, pluck: options.pluck, bigint: options.bigint, } as Options, () => this.#statementCache.delete(cacheKey), ); this.#statementCache.set(cacheKey, stmt); return stmt; } /** * Close the database and all associated statements. */ public close(): void { if (this.#native === undefined) { throw new Error('Database already closed'); } addon.databaseClose(this.#native); this.#native = undefined; } /** * Run a pragma statement and return the result. * * @param source - pragma query source * @param options - options to control the return value of `.pragma()` * @returns Either multiple rows returned by the statement, or the first * column of the first row (or `undefined`) if `options` has * `simple: true`. * * @see {@link PragmaOptions} */ public pragma( source: string, { simple }: Options, ): PragmaResult; /** * Run a pragma statement and return the result. * * @param source - pragma query source * @returns Either multiple rows returned by the statement. */ public pragma(source: string): PragmaResult; public pragma( source: string, { simple }: Options = {} as Options, ): PragmaResult { if (typeof source !== 'string') { throw new TypeError('Invalid pragma argument'); } if (simple === true) { const stmt = this.prepare(`PRAGMA ${source}`, { pluck: true }); return stmt.get() as unknown as PragmaResult; } const stmt = this.prepare(`PRAGMA ${source}`); return stmt.all() as unknown as PragmaResult; } /** * Wrap `fn()` in a transaction. * * @param fn - a function to be executed within a transaction. * @returns The value returned by `fn()`. */ public transaction( fn: (...params: Params) => Result, ): typeof fn { return (...params: Params) => { if (this.#transactionStmts === undefined) { const options = { persistent: true as const, pluck: true as const }; this.#transactionStmts = { begin: this.prepare('BEGIN', options), rollback: this.prepare('ROLLBACK', options), commit: this.prepare('COMMIT', options), savepoint: this.prepare('SAVEPOINT signalappsqlcipher', options), rollbackTo: this.prepare('ROLLBACK TO signalappsqlcipher', options), release: this.prepare('RELEASE signalappsqlcipher', options), }; } this.#transactionDepth += 1; let begin: TransactionStatement; let rollback: TransactionStatement; let commit: TransactionStatement; if (this.#transactionDepth === 1) { ({ begin, rollback, commit } = this.#transactionStmts); } else { ({ savepoint: begin, rollbackTo: rollback, release: commit, } = this.#transactionStmts); } begin.run(); try { const result = fn(...params); commit.run(); return result; } catch (error) { try { rollback.run(); } catch (rollbackError) { if (rollbackError instanceof Error) { rollbackError.cause = error; } throw rollbackError; } throw error; } finally { this.#transactionDepth -= 1; } }; } /** * Tokenize a given sentence with a Signal-FTS5-Extension. * * @param value - a sentence * @returns a list of word-like tokens. * * @see {@link https://github.com/signalapp/Signal-FTS5-Extension} */ public signalTokenize(value: string): Array { if (typeof value !== 'string') { throw new TypeError('Invalid value'); } return addon.signalTokenize(value); } } function setLogger(fn: (code: string, message: string) => void): void { if (typeof fn !== 'function') { throw new TypeError('Invalid value'); } return addon.setLogger(fn); } export { Database, setLogger };