From 1fa80540e7b428adf42a5f7614015888d5a38a17 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 28 Sep 2026 13:57:27 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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); + }); +});