diff --git a/main/db.ts b/main/db.ts index 1cd4a6355..9eb43944e 100644 --- a/main/db.ts +++ b/main/db.ts @@ -1323,6 +1323,19 @@ function recoverInterruptedDatabaseReplacement(dbPath: string, backupDir: string } } +export function registerOrderSearchFunctions(dbInstance: Database.Database): void { + try { + dbInstance.function('orders_search_contains', (value: string | null, query: string) => + typeof value === 'string' && value.toLowerCase().includes(query.toLowerCase()) ? 1 : 0, + ); + dbInstance.function('orders_search_digits_contains', (value: string | null, digits: string) => + typeof value === 'string' && value.replace(/\D/g, '').includes(digits) ? 1 : 0, + ); + } catch { + // Function already registered or connection closed + } +} + export function initDatabase( recoverInterruptedReplacement = true, allowDuringShutdown = false, @@ -1350,6 +1363,7 @@ export function initDatabase( console.log(`[DB] Opening database at: ${dbPath}`); dbHealthError = null; db = new Database(dbPath); + registerOrderSearchFunctions(db); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); db.pragma('busy_timeout = 5000'); @@ -2409,7 +2423,11 @@ export function getTables(dbInstance: Database.Database): string[] { const tables = dbInstance .prepare(` SELECT name FROM sqlite_master WHERE type='table' - AND name NOT LIKE 'sqlite_%' AND name <> '_flo_meta' + AND (sql NOT LIKE 'CREATE VIRTUAL TABLE%' OR sql IS NULL) + AND name NOT LIKE 'sqlite_%' + AND name NOT LIKE '%_fts_%' + AND name NOT LIKE '%_fts' + AND name <> '_flo_meta' `) .all() as { name: string }[]; return tables.map((t) => t.name); @@ -4093,6 +4111,7 @@ function dataOnlyRestore( ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `) .run(now()); + rebuildSearchIndex(currentDb); const inventoryValidationError = validateInventoryLedgerDatabase(currentDb); if (inventoryValidationError) { throw new Error(`Restore would violate the inventory ledger: ${inventoryValidationError}`); @@ -4247,9 +4266,31 @@ export function getCurrentSchemaVersion(): number { return (db ?? getDatabase()).pragma('user_version', { simple: true }) as number; } +export function rebuildSearchIndex(targetDb?: Database.Database): void { + const target = targetDb || db; + if (!target) return; + try { + const hasOrdersFts = target + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'orders_search_fts'") + .get(); + if (hasOrdersFts) { + target.exec("INSERT INTO orders_search_fts(orders_search_fts) VALUES('rebuild')"); + } + const hasCustomersFts = target + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'customers_search_fts'") + .get(); + if (hasCustomersFts) { + target.exec("INSERT INTO customers_search_fts(customers_search_fts) VALUES('rebuild')"); + } + } catch (err) { + console.warn('[DB] rebuildSearchIndex error:', err); + } +} + /** Builds in-memory database using migration pipeline as ideal schema reference. */ export function buildIdealSchemaDb(): Database.Database { const idealDb = new Database(':memory:'); + registerOrderSearchFunctions(idealDb); idealDb.pragma('foreign_keys = OFF'); // Off during migrations const previousDb = db; db = idealDb; @@ -7348,6 +7389,55 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ `); }, }, + { + version: 115, + name: 'add_order_and_customer_search_fts', + up: () => { + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS orders_search_fts USING fts5( + order_number, + content='orders', + content_rowid='id', + tokenize='trigram' + ); + CREATE TRIGGER IF NOT EXISTS trg_orders_search_ai AFTER INSERT ON orders BEGIN + INSERT INTO orders_search_fts(rowid, order_number) VALUES (new.id, new.order_number); + END; + CREATE TRIGGER IF NOT EXISTS trg_orders_search_ad AFTER DELETE ON orders BEGIN + INSERT INTO orders_search_fts(orders_search_fts, rowid, order_number) VALUES('delete', old.id, old.order_number); + END; + CREATE TRIGGER IF NOT EXISTS trg_orders_search_au AFTER UPDATE OF order_number ON orders BEGIN + INSERT INTO orders_search_fts(orders_search_fts, rowid, order_number) VALUES('delete', old.id, old.order_number); + INSERT INTO orders_search_fts(rowid, order_number) VALUES (new.id, new.order_number); + END; + + CREATE VIRTUAL TABLE IF NOT EXISTS customers_search_fts USING fts5( + name, + phone, + phone_digits, + content='customers', + content_rowid='rowid', + tokenize='trigram' + ); + CREATE TRIGGER IF NOT EXISTS trg_customers_search_ai AFTER INSERT ON customers BEGIN + INSERT INTO customers_search_fts(rowid, name, phone, phone_digits) VALUES (new.rowid, new.name, new.phone, new.phone_digits); + END; + CREATE TRIGGER IF NOT EXISTS trg_customers_search_ad AFTER DELETE ON customers BEGIN + INSERT INTO customers_search_fts(customers_search_fts, rowid, name, phone, phone_digits) VALUES('delete', old.rowid, old.name, old.phone, old.phone_digits); + END; + CREATE TRIGGER IF NOT EXISTS trg_customers_search_au AFTER UPDATE OF name, phone, phone_digits ON customers BEGIN + INSERT INTO customers_search_fts(customers_search_fts, rowid, name, phone, phone_digits) VALUES('delete', old.rowid, old.name, old.phone, old.phone_digits); + INSERT INTO customers_search_fts(rowid, name, phone, phone_digits) VALUES (new.rowid, new.name, new.phone, new.phone_digits); + END; + + INSERT INTO orders_search_fts(rowid, order_number) + SELECT id, order_number FROM orders; + + INSERT INTO customers_search_fts(rowid, name, phone, phone_digits) + SELECT rowid, name, phone, phone_digits FROM customers; + `); + }, + }, ]; function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void { diff --git a/main/routes/orders.ts b/main/routes/orders.ts index 501700fb5..098f1e30b 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -16,6 +16,7 @@ import { utcTodayDate, recordOrderAudit, isKdsEnabled, + registerOrderSearchFunctions, } from '../db'; import { asyncHandler } from '../middleware/async-handler'; import { @@ -70,6 +71,25 @@ import { isOperationReference, serializeOperationFailure } from '../lib/operatio type PinApproverCandidate = { id: string; role: string; pin_hash: string }; +const registeredSearchDatabases = new WeakSet(); + +function ensureOrderSearchFunctions(dbInstance: any): void { + if (registeredSearchDatabases.has(dbInstance)) return; + registerOrderSearchFunctions(dbInstance); + registeredSearchDatabases.add(dbInstance); +} + +function isSearchIndexAvailable(dbInstance: any): boolean { + try { + const row = dbInstance + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'orders_search_fts'") + .get(); + return Boolean(row); + } catch { + return false; + } +} + const router = Router(); const ORDER_WRITE_RATE_LIMIT_ERROR = { error: 'Too many requests, please try again later.', @@ -638,28 +658,67 @@ router.get( } const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; if (search) { - db.function('orders_search_contains', (value: string | null, query: string) => - typeof value === 'string' && value.toLowerCase().includes(query.toLowerCase()) ? 1 : 0, - ); - db.function('orders_search_digits_contains', (value: string | null, digits: string) => - typeof value === 'string' && value.replace(/\D/g, '').includes(digits) ? 1 : 0, - ); + ensureOrderSearchFunctions(db); const phoneDigits = search.replace(/\D/g, ''); + const isPhoneSearch = Boolean(phoneDigits && !/\p{L}/u.test(search)); const customerSearch = ['orders_search_contains(search_customer.name, ?)']; - if (phoneDigits && !/\p{L}/u.test(search)) { + if (isPhoneSearch) { customerSearch.push('orders_search_digits_contains(search_customer.phone_digits, ?)'); customerSearch.push('orders_search_digits_contains(search_customer.phone, ?)'); } - wheres.push(`( + const udfClause = `( orders_search_contains(orders.order_number, ?) OR EXISTS ( SELECT 1 FROM customers AS search_customer WHERE search_customer.id = orders.customer_id AND (${customerSearch.join(' OR ')}) ) - )`); - params.push(search, search); - if (phoneDigits && !/\p{L}/u.test(search)) params.push(phoneDigits, phoneDigits); + )`; + + const isEligible = [...search].length >= 3 && (!isPhoneSearch || phoneDigits.length >= 3); + const hasFts = isEligible && isSearchIndexAvailable(db); + + if (hasFts) { + let candidateClause: string; + const candidateParams: any[] = []; + + if (isPhoneSearch) { + // For phone searches, customer phone strings may contain varied formatting + // (e.g. "+1/555 ext 0199"). Querying customers table with digit UDF efficiently + // identifies matching customer IDs across the small customers table, avoiding a + // full orders scan while preserving full parity across phone formats. + const ftsSearch = '"' + phoneDigits.replace(/"/g, '""') + '"'; + candidateClause = `( + orders.id IN (SELECT rowid FROM orders_search_fts WHERE orders_search_fts MATCH ?) + OR orders.customer_id IN ( + SELECT id FROM customers + WHERE (phone_digits IS NOT NULL AND orders_search_digits_contains(phone_digits, ?)) + OR (phone IS NOT NULL AND orders_search_digits_contains(phone, ?)) + OR orders_search_contains(name, ?) + ) + )`; + candidateParams.push(ftsSearch, phoneDigits, phoneDigits, search); + } else { + const ftsSearch = '"' + search.replace(/"/g, '""') + '"'; + candidateClause = `( + orders.id IN (SELECT rowid FROM orders_search_fts WHERE orders_search_fts MATCH ?) + OR orders.customer_id IN ( + SELECT id FROM customers WHERE rowid IN ( + SELECT rowid FROM customers_search_fts WHERE customers_search_fts MATCH ? + ) + ) + )`; + candidateParams.push(ftsSearch, ftsSearch); + } + + wheres.push(`(${candidateClause} AND ${udfClause})`); + params.push(...candidateParams, search, search); + if (isPhoneSearch) params.push(phoneDigits, phoneDigits); + } else { + wheres.push(udfClause); + params.push(search, search); + if (isPhoneSearch) params.push(phoneDigits, phoneDigits); + } } // Filter by UTC day or date range across indexed created_at column. if (req.query.today && req.query.today !== '0' && req.query.today !== 'false') { diff --git a/package.json b/package.json index 739099d86..2c6fdc787 100644 --- a/package.json +++ b/package.json @@ -252,7 +252,8 @@ "test:rtl-dashboard-pos-common": "ts-node --transpile-only -P tests/tsconfig.json tests/rtl-dashboard-pos-common.test.ts", "test:rtl-kds-server-whatsapp": "ts-node --transpile-only -P tests/tsconfig.json tests/rtl-kds-server-whatsapp.test.ts", "test:phone": "ts-node --transpile-only -P tests/tsconfig.json tests/phone.test.ts", - "test:orders-search": "ts-node --transpile-only -P tests/tsconfig.json tests/orders-search.test.ts && node tests/run-electron-node-test.cjs tests/orders-search-api.test.ts", + "test:orders-search": "ts-node --transpile-only -P tests/tsconfig.json tests/orders-search.test.ts && node tests/run-electron-node-test.cjs tests/orders-search-api.test.ts && npm run test:orders-search-performance", + "test:orders-search-performance": "node tests/run-electron-node-test.cjs tests/orders-search-performance.test.ts", "test:country-localization": "ts-node --transpile-only -P tests/tsconfig.json tests/country-localization.test.ts", "test:currency": "ts-node --transpile-only -P tests/tsconfig.json tests/currency.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/country-profile.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/locale-iran.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/regional-snapshot.test.ts && node tests/run-electron-node-test.cjs tests/currency-split-checks.test.ts && node tests/run-electron-node-test.cjs tests/currency-payments-refunds-minor-units.test.ts && node tests/run-electron-node-test.cjs tests/currency-reset.test.ts", "test:currency-split": "node tests/run-electron-node-test.cjs tests/currency-split-checks.test.ts", diff --git a/tests/orders-search-api.test.ts b/tests/orders-search-api.test.ts index a89bf71f7..9e9b006e5 100644 --- a/tests/orders-search-api.test.ts +++ b/tests/orders-search-api.test.ts @@ -30,6 +30,8 @@ async function main() { const db = initTestDb(); const owner = seedOwnerUser(db); seedCustomer(db, 'orders-search-customer', 'Élodie Search Customer', '+1/555 ext 0199'); + seedCustomer(db, 'cust-zoe', 'Zoë Laurent', '+1 (555) 234-5678'); + seedCustomer(db, 'cust-oconnor', "Shaun O'Connor", '+353 1 496 0000'); const insertOrder = db.prepare(` INSERT INTO orders (order_number, customer_id, type, status, subtotal, total, created_at, updated_at) @@ -41,6 +43,16 @@ async function main() { '2020-01-01 00:00:00', '2020-01-01 00:00:00', ); + insertOrder.run('ORD-SYNTH-ZOE', 'cust-zoe', '2023-01-01 00:00:00', '2023-01-01 00:00:00'); + insertOrder.run( + 'ORD-SYNTH-OCONNOR', + 'cust-oconnor', + '2023-01-01 00:00:00', + '2023-01-01 00:00:00', + ); + insertOrder.run('ORD-SYNTH-NOCUST-999', null, '2023-01-01 00:00:00', '2023-01-01 00:00:00'); + insertOrder.run('ORD-SYNTH-ALPHA123', null, '2023-01-01 00:00:00', '2023-01-01 00:00:00'); + insertOrder.run('ORD-SYNTH-EMOJI-🍕🍔', null, '2023-01-01 00:00:00', '2023-01-01 00:00:00'); for (let i = 0; i < 55; i++) { insertOrder.run( `ORD-SEARCH-NEWER-${i}`, @@ -207,6 +219,143 @@ async function main() { ); } + // ── Synthetic API Probes (#1185) ───────────────────────────────────────── + // 1. Order number substring probe + const probeOrderNum = await api( + baseUrl, + `/api/orders?search=${encodeURIComponent('SYNTH-ALPHA')}`, + { + headers: owner.authHeader, + }, + ); + assertEqualOrThrow(probeOrderNum.status, 200, 'probe search for order number succeeds'); + assertOrThrow( + probeOrderNum.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ALPHA123'), + 'probe returns matching order by order number', + ); + + // 2. Unicode zoë probe + const probeZoe = await api(baseUrl, `/api/orders?search=${encodeURIComponent('zoë')}`, { + headers: owner.authHeader, + }); + assertEqualOrThrow(probeZoe.status, 200, 'probe search for Unicode zoë succeeds'); + assertOrThrow( + probeZoe.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ZOE'), + 'probe returns order matching customer Unicode zoë', + ); + + // 3. Case-insensitive Unicode ZOË probe + const probeZoeUpper = await api(baseUrl, `/api/orders?search=${encodeURIComponent('ZOË')}`, { + headers: owner.authHeader, + }); + assertEqualOrThrow(probeZoeUpper.status, 200, 'probe search for uppercase ZOË succeeds'); + assertOrThrow( + probeZoeUpper.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ZOE'), + 'probe returns order matching customer uppercase ZOË', + ); + + // 4. Case-insensitive apostrophe name o'connor probe + const probeOConnor = await api( + baseUrl, + `/api/orders?search=${encodeURIComponent("o'connor")}`, + { + headers: owner.authHeader, + }, + ); + assertEqualOrThrow(probeOConnor.status, 200, "probe search for o'connor succeeds"); + assertOrThrow( + probeOConnor.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-OCONNOR'), + "probe returns order matching customer o'connor", + ); + + // 5. Punctuated phone search probe: (555) 234 + const probePhonePunct = await api( + baseUrl, + `/api/orders?search=${encodeURIComponent('(555) 234')}`, + { + headers: owner.authHeader, + }, + ); + assertEqualOrThrow(probePhonePunct.status, 200, 'probe search for punctuated phone succeeds'); + assertOrThrow( + probePhonePunct.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ZOE'), + 'probe returns order matching punctuated phone (555) 234', + ); + + // 6. Phone fragment probe: 4960000 + const probePhoneFrag = await api( + baseUrl, + `/api/orders?search=${encodeURIComponent('4960000')}`, + { + headers: owner.authHeader, + }, + ); + assertEqualOrThrow(probePhoneFrag.status, 200, 'probe search for phone fragment succeeds'); + assertOrThrow( + probePhoneFrag.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-OCONNOR'), + 'probe returns order matching phone fragment 4960000', + ); + + // 7. Mixed alphanumeric input probe + const probeAlphaNum = await api( + baseUrl, + `/api/orders?search=${encodeURIComponent('ALPHA123')}`, + { + headers: owner.authHeader, + }, + ); + assertEqualOrThrow(probeAlphaNum.status, 200, 'probe search for alphanumeric input succeeds'); + assertOrThrow( + probeAlphaNum.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ALPHA123'), + 'probe returns order matching alphanumeric input', + ); + + // 8. Null-customer order probe + const probeNoCust = await api(baseUrl, `/api/orders?search=${encodeURIComponent('NOCUST')}`, { + headers: owner.authHeader, + }); + assertEqualOrThrow(probeNoCust.status, 200, 'probe search for null-customer order succeeds'); + assertOrThrow( + probeNoCust.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-NOCUST-999'), + 'probe returns order with null customer without error', + ); + + // 9. Short query probe (< 3 chars, falling back to UDF scan) + const probeShort = await api(baseUrl, `/api/orders?search=${encodeURIComponent('zo')}`, { + headers: owner.authHeader, + }); + assertEqualOrThrow(probeShort.status, 200, 'probe search for short query succeeds'); + assertOrThrow( + probeShort.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ZOE'), + 'probe returns order matching short query via fallback', + ); + + // 10. Astral Unicode probe (fewer than 3 codepoints, falling back to UDF scan) + const probeAstral = await api(baseUrl, `/api/orders?search=${encodeURIComponent('🍕🍔')}`, { + headers: owner.authHeader, + }); + assertEqualOrThrow(probeAstral.status, 200, 'probe search for astral characters succeeds'); + assertOrThrow( + probeAstral.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-EMOJI-🍕🍔'), + 'probe returns order matching astral emoji query via fallback', + ); + + // 11. Repeated requests after registration was skipped + for (let i = 0; i < 3; i++) { + const probeRepeated = await api( + baseUrl, + `/api/orders?search=${encodeURIComponent('ALPHA123')}`, + { + headers: owner.authHeader, + }, + ); + assertEqualOrThrow(probeRepeated.status, 200, `repeated search ${i + 1} succeeds`); + assertOrThrow( + probeRepeated.data.orders.some((o: any) => o.order_number === 'ORD-SYNTH-ALPHA123'), + `repeated search ${i + 1} returns target order`, + ); + } + const activePage1 = await api( baseUrl, '/api/orders?view=active&type=takeaway&status=pending&per_page=15', diff --git a/tests/orders-search-performance.test.ts b/tests/orders-search-performance.test.ts new file mode 100644 index 000000000..d753f5be3 --- /dev/null +++ b/tests/orders-search-performance.test.ts @@ -0,0 +1,466 @@ +/** + * Order Search Performance, Scaling & Invariants Test + * + * Verifies Issue #1185 (ARCH-009b): + * 1. Functions installed once per database connection; repeated requests do not re-register; + * connection reopen preserves function availability. + * 2. Indexed candidate retrieval (FTS5 trigram on orders and customers) narrows candidates + * before JS UDF verification. + * 3. EXPLAIN QUERY PLAN demonstrates indexed search plan (MULTI-INDEX OR with orders_search_fts + * and customers_search_fts) instead of scanning the full orders table. + * 4. Benchmarks at 100k and 300k rows demonstrating 0 UDF callbacks on no-match searches + * (down from 180,000 / 540,000) and bounded callbacks on selective searches. + * 5. Full UDF fallback preserved for short queries (< 3 chars) and phone normalization. + * + * Usage: node tests/run-electron-node-test.cjs tests/orders-search-performance.test.ts + */ + +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-orders-search-perf-')); +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + }, + }; + } + return originalLoad.apply(this, arguments as any); +}; + +const Database = require('better-sqlite3'); +const { assertOrThrow, assertEqualOrThrow } = require('./helpers/test-setup'); +const { + registerOrderSearchFunctions, + initDatabase, + getDatabase, + closeDatabase, +} = require('../main/db'); + +function calculatePercentile(values: number[], percentile: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(Math.floor((percentile / 100) * sorted.length), sorted.length - 1); + return sorted[index]; +} + +async function main() { + console.log('='.repeat(65)); + console.log('Order Search Scaling & Candidate Retrieval Tests (#1185 / ARCH-009b)'); + console.log('='.repeat(65)); + + try { + // ───────────────────────────────────────────────────────────────────────── + // Section 1: Connection Reopen & Idempotent Registration + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 1. Connection Reopen & Idempotent Registration ---'); + + const dbPath = path.join(testDir, 'reopen-test.db'); + let testConn = new Database(dbPath); + registerOrderSearchFunctions(testConn); + + // Verify functions work + const check1 = testConn + .prepare("SELECT orders_search_contains('Order-1234', '123') as res") + .get() as any; + assertEqualOrThrow(check1.res, 1, 'orders_search_contains is available and functional'); + const checkDigits1 = testConn + .prepare("SELECT orders_search_digits_contains('+1 (555) 234-5678', '555234') as res") + .get() as any; + assertEqualOrThrow( + checkDigits1.res, + 1, + 'orders_search_digits_contains is available and functional', + ); + + // Repeated registration on same connection is idempotent and does not throw + registerOrderSearchFunctions(testConn); + const check2 = testConn + .prepare("SELECT orders_search_contains('Hello World', 'world') as res") + .get() as any; + assertEqualOrThrow(check2.res, 1, 'Repeated registration preserves function availability'); + + // Close and reopen connection + testConn.close(); + testConn = new Database(dbPath); + registerOrderSearchFunctions(testConn); + const checkReopened = testConn + .prepare("SELECT orders_search_contains('Order-Reopen', 'reopen') as res") + .get() as any; + assertEqualOrThrow( + checkReopened.res, + 1, + 'Reopened connection registers functions successfully', + ); + testConn.close(); + console.log('✓ Connection reopen and idempotent registration verified'); + + // ───────────────────────────────────────────────────────────────────────── + // Section 2: 100,000 Rows Benchmark & Query Plan + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 2. 100,000 Rows Benchmark & Query Plan ---'); + + initDatabase(); + const db = getDatabase(); + + // Seed customers + const insertCust = db.prepare(` + INSERT OR IGNORE INTO customers (id, name, phone, country_code, is_active, created_at, updated_at) + VALUES (?, ?, ?, '+1', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); + + insertCust.run('cust-zoe', 'Zoë Laurent', '+1 (555) 234-5678'); + insertCust.run('cust-oconnor', "Shaun O'Connor", '+353 1 496 0000'); + insertCust.run('cust-elodie', 'Élodie Search Customer', '+1/555 ext 0199'); + for (let c = 4; c <= 200; c++) { + insertCust.run(`cust-${c}`, `Customer Name ${c}`, `+1 555 000 ${String(c).padStart(4, '0')}`); + } + + console.log('Seeding 100,000 orders...'); + const insertOrder = db.prepare(` + INSERT INTO orders (id, order_number, customer_id, type, status, subtotal, total, created_at, updated_at) + VALUES (?, ?, ?, 'takeaway', 'completed', 100, 100, ?, ?) + `); + + db.exec('BEGIN IMMEDIATE'); + for (let i = 1; i <= 100000; i++) { + let custId: string | null = null; + if (i === 1) custId = 'cust-zoe'; + else if (i === 2) custId = 'cust-oconnor'; + else if (i === 3) custId = 'cust-elodie'; + else if (i <= 1000) custId = `cust-${(i % 200) + 1}`; + + const orderNum = `ORD-PERF-${String(i).padStart(6, '0')}`; + insertOrder.run(i, orderNum, custId, '2026-01-01 00:00:00', '2026-01-01 00:00:00'); + } + db.exec('COMMIT'); + console.log('✓ Seeded 100,000 orders'); + + // Callback counters + let searchContainsCallbacks = 0; + let searchDigitsCallbacks = 0; + + db.function('orders_search_contains', (value: string | null, query: string) => { + searchContainsCallbacks++; + return typeof value === 'string' && value.toLowerCase().includes(query.toLowerCase()) ? 1 : 0; + }); + db.function('orders_search_digits_contains', (value: string | null, digits: string) => { + searchDigitsCallbacks++; + return typeof value === 'string' && value.replace(/\D/g, '').includes(digits) ? 1 : 0; + }); + + // 2.1 Query Plan Verification on 100k rows + const searchPlanSql = ` + EXPLAIN QUERY PLAN + SELECT orders.id, orders.order_number + FROM orders + WHERE ( + ( + orders.id IN (SELECT rowid FROM orders_search_fts WHERE orders_search_fts MATCH ?) + OR orders.customer_id IN ( + SELECT id FROM customers WHERE rowid IN ( + SELECT rowid FROM customers_search_fts WHERE customers_search_fts MATCH ? + ) + ) + ) + AND ( + orders_search_contains(orders.order_number, ?) + OR EXISTS ( + SELECT 1 FROM customers AS search_customer + WHERE search_customer.id = orders.customer_id + AND orders_search_contains(search_customer.name, ?) + ) + ) + ) + ORDER BY orders.id DESC + LIMIT 50 + `; + + const planRows = db + .prepare(searchPlanSql) + .all('"ORD-PERF-050000"', '"ORD-PERF-050000"', 'ORD-PERF-050000', 'ORD-PERF-050000') as any[]; + + const planDetails = planRows.map((r) => r.detail).join('\n'); + console.log('Query Plan for eligible search at 100k:\n' + planDetails); + + assertOrThrow( + planDetails.includes('orders_search_fts') || planDetails.includes('MULTI-INDEX OR'), + 'Query plan uses FTS virtual table index', + ); + assertOrThrow( + !planDetails.includes('SCAN orders\n'), + 'Query plan does NOT perform an unindexed full scan of the orders table', + ); + console.log('✓ Query plan verified at 100k rows'); + + // 2.2 Callback Count Verification at 100k rows + const searchSql = ` + SELECT orders.id, orders.order_number + FROM orders + WHERE ( + ( + orders.id IN (SELECT rowid FROM orders_search_fts WHERE orders_search_fts MATCH ?) + OR orders.customer_id IN ( + SELECT id FROM customers WHERE rowid IN ( + SELECT rowid FROM customers_search_fts WHERE customers_search_fts MATCH ? + ) + ) + ) + AND ( + orders_search_contains(orders.order_number, ?) + OR EXISTS ( + SELECT 1 FROM customers AS search_customer + WHERE search_customer.id = orders.customer_id + AND orders_search_contains(search_customer.name, ?) + ) + ) + ) + ORDER BY orders.id DESC + LIMIT 50 + `; + const searchStmt = db.prepare(searchSql); + + // No-match search: candidate retrieval returns 0 candidates, UDF runs 0 times! + searchContainsCallbacks = 0; + const noMatchRows = searchStmt.all( + '"nomatch99999"', + '"nomatch99999"', + 'nomatch99999', + 'nomatch99999', + ); + assertEqualOrThrow(noMatchRows.length, 0, 'No-match search returns 0 rows'); + assertEqualOrThrow( + searchContainsCallbacks, + 0, + 'No-match search executes exactly 0 UDF callbacks at 100k rows (down from 180,000)', + ); + console.log(`✓ No-match callback count at 100k: ${searchContainsCallbacks} (expected 0)`); + + // Selective search: candidate retrieval narrows to 1 candidate + searchContainsCallbacks = 0; + const matchRows = searchStmt.all( + '"ORD-PERF-050000"', + '"ORD-PERF-050000"', + 'ORD-PERF-050000', + 'ORD-PERF-050000', + ) as any[]; + assertEqualOrThrow(matchRows.length, 1, 'Selective search returns 1 matching order'); + assertEqualOrThrow( + matchRows[0].order_number, + 'ORD-PERF-050000', + 'Matching order number is correct', + ); + assertOrThrow( + searchContainsCallbacks <= 2, + `Selective search callback count is bounded (got ${searchContainsCallbacks}, expected <= 2)`, + ); + console.log( + `✓ Selective search callback count at 100k: ${searchContainsCallbacks} (expected <= 2)`, + ); + + // 2.3 Latency Benchmark at 100k rows + const latencies100k: number[] = []; + for (let run = 0; run < 25; run++) { + const t0 = performance.now(); + searchStmt.all('"nomatch99999"', '"nomatch99999"', 'nomatch99999', 'nomatch99999'); + latencies100k.push(performance.now() - t0); + } + const p50_100k = calculatePercentile(latencies100k, 50); + const p95_100k = calculatePercentile(latencies100k, 95); + console.log( + `✓ Latency at 100k rows (no-match): p50 = ${p50_100k.toFixed(3)} ms, p95 = ${p95_100k.toFixed(3)} ms`, + ); + + // ───────────────────────────────────────────────────────────────────────── + // Section 3: 300,000 Rows Scale Benchmark + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 3. 300,000 Rows Scale Benchmark ---'); + + console.log('Seeding additional 200,000 orders to reach 300,000 orders...'); + db.exec('BEGIN IMMEDIATE'); + for (let i = 100001; i <= 300000; i++) { + const custId = i <= 101000 ? `cust-${(i % 200) + 1}` : null; + const orderNum = `ORD-PERF-${String(i).padStart(6, '0')}`; + insertOrder.run(i, orderNum, custId, '2026-01-01 00:00:00', '2026-01-01 00:00:00'); + } + db.exec('COMMIT'); + console.log('✓ Seeded 300,000 orders total'); + + // 3.1 Callback Count Verification at 300k rows + searchContainsCallbacks = 0; + const noMatch300k = searchStmt.all( + '"nomatch99999"', + '"nomatch99999"', + 'nomatch99999', + 'nomatch99999', + ); + assertEqualOrThrow(noMatch300k.length, 0, 'No-match search returns 0 rows at 300k'); + assertEqualOrThrow( + searchContainsCallbacks, + 0, + 'No-match search executes exactly 0 UDF callbacks at 300k rows (down from 540,000)', + ); + console.log(`✓ No-match callback count at 300k: ${searchContainsCallbacks} (expected 0)`); + + // Selective search at 300k + searchContainsCallbacks = 0; + const match300k = searchStmt.all( + '"ORD-PERF-250000"', + '"ORD-PERF-250000"', + 'ORD-PERF-250000', + 'ORD-PERF-250000', + ) as any[]; + assertEqualOrThrow(match300k.length, 1, 'Selective search returns 1 matching order at 300k'); + assertEqualOrThrow( + match300k[0].order_number, + 'ORD-PERF-250000', + 'Matching order number is correct', + ); + assertOrThrow( + searchContainsCallbacks <= 2, + `Selective search callback count at 300k is bounded (got ${searchContainsCallbacks}, expected <= 2)`, + ); + console.log( + `✓ Selective search callback count at 300k: ${searchContainsCallbacks} (expected <= 2)`, + ); + + // Customer name search at 300k + searchContainsCallbacks = 0; + const matchCust300k = searchStmt.all('"Zoë"', '"Zoë"', 'zoë', 'zoë') as any[]; + assertEqualOrThrow(matchCust300k.length, 1, 'Customer name search returns matching order'); + assertEqualOrThrow( + matchCust300k[0].order_number, + 'ORD-PERF-000001', + 'Found order for customer Zoë', + ); + assertOrThrow( + searchContainsCallbacks <= 5, + `Customer name callback count at 300k is bounded (got ${searchContainsCallbacks}, expected <= 5)`, + ); + console.log( + `✓ Customer name callback count at 300k: ${searchContainsCallbacks} (expected <= 5)`, + ); + + // 3.2 Latency Benchmark at 300k rows + const latencies300k: number[] = []; + for (let run = 0; run < 25; run++) { + const t0 = performance.now(); + searchStmt.all('"nomatch99999"', '"nomatch99999"', 'nomatch99999', 'nomatch99999'); + latencies300k.push(performance.now() - t0); + } + const p50_300k = calculatePercentile(latencies300k, 50); + const p95_300k = calculatePercentile(latencies300k, 95); + console.log( + `✓ Latency at 300k rows (no-match): p50 = ${p50_300k.toFixed(3)} ms, p95 = ${p95_300k.toFixed(3)} ms`, + ); + + // ───────────────────────────────────────────────────────────────────────── + // Section 4: Phone Search Candidate & Fallback Verification at Scale + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 4. Phone Search Candidate & Fallback Verification at Scale ---'); + + const phoneSearchSql = ` + SELECT orders.id, orders.order_number + FROM orders + WHERE ( + ( + orders.id IN (SELECT rowid FROM orders_search_fts WHERE orders_search_fts MATCH ?) + OR orders.customer_id IN ( + SELECT id FROM customers + WHERE (phone_digits IS NOT NULL AND orders_search_digits_contains(phone_digits, ?)) + OR (phone IS NOT NULL AND orders_search_digits_contains(phone, ?)) + OR orders_search_contains(name, ?) + ) + ) + AND ( + orders_search_contains(orders.order_number, ?) + OR EXISTS ( + SELECT 1 FROM customers AS search_customer + WHERE search_customer.id = orders.customer_id + AND ( + orders_search_contains(search_customer.name, ?) + OR orders_search_digits_contains(search_customer.phone_digits, ?) + OR orders_search_digits_contains(search_customer.phone, ?) + ) + ) + ) + ) + ORDER BY orders.id DESC + LIMIT 50 + `; + const phoneStmt = db.prepare(phoneSearchSql); + + searchDigitsCallbacks = 0; + searchContainsCallbacks = 0; + const phoneResults = phoneStmt.all( + '"555234"', + '555234', + '555234', + '(555) 234', + '(555) 234', + '(555) 234', + '555234', + '555234', + ) as any[]; + assertEqualOrThrow( + phoneResults.length, + 1, + 'Phone search finds target order across 300k orders', + ); + assertEqualOrThrow( + phoneResults[0].order_number, + 'ORD-PERF-000001', + 'Found order for Zoë by phone fragment', + ); + assertOrThrow( + searchDigitsCallbacks < 2000, + `Phone search examines customers table only, not 300,000 orders (callbacks = ${searchDigitsCallbacks})`, + ); + console.log( + `✓ Phone search verified at 300k rows: 1 match, ${searchDigitsCallbacks} digit callbacks across customers`, + ); + + // 4.1 Short Query Fallback (< 3 chars) + const shortQuerySql = ` + SELECT orders.id, orders.order_number + FROM orders + WHERE ( + orders_search_contains(orders.order_number, ?) + OR EXISTS ( + SELECT 1 FROM customers AS search_customer + WHERE search_customer.id = orders.customer_id + AND orders_search_contains(search_customer.name, ?) + ) + ) + AND orders.id <= 100 + ORDER BY orders.id DESC + LIMIT 10 + `; + const shortResults = db.prepare(shortQuerySql).all('zo', 'zo') as any[]; + assertEqualOrThrow(shortResults.length, 1, 'Short query fallback finds target order'); + assertEqualOrThrow( + shortResults[0].order_number, + 'ORD-PERF-000001', + 'Short query matches customer Zoë', + ); + console.log('✓ Short query fallback (< 3 chars) verified'); + + console.log('\nAll order search performance, scaling & invariant tests passed successfully.'); + } finally { + closeDatabase(); + try { + fs.rmSync(testDir, { recursive: true, force: true }); + } catch {} + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});