From 1fa80540e7b428adf42a5f7614015888d5a38a17 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 13:57:27 +0200 Subject: [PATCH 01/18] chore(deps): support NitroSQLite 10 --- package-lock.json | 10 +++++----- package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4609fbcfd..9713f1410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "^9.6.0", + "react-native-nitro-sqlite": "^10.0.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", @@ -78,7 +78,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^9.6.0" + "react-native-nitro-sqlite": "^10.0.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -13936,9 +13936,9 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-9.6.0.tgz", - "integrity": "sha512-a/N1yGhM8RvCCnaYhEHhh35YS+HDOAcGKeKFsp2ExCzIjP8vPXuzQtHylgLQLeAh7rUaism5q0QQFfogXm1SXA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.0.0.tgz", + "integrity": "sha512-3+tcmKWjS2covvqBsK3EvpiClSekllwVOTRO3BzwsapIf/hvawcbippcPi1gcwEORS41UcQr0oI/QgYoJrjK2Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 89a1e0d1b..2906d33e5 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "^9.6.0", + "react-native-nitro-sqlite": "^10.0.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", @@ -108,7 +108,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^9.6.0" + "react-native-nitro-sqlite": "^10.0.0" }, "peerDependenciesMeta": { "idb-keyval": { From 455b30aee6ea0ad66de34307686b59b409221d83 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 15:10:40 +0200 Subject: [PATCH 02/18] feat(storage): use NitroSQLite 10 connections and statements --- lib/storage/providers/SQLiteProvider.ts | 98 +++++++--- tests/unit/mocks/sqliteMock.ts | 181 +++++++++++++++--- .../storage/providers/SQLiteProviderTest.ts | 76 +++++++- 3 files changed, 297 insertions(+), 58 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 013139120..76938cf13 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -2,7 +2,7 @@ * The SQLiteStorage provider stores everything in a key/value store by * converting the value to a JSON string */ -import type {BatchQueryCommand, NitroSQLiteConnection, QueryResult} from 'react-native-nitro-sqlite'; +import type {BatchQueryCommand, NitroSQLiteConnection, PreparedStatement, QueryResult} from 'react-native-nitro-sqlite'; import {open} from 'react-native-nitro-sqlite'; import {getFreeDiskStorage} from 'react-native-device-info'; import type {FastMergeReplaceNullPatch} from '../../utils'; @@ -50,6 +50,33 @@ const COMPILE_OPTIONS = { /** SQLite's maximum number of bound parameters per statement, read once from PRAGMA compile_options in init(). */ let sqliteMaxVariableNumber = SQLITE_MAX_VARIABLE_NUMBER; +let readStore: NitroSQLiteConnection | undefined; +let getItemStatement: PreparedStatement | undefined; +let setItemStatement: PreparedStatement | undefined; +let removeItemStatement: PreparedStatement | undefined; +let pendingWrite: Promise = Promise.resolve(); +let pendingWriteCount = 0; + +function trackWrite(operation: Promise): Promise { + // All writes use the same NitroSQLite connection queue and finish in call order. + // A failed write still releases later reads; its caller receives the rejection. + pendingWriteCount++; + pendingWrite = operation.then( + () => { + pendingWriteCount--; + }, + () => { + pendingWriteCount--; + }, + ); + return operation; +} + +function readAfterPendingWrites(read: () => Promise): Promise { + // Capture the writes already queued when the read was requested. New writes can run + // alongside this read on the separate connection once those writes have committed. + return pendingWriteCount === 0 ? read() : pendingWrite.then(read); +} /** * Returns the value of a compile option from the rows returned by `PRAGMA compile_options`. @@ -129,13 +156,22 @@ const provider: StorageProvider = { // stores it in a global variable, that is going to be used during runtime. const maxVariableNumber = Number(getCompileOptionValue(compileOptionsResult, COMPILE_OPTIONS.MAX_VARIABLE_NUMBER)); sqliteMaxVariableNumber = maxVariableNumber > 0 ? maxVariableNumber : SQLITE_MAX_VARIABLE_NUMBER; + + // WAL lets the read-only handle read committed data while the writer is active. + readStore = open({name: DB_NAME, connection: 'independent', readOnly: true}); + getItemStatement = readStore.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); + setItemStatement = provider.store.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'); + removeItemStatement = provider.store.prepare('DELETE FROM keyvaluepairs WHERE record_key = ?;'); + pendingWrite = Promise.resolve(); + pendingWriteCount = 0; }, getItem(key) { - if (!provider.store) { + const statement = getItemStatement; + if (!statement) { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;', [key]).then(({rows}) => { + return readAfterPendingWrites(() => statement.executeAsync([key])).then(({rows}) => { if (!rows || rows?.length === 0) { return null; } @@ -149,7 +185,7 @@ const provider: StorageProvider = { }); }, multiGet(keys) { - if (!provider.store) { + if (!readStore) { throw new Error('Store is not initialized!'); } @@ -158,18 +194,23 @@ const provider: StorageProvider = { } const keyChunks = utils.chunkArray(keys, sqliteMaxVariableNumber); + const reader = readStore; + const commands = keyChunks.map((keyChunk) => ({ + query: `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${keyChunk.map(() => '?').join(',')});`, + params: keyChunk, + })); - return Promise.all( - keyChunks.map((keyChunk) => { - if (!provider.store) { - throw new Error('Store is not initialized!'); - } + const readChunks = () => { + if (commands.length === 1) { + const {query, params} = commands[0]; + return reader.executeAsync(query, params).then((result) => [result]); + } + + // Keep all chunks on one snapshot even if the writer commits between queries. + return reader.transaction((tx) => Promise.all(commands.map(({query, params}) => tx.executeAsync(query, params)))); + }; - const placeholders = keyChunk.map(() => '?').join(','); - const command = `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`; - return provider.store.executeAsync(command, keyChunk); - }), - ).then((results) => { + return readAfterPendingWrites(readChunks).then((results) => { const result = results.flatMap( ({rows}) => // eslint-disable-next-line no-underscore-dangle @@ -179,11 +220,11 @@ const provider: StorageProvider = { }); }, setItem(key, value) { - if (!provider.store) { + if (!setItemStatement) { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);', [key, JSON.stringify(value)]).then(() => undefined); + return trackWrite(setItemStatement.executeAsync([key, JSON.stringify(value)]).then(() => undefined)); }, multiSet(pairs) { if (!provider.store) { @@ -195,7 +236,7 @@ const provider: StorageProvider = { if (utils.isEmptyObject(params)) { return Promise.resolve(); } - return provider.store.executeBatchAsync([{query, params}]).then(() => undefined); + return trackWrite(provider.store.executeBatchAsync([{query, params}]).then(() => undefined)); }, multiMerge(pairs) { if (!provider.store) { @@ -244,32 +285,33 @@ const provider: StorageProvider = { commands.push({query: replaceQuery, params: replaceQueryArguments}); } - return provider.store.executeBatchAsync(commands).then(() => undefined); + return trackWrite(provider.store.executeBatchAsync(commands).then(() => undefined)); }, mergeItem(key, change, replaceNullPatches) { // Since Onyx already merged the existing value with the changes, we can just set the value directly. return provider.multiMerge([[key, change, replaceNullPatches]]); }, getAllKeys() { - if (!provider.store) { + if (!readStore) { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('SELECT record_key FROM keyvaluepairs;').then(({rows}) => { + const reader = readStore; + return readAfterPendingWrites(() => reader.executeAsync('SELECT record_key FROM keyvaluepairs;')).then(({rows}) => { // eslint-disable-next-line no-underscore-dangle const result = rows?._array.map((row) => row.record_key); return (result ?? []) as StorageKeyList; }); }, getAll() { - if (!provider.store) { + if (!readStore) { throw new Error('Store is not initialized!'); } // Aggregate the whole table into a single JSON string in SQLite so we only run JSON.parse // once, instead of returning every row and parsing each one individually in JavaScript. - return provider.store - .executeAsync<{aggregated: string | null}>('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;') + const reader = readStore; + return readAfterPendingWrites(() => reader.executeAsync<{aggregated: string | null}>('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;')) .then(({rows}) => { const aggregated = rows?.item(0)?.aggregated; if (aggregated == null) { @@ -279,11 +321,11 @@ const provider: StorageProvider = { }); }, removeItem(key) { - if (!provider.store) { + if (!removeItemStatement) { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('DELETE FROM keyvaluepairs WHERE record_key = ?;', [key]).then(() => undefined); + return trackWrite(removeItemStatement.executeAsync([key]).then(() => undefined)); }, removeItems(keys) { if (!provider.store) { @@ -303,7 +345,7 @@ const provider: StorageProvider = { if (keyChunks.length === 1) { const keyChunk = keyChunks[0]; - return provider.store.executeAsync(buildDeleteQuery(keyChunk), keyChunk).then(() => undefined); + return trackWrite(provider.store.executeAsync(buildDeleteQuery(keyChunk), keyChunk).then(() => undefined)); } const commands: BatchQueryCommand[] = keyChunks.map((keyChunk) => ({ @@ -311,14 +353,14 @@ const provider: StorageProvider = { params: keyChunk, })); - return provider.store.executeBatchAsync(commands).then(() => undefined); + return trackWrite(provider.store.executeBatchAsync(commands).then(() => undefined)); }, clear() { if (!provider.store) { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('DELETE FROM keyvaluepairs;', []).then(() => undefined); + return trackWrite(provider.store.executeAsync('DELETE FROM keyvaluepairs;', []).then(() => undefined)); }, getDatabaseSize() { if (!provider.store) { diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index d7282e80b..8151e8acf 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -1,24 +1,36 @@ /** * Mock for `react-native-nitro-sqlite` backed by `better-sqlite3`, enabling - * Node-level integration tests against a real SQLite engine. + * Node-level integration tests against separate handles to a temporary SQLite file. * * Implements the NitroSQLite surface used by * `lib/storage/providers/SQLiteProvider.ts`: - * - open({name}) + * - open({name, connection?, readOnly?}) * - connection.execute(sql) * - connection.executeAsync(sql, params?) * - connection.executeBatchAsync([{query, params}, ...]) + * - connection.prepare(sql) * * Result rows are shaped to match Nitro: `{rows: {_array, item, length}}`. */ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import BetterSqlite3 from 'better-sqlite3'; -import type {Database} from 'better-sqlite3'; +import type {Database, Statement} from 'better-sqlite3'; import type {BatchQueryCommand, NitroSQLiteConnection, NitroSQLiteQueryResultRows, QueryResult, QueryResultRow, SQLiteQueryParams} from 'react-native-nitro-sqlite'; // `better-sqlite3` is declared as `export = Database` (CommonJS), so the type is // derived from the default import's namespace rather than via a named type import. -const databases = new Map(); +type OpenOptions = {name: string; connection?: 'default' | 'independent'; readOnly?: boolean}; + +const databases = new Set(); +const defaultConnectionNames = new Set(); +const openOptions: OpenOptions[] = []; +const asyncQueries: Array<{sql: string; readOnly: boolean}> = []; +const preparedQueries: string[] = []; +let databaseDirectory: string | undefined; +let scheduledAsyncExecutionDelay: {skip: number; until: Promise} | undefined; /** * Returns the named-placeholder identifiers (`:name`) in the order of first @@ -55,20 +67,24 @@ function wrapRows(rowsArray: TRow[]): NitroSQLiteQu } function prepareAndBind(database: Database, sql: BatchQueryCommand['query'], parameters?: BatchQueryCommand['params']) { + const statement = database.prepare(sql); + return {statement, boundArguments: getBoundArguments(sql, parameters)}; +} + +function getBoundArguments(sql: string, parameters?: BatchQueryCommand['params']) { const namedOrder = extractNamedParameterOrder(sql); if (namedOrder) { // Map positional parameters array to named bindings object — NitroSQLite's // first-occurrence-order convention. - const statement = database.prepare(sql); const bindings: Record = {}; for (let index = 0; index < namedOrder.length; index++) { bindings[namedOrder[index]] = parameters?.[index]; } // `arguments` is a reserved identifier in strict-mode modules, so the binding is // named `boundArguments`. - return {statement, boundArguments: [bindings] as const}; + return [bindings]; } - return {statement: database.prepare(sql), boundArguments: parameters ?? []}; + return parameters ?? []; } /** @@ -111,7 +127,11 @@ function runOne(database: Database, sql: string, pa return {rowsAffected: 0} as QueryResult; } - const {statement, boundArguments} = prepareAndBind(database, sql, parameters); + return runPrepared(database.prepare(sql), sql, parameters); +} + +function runPrepared(statement: Statement, sql: string, parameters?: SQLiteQueryParams): QueryResult { + const boundArguments = getBoundArguments(sql, parameters); // better-sqlite3 exposes `statement.reader` = true for statements that produce // result columns (SELECT, read-only PRAGMAs). For setter PRAGMAs and DDL @@ -125,24 +145,101 @@ function runOne(database: Database, sql: string, pa return {rowsAffected: info.changes, insertId: Number(info.lastInsertRowid)} as QueryResult; } -function makeConnection(name: string): Pick { - let database = databases.get(name); - if (!database) { - database = new BetterSqlite3(':memory:'); - databases.set(name, database); +function executeAsyncWithDelay(run: () => Result): Promise { + const scheduledDelay = scheduledAsyncExecutionDelay; + if (!scheduledDelay) { + return Promise.resolve().then(run); + } + if (scheduledDelay.skip > 0) { + scheduledDelay.skip--; + return Promise.resolve().then(run); + } + scheduledAsyncExecutionDelay = undefined; + return scheduledDelay.until.then(run); +} + +function makeConnection({name, connection, readOnly = false}: OpenOptions): Pick { + if (connection !== 'independent' && defaultConnectionNames.has(name)) { + throw new Error(`Database ${name} is already open`); + } + + databaseDirectory ??= mkdtempSync(join(tmpdir(), 'onyx-sqlite-test-')); + const database = new BetterSqlite3(join(databaseDirectory, name), {readonly: readOnly, fileMustExist: readOnly}); + databases.add(database); + if (connection !== 'independent') { + defaultConnectionNames.add(name); } - const connection = database; + openOptions.push({name, connection, readOnly}); return { execute(sql, parameters) { - return runOne(connection, sql, parameters); + return runOne(database, sql, parameters); }, executeAsync(sql, parameters) { + asyncQueries.push({sql, readOnly}); + return executeAsyncWithDelay(() => runOne(database, sql, parameters)); + }, + + prepare(sql) { + preparedQueries.push(sql); + const statement = database.prepare(sql); + let finalized = false; + const execute = (parameters?: SQLiteQueryParams) => { + if (finalized) { + throw new Error('Prepared statement is finalized'); + } + return runPrepared(statement, sql, parameters); + }; + + return { + get isFinalized() { + return finalized; + }, + execute, + executeAsync(parameters?: SQLiteQueryParams) { + asyncQueries.push({sql, readOnly}); + return executeAsyncWithDelay(() => execute(parameters)); + }, + finalize() { + finalized = true; + }, + }; + }, + + async transaction(callback) { + database.exec('BEGIN TRANSACTION'); + let finished = false; + const commit = () => { + const result = runOne(database, 'COMMIT'); + finished = true; + return result; + }; + const rollback = () => { + const result = runOne(database, 'ROLLBACK'); + finished = true; + return result; + }; + try { - return Promise.resolve(runOne(connection, sql, parameters)); + const result = await callback({ + execute: (sql, parameters) => runOne(database, sql, parameters), + executeAsync: (sql, parameters) => { + asyncQueries.push({sql, readOnly}); + return executeAsyncWithDelay(() => runOne(database, sql, parameters)); + }, + commit, + rollback, + }); + if (!finished) { + commit(); + } + return result; } catch (error) { - return Promise.reject(error); + if (!finished) { + rollback(); + } + throw error; } }, @@ -151,9 +248,9 @@ function makeConnection(name: string): Pick { + database.transaction(() => { for (const command of expandedCommands) { - const {statement, boundArguments} = prepareAndBind(connection, command.query, command.params); + const {statement, boundArguments} = prepareAndBind(database, command.query, command.params); const info = statement.run(...(boundArguments as unknown[])); total += info.changes; } @@ -165,21 +262,44 @@ function makeConnection(name: string): Pick) { + scheduledAsyncExecutionDelay = {skip: 0, until}; +} + +function delayAsyncExecutionAfter(skip: number, until: Promise) { + scheduledAsyncExecutionDelay = {skip, until}; } /** - * Test helper — wipe every in-memory DB between tests. + * Test helper — close every handle and remove the temporary database between tests. */ function resetAllDatabases() { - for (const database of databases.values()) { + for (const database of databases) { try { database.close(); } catch { @@ -187,6 +307,15 @@ function resetAllDatabases() { } } databases.clear(); + defaultConnectionNames.clear(); + openOptions.length = 0; + asyncQueries.length = 0; + preparedQueries.length = 0; + scheduledAsyncExecutionDelay = undefined; + if (databaseDirectory) { + rmSync(databaseDirectory, {recursive: true, force: true}); + databaseDirectory = undefined; + } } -export {open, resetAllDatabases}; +export {delayAsyncExecutionAfter, delayNextAsyncExecution, getAsyncQueries, getOpenOptions, getPreparedQueries, open, resetAllDatabases}; diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 55da98760..d45d42287 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -7,7 +7,7 @@ import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; import utils from '../../../../lib/utils'; import type {GenericDeepRecord} from '../../../types'; -import {resetAllDatabases} from '../../mocks/sqliteMock'; +import {delayAsyncExecutionAfter, delayNextAsyncExecution, getAsyncQueries, getOpenOptions, getPreparedQueries, resetAllDatabases} from '../../mocks/sqliteMock'; // `jest.mock` is hoisted by Jest above the imports — register the SQLite mock // (overriding the global jestSetup.js mock) and a tiny device-info stub. @@ -53,6 +53,51 @@ describe('SQLiteProvider', () => { resetAllDatabases(); }); + describe('NitroSQLite 10 connections and statements', () => { + it('opens a separate read-only connection after configuring the writer', () => { + expect(getOpenOptions()).toEqual([ + {name: 'OnyxDB', connection: undefined, readOnly: false}, + {name: 'OnyxDB', connection: 'independent', readOnly: true}, + ]); + }); + + it('reuses prepared statements for repeated single-key operations', async () => { + const preparedQueries = getPreparedQueries(); + expect(preparedQueries).toHaveLength(3); + + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'first'); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_2, 'second'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBe('first'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toBe('second'); + await SQLiteProvider.removeItem(ONYXKEYS.TEST_KEY); + + expect(getPreparedQueries()).toEqual(preparedQueries); + }); + + it('waits for an already queued write before reading on the independent connection', async () => { + let releaseWrite: () => void = () => undefined; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + delayNextAsyncExecution(writeGate); + + const write = SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'committed'); + const read = SQLiteProvider.getItem(ONYXKEYS.TEST_KEY); + + await Promise.resolve(); + expect(getAsyncQueries().filter(({readOnly}) => readOnly)).toHaveLength(0); + + releaseWrite(); + await write; + expect(await read).toBe('committed'); + }); + + it('does not block later reads after a failed write', async () => { + await expect(SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, undefined as unknown as null)).rejects.toThrow(); + await expect(SQLiteProvider.getAllKeys()).resolves.toEqual([]); + }); + }); + describe('getItem', () => { it('should return the stored value for the key', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); @@ -362,15 +407,38 @@ describe('SQLiteProvider', () => { const entries = createKeyValueEntries(5); await SQLiteProvider.multiSet(entries); - const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync'); - executeAsyncSpy.mockClear(); + const queryCountBeforeRead = getAsyncQueries().length; const keys = entries.map(([key]) => key); await SQLiteProvider.multiGet(keys); - const inQueries = executeAsyncSpy.mock.calls.filter(([sql]) => typeof sql === 'string' && sql.includes('WHERE record_key IN')); + const inQueries = getAsyncQueries() + .slice(queryCountBeforeRead) + .filter(({sql, readOnly}) => readOnly && sql.includes('WHERE record_key IN')); expect(inQueries).toHaveLength(3); }); + + it('reads all chunks from one snapshot while another connection writes', async () => { + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + + let releaseRead: () => void = () => undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + delayAsyncExecutionAfter(1, readGate); + + const keys = entries.map(([key]) => key); + const read = SQLiteProvider.multiGet(keys); + await Promise.resolve(); + await Promise.resolve(); + + await SQLiteProvider.setItem(keys[2], 99); + releaseRead(); + + expect(await read).toContainEqual([keys[2], 2]); + expect(await SQLiteProvider.getItem(keys[2])).toBe(99); + }); }); describe('removeItems', () => { From 6e00ff0ecd410a09e80d90dedb548db66c4601ef Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 15:17:39 +0200 Subject: [PATCH 03/18] style(storage): format NitroSQLite provider changes --- lib/storage/providers/SQLiteProvider.ts | 17 +++++++++-------- tests/unit/mocks/sqliteMock.ts | 6 +++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 76938cf13..00a3cb599 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -311,14 +311,15 @@ const provider: StorageProvider = { // Aggregate the whole table into a single JSON string in SQLite so we only run JSON.parse // once, instead of returning every row and parsing each one individually in JavaScript. const reader = readStore; - return readAfterPendingWrites(() => reader.executeAsync<{aggregated: string | null}>('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;')) - .then(({rows}) => { - const aggregated = rows?.item(0)?.aggregated; - if (aggregated == null) { - return []; - } - return JSON.parse(aggregated) as StorageKeyValuePair[]; - }); + return readAfterPendingWrites(() => + reader.executeAsync<{aggregated: string | null}>('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;'), + ).then(({rows}) => { + const aggregated = rows?.item(0)?.aggregated; + if (aggregated == null) { + return []; + } + return JSON.parse(aggregated) as StorageKeyValuePair[]; + }); }, removeItem(key) { if (!removeItemStatement) { diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 8151e8acf..2dbb94b86 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -158,7 +158,11 @@ function executeAsyncWithDelay(run: () => Result): Promise { return scheduledDelay.until.then(run); } -function makeConnection({name, connection, readOnly = false}: OpenOptions): Pick { +function makeConnection({ + name, + connection, + readOnly = false, +}: OpenOptions): Pick { if (connection !== 'independent' && defaultConnectionNames.has(name)) { throw new Error(`Database ${name} is already open`); } From 0bddb4247d666b9a9eb68d03989a7c89873c3a5a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 1 Oct 2026 11:02:44 +0200 Subject: [PATCH 04/18] test: use upstream NitroSQLite mock --- package.json | 2 +- tests/unit/mocks/sqliteMock.ts | 325 ------------------ .../storage/providers/SQLiteProviderTest.ts | 12 +- 3 files changed, 5 insertions(+), 334 deletions(-) delete mode 100644 tests/unit/mocks/sqliteMock.ts diff --git a/package.json b/package.json index 2906d33e5..58eae3020 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "^10.0.0", + "react-native-nitro-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite.git#3dfe21e0e959bcceaf410601f3fc69c4e002f86a", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts deleted file mode 100644 index 2dbb94b86..000000000 --- a/tests/unit/mocks/sqliteMock.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Mock for `react-native-nitro-sqlite` backed by `better-sqlite3`, enabling - * Node-level integration tests against separate handles to a temporary SQLite file. - * - * Implements the NitroSQLite surface used by - * `lib/storage/providers/SQLiteProvider.ts`: - * - open({name, connection?, readOnly?}) - * - connection.execute(sql) - * - connection.executeAsync(sql, params?) - * - connection.executeBatchAsync([{query, params}, ...]) - * - connection.prepare(sql) - * - * Result rows are shaped to match Nitro: `{rows: {_array, item, length}}`. - */ -import {mkdtempSync, rmSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; -import BetterSqlite3 from 'better-sqlite3'; -import type {Database, Statement} from 'better-sqlite3'; -import type {BatchQueryCommand, NitroSQLiteConnection, NitroSQLiteQueryResultRows, QueryResult, QueryResultRow, SQLiteQueryParams} from 'react-native-nitro-sqlite'; - -// `better-sqlite3` is declared as `export = Database` (CommonJS), so the type is -// derived from the default import's namespace rather than via a named type import. - -type OpenOptions = {name: string; connection?: 'default' | 'independent'; readOnly?: boolean}; - -const databases = new Set(); -const defaultConnectionNames = new Set(); -const openOptions: OpenOptions[] = []; -const asyncQueries: Array<{sql: string; readOnly: boolean}> = []; -const preparedQueries: string[] = []; -let databaseDirectory: string | undefined; -let scheduledAsyncExecutionDelay: {skip: number; until: Promise} | undefined; - -/** - * Returns the named-placeholder identifiers (`:name`) in the order of first - * occurrence within the SQL string. Returns null if the SQL uses only - * positional placeholders (`?`). - * - * SQLiteProvider's `multiMerge` uses `:key` and `:value` (with `:value` - * reused on the ON CONFLICT branch). NitroSQLite binds positional array - * parameters to these names by first-occurrence order — we mirror that. - */ -function extractNamedParameterOrder(sql: string): string[] | null { - const matches = sql.match(/:[A-Za-z_][A-Za-z0-9_]*/g); - if (!matches) { - return null; - } - const seen = new Set(); - const order: string[] = []; - for (const match of matches) { - const name = match.slice(1); - if (!seen.has(name)) { - seen.add(name); - order.push(name); - } - } - return order; -} - -function wrapRows(rowsArray: TRow[]): NitroSQLiteQueryResultRows { - return { - _array: rowsArray, - item: (index: number) => rowsArray[index], - length: rowsArray.length, - }; -} - -function prepareAndBind(database: Database, sql: BatchQueryCommand['query'], parameters?: BatchQueryCommand['params']) { - const statement = database.prepare(sql); - return {statement, boundArguments: getBoundArguments(sql, parameters)}; -} - -function getBoundArguments(sql: string, parameters?: BatchQueryCommand['params']) { - const namedOrder = extractNamedParameterOrder(sql); - if (namedOrder) { - // Map positional parameters array to named bindings object — NitroSQLite's - // first-occurrence-order convention. - const bindings: Record = {}; - for (let index = 0; index < namedOrder.length; index++) { - bindings[namedOrder[index]] = parameters?.[index]; - } - // `arguments` is a reserved identifier in strict-mode modules, so the binding is - // named `boundArguments`. - return [bindings]; - } - return parameters ?? []; -} - -/** - * Expands batch commands the same way NitroSQLite does in `batchParamsToCommands`: - * `params` is either one binding set for the query, or an array of binding sets - * (same query executed once per row). - */ -function batchParamsToCommands(commands: BatchQueryCommand[]): BatchQueryCommand[] { - const expanded: BatchQueryCommand[] = []; - - for (const command of commands) { - const {query, params} = command; - - if (!params) { - expanded.push({query}); - continue; - } - - if (Array.isArray(params[0])) { - for (const rowParams of params as SQLiteQueryParams[]) { - expanded.push({query, params: rowParams}); - } - continue; - } - - expanded.push({query, params: params as SQLiteQueryParams}); - } - - return expanded; -} - -function runOne(database: Database, sql: string, parameters?: SQLiteQueryParams): QueryResult { - // Multi-statement (CREATE TABLE; SELECT ...; etc.) — better-sqlite3 cannot - // prepare more than one statement at a time. SQLiteProvider's init() issues - // each statement separately, so this branch is rarely hit, but keep it - // defensive. - const semicolons = (sql.match(/;/g) ?? []).length; - if (semicolons > 1 || (semicolons === 1 && !sql.trim().endsWith(';'))) { - database.exec(sql); - return {rowsAffected: 0} as QueryResult; - } - - return runPrepared(database.prepare(sql), sql, parameters); -} - -function runPrepared(statement: Statement, sql: string, parameters?: SQLiteQueryParams): QueryResult { - const boundArguments = getBoundArguments(sql, parameters); - - // better-sqlite3 exposes `statement.reader` = true for statements that produce - // result columns (SELECT, read-only PRAGMAs). For setter PRAGMAs and DDL - // it's false. This is the cleanest way to dispatch correctly. - if (statement.reader) { - const rows = statement.all(...(boundArguments as unknown[])) as TRow[]; - return {rows: wrapRows(rows), rowsAffected: 0} as QueryResult; - } - - const info = statement.run(...(boundArguments as unknown[])); - return {rowsAffected: info.changes, insertId: Number(info.lastInsertRowid)} as QueryResult; -} - -function executeAsyncWithDelay(run: () => Result): Promise { - const scheduledDelay = scheduledAsyncExecutionDelay; - if (!scheduledDelay) { - return Promise.resolve().then(run); - } - if (scheduledDelay.skip > 0) { - scheduledDelay.skip--; - return Promise.resolve().then(run); - } - scheduledAsyncExecutionDelay = undefined; - return scheduledDelay.until.then(run); -} - -function makeConnection({ - name, - connection, - readOnly = false, -}: OpenOptions): Pick { - if (connection !== 'independent' && defaultConnectionNames.has(name)) { - throw new Error(`Database ${name} is already open`); - } - - databaseDirectory ??= mkdtempSync(join(tmpdir(), 'onyx-sqlite-test-')); - const database = new BetterSqlite3(join(databaseDirectory, name), {readonly: readOnly, fileMustExist: readOnly}); - databases.add(database); - if (connection !== 'independent') { - defaultConnectionNames.add(name); - } - openOptions.push({name, connection, readOnly}); - - return { - execute(sql, parameters) { - return runOne(database, sql, parameters); - }, - - executeAsync(sql, parameters) { - asyncQueries.push({sql, readOnly}); - return executeAsyncWithDelay(() => runOne(database, sql, parameters)); - }, - - prepare(sql) { - preparedQueries.push(sql); - const statement = database.prepare(sql); - let finalized = false; - const execute = (parameters?: SQLiteQueryParams) => { - if (finalized) { - throw new Error('Prepared statement is finalized'); - } - return runPrepared(statement, sql, parameters); - }; - - return { - get isFinalized() { - return finalized; - }, - execute, - executeAsync(parameters?: SQLiteQueryParams) { - asyncQueries.push({sql, readOnly}); - return executeAsyncWithDelay(() => execute(parameters)); - }, - finalize() { - finalized = true; - }, - }; - }, - - async transaction(callback) { - database.exec('BEGIN TRANSACTION'); - let finished = false; - const commit = () => { - const result = runOne(database, 'COMMIT'); - finished = true; - return result; - }; - const rollback = () => { - const result = runOne(database, 'ROLLBACK'); - finished = true; - return result; - }; - - try { - const result = await callback({ - execute: (sql, parameters) => runOne(database, sql, parameters), - executeAsync: (sql, parameters) => { - asyncQueries.push({sql, readOnly}); - return executeAsyncWithDelay(() => runOne(database, sql, parameters)); - }, - commit, - rollback, - }); - if (!finished) { - commit(); - } - return result; - } catch (error) { - if (!finished) { - rollback(); - } - throw error; - } - }, - - executeBatchAsync(commands) { - try { - let total = 0; - const expandedCommands = batchParamsToCommands(commands); - - database.transaction(() => { - for (const command of expandedCommands) { - const {statement, boundArguments} = prepareAndBind(database, command.query, command.params); - const info = statement.run(...(boundArguments as unknown[])); - total += info.changes; - } - })(); - return Promise.resolve({rowsAffected: total}); - } catch (error) { - return Promise.reject(error); - } - }, - - close() { - database.close(); - databases.delete(database); - if (connection !== 'independent') { - defaultConnectionNames.delete(name); - } - }, - }; -} - -function open(options: OpenOptions) { - return makeConnection(options); -} - -function getOpenOptions() { - return [...openOptions]; -} - -function getAsyncQueries() { - return [...asyncQueries]; -} - -function getPreparedQueries() { - return [...preparedQueries]; -} - -function delayNextAsyncExecution(until: Promise) { - scheduledAsyncExecutionDelay = {skip: 0, until}; -} - -function delayAsyncExecutionAfter(skip: number, until: Promise) { - scheduledAsyncExecutionDelay = {skip, until}; -} - -/** - * Test helper — close every handle and remove the temporary database between tests. - */ -function resetAllDatabases() { - for (const database of databases) { - try { - database.close(); - } catch { - /* ignore */ - } - } - databases.clear(); - defaultConnectionNames.clear(); - openOptions.length = 0; - asyncQueries.length = 0; - preparedQueries.length = 0; - scheduledAsyncExecutionDelay = undefined; - if (databaseDirectory) { - rmSync(databaseDirectory, {recursive: true, force: true}); - databaseDirectory = undefined; - } -} - -export {delayAsyncExecutionAfter, delayNextAsyncExecution, getAsyncQueries, getOpenOptions, getPreparedQueries, open, resetAllDatabases}; diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index d45d42287..eab8687bc 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,17 +1,13 @@ /** - * Integration test for `SQLiteProvider` using a Node-side SQLite engine. - * - * Pattern mirrors `IDBKeyvalProviderTest.ts` — real provider code + real - * SQLite engine (via better-sqlite3) standing in for `react-native-nitro-sqlite`. + * Integration test for `SQLiteProvider` using NitroSQLite's Node mock. */ import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; import utils from '../../../../lib/utils'; import type {GenericDeepRecord} from '../../../types'; -import {delayAsyncExecutionAfter, delayNextAsyncExecution, getAsyncQueries, getOpenOptions, getPreparedQueries, resetAllDatabases} from '../../mocks/sqliteMock'; +import {delayAsyncExecutionAfter, delayNextAsyncExecution, getAsyncQueries, getOpenOptions, getPreparedQueries, resetAllDatabases} from 'react-native-nitro-sqlite/mock'; -// `jest.mock` is hoisted by Jest above the imports — register the SQLite mock -// (overriding the global jestSetup.js mock) and a tiny device-info stub. -jest.mock('react-native-nitro-sqlite', () => require('../../mocks/sqliteMock')); +// Override the global native stub with NitroSQLite's mock. +jest.mock('react-native-nitro-sqlite', () => require('react-native-nitro-sqlite/mock')); jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); const ONYXKEYS = { From 471d0ffcf90bda8ccd82512de861699b58b9f532 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 1 Oct 2026 11:12:29 +0200 Subject: [PATCH 05/18] test: use extended NitroSQLite mock API --- package.json | 2 +- .../storage/providers/SQLiteProviderTest.ts | 105 +++++++++++++++--- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 58eae3020..51f6c6bc5 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite.git#3dfe21e0e959bcceaf410601f3fc69c4e002f86a", + "react-native-nitro-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite.git#b934d7b31cc58e440a70f3f6f6afbd58bea773ef", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index eab8687bc..15d47329b 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,13 +1,26 @@ /** * Integration test for `SQLiteProvider` using NitroSQLite's Node mock. */ +import {open} from 'react-native-nitro-sqlite'; +import type {NitroSQLiteConnection, PreparedStatement} from 'react-native-nitro-sqlite'; import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; import utils from '../../../../lib/utils'; import type {GenericDeepRecord} from '../../../types'; -import {delayAsyncExecutionAfter, delayNextAsyncExecution, getAsyncQueries, getOpenOptions, getPreparedQueries, resetAllDatabases} from 'react-native-nitro-sqlite/mock'; +// Jest resolves the package export, which TypeScript's legacy Node resolution cannot resolve. +const {resetAllDatabases} = jest.requireActual<{resetAllDatabases: () => void}>('react-native-nitro-sqlite/mock'); // Override the global native stub with NitroSQLite's mock. -jest.mock('react-native-nitro-sqlite', () => require('react-native-nitro-sqlite/mock')); +jest.mock('react-native-nitro-sqlite', () => { + const sqliteMock = jest.requireActual<{open: (options: Parameters[0]) => Pick}>('react-native-nitro-sqlite/mock'); + return { + ...sqliteMock, + open: jest.fn((options: Parameters[0]) => { + const connection = sqliteMock.open(options); + jest.spyOn(connection, 'prepare'); + return connection; + }), + }; +}); jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); const ONYXKEYS = { @@ -42,6 +55,7 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + jest.mocked(open).mockClear(); SQLiteProvider.init(); }); @@ -51,15 +65,14 @@ describe('SQLiteProvider', () => { describe('NitroSQLite 10 connections and statements', () => { it('opens a separate read-only connection after configuring the writer', () => { - expect(getOpenOptions()).toEqual([ - {name: 'OnyxDB', connection: undefined, readOnly: false}, - {name: 'OnyxDB', connection: 'independent', readOnly: true}, - ]); + expect(jest.mocked(open).mock.calls).toEqual([[{name: 'OnyxDB'}], [{name: 'OnyxDB', connection: 'independent', readOnly: true}]]); }); it('reuses prepared statements for repeated single-key operations', async () => { - const preparedQueries = getPreparedQueries(); - expect(preparedQueries).toHaveLength(3); + const writerPrepare = jest.mocked(getOpenedConnection(0).prepare); + const readerPrepare = jest.mocked(getOpenedConnection(1).prepare); + expect(writerPrepare).toHaveBeenCalledTimes(2); + expect(readerPrepare).toHaveBeenCalledTimes(1); await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'first'); await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_2, 'second'); @@ -67,7 +80,8 @@ describe('SQLiteProvider', () => { expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toBe('second'); await SQLiteProvider.removeItem(ONYXKEYS.TEST_KEY); - expect(getPreparedQueries()).toEqual(preparedQueries); + expect(writerPrepare).toHaveBeenCalledTimes(2); + expect(readerPrepare).toHaveBeenCalledTimes(1); }); it('waits for an already queued write before reading on the independent connection', async () => { @@ -75,13 +89,19 @@ describe('SQLiteProvider', () => { const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); - delayNextAsyncExecution(writeGate); + const setItemStatement = getPreparedStatement(getOpenedConnection(0), 0); + const executeWrite = setItemStatement.executeAsync.bind(setItemStatement); + jest.spyOn(setItemStatement, 'executeAsync').mockImplementationOnce(async (params) => { + await writeGate; + return executeWrite(params); + }); + const executeRead = jest.spyOn(getPreparedStatement(getOpenedConnection(1), 0), 'executeAsync'); const write = SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'committed'); const read = SQLiteProvider.getItem(ONYXKEYS.TEST_KEY); await Promise.resolve(); - expect(getAsyncQueries().filter(({readOnly}) => readOnly)).toHaveLength(0); + expect(executeRead).not.toHaveBeenCalled(); releaseWrite(); await write; @@ -378,6 +398,7 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + jest.mocked(open).mockClear(); SQLiteProvider.init(); jest.spyOn(utils, 'chunkArray').mockImplementation((items, _maxChunkSize) => originalChunkArray(items, CHUNK_SIZE)); @@ -403,14 +424,24 @@ describe('SQLiteProvider', () => { const entries = createKeyValueEntries(5); await SQLiteProvider.multiSet(entries); - const queryCountBeforeRead = getAsyncQueries().length; + const reader = getOpenedConnection(1); + const transaction = reader.transaction.bind(reader); + const queries: string[] = []; + jest.spyOn(reader, 'transaction').mockImplementation((callback) => + transaction((tx) => { + const executeAsync = tx.executeAsync.bind(tx); + jest.spyOn(tx, 'executeAsync').mockImplementation((query, params) => { + queries.push(query); + return executeAsync(query, params); + }); + return callback(tx); + }), + ); const keys = entries.map(([key]) => key); await SQLiteProvider.multiGet(keys); - const inQueries = getAsyncQueries() - .slice(queryCountBeforeRead) - .filter(({sql, readOnly}) => readOnly && sql.includes('WHERE record_key IN')); + const inQueries = queries.filter((query) => query.includes('WHERE record_key IN')); expect(inQueries).toHaveLength(3); }); @@ -422,12 +453,34 @@ describe('SQLiteProvider', () => { const readGate = new Promise((resolve) => { releaseRead = resolve; }); - delayAsyncExecutionAfter(1, readGate); + let firstReadCompleted: () => void = () => undefined; + const firstRead = new Promise((resolve) => { + firstReadCompleted = resolve; + }); + const reader = getOpenedConnection(1); + const transaction = reader.transaction.bind(reader); + jest.spyOn(reader, 'transaction').mockImplementation((callback) => + transaction((tx) => { + const executeAsync = tx.executeAsync.bind(tx); + let queryCount = 0; + jest.spyOn(tx, 'executeAsync').mockImplementation(async (query, params) => { + const isFirstQuery = queryCount++ === 0; + if (!isFirstQuery) { + await readGate; + } + const result = await executeAsync(query, params); + if (isFirstQuery) { + firstReadCompleted(); + } + return result; + }); + return callback(tx); + }), + ); const keys = entries.map(([key]) => key); const read = SQLiteProvider.multiGet(keys); - await Promise.resolve(); - await Promise.resolve(); + await firstRead; await SQLiteProvider.setItem(keys[2], 99); releaseRead(); @@ -529,3 +582,19 @@ describe('SQLiteProvider', () => { }); }); }); + +function getOpenedConnection(index: number): NitroSQLiteConnection { + const result = jest.mocked(open).mock.results[index]; + if (!result || result.type !== 'return') { + throw new Error(`Connection ${index} was not opened`); + } + return result.value; +} + +function getPreparedStatement(connection: NitroSQLiteConnection, index: number): PreparedStatement { + const result = jest.mocked(connection.prepare).mock.results[index]; + if (!result || result.type !== 'return') { + throw new Error(`Statement ${index} was not prepared`); + } + return result.value; +} From b60b4b2c1e2b70de2fd883c7687a64b0e686afe8 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 1 Oct 2026 16:31:10 +0200 Subject: [PATCH 06/18] feat(storage): integrate NitroSQLite 10.1.0 --- lib/storage/providers/SQLiteProvider.ts | 13 +- lib/storage/providers/classifySQLiteError.ts | 5 +- package-lock.json | 641 ++++++------------ package.json | 6 +- .../storage/providers/SQLiteProviderTest.ts | 98 ++- .../storage/providers/classifyErrorTest.ts | 26 + 6 files changed, 357 insertions(+), 432 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 00a3cb599..895d2a836 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -349,10 +349,15 @@ const provider: StorageProvider = { return trackWrite(provider.store.executeAsync(buildDeleteQuery(keyChunk), keyChunk).then(() => undefined)); } - const commands: BatchQueryCommand[] = keyChunks.map((keyChunk) => ({ - query: buildDeleteQuery(keyChunk), - params: keyChunk, - })); + const firstChunk = keyChunks[0]; + const lastChunk = keyChunks[keyChunks.length - 1]; + const hasSmallerLastChunk = lastChunk.length < firstChunk.length; + // NitroSQLite reuses one prepared statement for each grouped parameter set. + // Only the final partial chunk needs a different number of placeholders. + const commands: BatchQueryCommand[] = [{query: buildDeleteQuery(firstChunk), params: hasSmallerLastChunk ? keyChunks.slice(0, -1) : keyChunks}]; + if (hasSmallerLastChunk) { + commands.push({query: buildDeleteQuery(lastChunk), params: lastChunk}); + } return trackWrite(provider.store.executeBatchAsync(commands).then(() => undefined)); }, diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts index 06d4f7d51..0cf37bc3b 100644 --- a/lib/storage/providers/classifySQLiteError.ts +++ b/lib/storage/providers/classifySQLiteError.ts @@ -18,7 +18,10 @@ function classifySQLiteError(error: unknown): ValueOf return StorageErrorClass.CAPACITY; } - if (message.includes('disk i/o error') || message.includes('unable to open database file')) { + // NitroSQLite 10.1 exposes native open failures even when their message differs + // from SQLite's standard wording. Keep this classifier free of native imports. + const hasDatabaseOpenError = typeof error === 'object' && error !== null && 'type' in error && error.type === 'DatabaseCannotBeOpened'; + if (hasDatabaseOpenError || message.includes('disk i/o error') || message.includes('unable to open database file')) { return StorageErrorClass.DISK_PRESSURE; } diff --git a/package-lock.json b/package-lock.json index 566c1cba8..d0cbe2b4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", - "better-sqlite3": "^12.10.0", + "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", "eslint-config-expensify": "2.0.108", @@ -63,7 +63,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "^10.0.0", + "react-native-nitro-sqlite": "10.1.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", @@ -80,7 +80,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^10.0.0" + "react-native-nitro-sqlite": "^10.1.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -2732,102 +2732,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -3603,17 +3507,6 @@ "@octokit/openapi-types": "^24.2.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@react-native-community/eslint-config": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz", @@ -5305,9 +5198,9 @@ } }, "node_modules/ansis": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", - "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", "dev": true, "license": "ISC", "engines": { @@ -5341,16 +5234,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/app-root-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", - "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -5899,9 +5782,9 @@ "license": "Apache-2.0" }, "node_modules/better-sqlite3": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", - "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6046,31 +5929,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7001,9 +6859,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "dev": true, "license": "MIT" }, @@ -7049,9 +6907,9 @@ } }, "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7255,19 +7113,6 @@ "node": ">=12" } }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7283,13 +7128,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9127,36 +8965,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", @@ -9298,6 +9106,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -10621,22 +10442,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/javascript-natural-sort": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", @@ -12567,16 +12372,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -13234,13 +13029,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13363,30 +13151,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13405,9 +13169,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -13942,18 +13706,24 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.0.0.tgz", - "integrity": "sha512-3+tcmKWjS2covvqBsK3EvpiClSekllwVOTRO3BzwsapIf/hvawcbippcPi1gcwEORS41UcQr0oI/QgYoJrjK2Q==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.1.0.tgz", + "integrity": "sha512-5JCcht6ilXS+/gPBgYd0LKo91AzkTWFqM7/47Qc0qkqaNZjbdqlO2kRy3iL7qkDt4SzBUQLTcjzpr7VR0ol3LQ==", "dev": true, "license": "MIT", "dependencies": { - "typeorm": "0.3.27" + "typeorm": "1.1.1" }, "peerDependencies": { + "better-sqlite3": "^12.11.1", "react": ">=17.0.0", "react-native": ">=0.75.0", "react-native-nitro-modules": ">=0.35.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + } } }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { @@ -14222,8 +13992,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -14817,27 +14586,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "dev": true, - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -15348,29 +15096,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -15504,20 +15229,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -15822,14 +15533,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -15845,21 +15556,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -16213,26 +15909,22 @@ } }, "node_modules/typeorm": { - "version": "0.3.27", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", - "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.1.1.tgz", + "integrity": "sha512-og9mG4Lwlvj6MfvYd8yRCzXy73gETtwgTKQ/ISLYvEOpJPy4IrjU/89HK4ZAFmRIbEg1hkIk04v8phTrxfgPmQ==", "dev": true, "license": "MIT", "dependencies": { "@sqltools/formatter": "^1.2.5", - "ansis": "^3.17.0", - "app-root-path": "^3.1.0", - "buffer": "^6.0.3", - "dayjs": "^1.11.13", - "debug": "^4.4.0", - "dedent": "^1.6.0", - "dotenv": "^16.4.7", - "glob": "^10.4.5", - "sha.js": "^2.4.12", - "sql-highlight": "^6.0.0", + "ansis": "^4.3.1", + "dayjs": "^1.11.21", + "debug": "^4.4.3", + "dedent": "^1.7.2", + "reflect-metadata": "^0.2.2", + "sql-highlight": "^6.1.0", + "tinyglobby": "^0.2.17", "tslib": "^2.8.1", - "uuid": "^11.1.0", - "yargs": "^17.7.2" + "yargs": "^18.0.0" }, "bin": { "typeorm": "cli.js", @@ -16240,29 +15932,27 @@ "typeorm-ts-node-esm": "cli-ts-node-esm.js" }, "engines": { - "node": ">=16.13.0" + "node": "^20.19.0 || ^22.13.0 || >=24.11.0" }, "funding": { "url": "https://opencollective.com/typeorm" }, "peerDependencies": { - "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0", + "@google-cloud/spanner": "^8.0.0", "@sap/hana-client": "^2.14.22", - "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "better-sqlite3": "^12.0.0", "ioredis": "^5.0.4", - "mongodb": "^5.8.0 || ^6.0.0", - "mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", - "mysql2": "^2.2.5 || ^3.0.1", - "oracledb": "^6.3.0", + "mongodb": "^7.0.0", + "mssql": "^12.0.0", + "mysql2": "^3.15.3", + "oracledb": "^6.3.0 || ^7.0.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", - "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", - "reflect-metadata": "^0.1.14 || ^0.2.0", + "redis": "^5.0.0 || ^6.0.0", "sql.js": "^1.4.0", - "sqlite3": "^5.0.3", - "ts-node": "^10.7.0", - "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + "ts-node": "^10.9.2", + "typeorm-aurora-data-api-driver": "^3.0.0" }, "peerDependenciesMeta": { "@google-cloud/spanner": { @@ -16304,9 +15994,6 @@ "sql.js": { "optional": true }, - "sqlite3": { - "optional": true - }, "ts-node": { "optional": true }, @@ -16315,40 +16002,167 @@ } } }, - "node_modules/typeorm/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/typeorm/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">=20" + } + }, + "node_modules/typeorm/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typeorm/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "node_modules/typeorm/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/typeorm/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typeorm/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typeorm/node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/typeorm/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/typescript": { @@ -16919,25 +16733,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 52ec253b3..b8a73fbb3 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", - "better-sqlite3": "^12.10.0", + "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", "eslint-config-expensify": "2.0.108", @@ -97,7 +97,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite.git#b934d7b31cc58e440a70f3f6f6afbd58bea773ef", + "react-native-nitro-sqlite": "10.1.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", @@ -110,7 +110,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^10.0.0" + "react-native-nitro-sqlite": "^10.1.0" }, "peerDependenciesMeta": { "idb-keyval": { diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 15d47329b..4ca72d20e 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -184,6 +184,25 @@ describe('SQLiteProvider', () => { expect(sortedActual).toEqual(sortedExpected); }); + it('rolls back all grouped writes when a later parameter set fails', async () => { + // Given a constraint that rejects the second insert in a grouped batch. + SQLiteProvider.store!.execute(`CREATE TRIGGER reject_insert BEFORE INSERT ON keyvaluepairs + WHEN NEW.record_key = '${ONYXKEYS.TEST_KEY_2}' BEGIN SELECT RAISE(ABORT, 'rejected insert'); END;`); + + // When the first insert succeeds but the second one is rejected. + await expect( + SQLiteProvider.multiSet([ + [ONYXKEYS.TEST_KEY, 'first'], + [ONYXKEYS.TEST_KEY_2, 'second'], + ]), + ).rejects.toThrow('rejected insert'); + + // Then the batch is atomic and its failure does not block subsequent reads or writes. + expect(await SQLiteProvider.getAllKeys()).toEqual([]); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'recovered'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBe('recovered'); + }); + // SQLite-specific regression: `multiSet` substitutes null for undefined // before serializing, otherwise JSON.stringify(undefined) === undefined // and the row would store a literal "undefined" string. @@ -445,6 +464,34 @@ describe('SQLiteProvider', () => { expect(inQueries).toHaveLength(3); }); + it('releases a failed snapshot transaction before the next read', async () => { + // Given several chunks and a query failure inside the read transaction. + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + const reader = getOpenedConnection(1); + const transaction = reader.transaction.bind(reader); + jest.spyOn(reader, 'transaction').mockImplementationOnce((callback) => + transaction((tx) => { + const executeAsync = tx.executeAsync.bind(tx); + let queryCount = 0; + jest.spyOn(tx, 'executeAsync').mockImplementation((query, params) => { + if (++queryCount === 2) { + return Promise.reject(new Error('failed snapshot query')); + } + return executeAsync(query, params); + }); + return callback(tx); + }), + ); + const keys = entries.map(([key]) => key); + + // When one chunk fails, the original error reaches the caller. + await expect(SQLiteProvider.multiGet(keys)).rejects.toThrow('failed snapshot query'); + + // Then rollback releases the reader and the next snapshot returns all values. + await expect(SQLiteProvider.multiGet(keys)).resolves.toEqual(expect.arrayContaining(entries)); + }); + it('reads all chunks from one snapshot while another connection writes', async () => { const entries = createKeyValueEntries(5); await SQLiteProvider.multiSet(entries); @@ -501,6 +548,37 @@ describe('SQLiteProvider', () => { expect(await SQLiteProvider.getAllKeys()).toEqual([]); }); + it('removes every key when all grouped chunks have the same size', async () => { + // Given an exact number of full chunks and a key outside the removal list. + const entries = createKeyValueEntries(6); + await SQLiteProvider.multiSet([...entries, [ONYXKEYS.TEST_KEY, 'keep']]); + + // When all full chunks run through the same batch statement. + await SQLiteProvider.removeItems(entries.map(([key]) => key)); + + // Then every requested key is gone and unrelated data remains. + expect(await SQLiteProvider.getAllKeys()).toEqual([ONYXKEYS.TEST_KEY]); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBe('keep'); + }); + + it('rolls back earlier chunks when a later grouped delete fails', async () => { + // Given a trigger that rejects a key in the second full chunk. + const entries = createKeyValueEntries(5); + await SQLiteProvider.multiSet(entries); + SQLiteProvider.store!.execute(`CREATE TRIGGER reject_delete BEFORE DELETE ON keyvaluepairs + WHEN OLD.record_key = '${entries[2][0]}' BEGIN SELECT RAISE(ABORT, 'rejected delete'); END;`); + const keys = entries.map(([key]) => key); + + // When a later delete fails after the first chunk has executed. + await expect(SQLiteProvider.removeItems(keys)).rejects.toThrow('rejected delete'); + + // Then the entire batch rolls back and the writer remains usable. + expect(await SQLiteProvider.multiGet(keys)).toEqual(expect.arrayContaining(entries)); + SQLiteProvider.store!.execute('DROP TRIGGER reject_delete;'); + await SQLiteProvider.removeItems(keys); + expect(await SQLiteProvider.getAllKeys()).toEqual([]); + }); + it('should use executeAsync when keys fit in a single chunk', async () => { const entries = createKeyValueEntries(2); await SQLiteProvider.multiSet(entries); @@ -533,8 +611,11 @@ describe('SQLiteProvider', () => { expect(executeBatchAsyncSpy).toHaveBeenCalledTimes(1); const batchCommands = executeBatchAsyncSpy.mock.calls[0][0]; - expect(batchCommands).toHaveLength(3); + expect(batchCommands).toHaveLength(2); + expect(batchCommands[0].params).toEqual([keys.slice(0, 2), keys.slice(2, 4)]); + expect(batchCommands[1].params).toEqual(keys.slice(4)); expect(batchCommands.every((command) => command.query.includes('DELETE FROM keyvaluepairs WHERE record_key IN'))).toBe(true); + expect(await SQLiteProvider.getAllKeys()).toEqual([]); }); }); }); @@ -542,6 +623,21 @@ describe('SQLiteProvider', () => { // SQLite-specific: the IN-list is parameterised, so a key containing SQL // fragments must be treated as a literal record_key. describe('SQL-injection safety', () => { + it('preserves embedded NUL characters in keys and JSON values', async () => { + // Given a key with a NUL byte and a JSON value containing the same character. + const key = 'nul\0key'; + const value = {text: 'before\0after'}; + + // When both are stored through a grouped parameter set. + await SQLiteProvider.multiSet([[key, value]]); + + // Then prepared reads, bulk reads, and exports retain the complete key and value. + expect(await SQLiteProvider.getItem(key)).toEqual(value); + expect(await SQLiteProvider.multiGet([key])).toEqual([[key, value]]); + expect(await SQLiteProvider.getAllKeys()).toEqual([key]); + expect(await SQLiteProvider.getAll()).toEqual([[key, value]]); + }); + it('should treat a key containing SQL fragments as a literal record_key', async () => { const nastyKey = "'; DROP TABLE keyvaluepairs; --"; await SQLiteProvider.setItem(nastyKey as string, 'survived'); diff --git a/tests/unit/storage/providers/classifyErrorTest.ts b/tests/unit/storage/providers/classifyErrorTest.ts index a0051b570..1f03ee15a 100644 --- a/tests/unit/storage/providers/classifyErrorTest.ts +++ b/tests/unit/storage/providers/classifyErrorTest.ts @@ -1,4 +1,5 @@ import classifyIDBError from '../../../../lib/storage/providers/IDBKeyValProvider/classifyError'; +import classifySQLiteError from '../../../../lib/storage/providers/classifySQLiteError'; import {StorageErrorClass} from '../../../../lib/storage/errors'; describe('classifyIDBError', () => { @@ -27,3 +28,28 @@ describe('classifyIDBError', () => { expect(classifyIDBError(error)).toBe(expectedClass); }); }); + +describe('classifySQLiteError', () => { + it.each([ + [new Error('database or disk is full'), StorageErrorClass.CAPACITY], + [new Error('[NativeNitroSQLiteException][SqlExecutionError] database or disk is full'), StorageErrorClass.CAPACITY], + [new Error('disk I/O error'), StorageErrorClass.DISK_PRESSURE], + [new Error('unable to open database file'), StorageErrorClass.DISK_PRESSURE], + [Object.assign(new Error('Cannot create the database'), {type: 'DatabaseCannotBeOpened'}), StorageErrorClass.DISK_PRESSURE], + [{type: 'DatabaseCannotBeOpened', message: 'Permission denied'}, StorageErrorClass.DISK_PRESSURE], + // Encryption configuration and closed-connection errors do not mean that storage is unavailable. + [{type: 'EncryptionNotEnabled', message: 'SEE is not enabled'}, StorageErrorClass.UNKNOWN], + [{type: 'DatabaseCannotBeDecrypted', message: 'Wrong key'}, StorageErrorClass.UNKNOWN], + [{type: 'DatabaseNotOpen', message: 'Database is closed'}, StorageErrorClass.UNKNOWN], + [new Error('some brand new failure'), StorageErrorClass.UNKNOWN], + [null, StorageErrorClass.UNKNOWN], + [undefined, StorageErrorClass.UNKNOWN], + ])('classifies %s as %s', (error, expectedClass) => { + // Given either a structured native error or a legacy SQLite message. + // When classifying without loading the optional native dependency. + const errorClass = classifySQLiteError(error); + + // Then native open failures share the existing disk-pressure recovery behavior. + expect(errorClass).toBe(expectedClass); + }); +}); From f94b943a580fc9d7f39ab89c562c6145ed401df3 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 17:10:51 +0100 Subject: [PATCH 07/18] chore(deps): update NitroSQLite to 10.2.0 --- package-lock.json | 10 +++++----- package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72d578b63..14e0ac8bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "10.1.0", + "react-native-nitro-sqlite": "10.2.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", @@ -80,7 +80,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^10.1.0" + "react-native-nitro-sqlite": "^10.2.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -13706,9 +13706,9 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.1.0.tgz", - "integrity": "sha512-5JCcht6ilXS+/gPBgYd0LKo91AzkTWFqM7/47Qc0qkqaNZjbdqlO2kRy3iL7qkDt4SzBUQLTcjzpr7VR0ol3LQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-10.2.0.tgz", + "integrity": "sha512-lB0yc4SzVNTZoe0ItyBJqOQ9qAc2SY3ku7PzFwP97e4NpZIOX0ec7p49sLK04MdWRmInt96Q7NU4rPfbNyg7Nw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8bb9c1623..d5c213732 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "react-native": "0.76.3", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": "^0.35.0", - "react-native-nitro-sqlite": "10.1.0", + "react-native-nitro-sqlite": "10.2.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", "ts-node": "^10.9.2", @@ -110,7 +110,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.35.0", - "react-native-nitro-sqlite": "^10.1.0" + "react-native-nitro-sqlite": "^10.2.0" }, "peerDependenciesMeta": { "idb-keyval": { From 433c23e47baa2df4bca2c9e1070ba40de902642c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 17:34:53 +0100 Subject: [PATCH 08/18] refactor(storage): use NitroSQLite error constants --- lib/storage/providers/classifySQLiteError.ts | 10 +++++----- lib/types/modules/react-native-nitro-sqlite.d.ts | 4 ++++ tests/unit/onyxUtilsTest.ts | 8 ++++++-- .../unit/storage/providers/SQLiteProviderTest.ts | 5 ++++- tests/unit/storage/providers/classifyErrorTest.ts | 15 ++++++++------- 5 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 lib/types/modules/react-native-nitro-sqlite.d.ts diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts index 0cf37bc3b..907a5c415 100644 --- a/lib/storage/providers/classifySQLiteError.ts +++ b/lib/storage/providers/classifySQLiteError.ts @@ -1,11 +1,12 @@ +import {NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; import type {ValueOf} from 'type-fest'; import {StorageErrorClass, getErrorParts} from '../errors'; /** * Classifies a SQLite write failure into the shared storage taxonomy (lib/storage/errors.ts). * This is the SQLite engine's own dialect — it is NOT shared with other engines. It lives in a - * standalone module (no `react-native-nitro-sqlite` import) so it can be reused without pulling in - * native dependencies. + * standalone module that uses NitroSQLite's native-free errors export without initializing the + * native module. * * SQLite surfaces fewer distinct write-failure shapes than IndexedDB. As telemetry from the UNKNOWN * bucket (see OnyxUtils.retryOperation) reveals recurring native errors, add matchers here. @@ -18,9 +19,8 @@ function classifySQLiteError(error: unknown): ValueOf return StorageErrorClass.CAPACITY; } - // NitroSQLite 10.1 exposes native open failures even when their message differs - // from SQLite's standard wording. Keep this classifier free of native imports. - const hasDatabaseOpenError = typeof error === 'object' && error !== null && 'type' in error && error.type === 'DatabaseCannotBeOpened'; + // Native open failures can have messages that differ from SQLite's standard wording. + const hasDatabaseOpenError = typeof error === 'object' && error !== null && 'type' in error && error.type === NitroSQLiteExceptionType.DatabaseCannotBeOpened; if (hasDatabaseOpenError || message.includes('disk i/o error') || message.includes('unable to open database file')) { return StorageErrorClass.DISK_PRESSURE; } diff --git a/lib/types/modules/react-native-nitro-sqlite.d.ts b/lib/types/modules/react-native-nitro-sqlite.d.ts new file mode 100644 index 000000000..ac7c17f37 --- /dev/null +++ b/lib/types/modules/react-native-nitro-sqlite.d.ts @@ -0,0 +1,4 @@ +// Expose the native-free subpath to TypeScript's legacy Node module resolver. +declare module 'react-native-nitro-sqlite/errors' { + export {NitroSQLiteError, NitroSQLiteExceptionType} from 'react-native-nitro-sqlite'; +} diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 779786958..2ac5a4990 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -1,3 +1,4 @@ +import {NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; import {act} from '@testing-library/react-native'; import Onyx from '../../lib'; import OnyxUtils from '../../lib/OnyxUtils'; @@ -597,7 +598,10 @@ describe('OnyxUtils', () => { expect(retryOperationSpy).toHaveBeenCalledTimes(1); }); - it.each([['[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'], ['[NativeNitroSQLiteException][SqlExecutionError] unable to open database file']])( + it.each([ + [`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] disk I/O error`], + [`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] unable to open database file`], + ])( 'should not retry disk-pressure errors (%s)', async (message) => { StorageMock.setItem = jest.fn().mockRejectedValue(new Error(message)); @@ -612,7 +616,7 @@ describe('OnyxUtils', () => { it('should log a single throttled alert with a quota snapshot for a disk-pressure burst', async () => { const logAlertSpy = jest.spyOn(Logger, 'logAlert'); const logInfoSpy = jest.spyOn(Logger, 'logInfo'); - const diskIOError = new Error('[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'); + const diskIOError = new Error(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] disk I/O error`); StorageMock.setItem = jest.fn().mockRejectedValue(diskIOError); // A burst: several operations all failing with the identical error. diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 4ca72d20e..ac48b39a0 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,6 +1,7 @@ /** * Integration test for `SQLiteProvider` using NitroSQLite's Node mock. */ +import {NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; import {open} from 'react-native-nitro-sqlite'; import type {NitroSQLiteConnection, PreparedStatement} from 'react-native-nitro-sqlite'; import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; @@ -670,7 +671,9 @@ describe('SQLiteProvider', () => { it('should still report free-disk bytes when the PRAGMAs fail (disk pressure)', async () => { // During disk pressure the SQLite connection itself fails, but free-disk comes from the // filesystem — the snapshot must survive with bytesUsed degraded instead of rejecting. - const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync').mockRejectedValue(new Error('[NativeNitroSQLiteException][SqlExecutionError] disk I/O error')); + const executeAsyncSpy = jest + .spyOn(SQLiteProvider.store!, 'executeAsync') + .mockRejectedValue(new Error(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] disk I/O error`)); await expect(SQLiteProvider.getDatabaseSize()).resolves.toEqual({bytesUsed: -1, bytesRemaining: 12345}); diff --git a/tests/unit/storage/providers/classifyErrorTest.ts b/tests/unit/storage/providers/classifyErrorTest.ts index 1f03ee15a..a45a254e6 100644 --- a/tests/unit/storage/providers/classifyErrorTest.ts +++ b/tests/unit/storage/providers/classifyErrorTest.ts @@ -1,3 +1,4 @@ +import {NitroSQLiteError, NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; import classifyIDBError from '../../../../lib/storage/providers/IDBKeyValProvider/classifyError'; import classifySQLiteError from '../../../../lib/storage/providers/classifySQLiteError'; import {StorageErrorClass} from '../../../../lib/storage/errors'; @@ -32,21 +33,21 @@ describe('classifyIDBError', () => { describe('classifySQLiteError', () => { it.each([ [new Error('database or disk is full'), StorageErrorClass.CAPACITY], - [new Error('[NativeNitroSQLiteException][SqlExecutionError] database or disk is full'), StorageErrorClass.CAPACITY], + [new Error(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] database or disk is full`), StorageErrorClass.CAPACITY], [new Error('disk I/O error'), StorageErrorClass.DISK_PRESSURE], [new Error('unable to open database file'), StorageErrorClass.DISK_PRESSURE], - [Object.assign(new Error('Cannot create the database'), {type: 'DatabaseCannotBeOpened'}), StorageErrorClass.DISK_PRESSURE], - [{type: 'DatabaseCannotBeOpened', message: 'Permission denied'}, StorageErrorClass.DISK_PRESSURE], + [new NitroSQLiteError(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.DatabaseCannotBeOpened}] Cannot create the database`), StorageErrorClass.DISK_PRESSURE], + [{type: NitroSQLiteExceptionType.DatabaseCannotBeOpened, message: 'Permission denied'}, StorageErrorClass.DISK_PRESSURE], // Encryption configuration and closed-connection errors do not mean that storage is unavailable. - [{type: 'EncryptionNotEnabled', message: 'SEE is not enabled'}, StorageErrorClass.UNKNOWN], - [{type: 'DatabaseCannotBeDecrypted', message: 'Wrong key'}, StorageErrorClass.UNKNOWN], - [{type: 'DatabaseNotOpen', message: 'Database is closed'}, StorageErrorClass.UNKNOWN], + [{type: NitroSQLiteExceptionType.EncryptionNotEnabled, message: 'SEE is not enabled'}, StorageErrorClass.UNKNOWN], + [{type: NitroSQLiteExceptionType.DatabaseCannotBeDecrypted, message: 'Wrong key'}, StorageErrorClass.UNKNOWN], + [{type: NitroSQLiteExceptionType.DatabaseNotOpen, message: 'Database is closed'}, StorageErrorClass.UNKNOWN], [new Error('some brand new failure'), StorageErrorClass.UNKNOWN], [null, StorageErrorClass.UNKNOWN], [undefined, StorageErrorClass.UNKNOWN], ])('classifies %s as %s', (error, expectedClass) => { // Given either a structured native error or a legacy SQLite message. - // When classifying without loading the optional native dependency. + // When classifying without initializing the native module. const errorClass = classifySQLiteError(error); // Then native open failures share the existing disk-pressure recovery behavior. From 12b7b383b4ac6900538ebce85968704496307626 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 17:58:07 +0100 Subject: [PATCH 09/18] style(tests): format SQLite error fixtures --- tests/unit/onyxUtilsTest.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 2ac5a4990..3d58db89d 100644 --- a/tests/unit/onyxUtilsTest.ts +++ b/tests/unit/onyxUtilsTest.ts @@ -601,17 +601,14 @@ describe('OnyxUtils', () => { it.each([ [`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] disk I/O error`], [`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] unable to open database file`], - ])( - 'should not retry disk-pressure errors (%s)', - async (message) => { - StorageMock.setItem = jest.fn().mockRejectedValue(new Error(message)); + ])('should not retry disk-pressure errors (%s)', async (message) => { + StorageMock.setItem = jest.fn().mockRejectedValue(new Error(message)); - await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); + await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'}); - // Called once (initial attempt only): retries cannot succeed while the device disk is full. - expect(retryOperationSpy).toHaveBeenCalledTimes(1); - }, - ); + // Called once (initial attempt only): retries cannot succeed while the device disk is full. + expect(retryOperationSpy).toHaveBeenCalledTimes(1); + }); it('should log a single throttled alert with a quota snapshot for a disk-pressure burst', async () => { const logAlertSpy = jest.spyOn(Logger, 'logAlert'); From 6ee48a7f8e3e6e120d6e018aa14d6bddffb77b51 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 12 Oct 2026 19:22:11 +0100 Subject: [PATCH 10/18] fix(storage): preserve fallback when SQLite connections cannot open --- lib/storage/providers/SQLiteProvider.ts | 26 +++++++++--- lib/storage/providers/classifySQLiteError.ts | 15 +++++-- .../unit/storage/SQLiteStorageFallbackTest.ts | 37 ++++++++++++++++ .../storage/providers/SQLiteProviderTest.ts | 42 ++++++++++++++++++- .../storage/providers/classifyErrorTest.ts | 9 ++-- 5 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 tests/unit/storage/SQLiteStorageFallbackTest.ts diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 895d2a836..bbfc2217b 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -18,6 +18,11 @@ type CompileOptionsResult = { compile_options: string; }; +/** The result of aggregating all stored key/value pairs into one JSON string. */ +type AggregatedKeyValuePairsResult = { + aggregated: string | null; +}; + /** * The type of the key-value pair stored in the SQLite database * @property record_key - the key of the record @@ -57,7 +62,7 @@ let removeItemStatement: PreparedStatement | undefined; let pendingWrite: Promise = Promise.resolve(); let pendingWriteCount = 0; -function trackWrite(operation: Promise): Promise { +function trackWrite(operation: Promise): Promise { // All writes use the same NitroSQLite connection queue and finish in call order. // A failed write still releases later reads; its caller receives the rejection. pendingWriteCount++; @@ -72,7 +77,7 @@ function trackWrite(operation: Promise): Promise { return operation; } -function readAfterPendingWrites(read: () => Promise): Promise { +function readAfterPendingWrites(read: () => Promise): Promise { // Capture the writes already queued when the read was requested. New writes can run // alongside this read on the separate connection once those writes have committed. return pendingWriteCount === 0 ? read() : pendingWrite.then(read); @@ -133,7 +138,7 @@ const provider: StorageProvider = { */ name: 'SQLiteProvider', /** - * Classifies a SQLite write failure into the shared storage taxonomy. + * Classifies a SQLite failure into the shared storage taxonomy. */ classifyError: classifySQLiteError, /** @@ -158,7 +163,18 @@ const provider: StorageProvider = { sqliteMaxVariableNumber = maxVariableNumber > 0 ? maxVariableNumber : SQLITE_MAX_VARIABLE_NUMBER; // WAL lets the read-only handle read committed data while the writer is active. - readStore = open({name: DB_NAME, connection: 'independent', readOnly: true}); + try { + readStore = open({name: DB_NAME, connection: 'independent', readOnly: true}); + } catch { + // A second connection is an optimization; the writer can still serve reads. + readStore = provider.store; + } + + if (readStore !== provider.store) { + // Cache size is connection-local. WAL is shared, and synchronous only controls writes. + readStore.execute('PRAGMA CACHE_SIZE=-20000;'); + } + getItemStatement = readStore.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); setItemStatement = provider.store.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'); removeItemStatement = provider.store.prepare('DELETE FROM keyvaluepairs WHERE record_key = ?;'); @@ -312,7 +328,7 @@ const provider: StorageProvider = { // once, instead of returning every row and parsing each one individually in JavaScript. const reader = readStore; return readAfterPendingWrites(() => - reader.executeAsync<{aggregated: string | null}>('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;'), + reader.executeAsync('SELECT json_group_array(json_array(record_key, json(valueJSON))) AS aggregated FROM keyvaluepairs;'), ).then(({rows}) => { const aggregated = rows?.item(0)?.aggregated; if (aggregated == null) { diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts index 907a5c415..0e83ed9d3 100644 --- a/lib/storage/providers/classifySQLiteError.ts +++ b/lib/storage/providers/classifySQLiteError.ts @@ -3,7 +3,7 @@ import type {ValueOf} from 'type-fest'; import {StorageErrorClass, getErrorParts} from '../errors'; /** - * Classifies a SQLite write failure into the shared storage taxonomy (lib/storage/errors.ts). + * Classifies a SQLite failure into the shared storage taxonomy (lib/storage/errors.ts). * This is the SQLite engine's own dialect — it is NOT shared with other engines. It lives in a * standalone module that uses NitroSQLite's native-free errors export without initializing the * native module. @@ -12,6 +12,11 @@ import {StorageErrorClass, getErrorParts} from '../errors'; * bucket (see OnyxUtils.retryOperation) reveals recurring native errors, add matchers here. */ function classifySQLiteError(error: unknown): ValueOf { + // If the database cannot open, initialization must fall back to memory storage. + if (isDatabaseOpenError(error)) { + return StorageErrorClass.UNAVAILABLE; + } + const {message} = getErrorParts(error); // Device disk full. @@ -19,13 +24,15 @@ function classifySQLiteError(error: unknown): ValueOf return StorageErrorClass.CAPACITY; } - // Native open failures can have messages that differ from SQLite's standard wording. - const hasDatabaseOpenError = typeof error === 'object' && error !== null && 'type' in error && error.type === NitroSQLiteExceptionType.DatabaseCannotBeOpened; - if (hasDatabaseOpenError || message.includes('disk i/o error') || message.includes('unable to open database file')) { + if (message.includes('disk i/o error') || message.includes('unable to open database file')) { return StorageErrorClass.DISK_PRESSURE; } return StorageErrorClass.UNKNOWN; } +function isDatabaseOpenError(error: unknown): error is {type: typeof NitroSQLiteExceptionType.DatabaseCannotBeOpened} { + return typeof error === 'object' && error !== null && 'type' in error && error.type === NitroSQLiteExceptionType.DatabaseCannotBeOpened; +} + export default classifySQLiteError; diff --git a/tests/unit/storage/SQLiteStorageFallbackTest.ts b/tests/unit/storage/SQLiteStorageFallbackTest.ts new file mode 100644 index 000000000..23d264eb1 --- /dev/null +++ b/tests/unit/storage/SQLiteStorageFallbackTest.ts @@ -0,0 +1,37 @@ +/** Verifies that a native database open failure leaves Onyx usable in memory. */ +import {open} from 'react-native-nitro-sqlite'; +import type * as NitroSQLiteErrors from 'react-native-nitro-sqlite/errors'; +import storage from '../../../lib/storage'; +import {StorageErrorClass} from '../../../lib/storage/errors'; + +jest.unmock('../../../lib/storage'); +jest.mock('../../../lib/storage/platforms/index', () => require('../../../lib/storage/providers/SQLiteProvider')); +jest.mock('react-native-nitro-sqlite', () => ({ + open: jest.fn(() => { + const {NitroSQLiteError, NitroSQLiteExceptionType} = jest.requireActual('react-native-nitro-sqlite/errors'); + throw new NitroSQLiteError(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.DatabaseCannotBeOpened}] Cannot create the database`); + }), +})); + +describe('SQLite storage initialization', () => { + it('falls back to memory when the primary database cannot open', async () => { + // Given a native database that cannot open. + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(storage.getStorageProvider().name).toBe('SQLiteProvider'); + + try { + // When Onyx initializes and a caller waits for stored keys. + storage.init(); + await expect(storage.getAllKeys()).resolves.toEqual([]); + + // Then initialization completes and subsequent reads and writes work in memory. + expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider'); + const openError = jest.mocked(open).mock.results[0].value; + expect(storage.classifyError(openError)).toBe(StorageErrorClass.UNAVAILABLE); + await storage.setItem('test', {value: 'available'}); + await expect(storage.getItem('test')).resolves.toEqual({value: 'available'}); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index ac48b39a0..04eac1e0b 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,7 +1,7 @@ /** * Integration test for `SQLiteProvider` using NitroSQLite's Node mock. */ -import {NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; +import {NitroSQLiteError, NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; import {open} from 'react-native-nitro-sqlite'; import type {NitroSQLiteConnection, PreparedStatement} from 'react-native-nitro-sqlite'; import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; @@ -64,11 +64,49 @@ describe('SQLiteProvider', () => { resetAllDatabases(); }); - describe('NitroSQLite 10 connections and statements', () => { + describe('read connections and prepared statement reuse', () => { it('opens a separate read-only connection after configuring the writer', () => { expect(jest.mocked(open).mock.calls).toEqual([[{name: 'OnyxDB'}], [{name: 'OnyxDB', connection: 'independent', readOnly: true}]]); }); + it('configures the reader cache and shares the writer WAL mode', () => { + // Given an initialized writer and independent read-only connection. + const reader = getOpenedConnection(1); + + // When querying the connection settings used for reads. + const cacheSize = reader.execute<{cache_size: number}>('PRAGMA cache_size;').rows?.item(0)?.cache_size; + const journalMode = reader.execute<{journal_mode: string}>('PRAGMA journal_mode;').rows?.item(0)?.journal_mode; + + // Then the reader has the configured cache budget and sees the database in WAL mode. + expect(cacheSize).toBe(-20000); + expect(journalMode).toBe('wal'); + }); + + it('keeps reads and writes working when the independent reader cannot open', async () => { + // Given a working writer and a failure opening a second connection. + resetAllDatabases(); + const openConnection = jest.mocked(open).getMockImplementation()!; + jest.mocked(open) + .mockImplementationOnce(openConnection) + .mockImplementationOnce(() => { + throw new NitroSQLiteError(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.DatabaseCannotBeOpened}] Cannot open read connection`); + }); + + // When initialization falls back to the writer and a read follows a queued write. + SQLiteProvider.init(); + const write = SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'committed'); + const read = SQLiteProvider.getItem(ONYXKEYS.TEST_KEY); + await write; + + // Then prepared and bulk reads still see committed data, and deletes still work. + expect(await read).toBe('committed'); + expect(await SQLiteProvider.multiGet([ONYXKEYS.TEST_KEY])).toEqual([[ONYXKEYS.TEST_KEY, 'committed']]); + expect(await SQLiteProvider.getAllKeys()).toEqual([ONYXKEYS.TEST_KEY]); + expect(await SQLiteProvider.getAll()).toEqual([[ONYXKEYS.TEST_KEY, 'committed']]); + await SQLiteProvider.removeItem(ONYXKEYS.TEST_KEY); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + it('reuses prepared statements for repeated single-key operations', async () => { const writerPrepare = jest.mocked(getOpenedConnection(0).prepare); const readerPrepare = jest.mocked(getOpenedConnection(1).prepare); diff --git a/tests/unit/storage/providers/classifyErrorTest.ts b/tests/unit/storage/providers/classifyErrorTest.ts index a45a254e6..19f3b7920 100644 --- a/tests/unit/storage/providers/classifyErrorTest.ts +++ b/tests/unit/storage/providers/classifyErrorTest.ts @@ -36,13 +36,16 @@ describe('classifySQLiteError', () => { [new Error(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.SqlExecutionError}] database or disk is full`), StorageErrorClass.CAPACITY], [new Error('disk I/O error'), StorageErrorClass.DISK_PRESSURE], [new Error('unable to open database file'), StorageErrorClass.DISK_PRESSURE], - [new NitroSQLiteError(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.DatabaseCannotBeOpened}] Cannot create the database`), StorageErrorClass.DISK_PRESSURE], - [{type: NitroSQLiteExceptionType.DatabaseCannotBeOpened, message: 'Permission denied'}, StorageErrorClass.DISK_PRESSURE], + [new NitroSQLiteError(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.DatabaseCannotBeOpened}] Cannot create the database`), StorageErrorClass.UNAVAILABLE], + [new NitroSQLiteError(`[NativeNitroSQLiteException][${NitroSQLiteExceptionType.DatabaseCannotBeOpened}] database or disk is full`), StorageErrorClass.UNAVAILABLE], + [{type: NitroSQLiteExceptionType.DatabaseCannotBeOpened, message: 'Permission denied'}, StorageErrorClass.UNAVAILABLE], // Encryption configuration and closed-connection errors do not mean that storage is unavailable. [{type: NitroSQLiteExceptionType.EncryptionNotEnabled, message: 'SEE is not enabled'}, StorageErrorClass.UNKNOWN], [{type: NitroSQLiteExceptionType.DatabaseCannotBeDecrypted, message: 'Wrong key'}, StorageErrorClass.UNKNOWN], [{type: NitroSQLiteExceptionType.DatabaseNotOpen, message: 'Database is closed'}, StorageErrorClass.UNKNOWN], [new Error('some brand new failure'), StorageErrorClass.UNKNOWN], + [{type: null}, StorageErrorClass.UNKNOWN], + ['DatabaseCannotBeOpened', StorageErrorClass.UNKNOWN], [null, StorageErrorClass.UNKNOWN], [undefined, StorageErrorClass.UNKNOWN], ])('classifies %s as %s', (error, expectedClass) => { @@ -50,7 +53,7 @@ describe('classifySQLiteError', () => { // When classifying without initializing the native module. const errorClass = classifySQLiteError(error); - // Then native open failures share the existing disk-pressure recovery behavior. + // Then native open failures use memory storage, while write failures retain their recovery class. expect(errorClass).toBe(expectedClass); }); }); From c32e3c8c30466ec4a0f0af358e35935241a0ae1d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 15 Oct 2026 11:01:49 +0100 Subject: [PATCH 11/18] test(storage): centralize NitroSQLite mock types and failures --- .../modules/react-native-nitro-sqlite.d.ts | 17 ++++++++++++++++- .../storage/providers/SQLiteProviderTest.ts | 16 +++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/types/modules/react-native-nitro-sqlite.d.ts b/lib/types/modules/react-native-nitro-sqlite.d.ts index ac7c17f37..90384d73e 100644 --- a/lib/types/modules/react-native-nitro-sqlite.d.ts +++ b/lib/types/modules/react-native-nitro-sqlite.d.ts @@ -1,4 +1,19 @@ -// Expose the native-free subpath to TypeScript's legacy Node module resolver. +// Expose the native-free subpaths to TypeScript's legacy Node module resolver. declare module 'react-native-nitro-sqlite/errors' { export {NitroSQLiteError, NitroSQLiteExceptionType} from 'react-native-nitro-sqlite'; } + +declare module 'react-native-nitro-sqlite/mock' { + import type {NitroSQLiteConnection, NitroSQLiteConnectionOptions} from 'react-native-nitro-sqlite'; + + type MockConnection = Pick< + NitroSQLiteConnection, + 'close' | 'delete' | 'execute' | 'executeAsync' | 'executeBatch' | 'executeBatchAsync' | 'prepare' | 'transaction' + >; + function open(options: NitroSQLiteConnectionOptions): MockConnection; + const NitroSQLite: {open: typeof open}; + function resetAllDatabases(): void; + + export type {MockConnection}; + export {NitroSQLite, open, resetAllDatabases}; +} diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 04eac1e0b..0a61f6c27 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -4,15 +4,16 @@ import {NitroSQLiteError, NitroSQLiteExceptionType} from 'react-native-nitro-sqlite/errors'; import {open} from 'react-native-nitro-sqlite'; import type {NitroSQLiteConnection, PreparedStatement} from 'react-native-nitro-sqlite'; +import type * as NitroSQLiteMock from 'react-native-nitro-sqlite/mock'; import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; import utils from '../../../../lib/utils'; import type {GenericDeepRecord} from '../../../types'; -// Jest resolves the package export, which TypeScript's legacy Node resolution cannot resolve. -const {resetAllDatabases} = jest.requireActual<{resetAllDatabases: () => void}>('react-native-nitro-sqlite/mock'); + +const {resetAllDatabases} = jest.requireActual('react-native-nitro-sqlite/mock'); // Override the global native stub with NitroSQLite's mock. jest.mock('react-native-nitro-sqlite', () => { - const sqliteMock = jest.requireActual<{open: (options: Parameters[0]) => Pick}>('react-native-nitro-sqlite/mock'); + const sqliteMock = jest.requireActual('react-native-nitro-sqlite/mock'); return { ...sqliteMock, open: jest.fn((options: Parameters[0]) => { @@ -85,7 +86,10 @@ describe('SQLiteProvider', () => { it('keeps reads and writes working when the independent reader cannot open', async () => { // Given a working writer and a failure opening a second connection. resetAllDatabases(); - const openConnection = jest.mocked(open).getMockImplementation()!; + const openConnection = jest.mocked(open).getMockImplementation(); + if (!openConnection) { + throw new Error('open mock implementation is missing'); + } jest.mocked(open) .mockImplementationOnce(openConnection) .mockImplementationOnce(() => { @@ -148,7 +152,9 @@ describe('SQLiteProvider', () => { }); it('does not block later reads after a failed write', async () => { - await expect(SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, undefined as unknown as null)).rejects.toThrow(); + getOpenedConnection(0).execute(`CREATE TRIGGER reject_write BEFORE INSERT ON keyvaluepairs + BEGIN SELECT RAISE(ABORT, 'rejected write'); END;`); + await expect(SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value')).rejects.toThrow('rejected write'); await expect(SQLiteProvider.getAllKeys()).resolves.toEqual([]); }); }); From 9fd800b0d053295ccba0747032a11d3ab0809ae5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 15 Oct 2026 11:03:26 +0100 Subject: [PATCH 12/18] fix(storage): fall back when SQLite reader setup fails --- lib/storage/providers/SQLiteProvider.ts | 29 +++++++------ .../storage/providers/SQLiteProviderTest.ts | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index bbfc2217b..0d07017b3 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -162,22 +162,27 @@ const provider: StorageProvider = { const maxVariableNumber = Number(getCompileOptionValue(compileOptionsResult, COMPILE_OPTIONS.MAX_VARIABLE_NUMBER)); sqliteMaxVariableNumber = maxVariableNumber > 0 ? maxVariableNumber : SQLITE_MAX_VARIABLE_NUMBER; - // WAL lets the read-only handle read committed data while the writer is active. + setItemStatement = provider.store.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'); + removeItemStatement = provider.store.prepare('DELETE FROM keyvaluepairs WHERE record_key = ?;'); + + let independentReader: NitroSQLiteConnection | undefined; try { - readStore = open({name: DB_NAME, connection: 'independent', readOnly: true}); + // WAL lets the read-only handle read committed data while the writer is active. + independentReader = open({name: DB_NAME, connection: 'independent', readOnly: true}); + // Cache size is connection-local. WAL is shared, and synchronous only controls writes. + independentReader.execute('PRAGMA CACHE_SIZE=-20000;'); + getItemStatement = independentReader.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); + readStore = independentReader; } catch { - // A second connection is an optimization; the writer can still serve reads. + // SQLite can report failures on first use, so protect all reader setup. + try { + independentReader?.close(); + } catch { + // Reader cleanup must not prevent the writer from serving reads. + } readStore = provider.store; + getItemStatement = provider.store.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); } - - if (readStore !== provider.store) { - // Cache size is connection-local. WAL is shared, and synchronous only controls writes. - readStore.execute('PRAGMA CACHE_SIZE=-20000;'); - } - - getItemStatement = readStore.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); - setItemStatement = provider.store.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'); - removeItemStatement = provider.store.prepare('DELETE FROM keyvaluepairs WHERE record_key = ?;'); pendingWrite = Promise.resolve(); pendingWriteCount = 0; }, diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 0a61f6c27..fabd6b5b0 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -127,6 +127,47 @@ describe('SQLiteProvider', () => { expect(readerPrepare).toHaveBeenCalledTimes(1); }); + it.each([ + {operation: 'execute', failsClose: false}, + {operation: 'prepare', failsClose: false}, + {operation: 'prepare', failsClose: true}, + ] as const)('falls back when reader $operation fails, including when cleanup fails: $failsClose', async ({operation, failsClose}) => { + resetAllDatabases(); + const openConnection = jest.mocked(open).getMockImplementation(); + if (!openConnection) { + throw new Error('open mock implementation is missing'); + } + let closeReader: jest.SpyInstance | undefined; + jest.mocked(open) + .mockImplementationOnce(openConnection) + .mockImplementationOnce((options) => { + const reader = openConnection(options); + closeReader = jest.spyOn(reader, 'close'); + if (failsClose) { + closeReader.mockImplementationOnce(() => { + throw new Error('reader cleanup failed'); + }); + } + jest.spyOn(reader, operation).mockImplementationOnce(() => { + throw new Error('unable to open database file'); + }); + return reader; + }); + + SQLiteProvider.init(); + expect(closeReader).toHaveBeenCalledTimes(1); + const write = SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'committed'); + const read = SQLiteProvider.getItem(ONYXKEYS.TEST_KEY); + await write; + + expect(await read).toBe('committed'); + expect(await SQLiteProvider.multiGet([ONYXKEYS.TEST_KEY])).toEqual([[ONYXKEYS.TEST_KEY, 'committed']]); + expect(await SQLiteProvider.getAllKeys()).toEqual([ONYXKEYS.TEST_KEY]); + expect(await SQLiteProvider.getAll()).toEqual([[ONYXKEYS.TEST_KEY, 'committed']]); + await SQLiteProvider.removeItem(ONYXKEYS.TEST_KEY); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + it('waits for an already queued write before reading on the independent connection', async () => { let releaseWrite: () => void = () => undefined; const writeGate = new Promise((resolve) => { From d3f34bdcb89836cb4cde6ef47d754ecec24d01f2 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 15 Oct 2026 11:03:28 +0100 Subject: [PATCH 13/18] fix(storage): bound SQLite reader cache and lock waits --- lib/storage/providers/SQLiteProvider.ts | 4 +++- tests/unit/storage/providers/SQLiteProviderTest.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 0d07017b3..3ff755503 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -170,7 +170,9 @@ const provider: StorageProvider = { // WAL lets the read-only handle read committed data while the writer is active. independentReader = open({name: DB_NAME, connection: 'independent', readOnly: true}); // Cache size is connection-local. WAL is shared, and synchronous only controls writes. - independentReader.execute('PRAGMA CACHE_SIZE=-20000;'); + independentReader.execute('PRAGMA CACHE_SIZE=-8000;'); + // Reads are not retried by Onyx; allow temporary WAL locks to clear. + independentReader.execute('PRAGMA busy_timeout=2000;'); getItemStatement = independentReader.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); readStore = independentReader; } catch { diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index fabd6b5b0..9364c2b9b 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -70,16 +70,20 @@ describe('SQLiteProvider', () => { expect(jest.mocked(open).mock.calls).toEqual([[{name: 'OnyxDB'}], [{name: 'OnyxDB', connection: 'independent', readOnly: true}]]); }); - it('configures the reader cache and shares the writer WAL mode', () => { + it('configures a smaller reader cache, busy timeout, and shared WAL mode', () => { // Given an initialized writer and independent read-only connection. const reader = getOpenedConnection(1); // When querying the connection settings used for reads. const cacheSize = reader.execute<{cache_size: number}>('PRAGMA cache_size;').rows?.item(0)?.cache_size; + const writerCacheSize = getOpenedConnection(0).execute<{cache_size: number}>('PRAGMA cache_size;').rows?.item(0)?.cache_size; + const busyTimeout = reader.execute<{timeout: number}>('PRAGMA busy_timeout;').rows?.item(0)?.timeout; const journalMode = reader.execute<{journal_mode: string}>('PRAGMA journal_mode;').rows?.item(0)?.journal_mode; // Then the reader has the configured cache budget and sees the database in WAL mode. - expect(cacheSize).toBe(-20000); + expect(cacheSize).toBe(-8000); + expect(writerCacheSize).toBe(-20000); + expect(busyTimeout).toBe(2000); expect(journalMode).toBe('wal'); }); From c2cf3f07a131e0de0e91a288f8afb599b68b8797 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 15 Oct 2026 11:04:38 +0100 Subject: [PATCH 14/18] fix(storage): preserve SQLite state on repeated initialization --- lib/storage/providers/SQLiteProvider.ts | 4 ++++ tests/unit/storage/providers/SQLiteProviderTest.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 3ff755503..2a7aadbf6 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -145,6 +145,10 @@ const provider: StorageProvider = { * Initializes the storage provider */ init() { + if (provider.store) { + return; + } + provider.store = open({name: DB_NAME}); provider.store.execute('CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY , valueJSON JSON NOT NULL) WITHOUT ROWID;'); diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 9364c2b9b..e4ef73686 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -57,6 +57,7 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + SQLiteProvider.store = undefined; jest.mocked(open).mockClear(); SQLiteProvider.init(); }); @@ -90,6 +91,7 @@ describe('SQLiteProvider', () => { it('keeps reads and writes working when the independent reader cannot open', async () => { // Given a working writer and a failure opening a second connection. resetAllDatabases(); + SQLiteProvider.store = undefined; const openConnection = jest.mocked(open).getMockImplementation(); if (!openConnection) { throw new Error('open mock implementation is missing'); @@ -137,6 +139,7 @@ describe('SQLiteProvider', () => { {operation: 'prepare', failsClose: true}, ] as const)('falls back when reader $operation fails, including when cleanup fails: $failsClose', async ({operation, failsClose}) => { resetAllDatabases(); + SQLiteProvider.store = undefined; const openConnection = jest.mocked(open).getMockImplementation(); if (!openConnection) { throw new Error('open mock implementation is missing'); @@ -172,7 +175,7 @@ describe('SQLiteProvider', () => { expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); }); - it('waits for an already queued write before reading on the independent connection', async () => { + it.each([false, true])('waits for an already queued write, including after reinitialization: %s', async (reinitialize) => { let releaseWrite: () => void = () => undefined; const writeGate = new Promise((resolve) => { releaseWrite = resolve; @@ -186,10 +189,17 @@ describe('SQLiteProvider', () => { const executeRead = jest.spyOn(getPreparedStatement(getOpenedConnection(1), 0), 'executeAsync'); const write = SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'committed'); + if (reinitialize) { + SQLiteProvider.init(); + } const read = SQLiteProvider.getItem(ONYXKEYS.TEST_KEY); await Promise.resolve(); expect(executeRead).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledTimes(2); + expect(SQLiteProvider.store).toBe(getOpenedConnection(0)); + expect(getOpenedConnection(0).prepare).toHaveBeenCalledTimes(2); + expect(getOpenedConnection(1).prepare).toHaveBeenCalledTimes(1); releaseWrite(); await write; @@ -507,6 +517,7 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + SQLiteProvider.store = undefined; jest.mocked(open).mockClear(); SQLiteProvider.init(); From 0e91aa5d8572f9812aa21e48ee17eba549404a35 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 15 Oct 2026 11:04:39 +0100 Subject: [PATCH 15/18] docs(storage): note breaking NitroSQLite peer upgrade --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7b6127c3f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +### Breaking changes + +The native SQLite provider requires `react-native-nitro-sqlite` `^10.2.0`. NitroSQLite 9.x no longer satisfies Onyx's optional peer dependency. Upgrade NitroSQLite to 10.2.0 or a compatible 10.x release and rebuild your native app when upgrading Onyx. + +The peer dependency remains optional for apps that use another storage provider. From d0809980a3d8d02d381b883c52ca5986b4227b7e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 19 Oct 2026 15:06:52 +0100 Subject: [PATCH 16/18] fix(storage): log SQLite reader fallback and cleanup failures --- lib/storage/providers/SQLiteProvider.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 2a7aadbf6..c01844985 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -5,6 +5,7 @@ import type {BatchQueryCommand, NitroSQLiteConnection, PreparedStatement, QueryResult} from 'react-native-nitro-sqlite'; import {open} from 'react-native-nitro-sqlite'; import {getFreeDiskStorage} from 'react-native-device-info'; +import * as Logger from '../../Logger'; import type {FastMergeReplaceNullPatch} from '../../utils'; import utils from '../../utils'; import type StorageProvider from './types'; @@ -179,12 +180,18 @@ const provider: StorageProvider = { independentReader.execute('PRAGMA busy_timeout=2000;'); getItemStatement = independentReader.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); readStore = independentReader; - } catch { + } catch (error) { // SQLite can report failures on first use, so protect all reader setup. + Logger.logInfo('SQLite independent reader unavailable, reading through the writer.', { + error: error instanceof Error ? error.message : String(error), + }); try { independentReader?.close(); - } catch { + } catch (cleanupError) { // Reader cleanup must not prevent the writer from serving reads. + Logger.logInfo('SQLite independent reader cleanup failed.', { + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); } readStore = provider.store; getItemStatement = provider.store.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); From 523db5b6ffb54c4fe59cdfe95fa5d5c80b8b7373 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 19 Oct 2026 15:07:05 +0100 Subject: [PATCH 17/18] refactor(types): re-export upstream NitroSQLite mock declarations --- lib/types/modules/react-native-nitro-sqlite.d.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/types/modules/react-native-nitro-sqlite.d.ts b/lib/types/modules/react-native-nitro-sqlite.d.ts index 90384d73e..27cb3ffc2 100644 --- a/lib/types/modules/react-native-nitro-sqlite.d.ts +++ b/lib/types/modules/react-native-nitro-sqlite.d.ts @@ -4,16 +4,6 @@ declare module 'react-native-nitro-sqlite/errors' { } declare module 'react-native-nitro-sqlite/mock' { - import type {NitroSQLiteConnection, NitroSQLiteConnectionOptions} from 'react-native-nitro-sqlite'; - - type MockConnection = Pick< - NitroSQLiteConnection, - 'close' | 'delete' | 'execute' | 'executeAsync' | 'executeBatch' | 'executeBatchAsync' | 'prepare' | 'transaction' - >; - function open(options: NitroSQLiteConnectionOptions): MockConnection; - const NitroSQLite: {open: typeof open}; - function resetAllDatabases(): void; - - export type {MockConnection}; - export {NitroSQLite, open, resetAllDatabases}; + // eslint-disable-next-line import/no-unresolved -- Legacy Node type resolution uses this declaration file outside package exports. + export * from 'react-native-nitro-sqlite/lib/typescript/commonjs/mock'; } From 66913934834e19da916728e7b962850de442ce00 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 19 Oct 2026 15:07:13 +0100 Subject: [PATCH 18/18] docs: remove unmanaged changelog --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7b6127c3f..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# Changelog - -## Unreleased - -### Breaking changes - -The native SQLite provider requires `react-native-nitro-sqlite` `^10.2.0`. NitroSQLite 9.x no longer satisfies Onyx's optional peer dependency. Upgrade NitroSQLite to 10.2.0 or a compatible 10.x release and rebuild your native app when upgrading Onyx. - -The peer dependency remains optional for apps that use another storage provider.