From 8c627f841476ede85494d1d00e5df3d59a954e04 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 26 May 2026 11:55:08 +0200 Subject: [PATCH 1/7] PoC: Node-integration SQLite tests via better-sqlite3 mock --- package-lock.json | 439 +++++++++++++++++- package.json | 1 + tests/unit/mocks/sqliteMock.ts | 204 ++++++++ .../storage/providers/SQLiteProviderTest.ts | 185 ++++++++ 4 files changed, 807 insertions(+), 22 deletions(-) create mode 100644 tests/unit/mocks/sqliteMock.ts create mode 100644 tests/unit/storage/providers/SQLiteProviderTest.ts diff --git a/package-lock.json b/package-lock.json index ce083e175..0b2ce21eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", + "better-sqlite3": "^12.10.0", "date-fns": "^4.1.0", "eslint": "^9.39.2", "eslint-config-expensify": "2.0.108", @@ -223,7 +224,6 @@ "integrity": "sha512-fcdRcWahONYo+JRnJg1/AekOacGvKx12Gu0qXJXFi2WBqQA1i7+O5PaxRB7kxE/Op94dExnCiiar6T09pvdHpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", @@ -551,6 +551,7 @@ "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" @@ -568,6 +569,7 @@ "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -584,6 +586,7 @@ "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -600,6 +603,7 @@ "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", @@ -618,6 +622,7 @@ "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.3" @@ -706,6 +711,7 @@ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" }, @@ -819,6 +825,7 @@ "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1019,6 +1026,7 @@ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1088,6 +1096,7 @@ "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1137,6 +1146,7 @@ "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" @@ -1209,6 +1219,7 @@ "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1226,6 +1237,7 @@ "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1242,6 +1254,7 @@ "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1259,6 +1272,7 @@ "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1275,6 +1289,7 @@ "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0" @@ -1292,6 +1307,7 @@ "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1308,6 +1324,7 @@ "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1376,6 +1393,7 @@ "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1424,6 +1442,7 @@ "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1440,6 +1459,7 @@ "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1474,6 +1494,7 @@ "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1", @@ -1493,6 +1514,7 @@ "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1527,6 +1549,7 @@ "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1595,6 +1618,7 @@ "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1" @@ -1696,6 +1720,7 @@ "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1796,6 +1821,7 @@ "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1813,6 +1839,7 @@ "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1899,6 +1926,7 @@ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1915,6 +1943,7 @@ "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1951,6 +1980,7 @@ "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1967,6 +1997,7 @@ "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -2001,6 +2032,7 @@ "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -2122,6 +2154,7 @@ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -3410,7 +3443,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3610,7 +3642,6 @@ "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -3646,7 +3677,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4406,7 +4436,6 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -4425,7 +4454,6 @@ "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4558,7 +4586,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -5090,7 +5117,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5841,6 +5867,21 @@ "dev": true, "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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5854,6 +5895,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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.1.13" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -5904,7 +5992,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -6210,6 +6297,13 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -6907,6 +7001,22 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", @@ -7023,6 +7133,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -7183,6 +7303,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7458,7 +7588,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8484,6 +8613,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -8676,6 +8815,13 @@ "node": ">=10" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9021,6 +9167,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-then-native": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fs-then-native/-/fs-then-native-2.0.0.tgz", @@ -9207,6 +9360,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9730,6 +9890,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -10452,7 +10619,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11733,7 +11899,6 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -12324,6 +12489,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -12383,6 +12561,13 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp2": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/mkdirp2/-/mkdirp2-1.0.5.tgz", @@ -12397,6 +12582,13 @@ "dev": true, "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -12444,6 +12636,32 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-dir": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", @@ -13163,7 +13381,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13280,6 +13497,34 @@ "node": ">= 0.4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13296,7 +13541,6 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -13411,6 +13655,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -13496,13 +13751,38 @@ "node": ">= 0.6" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13556,7 +13836,6 @@ "integrity": "sha512-0TUhgmlouRNf6yuDIIAdbQl0g1VsONgCMsLs7Et64hjj5VLMCA7np+4dMrZvGZ3wRNqzgeyT9oWJsUm49AcwSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native/assets-registry": "0.76.3", @@ -13629,7 +13908,6 @@ "integrity": "sha512-Eho1yEcLbsteGpBFn2XZOp5FIptnEciWzuYBW49S0jo41Un2LeyesIO/MqYLY/c5o7D9Fw9th4pxGtV7OAb0+g==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -13747,7 +14025,6 @@ "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "react-is": "^18.2.0", "react-shallow-renderer": "^16.15.0", @@ -13757,6 +14034,21 @@ "react": "^18.2.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -14650,6 +14942,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-git": { "version": "3.28.0", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", @@ -14935,6 +15274,16 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -15263,6 +15612,36 @@ "node": ">=4" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/temp": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.4.tgz", @@ -15544,7 +15923,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15669,6 +16047,19 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15938,7 +16329,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16105,7 +16495,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -16186,6 +16575,13 @@ "requires-port": "^1.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -16647,7 +17043,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 12899aac3..c9ccdc22a 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", + "better-sqlite3": "^12.10.0", "date-fns": "^4.1.0", "eslint": "^9.39.2", "eslint-config-expensify": "2.0.108", diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts new file mode 100644 index 000000000..5a22c2619 --- /dev/null +++ b/tests/unit/mocks/sqliteMock.ts @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * PoC mock for `react-native-nitro-sqlite` backed by `better-sqlite3`. + * + * Goal: prove that a Node-level integration test can stand in for a real + * device run (Harness) when the bugs we want to catch are SQL-shaped. + * + * The mock implements just enough of the NitroSQLite surface used by + * `lib/storage/providers/SQLiteProvider.ts`: + * - open({name}) + * - enableSimpleNullHandling() + * - 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'; + +type Row = Record; + +type NitroRows = { + _array: T[]; + item: (i: number) => T | undefined; + length: number; +}; + +type NitroResult = { + rows?: NitroRows; + rowsAffected: number; + insertId?: number; +}; + +type BatchQueryCommand = { + query: string; + params?: unknown[][]; +}; + +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 + * params to these names by first-occurrence order — we mirror that. + */ +function extractNamedParamOrder(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 m of matches) { + const name = m.slice(1); + if (!seen.has(name)) { + seen.add(name); + order.push(name); + } + } + return order; +} + +function wrapRows(rowsArray: T[]): NitroRows { + return { + _array: rowsArray, + item: (i: number) => rowsArray[i], + length: rowsArray.length, + }; +} + +function prepareAndBind(db: Database, sql: string, params: unknown[]) { + const namedOrder = extractNamedParamOrder(sql); + if (namedOrder) { + // Map positional params array to named bindings object — NitroSQLite's + // first-occurrence-order convention. + const stmt = db.prepare(sql); + const bindings: Record = {}; + namedOrder.forEach((name, i) => { + bindings[name] = params[i]; + }); + return {stmt, args: [bindings] as const}; + } + return {stmt: db.prepare(sql), args: params}; +} + +function runOne(db: Database, sql: string, params: unknown[] = []): NitroResult { + // 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(';'))) { + db.exec(sql); + return {rowsAffected: 0}; + } + + const {stmt, args} = prepareAndBind(db, sql, params); + + // better-sqlite3 exposes `stmt.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 (stmt.reader) { + const rows = stmt.all(...(args as unknown[])) as T[]; + return {rows: wrapRows(rows), rowsAffected: 0}; + } + + const info = stmt.run(...(args as unknown[])); + return {rowsAffected: info.changes, insertId: Number(info.lastInsertRowid)}; +} + +function makeConnection(name: string) { + let db = databases.get(name); + if (!db) { + db = new BetterSqlite3(':memory:'); + databases.set(name, db); + } + const conn = db; + + return { + execute(sql: string, params: unknown[] = []): NitroResult { + return runOne(conn, sql, params); + }, + + executeAsync(sql: string, params: unknown[] = []): Promise> { + try { + return Promise.resolve(runOne(conn, sql, params)); + } catch (e) { + return Promise.reject(e); + } + }, + + executeBatchAsync(commands: BatchQueryCommand[]): Promise<{rowsAffected: number}> { + try { + let total = 0; + conn.transaction(() => { + for (const command of commands) { + const namedOrder = extractNamedParamOrder(command.query); + const stmt = conn.prepare(command.query); + const paramRows = command.params ?? []; + if (paramRows.length === 0) { + const info = stmt.run(); + total += info.changes; + continue; + } + for (const row of paramRows) { + if (namedOrder) { + const bindings: Record = {}; + namedOrder.forEach((name, i) => { + bindings[name] = row[i]; + }); + const info = stmt.run(bindings); + total += info.changes; + } else { + const info = stmt.run(...row); + total += info.changes; + } + } + } + })(); + return Promise.resolve({rowsAffected: total}); + } catch (e) { + return Promise.reject(e); + } + }, + + close() { + conn.close(); + databases.delete(name); + }, + }; +} + +function open({name}: {name: string}) { + return makeConnection(name); +} + +function enableSimpleNullHandling() { + // no-op; better-sqlite3 already handles null naturally. +} + +/** + * Test helper — wipe every in-memory DB between tests. + */ +function __resetAllDatabases() { + for (const db of databases.values()) { + try { + db.close(); + } catch { + /* ignore */ + } + } + databases.clear(); +} + +module.exports = { + open, + enableSimpleNullHandling, + __resetAllDatabases, +}; diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts new file mode 100644 index 000000000..352fa3fc0 --- /dev/null +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -0,0 +1,185 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * PoC: 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`. + * + * The goal of this PoC is to answer: do we need Harness, or does a Node-level + * integration test give us the same coverage at zero CI cost? + */ +// Hoisted by Jest before any imports → overrides the global jestSetup.js mock. +jest.mock('react-native-nitro-sqlite', () => require('../../mocks/sqliteMock')); +jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); + +import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; +import utils from '../../../../lib/utils'; + +const mock = require('../../mocks/sqliteMock'); + +const ONYXKEYS = { + TEST_KEY: 'test', + TEST_KEY_2: 'test2', + TEST_KEY_3: 'test3', + COLLECTION: { + TEST_KEY: 'test_', + }, +}; + +describe('SQLiteProvider (Node-integration PoC)', () => { + beforeEach(() => { + mock.__resetAllDatabases(); + SQLiteProvider.init(); + }); + + afterAll(() => { + mock.__resetAllDatabases(); + }); + + describe('setItem / getItem', () => { + it('round-trips a primitive', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value'); + }); + + it('returns null for a missing key', async () => { + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + + it('round-trips a nested object', async () => { + const value = {a: 1, nested: {b: [1, 2, 3], c: null}}; + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, value); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual(value); + }); + }); + + describe('multiSet / multiGet', () => { + it('writes multiple keys and reads them back in order', async () => { + await SQLiteProvider.multiSet([ + [ONYXKEYS.TEST_KEY, 'value'], + [ONYXKEYS.TEST_KEY_2, 1000], + [ONYXKEYS.TEST_KEY_3, {x: 1}], + ]); + const out = await SQLiteProvider.multiGet([ONYXKEYS.TEST_KEY_2, ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_3]); + expect(out).toEqual( + expect.arrayContaining([ + [ONYXKEYS.TEST_KEY, 'value'], + [ONYXKEYS.TEST_KEY_2, 1000], + [ONYXKEYS.TEST_KEY_3, {x: 1}], + ]), + ); + expect(out).toHaveLength(3); + }); + + it('treats undefined as null (regression for SQLiteProvider line 124)', async () => { + await SQLiteProvider.multiSet([[ONYXKEYS.TEST_KEY, undefined as unknown as null]]); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + }); + + describe('multiMerge — JSON_PATCH semantics', () => { + it('shallow-merges existing record_key value', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {a: 1, b: 2}); + await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_3, {b: 99, c: 3}]]); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({a: 1, b: 99, c: 3}); + }); + + it('deep-merges nested objects', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { + outer: {a: 1, b: 2, nested: {x: 1, y: 2}}, + }); + await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_3, {outer: {b: 99, nested: {y: 99, z: 3}}}]]); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({ + outer: {a: 1, b: 99, nested: {x: 1, y: 99, z: 3}}, + }); + }); + + it('inserts a new record when key does not exist', async () => { + await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); + }); + }); + + describe('multiMerge — JSON_REPLACE semantics (replaceNullPatches)', () => { + it('fully replaces a nested object marked with REPLACE_OBJECT_MARK', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { + outer: {a: 1, b: 2, nested: {keepMe: false, oldKey: 'gone'}}, + }); + + // Onyx flow: caller (utils.fastMerge) produces a `change` already merged + // plus a list of `replaceNullPatches` describing which nested objects + // should be wholesale replaced via JSON_REPLACE. + const change = { + outer: { + nested: { + // The mark is filtered out by SQLiteProvider's `objectMarkRemover` + // before the value is stringified. + [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + newKey: 'newValue', + }, + }, + }; + const replaceNullPatches: Array<[string[], unknown]> = [[['outer', 'nested'], {newKey: 'newValue'}]]; + + await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_3, change, replaceNullPatches]]); + + const stored = (await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)) as Record; + expect(stored).toEqual({ + outer: {a: 1, b: 2, nested: {newKey: 'newValue'}}, + }); + // Crucially: oldKey/keepMe should be gone (replace, not merge). + expect((stored.outer as Record).nested).not.toHaveProperty('oldKey'); + expect((stored.outer as Record).nested).not.toHaveProperty('keepMe'); + }); + }); + + describe('removeItem / removeItems (IN-list)', () => { + it('removes a single key', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'v'); + await SQLiteProvider.removeItem(ONYXKEYS.TEST_KEY); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + + it('removes a batch via IN-list', async () => { + await SQLiteProvider.multiSet([ + [ONYXKEYS.TEST_KEY, 1], + [ONYXKEYS.TEST_KEY_2, 2], + [ONYXKEYS.TEST_KEY_3, 3], + ]); + await SQLiteProvider.removeItems([ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_3]); + const keys = await SQLiteProvider.getAllKeys(); + expect(keys).toEqual([ONYXKEYS.TEST_KEY_2]); + }); + }); + + describe('SQL-injection safety', () => { + it('treats a key containing SQL fragments as a literal record_key', async () => { + const nastyKey = "'; DROP TABLE keyvaluepairs; --"; + await SQLiteProvider.setItem(nastyKey, 'survived'); + // If the placeholder weren't parameterised, the table would be gone here. + expect(await SQLiteProvider.getItem(nastyKey)).toEqual('survived'); + expect(await SQLiteProvider.getAllKeys()).toEqual([nastyKey]); + }); + }); + + describe('getDatabaseSize', () => { + it('returns positive bytesUsed after a write', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, {payload: 'x'.repeat(1024)}); + const size = await SQLiteProvider.getDatabaseSize(); + expect(size.bytesUsed).toBeGreaterThan(0); + // bytesRemaining comes from the mocked getFreeDiskStorage(): 12345 + expect(size.bytesRemaining).toBe(12345); + }); + }); + + describe('clear', () => { + it('empties the table', async () => { + await SQLiteProvider.multiSet([ + [ONYXKEYS.TEST_KEY, 1], + [ONYXKEYS.TEST_KEY_2, 2], + ]); + await SQLiteProvider.clear(); + expect(await SQLiteProvider.getAllKeys()).toEqual([]); + }); + }); +}); From a0a6771d3bcc0f4f8241557493223f0b3bcc8d03 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 28 May 2026 13:51:24 +0200 Subject: [PATCH 2/7] clean code --- tests/unit/mocks/sqliteMock.ts | 8 +++----- tests/unit/storage/providers/SQLiteProviderTest.ts | 7 ++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 5a22c2619..d6f85a43f 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -1,11 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /** - * PoC mock for `react-native-nitro-sqlite` backed by `better-sqlite3`. + * Mock for `react-native-nitro-sqlite` backed by `better-sqlite3`, enabling + * Node-level integration tests against a real SQLite engine. * - * Goal: prove that a Node-level integration test can stand in for a real - * device run (Harness) when the bugs we want to catch are SQL-shaped. - * - * The mock implements just enough of the NitroSQLite surface used by + * Implements the NitroSQLite surface used by * `lib/storage/providers/SQLiteProvider.ts`: * - open({name}) * - enableSimpleNullHandling() diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 352fa3fc0..de0ed49d9 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,12 +1,9 @@ /* eslint-disable @typescript-eslint/no-var-requires */ /** - * PoC: integration test for `SQLiteProvider` using a Node-side SQLite engine. + * 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`. - * - * The goal of this PoC is to answer: do we need Harness, or does a Node-level - * integration test give us the same coverage at zero CI cost? */ // Hoisted by Jest before any imports → overrides the global jestSetup.js mock. jest.mock('react-native-nitro-sqlite', () => require('../../mocks/sqliteMock')); @@ -26,7 +23,7 @@ const ONYXKEYS = { }, }; -describe('SQLiteProvider (Node-integration PoC)', () => { +describe('SQLiteProvider', () => { beforeEach(() => { mock.__resetAllDatabases(); SQLiteProvider.init(); From e006ca0d45c6f6ec0eba3a365bc7f03319422141 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 28 May 2026 14:54:32 +0200 Subject: [PATCH 3/7] Mirror IDBKeyVal test suite, add @types/better-sqlite3, fix lint --- package-lock.json | 11 + package.json | 1 + tests/unit/mocks/sqliteMock.ts | 21 +- .../storage/providers/SQLiteProviderTest.ts | 272 +++++++++++++----- 4 files changed, 225 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b2ce21eb..b08fc898a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", "@testing-library/react-native": "^13.2.0", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.14", "@types/jsdoc-to-markdown": "^7.0.6", "@types/lodash": "^4.14.202", @@ -4328,6 +4329,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index c9ccdc22a..095478269 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", "@testing-library/react-native": "^13.2.0", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.14", "@types/jsdoc-to-markdown": "^7.0.6", "@types/lodash": "^4.14.202", diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index d6f85a43f..52ce4fe0c 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /** * Mock for `react-native-nitro-sqlite` backed by `better-sqlite3`, enabling * Node-level integration tests against a real SQLite engine. @@ -78,9 +77,9 @@ function prepareAndBind(db: Database, sql: string, params: unknown[]) { // first-occurrence-order convention. const stmt = db.prepare(sql); const bindings: Record = {}; - namedOrder.forEach((name, i) => { - bindings[name] = params[i]; - }); + for (let i = 0; i < namedOrder.length; i++) { + bindings[namedOrder[i]] = params[i]; + } return {stmt, args: [bindings] as const}; } return {stmt: db.prepare(sql), args: params}; @@ -148,9 +147,9 @@ function makeConnection(name: string) { for (const row of paramRows) { if (namedOrder) { const bindings: Record = {}; - namedOrder.forEach((name, i) => { - bindings[name] = row[i]; - }); + for (let i = 0; i < namedOrder.length; i++) { + bindings[namedOrder[i]] = row[i]; + } const info = stmt.run(bindings); total += info.changes; } else { @@ -184,7 +183,7 @@ function enableSimpleNullHandling() { /** * Test helper — wipe every in-memory DB between tests. */ -function __resetAllDatabases() { +function resetAllDatabases() { for (const db of databases.values()) { try { db.close(); @@ -195,8 +194,4 @@ function __resetAllDatabases() { databases.clear(); } -module.exports = { - open, - enableSimpleNullHandling, - __resetAllDatabases, -}; +export {open, enableSimpleNullHandling, resetAllDatabases}; diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index de0ed49d9..7df18dfd0 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -1,18 +1,18 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ /** * 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`. */ -// Hoisted by Jest before any imports → overrides the global jestSetup.js mock. -jest.mock('react-native-nitro-sqlite', () => require('../../mocks/sqliteMock')); -jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); - import SQLiteProvider from '../../../../lib/storage/providers/SQLiteProvider'; import utils from '../../../../lib/utils'; +import type {GenericDeepRecord} from '../../../types'; +import {resetAllDatabases} from '../../mocks/sqliteMock'; -const mock = require('../../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.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); const ONYXKEYS = { TEST_KEY: 'test', @@ -20,61 +20,162 @@ const ONYXKEYS = { TEST_KEY_3: 'test3', COLLECTION: { TEST_KEY: 'test_', + TEST_KEY_2: 'test2_', }, }; describe('SQLiteProvider', () => { + const testEntries: Array<[string, unknown]> = [ + [ONYXKEYS.TEST_KEY, 'value'], + [ONYXKEYS.TEST_KEY_2, 1000], + [ + ONYXKEYS.TEST_KEY_3, + { + key: 'value', + property: { + nestedProperty: { + nestedKey1: 'nestedValue1', + nestedKey2: 'nestedValue2', + }, + }, + }, + ], + [`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, true], + [`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {key: 'value'}, 1, true]], + ]; + beforeEach(() => { - mock.__resetAllDatabases(); + resetAllDatabases(); SQLiteProvider.init(); }); afterAll(() => { - mock.__resetAllDatabases(); + resetAllDatabases(); }); - describe('setItem / getItem', () => { - it('round-trips a primitive', async () => { + describe('getItem', () => { + it('should return the stored value for the key', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value'); }); - it('returns null for a missing key', async () => { + it('should return null if there is no stored value for the key', async () => { expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); }); + }); - it('round-trips a nested object', async () => { - const value = {a: 1, nested: {b: [1, 2, 3], c: null}}; - await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, value); - expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual(value); + describe('multiGet', () => { + // SQLite's `WHERE record_key IN (...)` does not preserve the input order + // (rows come back in primary-key order). IDB's getMany() does. So this + // test mirrors the IDB one but asserts membership rather than order. + it('should return the tuples for the keys supplied in a batch', async () => { + await SQLiteProvider.multiSet(testEntries as Array<[string, unknown]>); + const out = await SQLiteProvider.multiGet([`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_2]); + expect(out).toEqual(expect.arrayContaining([testEntries[3], testEntries[0], testEntries[1]])); + expect(out).toHaveLength(3); }); }); - describe('multiSet / multiGet', () => { - it('writes multiple keys and reads them back in order', async () => { - await SQLiteProvider.multiSet([ - [ONYXKEYS.TEST_KEY, 'value'], - [ONYXKEYS.TEST_KEY_2, 1000], - [ONYXKEYS.TEST_KEY_3, {x: 1}], - ]); - const out = await SQLiteProvider.multiGet([ONYXKEYS.TEST_KEY_2, ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_3]); - expect(out).toEqual( - expect.arrayContaining([ - [ONYXKEYS.TEST_KEY, 'value'], - [ONYXKEYS.TEST_KEY_2, 1000], - [ONYXKEYS.TEST_KEY_3, {x: 1}], - ]), - ); - expect(out).toHaveLength(3); + describe('setItem', () => { + it('should set the value to the key', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value'); }); - it('treats undefined as null (regression for SQLiteProvider line 124)', async () => { + // SQLiteProvider stores `null` in valueJSON instead of deleting the row + // (unlike IDB, which removes the key). Callers wanting deletion call + // `removeItem` directly. + it('should store null when passing null', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value'); + + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, null); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + }); + + describe('multiSet', () => { + it('should set multiple keys in a batch', async () => { + await SQLiteProvider.multiSet(testEntries); + + const out = await SQLiteProvider.multiGet(testEntries.map((e) => e[0])); + const sortedActual = out.sort((a, b) => a[0].localeCompare(b[0])); + const sortedExpected = [...testEntries].sort((a, b) => a[0].localeCompare(b[0])); + expect(sortedActual).toEqual(sortedExpected); + }); + + // IDB's equivalent test asserts that null entries delete the key. SQLite + // stores the null value in place. See note on `setItem` null behavior. + it('should set and null-out multiple keys in a batch', async () => { + await SQLiteProvider.multiSet(testEntries); + const changedEntries: Array<[string, unknown]> = [ + [ONYXKEYS.TEST_KEY, 'value_changed'], + [ONYXKEYS.TEST_KEY_2, null], + [ONYXKEYS.TEST_KEY_3, {changed: true}], + [`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, null], + ]; + + await SQLiteProvider.multiSet(changedEntries); + + const out = await SQLiteProvider.multiGet(changedEntries.map((e) => e[0])); + const sortedActual = out.sort((a, b) => a[0].localeCompare(b[0])); + const sortedExpected = [...changedEntries].sort((a, b) => a[0].localeCompare(b[0])); + expect(sortedActual).toEqual(sortedExpected); + }); + + // SQLite-specific regression: `multiSet` substitutes null for undefined + // before serializing, otherwise JSON.stringify(undefined) === undefined + // and the row would store a literal "undefined" string. + it('treats undefined as null', async () => { await SQLiteProvider.multiSet([[ONYXKEYS.TEST_KEY, undefined as unknown as null]]); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); }); }); - describe('multiMerge — JSON_PATCH semantics', () => { + describe('multiMerge', () => { + it('should merge multiple keys in a batch', async () => { + await SQLiteProvider.multiSet(testEntries); + const changedEntries: Array<[string, unknown, Array<[string[], unknown]>?]> = [ + [ONYXKEYS.TEST_KEY, 'value_changed'], + [ONYXKEYS.TEST_KEY_2, 1001], + [ + ONYXKEYS.TEST_KEY_3, + { + key: 'value_changed', + property: { + nestedProperty: { + nestedKey2: 'nestedValue2_changed', + [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + }, + newKey: 'newValue', + }, + }, + // The mark above signals `property.nestedProperty` is replaced wholesale. + [[['property', 'nestedProperty'], {nestedKey2: 'nestedValue2_changed'}]], + ], + [`${ONYXKEYS.COLLECTION.TEST_KEY}id1`, false], + [`${ONYXKEYS.COLLECTION.TEST_KEY}id2`, ['a', {newKey: 'newValue'}]], + ]; + + const expectedTestKey3Value = structuredClone(testEntries[2])[1] as GenericDeepRecord; + expectedTestKey3Value.key = 'value_changed'; + expectedTestKey3Value.property.nestedProperty = {nestedKey2: 'nestedValue2_changed'}; + expectedTestKey3Value.property.newKey = 'newValue'; + + await SQLiteProvider.multiMerge(changedEntries); + + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value_changed'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual(1001); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual(expectedTestKey3Value); + expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`)).toEqual(false); + expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`)).toEqual(['a', {newKey: 'newValue'}]); + }); + + it('inserts a new record when key does not exist', async () => { + await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); + }); + it('shallow-merges existing record_key value', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {a: 1, b: 2}); await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_3, {b: 99, c: 3}]]); @@ -91,14 +192,9 @@ describe('SQLiteProvider', () => { }); }); - it('inserts a new record when key does not exist', async () => { - await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); - expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); - }); - }); - - describe('multiMerge — JSON_REPLACE semantics (replaceNullPatches)', () => { - it('fully replaces a nested object marked with REPLACE_OBJECT_MARK', async () => { + // SQLite-specific: the JSON_REPLACE path is what makes `REPLACE_OBJECT_MARK` + // actually wipe a nested object (JSON_PATCH alone would only merge into it). + it('fully replaces a nested object marked with REPLACE_OBJECT_MARK via JSON_REPLACE', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { outer: {a: 1, b: 2, nested: {keepMe: false, oldKey: 'gone'}}, }); @@ -130,37 +226,90 @@ describe('SQLiteProvider', () => { }); }); - describe('removeItem / removeItems (IN-list)', () => { - it('removes a single key', async () => { - await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'v'); + describe('mergeItem', () => { + it('should merge all the supported kinds of data correctly', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, 'value'); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_2, 1000); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {key: 'value', property: {propertyKey: 'propertyValue'}}); + await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, true); + await SQLiteProvider.setItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {key: 'value'}, 1, true]); + + await SQLiteProvider.mergeItem(ONYXKEYS.TEST_KEY, 'value_changed'); + await SQLiteProvider.mergeItem(ONYXKEYS.TEST_KEY_2, 1001); + await SQLiteProvider.mergeItem( + ONYXKEYS.TEST_KEY_3, + { + key: 'value_changed', + property: { + [utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true, + newKey: 'newValue', + }, + }, + [[['property'], {newKey: 'newValue'}]], + ); + await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1` as string, false); + await SQLiteProvider.mergeItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2` as string, ['a', {newKey: 'newValue'}]); + + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toEqual('value_changed'); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual(1001); + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({key: 'value_changed', property: {newKey: 'newValue'}}); + expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id1`)).toEqual(false); + expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`)).toEqual(['a', {newKey: 'newValue'}]); + }); + }); + + describe('getAllKeys', () => { + it('should list all the keys stored', async () => { + await SQLiteProvider.multiSet(testEntries); + expect((await SQLiteProvider.getAllKeys()).length).toEqual(5); + }); + }); + + describe('removeItem', () => { + it('should remove the key from the store', async () => { + await SQLiteProvider.multiSet(testEntries); + expect(await SQLiteProvider.getAllKeys()).toContain(ONYXKEYS.TEST_KEY); + await SQLiteProvider.removeItem(ONYXKEYS.TEST_KEY); - expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + expect(await SQLiteProvider.getAllKeys()).not.toContain(ONYXKEYS.TEST_KEY); }); + }); + + describe('removeItems', () => { + it('should remove all the supplied keys from the store', async () => { + await SQLiteProvider.multiSet(testEntries); + expect(await SQLiteProvider.getAllKeys()).toContain(ONYXKEYS.TEST_KEY); + expect(await SQLiteProvider.getAllKeys()).toContain(ONYXKEYS.TEST_KEY_3); - it('removes a batch via IN-list', async () => { - await SQLiteProvider.multiSet([ - [ONYXKEYS.TEST_KEY, 1], - [ONYXKEYS.TEST_KEY_2, 2], - [ONYXKEYS.TEST_KEY_3, 3], - ]); await SQLiteProvider.removeItems([ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_3]); - const keys = await SQLiteProvider.getAllKeys(); - expect(keys).toEqual([ONYXKEYS.TEST_KEY_2]); + expect(await SQLiteProvider.getAllKeys()).not.toContain(ONYXKEYS.TEST_KEY); + expect(await SQLiteProvider.getAllKeys()).not.toContain(ONYXKEYS.TEST_KEY_3); }); }); + // 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('treats a key containing SQL fragments as a literal record_key', async () => { const nastyKey = "'; DROP TABLE keyvaluepairs; --"; - await SQLiteProvider.setItem(nastyKey, 'survived'); - // If the placeholder weren't parameterised, the table would be gone here. + await SQLiteProvider.setItem(nastyKey as string, 'survived'); expect(await SQLiteProvider.getItem(nastyKey)).toEqual('survived'); expect(await SQLiteProvider.getAllKeys()).toEqual([nastyKey]); }); }); + describe('clear', () => { + it('should clear the storage', async () => { + await SQLiteProvider.multiSet(testEntries); + expect((await SQLiteProvider.getAllKeys()).length).toEqual(5); + + await SQLiteProvider.clear(); + expect((await SQLiteProvider.getAllKeys()).length).toEqual(0); + }); + }); + describe('getDatabaseSize', () => { - it('returns positive bytesUsed after a write', async () => { + it('should get the current size of the store', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, {payload: 'x'.repeat(1024)}); const size = await SQLiteProvider.getDatabaseSize(); expect(size.bytesUsed).toBeGreaterThan(0); @@ -168,15 +317,4 @@ describe('SQLiteProvider', () => { expect(size.bytesRemaining).toBe(12345); }); }); - - describe('clear', () => { - it('empties the table', async () => { - await SQLiteProvider.multiSet([ - [ONYXKEYS.TEST_KEY, 1], - [ONYXKEYS.TEST_KEY_2, 2], - ]); - await SQLiteProvider.clear(); - expect(await SQLiteProvider.getAllKeys()).toEqual([]); - }); - }); }); From 41f2a61255e3baa1b471b9324b0738dd82a900b6 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 28 May 2026 22:01:38 +0200 Subject: [PATCH 4/7] proper names --- tests/unit/mocks/sqliteMock.ts | 97 +++++++++++++++++----------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 52ce4fe0c..fd41a6326 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -19,7 +19,7 @@ type Row = Record; type NitroRows = { _array: T[]; - item: (i: number) => T | undefined; + item: (index: number) => T | undefined; length: number; }; @@ -43,17 +43,17 @@ const databases = new Map(); * * SQLiteProvider's `multiMerge` uses `:key` and `:value` (with `:value` * reused on the ON CONFLICT branch). NitroSQLite binds positional array - * params to these names by first-occurrence order — we mirror that. + * parameters to these names by first-occurrence order — we mirror that. */ -function extractNamedParamOrder(sql: string): string[] | null { +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 m of matches) { - const name = m.slice(1); + for (const match of matches) { + const name = match.slice(1); if (!seen.has(name)) { seen.add(name); order.push(name); @@ -65,108 +65,109 @@ function extractNamedParamOrder(sql: string): string[] | null { function wrapRows(rowsArray: T[]): NitroRows { return { _array: rowsArray, - item: (i: number) => rowsArray[i], + item: (index: number) => rowsArray[index], length: rowsArray.length, }; } -function prepareAndBind(db: Database, sql: string, params: unknown[]) { - const namedOrder = extractNamedParamOrder(sql); +function prepareAndBind(database: Database, sql: string, parameters: unknown[]) { + const namedOrder = extractNamedParameterOrder(sql); if (namedOrder) { - // Map positional params array to named bindings object — NitroSQLite's + // Map positional parameters array to named bindings object — NitroSQLite's // first-occurrence-order convention. - const stmt = db.prepare(sql); + const statement = database.prepare(sql); const bindings: Record = {}; - for (let i = 0; i < namedOrder.length; i++) { - bindings[namedOrder[i]] = params[i]; + for (let index = 0; index < namedOrder.length; index++) { + bindings[namedOrder[index]] = parameters[index]; } - return {stmt, args: [bindings] as const}; + + return {statement, boundArguments: [bindings] as const}; } - return {stmt: db.prepare(sql), args: params}; + return {statement: database.prepare(sql), boundArguments: parameters}; } -function runOne(db: Database, sql: string, params: unknown[] = []): NitroResult { +function runOne(database: Database, sql: string, parameters: unknown[] = []): NitroResult { // 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(';'))) { - db.exec(sql); + database.exec(sql); return {rowsAffected: 0}; } - const {stmt, args} = prepareAndBind(db, sql, params); + const {statement, boundArguments} = prepareAndBind(database, sql, parameters); - // better-sqlite3 exposes `stmt.reader` = true for statements that produce + // 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 (stmt.reader) { - const rows = stmt.all(...(args as unknown[])) as T[]; + if (statement.reader) { + const rows = statement.all(...(boundArguments as unknown[])) as T[]; return {rows: wrapRows(rows), rowsAffected: 0}; } - const info = stmt.run(...(args as unknown[])); + const info = statement.run(...(boundArguments as unknown[])); return {rowsAffected: info.changes, insertId: Number(info.lastInsertRowid)}; } function makeConnection(name: string) { - let db = databases.get(name); - if (!db) { - db = new BetterSqlite3(':memory:'); - databases.set(name, db); + let database = databases.get(name); + if (!database) { + database = new BetterSqlite3(':memory:'); + databases.set(name, database); } - const conn = db; + const connection = database; return { - execute(sql: string, params: unknown[] = []): NitroResult { - return runOne(conn, sql, params); + execute(sql: string, parameters: unknown[] = []): NitroResult { + return runOne(connection, sql, parameters); }, - executeAsync(sql: string, params: unknown[] = []): Promise> { + executeAsync(sql: string, parameters: unknown[] = []): Promise> { try { - return Promise.resolve(runOne(conn, sql, params)); - } catch (e) { - return Promise.reject(e); + return Promise.resolve(runOne(connection, sql, parameters)); + } catch (error) { + return Promise.reject(error); } }, executeBatchAsync(commands: BatchQueryCommand[]): Promise<{rowsAffected: number}> { try { let total = 0; - conn.transaction(() => { + connection.transaction(() => { for (const command of commands) { - const namedOrder = extractNamedParamOrder(command.query); - const stmt = conn.prepare(command.query); - const paramRows = command.params ?? []; - if (paramRows.length === 0) { - const info = stmt.run(); + const namedOrder = extractNamedParameterOrder(command.query); + const statement = connection.prepare(command.query); + const parameterRows = command.params ?? []; + if (parameterRows.length === 0) { + const info = statement.run(); total += info.changes; continue; } - for (const row of paramRows) { + for (const row of parameterRows) { if (namedOrder) { const bindings: Record = {}; - for (let i = 0; i < namedOrder.length; i++) { - bindings[namedOrder[i]] = row[i]; + for (let index = 0; index < namedOrder.length; index++) { + bindings[namedOrder[index]] = row[index]; } - const info = stmt.run(bindings); + const info = statement.run(bindings); total += info.changes; } else { - const info = stmt.run(...row); + const info = statement.run(...row); total += info.changes; } } } })(); return Promise.resolve({rowsAffected: total}); - } catch (e) { - return Promise.reject(e); + } catch (error) { + return Promise.reject(error); } }, close() { - conn.close(); + connection.close(); databases.delete(name); }, }; @@ -184,9 +185,9 @@ function enableSimpleNullHandling() { * Test helper — wipe every in-memory DB between tests. */ function resetAllDatabases() { - for (const db of databases.values()) { + for (const database of databases.values()) { try { - db.close(); + database.close(); } catch { /* ignore */ } From d944a805305f97b15c42cc7aa69eae47711b7aec Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 29 May 2026 09:53:05 +0200 Subject: [PATCH 5/7] comments fixes --- tests/unit/mocks/sqliteMock.ts | 5 ++- .../storage/providers/SQLiteProviderTest.ts | 39 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index fd41a6326..37936aad5 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -13,7 +13,10 @@ * Result rows are shaped to match Nitro: `{rows: {_array, item, length}}`. */ import BetterSqlite3 from 'better-sqlite3'; -import type {Database} from 'better-sqlite3'; + +// `better-sqlite3` is declared as `export = Database` (CommonJS), so the type is +// derived from the default import's namespace rather than via a named type import. +type Database = BetterSqlite3.Database; type Row = Record; diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 7df18dfd0..d62116aa6 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -192,6 +192,31 @@ describe('SQLiteProvider', () => { }); }); + // RFC 7396 (JSON Merge Patch): a `null` value in the patch removes the key + // from the target. SQLite's `JSON_PATCH` implements this directly. + it('deletes top-level and nested keys when the merge value is null', async () => { + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { + keepMe: 'still here', + removeMe: 'gone soon', + outer: {keepInner: 1, removeInner: 2, deeper: {keepDeep: 'a', removeDeep: 'b'}}, + }); + + await SQLiteProvider.multiMerge([ + [ + ONYXKEYS.TEST_KEY_3, + { + removeMe: null, + outer: {removeInner: null, deeper: {removeDeep: null}}, + }, + ], + ]); + + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({ + keepMe: 'still here', + outer: {keepInner: 1, deeper: {keepDeep: 'a'}}, + }); + }); + // SQLite-specific: the JSON_REPLACE path is what makes `REPLACE_OBJECT_MARK` // actually wipe a nested object (JSON_PATCH alone would only merge into it). it('fully replaces a nested object marked with REPLACE_OBJECT_MARK via JSON_REPLACE', async () => { @@ -309,12 +334,16 @@ describe('SQLiteProvider', () => { }); describe('getDatabaseSize', () => { - it('should get the current size of the store', async () => { - await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, {payload: 'x'.repeat(1024)}); - const size = await SQLiteProvider.getDatabaseSize(); - expect(size.bytesUsed).toBeGreaterThan(0); + it('should report a larger bytesUsed after a write', async () => { + // SQLite allocates pages on init (table + WAL), so bytesUsed is non-0 from the + // start; assert that a write increases it rather than comparing to 0. + const before = await SQLiteProvider.getDatabaseSize(); + await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY, {payload: 'x'.repeat(64 * 1024)}); + const after = await SQLiteProvider.getDatabaseSize(); + + expect(after.bytesUsed).toBeGreaterThan(before.bytesUsed); // bytesRemaining comes from the mocked getFreeDiskStorage(): 12345 - expect(size.bytesRemaining).toBe(12345); + expect(after.bytesRemaining).toBe(12345); }); }); }); From 6fd42e658907bb845a59000fa35a2eb6cb76da2b Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 29 May 2026 10:30:29 +0200 Subject: [PATCH 6/7] comments, merge main, proper types --- jestSetup.js | 1 - tests/unit/mocks/sqliteMock.ts | 64 ++++++------------- .../storage/providers/SQLiteProviderTest.ts | 10 +-- 3 files changed, 26 insertions(+), 49 deletions(-) diff --git a/jestSetup.js b/jestSetup.js index f51f0d54f..9e0a67317 100644 --- a/jestSetup.js +++ b/jestSetup.js @@ -5,7 +5,6 @@ jest.mock('./lib/storage/platforms/index', () => require('./lib/storage/__mocks_ jest.mock('react-native-device-info', () => ({getFreeDiskStorage: () => {}})); jest.mock('react-native-nitro-sqlite', () => ({ open: () => ({execute: () => {}}), - enableSimpleNullHandling: () => undefined, })); jest.useRealTimers(); diff --git a/tests/unit/mocks/sqliteMock.ts b/tests/unit/mocks/sqliteMock.ts index 37936aad5..8b5cd1d45 100644 --- a/tests/unit/mocks/sqliteMock.ts +++ b/tests/unit/mocks/sqliteMock.ts @@ -5,7 +5,6 @@ * Implements the NitroSQLite surface used by * `lib/storage/providers/SQLiteProvider.ts`: * - open({name}) - * - enableSimpleNullHandling() * - connection.execute(sql) * - connection.executeAsync(sql, params?) * - connection.executeBatchAsync([{query, params}]) @@ -13,29 +12,11 @@ * Result rows are shaped to match Nitro: `{rows: {_array, item, length}}`. */ import BetterSqlite3 from 'better-sqlite3'; +import type {Database} from 'better-sqlite3'; +import type {NitroSQLiteConnection, NitroSQLiteQueryResultRows, QueryResult, QueryResultRow, SQLiteQueryParams} from 'react-native-nitro-sqlite'; // `better-sqlite3` is declared as `export = Database` (CommonJS), so the type is // derived from the default import's namespace rather than via a named type import. -type Database = BetterSqlite3.Database; - -type Row = Record; - -type NitroRows = { - _array: T[]; - item: (index: number) => T | undefined; - length: number; -}; - -type NitroResult = { - rows?: NitroRows; - rowsAffected: number; - insertId?: number; -}; - -type BatchQueryCommand = { - query: string; - params?: unknown[][]; -}; const databases = new Map(); @@ -65,7 +46,7 @@ function extractNamedParameterOrder(sql: string): string[] | null { return order; } -function wrapRows(rowsArray: T[]): NitroRows { +function wrapRows(rowsArray: TRow[]): NitroSQLiteQueryResultRows { return { _array: rowsArray, item: (index: number) => rowsArray[index], @@ -73,7 +54,7 @@ function wrapRows(rowsArray: T[]): NitroRows { }; } -function prepareAndBind(database: Database, sql: string, parameters: unknown[]) { +function prepareAndBind(database: Database, sql: string, parameters?: SQLiteQueryParams) { const namedOrder = extractNamedParameterOrder(sql); if (namedOrder) { // Map positional parameters array to named bindings object — NitroSQLite's @@ -81,15 +62,16 @@ function prepareAndBind(database: Database, sql: string, parameters: unknown[]) const statement = database.prepare(sql); const bindings: Record = {}; for (let index = 0; index < namedOrder.length; index++) { - bindings[namedOrder[index]] = parameters[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}; + return {statement: database.prepare(sql), boundArguments: parameters ?? []}; } -function runOne(database: Database, sql: string, parameters: unknown[] = []): NitroResult { +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 @@ -97,7 +79,7 @@ function runOne(database: Database, sql: string, parameters: unkn const semicolons = (sql.match(/;/g) ?? []).length; if (semicolons > 1 || (semicolons === 1 && !sql.trim().endsWith(';'))) { database.exec(sql); - return {rowsAffected: 0}; + return {rowsAffected: 0} as QueryResult; } const {statement, boundArguments} = prepareAndBind(database, sql, parameters); @@ -106,15 +88,15 @@ function runOne(database: Database, sql: string, parameters: unkn // 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 T[]; - return {rows: wrapRows(rows), rowsAffected: 0}; + 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)}; + return {rowsAffected: info.changes, insertId: Number(info.lastInsertRowid)} as QueryResult; } -function makeConnection(name: string) { +function makeConnection(name: string): Pick { let database = databases.get(name); if (!database) { database = new BetterSqlite3(':memory:'); @@ -123,26 +105,26 @@ function makeConnection(name: string) { const connection = database; return { - execute(sql: string, parameters: unknown[] = []): NitroResult { - return runOne(connection, sql, parameters); + execute(sql, parameters) { + return runOne(connection, sql, parameters); }, - executeAsync(sql: string, parameters: unknown[] = []): Promise> { + executeAsync(sql, parameters) { try { - return Promise.resolve(runOne(connection, sql, parameters)); + return Promise.resolve(runOne(connection, sql, parameters)); } catch (error) { return Promise.reject(error); } }, - executeBatchAsync(commands: BatchQueryCommand[]): Promise<{rowsAffected: number}> { + executeBatchAsync(commands) { try { let total = 0; connection.transaction(() => { for (const command of commands) { const namedOrder = extractNamedParameterOrder(command.query); const statement = connection.prepare(command.query); - const parameterRows = command.params ?? []; + const parameterRows = (command.params ?? []) as SQLiteQueryParams[]; if (parameterRows.length === 0) { const info = statement.run(); total += info.changes; @@ -180,10 +162,6 @@ function open({name}: {name: string}) { return makeConnection(name); } -function enableSimpleNullHandling() { - // no-op; better-sqlite3 already handles null naturally. -} - /** * Test helper — wipe every in-memory DB between tests. */ @@ -198,4 +176,4 @@ function resetAllDatabases() { databases.clear(); } -export {open, enableSimpleNullHandling, resetAllDatabases}; +export {open, resetAllDatabases}; diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index d62116aa6..10d4ef5a8 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -171,18 +171,18 @@ describe('SQLiteProvider', () => { expect(await SQLiteProvider.getItem(`${ONYXKEYS.COLLECTION.TEST_KEY}id2`)).toEqual(['a', {newKey: 'newValue'}]); }); - it('inserts a new record when key does not exist', async () => { + it('should insert a new record when key does not exist', async () => { await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_2, {fresh: true}]]); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_2)).toEqual({fresh: true}); }); - it('shallow-merges existing record_key value', async () => { + it('should shallow-merge existing record_key value', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, {a: 1, b: 2}); await SQLiteProvider.multiMerge([[ONYXKEYS.TEST_KEY_3, {b: 99, c: 3}]]); expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY_3)).toEqual({a: 1, b: 99, c: 3}); }); - it('deep-merges nested objects', async () => { + it('should deep-merge nested objects', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { outer: {a: 1, b: 2, nested: {x: 1, y: 2}}, }); @@ -219,7 +219,7 @@ describe('SQLiteProvider', () => { // SQLite-specific: the JSON_REPLACE path is what makes `REPLACE_OBJECT_MARK` // actually wipe a nested object (JSON_PATCH alone would only merge into it). - it('fully replaces a nested object marked with REPLACE_OBJECT_MARK via JSON_REPLACE', async () => { + it('should fully replace a nested object marked with REPLACE_OBJECT_MARK via JSON_REPLACE', async () => { await SQLiteProvider.setItem(ONYXKEYS.TEST_KEY_3, { outer: {a: 1, b: 2, nested: {keepMe: false, oldKey: 'gone'}}, }); @@ -315,7 +315,7 @@ 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('treats a key containing SQL fragments as a literal record_key', async () => { + 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'); expect(await SQLiteProvider.getItem(nastyKey)).toEqual('survived'); From 43a2a9f7e659e7f3d668788a43cc0965a5816764 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 29 May 2026 10:53:44 +0200 Subject: [PATCH 7/7] clean getDatabaseSize test --- tests/unit/storage/providers/SQLiteProviderTest.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts index 10d4ef5a8..bc0cac00a 100644 --- a/tests/unit/storage/providers/SQLiteProviderTest.ts +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -342,8 +342,6 @@ describe('SQLiteProvider', () => { const after = await SQLiteProvider.getDatabaseSize(); expect(after.bytesUsed).toBeGreaterThan(before.bytesUsed); - // bytesRemaining comes from the mocked getFreeDiskStorage(): 12345 - expect(after.bytesRemaining).toBe(12345); }); }); });