diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts index 013139120..895d2a836 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,46 +285,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<{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 (!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 +346,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..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 4a61d40e6..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": "^9.6.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": "^9.6.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": "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.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 6ada7a45f..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": "^9.6.0", + "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": "^9.6.0" + "react-native-nitro-sqlite": "^10.1.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/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 55da98760..4ca72d20e 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,17 +1,26 @@ /** - * 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 {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 {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')); +// 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', () => { + 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 = { @@ -46,6 +55,7 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + jest.mocked(open).mockClear(); SQLiteProvider.init(); }); @@ -53,6 +63,57 @@ describe('SQLiteProvider', () => { resetAllDatabases(); }); + describe('NitroSQLite 10 connections and statements', () => { + 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('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('waits for an already queued write before reading on the independent connection', async () => { + 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'); + const read = SQLiteProvider.getItem(ONYXKEYS.TEST_KEY); + + await Promise.resolve(); + expect(executeRead).not.toHaveBeenCalled(); + + 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'); @@ -123,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. @@ -337,6 +417,7 @@ describe('SQLiteProvider', () => { beforeEach(() => { resetAllDatabases(); + jest.mocked(open).mockClear(); SQLiteProvider.init(); jest.spyOn(utils, 'chunkArray').mockImplementation((items, _maxChunkSize) => originalChunkArray(items, CHUNK_SIZE)); @@ -362,15 +443,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 +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); @@ -416,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([]); }); }); }); @@ -425,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'); @@ -465,3 +678,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..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); + }); +});