diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx
index d7a12f398499..fa8d24e06b22 100644
--- a/src/components/TestToolMenu.tsx
+++ b/src/components/TestToolMenu.tsx
@@ -11,6 +11,8 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation';
import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication';
import {isUsingStagingApi} from '@libs/ApiUtils';
import Navigation from '@libs/Navigation/Navigation';
+import MultiGetBenchmark from '@pages/Debug/MultiGetBenchmark';
+import SQLiteBenchmark from '@pages/Debug/SQLiteBenchmark';
import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network';
import {expireSessionWithDelay, invalidateAuthToken, invalidateCredentials} from '@userActions/Session';
import {setIsDebugModeEnabled, setShouldUseStagingServer} from '@userActions/User';
@@ -199,6 +201,8 @@ function TestToolMenu() {
+
+
>
);
}
diff --git a/src/pages/Debug/MultiGetBenchmark.tsx b/src/pages/Debug/MultiGetBenchmark.tsx
new file mode 100644
index 000000000000..0ed1643c0d28
--- /dev/null
+++ b/src/pages/Debug/MultiGetBenchmark.tsx
@@ -0,0 +1,454 @@
+/**
+ * MultiGet Real Data Benchmark
+ *
+ * Measures Storage.multiGet on real OnyxDB collections for each SQL strategy.
+ * Toggle which collections to include, then tap "Run All Strategies".
+ *
+ * Access via Test Tools Modal.
+ */
+import Clipboard from '@react-native-clipboard/clipboard';
+import React, {useCallback, useRef, useState} from 'react';
+import {Platform, StyleSheet, View} from 'react-native';
+// eslint-disable-next-line no-restricted-imports
+import storage from 'react-native-onyx/dist/storage';
+// eslint-disable-next-line no-restricted-imports
+import {setMultiGetStrategy, explainQueryPlan} from 'react-native-onyx/dist/storage/providers/SQLiteProvider';
+import type {MultiGetStrategy} from 'react-native-onyx/dist/storage/providers/SQLiteProvider';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Text from '@components/Text';
+
+const RUNS = 20;
+const LOOPS = 3;
+const PAUSE_BETWEEN_COLLECTIONS_MS = 300;
+
+// Hardcoded collections — toggle which ones to include in the benchmark
+const ALL_COLLECTIONS = [
+ 'transactions_',
+ 'reportActions_',
+ 'report_',
+ 'reportNameValuePairs_',
+ 'transactionViolations_',
+];
+
+const STRATEGIES: Array<{key: MultiGetStrategy; label: string}> = [
+ {key: 'in_clause', label: 'IN clause'},
+ {key: 'json_each_join', label: 'json_each JOIN'},
+ {key: 'chunked_500', label: 'Chunk 500'},
+ {key: 'temp_table', label: 'Temp table'},
+];
+
+type CollectionResult = {
+ prefix: string;
+ keyCount: number;
+ median: number;
+ min: number;
+ max: number;
+};
+
+type AllResults = Partial>;
+type SizeResult = {prefix: string; keyCount: number; avgKB: number; maxKB: number; avgKeyLen: number; sampleKey: string};
+
+const localStyles = StyleSheet.create({
+ title: {fontSize: 16, fontWeight: 'bold', marginTop: 20, marginBottom: 2},
+ sub: {fontSize: 11, color: '#666', marginBottom: 8},
+ sectionLabel: {fontSize: 11, fontWeight: '600', color: '#333', marginBottom: 4, marginTop: 8},
+ toggleRow: {flexDirection: 'row', flexWrap: 'wrap', marginBottom: 8, gap: 6},
+ toggle: {paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, borderWidth: 1, borderColor: '#0366d6'},
+ toggleOn: {backgroundColor: '#0366d6'},
+ toggleOff: {backgroundColor: '#fff'},
+ toggleTxtOn: {color: '#fff', fontSize: 11, fontWeight: '600'},
+ toggleTxtOff: {color: '#0366d6', fontSize: 11},
+ btn: {backgroundColor: '#0366d6', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8},
+ btnAll: {backgroundColor: '#e36209', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8},
+ btnGray: {backgroundColor: '#6c757d', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8},
+ btnOff: {backgroundColor: '#999'},
+ btnTxt: {color: '#fff', fontWeight: '600', fontSize: 14},
+ status: {fontSize: 11, color: '#666', marginBottom: 12, fontStyle: 'italic'},
+ group: {marginBottom: 12, borderWidth: 1, borderColor: '#ddd', borderRadius: 6, padding: 10},
+ groupTitle: {fontSize: 12, fontWeight: '600', marginBottom: 6, color: '#333'},
+ row: {flexDirection: 'row', paddingVertical: 2},
+ c: {flex: 1, fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'},
+ cWide: {flex: 1.8, fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'},
+ hRow: {flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 3, marginBottom: 4},
+ hTxt: {fontWeight: '600', fontSize: 10},
+});
+
+// eslint-disable-next-line no-promise-executor-return
+const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function getKeysForPrefix(prefix: string): Promise {
+ const allKeys = await storage.getAllKeys();
+ return allKeys.filter((k) => k.startsWith(prefix));
+}
+
+async function measureCollection(keys: string[], prefix: string): Promise {
+ await storage.multiGet(keys);
+ await storage.multiGet(keys);
+ await storage.multiGet(keys);
+
+ const loopMedians: number[] = [];
+ let overallMin = Infinity;
+ let overallMax = 0;
+
+ for (let loop = 0; loop < LOOPS; loop++) {
+ const times: number[] = [];
+ for (let i = 0; i < RUNS; i++) {
+ const start = performance.now();
+ // eslint-disable-next-line no-await-in-loop
+ await storage.multiGet(keys);
+ times.push(performance.now() - start);
+ }
+ times.sort((a, b) => a - b);
+ loopMedians.push(times.at(Math.floor(times.length / 2)) ?? 0);
+ overallMin = Math.min(overallMin, times.at(0) ?? 0);
+ overallMax = Math.max(overallMax, times.at(-1) ?? 0);
+ // eslint-disable-next-line no-await-in-loop
+ await pause(100);
+ }
+
+ loopMedians.sort((a, b) => a - b);
+ return {
+ prefix,
+ keyCount: keys.length,
+ median: loopMedians.at(Math.floor(loopMedians.length / 2)) ?? 0,
+ min: overallMin,
+ max: overallMax,
+ };
+}
+
+function MultiGetBenchmark() {
+ const [selectedCollections, setSelectedCollections] = useState>(new Set(ALL_COLLECTIONS));
+ const [allResults, setAllResults] = useState({});
+ const [sizeResults, setSizeResults] = useState([]);
+ const [running, setRunning] = useState(false);
+ const [status, setStatus] = useState('Ready');
+ const allResultsRef = useRef({});
+
+ const toggleCollection = useCallback((prefix: string) => {
+ setSelectedCollections((prev) => {
+ const next = new Set(prev);
+ if (next.has(prefix)) {
+ next.delete(prefix);
+ } else {
+ next.add(prefix);
+ }
+ return next;
+ });
+ }, []);
+
+ const runStrategy = useCallback(
+ async (strategy: MultiGetStrategy) => {
+ const label = STRATEGIES.find((s) => s.key === strategy)?.label ?? strategy;
+ setMultiGetStrategy(strategy);
+
+ const collections = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p));
+ const strategyResults: CollectionResult[] = [];
+
+ for (const prefix of collections) {
+ setStatus(`[${label}] Loading ${prefix}...`);
+ // eslint-disable-next-line no-await-in-loop
+ const keys = await getKeysForPrefix(prefix);
+ if (keys.length === 0) {
+ continue;
+ }
+ setStatus(`[${label}] ${prefix} (${keys.length} keys)...`);
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+ // eslint-disable-next-line no-await-in-loop
+ await pause(PAUSE_BETWEEN_COLLECTIONS_MS);
+ // eslint-disable-next-line no-await-in-loop
+ const r = await measureCollection(keys, prefix);
+ strategyResults.push(r);
+ }
+
+ allResultsRef.current = {...allResultsRef.current, [strategy]: strategyResults};
+ setAllResults({...allResultsRef.current});
+ setMultiGetStrategy('in_clause');
+ },
+ [selectedCollections],
+ );
+
+ const runAll = useCallback(() => {
+ setRunning(true);
+ setAllResults({});
+ allResultsRef.current = {};
+
+ const run = async () => {
+ for (const strat of STRATEGIES) {
+ // eslint-disable-next-line no-await-in-loop
+ await runStrategy(strat.key);
+ }
+ setStatus(`All ${STRATEGIES.length} strategies complete!`);
+ };
+
+ run()
+ .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`))
+ .finally(() => setRunning(false));
+ }, [runStrategy]);
+
+ const runSingle = useCallback(
+ (strategy: MultiGetStrategy) => {
+ setRunning(true);
+ runStrategy(strategy)
+ .then(() => setStatus('Done!'))
+ .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`))
+ .finally(() => setRunning(false));
+ },
+ [runStrategy],
+ );
+
+ const runExplainQueryPlan = useCallback(() => {
+ setRunning(true);
+ setStatus('Running EXPLAIN QUERY PLAN...');
+
+ const run = async () => {
+ const output: Record = {};
+ const collections = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p));
+ for (const prefix of collections) {
+ // eslint-disable-next-line no-await-in-loop
+ const keys = await getKeysForPrefix(prefix);
+ if (keys.length === 0) {
+ continue;
+ }
+ // Use ALL keys — query planner makes different decisions based on actual count
+ // eslint-disable-next-line no-await-in-loop
+ const plans = await explainQueryPlan(keys);
+ output[prefix] = {keyCount: keys.length, plans};
+ }
+ const json = JSON.stringify({platform: Platform.OS, timestamp: new Date().toISOString(), queryPlans: output}, null, 2);
+ Clipboard.setString(json);
+ setStatus('Query plans copied to clipboard!');
+ // eslint-disable-next-line no-console
+ console.log('EXPLAIN QUERY PLAN:\n', json);
+ };
+
+ run()
+ .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`))
+ .finally(() => setRunning(false));
+ }, [selectedCollections]);
+
+ const inspectSizes = useCallback(() => {
+ setRunning(true);
+ setSizeResults([]);
+ setStatus('Inspecting value sizes...');
+
+ const run = async () => {
+ const collections = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p));
+ const results: SizeResult[] = [];
+ for (const prefix of collections) {
+ // eslint-disable-next-line no-await-in-loop
+ const keys = await getKeysForPrefix(prefix);
+ if (keys.length === 0) {
+ continue;
+ }
+ const step = Math.max(1, Math.floor(keys.length / 200));
+ const sampleKeys = keys.filter((_, i) => i % step === 0).slice(0, 200);
+ // eslint-disable-next-line no-await-in-loop
+ const pairs = await storage.multiGet(sampleKeys);
+ const sizes = pairs.map(([, v]) => JSON.stringify(v).length);
+ const avg = sizes.reduce((s, n) => s + n, 0) / sizes.length;
+ const max = Math.max(...sizes);
+ const avgKeyLen = Math.round(sampleKeys.reduce((s, k) => s + k.length, 0) / sampleKeys.length);
+ results.push({prefix, keyCount: keys.length, avgKB: Math.round((avg / 1024) * 10) / 10, maxKB: Math.round((max / 1024) * 10) / 10, avgKeyLen, sampleKey: sampleKeys.at(0) ?? ''});
+ setSizeResults([...results]);
+ }
+ setStatus('Done inspecting sizes.');
+ };
+
+ run()
+ .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`))
+ .finally(() => setRunning(false));
+ }, [selectedCollections]);
+
+ const copySizeResults = useCallback(() => {
+ Clipboard.setString(JSON.stringify({platform: Platform.OS, timestamp: new Date().toISOString(), valueSizes: sizeResults}, null, 2));
+ setStatus('Sizes copied!');
+ }, [sizeResults]);
+
+ const copyResults = useCallback(() => {
+ Clipboard.setString(
+ JSON.stringify(
+ {
+ platform: Platform.OS,
+ timestamp: new Date().toISOString(),
+ config: {runs: RUNS, loops: LOOPS, collections: Array.from(selectedCollections)},
+ results: allResults,
+ },
+ null,
+ 2,
+ ),
+ );
+ setStatus('Copied!');
+ }, [allResults, selectedCollections]);
+
+ const measuredPrefixes = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p) && Object.values(allResults).some((r) => r?.some((res) => res.prefix === p)));
+ const baseline = allResults.in_clause;
+ const baselineMap = new Map(baseline?.map((r) => [r.prefix, r]) ?? []);
+
+ return (
+
+ multiGet — Real Data Benchmark
+
+ Real OnyxDB. {LOOPS}×{RUNS} runs, median of medians.
+
+
+ Collections to test:
+
+ {ALL_COLLECTIONS.map((prefix) => {
+ const on = selectedCollections.has(prefix);
+ return (
+ toggleCollection(prefix)}
+ disabled={running}
+ >
+ {prefix.replace(/_$/, '')}
+
+ );
+ })}
+
+
+
+ {running ? 'Running...' : `Run All ${STRATEGIES.length} Strategies`}
+
+
+ {STRATEGIES.map((s) => (
+ runSingle(s.key)}
+ disabled={running}
+ >
+ {running ? '...' : s.label}
+
+ ))}
+
+
+ Inspect Value Sizes
+
+
+
+ Explain Query Plan (copy)
+
+
+ {Object.keys(allResults).length > 0 && !running && (
+
+ Copy Results
+
+ )}
+
+ {status}
+
+ {sizeResults.length > 0 && (
+
+
+ Value sizes (200-key sample)
+
+ Copy
+
+
+
+ Collection
+ Keys
+ Avg KB
+ Key len
+
+ {sizeResults.map((r) => (
+
+
+ {r.prefix}
+ {r.keyCount}
+ {r.avgKB} KB
+ {r.avgKeyLen} ch
+
+
+ {r.sampleKey}
+
+
+ ))}
+
+ )}
+
+ {measuredPrefixes.map((prefix) => {
+ const baseResult = baselineMap.get(prefix);
+ return (
+
+
+ {prefix} ({baseResult?.keyCount ?? '?'} keys)
+
+
+ Strategy
+ Median
+ vs IN
+
+ {STRATEGIES.map((s) => {
+ const r = allResults[s.key]?.find((res) => res.prefix === prefix);
+ if (!r) {
+ return null;
+ }
+ const diff = baseResult && s.key !== 'in_clause' ? ((r.median - baseResult.median) / baseResult.median) * 100 : null;
+ const diffStr = diff == null ? '—' : `${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`;
+ // eslint-disable-next-line no-nested-ternary
+ const clr = diff == null ? '#666' : diff < -5 ? '#2d8a4e' : diff > 5 ? '#d32f2f' : '#666';
+ return (
+
+ {s.label}
+ {r.median.toFixed(1)}ms
+ {diffStr}
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+}
+
+MultiGetBenchmark.displayName = 'MultiGetBenchmark';
+
+export default MultiGetBenchmark;
diff --git a/src/pages/Debug/SQLiteBenchmark.tsx b/src/pages/Debug/SQLiteBenchmark.tsx
new file mode 100644
index 000000000000..9db3d22e515d
--- /dev/null
+++ b/src/pages/Debug/SQLiteBenchmark.tsx
@@ -0,0 +1,458 @@
+/**
+ * SQLite multiGet Benchmark Screen
+ *
+ * Measures query strategies in two phases to isolate SQL performance from JSON.parse noise:
+ * Phase 1 (SQL only): executeAsync → return raw _array (measures query + bridge overhead)
+ * Phase 2 (Full pipeline): executeAsync → JSON.parse each row (measures total cost as in real Onyx)
+ *
+ * Reports "best of N" (minimum) — GC/system load can only ADD time, never subtract,
+ * so the minimum is the most reliable indicator of true performance.
+ *
+ * Access via Test Tools Modal.
+ */
+import Clipboard from '@react-native-clipboard/clipboard';
+import React, {useCallback, useState} from 'react';
+import {Platform, StyleSheet, View} from 'react-native';
+import {open} from 'react-native-nitro-sqlite';
+import type {NitroSQLiteConnection} from 'react-native-nitro-sqlite';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Text from '@components/Text';
+
+const BENCH_DB_NAME = 'BenchmarkDB';
+const TABLE = 'keyvaluepairs';
+const COLLECTION_PREFIX = 'reportActions_';
+const PREFIXES = ['reportActions_', 'reports_', 'transactions_', 'policies_', 'accounts_'];
+const RUNS = 40;
+const PAUSE_BETWEEN_STRATEGIES_MS = 100;
+const RECORD_COUNTS = [500, 1000, 5000, 10000];
+
+const localStyles = StyleSheet.create({
+ title: {fontSize: 16, fontWeight: 'bold', marginTop: 20, marginBottom: 2},
+ sub: {fontSize: 11, color: '#666', marginBottom: 12},
+ btn: {backgroundColor: '#0366d6', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8},
+ btnOff: {backgroundColor: '#999'},
+ btnTxt: {color: '#fff', fontWeight: '600', fontSize: 14},
+ status: {fontSize: 11, color: '#666', marginBottom: 12, fontStyle: 'italic'},
+ group: {marginBottom: 16, borderWidth: 1, borderColor: '#ddd', borderRadius: 6, padding: 10},
+ gTitle: {fontSize: 13, fontWeight: '600', marginBottom: 6},
+ hRow: {flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 3, marginBottom: 3},
+ hTxt: {fontWeight: '600', fontSize: 10},
+ row: {flexDirection: 'row', paddingVertical: 2},
+ c: {flex: 1, fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'},
+ sc: {flex: 1.5},
+});
+
+type BenchResult = {
+ strategy: string;
+ count: number;
+ bestOf: number;
+ median: number;
+ min: number;
+ max: number;
+};
+
+// ---------------------------------------------------------------------------
+// Test data
+// ---------------------------------------------------------------------------
+
+function createRecord(index: number): string {
+ return JSON.stringify({
+ actionName: `action_${index}`,
+ reportActionID: String(index),
+ actorAccountID: index,
+ person: [{type: 'TEXT', style: 'strong', text: `User ${index}`}],
+ created: new Date(Date.now() - Math.random() * 1e10).toISOString(),
+ message: [{type: 'COMMENT', html: `Message ${index}
`, text: `Message ${index}`, isEdited: index % 3 === 0}],
+ originalMessage: {html: `Original ${index}
`, lastModified: new Date().toISOString()},
+ avatar: `avatar_${index}.png`,
+ automatic: false,
+ shouldShow: true,
+ lastModified: new Date().toISOString(),
+ pendingAction: null,
+ delegateAccountID: index,
+ errors: {},
+ isAttachmentOnly: false,
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Database helpers
+// ---------------------------------------------------------------------------
+
+function openBenchDB(): NitroSQLiteConnection {
+ const db = open({name: BENCH_DB_NAME});
+ db.execute(`CREATE TABLE IF NOT EXISTS ${TABLE} (record_key TEXT NOT NULL PRIMARY KEY, valueJSON JSON NOT NULL) WITHOUT ROWID;`);
+ db.execute('PRAGMA CACHE_SIZE=-20000;');
+ db.execute('PRAGMA synchronous=NORMAL;');
+ db.execute('PRAGMA journal_mode=WAL;');
+ return db;
+}
+
+function seedDB(db: NitroSQLiteConnection, count: number, prefix: string): string[] {
+ const keys: string[] = [];
+ const params: string[][] = [];
+ for (let i = 0; i < count; i++) {
+ const key = `${prefix}${i}`;
+ keys.push(key);
+ params.push([key, createRecord(i)]);
+ }
+ db.executeBatch([{query: `REPLACE INTO ${TABLE} (record_key, valueJSON) VALUES (?, ?)`, params}]);
+ return keys;
+}
+
+// ---------------------------------------------------------------------------
+// Strategy definitions — each returns a function for SQL-only and full pipeline
+// ---------------------------------------------------------------------------
+
+type StrategyFn = () => Promise;
+
+function makeStrategies(db: NitroSQLiteConnection, keys: string[], prefix: string) {
+ // Return raw rows — JSON.parse cost is identical for all strategies so we exclude it
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const getRows = (rows: {_array: unknown[]} | undefined) => {
+ // eslint-disable-next-line no-underscore-dangle
+ return rows?._array ?? [];
+ };
+
+ const inClause: StrategyFn = async () => {
+ const placeholders = keys.map(() => '?').join(',');
+ const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key IN (${placeholders})`, keys);
+ return getRows(rows);
+ };
+
+ const tempTable: StrategyFn = async () => {
+ const tableName = `temp_multiGet_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ await db.executeAsync(`CREATE TEMP TABLE ${tableName} (record_key TEXT PRIMARY KEY);`);
+ await db.executeBatchAsync([{query: `INSERT INTO ${tableName} (record_key) VALUES (?);`, params: keys.map((k) => [k])}]);
+ const {rows} = await db.executeAsync(`SELECT k.record_key, k.valueJSON FROM ${TABLE} AS k INNER JOIN ${tableName} AS t ON k.record_key = t.record_key;`);
+ const result = getRows(rows);
+ db.executeAsync(`DROP TABLE IF EXISTS ${tableName};`);
+ return result;
+ };
+
+ const makeChunkedIN =
+ (chunkSize: number): StrategyFn =>
+ async () => {
+ const allResults: unknown[] = [];
+ for (let i = 0; i < keys.length; i += chunkSize) {
+ const chunk = keys.slice(i, i + chunkSize);
+ const placeholders = chunk.map(() => '?').join(',');
+ // eslint-disable-next-line no-await-in-loop
+ const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key IN (${placeholders})`, chunk);
+ allResults.push(...getRows(rows));
+ }
+ return allResults;
+ };
+
+ /** json_each JOIN: pass all keys as a single JSON array, JOIN against json_each() virtual table */
+ const jsonEachJoin: StrategyFn = async () => {
+ const jsonArray = JSON.stringify(keys);
+ const {rows} = await db.executeAsync(`SELECT kv.record_key, kv.valueJSON FROM ${TABLE} kv INNER JOIN json_each(?) je ON kv.record_key = je.value`, [jsonArray]);
+ return getRows(rows);
+ };
+
+ /** json_each subselect: keys as JSON array, used in WHERE IN subquery */
+ const jsonEachSubselect: StrategyFn = async () => {
+ const jsonArray = JSON.stringify(keys);
+ const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key IN (SELECT value FROM json_each(?))`, [jsonArray]);
+ return getRows(rows);
+ };
+
+ const glob: StrategyFn = async () => {
+ const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key GLOB ?`, [`${prefix}*`]);
+ return getRows(rows);
+ };
+
+ return [
+ {name: 'IN clause', fn: inClause},
+ {name: 'Temp table', fn: tempTable},
+ {name: 'Chunk 500', fn: makeChunkedIN(500)},
+ {name: 'Chunk 1000', fn: makeChunkedIN(1000)},
+ {name: 'json_each JOIN', fn: jsonEachJoin},
+ {name: 'json_each SUB', fn: jsonEachSubselect},
+ {name: 'GLOB', fn: glob},
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Benchmark runner — reports min (best of N) and median
+// ---------------------------------------------------------------------------
+
+// eslint-disable-next-line no-promise-executor-return
+const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+
+async function runStrategyAsync(fn: StrategyFn, runs: number): Promise<{bestOf: number; median: number; min: number; max: number}> {
+ // 3 warmup
+ await fn();
+ await fn();
+ await fn();
+
+ const times: number[] = [];
+ for (let i = 0; i < runs; i++) {
+ const start = performance.now();
+ // eslint-disable-next-line no-await-in-loop
+ await fn();
+ times.push(performance.now() - start);
+ }
+ times.sort((a, b) => a - b);
+
+ const median = times.at(Math.floor(times.length / 2)) ?? 0;
+
+ return {
+ bestOf: times.at(0) ?? 0,
+ median,
+ min: times.at(0) ?? 0,
+ max: times.at(-1) ?? 0,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Run both phases for a single record count
+// ---------------------------------------------------------------------------
+
+async function benchmarkForCount(db: NitroSQLiteConnection, count: number): Promise {
+ db.execute(`DELETE FROM ${TABLE}`);
+ const allKeys: Record = {};
+ for (const pfx of PREFIXES) {
+ allKeys[pfx] = seedDB(db, count, pfx);
+ }
+ const targetKeys = allKeys[COLLECTION_PREFIX];
+
+ const results: BenchResult[] = [];
+
+ // SQL only — JSON.parse cost is identical for all strategies so we exclude it
+ const strategies = makeStrategies(db, targetKeys, COLLECTION_PREFIX);
+ for (const strat of strategies) {
+ // eslint-disable-next-line no-await-in-loop
+ await pause(PAUSE_BETWEEN_STRATEGIES_MS);
+ // eslint-disable-next-line no-await-in-loop
+ const r = await runStrategyAsync(strat.fn, RUNS);
+ results.push({strategy: strat.name, count, ...r});
+ }
+
+ return results;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+const MULTI_RUN_COUNT = 6;
+
+/** Run one full benchmark pass (all record counts, all strategies). Returns results array. */
+async function runSinglePass(setStatus: (s: string) => void, passNumber?: number): Promise {
+ const passLabel = passNumber != null ? ` (pass ${passNumber})` : '';
+ const db = openBenchDB();
+ const passResults: BenchResult[] = [];
+
+ try {
+ for (const count of RECORD_COUNTS) {
+ setStatus(`Benchmarking ${count} records${passLabel}...`);
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+
+ // eslint-disable-next-line no-await-in-loop
+ const stepResults = await benchmarkForCount(db, count);
+ passResults.push(...stepResults);
+ }
+ } finally {
+ try {
+ db.execute(`DROP TABLE IF EXISTS ${TABLE}`);
+ db.close();
+ db.delete();
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+
+ return passResults;
+}
+
+function SQLiteBenchmark() {
+ const [results, setResults] = useState([]);
+ const [allRuns, setAllRuns] = useState([]);
+ const [running, setRunning] = useState(false);
+ const [status, setStatus] = useState('Ready — tap to run');
+
+ const startSingleRun = useCallback(() => {
+ if (Platform.OS === 'web') {
+ setStatus('SQLite benchmarks only run on iOS/Android');
+ return;
+ }
+ setRunning(true);
+ setResults([]);
+ setAllRuns([]);
+
+ const run = async () => {
+ try {
+ const passResults = await runSinglePass(setStatus);
+ setResults(passResults);
+ setAllRuns([passResults]);
+ setStatus(`Done! ${RECORD_COUNTS.length} sizes x 7 strategies`);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ setStatus(`Error: ${msg}`);
+ // eslint-disable-next-line no-console
+ console.error('Benchmark error:', e);
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ run();
+ }, []);
+
+ const startMultiRun = useCallback(() => {
+ if (Platform.OS === 'web') {
+ setStatus('SQLite benchmarks only run on iOS/Android');
+ return;
+ }
+ setRunning(true);
+ setResults([]);
+ setAllRuns([]);
+
+ const run = async () => {
+ const collectedRuns: BenchResult[][] = [];
+ try {
+ for (let i = 0; i < MULTI_RUN_COUNT; i++) {
+ // eslint-disable-next-line no-await-in-loop
+ const passResults = await runSinglePass(setStatus, i + 1);
+ collectedRuns.push(passResults);
+ // Show latest run results on screen
+ setResults(passResults);
+ setAllRuns([...collectedRuns]);
+ }
+ setStatus(`Done! ${MULTI_RUN_COUNT} runs complete — tap Copy All to export`);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ setStatus(`Error: ${msg}`);
+ // eslint-disable-next-line no-console
+ console.error('Benchmark error:', e);
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ run();
+ }, []);
+
+ const copyResults = useCallback(() => {
+ const jsonOutput = JSON.stringify(
+ {
+ platform: Platform.OS,
+ timestamp: new Date().toISOString(),
+ totalRuns: allRuns.length,
+ config: {runsPerPass: RUNS, collections: PREFIXES.length, recordCounts: RECORD_COUNTS},
+ runs: allRuns.map((passResults, i) => ({run: i + 1, results: passResults})),
+ },
+ null,
+ 2,
+ );
+ Clipboard.setString(jsonOutput);
+ setStatus(`Copied ${allRuns.length} run(s) to clipboard!`);
+ }, [allRuns]);
+
+ // Group by count
+ const grouped = new Map();
+ for (const r of results) {
+ const list = grouped.get(r.count) ?? [];
+ if (!grouped.has(r.count)) {
+ grouped.set(r.count, list);
+ }
+ list.push(r);
+ }
+
+ const renderTable = (rows: BenchResult[], baselineStrategy: string) => {
+ const baselineMedian = rows.find((r) => r.strategy === baselineStrategy)?.median ?? 1;
+ return (
+ <>
+
+ Strategy
+ Median
+ Best
+ vs IN
+
+ {rows.map((r) => {
+ const diff = ((r.median - baselineMedian) / baselineMedian) * 100;
+ const isBase = r.strategy === baselineStrategy;
+ const diffStr = isBase ? '—' : `${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`;
+ // eslint-disable-next-line no-nested-ternary
+ const clr = isBase ? '#666' : diff < -5 ? '#2d8a4e' : diff > 5 ? '#d32f2f' : '#666';
+ return (
+
+ {r.strategy}
+ {r.median.toFixed(2)}
+ {r.min.toFixed(2)}
+ {diffStr}
+
+ );
+ })}
+ >
+ );
+ };
+
+ return (
+
+ SQLite multiGet Benchmark
+
+ SQL query only (JSON.parse excluded — identical cost for all strategies).{'\n'}
+ {RUNS} runs, median. {PREFIXES.length} collections x N records.
+
+
+
+ {running ? 'Running...' : 'Run 1x'}
+
+
+
+ {running ? 'Running...' : `Run ${MULTI_RUN_COUNT}x + Copy`}
+
+
+ {allRuns.length > 0 && !running && (
+
+ Copy All ({allRuns.length} runs)
+
+ )}
+
+ {status}
+
+ {Array.from(grouped.entries()).map(([count, rows]) => (
+
+
+ {count} records (DB: {count * PREFIXES.length})
+
+ {renderTable(rows, 'IN clause')}
+
+ ))}
+
+ );
+}
+
+SQLiteBenchmark.displayName = 'SQLiteBenchmark';
+
+export default SQLiteBenchmark;