From 92a36612e05d9584fcf04db9996ea68ba396b974 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 31 May 2026 11:51:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(search-ui):=20add=20Idempotency-Key=20head?= =?UTF-8?q?er=20for=20query=20coalescing=20(plan=20=C2=A713.10,=20=C2=A713?= =?UTF-8?q?.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add canonicalJson() helper to sort object keys recursively - Add generateIdempotencyKey() to create per-query idempotency keys from index + canonicalized request body (hash-based) - Send Idempotency-Key header on search requests for server-side coalescing - Add unit test (test_idempotency_key.js) verifying: - Same parameters produce same key - Different parameters produce different keys - Key format is correct (search-{hex}) - Canonical JSON ensures consistency across key orderings Co-Authored-By: Claude Opus 4.8 --- crates/miroir-proxy/static/search/search.js | 57 +++++++- .../static/search/test_idempotency_key.js | 123 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100755 crates/miroir-proxy/static/search/test_idempotency_key.js diff --git a/crates/miroir-proxy/static/search/search.js b/crates/miroir-proxy/static/search/search.js index 29e0162..77afdf9 100644 --- a/crates/miroir-proxy/static/search/search.js +++ b/crates/miroir-proxy/static/search/search.js @@ -183,6 +183,57 @@ }).join(''); } + // Canonicalize JSON by sorting object keys recursively (plan §13.10) + function canonicalJson(obj) { + if (obj === null || typeof obj !== 'object') { + return JSON.stringify(obj); + } + if (Array.isArray(obj)) { + return '[' + obj.map(canonicalJson).join(',') + ']'; + } + const sortedKeys = Object.keys(obj).sort(); + return '{' + sortedKeys.map(k => `"${k}":${canonicalJson(obj[k])}`).join(',') + '}'; + } + + // Generate per-query idempotency key (plan §13.10, §13.21) + // Hash of index + normalized query body for query coalescing + function generateIdempotencyKey(query, filters, page, sort, perPage) { + const requestBody = { + q: query, + limit: perPage || currentPerPage || RESULTS_PER_PAGE, + offset: page * (perPage || currentPerPage || RESULTS_PER_PAGE), + attributesToRetrieve: ['*'], + attributesToHighlight: config?.display_attributes || ['*'], + facets: config?.facets?.map(f => f.attribute) || [] + }; + + // Add filters + const filterParts = []; + for (const [key, values] of Object.entries(filters)) { + if (Array.isArray(values) && values.length > 0) { + filterParts.push(`${key} IN ${JSON.stringify(values)}`); + } + } + if (filterParts.length > 0) { + requestBody.filter = filterParts.join(' AND '); + } + + // Add sort + if (sort) { + requestBody.sort = [sort]; + } + + // Canonicalize and hash + const canonical = `${currentIndex}:${canonicalJson(requestBody)}`; + let hash = 0; + for (let i = 0; i < canonical.length; i++) { + const char = canonical.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return `search-${Math.abs(hash).toString(16)}`; + } + // API helper async function search(query, filters = {}, page = 0, sort = null, perPage = null) { const requestBody = { @@ -210,11 +261,15 @@ requestBody.sort = [sort]; } + // Generate idempotency key for query coalescing (plan §13.10, §13.21) + const idempotencyKey = generateIdempotencyKey(query, filters, page, sort, perPage); + const response = await fetch(`/indexes/${currentIndex}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${sessionToken}` + 'Authorization': `Bearer ${sessionToken}`, + 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(requestBody) }); diff --git a/crates/miroir-proxy/static/search/test_idempotency_key.js b/crates/miroir-proxy/static/search/test_idempotency_key.js new file mode 100755 index 0000000..9a185a5 --- /dev/null +++ b/crates/miroir-proxy/static/search/test_idempotency_key.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Unit test for search UI idempotency key generation (plan §13.10, §13.21). + * + * Verifies that: + * - Same query parameters generate the same idempotency key + * - Different query parameters generate different idempotency keys + * - The key format is correct (starts with "search-" followed by hex) + */ + +// Canonicalize JSON by sorting object keys recursively +function canonicalJson(obj) { + if (obj === null || typeof obj !== 'object') { + return JSON.stringify(obj); + } + if (Array.isArray(obj)) { + return '[' + obj.map(canonicalJson).join(',') + ']'; + } + const sortedKeys = Object.keys(obj).sort(); + return '{' + sortedKeys.map(k => `"${k}":${canonicalJson(obj[k])}`).join(',') + '}'; +} + +// Generate per-query idempotency key +function generateIdempotencyKey(index, requestBody) { + // Canonicalize and hash + const canonical = `${index}:${canonicalJson(requestBody)}`; + let hash = 0; + for (let i = 0; i < canonical.length; i++) { + const char = canonical.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return `search-${Math.abs(hash).toString(16)}`; +} + +function assert(condition, message) { + if (!condition) { + console.error(`❌ FAILED: ${message}`); + process.exit(1); + } + console.log(`✓ ${message}`); +} + +// Test 1: Same parameters generate same key +console.log('\n=== Test 1: Same parameters generate same idempotency key ==='); +const index = 'products'; +const requestBody1 = { q: 'laptop', limit: 10, offset: 0 }; +const key1 = generateIdempotencyKey(index, requestBody1); +const key2 = generateIdempotencyKey(index, requestBody1); +assert(key1 === key2, 'Same parameters produce same key'); +assert(key1.startsWith('search-'), 'Key starts with "search-"'); + +// Test 2: Different queries generate different keys +console.log('\n=== Test 2: Different queries generate different keys ==='); +const requestBody2 = { q: 'phone', limit: 10, offset: 0 }; +const key3 = generateIdempotencyKey(index, requestBody2); +assert(key1 !== key3, 'Different queries produce different keys'); + +// Test 3: Different limit values generate different keys +console.log('\n=== Test 3: Different limit values generate different keys ==='); +const requestBody3 = { q: 'laptop', limit: 20, offset: 0 }; +const key4 = generateIdempotencyKey(index, requestBody3); +assert(key1 !== key4, 'Different limit values produce different keys'); + +// Test 4: Different page (offset) values generate different keys +console.log('\n=== Test 4: Different page values generate different keys ==='); +const requestBody4 = { q: 'laptop', limit: 10, offset: 10 }; +const key5 = generateIdempotencyKey(index, requestBody4); +assert(key1 !== key5, 'Different page values produce different keys'); + +// Test 5: Different filters generate different keys +console.log('\n=== Test 5: Different filters generate different keys ==='); +const requestBody5 = { q: 'laptop', limit: 10, offset: 0, filter: 'category IN ["electronics"]' }; +const key6 = generateIdempotencyKey(index, requestBody5); +assert(key1 !== key6, 'Different filters produce different keys'); + +// Test 6: Different indexes generate different keys +console.log('\n=== Test 6: Different indexes generate different keys ==='); +const key7 = generateIdempotencyKey('users', requestBody1); +assert(key1 !== key7, 'Different indexes produce different keys'); + +// Test 7: Canonical JSON ensures consistent ordering +console.log('\n=== Test 7: Canonical JSON ensures key ordering consistency ==='); +const requestBody6 = { limit: 10, q: 'laptop', offset: 0 }; +const key8 = generateIdempotencyKey(index, requestBody6); +assert(key1 === key8, 'Different key orders produce same canonical key'); + +// Test 8: Complex nested objects are handled correctly +console.log('\n=== Test 8: Complex nested objects are handled correctly ==='); +const requestBody7 = { + q: 'test', + filter: 'category IN ["electronics"] AND price IN ["100-500"]', + facets: ['category', 'price'], + sort: ['price:asc'] +}; +const key9 = generateIdempotencyKey(index, requestBody7); +assert(key9.startsWith('search-'), 'Complex request produces valid key'); + +const requestBody8 = { + facets: ['category', 'price'], + filter: 'category IN ["electronics"] AND price IN ["100-500"]', + q: 'test', + sort: ['price:asc'] +}; +const key10 = generateIdempotencyKey(index, requestBody8); +assert(key9 === key10, 'Complex requests with different key orders produce same key'); + +// Test 9: Empty/null values are handled correctly +console.log('\n=== Test 9: Empty/null values are handled correctly ==='); +const requestBody9 = { q: '', limit: 10, offset: 0 }; +const key11 = generateIdempotencyKey(index, requestBody9); +assert(key11.startsWith('search-'), 'Empty query string produces valid key'); + +// Test 10: Arrays are handled correctly (order matters) +console.log('\n=== Test 10: Arrays preserve order ==='); +const requestBody10 = { facets: ['category', 'brand', 'price'] }; +const key12 = generateIdempotencyKey(index, requestBody10); +const requestBody11 = { facets: ['brand', 'category', 'price'] }; +const key13 = generateIdempotencyKey(index, requestBody11); +assert(key12 !== key13, 'Different array orders produce different keys'); + +console.log('\n✅ All idempotency key tests passed!'); +process.exit(0);