From e2e7aaa603facc82f0dd415f6fa5e052c48a7783 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 12:59:48 -0800 Subject: [PATCH 01/25] Add vitest browser-mode benchmarking infrastructure Add vitest, @vitest/browser, @vitest/browser-playwright, playwright, and tinybench as dev dependencies. Configure vitest for browser-mode benchmarks running in headless Chromium with real IndexedDB via the IDBKeyValProvider storage backend. Add npm scripts: bench, bench:compare, bench:save. Co-authored-by: Cursor --- .gitignore | 3 + package-lock.json | 1949 +++++++++++++++++++++++++++++++++++----- package.json | 10 +- vitest.bench.config.ts | 30 + 4 files changed, 1780 insertions(+), 212 deletions(-) create mode 100644 vitest.bench.config.ts diff --git a/.gitignore b/.gitignore index a46d06d84..8927373a4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ yalc.lock # Perf tests .reassure + +# Benchmark baseline files +.bench-baseline.json diff --git a/package-lock.json b/package-lock.json index d153c8b76..4e869d263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", "date-fns": "^4.1.0", "eslint": "^9.39.2", "eslint-config-expensify": "^2.0.102", @@ -51,6 +53,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", + "playwright": "^1.58.2", "prettier": "^2.8.8", "prop-types": "^15.7.2", "react": "18.2.0", @@ -61,9 +64,11 @@ "react-native-performance": "^5.1.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", + "tinybench": "^6.0.0", "ts-node": "^10.9.2", "type-fest": "^3.12.0", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^4.0.18" }, "engines": { "node": ">=20.19.5", @@ -211,6 +216,7 @@ "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", @@ -538,7 +544,6 @@ "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" @@ -556,7 +561,6 @@ "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -573,7 +577,6 @@ "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -590,7 +593,6 @@ "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", @@ -609,7 +611,6 @@ "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" @@ -698,7 +699,6 @@ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" }, @@ -812,7 +812,6 @@ "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1013,7 +1012,6 @@ "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" @@ -1083,7 +1081,6 @@ "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1133,7 +1130,6 @@ "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" @@ -1206,7 +1202,6 @@ "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" @@ -1224,7 +1219,6 @@ "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1241,7 +1235,6 @@ "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,7 +1252,6 @@ "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1276,7 +1268,6 @@ "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" @@ -1294,7 +1285,6 @@ "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1311,7 +1301,6 @@ "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1380,7 +1369,6 @@ "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1429,7 +1417,6 @@ "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1446,7 +1433,6 @@ "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" @@ -1481,7 +1467,6 @@ "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", @@ -1501,7 +1486,6 @@ "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" @@ -1536,7 +1520,6 @@ "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1605,7 +1588,6 @@ "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" @@ -1707,7 +1689,6 @@ "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1808,7 +1789,6 @@ "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" @@ -1826,7 +1806,6 @@ "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1913,7 +1892,6 @@ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1930,7 +1908,6 @@ "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1967,7 +1944,6 @@ "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1984,7 +1960,6 @@ "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" @@ -2019,7 +1994,6 @@ "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" @@ -2141,7 +2115,6 @@ "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", @@ -2448,178 +2421,620 @@ "node": ">=20.11.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -3276,9 +3691,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -3425,6 +3840,13 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@react-native-community/eslint-config": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz", @@ -3457,6 +3879,7 @@ "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", @@ -3492,6 +3915,7 @@ "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", @@ -3907,79 +4331,429 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.76.3", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.76.3.tgz", + "integrity": "sha512-t0aYZ8ND7+yc+yIm6Yp52bInneYpki6RSIFZ9/LMUzgMKvEB62ptt/7sfho9QkKHCNxE1DJSWIqLIGi/iHHkyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.76.3", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.76.3.tgz", + "integrity": "sha512-pubJFArMMrdZiytH+W95KngcSQs+LsxOBsVHkwgMnpBfRUxXPMK4fudtBwWvhnwN76Oe+WhxSq7vOS5XgoPhmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.76.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.76.3.tgz", + "integrity": "sha512-b2zQPXmW7avw/7zewc9nzMULPIAjsTwN03hskhxHUJH5pzUf7pIklB3FrgYPZrRhJgzHiNl3tOPu7vqiKzBYPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.76.3", + "hermes-parser": "0.23.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.76.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.3.tgz", + "integrity": "sha512-Yrpmrh4IDEupUUM/dqVxhAN8QW1VEUR3Qrk2lzJC1jB2s46hDe0hrMP2vs12YJqlzshteOthjwXQlY0TgIzgbg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-native/polyfills": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-native/polyfills/-/polyfills-2.0.0.tgz", + "integrity": "sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@react-native/dev-middleware/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "async-limiter": "~1.0.0" - } + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@react-native/gradle-plugin": { - "version": "0.76.3", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.76.3.tgz", - "integrity": "sha512-t0aYZ8ND7+yc+yIm6Yp52bInneYpki6RSIFZ9/LMUzgMKvEB62ptt/7sfho9QkKHCNxE1DJSWIqLIGi/iHHkyg==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@react-native/js-polyfills": { - "version": "0.76.3", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.76.3.tgz", - "integrity": "sha512-pubJFArMMrdZiytH+W95KngcSQs+LsxOBsVHkwgMnpBfRUxXPMK4fudtBwWvhnwN76Oe+WhxSq7vOS5XgoPhmw==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-native/metro-babel-transformer": { - "version": "0.76.3", - "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.76.3.tgz", - "integrity": "sha512-b2zQPXmW7avw/7zewc9nzMULPIAjsTwN03hskhxHUJH5pzUf7pIklB3FrgYPZrRhJgzHiNl3tOPu7vqiKzBYPg==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@react-native/babel-preset": "0.76.3", - "hermes-parser": "0.23.1", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@babel/core": "*" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-native/normalize-colors": { - "version": "0.76.3", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.3.tgz", - "integrity": "sha512-Yrpmrh4IDEupUUM/dqVxhAN8QW1VEUR3Qrk2lzJC1jB2s46hDe0hrMP2vs12YJqlzshteOthjwXQlY0TgIzgbg==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-native/polyfills": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@react-native/polyfills/-/polyfills-2.0.0.tgz", - "integrity": "sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -4022,6 +4796,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react-native": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.2.0.tgz", @@ -4143,6 +4924,24 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4251,6 +5050,7 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -4269,6 +5069,7 @@ "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4401,6 +5202,7 @@ "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", @@ -4891,6 +5693,165 @@ "ncc": "dist/ncc/cli.js" } }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -4932,6 +5893,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5359,6 +6321,16 @@ "integrity": "sha512-xpkr6sCDIYTPqzvjG8M3ncw1YOTaloWZOyrUmicoEifBEKzQzt+ooUpRpQ/AbOoJfO/p2ZKiyp79qHThzJDulQ==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", @@ -5735,6 +6707,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -5968,6 +6941,16 @@ "node": ">= 10" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7226,6 +8209,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7286,6 +8276,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7351,6 +8383,7 @@ "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", @@ -8302,6 +9335,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8382,6 +9425,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -10343,6 +11396,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11571,6 +12625,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11623,6 +12687,7 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -12279,6 +13344,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12298,6 +13373,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -12754,6 +13848,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -13068,6 +14173,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13108,6 +14220,19 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -13177,6 +14302,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -13187,6 +14359,16 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13197,6 +14379,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13213,6 +14424,7 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -13418,6 +14630,7 @@ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13471,6 +14684,7 @@ "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", @@ -13544,6 +14758,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -13671,6 +14886,7 @@ "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "react-is": "^18.2.0", "react-shallow-renderer": "^16.15.0", @@ -14101,6 +15317,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14565,6 +15826,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -14614,6 +15882,21 @@ "node": ">=10" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -14673,6 +15956,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -14749,6 +16042,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -14789,6 +16089,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -15342,6 +16649,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.0.tgz", + "integrity": "sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -15359,6 +16686,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15404,6 +16741,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -15472,6 +16819,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15884,6 +17232,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16043,6 +17392,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -16165,6 +17515,166 @@ "node": ">=10.12.0" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -16364,6 +17874,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index b7e428cea..d37c2d505 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ "test": "jest", "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", "perf-test": "npx reassure", + "bench": "vitest bench --config vitest.bench.config.ts", + "bench:compare": "./scripts/compareBenchmarks.sh", + "bench:save": "vitest bench --config vitest.bench.config.ts --outputJson .bench-baseline.json", "build": "tsc -p tsconfig.build.json", "build:watch": "nodemon --watch lib --ext js,json,ts,tsx --exec \"npm run build && npm pack\"", "prebuild:docs": "npm run build", @@ -70,6 +73,8 @@ "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "@vercel/ncc": "0.38.1", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", "date-fns": "^4.1.0", "eslint": "^9.39.2", "eslint-config-expensify": "^2.0.102", @@ -85,6 +90,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", + "playwright": "^1.58.2", "prettier": "^2.8.8", "prop-types": "^15.7.2", "react": "18.2.0", @@ -95,9 +101,11 @@ "react-native-performance": "^5.1.0", "react-test-renderer": "18.2.0", "reassure": "1.4.0", + "tinybench": "^6.0.0", "ts-node": "^10.9.2", "type-fest": "^3.12.0", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^4.0.18" }, "peerDependencies": { "idb-keyval": "^6.2.1", diff --git a/vitest.bench.config.ts b/vitest.bench.config.ts new file mode 100644 index 000000000..9bddf6fee --- /dev/null +++ b/vitest.bench.config.ts @@ -0,0 +1,30 @@ +import {defineConfig} from 'vitest/config'; +import {playwright} from '@vitest/browser-playwright'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + // Ensure we use the web (IDB) storage provider, not the native one + './platforms': path.resolve(__dirname, 'lib/storage/platforms/index.ts'), + }, + }, + define: { + // Onyx references `global` (a Node/RN global) which doesn't exist in browsers. + // Map it to `globalThis` which works in all environments. + global: 'globalThis', + }, + test: { + browser: { + provider: playwright(), + enabled: true, + headless: true, + instances: [{browser: 'chromium'}], + }, + benchmark: { + include: ['benchmarks/**/*.bench.ts'], + }, + // Don't use the __mocks__ directory — we want real storage providers + automock: false, + }, +}); From c34ced13c904749be3288bd0576a321029e12c0e Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 12:59:53 -0800 Subject: [PATCH 02/25] Add production-scale data generators for benchmarks Create data generators that produce realistic Onyx store data modeled after App's ONYXKEYS structure, with four configurable tiers: - small: 50 reports, 500 actions, 50 txns (~1 MB) - modest: 250 reports, 2.5k actions, 250 txns (~5 MB) - heavy: 1k reports, 10k actions, 1k txns (~20 MB) - extreme: 5k reports, 50k actions, 5k txns (~100 MB) Includes factory functions for reports, report actions, transactions, policies, and personal details with realistic field shapes. Also adds shared setup/teardown helpers for initializing and seeding Onyx in benchmark runs. Co-authored-by: Cursor --- benchmarks/dataGenerators.ts | 567 +++++++++++++++++++++++++++++++++++ benchmarks/setup.ts | 69 +++++ 2 files changed, 636 insertions(+) create mode 100644 benchmarks/dataGenerators.ts create mode 100644 benchmarks/setup.ts diff --git a/benchmarks/dataGenerators.ts b/benchmarks/dataGenerators.ts new file mode 100644 index 000000000..0aacbd81b --- /dev/null +++ b/benchmarks/dataGenerators.ts @@ -0,0 +1,567 @@ +/** + * Data generators for Onyx benchmarks. + * + * Produces production-realistic mock data modeled after Expensify App's ONYXKEYS, + * without importing from App to avoid pulling in the entire dependency tree. + * Key strings are replicated verbatim so the shape of the Onyx store matches production. + */ + +// --------------------------------------------------------------------------- +// ONYXKEYS (simplified mirror of App/src/ONYXKEYS.ts) +// --------------------------------------------------------------------------- + +const ONYXKEYS = { + // Scalar keys + ACCOUNT: 'account', + ACCOUNT_MANAGER_REPORT_ID: 'accountManagerReportID', + ACTIVE_CLIENTS: 'activeClients', + DEVICE_ID: 'deviceID', + IS_SIDEBAR_LOADED: 'isSidebarLoaded', + IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + PERSISTED_REQUESTS: 'networkRequestQueue', + PERSISTED_ONGOING_REQUESTS: 'networkOngoingRequestQueue', + CURRENT_DATE: 'currentDate', + CREDENTIALS: 'credentials', + STASHED_CREDENTIALS: 'stashedCredentials', + MODAL: 'modal', + NETWORK: 'network', + PERSONAL_DETAILS_LIST: 'personalDetailsList', + PRIVATE_PERSONAL_DETAILS: 'private_personalDetails', + PERSONAL_DETAILS_METADATA: 'personalDetailsMetadata', + SESSION: 'session', + STASHED_SESSION: 'stashedSession', + BETAS: 'betas', + CURRENCY_LIST: 'currencyList', + LOGIN_LIST: 'loginList', + USER_WALLET: 'userWallet', + BANK_ACCOUNT_LIST: 'bankAccountList', + FUND_LIST: 'fundList', + CARD_LIST: 'cardList', + IS_LOADING_APP: 'isLoadingApp', + HAS_LOADED_APP: 'hasLoadedApp', + COUNTRY_CODE: 'countryCode', + COUNTRY: 'country', + PLAID_DATA: 'plaidData', + PREFERRED_THEME: 'nvp_preferredTheme', + NVP_PRIORITY_MODE: 'nvp_priorityMode', + NVP_PREFERRED_LOCALE: 'nvp_preferredLocale', + NVP_ONBOARDING: 'nvp_onboarding', + NVP_QUICK_ACTION_GLOBAL_CREATE: 'nvp_quickActionGlobalCreate', + NVP_TRAVEL_SETTINGS: 'nvp_travelSettings', + PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone', + FREQUENTLY_USED_EMOJIS: 'nvp_expensify_frequentlyUsedEmojis', + SAVED_SEARCHES: 'nvp_savedSearches', + RECENT_SEARCHES: 'nvp_recentSearches', + ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT: 'OnyxUpdatesLastUpdateIDAppliedToClient', + LAST_VISITED_PATH: 'lastVisitedPath', + RECENTLY_USED_REPORT_FIELDS: 'recentlyUsedReportFields', + CONCIERGE_REPORT_ID: 'conciergeReportID', + SELF_DM_REPORT_ID: 'selfDMReportID', + MAPBOX_ACCESS_TOKEN: 'mapboxAccessToken', + LAST_ACCESSED_WORKSPACE_POLICY_ID: 'lastAccessedWorkspacePolicyID', + IS_LOADING_REPORT_DATA: 'isLoadingReportData', + WALLET_TRANSFER: 'walletTransfer', + CUSTOM_STATUS_DRAFT: 'customStatusDraft', + + // Collection keys + COLLECTION: { + REPORT: 'report_', + REPORT_ACTIONS: 'reportActions_', + REPORT_METADATA: 'reportMetadata_', + REPORT_DRAFT_COMMENT: 'reportDraftComment_', + REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', + REPORT_USER_IS_TYPING: 'reportUserIsTyping_', + REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs_', + POLICY: 'policy_', + POLICY_DRAFTS: 'policyDrafts_', + POLICY_CATEGORIES: 'policyCategories_', + POLICY_TAGS: 'policyTags_', + POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', + POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', + POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', + TRANSACTION: 'transactions_', + TRANSACTION_VIOLATIONS: 'transactionViolations_', + TRANSACTION_DRAFT: 'transactionsDraft_', + TRANSACTION_BACKUP: 'transactionsBackup_', + SECURITY_GROUP: 'securityGroup_', + DOWNLOAD: 'download_', + DOMAIN: 'domain_', + NEXT_STEP: 'reportNextStep_', + SNAPSHOT: 'snapshot_', + WORKSPACE_CARDS_LIST: 'cards_', + REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_', + REPORT_ACTIONS_PAGES: 'reportActionsPages_', + REPORT_ACTIONS_REACTIONS: 'reportActionsReactions_', + REPORT_VIOLATIONS: 'reportViolations_', + SELECTED_TAB: 'selectedTab_', + }, +} as const; + +export {ONYXKEYS}; + +// --------------------------------------------------------------------------- +// Data tiers +// --------------------------------------------------------------------------- + +export type DataTierName = 'small' | 'modest' | 'heavy' | 'extreme'; + +export interface DataTierConfig { + reports: number; + reportActions: number; + transactions: number; + policies: number; + personalDetails: number; +} + +export const DATA_TIERS: Record = { + small: {reports: 50, reportActions: 500, transactions: 50, policies: 1, personalDetails: 20}, + modest: {reports: 250, reportActions: 2500, transactions: 250, policies: 3, personalDetails: 100}, + heavy: {reports: 1000, reportActions: 10000, transactions: 1000, policies: 10, personalDetails: 500}, + extreme: {reports: 5000, reportActions: 50000, transactions: 5000, policies: 25, personalDetails: 2000}, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let _counter = 0; +function uid(): string { + return String(++_counter); +} + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function randomChoice(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function isoDate(daysAgo = 0): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().replace('T', ' ').slice(0, 23); +} + +const MERCHANTS = ['Uber', 'Starbucks', 'Amazon', 'Delta Airlines', 'Marriott', 'WeWork', 'Whole Foods', 'Shell Gas', 'FedEx', 'Apple Store']; +const CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY']; +const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Heidi', 'Ivan', 'Judy']; +const LAST_NAMES = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez']; +const REPORT_TYPES = ['chat', 'expense', 'iou', 'task']; +const ACTION_NAMES = ['ADDCOMMENT', 'IOU', 'CREATED', 'CLOSED', 'REPORTPREVIEW', 'MARKEDREIMBURSED', 'MODIFIEDEXPENSE']; + +// --------------------------------------------------------------------------- +// Entity generators +// --------------------------------------------------------------------------- + +export interface MockReport { + reportID: string; + reportName: string; + type: string; + stateNum: number; + statusNum: number; + ownerAccountID: number; + managerID: number; + currency: string; + total: number; + nonReimbursableTotal: number; + lastVisibleActionCreated: string; + lastReadTime: string; + lastMessageText: string; + participantAccountIDs: number[]; + participants: Record; + isPinned: boolean; + chatType: string; + policyID: string; + isOwnPolicyExpenseChat: boolean; + hasOutstandingChildRequest: boolean; + description: string; +} + +export function generateReport(id: number, policyID: string): MockReport { + const reportID = String(id); + const ownerAccountID = randomInt(1, 200); + const participantCount = randomInt(1, 5); + const participantAccountIDs = Array.from({length: participantCount}, (_, i) => ownerAccountID + i + 1); + const participants: Record = {}; + for (const pid of participantAccountIDs) { + participants[String(pid)] = {notificationPreference: 'always'}; + } + + return { + reportID, + reportName: `Report #${reportID}`, + type: randomChoice(REPORT_TYPES), + stateNum: randomInt(0, 2), + statusNum: randomInt(0, 4), + ownerAccountID, + managerID: randomInt(1, 200), + currency: randomChoice(CURRENCIES), + total: randomInt(100, 100000), + nonReimbursableTotal: randomInt(0, 5000), + lastVisibleActionCreated: isoDate(randomInt(0, 90)), + lastReadTime: isoDate(randomInt(0, 30)), + lastMessageText: `This is a sample message for report ${reportID}`, + participantAccountIDs, + participants, + isPinned: Math.random() < 0.1, + chatType: 'policyExpenseChat', + policyID, + isOwnPolicyExpenseChat: Math.random() < 0.3, + hasOutstandingChildRequest: Math.random() < 0.2, + description: '', + }; +} + +export interface MockReportAction { + reportActionID: string; + actionName: string; + actorAccountID: number; + created: string; + message: Array<{type: string; html: string; text: string; isEdited: boolean}>; + originalMessage: {html: string; lastModified: string}; + person: Array<{type: string; style: string; text: string}>; + avatar: string; + automatic: boolean; + shouldShow: boolean; + lastModified: string; + pendingAction: string | null; + errors: Record; +} + +export function generateReportAction(index: number): MockReportAction { + const actorAccountID = randomInt(1, 200); + return { + reportActionID: String(index), + actionName: randomChoice(ACTION_NAMES), + actorAccountID, + created: isoDate(randomInt(0, 90)), + message: [ + { + type: 'COMMENT', + html: `

