@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.
@@ -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(needsEnrichment);
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 (!needsEnrichment(result)) return result;
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 ?? enriched.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": "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",
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 needsEnrichment = (r) => {
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);
@@ -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": "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",
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.2083.e52e08a1fc",
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.2083.e52e08a1fc",
130
- "@open-mercato/queue": "0.4.11-develop.2083.e52e08a1fc",
131
- "@open-mercato/shared": "0.4.11-develop.2083.e52e08a1fc"
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(needsEnrichment)
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 (!needsEnrichment(result)) return result
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 ?? enriched.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 data
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
- // Check if any results need enrichment (missing or encrypted presenter)
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