node-sqlcipher/lib/index.ts
2025-08-07 10:32:09 -07:00

591 lines
16 KiB
TypeScript

// 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,
): NativeStatement;
statementRun<Options extends StatementOptions>(
stmt: NativeStatement,
params: StatementParameters<Options> | undefined,
result: [number, number],
): void;
statementStep<Options extends StatementOptions>(
stmt: NativeStatement,
params: StatementParameters<Options> | null | undefined,
cache: Array<SqliteValue<Options>> | undefined,
isGet: boolean,
): Array<SqliteValue<Options>>;
statementScanStats(stmt: NativeStatement): Array<ScanStats>;
statementClose(stmt: NativeStatement): void;
databaseOpen(path: string): NativeDatabase;
databaseInitTokenizer(db: NativeDatabase): void;
databaseExec(db: NativeDatabase, query: string): void;
databaseClose(db: NativeDatabase): void;
signalTokenize(value: string): Array<string>;
}>(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;
}>;
/**
* Parameters accepted by `.run()`/`.get()`/`.all()` methods of the statement.
*/
export type StatementParameters<Options extends StatementOptions> =
| ReadonlyArray<SqliteValue<Options>>
| Readonly<Record<string, SqliteValue<Options>>>;
/**
* Possible SQL values given statement options.
*/
export type SqliteValue<Options extends StatementOptions> =
| 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 StatementOptions> = Options extends {
pluck: true;
}
? SqliteValue<Options>
: Record<string, SqliteValue<Options>>;
/**
* A compiled SQL statement class.
*/
class Statement<Options extends StatementOptions = object> {
readonly #needsTranslation: boolean;
#cache: Array<SqliteValue<Options>> | undefined;
#createRow: undefined | ((result: unknown) => RowType<Options>);
#native: NativeStatement | undefined;
#onClose: (() => void) | undefined;
/** @internal */
constructor(
db: NativeDatabase,
query: string,
{ persistent, pluck, bigint }: Options,
onClose?: () => void,
) {
this.#needsTranslation = persistent === true && !pluck;
this.#native = addon.statementNew(
db,
query,
persistent === true,
pluck === true,
bigint === true,
);
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<Options>): RunResult {
if (this.#native === undefined) {
throw new Error('Statement closed');
}
const result: [number, number] = [0, 0];
this.#checkParams(params);
addon.statementRun(this.#native, params, 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<Row extends RowType<Options> = RowType<Options>>(
params?: StatementParameters<Options>,
): Row | undefined {
if (this.#native === undefined) {
throw new Error('Statement closed');
}
this.#checkParams(params);
const result = addon.statementStep(this.#native, params, 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<Row extends RowType<Options> = RowType<Options>>(
params?: StatementParameters<Options>,
): Array<Row> {
if (this.#native === undefined) {
throw new Error('Statement closed');
}
const result = [];
this.#checkParams(params);
let singleUseParams: StatementParameters<Options> | undefined | null =
params;
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<Row>;
}
/**
* 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<ScanStats> {
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<SqliteValue<Options>>,
): (result: unknown) => RowType<Options> {
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<Options> | undefined): void {
if (params === undefined) {
return;
}
if (typeof params !== 'object') {
throw new TypeError('Params must be either object or array');
}
if (params === null) {
throw new TypeError('Params cannot be null');
}
}
}
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 PragmaOptions> = Options extends {
simple: true;
}
? RowType<{ pluck: true }> | undefined
: Array<RowType<object>>;
/**
* 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;
}>;
/**
* A sqlite database class.
*/
export default class Database {
#native: NativeDatabase | undefined;
#transactionDepth = 0;
#isCacheEnabled: boolean;
#statementCache = new Map<string, Statement>();
#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);
}
/**
* Compile a single SQL statement.
*
* @param query - a single SQL statement.
* @param options - statement options.
* @returns Statement instance.
*
* @see {@link StatementOptions}
*/
public prepare<Options extends StatementOptions = StatementOptions>(
query: string,
options: Options,
): Statement<Options>;
/**
* Compile a single SQL statement.
*
* @param query - a single SQL statement.
* @returns Statement instance.
*/
public prepare(query: string): Statement<object>;
public prepare<Options extends StatementOptions = StatementOptions>(
query: string,
options: Options = {} as Options,
): Statement<Options> {
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<Options extends PragmaOptions>(
source: string,
{ simple }: Options,
): PragmaResult<Options>;
/**
* 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<object>;
public pragma<Options extends PragmaOptions = object>(
source: string,
{ simple }: Options = {} as Options,
): PragmaResult<Options> {
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<Options>;
}
const stmt = this.prepare(`PRAGMA ${source}`);
return stmt.all() as unknown as PragmaResult<Options>;
}
/**
* Wrap `fn()` in a transaction.
*
* @param fn - a function to be executed within a transaction.
* @returns The value returned by `fn()`.
*/
public transaction<Params extends [], Result>(
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<string> {
if (typeof value !== 'string') {
throw new TypeError('Invalid value');
}
return addon.signalTokenize(value);
}
}
export { Database };