@open-mercato/search 0.4.11-develop.2083.e52e08a1fc → 0.4.11-develop.2085.d3a54c890f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/presenter-enricher.js +5 -16
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/lib/search-result-enrichment.js +18 -0
- package/dist/lib/search-result-enrichment.js.map +7 -0
- package/dist/service.js +2 -10
- package/dist/service.js.map +2 -2
- package/jest.config.cjs +3 -0
- package/package.json +4 -4
- package/src/__tests__/debug.test.ts +113 -0
- package/src/__tests__/presenter-enricher.test.ts +188 -0
- package/src/__tests__/service.test.ts +44 -0
- package/src/lib/presenter-enricher.ts +5 -28
- package/src/lib/search-result-enrichment.ts +17 -0
- package/src/service.ts +3 -13
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
import { decryptIndexDocForSearch } from "@open-mercato/shared/lib/encryption/indexDoc";
|
|
2
2
|
import { extractFallbackPresenter } from "./fallback-presenter.js";
|
|
3
|
+
import { needsSearchResultEnrichment } from "./search-result-enrichment.js";
|
|
3
4
|
const BATCH_SIZE = 500;
|
|
4
5
|
const logWarning = (message, context) => {
|
|
5
6
|
if (process.env.NODE_ENV === "development" || process.env.DEBUG_SEARCH_ENRICHER) {
|
|
6
7
|
console.warn(`[search:presenter-enricher] ${message}`, context ?? "");
|
|
7
8
|
}
|
|
8
9
|
};
|
|
9
|
-
function looksEncrypted(value) {
|
|
10
|
-
if (typeof value !== "string") return false;
|
|
11
|
-
if (!value.includes(":")) return false;
|
|
12
|
-
const parts = value.split(":");
|
|
13
|
-
return parts.length >= 3 && parts[parts.length - 1] === "v1";
|
|
14
|
-
}
|
|
15
|
-
function needsEnrichment(result) {
|
|
16
|
-
if (!result.presenter?.title) return true;
|
|
17
|
-
if (looksEncrypted(result.presenter.title)) return true;
|
|
18
|
-
if (looksEncrypted(result.presenter.subtitle)) return true;
|
|
19
|
-
if (!result.url && (!result.links || result.links.length === 0)) return true;
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
10
|
function chunk(array, size) {
|
|
23
11
|
const chunks = [];
|
|
24
12
|
for (let i = 0; i < array.length; i += size) {
|
|
@@ -114,7 +102,7 @@ async function computePresenterAndLinks(doc, entityId, recordId, config, tenantI
|
|
|
114
102
|
}
|
|
115
103
|
function createPresenterEnricher(knex, entityConfigMap, queryEngine, encryptionService) {
|
|
116
104
|
return async (results, tenantId, organizationId) => {
|
|
117
|
-
const missingResults = results.filter(
|
|
105
|
+
const missingResults = results.filter(needsSearchResultEnrichment);
|
|
118
106
|
if (missingResults.length === 0) return results;
|
|
119
107
|
const byEntityType = /* @__PURE__ */ new Map();
|
|
120
108
|
for (const result of missingResults) {
|
|
@@ -173,15 +161,16 @@ function createPresenterEnricher(knex, entityConfigMap, queryEngine, encryptionS
|
|
|
173
161
|
enrichmentMap.set(key, { presenter, url, links });
|
|
174
162
|
}
|
|
175
163
|
return results.map((result) => {
|
|
176
|
-
if (!
|
|
164
|
+
if (!needsSearchResultEnrichment(result)) return result;
|
|
177
165
|
const key = `${result.entityId}:${result.recordId}`;
|
|
178
166
|
const enriched = enrichmentMap.get(key);
|
|
179
167
|
if (!enriched) return result;
|
|
168
|
+
const hasExistingLinks = Array.isArray(result.links) && result.links.length > 0;
|
|
180
169
|
return {
|
|
181
170
|
...result,
|
|
182
171
|
presenter: enriched.presenter ?? result.presenter,
|
|
183
172
|
url: result.url ?? enriched.url,
|
|
184
|
-
links: result.links
|
|
173
|
+
links: hasExistingLinks ? result.links : enriched.links ?? result.links
|
|
185
174
|
};
|
|
186
175
|
});
|
|
187
176
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/presenter-enricher.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Knex } from 'knex'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { extractFallbackPresenter } from './fallback-presenter'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Logger for debugging - uses console.warn to surface issues without breaking flow */\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SEARCH_ENRICHER) {\n console.warn(`[search:presenter-enricher] ${message}`, context ?? '')\n }\n}\n\n/**\n * Check if a string looks like an encrypted value.\n * Encrypted format: iv:ciphertext:authTag:v1\n */\nfunction looksEncrypted(value: unknown): boolean {\n if (typeof value !== 'string') return false\n if (!value.includes(':')) return false\n const parts = value.split(':')\n // Encrypted strings end with :v1 and have at least 3 colon-separated parts\n return parts.length >= 3 && parts[parts.length - 1] === 'v1'\n}\n\n/**\n * Check if a result needs enrichment (missing presenter, encrypted values, or missing URL/links)\n */\nfunction needsEnrichment(result: SearchResult): boolean {\n if (!result.presenter?.title) return true\n // Also re-enrich if presenter looks encrypted\n if (looksEncrypted(result.presenter.title)) return true\n if (looksEncrypted(result.presenter.subtitle)) return true\n // Also enrich if missing URL/links (needed for token search results)\n if (!result.url && (!result.links || result.links.length === 0)) return true\n return false\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n knex: Knex,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n const query = knex('entity_indexes')\n .select('entity_type', 'entity_id', 'doc')\n .where('tenant_id', tenantId)\n .whereNull('deleted_at')\n .where((builder) => {\n for (const [entityType, recordIds] of chunkByType) {\n builder.orWhere((sub) => {\n sub.where('entity_type', entityType).whereIn('entity_id', recordIds)\n })\n }\n })\n\n // Add organization filter if provided\n if (organizationId) {\n query.where((builder) => {\n builder.where('organization_id', organizationId).orWhereNull('organization_id')\n })\n }\n\n const rows = await query\n allDocs.push(...(rows as typeof allDocs))\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n // If search.ts config exists, use formatResult/buildSource for presenter\n if (config?.formatResult || config?.buildSource) {\n if (config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) })\n }\n }\n\n if (!presenter && config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n knex: Knex,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n // Find results missing presenter OR with encrypted presenter\n const missingResults = results.filter(needsEnrichment)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(knex, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n return results.map((result) => {\n if (!needsEnrichment(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: result.links ?? enriched.links,\n }\n })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAYA,SAAS,gCAAgC;AACzC,SAAS,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import type { Knex } from 'knex'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Logger for debugging - uses console.warn to surface issues without breaking flow */\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SEARCH_ENRICHER) {\n console.warn(`[search:presenter-enricher] ${message}`, context ?? '')\n }\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n knex: Knex,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n const query = knex('entity_indexes')\n .select('entity_type', 'entity_id', 'doc')\n .where('tenant_id', tenantId)\n .whereNull('deleted_at')\n .where((builder) => {\n for (const [entityType, recordIds] of chunkByType) {\n builder.orWhere((sub) => {\n sub.where('entity_type', entityType).whereIn('entity_id', recordIds)\n })\n }\n })\n\n // Add organization filter if provided\n if (organizationId) {\n query.where((builder) => {\n builder.where('organization_id', organizationId).orWhereNull('organization_id')\n })\n }\n\n const rows = await query\n allDocs.push(...(rows as typeof allDocs))\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n // If search.ts config exists, use formatResult/buildSource for presenter\n if (config?.formatResult || config?.buildSource) {\n if (config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) })\n }\n }\n\n if (!presenter && config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n knex: Knex,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n // Find results missing presenter OR with encrypted presenter\n const missingResults = results.filter(needsSearchResultEnrichment)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(knex, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n return results.map((result) => {\n if (!needsSearchResultEnrichment(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n const hasExistingLinks = Array.isArray(result.links) && result.links.length > 0\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: hasExistingLinks ? result.links : (enriched.links ?? result.links),\n }\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function looksLikeEncryptedSearchValue(value) {
|
|
2
|
+
if (typeof value !== "string") return false;
|
|
3
|
+
if (!value.includes(":")) return false;
|
|
4
|
+
const parts = value.split(":");
|
|
5
|
+
return parts.length >= 3 && parts[parts.length - 1] === "v1";
|
|
6
|
+
}
|
|
7
|
+
function needsSearchResultEnrichment(result) {
|
|
8
|
+
if (!result.presenter?.title) return true;
|
|
9
|
+
if (looksLikeEncryptedSearchValue(result.presenter.title)) return true;
|
|
10
|
+
if (looksLikeEncryptedSearchValue(result.presenter.subtitle)) return true;
|
|
11
|
+
if (!result.url && (!result.links || result.links.length === 0)) return true;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
looksLikeEncryptedSearchValue,
|
|
16
|
+
needsSearchResultEnrichment
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=search-result-enrichment.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/search-result-enrichment.ts"],
|
|
4
|
+
"sourcesContent": ["import type { SearchResult } from '../types'\n\nexport function looksLikeEncryptedSearchValue(value: unknown): boolean {\n if (typeof value !== 'string') return false\n if (!value.includes(':')) return false\n\n const parts = value.split(':')\n return parts.length >= 3 && parts[parts.length - 1] === 'v1'\n}\n\nexport function needsSearchResultEnrichment(result: SearchResult): boolean {\n if (!result.presenter?.title) return true\n if (looksLikeEncryptedSearchValue(result.presenter.title)) return true\n if (looksLikeEncryptedSearchValue(result.presenter.subtitle)) return true\n if (!result.url && (!result.links || result.links.length === 0)) return true\n return false\n}\n"],
|
|
5
|
+
"mappings": "AAEO,SAAS,8BAA8B,OAAyB;AACrE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,CAAC,MAAM,SAAS,GAAG,EAAG,QAAO;AAEjC,QAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,SAAO,MAAM,UAAU,KAAK,MAAM,MAAM,SAAS,CAAC,MAAM;AAC1D;AAEO,SAAS,4BAA4B,QAA+B;AACzE,MAAI,CAAC,OAAO,WAAW,MAAO,QAAO;AACrC,MAAI,8BAA8B,OAAO,UAAU,KAAK,EAAG,QAAO;AAClE,MAAI,8BAA8B,OAAO,UAAU,QAAQ,EAAG,QAAO;AACrE,MAAI,CAAC,OAAO,QAAQ,CAAC,OAAO,SAAS,OAAO,MAAM,WAAW,GAAI,QAAO;AACxE,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mergeAndRankResults } from "./lib/merger.js";
|
|
2
2
|
import { searchError } from "./lib/debug.js";
|
|
3
|
+
import { needsSearchResultEnrichment } from "./lib/search-result-enrichment.js";
|
|
3
4
|
const DEFAULT_MERGE_CONFIG = {
|
|
4
5
|
duplicateHandling: "highest_score"
|
|
5
6
|
};
|
|
@@ -66,16 +67,7 @@ class SearchService {
|
|
|
66
67
|
*/
|
|
67
68
|
async enrichResultsWithPresenter(results, tenantId, organizationId) {
|
|
68
69
|
if (!this.presenterEnricher) return results;
|
|
69
|
-
const
|
|
70
|
-
if (!r.presenter?.title) return true;
|
|
71
|
-
const title = r.presenter.title;
|
|
72
|
-
if (typeof title === "string" && title.includes(":")) {
|
|
73
|
-
const parts = title.split(":");
|
|
74
|
-
if (parts.length >= 3 && parts[parts.length - 1] === "v1") return true;
|
|
75
|
-
}
|
|
76
|
-
return false;
|
|
77
|
-
};
|
|
78
|
-
const hasMissing = results.some(needsEnrichment);
|
|
70
|
+
const hasMissing = results.some(needsSearchResultEnrichment);
|
|
79
71
|
if (!hasMissing) return results;
|
|
80
72
|
try {
|
|
81
73
|
return await this.presenterEnricher(results, tenantId, organizationId);
|
package/dist/service.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/service.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n\n // Enrich results missing presenter data\n return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n // Check if any results need enrichment (missing or encrypted presenter)\n const needsEnrichment = (r: SearchResult) => {\n if (!r.presenter?.title) return true\n // Also enrich if presenter looks encrypted (format: iv:ciphertext:authTag:v1)\n const title = r.presenter.title\n if (typeof title === 'string' && title.includes(':')) {\n const parts = title.split(':')\n if (parts.length >= 3 && parts[parts.length - 1] === 'v1') return true\n }\n return false\n }\n const hasMissing = results.some(needsEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return strategy.isAvailable()\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const available: SearchStrategy[] = []\n\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) {\n try {\n const isAvailable = await strategy.isAvailable()\n if (isAvailable) {\n available.push(strategy)\n }\n } catch {\n // Strategy availability check failed, skip it\n }\n }\n }\n\n // Sort by priority (higher priority first)\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAUA,SAAS,2BAA2B;AACpC,SAAS,mBAAmB;
|
|
4
|
+
"sourcesContent": ["import type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n SearchServiceOptions,\n ResultMergeConfig,\n IndexableRecord,\n PresenterEnricherFn,\n} from './types'\nimport { mergeAndRankResults } from './lib/merger'\nimport { searchError } from './lib/debug'\nimport { needsSearchResultEnrichment } from './lib/search-result-enrichment'\n\n/**\n * Default merge configuration.\n */\nconst DEFAULT_MERGE_CONFIG: ResultMergeConfig = {\n duplicateHandling: 'highest_score',\n}\n\n/**\n * SearchService orchestrates multiple search strategies, executing searches in parallel\n * and merging results using the RRF algorithm.\n *\n * Features:\n * - Parallel strategy execution for optimal performance\n * - Graceful degradation when strategies fail\n * - Result merging with configurable weights\n * - Strategy availability checking\n *\n * @example\n * ```typescript\n * const service = new SearchService({\n * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],\n * defaultStrategies: ['fulltext', 'vector', 'tokens'],\n * mergeConfig: {\n * duplicateHandling: 'highest_score',\n * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },\n * },\n * })\n *\n * const results = await service.search('john doe', { tenantId: 'tenant-123' })\n * ```\n */\nexport class SearchService {\n private readonly strategies: Map<SearchStrategyId, SearchStrategy>\n private readonly defaultStrategies: SearchStrategyId[]\n private readonly fallbackStrategy: SearchStrategyId | undefined\n private readonly mergeConfig: ResultMergeConfig\n private readonly presenterEnricher?: PresenterEnricherFn\n\n constructor(options: SearchServiceOptions = {}) {\n this.strategies = new Map()\n for (const strategy of options.strategies ?? []) {\n this.strategies.set(strategy.id, strategy)\n }\n this.defaultStrategies = options.defaultStrategies ?? ['tokens']\n this.fallbackStrategy = options.fallbackStrategy\n this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG\n this.presenterEnricher = options.presenterEnricher\n }\n\n /**\n * Get all registered strategies.\n */\n getStrategies(): SearchStrategy[] {\n return Array.from(this.strategies.values())\n }\n\n /**\n * Execute a search query across configured strategies.\n *\n * @param query - Search query string\n * @param options - Search options with tenant, filters, etc.\n * @returns Merged and ranked search results\n */\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const strategyIds = options.strategies ?? this.defaultStrategies\n const activeStrategies = await this.getAvailableStrategies(strategyIds)\n\n if (activeStrategies.length === 0) {\n // Try fallback strategy if defined\n if (this.fallbackStrategy) {\n const fallback = await this.getAvailableStrategies([this.fallbackStrategy])\n if (fallback.length > 0) {\n activeStrategies.push(...fallback)\n }\n }\n }\n\n if (activeStrategies.length === 0) {\n return []\n }\n\n // Execute searches in parallel with graceful degradation\n const results = await Promise.allSettled(\n activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),\n )\n\n // Collect successful results, log failures\n const allResults: SearchResult[] = []\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'fulfilled') {\n allResults.push(...result.value)\n } else {\n const strategy = activeStrategies[i]\n searchError('SearchService', 'Strategy search failed', {\n strategyId: strategy?.id,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n\n // Merge and rank results\n const merged = mergeAndRankResults(allResults, this.mergeConfig)\n\n // Enrich results missing presenter or navigation metadata\n return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)\n }\n\n /**\n * Enrich results that are missing presenter data using the configured enricher.\n * This ensures token-only results get proper titles/subtitles for display.\n */\n private async enrichResultsWithPresenter(\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n ): Promise<SearchResult[]> {\n // If no enricher configured, return as-is\n if (!this.presenterEnricher) return results\n\n const hasMissing = results.some(needsSearchResultEnrichment)\n if (!hasMissing) return results\n\n // Use the configured presenter enricher\n try {\n return await this.presenterEnricher(results, tenantId, organizationId)\n } catch {\n // Enrichment failed, return results as-is\n return results\n }\n }\n\n /**\n * Index a record across all available strategies.\n *\n * @param record - Record to index\n */\n async index(record: IndexableRecord): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n if (strategies.length === 0) {\n return\n }\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy index failed', {\n strategyId: strategy?.id,\n entityId: record.entityId,\n recordId: record.recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Delete a record from all strategies.\n *\n * @param entityId - Entity type identifier\n * @param recordId - Record primary key\n * @param tenantId - Tenant for isolation\n */\n async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy delete failed', {\n strategyId: strategy?.id,\n entityId,\n recordId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Bulk index multiple records.\n *\n * @param records - Records to index\n */\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.bulkIndex) {\n return strategy.bulkIndex(records)\n }\n // Fallback to individual indexing\n return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy bulkIndex failed', {\n strategyId: strategy?.id,\n recordCount: records.length,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Purge all records for an entity type.\n *\n * @param entityId - Entity type to purge\n * @param tenantId - Tenant for isolation\n */\n async purge(entityId: string, tenantId: string): Promise<void> {\n const strategies = await this.getAvailableStrategies()\n\n const results = await Promise.allSettled(\n strategies.map((strategy) => {\n if (strategy.purge) {\n return strategy.purge(entityId, tenantId)\n }\n return Promise.resolve()\n }),\n )\n\n // Log any failures\n for (let i = 0; i < results.length; i++) {\n const result = results[i]\n if (result.status === 'rejected') {\n const strategy = strategies[i]\n searchError('SearchService', 'Strategy purge failed', {\n strategyId: strategy?.id,\n entityId,\n error: result.reason instanceof Error ? result.reason.message : result.reason,\n })\n }\n }\n }\n\n /**\n * Register a new strategy at runtime.\n *\n * @param strategy - Strategy to register\n */\n registerStrategy(strategy: SearchStrategy): void {\n this.strategies.set(strategy.id, strategy)\n }\n\n /**\n * Unregister a strategy.\n *\n * @param strategyId - Strategy ID to remove\n */\n unregisterStrategy(strategyId: SearchStrategyId): void {\n this.strategies.delete(strategyId)\n }\n\n /**\n * Get all registered strategy IDs.\n */\n getRegisteredStrategies(): SearchStrategyId[] {\n return Array.from(this.strategies.keys())\n }\n\n /**\n * Get a specific strategy by ID.\n *\n * @param strategyId - Strategy ID to retrieve\n * @returns The strategy if registered, undefined otherwise\n */\n getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {\n return this.strategies.get(strategyId)\n }\n\n /**\n * Get the default strategies list.\n */\n getDefaultStrategies(): SearchStrategyId[] {\n return [...this.defaultStrategies]\n }\n\n /**\n * Check if a specific strategy is available.\n *\n * @param strategyId - Strategy ID to check\n */\n async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {\n const strategy = this.strategies.get(strategyId)\n if (!strategy) return false\n return strategy.isAvailable()\n }\n\n /**\n * Get available strategies from the requested list.\n * Filters out strategies that are not registered or not available.\n */\n private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {\n const targetIds = ids ?? Array.from(this.strategies.keys())\n const available: SearchStrategy[] = []\n\n for (const id of targetIds) {\n const strategy = this.strategies.get(id)\n if (strategy) {\n try {\n const isAvailable = await strategy.isAvailable()\n if (isAvailable) {\n available.push(strategy)\n }\n } catch {\n // Strategy availability check failed, skip it\n }\n }\n }\n\n // Sort by priority (higher priority first)\n return available.sort((a, b) => b.priority - a.priority)\n }\n\n /**\n * Execute search on a single strategy with error handling.\n */\n private async executeStrategySearch(\n strategy: SearchStrategy,\n query: string,\n options: SearchOptions,\n ): Promise<SearchResult[]> {\n await strategy.ensureReady()\n return strategy.search(query, options)\n }\n\n /**\n * Execute index on a single strategy with error handling.\n */\n private async executeStrategyIndex(\n strategy: SearchStrategy,\n record: IndexableRecord,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.index(record)\n }\n\n /**\n * Execute delete on a single strategy with error handling.\n */\n private async executeStrategyDelete(\n strategy: SearchStrategy,\n entityId: string,\n recordId: string,\n tenantId: string,\n ): Promise<void> {\n await strategy.ensureReady()\n return strategy.delete(entityId, recordId, tenantId)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/jest.config.cjs
CHANGED
|
@@ -6,8 +6,11 @@ module.exports = {
|
|
|
6
6
|
rootDir: '.',
|
|
7
7
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
8
8
|
moduleNameMapper: {
|
|
9
|
+
'^@open-mercato/shared$': '<rootDir>/../shared/src/index.ts',
|
|
9
10
|
'^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
|
|
11
|
+
'^@open-mercato/core$': '<rootDir>/../core/src/index.ts',
|
|
10
12
|
'^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
|
|
13
|
+
'^@open-mercato/queue$': '<rootDir>/../queue/src/index.ts',
|
|
11
14
|
'^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
|
|
12
15
|
},
|
|
13
16
|
transform: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.4.11-develop.
|
|
3
|
+
"version": "0.4.11-develop.2085.d3a54c890f",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -126,9 +126,9 @@
|
|
|
126
126
|
"zod": "^4.0.0"
|
|
127
127
|
},
|
|
128
128
|
"peerDependencies": {
|
|
129
|
-
"@open-mercato/core": "0.4.11-develop.
|
|
130
|
-
"@open-mercato/queue": "0.4.11-develop.
|
|
131
|
-
"@open-mercato/shared": "0.4.11-develop.
|
|
129
|
+
"@open-mercato/core": "0.4.11-develop.2085.d3a54c890f",
|
|
130
|
+
"@open-mercato/queue": "0.4.11-develop.2085.d3a54c890f",
|
|
131
|
+
"@open-mercato/shared": "0.4.11-develop.2085.d3a54c890f"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@types/jest": "^30.0.0",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { isSearchDebugEnabled, searchDebug, searchDebugWarn, searchError } from '../lib/debug'
|
|
2
|
+
|
|
3
|
+
describe('search debug utilities', () => {
|
|
4
|
+
const originalDebugEnv = process.env.OM_SEARCH_DEBUG
|
|
5
|
+
|
|
6
|
+
const restoreDebugEnv = () => {
|
|
7
|
+
if (originalDebugEnv === undefined) {
|
|
8
|
+
delete process.env.OM_SEARCH_DEBUG
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
process.env.OM_SEARCH_DEBUG = originalDebugEnv
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
restoreDebugEnv()
|
|
16
|
+
jest.restoreAllMocks()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
restoreDebugEnv()
|
|
21
|
+
jest.restoreAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('isSearchDebugEnabled', () => {
|
|
25
|
+
it.each(['1', 'true', 'TRUE', 'Yes', 'on'])('returns true for %s', (value) => {
|
|
26
|
+
process.env.OM_SEARCH_DEBUG = value
|
|
27
|
+
|
|
28
|
+
expect(isSearchDebugEnabled()).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it.each([undefined, '', '0', 'false', 'no', 'off', 'debug'])('returns false for %s', (value) => {
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
delete process.env.OM_SEARCH_DEBUG
|
|
34
|
+
} else {
|
|
35
|
+
process.env.OM_SEARCH_DEBUG = value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect(isSearchDebugEnabled()).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('searchDebug', () => {
|
|
43
|
+
it('does not log when debug is disabled', () => {
|
|
44
|
+
delete process.env.OM_SEARCH_DEBUG
|
|
45
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
46
|
+
|
|
47
|
+
searchDebug('search.test', 'suppressed')
|
|
48
|
+
|
|
49
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('logs message and payload when debug is enabled', () => {
|
|
53
|
+
process.env.OM_SEARCH_DEBUG = 'true'
|
|
54
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
55
|
+
const payload = { entityId: 'customers:person', recordId: 'rec-123' }
|
|
56
|
+
|
|
57
|
+
searchDebug('search.test', 'indexed', payload)
|
|
58
|
+
|
|
59
|
+
expect(consoleSpy).toHaveBeenCalledWith('[search.test] indexed', payload)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('logs only the formatted message when payload is omitted', () => {
|
|
63
|
+
process.env.OM_SEARCH_DEBUG = 'true'
|
|
64
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
65
|
+
|
|
66
|
+
searchDebug('search.test', 'indexed')
|
|
67
|
+
|
|
68
|
+
expect(consoleSpy).toHaveBeenCalledWith('[search.test] indexed')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('searchDebugWarn', () => {
|
|
73
|
+
it('does not warn when debug is disabled', () => {
|
|
74
|
+
process.env.OM_SEARCH_DEBUG = 'false'
|
|
75
|
+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
76
|
+
|
|
77
|
+
searchDebugWarn('search.test', 'suppressed')
|
|
78
|
+
|
|
79
|
+
expect(consoleSpy).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('warns with the formatted message and payload when enabled', () => {
|
|
83
|
+
process.env.OM_SEARCH_DEBUG = 'yes'
|
|
84
|
+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
85
|
+
const payload = { queue: 'vector-indexing' }
|
|
86
|
+
|
|
87
|
+
searchDebugWarn('search.test', 'retrying', payload)
|
|
88
|
+
|
|
89
|
+
expect(consoleSpy).toHaveBeenCalledWith('[search.test] retrying', payload)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('searchError', () => {
|
|
94
|
+
it('always logs errors even when debug is disabled', () => {
|
|
95
|
+
process.env.OM_SEARCH_DEBUG = 'false'
|
|
96
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
97
|
+
const payload = { error: 'boom' }
|
|
98
|
+
|
|
99
|
+
searchError('search.test', 'failed', payload)
|
|
100
|
+
|
|
101
|
+
expect(consoleSpy).toHaveBeenCalledWith('[search.test] failed', payload)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('logs only the formatted error message when payload is omitted', () => {
|
|
105
|
+
delete process.env.OM_SEARCH_DEBUG
|
|
106
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
107
|
+
|
|
108
|
+
searchError('search.test', 'failed')
|
|
109
|
+
|
|
110
|
+
expect(consoleSpy).toHaveBeenCalledWith('[search.test] failed')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Knex } from 'knex'
|
|
2
|
+
import type { SearchEntityConfig } from '../types'
|
|
3
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
4
|
+
import type { SearchResult } from '@open-mercato/shared/modules/search'
|
|
5
|
+
import { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'
|
|
6
|
+
import { createPresenterEnricher } from '../lib/presenter-enricher'
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/lib/encryption/indexDoc', () => ({
|
|
9
|
+
decryptIndexDocForSearch: jest.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
type IndexRow = {
|
|
13
|
+
entity_type: string
|
|
14
|
+
entity_id: string
|
|
15
|
+
doc: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ConditionBuilder = {
|
|
19
|
+
where: (fieldOrCallback: unknown, value?: unknown) => ConditionBuilder
|
|
20
|
+
whereIn: (field: string, values: string[]) => ConditionBuilder
|
|
21
|
+
whereNull: (field: string) => ConditionBuilder
|
|
22
|
+
orWhere: (callback: (builder: ConditionBuilder) => void) => ConditionBuilder
|
|
23
|
+
orWhereNull: (field: string) => ConditionBuilder
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type QueryBuilder = ConditionBuilder & {
|
|
27
|
+
select: (...fields: string[]) => QueryBuilder
|
|
28
|
+
then: Promise<IndexRow[]>['then']
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mockedDecryptIndexDocForSearch = jest.mocked(decryptIndexDocForSearch)
|
|
32
|
+
|
|
33
|
+
function createConditionBuilder(): ConditionBuilder {
|
|
34
|
+
const builder: ConditionBuilder = {
|
|
35
|
+
where: (fieldOrCallback) => {
|
|
36
|
+
if (typeof fieldOrCallback === 'function') {
|
|
37
|
+
fieldOrCallback(createConditionBuilder())
|
|
38
|
+
}
|
|
39
|
+
return builder
|
|
40
|
+
},
|
|
41
|
+
whereIn: () => builder,
|
|
42
|
+
whereNull: () => builder,
|
|
43
|
+
orWhere: (callback) => {
|
|
44
|
+
callback(createConditionBuilder())
|
|
45
|
+
return builder
|
|
46
|
+
},
|
|
47
|
+
orWhereNull: () => builder,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return builder
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createQueryBuilder(rows: IndexRow[]): QueryBuilder {
|
|
54
|
+
let query: QueryBuilder
|
|
55
|
+
|
|
56
|
+
query = {
|
|
57
|
+
where: (fieldOrCallback) => {
|
|
58
|
+
if (typeof fieldOrCallback === 'function') {
|
|
59
|
+
fieldOrCallback(createConditionBuilder())
|
|
60
|
+
}
|
|
61
|
+
return query
|
|
62
|
+
},
|
|
63
|
+
whereIn: () => query,
|
|
64
|
+
whereNull: () => query,
|
|
65
|
+
orWhere: (callback) => {
|
|
66
|
+
callback(createConditionBuilder())
|
|
67
|
+
return query
|
|
68
|
+
},
|
|
69
|
+
orWhereNull: () => query,
|
|
70
|
+
select: () => query,
|
|
71
|
+
then: (onFulfilled, onRejected) => Promise.resolve(rows).then(onFulfilled, onRejected),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return query
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createKnex(rows: IndexRow[]): Knex {
|
|
78
|
+
return jest.fn((_tableName: string) => createQueryBuilder(rows)) as unknown as Knex
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createConfig(config: Omit<SearchEntityConfig, 'entityId'> & { entityId?: SearchEntityConfig['entityId'] }): SearchEntityConfig {
|
|
82
|
+
return {
|
|
83
|
+
entityId: (config.entityId ?? 'customers:person') as SearchEntityConfig['entityId'],
|
|
84
|
+
...config,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
|
|
89
|
+
return {
|
|
90
|
+
entityId: 'customers:person',
|
|
91
|
+
recordId: 'person-1',
|
|
92
|
+
score: 0.8,
|
|
93
|
+
source: 'tokens',
|
|
94
|
+
...overrides,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('createPresenterEnricher', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
mockedDecryptIndexDocForSearch.mockReset()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('uses search config presenters and the stored organization scope for doc decryption', async () => {
|
|
104
|
+
const decryptedDoc = {
|
|
105
|
+
id: 'person-1',
|
|
106
|
+
name: 'Ada Lovelace',
|
|
107
|
+
organization_id: 'org-from-doc',
|
|
108
|
+
'cf:nickname': 'Countess',
|
|
109
|
+
}
|
|
110
|
+
mockedDecryptIndexDocForSearch.mockResolvedValue(decryptedDoc)
|
|
111
|
+
|
|
112
|
+
const queryEngine = { query: jest.fn() } as unknown as QueryEngine
|
|
113
|
+
const buildSource = jest.fn().mockResolvedValue({
|
|
114
|
+
text: 'Ada Lovelace',
|
|
115
|
+
presenter: {
|
|
116
|
+
title: 'Ada Lovelace',
|
|
117
|
+
subtitle: 'Countess',
|
|
118
|
+
badge: 'Person',
|
|
119
|
+
},
|
|
120
|
+
links: [{ href: '/backend/customers/person-1/edit', label: 'Edit', kind: 'secondary' as const }],
|
|
121
|
+
})
|
|
122
|
+
const resolveUrl = jest.fn().mockResolvedValue('/backend/customers/person-1')
|
|
123
|
+
const config = createConfig({ buildSource, resolveUrl })
|
|
124
|
+
|
|
125
|
+
const enrich = createPresenterEnricher(
|
|
126
|
+
createKnex([{ entity_type: 'customers:person', entity_id: 'person-1', doc: decryptedDoc }]),
|
|
127
|
+
new Map([[config.entityId, config]]),
|
|
128
|
+
queryEngine,
|
|
129
|
+
{} as never,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const [enriched] = await enrich([createResult()], 'tenant-1', null)
|
|
133
|
+
|
|
134
|
+
expect(mockedDecryptIndexDocForSearch).toHaveBeenCalledWith(
|
|
135
|
+
'customers:person',
|
|
136
|
+
decryptedDoc,
|
|
137
|
+
{ tenantId: 'tenant-1', organizationId: 'org-from-doc' },
|
|
138
|
+
expect.anything(),
|
|
139
|
+
expect.any(Map),
|
|
140
|
+
)
|
|
141
|
+
expect(buildSource).toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
record: decryptedDoc,
|
|
144
|
+
customFields: { nickname: 'Countess' },
|
|
145
|
+
tenantId: 'tenant-1',
|
|
146
|
+
organizationId: null,
|
|
147
|
+
queryEngine,
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
expect(resolveUrl).toHaveBeenCalled()
|
|
151
|
+
expect(enriched.presenter).toEqual({
|
|
152
|
+
title: 'Ada Lovelace',
|
|
153
|
+
subtitle: 'Countess',
|
|
154
|
+
badge: 'Person',
|
|
155
|
+
})
|
|
156
|
+
expect(enriched.url).toBe('/backend/customers/person-1')
|
|
157
|
+
expect(enriched.links).toEqual([{ href: '/backend/customers/person-1/edit', label: 'Edit', kind: 'secondary' }])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('replaces empty link arrays with resolved links when url metadata is missing', async () => {
|
|
161
|
+
const doc = {
|
|
162
|
+
id: 'person-1',
|
|
163
|
+
name: 'Ada Lovelace',
|
|
164
|
+
organization_id: 'org-1',
|
|
165
|
+
}
|
|
166
|
+
mockedDecryptIndexDocForSearch.mockResolvedValue(doc)
|
|
167
|
+
|
|
168
|
+
const resolveLinks = jest.fn().mockResolvedValue([
|
|
169
|
+
{ href: '/backend/customers/person-1', label: 'View', kind: 'primary' as const },
|
|
170
|
+
])
|
|
171
|
+
const config = createConfig({ resolveLinks })
|
|
172
|
+
|
|
173
|
+
const enrich = createPresenterEnricher(
|
|
174
|
+
createKnex([{ entity_type: 'customers:person', entity_id: 'person-1', doc }]),
|
|
175
|
+
new Map([[config.entityId, config]]),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const [enriched] = await enrich([
|
|
179
|
+
createResult({
|
|
180
|
+
presenter: { title: 'Ada Lovelace' },
|
|
181
|
+
links: [],
|
|
182
|
+
}),
|
|
183
|
+
], 'tenant-1', 'org-1')
|
|
184
|
+
|
|
185
|
+
expect(resolveLinks).toHaveBeenCalled()
|
|
186
|
+
expect(enriched.links).toEqual([{ href: '/backend/customers/person-1', label: 'View', kind: 'primary' }])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -231,6 +231,50 @@ describe('SearchService', () => {
|
|
|
231
231
|
expect(results).toHaveLength(1)
|
|
232
232
|
expect(results[0].source).toBe('fallback')
|
|
233
233
|
})
|
|
234
|
+
|
|
235
|
+
it('should enrich results when navigation metadata is missing even if presenter title exists', async () => {
|
|
236
|
+
const strategy = createMockStrategy({
|
|
237
|
+
id: 'test',
|
|
238
|
+
search: jest.fn().mockResolvedValue([
|
|
239
|
+
createMockResult({
|
|
240
|
+
presenter: { title: 'Needs Link' },
|
|
241
|
+
url: undefined,
|
|
242
|
+
links: [],
|
|
243
|
+
}),
|
|
244
|
+
]),
|
|
245
|
+
})
|
|
246
|
+
const presenterEnricher = jest.fn().mockResolvedValue([
|
|
247
|
+
createMockResult({
|
|
248
|
+
presenter: { title: 'Needs Link' },
|
|
249
|
+
url: '/backend/test/rec-123',
|
|
250
|
+
links: [{ href: '/backend/test/rec-123/edit', label: 'Edit', kind: 'secondary' }],
|
|
251
|
+
}),
|
|
252
|
+
])
|
|
253
|
+
const service = new SearchService({
|
|
254
|
+
strategies: [strategy],
|
|
255
|
+
defaultStrategies: ['test'],
|
|
256
|
+
presenterEnricher,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const results = await service.search('test', { tenantId: 'tenant-123' })
|
|
260
|
+
|
|
261
|
+
expect(presenterEnricher).toHaveBeenCalledWith(
|
|
262
|
+
expect.arrayContaining([
|
|
263
|
+
expect.objectContaining({
|
|
264
|
+
recordId: 'rec-123',
|
|
265
|
+
presenter: { title: 'Needs Link' },
|
|
266
|
+
url: undefined,
|
|
267
|
+
links: [],
|
|
268
|
+
}),
|
|
269
|
+
]),
|
|
270
|
+
'tenant-123',
|
|
271
|
+
undefined,
|
|
272
|
+
)
|
|
273
|
+
expect(results[0].url).toBe('/backend/test/rec-123')
|
|
274
|
+
expect(results[0].links).toEqual([
|
|
275
|
+
{ href: '/backend/test/rec-123/edit', label: 'Edit', kind: 'secondary' },
|
|
276
|
+
])
|
|
277
|
+
})
|
|
234
278
|
})
|
|
235
279
|
|
|
236
280
|
describe('index', () => {
|
|
@@ -12,6 +12,7 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
|
12
12
|
import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
13
13
|
import { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'
|
|
14
14
|
import { extractFallbackPresenter } from './fallback-presenter'
|
|
15
|
+
import { needsSearchResultEnrichment } from './search-result-enrichment'
|
|
15
16
|
|
|
16
17
|
/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */
|
|
17
18
|
const BATCH_SIZE = 500
|
|
@@ -23,31 +24,6 @@ const logWarning = (message: string, context?: Record<string, unknown>) => {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* Check if a string looks like an encrypted value.
|
|
28
|
-
* Encrypted format: iv:ciphertext:authTag:v1
|
|
29
|
-
*/
|
|
30
|
-
function looksEncrypted(value: unknown): boolean {
|
|
31
|
-
if (typeof value !== 'string') return false
|
|
32
|
-
if (!value.includes(':')) return false
|
|
33
|
-
const parts = value.split(':')
|
|
34
|
-
// Encrypted strings end with :v1 and have at least 3 colon-separated parts
|
|
35
|
-
return parts.length >= 3 && parts[parts.length - 1] === 'v1'
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Check if a result needs enrichment (missing presenter, encrypted values, or missing URL/links)
|
|
40
|
-
*/
|
|
41
|
-
function needsEnrichment(result: SearchResult): boolean {
|
|
42
|
-
if (!result.presenter?.title) return true
|
|
43
|
-
// Also re-enrich if presenter looks encrypted
|
|
44
|
-
if (looksEncrypted(result.presenter.title)) return true
|
|
45
|
-
if (looksEncrypted(result.presenter.subtitle)) return true
|
|
46
|
-
// Also enrich if missing URL/links (needed for token search results)
|
|
47
|
-
if (!result.url && (!result.links || result.links.length === 0)) return true
|
|
48
|
-
return false
|
|
49
|
-
}
|
|
50
|
-
|
|
51
27
|
/**
|
|
52
28
|
* Split an array into chunks of specified size.
|
|
53
29
|
*/
|
|
@@ -227,7 +203,7 @@ export function createPresenterEnricher(
|
|
|
227
203
|
): PresenterEnricherFn {
|
|
228
204
|
return async (results, tenantId, organizationId) => {
|
|
229
205
|
// Find results missing presenter OR with encrypted presenter
|
|
230
|
-
const missingResults = results.filter(
|
|
206
|
+
const missingResults = results.filter(needsSearchResultEnrichment)
|
|
231
207
|
if (missingResults.length === 0) return results
|
|
232
208
|
|
|
233
209
|
// Group by entity type for config lookup
|
|
@@ -308,15 +284,16 @@ export function createPresenterEnricher(
|
|
|
308
284
|
|
|
309
285
|
// Enrich results with computed presenter, URL, and links
|
|
310
286
|
return results.map((result) => {
|
|
311
|
-
if (!
|
|
287
|
+
if (!needsSearchResultEnrichment(result)) return result
|
|
312
288
|
const key = `${result.entityId}:${result.recordId}`
|
|
313
289
|
const enriched = enrichmentMap.get(key)
|
|
314
290
|
if (!enriched) return result
|
|
291
|
+
const hasExistingLinks = Array.isArray(result.links) && result.links.length > 0
|
|
315
292
|
return {
|
|
316
293
|
...result,
|
|
317
294
|
presenter: enriched.presenter ?? result.presenter,
|
|
318
295
|
url: result.url ?? enriched.url,
|
|
319
|
-
links: result.links
|
|
296
|
+
links: hasExistingLinks ? result.links : (enriched.links ?? result.links),
|
|
320
297
|
}
|
|
321
298
|
})
|
|
322
299
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SearchResult } from '../types'
|
|
2
|
+
|
|
3
|
+
export function looksLikeEncryptedSearchValue(value: unknown): boolean {
|
|
4
|
+
if (typeof value !== 'string') return false
|
|
5
|
+
if (!value.includes(':')) return false
|
|
6
|
+
|
|
7
|
+
const parts = value.split(':')
|
|
8
|
+
return parts.length >= 3 && parts[parts.length - 1] === 'v1'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function needsSearchResultEnrichment(result: SearchResult): boolean {
|
|
12
|
+
if (!result.presenter?.title) return true
|
|
13
|
+
if (looksLikeEncryptedSearchValue(result.presenter.title)) return true
|
|
14
|
+
if (looksLikeEncryptedSearchValue(result.presenter.subtitle)) return true
|
|
15
|
+
if (!result.url && (!result.links || result.links.length === 0)) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from './types'
|
|
11
11
|
import { mergeAndRankResults } from './lib/merger'
|
|
12
12
|
import { searchError } from './lib/debug'
|
|
13
|
+
import { needsSearchResultEnrichment } from './lib/search-result-enrichment'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Default merge configuration.
|
|
@@ -115,7 +116,7 @@ export class SearchService {
|
|
|
115
116
|
// Merge and rank results
|
|
116
117
|
const merged = mergeAndRankResults(allResults, this.mergeConfig)
|
|
117
118
|
|
|
118
|
-
// Enrich results missing presenter
|
|
119
|
+
// Enrich results missing presenter or navigation metadata
|
|
119
120
|
return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -131,18 +132,7 @@ export class SearchService {
|
|
|
131
132
|
// If no enricher configured, return as-is
|
|
132
133
|
if (!this.presenterEnricher) return results
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
const needsEnrichment = (r: SearchResult) => {
|
|
136
|
-
if (!r.presenter?.title) return true
|
|
137
|
-
// Also enrich if presenter looks encrypted (format: iv:ciphertext:authTag:v1)
|
|
138
|
-
const title = r.presenter.title
|
|
139
|
-
if (typeof title === 'string' && title.includes(':')) {
|
|
140
|
-
const parts = title.split(':')
|
|
141
|
-
if (parts.length >= 3 && parts[parts.length - 1] === 'v1') return true
|
|
142
|
-
}
|
|
143
|
-
return false
|
|
144
|
-
}
|
|
145
|
-
const hasMissing = results.some(needsEnrichment)
|
|
135
|
+
const hasMissing = results.some(needsSearchResultEnrichment)
|
|
146
136
|
if (!hasMissing) return results
|
|
147
137
|
|
|
148
138
|
// Use the configured presenter enricher
|