// Guarded loader for the SQLCipher native binding. // // WHY THIS FILE EXISTS AT ALL: `@signalapp/sqlcipher` is declared in // package.json's `optionalDependencies`, not `dependencies`, and it MUST stay // there. It publishes six N-API prebuilds (darwin/linux/win32 x arm64/x64) and // **no build sources at all** - the published tarball has no `binding.gyp`, no // `src/`, no `deps/`. Its install script is `node-gyp-build`, which falls back // to `node-gyp rebuild` when no prebuild matches, and that fallback cannot // succeed without sources. So on a platform with no matching prebuild the // install FAILS. // // Both Dockerfiles in this repo are `FROM node:24-alpine` + `npm ci` // (`Dockerfile:1-4`, `integration/webmail.Dockerfile:15-19`). Alpine is musl; // there is no `linuxmusl-*` prebuild (and the glibc prebuild could not load // there anyway). As a hard `dependencies` entry this would break the // production image build and the integration fixture's webmail container - // neither of which wants this feature, they just need `npm ci` to exit 0. // `optionalDependencies` makes npm treat that install failure as non-fatal and // simply omit the package. // // The cost of that choice is exactly this module: the require must be guarded // at runtime, because "installed" is no longer guaranteed. Callers get // `null` and the feature turns itself off, which is the correct behaviour for // a desktop-only search index in a server that may not be a desktop. /** * Minimal structural type for the bits of `@signalapp/sqlcipher` we use. * * Deliberately hand-written rather than `typeof import('@signalapp/sqlcipher')`: * the package is optional, so a type-only import would make `tsc` fail on any * machine where the install was skipped - which is every Alpine CI container. * * NOTE the parameter shape. `@signalapp/sqlcipher` is NOT drop-in compatible * with better-sqlite3 here: its `#checkParams` throws * `TypeError: Params must be either object or array`, so `stmt.run(a, b, c)` * (varargs, which better-sqlite3 accepts) is a runtime error. Always pass a * single array or object. Found by executing it, not by reading the types. */ export interface SqlcipherStatement { run(params?: readonly unknown[] | Record): { changes: number; lastInsertRowid: number }; get(params?: readonly unknown[] | Record): Record | undefined; all(params?: readonly unknown[] | Record): Array>; } export interface SqlcipherDatabase { exec(sql: string): void; prepare(sql: string): SqlcipherStatement; pragma(source: string): unknown; close(): void; } export interface SqlcipherConstructor { new (path?: string): SqlcipherDatabase; } let cached: SqlcipherConstructor | null | undefined; /** * Returns the Database constructor, or `null` when the optional native binding * is not installed / cannot load on this platform. Never throws. * * Memoised on both outcomes so a missing binding costs one failed require per * process rather than one per request. */ export function loadSqlcipher(): SqlcipherConstructor | null { if (cached !== undefined) return cached; try { // eslint-disable-next-line @typescript-eslint/no-require-imports const mod = require('@signalapp/sqlcipher') as | { default?: SqlcipherConstructor } | SqlcipherConstructor; const ctor = (mod as { default?: SqlcipherConstructor }).default ?? (mod as SqlcipherConstructor); cached = typeof ctor === 'function' ? ctor : null; } catch { cached = null; } return cached; } /** True when the local index can work at all in this process. */ export function isSqlcipherAvailable(): boolean { return loadSqlcipher() !== null; }