Message body ${index}

`, + text: `Message body ${index}`, + isEdited: Math.random() < 0.1, + }, + ], + originalMessage: { + html: `

Original message ${index}

`, + lastModified: isoDate(randomInt(0, 90)), + }, + person: [{type: 'TEXT', style: 'strong', text: `User ${actorAccountID}`}], + avatar: `https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_${actorAccountID % 8}.png`, + automatic: false, + shouldShow: true, + lastModified: isoDate(randomInt(0, 30)), + pendingAction: null, + errors: {}, + }; +} + +export interface MockTransaction { + transactionID: string; + amount: number; + merchant: string; + currency: string; + created: string; + modifiedCreated: string; + comment: {comment: string}; + category: string; + tag: string; + reportID: string; + receipt: Record; + filename: string; + billable: boolean; + reimbursable: boolean; + pendingAction: string | null; + errors: Record; + cardID: number; + originalAmount: number; + originalCurrency: string; +} + +export function generateTransaction(id: number, reportID: string): MockTransaction { + const currency = randomChoice(CURRENCIES); + const amount = randomInt(100, 50000); + return { + transactionID: String(id), + amount, + merchant: randomChoice(MERCHANTS), + currency, + created: isoDate(randomInt(0, 90)), + modifiedCreated: '', + comment: {comment: `Expense note ${id}`}, + category: randomChoice(['Travel', 'Meals', 'Office', 'Software', 'Transport', '']), + tag: randomChoice(['Project A', 'Project B', 'General', '']), + reportID, + receipt: {}, + filename: '', + billable: Math.random() < 0.3, + reimbursable: Math.random() < 0.7, + pendingAction: null, + errors: {}, + cardID: randomInt(0, 5), + originalAmount: amount, + originalCurrency: currency, + }; +} + +export interface MockPolicy { + id: string; + name: string; + type: string; + role: string; + owner: string; + ownerAccountID: number; + outputCurrency: string; + isPolicyExpenseChatEnabled: boolean; + autoReporting: boolean; + autoReportingFrequency: string; + harvesting: {enabled: boolean}; + defaultBillable: boolean; + disabledFields: {defaultBillable: boolean}; + customUnits: Record; + areCategoriesEnabled: boolean; + areTagsEnabled: boolean; + areDistanceRatesEnabled: boolean; + areWorkflowsEnabled: boolean; + areReportFieldsEnabled: boolean; + areConnectionsEnabled: boolean; + employeeList: Record; +} + +export function generatePolicy(id: number, memberCount = 10): MockPolicy { + const policyID = String(id); + const employeeList: Record = {}; + for (let i = 0; i < memberCount; i++) { + const email = `user${i}@company${id}.com`; + employeeList[email] = {email, role: i === 0 ? 'admin' : 'user'}; + } + return { + id: policyID, + name: `Workspace ${policyID}`, + type: 'team', + role: 'admin', + owner: `admin@company${id}.com`, + ownerAccountID: randomInt(1, 200), + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + autoReporting: true, + autoReportingFrequency: 'immediate', + harvesting: {enabled: true}, + defaultBillable: false, + disabledFields: {defaultBillable: true}, + customUnits: {}, + areCategoriesEnabled: true, + areTagsEnabled: true, + areDistanceRatesEnabled: false, + areWorkflowsEnabled: true, + areReportFieldsEnabled: false, + areConnectionsEnabled: false, + employeeList, + }; +} + +export interface MockPersonalDetails { + accountID: number; + displayName: string; + firstName: string; + lastName: string; + login: string; + avatar: string; + pronouns: string; + timezone: {selected: string; automatic: boolean}; + phoneNumber: string; + validated: boolean; +} + +export function generatePersonalDetails(accountID: number): MockPersonalDetails { + const firstName = randomChoice(FIRST_NAMES); + const lastName = randomChoice(LAST_NAMES); + return { + accountID, + displayName: `${firstName} ${lastName}`, + firstName, + lastName, + login: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${accountID}@example.com`, + avatar: `https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_${accountID % 8}.png`, + pronouns: '', + timezone: {selected: 'America/New_York', automatic: true}, + phoneNumber: `+1${String(randomInt(2000000000, 9999999999))}`, + validated: true, + }; +} + +// --------------------------------------------------------------------------- +// Scalar keys generator +// --------------------------------------------------------------------------- + +function generateScalarKeys(): Record { + return { + [ONYXKEYS.ACCOUNT]: { + accountID: 12345, + email: 'user@example.com', + validated: true, + requiresTwoFactorAuth: false, + isLoading: false, + }, + [ONYXKEYS.SESSION]: { + authToken: 'mock-auth-token-abcdef123456', + accountID: 12345, + email: 'user@example.com', + encryptedAuthToken: 'mock-encrypted-token', + autoAuthState: 'authenticated', + }, + [ONYXKEYS.CREDENTIALS]: { + login: 'user@example.com', + autoGeneratedLogin: 'user-auto-12345', + autoGeneratedPassword: 'mock-password-hash', + }, + [ONYXKEYS.NETWORK]: {isOffline: false, shouldForceOffline: false}, + [ONYXKEYS.BETAS]: ['all'], + [ONYXKEYS.IS_SIDEBAR_LOADED]: true, + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.HAS_LOADED_APP]: true, + [ONYXKEYS.CURRENT_DATE]: new Date().toISOString().slice(0, 10), + [ONYXKEYS.PREFERRED_THEME]: 'system', + [ONYXKEYS.NVP_PREFERRED_LOCALE]: 'en', + [ONYXKEYS.NVP_PRIORITY_MODE]: 'default', + [ONYXKEYS.COUNTRY_CODE]: 1, + [ONYXKEYS.COUNTRY]: 'US', + [ONYXKEYS.DEVICE_ID]: 'mock-device-id-abcdef', + [ONYXKEYS.ACTIVE_CLIENTS]: ['client-1'], + [ONYXKEYS.MODAL]: {isVisible: false, willAlertModalBecomeVisible: false}, + [ONYXKEYS.PERSISTED_REQUESTS]: [], + [ONYXKEYS.CONCIERGE_REPORT_ID]: '1', + [ONYXKEYS.SELF_DM_REPORT_ID]: '2', + [ONYXKEYS.LAST_VISITED_PATH]: '/home', + [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: {token: 'mock-mapbox-token'}, + [ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE]: 0, + [ONYXKEYS.FREQUENTLY_USED_EMOJIS]: [ + {code: '👍', count: 42, lastUpdatedAt: Date.now()}, + {code: '😂', count: 30, lastUpdatedAt: Date.now()}, + ], + [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: {}, + [ONYXKEYS.SAVED_SEARCHES]: {}, + [ONYXKEYS.RECENT_SEARCHES]: [], + [ONYXKEYS.LOGIN_LIST]: { + 'user@example.com': {partnerUserID: 'user@example.com', validatedDate: isoDate(30)}, + }, + [ONYXKEYS.BANK_ACCOUNT_LIST]: {}, + [ONYXKEYS.FUND_LIST]: {}, + [ONYXKEYS.CARD_LIST]: {}, + [ONYXKEYS.USER_WALLET]: {currentBalance: 0}, + [ONYXKEYS.WALLET_TRANSFER]: {}, + [ONYXKEYS.CURRENCY_LIST]: generateCurrencyList(), + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT]: randomInt(100000, 999999), + }; +} + +function generateCurrencyList(): Record { + const list: Record = {}; + const entries: Array<[string, string, string]> = [ + ['USD', '$', 'US Dollar'], + ['EUR', '€', 'Euro'], + ['GBP', '£', 'British Pound'], + ['CAD', 'CA$', 'Canadian Dollar'], + ['AUD', 'A$', 'Australian Dollar'], + ['JPY', '¥', 'Japanese Yen'], + ]; + for (const [iso, symbol, name] of entries) { + list[iso] = {symbol, name, ISO4217: iso}; + } + return list; +} + +// --------------------------------------------------------------------------- +// Full store generator +// --------------------------------------------------------------------------- + +export interface GeneratedStore { + /** Flat key→value map ready to be passed to Onyx.multiSet */ + data: Record; + /** Metadata about what was generated */ + meta: { + tier: DataTierName | 'custom'; + config: DataTierConfig; + reportIDs: string[]; + transactionIDs: string[]; + policyIDs: string[]; + totalKeys: number; + }; +} + +export function generateFullStore(tierOrConfig: DataTierName | DataTierConfig): GeneratedStore { + const tierName: DataTierName | 'custom' = typeof tierOrConfig === 'string' ? tierOrConfig : 'custom'; + const config: DataTierConfig = typeof tierOrConfig === 'string' ? DATA_TIERS[tierOrConfig] : tierOrConfig; + + _counter = 0; // reset uid counter for deterministic IDs + const data: Record = {}; + + // 1. Scalar keys + Object.assign(data, generateScalarKeys()); + + // 2. Policies + const policyIDs: string[] = []; + for (let i = 0; i < config.policies; i++) { + const policy = generatePolicy(i + 1, Math.min(config.personalDetails, 50)); + policyIDs.push(policy.id); + data[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; + } + + // 3. Reports + const reportIDs: string[] = []; + for (let i = 0; i < config.reports; i++) { + const policyID = policyIDs[i % policyIDs.length]; + const report = generateReport(i + 1, policyID); + reportIDs.push(report.reportID); + data[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + data[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`] = { + isLoadingInitialReportActions: false, + isLoadingOlderReportActions: false, + isLoadingNewerReportActions: false, + }; + } + + // 4. Report Actions (spread across reports) + for (let i = 0; i < config.reportActions; i++) { + const reportID = reportIDs[i % reportIDs.length]; + const action = generateReportAction(i + 1); + // Report actions are stored as a single object per reportID containing all actions + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + if (!data[key]) { + data[key] = {}; + } + (data[key] as Record)[action.reportActionID] = action; + } + + // 5. Transactions + const transactionIDs: string[] = []; + for (let i = 0; i < config.transactions; i++) { + const reportID = reportIDs[i % reportIDs.length]; + const txn = generateTransaction(i + 1, reportID); + transactionIDs.push(txn.transactionID); + data[`${ONYXKEYS.COLLECTION.TRANSACTION}${txn.transactionID}`] = txn; + } + + // 6. Personal Details (stored as a single map) + const personalDetailsList: Record = {}; + for (let i = 0; i < config.personalDetails; i++) { + const pd = generatePersonalDetails(i + 1); + personalDetailsList[String(pd.accountID)] = pd; + } + data[ONYXKEYS.PERSONAL_DETAILS_LIST] = personalDetailsList; + + return { + data, + meta: { + tier: tierName, + config, + reportIDs, + transactionIDs, + policyIDs, + totalKeys: Object.keys(data).length, + }, + }; +} diff --git a/benchmarks/setup.ts b/benchmarks/setup.ts new file mode 100644 index 000000000..1c6d5416f --- /dev/null +++ b/benchmarks/setup.ts @@ -0,0 +1,69 @@ +/** + * Shared setup / teardown utilities for Onyx benchmarks. + * + * These helpers initialize Onyx with realistic keys, seed IndexedDB with + * generated data, and tear everything down between benchmark iterations. + */ + +import Onyx from '../lib'; +import type {DataTierName, GeneratedStore} from './dataGenerators'; +import {DATA_TIERS, generateFullStore, ONYXKEYS} from './dataGenerators'; + +/** + * Initialize Onyx with the production-like ONYXKEYS. + * Must be called once before benchmarks run. + */ +export async function initOnyx(): Promise { + Onyx.init({ + keys: ONYXKEYS, + maxCachedKeysCount: 100000, + }); + + // Wait for init to settle + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); +} + +/** + * Seed the Onyx store (and underlying IndexedDB) with data from a generated store. + */ +export async function seedStore(store: GeneratedStore): Promise { + await Onyx.multiSet(store.data); +} + +/** + * Clear all Onyx data and the underlying IndexedDB store. + */ +export async function clearStore(): Promise { + await Onyx.clear(); +} + +/** + * Generate data for a tier, seed Onyx, and return the store for later use. + */ +export async function seedTier(tier: DataTierName): Promise { + const store = generateFullStore(tier); + await seedStore(store); + return store; +} + +/** + * Convenience: generate store data without seeding. + */ +export function generateTierData(tier: DataTierName): GeneratedStore { + return generateFullStore(tier); +} + +/** + * Get the human-readable label for a tier (used in benchmark names). + */ +export function tierLabel(tier: DataTierName): string { + const cfg = DATA_TIERS[tier]; + return `${tier} (${cfg.reports} reports, ${cfg.transactions} txns)`; +} + +/** + * All tier names for iteration in benchmarks. + */ +export const ALL_TIERS: DataTierName[] = ['small', 'modest', 'heavy', 'extreme']; From 619381188de1f467240e8c9748c3333a877d2ebf Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 12:59:57 -0800 Subject: [PATCH 03/25] Add benchmark suites and branch comparison script Add tinybench-based benchmarks for all perf-sensitive Onyx methods, each running across four data tiers (small/modest/heavy/extreme): - set.bench.ts: set(), multiSet(), setCollection() - merge.bench.ts: merge(), mergeCollection(), update() (mixed ops) - connect.bench.ts: connect() registration, collection subscribers, notification throughput - init.bench.ts: init() with initialKeyStates - clear.bench.ts: clear() at each scale Add scripts/compareBenchmarks.sh which automates baseline-vs-current branch comparison using vitest's --outputJson and --compare flags. Co-authored-by: Cursor --- benchmarks/clear.bench.ts | 35 ++++++++++ benchmarks/connect.bench.ts | 131 +++++++++++++++++++++++++++++++++++ benchmarks/init.bench.ts | 41 +++++++++++ benchmarks/merge.bench.ts | 108 +++++++++++++++++++++++++++++ benchmarks/set.bench.ts | 72 +++++++++++++++++++ scripts/compareBenchmarks.sh | 116 +++++++++++++++++++++++++++++++ 6 files changed, 503 insertions(+) create mode 100644 benchmarks/clear.bench.ts create mode 100644 benchmarks/connect.bench.ts create mode 100644 benchmarks/init.bench.ts create mode 100644 benchmarks/merge.bench.ts create mode 100644 benchmarks/set.bench.ts create mode 100755 scripts/compareBenchmarks.sh diff --git a/benchmarks/clear.bench.ts b/benchmarks/clear.bench.ts new file mode 100644 index 000000000..3eb6e1b1b --- /dev/null +++ b/benchmarks/clear.bench.ts @@ -0,0 +1,35 @@ +/** + * Benchmarks for Onyx.clear() at each data tier. + * + * Measures how long it takes to clear the entire store + * (both in-memory cache and IndexedDB) at various scales. + */ + +import {bench, describe, beforeAll} from 'vitest'; +import Onyx from '../lib'; +import {generateFullStore, DATA_TIERS} from './dataGenerators'; +import {initOnyx, seedStore, ALL_TIERS, tierLabel} from './setup'; + +beforeAll(async () => { + await initOnyx(); +}); + +for (const tier of ALL_TIERS) { + const label = tierLabel(tier); + const cfg = DATA_TIERS[tier]; + + describe(`clear (${label})`, () => { + bench( + `Onyx.clear() - ${cfg.reports} reports + ${cfg.transactions} txns`, + async () => { + await Onyx.clear(); + }, + { + setup: async () => { + const store = generateFullStore(tier); + await seedStore(store); + }, + }, + ); + }); +} diff --git a/benchmarks/connect.bench.ts b/benchmarks/connect.bench.ts new file mode 100644 index 000000000..b54b39b40 --- /dev/null +++ b/benchmarks/connect.bench.ts @@ -0,0 +1,131 @@ +/** + * Benchmarks for Onyx.connect() subscriber registration + * and subscriber notification throughput. + */ + +import {bench, describe, beforeAll, afterEach} from 'vitest'; +import Onyx from '../lib'; +import {ONYXKEYS, generateFullStore, DATA_TIERS} from './dataGenerators'; +import type {GeneratedStore} from './dataGenerators'; +import {initOnyx, seedStore, clearStore, ALL_TIERS, tierLabel} from './setup'; + +beforeAll(async () => { + await initOnyx(); +}); + +afterEach(async () => { + await clearStore(); +}); + +for (const tier of ALL_TIERS) { + const label = tierLabel(tier); + const cfg = DATA_TIERS[tier]; + + describe(`connect (${label})`, () => { + let store: GeneratedStore; + + bench( + `Onyx.connect() - register ${cfg.reports} individual key subscribers`, + async () => { + const connections: Array> = []; + for (const reportID of store.meta.reportIDs) { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + callback: () => {}, + }); + connections.push(connection); + } + // Disconnect all + for (const connection of connections) { + Onyx.disconnect(connection); + } + }, + { + setup: async () => { + store = generateFullStore(tier); + await seedStore(store); + }, + teardown: async () => { + await clearStore(); + }, + }, + ); + + bench( + `Onyx.connect() - collection subscriber for ${cfg.reports} reports`, + async () => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: () => {}, + waitForCollectionCallback: true, + }); + // Give it a moment to fire the initial callback, then disconnect + await new Promise((resolve) => setTimeout(resolve, 0)); + Onyx.disconnect(connection); + }, + { + setup: async () => { + store = generateFullStore(tier); + await seedStore(store); + }, + teardown: async () => { + await clearStore(); + }, + }, + ); + + bench( + `Notification throughput - write with ${Math.min(cfg.reports, 100)} active subscribers`, + async () => { + // Register subscribers + const subscriberCount = Math.min(cfg.reports, 100); + const connections: Array> = []; + let callbackCount = 0; + for (let i = 0; i < subscriberCount; i++) { + const reportID = store.meta.reportIDs[i]; + connections.push( + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + callback: () => { + callbackCount++; + }, + }), + ); + } + + // Wait for initial callbacks to settle + await new Promise((resolve) => setTimeout(resolve, 50)); + callbackCount = 0; + + // Now trigger updates to all subscribed keys + const promises: Array> = []; + for (let i = 0; i < subscriberCount; i++) { + const reportID = store.meta.reportIDs[i]; + promises.push( + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + lastMessageText: `Notification bench ${Date.now()}`, + }), + ); + } + await Promise.all(promises); + + // Wait for all notifications to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Disconnect all + for (const connection of connections) { + Onyx.disconnect(connection); + } + }, + { + setup: async () => { + store = generateFullStore(tier); + await seedStore(store); + }, + teardown: async () => { + await clearStore(); + }, + }, + ); + }); +} diff --git a/benchmarks/init.bench.ts b/benchmarks/init.bench.ts new file mode 100644 index 000000000..901066a5f --- /dev/null +++ b/benchmarks/init.bench.ts @@ -0,0 +1,41 @@ +/** + * Benchmarks for Onyx.init() — cold start and cache hydration. + * + * These measure how long it takes Onyx to initialize and hydrate its + * in-memory cache from IndexedDB with varying store sizes. + */ + +import {bench, describe} from 'vitest'; +import Onyx from '../lib'; +import OnyxUtils from '../lib/OnyxUtils'; +import createDeferredTask from '../lib/createDeferredTask'; +import {ONYXKEYS, generateFullStore, DATA_TIERS} from './dataGenerators'; +import {clearStore, ALL_TIERS, tierLabel} from './setup'; + +for (const tier of ALL_TIERS) { + const label = tierLabel(tier); + const cfg = DATA_TIERS[tier]; + + describe(`init (${label})`, () => { + bench( + `Onyx.init() with ${cfg.reports} initialKeyStates`, + async () => { + const store = generateFullStore(tier); + // Reset the deferred task so init() can resolve fresh + Object.assign(OnyxUtils.getDeferredInitTask(), createDeferredTask()); + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: store.data, + maxCachedKeysCount: 100000, + enableDevTools: false, + }); + await OnyxUtils.getDeferredInitTask().promise; + }, + { + teardown: async () => { + await Onyx.clear(); + }, + }, + ); + }); +} diff --git a/benchmarks/merge.bench.ts b/benchmarks/merge.bench.ts new file mode 100644 index 000000000..a1f9045c9 --- /dev/null +++ b/benchmarks/merge.bench.ts @@ -0,0 +1,108 @@ +/** + * Benchmarks for Onyx.merge(), Onyx.mergeCollection(), and Onyx.update(). + * + * These benchmarks pre-seed data and then measure the cost of merging + * partial updates into the existing store. + */ + +import {bench, describe, beforeAll, afterEach} from 'vitest'; +import Onyx from '../lib'; +import type {OnyxKey, OnyxUpdate} from '../lib'; +import {ONYXKEYS, generateFullStore, generateReport, DATA_TIERS} from './dataGenerators'; +import type {GeneratedStore} from './dataGenerators'; +import {initOnyx, seedStore, clearStore, ALL_TIERS, tierLabel} from './setup'; + +beforeAll(async () => { + await initOnyx(); +}); + +afterEach(async () => { + await clearStore(); +}); + +for (const tier of ALL_TIERS) { + const label = tierLabel(tier); + const cfg = DATA_TIERS[tier]; + + describe(`merge (${label})`, () => { + let store: GeneratedStore; + + bench( + `Onyx.merge() - partial update ${cfg.reports} reports`, + async () => { + const promises: Array> = []; + for (const reportID of store.meta.reportIDs) { + promises.push( + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + lastMessageText: `Updated message at ${Date.now()}`, + lastVisibleActionCreated: new Date().toISOString(), + }), + ); + } + await Promise.all(promises); + }, + { + setup: async () => { + store = generateFullStore(tier); + await seedStore(store); + }, + teardown: async () => { + await clearStore(); + }, + }, + ); + + bench( + `Onyx.mergeCollection() - partial update ${cfg.reports} reports`, + async () => { + const updates: Record = {}; + for (const reportID of store.meta.reportIDs) { + updates[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = { + lastMessageText: `Bulk updated at ${Date.now()}`, + isPinned: true, + }; + } + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, updates); + }, + { + setup: async () => { + store = generateFullStore(tier); + await seedStore(store); + }, + teardown: async () => { + await clearStore(); + }, + }, + ); + + bench( + `Onyx.update() - mixed set/merge (${cfg.reports} ops)`, + async () => { + const updates: Array> = store.meta.reportIDs.map((reportID, i) => { + if (i % 2 === 0) { + return { + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + onyxMethod: Onyx.METHOD.SET, + value: generateReport(Number(reportID), '1'), + }; + } + return { + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: {lastMessageText: `Mixed update ${Date.now()}`}, + }; + }); + await Onyx.update(updates); + }, + { + setup: async () => { + store = generateFullStore(tier); + await seedStore(store); + }, + teardown: async () => { + await clearStore(); + }, + }, + ); + }); +} diff --git a/benchmarks/set.bench.ts b/benchmarks/set.bench.ts new file mode 100644 index 000000000..e526662db --- /dev/null +++ b/benchmarks/set.bench.ts @@ -0,0 +1,72 @@ +/** + * Benchmarks for Onyx.set(), Onyx.multiSet(), and Onyx.setCollection(). + * + * Each benchmark is run for every data tier (small → extreme). + */ + +import {bench, describe, beforeAll, afterEach} from 'vitest'; +import Onyx from '../lib'; +import {ONYXKEYS, generateFullStore, generateReport, DATA_TIERS} from './dataGenerators'; +import {initOnyx, clearStore, ALL_TIERS, tierLabel} from './setup'; + +beforeAll(async () => { + await initOnyx(); +}); + +afterEach(async () => { + await clearStore(); +}); + +for (const tier of ALL_TIERS) { + const label = tierLabel(tier); + const cfg = DATA_TIERS[tier]; + + describe(`set (${label})`, () => { + bench( + `Onyx.set() - ${cfg.reports} reports individually`, + async () => { + const promises: Array> = []; + for (let i = 0; i < cfg.reports; i++) { + const report = generateReport(i + 1, '1'); + promises.push(Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report)); + } + await Promise.all(promises); + }, + { + teardown: async () => { + await clearStore(); + }, + }, + ); + + bench( + `Onyx.multiSet() - full store (${cfg.reports} reports + ${cfg.transactions} txns)`, + async () => { + const store = generateFullStore(tier); + await Onyx.multiSet(store.data); + }, + { + teardown: async () => { + await clearStore(); + }, + }, + ); + + bench( + `Onyx.setCollection() - ${cfg.reports} reports`, + async () => { + const collection: Record = {}; + for (let i = 0; i < cfg.reports; i++) { + const report = generateReport(i + 1, '1'); + collection[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + } + await Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT, collection); + }, + { + teardown: async () => { + await clearStore(); + }, + }, + ); + }); +} diff --git a/scripts/compareBenchmarks.sh b/scripts/compareBenchmarks.sh new file mode 100755 index 000000000..789a9acdf --- /dev/null +++ b/scripts/compareBenchmarks.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# +# compareBenchmarks.sh — Compare benchmark results between the current branch +# and a base branch (defaults to "main"). +# +# Usage: +# ./scripts/compareBenchmarks.sh [base-branch] [-- vitest args...] +# +# Examples: +# ./scripts/compareBenchmarks.sh # Compare against main +# ./scripts/compareBenchmarks.sh release # Compare against 'release' branch +# ./scripts/compareBenchmarks.sh main -- benchmarks/set # Only run set benchmarks +# + +set -euo pipefail + +BASELINE_FILE=".bench-baseline.json" +BASE_BRANCH="${1:-main}" + +# Collect extra vitest args (everything after --) +EXTRA_ARGS=() +shift || true +if [[ "${1:-}" == "--" ]]; then + shift + EXTRA_ARGS=("$@") +fi + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +CURRENT_BRANCH="" +STASH_CREATED=false + +cleanup() { + echo "" + echo "==> Cleaning up..." + if [[ -n "$CURRENT_BRANCH" ]]; then + git checkout "$CURRENT_BRANCH" 2>/dev/null || true + fi + if $STASH_CREATED; then + echo "==> Restoring stashed changes..." + git stash pop 2>/dev/null || true + fi + rm -f "$BASELINE_FILE" +} + +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# 1. Record current state +# --------------------------------------------------------------------------- + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +echo "==> Current branch: $CURRENT_BRANCH" +echo "==> Base branch: $BASE_BRANCH" +echo "" + +# Stash uncommitted changes if any +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "==> Stashing uncommitted changes..." + git stash push -m "compareBenchmarks: auto-stash" + STASH_CREATED=true +fi + +# --------------------------------------------------------------------------- +# 2. Run baseline on base branch +# --------------------------------------------------------------------------- + +echo "" +echo "==> Switching to base branch '$BASE_BRANCH'..." +git checkout "$BASE_BRANCH" + +echo "==> Installing dependencies for baseline..." +npm ci --silent 2>/dev/null || npm install --silent + +echo "" +echo "==> Running baseline benchmarks on '$BASE_BRANCH'..." +npx vitest bench --config vitest.bench.config.ts --outputJson "$BASELINE_FILE" "${EXTRA_ARGS[@]}" || { + echo "ERROR: Baseline benchmarks failed on '$BASE_BRANCH'" + exit 1 +} + +echo "" +echo "==> Baseline results saved to $BASELINE_FILE" + +# --------------------------------------------------------------------------- +# 3. Switch back and run comparison +# --------------------------------------------------------------------------- + +echo "" +echo "==> Switching back to '$CURRENT_BRANCH'..." +git checkout "$CURRENT_BRANCH" + +if $STASH_CREATED; then + echo "==> Restoring stashed changes..." + git stash pop + STASH_CREATED=false +fi + +echo "==> Installing dependencies for current branch..." +npm ci --silent 2>/dev/null || npm install --silent + +echo "" +echo "============================================================" +echo " BENCHMARK COMPARISON: $BASE_BRANCH → $CURRENT_BRANCH" +echo "============================================================" +echo "" + +npx vitest bench --config vitest.bench.config.ts --compare "$BASELINE_FILE" "${EXTRA_ARGS[@]}" || { + echo "ERROR: Benchmarks failed on '$CURRENT_BRANCH'" + exit 1 +} + +echo "" +echo "==> Comparison complete!" From 811b153ff079f04f6a26a9d3dcc72046f855e385 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 13:02:00 -0800 Subject: [PATCH 04/25] Add benchmark documentation to README Document the benchmark suite, data tiers, how to run benchmarks, and how to compare performance across branches. Co-authored-by: Cursor --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/README.md b/README.md index e5384e8dc..7f3742dd8 100644 --- a/README.md +++ b/README.md @@ -510,3 +510,72 @@ you to edit the Onyx source directly in the Onyx repo, and have those changes ho Now you can make changes directly to the `react-native-onyx` source code and your React Native project should-hot reload with those changes in realtime. _Note:_ If you want to unlink `react-native-onyx`, simply run `npm install` from your React Native project directory again. That will reinstall `react-native-onyx` from npm. + +# Benchmarks + +The `benchmarks/` directory contains a browser-based benchmark suite that measures the performance of core Onyx operations using real IndexedDB in a headless Chromium browser. This gives accurate measurements that reflect production web behavior, unlike Jest-based tests which use mocked storage. + +## What's benchmarked + +Every perf-sensitive Onyx method is covered: + +| Method | File | +|---|---| +| `set()`, `multiSet()`, `setCollection()` | `benchmarks/set.bench.ts` | +| `merge()`, `mergeCollection()`, `update()` | `benchmarks/merge.bench.ts` | +| `connect()`, `disconnect()`, subscriber notifications | `benchmarks/connect.bench.ts` | +| `init()` | `benchmarks/init.bench.ts` | +| `clear()` | `benchmarks/clear.bench.ts` | + +Each benchmark runs across four data tiers to show how operations scale: + +| Tier | Reports | Report Actions | Transactions | +|---|---|---|---| +| **small** | 50 | 500 | 50 | +| **modest** | 250 | 2,500 | 250 | +| **heavy** | 1,000 | 10,000 | 1,000 | +| **extreme** | 5,000 | 50,000 | 5,000 | + +## Running benchmarks + +```bash +# Run all benchmarks +npm run bench + +# Run a specific benchmark file +npx vitest bench --config vitest.bench.config.ts benchmarks/set +``` + +## Comparing branches + +To measure the performance impact of a change, use the comparison workflow: + +```bash +# Compare current branch against main (automated) +npm run bench:compare + +# Compare against a different base branch +npm run bench:compare -- some-branch + +# Compare only specific benchmarks +npm run bench:compare -- main -- benchmarks/set +``` + +This checks out the base branch, runs benchmarks to capture a baseline, switches back, and runs again with `--compare` to show the diff: + +``` +· Onyx.merge() - partial update 250 reports 21.46 hz [0.95x] ⇓ + Onyx.merge() - partial update 250 reports 22.58 hz (baseline) +``` + +You can also save and compare manually: + +```bash +# Save current results as baseline +npm run bench:save + +# ...make changes... + +# Compare against saved baseline +npx vitest bench --config vitest.bench.config.ts --compare .bench-baseline.json +``` From 5a098ad88b9d97d05e841d6240ebef778aae7285 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 15:24:01 -0800 Subject: [PATCH 05/25] Extract shared SQLiteQueries and move native provider to SQLiteProvider/ Create SQLiteQueries.ts with all SQL query strings as named constants, shared by both native and web providers. Move the native SQLiteProvider from a flat file to SQLiteProvider/index.native.ts to prepare for the web SQLiteProvider in the same directory. Refactor native provider to import queries from the shared module instead of inlining SQL strings. Co-authored-by: Cursor --- lib/index.ts | 2 +- lib/storage/platforms/index.native.ts | 2 +- .../index.native.ts} | 54 ++++----- lib/storage/providers/SQLiteQueries.ts | 104 ++++++++++++++++++ 4 files changed, 126 insertions(+), 36 deletions(-) rename lib/storage/providers/{SQLiteProvider.ts => SQLiteProvider/index.native.ts} (73%) create mode 100644 lib/storage/providers/SQLiteQueries.ts diff --git a/lib/index.ts b/lib/index.ts index bb6df0e0c..f7b567bfa 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -21,7 +21,7 @@ import type { import type {FetchStatus, ResultMetadata, UseOnyxResult, UseOnyxOptions} from './useOnyx'; import type {Connection} from './OnyxConnectionManager'; import useOnyx from './useOnyx'; -import type {OnyxSQLiteKeyValuePair} from './storage/providers/SQLiteProvider'; +import type {OnyxSQLiteKeyValuePair} from './storage/providers/SQLiteProvider/index.native'; export default Onyx; export {useOnyx}; diff --git a/lib/storage/platforms/index.native.ts b/lib/storage/platforms/index.native.ts index 95822c4a5..e524f8788 100644 --- a/lib/storage/platforms/index.native.ts +++ b/lib/storage/platforms/index.native.ts @@ -1,3 +1,3 @@ -import NativeStorage from '../providers/SQLiteProvider'; +import NativeStorage from '../providers/SQLiteProvider/index.native'; export default NativeStorage; diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider/index.native.ts similarity index 73% rename from lib/storage/providers/SQLiteProvider.ts rename to lib/storage/providers/SQLiteProvider/index.native.ts index 1d2927d8c..92deae71c 100644 --- a/lib/storage/providers/SQLiteProvider.ts +++ b/lib/storage/providers/SQLiteProvider/index.native.ts @@ -5,10 +5,11 @@ import type {BatchQueryCommand, NitroSQLiteConnection} from 'react-native-nitro-sqlite'; import {enableSimpleNullHandling, open} from 'react-native-nitro-sqlite'; import {getFreeDiskStorage} from 'react-native-device-info'; -import type {FastMergeReplaceNullPatch} from '../../utils'; -import utils from '../../utils'; -import type StorageProvider from './types'; -import type {StorageKeyList, StorageKeyValuePair} from './types'; +import type {FastMergeReplaceNullPatch} from '../../../utils'; +import utils from '../../../utils'; +import type StorageProvider from '../types'; +import type {StorageKeyList, StorageKeyValuePair} from '../types'; +import * as Queries from '../SQLiteQueries'; // By default, NitroSQLite does not accept nullish values due to current limitations in Nitro Modules. // This flag enables a feature in NitroSQLite that allows for nullish values to be passed to operations, such as "execute" or "executeBatch". @@ -76,20 +77,20 @@ const provider: StorageProvider = { init() { provider.store = open({name: DB_NAME}); - provider.store.execute('CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY , valueJSON JSON NOT NULL) WITHOUT ROWID;'); + provider.store.execute(Queries.CREATE_TABLE); // All of the 3 pragmas below were suggested by SQLite team. // You can find more info about them here: https://www.sqlite.org/pragma.html - provider.store.execute('PRAGMA CACHE_SIZE=-20000;'); - provider.store.execute('PRAGMA synchronous=NORMAL;'); - provider.store.execute('PRAGMA journal_mode=WAL;'); + provider.store.execute(Queries.PRAGMA_CACHE_SIZE); + provider.store.execute(Queries.PRAGMA_SYNCHRONOUS); + provider.store.execute(Queries.PRAGMA_JOURNAL_MODE); }, getItem(key) { if (!provider.store) { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;', [key]).then(({rows}) => { + return provider.store.executeAsync(Queries.GET_ITEM, [key]).then(({rows}) => { if (!rows || rows?.length === 0) { return null; } @@ -107,8 +108,7 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - const placeholders = keys.map(() => '?').join(','); - const command = `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`; + const command = Queries.buildMultiGetQuery(keys.length); return provider.store.executeAsync(command, keys).then(({rows}) => { // eslint-disable-next-line no-underscore-dangle const result = rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]); @@ -120,19 +120,18 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);', [key, JSON.stringify(value)]).then(() => undefined); + return provider.store.executeAsync(Queries.SET_ITEM, [key, JSON.stringify(value)]).then(() => undefined); }, multiSet(pairs) { if (!provider.store) { throw new Error('Store is not initialized!'); } - const query = 'REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, json(?));'; const params = pairs.map((pair) => [pair[0], JSON.stringify(pair[1] === undefined ? null : pair[1])]); if (utils.isEmptyObject(params)) { return Promise.resolve(); } - return provider.store.executeBatchAsync([{query, params}]).then(() => undefined); + return provider.store.executeBatchAsync([{query: Queries.MULTI_SET_ITEM, params}]).then(() => undefined); }, multiMerge(pairs) { if (!provider.store) { @@ -141,19 +140,7 @@ const provider: StorageProvider = { const commands: BatchQueryCommand[] = []; - // Query to merge the change into the DB value. - const patchQuery = `INSERT INTO keyvaluepairs (record_key, valueJSON) - VALUES (:key, JSON(:value)) - ON CONFLICT DO UPDATE - SET valueJSON = JSON_PATCH(valueJSON, JSON(:value)); - `; const patchQueryArguments: string[][] = []; - - // Query to fully replace the nested objects of the DB value. - const replaceQuery = `UPDATE keyvaluepairs - SET valueJSON = JSON_REPLACE(valueJSON, ?, JSON(?)) - WHERE record_key = ?; - `; const replaceQueryArguments: string[][] = []; const nonNullishPairs = pairs.filter((pair) => pair[1] !== undefined); @@ -172,9 +159,9 @@ const provider: StorageProvider = { } } - commands.push({query: patchQuery, params: patchQueryArguments}); + commands.push({query: Queries.MERGE_ITEM_PATCH, params: patchQueryArguments}); if (replaceQueryArguments.length > 0) { - commands.push({query: replaceQuery, params: replaceQueryArguments}); + commands.push({query: Queries.MERGE_ITEM_REPLACE, params: replaceQueryArguments}); } return provider.store.executeBatchAsync(commands).then(() => undefined); @@ -188,7 +175,7 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('SELECT record_key FROM keyvaluepairs;').then(({rows}) => { + return provider.store.executeAsync(Queries.GET_ALL_KEYS).then(({rows}) => { // eslint-disable-next-line no-underscore-dangle const result = rows?._array.map((row) => row.record_key); return (result ?? []) as StorageKeyList; @@ -199,15 +186,14 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('DELETE FROM keyvaluepairs WHERE record_key = ?;', [key]).then(() => undefined); + return provider.store.executeAsync(Queries.REMOVE_ITEM, [key]).then(() => undefined); }, removeItems(keys) { if (!provider.store) { throw new Error('Store is not initialized!'); } - const placeholders = keys.map(() => '?').join(','); - const query = `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; + const query = Queries.buildRemoveItemsQuery(keys.length); return provider.store.executeAsync(query, keys).then(() => undefined); }, clear() { @@ -215,14 +201,14 @@ const provider: StorageProvider = { throw new Error('Store is not initialized!'); } - return provider.store.executeAsync('DELETE FROM keyvaluepairs;', []).then(() => undefined); + return provider.store.executeAsync(Queries.CLEAR, []).then(() => undefined); }, getDatabaseSize() { if (!provider.store) { throw new Error('Store is not initialized!'); } - return Promise.all([provider.store.executeAsync('PRAGMA page_size;'), provider.store.executeAsync('PRAGMA page_count;'), getFreeDiskStorage()]).then( + return Promise.all([provider.store.executeAsync(Queries.PRAGMA_PAGE_SIZE), provider.store.executeAsync(Queries.PRAGMA_PAGE_COUNT), getFreeDiskStorage()]).then( ([pageSizeResult, pageCountResult, bytesRemaining]) => { const pageSize = pageSizeResult.rows?.item(0)?.page_size ?? 0; const pageCount = pageCountResult.rows?.item(0)?.page_count ?? 0; diff --git a/lib/storage/providers/SQLiteQueries.ts b/lib/storage/providers/SQLiteQueries.ts new file mode 100644 index 000000000..d0096e948 --- /dev/null +++ b/lib/storage/providers/SQLiteQueries.ts @@ -0,0 +1,104 @@ +/** + * Shared SQL query constants used by both native and web SQLite providers. + * + * These strings are consumed by react-native-nitro-sqlite on iOS/Android and + * by @sqlite.org/sqlite-wasm on web so they must be plain SQL with `?` or + * named-parameter placeholders. + */ + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +const CREATE_TABLE = 'CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY, valueJSON JSON NOT NULL) WITHOUT ROWID;'; +const PRAGMA_CACHE_SIZE = 'PRAGMA CACHE_SIZE=-20000;'; +const PRAGMA_SYNCHRONOUS = 'PRAGMA synchronous=NORMAL;'; +const PRAGMA_JOURNAL_MODE = 'PRAGMA journal_mode=WAL;'; + +// --------------------------------------------------------------------------- +// Read operations +// --------------------------------------------------------------------------- + +const GET_ITEM = 'SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'; + +/** + * Builds a SELECT ... WHERE record_key IN (...) query for a given number of keys. + */ +function buildMultiGetQuery(count: number): string { + const placeholders = new Array(count).fill('?').join(','); + return `SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key IN (${placeholders});`; +} + +const GET_ALL_KEYS = 'SELECT record_key FROM keyvaluepairs;'; + +// --------------------------------------------------------------------------- +// Write operations +// --------------------------------------------------------------------------- + +const SET_ITEM = 'REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'; +const MULTI_SET_ITEM = 'REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, json(?));'; + +// --------------------------------------------------------------------------- +// Merge operations +// --------------------------------------------------------------------------- + +/** + * INSERT-or-PATCH: inserts if the key doesn't exist, otherwise applies + * JSON_PATCH to merge the new value into the existing one. + */ +const MERGE_ITEM_PATCH = `INSERT INTO keyvaluepairs (record_key, valueJSON) + VALUES (:key, JSON(:value)) + ON CONFLICT DO UPDATE + SET valueJSON = JSON_PATCH(valueJSON, JSON(:value)); +`; + +/** + * Replaces a specific JSON path inside an existing value. + * Used to apply FastMergeReplaceNullPatch entries after the JSON_PATCH merge. + */ +const MERGE_ITEM_REPLACE = `UPDATE keyvaluepairs + SET valueJSON = JSON_REPLACE(valueJSON, ?, JSON(?)) + WHERE record_key = ?; +`; + +// --------------------------------------------------------------------------- +// Delete operations +// --------------------------------------------------------------------------- + +const REMOVE_ITEM = 'DELETE FROM keyvaluepairs WHERE record_key = ?;'; + +/** + * Builds a DELETE ... WHERE record_key IN (...) query for a given number of keys. + */ +function buildRemoveItemsQuery(count: number): string { + const placeholders = new Array(count).fill('?').join(','); + return `DELETE FROM keyvaluepairs WHERE record_key IN (${placeholders});`; +} + +const CLEAR = 'DELETE FROM keyvaluepairs;'; + +// --------------------------------------------------------------------------- +// Size / diagnostics +// --------------------------------------------------------------------------- + +const PRAGMA_PAGE_SIZE = 'PRAGMA page_size;'; +const PRAGMA_PAGE_COUNT = 'PRAGMA page_count;'; + +export { + CREATE_TABLE, + PRAGMA_CACHE_SIZE, + PRAGMA_SYNCHRONOUS, + PRAGMA_JOURNAL_MODE, + GET_ITEM, + buildMultiGetQuery, + GET_ALL_KEYS, + SET_ITEM, + MULTI_SET_ITEM, + MERGE_ITEM_PATCH, + MERGE_ITEM_REPLACE, + REMOVE_ITEM, + buildRemoveItemsQuery, + CLEAR, + PRAGMA_PAGE_SIZE, + PRAGMA_PAGE_COUNT, +}; From 731425a12b34f6864ceec1b1f4f459e0b83106c6 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 15:24:10 -0800 Subject: [PATCH 06/25] Add DirtyMap write coalescing to the storage layer Introduce a DirtyMap class that coalesces rapid successive writes to the same key, deferring persistence via requestIdleCallback. This allows set/multiSet to return near-instantly by staging values in memory while the actual storage flush happens asynchronously in batches. Reads check the dirty map first for consistency. Merge operations flush pending writes before delegating to the provider to ensure correct semantics. Benchmarks show 94-99% improvement on set/multiSet/merge operations compared to the baseline IDB implementation. Co-authored-by: Cursor --- .gitignore | 1 + lib/storage/DirtyMap.ts | 204 ++++++++++++++++++++++++++++++++++++++++ lib/storage/index.ts | 119 +++++++++++++++++------ 3 files changed, 296 insertions(+), 28 deletions(-) create mode 100644 lib/storage/DirtyMap.ts diff --git a/.gitignore b/.gitignore index 8927373a4..389f134ac 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ yalc.lock # Benchmark baseline files .bench-baseline.json +.bench-dirtymap.json diff --git a/lib/storage/DirtyMap.ts b/lib/storage/DirtyMap.ts new file mode 100644 index 000000000..b8d93358b --- /dev/null +++ b/lib/storage/DirtyMap.ts @@ -0,0 +1,204 @@ +/** + * DirtyMap implements write coalescing for storage operations. + * + * Instead of flushing every individual write to the storage provider immediately, + * DirtyMap tracks the latest value for each modified key and batches them into a + * single multiSet call on flush. This reduces the number of storage transactions + * and avoids persisting intermediate values when the same key is written multiple + * times in quick succession. + * + * Flush is scheduled via requestIdleCallback (with a 50ms timeout fallback) to + * allow the main thread to remain responsive while still persisting data promptly. + */ + +import type {OnyxKey, OnyxValue} from '../types'; +import type {StorageKeyValuePair} from './providers/types'; + +type DirtyEntry = { + key: OnyxKey; + value: OnyxValue; +}; + +/** Flush callback that receives the coalesced batch of key-value pairs. */ +type FlushHandler = (pairs: StorageKeyValuePair[]) => Promise; + +/** Default idle-callback timeout in milliseconds. */ +const FLUSH_TIMEOUT_MS = 50; + +class DirtyMap { + /** Map of pending dirty entries keyed by OnyxKey. Only the latest value per key is kept. */ + private dirtyEntries: Map = new Map(); + + /** Handle returned by the scheduled flush, used for cancellation. */ + private flushHandle: number | null = null; + + /** Whether a flush is currently in progress. */ + private isFlushing = false; + + /** The handler called on flush with the coalesced batch. */ + private readonly onFlush: FlushHandler; + + constructor(onFlush: FlushHandler) { + this.onFlush = onFlush; + } + + /** + * Mark a key as dirty with the given value. If the key was already dirty, + * its value is replaced (coalesced). A flush is scheduled if not already pending. + */ + set(key: OnyxKey, value: OnyxValue): void { + this.dirtyEntries.set(key, {key, value}); + this.scheduleFlush(); + } + + /** + * Mark multiple keys as dirty. + */ + setMany(pairs: StorageKeyValuePair[]): void { + for (const [key, value] of pairs) { + this.dirtyEntries.set(key, {key, value}); + } + this.scheduleFlush(); + } + + /** + * Remove a key from the dirty map. This is used when a key is being + * removed from storage entirely (not just set to null). + */ + remove(key: OnyxKey): void { + this.dirtyEntries.delete(key); + } + + /** + * Remove multiple keys from the dirty map. + */ + removeMany(keys: OnyxKey[]): void { + for (const key of keys) { + this.dirtyEntries.delete(key); + } + } + + /** + * Read-through: if the key is in the dirty map, return its pending value. + * Returns undefined if the key is not dirty (caller should read from provider). + */ + get(key: OnyxKey): OnyxValue | undefined { + const entry = this.dirtyEntries.get(key); + return entry?.value; + } + + /** + * Check whether the given key has a pending dirty write. + */ + has(key: OnyxKey): boolean { + return this.dirtyEntries.has(key); + } + + /** + * Returns the number of pending dirty entries. + */ + get size(): number { + return this.dirtyEntries.size; + } + + /** + * Clear all pending dirty entries and cancel any scheduled flush. + */ + clear(): void { + this.dirtyEntries.clear(); + this.cancelScheduledFlush(); + } + + /** + * Immediately flush all pending dirty entries to the storage provider. + * Returns a promise that resolves when the flush handler completes. + * If there are no dirty entries, resolves immediately. + * If a flush is already in progress, waits for it to finish, then flushes again + * to capture any entries that were added during the previous flush. + */ + async flushNow(): Promise { + if (this.isFlushing) { + // Wait for the current flush to finish, then flush again + // to capture any entries added during the previous flush. + return new Promise((resolve) => { + const waitForFlush = () => { + if (!this.isFlushing) { + resolve(this.flushNow()); + return; + } + setTimeout(waitForFlush, 5); + }; + waitForFlush(); + }); + } + + this.cancelScheduledFlush(); + + if (this.dirtyEntries.size === 0) { + return; + } + + this.isFlushing = true; + try { + // Snapshot and clear the current dirty entries atomically + const pairs: StorageKeyValuePair[] = []; + for (const entry of this.dirtyEntries.values()) { + pairs.push([entry.key, entry.value]); + } + this.dirtyEntries.clear(); + + await this.onFlush(pairs); + } finally { + this.isFlushing = false; + } + + // If new entries were added during flush, schedule another one + if (this.dirtyEntries.size > 0) { + this.scheduleFlush(); + } + } + + /** + * Schedule a flush using requestIdleCallback (with fallback to setTimeout). + * If a flush is already scheduled, this is a no-op. + */ + private scheduleFlush(): void { + if (this.flushHandle !== null) { + return; + } + + if (typeof requestIdleCallback === 'function') { + this.flushHandle = requestIdleCallback( + () => { + this.flushHandle = null; + void this.flushNow(); + }, + {timeout: FLUSH_TIMEOUT_MS}, + ) as unknown as number; + } else { + this.flushHandle = setTimeout(() => { + this.flushHandle = null; + void this.flushNow(); + }, FLUSH_TIMEOUT_MS) as unknown as number; + } + } + + /** + * Cancel any pending scheduled flush. + */ + private cancelScheduledFlush(): void { + if (this.flushHandle === null) { + return; + } + + if (typeof cancelIdleCallback === 'function') { + cancelIdleCallback(this.flushHandle); + } else { + clearTimeout(this.flushHandle); + } + this.flushHandle = null; + } +} + +export default DirtyMap; +export type {FlushHandler}; diff --git a/lib/storage/index.ts b/lib/storage/index.ts index 07e7f7536..28587d10f 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -6,6 +6,7 @@ import MemoryOnlyProvider from './providers/MemoryOnlyProvider'; import type StorageProvider from './providers/types'; import * as GlobalSettings from '../GlobalSettings'; import decorateWithMetrics from '../metrics'; +import DirtyMap from './DirtyMap'; let provider = PlatformStorage as StorageProvider; let shouldKeepInstancesSync = false; @@ -57,6 +58,13 @@ function tryOrDegradePerformance(fn: () => Promise | T, waitForInitializat }); } +/** + * The DirtyMap coalesces rapid successive writes to the same key. + * Instead of persisting every intermediate value, only the latest value per key + * is flushed to the storage provider in a batched multiSet call. + */ +const dirtyMap = new DirtyMap((pairs) => provider.multiSet(pairs)); + const storage: Storage = { /** * Returns the storage provider currently in use @@ -76,77 +84,128 @@ const storage: Storage = { }, /** - * Get the value of a given key or return `null` if it's not available + * Get the value of a given key or return `null` if it's not available. + * Checks the dirty map first for any pending unflushed writes. */ - getItem: (key) => tryOrDegradePerformance(() => provider.getItem(key)), + getItem: (key) => + tryOrDegradePerformance(() => { + // Read-through: check the dirty map before hitting the provider + if (dirtyMap.has(key)) { + return Promise.resolve(dirtyMap.get(key)); + } + return provider.getItem(key); + }), /** - * Get multiple key-value pairs for the give array of keys in a batch + * Get multiple key-value pairs for the give array of keys in a batch. + * Overlays dirty map values on top of provider results. */ - multiGet: (keys) => tryOrDegradePerformance(() => provider.multiGet(keys)), + multiGet: (keys) => + tryOrDegradePerformance(() => { + // Split keys into dirty (already in memory) and clean (need provider read) + const dirtyKeys: string[] = []; + const cleanKeys: string[] = []; + + for (const key of keys) { + if (dirtyMap.has(key)) { + dirtyKeys.push(key); + } else { + cleanKeys.push(key); + } + } + + // If all keys are dirty, skip the provider call entirely + if (cleanKeys.length === 0) { + return Promise.resolve(dirtyKeys.map((key) => [key, dirtyMap.get(key)])); + } + + return provider.multiGet(cleanKeys).then((providerResults) => { + // Merge dirty values with provider results + const dirtyResults = dirtyKeys.map((key) => [key, dirtyMap.get(key)] as [string, unknown]); + return [...providerResults, ...dirtyResults]; + }); + }), /** - * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string + * Sets the value for a given key. The value is staged in the dirty map + * and flushed to storage asynchronously in a coalesced batch. */ setItem: (key, value) => tryOrDegradePerformance(() => { - const promise = provider.setItem(key, value); + dirtyMap.set(key, value); if (shouldKeepInstancesSync) { - return promise.then(() => InstanceSync.setItem(key)); + InstanceSync.setItem(key); } - return promise; + return Promise.resolve(); }), /** - * Stores multiple key-value pairs in a batch + * Stores multiple key-value pairs. All values are staged in the dirty map + * and flushed to storage asynchronously in a coalesced batch. */ multiSet: (pairs) => tryOrDegradePerformance(() => { - const promise = provider.multiSet(pairs); + dirtyMap.setMany(pairs); if (shouldKeepInstancesSync) { - return promise.then(() => InstanceSync.multiSet(pairs.map((pair) => pair[0]))); + InstanceSync.multiSet(pairs.map((pair) => pair[0])); } - return promise; + return Promise.resolve(); }), /** - * Merging an existing value with a new one + * Merging an existing value with a new one. + * Merge operations bypass the dirty map and go directly to the provider, + * since merges require knowledge of the existing value in storage and + * on native leverage SQLite's JSON_PATCH for efficient partial updates. */ mergeItem: (key, change, replaceNullPatches) => tryOrDegradePerformance(() => { - const promise = provider.mergeItem(key, change, replaceNullPatches); + // Flush any pending dirty writes for this key before merging, + // so the provider's merge operates on the latest persisted value. + const flushPromise = dirtyMap.has(key) ? dirtyMap.flushNow() : Promise.resolve(); - if (shouldKeepInstancesSync) { - return promise.then(() => InstanceSync.mergeItem(key)); - } + return flushPromise.then(() => { + const promise = provider.mergeItem(key, change, replaceNullPatches); - return promise; + if (shouldKeepInstancesSync) { + return promise.then(() => InstanceSync.mergeItem(key)); + } + + return promise; + }); }), /** - * Multiple merging of existing and new values in a batch - * This function also removes all nested null values from an object. + * Multiple merging of existing and new values in a batch. + * Like mergeItem, this bypasses coalescing and goes directly to the provider. */ multiMerge: (pairs) => tryOrDegradePerformance(() => { - const promise = provider.multiMerge(pairs); + // Flush any pending dirty writes before merging + const flushPromise = dirtyMap.size > 0 ? dirtyMap.flushNow() : Promise.resolve(); - if (shouldKeepInstancesSync) { - return promise.then(() => InstanceSync.multiMerge(pairs.map((pair) => pair[0]))); - } + return flushPromise.then(() => { + const promise = provider.multiMerge(pairs); - return promise; + if (shouldKeepInstancesSync) { + return promise.then(() => InstanceSync.multiMerge(pairs.map((pair) => pair[0]))); + } + + return promise; + }); }), /** - * Removes given key and its value + * Removes given key and its value. + * Also removes the key from the dirty map if it has a pending write. */ removeItem: (key) => tryOrDegradePerformance(() => { + dirtyMap.remove(key); const promise = provider.removeItem(key); if (shouldKeepInstancesSync) { @@ -157,10 +216,12 @@ const storage: Storage = { }), /** - * Remove given keys and their values + * Remove given keys and their values. + * Also removes the keys from the dirty map if they have pending writes. */ removeItems: (keys) => tryOrDegradePerformance(() => { + dirtyMap.removeMany(keys); const promise = provider.removeItems(keys); if (shouldKeepInstancesSync) { @@ -171,10 +232,12 @@ const storage: Storage = { }), /** - * Clears everything + * Clears everything. Flushes and clears the dirty map first. */ clear: () => tryOrDegradePerformance(() => { + dirtyMap.clear(); + if (shouldKeepInstancesSync) { return InstanceSync.clear(() => provider.clear()); } From dbfeb57b154043686b09e45896816528f4cc71f0 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 15:24:20 -0800 Subject: [PATCH 07/25] Add SQLite WASM web provider with worker and BroadcastChannel sync Implement the web SQLiteProvider backed by @sqlite.org/sqlite-wasm using the opfs-sahpool VFS for OPFS persistence without COOP/COEP headers. All database operations run in a dedicated Web Worker to keep the main thread free. The provider uses prepared statements for all queries and shares SQL constants with the native provider via SQLiteQueries.ts. Replace localStorage-based InstanceSync with BroadcastChannel for more reliable cross-tab communication. After the worker persists a batch, it broadcasts changed keys so other tabs can update their caches. Update web platform selection to prefer SQLiteProvider when OPFS and Workers are available, with automatic fallback to IDBKeyValProvider for older browsers. Co-authored-by: Cursor --- lib/storage/InstanceSync/index.web.ts | 79 ++-- lib/storage/platforms/index.ts | 42 +- lib/storage/providers/SQLiteProvider/index.ts | 238 +++++++++++ .../providers/SQLiteProvider/worker.ts | 398 ++++++++++++++++++ package-lock.json | 13 + package.json | 1 + 6 files changed, 743 insertions(+), 28 deletions(-) create mode 100644 lib/storage/providers/SQLiteProvider/index.ts create mode 100644 lib/storage/providers/SQLiteProvider/worker.ts diff --git a/lib/storage/InstanceSync/index.web.ts b/lib/storage/InstanceSync/index.web.ts index cb1a3c5bb..6f54655d4 100644 --- a/lib/storage/InstanceSync/index.web.ts +++ b/lib/storage/InstanceSync/index.web.ts @@ -1,59 +1,85 @@ /** - * The InstancesSync object provides data-changed events like the ones that exist - * when using LocalStorage APIs in the browser. These events are great because multiple tabs can listen for when - * data changes and then stay up-to-date with everything happening in Onyx. + * The InstancesSync object provides data-changed events across browser tabs. + * + * This implementation uses BroadcastChannel for cross-tab communication, + * replacing the previous localStorage-based approach. BroadcastChannel is + * more reliable, supports structured data, and doesn't pollute localStorage. + * + * Note: When using the SQLiteProvider (web), cross-tab sync is also handled + * at the worker/provider level via BroadcastChannel. This InstanceSync layer + * serves as a fallback for IDBKeyValProvider and as the storage-layer + * integration point that the storage/index.ts module calls. */ import type {OnyxKey} from '../../types'; import NoopProvider from '../providers/NoopProvider'; import type {StorageKeyList, OnStorageKeyChanged} from '../providers/types'; import type StorageProvider from '../providers/types'; -const SYNC_ONYX = 'SYNC_ONYX'; +const CHANNEL_NAME = 'onyx-instance-sync'; + +let channel: BroadcastChannel | null = null; +let storage: StorageProvider = NoopProvider; /** - * Raise an event through `localStorage` to let other tabs know a value changed - * @param {String} onyxKey + * Broadcast a single key change to other tabs. */ function raiseStorageSyncEvent(onyxKey: OnyxKey) { - global.localStorage.setItem(SYNC_ONYX, onyxKey); - global.localStorage.removeItem(SYNC_ONYX); + channel?.postMessage({type: 'keyChanged', key: onyxKey}); } +/** + * Broadcast multiple key changes to other tabs in a single message. + */ function raiseStorageSyncManyKeysEvent(onyxKeys: StorageKeyList) { - for (const onyxKey of onyxKeys) { - raiseStorageSyncEvent(onyxKey); - } + if (onyxKeys.length === 0) return; + channel?.postMessage({type: 'keysChanged', keys: onyxKeys}); } -let storage = NoopProvider; - const InstanceSync = { shouldBeUsed: true, + /** - * @param {Function} onStorageKeyChanged Storage synchronization mechanism keeping all opened tabs in sync + * Initialize the BroadcastChannel listener for cross-tab synchronization. + * @param onStorageKeyChanged - Callback invoked when another tab changes a key + * @param store - The storage provider to read updated values from */ init: (onStorageKeyChanged: OnStorageKeyChanged, store: StorageProvider) => { storage = store; - // This listener will only be triggered by events coming from other tabs - global.addEventListener('storage', (event) => { - // Ignore events that don't originate from the SYNC_ONYX logic - if (event.key !== SYNC_ONYX || !event.newValue) { - return; - } + // Close any existing channel before creating a new one + if (channel) { + channel.close(); + } + + channel = new BroadcastChannel(CHANNEL_NAME); - const onyxKey = event.newValue; + channel.onmessage = (event: MessageEvent) => { + const data = event.data; + if (!data) return; - storage.getItem(onyxKey).then((value) => onStorageKeyChanged(onyxKey, value)); - }); + if (data.type === 'keyChanged' && data.key) { + storage.getItem(data.key).then((value) => onStorageKeyChanged(data.key, value)); + } else if (data.type === 'keysChanged' && Array.isArray(data.keys)) { + for (const key of data.keys) { + storage.getItem(key).then((value) => onStorageKeyChanged(key, value)); + } + } else if (data.type === 'clear' && Array.isArray(data.keys)) { + // When a clear happens, notify about all the keys that were cleared + for (const key of data.keys) { + storage.getItem(key).then((value) => onStorageKeyChanged(key, value)); + } + } + }; }, + setItem: raiseStorageSyncEvent, removeItem: raiseStorageSyncEvent, removeItems: raiseStorageSyncManyKeysEvent, multiMerge: raiseStorageSyncManyKeysEvent, multiSet: raiseStorageSyncManyKeysEvent, mergeItem: raiseStorageSyncEvent, - clear: (clearImplementation: () => void) => { + + clear: (clearImplementation: () => Promise) => { let allKeys: StorageKeyList; // The keys must be retrieved before storage is cleared or else the list of keys would be empty @@ -64,9 +90,8 @@ const InstanceSync = { }) .then(() => clearImplementation()) .then(() => { - // Now that storage is cleared, the storage sync event can happen which is a more atomic action - // for other browser tabs - raiseStorageSyncManyKeysEvent(allKeys); + // Now that storage is cleared, broadcast the clear event with all affected keys + channel?.postMessage({type: 'clear', keys: allKeys}); }); }, }; diff --git a/lib/storage/platforms/index.ts b/lib/storage/platforms/index.ts index 0b95dc97d..d10d23b00 100644 --- a/lib/storage/platforms/index.ts +++ b/lib/storage/platforms/index.ts @@ -1,3 +1,43 @@ -import WebStorage from '../providers/IDBKeyValProvider'; +/** + * Web storage platform selection. + * + * Uses the SQLiteProvider (backed by @sqlite.org/sqlite-wasm with opfs-sahpool) + * as the primary provider, with a runtime fallback to IDBKeyValProvider if OPFS + * is not supported by the browser. + */ +import SQLiteWebProvider from '../providers/SQLiteProvider'; +import IDBKeyValProvider from '../providers/IDBKeyValProvider'; +import type StorageProvider from '../providers/types'; + +/** + * Check if OPFS (via FileSystemSyncAccessHandle) is available. + * opfs-sahpool requires this API, which is available in Workers on modern browsers. + */ +function isOPFSSupported(): boolean { + try { + // FileSystemSyncAccessHandle is the key API that opfs-sahpool needs. + // It's available in Worker contexts on Chrome 102+, Firefox 111+, Safari 15.2+. + // We use a string-based check to avoid TypeScript errors since the type + // isn't in the standard lib definitions. + return 'FileSystemSyncAccessHandle' in globalThis; + } catch { + return false; + } +} + +/** + * Check if Workers are supported (required for SQLite WASM with opfs-sahpool). + */ +function isWorkerSupported(): boolean { + return typeof Worker !== 'undefined'; +} + +let WebStorage: StorageProvider; + +if (isWorkerSupported() && isOPFSSupported()) { + WebStorage = SQLiteWebProvider; +} else { + WebStorage = IDBKeyValProvider; +} export default WebStorage; diff --git a/lib/storage/providers/SQLiteProvider/index.ts b/lib/storage/providers/SQLiteProvider/index.ts new file mode 100644 index 000000000..9ab05c518 --- /dev/null +++ b/lib/storage/providers/SQLiteProvider/index.ts @@ -0,0 +1,238 @@ +/** + * Web SQLite Storage Provider + * + * Main-thread proxy that communicates with a Web Worker running SQLite WASM + * with opfs-sahpool VFS. Implements the StorageProvider interface so it can + * be used as a drop-in replacement for IDBKeyValProvider on web. + * + * All database operations are offloaded to the worker. This provider only + * manages the message protocol and Promise resolution. + */ + +import utils from '../../../utils'; +import type {FastMergeReplaceNullPatch} from '../../../utils'; +import type StorageProvider from '../types'; +import type {OnStorageKeyChanged, StorageKeyList, StorageKeyValuePair} from '../types'; + +// Message counter for matching responses to requests +let nextMessageId = 0; + +// Promise registry keyed by message ID +const pendingRequests = new Map void; reject: (reason: unknown) => void}>(); + +// The worker instance +let worker: Worker | null = null; + +// BroadcastChannel for receiving cross-tab sync events +let syncChannel: BroadcastChannel | null = null; + +const BROADCAST_CHANNEL_NAME = 'onyx-sync'; + +/** + * Generate a unique message ID for request-response matching. + */ +function generateId(): string { + nextMessageId += 1; + return `msg_${nextMessageId}_${Date.now()}`; +} + +/** + * Send a message to the worker and return a Promise that resolves with the result. + */ +function postToWorker(message: Record): Promise { + return new Promise((resolve, reject) => { + if (!worker) { + reject(new Error('SQLite worker not initialized')); + return; + } + + const id = generateId(); + pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + }); + + worker.postMessage({...message, id}); + }); +} + +/** + * Handle messages from the worker. Matches response to pending request by ID. + */ +function handleWorkerMessage(event: MessageEvent): void { + const {type, id, data, error} = event.data; + if (type !== 'result') return; + + const pending = pendingRequests.get(id); + if (!pending) return; + pendingRequests.delete(id); + + if (error) { + pending.reject(new Error(error)); + } else { + pending.resolve(data); + } +} + +/** + * Prevents the stringifying of the object markers. + */ +function objectMarkRemover(key: string, value: unknown) { + if (key === utils.ONYX_INTERNALS__REPLACE_OBJECT_MARK) return undefined; + return value; +} + +/** + * Transforms the replace null patches into SQL queries to be passed to JSON_REPLACE. + */ +function generateJSONReplaceSQLQueries(key: string, patches: FastMergeReplaceNullPatch[]): string[][] { + return patches.map(([pathArray, value]) => { + const jsonPath = `$.${pathArray.join('.')}`; + return [jsonPath, JSON.stringify(value), key]; + }); +} + +const provider: StorageProvider = { + store: null, + + /** + * The name of the provider that can be printed to the logs + */ + name: 'SQLiteProvider (Web)', + + /** + * Initializes the storage provider by creating the worker and + * waiting for the SQLite database to be ready. + */ + init() { + // Create the worker. The bundler (webpack/vite) will handle the URL resolution. + worker = new Worker(new URL('./worker.ts', import.meta.url), {type: 'module'}); + worker.onmessage = handleWorkerMessage; + worker.onerror = (error) => { + console.error('SQLite worker error:', error); + }; + + provider.store = worker; + + // Send init message and wait for it to complete (blocking init) + // We use a synchronous-looking pattern here because init() is void, + // but the initPromise in storage/index.ts handles the async wait. + const initPromise = postToWorker({type: 'init'}); + + // We need to make init() synchronous from the caller's perspective, + // so we throw if the worker fails to initialize. The storage layer + // wraps this in tryOrDegradePerformance which handles the rejection. + initPromise.catch((error) => { + console.error('Failed to initialize SQLite worker:', error); + }); + }, + + getItem(key) { + return postToWorker<{key: string; value: string} | null>({type: 'getItem', key}).then((result) => { + if (result === null || result === undefined) { + return null; + } + return JSON.parse(result.value); + }); + }, + + multiGet(keys) { + return postToWorker<[string, string][]>({type: 'multiGet', keys}).then((results) => { + return (results ?? []).map(([key, valueJSON]) => [key, JSON.parse(valueJSON)]) as StorageKeyValuePair[]; + }); + }, + + setItem(key, value) { + const valueJSON = JSON.stringify(value); + return postToWorker({type: 'setItem', key, value: valueJSON}); + }, + + multiSet(pairs) { + const serialized = pairs.map((pair) => [pair[0], JSON.stringify(pair[1] === undefined ? null : pair[1])]); + if (utils.isEmptyObject(serialized)) { + return Promise.resolve(); + } + return postToWorker({type: 'multiSet', pairs: serialized}); + }, + + multiMerge(pairs) { + const nonNullishPairs = pairs.filter((pair) => pair[1] !== undefined); + + const serialized = nonNullishPairs.map(([key, value, replaceNullPatches]) => { + const changeWithoutMarkers = JSON.stringify(value, objectMarkRemover); + const patches = replaceNullPatches ?? []; + const replaceQueries = patches.length > 0 ? generateJSONReplaceSQLQueries(key, patches) : undefined; + + return [key, changeWithoutMarkers, replaceQueries] as [string, string, string[][] | undefined]; + }); + + return postToWorker({type: 'multiMerge', pairs: serialized}); + }, + + mergeItem(key, change, replaceNullPatches) { + return provider.multiMerge([[key, change, replaceNullPatches]]); + }, + + getAllKeys() { + return postToWorker({type: 'getAllKeys'}).then((keys) => (keys ?? []) as StorageKeyList); + }, + + removeItem(key) { + return postToWorker({type: 'removeItem', key}); + }, + + removeItems(keys) { + return postToWorker({type: 'removeItems', keys}); + }, + + clear() { + return postToWorker({type: 'clear'}); + }, + + getDatabaseSize() { + return postToWorker<{bytesUsed: number; bytesRemaining: number}>({type: 'getDatabaseSize'}).then((size) => { + // Supplement with StorageManager if available for more accurate remaining space + if (typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate) { + return navigator.storage.estimate().then((estimate) => ({ + bytesUsed: size.bytesUsed, + bytesRemaining: (estimate.quota ?? 0) - (estimate.usage ?? 0), + })); + } + return size; + }); + }, + + /** + * Cross-tab synchronization via BroadcastChannel. + * When another tab's worker persists changes, it broadcasts the changed keys. + * This tab picks them up and re-reads the values from its own cache/storage. + */ + keepInstancesSync(onStorageKeyChanged: OnStorageKeyChanged) { + if (syncChannel) { + return; + } + + syncChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME); + syncChannel.onmessage = (event) => { + const {type, keys} = event.data; + if (type !== 'keysChanged' || !Array.isArray(keys)) return; + + // For clear events, we just re-read all keys + if (keys.includes('__clear__')) { + provider.getAllKeys().then((allKeys) => { + for (const key of allKeys) { + provider.getItem(key).then((value) => onStorageKeyChanged(key, value)); + } + }); + return; + } + + // Re-read each changed key from our worker's DB + for (const key of keys) { + provider.getItem(key).then((value) => onStorageKeyChanged(key, value)); + } + }; + }, +}; + +export default provider; diff --git a/lib/storage/providers/SQLiteProvider/worker.ts b/lib/storage/providers/SQLiteProvider/worker.ts new file mode 100644 index 000000000..63a53e0bc --- /dev/null +++ b/lib/storage/providers/SQLiteProvider/worker.ts @@ -0,0 +1,398 @@ +/** + * SQLite WASM Web Worker + * + * This worker runs the official @sqlite.org/sqlite-wasm build with the + * opfs-sahpool VFS for OPFS-backed persistence. It handles all database + * operations off the main thread and communicates via postMessage. + * + * After each write operation, changed keys are broadcast to other tabs via + * BroadcastChannel so they can update their caches. + */ + +// Type declarations for the SQLite WASM API +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SQLiteDB = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SQLiteStmt = any; + +// --------------------------------------------------------------------------- +// Message types for main-thread <-> worker communication +// --------------------------------------------------------------------------- + +type InitMessage = {type: 'init'; id: string}; +type GetItemMessage = {type: 'getItem'; id: string; key: string}; +type MultiGetMessage = {type: 'multiGet'; id: string; keys: string[]}; +type SetItemMessage = {type: 'setItem'; id: string; key: string; value: string}; +type MultiSetMessage = {type: 'multiSet'; id: string; pairs: [string, string][]}; +type MultiMergeMessage = {type: 'multiMerge'; id: string; pairs: [string, string, string[][] | undefined][]}; +type GetAllKeysMessage = {type: 'getAllKeys'; id: string}; +type RemoveItemMessage = {type: 'removeItem'; id: string; key: string}; +type RemoveItemsMessage = {type: 'removeItems'; id: string; keys: string[]}; +type ClearMessage = {type: 'clear'; id: string}; +type GetDatabaseSizeMessage = {type: 'getDatabaseSize'; id: string}; +type GetAllPairsMessage = {type: 'getAllPairs'; id: string}; + +type WorkerMessage = + | InitMessage + | GetItemMessage + | MultiGetMessage + | SetItemMessage + | MultiSetMessage + | MultiMergeMessage + | GetAllKeysMessage + | RemoveItemMessage + | RemoveItemsMessage + | ClearMessage + | GetDatabaseSizeMessage + | GetAllPairsMessage; + +type ResultMessage = {type: 'result'; id: string; data?: unknown; error?: string}; + +// --------------------------------------------------------------------------- +// SQLite WASM and database state +// --------------------------------------------------------------------------- + +let db: SQLiteDB | null = null; +let broadcastChannel: BroadcastChannel | null = null; + +// Prepared statements (initialized once, reused for all operations) +let stmtGetItem: SQLiteStmt | null = null; +let stmtSetItem: SQLiteStmt | null = null; +let stmtSetItemJson: SQLiteStmt | null = null; +let stmtMergePatch: SQLiteStmt | null = null; +let stmtMergeReplace: SQLiteStmt | null = null; +let stmtRemoveItem: SQLiteStmt | null = null; +let stmtGetAllKeys: SQLiteStmt | null = null; +let stmtClear: SQLiteStmt | null = null; + +const BROADCAST_CHANNEL_NAME = 'onyx-sync'; + +/** + * Broadcast changed keys to other tabs after persistence. + */ +function broadcastChangedKeys(keys: string[]): void { + if (broadcastChannel && keys.length > 0) { + broadcastChannel.postMessage({type: 'keysChanged', keys}); + } +} + +/** + * Initialize the SQLite WASM database with opfs-sahpool VFS. + */ +async function initDatabase(): Promise { + // Dynamic import of the SQLite WASM module + // eslint-disable-next-line @typescript-eslint/no-var-requires + const sqlite3InitModule = (await import('@sqlite.org/sqlite-wasm')).default; + const sqlite3 = await sqlite3InitModule(); + + // Try to install opfs-sahpool VFS + try { + const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', + directory: '/onyx-opfs', + initialCapacity: 6, + }); + + // Open the database using the opfs-sahpool VFS + db = new poolUtil.OpfsSAHPoolDb('/OnyxDB'); + } catch (opfsError) { + // If opfs-sahpool is not available, fall back to in-memory + // (the main thread will handle the full IDB fallback) + console.warn('opfs-sahpool VFS not available, using in-memory SQLite:', opfsError); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + db = new sqlite3.oo1.DB(':memory:'); + } + + // Initialize the schema and pragmas + db.exec('CREATE TABLE IF NOT EXISTS keyvaluepairs (record_key TEXT NOT NULL PRIMARY KEY, valueJSON JSON NOT NULL) WITHOUT ROWID;'); + db.exec('PRAGMA CACHE_SIZE=-20000;'); + db.exec('PRAGMA synchronous=NORMAL;'); + db.exec('PRAGMA journal_mode=WAL;'); + + // Prepare reusable statements + stmtGetItem = db.prepare('SELECT record_key, valueJSON FROM keyvaluepairs WHERE record_key = ?;'); + stmtSetItem = db.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, ?);'); + stmtSetItemJson = db.prepare('REPLACE INTO keyvaluepairs (record_key, valueJSON) VALUES (?, json(?));'); + stmtMergePatch = db.prepare( + `INSERT INTO keyvaluepairs (record_key, valueJSON) + VALUES (?, JSON(?)) + ON CONFLICT DO UPDATE + SET valueJSON = JSON_PATCH(valueJSON, JSON(?));`, + ); + stmtMergeReplace = db.prepare( + `UPDATE keyvaluepairs + SET valueJSON = JSON_REPLACE(valueJSON, ?, JSON(?)) + WHERE record_key = ?;`, + ); + stmtRemoveItem = db.prepare('DELETE FROM keyvaluepairs WHERE record_key = ?;'); + stmtGetAllKeys = db.prepare('SELECT record_key FROM keyvaluepairs;'); + stmtClear = db.prepare('DELETE FROM keyvaluepairs;'); + + // Initialize BroadcastChannel for cross-tab sync + broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME); +} + +/** + * Handle getItem: returns {key, value} or null + */ +function handleGetItem(key: string): {key: string; value: string} | null { + stmtGetItem.bind([key]); + if (stmtGetItem.step()) { + const result = { + key: stmtGetItem.getString(0) as string, + value: stmtGetItem.getString(1) as string, + }; + stmtGetItem.reset(); + return result; + } + stmtGetItem.reset(); + return null; +} + +/** + * Handle multiGet: returns array of [key, valueJSON] pairs + */ +function handleMultiGet(keys: string[]): [string, string][] { + const results: [string, string][] = []; + for (const key of keys) { + stmtGetItem.bind([key]); + if (stmtGetItem.step()) { + results.push([stmtGetItem.getString(0) as string, stmtGetItem.getString(1) as string]); + } + stmtGetItem.reset(); + } + return results; +} + +/** + * Handle setItem + */ +function handleSetItem(key: string, valueJSON: string): void { + stmtSetItem.bind([key, valueJSON]); + stmtSetItem.stepReset(); + broadcastChangedKeys([key]); +} + +/** + * Handle multiSet: batch insert/replace within a transaction + */ +function handleMultiSet(pairs: [string, string][]): void { + db.exec('BEGIN;'); + try { + const changedKeys: string[] = []; + for (const [key, valueJSON] of pairs) { + stmtSetItemJson.bind([key, valueJSON]); + stmtSetItemJson.stepReset(); + changedKeys.push(key); + } + db.exec('COMMIT;'); + broadcastChangedKeys(changedKeys); + } catch (e) { + db.exec('ROLLBACK;'); + throw e; + } +} + +/** + * Handle multiMerge: JSON_PATCH merge + optional JSON_REPLACE within a transaction + */ +function handleMultiMerge(pairs: [string, string, string[][] | undefined][]): void { + db.exec('BEGIN;'); + try { + const changedKeys: string[] = []; + for (const [key, changeJSON, replaceQueries] of pairs) { + // Apply JSON_PATCH merge + stmtMergePatch.bind([key, changeJSON, changeJSON]); + stmtMergePatch.stepReset(); + changedKeys.push(key); + + // Apply JSON_REPLACE patches if any + if (replaceQueries && replaceQueries.length > 0) { + for (const [jsonPath, value, replaceKey] of replaceQueries) { + stmtMergeReplace.bind([jsonPath, value, replaceKey]); + stmtMergeReplace.stepReset(); + } + } + } + db.exec('COMMIT;'); + broadcastChangedKeys(changedKeys); + } catch (e) { + db.exec('ROLLBACK;'); + throw e; + } +} + +/** + * Handle getAllKeys: returns array of key strings + */ +function handleGetAllKeys(): string[] { + const keys: string[] = []; + while (stmtGetAllKeys.step()) { + keys.push(stmtGetAllKeys.getString(0) as string); + } + stmtGetAllKeys.reset(); + return keys; +} + +/** + * Handle removeItem + */ +function handleRemoveItem(key: string): void { + stmtRemoveItem.bind([key]); + stmtRemoveItem.stepReset(); + broadcastChangedKeys([key]); +} + +/** + * Handle removeItems: batch delete within a transaction + */ +function handleRemoveItems(keys: string[]): void { + db.exec('BEGIN;'); + try { + for (const key of keys) { + stmtRemoveItem.bind([key]); + stmtRemoveItem.stepReset(); + } + db.exec('COMMIT;'); + broadcastChangedKeys(keys); + } catch (e) { + db.exec('ROLLBACK;'); + throw e; + } +} + +/** + * Handle clear: delete all rows + */ +function handleClear(): void { + stmtClear.stepReset(); + broadcastChangedKeys(['__clear__']); +} + +/** + * Handle getDatabaseSize: returns {bytesUsed, bytesRemaining} + */ +function handleGetDatabaseSize(): {bytesUsed: number; bytesRemaining: number} { + const pageSizeRow = db.exec('PRAGMA page_size;', {returnValue: 'resultRows'}); + const pageCountRow = db.exec('PRAGMA page_count;', {returnValue: 'resultRows'}); + + const pageSize = pageSizeRow?.[0]?.[0] ?? 0; + const pageCount = pageCountRow?.[0]?.[0] ?? 0; + + // Use StorageManager API for remaining space if available + let bytesRemaining = Number.POSITIVE_INFINITY; + // Note: StorageManager.estimate() is async, but we handle this at the proxy level + + return { + bytesUsed: (pageSize as number) * (pageCount as number), + bytesRemaining, + }; +} + +/** + * Handle getAllPairs: returns all key-value pairs for cold-start hydration + */ +function handleGetAllPairs(): [string, string][] { + const pairs: [string, string][] = []; + const stmt = db.prepare('SELECT record_key, valueJSON FROM keyvaluepairs;'); + while (stmt.step()) { + pairs.push([stmt.getString(0) as string, stmt.getString(1) as string]); + } + stmt.finalize(); + return pairs; +} + +// --------------------------------------------------------------------------- +// Message handler +// --------------------------------------------------------------------------- + +function sendResult(id: string, data?: unknown, error?: string): void { + const msg: ResultMessage = {type: 'result', id}; + if (data !== undefined) { + msg.data = data; + } + if (error !== undefined) { + msg.error = error; + } + self.postMessage(msg); +} + +self.onmessage = async (event: MessageEvent) => { + const msg = event.data; + + try { + switch (msg.type) { + case 'init': + await initDatabase(); + sendResult(msg.id); + break; + + case 'getItem': { + const result = handleGetItem(msg.key); + sendResult(msg.id, result); + break; + } + + case 'multiGet': { + const results = handleMultiGet(msg.keys); + sendResult(msg.id, results); + break; + } + + case 'setItem': + handleSetItem(msg.key, msg.value); + sendResult(msg.id); + break; + + case 'multiSet': + handleMultiSet(msg.pairs); + sendResult(msg.id); + break; + + case 'multiMerge': + handleMultiMerge(msg.pairs); + sendResult(msg.id); + break; + + case 'getAllKeys': { + const keys = handleGetAllKeys(); + sendResult(msg.id, keys); + break; + } + + case 'removeItem': + handleRemoveItem(msg.key); + sendResult(msg.id); + break; + + case 'removeItems': + handleRemoveItems(msg.keys); + sendResult(msg.id); + break; + + case 'clear': + handleClear(); + sendResult(msg.id); + break; + + case 'getDatabaseSize': { + const size = handleGetDatabaseSize(); + sendResult(msg.id, size); + break; + } + + case 'getAllPairs': { + const pairs = handleGetAllPairs(); + sendResult(msg.id, pairs); + break; + } + + default: + sendResult((msg as {id: string}).id, undefined, `Unknown message type: ${(msg as {type: string}).type}`); + } + } catch (error) { + sendResult(msg.id, undefined, error instanceof Error ? error.message : String(error)); + } +}; + +export type {WorkerMessage, ResultMessage}; diff --git a/package-lock.json b/package-lock.json index 4e869d263..c93e76686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.34", "license": "MIT", "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.51.2-build6", "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "lodash.bindall": "^4.4.0", @@ -4789,6 +4790,15 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.51.2-build6", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.51.2-build6.tgz", + "integrity": "sha512-5ibsgipkqcLINZ5qNSp5KfrtL6KwiNVtwBksNO6zhTghhLmEf3/u1sPoAkgH5RzuLpMw7zi50IWgkZ0WhfqpaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -14193,6 +14203,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17521,6 +17532,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17596,6 +17608,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index d37c2d505..3bc62f1b7 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "gh-actions-build": "./.github/scripts/buildActions.sh" }, "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.51.2-build6", "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "lodash.bindall": "^4.4.0", From 09b4727fd5ed4aec70f3ac45f62296fd66a75fa6 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 16:04:12 -0800 Subject: [PATCH 08/25] Evolve DirtyMap into a patch-staging layer with SET/MERGE entry types Replace the single-type DirtyMap with a two-type staging layer that distinguishes between SET entries (full values, flushed via multiSet) and MERGE entries (accumulated patches, flushed via multiMerge). This preserves JSON_PATCH efficiency on SQLite while eliminating the flushNow() call that caused mergeCollection() and update() to regress. All write operations (set, merge, multiSet, multiMerge) now return immediately after staging in the DirtyMap. Merges on keys with a pending SET apply the patch in-memory; merges on keys with a pending MERGE accumulate patches. On flush, SET entries use reference identity to handle concurrent mutations, while MERGE entries are removed at flush start to prevent double-application. Benchmarks show mergeCollection() improved from +228% regression to -90% improvement at modest scale (56ms -> 5.6ms), and from +2% to -99% at extreme scale (1.91s -> 27.8ms). Co-authored-by: Cursor --- lib/storage/DirtyMap.ts | 170 +++++++++++++++++++++++++++++++--------- lib/storage/index.ts | 76 +++++++++--------- 2 files changed, 173 insertions(+), 73 deletions(-) diff --git a/lib/storage/DirtyMap.ts b/lib/storage/DirtyMap.ts index b8d93358b..c1e85795d 100644 --- a/lib/storage/DirtyMap.ts +++ b/lib/storage/DirtyMap.ts @@ -1,32 +1,45 @@ /** - * DirtyMap implements write coalescing for storage operations. + * DirtyMap implements a patch-staging layer for storage operations. * - * Instead of flushing every individual write to the storage provider immediately, - * DirtyMap tracks the latest value for each modified key and batches them into a - * single multiSet call on flush. This reduces the number of storage transactions - * and avoids persisting intermediate values when the same key is written multiple - * times in quick succession. + * It tracks two types of pending entries: + * - SET entries: full values from set()/multiSet(), flushed via multiSet + * - MERGE entries: accumulated patch deltas from merge operations, flushed via + * multiMerge (preserving JSON_PATCH efficiency on SQLite) + * + * Multiple writes to the same key are coalesced: successive sets replace the + * value, successive merges accumulate patches, and a set after a merge discards + * the pending patch. A merge after a set applies the patch to the full value + * in-memory (unavoidable since the set hasn't been persisted yet). * * Flush is scheduled via requestIdleCallback (with a 50ms timeout fallback) to - * allow the main thread to remain responsive while still persisting data promptly. + * keep the main thread responsive while persisting data promptly. */ import type {OnyxKey, OnyxValue} from '../types'; +import type {FastMergeReplaceNullPatch} from '../utils'; +import utils from '../utils'; import type {StorageKeyValuePair} from './providers/types'; +type EntryType = 'set' | 'merge'; + type DirtyEntry = { key: OnyxKey; value: OnyxValue; + entryType: EntryType; + replaceNullPatches?: FastMergeReplaceNullPatch[]; }; -/** Flush callback that receives the coalesced batch of key-value pairs. */ -type FlushHandler = (pairs: StorageKeyValuePair[]) => Promise; +/** Flush handlers for the two entry types. */ +type FlushHandlers = { + multiSet: (pairs: StorageKeyValuePair[]) => Promise; + multiMerge: (pairs: StorageKeyValuePair[]) => Promise; +}; /** Default idle-callback timeout in milliseconds. */ const FLUSH_TIMEOUT_MS = 50; class DirtyMap { - /** Map of pending dirty entries keyed by OnyxKey. Only the latest value per key is kept. */ + /** Map of pending dirty entries keyed by OnyxKey. */ private dirtyEntries: Map = new Map(); /** Handle returned by the scheduled flush, used for cancellation. */ @@ -35,32 +48,68 @@ class DirtyMap { /** Whether a flush is currently in progress. */ private isFlushing = false; - /** The handler called on flush with the coalesced batch. */ - private readonly onFlush: FlushHandler; + /** The handlers called on flush for each entry type. */ + private readonly handlers: FlushHandlers; - constructor(onFlush: FlushHandler) { - this.onFlush = onFlush; + constructor(handlers: FlushHandlers) { + this.handlers = handlers; } /** - * Mark a key as dirty with the given value. If the key was already dirty, - * its value is replaced (coalesced). A flush is scheduled if not already pending. + * Stage a full value for a key (SET entry). If the key had a pending MERGE, + * the set replaces it entirely. A flush is scheduled if not already pending. */ set(key: OnyxKey, value: OnyxValue): void { - this.dirtyEntries.set(key, {key, value}); + this.dirtyEntries.set(key, {key, value, entryType: 'set'}); this.scheduleFlush(); } /** - * Mark multiple keys as dirty. + * Stage full values for multiple keys (all as SET entries). */ setMany(pairs: StorageKeyValuePair[]): void { for (const [key, value] of pairs) { - this.dirtyEntries.set(key, {key, value}); + this.dirtyEntries.set(key, {key, value, entryType: 'set'}); } this.scheduleFlush(); } + /** + * Stage a merge patch for a key (MERGE entry). Interaction with existing entries: + * - No existing entry: create a MERGE entry with just the patch + * - Existing SET entry: apply patch to the full value in-memory, keep as SET + * - Existing MERGE entry: merge patches together, keep as MERGE + */ + merge(key: OnyxKey, patch: OnyxValue, replaceNullPatches?: FastMergeReplaceNullPatch[]): void { + const existing = this.dirtyEntries.get(key); + + if (!existing) { + // No pending write -- stage as a MERGE entry (just the patch) + this.dirtyEntries.set(key, {key, value: patch, entryType: 'merge', replaceNullPatches}); + } else if (existing.entryType === 'set') { + // Pending SET -- apply patch to the full value, stay as SET + const {result: merged} = utils.fastMerge(existing.value as Record, patch as Record, { + shouldRemoveNestedNulls: true, + objectRemovalMode: 'replace', + }); + this.dirtyEntries.set(key, {key, value: merged as OnyxValue, entryType: 'set'}); + } else { + // Pending MERGE -- merge patches together, stay as MERGE + const {result: mergedPatch} = utils.fastMerge(existing.value as Record, patch as Record, { + shouldRemoveNestedNulls: false, // preserve nulls -- provider handles them + }); + const combinedPatches = [...(existing.replaceNullPatches ?? []), ...(replaceNullPatches ?? [])]; + this.dirtyEntries.set(key, { + key, + value: mergedPatch as OnyxValue, + entryType: 'merge', + replaceNullPatches: combinedPatches.length > 0 ? combinedPatches : undefined, + }); + } + + this.scheduleFlush(); + } + /** * Remove a key from the dirty map. This is used when a key is being * removed from storage entirely (not just set to null). @@ -79,23 +128,38 @@ class DirtyMap { } /** - * Read-through: if the key is in the dirty map, return its pending value. - * Returns undefined if the key is not dirty (caller should read from provider). + * Read-through for SET entries only. Returns the pending full value if the + * key has a SET entry, or undefined otherwise. MERGE entries are not served + * because they contain only a patch, not a complete value -- the caller + * should fall through to the provider for those. */ get(key: OnyxKey): OnyxValue | undefined { const entry = this.dirtyEntries.get(key); - return entry?.value; + if (entry?.entryType === 'set') { + return entry.value; + } + return undefined; } /** - * Check whether the given key has a pending dirty write. + * Check whether the given key has a pending SET entry (suitable for + * read-through). Returns false for MERGE entries since those only + * contain a patch, not a complete value. */ has(key: OnyxKey): boolean { + const entry = this.dirtyEntries.get(key); + return entry?.entryType === 'set'; + } + + /** + * Check whether the key has any pending entry (SET or MERGE). + */ + hasAny(key: OnyxKey): boolean { return this.dirtyEntries.has(key); } /** - * Returns the number of pending dirty entries. + * Returns the number of pending dirty entries (both SET and MERGE). */ get size(): number { return this.dirtyEntries.size; @@ -111,15 +175,17 @@ class DirtyMap { /** * Immediately flush all pending dirty entries to the storage provider. - * Returns a promise that resolves when the flush handler completes. - * If there are no dirty entries, resolves immediately. - * If a flush is already in progress, waits for it to finish, then flushes again - * to capture any entries that were added during the previous flush. + * + * SET entries use reference identity: they stay in the map during flush and + * are only removed afterward if their reference hasn't changed (handling + * concurrent set-during-flush correctly). + * + * MERGE entries are removed from the map at flush start. New merges during + * flush create fresh entries, avoiding double-application of patches. */ async flushNow(): Promise { if (this.isFlushing) { // Wait for the current flush to finish, then flush again - // to capture any entries added during the previous flush. return new Promise((resolve) => { const waitForFlush = () => { if (!this.isFlushing) { @@ -140,14 +206,46 @@ class DirtyMap { this.isFlushing = true; try { - // Snapshot and clear the current dirty entries atomically - const pairs: StorageKeyValuePair[] = []; - for (const entry of this.dirtyEntries.values()) { - pairs.push([entry.key, entry.value]); + // Separate entries by type + const setPairs: StorageKeyValuePair[] = []; + const setSnapshot: Map = new Map(); + const mergePairs: StorageKeyValuePair[] = []; + const mergeKeys: OnyxKey[] = []; + + for (const [key, entry] of this.dirtyEntries) { + if (entry.entryType === 'set') { + setPairs.push([entry.key, entry.value]); + setSnapshot.set(key, entry); + } else { + mergePairs.push([entry.key, entry.value, entry.replaceNullPatches]); + mergeKeys.push(key); + } } - this.dirtyEntries.clear(); - await this.onFlush(pairs); + // Remove MERGE entries from the map at flush start. + // New merges during flush will create fresh entries. + for (const key of mergeKeys) { + this.dirtyEntries.delete(key); + } + + // Flush both types concurrently + const promises: Array> = []; + if (setPairs.length > 0) { + promises.push(this.handlers.multiSet(setPairs)); + } + if (mergePairs.length > 0) { + promises.push(this.handlers.multiMerge(mergePairs)); + } + + await Promise.all(promises); + + // For SET entries: only remove if the reference hasn't changed + // (i.e., no set or merge was applied to this key during flush) + for (const [key, flushedEntry] of setSnapshot) { + if (this.dirtyEntries.get(key) === flushedEntry) { + this.dirtyEntries.delete(key); + } + } } finally { this.isFlushing = false; } @@ -201,4 +299,4 @@ class DirtyMap { } export default DirtyMap; -export type {FlushHandler}; +export type {FlushHandlers, DirtyEntry, EntryType}; diff --git a/lib/storage/index.ts b/lib/storage/index.ts index 28587d10f..c090203cd 100644 --- a/lib/storage/index.ts +++ b/lib/storage/index.ts @@ -59,11 +59,19 @@ function tryOrDegradePerformance(fn: () => Promise | T, waitForInitializat } /** - * The DirtyMap coalesces rapid successive writes to the same key. - * Instead of persisting every intermediate value, only the latest value per key - * is flushed to the storage provider in a batched multiSet call. + * The DirtyMap is a patch-staging layer between Onyx's cache and the storage + * provider. It tracks two types of pending entries: + * - SET entries (full values) flushed via provider.multiSet() + * - MERGE entries (accumulated patches) flushed via provider.multiMerge(), + * preserving JSON_PATCH efficiency on SQLite + * + * All writes (set, merge) return immediately after staging in the DirtyMap. + * Persistence happens asynchronously in coalesced batches. */ -const dirtyMap = new DirtyMap((pairs) => provider.multiSet(pairs)); +const dirtyMap = new DirtyMap({ + multiSet: (pairs) => provider.multiSet(pairs), + multiMerge: (pairs) => provider.multiMerge(pairs), +}); const storage: Storage = { /** @@ -85,11 +93,12 @@ const storage: Storage = { /** * Get the value of a given key or return `null` if it's not available. - * Checks the dirty map first for any pending unflushed writes. + * Checks the dirty map for pending SET entries first. MERGE entries + * (patches) are not served since they don't contain complete values. */ getItem: (key) => tryOrDegradePerformance(() => { - // Read-through: check the dirty map before hitting the provider + // Read-through: check the dirty map for SET entries if (dirtyMap.has(key)) { return Promise.resolve(dirtyMap.get(key)); } @@ -98,11 +107,11 @@ const storage: Storage = { /** * Get multiple key-value pairs for the give array of keys in a batch. - * Overlays dirty map values on top of provider results. + * Overlays dirty map SET values on top of provider results. */ multiGet: (keys) => tryOrDegradePerformance(() => { - // Split keys into dirty (already in memory) and clean (need provider read) + // Split keys into those with SET entries (in memory) and the rest (need provider read) const dirtyKeys: string[] = []; const cleanKeys: string[] = []; @@ -114,7 +123,7 @@ const storage: Storage = { } } - // If all keys are dirty, skip the provider call entirely + // If all keys have SET entries, skip the provider call entirely if (cleanKeys.length === 0) { return Promise.resolve(dirtyKeys.map((key) => [key, dirtyMap.get(key)])); } @@ -128,7 +137,7 @@ const storage: Storage = { /** * Sets the value for a given key. The value is staged in the dirty map - * and flushed to storage asynchronously in a coalesced batch. + * as a SET entry and flushed to storage asynchronously in a coalesced batch. */ setItem: (key, value) => tryOrDegradePerformance(() => { @@ -143,7 +152,7 @@ const storage: Storage = { /** * Stores multiple key-value pairs. All values are staged in the dirty map - * and flushed to storage asynchronously in a coalesced batch. + * as SET entries and flushed to storage asynchronously in a coalesced batch. */ multiSet: (pairs) => tryOrDegradePerformance(() => { @@ -158,45 +167,38 @@ const storage: Storage = { /** * Merging an existing value with a new one. - * Merge operations bypass the dirty map and go directly to the provider, - * since merges require knowledge of the existing value in storage and - * on native leverage SQLite's JSON_PATCH for efficient partial updates. + * The patch is staged in the dirty map as a MERGE entry. If the key + * already has a pending SET, the patch is applied to the full value + * in-memory. If it already has a pending MERGE, patches are accumulated. + * Returns immediately -- no flushNow() needed. */ mergeItem: (key, change, replaceNullPatches) => tryOrDegradePerformance(() => { - // Flush any pending dirty writes for this key before merging, - // so the provider's merge operates on the latest persisted value. - const flushPromise = dirtyMap.has(key) ? dirtyMap.flushNow() : Promise.resolve(); - - return flushPromise.then(() => { - const promise = provider.mergeItem(key, change, replaceNullPatches); + dirtyMap.merge(key, change, replaceNullPatches); - if (shouldKeepInstancesSync) { - return promise.then(() => InstanceSync.mergeItem(key)); - } + if (shouldKeepInstancesSync) { + InstanceSync.mergeItem(key); + } - return promise; - }); + return Promise.resolve(); }), /** * Multiple merging of existing and new values in a batch. - * Like mergeItem, this bypasses coalescing and goes directly to the provider. + * Each pair's patch is staged in the dirty map as a MERGE entry. + * Returns immediately -- no flushNow() needed. */ multiMerge: (pairs) => tryOrDegradePerformance(() => { - // Flush any pending dirty writes before merging - const flushPromise = dirtyMap.size > 0 ? dirtyMap.flushNow() : Promise.resolve(); - - return flushPromise.then(() => { - const promise = provider.multiMerge(pairs); + for (const [key, value, replaceNullPatches] of pairs) { + dirtyMap.merge(key, value, replaceNullPatches); + } - if (shouldKeepInstancesSync) { - return promise.then(() => InstanceSync.multiMerge(pairs.map((pair) => pair[0]))); - } + if (shouldKeepInstancesSync) { + InstanceSync.multiMerge(pairs.map((pair) => pair[0])); + } - return promise; - }); + return Promise.resolve(); }), /** @@ -232,7 +234,7 @@ const storage: Storage = { }), /** - * Clears everything. Flushes and clears the dirty map first. + * Clears everything. Clears the dirty map first, then the provider. */ clear: () => tryOrDegradePerformance(() => { From 53efc01bd5c88426fe89e1032d30e9197fa24b07 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 9 Feb 2026 16:52:31 -0800 Subject: [PATCH 09/25] Add automated benchmark report generation - scripts/generateBenchReport.ts: parse Vitest benchmark JSON, output color-coded HTML - scripts/benchAndReport.sh: run benchmarks and generate report (single run, branch compare, or multi-config) - npm run bench:report and bench:report:compare - Add tsx dev dependency for report generator - Document in README; add bench-results.html to .gitignore Co-authored-by: Cursor --- .gitignore | 3 +- README.md | 33 ++++ package-lock.json | 22 +++ package.json | 3 + scripts/benchAndReport.sh | 238 +++++++++++++++++++++++ scripts/generateBenchReport.ts | 332 +++++++++++++++++++++++++++++++++ 6 files changed, 630 insertions(+), 1 deletion(-) create mode 100755 scripts/benchAndReport.sh create mode 100644 scripts/generateBenchReport.ts diff --git a/.gitignore b/.gitignore index 389f134ac..e73b3f1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ yalc.lock # Perf tests .reassure -# Benchmark baseline files +# Benchmark output files .bench-baseline.json .bench-dirtymap.json +bench-results.html diff --git a/README.md b/README.md index 7f3742dd8..64b7852ad 100644 --- a/README.md +++ b/README.md @@ -579,3 +579,36 @@ npm run bench:save # Compare against saved baseline npx vitest bench --config vitest.bench.config.ts --compare .bench-baseline.json ``` + +## HTML benchmark reports + +For a visual, color-coded comparison of benchmark results, use the report scripts: + +```bash +# Run benchmarks and generate an HTML report (opens in browser) +npm run bench:report + +# Compare current branch vs main with a visual report +npm run bench:report:compare main + +# Don't auto-open the browser +./scripts/benchAndReport.sh --no-open + +# Compare multiple configurations (e.g. different storage providers) +./scripts/benchAndReport.sh \ + --run "SQLite (default)" \ + --run "IDB only:printf 'import W from \"../providers/IDBKeyValProvider\";\nexport default W;\n' > lib/storage/platforms/index.ts" +``` + +The report generator can also be used standalone on previously-captured JSON files: + +```bash +# Generate a comparison report from saved JSON files +npx tsx scripts/generateBenchReport.ts baseline.json current.json \ + --labels "Baseline,Current" -o bench-results.html --open +``` + +The generated HTML uses green/red highlighting: +- **Green** = faster (improvement >= 5% and >= 1ms absolute) +- **Red** = slower (regression >= 5% and >= 1ms absolute) +- **Gray** = negligible change diff --git a/package-lock.json b/package-lock.json index c93e76686..52fb13e86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "reassure": "1.4.0", "tinybench": "^6.0.0", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "type-fest": "^3.12.0", "typescript": "^5.9.2", "vitest": "^4.0.18" @@ -16945,6 +16946,27 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/package.json b/package.json index 3bc62f1b7..a8f361f1a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "bench": "vitest bench --config vitest.bench.config.ts", "bench:compare": "./scripts/compareBenchmarks.sh", "bench:save": "vitest bench --config vitest.bench.config.ts --outputJson .bench-baseline.json", + "bench:report": "./scripts/benchAndReport.sh", + "bench:report:compare": "./scripts/benchAndReport.sh --compare", "build": "tsc -p tsconfig.build.json", "build:watch": "nodemon --watch lib --ext js,json,ts,tsx --exec \"npm run build && npm pack\"", "prebuild:docs": "npm run build", @@ -104,6 +106,7 @@ "reassure": "1.4.0", "tinybench": "^6.0.0", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "type-fest": "^3.12.0", "typescript": "^5.9.2", "vitest": "^4.0.18" diff --git a/scripts/benchAndReport.sh b/scripts/benchAndReport.sh new file mode 100755 index 000000000..f1ee2e1db --- /dev/null +++ b/scripts/benchAndReport.sh @@ -0,0 +1,238 @@ +#!/bin/bash +# +# benchAndReport.sh — Run benchmarks, generate a color-coded HTML report, and open it. +# +# This script supports three modes: +# +# 1. Single run (current code only): +# ./scripts/benchAndReport.sh +# +# 2. Compare current branch vs a base branch: +# ./scripts/benchAndReport.sh --compare main +# +# 3. Multi-config comparison (e.g. swapping storage providers): +# Provide one or more --run flags with a label and an optional setup command. +# ./scripts/benchAndReport.sh \ +# --run "Baseline" \ +# --run "DM+SQLite" \ +# --run "DM+IDB:printf 'import W from \"../providers/IDBKeyValProvider\";\nexport default W;\n' > lib/storage/platforms/index.ts" +# +# Options: +# --compare Compare current branch against +# --run "