@open-mercato/search 0.4.2-canary-c02407ff85

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.
Files changed (237) hide show
  1. package/AGENTS.md +678 -0
  2. package/build.mjs +92 -0
  3. package/dist/di.js +157 -0
  4. package/dist/di.js.map +7 -0
  5. package/dist/fulltext/drivers/index.js +21 -0
  6. package/dist/fulltext/drivers/index.js.map +7 -0
  7. package/dist/fulltext/drivers/meilisearch/index.js +320 -0
  8. package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
  9. package/dist/fulltext/index.js +7 -0
  10. package/dist/fulltext/index.js.map +7 -0
  11. package/dist/fulltext/types.js +1 -0
  12. package/dist/fulltext/types.js.map +7 -0
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/indexer/index.js +8 -0
  16. package/dist/indexer/index.js.map +7 -0
  17. package/dist/indexer/search-indexer.js +848 -0
  18. package/dist/indexer/search-indexer.js.map +7 -0
  19. package/dist/indexer/subscribers/delete.js +41 -0
  20. package/dist/indexer/subscribers/delete.js.map +7 -0
  21. package/dist/lib/debug.js +34 -0
  22. package/dist/lib/debug.js.map +7 -0
  23. package/dist/lib/fallback-presenter.js +107 -0
  24. package/dist/lib/fallback-presenter.js.map +7 -0
  25. package/dist/lib/field-policy.js +75 -0
  26. package/dist/lib/field-policy.js.map +7 -0
  27. package/dist/lib/index.js +19 -0
  28. package/dist/lib/index.js.map +7 -0
  29. package/dist/lib/merger.js +93 -0
  30. package/dist/lib/merger.js.map +7 -0
  31. package/dist/lib/presenter-enricher.js +192 -0
  32. package/dist/lib/presenter-enricher.js.map +7 -0
  33. package/dist/modules/search/acl.js +14 -0
  34. package/dist/modules/search/acl.js.map +7 -0
  35. package/dist/modules/search/ai-tools.js +284 -0
  36. package/dist/modules/search/ai-tools.js.map +7 -0
  37. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
  38. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
  39. package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
  40. package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
  41. package/dist/modules/search/api/embeddings/route.js +246 -0
  42. package/dist/modules/search/api/embeddings/route.js.map +7 -0
  43. package/dist/modules/search/api/index/route.js +245 -0
  44. package/dist/modules/search/api/index/route.js.map +7 -0
  45. package/dist/modules/search/api/reindex/cancel/route.js +65 -0
  46. package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
  47. package/dist/modules/search/api/reindex/route.js +332 -0
  48. package/dist/modules/search/api/reindex/route.js.map +7 -0
  49. package/dist/modules/search/api/search/global/route.js +100 -0
  50. package/dist/modules/search/api/search/global/route.js.map +7 -0
  51. package/dist/modules/search/api/search/route.js +101 -0
  52. package/dist/modules/search/api/search/route.js.map +7 -0
  53. package/dist/modules/search/api/settings/fulltext/route.js +55 -0
  54. package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
  55. package/dist/modules/search/api/settings/global-search/route.js +80 -0
  56. package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
  57. package/dist/modules/search/api/settings/route.js +118 -0
  58. package/dist/modules/search/api/settings/route.js.map +7 -0
  59. package/dist/modules/search/api/settings/vector-store/route.js +77 -0
  60. package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
  61. package/dist/modules/search/backend/config/search/page.js +10 -0
  62. package/dist/modules/search/backend/config/search/page.js.map +7 -0
  63. package/dist/modules/search/backend/config/search/page.meta.js +24 -0
  64. package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
  65. package/dist/modules/search/cli.js +698 -0
  66. package/dist/modules/search/cli.js.map +7 -0
  67. package/dist/modules/search/di.js +32 -0
  68. package/dist/modules/search/di.js.map +7 -0
  69. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
  70. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
  71. package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
  72. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
  73. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
  74. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
  75. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
  76. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
  77. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
  78. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
  79. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
  80. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
  81. package/dist/modules/search/frontend/index.js +9 -0
  82. package/dist/modules/search/frontend/index.js.map +7 -0
  83. package/dist/modules/search/frontend/utils.js +41 -0
  84. package/dist/modules/search/frontend/utils.js.map +7 -0
  85. package/dist/modules/search/i18n/de.json +61 -0
  86. package/dist/modules/search/i18n/en.json +72 -0
  87. package/dist/modules/search/i18n/es.json +61 -0
  88. package/dist/modules/search/i18n/pl.json +61 -0
  89. package/dist/modules/search/index.js +11 -0
  90. package/dist/modules/search/index.js.map +7 -0
  91. package/dist/modules/search/lib/auto-indexing.js +29 -0
  92. package/dist/modules/search/lib/auto-indexing.js.map +7 -0
  93. package/dist/modules/search/lib/embedding-config.js +131 -0
  94. package/dist/modules/search/lib/embedding-config.js.map +7 -0
  95. package/dist/modules/search/lib/global-search-config.js +45 -0
  96. package/dist/modules/search/lib/global-search-config.js.map +7 -0
  97. package/dist/modules/search/lib/reindex-lock.js +99 -0
  98. package/dist/modules/search/lib/reindex-lock.js.map +7 -0
  99. package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
  100. package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
  101. package/dist/modules/search/subscribers/vector_delete.js +58 -0
  102. package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
  103. package/dist/modules/search/subscribers/vector_purge.js +142 -0
  104. package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
  105. package/dist/modules/search/subscribers/vector_upsert.js +58 -0
  106. package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
  107. package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
  108. package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
  109. package/dist/modules/search/workers/vector-index.worker.js +234 -0
  110. package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
  111. package/dist/queue/fulltext-indexing.js +15 -0
  112. package/dist/queue/fulltext-indexing.js.map +7 -0
  113. package/dist/queue/index.js +3 -0
  114. package/dist/queue/index.js.map +7 -0
  115. package/dist/queue/vector-indexing.js +15 -0
  116. package/dist/queue/vector-indexing.js.map +7 -0
  117. package/dist/service.js +286 -0
  118. package/dist/service.js.map +7 -0
  119. package/dist/strategies/fulltext.strategy.js +116 -0
  120. package/dist/strategies/fulltext.strategy.js.map +7 -0
  121. package/dist/strategies/index.js +12 -0
  122. package/dist/strategies/index.js.map +7 -0
  123. package/dist/strategies/token.strategy.js +80 -0
  124. package/dist/strategies/token.strategy.js.map +7 -0
  125. package/dist/strategies/vector.strategy.js +137 -0
  126. package/dist/strategies/vector.strategy.js.map +7 -0
  127. package/dist/types.js +1 -0
  128. package/dist/types.js.map +7 -0
  129. package/dist/vector/drivers/chromadb/index.js +44 -0
  130. package/dist/vector/drivers/chromadb/index.js.map +7 -0
  131. package/dist/vector/drivers/index.js +9 -0
  132. package/dist/vector/drivers/index.js.map +7 -0
  133. package/dist/vector/drivers/pgvector/index.js +509 -0
  134. package/dist/vector/drivers/pgvector/index.js.map +7 -0
  135. package/dist/vector/drivers/qdrant/index.js +44 -0
  136. package/dist/vector/drivers/qdrant/index.js.map +7 -0
  137. package/dist/vector/index.js +4 -0
  138. package/dist/vector/index.js.map +7 -0
  139. package/dist/vector/lib/vector-logs.js +33 -0
  140. package/dist/vector/lib/vector-logs.js.map +7 -0
  141. package/dist/vector/services/checksum.js +20 -0
  142. package/dist/vector/services/checksum.js.map +7 -0
  143. package/dist/vector/services/embedding.js +222 -0
  144. package/dist/vector/services/embedding.js.map +7 -0
  145. package/dist/vector/services/index.js +4 -0
  146. package/dist/vector/services/index.js.map +7 -0
  147. package/dist/vector/services/vector-index.service.js +960 -0
  148. package/dist/vector/services/vector-index.service.js.map +7 -0
  149. package/dist/vector/types/pg.d.js +1 -0
  150. package/dist/vector/types/pg.d.js.map +7 -0
  151. package/dist/vector/types.js +75 -0
  152. package/dist/vector/types.js.map +7 -0
  153. package/jest.config.cjs +19 -0
  154. package/package.json +142 -0
  155. package/src/__tests__/queue.test.ts +148 -0
  156. package/src/__tests__/service.test.ts +345 -0
  157. package/src/__tests__/workers.test.ts +319 -0
  158. package/src/di.ts +291 -0
  159. package/src/fulltext/drivers/index.ts +41 -0
  160. package/src/fulltext/drivers/meilisearch/index.ts +410 -0
  161. package/src/fulltext/index.ts +13 -0
  162. package/src/fulltext/types.ts +115 -0
  163. package/src/index.ts +36 -0
  164. package/src/indexer/index.ts +13 -0
  165. package/src/indexer/search-indexer.ts +1141 -0
  166. package/src/indexer/subscribers/delete.ts +49 -0
  167. package/src/lib/debug.ts +46 -0
  168. package/src/lib/fallback-presenter.ts +106 -0
  169. package/src/lib/field-policy.ts +169 -0
  170. package/src/lib/index.ts +13 -0
  171. package/src/lib/merger.ts +159 -0
  172. package/src/lib/presenter-enricher.ts +323 -0
  173. package/src/modules/search/README.md +694 -0
  174. package/src/modules/search/acl.ts +10 -0
  175. package/src/modules/search/ai-tools.ts +467 -0
  176. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
  177. package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
  178. package/src/modules/search/api/embeddings/route.ts +304 -0
  179. package/src/modules/search/api/index/route.ts +297 -0
  180. package/src/modules/search/api/reindex/cancel/route.ts +77 -0
  181. package/src/modules/search/api/reindex/route.ts +419 -0
  182. package/src/modules/search/api/search/global/route.ts +120 -0
  183. package/src/modules/search/api/search/route.ts +121 -0
  184. package/src/modules/search/api/settings/fulltext/route.ts +82 -0
  185. package/src/modules/search/api/settings/global-search/route.ts +91 -0
  186. package/src/modules/search/api/settings/route.ts +187 -0
  187. package/src/modules/search/api/settings/vector-store/route.ts +105 -0
  188. package/src/modules/search/backend/config/search/page.meta.ts +22 -0
  189. package/src/modules/search/backend/config/search/page.tsx +12 -0
  190. package/src/modules/search/cli.ts +818 -0
  191. package/src/modules/search/di.ts +50 -0
  192. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
  193. package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
  194. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
  195. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
  196. package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
  197. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
  198. package/src/modules/search/frontend/index.ts +3 -0
  199. package/src/modules/search/frontend/utils.ts +82 -0
  200. package/src/modules/search/i18n/de.json +61 -0
  201. package/src/modules/search/i18n/en.json +72 -0
  202. package/src/modules/search/i18n/es.json +61 -0
  203. package/src/modules/search/i18n/pl.json +61 -0
  204. package/src/modules/search/index.ts +9 -0
  205. package/src/modules/search/lib/auto-indexing.ts +35 -0
  206. package/src/modules/search/lib/embedding-config.ts +161 -0
  207. package/src/modules/search/lib/global-search-config.ts +69 -0
  208. package/src/modules/search/lib/reindex-lock.ts +201 -0
  209. package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
  210. package/src/modules/search/subscribers/vector_delete.ts +75 -0
  211. package/src/modules/search/subscribers/vector_purge.ts +161 -0
  212. package/src/modules/search/subscribers/vector_upsert.ts +75 -0
  213. package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
  214. package/src/modules/search/workers/vector-index.worker.ts +292 -0
  215. package/src/queue/fulltext-indexing.ts +87 -0
  216. package/src/queue/index.ts +2 -0
  217. package/src/queue/vector-indexing.ts +66 -0
  218. package/src/service.ts +397 -0
  219. package/src/strategies/fulltext.strategy.ts +155 -0
  220. package/src/strategies/index.ts +17 -0
  221. package/src/strategies/token.strategy.ts +153 -0
  222. package/src/strategies/vector.strategy.ts +234 -0
  223. package/src/types.ts +38 -0
  224. package/src/vector/drivers/chromadb/index.ts +49 -0
  225. package/src/vector/drivers/index.ts +4 -0
  226. package/src/vector/drivers/pgvector/index.ts +627 -0
  227. package/src/vector/drivers/qdrant/index.ts +49 -0
  228. package/src/vector/index.ts +3 -0
  229. package/src/vector/lib/vector-logs.ts +46 -0
  230. package/src/vector/services/checksum.ts +18 -0
  231. package/src/vector/services/embedding.ts +275 -0
  232. package/src/vector/services/index.ts +3 -0
  233. package/src/vector/services/vector-index.service.ts +1234 -0
  234. package/src/vector/types/pg.d.ts +1 -0
  235. package/src/vector/types.ts +220 -0
  236. package/tsconfig.json +9 -0
  237. package/watch.mjs +6 -0
