@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 +2 -2
- package/dist/lib/debug.js +16 -22
- package/dist/lib/debug.js.map +2 -2
- package/dist/lib/presenter-enricher.js +7 -7
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/debug.test.ts +45 -20
- package/src/__tests__/workers.test.ts +25 -13
- package/src/lib/debug.ts +25 -24
- package/src/lib/presenter-enricher.ts +8 -8
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
|
-
| `
|
|
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
|
-
- [ ]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/lib/debug.js.map
CHANGED
|
@@ -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
|
|
5
|
-
"mappings": "
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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": "AAYA,SAAS,gCAAgC;AACzC,SAAS,gCAAgC;AACzC,SAAS,mCAAmC;AAG5C,MAAM,aAAa;AAGnB,MAAM,aAAa,
|
|
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.
|
|
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.
|
|
131
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
132
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
77
|
+
expect(searchLoggerChild).toHaveBeenCalledWith({ component: 'search.test' })
|
|
78
|
+
expect(searchLoggerDebug).toHaveBeenCalledWith('indexed', payload)
|
|
60
79
|
})
|
|
61
80
|
|
|
62
|
-
it('logs only the
|
|
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(
|
|
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(
|
|
96
|
+
expect(searchLoggerWarn).not.toHaveBeenCalled()
|
|
80
97
|
})
|
|
81
98
|
|
|
82
|
-
it('warns with the
|
|
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(
|
|
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(
|
|
127
|
+
expect(searchLoggerError).toHaveBeenCalledWith('failed', payload)
|
|
102
128
|
})
|
|
103
129
|
|
|
104
|
-
it('logs only the
|
|
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(
|
|
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
|
-
|
|
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(
|
|
240
|
-
expect(String(
|
|
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
|
-
|
|
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(
|
|
263
|
-
expect(String(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|