@open-mercato/search 0.6.6-develop.6465.1.019f0fb26f → 0.6.6-develop.6471.1.9ab5bdd79a

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/AGENTS.md CHANGED
@@ -538,7 +538,7 @@ curl "https://your-app.com/api/search?q=john%20doe&limit=20" \
538
538
  | `OM_SEARCH_ENABLED` | When you need to disable the search module entirely | Default: `true`; set to `false` to disable |
539
539
  | `OM_SEARCH_DEBUG` | When debugging search behavior | Enables verbose debug logging |
540
540
  | `SEARCH_EXCLUDE_ENCRYPTED_FIELDS` | When you need to keep encrypted fields out of fulltext | Set to `true` to exclude encrypted fields from fulltext index |
541
- | `DEBUG_SEARCH_ENRICHER` | When debugging presenter enrichment | Enables presenter enricher debug logs |
541
+ | `OM_LOG_LEVEL` | When debugging presenter enrichment | Set to `debug` to surface presenter enricher diagnostics (replaces the former `DEBUG_SEARCH_ENRICHER` flag) |
542
542
 
543
543
  ## Run Queue Workers
544
544
 
@@ -716,7 +716,7 @@ buildSource: async (ctx) => {
716
716
  - [ ] Run `yarn mercato search test-meilisearch` if fulltext is not returning results
717
717
  - [ ] Check `OM_SEARCH_ENABLED` is not set to `false`
718
718
  - [ ] Enable `OM_SEARCH_DEBUG=true` for verbose logging
719
- - [ ] Enable `DEBUG_SEARCH_ENRICHER=true` if presenters are missing or wrong
719
+ - [ ] Set `OM_LOG_LEVEL=debug` if presenters are missing or wrong (surfaces presenter enricher diagnostics)
720
720
  - [ ] Verify the entity has `enabled: true` (or omitted, since default is `true`)
721
721
  - [ ] Verify the CRUD route has `indexer: { entityType }` for auto-indexing
722
722
  - [ ] Check queue workers are running if using `QUEUE_STRATEGY=async`
package/dist/lib/debug.js CHANGED
@@ -1,36 +1,30 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
+ const packageLogger = createLogger("search");
4
+ const componentLoggers = /* @__PURE__ */ new Map();
5
+ function componentLogger(prefix) {
6
+ const existing = componentLoggers.get(prefix);
7
+ if (existing) return existing;
8
+ const scoped = packageLogger.child({ component: prefix });
9
+ componentLoggers.set(prefix, scoped);
10
+ return scoped;
11
+ }
1
12
  function isSearchDebugEnabled() {
2
- const raw = (process.env.OM_SEARCH_DEBUG ?? "").toLowerCase();
3
- return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
13
+ return parseBooleanWithDefault(process.env.OM_SEARCH_DEBUG, false);
4
14
  }
5
15
  function searchDebug(prefix, message, data) {
6
16
  if (!isSearchDebugEnabled()) return;
7
- if (data) {
8
- console.log(`[${prefix}] ${message}`, data);
9
- } else {
10
- console.log(`[${prefix}] ${message}`);
11
- }
17
+ componentLogger(prefix).debug(message, data);
12
18
  }
13
19
  function searchDebugWarn(prefix, message, data) {
14
20
  if (!isSearchDebugEnabled()) return;
15
- if (data) {
16
- console.warn(`[${prefix}] ${message}`, data);
17
- } else {
18
- console.warn(`[${prefix}] ${message}`);
19
- }
21
+ componentLogger(prefix).warn(message, data);
20
22
  }
21
23
  function searchWarn(prefix, message, data) {
22
- if (data) {
23
- console.warn(`[${prefix}] ${message}`, data);
24
- } else {
25
- console.warn(`[${prefix}] ${message}`);
26
- }
24
+ componentLogger(prefix).warn(message, data);
27
25
  }
28
26
  function searchError(prefix, message, data) {
29
- if (data) {
30
- console.error(`[${prefix}] ${message}`, data);
31
- } else {
32
- console.error(`[${prefix}] ${message}`);
33
- }
27
+ componentLogger(prefix).error(message, data);
34
28
  }
35
29
  export {
36
30
  isSearchDebugEnabled,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/debug.ts"],
4
- "sourcesContent": ["/**\n * Debug utilities for search module.\n *\n * Set OM_SEARCH_DEBUG=true to enable debug logging.\n */\n\nexport function isSearchDebugEnabled(): boolean {\n const raw = (process.env.OM_SEARCH_DEBUG ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Log a debug message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebug(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n if (data) {\n console.log(`[${prefix}] ${message}`, data)\n } else {\n console.log(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log a warning message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebugWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n if (data) {\n console.warn(`[${prefix}] ${message}`, data)\n } else {\n console.warn(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log a warning message (always logs, not gated by debug flag).\n * Use for operational warnings that must stay visible without OM_SEARCH_DEBUG,\n * such as skipping a vector-index run because the provider is unreachable or\n * the configured embedding dimension no longer matches the vector table.\n */\nexport function searchWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (data) {\n console.warn(`[${prefix}] ${message}`, data)\n } else {\n console.warn(`[${prefix}] ${message}`)\n }\n}\n\n/**\n * Log an error message (always logs, not gated by debug flag).\n * Errors should always be visible for troubleshooting.\n */\nexport function searchError(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (data) {\n console.error(`[${prefix}] ${message}`, data)\n } else {\n console.error(`[${prefix}] ${message}`)\n }\n}\n"],
5
- "mappings": "AAMO,SAAS,uBAAgC;AAC9C,QAAM,OAAO,QAAQ,IAAI,mBAAmB,IAAI,YAAY;AAC5D,SAAO,QAAQ,OAAO,QAAQ,UAAU,QAAQ,SAAS,QAAQ;AACnE;AAKO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI,MAAM;AACR,YAAQ,IAAI,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC5C,OAAO;AACL,YAAQ,IAAI,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACtC;AACF;AAKO,SAAS,gBAAgB,QAAgB,SAAiB,MAAsC;AACrG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI,MAAM;AACR,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC7C,OAAO;AACL,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACvC;AACF;AAQO,SAAS,WAAW,QAAgB,SAAiB,MAAsC;AAChG,MAAI,MAAM;AACR,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC7C,OAAO;AACL,YAAQ,KAAK,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACvC;AACF;AAMO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,MAAM;AACR,YAAQ,MAAM,IAAI,MAAM,KAAK,OAAO,IAAI,IAAI;AAAA,EAC9C,OAAO;AACL,YAAQ,MAAM,IAAI,MAAM,KAAK,OAAO,EAAE;AAAA,EACxC;AACF;",
4
+ "sourcesContent": ["/**\n * Debug utilities for search module, backed by the shared structured\n * logging facade (`@open-mercato/shared/lib/logger`).\n *\n * Set OM_SEARCH_DEBUG=true to opt into the verbose diagnostic helpers\n * (`searchDebug`/`searchDebugWarn`). Emission also flows through the\n * global `OM_LOG_LEVEL` gate, so when using OM_SEARCH_DEBUG in\n * production set OM_LOG_LEVEL=debug as well.\n */\n\nimport { createLogger, type Logger } from '@open-mercato/shared/lib/logger'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nconst packageLogger = createLogger('search')\nconst componentLoggers = new Map<string, Logger>()\n\nfunction componentLogger(prefix: string): Logger {\n const existing = componentLoggers.get(prefix)\n if (existing) return existing\n const scoped = packageLogger.child({ component: prefix })\n componentLoggers.set(prefix, scoped)\n return scoped\n}\n\nexport function isSearchDebugEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_SEARCH_DEBUG, false)\n}\n\n/**\n * Log a debug message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebug(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n componentLogger(prefix).debug(message, data)\n}\n\n/**\n * Log a warning message if OM_SEARCH_DEBUG is enabled.\n */\nexport function searchDebugWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n if (!isSearchDebugEnabled()) return\n componentLogger(prefix).warn(message, data)\n}\n\n/**\n * Log a warning message (always logs, not gated by debug flag).\n * Use for operational warnings that must stay visible without OM_SEARCH_DEBUG,\n * such as skipping a vector-index run because the provider is unreachable or\n * the configured embedding dimension no longer matches the vector table.\n */\nexport function searchWarn(prefix: string, message: string, data?: Record<string, unknown>): void {\n componentLogger(prefix).warn(message, data)\n}\n\n/**\n * Log an error message (always logs, not gated by debug flag).\n * Errors should always be visible for troubleshooting.\n */\nexport function searchError(prefix: string, message: string, data?: Record<string, unknown>): void {\n componentLogger(prefix).error(message, data)\n}\n"],
5
+ "mappings": "AAUA,SAAS,oBAAiC;AAC1C,SAAS,+BAA+B;AAExC,MAAM,gBAAgB,aAAa,QAAQ;AAC3C,MAAM,mBAAmB,oBAAI,IAAoB;AAEjD,SAAS,gBAAgB,QAAwB;AAC/C,QAAM,WAAW,iBAAiB,IAAI,MAAM;AAC5C,MAAI,SAAU,QAAO;AACrB,QAAM,SAAS,cAAc,MAAM,EAAE,WAAW,OAAO,CAAC;AACxD,mBAAiB,IAAI,QAAQ,MAAM;AACnC,SAAO;AACT;AAEO,SAAS,uBAAgC;AAC9C,SAAO,wBAAwB,QAAQ,IAAI,iBAAiB,KAAK;AACnE;AAKO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,kBAAgB,MAAM,EAAE,MAAM,SAAS,IAAI;AAC7C;AAKO,SAAS,gBAAgB,QAAgB,SAAiB,MAAsC;AACrG,MAAI,CAAC,qBAAqB,EAAG;AAC7B,kBAAgB,MAAM,EAAE,KAAK,SAAS,IAAI;AAC5C;AAQO,SAAS,WAAW,QAAgB,SAAiB,MAAsC;AAChG,kBAAgB,MAAM,EAAE,KAAK,SAAS,IAAI;AAC5C;AAMO,SAAS,YAAY,QAAgB,SAAiB,MAAsC;AACjG,kBAAgB,MAAM,EAAE,MAAM,SAAS,IAAI;AAC7C;",
6
6
  "names": []
7
7
  }
@@ -1,11 +1,11 @@
1
1
  import { decryptIndexDocForSearch } from "@open-mercato/shared/lib/encryption/indexDoc";
2
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
3
  import { extractFallbackPresenter } from "./fallback-presenter.js";
3
4
  import { needsSearchResultEnrichment } from "./search-result-enrichment.js";
4
5
  const BATCH_SIZE = 500;
6
+ const enricherLogger = createLogger("search").child({ component: "presenter-enricher" });
5
7
  const logWarning = (message, context) => {
6
- if (process.env.NODE_ENV === "development" || process.env.DEBUG_SEARCH_ENRICHER) {
7
- console.warn(`[search:presenter-enricher] ${message}`, context ?? "");
8
- }
8
+ enricherLogger.debug(message, context);
9
9
  };
10
10
  function chunk(array, size) {
11
11
  const chunks = [];
@@ -72,14 +72,14 @@ async function computePresenterAndLinks(doc, entityId, recordId, config, tenantI
72
72
  if (source?.presenter) presenter = source.presenter;
73
73
  if (source?.links) links = source.links;
74
74
  } catch (err) {
75
- logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) });
75
+ logWarning("buildSource failed", { entityId, recordId, err });
76
76
  }
