diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 013139120..c01844985 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider.ts @@ -2,9 +2,10 @@ * 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 * as Logger from '../../Logger'; import type {FastMergeReplaceNullPatch} from '../../utils'; import utils from '../../utils'; import type StorageProvider from './types'; @@ -18,6 +19,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 @@ -50,6 +56,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`. @@ -106,13 +139,17 @@ 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, /** * 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;'); @@ -129,13 +166,46 @@ 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; + + 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 { + // 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=-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 (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 (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 = ?;'); + } + 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 +219,7 @@ const provider: StorageProvider = { }); }, multiGet(keys) { - if (!provider.store) { + if (!readStore) { throw new Error('Store is not initialized!'); } @@ -158,18 +228,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 +254,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 +270,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,46 +319,48 @@ 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;') - .then(({rows}) => { - const aggregated = rows?.item(0)?.aggregated; - if (aggregated == null) { - return []; - } - return JSON.parse(aggregated) as StorageKeyValuePair[]; - }); + const reader = readStore; + return readAfterPendingWrites(() => + 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) { + return []; + } + return JSON.parse(aggregated) as StorageKeyValuePair[]; + }); }, 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,22 +380,27 @@ 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) => ({ - 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 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/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts index 06d4f7d51..0e83ed9d3 100644 --- a/lib/storage/providers/classifySQLiteError.ts +++ b/lib/storage/providers/classifySQLiteError.ts @@ -1,16 +1,22 @@ +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). + * 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 (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. */ 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. @@ -25,4 +31,8 @@ function classifySQLiteError(error: unknown): ValueOf 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/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..27cb3ffc2 --- /dev/null +++ b/lib/types/modules/react-native-nitro-sqlite.d.ts @@ -0,0 +1,9 @@ +// 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' { + // 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'; +} diff --git a/package-lock.json b/package-lock.json index 04319c9c6..14e0ac8bc 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": "^9.6.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": "^9.6.0" + "react-native-nitro-sqlite": "^10.2.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": "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.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": { - "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 492005ed7..d5c213732 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": "^9.6.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": "^9.6.0" + "react-native-nitro-sqlite": "^10.2.0" }, "peerDependenciesMeta": { "idb-keyval": { diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts deleted file mode 100644 index d7282e80b..000000000 --- a/tests/unit/mocks/sqliteMock.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Mock for `react-native-nitro-sqlite` backed by `better-sqlite3`, enabling - * Node-level integration tests against a real SQLite engine. - * - * Implements the NitroSQLite surface used by - * `lib/storage/providers/SQLiteProvider.ts`: - * - open({name}) - * - connection.execute(sql) - * - connection.executeAsync(sql, params?) - * - connection.executeBatchAsync([{query, params}, ...]) - * - * Result rows are shaped to match Nitro: `{rows: {_array, item, length}}`. - */ -import BetterSqlite3 from 'better-sqlite3'; -import type {Database} 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(); - -/** - * 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 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 {statement: database.prepare(sql), boundArguments: 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; - } - - const {statement, boundArguments} = prepareAndBind(database, 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 makeConnection(name: string): Pick { - let database = databases.get(name); - if (!database) { - database = new BetterSqlite3(':memory:'); - databases.set(name, database); - } - const connection = database; - - return { - execute(sql, parameters) { - return runOne(connection, sql, parameters); - }, - - executeAsync(sql, parameters) { - try { - return Promise.resolve(runOne(connection, sql, parameters)); - } catch (error) { - return Promise.reject(error); - } - }, - - executeBatchAsync(commands) { - try { - let total = 0; - const expandedCommands = batchParamsToCommands(commands); - - connection.transaction(() => { - for (const command of expandedCommands) { - const {statement, boundArguments} = prepareAndBind(connection, 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() { - connection.close(); - databases.delete(name); - }, - }; -} - -function open({name}: {name: string}) { - return makeConnection(name); -} - -/** - * Test helper — wipe every in-memory DB between tests. - */ -function resetAllDatabases() { - for (const database of databases.values()) { - try { - database.close(); - } catch { - /* ignore */ - } - } - databases.clear(); -} - -export {open, resetAllDatabases}; diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts index 779786958..3d58db89d 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,22 +598,22 @@ describe('OnyxUtils', () => { expect(retryOperationSpy).toHaveBeenCalledTimes(1); }); - it.each([['[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'], ['[NativeNitroSQLiteException][SqlExecutionError] unable to open database file']])( - 'should not retry disk-pressure errors (%s)', - async (message) => { - StorageMock.setItem = jest.fn().mockRejectedValue(new Error(message)); + 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)); - 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'); 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/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 55da98760..e4ef73686 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,17 +1,28 @@ /** - * 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 {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'; -import {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. -jest.mock('react-native-nitro-sqlite', () => require('../../mocks/sqliteMock')); +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('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 = { @@ -46,6 +57,8 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + SQLiteProvider.store = undefined; + jest.mocked(open).mockClear(); SQLiteProvider.init(); }); @@ -53,6 +66,154 @@ describe('SQLiteProvider', () => { resetAllDatabases(); }); + 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 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(-8000); + expect(writerCacheSize).toBe(-20000); + expect(busyTimeout).toBe(2000); + 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(); + SQLiteProvider.store = undefined; + const openConnection = jest.mocked(open).getMockImplementation(); + if (!openConnection) { + throw new Error('open mock implementation is missing'); + } + 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); + expect(writerPrepare).toHaveBeenCalledTimes(2); + expect(readerPrepare).toHaveBeenCalledTimes(1); + + 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(writerPrepare).toHaveBeenCalledTimes(2); + 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(); + SQLiteProvider.store = undefined; + 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.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; + }); + 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'); + 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; + expect(await read).toBe('committed'); + }); + + it('does not block later reads after a failed write', async () => { + 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([]); + }); + }); + describe('getItem', () => { it('should return the stored value for the key', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); @@ -123,6 +284,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. @@ -337,6 +517,8 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + SQLiteProvider.store = undefined; + jest.mocked(open).mockClear(); SQLiteProvider.init(); jest.spyOn(utils, 'chunkArray').mockImplementation((items, _maxChunkSize) => originalChunkArray(items, CHUNK_SIZE)); @@ -362,15 +544,98 @@ describe('SQLiteProvider', () => { const entries = createKeyValueEntries(5); await SQLiteProvider.multiSet(entries); - const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync'); - executeAsyncSpy.mockClear(); + 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 = executeAsyncSpy.mock.calls.filter(([sql]) => typeof sql === 'string' && sql.includes('WHERE record_key IN')); + const inQueries = queries.filter((query) => query.includes('WHERE record_key IN')); 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); + + let releaseRead: () => void = () => undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + 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 firstRead; + + await SQLiteProvider.setItem(keys[2], 99); + releaseRead(); + + expect(await read).toContainEqual([keys[2], 2]); + expect(await SQLiteProvider.getItem(keys[2])).toBe(99); + }); }); describe('removeItems', () => { @@ -384,6 +649,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); @@ -416,8 +712,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([]); }); }); }); @@ -425,6 +724,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'); @@ -457,7 +771,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}); @@ -465,3 +781,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; +} diff --git a/tests/unit/storage/providers/classifyErrorTest.ts b/tests/unit/storage/providers/classifyErrorTest.ts index a0051b570..19f3b7920 100644 --- a/tests/unit/storage/providers/classifyErrorTest.ts +++ b/tests/unit/storage/providers/classifyErrorTest.ts @@ -1,4 +1,6 @@ +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'; describe('classifyIDBError', () => { @@ -27,3 +29,31 @@ describe('classifyIDBError', () => { expect(classifyIDBError(error)).toBe(expectedClass); }); }); + +describe('classifySQLiteError', () => { + it.each([ + [new Error('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], + [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) => { + // Given either a structured native error or a legacy SQLite message. + // When classifying without initializing the native module. + const errorClass = classifySQLiteError(error); + + // Then native open failures use memory storage, while write failures retain their recovery class. + expect(errorClass).toBe(expectedClass); + }); +});