@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,345 @@
1
+ import { SearchService } from '../service'
2
+ import { SearchStrategy, SearchResult, IndexableRecord } from '../types'
3
+
4
+ /**
5
+ * Create a mock search strategy for testing
6
+ */
7
+ function createMockStrategy(overrides: Partial<SearchStrategy> = {}): SearchStrategy {
8
+ return {
9
+ id: 'mock',
10
+ name: 'Mock Strategy',
11
+ priority: 10,
12
+ isAvailable: jest.fn().mockResolvedValue(true),
13
+ ensureReady: jest.fn().mockResolvedValue(undefined),
14
+ search: jest.fn().mockResolvedValue([]),
15
+ index: jest.fn().mockResolvedValue(undefined),
16
+ delete: jest.fn().mockResolvedValue(undefined),
17
+ bulkIndex: jest.fn().mockResolvedValue(undefined),
18
+ purge: jest.fn().mockResolvedValue(undefined),
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Create a mock search result for testing
25
+ */
26
+ function createMockResult(overrides: Partial<SearchResult> = {}): SearchResult {
27
+ return {
28
+ entityId: 'test:entity',
29
+ recordId: 'rec-123',
30
+ score: 0.9,
31
+ source: 'mock',
32
+ presenter: { title: 'Test Result' },
33
+ ...overrides,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Create a mock indexable record for testing
39
+ */
40
+ function createMockRecord(overrides: Partial<IndexableRecord> = {}): IndexableRecord {
41
+ return {
42
+ entityId: 'test:entity',
43
+ recordId: 'rec-123',
44
+ tenantId: 'tenant-123',
45
+ organizationId: 'org-456',
46
+ fields: { name: 'Test' },
47
+ presenter: { title: 'Test Record' },
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ describe('SearchService', () => {
53
+ describe('constructor', () => {
54
+ it('should create service with no strategies', () => {
55
+ const service = new SearchService()
56
+ expect(service.getRegisteredStrategies()).toEqual([])
57
+ })
58
+
59
+ it('should create service with provided strategies', () => {
60
+ const strategy = createMockStrategy({ id: 'test' })
61
+ const service = new SearchService({ strategies: [strategy] })
62
+ expect(service.getRegisteredStrategies()).toContain('test')
63
+ })
64
+
65
+ it('should use default strategies when not specified', () => {
66
+ const service = new SearchService()
67
+ expect(service.getDefaultStrategies()).toEqual(['tokens'])
68
+ })
69
+
70
+ it('should use custom default strategies when provided', () => {
71
+ const service = new SearchService({
72
+ defaultStrategies: ['meilisearch', 'vector'],
73
+ })
74
+ expect(service.getDefaultStrategies()).toEqual(['meilisearch', 'vector'])
75
+ })
76
+ })
77
+
78
+ describe('strategy management', () => {
79
+ it('should register a strategy', () => {
80
+ const service = new SearchService()
81
+ const strategy = createMockStrategy({ id: 'new-strategy' })
82
+
83
+ service.registerStrategy(strategy)
84
+
85
+ expect(service.getRegisteredStrategies()).toContain('new-strategy')
86
+ expect(service.getStrategy('new-strategy')).toBe(strategy)
87
+ })
88
+
89
+ it('should unregister a strategy', () => {
90
+ const strategy = createMockStrategy({ id: 'test' })
91
+ const service = new SearchService({ strategies: [strategy] })
92
+
93
+ service.unregisterStrategy('test')
94
+
95
+ expect(service.getRegisteredStrategies()).not.toContain('test')
96
+ expect(service.getStrategy('test')).toBeUndefined()
97
+ })
98
+
99
+ it('should check strategy availability', async () => {
100
+ const availableStrategy = createMockStrategy({
101
+ id: 'available',
102
+ isAvailable: jest.fn().mockResolvedValue(true),
103
+ })
104
+ const unavailableStrategy = createMockStrategy({
105
+ id: 'unavailable',
106
+ isAvailable: jest.fn().mockResolvedValue(false),
107
+ })
108
+ const service = new SearchService({
109
+ strategies: [availableStrategy, unavailableStrategy],
110
+ })
111
+
112
+ expect(await service.isStrategyAvailable('available')).toBe(true)
113
+ expect(await service.isStrategyAvailable('unavailable')).toBe(false)
114
+ expect(await service.isStrategyAvailable('nonexistent')).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe('search', () => {
119
+ it('should return empty array when no strategies available', async () => {
120
+ const service = new SearchService()
121
+
122
+ const results = await service.search('test query', { tenantId: 'tenant-123' })
123
+
124
+ expect(results).toEqual([])
125
+ })
126
+
127
+ it('should execute search on available strategies', async () => {
128
+ const mockResults = [createMockResult()]
129
+ const strategy = createMockStrategy({
130
+ id: 'test',
131
+ search: jest.fn().mockResolvedValue(mockResults),
132
+ })
133
+ const service = new SearchService({
134
+ strategies: [strategy],
135
+ defaultStrategies: ['test'],
136
+ })
137
+
138
+ const results = await service.search('test query', { tenantId: 'tenant-123' })
139
+
140
+ expect(strategy.ensureReady).toHaveBeenCalled()
141
+ expect(strategy.search).toHaveBeenCalledWith('test query', { tenantId: 'tenant-123' })
142
+ expect(results).toHaveLength(1)
143
+ expect(results[0].recordId).toBe('rec-123')
144
+ })
145
+
146
+ it('should skip unavailable strategies', async () => {
147
+ const availableStrategy = createMockStrategy({
148
+ id: 'available',
149
+ isAvailable: jest.fn().mockResolvedValue(true),
150
+ search: jest.fn().mockResolvedValue([createMockResult({ source: 'available' })]),
151
+ })
152
+ const unavailableStrategy = createMockStrategy({
153
+ id: 'unavailable',
154
+ isAvailable: jest.fn().mockResolvedValue(false),
155
+ search: jest.fn().mockResolvedValue([createMockResult({ source: 'unavailable' })]),
156
+ })
157
+ const service = new SearchService({
158
+ strategies: [availableStrategy, unavailableStrategy],
159
+ defaultStrategies: ['available', 'unavailable'],
160
+ })
161
+
162
+ const results = await service.search('test', { tenantId: 'tenant-123' })
163
+
164
+ expect(unavailableStrategy.search).not.toHaveBeenCalled()
165
+ expect(results.every((r) => r.source === 'available')).toBe(true)
166
+ })
167
+
168
+ it('should merge results from multiple strategies', async () => {
169
+ const strategy1 = createMockStrategy({
170
+ id: 'strategy1',
171
+ priority: 20,
172
+ search: jest.fn().mockResolvedValue([
173
+ createMockResult({ recordId: 'rec-1', source: 'strategy1', score: 0.9 }),
174
+ ]),
175
+ })
176
+ const strategy2 = createMockStrategy({
177
+ id: 'strategy2',
178
+ priority: 10,
179
+ search: jest.fn().mockResolvedValue([
180
+ createMockResult({ recordId: 'rec-2', source: 'strategy2', score: 0.8 }),
181
+ ]),
182
+ })
183
+ const service = new SearchService({
184
+ strategies: [strategy1, strategy2],
185
+ defaultStrategies: ['strategy1', 'strategy2'],
186
+ })
187
+
188
+ const results = await service.search('test', { tenantId: 'tenant-123' })
189
+
190
+ expect(results).toHaveLength(2)
191
+ })
192
+
193
+ it('should handle strategy search failures gracefully', async () => {
194
+ const failingStrategy = createMockStrategy({
195
+ id: 'failing',
196
+ search: jest.fn().mockRejectedValue(new Error('Search failed')),
197
+ })
198
+ const workingStrategy = createMockStrategy({
199
+ id: 'working',
200
+ search: jest.fn().mockResolvedValue([createMockResult({ source: 'working' })]),
201
+ })
202
+ const service = new SearchService({
203
+ strategies: [failingStrategy, workingStrategy],
204
+ defaultStrategies: ['failing', 'working'],
205
+ })
206
+
207
+ const results = await service.search('test', { tenantId: 'tenant-123' })
208
+
209
+ // Should return results from working strategy despite failing strategy
210
+ expect(results).toHaveLength(1)
211
+ expect(results[0].source).toBe('working')
212
+ })
213
+
214
+ it('should use fallback strategy when no default strategies available', async () => {
215
+ const fallbackStrategy = createMockStrategy({
216
+ id: 'fallback',
217
+ search: jest.fn().mockResolvedValue([createMockResult({ source: 'fallback' })]),
218
+ })
219
+ const unavailableStrategy = createMockStrategy({
220
+ id: 'primary',
221
+ isAvailable: jest.fn().mockResolvedValue(false),
222
+ })
223
+ const service = new SearchService({
224
+ strategies: [unavailableStrategy, fallbackStrategy],
225
+ defaultStrategies: ['primary'],
226
+ fallbackStrategy: 'fallback',
227
+ })
228
+
229
+ const results = await service.search('test', { tenantId: 'tenant-123' })
230
+
231
+ expect(results).toHaveLength(1)
232
+ expect(results[0].source).toBe('fallback')
233
+ })
234
+ })
235
+
236
+ describe('index', () => {
237
+ it('should index record on all available strategies', async () => {
238
+ const strategy1 = createMockStrategy({ id: 'strategy1' })
239
+ const strategy2 = createMockStrategy({ id: 'strategy2' })
240
+ const service = new SearchService({
241
+ strategies: [strategy1, strategy2],
242
+ })
243
+ const record = createMockRecord()
244
+
245
+ await service.index(record)
246
+
247
+ expect(strategy1.index).toHaveBeenCalledWith(record)
248
+ expect(strategy2.index).toHaveBeenCalledWith(record)
249
+ })
250
+
251
+ it('should skip unavailable strategies', async () => {
252
+ const availableStrategy = createMockStrategy({
253
+ id: 'available',
254
+ isAvailable: jest.fn().mockResolvedValue(true),
255
+ })
256
+ const unavailableStrategy = createMockStrategy({
257
+ id: 'unavailable',
258
+ isAvailable: jest.fn().mockResolvedValue(false),
259
+ })
260
+ const service = new SearchService({
261
+ strategies: [availableStrategy, unavailableStrategy],
262
+ })
263
+ const record = createMockRecord()
264
+
265
+ await service.index(record)
266
+
267
+ expect(availableStrategy.index).toHaveBeenCalled()
268
+ expect(unavailableStrategy.index).not.toHaveBeenCalled()
269
+ })
270
+ })
271
+
272
+ describe('bulkIndex', () => {
273
+ it('should bulk index records on strategies that support it', async () => {
274
+ const strategy = createMockStrategy({
275
+ id: 'test',
276
+ bulkIndex: jest.fn().mockResolvedValue(undefined),
277
+ })
278
+ const service = new SearchService({ strategies: [strategy] })
279
+ const records = [createMockRecord({ recordId: 'rec-1' }), createMockRecord({ recordId: 'rec-2' })]
280
+
281
+ await service.bulkIndex(records)
282
+
283
+ expect(strategy.bulkIndex).toHaveBeenCalledWith(records)
284
+ })
285
+
286
+ it('should fallback to individual indexing when bulkIndex not supported', async () => {
287
+ const strategy = createMockStrategy({
288
+ id: 'test',
289
+ bulkIndex: undefined,
290
+ index: jest.fn().mockResolvedValue(undefined),
291
+ })
292
+ const service = new SearchService({ strategies: [strategy] })
293
+ const records = [createMockRecord({ recordId: 'rec-1' }), createMockRecord({ recordId: 'rec-2' })]
294
+
295
+ await service.bulkIndex(records)
296
+
297
+ expect(strategy.index).toHaveBeenCalledTimes(2)
298
+ })
299
+
300
+ it('should do nothing when records array is empty', async () => {
301
+ const strategy = createMockStrategy({ id: 'test' })
302
+ const service = new SearchService({ strategies: [strategy] })
303
+
304
+ await service.bulkIndex([])
305
+
306
+ expect(strategy.bulkIndex).not.toHaveBeenCalled()
307
+ expect(strategy.index).not.toHaveBeenCalled()
308
+ })
309
+ })
310
+
311
+ describe('delete', () => {
312
+ it('should delete from all available strategies', async () => {
313
+ const strategy1 = createMockStrategy({ id: 'strategy1' })
314
+ const strategy2 = createMockStrategy({ id: 'strategy2' })
315
+ const service = new SearchService({
316
+ strategies: [strategy1, strategy2],
317
+ })
318
+
319
+ await service.delete('test:entity', 'rec-123', 'tenant-123')
320
+
321
+ expect(strategy1.delete).toHaveBeenCalledWith('test:entity', 'rec-123', 'tenant-123')
322
+ expect(strategy2.delete).toHaveBeenCalledWith('test:entity', 'rec-123', 'tenant-123')
323
+ })
324
+ })
325
+
326
+ describe('purge', () => {
327
+ it('should purge from strategies that support it', async () => {
328
+ const strategyWithPurge = createMockStrategy({
329
+ id: 'with-purge',
330
+ purge: jest.fn().mockResolvedValue(undefined),
331
+ })
332
+ const strategyWithoutPurge = createMockStrategy({
333
+ id: 'without-purge',
334
+ purge: undefined,
335
+ })
336
+ const service = new SearchService({
337
+ strategies: [strategyWithPurge, strategyWithoutPurge],
338
+ })
339
+
340
+ await service.purge('test:entity', 'tenant-123')
341
+
342
+ expect(strategyWithPurge.purge).toHaveBeenCalledWith('test:entity', 'tenant-123')
343
+ })
344
+ })
345
+ })
@@ -0,0 +1,319 @@
1
+ import { QueuedJob, JobContext } from '@open-mercato/queue'
2
+ import { VectorIndexJobPayload } from '../queue/vector-indexing'
3
+ import { FulltextIndexJobPayload } from '../queue/fulltext-indexing'
4
+
5
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
6
+
7
+ // Mock dependencies before importing workers
8
+ jest.mock('@open-mercato/shared/lib/indexers/error-log', () => ({
9
+ recordIndexerError: jest.fn().mockResolvedValue(undefined),
10
+ }))
11
+
12
+ jest.mock('@open-mercato/shared/lib/indexers/status-log', () => ({
13
+ recordIndexerLog: jest.fn().mockResolvedValue(undefined),
14
+ }))
15
+
16
+ jest.mock('@open-mercato/core/modules/query_index/lib/coverage', () => ({
17
+ applyCoverageAdjustments: jest.fn().mockResolvedValue(undefined),
18
+ createCoverageAdjustments: jest.fn().mockReturnValue([]),
19
+ }))
20
+
21
+ jest.mock('../vector/lib/vector-logs', () => ({
22
+ logVectorOperation: jest.fn().mockResolvedValue(undefined),
23
+ }))
24
+
25
+ jest.mock('../modules/search/lib/auto-indexing', () => ({
26
+ resolveAutoIndexingEnabled: jest.fn().mockResolvedValue(true),
27
+ }))
28
+
29
+ jest.mock('../modules/search/lib/embedding-config', () => ({
30
+ resolveEmbeddingConfig: jest.fn().mockResolvedValue(null),
31
+ }))
32
+
33
+ import { handleVectorIndexJob } from '../modules/search/workers/vector-index.worker'
34
+ import { handleFulltextIndexJob } from '../modules/search/workers/fulltext-index.worker'
35
+
36
+ /**
37
+ * Create a mock job context
38
+ */
39
+ function createMockJobContext(overrides: Partial<JobContext> = {}): JobContext {
40
+ return {
41
+ jobId: 'job-123',
42
+ attemptNumber: 1,
43
+ queueName: 'test-queue',
44
+ ...overrides,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Create a mock queued job
50
+ */
51
+ function createMockJob<T>(payload: T): QueuedJob<T> {
52
+ return {
53
+ id: 'job-123',
54
+ payload,
55
+ createdAt: new Date().toISOString(),
56
+ }
57
+ }
58
+
59
+ describe('Vector Index Worker', () => {
60
+ const mockSearchIndexer = {
61
+ indexRecordById: jest.fn().mockResolvedValue({ action: 'indexed', created: true }),
62
+ deleteRecord: jest.fn().mockResolvedValue({ action: 'deleted', existed: true }),
63
+ }
64
+
65
+ const mockContainer: HandlerContext = {
66
+ resolve: jest.fn((name: string) => {
67
+ if (name === 'searchIndexer') return mockSearchIndexer
68
+ if (name === 'em') return null
69
+ if (name === 'eventBus') return null
70
+ if (name === 'vectorEmbeddingService') return { updateConfig: jest.fn() }
71
+ throw new Error(`Unknown service: ${name}`)
72
+ }) as HandlerContext['resolve'],
73
+ }
74
+
75
+ beforeEach(() => {
76
+ jest.clearAllMocks()
77
+ })
78
+
79
+ it('should skip job with missing required fields', async () => {
80
+ const job = createMockJob<VectorIndexJobPayload>({
81
+ jobType: 'index',
82
+ entityType: '',
83
+ recordId: 'rec-123',
84
+ tenantId: 'tenant-123',
85
+ organizationId: null,
86
+ })
87
+ const ctx = createMockJobContext()
88
+
89
+ await handleVectorIndexJob(job, ctx, mockContainer)
90
+
91
+ expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
92
+ })
93
+
94
+ it('should index record when jobType is index', async () => {
95
+ const job = createMockJob<VectorIndexJobPayload>({
96
+ jobType: 'index',
97
+ entityType: 'customers:customer_person_profile',
98
+ recordId: 'rec-123',
99
+ tenantId: 'tenant-123',
100
+ organizationId: 'org-456',
101
+ })
102
+ const ctx = createMockJobContext()
103
+
104
+ await handleVectorIndexJob(job, ctx, mockContainer)
105
+
106
+ expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
107
+ entityId: 'customers:customer_person_profile',
108
+ recordId: 'rec-123',
109
+ tenantId: 'tenant-123',
110
+ organizationId: 'org-456',
111
+ })
112
+ })
113
+
114
+ it('should delete record when jobType is delete', async () => {
115
+ const job = createMockJob<VectorIndexJobPayload>({
116
+ jobType: 'delete',
117
+ entityType: 'customers:customer_person_profile',
118
+ recordId: 'rec-123',
119
+ tenantId: 'tenant-123',
120
+ organizationId: null,
121
+ })
122
+ const ctx = createMockJobContext()
123
+
124
+ await handleVectorIndexJob(job, ctx, mockContainer)
125
+
126
+ expect(mockSearchIndexer.deleteRecord).toHaveBeenCalledWith({
127
+ entityId: 'customers:customer_person_profile',
128
+ recordId: 'rec-123',
129
+ tenantId: 'tenant-123',
130
+ })
131
+ })
132
+
133
+ it('should skip when vectorIndexService is not available', async () => {
134
+ const containerWithoutService: HandlerContext = {
135
+ resolve: jest.fn(() => {
136
+ throw new Error('Service not available')
137
+ }) as HandlerContext['resolve'],
138
+ }
139
+ const job = createMockJob<VectorIndexJobPayload>({
140
+ jobType: 'index',
141
+ entityType: 'test:entity',
142
+ recordId: 'rec-123',
143
+ tenantId: 'tenant-123',
144
+ organizationId: null,
145
+ })
146
+ const ctx = createMockJobContext()
147
+
148
+ // Should not throw
149
+ await handleVectorIndexJob(job, ctx, containerWithoutService)
150
+ })
151
+ })
152
+
153
+ describe('Fulltext Index Worker', () => {
154
+ const mockFulltextStrategy = {
155
+ id: 'fulltext',
156
+ isAvailable: jest.fn().mockResolvedValue(true),
157
+ bulkIndex: jest.fn().mockResolvedValue(undefined),
158
+ delete: jest.fn().mockResolvedValue(undefined),
159
+ purge: jest.fn().mockResolvedValue(undefined),
160
+ }
161
+
162
+ // Mock knex query builder for batch-index tests
163
+ const mockKnexQuery = {
164
+ select: jest.fn().mockReturnThis(),
165
+ where: jest.fn().mockReturnThis(),
166
+ whereIn: jest.fn().mockReturnThis(),
167
+ whereNull: jest.fn().mockResolvedValue([
168
+ { entity_id: 'rec-1', doc: { name: 'Test 1' } },
169
+ { entity_id: 'rec-2', doc: { name: 'Test 2' } },
170
+ ]),
171
+ }
172
+ const mockKnex = jest.fn(() => mockKnexQuery)
173
+
174
+ const mockSearchIndexer = {
175
+ getEntityConfig: jest.fn().mockReturnValue(null),
176
+ indexRecordById: jest.fn().mockResolvedValue({ action: 'indexed', created: true }),
177
+ }
178
+
179
+ const mockEm = {
180
+ getConnection: jest.fn().mockReturnValue({
181
+ getKnex: jest.fn().mockReturnValue(mockKnex),
182
+ }),
183
+ }
184
+
185
+ const mockContainer: HandlerContext = {
186
+ resolve: jest.fn((name: string) => {
187
+ if (name === 'searchStrategies') return [mockFulltextStrategy]
188
+ if (name === 'em') return mockEm
189
+ if (name === 'searchIndexer') return mockSearchIndexer
190
+ throw new Error(`Unknown service: ${name}`)
191
+ }) as HandlerContext['resolve'],
192
+ }
193
+
194
+ beforeEach(() => {
195
+ jest.clearAllMocks()
196
+ // Reset knex mock chain
197
+ mockKnexQuery.select.mockReturnThis()
198
+ mockKnexQuery.where.mockReturnThis()
199
+ mockKnexQuery.whereIn.mockReturnThis()
200
+ mockKnexQuery.whereNull.mockResolvedValue([
201
+ { entity_id: 'rec-1', doc: { name: 'Test 1' } },
202
+ { entity_id: 'rec-2', doc: { name: 'Test 2' } },
203
+ ])
204
+ })
205
+
206
+ it('should skip job with missing tenantId', async () => {
207
+ const job = createMockJob<FulltextIndexJobPayload>({
208
+ jobType: 'batch-index',
209
+ tenantId: '',
210
+ records: [],
211
+ })
212
+ const ctx = createMockJobContext()
213
+
214
+ await handleFulltextIndexJob(job, ctx, mockContainer)
215
+
216
+ expect(mockFulltextStrategy.bulkIndex).not.toHaveBeenCalled()
217
+ })
218
+
219
+ it('should index records via searchIndexer when jobType is batch-index', async () => {
220
+ // Use minimal record format (just entityId + recordId)
221
+ const records = [
222
+ { entityId: 'test:entity', recordId: 'rec-1' },
223
+ { entityId: 'test:entity', recordId: 'rec-2' },
224
+ ]
225
+ const job = createMockJob<FulltextIndexJobPayload>({
226
+ jobType: 'batch-index',
227
+ tenantId: 'tenant-123',
228
+ records,
229
+ })
230
+ const ctx = createMockJobContext()
231
+
232
+ await handleFulltextIndexJob(job, ctx, mockContainer)
233
+
234
+ // Verify indexRecordById was called for each record
235
+ expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(2)
236
+ expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
237
+ entityId: 'test:entity',
238
+ recordId: 'rec-1',
239
+ tenantId: 'tenant-123',
240
+ organizationId: undefined,
241
+ })
242
+ expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
243
+ entityId: 'test:entity',
244
+ recordId: 'rec-2',
245
+ tenantId: 'tenant-123',
246
+ organizationId: undefined,
247
+ })
248
+ })
249
+
250
+ it('should skip batch-index with empty records', async () => {
251
+ const job = createMockJob<FulltextIndexJobPayload>({
252
+ jobType: 'batch-index',
253
+ tenantId: 'tenant-123',
254
+ records: [],
255
+ })
256
+ const ctx = createMockJobContext()
257
+
258
+ await handleFulltextIndexJob(job, ctx, mockContainer)
259
+
260
+ expect(mockFulltextStrategy.bulkIndex).not.toHaveBeenCalled()
261
+ })
262
+
263
+ it('should delete record when jobType is delete', async () => {
264
+ const job = createMockJob<FulltextIndexJobPayload>({
265
+ jobType: 'delete',
266
+ tenantId: 'tenant-123',
267
+ entityId: 'test:entity',
268
+ recordId: 'rec-123',
269
+ })
270
+ const ctx = createMockJobContext()
271
+
272
+ await handleFulltextIndexJob(job, ctx, mockContainer)
273
+
274
+ expect(mockFulltextStrategy.delete).toHaveBeenCalledWith('test:entity', 'rec-123', 'tenant-123')
275
+ })
276
+
277
+ it('should purge entity when jobType is purge', async () => {
278
+ const job = createMockJob<FulltextIndexJobPayload>({
279
+ jobType: 'purge',
280
+ tenantId: 'tenant-123',
281
+ entityId: 'test:entity',
282
+ })
283
+ const ctx = createMockJobContext()
284
+
285
+ await handleFulltextIndexJob(job, ctx, mockContainer)
286
+
287
+ expect(mockFulltextStrategy.purge).toHaveBeenCalledWith('test:entity', 'tenant-123')
288
+ })
289
+
290
+ it('should skip when fulltext strategy not configured', async () => {
291
+ const containerWithoutStrategy: HandlerContext = {
292
+ resolve: jest.fn(() => []) as HandlerContext['resolve'],
293
+ }
294
+ const job = createMockJob<FulltextIndexJobPayload>({
295
+ jobType: 'batch-index',
296
+ tenantId: 'tenant-123',
297
+ records: [{ entityId: 'test', recordId: '1' }],
298
+ })
299
+ const ctx = createMockJobContext()
300
+
301
+ await handleFulltextIndexJob(job, ctx, containerWithoutStrategy)
302
+
303
+ expect(mockFulltextStrategy.bulkIndex).not.toHaveBeenCalled()
304
+ })
305
+
306
+ it('should throw when fulltext search is not available', async () => {
307
+ mockFulltextStrategy.isAvailable.mockResolvedValueOnce(false)
308
+ const job = createMockJob<FulltextIndexJobPayload>({
309
+ jobType: 'batch-index',
310
+ tenantId: 'tenant-123',
311
+ records: [{ entityId: 'test', recordId: '1' }],
312
+ })
313
+ const ctx = createMockJobContext()
314
+
315
+ await expect(handleFulltextIndexJob(job, ctx, mockContainer)).rejects.toThrow(
316
+ 'Fulltext search is not available'
317
+ )
318
+ })
319
+ })