@open-mercato/search 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518
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
|
|
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.
|
|
3
|
+
"version": "0.8.1-develop.7294.1.0ef99fc518",
|
|
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.
|
|
131
|
-
"@open-mercato/queue": "0.8.1-develop.
|
|
132
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
130
|
+
"@open-mercato/core": "0.8.1-develop.7294.1.0ef99fc518",
|
|
131
|
+
"@open-mercato/queue": "0.8.1-develop.7294.1.0ef99fc518",
|
|
132
|
+
"@open-mercato/shared": "0.8.1-develop.7294.1.0ef99fc518"
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(() =>
|
|
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
|
|
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) {
|