testing local-first datastores
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add in-browser tests

+3258 -8
.README.md.swp

This is a binary file and will not be displayed.

+3 -1
.claude/settings.local.json
··· 9 9 "Bash(npm uninstall:*)", 10 10 "Bash(npm run bench:*)", 11 11 "Bash(npx tsx:*)", 12 - "Bash(du -sh:*)" 12 + "Bash(du -sh:*)", 13 + "Bash(npm run bench:browser:*)", 14 + "Bash(cat:*)" 13 15 ], 14 16 "deny": [], 15 17 "ask": []
+60 -7
README.md
··· 12 12 13 13 ## Datastores Tested 14 14 15 + ### Node.js Datastores 15 16 - **TinyBase** - Reactive data store for local-first apps 16 17 - **LevelGraph** - Graph database built on LevelDB 17 18 - **SQLite** - Embedded SQL database (via better-sqlite3) 19 + 20 + ### Browser Datastores 21 + - **PGLite** - PostgreSQL compiled to WebAssembly 22 + - **PouchDB** - Sync-enabled NoSQL database for browsers 23 + - **IndexedDB** - Browser's native structured storage (key-value + indexes) 24 + - **LocalStorage** - Browser's simple key-value storage (limited to ~5-10MB) 18 25 19 26 ## Methodology 20 27 ··· 51 58 ## Commands 52 59 53 60 ```bash 61 + # Node.js benchmarks 54 62 npm run generate # Generate test data to test-data/ 55 - npm run bench # Benchmark all stores (10 iterations each) 63 + npm run bench # Benchmark all Node.js stores (10 iterations each) 56 64 npm run bench:store tinybase # Benchmark single store 57 65 npm run bench:store levelgraph 58 66 npm run bench:store sqlite 67 + 68 + # Browser benchmarks 69 + npm run bench:browser # Benchmark browser stores (PGLite, PouchDB) in multiple browsers 70 + 71 + # Results 59 72 npm run charts # Regenerate charts from latest results 60 73 ``` 61 74 75 + ## Browser Benchmarking 76 + 77 + Browser benchmarks test in-browser datastores across multiple browsers (Chromium, Firefox, WebKit): 78 + 79 + ```bash 80 + npm run bench:browser 81 + ``` 82 + 83 + This launches browser instances (Chromium, Firefox, WebKit), runs benchmarks for each datastore, and measures storage usage via the Storage Estimation API. 84 + 85 + **Benchmarks:** 86 + - IndexedDB - Transactional object store with indexes ✓ 87 + - LocalStorage - Key-value storage (limited capacity) ✓ 88 + - PouchDB - Document database (interactive UI only) 89 + - PGLite - PostgreSQL in WASM (interactive UI only) 90 + 91 + ### Interactive Browser Testing 92 + 93 + The HTML harness (`src/browser/harness.html`) provides an interactive UI for manual testing: 94 + 95 + ```bash 96 + # In one terminal, start a local server: 97 + python3 -m http.server 8000 --directory src/browser 98 + 99 + # Open in browser: 100 + # http://localhost:8000/harness.html 101 + ``` 102 + 103 + Then select a datastore and click "Start Benchmark". 104 + 62 105 ## Adding a New Datastore 63 106 107 + ### Node.js Datastores 64 108 1. Create `src/stores/yourstore.ts` implementing `DatastoreAdapter` from `src/harness/types.ts` 65 109 2. Register in `src/stores/index.ts` 66 110 111 + ### Browser Datastores 112 + 1. Create `src/browser/adapters/yourstore.ts` implementing `BrowserDatastoreAdapter` from `src/harness/browser-types.ts` 113 + 2. Add to the `stores` array in `src/browser/runner.ts` 114 + 67 115 ## Project Structure 68 116 69 117 ``` 70 118 src/ 71 - generator/ # Test data generation 72 - harness/ # Benchmark runner and reporting 73 - stores/ # Datastore adapters 74 - runner.ts # CLI entry point 75 - test-data/ # Generated test data (gitignored) 76 - results/ # Benchmark results JSON (gitignored) 119 + generator/ # Test data generation 120 + harness/ # Benchmark infrastructure (types, runner, reporter) 121 + stores/ # Node.js datastore adapters 122 + browser/ # Browser benchmark infrastructure 123 + adapters/ # In-browser datastore adapters (PGLite, PouchDB) 124 + harness.html # Browser-based benchmark UI 125 + harness.ts # Browser benchmark runner 126 + runner.ts # Playwright automation for browser testing 127 + runner.ts # CLI entry point for Node.js benchmarks 128 + test-data/ # Generated test data (gitignored) 129 + results/ # Benchmark results JSON (gitignored) 77 130 ```
+621
package-lock.json
··· 8 8 "name": "localstress", 9 9 "version": "1.0.0", 10 10 "dependencies": { 11 + "@electric-sql/pglite": "^0.3.0", 11 12 "better-sqlite3": "^11.0.0", 12 13 "canvas": "^2.11.0", 13 14 "chart.js": "^4.4.0", 14 15 "level": "^8.0.0", 15 16 "levelgraph": "^4.0.0", 17 + "pouchdb": "^8.0.1", 16 18 "sharp": "^0.33.0", 17 19 "tinybase": "^5.0.0", 18 20 "vega": "^6.2.0", 19 21 "vega-lite": "^6.4.1" 20 22 }, 21 23 "devDependencies": { 24 + "@playwright/test": "^1.40.0", 22 25 "@types/node": "^20.10.0", 23 26 "tsx": "^4.6.0", 24 27 "typescript": "^5.3.0" 25 28 } 29 + }, 30 + "node_modules/@electric-sql/pglite": { 31 + "version": "0.3.14", 32 + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.14.tgz", 33 + "integrity": "sha512-3DB258dhqdsArOI1fIt7cb9RpUOgcDg5hXWVgVHAeqVQ/qxtFy605QKs4gx6mFq3jWsSPqDN8TgSEsqC3OfV9Q==", 34 + "license": "Apache-2.0" 26 35 }, 27 36 "node_modules/@emnapi/runtime": { 28 37 "version": "1.7.1", ··· 863 872 "node-pre-gyp": "bin/node-pre-gyp" 864 873 } 865 874 }, 875 + "node_modules/@playwright/test": { 876 + "version": "1.57.0", 877 + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", 878 + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", 879 + "dev": true, 880 + "license": "Apache-2.0", 881 + "dependencies": { 882 + "playwright": "1.57.0" 883 + }, 884 + "bin": { 885 + "playwright": "cli.js" 886 + }, 887 + "engines": { 888 + "node": ">=18" 889 + } 890 + }, 866 891 "node_modules/@types/estree": { 867 892 "version": "1.0.8", 868 893 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", ··· 919 944 }, 920 945 "engines": { 921 946 "node": ">=12" 947 + } 948 + }, 949 + "node_modules/abstract-leveldown": { 950 + "version": "6.2.3", 951 + "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", 952 + "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", 953 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 954 + "license": "MIT", 955 + "dependencies": { 956 + "buffer": "^5.5.0", 957 + "immediate": "^3.2.3", 958 + "level-concat-iterator": "~2.0.0", 959 + "level-supports": "~1.0.0", 960 + "xtend": "~4.0.0" 961 + }, 962 + "engines": { 963 + "node": ">=6" 964 + } 965 + }, 966 + "node_modules/abstract-leveldown/node_modules/buffer": { 967 + "version": "5.7.1", 968 + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 969 + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 970 + "funding": [ 971 + { 972 + "type": "github", 973 + "url": "https://github.com/sponsors/feross" 974 + }, 975 + { 976 + "type": "patreon", 977 + "url": "https://www.patreon.com/feross" 978 + }, 979 + { 980 + "type": "consulting", 981 + "url": "https://feross.org/support" 982 + } 983 + ], 984 + "license": "MIT", 985 + "dependencies": { 986 + "base64-js": "^1.3.1", 987 + "ieee754": "^1.1.13" 988 + } 989 + }, 990 + "node_modules/abstract-leveldown/node_modules/level-supports": { 991 + "version": "1.0.1", 992 + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", 993 + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", 994 + "license": "MIT", 995 + "dependencies": { 996 + "xtend": "^4.0.2" 997 + }, 998 + "engines": { 999 + "node": ">=6" 922 1000 } 923 1001 }, 924 1002 "node_modules/agent-base": { ··· 1101 1179 "ieee754": "^1.2.1" 1102 1180 } 1103 1181 }, 1182 + "node_modules/buffer-from": { 1183 + "version": "1.1.2", 1184 + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 1185 + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", 1186 + "license": "MIT" 1187 + }, 1104 1188 "node_modules/callback-stream": { 1105 1189 "version": "1.1.0", 1106 1190 "resolved": "https://registry.npmjs.org/callback-stream/-/callback-stream-1.1.0.tgz", ··· 1265 1349 }, 1266 1350 "funding": { 1267 1351 "url": "https://github.com/chalk/strip-ansi?sponsor=1" 1352 + } 1353 + }, 1354 + "node_modules/clone-buffer": { 1355 + "version": "1.0.0", 1356 + "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", 1357 + "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", 1358 + "license": "MIT", 1359 + "engines": { 1360 + "node": ">= 0.10" 1268 1361 } 1269 1362 }, 1270 1363 "node_modules/color": { ··· 1618 1711 "node": ">=4.0.0" 1619 1712 } 1620 1713 }, 1714 + "node_modules/deferred-leveldown": { 1715 + "version": "5.3.0", 1716 + "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz", 1717 + "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", 1718 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 1719 + "license": "MIT", 1720 + "dependencies": { 1721 + "abstract-leveldown": "~6.2.1", 1722 + "inherits": "^2.0.3" 1723 + }, 1724 + "engines": { 1725 + "node": ">=6" 1726 + } 1727 + }, 1621 1728 "node_modules/delaunator": { 1622 1729 "version": "5.0.1", 1623 1730 "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", ··· 1642 1749 "node": ">=8" 1643 1750 } 1644 1751 }, 1752 + "node_modules/double-ended-queue": { 1753 + "version": "2.1.0-0", 1754 + "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz", 1755 + "integrity": "sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ==", 1756 + "license": "MIT" 1757 + }, 1645 1758 "node_modules/emoji-regex": { 1646 1759 "version": "8.0.0", 1647 1760 "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1648 1761 "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1649 1762 "license": "MIT" 1650 1763 }, 1764 + "node_modules/encoding-down": { 1765 + "version": "6.3.0", 1766 + "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", 1767 + "integrity": "sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==", 1768 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 1769 + "license": "MIT", 1770 + "dependencies": { 1771 + "abstract-leveldown": "^6.2.1", 1772 + "inherits": "^2.0.3", 1773 + "level-codec": "^9.0.0", 1774 + "level-errors": "^2.0.0" 1775 + }, 1776 + "engines": { 1777 + "node": ">=6" 1778 + } 1779 + }, 1651 1780 "node_modules/end-of-stream": { 1652 1781 "version": "1.4.5", 1653 1782 "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", ··· 1655 1784 "license": "MIT", 1656 1785 "dependencies": { 1657 1786 "once": "^1.4.0" 1787 + } 1788 + }, 1789 + "node_modules/end-stream": { 1790 + "version": "0.1.0", 1791 + "resolved": "https://registry.npmjs.org/end-stream/-/end-stream-0.1.0.tgz", 1792 + "integrity": "sha512-Brl10T8kYnc75IepKizW6Y9liyW8ikz1B7n/xoHrJxoVSSjoqPn30sb7XVFfQERK4QfUMYRGs9dhWwtt2eu6uA==", 1793 + "dependencies": { 1794 + "write-stream": "~0.4.3" 1795 + } 1796 + }, 1797 + "node_modules/errno": { 1798 + "version": "0.1.8", 1799 + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", 1800 + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", 1801 + "license": "MIT", 1802 + "dependencies": { 1803 + "prr": "~1.0.1" 1804 + }, 1805 + "bin": { 1806 + "errno": "cli.js" 1658 1807 } 1659 1808 }, 1660 1809 "node_modules/esbuild": { ··· 1776 1925 "xtend": "^4.0.0" 1777 1926 } 1778 1927 }, 1928 + "node_modules/fetch-cookie": { 1929 + "version": "0.11.0", 1930 + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", 1931 + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", 1932 + "license": "Unlicense", 1933 + "dependencies": { 1934 + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" 1935 + }, 1936 + "engines": { 1937 + "node": ">=8" 1938 + } 1939 + }, 1779 1940 "node_modules/file-uri-to-path": { 1780 1941 "version": "1.0.0", 1781 1942 "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", ··· 1966 2127 ], 1967 2128 "license": "BSD-3-Clause" 1968 2129 }, 2130 + "node_modules/immediate": { 2131 + "version": "3.3.0", 2132 + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", 2133 + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", 2134 + "license": "MIT" 2135 + }, 1969 2136 "node_modules/inflight": { 1970 2137 "version": "1.0.6", 1971 2138 "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", ··· 2066 2233 "url": "https://opencollective.com/level" 2067 2234 } 2068 2235 }, 2236 + "node_modules/level-codec": { 2237 + "version": "9.0.2", 2238 + "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", 2239 + "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", 2240 + "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", 2241 + "license": "MIT", 2242 + "dependencies": { 2243 + "buffer": "^5.6.0" 2244 + }, 2245 + "engines": { 2246 + "node": ">=6" 2247 + } 2248 + }, 2249 + "node_modules/level-codec/node_modules/buffer": { 2250 + "version": "5.7.1", 2251 + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 2252 + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 2253 + "funding": [ 2254 + { 2255 + "type": "github", 2256 + "url": "https://github.com/sponsors/feross" 2257 + }, 2258 + { 2259 + "type": "patreon", 2260 + "url": "https://www.patreon.com/feross" 2261 + }, 2262 + { 2263 + "type": "consulting", 2264 + "url": "https://feross.org/support" 2265 + } 2266 + ], 2267 + "license": "MIT", 2268 + "dependencies": { 2269 + "base64-js": "^1.3.1", 2270 + "ieee754": "^1.1.13" 2271 + } 2272 + }, 2273 + "node_modules/level-concat-iterator": { 2274 + "version": "2.0.1", 2275 + "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", 2276 + "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", 2277 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 2278 + "license": "MIT", 2279 + "engines": { 2280 + "node": ">=6" 2281 + } 2282 + }, 2283 + "node_modules/level-errors": { 2284 + "version": "2.0.1", 2285 + "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-2.0.1.tgz", 2286 + "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", 2287 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 2288 + "license": "MIT", 2289 + "dependencies": { 2290 + "errno": "~0.1.1" 2291 + }, 2292 + "engines": { 2293 + "node": ">=6" 2294 + } 2295 + }, 2296 + "node_modules/level-iterator-stream": { 2297 + "version": "4.0.2", 2298 + "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz", 2299 + "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", 2300 + "license": "MIT", 2301 + "dependencies": { 2302 + "inherits": "^2.0.4", 2303 + "readable-stream": "^3.4.0", 2304 + "xtend": "^4.0.2" 2305 + }, 2306 + "engines": { 2307 + "node": ">=6" 2308 + } 2309 + }, 2310 + "node_modules/level-js": { 2311 + "version": "5.0.2", 2312 + "resolved": "https://registry.npmjs.org/level-js/-/level-js-5.0.2.tgz", 2313 + "integrity": "sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==", 2314 + "deprecated": "Superseded by browser-level (https://github.com/Level/community#faq)", 2315 + "license": "MIT", 2316 + "dependencies": { 2317 + "abstract-leveldown": "~6.2.3", 2318 + "buffer": "^5.5.0", 2319 + "inherits": "^2.0.3", 2320 + "ltgt": "^2.1.2" 2321 + } 2322 + }, 2323 + "node_modules/level-js/node_modules/buffer": { 2324 + "version": "5.7.1", 2325 + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 2326 + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 2327 + "funding": [ 2328 + { 2329 + "type": "github", 2330 + "url": "https://github.com/sponsors/feross" 2331 + }, 2332 + { 2333 + "type": "patreon", 2334 + "url": "https://www.patreon.com/feross" 2335 + }, 2336 + { 2337 + "type": "consulting", 2338 + "url": "https://feross.org/support" 2339 + } 2340 + ], 2341 + "license": "MIT", 2342 + "dependencies": { 2343 + "base64-js": "^1.3.1", 2344 + "ieee754": "^1.1.13" 2345 + } 2346 + }, 2347 + "node_modules/level-packager": { 2348 + "version": "5.1.1", 2349 + "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", 2350 + "integrity": "sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==", 2351 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 2352 + "license": "MIT", 2353 + "dependencies": { 2354 + "encoding-down": "^6.3.0", 2355 + "levelup": "^4.3.2" 2356 + }, 2357 + "engines": { 2358 + "node": ">=6" 2359 + } 2360 + }, 2069 2361 "node_modules/level-read-stream": { 2070 2362 "version": "1.1.1", 2071 2363 "resolved": "https://registry.npmjs.org/level-read-stream/-/level-read-stream-1.1.1.tgz", ··· 2108 2400 "node": ">=12" 2109 2401 } 2110 2402 }, 2403 + "node_modules/level-write-stream": { 2404 + "version": "1.0.0", 2405 + "resolved": "https://registry.npmjs.org/level-write-stream/-/level-write-stream-1.0.0.tgz", 2406 + "integrity": "sha512-bBNKOEOMl8msO+uIM9YX/gUO6ckokZ/4pCwTm/lwvs46x6Xs8Zy0sn3Vh37eDqse4mhy4fOMIb/JsSM2nyQFtw==", 2407 + "dependencies": { 2408 + "end-stream": "~0.1.0" 2409 + } 2410 + }, 2111 2411 "node_modules/level-ws": { 2112 2412 "version": "4.0.0", 2113 2413 "resolved": "https://registry.npmjs.org/level-ws/-/level-ws-4.0.0.tgz", ··· 2136 2436 "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 2137 2437 } 2138 2438 }, 2439 + "node_modules/leveldown": { 2440 + "version": "5.6.0", 2441 + "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-5.6.0.tgz", 2442 + "integrity": "sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==", 2443 + "deprecated": "Superseded by classic-level (https://github.com/Level/community#faq)", 2444 + "hasInstallScript": true, 2445 + "license": "MIT", 2446 + "dependencies": { 2447 + "abstract-leveldown": "~6.2.1", 2448 + "napi-macros": "~2.0.0", 2449 + "node-gyp-build": "~4.1.0" 2450 + }, 2451 + "engines": { 2452 + "node": ">=8.6.0" 2453 + } 2454 + }, 2455 + "node_modules/leveldown/node_modules/napi-macros": { 2456 + "version": "2.0.0", 2457 + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", 2458 + "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", 2459 + "license": "MIT" 2460 + }, 2461 + "node_modules/leveldown/node_modules/node-gyp-build": { 2462 + "version": "4.1.1", 2463 + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", 2464 + "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==", 2465 + "license": "MIT", 2466 + "bin": { 2467 + "node-gyp-build": "bin.js", 2468 + "node-gyp-build-optional": "optional.js", 2469 + "node-gyp-build-test": "build-test.js" 2470 + } 2471 + }, 2139 2472 "node_modules/levelgraph": { 2140 2473 "version": "4.0.0", 2141 2474 "resolved": "https://registry.npmjs.org/levelgraph/-/levelgraph-4.0.0.tgz", ··· 2169 2502 "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 2170 2503 } 2171 2504 }, 2505 + "node_modules/levelup": { 2506 + "version": "4.4.0", 2507 + "resolved": "https://registry.npmjs.org/levelup/-/levelup-4.4.0.tgz", 2508 + "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", 2509 + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 2510 + "license": "MIT", 2511 + "dependencies": { 2512 + "deferred-leveldown": "~5.3.0", 2513 + "level-errors": "~2.0.0", 2514 + "level-iterator-stream": "~4.0.0", 2515 + "level-supports": "~1.0.0", 2516 + "xtend": "~4.0.0" 2517 + }, 2518 + "engines": { 2519 + "node": ">=6" 2520 + } 2521 + }, 2522 + "node_modules/levelup/node_modules/level-supports": { 2523 + "version": "1.0.1", 2524 + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", 2525 + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", 2526 + "license": "MIT", 2527 + "dependencies": { 2528 + "xtend": "^4.0.2" 2529 + }, 2530 + "engines": { 2531 + "node": ">=6" 2532 + } 2533 + }, 2172 2534 "node_modules/lodash.keys": { 2173 2535 "version": "4.2.0", 2174 2536 "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-4.2.0.tgz", 2175 2537 "integrity": "sha512-J79MkJcp7Df5mizHiVNpjoHXLi4HLjh9VLS/M7lQSGoQ+0oQ+lWEigREkqKyizPB1IawvQLLKY8mzEcm1tkyxQ==", 2538 + "license": "MIT" 2539 + }, 2540 + "node_modules/ltgt": { 2541 + "version": "2.2.1", 2542 + "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", 2543 + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", 2176 2544 "license": "MIT" 2177 2545 }, 2178 2546 "node_modules/make-dir": { ··· 2415 2783 "node": ">=0.10.0" 2416 2784 } 2417 2785 }, 2786 + "node_modules/playwright": { 2787 + "version": "1.57.0", 2788 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", 2789 + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", 2790 + "dev": true, 2791 + "license": "Apache-2.0", 2792 + "dependencies": { 2793 + "playwright-core": "1.57.0" 2794 + }, 2795 + "bin": { 2796 + "playwright": "cli.js" 2797 + }, 2798 + "engines": { 2799 + "node": ">=18" 2800 + }, 2801 + "optionalDependencies": { 2802 + "fsevents": "2.3.2" 2803 + } 2804 + }, 2805 + "node_modules/playwright-core": { 2806 + "version": "1.57.0", 2807 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", 2808 + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", 2809 + "dev": true, 2810 + "license": "Apache-2.0", 2811 + "bin": { 2812 + "playwright-core": "cli.js" 2813 + }, 2814 + "engines": { 2815 + "node": ">=18" 2816 + } 2817 + }, 2818 + "node_modules/playwright/node_modules/fsevents": { 2819 + "version": "2.3.2", 2820 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 2821 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 2822 + "dev": true, 2823 + "hasInstallScript": true, 2824 + "license": "MIT", 2825 + "optional": true, 2826 + "os": [ 2827 + "darwin" 2828 + ], 2829 + "engines": { 2830 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 2831 + } 2832 + }, 2833 + "node_modules/pouchdb": { 2834 + "version": "8.0.1", 2835 + "resolved": "https://registry.npmjs.org/pouchdb/-/pouchdb-8.0.1.tgz", 2836 + "integrity": "sha512-xp5S83JOQn2NAL0ZQ5CU+DI26V9/YrYuVtkXnbGEIDrYiFfj5A8gAcfbxefXb/9O+Qn4n5RaT/19+8UBSZ42sw==", 2837 + "license": "Apache-2.0", 2838 + "dependencies": { 2839 + "abort-controller": "3.0.0", 2840 + "buffer-from": "1.1.2", 2841 + "clone-buffer": "1.0.0", 2842 + "double-ended-queue": "2.1.0-0", 2843 + "fetch-cookie": "0.11.0", 2844 + "immediate": "3.3.0", 2845 + "level": "6.0.1", 2846 + "level-codec": "9.0.2", 2847 + "level-write-stream": "1.0.0", 2848 + "leveldown": "5.6.0", 2849 + "levelup": "4.4.0", 2850 + "ltgt": "2.2.1", 2851 + "node-fetch": "2.6.7", 2852 + "readable-stream": "1.1.14", 2853 + "spark-md5": "3.0.2", 2854 + "through2": "3.0.2", 2855 + "uuid": "8.3.2", 2856 + "vuvuzela": "1.0.3" 2857 + } 2858 + }, 2859 + "node_modules/pouchdb/node_modules/isarray": { 2860 + "version": "0.0.1", 2861 + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", 2862 + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", 2863 + "license": "MIT" 2864 + }, 2865 + "node_modules/pouchdb/node_modules/level": { 2866 + "version": "6.0.1", 2867 + "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", 2868 + "integrity": "sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==", 2869 + "license": "MIT", 2870 + "dependencies": { 2871 + "level-js": "^5.0.0", 2872 + "level-packager": "^5.1.0", 2873 + "leveldown": "^5.4.0" 2874 + }, 2875 + "engines": { 2876 + "node": ">=8.6.0" 2877 + }, 2878 + "funding": { 2879 + "type": "opencollective", 2880 + "url": "https://opencollective.com/level" 2881 + } 2882 + }, 2883 + "node_modules/pouchdb/node_modules/node-fetch": { 2884 + "version": "2.6.7", 2885 + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", 2886 + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", 2887 + "license": "MIT", 2888 + "dependencies": { 2889 + "whatwg-url": "^5.0.0" 2890 + }, 2891 + "engines": { 2892 + "node": "4.x || >=6.0.0" 2893 + }, 2894 + "peerDependencies": { 2895 + "encoding": "^0.1.0" 2896 + }, 2897 + "peerDependenciesMeta": { 2898 + "encoding": { 2899 + "optional": true 2900 + } 2901 + } 2902 + }, 2903 + "node_modules/pouchdb/node_modules/readable-stream": { 2904 + "version": "1.1.14", 2905 + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", 2906 + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", 2907 + "license": "MIT", 2908 + "dependencies": { 2909 + "core-util-is": "~1.0.0", 2910 + "inherits": "~2.0.1", 2911 + "isarray": "0.0.1", 2912 + "string_decoder": "~0.10.x" 2913 + } 2914 + }, 2915 + "node_modules/pouchdb/node_modules/string_decoder": { 2916 + "version": "0.10.31", 2917 + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", 2918 + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", 2919 + "license": "MIT" 2920 + }, 2418 2921 "node_modules/prebuild-install": { 2419 2922 "version": "7.1.3", 2420 2923 "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", ··· 2508 3011 "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", 2509 3012 "license": "MIT" 2510 3013 }, 3014 + "node_modules/prr": { 3015 + "version": "1.0.1", 3016 + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", 3017 + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", 3018 + "license": "MIT" 3019 + }, 3020 + "node_modules/psl": { 3021 + "version": "1.15.0", 3022 + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", 3023 + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", 3024 + "license": "MIT", 3025 + "dependencies": { 3026 + "punycode": "^2.3.1" 3027 + }, 3028 + "funding": { 3029 + "url": "https://github.com/sponsors/lupomontero" 3030 + } 3031 + }, 2511 3032 "node_modules/pump": { 2512 3033 "version": "3.0.3", 2513 3034 "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", ··· 2517 3038 "end-of-stream": "^1.1.0", 2518 3039 "once": "^1.3.1" 2519 3040 } 3041 + }, 3042 + "node_modules/punycode": { 3043 + "version": "2.3.1", 3044 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 3045 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 3046 + "license": "MIT", 3047 + "engines": { 3048 + "node": ">=6" 3049 + } 3050 + }, 3051 + "node_modules/querystringify": { 3052 + "version": "2.2.0", 3053 + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", 3054 + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", 3055 + "license": "MIT" 2520 3056 }, 2521 3057 "node_modules/queue-microtask": { 2522 3058 "version": "1.2.3", ··· 2566 3102 "engines": { 2567 3103 "node": ">= 6" 2568 3104 } 3105 + }, 3106 + "node_modules/requires-port": { 3107 + "version": "1.0.0", 3108 + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", 3109 + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", 3110 + "license": "MIT" 2569 3111 }, 2570 3112 "node_modules/resolve-pkg-maps": { 2571 3113 "version": "1.0.0", ··· 2767 3309 "is-arrayish": "^0.3.1" 2768 3310 } 2769 3311 }, 3312 + "node_modules/spark-md5": { 3313 + "version": "3.0.2", 3314 + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", 3315 + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", 3316 + "license": "(WTFPL OR MIT)" 3317 + }, 2770 3318 "node_modules/steed": { 2771 3319 "version": "1.1.3", 2772 3320 "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", ··· 2875 3423 "node": ">=6" 2876 3424 } 2877 3425 }, 3426 + "node_modules/through2": { 3427 + "version": "3.0.2", 3428 + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", 3429 + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", 3430 + "license": "MIT", 3431 + "dependencies": { 3432 + "inherits": "^2.0.4", 3433 + "readable-stream": "2 || 3" 3434 + } 3435 + }, 2878 3436 "node_modules/tinybase": { 2879 3437 "version": "5.4.9", 2880 3438 "resolved": "https://registry.npmjs.org/tinybase/-/tinybase-5.4.9.tgz", ··· 2984 3542 "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", 2985 3543 "license": "MIT" 2986 3544 }, 3545 + "node_modules/tough-cookie": { 3546 + "version": "4.1.4", 3547 + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", 3548 + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", 3549 + "license": "BSD-3-Clause", 3550 + "dependencies": { 3551 + "psl": "^1.1.33", 3552 + "punycode": "^2.1.1", 3553 + "universalify": "^0.2.0", 3554 + "url-parse": "^1.5.3" 3555 + }, 3556 + "engines": { 3557 + "node": ">=6" 3558 + } 3559 + }, 2987 3560 "node_modules/tr46": { 2988 3561 "version": "0.0.3", 2989 3562 "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", ··· 3049 3622 "dev": true, 3050 3623 "license": "MIT" 3051 3624 }, 3625 + "node_modules/universalify": { 3626 + "version": "0.2.0", 3627 + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", 3628 + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", 3629 + "license": "MIT", 3630 + "engines": { 3631 + "node": ">= 4.0.0" 3632 + } 3633 + }, 3634 + "node_modules/url-parse": { 3635 + "version": "1.5.10", 3636 + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", 3637 + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", 3638 + "license": "MIT", 3639 + "dependencies": { 3640 + "querystringify": "^2.1.1", 3641 + "requires-port": "^1.0.0" 3642 + } 3643 + }, 3052 3644 "node_modules/util-deprecate": { 3053 3645 "version": "1.0.2", 3054 3646 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 3055 3647 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 3056 3648 "license": "MIT" 3649 + }, 3650 + "node_modules/uuid": { 3651 + "version": "8.3.2", 3652 + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", 3653 + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", 3654 + "license": "MIT", 3655 + "bin": { 3656 + "uuid": "dist/bin/uuid" 3657 + } 3057 3658 }, 3058 3659 "node_modules/vega": { 3059 3660 "version": "6.2.0", ··· 3460 4061 "vega-util": "^2.1.0" 3461 4062 } 3462 4063 }, 4064 + "node_modules/vuvuzela": { 4065 + "version": "1.0.3", 4066 + "resolved": "https://registry.npmjs.org/vuvuzela/-/vuvuzela-1.0.3.tgz", 4067 + "integrity": "sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==", 4068 + "license": "Apache-2.0" 4069 + }, 3463 4070 "node_modules/webidl-conversions": { 3464 4071 "version": "3.0.1", 3465 4072 "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", ··· 3557 4164 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 3558 4165 "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 3559 4166 "license": "ISC" 4167 + }, 4168 + "node_modules/write-stream": { 4169 + "version": "0.4.3", 4170 + "resolved": "https://registry.npmjs.org/write-stream/-/write-stream-0.4.3.tgz", 4171 + "integrity": "sha512-IJrvkhbAnj89W/GAVdVgbnPiVw5Ntg/B4tc/MUCIEwj/g6JIww1DWJyB/yBMT3yw2/TkT6IUZ0+IYef3flEw8A==", 4172 + "dependencies": { 4173 + "readable-stream": "~0.0.2" 4174 + } 4175 + }, 4176 + "node_modules/write-stream/node_modules/readable-stream": { 4177 + "version": "0.0.4", 4178 + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-0.0.4.tgz", 4179 + "integrity": "sha512-azrivNydKRYt7zwLV5wWUK7YzKTWs3q87xSmY6DlHapPrCvaT6ZrukvM5erV+yCSSPmZT8zkSdttOHQpWWm9zw==", 4180 + "license": "BSD" 3560 4181 }, 3561 4182 "node_modules/xtend": { 3562 4183 "version": "4.0.2",
+5
package.json
··· 7 7 "generate": "tsx src/generator/index.ts", 8 8 "bench": "tsx src/runner.ts", 9 9 "bench:store": "tsx src/runner.ts --store", 10 + "bench:browser": "tsx src/browser/runner.ts", 11 + "browsers:install": "playwright install", 10 12 "charts": "tsx src/regenerate-charts.ts" 11 13 }, 12 14 "devDependencies": { 13 15 "@types/node": "^20.10.0", 16 + "@playwright/test": "^1.40.0", 14 17 "tsx": "^4.6.0", 15 18 "typescript": "^5.3.0" 16 19 }, 17 20 "dependencies": { 21 + "@electric-sql/pglite": "^0.3.0", 18 22 "better-sqlite3": "^11.0.0", 19 23 "canvas": "^2.11.0", 20 24 "chart.js": "^4.4.0", 21 25 "level": "^8.0.0", 22 26 "levelgraph": "^4.0.0", 27 + "pouchdb": "^8.0.1", 23 28 "sharp": "^0.33.0", 24 29 "tinybase": "^5.0.0", 25 30 "vega": "^6.2.0",
+279
src/browser/adapters/indexeddb.ts
··· 1 + import type { BrowserDatastoreAdapter, BrowserUrl, BrowserMetadata } from '../../harness/browser-types.js'; 2 + 3 + const DB_NAME = 'localstress-idb'; 4 + const DB_VERSION = 1; 5 + const STORE_NAMES = { 6 + urls: 'urls', 7 + images: 'images', 8 + documents: 'documents', 9 + metadata: 'metadata' 10 + }; 11 + 12 + export class IndexedDBAdapter implements BrowserDatastoreAdapter { 13 + name = 'IndexedDB'; 14 + private db: IDBDatabase | null = null; 15 + 16 + async init(): Promise<void> { 17 + return new Promise((resolve, reject) => { 18 + // Delete existing database to start fresh 19 + const deleteRequest = indexedDB.deleteDatabase(DB_NAME); 20 + 21 + deleteRequest.onsuccess = () => { 22 + const request = indexedDB.open(DB_NAME, DB_VERSION); 23 + 24 + request.onerror = () => { 25 + reject(new Error(`Failed to open IndexedDB: ${request.error}`)); 26 + }; 27 + 28 + request.onupgradeneeded = (event) => { 29 + const db = (event.target as IDBOpenDBRequest).result; 30 + 31 + // Create object stores 32 + if (!db.objectStoreNames.contains(STORE_NAMES.urls)) { 33 + const urlStore = db.createObjectStore(STORE_NAMES.urls, { keyPath: 'id' }); 34 + urlStore.createIndex('createdAt', 'createdAt', { unique: false }); 35 + } 36 + 37 + if (!db.objectStoreNames.contains(STORE_NAMES.images)) { 38 + db.createObjectStore(STORE_NAMES.images, { keyPath: 'id' }); 39 + } 40 + 41 + if (!db.objectStoreNames.contains(STORE_NAMES.documents)) { 42 + db.createObjectStore(STORE_NAMES.documents, { keyPath: 'id' }); 43 + } 44 + 45 + if (!db.objectStoreNames.contains(STORE_NAMES.metadata)) { 46 + const metaStore = db.createObjectStore(STORE_NAMES.metadata, { keyPath: 'id' }); 47 + metaStore.createIndex('timestamp', 'timestamp', { unique: false }); 48 + } 49 + }; 50 + 51 + request.onsuccess = () => { 52 + this.db = request.result; 53 + resolve(); 54 + }; 55 + }; 56 + 57 + deleteRequest.onerror = () => { 58 + // Continue even if delete fails 59 + const request = indexedDB.open(DB_NAME, DB_VERSION); 60 + 61 + request.onerror = () => { 62 + reject(new Error(`Failed to open IndexedDB: ${request.error}`)); 63 + }; 64 + 65 + request.onupgradeneeded = (event) => { 66 + const db = (event.target as IDBOpenDBRequest).result; 67 + 68 + if (!db.objectStoreNames.contains(STORE_NAMES.urls)) { 69 + const urlStore = db.createObjectStore(STORE_NAMES.urls, { keyPath: 'id' }); 70 + urlStore.createIndex('createdAt', 'createdAt', { unique: false }); 71 + } 72 + 73 + if (!db.objectStoreNames.contains(STORE_NAMES.images)) { 74 + db.createObjectStore(STORE_NAMES.images, { keyPath: 'id' }); 75 + } 76 + 77 + if (!db.objectStoreNames.contains(STORE_NAMES.documents)) { 78 + db.createObjectStore(STORE_NAMES.documents, { keyPath: 'id' }); 79 + } 80 + 81 + if (!db.objectStoreNames.contains(STORE_NAMES.metadata)) { 82 + const metaStore = db.createObjectStore(STORE_NAMES.metadata, { keyPath: 'id' }); 83 + metaStore.createIndex('timestamp', 'timestamp', { unique: false }); 84 + } 85 + }; 86 + 87 + request.onsuccess = () => { 88 + this.db = request.result; 89 + resolve(); 90 + }; 91 + }; 92 + }); 93 + } 94 + 95 + async cleanup(): Promise<void> { 96 + if (this.db) { 97 + this.db.close(); 98 + this.db = null; 99 + } 100 + 101 + return new Promise((resolve, reject) => { 102 + const request = indexedDB.deleteDatabase(DB_NAME); 103 + request.onsuccess = () => resolve(); 104 + request.onerror = () => reject(request.error); 105 + }); 106 + } 107 + 108 + async addUrls(urls: BrowserUrl[]): Promise<void> { 109 + if (!this.db) throw new Error('DB not initialized'); 110 + 111 + return new Promise((resolve, reject) => { 112 + const transaction = this.db!.transaction([STORE_NAMES.urls], 'readwrite'); 113 + const store = transaction.objectStore(STORE_NAMES.urls); 114 + 115 + for (const url of urls) { 116 + store.add(url); 117 + } 118 + 119 + transaction.oncomplete = () => resolve(); 120 + transaction.onerror = () => reject(transaction.error); 121 + }); 122 + } 123 + 124 + async addImage(id: string, data: Uint8Array): Promise<void> { 125 + if (!this.db) throw new Error('DB not initialized'); 126 + 127 + return new Promise((resolve, reject) => { 128 + const transaction = this.db!.transaction([STORE_NAMES.images], 'readwrite'); 129 + const store = transaction.objectStore(STORE_NAMES.images); 130 + 131 + store.add({ 132 + id, 133 + data, 134 + size: data.byteLength 135 + }); 136 + 137 + transaction.oncomplete = () => resolve(); 138 + transaction.onerror = () => reject(transaction.error); 139 + }); 140 + } 141 + 142 + async addDocument(id: string, content: string): Promise<void> { 143 + if (!this.db) throw new Error('DB not initialized'); 144 + 145 + return new Promise((resolve, reject) => { 146 + const transaction = this.db!.transaction([STORE_NAMES.documents], 'readwrite'); 147 + const store = transaction.objectStore(STORE_NAMES.documents); 148 + 149 + store.add({ 150 + id, 151 + content, 152 + size: new TextEncoder().encode(content).byteLength 153 + }); 154 + 155 + transaction.oncomplete = () => resolve(); 156 + transaction.onerror = () => reject(transaction.error); 157 + }); 158 + } 159 + 160 + async addMetadata(rows: BrowserMetadata[]): Promise<void> { 161 + if (!this.db) throw new Error('DB not initialized'); 162 + 163 + return new Promise((resolve, reject) => { 164 + const transaction = this.db!.transaction([STORE_NAMES.metadata], 'readwrite'); 165 + const store = transaction.objectStore(STORE_NAMES.metadata); 166 + 167 + for (const row of rows) { 168 + store.add(row); 169 + } 170 + 171 + transaction.oncomplete = () => resolve(); 172 + transaction.onerror = () => reject(transaction.error); 173 + }); 174 + } 175 + 176 + async getRecentUrls(count: number): Promise<BrowserUrl[]> { 177 + if (!this.db) throw new Error('DB not initialized'); 178 + 179 + return new Promise((resolve, reject) => { 180 + const transaction = this.db!.transaction([STORE_NAMES.urls], 'readonly'); 181 + const store = transaction.objectStore(STORE_NAMES.urls); 182 + const index = store.index('createdAt'); 183 + 184 + const range = IDBKeyRange.lowerBound(0); 185 + const request = index.openCursor(range, 'prev'); 186 + 187 + const results: BrowserUrl[] = []; 188 + 189 + request.onsuccess = (event) => { 190 + const cursor = (event.target as IDBRequest).result; 191 + 192 + if (cursor && results.length < count) { 193 + results.push(cursor.value); 194 + cursor.continue(); 195 + } else { 196 + resolve(results); 197 + } 198 + }; 199 + 200 + request.onerror = () => reject(request.error); 201 + }); 202 + } 203 + 204 + async getImages(ids: string[]): Promise<Map<string, Uint8Array>> { 205 + if (!this.db) throw new Error('DB not initialized'); 206 + 207 + return new Promise((resolve, reject) => { 208 + const transaction = this.db!.transaction([STORE_NAMES.images], 'readonly'); 209 + const store = transaction.objectStore(STORE_NAMES.images); 210 + 211 + const result = new Map<string, Uint8Array>(); 212 + let completed = 0; 213 + 214 + for (const id of ids) { 215 + const request = store.get(id); 216 + 217 + request.onsuccess = () => { 218 + if (request.result) { 219 + result.set(id, request.result.data); 220 + } 221 + completed++; 222 + 223 + if (completed === ids.length) { 224 + resolve(result); 225 + } 226 + }; 227 + 228 + request.onerror = () => reject(request.error); 229 + } 230 + 231 + if (ids.length === 0) { 232 + resolve(result); 233 + } 234 + }); 235 + } 236 + 237 + async getDocuments(ids: string[]): Promise<Map<string, string>> { 238 + if (!this.db) throw new Error('DB not initialized'); 239 + 240 + return new Promise((resolve, reject) => { 241 + const transaction = this.db!.transaction([STORE_NAMES.documents], 'readonly'); 242 + const store = transaction.objectStore(STORE_NAMES.documents); 243 + 244 + const result = new Map<string, string>(); 245 + let completed = 0; 246 + 247 + for (const id of ids) { 248 + const request = store.get(id); 249 + 250 + request.onsuccess = () => { 251 + if (request.result) { 252 + result.set(id, request.result.content); 253 + } 254 + completed++; 255 + 256 + if (completed === ids.length) { 257 + resolve(result); 258 + } 259 + }; 260 + 261 + request.onerror = () => reject(request.error); 262 + } 263 + 264 + if (ids.length === 0) { 265 + resolve(result); 266 + } 267 + }); 268 + } 269 + 270 + async getStorageUsage(): Promise<number> { 271 + // Use Storage Estimation API to get IndexedDB usage 272 + if (navigator.storage && navigator.storage.estimate) { 273 + const estimate = await navigator.storage.estimate(); 274 + return estimate.usage ?? 0; 275 + } 276 + 277 + return 0; 278 + } 279 + }
+158
src/browser/adapters/localstorage.ts
··· 1 + import type { BrowserDatastoreAdapter, BrowserUrl, BrowserMetadata } from '../../harness/browser-types.js'; 2 + 3 + const PREFIX = 'localstress-'; 4 + const KEYS = { 5 + urls: PREFIX + 'urls', 6 + images: PREFIX + 'images', 7 + documents: PREFIX + 'documents', 8 + metadata: PREFIX + 'metadata' 9 + }; 10 + 11 + export class LocalStorageAdapter implements BrowserDatastoreAdapter { 12 + name = 'LocalStorage'; 13 + 14 + async init(): Promise<void> { 15 + // Clear existing data 16 + for (const key of Object.values(KEYS)) { 17 + localStorage.removeItem(key); 18 + } 19 + 20 + // Initialize storage 21 + localStorage.setItem(KEYS.urls, JSON.stringify([])); 22 + localStorage.setItem(KEYS.images, JSON.stringify([])); 23 + localStorage.setItem(KEYS.documents, JSON.stringify([])); 24 + localStorage.setItem(KEYS.metadata, JSON.stringify([])); 25 + } 26 + 27 + async cleanup(): Promise<void> { 28 + // Clear all data 29 + for (const key of Object.values(KEYS)) { 30 + localStorage.removeItem(key); 31 + } 32 + } 33 + 34 + async addUrls(urls: BrowserUrl[]): Promise<void> { 35 + const existing = this.getUrls(); 36 + const all = [...existing, ...urls]; 37 + localStorage.setItem(KEYS.urls, JSON.stringify(all)); 38 + } 39 + 40 + async addImage(id: string, data: Uint8Array): Promise<void> { 41 + const existing = this.getImages(); 42 + // Store as base64 since localStorage is string-only 43 + const base64 = this.uint8ArrayToBase64(data); 44 + const images = [...existing, { id, data: base64, size: data.byteLength }]; 45 + localStorage.setItem(KEYS.images, JSON.stringify(images)); 46 + } 47 + 48 + async addDocument(id: string, content: string): Promise<void> { 49 + const existing = this.getDocuments(); 50 + const documents = [...existing, { id, content, size: new TextEncoder().encode(content).byteLength }]; 51 + localStorage.setItem(KEYS.documents, JSON.stringify(documents)); 52 + } 53 + 54 + async addMetadata(rows: BrowserMetadata[]): Promise<void> { 55 + const existing = this.getMetadata(); 56 + const all = [...existing, ...rows]; 57 + localStorage.setItem(KEYS.metadata, JSON.stringify(all)); 58 + } 59 + 60 + async getRecentUrls(count: number): Promise<BrowserUrl[]> { 61 + const urls = this.getUrls(); 62 + return urls 63 + .sort((a, b) => b.createdAt - a.createdAt) 64 + .slice(0, count); 65 + } 66 + 67 + async getImages(ids: string[]): Promise<Map<string, Uint8Array>> { 68 + const images = this.getImages(); 69 + const result = new Map<string, Uint8Array>(); 70 + 71 + for (const id of ids) { 72 + const img = images.find(i => i.id === id); 73 + if (img && img.data) { 74 + result.set(id, this.base64ToUint8Array(img.data)); 75 + } 76 + } 77 + 78 + return result; 79 + } 80 + 81 + async getDocuments(ids: string[]): Promise<Map<string, string>> { 82 + const documents = this.getDocuments(); 83 + const result = new Map<string, string>(); 84 + 85 + for (const id of ids) { 86 + const doc = documents.find(d => d.id === id); 87 + if (doc && doc.content) { 88 + result.set(id, doc.content); 89 + } 90 + } 91 + 92 + return result; 93 + } 94 + 95 + async getStorageUsage(): Promise<number> { 96 + let total = 0; 97 + 98 + for (const key of Object.values(KEYS)) { 99 + const item = localStorage.getItem(key); 100 + if (item) { 101 + total += new TextEncoder().encode(item).byteLength; 102 + } 103 + } 104 + 105 + return total; 106 + } 107 + 108 + // Helper methods 109 + private getUrls(): BrowserUrl[] { 110 + try { 111 + const data = localStorage.getItem(KEYS.urls); 112 + return data ? JSON.parse(data) : []; 113 + } catch { 114 + return []; 115 + } 116 + } 117 + 118 + private getImages(): Array<{ id: string; data: string; size: number }> { 119 + try { 120 + const data = localStorage.getItem(KEYS.images); 121 + return data ? JSON.parse(data) : []; 122 + } catch { 123 + return []; 124 + } 125 + } 126 + 127 + private getDocuments(): Array<{ id: string; content: string; size: number }> { 128 + try { 129 + const data = localStorage.getItem(KEYS.documents); 130 + return data ? JSON.parse(data) : []; 131 + } catch { 132 + return []; 133 + } 134 + } 135 + 136 + private getMetadata(): BrowserMetadata[] { 137 + try { 138 + const data = localStorage.getItem(KEYS.metadata); 139 + return data ? JSON.parse(data) : []; 140 + } catch { 141 + return []; 142 + } 143 + } 144 + 145 + private uint8ArrayToBase64(arr: Uint8Array): string { 146 + return btoa(String.fromCharCode.apply(null, Array.from(arr))); 147 + } 148 + 149 + private base64ToUint8Array(str: string): Uint8Array { 150 + const binaryString = atob(str); 151 + const len = binaryString.length; 152 + const bytes = new Uint8Array(len); 153 + for (let i = 0; i < len; i++) { 154 + bytes[i] = binaryString.charCodeAt(i); 155 + } 156 + return bytes; 157 + } 158 + }
+179
src/browser/adapters/pglite.ts
··· 1 + import { PGlite } from '@electric-sql/pglite'; 2 + import type { BrowserDatastoreAdapter, BrowserUrl, BrowserMetadata } from '../../harness/browser-types.js'; 3 + 4 + export class PGLiteAdapter implements BrowserDatastoreAdapter { 5 + name = 'PGLite'; 6 + private db: PGlite | null = null; 7 + 8 + async init(): Promise<void> { 9 + // Use IndexedDB for storage in browser 10 + this.db = new PGlite('idb://localstress-pglite'); 11 + 12 + // Create tables 13 + await this.db.sql` 14 + CREATE TABLE IF NOT EXISTS urls ( 15 + id TEXT PRIMARY KEY, 16 + url TEXT NOT NULL, 17 + title TEXT NOT NULL, 18 + "createdAt" INTEGER NOT NULL, 19 + tags TEXT NOT NULL 20 + ); 21 + 22 + CREATE INDEX IF NOT EXISTS idx_urls_created_at ON urls("createdAt" DESC); 23 + 24 + CREATE TABLE IF NOT EXISTS images ( 25 + id TEXT PRIMARY KEY, 26 + data BYTEA NOT NULL, 27 + size INTEGER NOT NULL 28 + ); 29 + 30 + CREATE TABLE IF NOT EXISTS documents ( 31 + id TEXT PRIMARY KEY, 32 + content TEXT NOT NULL, 33 + size INTEGER NOT NULL 34 + ); 35 + 36 + CREATE TABLE IF NOT EXISTS metadata ( 37 + id TEXT PRIMARY KEY, 38 + key TEXT NOT NULL, 39 + value TEXT NOT NULL, 40 + category TEXT NOT NULL, 41 + timestamp INTEGER NOT NULL 42 + ); 43 + 44 + CREATE INDEX IF NOT EXISTS idx_metadata_timestamp ON metadata(timestamp DESC); 45 + `; 46 + } 47 + 48 + async cleanup(): Promise<void> { 49 + if (this.db) { 50 + await this.db.close(); 51 + this.db = null; 52 + } 53 + } 54 + 55 + async addUrls(urls: BrowserUrl[]): Promise<void> { 56 + if (!this.db) throw new Error('DB not initialized'); 57 + 58 + const stmt = await this.db.prepare(` 59 + INSERT INTO urls (id, url, title, "createdAt", tags) 60 + VALUES ($1, $2, $3, $4, $5) 61 + `); 62 + 63 + for (const url of urls) { 64 + await stmt.execute([ 65 + url.id, 66 + url.url, 67 + url.title, 68 + url.createdAt, 69 + JSON.stringify(url.tags) 70 + ]); 71 + } 72 + } 73 + 74 + async addImage(id: string, data: Uint8Array): Promise<void> { 75 + if (!this.db) throw new Error('DB not initialized'); 76 + 77 + await this.db.sql` 78 + INSERT INTO images (id, data, size) 79 + VALUES (${id}, ${Buffer.from(data)}, ${data.byteLength}) 80 + `; 81 + } 82 + 83 + async addDocument(id: string, content: string): Promise<void> { 84 + if (!this.db) throw new Error('DB not initialized'); 85 + 86 + await this.db.sql` 87 + INSERT INTO documents (id, content, size) 88 + VALUES (${id}, ${content}, ${new TextEncoder().encode(content).byteLength}) 89 + `; 90 + } 91 + 92 + async addMetadata(rows: BrowserMetadata[]): Promise<void> { 93 + if (!this.db) throw new Error('DB not initialized'); 94 + 95 + const stmt = await this.db.prepare(` 96 + INSERT INTO metadata (id, key, value, category, timestamp) 97 + VALUES ($1, $2, $3, $4, $5) 98 + `); 99 + 100 + for (const row of rows) { 101 + await stmt.execute([ 102 + row.id, 103 + row.key, 104 + JSON.stringify(row.value), 105 + row.category, 106 + row.timestamp 107 + ]); 108 + } 109 + } 110 + 111 + async getRecentUrls(count: number): Promise<BrowserUrl[]> { 112 + if (!this.db) throw new Error('DB not initialized'); 113 + 114 + const result = await this.db.sql<BrowserUrl[]>` 115 + SELECT id, url, title, "createdAt", tags 116 + FROM urls 117 + ORDER BY "createdAt" DESC 118 + LIMIT ${count} 119 + `; 120 + 121 + return result.map(row => ({ 122 + id: row.id, 123 + url: row.url, 124 + title: row.title, 125 + createdAt: row.createdAt as number, 126 + tags: JSON.parse(row.tags as string) 127 + })); 128 + } 129 + 130 + async getImages(ids: string[]): Promise<Map<string, Uint8Array>> { 131 + if (!this.db) throw new Error('DB not initialized'); 132 + 133 + const result = new Map<string, Uint8Array>(); 134 + 135 + for (const id of ids) { 136 + const rows = await this.db.sql` 137 + SELECT id, data FROM images WHERE id = ${id} 138 + `; 139 + 140 + if (rows.length > 0) { 141 + const row = rows[0] as { id: string; data: Uint8Array }; 142 + result.set(row.id, row.data); 143 + } 144 + } 145 + 146 + return result; 147 + } 148 + 149 + async getDocuments(ids: string[]): Promise<Map<string, string>> { 150 + if (!this.db) throw new Error('DB not initialized'); 151 + 152 + const result = new Map<string, string>(); 153 + 154 + for (const id of ids) { 155 + const rows = await this.db.sql` 156 + SELECT id, content FROM documents WHERE id = ${id} 157 + `; 158 + 159 + if (rows.length > 0) { 160 + const row = rows[0] as { id: string; content: string }; 161 + result.set(row.id, row.content); 162 + } 163 + } 164 + 165 + return result; 166 + } 167 + 168 + async getStorageUsage(): Promise<number> { 169 + if (!this.db) throw new Error('DB not initialized'); 170 + 171 + // Estimate storage using IndexedDB estimate API 172 + if (navigator.storage && navigator.storage.estimate) { 173 + const estimate = await navigator.storage.estimate(); 174 + return estimate.usage ?? 0; 175 + } 176 + 177 + return 0; 178 + } 179 + }
+180
src/browser/adapters/pouchdb.ts
··· 1 + import PouchDB from 'pouchdb'; 2 + import type { BrowserDatastoreAdapter, BrowserUrl, BrowserMetadata } from '../../harness/browser-types.js'; 3 + 4 + const DB_NAME = 'localstress-pouchdb'; 5 + 6 + export class PouchDBAdapter implements BrowserDatastoreAdapter { 7 + name = 'PouchDB'; 8 + private urls: PouchDB.Database | null = null; 9 + private images: PouchDB.Database | null = null; 10 + private documents: PouchDB.Database | null = null; 11 + private metadata: PouchDB.Database | null = null; 12 + 13 + async init(): Promise<void> { 14 + // Create separate databases for each data type 15 + this.urls = new PouchDB(`${DB_NAME}-urls`); 16 + this.images = new PouchDB(`${DB_NAME}-images`); 17 + this.documents = new PouchDB(`${DB_NAME}-documents`); 18 + this.metadata = new PouchDB(`${DB_NAME}-metadata`); 19 + 20 + // Create design documents with indexes 21 + await this.urls.put({ 22 + _id: '_design/urls', 23 + views: { 24 + by_created_at: { 25 + map: 'function(doc) { emit(doc.createdAt, doc); }' 26 + } 27 + } 28 + }).catch(() => { 29 + // Index may already exist 30 + }); 31 + 32 + await this.metadata.put({ 33 + _id: '_design/metadata', 34 + views: { 35 + by_timestamp: { 36 + map: 'function(doc) { emit(doc.timestamp, doc); }' 37 + } 38 + } 39 + }).catch(() => { 40 + // Index may already exist 41 + }); 42 + } 43 + 44 + async cleanup(): Promise<void> { 45 + try { 46 + if (this.urls) await this.urls.destroy(); 47 + if (this.images) await this.images.destroy(); 48 + if (this.documents) await this.documents.destroy(); 49 + if (this.metadata) await this.metadata.destroy(); 50 + } catch (error) { 51 + // Cleanup errors are non-critical 52 + } 53 + 54 + this.urls = null; 55 + this.images = null; 56 + this.documents = null; 57 + this.metadata = null; 58 + } 59 + 60 + async addUrls(urls: BrowserUrl[]): Promise<void> { 61 + if (!this.urls) throw new Error('DB not initialized'); 62 + 63 + const docs = urls.map(url => ({ 64 + _id: url.id, 65 + ...url 66 + })); 67 + 68 + await this.urls.bulkDocs(docs); 69 + } 70 + 71 + async addImage(id: string, data: Uint8Array): Promise<void> { 72 + if (!this.images) throw new Error('DB not initialized'); 73 + 74 + // PouchDB handles binary data via base64 encoding or attachments 75 + // Store as base64 encoded string 76 + const base64 = Buffer.from(data).toString('base64'); 77 + 78 + await this.images.put({ 79 + _id: id, 80 + data: base64, 81 + size: data.byteLength, 82 + _attachments: { 83 + 'data': { 84 + content_type: 'application/octet-stream', 85 + data: base64 86 + } 87 + } 88 + }); 89 + } 90 + 91 + async addDocument(id: string, content: string): Promise<void> { 92 + if (!this.documents) throw new Error('DB not initialized'); 93 + 94 + await this.documents.put({ 95 + _id: id, 96 + content, 97 + size: new TextEncoder().encode(content).byteLength 98 + }); 99 + } 100 + 101 + async addMetadata(rows: BrowserMetadata[]): Promise<void> { 102 + if (!this.metadata) throw new Error('DB not initialized'); 103 + 104 + const docs = rows.map(row => ({ 105 + _id: row.id, 106 + ...row 107 + })); 108 + 109 + await this.metadata.bulkDocs(docs); 110 + } 111 + 112 + async getRecentUrls(count: number): Promise<BrowserUrl[]> { 113 + if (!this.urls) throw new Error('DB not initialized'); 114 + 115 + const result = await this.urls.query('urls/by_created_at', { 116 + descending: true, 117 + limit: count, 118 + include_docs: true 119 + }); 120 + 121 + return result.rows 122 + .map(row => row.doc as any) 123 + .map(doc => ({ 124 + id: doc._id, 125 + url: doc.url, 126 + title: doc.title, 127 + createdAt: doc.createdAt, 128 + tags: doc.tags 129 + })); 130 + } 131 + 132 + async getImages(ids: string[]): Promise<Map<string, Uint8Array>> { 133 + if (!this.images) throw new Error('DB not initialized'); 134 + 135 + const result = new Map<string, Uint8Array>(); 136 + 137 + for (const id of ids) { 138 + try { 139 + const doc = (await this.images.get(id)) as any; 140 + if (doc.data) { 141 + const uint8array = new Uint8Array(Buffer.from(doc.data, 'base64')); 142 + result.set(id, uint8array); 143 + } 144 + } catch { 145 + // Document not found 146 + } 147 + } 148 + 149 + return result; 150 + } 151 + 152 + async getDocuments(ids: string[]): Promise<Map<string, string>> { 153 + if (!this.documents) throw new Error('DB not initialized'); 154 + 155 + const result = new Map<string, string>(); 156 + 157 + for (const id of ids) { 158 + try { 159 + const doc = (await this.documents.get(id)) as any; 160 + if (doc.content) { 161 + result.set(id, doc.content); 162 + } 163 + } catch { 164 + // Document not found 165 + } 166 + } 167 + 168 + return result; 169 + } 170 + 171 + async getStorageUsage(): Promise<number> { 172 + // Estimate storage using IndexedDB estimate API 173 + if (navigator.storage && navigator.storage.estimate) { 174 + const estimate = await navigator.storage.estimate(); 175 + return estimate.usage ?? 0; 176 + } 177 + 178 + return 0; 179 + } 180 + }
+366
src/browser/harness.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>LocalStress - Browser Benchmark Harness</title> 7 + <!-- Third-party libraries --> 8 + <script src="https://cdn.jsdelivr.net/npm/pouchdb@9.0.0/dist/pouchdb.min.js"></script> 9 + <script type="module"> 10 + import { PGlite } from 'https://cdn.jsdelivr.net/npm/@electric-sql/pglite/dist/index.js'; 11 + window.PGlite = PGlite; 12 + </script> 13 + <style> 14 + body { 15 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 16 + max-width: 1200px; 17 + margin: 0 auto; 18 + padding: 20px; 19 + background: #f5f5f5; 20 + } 21 + h1 { 22 + color: #333; 23 + } 24 + .container { 25 + background: white; 26 + border-radius: 8px; 27 + padding: 20px; 28 + box-shadow: 0 2px 8px rgba(0,0,0,0.1); 29 + margin-bottom: 20px; 30 + } 31 + .status { 32 + padding: 12px; 33 + border-radius: 4px; 34 + margin-bottom: 12px; 35 + font-size: 14px; 36 + } 37 + .status.info { 38 + background: #e3f2fd; 39 + color: #1976d2; 40 + } 41 + .status.running { 42 + background: #fff3e0; 43 + color: #f57c00; 44 + } 45 + .status.error { 46 + background: #ffebee; 47 + color: #d32f2f; 48 + } 49 + .status.success { 50 + background: #e8f5e9; 51 + color: #388e3c; 52 + } 53 + .progress { 54 + width: 100%; 55 + height: 4px; 56 + background: #e0e0e0; 57 + border-radius: 2px; 58 + overflow: hidden; 59 + margin-bottom: 20px; 60 + } 61 + .progress-bar { 62 + height: 100%; 63 + background: #4caf50; 64 + transition: width 0.3s ease; 65 + } 66 + button { 67 + background: #1976d2; 68 + color: white; 69 + border: none; 70 + padding: 10px 20px; 71 + border-radius: 4px; 72 + cursor: pointer; 73 + font-size: 14px; 74 + margin-right: 10px; 75 + } 76 + button:hover { 77 + background: #1565c0; 78 + } 79 + button:disabled { 80 + background: #ccc; 81 + cursor: not-allowed; 82 + } 83 + .results { 84 + font-family: 'Monaco', 'Menlo', monospace; 85 + background: #f9f9f9; 86 + padding: 12px; 87 + border-radius: 4px; 88 + border: 1px solid #e0e0e0; 89 + max-height: 400px; 90 + overflow-y: auto; 91 + font-size: 12px; 92 + line-height: 1.4; 93 + white-space: pre-wrap; 94 + word-break: break-word; 95 + } 96 + .benchmark-controls { 97 + margin-bottom: 20px; 98 + } 99 + .store-select { 100 + padding: 8px; 101 + border-radius: 4px; 102 + border: 1px solid #ccc; 103 + margin-right: 10px; 104 + } 105 + </style> 106 + </head> 107 + <body> 108 + <h1>LocalStress - Browser Datastore Benchmark</h1> 109 + 110 + <div class="container"> 111 + <div class="benchmark-controls"> 112 + <label for="store-select">Select Store:</label> 113 + <select id="store-select" class="store-select"> 114 + <option value="pglite">PGLite</option> 115 + <option value="pouchdb">PouchDB</option> 116 + <option value="indexeddb">IndexedDB</option> 117 + <option value="localstorage">LocalStorage</option> 118 + </select> 119 + <label for="iterations-input">Iterations:</label> 120 + <input type="number" id="iterations-input" value="3" min="1" max="10" style="width: 60px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"> 121 + <button onclick="startBenchmark()">Start Benchmark</button> 122 + <button onclick="clearResults()">Clear Results</button> 123 + </div> 124 + 125 + <div id="status" class="status info" style="display: none;"></div> 126 + <div class="progress" style="display: none;" id="progress"> 127 + <div class="progress-bar" id="progress-bar"></div> 128 + </div> 129 + <div id="output" class="results"></div> 130 + </div> 131 + 132 + <script type="module"> 133 + // Global functions for HTML 134 + window.startBenchmark = startBenchmark; 135 + window.clearResults = clearResults; 136 + 137 + const statusEl = document.getElementById('status'); 138 + const outputEl = document.getElementById('output'); 139 + const progressEl = document.getElementById('progress'); 140 + const progressBar = document.getElementById('progress-bar'); 141 + const storeSelect = document.getElementById('store-select'); 142 + const iterationsInput = document.getElementById('iterations-input'); 143 + 144 + let currentIteration = 0; 145 + let totalIterations = 0; 146 + 147 + function log(message) { 148 + const timestamp = new Date().toLocaleTimeString(); 149 + outputEl.textContent += `[${timestamp}] ${message}\n`; 150 + outputEl.scrollTop = outputEl.scrollHeight; 151 + } 152 + 153 + function clearResults() { 154 + outputEl.textContent = ''; 155 + statusEl.style.display = 'none'; 156 + } 157 + 158 + function showStatus(message, type = 'info') { 159 + statusEl.textContent = message; 160 + statusEl.className = `status ${type}`; 161 + statusEl.style.display = 'block'; 162 + } 163 + 164 + function updateProgress() { 165 + const progress = totalIterations > 0 ? (currentIteration / totalIterations) * 100 : 0; 166 + progressBar.style.width = progress + '%'; 167 + } 168 + 169 + async function startBenchmark() { 170 + const storeName = storeSelect.value; 171 + totalIterations = parseInt(iterationsInput.value); 172 + 173 + clearResults(); 174 + progressEl.style.display = 'block'; 175 + showStatus(`Starting benchmark for ${storeName}...`, 'running'); 176 + 177 + try { 178 + // Dynamic import of adapters 179 + const { PGLiteAdapter } = await import('./adapters/pglite.js'); 180 + const { PouchDBAdapter } = await import('./adapters/pouchdb.js'); 181 + const { IndexedDBAdapter } = await import('./adapters/indexeddb.js'); 182 + const { LocalStorageAdapter } = await import('./adapters/localstorage.js'); 183 + const { runBrowserBenchmarks } = await import('./harness.js'); 184 + 185 + const adapters = { 186 + pglite: new PGLiteAdapter(), 187 + pouchdb: new PouchDBAdapter(), 188 + indexeddb: new IndexedDBAdapter(), 189 + localstorage: new LocalStorageAdapter() 190 + }; 191 + 192 + const adapter = adapters[storeName]; 193 + if (!adapter) { 194 + showStatus(`Unknown store: ${storeName}`, 'error'); 195 + return; 196 + } 197 + 198 + log(`Starting benchmark: ${adapter.name} with ${totalIterations} iterations`); 199 + log(`User Agent: ${navigator.userAgent}`); 200 + log(''); 201 + 202 + // Generate test data 203 + log('Generating test data...'); 204 + const testData = window.generateTestData(); 205 + log(` URLs: ${testData.urls.length}`); 206 + log(` Images: ${testData.images.length}`); 207 + log(` Documents: ${testData.documents.length}`); 208 + log(` Metadata: ${testData.metadata.length}`); 209 + log(''); 210 + 211 + const results = []; 212 + 213 + for (currentIteration = 1; currentIteration <= totalIterations; currentIteration++) { 214 + updateProgress(); 215 + log(`\n[Run ${currentIteration}/${totalIterations}]`); 216 + 217 + const result = await runBrowserBenchmarks(adapter, testData, (phase, durationMs, failed, error) => { 218 + if (durationMs !== undefined) { 219 + if (failed) { 220 + log(` ✗ ${phase} FAILED (${durationMs.toFixed(0)}ms) - ${error}`); 221 + } else { 222 + const duration = durationMs < 1000 ? `${durationMs.toFixed(0)}ms` : `${(durationMs / 1000).toFixed(2)}s`; 223 + log(` ✓ ${phase} (${duration})`); 224 + } 225 + } else { 226 + log(` → ${phase}`); 227 + } 228 + }); 229 + 230 + results.push(result); 231 + 232 + // Small delay between iterations 233 + await new Promise(resolve => setTimeout(resolve, 500)); 234 + } 235 + 236 + updateProgress(); 237 + log('\n' + '='.repeat(60)); 238 + log('BENCHMARK COMPLETE'); 239 + log('='.repeat(60)); 240 + 241 + // Send results back to parent 242 + if (window.parent !== window) { 243 + window.parent.postMessage({ 244 + type: 'benchmark-results', 245 + data: results 246 + }, '*'); 247 + } 248 + 249 + showStatus(`Benchmark complete! Results sent.`, 'success'); 250 + 251 + } catch (error) { 252 + console.error(error); 253 + log(`\n❌ ERROR: ${error.message}`); 254 + log(error.stack); 255 + showStatus(`Benchmark failed: ${error.message}`, 'error'); 256 + } 257 + } 258 + 259 + // Generate test data in browser 260 + window.generateTestData = function() { 261 + function randomChoice(arr) { 262 + return arr[Math.floor(Math.random() * arr.length)]; 263 + } 264 + 265 + function randomTags() { 266 + const TAGS = ['tech', 'news', 'tutorial', 'reference', 'api', 'documentation', 'blog', 'product', 'marketing', 'support', 'faq', 'guide']; 267 + const count = Math.floor(Math.random() * 4) + 1; 268 + const tags = []; 269 + for (let i = 0; i < count; i++) { 270 + const tag = randomChoice(TAGS); 271 + if (!tags.includes(tag)) tags.push(tag); 272 + } 273 + return tags; 274 + } 275 + 276 + // Generate URLs 277 + const DOMAINS = ['example.com', 'test.org', 'demo.net', 'sample.io', 'docs.dev']; 278 + const PATHS = ['/articles', '/posts', '/docs', '/api', '/users', '/products']; 279 + const urls = []; 280 + const baseTime = Date.now(); 281 + const URL_COUNT = 1000; 282 + 283 + for (let i = 0; i < URL_COUNT; i++) { 284 + const domain = randomChoice(DOMAINS); 285 + const path = randomChoice(PATHS); 286 + const id = String(i).padStart(5, '0'); 287 + urls.push({ 288 + id: `url-${id}`, 289 + url: `https://${domain}${path}/${id}`, 290 + title: `Page ${id} - ${domain}`, 291 + createdAt: baseTime - (URL_COUNT - i) * 1000, 292 + tags: randomTags() 293 + }); 294 + } 295 + 296 + // Generate Metadata 297 + const CATEGORIES = ['analytics', 'user', 'system', 'config', 'cache', 'session', 'event', 'metric', 'log', 'audit']; 298 + const KEY_PREFIXES = ['page_view', 'click', 'session', 'user_action', 'api_call', 'error', 'warning', 'info']; 299 + const metadata = []; 300 + const METADATA_COUNT = 10000; 301 + 302 + for (let i = 0; i < METADATA_COUNT; i++) { 303 + const id = String(i).padStart(6, '0'); 304 + const prefix = randomChoice(KEY_PREFIXES); 305 + const valueType = Math.floor(Math.random() * 3); 306 + let value; 307 + switch (valueType) { 308 + case 0: 309 + value = Math.floor(Math.random() * 10000); 310 + break; 311 + case 1: 312 + value = Math.random() > 0.5; 313 + break; 314 + default: 315 + value = `value_${Math.floor(Math.random() * 1000)}`; 316 + } 317 + 318 + metadata.push({ 319 + id: `meta-${id}`, 320 + key: `${prefix}_${i % 1000}`, 321 + value, 322 + category: randomChoice(CATEGORIES), 323 + timestamp: baseTime - (METADATA_COUNT - i) * 100 324 + }); 325 + } 326 + 327 + // Generate Images (synthetic binary data) 328 + const images = []; 329 + const IMAGE_COUNT = 20; 330 + const IMAGE_SIZE = 50000; // 50KB per image 331 + for (let i = 0; i < IMAGE_COUNT; i++) { 332 + const data = new Uint8Array(IMAGE_SIZE); 333 + crypto.getRandomValues(data); 334 + images.push({ 335 + id: `img-${String(i).padStart(4, '0')}`, 336 + filename: `image-${i}.png`, 337 + data 338 + }); 339 + } 340 + 341 + // Generate Documents 342 + const documents = []; 343 + const DOCUMENT_COUNT = 500; 344 + const LOREM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; 345 + 346 + for (let i = 0; i < DOCUMENT_COUNT; i++) { 347 + let content = ''; 348 + for (let j = 0; j < 10; j++) { 349 + content += LOREM + ' '; 350 + } 351 + documents.push({ 352 + id: `doc-${String(i).padStart(5, '0')}`, 353 + filename: `document-${i}.txt`, 354 + content 355 + }); 356 + } 357 + 358 + return { urls, metadata, images, documents }; 359 + }; 360 + 361 + // Show initial message 362 + log('Browser Benchmark Harness Ready'); 363 + log('Select a datastore and click "Start Benchmark" to begin'); 364 + </script> 365 + </body> 366 + </html>
+131
src/browser/harness.ts
··· 1 + import type { BrowserDatastoreAdapter, BrowserStoreBenchmarkResults, BrowserBenchmarkResult, BrowserTestData } from '../harness/browser-types.js'; 2 + 3 + export async function runBrowserBenchmarks( 4 + adapter: BrowserDatastoreAdapter, 5 + testData: BrowserTestData, 6 + onProgress?: (phase: string, durationMs?: number, failed?: boolean, error?: string) => void 7 + ): Promise<BrowserStoreBenchmarkResults> { 8 + const results: BrowserStoreBenchmarkResults = { 9 + storeName: adapter.name, 10 + userAgent: navigator.userAgent, 11 + init: { name: 'init', durationMs: 0 }, 12 + writes: { 13 + allUrls: { name: 'addUrls', durationMs: 0 }, 14 + allImages: { name: 'addImages', durationMs: 0 }, 15 + allDocuments: { name: 'addDocuments', durationMs: 0 }, 16 + allMetadata: { name: 'addMetadata', durationMs: 0 } 17 + }, 18 + reads: { 19 + recentUrls: { name: 'getRecentUrls', durationMs: 0 }, 20 + randomImages: { name: 'getRandomImages', durationMs: 0 }, 21 + randomDocuments: { name: 'getRandomDocuments', durationMs: 0 } 22 + }, 23 + storage: { 24 + totalBytes: 0 25 + } 26 + }; 27 + 28 + try { 29 + // Init 30 + onProgress?.('Initializing datastore'); 31 + const initStart = performance.now(); 32 + await adapter.init(); 33 + results.init.durationMs = performance.now() - initStart; 34 + onProgress?.('init', results.init.durationMs); 35 + 36 + // Write: URLs 37 + onProgress?.('Writing URLs'); 38 + const urlsStart = performance.now(); 39 + await adapter.addUrls(testData.urls); 40 + results.writes.allUrls.durationMs = performance.now() - urlsStart; 41 + results.writes.allUrls.itemCount = testData.urls.length; 42 + onProgress?.('addUrls', results.writes.allUrls.durationMs); 43 + 44 + // Write: Images 45 + onProgress?.('Writing images'); 46 + const imagesStart = performance.now(); 47 + let imageBytes = 0; 48 + for (const img of testData.images) { 49 + await adapter.addImage(img.id, img.data); 50 + imageBytes += img.data.byteLength; 51 + } 52 + results.writes.allImages.durationMs = performance.now() - imagesStart; 53 + results.writes.allImages.itemCount = testData.images.length; 54 + results.writes.allImages.bytesProcessed = imageBytes; 55 + onProgress?.('addImages', results.writes.allImages.durationMs); 56 + 57 + // Write: Documents 58 + onProgress?.('Writing documents'); 59 + const docsStart = performance.now(); 60 + let docBytes = 0; 61 + for (const doc of testData.documents) { 62 + await adapter.addDocument(doc.id, doc.content); 63 + docBytes += new TextEncoder().encode(doc.content).byteLength; 64 + } 65 + results.writes.allDocuments.durationMs = performance.now() - docsStart; 66 + results.writes.allDocuments.itemCount = testData.documents.length; 67 + results.writes.allDocuments.bytesProcessed = docBytes; 68 + onProgress?.('addDocuments', results.writes.allDocuments.durationMs); 69 + 70 + // Write: Metadata 71 + onProgress?.('Writing metadata'); 72 + const metaStart = performance.now(); 73 + await adapter.addMetadata(testData.metadata); 74 + results.writes.allMetadata.durationMs = performance.now() - metaStart; 75 + results.writes.allMetadata.itemCount = testData.metadata.length; 76 + onProgress?.('addMetadata', results.writes.allMetadata.durationMs); 77 + 78 + // Read: Recent URLs 79 + onProgress?.('Reading recent URLs'); 80 + const recentUrlsStart = performance.now(); 81 + await adapter.getRecentUrls(100); 82 + results.reads.recentUrls.durationMs = performance.now() - recentUrlsStart; 83 + results.reads.recentUrls.itemCount = 100; 84 + onProgress?.('getRecentUrls', results.reads.recentUrls.durationMs); 85 + 86 + // Read: Random images 87 + onProgress?.('Reading random images'); 88 + const imageIds = testData.images.slice(0, 10).map(img => img.id); 89 + const randomImagesStart = performance.now(); 90 + const imgs = await adapter.getImages(imageIds); 91 + results.reads.randomImages.durationMs = performance.now() - randomImagesStart; 92 + results.reads.randomImages.itemCount = imgs.size; 93 + let imgBytes = 0; 94 + for (const data of imgs.values()) { 95 + imgBytes += data.byteLength; 96 + } 97 + results.reads.randomImages.bytesProcessed = imgBytes; 98 + onProgress?.('getImages', results.reads.randomImages.durationMs); 99 + 100 + // Read: Random documents 101 + onProgress?.('Reading random documents'); 102 + const docIds = testData.documents.slice(0, Math.min(1000, testData.documents.length)).map(doc => doc.id); 103 + const randomDocsStart = performance.now(); 104 + const docs = await adapter.getDocuments(docIds); 105 + results.reads.randomDocuments.durationMs = performance.now() - randomDocsStart; 106 + results.reads.randomDocuments.itemCount = docs.size; 107 + let docSize = 0; 108 + for (const content of docs.values()) { 109 + docSize += new TextEncoder().encode(content).byteLength; 110 + } 111 + results.reads.randomDocuments.bytesProcessed = docSize; 112 + onProgress?.('getDocuments', results.reads.randomDocuments.durationMs); 113 + 114 + // Storage usage 115 + onProgress?.('Measuring storage'); 116 + results.storage.totalBytes = await adapter.getStorageUsage(); 117 + 118 + // Cleanup 119 + await adapter.cleanup(); 120 + 121 + } catch (error) { 122 + console.error('Benchmark error:', error); 123 + const err = error as Error; 124 + return { 125 + ...results, 126 + init: { ...results.init, failed: true, error: err.message } 127 + }; 128 + } 129 + 130 + return results; 131 + }
+1202
src/browser/runner.ts
··· 1 + import { chromium, firefox, webkit, Browser } from '@playwright/test'; 2 + import { createServer, Server } from 'http'; 3 + import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 4 + import { dirname, join } from 'path'; 5 + import { fileURLToPath } from 'url'; 6 + import * as vega from 'vega'; 7 + import * as vegaLite from 'vega-lite'; 8 + import sharp from 'sharp'; 9 + import type { BrowserStoreBenchmarkResults, BrowserBenchmarkResult } from '../harness/browser-types.js'; 10 + 11 + const __filename = fileURLToPath(import.meta.url); 12 + const __dirname = dirname(__filename); 13 + 14 + const HARNESS_PATH = join(__dirname, 'harness.html'); 15 + const BROWSERS = ['chromium', 'firefox', 'webkit'] as const; 16 + const STORES = ['indexeddb', 'localstorage', 'pouchdb', 'pglite'] as const; 17 + const ITERATIONS = 10; 18 + const PORT = 9876; 19 + 20 + type BrowserType = typeof BROWSERS[number]; 21 + type StoreType = typeof STORES[number]; 22 + 23 + interface StoreRunResult { 24 + success: boolean; 25 + error?: string; 26 + init: BrowserBenchmarkResult; 27 + writes: { 28 + allUrls: BrowserBenchmarkResult; 29 + allMetadata: BrowserBenchmarkResult; 30 + allImages: BrowserBenchmarkResult; 31 + allDocuments: BrowserBenchmarkResult; 32 + }; 33 + reads: { 34 + recentUrls: BrowserBenchmarkResult; 35 + randomImages: BrowserBenchmarkResult; 36 + randomDocuments: BrowserBenchmarkResult; 37 + }; 38 + storage: { totalBytes: number }; 39 + } 40 + 41 + interface BrowserRunResults { 42 + browserName: string; 43 + userAgent: string; 44 + stores: Record<StoreType, BrowserStoreBenchmarkResults>; 45 + success: boolean; 46 + error?: string; 47 + } 48 + 49 + function formatDuration(ms: number): string { 50 + if (ms < 1000) return `${ms.toFixed(0)}ms`; 51 + if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; 52 + return `${(ms / 60000).toFixed(2)}m`; 53 + } 54 + 55 + function medianExcludingExtremes(values: number[]): number { 56 + if (values.length <= 2) return values[0] ?? 0; 57 + const sorted = [...values].sort((a, b) => a - b); 58 + const trimmed = sorted.slice(1, -1); 59 + const mid = Math.floor(trimmed.length / 2); 60 + if (trimmed.length % 2 === 0) { 61 + return (trimmed[mid - 1] + trimmed[mid]) / 2; 62 + } 63 + return trimmed[mid]; 64 + } 65 + 66 + function aggregateBenchmarkResults(results: BrowserBenchmarkResult[]): BrowserBenchmarkResult { 67 + const successful = results.filter(r => !r.failed); 68 + if (successful.length === 0) return results[0]; 69 + 70 + const durations = successful.map(r => r.durationMs); 71 + return { 72 + name: successful[0].name, 73 + durationMs: medianExcludingExtremes(durations), 74 + itemCount: successful[0].itemCount, 75 + bytesProcessed: successful[0].bytesProcessed, 76 + failed: false 77 + }; 78 + } 79 + 80 + function aggregateStoreRuns(runs: StoreRunResult[], storeName: string, userAgent: string): BrowserStoreBenchmarkResults { 81 + const successful = runs.filter(r => r.success); 82 + if (successful.length === 0) { 83 + return { 84 + storeName, 85 + userAgent, 86 + init: { name: 'init', durationMs: 0, failed: true, error: runs[0]?.error }, 87 + writes: { 88 + allUrls: { name: 'allUrls', durationMs: 0, failed: true }, 89 + allMetadata: { name: 'allMetadata', durationMs: 0, failed: true }, 90 + allImages: { name: 'allImages', durationMs: 0, failed: true }, 91 + allDocuments: { name: 'allDocuments', durationMs: 0, failed: true } 92 + }, 93 + reads: { 94 + recentUrls: { name: 'recentUrls', durationMs: 0, failed: true }, 95 + randomImages: { name: 'randomImages', durationMs: 0, failed: true }, 96 + randomDocuments: { name: 'randomDocuments', durationMs: 0, failed: true } 97 + }, 98 + storage: { totalBytes: 0 } 99 + }; 100 + } 101 + 102 + return { 103 + storeName, 104 + userAgent, 105 + init: aggregateBenchmarkResults(successful.map(r => r.init)), 106 + writes: { 107 + allUrls: aggregateBenchmarkResults(successful.map(r => r.writes.allUrls)), 108 + allMetadata: aggregateBenchmarkResults(successful.map(r => r.writes.allMetadata)), 109 + allImages: aggregateBenchmarkResults(successful.map(r => r.writes.allImages)), 110 + allDocuments: aggregateBenchmarkResults(successful.map(r => r.writes.allDocuments)) 111 + }, 112 + reads: { 113 + recentUrls: aggregateBenchmarkResults(successful.map(r => r.reads.recentUrls)), 114 + randomImages: aggregateBenchmarkResults(successful.map(r => r.reads.randomImages)), 115 + randomDocuments: aggregateBenchmarkResults(successful.map(r => r.reads.randomDocuments)) 116 + }, 117 + storage: { 118 + totalBytes: medianExcludingExtremes(successful.map(r => r.storage.totalBytes)) 119 + } 120 + }; 121 + } 122 + 123 + const BENCHMARK_TIMEOUT = 30000; // 30 seconds per benchmark 124 + 125 + async function benchmarkStore(page: any, store: StoreType): Promise<StoreRunResult> { 126 + const script = ` 127 + (async function(storeName) { 128 + const testData = window.generateTestData(); 129 + 130 + function createResult(name, durationMs, itemCount, bytesProcessed) { 131 + return { name: name, durationMs: durationMs, itemCount: itemCount, bytesProcessed: bytesProcessed, failed: false }; 132 + } 133 + 134 + if (storeName === 'indexeddb') { 135 + const DB_NAME = 'localstress-bench'; 136 + 137 + try { 138 + // Delete old DB 139 + await new Promise(function(resolve, reject) { 140 + const req = indexedDB.deleteDatabase(DB_NAME); 141 + req.onsuccess = function() { resolve(); }; 142 + req.onerror = function() { reject(req.error); }; 143 + }); 144 + 145 + // Init 146 + const initStart = performance.now(); 147 + const db = await new Promise(function(resolve, reject) { 148 + const req = indexedDB.open(DB_NAME, 1); 149 + req.onupgradeneeded = function(e) { 150 + const db = e.target.result; 151 + const urlStore = db.createObjectStore('urls', { keyPath: 'id' }); 152 + urlStore.createIndex('createdAt', 'createdAt'); 153 + db.createObjectStore('images', { keyPath: 'id' }); 154 + db.createObjectStore('documents', { keyPath: 'id' }); 155 + const metaStore = db.createObjectStore('metadata', { keyPath: 'id' }); 156 + metaStore.createIndex('timestamp', 'timestamp'); 157 + }; 158 + req.onsuccess = function() { resolve(req.result); }; 159 + req.onerror = function() { reject(req.error); }; 160 + }); 161 + const initDuration = performance.now() - initStart; 162 + 163 + // Write URLs 164 + const urlsStart = performance.now(); 165 + await new Promise(function(resolve, reject) { 166 + const tx = db.transaction(['urls'], 'readwrite'); 167 + const store = tx.objectStore('urls'); 168 + for (let i = 0; i < testData.urls.length; i++) { 169 + store.add(testData.urls[i]); 170 + } 171 + tx.oncomplete = function() { resolve(); }; 172 + tx.onerror = function() { reject(tx.error); }; 173 + }); 174 + const urlsDuration = performance.now() - urlsStart; 175 + 176 + // Write Metadata 177 + const metaStart = performance.now(); 178 + await new Promise(function(resolve, reject) { 179 + const tx = db.transaction(['metadata'], 'readwrite'); 180 + const store = tx.objectStore('metadata'); 181 + for (let i = 0; i < testData.metadata.length; i++) { 182 + store.add(testData.metadata[i]); 183 + } 184 + tx.oncomplete = function() { resolve(); }; 185 + tx.onerror = function() { reject(tx.error); }; 186 + }); 187 + const metaDuration = performance.now() - metaStart; 188 + 189 + // Write Images 190 + const imagesStart = performance.now(); 191 + let imageBytes = 0; 192 + await new Promise(function(resolve, reject) { 193 + const tx = db.transaction(['images'], 'readwrite'); 194 + const store = tx.objectStore('images'); 195 + for (let i = 0; i < testData.images.length; i++) { 196 + const img = testData.images[i]; 197 + store.add({ id: img.id, data: img.data, size: img.data.byteLength }); 198 + imageBytes += img.data.byteLength; 199 + } 200 + tx.oncomplete = function() { resolve(); }; 201 + tx.onerror = function() { reject(tx.error); }; 202 + }); 203 + const imagesDuration = performance.now() - imagesStart; 204 + 205 + // Write Documents 206 + const docsStart = performance.now(); 207 + let docBytes = 0; 208 + await new Promise(function(resolve, reject) { 209 + const tx = db.transaction(['documents'], 'readwrite'); 210 + const store = tx.objectStore('documents'); 211 + for (let i = 0; i < testData.documents.length; i++) { 212 + const doc = testData.documents[i]; 213 + const size = new TextEncoder().encode(doc.content).byteLength; 214 + store.add({ id: doc.id, content: doc.content, size: size }); 215 + docBytes += size; 216 + } 217 + tx.oncomplete = function() { resolve(); }; 218 + tx.onerror = function() { reject(tx.error); }; 219 + }); 220 + const docsDuration = performance.now() - docsStart; 221 + 222 + // Read recent URLs 223 + const readUrlsStart = performance.now(); 224 + await new Promise(function(resolve, reject) { 225 + const tx = db.transaction(['urls'], 'readonly'); 226 + const idx = tx.objectStore('urls').index('createdAt'); 227 + const req = idx.openCursor(null, 'prev'); 228 + const results = []; 229 + req.onsuccess = function(e) { 230 + const cursor = e.target.result; 231 + if (cursor && results.length < 100) { 232 + results.push(cursor.value); 233 + cursor.continue(); 234 + } else { 235 + resolve(results); 236 + } 237 + }; 238 + req.onerror = function() { reject(req.error); }; 239 + }); 240 + const readUrlsDuration = performance.now() - readUrlsStart; 241 + 242 + // Read random images 243 + const readImagesStart = performance.now(); 244 + const imageIds = testData.images.slice(0, Math.min(10, testData.images.length)).map(function(i) { return i.id; }); 245 + let readImageBytes = 0; 246 + if (imageIds.length > 0) { 247 + await new Promise(function(resolve, reject) { 248 + const tx = db.transaction(['images'], 'readonly'); 249 + const store = tx.objectStore('images'); 250 + let completed = 0; 251 + for (let i = 0; i < imageIds.length; i++) { 252 + const req = store.get(imageIds[i]); 253 + req.onsuccess = function() { 254 + if (req.result) readImageBytes += req.result.data.byteLength; 255 + completed++; 256 + if (completed === imageIds.length) resolve(); 257 + }; 258 + req.onerror = function() { reject(req.error); }; 259 + } 260 + }); 261 + } 262 + const readImagesDuration = performance.now() - readImagesStart; 263 + 264 + // Read random documents 265 + const readDocsStart = performance.now(); 266 + const docIds = testData.documents.slice(0, Math.min(50, testData.documents.length)).map(function(d) { return d.id; }); 267 + let readDocBytes = 0; 268 + if (docIds.length > 0) { 269 + await new Promise(function(resolve, reject) { 270 + const tx = db.transaction(['documents'], 'readonly'); 271 + const store = tx.objectStore('documents'); 272 + let completed = 0; 273 + for (let i = 0; i < docIds.length; i++) { 274 + const req = store.get(docIds[i]); 275 + req.onsuccess = function() { 276 + if (req.result) readDocBytes += new TextEncoder().encode(req.result.content).byteLength; 277 + completed++; 278 + if (completed === docIds.length) resolve(); 279 + }; 280 + req.onerror = function() { reject(req.error); }; 281 + } 282 + }); 283 + } 284 + const readDocsDuration = performance.now() - readDocsStart; 285 + 286 + // Storage 287 + const estimate = await navigator.storage.estimate(); 288 + const storageBytes = estimate.usage || 0; 289 + 290 + db.close(); 291 + await new Promise(function(resolve) { 292 + const req = indexedDB.deleteDatabase(DB_NAME); 293 + req.onsuccess = function() { resolve(); }; 294 + req.onerror = function() { resolve(); }; 295 + }); 296 + 297 + return { 298 + success: true, 299 + init: createResult('init', initDuration), 300 + writes: { 301 + allUrls: createResult('allUrls', urlsDuration, testData.urls.length), 302 + allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), 303 + allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), 304 + allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) 305 + }, 306 + reads: { 307 + recentUrls: createResult('recentUrls', readUrlsDuration, 100), 308 + randomImages: createResult('randomImages', readImagesDuration, imageIds.length, readImageBytes), 309 + randomDocuments: createResult('randomDocuments', readDocsDuration, docIds.length, readDocBytes) 310 + }, 311 + storage: { totalBytes: storageBytes } 312 + }; 313 + } catch (error) { 314 + return { success: false, error: error.message }; 315 + } 316 + 317 + } else if (storeName === 'localstorage') { 318 + try { 319 + localStorage.clear(); 320 + 321 + // Init 322 + const initStart = performance.now(); 323 + const initDuration = performance.now() - initStart; 324 + 325 + // Write URLs 326 + const urlsStart = performance.now(); 327 + localStorage.setItem('urls', JSON.stringify(testData.urls)); 328 + const urlsDuration = performance.now() - urlsStart; 329 + 330 + // Write Metadata 331 + const metaStart = performance.now(); 332 + localStorage.setItem('metadata', JSON.stringify(testData.metadata)); 333 + const metaDuration = performance.now() - metaStart; 334 + 335 + // Images - base64 encode 336 + const imagesStart = performance.now(); 337 + let imageBytes = 0; 338 + const imageData = []; 339 + for (let i = 0; i < testData.images.length; i++) { 340 + const img = testData.images[i]; 341 + const base64 = btoa(String.fromCharCode.apply(null, img.data)); 342 + imageData.push({ id: img.id, data: base64 }); 343 + imageBytes += img.data.byteLength; 344 + } 345 + localStorage.setItem('images', JSON.stringify(imageData)); 346 + const imagesDuration = performance.now() - imagesStart; 347 + 348 + // Documents 349 + const docsStart = performance.now(); 350 + let docBytes = 0; 351 + for (let i = 0; i < testData.documents.length; i++) { 352 + docBytes += new TextEncoder().encode(testData.documents[i].content).byteLength; 353 + } 354 + localStorage.setItem('documents', JSON.stringify(testData.documents)); 355 + const docsDuration = performance.now() - docsStart; 356 + 357 + // Read URLs 358 + const readUrlsStart = performance.now(); 359 + const urls = JSON.parse(localStorage.getItem('urls') || '[]'); 360 + urls.sort(function(a, b) { return b.createdAt - a.createdAt; }).slice(0, 100); 361 + const readUrlsDuration = performance.now() - readUrlsStart; 362 + 363 + // Read Images 364 + const readImagesStart = performance.now(); 365 + const images = JSON.parse(localStorage.getItem('images') || '[]'); 366 + let readImageBytes = 0; 367 + for (let i = 0; i < Math.min(10, images.length); i++) { 368 + const binary = atob(images[i].data); 369 + readImageBytes += binary.length; 370 + } 371 + const readImagesDuration = performance.now() - readImagesStart; 372 + 373 + // Read Documents 374 + const readDocsStart = performance.now(); 375 + const docs = JSON.parse(localStorage.getItem('documents') || '[]'); 376 + let readDocBytes = 0; 377 + for (let i = 0; i < Math.min(50, docs.length); i++) { 378 + readDocBytes += new TextEncoder().encode(docs[i].content).byteLength; 379 + } 380 + const readDocsDuration = performance.now() - readDocsStart; 381 + 382 + // Storage estimate 383 + const estimate = await navigator.storage.estimate(); 384 + const storageBytes = estimate.usage || 0; 385 + 386 + localStorage.clear(); 387 + 388 + return { 389 + success: true, 390 + init: createResult('init', initDuration), 391 + writes: { 392 + allUrls: createResult('allUrls', urlsDuration, testData.urls.length), 393 + allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), 394 + allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), 395 + allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) 396 + }, 397 + reads: { 398 + recentUrls: createResult('recentUrls', readUrlsDuration, 100), 399 + randomImages: createResult('randomImages', readImagesDuration, 10, readImageBytes), 400 + randomDocuments: createResult('randomDocuments', readDocsDuration, 50, readDocBytes) 401 + }, 402 + storage: { totalBytes: storageBytes } 403 + }; 404 + } catch (error) { 405 + return { success: false, error: error.message }; 406 + } 407 + 408 + } else if (storeName === 'pouchdb') { 409 + if (!window.PouchDB) { 410 + return { success: false, error: 'PouchDB not loaded' }; 411 + } 412 + 413 + const PouchDB = window.PouchDB; 414 + const DB_PREFIX = 'localstress-pouchdb-' + Date.now(); 415 + 416 + try { 417 + // Init - create databases 418 + const initStart = performance.now(); 419 + const urlsDb = new PouchDB(DB_PREFIX + '-urls'); 420 + const metaDb = new PouchDB(DB_PREFIX + '-metadata'); 421 + const imagesDb = new PouchDB(DB_PREFIX + '-images'); 422 + const docsDb = new PouchDB(DB_PREFIX + '-documents'); 423 + const initDuration = performance.now() - initStart; 424 + 425 + // Write URLs 426 + const urlsStart = performance.now(); 427 + const urlDocs = testData.urls.map(function(url) { 428 + return { _id: url.id, url: url.url, title: url.title, createdAt: url.createdAt, tags: url.tags }; 429 + }); 430 + await urlsDb.bulkDocs(urlDocs); 431 + const urlsDuration = performance.now() - urlsStart; 432 + 433 + // Write Metadata 434 + const metaStart = performance.now(); 435 + const metaDocs = testData.metadata.map(function(m) { 436 + return { _id: m.id, key: m.key, value: m.value, category: m.category, timestamp: m.timestamp }; 437 + }); 438 + await metaDb.bulkDocs(metaDocs); 439 + const metaDuration = performance.now() - metaStart; 440 + 441 + // Write Images (as base64) 442 + const imagesStart = performance.now(); 443 + let imageBytes = 0; 444 + for (let i = 0; i < testData.images.length; i++) { 445 + const img = testData.images[i]; 446 + // Convert Uint8Array to base64 in chunks to avoid stack overflow 447 + let binary = ''; 448 + const bytes = img.data; 449 + const chunkSize = 8192; 450 + for (let j = 0; j < bytes.length; j += chunkSize) { 451 + const chunk = bytes.subarray(j, j + chunkSize); 452 + binary += String.fromCharCode.apply(null, chunk); 453 + } 454 + const base64 = btoa(binary); 455 + await imagesDb.put({ _id: img.id, data: base64, size: img.data.byteLength }); 456 + imageBytes += img.data.byteLength; 457 + } 458 + const imagesDuration = performance.now() - imagesStart; 459 + 460 + // Write Documents 461 + const docsStart = performance.now(); 462 + let docBytes = 0; 463 + const docDocs = testData.documents.map(function(doc) { 464 + const size = new TextEncoder().encode(doc.content).byteLength; 465 + docBytes += size; 466 + return { _id: doc.id, content: doc.content, size: size }; 467 + }); 468 + await docsDb.bulkDocs(docDocs); 469 + const docsDuration = performance.now() - docsStart; 470 + 471 + // Read recent URLs - PouchDB allDocs with descending 472 + const readUrlsStart = performance.now(); 473 + const urlResult = await urlsDb.allDocs({ include_docs: true, limit: 100 }); 474 + // Sort by createdAt descending (PouchDB doesn't have native sorting without views) 475 + const sortedUrls = urlResult.rows 476 + .map(function(r) { return r.doc; }) 477 + .sort(function(a, b) { return b.createdAt - a.createdAt; }) 478 + .slice(0, 100); 479 + const readUrlsDuration = performance.now() - readUrlsStart; 480 + 481 + // Read random images 482 + const readImagesStart = performance.now(); 483 + const imageIds = testData.images.slice(0, Math.min(10, testData.images.length)).map(function(i) { return i.id; }); 484 + let readImageBytes = 0; 485 + for (let i = 0; i < imageIds.length; i++) { 486 + try { 487 + const doc = await imagesDb.get(imageIds[i]); 488 + if (doc.data) { 489 + const binary = atob(doc.data); 490 + readImageBytes += binary.length; 491 + } 492 + } catch (e) {} 493 + } 494 + const readImagesDuration = performance.now() - readImagesStart; 495 + 496 + // Read random documents 497 + const readDocsStart = performance.now(); 498 + const docIds = testData.documents.slice(0, Math.min(50, testData.documents.length)).map(function(d) { return d.id; }); 499 + let readDocBytes = 0; 500 + for (let i = 0; i < docIds.length; i++) { 501 + try { 502 + const doc = await docsDb.get(docIds[i]); 503 + if (doc.content) { 504 + readDocBytes += new TextEncoder().encode(doc.content).byteLength; 505 + } 506 + } catch (e) {} 507 + } 508 + const readDocsDuration = performance.now() - readDocsStart; 509 + 510 + // Storage 511 + const estimate = await navigator.storage.estimate(); 512 + const storageBytes = estimate.usage || 0; 513 + 514 + // Cleanup 515 + await urlsDb.destroy(); 516 + await metaDb.destroy(); 517 + await imagesDb.destroy(); 518 + await docsDb.destroy(); 519 + 520 + return { 521 + success: true, 522 + init: createResult('init', initDuration), 523 + writes: { 524 + allUrls: createResult('allUrls', urlsDuration, testData.urls.length), 525 + allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), 526 + allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), 527 + allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) 528 + }, 529 + reads: { 530 + recentUrls: createResult('recentUrls', readUrlsDuration, 100), 531 + randomImages: createResult('randomImages', readImagesDuration, imageIds.length, readImageBytes), 532 + randomDocuments: createResult('randomDocuments', readDocsDuration, docIds.length, readDocBytes) 533 + }, 534 + storage: { totalBytes: storageBytes } 535 + }; 536 + } catch (error) { 537 + return { success: false, error: error.message }; 538 + } 539 + 540 + } else if (storeName === 'pglite') { 541 + if (!window.PGlite) { 542 + return { success: false, error: 'PGlite not loaded' }; 543 + } 544 + 545 + const PGlite = window.PGlite; 546 + 547 + try { 548 + // Init - create in-memory database and tables 549 + const initStart = performance.now(); 550 + const db = new PGlite(); 551 + await db.exec(\` 552 + CREATE TABLE IF NOT EXISTS urls ( 553 + id TEXT PRIMARY KEY, 554 + url TEXT, 555 + title TEXT, 556 + created_at BIGINT, 557 + tags TEXT 558 + ); 559 + CREATE TABLE IF NOT EXISTS metadata ( 560 + id TEXT PRIMARY KEY, 561 + key TEXT, 562 + value TEXT, 563 + category TEXT, 564 + timestamp BIGINT 565 + ); 566 + CREATE TABLE IF NOT EXISTS images ( 567 + id TEXT PRIMARY KEY, 568 + data TEXT, 569 + size INTEGER 570 + ); 571 + CREATE TABLE IF NOT EXISTS documents ( 572 + id TEXT PRIMARY KEY, 573 + content TEXT, 574 + size INTEGER 575 + ); 576 + CREATE INDEX IF NOT EXISTS idx_urls_created ON urls(created_at); 577 + CREATE INDEX IF NOT EXISTS idx_meta_timestamp ON metadata(timestamp); 578 + \`); 579 + const initDuration = performance.now() - initStart; 580 + 581 + // Write URLs 582 + const urlsStart = performance.now(); 583 + for (let i = 0; i < testData.urls.length; i++) { 584 + const u = testData.urls[i]; 585 + await db.query( 586 + 'INSERT INTO urls (id, url, title, created_at, tags) VALUES ($1, $2, $3, $4, $5)', 587 + [u.id, u.url, u.title, u.createdAt, JSON.stringify(u.tags)] 588 + ); 589 + } 590 + const urlsDuration = performance.now() - urlsStart; 591 + 592 + // Write Metadata 593 + const metaStart = performance.now(); 594 + for (let i = 0; i < testData.metadata.length; i++) { 595 + const m = testData.metadata[i]; 596 + await db.query( 597 + 'INSERT INTO metadata (id, key, value, category, timestamp) VALUES ($1, $2, $3, $4, $5)', 598 + [m.id, m.key, String(m.value), m.category, m.timestamp] 599 + ); 600 + } 601 + const metaDuration = performance.now() - metaStart; 602 + 603 + // Write Images (as base64) 604 + const imagesStart = performance.now(); 605 + let imageBytes = 0; 606 + for (let i = 0; i < testData.images.length; i++) { 607 + const img = testData.images[i]; 608 + let binary = ''; 609 + const bytes = img.data; 610 + const chunkSize = 8192; 611 + for (let j = 0; j < bytes.length; j += chunkSize) { 612 + const chunk = bytes.subarray(j, j + chunkSize); 613 + binary += String.fromCharCode.apply(null, chunk); 614 + } 615 + const base64 = btoa(binary); 616 + await db.query( 617 + 'INSERT INTO images (id, data, size) VALUES ($1, $2, $3)', 618 + [img.id, base64, img.data.byteLength] 619 + ); 620 + imageBytes += img.data.byteLength; 621 + } 622 + const imagesDuration = performance.now() - imagesStart; 623 + 624 + // Write Documents 625 + const docsStart = performance.now(); 626 + let docBytes = 0; 627 + for (let i = 0; i < testData.documents.length; i++) { 628 + const doc = testData.documents[i]; 629 + const size = new TextEncoder().encode(doc.content).byteLength; 630 + await db.query( 631 + 'INSERT INTO documents (id, content, size) VALUES ($1, $2, $3)', 632 + [doc.id, doc.content, size] 633 + ); 634 + docBytes += size; 635 + } 636 + const docsDuration = performance.now() - docsStart; 637 + 638 + // Read recent URLs 639 + const readUrlsStart = performance.now(); 640 + await db.query('SELECT * FROM urls ORDER BY created_at DESC LIMIT 100'); 641 + const readUrlsDuration = performance.now() - readUrlsStart; 642 + 643 + // Read random images 644 + const readImagesStart = performance.now(); 645 + const imageIds = testData.images.slice(0, Math.min(10, testData.images.length)).map(function(i) { return i.id; }); 646 + let readImageBytes = 0; 647 + for (let i = 0; i < imageIds.length; i++) { 648 + const result = await db.query('SELECT data FROM images WHERE id = $1', [imageIds[i]]); 649 + if (result.rows.length > 0 && result.rows[0].data) { 650 + const binary = atob(result.rows[0].data); 651 + readImageBytes += binary.length; 652 + } 653 + } 654 + const readImagesDuration = performance.now() - readImagesStart; 655 + 656 + // Read random documents 657 + const readDocsStart = performance.now(); 658 + const docIds = testData.documents.slice(0, Math.min(50, testData.documents.length)).map(function(d) { return d.id; }); 659 + let readDocBytes = 0; 660 + for (let i = 0; i < docIds.length; i++) { 661 + const result = await db.query('SELECT content FROM documents WHERE id = $1', [docIds[i]]); 662 + if (result.rows.length > 0 && result.rows[0].content) { 663 + readDocBytes += new TextEncoder().encode(result.rows[0].content).byteLength; 664 + } 665 + } 666 + const readDocsDuration = performance.now() - readDocsStart; 667 + 668 + // Storage estimate 669 + const estimate = await navigator.storage.estimate(); 670 + const storageBytes = estimate.usage || 0; 671 + 672 + // Cleanup 673 + await db.close(); 674 + 675 + return { 676 + success: true, 677 + init: createResult('init', initDuration), 678 + writes: { 679 + allUrls: createResult('allUrls', urlsDuration, testData.urls.length), 680 + allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), 681 + allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), 682 + allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) 683 + }, 684 + reads: { 685 + recentUrls: createResult('recentUrls', readUrlsDuration, 100), 686 + randomImages: createResult('randomImages', readImagesDuration, imageIds.length, readImageBytes), 687 + randomDocuments: createResult('randomDocuments', readDocsDuration, docIds.length, readDocBytes) 688 + }, 689 + storage: { totalBytes: storageBytes } 690 + }; 691 + } catch (error) { 692 + return { success: false, error: error.message }; 693 + } 694 + } 695 + 696 + return { success: false, error: 'Unknown store' }; 697 + })('${store}') 698 + `; 699 + 700 + try { 701 + const result = await Promise.race([ 702 + page.evaluate(script), 703 + new Promise<StoreRunResult>((_, reject) => 704 + setTimeout(() => reject(new Error('Benchmark timeout')), BENCHMARK_TIMEOUT) 705 + ) 706 + ]); 707 + return result as StoreRunResult; 708 + } catch (error) { 709 + const err = error as Error; 710 + return { 711 + success: false, 712 + error: err.message, 713 + init: { name: 'init', durationMs: 0, failed: true, error: err.message }, 714 + writes: { 715 + allUrls: { name: 'allUrls', durationMs: 0, failed: true }, 716 + allMetadata: { name: 'allMetadata', durationMs: 0, failed: true }, 717 + allImages: { name: 'allImages', durationMs: 0, failed: true }, 718 + allDocuments: { name: 'allDocuments', durationMs: 0, failed: true } 719 + }, 720 + reads: { 721 + recentUrls: { name: 'recentUrls', durationMs: 0, failed: true }, 722 + randomImages: { name: 'randomImages', durationMs: 0, failed: true }, 723 + randomDocuments: { name: 'randomDocuments', durationMs: 0, failed: true } 724 + }, 725 + storage: { totalBytes: 0 } 726 + }; 727 + } 728 + } 729 + 730 + async function runBrowserBenchmarks( 731 + browserType: BrowserType, 732 + serverUrl: string 733 + ): Promise<BrowserRunResults> { 734 + console.log(`\n${'─'.repeat(60)}`); 735 + console.log(`Testing ${browserType.toUpperCase()} (${ITERATIONS} iterations per store)`); 736 + console.log('─'.repeat(60)); 737 + 738 + let browser: Browser | undefined; 739 + const result: BrowserRunResults = { 740 + browserName: browserType, 741 + userAgent: '', 742 + stores: {} as Record<StoreType, BrowserStoreBenchmarkResults>, 743 + success: false 744 + }; 745 + 746 + try { 747 + if (browserType === 'chromium') { 748 + browser = await chromium.launch({ headless: true }); 749 + } else if (browserType === 'firefox') { 750 + browser = await firefox.launch({ headless: true }); 751 + } else if (browserType === 'webkit') { 752 + browser = await webkit.launch({ headless: true }); 753 + } 754 + 755 + const page = await browser!.newPage(); 756 + 757 + console.log(' Loading harness...'); 758 + await page.goto(serverUrl, { waitUntil: 'domcontentloaded' }); 759 + 760 + await page.waitForFunction(() => (window as any).generateTestData !== undefined, { 761 + timeout: 10000 762 + }); 763 + 764 + result.userAgent = await page.evaluate(() => navigator.userAgent); 765 + 766 + for (const store of STORES) { 767 + console.log(`\n Benchmarking ${store.toUpperCase()}`); 768 + const runs: StoreRunResult[] = []; 769 + let consecutiveFailures = 0; 770 + 771 + for (let i = 0; i < ITERATIONS; i++) { 772 + process.stdout.write(` [${i + 1}/${ITERATIONS}] `); 773 + const runResult = await benchmarkStore(page, store); 774 + runs.push(runResult); 775 + 776 + if (runResult.success) { 777 + consecutiveFailures = 0; 778 + console.log(`✓ ${formatDuration(runResult.init.durationMs + runResult.writes.allUrls.durationMs + runResult.writes.allMetadata.durationMs)}`); 779 + } else { 780 + consecutiveFailures++; 781 + console.log(`✗ ${runResult.error}`); 782 + // If 3 consecutive failures (including timeouts), skip remaining iterations 783 + if (consecutiveFailures >= 3) { 784 + console.log(` Skipping remaining iterations due to repeated failures`); 785 + break; 786 + } 787 + } 788 + } 789 + 790 + result.stores[store] = aggregateStoreRuns(runs, store, result.userAgent); 791 + } 792 + 793 + await page.close(); 794 + result.success = true; 795 + 796 + } catch (error) { 797 + const err = error as Error; 798 + result.error = err.message; 799 + 800 + if (err.message.includes("Executable doesn't exist")) { 801 + console.error(` ✗ Browser not installed. Run: npm run browsers:install`); 802 + } else { 803 + console.error(` ✗ Error: ${err.message}`); 804 + } 805 + } finally { 806 + if (browser) { 807 + try { 808 + await browser.close(); 809 + } catch { 810 + // Ignore 811 + } 812 + } 813 + } 814 + 815 + return result; 816 + } 817 + 818 + function startServer(): Promise<{ url: string; server: Server }> { 819 + const harness = readFileSync(HARNESS_PATH, 'utf-8'); 820 + 821 + return new Promise((resolve) => { 822 + const server = createServer((req, res) => { 823 + if (req.url === '/') { 824 + res.writeHead(200, { 'Content-Type': 'text/html' }); 825 + res.end(harness); 826 + } else { 827 + res.writeHead(404); 828 + res.end('Not found'); 829 + } 830 + }); 831 + 832 + server.listen(PORT, () => { 833 + resolve({ url: `http://localhost:${PORT}`, server }); 834 + }); 835 + }); 836 + } 837 + 838 + async function main() { 839 + console.log('='.repeat(60)); 840 + console.log('BROWSER DATASTORE BENCHMARK SUITE'); 841 + console.log('='.repeat(60)); 842 + console.log(`Iterations per store: ${ITERATIONS} (excluding high/low, using median)`); 843 + 844 + const { url: serverUrl, server } = await startServer(); 845 + console.log(`Server started at ${serverUrl}`); 846 + 847 + const allResults: BrowserRunResults[] = []; 848 + 849 + for (const browserType of BROWSERS) { 850 + const result = await runBrowserBenchmarks(browserType, serverUrl); 851 + allResults.push(result); 852 + 853 + if (result.success) { 854 + console.log(`\n ✓ ${browserType} completed`); 855 + } else { 856 + console.log(`\n ✗ ${browserType} failed: ${result.error}`); 857 + } 858 + } 859 + 860 + server.close(); 861 + 862 + // Save results 863 + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 864 + const runDir = join('results', `browser-benchmark-${timestamp}`); 865 + mkdirSync(runDir, { recursive: true }); 866 + 867 + const resultsData = { 868 + timestamp: new Date().toISOString(), 869 + type: 'browser', 870 + iterations: ITERATIONS, 871 + browsers: allResults.map(r => ({ 872 + browserName: r.browserName, 873 + userAgent: r.userAgent, 874 + success: r.success, 875 + error: r.error, 876 + stores: r.stores 877 + })) 878 + }; 879 + 880 + writeFileSync(join(runDir, 'results.json'), JSON.stringify(resultsData, null, 2)); 881 + console.log(`\nResults saved to ${runDir}/results.json`); 882 + 883 + // Print summary 884 + console.log(`\n${'='.repeat(60)}`); 885 + console.log('BROWSER BENCHMARK SUMMARY'); 886 + console.log('='.repeat(60)); 887 + 888 + for (const browserResult of allResults) { 889 + if (!browserResult.success) continue; 890 + 891 + console.log(`\n${browserResult.browserName.toUpperCase()}`); 892 + for (const [storeName, storeResult] of Object.entries(browserResult.stores)) { 893 + if (storeResult.init.failed) { 894 + console.log(` ${storeName}: FAILED`); 895 + continue; 896 + } 897 + 898 + const totalWrite = storeResult.writes.allUrls.durationMs + 899 + storeResult.writes.allMetadata.durationMs + 900 + storeResult.writes.allImages.durationMs + 901 + storeResult.writes.allDocuments.durationMs; 902 + 903 + const totalRead = storeResult.reads.recentUrls.durationMs + 904 + storeResult.reads.randomImages.durationMs + 905 + storeResult.reads.randomDocuments.durationMs; 906 + 907 + console.log(` ${storeName}: init=${formatDuration(storeResult.init.durationMs)} write=${formatDuration(totalWrite)} read=${formatDuration(totalRead)}`); 908 + } 909 + } 910 + 911 + const successCount = allResults.filter(r => r.success).length; 912 + console.log(`\nBrowsers tested: ${successCount}/${BROWSERS.length} successful`); 913 + 914 + // Generate charts and HTML report 915 + if (successCount > 0) { 916 + console.log('\nGenerating charts...'); 917 + await generateBrowserCharts(allResults, runDir); 918 + console.log(`Charts and report saved to ${runDir}/`); 919 + } 920 + } 921 + 922 + const COLORS = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6']; 923 + const WIDTH = 700; 924 + const HEIGHT = 400; 925 + 926 + async function renderVegaLiteChart(spec: any): Promise<Buffer> { 927 + const vegaSpec = vegaLite.compile(spec).spec; 928 + const view = new vega.View(vega.parse(vegaSpec), { renderer: 'none' }); 929 + const svg = await view.toSVG(); 930 + return await sharp(Buffer.from(svg)).png().toBuffer(); 931 + } 932 + 933 + async function generateBrowserCharts(results: BrowserRunResults[], outputDir: string): Promise<void> { 934 + const successfulResults = results.filter(r => r.success); 935 + if (successfulResults.length === 0) return; 936 + 937 + // Prepare data for charts - one bar per browser/store combo 938 + const writeData: any[] = []; 939 + const readData: any[] = []; 940 + 941 + for (const browserResult of successfulResults) { 942 + for (const [storeName, storeResult] of Object.entries(browserResult.stores)) { 943 + if (storeResult.init.failed) continue; 944 + 945 + const label = `${browserResult.browserName}/${storeName}`; 946 + 947 + const totalWrite = storeResult.writes.allUrls.durationMs + 948 + storeResult.writes.allMetadata.durationMs + 949 + storeResult.writes.allImages.durationMs + 950 + storeResult.writes.allDocuments.durationMs; 951 + 952 + const totalRead = storeResult.reads.recentUrls.durationMs + 953 + storeResult.reads.randomImages.durationMs + 954 + storeResult.reads.randomDocuments.durationMs; 955 + 956 + writeData.push({ label, browser: browserResult.browserName, store: storeName, value: totalWrite }); 957 + readData.push({ label, browser: browserResult.browserName, store: storeName, value: totalRead }); 958 + } 959 + } 960 + 961 + // Write performance chart 962 + const writeSpec = { 963 + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', 964 + width: WIDTH, 965 + height: HEIGHT, 966 + title: { text: 'Browser Write Performance', fontSize: 18 }, 967 + data: { values: writeData }, 968 + mark: 'bar', 969 + encoding: { 970 + x: { 971 + field: 'label', 972 + type: 'nominal', 973 + title: null, 974 + axis: { labelAngle: -45, labelFontSize: 10 }, 975 + sort: null 976 + }, 977 + y: { 978 + field: 'value', 979 + type: 'quantitative', 980 + title: 'Duration (ms)', 981 + axis: { titleFontSize: 12, labelFontSize: 11 } 982 + }, 983 + color: { 984 + field: 'store', 985 + type: 'nominal', 986 + title: 'Store', 987 + scale: { range: COLORS }, 988 + legend: { titleFontSize: 12, labelFontSize: 11 } 989 + } 990 + }, 991 + config: { background: 'white', view: { stroke: null } } 992 + }; 993 + 994 + const writeChart = await renderVegaLiteChart(writeSpec); 995 + writeFileSync(join(outputDir, 'write-comparison.png'), writeChart); 996 + 997 + // Read performance chart 998 + const readSpec = { 999 + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', 1000 + width: WIDTH, 1001 + height: HEIGHT, 1002 + title: { text: 'Browser Read Performance', fontSize: 18 }, 1003 + data: { values: readData }, 1004 + mark: 'bar', 1005 + encoding: { 1006 + x: { 1007 + field: 'label', 1008 + type: 'nominal', 1009 + title: null, 1010 + axis: { labelAngle: -45, labelFontSize: 10 }, 1011 + sort: null 1012 + }, 1013 + y: { 1014 + field: 'value', 1015 + type: 'quantitative', 1016 + title: 'Duration (ms)', 1017 + axis: { titleFontSize: 12, labelFontSize: 11 } 1018 + }, 1019 + color: { 1020 + field: 'store', 1021 + type: 'nominal', 1022 + title: 'Store', 1023 + scale: { range: COLORS }, 1024 + legend: { titleFontSize: 12, labelFontSize: 11 } 1025 + } 1026 + }, 1027 + config: { background: 'white', view: { stroke: null } } 1028 + }; 1029 + 1030 + const readChart = await renderVegaLiteChart(readSpec); 1031 + writeFileSync(join(outputDir, 'read-comparison.png'), readChart); 1032 + 1033 + // Generate HTML report 1034 + const html = generateBrowserHtmlReport(successfulResults); 1035 + writeFileSync(join(outputDir, 'report.html'), html); 1036 + } 1037 + 1038 + function generateBrowserHtmlReport(results: BrowserRunResults[]): string { 1039 + const storeNames = [...new Set(results.flatMap(r => Object.keys(r.stores)))]; 1040 + 1041 + // Calculate totals for each browser/store combo 1042 + type CellData = { write: number; read: number; failed: boolean }; 1043 + const cellData: Map<string, CellData> = new Map(); 1044 + 1045 + for (const browserResult of results) { 1046 + for (const storeName of storeNames) { 1047 + const key = `${browserResult.browserName}-${storeName}`; 1048 + const storeResult = browserResult.stores[storeName as StoreType]; 1049 + 1050 + if (!storeResult || storeResult.init.failed) { 1051 + cellData.set(key, { write: Infinity, read: Infinity, failed: true }); 1052 + } else { 1053 + const totalWrite = storeResult.writes.allUrls.durationMs + 1054 + storeResult.writes.allMetadata.durationMs + 1055 + storeResult.writes.allImages.durationMs + 1056 + storeResult.writes.allDocuments.durationMs; 1057 + const totalRead = storeResult.reads.recentUrls.durationMs + 1058 + storeResult.reads.randomImages.durationMs + 1059 + storeResult.reads.randomDocuments.durationMs; 1060 + cellData.set(key, { write: totalWrite, read: totalRead, failed: false }); 1061 + } 1062 + } 1063 + } 1064 + 1065 + // Find min/max for each store column (excluding failed) 1066 + const storeStats: Map<string, { minWrite: number; maxWrite: number; minRead: number; maxRead: number }> = new Map(); 1067 + for (const storeName of storeNames) { 1068 + const values = results 1069 + .map(br => cellData.get(`${br.browserName}-${storeName}`)) 1070 + .filter(v => v && !v.failed) as CellData[]; 1071 + 1072 + if (values.length > 0) { 1073 + storeStats.set(storeName, { 1074 + minWrite: Math.min(...values.map(v => v.write)), 1075 + maxWrite: Math.max(...values.map(v => v.write)), 1076 + minRead: Math.min(...values.map(v => v.read)), 1077 + maxRead: Math.max(...values.map(v => v.read)) 1078 + }); 1079 + } 1080 + } 1081 + 1082 + // Build table rows with color coding 1083 + const tableRows = results.map(browserResult => { 1084 + const cells = storeNames.map(storeName => { 1085 + const key = `${browserResult.browserName}-${storeName}`; 1086 + const data = cellData.get(key); 1087 + const stats = storeStats.get(storeName); 1088 + 1089 + if (!data || data.failed) { 1090 + return '<td class="failed">FAILED</td>'; 1091 + } 1092 + 1093 + // Determine colors (green = best/lowest, red = worst/highest) 1094 + let writeClass = ''; 1095 + let readClass = ''; 1096 + 1097 + if (stats) { 1098 + if (data.write === stats.minWrite) writeClass = 'best'; 1099 + else if (data.write === stats.maxWrite) writeClass = 'worst'; 1100 + 1101 + if (data.read === stats.minRead) readClass = 'best'; 1102 + else if (data.read === stats.maxRead) readClass = 'worst'; 1103 + } 1104 + 1105 + const writeSpan = writeClass 1106 + ? `<span class="${writeClass}">${formatDuration(data.write)}</span>` 1107 + : formatDuration(data.write); 1108 + const readSpan = readClass 1109 + ? `<span class="${readClass}">${formatDuration(data.read)}</span>` 1110 + : formatDuration(data.read); 1111 + 1112 + return `<td>${writeSpan} / ${readSpan}</td>`; 1113 + }); 1114 + 1115 + return ` 1116 + <tr> 1117 + <td><strong>${browserResult.browserName}</strong></td> 1118 + ${cells.join('\n ')} 1119 + </tr>`; 1120 + }).join('\n'); 1121 + 1122 + return `<!DOCTYPE html> 1123 + <html lang="en"> 1124 + <head> 1125 + <meta charset="UTF-8"> 1126 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 1127 + <title>Browser Benchmark Results</title> 1128 + <style> 1129 + body { 1130 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 1131 + max-width: 1200px; 1132 + margin: 0 auto; 1133 + padding: 20px; 1134 + background: #f5f5f5; 1135 + } 1136 + h1 { color: #2c3e50; text-align: center; } 1137 + .timestamp { text-align: center; color: #7f8c8d; margin-bottom: 30px; } 1138 + .container { 1139 + background: white; 1140 + border-radius: 8px; 1141 + padding: 20px; 1142 + margin-bottom: 20px; 1143 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 1144 + } 1145 + table { width: 100%; border-collapse: collapse; font-size: 14px; } 1146 + th, td { padding: 12px 8px; text-align: center; border-bottom: 1px solid #eee; } 1147 + th { background: #f8f9fa; font-weight: 600; color: #2c3e50; } 1148 + td:first-child { text-align: left; } 1149 + .failed { color: #e74c3c; font-weight: 600; } 1150 + .best { color: #27ae60; font-weight: 600; } 1151 + .worst { color: #e74c3c; font-weight: 600; } 1152 + .charts { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } 1153 + .chart { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; } 1154 + .chart img { max-width: 100%; height: auto; } 1155 + .info { background: #e3f2fd; padding: 15px; border-radius: 4px; margin-bottom: 20px; } 1156 + .info p { margin: 5px 0; font-size: 14px; } 1157 + @media (max-width: 900px) { .charts { grid-template-columns: 1fr; } } 1158 + </style> 1159 + </head> 1160 + <body> 1161 + <h1>Browser Datastore Benchmark Results</h1> 1162 + <p class="timestamp">${new Date().toLocaleString()}</p> 1163 + 1164 + <div class="container"> 1165 + <div class="info"> 1166 + <p><strong>Environment:</strong> Browser-based benchmarks running in Chromium, Firefox, and WebKit</p> 1167 + <p><strong>Methodology:</strong> 10 iterations per store, excluding high/low, reporting median</p> 1168 + <p><strong>Test Data:</strong> 1000 URLs, 10000 metadata rows, 20 images (50KB each = 1MB), 500 documents</p> 1169 + <p><strong>Values shown:</strong> Total Write Time / Total Read Time</p> 1170 + <p><strong>Note:</strong> PouchDB fails on WebKit in Playwright due to a compatibility issue with PouchDB's IndexedDB adapter (raw IndexedDB works fine)</p> 1171 + </div> 1172 + 1173 + <h2>Summary</h2> 1174 + <table> 1175 + <thead> 1176 + <tr> 1177 + <th>Browser</th> 1178 + ${storeNames.map(s => `<th>${s}</th>`).join('\n ')} 1179 + </tr> 1180 + </thead> 1181 + <tbody> 1182 + ${tableRows} 1183 + </tbody> 1184 + </table> 1185 + </div> 1186 + 1187 + <div class="charts"> 1188 + <div class="chart"> 1189 + <img src="write-comparison.png" alt="Write Performance"> 1190 + </div> 1191 + <div class="chart"> 1192 + <img src="read-comparison.png" alt="Read Performance"> 1193 + </div> 1194 + </div> 1195 + </body> 1196 + </html>`; 1197 + } 1198 + 1199 + main().catch((err) => { 1200 + console.error('Benchmark failed:', err); 1201 + process.exit(1); 1202 + });
+74
src/harness/browser-types.ts
··· 1 + // Types used in browser environment 2 + export interface BrowserUrl { 3 + id: string; 4 + url: string; 5 + title: string; 6 + createdAt: number; 7 + tags: string[]; 8 + } 9 + 10 + export interface BrowserMetadata { 11 + id: string; 12 + key: string; 13 + value: string | number | boolean; 14 + category: string; 15 + timestamp: number; 16 + } 17 + 18 + export interface BrowserDatastoreAdapter { 19 + name: string; 20 + 21 + // Lifecycle 22 + init(): Promise<void>; 23 + cleanup(): Promise<void>; 24 + 25 + // Writes 26 + addUrls(urls: BrowserUrl[]): Promise<void>; 27 + addImage(id: string, data: Uint8Array): Promise<void>; 28 + addDocument(id: string, content: string): Promise<void>; 29 + addMetadata(rows: BrowserMetadata[]): Promise<void>; 30 + 31 + // Reads 32 + getRecentUrls(count: number): Promise<BrowserUrl[]>; 33 + getImages(ids: string[]): Promise<Map<string, Uint8Array>>; 34 + getDocuments(ids: string[]): Promise<Map<string, string>>; 35 + 36 + // Storage 37 + getStorageUsage(): Promise<number>; 38 + } 39 + 40 + export interface BrowserBenchmarkResult { 41 + name: string; 42 + durationMs: number; 43 + itemCount?: number; 44 + bytesProcessed?: number; 45 + failed?: boolean; 46 + error?: string; 47 + } 48 + 49 + export interface BrowserStoreBenchmarkResults { 50 + storeName: string; 51 + userAgent: string; 52 + init: BrowserBenchmarkResult; 53 + writes: { 54 + allUrls: BrowserBenchmarkResult; 55 + allImages: BrowserBenchmarkResult; 56 + allDocuments: BrowserBenchmarkResult; 57 + allMetadata: BrowserBenchmarkResult; 58 + }; 59 + reads: { 60 + recentUrls: BrowserBenchmarkResult; 61 + randomImages: BrowserBenchmarkResult; 62 + randomDocuments: BrowserBenchmarkResult; 63 + }; 64 + storage: { 65 + totalBytes: number; 66 + }; 67 + } 68 + 69 + export interface BrowserTestData { 70 + urls: BrowserUrl[]; 71 + metadata: BrowserMetadata[]; 72 + images: Array<{ id: string; filename: string; data: Uint8Array }>; 73 + documents: Array<{ id: string; filename: string; content: string }>; 74 + }