@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,1234 @@
1
+ import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
4
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
5
+ import {
6
+ type VectorModuleConfig,
7
+ type VectorEntityConfig,
8
+ type VectorQueryRequest,
9
+ type VectorSearchHit,
10
+ type VectorIndexSource,
11
+ type VectorDriverId,
12
+ type VectorLinkDescriptor,
13
+ type VectorResultPresenter,
14
+ type VectorIndexEntry,
15
+ } from '../types'
16
+ import type { VectorDriver } from '../types'
17
+ import { computeChecksum } from './checksum'
18
+ import { EmbeddingService } from './embedding'
19
+ import { logVectorOperation } from '../lib/vector-logs'
20
+ import { searchDebug, searchDebugWarn } from '../../lib/debug'
21
+
22
+ type ContainerResolver = () => unknown
23
+ const VECTOR_ENTRY_ENCRYPTION_ENTITY_ID = 'vector:vector_search'
24
+
25
+ const ENRICHMENT_FIELD_HINTS: Record<EntityId, string[]> = {
26
+ 'customers:customer_entity': [
27
+ 'id',
28
+ 'organization_id',
29
+ 'tenant_id',
30
+ 'display_name',
31
+ 'description',
32
+ 'status',
33
+ 'lifecycle_stage',
34
+ 'primary_email',
35
+ 'primary_phone',
36
+ 'kind',
37
+ 'customer_kind',
38
+ ],
39
+ 'customers:customer_comment': ['id', 'organization_id', 'tenant_id', 'entity_id', 'body', 'appearance_icon', 'appearance_color'],
40
+ 'customers:customer_activity': ['id', 'organization_id', 'tenant_id', 'entity_id', 'activity_type', 'subject', 'body', 'deal_id'],
41
+ 'customers:customer_deal': ['id', 'organization_id', 'tenant_id', 'title', 'pipeline_stage', 'status', 'value_amount', 'value_currency'],
42
+ 'customers:customer_todo_link': ['id', 'organization_id', 'tenant_id', 'entity_id', 'todo_id', 'todo_source'],
43
+ 'customers:customer_person_profile': [
44
+ 'id',
45
+ 'organization_id',
46
+ 'tenant_id',
47
+ 'entity_id',
48
+ 'first_name',
49
+ 'last_name',
50
+ 'preferred_name',
51
+ 'job_title',
52
+ 'department',
53
+ ],
54
+ 'customers:customer_company_profile': [
55
+ 'id',
56
+ 'organization_id',
57
+ 'tenant_id',
58
+ 'entity_id',
59
+ 'brand_name',
60
+ 'legal_name',
61
+ 'domain',
62
+ 'industry',
63
+ 'size_bucket',
64
+ ],
65
+ }
66
+
67
+ export type VectorIndexServiceOptions = {
68
+ drivers: VectorDriver[]
69
+ embeddingService: EmbeddingService
70
+ queryEngine: QueryEngine
71
+ moduleConfigs: VectorModuleConfig[]
72
+ defaultDriverId?: VectorDriverId
73
+ containerResolver?: ContainerResolver
74
+ eventBus?: {
75
+ emitEvent(event: string, payload: any, options?: any): Promise<void>
76
+ }
77
+ }
78
+
79
+ type IndexRecordArgs = {
80
+ entityId: EntityId
81
+ recordId: string
82
+ tenantId: string
83
+ organizationId?: string | null
84
+ }
85
+
86
+ type DeleteRecordArgs = {
87
+ entityId: EntityId
88
+ recordId: string
89
+ tenantId: string
90
+ organizationId?: string | null
91
+ }
92
+
93
+ export type VectorIndexOperationResult = {
94
+ action: 'indexed' | 'deleted' | 'skipped'
95
+ created?: boolean
96
+ existed?: boolean
97
+ tenantId: string
98
+ organizationId: string | null
99
+ reason?: 'unsupported' | 'missing_record' | 'checksum_match'
100
+ }
101
+
102
+ export class VectorIndexService {
103
+ private readonly driverMap = new Map<VectorDriverId, VectorDriver>()
104
+ private readonly entityConfig = new Map<EntityId, { config: VectorEntityConfig; driverId: VectorDriverId }>()
105
+ private readonly defaultDriverId: VectorDriverId
106
+
107
+ constructor(private readonly opts: VectorIndexServiceOptions) {
108
+ for (const driver of opts.drivers) {
109
+ this.driverMap.set(driver.id, driver)
110
+ }
111
+ this.defaultDriverId = opts.defaultDriverId ?? 'pgvector'
112
+ for (const moduleConfig of opts.moduleConfigs) {
113
+ const driverId = moduleConfig.defaultDriverId ?? this.defaultDriverId
114
+ for (const entity of moduleConfig.entities ?? []) {
115
+ if (!entity?.entityId) continue
116
+ if (entity.enabled === false) continue
117
+ const targetDriver = entity.driverId ?? driverId
118
+ this.entityConfig.set(entity.entityId, { config: entity, driverId: targetDriver })
119
+ }
120
+ }
121
+ }
122
+
123
+ private resolveEncryptionService(): TenantDataEncryptionService | null {
124
+ if (!this.opts.containerResolver) return null
125
+ try {
126
+ const container = this.opts.containerResolver() as any
127
+ if (!container || typeof container.resolve !== 'function') return null
128
+ return container.resolve('tenantEncryptionService') as TenantDataEncryptionService
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
134
+ private async encryptResultFields(args: {
135
+ tenantId: string
136
+ organizationId: string | null
137
+ resultTitle: string
138
+ resultSubtitle: string | null
139
+ resultIcon: string | null
140
+ resultSnapshot: string | null
141
+ primaryLinkHref: string | null
142
+ primaryLinkLabel: string | null
143
+ links: VectorLinkDescriptor[] | string | null
144
+ payload: Record<string, unknown> | string | null
145
+ }): Promise<{
146
+ resultTitle: string
147
+ resultSubtitle: string | null
148
+ resultIcon: string | null
149
+ resultSnapshot: string | null
150
+ primaryLinkHref: string | null
151
+ primaryLinkLabel: string | null
152
+ links: VectorLinkDescriptor[] | string | null
153
+ payload: Record<string, unknown> | string | null
154
+ }> {
155
+ const service = this.resolveEncryptionService()
156
+ if (!service || !service.isEnabled?.()) {
157
+ return {
158
+ resultTitle: args.resultTitle,
159
+ resultSubtitle: args.resultSubtitle,
160
+ resultIcon: args.resultIcon,
161
+ resultSnapshot: args.resultSnapshot,
162
+ primaryLinkHref: args.primaryLinkHref,
163
+ primaryLinkLabel: args.primaryLinkLabel,
164
+ links: args.links,
165
+ payload: args.payload,
166
+ }
167
+ }
168
+ try {
169
+ const encrypted = await service.encryptEntityPayload(
170
+ VECTOR_ENTRY_ENCRYPTION_ENTITY_ID,
171
+ {
172
+ resultTitle: args.resultTitle,
173
+ resultSubtitle: args.resultSubtitle,
174
+ resultIcon: args.resultIcon,
175
+ resultSnapshot: args.resultSnapshot,
176
+ primaryLinkHref: args.primaryLinkHref,
177
+ primaryLinkLabel: args.primaryLinkLabel,
178
+ links: args.links,
179
+ payload: args.payload,
180
+ },
181
+ args.tenantId,
182
+ args.organizationId,
183
+ )
184
+ return {
185
+ resultTitle: String((encrypted as any).resultTitle ?? args.resultTitle),
186
+ resultSubtitle: ((encrypted as any).resultSubtitle ?? args.resultSubtitle) as any,
187
+ resultIcon: ((encrypted as any).resultIcon ?? args.resultIcon) as any,
188
+ resultSnapshot: ((encrypted as any).resultSnapshot ?? args.resultSnapshot) as any,
189
+ primaryLinkHref: ((encrypted as any).primaryLinkHref ?? args.primaryLinkHref) as any,
190
+ primaryLinkLabel: ((encrypted as any).primaryLinkLabel ?? args.primaryLinkLabel) as any,
191
+ links: ((encrypted as any).links ?? args.links) as any,
192
+ payload: ((encrypted as any).payload ?? args.payload) as any,
193
+ }
194
+ } catch {
195
+ return {
196
+ resultTitle: args.resultTitle,
197
+ resultSubtitle: args.resultSubtitle,
198
+ resultIcon: args.resultIcon,
199
+ resultSnapshot: args.resultSnapshot,
200
+ primaryLinkHref: args.primaryLinkHref,
201
+ primaryLinkLabel: args.primaryLinkLabel,
202
+ links: args.links,
203
+ payload: args.payload,
204
+ }
205
+ }
206
+ }
207
+
208
+ private async decryptResultFields(args: {
209
+ tenantId: string
210
+ organizationId: string | null
211
+ resultTitle: string
212
+ resultSubtitle: string | null
213
+ resultIcon: string | null
214
+ resultSnapshot: string | null
215
+ primaryLinkHref: string | null
216
+ primaryLinkLabel: string | null
217
+ links: VectorLinkDescriptor[] | string | null
218
+ payload: Record<string, unknown> | string | null
219
+ }): Promise<{
220
+ resultTitle: string
221
+ resultSubtitle: string | null
222
+ resultIcon: string | null
223
+ resultSnapshot: string | null
224
+ primaryLinkHref: string | null
225
+ primaryLinkLabel: string | null
226
+ links: VectorLinkDescriptor[] | string | null
227
+ payload: Record<string, unknown> | string | null
228
+ }> {
229
+ const service = this.resolveEncryptionService()
230
+ if (!service || !service.isEnabled?.() || typeof service.decryptEntityPayload !== 'function') {
231
+ return {
232
+ resultTitle: args.resultTitle,
233
+ resultSubtitle: args.resultSubtitle,
234
+ resultIcon: args.resultIcon,
235
+ resultSnapshot: args.resultSnapshot,
236
+ primaryLinkHref: args.primaryLinkHref,
237
+ primaryLinkLabel: args.primaryLinkLabel,
238
+ links: args.links,
239
+ payload: args.payload,
240
+ }
241
+ }
242
+ try {
243
+ const decrypted = await service.decryptEntityPayload(
244
+ VECTOR_ENTRY_ENCRYPTION_ENTITY_ID,
245
+ {
246
+ resultTitle: args.resultTitle,
247
+ resultSubtitle: args.resultSubtitle,
248
+ resultIcon: args.resultIcon,
249
+ resultSnapshot: args.resultSnapshot,
250
+ primaryLinkHref: args.primaryLinkHref,
251
+ primaryLinkLabel: args.primaryLinkLabel,
252
+ links: args.links,
253
+ payload: args.payload,
254
+ },
255
+ args.tenantId,
256
+ args.organizationId,
257
+ )
258
+ return {
259
+ resultTitle: String((decrypted as any).resultTitle ?? args.resultTitle),
260
+ resultSubtitle: ((decrypted as any).resultSubtitle ?? args.resultSubtitle) as any,
261
+ resultIcon: ((decrypted as any).resultIcon ?? args.resultIcon) as any,
262
+ resultSnapshot: ((decrypted as any).resultSnapshot ?? args.resultSnapshot) as any,
263
+ primaryLinkHref: ((decrypted as any).primaryLinkHref ?? args.primaryLinkHref) as any,
264
+ primaryLinkLabel: ((decrypted as any).primaryLinkLabel ?? args.primaryLinkLabel) as any,
265
+ links: ((decrypted as any).links ?? args.links) as any,
266
+ payload: ((decrypted as any).payload ?? args.payload) as any,
267
+ }
268
+ } catch {
269
+ return {
270
+ resultTitle: args.resultTitle,
271
+ resultSubtitle: args.resultSubtitle,
272
+ resultIcon: args.resultIcon,
273
+ resultSnapshot: args.resultSnapshot,
274
+ primaryLinkHref: args.primaryLinkHref,
275
+ primaryLinkLabel: args.primaryLinkLabel,
276
+ links: args.links,
277
+ payload: args.payload,
278
+ }
279
+ }
280
+ }
281
+
282
+ listEnabledEntities(): EntityId[] {
283
+ return Array.from(this.entityConfig.keys())
284
+ }
285
+
286
+ async ensureDriverReady(entityId?: EntityId): Promise<void> {
287
+ if (entityId) {
288
+ const entry = this.entityConfig.get(entityId)
289
+ if (!entry) return
290
+ const driver = this.getDriver(entry.driverId)
291
+ await driver.ensureReady()
292
+ return
293
+ }
294
+ const uniqueDrivers = new Set<VectorDriverId>()
295
+ for (const entry of this.entityConfig.values()) {
296
+ uniqueDrivers.add(entry.driverId)
297
+ }
298
+ if (!uniqueDrivers.size) uniqueDrivers.add(this.defaultDriverId)
299
+ await Promise.all(Array.from(uniqueDrivers).map(async (driverId) => {
300
+ try {
301
+ const driver = this.getDriver(driverId)
302
+ await driver.ensureReady()
303
+ } catch (err) {
304
+ searchDebugWarn('vector', 'Failed to ensure driver readiness', { driverId, error: err instanceof Error ? err.message : err })
305
+ }
306
+ }))
307
+ }
308
+
309
+ private getDriver(driverId: VectorDriverId): VectorDriver {
310
+ const driver = this.driverMap.get(driverId)
311
+ if (!driver) {
312
+ throw new Error(`[vector] Driver ${driverId} is not registered`)
313
+ }
314
+ return driver
315
+ }
316
+
317
+ private getEnrichmentFields(entityId: EntityId): string[] | undefined {
318
+ const hints = ENRICHMENT_FIELD_HINTS[entityId]
319
+ if (!hints) return undefined
320
+ const unique = new Set<string>(['id', ...hints])
321
+ return Array.from(unique)
322
+ }
323
+
324
+ private async fetchRecord(entityId: EntityId, recordIds: string[], tenantId: string, organizationId?: string | null) {
325
+ const filters: Record<string, any> = { id: { $in: recordIds } }
326
+ const result = await this.opts.queryEngine.query(entityId, {
327
+ tenantId,
328
+ organizationId: organizationId ?? undefined,
329
+ filters,
330
+ includeCustomFields: true,
331
+ fields: this.getEnrichmentFields(entityId),
332
+ })
333
+ const byId = new Map<string, Record<string, any>>()
334
+ for (const item of result.items) {
335
+ const key = String((item as any).id ?? '')
336
+ if (!key) continue
337
+ byId.set(key, item as Record<string, any>)
338
+ }
339
+ return byId
340
+ }
341
+
342
+ private extractRecordPayload(entityId: EntityId, raw: Record<string, any>) {
343
+ const record: Record<string, any> = {}
344
+ const customFields: Record<string, any> = {}
345
+ const multiMap = new Map<string, boolean>()
346
+
347
+ for (const [key, value] of Object.entries(raw)) {
348
+ if (key.startsWith('cf:') && key.endsWith('__is_multi')) {
349
+ const base = key.replace(/__is_multi$/, '')
350
+ multiMap.set(base, Boolean(value))
351
+ continue
352
+ }
353
+ if (key.startsWith('cf:')) {
354
+ customFields[key.slice(3)] = value
355
+ continue
356
+ }
357
+ record[key] = value
358
+ }
359
+
360
+ for (const [key, isMulti] of multiMap.entries()) {
361
+ const bare = key.slice(3)
362
+ if (bare && customFields[bare] != null && !Array.isArray(customFields[bare]) && isMulti) {
363
+ customFields[bare] = [customFields[bare]]
364
+ }
365
+ }
366
+
367
+ if (record.entity_id == null && record.entityId == null && entityId.endsWith('_company_profile')) {
368
+ searchDebugWarn('vector.index', 'company profile missing entity id in payload', {
369
+ id: record.id,
370
+ keys: Object.keys(record),
371
+ })
372
+ }
373
+
374
+ return { record, customFields }
375
+ }
376
+
377
+ private async indexExisting(
378
+ entry: { config: VectorEntityConfig; driverId: VectorDriverId },
379
+ driver: VectorDriver,
380
+ args: IndexRecordArgs,
381
+ raw: Record<string, any>,
382
+ opts: { skipDelete?: boolean } = {},
383
+ ): Promise<VectorIndexOperationResult> {
384
+ const scopeOrg = args.organizationId ?? null
385
+ const { record, customFields } = this.extractRecordPayload(args.entityId, raw)
386
+ const resolvedOrgId = scopeOrg ?? (record.organization_id ?? record.organizationId ?? null)
387
+ const source = await this.resolveSource(args.entityId, entry.config, {
388
+ record,
389
+ customFields,
390
+ tenantId: args.tenantId,
391
+ organizationId: resolvedOrgId,
392
+ })
393
+ if (!source) {
394
+ const existing = await driver.getChecksum(args.entityId, args.recordId, args.tenantId)
395
+ if (!opts.skipDelete && existing) {
396
+ await driver.delete(args.entityId, args.recordId, args.tenantId)
397
+ return {
398
+ action: 'deleted',
399
+ existed: true,
400
+ tenantId: args.tenantId,
401
+ organizationId: resolvedOrgId,
402
+ }
403
+ }
404
+ return {
405
+ action: 'skipped',
406
+ existed: Boolean(existing),
407
+ tenantId: args.tenantId,
408
+ organizationId: resolvedOrgId,
409
+ reason: 'missing_record',
410
+ }
411
+ }
412
+
413
+ const checksumSource = source.checksumSource ?? { record, customFields }
414
+ const checksum = computeChecksum(checksumSource)
415
+ const current = await driver.getChecksum(args.entityId, args.recordId, args.tenantId)
416
+ if (current && current === checksum) {
417
+ return {
418
+ action: 'skipped',
419
+ existed: true,
420
+ tenantId: args.tenantId,
421
+ organizationId: scopeOrg,
422
+ reason: 'checksum_match',
423
+ }
424
+ }
425
+ if (!this.opts.embeddingService.available) {
426
+ throw new Error('[vector] Embedding service unavailable (missing OPENAI_API_KEY)')
427
+ }
428
+ const embedding = await this.opts.embeddingService.createEmbedding(source.input)
429
+ const presenter = await this.resolvePresenter(entry.config, {
430
+ record,
431
+ customFields,
432
+ tenantId: args.tenantId,
433
+ organizationId: resolvedOrgId,
434
+ }, source.presenter ?? null)
435
+ if (!presenter?.title) {
436
+ searchDebugWarn('vector.index', 'missing presenter title', {
437
+ entityId: args.entityId,
438
+ recordId: args.recordId,
439
+ recordSample: {
440
+ display_name: record.display_name,
441
+ displayName: record.displayName,
442
+ name: record.name,
443
+ title: record.title,
444
+ subject: record.subject,
445
+ kind: record.kind,
446
+ },
447
+ })
448
+ }
449
+ const links = await this.resolveLinks(entry.config, {
450
+ record,
451
+ customFields,
452
+ tenantId: args.tenantId,
453
+ organizationId: resolvedOrgId,
454
+ }, source.links ?? null)
455
+ const url = await this.resolveUrl(entry.config, {
456
+ record,
457
+ customFields,
458
+ tenantId: args.tenantId,
459
+ organizationId: resolvedOrgId,
460
+ })
461
+
462
+ const normalizedPresenter = this.ensurePresenter(presenter, record, customFields, args.recordId, args.entityId)
463
+ const normalizedLinks = Array.isArray(links) && links.length ? links : null
464
+ const snapshot = this.deriveSnapshot(record, customFields)
465
+ const rawResultTitle = this.resolveResultTitle(normalizedPresenter, record, customFields, args.recordId)
466
+ const rawResultSubtitle = normalizedPresenter.subtitle ?? snapshot ?? null
467
+ const rawResultIcon = normalizedPresenter.icon ?? this.mapDefaultIcon(args.entityId)
468
+ const resultBadge = normalizedPresenter.badge ?? null
469
+ const primaryLink = this.resolvePrimaryLink(normalizedLinks, url, rawResultTitle)
470
+
471
+ const encryptedResult = await this.encryptResultFields({
472
+ tenantId: args.tenantId,
473
+ organizationId: resolvedOrgId,
474
+ resultTitle: rawResultTitle,
475
+ resultSubtitle: rawResultSubtitle,
476
+ resultIcon: rawResultIcon ?? null,
477
+ resultSnapshot: snapshot ?? null,
478
+ primaryLinkHref: primaryLink?.href ?? null,
479
+ primaryLinkLabel: primaryLink?.label ?? null,
480
+ links: normalizedLinks,
481
+ payload: source.payload ?? null,
482
+ })
483
+
484
+ const presenterForStorage: VectorResultPresenter = {
485
+ title: encryptedResult.resultTitle,
486
+ subtitle: encryptedResult.resultSubtitle ?? undefined,
487
+ icon: encryptedResult.resultIcon ?? undefined,
488
+ badge: resultBadge ?? undefined,
489
+ }
490
+
491
+ searchDebug('VectorIndexService', 'Storing vector index entry', {
492
+ entityId: args.entityId,
493
+ recordId: args.recordId,
494
+ tenantId: args.tenantId,
495
+ organizationId: resolvedOrgId,
496
+ })
497
+ await driver.upsert({
498
+ driverId: entry.driverId,
499
+ entityId: args.entityId,
500
+ recordId: args.recordId,
501
+ tenantId: args.tenantId,
502
+ organizationId: resolvedOrgId,
503
+ checksum,
504
+ embedding,
505
+ url: url ?? null,
506
+ presenter: presenterForStorage,
507
+ links: (encryptedResult.links as any) ?? normalizedLinks,
508
+ payload: (encryptedResult.payload as any) ?? source.payload ?? null,
509
+ resultTitle: encryptedResult.resultTitle,
510
+ resultSubtitle: encryptedResult.resultSubtitle,
511
+ resultIcon: encryptedResult.resultIcon ?? null,
512
+ resultBadge,
513
+ resultSnapshot: encryptedResult.resultSnapshot ?? snapshot,
514
+ primaryLinkHref: encryptedResult.primaryLinkHref ?? primaryLink?.href ?? null,
515
+ primaryLinkLabel: encryptedResult.primaryLinkLabel ?? null,
516
+ })
517
+
518
+ return {
519
+ action: 'indexed',
520
+ created: !current,
521
+ existed: true,
522
+ tenantId: args.tenantId,
523
+ organizationId: resolvedOrgId,
524
+ }
525
+ }
526
+
527
+ private ensurePresenter(
528
+ presenter: VectorResultPresenter | null,
529
+ record: Record<string, any>,
530
+ customFields: Record<string, any>,
531
+ recordId: string,
532
+ entityId: EntityId,
533
+ ): VectorResultPresenter {
534
+ if (presenter?.title) return presenter
535
+ const fallback = this.buildFallbackPresenter(record, customFields, recordId, entityId)
536
+ return fallback
537
+ }
538
+
539
+ private resolveResultTitle(
540
+ presenter: VectorResultPresenter,
541
+ record: Record<string, any>,
542
+ customFields: Record<string, any>,
543
+ recordId: string,
544
+ ): string {
545
+ const candidate =
546
+ presenter.title ??
547
+ record.display_name ??
548
+ record.displayName ??
549
+ record.name ??
550
+ record.title ??
551
+ record.subject ??
552
+ customFields.title ??
553
+ customFields.name ??
554
+ recordId
555
+ const text = String(candidate).trim()
556
+ return text.length ? text : recordId
557
+ }
558
+
559
+ private buildFallbackPresenter(
560
+ record: Record<string, any>,
561
+ customFields: Record<string, any>,
562
+ recordId: string,
563
+ entityId: EntityId,
564
+ ): VectorResultPresenter {
565
+ const titleCandidate =
566
+ record.display_name ??
567
+ record.displayName ??
568
+ record.name ??
569
+ record.title ??
570
+ record.subject ??
571
+ recordId
572
+ const subtitleCandidate =
573
+ record.description ??
574
+ record.summary ??
575
+ record.body ??
576
+ customFields.summary ??
577
+ customFields.description ??
578
+ null
579
+ const icon = typeof record.kind === 'string'
580
+ ? this.mapEntityIcon(record.kind)
581
+ : this.mapDefaultIcon(entityId)
582
+ return {
583
+ title: String(titleCandidate),
584
+ subtitle: subtitleCandidate ? String(subtitleCandidate) : undefined,
585
+ icon: icon ?? undefined,
586
+ }
587
+ }
588
+
589
+ private mapEntityIcon(kind?: string | null): string | null {
590
+ if (!kind) return null
591
+ const normalized = kind.toLowerCase()
592
+ if (normalized === 'person') return 'user'
593
+ if (normalized === 'company' || normalized === 'organization') return 'building'
594
+ return null
595
+ }
596
+
597
+ private mapDefaultIcon(entityId: EntityId): string | null {
598
+ if (entityId.startsWith('customers:customer_deal')) return 'briefcase'
599
+ if (entityId.startsWith('customers:customer_comment')) return 'sticky-note'
600
+ if (entityId.startsWith('customers:customer_activity')) return 'bolt'
601
+ if (entityId.startsWith('customers:customer_todo')) return 'check-square'
602
+ return null
603
+ }
604
+
605
+ private resolvePrimaryLink(
606
+ links: VectorLinkDescriptor[] | null,
607
+ url: string | null,
608
+ fallbackLabel: string,
609
+ ): { href: string; label: string } | null {
610
+ if (links?.length) {
611
+ const primary = links.find((link) => link.kind === 'primary') ?? links[0]
612
+ if (primary?.href) {
613
+ return { href: primary.href, label: primary.label ?? fallbackLabel }
614
+ }
615
+ }
616
+ if (url) {
617
+ return { href: url, label: fallbackLabel }
618
+ }
619
+ return null
620
+ }
621
+
622
+ private deriveSnapshot(
623
+ record: Record<string, any>,
624
+ customFields: Record<string, any>,
625
+ ): string | null {
626
+ const candidates = [
627
+ record.summary,
628
+ record.description,
629
+ record.body,
630
+ customFields.summary,
631
+ customFields.description,
632
+ customFields.body,
633
+ ]
634
+ for (const value of candidates) {
635
+ if (typeof value === 'string') {
636
+ const trimmed = value.trim()
637
+ if (trimmed.length) return trimmed
638
+ }
639
+ }
640
+ return null
641
+ }
642
+
643
+ private buildDefaultSource(entityId: EntityId, payload: { record: Record<string, any>; customFields: Record<string, any> }): VectorIndexSource {
644
+ const { record, customFields } = payload
645
+ const lines: string[] = []
646
+
647
+ const pushEntry = (label: string, value: unknown) => {
648
+ if (value === null || value === undefined) return
649
+ if (typeof value === 'string' && value.trim().length === 0) return
650
+ if (typeof value === 'object') {
651
+ lines.push(`${label}: ${JSON.stringify(value)}`)
652
+ } else {
653
+ lines.push(`${label}: ${value}`)
654
+ }
655
+ }
656
+
657
+ const preferredFields = ['title', 'name', 'displayName', 'summary', 'subject']
658
+ for (const field of preferredFields) {
659
+ if (record[field] != null) pushEntry(field, record[field])
660
+ }
661
+
662
+ for (const [key, value] of Object.entries(record)) {
663
+ if (preferredFields.includes(key)) continue
664
+ if (key === 'id' || key === 'tenantId' || key === 'organizationId' || key === 'createdAt' || key === 'updatedAt') continue
665
+ pushEntry(key, value)
666
+ }
667
+
668
+ for (const [key, value] of Object.entries(customFields)) {
669
+ pushEntry(`custom.${key}`, value)
670
+ }
671
+
672
+ if (lines.length === 0) {
673
+ lines.push(`${entityId}#${record.id ?? ''}`)
674
+ }
675
+
676
+ return {
677
+ input: lines,
678
+ payload: null,
679
+ checksumSource: { record, customFields },
680
+ }
681
+ }
682
+
683
+ private async resolveSource(entityId: EntityId, config: VectorEntityConfig, ctx: {
684
+ record: Record<string, any>
685
+ customFields: Record<string, any>
686
+ organizationId?: string | null
687
+ tenantId: string
688
+ }): Promise<VectorIndexSource | null> {
689
+ const baseCtx = {
690
+ record: ctx.record,
691
+ customFields: ctx.customFields,
692
+ organizationId: ctx.organizationId ?? null,
693
+ tenantId: ctx.tenantId,
694
+ queryEngine: this.opts.queryEngine,
695
+ container: this.opts.containerResolver ? this.opts.containerResolver() : undefined,
696
+ }
697
+ if (config.buildSource) {
698
+ const built = await config.buildSource(baseCtx)
699
+ if (built) return built
700
+ return null
701
+ }
702
+ return this.buildDefaultSource(entityId, { record: ctx.record, customFields: ctx.customFields })
703
+ }
704
+
705
+ private async resolvePresenter(
706
+ config: VectorEntityConfig,
707
+ ctx: {
708
+ record: Record<string, any>
709
+ customFields: Record<string, any>
710
+ organizationId?: string | null
711
+ tenantId: string
712
+ },
713
+ fallback?: VectorResultPresenter | null,
714
+ ): Promise<VectorResultPresenter | null> {
715
+ const baseCtx = {
716
+ record: ctx.record,
717
+ customFields: ctx.customFields,
718
+ organizationId: ctx.organizationId ?? null,
719
+ tenantId: ctx.tenantId,
720
+ queryEngine: this.opts.queryEngine,
721
+ container: this.opts.containerResolver ? this.opts.containerResolver() : undefined,
722
+ }
723
+ if (config.formatResult) {
724
+ const formatted = await config.formatResult(baseCtx)
725
+ if (formatted) return formatted
726
+ }
727
+ if (fallback) return fallback
728
+ const nameLike = ctx.record.displayName || ctx.record.title || ctx.record.name
729
+ if (typeof nameLike === 'string' && nameLike.trim().length > 0) {
730
+ const subtitle = ctx.record.description || ctx.record.summary
731
+ return {
732
+ title: nameLike,
733
+ subtitle: typeof subtitle === 'string' ? subtitle : undefined,
734
+ }
735
+ }
736
+ return null
737
+ }
738
+
739
+ private async resolveLinks(
740
+ config: VectorEntityConfig,
741
+ ctx: {
742
+ record: Record<string, any>
743
+ customFields: Record<string, any>
744
+ organizationId?: string | null
745
+ tenantId: string
746
+ },
747
+ fallback?: VectorLinkDescriptor[] | null,
748
+ ): Promise<VectorLinkDescriptor[] | null> {
749
+ const baseCtx = {
750
+ record: ctx.record,
751
+ customFields: ctx.customFields,
752
+ organizationId: ctx.organizationId ?? null,
753
+ tenantId: ctx.tenantId,
754
+ queryEngine: this.opts.queryEngine,
755
+ container: this.opts.containerResolver ? this.opts.containerResolver() : undefined,
756
+ }
757
+ if (config.resolveLinks) {
758
+ const resolved = await config.resolveLinks(baseCtx)
759
+ if (resolved?.length) return resolved
760
+ }
761
+ return fallback ?? null
762
+ }
763
+
764
+ private async resolveUrl(
765
+ config: VectorEntityConfig,
766
+ ctx: {
767
+ record: Record<string, any>
768
+ customFields: Record<string, any>
769
+ organizationId?: string | null
770
+ tenantId: string
771
+ },
772
+ fallback?: string | null,
773
+ ): Promise<string | null> {
774
+ if (config.resolveUrl) {
775
+ const candidate = await config.resolveUrl({
776
+ record: ctx.record,
777
+ customFields: ctx.customFields,
778
+ organizationId: ctx.organizationId ?? null,
779
+ tenantId: ctx.tenantId,
780
+ queryEngine: this.opts.queryEngine,
781
+ container: this.opts.containerResolver ? this.opts.containerResolver() : undefined,
782
+ })
783
+ if (candidate) return candidate
784
+ }
785
+ return fallback ?? null
786
+ }
787
+
788
+ async indexRecord(args: IndexRecordArgs): Promise<VectorIndexOperationResult> {
789
+ const entry = this.entityConfig.get(args.entityId)
790
+ if (!entry) {
791
+ return {
792
+ action: 'skipped',
793
+ existed: false,
794
+ tenantId: args.tenantId,
795
+ organizationId: args.organizationId ?? null,
796
+ reason: 'unsupported',
797
+ }
798
+ }
799
+ const driver = this.getDriver(entry.driverId)
800
+ await driver.ensureReady()
801
+
802
+ const records = await this.fetchRecord(args.entityId, [args.recordId], args.tenantId, args.organizationId)
803
+ const raw = records.get(args.recordId)
804
+ if (!raw) {
805
+ const existing = await driver.getChecksum(args.entityId, args.recordId, args.tenantId)
806
+ if (existing) {
807
+ await driver.delete(args.entityId, args.recordId, args.tenantId)
808
+ return {
809
+ action: 'deleted',
810
+ existed: true,
811
+ tenantId: args.tenantId,
812
+ organizationId: args.organizationId ?? null,
813
+ }
814
+ }
815
+ return {
816
+ action: 'skipped',
817
+ existed: false,
818
+ tenantId: args.tenantId,
819
+ organizationId: args.organizationId ?? null,
820
+ reason: 'missing_record',
821
+ }
822
+ }
823
+ return this.indexExisting(entry, driver, args, raw as Record<string, any>)
824
+ }
825
+
826
+ async deleteRecord(args: DeleteRecordArgs): Promise<VectorIndexOperationResult> {
827
+ const entry = this.entityConfig.get(args.entityId)
828
+ if (!entry) {
829
+ return {
830
+ action: 'skipped',
831
+ existed: false,
832
+ tenantId: args.tenantId,
833
+ organizationId: args.organizationId ?? null,
834
+ reason: 'unsupported',
835
+ }
836
+ }
837
+ const driver = this.getDriver(entry.driverId)
838
+ await driver.ensureReady()
839
+ const scopeOrg = args.organizationId ?? null
840
+ const existing = await driver.getChecksum(args.entityId, args.recordId, args.tenantId)
841
+ if (existing) {
842
+ await driver.delete(args.entityId, args.recordId, args.tenantId)
843
+ return {
844
+ action: 'deleted',
845
+ existed: true,
846
+ tenantId: args.tenantId,
847
+ organizationId: scopeOrg,
848
+ }
849
+ }
850
+ return {
851
+ action: 'skipped',
852
+ existed: false,
853
+ tenantId: args.tenantId,
854
+ organizationId: scopeOrg,
855
+ reason: 'missing_record',
856
+ }
857
+ }
858
+
859
+ async reindexEntity(args: { entityId: EntityId; tenantId?: string | null; organizationId?: string | null; purgeFirst?: boolean }): Promise<void> {
860
+ const entry = this.entityConfig.get(args.entityId)
861
+ if (!entry) return
862
+ const driver = this.getDriver(entry.driverId)
863
+ await driver.ensureReady()
864
+
865
+ const shouldPurge = args.purgeFirst === true
866
+ const reindexStartedAt = new Date()
867
+
868
+ if (this.opts.eventBus) {
869
+ if (shouldPurge && driver.purge && args.tenantId) {
870
+ await driver.purge(args.entityId, args.tenantId)
871
+ } else if (shouldPurge && !args.tenantId) {
872
+ searchDebugWarn('vector', 'Skipping purge for multi-tenant reindex (tenant not provided)')
873
+ }
874
+ const payload: Record<string, unknown> = {
875
+ entityType: args.entityId,
876
+ }
877
+ if (shouldPurge) {
878
+ payload.force = true
879
+ payload.resetCoverage = true
880
+ }
881
+ if (args.tenantId !== undefined) payload.tenantId = args.tenantId
882
+ if (args.organizationId !== undefined) payload.organizationId = args.organizationId
883
+ await this.opts.eventBus.emitEvent('query_index.reindex', payload)
884
+ return
885
+ }
886
+
887
+ if (!args.tenantId) {
888
+ throw new Error('[vector] Reindex without tenantId requires event bus integration')
889
+ }
890
+
891
+ if (shouldPurge && driver.purge) {
892
+ await driver.purge(args.entityId, args.tenantId)
893
+ }
894
+
895
+ const pageSize = 50
896
+ let page = 1
897
+ const loggingEm = this.resolveEntityManager()
898
+ for (;;) {
899
+ const result = await this.opts.queryEngine.query(args.entityId, {
900
+ tenantId: args.tenantId,
901
+ organizationId: args.organizationId ?? undefined,
902
+ page: { page, pageSize },
903
+ includeCustomFields: true,
904
+ fields: this.getEnrichmentFields(args.entityId),
905
+ })
906
+ if (!result.items.length) break
907
+ for (const raw of result.items) {
908
+ const recordId = String((raw as any).id ?? '')
909
+ if (!recordId) continue
910
+ const opResult = await this.indexExisting(
911
+ entry,
912
+ driver,
913
+ {
914
+ entityId: args.entityId,
915
+ recordId,
916
+ tenantId: args.tenantId,
917
+ organizationId: args.organizationId ?? null,
918
+ },
919
+ raw as Record<string, any>,
920
+ { skipDelete: true },
921
+ )
922
+ await logVectorOperation({
923
+ em: loggingEm,
924
+ handler: 'service:vector.reindex',
925
+ entityType: args.entityId,
926
+ recordId,
927
+ result: opResult,
928
+ })
929
+ }
930
+ if (result.items.length < pageSize) break
931
+ page += 1
932
+ }
933
+
934
+ if (shouldPurge) {
935
+ await this.removeOrphans({
936
+ entityId: args.entityId,
937
+ tenantId: args.tenantId,
938
+ organizationId: args.organizationId,
939
+ olderThan: reindexStartedAt,
940
+ })
941
+ }
942
+ }
943
+
944
+ async reindexAll(args: { tenantId?: string | null; organizationId?: string | null; purgeFirst?: boolean }): Promise<void> {
945
+ for (const entityId of this.listEnabledEntities()) {
946
+ await this.reindexEntity({ entityId, tenantId: args.tenantId, organizationId: args.organizationId ?? null, purgeFirst: args.purgeFirst })
947
+ }
948
+ }
949
+
950
+ private resolveEntityManager(): EntityManager | null {
951
+ if (!this.opts.containerResolver) return null
952
+ try {
953
+ const container = this.opts.containerResolver()
954
+ if (!container || typeof (container as any).resolve !== 'function') return null
955
+ const resolver = container as { resolve(name: string): unknown }
956
+ const em = resolver.resolve('em')
957
+ return (em as EntityManager | null) ?? null
958
+ } catch {
959
+ return null
960
+ }
961
+ }
962
+
963
+ async purgeIndex(args: { tenantId: string; organizationId?: string | null; entityId?: EntityId | null }): Promise<void> {
964
+ const targets = args.entityId ? [args.entityId] : this.listEnabledEntities()
965
+ if (!targets.length) return
966
+
967
+ const grouped = new Map<VectorDriverId, EntityId[]>()
968
+ for (const entityId of targets) {
969
+ const cfg = this.entityConfig.get(entityId)
970
+ if (!cfg) continue
971
+ const driver = this.getDriver(cfg.driverId)
972
+ if (typeof driver.purge !== 'function') {
973
+ throw new Error(`[vector] Driver ${cfg.driverId} does not support purging entities`)
974
+ }
975
+ if (!grouped.has(cfg.driverId)) grouped.set(cfg.driverId, [])
976
+ grouped.get(cfg.driverId)!.push(entityId)
977
+ }
978
+
979
+ for (const [driverId, entityIds] of grouped.entries()) {
980
+ const driver = this.getDriver(driverId)
981
+ await driver.ensureReady()
982
+ for (const entityId of entityIds) {
983
+ await driver.purge!(entityId, args.tenantId)
984
+ }
985
+ }
986
+ }
987
+
988
+ async removeOrphans(args: {
989
+ entityId: EntityId
990
+ tenantId?: string | null
991
+ organizationId?: string | null
992
+ olderThan: Date
993
+ }): Promise<number> {
994
+ const entry = this.entityConfig.get(args.entityId)
995
+ if (!entry) return 0
996
+ const driver = this.getDriver(entry.driverId)
997
+ await driver.ensureReady()
998
+
999
+ const driverAny = driver as VectorDriver & {
1000
+ removeOrphans?: (params: {
1001
+ entityId: EntityId
1002
+ tenantId?: string | null
1003
+ organizationId?: string | null
1004
+ olderThan: Date
1005
+ }) => Promise<number | void>
1006
+ }
1007
+
1008
+ if (typeof driverAny.removeOrphans === 'function') {
1009
+ const deleted = await driverAny.removeOrphans({
1010
+ entityId: args.entityId,
1011
+ tenantId: args.tenantId,
1012
+ organizationId: args.organizationId,
1013
+ olderThan: args.olderThan,
1014
+ })
1015
+ return typeof deleted === 'number' ? deleted : 0
1016
+ }
1017
+
1018
+ searchDebugWarn('vector', 'Driver does not support orphan cleanup', { driverId: entry.driverId })
1019
+ return 0
1020
+ }
1021
+
1022
+ async countIndexEntries(args: {
1023
+ tenantId: string
1024
+ organizationId?: string | null
1025
+ entityId?: EntityId
1026
+ driverId?: VectorDriverId
1027
+ }): Promise<number> {
1028
+ if (!args.tenantId) return 0
1029
+ const targetEntity = args.entityId ? this.entityConfig.get(args.entityId) : null
1030
+ if (args.entityId && !targetEntity) {
1031
+ return 0
1032
+ }
1033
+ const driverId =
1034
+ args.driverId ??
1035
+ (targetEntity ? targetEntity.driverId : this.defaultDriverId)
1036
+ const driver = this.getDriver(driverId)
1037
+ await driver.ensureReady()
1038
+ const countParams = {
1039
+ tenantId: args.tenantId,
1040
+ organizationId: args.organizationId,
1041
+ entityId: args.entityId,
1042
+ }
1043
+ if (typeof driver.count === 'function') {
1044
+ try {
1045
+ return await driver.count(countParams)
1046
+ } catch (err) {
1047
+ searchDebugWarn('vector', 'Driver count failed, falling back to list', {
1048
+ driverId,
1049
+ error: err instanceof Error ? err.message : err,
1050
+ })
1051
+ }
1052
+ }
1053
+ if (typeof driver.list === 'function') {
1054
+ const limit = 1000
1055
+ let offset = 0
1056
+ let total = 0
1057
+ for (;;) {
1058
+ const batch = await driver.list({
1059
+ tenantId: countParams.tenantId,
1060
+ organizationId: countParams.organizationId,
1061
+ entityId: countParams.entityId,
1062
+ limit,
1063
+ offset,
1064
+ orderBy: 'created',
1065
+ })
1066
+ const size = batch.length
1067
+ total += size
1068
+ if (size < limit) break
1069
+ offset += limit
1070
+ }
1071
+ return total
1072
+ }
1073
+ searchDebugWarn('vector', 'Driver does not support counting or listing index entries', { driverId })
1074
+ return 0
1075
+ }
1076
+
1077
+ async listIndexEntries(args: {
1078
+ tenantId: string
1079
+ organizationId?: string | null
1080
+ entityId?: EntityId
1081
+ limit?: number
1082
+ offset?: number
1083
+ driverId?: VectorDriverId
1084
+ }): Promise<VectorIndexEntry[]> {
1085
+ const targetEntity = args.entityId ? this.entityConfig.get(args.entityId) : null
1086
+ if (args.entityId && !targetEntity) {
1087
+ return []
1088
+ }
1089
+ const driverId =
1090
+ args.driverId ??
1091
+ (targetEntity ? targetEntity.driverId : this.defaultDriverId)
1092
+ const driver = this.getDriver(driverId)
1093
+ if (typeof driver.list !== 'function') {
1094
+ throw new Error(`[vector] Driver ${driverId} does not support listing index entries`)
1095
+ }
1096
+ await driver.ensureReady()
1097
+ const list = await driver.list({
1098
+ tenantId: args.tenantId,
1099
+ organizationId: args.organizationId,
1100
+ entityId: args.entityId,
1101
+ limit: args.limit,
1102
+ offset: args.offset,
1103
+ orderBy: 'updated',
1104
+ })
1105
+ if (!list.length) {
1106
+ return []
1107
+ }
1108
+
1109
+ const decrypted = await Promise.all(
1110
+ list.map(async (entry) => {
1111
+ const decryptedResult = await this.decryptResultFields({
1112
+ tenantId: args.tenantId,
1113
+ organizationId: entry.organizationId ?? null,
1114
+ resultTitle: entry.resultTitle,
1115
+ resultSubtitle: entry.resultSubtitle ?? null,
1116
+ resultIcon: entry.resultIcon ?? null,
1117
+ resultSnapshot: entry.resultSnapshot ?? null,
1118
+ primaryLinkHref: entry.primaryLinkHref ?? null,
1119
+ primaryLinkLabel: entry.primaryLinkLabel ?? null,
1120
+ links: (entry.links as any) ?? null,
1121
+ payload: (entry.payload as any) ?? null,
1122
+ })
1123
+ return {
1124
+ ...entry,
1125
+ resultTitle: decryptedResult.resultTitle,
1126
+ resultSubtitle: decryptedResult.resultSubtitle,
1127
+ resultIcon: decryptedResult.resultIcon,
1128
+ resultSnapshot: decryptedResult.resultSnapshot,
1129
+ primaryLinkHref: decryptedResult.primaryLinkHref,
1130
+ primaryLinkLabel: decryptedResult.primaryLinkLabel,
1131
+ links: decryptedResult.links as any,
1132
+ payload: decryptedResult.payload as any,
1133
+ metadata: (decryptedResult.payload as any) ?? null,
1134
+ }
1135
+ }),
1136
+ )
1137
+
1138
+ return decrypted.map((entry) => {
1139
+ const presenter = {
1140
+ title: entry.resultTitle,
1141
+ subtitle: entry.resultSubtitle ?? undefined,
1142
+ icon: entry.resultIcon ?? undefined,
1143
+ badge: entry.presenter?.badge ?? entry.resultBadge ?? undefined,
1144
+ }
1145
+ const links = entry.links ?? (entry.primaryLinkHref
1146
+ ? [{ href: entry.primaryLinkHref, label: entry.primaryLinkLabel ?? entry.resultTitle, kind: 'primary' as const }]
1147
+ : null)
1148
+ const url = entry.url ?? entry.primaryLinkHref ?? null
1149
+ const metadata = entry.metadata ?? (entry.resultSnapshot ? { snapshot: entry.resultSnapshot } : null)
1150
+ return {
1151
+ ...entry,
1152
+ driverId,
1153
+ presenter,
1154
+ links,
1155
+ url,
1156
+ metadata,
1157
+ score: entry.score ?? null,
1158
+ }
1159
+ })
1160
+ }
1161
+
1162
+ async search(request: VectorQueryRequest): Promise<VectorSearchHit[]> {
1163
+ const driverId = request.driverId ?? this.defaultDriverId
1164
+ const driver = this.getDriver(driverId)
1165
+ await driver.ensureReady()
1166
+ if (!this.opts.embeddingService.available) {
1167
+ throw new Error('[vector] Embedding service unavailable (missing OPENAI_API_KEY)')
1168
+ }
1169
+ const embedding = await this.opts.embeddingService.createEmbedding(request.query)
1170
+ const hits = await driver.query({
1171
+ vector: embedding,
1172
+ limit: request.limit ?? 10,
1173
+ filter: {
1174
+ tenantId: request.tenantId,
1175
+ organizationId: request.organizationId ?? null,
1176
+ entityIds: undefined,
1177
+ },
1178
+ })
1179
+
1180
+ if (!hits.length) return []
1181
+
1182
+ const decrypted = await Promise.all(
1183
+ hits.map(async (hit) => {
1184
+ const decryptedResult = await this.decryptResultFields({
1185
+ tenantId: request.tenantId,
1186
+ organizationId: hit.organizationId ?? request.organizationId ?? null,
1187
+ resultTitle: hit.resultTitle,
1188
+ resultSubtitle: hit.resultSubtitle ?? null,
1189
+ resultIcon: hit.resultIcon ?? null,
1190
+ resultSnapshot: hit.resultSnapshot ?? null,
1191
+ primaryLinkHref: hit.primaryLinkHref ?? null,
1192
+ primaryLinkLabel: hit.primaryLinkLabel ?? null,
1193
+ links: (hit.links as any) ?? null,
1194
+ payload: (hit.payload as any) ?? null,
1195
+ })
1196
+ return {
1197
+ ...hit,
1198
+ resultTitle: decryptedResult.resultTitle,
1199
+ resultSubtitle: decryptedResult.resultSubtitle,
1200
+ resultIcon: decryptedResult.resultIcon,
1201
+ resultSnapshot: decryptedResult.resultSnapshot,
1202
+ primaryLinkHref: decryptedResult.primaryLinkHref,
1203
+ primaryLinkLabel: decryptedResult.primaryLinkLabel,
1204
+ links: decryptedResult.links as any,
1205
+ payload: decryptedResult.payload as any,
1206
+ }
1207
+ }),
1208
+ )
1209
+
1210
+ return decrypted.map((hit) => {
1211
+ const presenter = {
1212
+ title: hit.resultTitle,
1213
+ subtitle: hit.resultSubtitle ?? undefined,
1214
+ icon: hit.resultIcon ?? undefined,
1215
+ badge: hit.presenter?.badge ?? hit.resultBadge ?? undefined,
1216
+ }
1217
+ const links = hit.links ?? (hit.primaryLinkHref
1218
+ ? [{ href: hit.primaryLinkHref, label: hit.primaryLinkLabel ?? hit.resultTitle, kind: 'primary' as const }]
1219
+ : null)
1220
+ const url = hit.url ?? hit.primaryLinkHref ?? null
1221
+ const metadata = hit.payload ?? (hit.resultSnapshot ? { snapshot: hit.resultSnapshot } : null)
1222
+ return {
1223
+ entityId: hit.entityId,
1224
+ recordId: hit.recordId,
1225
+ score: hit.score,
1226
+ url,
1227
+ presenter,
1228
+ links,
1229
+ driverId,
1230
+ metadata,
1231
+ }
1232
+ })
1233
+ }
1234
+ }