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/package-lock.json b/package-lock.json index 24b16cf59..df2b9fcfb 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", @@ -37,6 +38,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", @@ -4327,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", @@ -5866,6 +5878,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", @@ -5879,6 +5906,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", @@ -6234,6 +6308,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", @@ -6931,6 +7012,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", @@ -7047,6 +7144,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", @@ -7207,6 +7314,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", @@ -8507,6 +8624,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", @@ -8699,6 +8826,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", @@ -9044,6 +9178,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", @@ -9230,6 +9371,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", @@ -9753,6 +9901,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", @@ -12345,6 +12500,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", @@ -12404,6 +12572,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", @@ -12418,6 +12593,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", @@ -12465,6 +12647,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", @@ -13300,6 +13508,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", @@ -13430,6 +13666,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", @@ -13515,6 +13762,32 @@ "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", @@ -13772,6 +14045,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", @@ -14665,6 +14953,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", @@ -14950,6 +15285,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", @@ -15278,6 +15623,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", @@ -15683,6 +16058,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", @@ -16198,6 +16586,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", diff --git a/package.json b/package.json index f9386eef8..44b74a56e 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", @@ -71,6 +72,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..8b5cd1d45 --- /dev/null +++ b/tests/unit/mocks/sqliteMock.ts @@ -0,0 +1,179 @@ +/** + * 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 {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: string, parameters?: SQLiteQueryParams) { + 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 ?? []}; +} + +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; + connection.transaction(() => { + for (const command of commands) { + const namedOrder = extractNamedParameterOrder(command.query); + const statement = connection.prepare(command.query); + const parameterRows = (command.params ?? []) as SQLiteQueryParams[]; + if (parameterRows.length === 0) { + const info = statement.run(); + total += info.changes; + continue; + } + for (const row of parameterRows) { + if (namedOrder) { + const bindings: Record = {}; + for (let index = 0; index < namedOrder.length; index++) { + bindings[namedOrder[index]] = row[index]; + } + const info = statement.run(bindings); + total += info.changes; + } else { + const info = statement.run(...row); + 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 new file mode 100644 index 000000000..bc0cac00a --- /dev/null +++ b/tests/unit/storage/providers/SQLiteProviderTest.ts @@ -0,0 +1,347 @@ +/** + * 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`. + */ +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.mock('react-native-device-info', () => ({getFreeDiskStorage: () => 12345})); + +const ONYXKEYS = { + TEST_KEY: 'test', + TEST_KEY_2: 'test2', + 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(() => { + resetAllDatabases(); + SQLiteProvider.init(); + }); + + afterAll(() => { + resetAllDatabases(); + }); + + 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('should return null if there is no stored value for the key', async () => { + expect(await SQLiteProvider.getItem(ONYXKEYS.TEST_KEY)).toBeNull(); + }); + }); + + 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('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'); + }); + + // 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', () => { + 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('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('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('should deep-merge 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}}, + }); + }); + + // 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('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'}}, + }); + + // 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('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.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); + + await SQLiteProvider.removeItems([ONYXKEYS.TEST_KEY, ONYXKEYS.TEST_KEY_3]); + 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('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'); + 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('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); + }); + }); +});