@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,419 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import type { SearchStrategy } from '@open-mercato/shared/modules/search'
6
+ import type { SearchIndexer } from '@open-mercato/search/indexer'
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 type { EntityManager } from '@mikro-orm/postgresql'
11
+ import type { Knex } from 'knex'
12
+ import { searchDebug, searchError } from '../../../../lib/debug'
13
+ import {
14
+ acquireReindexLock,
15
+ clearReindexLock,
16
+ getReindexLockStatus,
17
+ } from '../../lib/reindex-lock'
18
+
19
+ /** Strategy with optional stats support */
20
+ type StrategyWithStats = SearchStrategy & {
21
+ getIndexStats?: (tenantId: string) => Promise<Record<string, unknown> | null>
22
+ clearIndex?: (tenantId: string) => Promise<void>
23
+ recreateIndex?: (tenantId: string) => Promise<void>
24
+ }
25
+
26
+ /** Collect stats from all strategies that support it */
27
+ async function collectStrategyStats(
28
+ strategies: StrategyWithStats[],
29
+ tenantId: string
30
+ ): Promise<Record<string, Record<string, unknown> | null>> {
31
+ const stats: Record<string, Record<string, unknown> | null> = {}
32
+ for (const strategy of strategies) {
33
+ if (typeof strategy.getIndexStats === 'function') {
34
+ try {
35
+ const isAvailable = await strategy.isAvailable()
36
+ if (isAvailable) {
37
+ stats[strategy.id] = await strategy.getIndexStats(tenantId)
38
+ }
39
+ } catch {
40
+ // Skip strategy if stats collection fails
41
+ }
42
+ }
43
+ }
44
+ return stats
45
+ }
46
+
47
+ export const metadata = {
48
+ POST: { requireAuth: true, requireFeatures: ['search.reindex'] },
49
+ }
50
+
51
+ type ReindexAction = 'clear' | 'recreate' | 'reindex'
52
+
53
+ const toJson = (payload: Record<string, unknown>, init?: ResponseInit) => NextResponse.json(payload, init)
54
+
55
+ const unauthorized = async () => {
56
+ const { t } = await resolveTranslations()
57
+ return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
58
+ }
59
+
60
+ export async function POST(req: Request) {
61
+ const { t } = await resolveTranslations()
62
+ const auth = await getAuthFromRequest(req)
63
+ if (!auth?.tenantId) {
64
+ return await unauthorized()
65
+ }
66
+
67
+ // Capture tenantId as non-null for TypeScript (we checked above)
68
+ const tenantId = auth.tenantId
69
+
70
+ let payload: { action?: ReindexAction; entityId?: string; useQueue?: boolean } = {}
71
+ try {
72
+ payload = await req.json()
73
+ } catch {
74
+ // Default to reindex
75
+ }
76
+
77
+ const action: ReindexAction =
78
+ payload.action === 'clear' ? 'clear' :
79
+ payload.action === 'recreate' ? 'recreate' : 'reindex'
80
+ const entityId = typeof payload.entityId === 'string' ? payload.entityId : undefined
81
+ // Use queue by default (requires queue workers to be running), can be disabled with useQueue: false
82
+ const useQueue = payload.useQueue !== false
83
+
84
+ const container = await createRequestContainer()
85
+ const em = container.resolve('em') as EntityManager
86
+ const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
87
+
88
+ // Check if another fulltext reindex operation is already in progress
89
+ const existingLock = await getReindexLockStatus(knex, tenantId, { type: 'fulltext' })
90
+ if (existingLock) {
91
+ const startedAt = new Date(existingLock.startedAt)
92
+ return NextResponse.json(
93
+ {
94
+ error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),
95
+ lock: {
96
+ type: existingLock.type,
97
+ action: existingLock.action,
98
+ startedAt: existingLock.startedAt,
99
+ elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),
100
+ processedCount: existingLock.processedCount,
101
+ totalCount: existingLock.totalCount,
102
+ },
103
+ },
104
+ { status: 409 }
105
+ )
106
+ }
107
+
108
+ // Acquire lock before starting the operation
109
+ const { acquired: lockAcquired } = await acquireReindexLock(knex, {
110
+ type: 'fulltext',
111
+ action,
112
+ tenantId: tenantId,
113
+ organizationId: auth.orgId ?? null,
114
+ })
115
+
116
+ if (!lockAcquired) {
117
+ return NextResponse.json(
118
+ { error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },
119
+ { status: 409 }
120
+ )
121
+ }
122
+
123
+ try {
124
+ // Get all search strategies
125
+ const searchStrategies = (container.resolve('searchStrategies') as StrategyWithStats[] | undefined) ?? []
126
+
127
+ // Find a strategy that supports index management (clear/recreate)
128
+ const indexableStrategy = searchStrategies.find(
129
+ (s) => typeof s.clearIndex === 'function' || typeof s.recreateIndex === 'function'
130
+ )
131
+
132
+ if (!indexableStrategy) {
133
+ return toJson(
134
+ { error: t('search.api.errors.noIndexableStrategy', 'No indexable search strategy is configured') },
135
+ { status: 503 }
136
+ )
137
+ }
138
+
139
+ // Check if strategy is available
140
+ const isAvailable = await indexableStrategy.isAvailable()
141
+ if (!isAvailable) {
142
+ return toJson(
143
+ { error: t('search.api.errors.strategyUnavailable', 'Search strategy is not available') },
144
+ { status: 503 }
145
+ )
146
+ }
147
+
148
+ // Perform the requested action
149
+ if (action === 'reindex') {
150
+ // Full reindex: recreate index and re-index all data
151
+ const searchIndexer = container.resolve('searchIndexer') as SearchIndexer | undefined
152
+ if (!searchIndexer) {
153
+ return toJson(
154
+ { error: t('search.api.errors.indexerUnavailable', 'Search indexer is not available') },
155
+ { status: 503 }
156
+ )
157
+ }
158
+
159
+ let result
160
+ const orgId = typeof auth.orgId === 'string' ? auth.orgId : null
161
+
162
+ // Debug: List enabled entities
163
+ const enabledEntities = searchIndexer.listEnabledEntities()
164
+ searchDebug('search.reindex', 'Starting reindex', {
165
+ tenantId: tenantId,
166
+ orgId,
167
+ enabledEntities,
168
+ entityId: entityId ?? 'all',
169
+ useQueue,
170
+ })
171
+
172
+ // Log reindex started
173
+ await recordIndexerLog(
174
+ { em },
175
+ {
176
+ source: 'fulltext',
177
+ handler: 'api:search.reindex',
178
+ message: entityId
179
+ ? `Starting Meilisearch reindex for ${entityId}`
180
+ : `Starting Meilisearch reindex for all entities (${enabledEntities.join(', ')})`,
181
+ entityType: entityId ?? null,
182
+ tenantId: tenantId,
183
+ organizationId: orgId,
184
+ details: { enabledEntities, useQueue },
185
+ },
186
+ )
187
+
188
+ if (entityId) {
189
+ // Reindex specific entity
190
+ result = await searchIndexer.reindexEntityToFulltext({
191
+ entityId: entityId as EntityId,
192
+ tenantId: tenantId,
193
+ organizationId: orgId,
194
+ recreateIndex: true,
195
+ useQueue,
196
+ onProgress: async (progress) => {
197
+ searchDebug('search.reindex', 'Progress', progress)
198
+ // Note: Heartbeat is updated by workers during job processing, not during enqueueing
199
+ },
200
+ })
201
+ searchDebug('search.reindex', 'Reindexed entity to Meilisearch', {
202
+ entityId,
203
+ tenantId: tenantId,
204
+ recordsIndexed: result.recordsIndexed,
205
+ jobsEnqueued: result.jobsEnqueued,
206
+ errors: result.errors,
207
+ })
208
+
209
+ // Log to indexer status logs
210
+ await recordIndexerLog(
211
+ { em },
212
+ {
213
+ source: 'fulltext',
214
+ handler: 'api:search.reindex',
215
+ message: useQueue
216
+ ? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of ${entityId}`
217
+ : `Reindexed ${result.recordsIndexed} records to Meilisearch for ${entityId}`,
218
+ entityType: entityId,
219
+ tenantId: tenantId,
220
+ organizationId: orgId,
221
+ details: {
222
+ recordsIndexed: result.recordsIndexed,
223
+ jobsEnqueued: result.jobsEnqueued,
224
+ useQueue,
225
+ errors: result.errors.length > 0 ? result.errors : undefined,
226
+ },
227
+ },
228
+ )
229
+
230
+ // Log any batch errors to error logs
231
+ for (const err of result.errors) {
232
+ await recordIndexerError(
233
+ { em },
234
+ {
235
+ source: 'fulltext',
236
+ handler: 'api:search.reindex',
237
+ error: new Error(err.error),
238
+ entityType: err.entityId,
239
+ tenantId: tenantId,
240
+ organizationId: orgId,
241
+ payload: { action, useQueue },
242
+ },
243
+ )
244
+ }
245
+ } else {
246
+ // Reindex all entities
247
+ result = await searchIndexer.reindexAllToFulltext({
248
+ tenantId: tenantId,
249
+ organizationId: orgId,
250
+ recreateIndex: true,
251
+ useQueue,
252
+ onProgress: async (progress) => {
253
+ searchDebug('search.reindex', 'Progress', progress)
254
+ // Note: Heartbeat is updated by workers during job processing, not during enqueueing
255
+ },
256
+ })
257
+ searchDebug('search.reindex', 'Reindexed all entities to Meilisearch', {
258
+ tenantId: tenantId,
259
+ entitiesProcessed: result.entitiesProcessed,
260
+ recordsIndexed: result.recordsIndexed,
261
+ jobsEnqueued: result.jobsEnqueued,
262
+ errors: result.errors,
263
+ })
264
+
265
+ // Log to indexer status logs
266
+ await recordIndexerLog(
267
+ { em },
268
+ {
269
+ source: 'fulltext',
270
+ handler: 'api:search.reindex',
271
+ message: useQueue
272
+ ? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of all entities`
273
+ : `Reindexed ${result.recordsIndexed} records to Meilisearch for ${result.entitiesProcessed} entities`,
274
+ tenantId: tenantId,
275
+ organizationId: orgId,
276
+ details: {
277
+ entitiesProcessed: result.entitiesProcessed,
278
+ recordsIndexed: result.recordsIndexed,
279
+ jobsEnqueued: result.jobsEnqueued,
280
+ useQueue,
281
+ errors: result.errors.length > 0 ? result.errors : undefined,
282
+ },
283
+ },
284
+ )
285
+
286
+ // Log any batch errors to error logs
287
+ for (const err of result.errors) {
288
+ await recordIndexerError(
289
+ { em },
290
+ {
291
+ source: 'fulltext',
292
+ handler: 'api:search.reindex',
293
+ error: new Error(err.error),
294
+ entityType: err.entityId,
295
+ tenantId: tenantId,
296
+ organizationId: orgId,
297
+ payload: { action, useQueue },
298
+ },
299
+ )
300
+ }
301
+ }
302
+
303
+ // Get updated stats from all strategies
304
+ const stats = await collectStrategyStats(searchStrategies, tenantId)
305
+
306
+ return toJson({
307
+ ok: result.success,
308
+ action,
309
+ entityId: entityId ?? null,
310
+ useQueue,
311
+ result: {
312
+ entitiesProcessed: result.entitiesProcessed,
313
+ recordsIndexed: result.recordsIndexed,
314
+ jobsEnqueued: result.jobsEnqueued ?? 0,
315
+ errors: result.errors.length > 0 ? result.errors : undefined,
316
+ },
317
+ stats,
318
+ })
319
+ } else if (entityId) {
320
+ // Purge specific entity
321
+ await indexableStrategy.purge?.(entityId as EntityId, tenantId)
322
+ searchDebug('search.reindex', 'Purged entity', { strategyId: indexableStrategy.id, entityId, tenantId: tenantId })
323
+
324
+ await recordIndexerLog(
325
+ { em },
326
+ {
327
+ source: 'fulltext',
328
+ handler: 'api:search.reindex',
329
+ message: `Purged entity ${entityId} from Meilisearch`,
330
+ entityType: entityId,
331
+ tenantId: tenantId,
332
+ organizationId: auth.orgId ?? null,
333
+ },
334
+ )
335
+ } else if (action === 'clear') {
336
+ // Clear all documents but keep index
337
+ if (indexableStrategy.clearIndex) {
338
+ await indexableStrategy.clearIndex(tenantId)
339
+ searchDebug('search.reindex', 'Cleared index', { strategyId: indexableStrategy.id, tenantId: tenantId })
340
+
341
+ await recordIndexerLog(
342
+ { em },
343
+ {
344
+ source: 'fulltext',
345
+ handler: 'api:search.reindex',
346
+ message: 'Cleared all documents from Meilisearch index',
347
+ tenantId: tenantId,
348
+ organizationId: auth.orgId ?? null,
349
+ },
350
+ )
351
+ }
352
+ } else {
353
+ // Recreate the entire index
354
+ if (indexableStrategy.recreateIndex) {
355
+ await indexableStrategy.recreateIndex(tenantId)
356
+ searchDebug('search.reindex', 'Recreated index', { strategyId: indexableStrategy.id, tenantId: tenantId })
357
+
358
+ await recordIndexerLog(
359
+ { em },
360
+ {
361
+ source: 'fulltext',
362
+ handler: 'api:search.reindex',
363
+ message: 'Recreated Meilisearch index',
364
+ tenantId: tenantId,
365
+ organizationId: auth.orgId ?? null,
366
+ },
367
+ )
368
+ }
369
+ }
370
+
371
+ // Get updated stats from all strategies
372
+ const stats = await collectStrategyStats(searchStrategies, tenantId)
373
+
374
+ return toJson({
375
+ ok: true,
376
+ action,
377
+ entityId: entityId ?? null,
378
+ stats,
379
+ })
380
+ } catch (error: unknown) {
381
+ // Log full error details server-side only
382
+ searchError('search.reindex', 'Failed', {
383
+ error: error instanceof Error ? error.message : error,
384
+ stack: error instanceof Error ? error.stack : undefined,
385
+ tenantId: tenantId,
386
+ })
387
+
388
+ // Record error to indexer error logs
389
+ await recordIndexerError(
390
+ { em },
391
+ {
392
+ source: 'fulltext',
393
+ handler: 'api:search.reindex',
394
+ error,
395
+ entityType: entityId ?? null,
396
+ tenantId: tenantId,
397
+ organizationId: auth.orgId ?? null,
398
+ payload: { action, entityId, useQueue },
399
+ },
400
+ )
401
+
402
+ // Return generic message to client - don't expose internal error details
403
+ return toJson(
404
+ { error: t('search.api.errors.reindexFailed', 'Reindex operation failed. Please try again or contact support.') },
405
+ { status: 500 }
406
+ )
407
+ } finally {
408
+ // Only clear lock immediately if NOT using queue mode
409
+ // When using queue mode, workers update heartbeat and stale detection handles cleanup
410
+ if (!useQueue) {
411
+ await clearReindexLock(knex, tenantId, 'fulltext', auth.orgId ?? null)
412
+ }
413
+
414
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
415
+ if (typeof disposable.dispose === 'function') {
416
+ await disposable.dispose()
417
+ }
418
+ }
419
+ }
@@ -0,0 +1,120 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import type { SearchService } from '@open-mercato/search'
6
+ import type { EmbeddingService } from '../../../../../vector'
7
+ import { resolveEmbeddingConfig } from '../../../lib/embedding-config'
8
+ import { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'
9
+ import { searchError } from '../../../../../lib/debug'
10
+
11
+ export const metadata = {
12
+ GET: { requireAuth: true, requireFeatures: ['search.view'] },
13
+ }
14
+
15
+ function parseLimit(value: string | null): number {
16
+ if (!value) return 50
17
+ const parsed = Number.parseInt(value, 10)
18
+ if (Number.isNaN(parsed) || parsed <= 0) return 50
19
+ return Math.min(parsed, 100)
20
+ }
21
+
22
+ function parseEntityTypes(value: string | null): string[] | undefined {
23
+ if (!value) return undefined
24
+ const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)
25
+ return entityTypes.length > 0 ? entityTypes : undefined
26
+ }
27
+
28
+ /**
29
+ * Global search endpoint for Cmd+K.
30
+ * Always uses saved global search settings - does NOT accept strategies from URL.
31
+ */
32
+ export async function GET(req: Request) {
33
+ const { t } = await resolveTranslations()
34
+ const url = new URL(req.url)
35
+ const query = (url.searchParams.get('q') || '').trim()
36
+ const limit = parseLimit(url.searchParams.get('limit'))
37
+ const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))
38
+
39
+ if (!query) {
40
+ return NextResponse.json(
41
+ { error: t('search.api.errors.missingQuery', 'Missing query') },
42
+ { status: 400 }
43
+ )
44
+ }
45
+
46
+ const auth = await getAuthFromRequest(req)
47
+ if (!auth?.tenantId) {
48
+ return NextResponse.json(
49
+ { error: t('api.errors.unauthorized', 'Unauthorized') },
50
+ { status: 401 }
51
+ )
52
+ }
53
+
54
+ const container = await createRequestContainer()
55
+ try {
56
+ const searchService = container.resolve('searchService') as SearchService | undefined
57
+ if (!searchService) {
58
+ return NextResponse.json(
59
+ { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },
60
+ { status: 503 }
61
+ )
62
+ }
63
+
64
+ // Fetch saved global search strategies
65
+ const strategies = await resolveGlobalSearchStrategies(container)
66
+
67
+ // Load embedding config for vector strategy (only if vector is enabled)
68
+ if (strategies.includes('vector')) {
69
+ try {
70
+ const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
71
+ if (embeddingConfig) {
72
+ const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')
73
+ embeddingService.updateConfig(embeddingConfig)
74
+ }
75
+ } catch {
76
+ // Embedding config not available, vector strategy may not work
77
+ }
78
+ }
79
+
80
+ const startTime = Date.now()
81
+
82
+ const searchOptions = {
83
+ tenantId: auth.tenantId,
84
+ organizationId: null,
85
+ limit,
86
+ strategies,
87
+ entityTypes,
88
+ }
89
+
90
+ const results = await searchService.search(query, searchOptions)
91
+
92
+ const timing = Date.now() - startTime
93
+
94
+ // Collect unique strategies that returned results
95
+ const strategiesUsed = [...new Set(results.map((r) => r.source))]
96
+
97
+ return NextResponse.json({
98
+ results,
99
+ strategiesUsed,
100
+ strategiesEnabled: strategies,
101
+ timing,
102
+ query,
103
+ limit,
104
+ })
105
+ } catch (error: unknown) {
106
+ searchError('search.api.global', 'failed', {
107
+ error: error instanceof Error ? error.message : error,
108
+ stack: error instanceof Error ? error.stack : undefined,
109
+ })
110
+ return NextResponse.json(
111
+ { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },
112
+ { status: 500 }
113
+ )
114
+ } finally {
115
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
116
+ if (typeof disposable.dispose === 'function') {
117
+ await disposable.dispose()
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,121 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import type { SearchService } from '@open-mercato/search'
6
+ import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
7
+ import type { EmbeddingService } from '../../../../vector'
8
+ import { resolveEmbeddingConfig } from '../../lib/embedding-config'
9
+ import { searchError } from '../../../../lib/debug'
10
+
11
+ export const metadata = {
12
+ GET: { requireAuth: true, requireFeatures: ['search.view'] },
13
+ }
14
+
15
+ function parseLimit(value: string | null): number {
16
+ if (!value) return 50
17
+ const parsed = Number.parseInt(value, 10)
18
+ if (Number.isNaN(parsed) || parsed <= 0) return 50
19
+ return Math.min(parsed, 100)
20
+ }
21
+
22
+ function parseStrategies(value: string | null): SearchStrategyId[] | undefined {
23
+ if (!value) return undefined
24
+ const strategies = value.split(',').map((s) => s.trim()).filter(Boolean)
25
+ return strategies.length > 0 ? strategies : undefined
26
+ }
27
+
28
+ function parseEntityTypes(value: string | null): string[] | undefined {
29
+ if (!value) return undefined
30
+ const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)
31
+ return entityTypes.length > 0 ? entityTypes : undefined
32
+ }
33
+
34
+ export async function GET(req: Request) {
35
+ const { t } = await resolveTranslations()
36
+ const url = new URL(req.url)
37
+ const query = (url.searchParams.get('q') || '').trim()
38
+ const limit = parseLimit(url.searchParams.get('limit'))
39
+ const strategies = parseStrategies(url.searchParams.get('strategies'))
40
+ const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))
41
+
42
+ if (!query) {
43
+ return NextResponse.json(
44
+ { error: t('search.api.errors.missingQuery', 'Missing query') },
45
+ { status: 400 }
46
+ )
47
+ }
48
+
49
+ const auth = await getAuthFromRequest(req)
50
+ if (!auth?.tenantId) {
51
+ return NextResponse.json(
52
+ { error: t('api.errors.unauthorized', 'Unauthorized') },
53
+ { status: 401 }
54
+ )
55
+ }
56
+
57
+ const container = await createRequestContainer()
58
+ try {
59
+ const searchService = container.resolve('searchService') as SearchService | undefined
60
+ if (!searchService) {
61
+ return NextResponse.json(
62
+ { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },
63
+ { status: 503 }
64
+ )
65
+ }
66
+
67
+ // Load embedding config for vector strategy (same as Vector Search playground)
68
+ try {
69
+ const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
70
+ if (embeddingConfig) {
71
+ const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')
72
+ embeddingService.updateConfig(embeddingConfig)
73
+ }
74
+ } catch {
75
+ // Embedding config not available, vector strategy may not work
76
+ }
77
+
78
+ const startTime = Date.now()
79
+
80
+ // Don't filter by organization in the playground - show all results
81
+ // Both strategies handle null as "no organization filter"
82
+ const searchOptions = {
83
+ tenantId: auth.tenantId,
84
+ organizationId: null,
85
+ limit,
86
+ strategies,
87
+ entityTypes,
88
+ }
89
+
90
+ const results = await searchService.search(query, searchOptions)
91
+
92
+ const timing = Date.now() - startTime
93
+
94
+ // Collect unique strategies that returned results
95
+ const strategiesUsed = [...new Set(results.map((r) => r.source))]
96
+
97
+ return NextResponse.json({
98
+ results,
99
+ strategiesUsed,
100
+ timing,
101
+ query,
102
+ limit,
103
+ })
104
+ } catch (error: unknown) {
105
+ // Log full error details server-side only
106
+ searchError('search.api.search', 'failed', {
107
+ error: error instanceof Error ? error.message : error,
108
+ stack: error instanceof Error ? error.stack : undefined,
109
+ })
110
+ // Return generic message to client - don't expose internal error details
111
+ return NextResponse.json(
112
+ { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },
113
+ { status: 500 }
114
+ )
115
+ } finally {
116
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
117
+ if (typeof disposable.dispose === 'function') {
118
+ await disposable.dispose()
119
+ }
120
+ }
121
+ }