@@ -0,0 +1,323 @@
1
+ import type { Knex } from 'knex'
2
+ import type {
3
+ SearchBuildContext,
4
+ SearchResult,
5
+ SearchResultPresenter,
6
+ SearchResultLink,
7
+ SearchEntityConfig,
8
+ PresenterEnricherFn,
9
+ } from '../types'
10
+ import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
11
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
12
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
13
+ import { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'
14
+ import { extractFallbackPresenter } from './fallback-presenter'
15
+
16
+ /** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */
17
+ const BATCH_SIZE = 500
18
+
19
+ /** Logger for debugging - uses console.warn to surface issues without breaking flow */
20
+ const logWarning = (message: string, context?: Record<string, unknown>) => {
21
+ if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SEARCH_ENRICHER) {
22
+ console.warn(`[search:presenter-enricher] ${message}`, context ?? '')
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Check if a string looks like an encrypted value.
28
+ * Encrypted format: iv:ciphertext:authTag:v1
29
+ */
30
+ function looksEncrypted(value: unknown): boolean {
31
+ if (typeof value !== 'string') return false
32
+ if (!value.includes(':')) return false
33
+ const parts = value.split(':')
34
+ // Encrypted strings end with :v1 and have at least 3 colon-separated parts
35
+ return parts.length >= 3 && parts[parts.length - 1] === 'v1'
36
+ }
37
+
38
+ /**
39
+ * Check if a result needs enrichment (missing presenter, encrypted values, or missing URL/links)
40
+ */
41
+ function needsEnrichment(result: SearchResult): boolean {
42
+ if (!result.presenter?.title) return true
43
+ // Also re-enrich if presenter looks encrypted
44
+ if (looksEncrypted(result.presenter.title)) return true
45
+ if (looksEncrypted(result.presenter.subtitle)) return true
46
+ // Also enrich if missing URL/links (needed for token search results)
47
+ if (!result.url && (!result.links || result.links.length === 0)) return true
48
+ return false
49
+ }
50
+
51
+ /**
52
+ * Split an array into chunks of specified size.
53
+ */
54
+ function chunk<T>(array: T[], size: number): T[][] {
55
+ const chunks: T[][] = []
56
+ for (let i = 0; i < array.length; i += size) {
57
+ chunks.push(array.slice(i, i + size))
58
+ }
59
+ return chunks
60
+ }
61
+
62
+ /**
63
+ * Build a single batch query for multiple entity types and their record IDs.
64
+ * Uses OR conditions to fetch all needed docs in one round trip.
65
+ */
66
+ async function fetchDocsBatch(
67
+ knex: Knex,
68
+ byEntityType: Map<string, SearchResult[]>,
69
+ tenantId: string,
70
+ organizationId?: string | null,
71
+ ): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {
72
+ const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []
73
+
74
+ // Collect all entity type + record ID pairs
75
+ const allPairs: Array<{ entityType: string; recordId: string }> = []
76
+ for (const [entityType, results] of byEntityType) {
77
+ for (const result of results) {
78
+ allPairs.push({ entityType, recordId: result.recordId })
79
+ }
80
+ }
81
+
82
+ if (allPairs.length === 0) return allDocs
83
+
84
+ // Process in chunks to avoid hitting DB parameter limits
85
+ const chunks = chunk(allPairs, BATCH_SIZE)
86
+
87
+ for (const pairChunk of chunks) {
88
+ // Group by entity type within this chunk for efficient OR query
89
+ const chunkByType = new Map<string, string[]>()
90
+ for (const { entityType, recordId } of pairChunk) {
91
+ const ids = chunkByType.get(entityType) ?? []
92
+ ids.push(recordId)
93
+ chunkByType.set(entityType, ids)
94
+ }
95
+
96
+ // Build query with OR conditions per entity type
97
+ const query = knex('entity_indexes')
98
+ .select('entity_type', 'entity_id', 'doc')
99
+ .where('tenant_id', tenantId)
100
+ .whereNull('deleted_at')
101
+ .where((builder) => {
102
+ for (const [entityType, recordIds] of chunkByType) {
103
+ builder.orWhere((sub) => {
104
+ sub.where('entity_type', entityType).whereIn('entity_id', recordIds)
105
+ })
106
+ }
107
+ })
108
+
109
+ // Add organization filter if provided
110
+ if (organizationId) {
111
+ query.where((builder) => {
112
+ builder.where('organization_id', organizationId).orWhereNull('organization_id')
113
+ })
114
+ }
115
+
116
+ const rows = await query
117
+ allDocs.push(...(rows as typeof allDocs))
118
+ }
119
+
120
+ return allDocs
121
+ }
122
+
123
+ /** Result type for presenter and links computation */
124
+ type EnrichmentResult = {
125
+ presenter: SearchResultPresenter | null
126
+ url?: string
127
+ links?: SearchResultLink[]
128
+ }
129
+
130
+ /**
131
+ * Compute presenter, URL, and links for a single doc using config or fallback.
132
+ * Returns presenter (null if cannot be computed), and optionally URL/links from config.
133
+ */
134
+ async function computePresenterAndLinks(
135
+ doc: Record<string, unknown>,
136
+ entityId: string,
137
+ recordId: string,
138
+ config: SearchEntityConfig | undefined,
139
+ tenantId: string,
140
+ organizationId: string | null | undefined,
141
+ queryEngine: QueryEngine | undefined,
142
+ ): Promise<EnrichmentResult> {
143
+ let presenter: SearchResultPresenter | null = null
144
+ let url: string | undefined
145
+ let links: SearchResultLink[] | undefined
146
+
147
+ // Build context for config functions
148
+ const customFields: Record<string, unknown> = {}
149
+ for (const [key, value] of Object.entries(doc)) {
150
+ if (key.startsWith('cf:') || key.startsWith('cf_')) {
151
+ customFields[key.slice(3)] = value
152
+ }
153
+ }
154
+
155
+ const buildContext: SearchBuildContext = {
156
+ record: doc,
157
+ customFields,
158
+ organizationId,
159
+ tenantId,
160
+ queryEngine,
161
+ }
162
+
163
+ // If search.ts config exists, use formatResult/buildSource for presenter
164
+ if (config?.formatResult || config?.buildSource) {
165
+ if (config.buildSource) {
166
+ try {
167
+ const source = await config.buildSource(buildContext)
168
+ if (source?.presenter) presenter = source.presenter
169
+ if (source?.links) links = source.links
170
+ } catch (err) {
171
+ logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) })
172
+ }
173
+ }
174
+
175
+ if (!presenter && config.formatResult) {
176
+ try {
177
+ presenter = (await config.formatResult(buildContext)) ?? null
178
+ } catch (err) {
179
+ logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) })
180
+ }
181
+ }
182
+ }
183
+
184
+ // Fallback presenter: extract from doc fields directly
185
+ if (!presenter) {
186
+ presenter = extractFallbackPresenter(doc, entityId, recordId)
187
+ }
188
+
189
+ // Resolve URL from config
190
+ if (config?.resolveUrl) {
191
+ try {
192
+ url = (await config.resolveUrl(buildContext)) ?? undefined
193
+ } catch {
194
+ // Skip URL resolution errors
195
+ }
196
+ }
197
+
198
+ // Resolve links from config (if not already set from buildSource)
199
+ if (!links && config?.resolveLinks) {
200
+ try {
201
+ links = (await config.resolveLinks(buildContext)) ?? undefined
202
+ } catch {
203
+ // Skip link resolution errors
204
+ }
205
+ }
206
+
207
+ return { presenter, url, links }
208
+ }
209
+
210
+ /**
211
+ * Create a presenter enricher that loads data from entity_indexes and computes presenter.
212
+ * Uses formatResult from search.ts configs when available, otherwise falls back to extracting
213
+ * common fields like display_name, name, title from the doc.
214
+ *
215
+ * Optimizations:
216
+ * - Single batch DB query for all entity types (instead of one per type)
217
+ * - Parallel Promise.all for formatResult/buildSource calls
218
+ * - Tenant/organization scoping for security
219
+ * - Chunked queries to avoid DB parameter limits
220
+ * - Automatic decryption of encrypted fields when encryption service is provided
221
+ */
222
+ export function createPresenterEnricher(
223
+ knex: Knex,
224
+ entityConfigMap: Map<EntityId, SearchEntityConfig>,
225
+ queryEngine?: QueryEngine,
226
+ encryptionService?: TenantDataEncryptionService | null,
227
+ ): PresenterEnricherFn {
228
+ return async (results, tenantId, organizationId) => {
229
+ // Find results missing presenter OR with encrypted presenter
230
+ const missingResults = results.filter(needsEnrichment)
231
+ if (missingResults.length === 0) return results
232
+
233
+ // Group by entity type for config lookup
234
+ const byEntityType = new Map<string, SearchResult[]>()
235
+ for (const result of missingResults) {
236
+ const group = byEntityType.get(result.entityId) ?? []
237
+ group.push(result)
238
+ byEntityType.set(result.entityId, group)
239
+ }
240
+
241
+ // Single batch query for all docs across all entity types
242
+ const rawDocs = await fetchDocsBatch(knex, byEntityType, tenantId, organizationId)
243
+
244
+ // Decrypt docs in parallel using DEK cache for efficiency
245
+ const dekCache = new Map<string | null, string | null>()
246
+
247
+ const decryptedDocs = await Promise.all(
248
+ rawDocs.map(async (row) => {
249
+ try {
250
+ // Use organization_id from the doc itself for proper encryption map lookup
251
+ // This is critical for global search where organizationId param is null
252
+ const docData = row.doc as Record<string, unknown>
253
+ const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId
254
+ const scope = { tenantId, organizationId: docOrgId }
255
+
256
+ const decryptedDoc = await decryptIndexDocForSearch(
257
+ row.entity_type,
258
+ row.doc,
259
+ scope,
260
+ encryptionService ?? null,
261
+ dekCache,
262
+ )
263
+ return { ...row, doc: decryptedDoc }
264
+ } catch (err) {
265
+ logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) })
266
+ return row // Return original doc if decryption fails
267
+ }
268
+ }),
269
+ )
270
+
271
+ // Build doc lookup map for fast access
272
+ const docMap = new Map<string, Record<string, unknown>>()
273
+ for (const row of decryptedDocs) {
274
+ docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)
275
+ }
276
+
277
+ // Compute presenters and links in parallel
278
+ const enrichmentPromises = missingResults.map(async (result) => {
279
+ const key = `${result.entityId}:${result.recordId}`
280
+ const doc = docMap.get(key)
281
+
282
+ if (!doc) {
283
+ logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId })
284
+ return { key, presenter: null, url: undefined, links: undefined }
285
+ }
286
+
287
+ const config = entityConfigMap.get(result.entityId as EntityId)
288
+ const enrichment = await computePresenterAndLinks(
289
+ doc,
290
+ result.entityId,
291
+ result.recordId,
292
+ config,
293
+ tenantId,
294
+ organizationId,
295
+ queryEngine,
296
+ )
297
+
298
+ return { key, ...enrichment }
299
+ })
300
+
301
+ const computed = await Promise.all(enrichmentPromises)
302
+
303
+ // Build enrichment map from parallel results
304
+ const enrichmentMap = new Map<string, EnrichmentResult>()
305
+ for (const { key, presenter, url, links } of computed) {
306
+ enrichmentMap.set(key, { presenter, url, links })
307
+ }
308
+
309
+ // Enrich results with computed presenter, URL, and links
310
+ return results.map((result) => {
311
+ if (!needsEnrichment(result)) return result
312
+ const key = `${result.entityId}:${result.recordId}`
313
+ const enriched = enrichmentMap.get(key)
314
+ if (!enriched) return result
315
+ return {
316
+ ...result,
317
+ presenter: enriched.presenter ?? result.presenter,
318
+ url: result.url ?? enriched.url,
319
+ links: result.links ?? enriched.links,
320
+ }
321
+ })
322
+ }
323
+ }