77
77
  }
78
78
  if (!presenter && config.formatResult) {
79
79
  try {
80
80
  presenter = await config.formatResult(buildContext) ?? null;
81
81
  } catch (err) {
82
- logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) });
82
+ logWarning("formatResult failed", { entityId, recordId, err });
83
83
  }
84
84
  }
85
85
  }
@@ -127,7 +127,7 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
127
127
  );
128
128
  return { ...row, doc: decryptedDoc };
129
129
  } catch (err) {
130
- logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) });
130
+ logWarning("Failed to decrypt doc", { entityId: row.entity_type, recordId: row.entity_id, err });
131
131
  return row;
132
132
  }
133
133
  })
@@ -140,7 +140,7 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
140
140
  const key = `${result.entityId}:${result.recordId}`;
141
141
  const doc = docMap.get(key);
142
142
  if (!doc) {
143
- logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId });
143
+ logWarning("Doc not found in entity_indexes", { entityId: result.entityId, recordId: result.recordId });
144
144
  return { key, presenter: null, url: void 0, links: void 0 };
145
145
  }
146
146
  const config = entityConfigMap.get(result.entityId);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/presenter-enricher.ts"],
4
- "sourcesContent": ["import type { Kysely } from 'kysely'\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 db: Kysely<any>,\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 let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\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 db: Kysely<any>,\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(db, 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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",
4
+ "sourcesContent": ["import type { Kysely } from 'kysely'\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 { createLogger } from '@open-mercato/shared/lib/logger'\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/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\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 db: Kysely<any>,\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 let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\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', { entityId, recordId, 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', { entityId, recordId, 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 db: Kysely<any>,\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(db, 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', { entityId: row.entity_type, recordId: row.entity_id, 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.6-develop.6465.1.019f0fb26f",
3
+ "version": "0.6.6-develop.6471.1.9ab5bdd79a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.6-develop.6465.1.019f0fb26f",
131
- "@open-mercato/queue": "0.6.6-develop.6465.1.019f0fb26f",
132
- "@open-mercato/shared": "0.6.6-develop.6465.1.019f0fb26f"
130
+ "@open-mercato/core": "0.6.6-develop.6471.1.9ab5bdd79a",
131
+ "@open-mercato/queue": "0.6.6-develop.6471.1.9ab5bdd79a",
132
+ "@open-mercato/shared": "0.6.6-develop.6471.1.9ab5bdd79a"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -1,4 +1,22 @@
1
- import { isSearchDebugEnabled, searchDebug, searchDebugWarn, searchError } from '../lib/debug'
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
+ import { isSearchDebugEnabled, searchDebug, searchDebugWarn, searchWarn, searchError } from '../lib/debug'
3
+
4
+ jest.mock('@open-mercato/shared/lib/logger', () => {
5
+ const mocked = {
6
+ debug: jest.fn(),
7
+ info: jest.fn(),
8
+ warn: jest.fn(),
9
+ error: jest.fn(),
10
+ child: jest.fn(),
11
+ }
12
+ mocked.child.mockImplementation(() => mocked)
13
+ return { createLogger: jest.fn(() => mocked) }
14
+ })
15
+
16
+ const searchLoggerDebug = createLogger('search').debug as jest.Mock
17
+ const searchLoggerWarn = createLogger('search').warn as jest.Mock
18
+ const searchLoggerError = createLogger('search').error as jest.Mock
19
+ const searchLoggerChild = createLogger('search').child as jest.Mock
2
20
 
3
21
  describe('search debug utilities', () => {
4
22
  const originalDebugEnv = process.env.OM_SEARCH_DEBUG
@@ -13,12 +31,14 @@ describe('search debug utilities', () => {
13
31
 
14
32
  beforeEach(() => {
15
33
  restoreDebugEnv()
16
- jest.restoreAllMocks()
34
+ searchLoggerDebug.mockClear()
35
+ searchLoggerWarn.mockClear()
36
+ searchLoggerError.mockClear()
37
+ searchLoggerChild.mockClear()
17
38
  })
18
39
 
19
40
  afterAll(() => {
20
41
  restoreDebugEnv()
21
- jest.restoreAllMocks()
22
42
  })
23
43
 
24
44
  describe('isSearchDebugEnabled', () => {
@@ -42,72 +62,77 @@ describe('search debug utilities', () => {
42
62
  describe('searchDebug', () => {
43
63
  it('does not log when debug is disabled', () => {
44
64
  delete process.env.OM_SEARCH_DEBUG
45
- const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined)
46
65
 
47
66
  searchDebug('search.test', 'suppressed')
48
67
 
49
- expect(consoleSpy).not.toHaveBeenCalled()
68
+ expect(searchLoggerDebug).not.toHaveBeenCalled()
50
69
  })
51
70
 
52
71
  it('logs message and payload when debug is enabled', () => {
53
72
  process.env.OM_SEARCH_DEBUG = 'true'
54
- const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined)
55
73
  const payload = { entityId: 'customers:person', recordId: 'rec-123' }
56
74
 
57
75
  searchDebug('search.test', 'indexed', payload)
58
76
 
59
- expect(consoleSpy).toHaveBeenCalledWith('[search.test] indexed', payload)
77
+ expect(searchLoggerChild).toHaveBeenCalledWith({ component: 'search.test' })
78
+ expect(searchLoggerDebug).toHaveBeenCalledWith('indexed', payload)
60
79
  })
61
80
 
62
- it('logs only the formatted message when payload is omitted', () => {
81
+ it('logs only the message when payload is omitted', () => {
63
82
  process.env.OM_SEARCH_DEBUG = 'true'
64
- const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined)
65
83
 
66
84
  searchDebug('search.test', 'indexed')
67
85
 
68
- expect(consoleSpy).toHaveBeenCalledWith('[search.test] indexed')
86
+ expect(searchLoggerDebug).toHaveBeenCalledWith('indexed', undefined)
69
87
  })
70
88
  })
71
89
 
72
90
  describe('searchDebugWarn', () => {
73
91
  it('does not warn when debug is disabled', () => {
74
92
  process.env.OM_SEARCH_DEBUG = 'false'
75
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
76
93
 
77
94
  searchDebugWarn('search.test', 'suppressed')
78
95
 
79
- expect(consoleSpy).not.toHaveBeenCalled()
96
+ expect(searchLoggerWarn).not.toHaveBeenCalled()
80
97
  })
81
98
 
82
- it('warns with the formatted message and payload when enabled', () => {
99
+ it('warns with the message and payload when enabled', () => {
83
100
  process.env.OM_SEARCH_DEBUG = 'yes'
84
- const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
85
101
  const payload = { queue: 'vector-indexing' }
86
102
 
87
103
  searchDebugWarn('search.test', 'retrying', payload)
88
104
 
89
- expect(consoleSpy).toHaveBeenCalledWith('[search.test] retrying', payload)
105
+ expect(searchLoggerWarn).toHaveBeenCalledWith('retrying', payload)
106
+ })
107
+ })
108
+
109
+ describe('searchWarn', () => {
110
+ it('always warns even when debug is disabled', () => {
111
+ process.env.OM_SEARCH_DEBUG = 'false'
112
+ const payload = { provider: 'ollama' }
113
+
114
+ searchWarn('search.test', 'provider unreachable', payload)
115
+
116
+ expect(searchLoggerWarn).toHaveBeenCalledWith('provider unreachable', payload)
90
117
  })
91
118
  })
92
119
 
93
120
  describe('searchError', () => {
94
121
  it('always logs errors even when debug is disabled', () => {
95
122
  process.env.OM_SEARCH_DEBUG = 'false'
96
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
97
123
  const payload = { error: 'boom' }
98
124
 
99
125
  searchError('search.test', 'failed', payload)
100
126
 
101
- expect(consoleSpy).toHaveBeenCalledWith('[search.test] failed', payload)
127
+ expect(searchLoggerError).toHaveBeenCalledWith('failed', payload)
102
128
  })
103
129
 
104
- it('logs only the formatted error message when payload is omitted', () => {
130
+ it('logs only the error message when payload is omitted', () => {
105
131
  delete process.env.OM_SEARCH_DEBUG
106
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
107
132
 
108
133
  searchError('search.test', 'failed')
109
134
 
110
- expect(consoleSpy).toHaveBeenCalledWith('[search.test] failed')
135
+ expect(searchLoggerError).toHaveBeenCalledWith('failed', undefined)
111
136
  })
112
137
  })
113
138
  })
@@ -1,9 +1,25 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
1
2
  import { QueuedJob, JobContext } from '@open-mercato/queue'
2
3
  import { VectorIndexJobPayload } from '../queue/vector-indexing'
3
4
  import { FulltextIndexJobPayload } from '../queue/fulltext-indexing'
4
5
 
5
6
  type HandlerContext = { resolve: <T = unknown>(name: string) => T }
6
7
 
8
+ jest.mock('@open-mercato/shared/lib/logger', () => {
9
+ const mocked = {
10
+ debug: jest.fn(),
11
+ info: jest.fn(),
12
+ warn: jest.fn(),
13
+ error: jest.fn(),
14
+ child: jest.fn(),
15
+ }
16
+ mocked.child.mockImplementation(() => mocked)
17
+ return { createLogger: jest.fn(() => mocked) }
18
+ })
19
+
20
+
21
+ const searchLoggerWarn = createLogger('search').warn as jest.Mock
22
+
7
23
  // Mock dependencies before importing workers
8
24
  jest.mock('@open-mercato/shared/lib/indexers/error-log', () => ({
9
25
  recordIndexerError: jest.fn().mockResolvedValue(undefined),
@@ -221,7 +237,7 @@ describe('Vector Index Worker', () => {
221
237
  it('should skip a batch with one warning when the dimension mismatches (no per-record indexing)', async () => {
222
238
  mockTableDimension = 768
223
239
  mockEmbeddingService.dimension = 1536
224
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
240
+ searchLoggerWarn.mockClear()
225
241
  const job = createMockJob<VectorIndexJobPayload>({
226
242
  jobType: 'batch-index',
227
243
  tenantId: 'tenant-123',
@@ -236,9 +252,8 @@ describe('Vector Index Worker', () => {
236
252
 
237
253
  expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
238
254
  expect(mockEmbeddingService.createEmbedding).not.toHaveBeenCalled()
239
- expect(warnSpy).toHaveBeenCalledTimes(1)
240
- expect(String(warnSpy.mock.calls[0][0])).toContain('Skipping vector batch')
241
- warnSpy.mockRestore()
255
+ expect(searchLoggerWarn).toHaveBeenCalledTimes(1)
256
+ expect(String(searchLoggerWarn.mock.calls[0][0])).toContain('Skipping vector batch')
242
257
  })
243
258
 
244
259
  it('should skip a batch with one warning when the provider probe is unreachable', async () => {
@@ -247,7 +262,7 @@ describe('Vector Index Worker', () => {
247
262
  mockEmbeddingService.createEmbedding.mockRejectedValueOnce(
248
263
  new Error('fetch failed. Check OLLAMA_BASE_URL.'),
249
264
  )
250
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
265
+ searchLoggerWarn.mockClear()
251
266
  const job = createMockJob<VectorIndexJobPayload>({
252
267
  jobType: 'batch-index',
253
268
  tenantId: 'tenant-123',
@@ -259,15 +274,14 @@ describe('Vector Index Worker', () => {
259
274
 
260
275
  expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
261
276
  expect(mockEmbeddingService.createEmbedding).toHaveBeenCalledTimes(1)
262
- expect(warnSpy).toHaveBeenCalledTimes(1)
263
- expect(String(warnSpy.mock.calls[0][0])).toContain('Skipping vector batch')
264
- warnSpy.mockRestore()
277
+ expect(searchLoggerWarn).toHaveBeenCalledTimes(1)
278
+ expect(String(searchLoggerWarn.mock.calls[0][0])).toContain('Skipping vector batch')
265
279
  })
266
280
 
267
281
  it('should still advance reindex progress/lock when a batch is skipped (no stuck run)', async () => {
268
282
  mockTableDimension = 768
269
283
  mockEmbeddingService.dimension = 1536
270
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
284
+ searchLoggerWarn.mockClear()
271
285
  const mockDb = { kysely: true }
272
286
  const containerWithProgress: HandlerContext = {
273
287
  resolve: jest.fn((name: string) => {
@@ -298,7 +312,6 @@ describe('Vector Index Worker', () => {
298
312
  expect.objectContaining({ type: 'vector', tenantId: 'tenant-123', delta: 2 }),
299
313
  )
300
314
  expect(clearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'vector', 'org-456')
301
- warnSpy.mockRestore()
302
315
  })
303
316
 
304
317
  it('counts handled-but-skipped batch records as processed so progress can complete', async () => {
@@ -373,7 +386,7 @@ describe('Vector Index Worker', () => {
373
386
  it('should skip a single-record index on dimension mismatch without indexing or embedding', async () => {
374
387
  mockTableDimension = 768
375
388
  mockEmbeddingService.dimension = 1536
376
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
389
+ searchLoggerWarn.mockClear()
377
390
  const job = createMockJob<VectorIndexJobPayload>({
378
391
  jobType: 'index',
379
392
  entityType: 'customers:customer_person_profile',
@@ -386,8 +399,7 @@ describe('Vector Index Worker', () => {
386
399
 
387
400
  expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
388
401
  expect(mockEmbeddingService.createEmbedding).not.toHaveBeenCalled()
389
- expect(warnSpy).toHaveBeenCalledTimes(1)
390
- warnSpy.mockRestore()
402
+ expect(searchLoggerWarn).toHaveBeenCalledTimes(1)
391
403
  })
392
404
 
393
405
  it('should still delete a record even when the provider is misconfigured', async () => {
package/src/lib/debug.ts CHANGED
@@ -1,12 +1,29 @@
1
1
  /**
2
- * Debug utilities for search module.
2
+ * Debug utilities for search module, backed by the shared structured
3
+ * logging facade (`@open-mercato/shared/lib/logger`).
3
4
  *
4
- * Set OM_SEARCH_DEBUG=true to enable debug logging.
5
+ * Set OM_SEARCH_DEBUG=true to opt into the verbose diagnostic helpers
6
+ * (`searchDebug`/`searchDebugWarn`). Emission also flows through the
7
+ * global `OM_LOG_LEVEL` gate, so when using OM_SEARCH_DEBUG in
8
+ * production set OM_LOG_LEVEL=debug as well.
5
9
  */
6
10
 
11
+ import { createLogger, type Logger } from '@open-mercato/shared/lib/logger'
12
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
13
+
14
+ const packageLogger = createLogger('search')
15
+ const componentLoggers = new Map<string, Logger>()
16
+
17
+ function componentLogger(prefix: string): Logger {
18
+ const existing = componentLoggers.get(prefix)
19
+ if (existing) return existing
20
+ const scoped = packageLogger.child({ component: prefix })
21
+ componentLoggers.set(prefix, scoped)
22
+ return scoped
23
+ }
24
+
7
25
  export function isSearchDebugEnabled(): boolean {
8
- const raw = (process.env.OM_SEARCH_DEBUG ?? '').toLowerCase()
9
- return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'
26
+ return parseBooleanWithDefault(process.env.OM_SEARCH_DEBUG, false)
10
27
  }
11
28
 
12
29
  /**
@@ -14,11 +31,7 @@ export function isSearchDebugEnabled(): boolean {
14
31
  */
15
32
  export function searchDebug(prefix: string, message: string, data?: Record<string, unknown>): void {
16
33
  if (!isSearchDebugEnabled()) return
17
- if (data) {
18
- console.log(`[${prefix}] ${message}`, data)
19
- } else {
20
- console.log(`[${prefix}] ${message}`)
21
- }
34
+ componentLogger(prefix).debug(message, data)
22
35
  }
23
36
 
24
37
  /**
@@ -26,11 +39,7 @@ export function searchDebug(prefix: string, message: string, data?: Record<strin
26
39
  */
27
40
  export function searchDebugWarn(prefix: string, message: string, data?: Record<string, unknown>): void {
28
41
  if (!isSearchDebugEnabled()) return
29
- if (data) {
30
- console.warn(`[${prefix}] ${message}`, data)
31
- } else {
32
- console.warn(`[${prefix}] ${message}`)
33
- }
42
+ componentLogger(prefix).warn(message, data)
34
43
  }
35
44
 
36
45
  /**
@@ -40,11 +49,7 @@ export function searchDebugWarn(prefix: string, message: string, data?: Record<s
40
49
  * the configured embedding dimension no longer matches the vector table.
41
50
  */
42
51
  export function searchWarn(prefix: string, message: string, data?: Record<string, unknown>): void {
43
- if (data) {
44
- console.warn(`[${prefix}] ${message}`, data)
45
- } else {
46
- console.warn(`[${prefix}] ${message}`)
47
- }
52
+ componentLogger(prefix).warn(message, data)
48
53
  }
49
54
 
50
55
  /**
@@ -52,9 +57,5 @@ export function searchWarn(prefix: string, message: string, data?: Record<string
52
57
  * Errors should always be visible for troubleshooting.
53
58
  */
54
59
  export function searchError(prefix: string, message: string, data?: Record<string, unknown>): void {
55
- if (data) {
56
- console.error(`[${prefix}] ${message}`, data)
57
- } else {
58
- console.error(`[${prefix}] ${message}`)
59
- }
60
+ componentLogger(prefix).error(message, data)
60
61
  }
@@ -11,17 +11,17 @@ import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
11
11
  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
+ import { createLogger } from '@open-mercato/shared/lib/logger'
14
15
  import { extractFallbackPresenter } from './fallback-presenter'
15
16
  import { needsSearchResultEnrichment } from './search-result-enrichment'
16
17
 
17
18
  /** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */
18
19
  const BATCH_SIZE = 500
19
20
 
20
- /** Logger for debugging - uses console.warn to surface issues without breaking flow */
21
+ /** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */
22
+ const enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })
21
23
  const logWarning = (message: string, context?: Record<string, unknown>) => {
22
- if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SEARCH_ENRICHER) {
23
- console.warn(`[search:presenter-enricher] ${message}`, context ?? '')
24
- }
24
+ enricherLogger.debug(message, context)
25
25
  }
26
26
 
27
27
  /**
@@ -145,7 +145,7 @@ async function computePresenterAndLinks(
145
145
  if (source?.presenter) presenter = source.presenter
146
146
  if (source?.links) links = source.links
147
147
  } catch (err) {
148
- logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) })
148
+ logWarning('buildSource failed', { entityId, recordId, err })
149
149
  }
150
150
  }
151
151
 
@@ -153,7 +153,7 @@ async function computePresenterAndLinks(
153
153
  try {
154
154
  presenter = (await config.formatResult(buildContext)) ?? null
155
155
  } catch (err) {
156
- logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) })
156
+ logWarning('formatResult failed', { entityId, recordId, err })
157
157
  }
158
158
  }
159
159
  }
@@ -239,7 +239,7 @@ export function createPresenterEnricher(
239
239
  )
240
240
  return { ...row, doc: decryptedDoc }
241
241
  } catch (err) {
242
- logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) })
242
+ logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })
243
243
  return row // Return original doc if decryption fails
244
244
  }
245
245
  }),
@@ -257,7 +257,7 @@ export function createPresenterEnricher(
257
257
  const doc = docMap.get(key)
258
258
 
259
259
  if (!doc) {
260
- logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId })
260
+ logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })
261
261
  return { key, presenter: null, url: undefined, links: undefined }
262
262
  }
263
263