@open-mercato/search 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7295.1.d0e0e33014

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,8 @@
1
1
  import { decryptIndexDocForSearch } from "@open-mercato/shared/lib/encryption/indexDoc";
2
+ import {
3
+ buildCustomFieldKindIndex,
4
+ createCustomFieldKindMapResolver
5
+ } from "@open-mercato/shared/lib/custom-fields/kinds";
2
6
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
7
  import { extractFallbackPresenter } from "./fallback-presenter.js";
4
8
  import { needsSearchResultEnrichment } from "./search-result-enrichment.js";
@@ -14,6 +18,38 @@ function chunk(array, size) {
14
18
  }
15
19
  return chunks;
16
20
  }
21
+ async function fetchCustomFieldKindResolversByEntityType(db, entityTypes, tenantId) {
22
+ const byEntityType = /* @__PURE__ */ new Map();
23
+ if (!entityTypes.length) return byEntityType;
24
+ try {
25
+ const rows = await db.selectFrom("custom_field_defs").select(["entity_id", "key", "kind", "organization_id", "tenant_id", "config_json", "updated_at"]).where("entity_id", "in", entityTypes).where("is_active", "=", true).where((eb) => eb.or([
26
+ eb("tenant_id", "=", tenantId),
27
+ eb("tenant_id", "is", null)
28
+ ])).execute();
29
+ const grouped = /* @__PURE__ */ new Map();
30
+ for (const row of rows) {
31
+ const entityType = typeof row.entity_id === "string" ? row.entity_id : String(row.entity_id ?? "");
32
+ if (!entityType) continue;
33
+ const group = grouped.get(entityType) ?? [];
34
+ group.push(row);
35
+ grouped.set(entityType, group);
36
+ }
37
+ for (const [entityType, group] of grouped) {
38
+ const index = buildCustomFieldKindIndex(group.map((row) => ({
39
+ key: row.key,
40
+ kind: row.kind,
41
+ organizationId: row.organization_id,
42
+ tenantId: row.tenant_id,
43
+ configJson: row.config_json,
44
+ updatedAt: row.updated_at
45
+ })));
46
+ byEntityType.set(entityType, createCustomFieldKindMapResolver(index));
47
+ }
48
+ } catch (err) {
49
+ logWarning("Failed to resolve custom field kinds", { err });
50
+ }
51
+ return byEntityType;
52
+ }
17
53
  async function fetchDocsBatch(db, byEntityType, tenantId, organizationId) {
18
54
  const allDocs = [];
19
55
  const allPairs = [];
@@ -180,7 +216,11 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
180
216
  group.push(result);
181
217
  byEntityType.set(result.entityId, group);
182
218
  }
183
- const rawDocs = await fetchDocsBatch(db, byEntityType, tenantId, organizationId);
219
+ const decryptionActive = Boolean(encryptionService) && encryptionService?.isEnabled?.() !== false;
220
+ const [rawDocs, kindResolversByEntityType] = await Promise.all([
221
+ fetchDocsBatch(db, byEntityType, tenantId, organizationId),
222
+ decryptionActive ? fetchCustomFieldKindResolversByEntityType(db, Array.from(byEntityType.keys()), tenantId) : Promise.resolve(/* @__PURE__ */ new Map())
223
+ ]);
184
224
  const dekCache = /* @__PURE__ */ new Map();
