@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,66 @@
1
+ import { createQueue } from '@open-mercato/queue'
2
+ import type { Queue } from '@open-mercato/queue'
3
+
4
+ /**
5
+ * Job types for vector indexing queue
6
+ */
7
+ export type VectorIndexJobType = 'index' | 'delete' | 'batch-index'
8
+
9
+ /**
10
+ * Record reference for batch indexing
11
+ */
12
+ export type VectorBatchRecord = {
13
+ entityId: string
14
+ recordId: string
15
+ }
16
+
17
+ /**
18
+ * Payload for vector indexing jobs
19
+ */
20
+ export type VectorIndexJobPayload = {
21
+ jobType: VectorIndexJobType
22
+ tenantId: string
23
+ organizationId: string | null
24
+ // For single record jobs (index/delete)
25
+ entityType?: string
26
+ recordId?: string
27
+ // For batch-index jobs
28
+ records?: VectorBatchRecord[]
29
+ }
30
+
31
+ /**
32
+ * Queue name for vector indexing
33
+ */
34
+ export const VECTOR_INDEXING_QUEUE_NAME = 'vector-indexing'
35
+
36
+ /**
37
+ * Creates a vector indexing queue instance.
38
+ *
39
+ * @param strategy - Queue strategy: 'local' for file-based, 'async' for BullMQ/Redis
40
+ * @param options - Strategy-specific options
41
+ * @returns Queue instance for vector indexing jobs
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * // Local queue for development
46
+ * const queue = createVectorIndexingQueue('local')
47
+ *
48
+ * // Async queue for production
49
+ * const queue = createVectorIndexingQueue('async', {
50
+ * connection: { url: process.env.REDIS_URL }
51
+ * })
52
+ * ```
53
+ */
54
+ export function createVectorIndexingQueue(
55
+ strategy: 'local' | 'async' = 'local',
56
+ options?: {
57
+ connection?: { url?: string; host?: string; port?: number }
58
+ },
59
+ ): Queue<VectorIndexJobPayload> {
60
+ if (strategy === 'async') {
61
+ return createQueue<VectorIndexJobPayload>(VECTOR_INDEXING_QUEUE_NAME, 'async', {
62
+ connection: options?.connection,
63
+ })
64
+ }
65
+ return createQueue<VectorIndexJobPayload>(VECTOR_INDEXING_QUEUE_NAME, 'local')
66
+ }
package/src/service.ts ADDED
@@ -0,0 +1,397 @@
1
+ import type {
2
+ SearchStrategy,
3
+ SearchStrategyId,
4
+ SearchOptions,
5
+ SearchResult,
6
+ SearchServiceOptions,
7
+ ResultMergeConfig,
8
+ IndexableRecord,
9
+ PresenterEnricherFn,
10
+ } from './types'
11
+ import { mergeAndRankResults } from './lib/merger'
12
+ import { searchError } from './lib/debug'
13
+
14
+ /**
15
+ * Default merge configuration.
16
+ */
17
+ const DEFAULT_MERGE_CONFIG: ResultMergeConfig = {
18
+ duplicateHandling: 'highest_score',
19
+ }
20
+
21
+ /**
22
+ * SearchService orchestrates multiple search strategies, executing searches in parallel
23
+ * and merging results using the RRF algorithm.
24
+ *
25
+ * Features:
26
+ * - Parallel strategy execution for optimal performance
27
+ * - Graceful degradation when strategies fail
28
+ * - Result merging with configurable weights
29
+ * - Strategy availability checking
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const service = new SearchService({
34
+ * strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],
35
+ * defaultStrategies: ['fulltext', 'vector', 'tokens'],
36
+ * mergeConfig: {
37
+ * duplicateHandling: 'highest_score',
38
+ * strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },
39
+ * },
40
+ * })
41
+ *
42
+ * const results = await service.search('john doe', { tenantId: 'tenant-123' })
43
+ * ```
44
+ */
45
+ export class SearchService {
46
+ private readonly strategies: Map<SearchStrategyId, SearchStrategy>
47
+ private readonly defaultStrategies: SearchStrategyId[]
48
+ private readonly fallbackStrategy: SearchStrategyId | undefined
49
+ private readonly mergeConfig: ResultMergeConfig
50
+ private readonly presenterEnricher?: PresenterEnricherFn
51
+
52
+ constructor(options: SearchServiceOptions = {}) {
53
+ this.strategies = new Map()
54
+ for (const strategy of options.strategies ?? []) {
55
+ this.strategies.set(strategy.id, strategy)
56
+ }
57
+ this.defaultStrategies = options.defaultStrategies ?? ['tokens']
58
+ this.fallbackStrategy = options.fallbackStrategy
59
+ this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG
60
+ this.presenterEnricher = options.presenterEnricher
61
+ }
62
+
63
+ /**
64
+ * Get all registered strategies.
65
+ */
66
+ getStrategies(): SearchStrategy[] {
67
+ return Array.from(this.strategies.values())
68
+ }
69
+
70
+ /**
71
+ * Execute a search query across configured strategies.
72
+ *
73
+ * @param query - Search query string
74
+ * @param options - Search options with tenant, filters, etc.
75
+ * @returns Merged and ranked search results
76
+ */
77
+ async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
78
+ const strategyIds = options.strategies ?? this.defaultStrategies
79
+ const activeStrategies = await this.getAvailableStrategies(strategyIds)
80
+
81
+ if (activeStrategies.length === 0) {
82
+ // Try fallback strategy if defined
83
+ if (this.fallbackStrategy) {
84
+ const fallback = await this.getAvailableStrategies([this.fallbackStrategy])
85
+ if (fallback.length > 0) {
86
+ activeStrategies.push(...fallback)
87
+ }
88
+ }
89
+ }
90
+
91
+ if (activeStrategies.length === 0) {
92
+ return []
93
+ }
94
+
95
+ // Execute searches in parallel with graceful degradation
96
+ const results = await Promise.allSettled(
97
+ activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),
98
+ )
99
+
100
+ // Collect successful results, log failures
101
+ const allResults: SearchResult[] = []
102
+ for (let i = 0; i < results.length; i++) {
103
+ const result = results[i]
104
+ if (result.status === 'fulfilled') {
105
+ allResults.push(...result.value)
106
+ } else {
107
+ const strategy = activeStrategies[i]
108
+ searchError('SearchService', 'Strategy search failed', {
109
+ strategyId: strategy?.id,
110
+ error: result.reason instanceof Error ? result.reason.message : result.reason,
111
+ })
112
+ }
113
+ }
114
+
115
+ // Merge and rank results
116
+ const merged = mergeAndRankResults(allResults, this.mergeConfig)
117
+
118
+ // Enrich results missing presenter data
119
+ return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)
120
+ }
121
+
122
+ /**
123
+ * Enrich results that are missing presenter data using the configured enricher.
124
+ * This ensures token-only results get proper titles/subtitles for display.
125
+ */
126
+ private async enrichResultsWithPresenter(
127
+ results: SearchResult[],
128
+ tenantId: string,
129
+ organizationId?: string | null,
130
+ ): Promise<SearchResult[]> {
131
+ // If no enricher configured, return as-is
132
+ if (!this.presenterEnricher) return results
133
+
134
+ // Check if any results need enrichment (missing or encrypted presenter)
135
+ const needsEnrichment = (r: SearchResult) => {
136
+ if (!r.presenter?.title) return true
137
+ // Also enrich if presenter looks encrypted (format: iv:ciphertext:authTag:v1)
138
+ const title = r.presenter.title
139
+ if (typeof title === 'string' && title.includes(':')) {
140
+ const parts = title.split(':')
141
+ if (parts.length >= 3 && parts[parts.length - 1] === 'v1') return true
142
+ }
143
+ return false
144
+ }
145
+ const hasMissing = results.some(needsEnrichment)
146
+ if (!hasMissing) return results
147
+
148
+ // Use the configured presenter enricher
149
+ try {
150
+ return await this.presenterEnricher(results, tenantId, organizationId)
151
+ } catch {
152
+ // Enrichment failed, return results as-is
153
+ return results
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Index a record across all available strategies.
159
+ *
160
+ * @param record - Record to index
161
+ */
162
+ async index(record: IndexableRecord): Promise<void> {
163
+ const strategies = await this.getAvailableStrategies()
164
+
165
+ if (strategies.length === 0) {
166
+ return
167
+ }
168
+
169
+ const results = await Promise.allSettled(
170
+ strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),
171
+ )
172
+
173
+ // Log any failures
174
+ for (let i = 0; i < results.length; i++) {
175
+ const result = results[i]
176
+ if (result.status === 'rejected') {
177
+ const strategy = strategies[i]
178
+ searchError('SearchService', 'Strategy index failed', {
179
+ strategyId: strategy?.id,
180
+ entityId: record.entityId,
181
+ recordId: record.recordId,
182
+ error: result.reason instanceof Error ? result.reason.message : result.reason,
183
+ })
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Delete a record from all strategies.
190
+ *
191
+ * @param entityId - Entity type identifier
192
+ * @param recordId - Record primary key
193
+ * @param tenantId - Tenant for isolation
194
+ */
195
+ async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {
196
+ const strategies = await this.getAvailableStrategies()
197
+
198
+ const results = await Promise.allSettled(
199
+ strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),
200
+ )
201
+
202
+ // Log any failures
203
+ for (let i = 0; i < results.length; i++) {
204
+ const result = results[i]
205
+ if (result.status === 'rejected') {
206
+ const strategy = strategies[i]
207
+ searchError('SearchService', 'Strategy delete failed', {
208
+ strategyId: strategy?.id,
209
+ entityId,
210
+ recordId,
211
+ error: result.reason instanceof Error ? result.reason.message : result.reason,
212
+ })
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Bulk index multiple records.
219
+ *
220
+ * @param records - Records to index
221
+ */
222
+ async bulkIndex(records: IndexableRecord[]): Promise<void> {
223
+ if (records.length === 0) return
224
+
225
+ const strategies = await this.getAvailableStrategies()
226
+
227
+ const results = await Promise.allSettled(
228
+ strategies.map((strategy) => {
229
+ if (strategy.bulkIndex) {
230
+ return strategy.bulkIndex(records)
231
+ }
232
+ // Fallback to individual indexing
233
+ return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))
234
+ }),
235
+ )
236
+
237
+ // Log any failures
238
+ for (let i = 0; i < results.length; i++) {
239
+ const result = results[i]
240
+ if (result.status === 'rejected') {
241
+ const strategy = strategies[i]
242
+ searchError('SearchService', 'Strategy bulkIndex failed', {
243
+ strategyId: strategy?.id,
244
+ recordCount: records.length,
245
+ error: result.reason instanceof Error ? result.reason.message : result.reason,
246
+ })
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Purge all records for an entity type.
253
+ *
254
+ * @param entityId - Entity type to purge
255
+ * @param tenantId - Tenant for isolation
256
+ */
257
+ async purge(entityId: string, tenantId: string): Promise<void> {
258
+ const strategies = await this.getAvailableStrategies()
259
+
260
+ const results = await Promise.allSettled(
261
+ strategies.map((strategy) => {
262
+ if (strategy.purge) {
263
+ return strategy.purge(entityId, tenantId)
264
+ }
265
+ return Promise.resolve()
266
+ }),
267
+ )
268
+
269
+ // Log any failures
270
+ for (let i = 0; i < results.length; i++) {
271
+ const result = results[i]
272
+ if (result.status === 'rejected') {
273
+ const strategy = strategies[i]
274
+ searchError('SearchService', 'Strategy purge failed', {
275
+ strategyId: strategy?.id,
276
+ entityId,
277
+ error: result.reason instanceof Error ? result.reason.message : result.reason,
278
+ })
279
+ }
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Register a new strategy at runtime.
285
+ *
286
+ * @param strategy - Strategy to register
287
+ */
288
+ registerStrategy(strategy: SearchStrategy): void {
289
+ this.strategies.set(strategy.id, strategy)
290
+ }
291
+
292
+ /**
293
+ * Unregister a strategy.
294
+ *
295
+ * @param strategyId - Strategy ID to remove
296
+ */
297
+ unregisterStrategy(strategyId: SearchStrategyId): void {
298
+ this.strategies.delete(strategyId)
299
+ }
300
+
301
+ /**
302
+ * Get all registered strategy IDs.
303
+ */
304
+ getRegisteredStrategies(): SearchStrategyId[] {
305
+ return Array.from(this.strategies.keys())
306
+ }
307
+
308
+ /**
309
+ * Get a specific strategy by ID.
310
+ *
311
+ * @param strategyId - Strategy ID to retrieve
312
+ * @returns The strategy if registered, undefined otherwise
313
+ */
314
+ getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {
315
+ return this.strategies.get(strategyId)
316
+ }
317
+
318
+ /**
319
+ * Get the default strategies list.
320
+ */
321
+ getDefaultStrategies(): SearchStrategyId[] {
322
+ return [...this.defaultStrategies]
323
+ }
324
+
325
+ /**
326
+ * Check if a specific strategy is available.
327
+ *
328
+ * @param strategyId - Strategy ID to check
329
+ */
330
+ async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {
331
+ const strategy = this.strategies.get(strategyId)
332
+ if (!strategy) return false
333
+ return strategy.isAvailable()
334
+ }
335
+
336
+ /**
337
+ * Get available strategies from the requested list.
338
+ * Filters out strategies that are not registered or not available.
339
+ */
340
+ private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {
341
+ const targetIds = ids ?? Array.from(this.strategies.keys())
342
+ const available: SearchStrategy[] = []
343
+
344
+ for (const id of targetIds) {
345
+ const strategy = this.strategies.get(id)
346
+ if (strategy) {
347
+ try {
348
+ const isAvailable = await strategy.isAvailable()
349
+ if (isAvailable) {
350
+ available.push(strategy)
351
+ }
352
+ } catch {
353
+ // Strategy availability check failed, skip it
354
+ }
355
+ }
356
+ }
357
+
358
+ // Sort by priority (higher priority first)
359
+ return available.sort((a, b) => b.priority - a.priority)
360
+ }
361
+
362
+ /**
363
+ * Execute search on a single strategy with error handling.
364
+ */
365
+ private async executeStrategySearch(
366
+ strategy: SearchStrategy,
367
+ query: string,
368
+ options: SearchOptions,
369
+ ): Promise<SearchResult[]> {
370
+ await strategy.ensureReady()
371
+ return strategy.search(query, options)
372
+ }
373
+
374
+ /**
375
+ * Execute index on a single strategy with error handling.
376
+ */
377
+ private async executeStrategyIndex(
378
+ strategy: SearchStrategy,
379
+ record: IndexableRecord,
380
+ ): Promise<void> {
381
+ await strategy.ensureReady()
382
+ return strategy.index(record)
383
+ }
384
+
385
+ /**
386
+ * Execute delete on a single strategy with error handling.
387
+ */
388
+ private async executeStrategyDelete(
389
+ strategy: SearchStrategy,
390
+ entityId: string,
391
+ recordId: string,
392
+ tenantId: string,
393
+ ): Promise<void> {
394
+ await strategy.ensureReady()
395
+ return strategy.delete(entityId, recordId, tenantId)
396
+ }
397
+ }
@@ -0,0 +1,155 @@
1
+ import type {
2
+ SearchStrategy,
3
+ SearchStrategyId,
4
+ SearchOptions,
5
+ SearchResult,
6
+ IndexableRecord,
7
+ } from '../types'
8
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
9
+ import type {
10
+ FullTextSearchDriver,
11
+ FullTextSearchDocument,
12
+ FullTextSearchHit,
13
+ DocumentLookupKey,
14
+ IndexStats,
15
+ } from '../fulltext/types'
16
+
17
+ /**
18
+ * FullTextSearchStrategy provides full-text fuzzy search using a pluggable driver.
19
+ * Default driver is Meilisearch, but can be swapped for Algolia, Elasticsearch, etc.
20
+ */
21
+ export class FullTextSearchStrategy implements SearchStrategy {
22
+ readonly id: SearchStrategyId = 'fulltext'
23
+ readonly name = 'Full-Text Search'
24
+ readonly priority = 30 // Highest priority when available
25
+
26
+ constructor(private readonly driver: FullTextSearchDriver) {}
27
+
28
+ async isAvailable(): Promise<boolean> {
29
+ return this.driver.isHealthy()
30
+ }
31
+
32
+ async ensureReady(): Promise<void> {
33
+ return this.driver.ensureReady()
34
+ }
35
+
36
+ async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
37
+ const hits = await this.driver.search(query, {
38
+ tenantId: options.tenantId,
39
+ organizationId: options.organizationId,
40
+ entityTypes: options.entityTypes,
41
+ limit: options.limit,
42
+ offset: options.offset,
43
+ })
44
+
45
+ return hits.map((hit) => this.mapHitToResult(hit))
46
+ }
47
+
48
+ async index(record: IndexableRecord): Promise<void> {
49
+ const doc = this.mapRecordToDocument(record)
50
+ await this.driver.index(doc)
51
+ }
52
+
53
+ async delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void> {
54
+ return this.driver.delete(recordId, tenantId)
55
+ }
56
+
57
+ async bulkIndex(records: IndexableRecord[]): Promise<void> {
58
+ if (!this.driver.bulkIndex) {
59
+ // Fallback to sequential indexing
60
+ for (const record of records) {
61
+ await this.index(record)
62
+ }
63
+ return
64
+ }
65
+
66
+ const docs = records.map((record) => this.mapRecordToDocument(record))
67
+ return this.driver.bulkIndex(docs)
68
+ }
69
+
70
+ async purge(entityId: EntityId, tenantId: string): Promise<void> {
71
+ if (!this.driver.purge) {
72
+ return
73
+ }
74
+ return this.driver.purge(entityId, tenantId)
75
+ }
76
+
77
+ // Additional methods exposed for enrichment and admin purposes
78
+ // These delegate to optional driver methods
79
+
80
+ async clearIndex(tenantId: string): Promise<void> {
81
+ if (!this.driver.clearIndex) {
82
+ return
83
+ }
84
+ return this.driver.clearIndex(tenantId)
85
+ }
86
+
87
+ async recreateIndex(tenantId: string): Promise<void> {
88
+ if (!this.driver.recreateIndex) {
89
+ return
90
+ }
91
+ return this.driver.recreateIndex(tenantId)
92
+ }
93
+
94
+ async getDocuments(
95
+ ids: DocumentLookupKey[],
96
+ tenantId: string
97
+ ): Promise<Map<string, SearchResult>> {
98
+ if (!this.driver.getDocuments) {
99
+ return new Map()
100
+ }
101
+
102
+ const hits = await this.driver.getDocuments(ids, tenantId)
103
+ const result = new Map<string, SearchResult>()
104
+
105
+ for (const [key, hit] of hits) {
106
+ result.set(key, this.mapHitToResult(hit))
107
+ }
108
+
109
+ return result
110
+ }
111
+
112
+ async getIndexStats(tenantId: string): Promise<IndexStats | null> {
113
+ if (!this.driver.getIndexStats) {
114
+ return null
115
+ }
116
+ return this.driver.getIndexStats(tenantId)
117
+ }
118
+
119
+ async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {
120
+ if (!this.driver.getEntityCounts) {
121
+ return null
122
+ }
123
+ return this.driver.getEntityCounts(tenantId)
124
+ }
125
+
126
+ get driverId(): string {
127
+ return this.driver.id
128
+ }
129
+
130
+ private mapHitToResult(hit: FullTextSearchHit): SearchResult {
131
+ return {
132
+ entityId: hit.entityId,
133
+ recordId: hit.recordId,
134
+ score: hit.score,
135
+ source: this.id,
136
+ presenter: hit.presenter,
137
+ url: hit.url,
138
+ links: hit.links,
139
+ metadata: hit.metadata,
140
+ }
141
+ }
142
+
143
+ private mapRecordToDocument(record: IndexableRecord): FullTextSearchDocument {
144
+ return {
145
+ recordId: record.recordId,
146
+ entityId: record.entityId,
147
+ tenantId: record.tenantId,
148
+ organizationId: record.organizationId,
149
+ fields: record.fields,
150
+ presenter: record.presenter,
151
+ url: record.url,
152
+ links: record.links,
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,17 @@
1
+ export { TokenSearchStrategy, type TokenStrategyConfig } from './token.strategy'
2
+ export { VectorSearchStrategy, type VectorStrategyConfig, type EmbeddingService } from './vector.strategy'
3
+ export { FullTextSearchStrategy } from './fulltext.strategy'
4
+
5
+ // Re-export fulltext driver types for convenience
6
+ export type {
7
+ FullTextSearchDriver,
8
+ FullTextSearchDriverId,
9
+ FullTextSearchDocument,
10
+ FullTextSearchQuery,
11
+ FullTextSearchHit,
12
+ FullTextSearchDriverConfig,
13
+ DocumentLookupKey,
14
+ IndexStats,
15
+ } from '../fulltext/types'
16
+ export { createMeilisearchDriver, createFulltextDriver } from '../fulltext/drivers'
17
+ export type { MeilisearchDriverOptions } from '../fulltext/drivers/meilisearch'