diff --git a/package.json b/package.json index 82a3d1ab..9fcb22b7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@gocodingnow/rn-size-matters": "^0.0.4", "@hot-updater/react-native": "0.23.1", "@notifee/react-native": "^9.1.8", + "@op-engineering/op-sqlite": "^16.2.0", "@react-native-clipboard/clipboard": "^1.16.3", "@react-native-community/netinfo": "^11.4.1", "@react-native-firebase/app": "^23.5.0", @@ -77,7 +78,6 @@ "react-native-pager-view": "^8.0.0", "react-native-qrcode-svg": "^6.3.15", "react-native-quick-crypto": "^0.7.17", - "react-native-quick-sqlite": "8.2.7", "react-native-reanimated": "^4.2.2", "react-native-safe-area-context": "^5.6.1", "react-native-screens": "^4.16.0", diff --git a/src/services/db/connection.ts b/src/services/db/connection.ts new file mode 100644 index 00000000..6539c163 --- /dev/null +++ b/src/services/db/connection.ts @@ -0,0 +1,201 @@ +/** + * op-sqlite connection adapter. + * + * This module is the single seam between the rest of the app and the native + * SQLite library. It exposes a small, quick-sqlite-compatible surface so that + * `services/sqlite.ts` (and its `Database.*` facade, consumed across 9 files) + * keeps working with minimal changes after the migration from + * `react-native-quick-sqlite` to `@op-engineering/op-sqlite`. + * + * Three op-sqlite differences are reconciled here so callers never see them: + * + * 1. SYNC vs ASYNC. In op-sqlite `execute()` is ASYNC and the synchronous + * variant is `executeSync()`. quick-sqlite was the opposite: `execute()` + * was sync and `executeAsync()` async. We map: + * our `execute` -> op-sqlite `executeSync` (stays synchronous) + * our `executeAsync` -> op-sqlite `execute` (stays async) + * + * 2. NO SYNC BATCH. op-sqlite `executeBatch()` is async-only and returns no + * `insertId`. Our reservation / schema / migration code calls `executeBatch` + * synchronously and depends on its all-or-nothing atomicity. We emulate a + * synchronous atomic batch with an explicit BEGIN / COMMIT / ROLLBACK over + * `executeSync` (see `executeBatch` below). + * + * 3. RESULT SHAPE. op-sqlite returns `rows` as a plain array. quick-sqlite + * returned a WebSQL-style object with `item(i)`, `length`, and `_array`. + * We wrap every result back into that shape (see `adaptResult`) so the ~600 + * lines of query code in `sqlite.ts` are untouched. + * + * It is ALSO the one place where parameters are sanitized before they reach the + * native binder (see `sanitizeParams`). This closes the silent-bind footgun that + * motivated the migration: in quick-sqlite a non-bindable JS object (e.g. a + * cashu-ts `Amount` instance) fell through the C++ binding silently, shifting + * every subsequent parameter and corrupting the row. Here, such a value is + * either coerced deliberately or rejected loudly — never silently dropped. + */ +import {open as opOpen, DB, Scalar, SQLBatchTuple as OpSQLBatchTuple} from '@op-engineering/op-sqlite' +import AppError, {Err} from '../../utils/AppError' + +export type {Scalar} + +/** + * A batch command: a SQL string, optionally with a single row of params. + * + * Intentionally looser than op-sqlite's `SQLBatchTuple` (whose `Scalar[][]` + * "param sets" variant makes TS reject the ordinary single-row tuples this + * codebase builds). Params are sanitized at bind time, so `any[]` here is safe. + */ +export type SQLBatchTuple = [string] | [string, any[]] + +/** WebSQL-style row accessor, preserved for compatibility with existing code. */ +export type AdaptedRows = { + _array: any[] + length: number + // Returns `any` (not `T | undefined`) to match quick-sqlite's original typing; + // callers already guard the empty-result case where it matters. + item: (index: number) => any +} + +/** quick-sqlite-compatible result shape. */ +export type QueryResult = { + insertId?: number + rowsAffected: number + rows: AdaptedRows +} + +/** + * The connection surface the rest of the app sees. Intentionally a subset of + * the old `QuickSQLiteConnection` — only the members `sqlite.ts` actually uses. + */ +export type DbConnection = { + /** Synchronous single statement (maps to op-sqlite `executeSync`). */ + execute: (query: string, params?: unknown[]) => QueryResult + /** Asynchronous single statement (maps to op-sqlite `execute`). */ + executeAsync: (query: string, params?: unknown[]) => Promise + /** + * Synchronous atomic batch, emulated with BEGIN/COMMIT/ROLLBACK so the + * historical synchronous semantics and all-or-nothing guarantee are kept. + */ + executeBatch: (commands: SQLBatchTuple[]) => {rowsAffected: number} + /** Native async batch (op-sqlite `executeBatch`), for non-atomic-sync paths. */ + executeBatchAsync: (commands: SQLBatchTuple[]) => Promise<{rowsAffected: number}> +} + +/** + * Coerce a single JS value to a SQLite-bindable `Scalar`, or throw. + * + * The whole point of routing every bind through here is that an unbindable + * value can NEVER be silently skipped (the quick-sqlite bug). The rules: + * - null / undefined -> null + * - string / boolean / buffers -> passed through unchanged + * - finite number -> passed through (NaN / Infinity rejected) + * - bigint -> number if safe, else string + * - Date -> ISO string (every date column here is TEXT) + * - other object (e.g. Amount) -> Number(v) if finite, else THROW + * - anything else -> THROW + */ +const sanitizeValue = (value: unknown, index: number): Scalar => { + if (value === null || value === undefined) return null + + const t = typeof value + if (t === 'string' || t === 'boolean') return value as Scalar + if (t === 'number') { + if (!Number.isFinite(value as number)) { + throw new AppError(Err.DATABASE_ERROR, 'Cannot bind non-finite number', {index, value}) + } + return value as number + } + if (t === 'bigint') { + const n = Number(value) + return Number.isSafeInteger(n) ? n : String(value) + } + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return value as Scalar + } + if (value instanceof Date) { + return value.toISOString() + } + if (t === 'object') { + // cashu-ts `Amount` and other numeric-like wrappers coerce cleanly. Plain + // objects / arrays produce NaN and are rejected so the bug surfaces loudly. + const n = Number(value) + if (Number.isFinite(n)) return n + throw new AppError(Err.DATABASE_ERROR, 'Cannot bind non-numeric object parameter', { + index, + constructor: (value as object)?.constructor?.name, + }) + } + + throw new AppError(Err.DATABASE_ERROR, `Cannot bind parameter of type ${t}`, {index}) +} + +const sanitizeParams = (params?: unknown[]): Scalar[] | undefined => { + if (!params || params.length === 0) return undefined + return params.map(sanitizeValue) +} + +const adaptResult = (r: {insertId?: number; rowsAffected?: number; rows?: any[]}): QueryResult => { + const rows = r.rows ?? [] + return { + insertId: r.insertId, + rowsAffected: r.rowsAffected ?? 0, + rows: { + _array: rows, + length: rows.length, + item: (index: number) => rows[index], + }, + } +} + +/** + * Is the tuple's params slot a list of param SETS (Scalar[][]) rather than a + * single param row (Scalar[])? op-sqlite's `SQLBatchTuple` allows both; the old + * code only ever used the single-row form, but we handle both for safety. + */ +const isParamSets = (params: unknown[]): params is unknown[][] => + params.length > 0 && Array.isArray(params[0]) + +const open = (params: {name: string; location?: string; encryptionKey?: string}): DbConnection => { + const db: DB = opOpen(params) + + const execute = (query: string, p?: unknown[]): QueryResult => + adaptResult(db.executeSync(query, sanitizeParams(p))) + + const executeAsync = async (query: string, p?: unknown[]): Promise => + adaptResult(await db.execute(query, sanitizeParams(p))) + + const executeBatch = (commands: SQLBatchTuple[]): {rowsAffected: number} => { + let rowsAffected = 0 + db.executeSync('BEGIN') + try { + for (const [query, p] of commands as Array<[string, unknown[]?]>) { + if (p && isParamSets(p)) { + for (const set of p) { + rowsAffected += db.executeSync(query, sanitizeParams(set)).rowsAffected ?? 0 + } + } else { + rowsAffected += db.executeSync(query, sanitizeParams(p)).rowsAffected ?? 0 + } + } + db.executeSync('COMMIT') + } catch (e) { + // Best-effort rollback; surface the original error to the caller. + try { + db.executeSync('ROLLBACK') + } catch { + // ignore — nothing to roll back / already aborted + } + throw e + } + return {rowsAffected} + } + + const executeBatchAsync = async (commands: SQLBatchTuple[]): Promise<{rowsAffected: number}> => { + const r = await db.executeBatch(commands as OpSQLBatchTuple[]) + return {rowsAffected: r.rowsAffected ?? 0} + } + + return {execute, executeAsync, executeBatch, executeBatchAsync} +} + +export {open, sanitizeParams} diff --git a/src/services/sqlite.ts b/src/services/sqlite.ts index 8bb4bcbe..235560b3 100644 --- a/src/services/sqlite.ts +++ b/src/services/sqlite.ts @@ -1,8 +1,8 @@ import { - QuickSQLiteConnection, + DbConnection, open, SQLBatchTuple, -} from 'react-native-quick-sqlite' +} from './db/connection' import {Proof, ProofState} from '../models/Proof' import {CashuProof} from './cashu/cashuUtils' import { @@ -14,6 +14,16 @@ import {log} from './logService' import {ProofRecord} from '../models/Proof' import { isAlive } from 'mobx-state-tree' +/** + * Normalize an arbitrary caught error into an AppError for the DB layer. + * + * Existing AppErrors (e.g. a NOTFOUND_ERROR raised deliberately inside a query) + * pass through unchanged so their specific `name`/code isn't flattened into a + * generic DATABASE_ERROR. Everything else is wrapped with the supplied message. + */ +const dbError = (message: string, e: any): AppError => + e instanceof AppError ? e : new AppError(Err.DATABASE_ERROR, message, e?.message) + // Helper functions to normalize transaction records with Date objects const normalizeTransactionRecord = function (r: any) { if (r.createdAt) r.createdAt = new Date(r.createdAt); @@ -25,14 +35,14 @@ const normalizeTransactionRows = function(rows: any) { return rows?._array.map(normalizeTransactionRecord) as Transaction[]; } -let _db: QuickSQLiteConnection +let _db: DbConnection const _dbVersion = 26 // Update this if db changes require migrations const getInstance = function () { if (!_db) { // 1. creates database - _db = _createDatabaseInstance() as QuickSQLiteConnection + _db = _createDatabaseInstance() as DbConnection // 2. Runs possible migrations and sets version _createOrUpdateSchema(_db) @@ -44,17 +54,13 @@ const getInstance = function () { const _createDatabaseInstance = function () { try { const instance = open({name: 'minibits.db'}) - return instance as QuickSQLiteConnection + return instance as DbConnection } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Could not create or open database', - e.message, - ) + throw dbError('Could not create or open database', e) } } -const _createOrUpdateSchema = function (db: QuickSQLiteConnection) { +const _createOrUpdateSchema = function (db: DbConnection) { const creationQueries = [ [ `CREATE TABLE IF NOT EXISTS transactions ( @@ -141,17 +147,13 @@ const _createOrUpdateSchema = function (db: QuickSQLiteConnection) { } } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Could not create or update database schema', - e.message, - ) + throw dbError('Could not create or update database schema', e) } } // Run database migrations in case on device version of schema is not yet set or outdated -const _runMigrations = function (db: QuickSQLiteConnection) { +const _runMigrations = function (db: DbConnection) { const now = new Date() const {version} = getDatabaseVersion(db) @@ -322,16 +324,12 @@ const cleanAll = function () { log.info('[cleanAll]', 'Database tables were deleted') } } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Could not delete database schema', - e.message, - ) + throw dbError('Could not delete database schema', e) } } -const getDatabaseVersion = function (db: QuickSQLiteConnection): {version: number} { +const getDatabaseVersion = function (db: DbConnection): {version: number} { try { const query = ` SELECT version FROM dbVersion @@ -355,11 +353,7 @@ const getDatabaseVersion = function (db: QuickSQLiteConnection): {version: numbe return rows?.item(0) } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Could not get database version', - e.message, - ) + throw dbError('Could not get database version', e) } } /* @@ -406,11 +400,7 @@ const updateTransaction = function (id: number, fields: Partial): T return updated as Transaction } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Could not update transaction in database', - e.message, - ) + throw dbError('Could not update transaction in database', e) } } @@ -447,11 +437,7 @@ const getTransactionsAsync = async function (limit: number, offset: number, only return normalizeTransactionRows(rows) } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions could not be retrieved from the database', - e.message, - ) + throw dbError('Transactions could not be retrieved from the database', e) } } @@ -524,11 +510,7 @@ const searchTransactionsAsync = async function ( return normalizeTransactionRows(rows) } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions search failed', - e.message, - ) + throw dbError('Transactions search failed', e) } } @@ -543,11 +525,7 @@ const searchTransactionsCount = function ( const {rows} = db.execute(query, params) return (rows?.item(0)?.total as number) || 0 } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions search count failed', - e.message, - ) + throw dbError('Transactions search count failed', e) } } @@ -569,11 +547,7 @@ const getPendingTopups = function () { return normalizeTransactionRows(rows) } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions could not be retrieved from the database', - e.message, - ) + throw dbError('Transactions could not be retrieved from the database', e) } } @@ -596,11 +570,7 @@ const getPendingTransfers = function () { return normalizeTransactionRows(rows) } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions could not be retrieved from the database', - e.message, - ) + throw dbError('Transactions could not be retrieved from the database', e) } } @@ -624,11 +594,7 @@ const getPendingTopupsCount = function () { return rows?.item(0)['total'] as number } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions could not be retrieved from the database', - e.message, - ) + throw dbError('Transactions could not be retrieved from the database', e) } } @@ -652,11 +618,7 @@ const getPendingTransfersCount = function () { return rows?.item(0)['total'] as number } catch (e: any) { - throw new AppError( - Err.DATABASE_ERROR, - 'Transactions could not be retrieved from the database', - e.message, - ) + throw dbError('Transactions could not be retrieved from the database', e) } } @@ -707,7 +669,7 @@ const getTransactionsCount = function (status?: TransactionStatus) { } } catch (e: any) { - throw new AppError(Err.DATABASE_ERROR, 'Transaction count error', e.message) + throw dbError('Transaction count error', e) } } @@ -732,7 +694,7 @@ const getRecentTransactionsByUnitAsync = async (countRecent: number) => { return normalizeTransactionRows(rows) } catch (e: any) { - throw new AppError(Err.DATABASE_ERROR, 'Error retrieving last 3 transactions by unit', e.message) + throw dbError('Error retrieving last 3 transactions by unit', e) } } @@ -753,7 +715,7 @@ const getPendingAmount = function () { return rows?.item(0)['SUM(amount)'] } catch (e: any) { - throw new AppError(Err.DATABASE_ERROR, 'Transaction not found', e.message) + throw dbError('Transaction not found', e) } } @@ -771,7 +733,7 @@ const getTransactionById = function (id: number) { return normalizeTransactionRecord(rows?.item(0)) } catch (e: any) { - throw new AppError(Err.DATABASE_ERROR, 'Transaction not found', e.message) + throw dbError('Transaction not found', e) } } @@ -842,16 +804,9 @@ const getLastTransactionBy = function ( return normalizeTransactionRecord(row) } catch (e: any) { - if (e instanceof AppError) { - throw e // rethrow known app errors - } - - log.error('[getLastTransactionBy] Database error', e) - throw new AppError( - Err.DATABASE_ERROR, - 'Failed to fetch transaction', - e.message || String(e) - ) + // dbError passes through the deliberate NOTFOUND/validation AppErrors above + // and wraps anything else as a DATABASE_ERROR. + throw dbError('Failed to fetch transaction', e) } } @@ -875,11 +830,7 @@ const addTransactionAsync = async function (tx: Partial): Promise=17.0.0" - react-native: ">=0.74.0" - checksum: a2b9202025dae60896445674f65ca56c3b8fb8371346dd95dbbc2aa2c8637993729f92277d4f7d4d4ae0ea39e943aa3f8b611c2d430a19e7bffc0e6191c80211 - languageName: node - linkType: hard - "react-native-reanimated@npm:^4.2.2": version: 4.2.2 resolution: "react-native-reanimated@npm:4.2.2" @@ -17006,13 +16978,6 @@ __metadata: languageName: node linkType: hard -"reflect-metadata@npm:^0.2.1": - version: 0.2.2 - resolution: "reflect-metadata@npm:0.2.2" - checksum: a66c7b583e4efdd8f3c3124fbff33da2d0c86d8280617516308b32b2159af7a3698c961db3246387f56f6316b1d33a608f39bb2b49d813316dfc58f6d3bf3210 - languageName: node - linkType: hard - "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -17644,18 +17609,6 @@ __metadata: languageName: node linkType: hard -"sha.js@npm:^2.4.11": - version: 2.4.11 - resolution: "sha.js@npm:2.4.11" - dependencies: - inherits: ^2.0.1 - safe-buffer: ^5.0.1 - bin: - sha.js: ./bin.js - checksum: ebd3f59d4b799000699097dadb831c8e3da3eb579144fd7eb7a19484cbcbb7aca3c68ba2bb362242eb09e33217de3b4ea56e4678184c334323eca24a58e3ad07 - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -18598,7 +18551,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.5.0": +"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a @@ -18748,86 +18701,6 @@ __metadata: languageName: node linkType: hard -"typeorm@npm:^0.3.20": - version: 0.3.20 - resolution: "typeorm@npm:0.3.20" - dependencies: - "@sqltools/formatter": ^1.2.5 - app-root-path: ^3.1.0 - buffer: ^6.0.3 - chalk: ^4.1.2 - cli-highlight: ^2.1.11 - dayjs: ^1.11.9 - debug: ^4.3.4 - dotenv: ^16.0.3 - glob: ^10.3.10 - mkdirp: ^2.1.3 - reflect-metadata: ^0.2.1 - sha.js: ^2.4.11 - tslib: ^2.5.0 - uuid: ^9.0.0 - yargs: ^17.6.2 - peerDependencies: - "@google-cloud/spanner": ^5.18.0 - "@sap/hana-client": ^2.12.25 - better-sqlite3: ^7.1.2 || ^8.0.0 || ^9.0.0 - hdb-pool: ^0.1.6 - ioredis: ^5.0.4 - mongodb: ^5.8.0 - mssql: ^9.1.1 || ^10.0.1 - mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^6.3.0 - pg: ^8.5.1 - pg-native: ^3.0.0 - pg-query-stream: ^4.0.0 - redis: ^3.1.1 || ^4.0.0 - sql.js: ^1.4.0 - sqlite3: ^5.0.3 - ts-node: ^10.7.0 - typeorm-aurora-data-api-driver: ^2.0.0 - peerDependenciesMeta: - "@google-cloud/spanner": - optional: true - "@sap/hana-client": - optional: true - better-sqlite3: - optional: true - hdb-pool: - optional: true - ioredis: - optional: true - mongodb: - optional: true - mssql: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-native: - optional: true - pg-query-stream: - optional: true - redis: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - ts-node: - optional: true - typeorm-aurora-data-api-driver: - optional: true - bin: - typeorm: cli.js - typeorm-ts-node-commonjs: cli-ts-node-commonjs.js - typeorm-ts-node-esm: cli-ts-node-esm.js - checksum: 9d6e5ecd0688eed5a151f33fb26b6d7c3e0af0eaf14ceaefe4c1c1863cdda6eabf2e865c9df2321f7dce26e40f320b76b811e2d1d58bf43f6ce19b36aa1ed1c4 - languageName: node - linkType: hard - "typescript@npm:^5.8.3": version: 5.9.3 resolution: "typescript@npm:5.9.3"