@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,318 @@
1
+ import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
+ import { FULLTEXT_INDEXING_QUEUE_NAME, type FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'
3
+ import type { FullTextSearchStrategy } from '../../../strategies/fulltext.strategy'
4
+ import type { SearchIndexer } from '../../../indexer/search-indexer'
5
+ import type { EntityManager } from '@mikro-orm/postgresql'
6
+ import type { Knex } from 'knex'
7
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
8
+ import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
9
+ import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
10
+ import { searchDebug, searchDebugWarn, searchError } from '../../../lib/debug'
11
+ import { updateReindexProgress } from '../lib/reindex-lock'
12
+
13
+ // Worker metadata for auto-discovery
14
+ const DEFAULT_CONCURRENCY = 2
15
+ const envConcurrency = process.env.WORKERS_FULLTEXT_INDEXING_CONCURRENCY
16
+
17
+ export const metadata: WorkerMeta = {
18
+ queue: FULLTEXT_INDEXING_QUEUE_NAME,
19
+ concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,
20
+ }
21
+
22
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
23
+
24
+ /**
25
+ * Process a fulltext indexing job.
26
+ *
27
+ * This handler processes single record indexing, batch indexing, deletion, and purge
28
+ * operations for the fulltext search strategy.
29
+ *
30
+ * All indexing operations (single and batch) use searchIndexer.indexRecordById() to load
31
+ * fresh data, ensuring consistency with the vector worker pattern.
32
+ *
33
+ * @param job - The queued job containing payload
34
+ * @param jobCtx - Queue job context with job ID and attempt info
35
+ * @param ctx - DI container context for resolving services
36
+ */
37
+ export async function handleFulltextIndexJob(
38
+ job: QueuedJob<FulltextIndexJobPayload>,
39
+ jobCtx: JobContext,
40
+ ctx: HandlerContext,
41
+ ): Promise<void> {
42
+ const { jobType, tenantId } = job.payload
43
+
44
+ if (!tenantId) {
45
+ searchDebugWarn('fulltext-index.worker', 'Skipping job with missing tenantId', {
46
+ jobId: jobCtx.jobId,
47
+ jobType,
48
+ })
49
+ return
50
+ }
51
+
52
+ // Resolve EntityManager for logging and knex for database queries
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ let em: any | null = null
55
+ let knex: Knex | null = null
56
+ try {
57
+ em = ctx.resolve('em') as EntityManager
58
+ knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
59
+ } catch {
60
+ em = null
61
+ knex = null
62
+ }
63
+
64
+ // Resolve searchIndexer for loading fresh data
65
+ let searchIndexer: SearchIndexer | undefined
66
+ try {
67
+ searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')
68
+ } catch {
69
+ searchDebugWarn('fulltext-index.worker', 'searchIndexer not available')
70
+ }
71
+
72
+ // Resolve fulltext strategy
73
+ let fulltextStrategy: FullTextSearchStrategy | undefined
74
+ try {
75
+ const searchStrategies = ctx.resolve<unknown[]>('searchStrategies')
76
+ fulltextStrategy = searchStrategies?.find(
77
+ (s: unknown) => (s as { id?: string })?.id === 'fulltext',
78
+ ) as FullTextSearchStrategy | undefined
79
+ } catch {
80
+ searchDebugWarn('fulltext-index.worker', 'searchStrategies not available')
81
+ return
82
+ }
83
+
84
+ if (!fulltextStrategy) {
85
+ searchDebugWarn('fulltext-index.worker', 'Fulltext strategy not configured')
86
+ return
87
+ }
88
+
89
+ // Check if fulltext is available
90
+ const isAvailable = await fulltextStrategy.isAvailable()
91
+ if (!isAvailable) {
92
+ throw new Error('Fulltext search is not available') // Will trigger retry
93
+ }
94
+
95
+ try {
96
+ // ========== SINGLE INDEX: Use searchIndexer.indexRecordById() for fresh data ==========
97
+ if (jobType === 'index') {
98
+ const { entityType, recordId, organizationId } = job.payload as {
99
+ entityType: string
100
+ recordId: string
101
+ organizationId?: string | null
102
+ }
103
+
104
+ if (!entityType || !recordId) {
105
+ searchDebugWarn('fulltext-index.worker', 'Skipping index with missing fields', {
106
+ jobId: jobCtx.jobId,
107
+ entityType,
108
+ recordId,
109
+ })
110
+ return
111
+ }
112
+
113
+ if (!searchIndexer) {
114
+ throw new Error('searchIndexer not available for single-record index')
115
+ }
116
+
117
+ const result = await searchIndexer.indexRecordById({
118
+ entityId: entityType as EntityId,
119
+ recordId,
120
+ tenantId,
121
+ organizationId,
122
+ })
123
+
124
+ searchDebug('fulltext-index.worker', 'Indexed single record to fulltext', {
125
+ jobId: jobCtx.jobId,
126
+ tenantId,
127
+ entityType,
128
+ recordId,
129
+ action: result.action,
130
+ })
131
+
132
+ await recordIndexerLog(
133
+ { em: em ?? undefined },
134
+ {
135
+ source: 'fulltext',
136
+ handler: 'worker:fulltext:index',
137
+ message: `Indexed record to fulltext (${result.action})`,
138
+ entityType,
139
+ recordId,
140
+ tenantId,
141
+ details: { jobId: jobCtx.jobId },
142
+ },
143
+ )
144
+ return
145
+ }
146
+
147
+ // ========== BATCH-INDEX: Use searchIndexer.indexRecordById() for fresh data ==========
148
+ if (jobType === 'batch-index') {
149
+ const { records, organizationId } = job.payload
150
+ if (!records || records.length === 0) {
151
+ searchDebugWarn('fulltext-index.worker', 'Skipping batch-index with no records', {
152
+ jobId: jobCtx.jobId,
153
+ })
154
+ return
155
+ }
156
+
157
+ if (!searchIndexer) {
158
+ throw new Error('searchIndexer not available for batch indexing')
159
+ }
160
+
161
+ // Process each record using indexRecordById (same pattern as vector worker)
162
+ let successCount = 0
163
+ let failCount = 0
164
+
165
+ for (const { entityId, recordId } of records) {
166
+ try {
167
+ const result = await searchIndexer.indexRecordById({
168
+ entityId: entityId as EntityId,
169
+ recordId,
170
+ tenantId,
171
+ organizationId,
172
+ })
173
+ if (result.action === 'indexed') {
174
+ successCount++
175
+ }
176
+ } catch (error) {
177
+ failCount++
178
+ searchDebugWarn('fulltext-index.worker', 'Failed to index record in batch', {
179
+ entityId,
180
+ recordId,
181
+ error: error instanceof Error ? error.message : error,
182
+ })
183
+ }
184
+ }
185
+
186
+ // Update heartbeat to signal worker is still processing
187
+ if (knex && successCount > 0) {
188
+ await updateReindexProgress(knex, tenantId, 'fulltext', successCount, organizationId ?? null)
189
+ }
190
+
191
+ searchDebug('fulltext-index.worker', 'Batch indexed to fulltext', {
192
+ jobId: jobCtx.jobId,
193
+ tenantId,
194
+ requestedCount: records.length,
195
+ successCount,
196
+ failCount,
197
+ })
198
+
199
+ await recordIndexerLog(
200
+ { em: em ?? undefined },
201
+ {
202
+ source: 'fulltext',
203
+ handler: 'worker:fulltext:batch-index',
204
+ message: `Indexed ${successCount}/${records.length} records to fulltext`,
205
+ tenantId,
206
+ details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, failCount },
207
+ },
208
+ )
209
+ return
210
+ }
211
+
212
+ // ========== DELETE ==========
213
+ if (jobType === 'delete') {
214
+ const { entityId, recordId } = job.payload
215
+ if (!entityId || !recordId) {
216
+ searchDebugWarn('fulltext-index.worker', 'Skipping delete with missing fields', {
217
+ jobId: jobCtx.jobId,
218
+ entityId,
219
+ recordId,
220
+ })
221
+ return
222
+ }
223
+
224
+ await fulltextStrategy.delete(entityId, recordId, tenantId)
225
+
226
+ searchDebug('fulltext-index.worker', 'Deleted from fulltext', {
227
+ jobId: jobCtx.jobId,
228
+ tenantId,
229
+ entityId,
230
+ recordId,
231
+ })
232
+
233
+ await recordIndexerLog(
234
+ { em: em ?? undefined },
235
+ {
236
+ source: 'fulltext',
237
+ handler: 'worker:fulltext:delete',
238
+ message: `Deleted record from fulltext`,
239
+ entityType: entityId,
240
+ recordId,
241
+ tenantId,
242
+ details: { jobId: jobCtx.jobId },
243
+ },
244
+ )
245
+ return
246
+ }
247
+
248
+ // ========== PURGE ==========
249
+ if (jobType === 'purge') {
250
+ const { entityId } = job.payload
251
+ if (!entityId) {
252
+ searchDebugWarn('fulltext-index.worker', 'Skipping purge with missing entityId', {
253
+ jobId: jobCtx.jobId,
254
+ })
255
+ return
256
+ }
257
+
258
+ await fulltextStrategy.purge(entityId, tenantId)
259
+
260
+ searchDebug('fulltext-index.worker', 'Purged entity from fulltext', {
261
+ jobId: jobCtx.jobId,
262
+ tenantId,
263
+ entityId,
264
+ })
265
+
266
+ await recordIndexerLog(
267
+ { em: em ?? undefined },
268
+ {
269
+ source: 'fulltext',
270
+ handler: 'worker:fulltext:purge',
271
+ message: `Purged entity from fulltext`,
272
+ entityType: entityId,
273
+ tenantId,
274
+ details: { jobId: jobCtx.jobId },
275
+ },
276
+ )
277
+ return
278
+ }
279
+ } catch (error) {
280
+ searchError('fulltext-index.worker', `Failed to ${jobType}`, {
281
+ jobId: jobCtx.jobId,
282
+ tenantId,
283
+ error: error instanceof Error ? error.message : error,
284
+ attemptNumber: jobCtx.attemptNumber,
285
+ })
286
+
287
+ const entityId = 'entityId' in job.payload ? job.payload.entityId :
288
+ 'entityType' in job.payload ? (job.payload as { entityType?: string }).entityType : undefined
289
+ const recordId = 'recordId' in job.payload ? job.payload.recordId : undefined
290
+
291
+ await recordIndexerError(
292
+ { em: em ?? undefined },
293
+ {
294
+ source: 'fulltext',
295
+ handler: `worker:fulltext:${jobType}`,
296
+ error,
297
+ entityType: entityId,
298
+ recordId,
299
+ tenantId,
300
+ payload: job.payload,
301
+ },
302
+ )
303
+
304
+ // Re-throw to let the queue handle retry logic
305
+ throw error
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Default export for worker auto-discovery.
311
+ * Wraps handleFulltextIndexJob to match the expected handler signature.
312
+ */
313
+ export default async function handle(
314
+ job: QueuedJob<FulltextIndexJobPayload>,
315
+ ctx: JobContext & HandlerContext
316
+ ): Promise<void> {
317
+ return handleFulltextIndexJob(job, ctx, ctx)
318
+ }
@@ -0,0 +1,292 @@
1
+ import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
+ import { VECTOR_INDEXING_QUEUE_NAME, type VectorIndexJobPayload } from '../../../queue/vector-indexing'
3
+ import type { SearchIndexer } from '../../../indexer/search-indexer'
4
+ import type { EmbeddingService } from '../../../vector'
5
+ import type { EntityManager } from '@mikro-orm/postgresql'
6
+ import type { Knex } from 'knex'
7
+ import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
8
+ import { applyCoverageAdjustments, createCoverageAdjustments } from '@open-mercato/core/modules/query_index/lib/coverage'
9
+ import { logVectorOperation } from '../../../vector/lib/vector-logs'
10
+ import { resolveAutoIndexingEnabled } from '../lib/auto-indexing'
11
+ import { resolveEmbeddingConfig } from '../lib/embedding-config'
12
+ import { searchDebugWarn } from '../../../lib/debug'
13
+ import { updateReindexProgress } from '../lib/reindex-lock'
14
+
15
+ // Worker metadata for auto-discovery
16
+ const DEFAULT_CONCURRENCY = 2
17
+ const envConcurrency = process.env.WORKERS_VECTOR_INDEXING_CONCURRENCY
18
+
19
+ export const metadata: WorkerMeta = {
20
+ queue: VECTOR_INDEXING_QUEUE_NAME,
21
+ concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,
22
+ }
23
+
24
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
25
+
26
+ /**
27
+ * Process a vector index job.
28
+ *
29
+ * This handler is called by the queue worker to process indexing and deletion jobs.
30
+ * It uses SearchIndexer to load records and index them via SearchService.
31
+ *
32
+ * @param job - The queued job containing payload
33
+ * @param jobCtx - Queue job context with job ID and attempt info
34
+ * @param ctx - DI container context for resolving services
35
+ */
36
+ export async function handleVectorIndexJob(
37
+ job: QueuedJob<VectorIndexJobPayload>,
38
+ jobCtx: JobContext,
39
+ ctx: HandlerContext,
40
+ ): Promise<void> {
41
+ const { jobType, entityType, recordId, tenantId, organizationId, records } = job.payload
42
+
43
+ // Handle batch-index jobs (from reindex operations)
44
+ if (jobType === 'batch-index') {
45
+ if (!records?.length || !tenantId) {
46
+ searchDebugWarn('vector-index.worker', 'Skipping batch-index job with missing required fields', {
47
+ jobId: jobCtx.jobId,
48
+ recordCount: records?.length ?? 0,
49
+ tenantId,
50
+ })
51
+ return
52
+ }
53
+
54
+ let searchIndexer: SearchIndexer
55
+ try {
56
+ searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')
57
+ } catch {
58
+ searchDebugWarn('vector-index.worker', 'searchIndexer not available')
59
+ return
60
+ }
61
+
62
+ // Get knex for heartbeat updates
63
+ let knex: Knex | null = null
64
+ try {
65
+ const em = ctx.resolve('em') as EntityManager
66
+ knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
67
+ } catch {
68
+ knex = null
69
+ }
70
+
71
+ // Load saved embedding config to use the correct provider/model
72
+ try {
73
+ const embeddingConfig = await resolveEmbeddingConfig(ctx, { defaultValue: null })
74
+ if (embeddingConfig) {
75
+ const embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')
76
+ embeddingService.updateConfig(embeddingConfig)
77
+ }
78
+ } catch (configErr) {
79
+ searchDebugWarn('vector-index.worker', 'Failed to load embedding config for batch, using defaults', {
80
+ error: configErr instanceof Error ? configErr.message : configErr,
81
+ })
82
+ }
83
+
84
+ // Process each record in the batch
85
+ let successCount = 0
86
+ let failCount = 0
87
+ for (const { entityId, recordId: recId } of records) {
88
+ try {
89
+ const result = await searchIndexer.indexRecordById({
90
+ entityId: entityId as Parameters<typeof searchIndexer.indexRecordById>[0]['entityId'],
91
+ recordId: recId,
92
+ tenantId,
93
+ organizationId,
94
+ })
95
+ if (result.action === 'indexed') {
96
+ successCount++
97
+ }
98
+ } catch (error) {
99
+ failCount++
100
+ searchDebugWarn('vector-index.worker', 'Failed to index record in batch', {
101
+ entityId,
102
+ recordId: recId,
103
+ error: error instanceof Error ? error.message : error,
104
+ })
105
+ }
106
+ }
107
+
108
+ // Update heartbeat to signal worker is still processing
109
+ if (knex && successCount > 0) {
110
+ await updateReindexProgress(knex, tenantId, 'vector', successCount, organizationId ?? null)
111
+ }
112
+
113
+ searchDebugWarn('vector-index.worker', 'Batch-index job completed', {
114
+ jobId: jobCtx.jobId,
115
+ totalRecords: records.length,
116
+ successCount,
117
+ failCount,
118
+ })
119
+ return
120
+ }
121
+
122
+ // Handle single record jobs (index/delete)
123
+ if (!entityType || !recordId || !tenantId) {
124
+ searchDebugWarn('vector-index.worker', 'Skipping job with missing required fields', {
125
+ jobId: jobCtx.jobId,
126
+ entityType,
127
+ recordId,
128
+ tenantId,
129
+ })
130
+ return
131
+ }
132
+
133
+ const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })
134
+ if (!autoIndexingEnabled) {
135
+ return
136
+ }
137
+
138
+ let searchIndexer: SearchIndexer
139
+ try {
140
+ searchIndexer = ctx.resolve<SearchIndexer>('searchIndexer')
141
+ } catch {
142
+ searchDebugWarn('vector-index.worker', 'searchIndexer not available')
143
+ return
144
+ }
145
+
146
+ // Load saved embedding config to use the correct provider/model
147
+ try {
148
+ const embeddingConfig = await resolveEmbeddingConfig(ctx, { defaultValue: null })
149
+ if (embeddingConfig) {
150
+ const embeddingService = ctx.resolve<EmbeddingService>('vectorEmbeddingService')
151
+ embeddingService.updateConfig(embeddingConfig)
152
+ }
153
+ } catch (configErr) {
154
+ // Delete operations don't require embedding, only warn for index operations
155
+ if (jobType === 'index') {
156
+ searchDebugWarn('vector-index.worker', 'Failed to load embedding config, using defaults', {
157
+ error: configErr instanceof Error ? configErr.message : configErr,
158
+ })
159
+ }
160
+ }
161
+
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ let em: any | null = null
164
+ try {
165
+ em = ctx.resolve('em')
166
+ } catch {
167
+ em = null
168
+ }
169
+
170
+ let eventBus: { emitEvent(event: string, payload: unknown, options?: unknown): Promise<void> } | null = null
171
+ try {
172
+ eventBus = ctx.resolve('eventBus')
173
+ } catch {
174
+ eventBus = null
175
+ }
176
+
177
+ const handlerName = jobType === 'delete'
178
+ ? 'worker:vector-indexing:delete'
179
+ : 'worker:vector-indexing:index'
180
+
181
+ try {
182
+ let action: 'indexed' | 'deleted' | 'skipped' = 'skipped'
183
+ let delta = 0
184
+
185
+ if (jobType === 'delete') {
186
+ await searchIndexer.deleteRecord({
187
+ entityId: entityType,
188
+ recordId,
189
+ tenantId,
190
+ })
191
+ action = 'deleted'
192
+ delta = -1
193
+ } else {
194
+ const result = await searchIndexer.indexRecordById({
195
+ entityId: entityType,
196
+ recordId,
197
+ tenantId,
198
+ organizationId,
199
+ })
200
+ action = result.action
201
+ if (result.action === 'indexed') {
202
+ delta = 1
203
+ }
204
+ }
205
+
206
+ if (delta !== 0) {
207
+ let adjustmentsApplied = false
208
+ if (em) {
209
+ try {
210
+ const adjustments = createCoverageAdjustments({
211
+ entityType,
212
+ tenantId,
213
+ organizationId,
214
+ baseDelta: 0,
215
+ indexDelta: 0,
216
+ vectorDelta: delta,
217
+ })
218
+ if (adjustments.length) {
219
+ await applyCoverageAdjustments(em, adjustments)
220
+ adjustmentsApplied = true
221
+ }
222
+ } catch (coverageError) {
223
+ searchDebugWarn('vector-index.worker', 'Failed to adjust vector coverage', {
224
+ error: coverageError instanceof Error ? coverageError.message : coverageError,
225
+ })
226
+ }
227
+ }
228
+
229
+ if (!adjustmentsApplied && eventBus) {
230
+ try {
231
+ await eventBus.emitEvent('query_index.coverage.refresh', {
232
+ entityType,
233
+ tenantId,
234
+ organizationId,
235
+ withDeleted: false,
236
+ delayMs: 1000,
237
+ })
238
+ } catch (emitError) {
239
+ searchDebugWarn('vector-index.worker', 'Failed to enqueue coverage refresh', {
240
+ error: emitError instanceof Error ? emitError.message : emitError,
241
+ })
242
+ }
243
+ }
244
+ }
245
+
246
+ await logVectorOperation({
247
+ em,
248
+ handler: handlerName,
249
+ entityType,
250
+ recordId,
251
+ result: {
252
+ action,
253
+ tenantId,
254
+ organizationId: organizationId ?? null,
255
+ created: action === 'indexed',
256
+ existed: action === 'deleted',
257
+ },
258
+ })
259
+ } catch (error) {
260
+ searchDebugWarn('vector-index.worker', `Failed to ${jobType} vector index`, {
261
+ entityType,
262
+ recordId,
263
+ error: error instanceof Error ? error.message : error,
264
+ })
265
+ await recordIndexerError(
266
+ { em: em ?? undefined },
267
+ {
268
+ source: 'vector',
269
+ handler: handlerName,
270
+ error,
271
+ entityType,
272
+ recordId,
273
+ tenantId,
274
+ organizationId,
275
+ payload: job.payload,
276
+ },
277
+ )
278
+ // Re-throw to let the queue handle retry logic
279
+ throw error
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Default export for worker auto-discovery.
285
+ * Wraps handleVectorIndexJob to match the expected handler signature.
286
+ */
287
+ export default async function handle(
288
+ job: QueuedJob<VectorIndexJobPayload>,
289
+ ctx: JobContext & HandlerContext
290
+ ): Promise<void> {
291
+ return handleVectorIndexJob(job, ctx, ctx)
292
+ }
@@ -0,0 +1,87 @@
1
+ import { createQueue, type Queue } from '@open-mercato/queue'
2
+
3
+ /**
4
+ * Job types for fulltext indexing queue.
5
+ */
6
+ export type FulltextIndexJobType = 'index' | 'batch-index' | 'delete' | 'purge'
7
+
8
+ /**
9
+ * Minimal record reference for batch indexing.
10
+ * Only contains identifiers - actual data is loaded fresh via searchIndexer.indexRecordById().
11
+ * This keeps queue payloads small and ensures fresh data is indexed.
12
+ */
13
+ export type FulltextBatchRecord = {
14
+ entityId: string
15
+ recordId: string
16
+ }
17
+
18
+ /**
19
+ * Payload for single record indexing jobs.
20
+ * Worker loads fresh data via searchIndexer.indexRecordById().
21
+ */
22
+ export type FulltextIndexPayload = {
23
+ jobType: 'index'
24
+ tenantId: string
25
+ organizationId?: string | null
26
+ entityType: string
27
+ recordId: string
28
+ }
29
+
30
+ /**
31
+ * Payload for batch indexing jobs.
32
+ * Worker loads fresh data via searchIndexer.indexRecordById() for each record.
33
+ */
34
+ export type FulltextBatchIndexPayload = {
35
+ jobType: 'batch-index'
36
+ tenantId: string
37
+ organizationId?: string | null
38
+ records: FulltextBatchRecord[]
39
+ }
40
+
41
+ /**
42
+ * Payload for delete jobs.
43
+ */
44
+ export type FulltextDeletePayload = {
45
+ jobType: 'delete'
46
+ tenantId: string
47
+ entityId: string
48
+ recordId: string
49
+ }
50
+
51
+ /**
52
+ * Payload for purge jobs (delete all records of an entity type).
53
+ */
54
+ export type FulltextPurgePayload = {
55
+ jobType: 'purge'
56
+ tenantId: string
57
+ entityId: string
58
+ }
59
+
60
+ /**
61
+ * Union type for all fulltext indexing job payloads.
62
+ */
63
+ export type FulltextIndexJobPayload =
64
+ | FulltextIndexPayload
65
+ | FulltextBatchIndexPayload
66
+ | FulltextDeletePayload
67
+ | FulltextPurgePayload
68
+
69
+ export const FULLTEXT_INDEXING_QUEUE_NAME = 'fulltext-indexing'
70
+
71
+ /**
72
+ * Create a fulltext indexing queue.
73
+ *
74
+ * @param strategy - Queue strategy ('local' for development, 'async' for production with Redis)
75
+ * @param options - Optional connection configuration for async strategy
76
+ */
77
+ export function createFulltextIndexingQueue(
78
+ strategy: 'local' | 'async' = 'local',
79
+ options?: { connection?: { url?: string; host?: string; port?: number } },
80
+ ): Queue<FulltextIndexJobPayload> {
81
+ if (strategy === 'async') {
82
+ return createQueue<FulltextIndexJobPayload>(FULLTEXT_INDEXING_QUEUE_NAME, 'async', {
83
+ connection: options?.connection,
84
+ })
85
+ }
86
+ return createQueue<FulltextIndexJobPayload>(FULLTEXT_INDEXING_QUEUE_NAME, 'local')
87
+ }
@@ -0,0 +1,2 @@
1
+ export * from './vector-indexing'
2
+ export * from './fulltext-indexing'