185
225
  const decryptedDocs = await Promise.all(
186
226
  rawDocs.map(async (row) => {
@@ -188,12 +228,14 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
188
228
  const docData = row.doc;
189
229
  const docOrgId = docData.organization_id ?? organizationId;
190
230
  const scope = { tenantId, organizationId: docOrgId };
231
+ const resolveKinds = kindResolversByEntityType.get(row.entity_type);
191
232
  const decryptedDoc = await decryptIndexDocForSearch(
192
233
  row.entity_type,
193
234
  row.doc,
194
235
  scope,
195
236
  encryptionService ?? null,
196
- dekCache
237
+ dekCache,
238
+ resolveKinds ? resolveKinds(docOrgId, tenantId) : null
197
239
  );
198
240
  return { ...row, doc: decryptedDoc };
199
241
  } catch (err) {
@@ -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 { 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\nfunction primaryNavigationHref(result: SearchResult): string | null {\n if (typeof result.url === 'string' && result.url.trim().length > 0) {\n return result.url.trim()\n }\n const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)\n return primaryLink?.href.trim() ?? null\n}\n\nfunction directNavigationRecordId(href: string): string | null {\n try {\n const url = new URL(href, 'http://search.local')\n if (url.search || url.hash) return null\n const segments = url.pathname.split('/').filter(Boolean)\n const lastSegment = segments.at(-1)\n return lastSegment ? decodeURIComponent(lastSegment) : null\n } catch {\n return null\n }\n}\n\nfunction presenterTitle(result: SearchResult): string | null {\n const title = result.presenter?.title?.trim()\n return title?.length ? title : null\n}\n\nfunction resultScopeKey(result: SearchResult, recordId: string): string {\n return `${result.organizationId ?? ''}:${recordId}`\n}\n\nfunction mergeResultMetadata(\n targetMetadata: SearchResult['metadata'],\n linkedMetadata: SearchResult['metadata'],\n): SearchResult['metadata'] {\n if (!targetMetadata && !linkedMetadata) return undefined\n return {\n ...targetMetadata,\n ...linkedMetadata,\n }\n}\n\nfunction mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {\n const indexesByRecord = new Map<string, number[]>()\n for (let index = 0; index < results.length; index += 1) {\n const result = results[index]\n const key = resultScopeKey(result, result.recordId)\n const indexes = indexesByRecord.get(key) ?? []\n indexes.push(index)\n indexesByRecord.set(key, indexes)\n }\n\n const replacements = new Map<number, SearchResult>()\n const removedIndexes = new Set<number>()\n\n for (let index = 0; index < results.length; index += 1) {\n const linkedResult = results[index]\n const href = primaryNavigationHref(linkedResult)\n const targetRecordId = href ? directNavigationRecordId(href) : null\n if (!targetRecordId || targetRecordId === linkedResult.recordId) continue\n\n const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])\n .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))\n if (targetIndexes.length !== 1) continue\n\n const targetIndex = targetIndexes[0]\n const targetResult = replacements.get(targetIndex) ?? results[targetIndex]\n if (primaryNavigationHref(targetResult)) continue\n\n const linkedTitle = presenterTitle(linkedResult)\n const targetTitle = presenterTitle(targetResult)\n if (!linkedTitle || linkedTitle !== targetTitle) continue\n\n replacements.set(targetIndex, {\n ...targetResult,\n score: Math.max(targetResult.score, linkedResult.score),\n source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,\n presenter: linkedResult.presenter ?? targetResult.presenter,\n url: linkedResult.url ?? targetResult.url,\n links: linkedResult.links ?? targetResult.links,\n metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),\n })\n removedIndexes.add(index)\n }\n\n return results\n .map((result, index) => replacements.get(index) ?? result)\n .filter((_, index) => !removedIndexes.has(index))\n .sort((left, right) => right.score - left.score)\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 (config?.formatResult || config?.buildSource) {\n if (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 if (!presenter && 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\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 const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\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 const enrichedResults = results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n\n return mergeLinkedDuplicateResults(enrichedResults)\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 {\n buildCustomFieldKindIndex,\n createCustomFieldKindMapResolver,\n type CustomFieldKindMapResolver,\n} from '@open-mercato/shared/lib/custom-fields/kinds'\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 * Load the `kind` of every active custom field on the entity types in this batch, as a\n * per-entity-type resolver. Without it `decryptIndexDocForSearch` runs `JSON.parse` over\n * decrypted string-typed values and returns `123` where `\"123\"` was stored (issue #5968).\n *\n * The definitions' scope columns come along so each result row can be typed by the\n * definition that applies to *its* organization: global search spans organizations, and one\n * of them may override a key with a different `kind` than another.\n *\n * Fails open: a lookup error yields an empty map, which keeps the previous behavior rather\n * than breaking search.\n */\nasync function fetchCustomFieldKindResolversByEntityType(\n db: Kysely<any>,\n entityTypes: string[],\n tenantId: string,\n): Promise<Map<string, CustomFieldKindMapResolver>> {\n const byEntityType = new Map<string, CustomFieldKindMapResolver>()\n if (!entityTypes.length) return byEntityType\n try {\n const rows = await db\n .selectFrom('custom_field_defs')\n .select(['entity_id', 'key', 'kind', 'organization_id', 'tenant_id', 'config_json', 'updated_at'])\n .where('entity_id', 'in', entityTypes)\n .where('is_active', '=', true)\n .where((eb: any) => eb.or([\n eb('tenant_id', '=', tenantId),\n eb('tenant_id', 'is', null),\n ]))\n .execute() as Array<Record<string, unknown>>\n const grouped = new Map<string, Array<Record<string, unknown>>>()\n for (const row of rows) {\n const entityType = typeof row.entity_id === 'string' ? row.entity_id : String(row.entity_id ?? '')\n if (!entityType) continue\n const group = grouped.get(entityType) ?? []\n group.push(row)\n grouped.set(entityType, group)\n }\n for (const [entityType, group] of grouped) {\n const index = buildCustomFieldKindIndex(group.map((row) => ({\n key: row.key,\n kind: row.kind,\n organizationId: row.organization_id,\n tenantId: row.tenant_id,\n configJson: row.config_json,\n updatedAt: row.updated_at,\n })))\n byEntityType.set(entityType, createCustomFieldKindMapResolver(index))\n }\n } catch (err) {\n logWarning('Failed to resolve custom field kinds', { err })\n }\n return byEntityType\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\nfunction primaryNavigationHref(result: SearchResult): string | null {\n if (typeof result.url === 'string' && result.url.trim().length > 0) {\n return result.url.trim()\n }\n const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)\n return primaryLink?.href.trim() ?? null\n}\n\nfunction directNavigationRecordId(href: string): string | null {\n try {\n const url = new URL(href, 'http://search.local')\n if (url.search || url.hash) return null\n const segments = url.pathname.split('/').filter(Boolean)\n const lastSegment = segments.at(-1)\n return lastSegment ? decodeURIComponent(lastSegment) : null\n } catch {\n return null\n }\n}\n\nfunction presenterTitle(result: SearchResult): string | null {\n const title = result.presenter?.title?.trim()\n return title?.length ? title : null\n}\n\nfunction resultScopeKey(result: SearchResult, recordId: string): string {\n return `${result.organizationId ?? ''}:${recordId}`\n}\n\nfunction mergeResultMetadata(\n targetMetadata: SearchResult['metadata'],\n linkedMetadata: SearchResult['metadata'],\n): SearchResult['metadata'] {\n if (!targetMetadata && !linkedMetadata) return undefined\n return {\n ...targetMetadata,\n ...linkedMetadata,\n }\n}\n\nfunction mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {\n const indexesByRecord = new Map<string, number[]>()\n for (let index = 0; index < results.length; index += 1) {\n const result = results[index]\n const key = resultScopeKey(result, result.recordId)\n const indexes = indexesByRecord.get(key) ?? []\n indexes.push(index)\n indexesByRecord.set(key, indexes)\n }\n\n const replacements = new Map<number, SearchResult>()\n const removedIndexes = new Set<number>()\n\n for (let index = 0; index < results.length; index += 1) {\n const linkedResult = results[index]\n const href = primaryNavigationHref(linkedResult)\n const targetRecordId = href ? directNavigationRecordId(href) : null\n if (!targetRecordId || targetRecordId === linkedResult.recordId) continue\n\n const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])\n .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))\n if (targetIndexes.length !== 1) continue\n\n const targetIndex = targetIndexes[0]\n const targetResult = replacements.get(targetIndex) ?? results[targetIndex]\n if (primaryNavigationHref(targetResult)) continue\n\n const linkedTitle = presenterTitle(linkedResult)\n const targetTitle = presenterTitle(targetResult)\n if (!linkedTitle || linkedTitle !== targetTitle) continue\n\n replacements.set(targetIndex, {\n ...targetResult,\n score: Math.max(targetResult.score, linkedResult.score),\n source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,\n presenter: linkedResult.presenter ?? targetResult.presenter,\n url: linkedResult.url ?? targetResult.url,\n links: linkedResult.links ?? targetResult.links,\n metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),\n })\n removedIndexes.add(index)\n }\n\n return results\n .map((result, index) => replacements.get(index) ?? result)\n .filter((_, index) => !removedIndexes.has(index))\n .sort((left, right) => right.score - left.score)\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 (config?.formatResult || config?.buildSource) {\n if (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 if (!presenter && 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\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 const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\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 // The kind lookup only matters to decryption, so skip the round trip entirely when no\n // encryption service will run \u2014 search is a hot path and the map would be dead weight.\n const decryptionActive = Boolean(encryptionService) && encryptionService?.isEnabled?.() !== false\n\n // Single batch query for all docs across all entity types\n const [rawDocs, kindResolversByEntityType] = await Promise.all([\n fetchDocsBatch(db, byEntityType, tenantId, organizationId),\n decryptionActive\n ? fetchCustomFieldKindResolversByEntityType(db, Array.from(byEntityType.keys()), tenantId)\n : Promise.resolve(new Map<string, CustomFieldKindMapResolver>()),\n ])\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 // Same scope for the kind as for the decryption itself \u2014 a result from another\n // organization must not be typed by this organization's definition.\n const resolveKinds = kindResolversByEntityType.get(row.entity_type)\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n resolveKinds ? resolveKinds(docOrgId, tenantId) : null,\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 const enrichedResults = results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n\n return mergeLinkedDuplicateResults(enrichedResults)\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.8.1-develop.7275.1.f772b944d4",
3
+ "version": "0.8.1-develop.7295.1.d0e0e33014",
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.8.1-develop.7275.1.f772b944d4",
131
- "@open-mercato/queue": "0.8.1-develop.7275.1.f772b944d4",
132
- "@open-mercato/shared": "0.8.1-develop.7275.1.f772b944d4"
130
+ "@open-mercato/core": "0.8.1-develop.7295.1.d0e0e33014",
131
+ "@open-mercato/queue": "0.8.1-develop.7295.1.d0e0e33014",
132
+ "@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -19,19 +19,31 @@ type IndexRow = {
19
19
 
20
20
  const mockedDecryptIndexDocForSearch = jest.mocked(decryptIndexDocForSearch)
21
21
 
22
+ type CustomFieldDefRow = {
23
+ entity_id: string
24
+ key: string
25
+ kind: string | null
26
+ }
27
+
22
28
  /**
23
29
  * Build a minimal Kysely-like mock for `db.selectFrom(...).select(...).where(...).execute()` chains.
24
30
  * The presenter enricher only uses selectFrom/select/where/execute on the resolved Kysely instance,
25
- * so we don't need full coverage here.
31
+ * so we don't need full coverage here. Rows are routed per table because the enricher reads both
32
+ * `entity_indexes` (the docs) and `custom_field_defs` (the field kinds, issue #5968).
26
33
  */
27
- function createKyselyMock(rows: IndexRow[]): Kysely<any> {
28
- const chain: any = {
29
- select: jest.fn(() => chain),
30
- where: jest.fn(() => chain),
31
- execute: jest.fn().mockResolvedValue(rows),
34
+ function createKyselyMock(rows: IndexRow[], customFieldDefs: CustomFieldDefRow[] = []): Kysely<any> {
35
+ const createChain = (result: unknown[]) => {
36
+ const chain: any = {
37
+ select: jest.fn(() => chain),
38
+ where: jest.fn(() => chain),
39
+ execute: jest.fn().mockResolvedValue(result),
40
+ }
41
+ return chain
32
42
  }
33
43
  const db: any = {
34
- selectFrom: jest.fn(() => chain),
44
+ selectFrom: jest.fn((table: string) => (
45
+ String(table).startsWith('custom_field_defs') ? createChain(customFieldDefs) : createChain(rows)
46
+ )),
35
47
  }
36
48
  return db as Kysely<any>
37
49
  }
@@ -95,6 +107,7 @@ describe('createPresenterEnricher', () => {
95
107
  { tenantId: 'tenant-1', organizationId: 'org-from-doc' },
96
108
  expect.anything(),
97
109
  expect.any(Map),
110
+ null,
98
111
  )
99
112
  expect(buildSource).toHaveBeenCalledWith(
100
113
  expect.objectContaining({
@@ -115,6 +128,61 @@ describe('createPresenterEnricher', () => {
115
128
  expect(enriched.links).toEqual([{ href: '/backend/customers/person-1/edit', label: 'Edit', kind: 'secondary' }])
116
129
  })
117
130
 
131
+ it('forwards each entity type its custom field kinds so encrypted text is not retyped (#5968)', async () => {
132
+ const doc = { id: 'person-1', organization_id: 'org-1', 'cf:nickname': 'Countess' }
133
+ mockedDecryptIndexDocForSearch.mockResolvedValue(doc)
134
+
135
+ const config = createConfig({ formatResult: async () => ({ title: 'Ada' }) })
136
+ const db = createKyselyMock(
137
+ [{ entity_type: 'customers:person', entity_id: 'person-1', doc }],
138
+ [
139
+ { entity_id: 'customers:person', key: 'nickname', kind: 'text' },
140
+ { entity_id: 'customers:person', key: 'visit-count', kind: 'integer' },
141
+ { entity_id: 'orders:order', key: 'reference', kind: 'text' },
142
+ ],
143
+ )
144
+
145
+ const enrich = createPresenterEnricher(db, new Map([[config.entityId, config]]), undefined, {} as never)
146
+ await enrich([createResult()], 'tenant-1', 'org-1')
147
+
148
+ expect(mockedDecryptIndexDocForSearch).toHaveBeenCalledWith(
149
+ 'customers:person',
150
+ doc,
151
+ expect.anything(),
152
+ expect.anything(),
153
+ expect.any(Map),
154
+ // Only this entity type's definitions, keyed by authored key and sanitized alias.
155
+ { nickname: 'text', 'visit-count': 'integer', visit_count: 'integer' },
156
+ )
157
+ })
158
+
159
+ it('degrades to the previous behaviour when the custom field kind lookup fails (#5968)', async () => {
160
+ const doc = { id: 'person-1', organization_id: 'org-1' }
161
+ mockedDecryptIndexDocForSearch.mockResolvedValue(doc)
162
+
163
+ const db = createKyselyMock([{ entity_type: 'customers:person', entity_id: 'person-1', doc }])
164
+ const failingDb: any = {
165
+ selectFrom: jest.fn((table: string) => {
166
+ if (String(table).startsWith('custom_field_defs')) throw new Error('relation does not exist')
167
+ return (db as any).selectFrom(table)
168
+ }),
169
+ }
170
+ const config = createConfig({ formatResult: async () => ({ title: 'Ada' }) })
171
+
172
+ const enrich = createPresenterEnricher(failingDb, new Map([[config.entityId, config]]), undefined, {} as never)
173
+ const [enriched] = await enrich([createResult()], 'tenant-1', 'org-1')
174
+
175
+ expect(enriched.presenter?.title).toBe('Ada')
176
+ expect(mockedDecryptIndexDocForSearch).toHaveBeenCalledWith(
177
+ 'customers:person',
178
+ doc,
179
+ expect.anything(),
180
+ expect.anything(),
181
+ expect.any(Map),
182
+ null,
183
+ )
184
+ })
185
+
118
186
  it('replaces empty link arrays with resolved links when url metadata is missing', async () => {
119
187
  const doc = {
120
188
  id: 'person-1',
@@ -11,6 +11,11 @@ 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 {
15
+ buildCustomFieldKindIndex,
16
+ createCustomFieldKindMapResolver,
17
+ type CustomFieldKindMapResolver,
18
+ } from '@open-mercato/shared/lib/custom-fields/kinds'
14
19
  import { createLogger } from '@open-mercato/shared/lib/logger'
15
20
  import { extractFallbackPresenter } from './fallback-presenter'
16
21
  import { needsSearchResultEnrichment } from './search-result-enrichment'
@@ -35,6 +40,61 @@ function chunk<T>(array: T[], size: number): T[][] {
35
40
  return chunks
36
41
  }
37
42
 
43
+ /**
44
+ * Load the `kind` of every active custom field on the entity types in this batch, as a
45
+ * per-entity-type resolver. Without it `decryptIndexDocForSearch` runs `JSON.parse` over
46
+ * decrypted string-typed values and returns `123` where `"123"` was stored (issue #5968).
47
+ *
48
+ * The definitions' scope columns come along so each result row can be typed by the
49
+ * definition that applies to *its* organization: global search spans organizations, and one
50
+ * of them may override a key with a different `kind` than another.
51
+ *
52
+ * Fails open: a lookup error yields an empty map, which keeps the previous behavior rather
53
+ * than breaking search.
54
+ */
55
+ async function fetchCustomFieldKindResolversByEntityType(
56
+ db: Kysely<any>,
57
+ entityTypes: string[],
58
+ tenantId: string,
59
+ ): Promise<Map<string, CustomFieldKindMapResolver>> {
60
+ const byEntityType = new Map<string, CustomFieldKindMapResolver>()
61
+ if (!entityTypes.length) return byEntityType
62
+ try {
63
+ const rows = await db
64
+ .selectFrom('custom_field_defs')
65
+ .select(['entity_id', 'key', 'kind', 'organization_id', 'tenant_id', 'config_json', 'updated_at'])
66
+ .where('entity_id', 'in', entityTypes)
67
+ .where('is_active', '=', true)
68
+ .where((eb: any) => eb.or([
69
+ eb('tenant_id', '=', tenantId),
70
+ eb('tenant_id', 'is', null),
71
+ ]))
72
+ .execute() as Array<Record<string, unknown>>
73
+ const grouped = new Map<string, Array<Record<string, unknown>>>()
74
+ for (const row of rows) {
75
+ const entityType = typeof row.entity_id === 'string' ? row.entity_id : String(row.entity_id ?? '')
76
+ if (!entityType) continue
77
+ const group = grouped.get(entityType) ?? []
78
+ group.push(row)
79
+ grouped.set(entityType, group)
80
+ }
81
+ for (const [entityType, group] of grouped) {
82
+ const index = buildCustomFieldKindIndex(group.map((row) => ({
83
+ key: row.key,
84
+ kind: row.kind,
85
+ organizationId: row.organization_id,
86
+ tenantId: row.tenant_id,
87
+ configJson: row.config_json,
88
+ updatedAt: row.updated_at,
89
+ })))
90
+ byEntityType.set(entityType, createCustomFieldKindMapResolver(index))
91
+ }
92
+ } catch (err) {
93
+ logWarning('Failed to resolve custom field kinds', { err })
94
+ }
95
+ return byEntityType
96
+ }
97
+
38
98
  /**
39
99
  * Build a single batch query for multiple entity types and their record IDs.
40
100
  * Uses OR conditions to fetch all needed docs in one round trip.
@@ -305,8 +365,17 @@ export function createPresenterEnricher(
305
365
  byEntityType.set(result.entityId, group)
306
366
  }
307
367
 
368
+ // The kind lookup only matters to decryption, so skip the round trip entirely when no
369
+ // encryption service will run — search is a hot path and the map would be dead weight.
370
+ const decryptionActive = Boolean(encryptionService) && encryptionService?.isEnabled?.() !== false
371
+
308
372
  // Single batch query for all docs across all entity types
309
- const rawDocs = await fetchDocsBatch(db, byEntityType, tenantId, organizationId)
373
+ const [rawDocs, kindResolversByEntityType] = await Promise.all([
374
+ fetchDocsBatch(db, byEntityType, tenantId, organizationId),
375
+ decryptionActive
376
+ ? fetchCustomFieldKindResolversByEntityType(db, Array.from(byEntityType.keys()), tenantId)
377
+ : Promise.resolve(new Map<string, CustomFieldKindMapResolver>()),
378
+ ])
310
379
 
311
380
  // Decrypt docs in parallel using DEK cache for efficiency
312
381
  const dekCache = new Map<string | null, string | null>()
@@ -320,12 +389,17 @@ export function createPresenterEnricher(
320
389
  const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId
321
390
  const scope = { tenantId, organizationId: docOrgId }
322
391
 
392
+ // Same scope for the kind as for the decryption itself — a result from another
393
+ // organization must not be typed by this organization's definition.
394
+ const resolveKinds = kindResolversByEntityType.get(row.entity_type)
395
+
323
396
  const decryptedDoc = await decryptIndexDocForSearch(
324
397
  row.entity_type,
325
398
  row.doc,
326
399
  scope,
327
400
  encryptionService ?? null,
328
401
  dekCache,
402
+ resolveKinds ? resolveKinds(docOrgId, tenantId) : null,
329
403
  )
330
404
  return { ...row, doc: decryptedDoc }
331
405
  } catch (err) {