@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,197 @@
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 type { SearchIndexer } from '../../../../../indexer/search-indexer'
5
+ import type { EmbeddingService } from '../../../../../vector'
6
+ import type { Knex } from 'knex'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
9
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
+ import { resolveEmbeddingConfig } from '../../../lib/embedding-config'
11
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
12
+ import { searchDebug, searchDebugWarn, searchError } from '../../../../../lib/debug'
13
+ import { acquireReindexLock, clearReindexLock, getReindexLockStatus } from '../../../lib/reindex-lock'
14
+
15
+ export const metadata = {
16
+ POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },
17
+ }
18
+
19
+ export async function POST(req: Request) {
20
+ const { t } = await resolveTranslations()
21
+ const auth = await getAuthFromRequest(req)
22
+ if (!auth?.tenantId) {
23
+ return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
24
+ }
25
+
26
+ let payload: { entityId?: string; purgeFirst?: boolean } = {}
27
+ try {
28
+ payload = await req.json()
29
+ } catch {
30
+ // Default values
31
+ }
32
+
33
+ const entityId = typeof payload?.entityId === 'string' ? payload.entityId : undefined
34
+ const purgeFirst = payload?.purgeFirst === true
35
+
36
+ const container = await createRequestContainer()
37
+ const em = container.resolve('em') as EntityManager
38
+ const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
39
+
40
+ // Check if another vector reindex operation is already in progress
41
+ const existingLock = await getReindexLockStatus(knex, auth.tenantId, { type: 'vector' })
42
+ if (existingLock) {
43
+ const startedAt = new Date(existingLock.startedAt)
44
+ return NextResponse.json(
45
+ {
46
+ error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),
47
+ lock: {
48
+ type: existingLock.type,
49
+ action: existingLock.action,
50
+ startedAt: existingLock.startedAt,
51
+ elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),
52
+ processedCount: existingLock.processedCount,
53
+ totalCount: existingLock.totalCount,
54
+ },
55
+ },
56
+ { status: 409 }
57
+ )
58
+ }
59
+
60
+ // Acquire lock before starting the operation
61
+ const { acquired: lockAcquired } = await acquireReindexLock(knex, {
62
+ type: 'vector',
63
+ action: entityId ? `reindex:${entityId}` : 'reindex:all',
64
+ tenantId: auth.tenantId,
65
+ organizationId: auth.orgId ?? null,
66
+ })
67
+
68
+ if (!lockAcquired) {
69
+ return NextResponse.json(
70
+ { error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },
71
+ { status: 409 }
72
+ )
73
+ }
74
+
75
+ try {
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ let em: any = null
78
+ try {
79
+ em = container.resolve('em')
80
+ } catch {
81
+ // em not available
82
+ }
83
+
84
+ let searchIndexer: SearchIndexer
85
+ try {
86
+ searchIndexer = container.resolve('searchIndexer') as SearchIndexer
87
+ } catch {
88
+ return NextResponse.json(
89
+ { error: t('search.api.errors.indexUnavailable', 'Search indexer unavailable') },
90
+ { status: 503 }
91
+ )
92
+ }
93
+
94
+ // Load saved embedding config and update the embedding service
95
+ try {
96
+ const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
97
+ if (embeddingConfig) {
98
+ const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')
99
+ embeddingService.updateConfig(embeddingConfig)
100
+ searchDebug('search.embeddings.reindex', 'using embedding config', {
101
+ providerId: embeddingConfig.providerId,
102
+ model: embeddingConfig.model,
103
+ dimension: embeddingConfig.dimension,
104
+ })
105
+ }
106
+ } catch (err) {
107
+ searchDebugWarn('search.embeddings.reindex', 'failed to load embedding config, using defaults', {
108
+ error: err instanceof Error ? err.message : err,
109
+ })
110
+ }
111
+
112
+ await recordIndexerLog(
113
+ { em: em ?? undefined },
114
+ {
115
+ source: 'vector',
116
+ handler: 'api:search.embeddings.reindex',
117
+ message: entityId
118
+ ? `Vector reindex requested for ${entityId}`
119
+ : 'Vector reindex requested for all entities',
120
+ entityType: entityId ?? null,
121
+ tenantId: auth.tenantId ?? null,
122
+ organizationId: auth.orgId ?? null,
123
+ details: { purgeFirst },
124
+ },
125
+ ).catch(() => undefined)
126
+
127
+ // Use queue-based vector reindexing (similar to fulltext)
128
+ // This enqueues batches for background processing by workers
129
+ let result
130
+ if (entityId) {
131
+ result = await searchIndexer.reindexEntityToVector({
132
+ entityId: entityId as EntityId,
133
+ tenantId: auth.tenantId,
134
+ organizationId: auth.orgId ?? null,
135
+ purgeFirst,
136
+ useQueue: true,
137
+ })
138
+ } else {
139
+ result = await searchIndexer.reindexAllToVector({
140
+ tenantId: auth.tenantId,
141
+ organizationId: auth.orgId ?? null,
142
+ purgeFirst,
143
+ useQueue: true,
144
+ })
145
+ }
146
+
147
+ await recordIndexerLog(
148
+ { em: em ?? undefined },
149
+ {
150
+ source: 'vector',
151
+ handler: 'api:search.embeddings.reindex',
152
+ message: result.jobsEnqueued
153
+ ? `Vector reindex enqueued ${result.jobsEnqueued} jobs for ${entityId ?? 'all entities'}`
154
+ : `Vector reindex completed for ${entityId ?? 'all entities'}`,
155
+ entityType: entityId ?? null,
156
+ tenantId: auth.tenantId ?? null,
157
+ organizationId: auth.orgId ?? null,
158
+ details: {
159
+ purgeFirst,
160
+ recordsIndexed: result.recordsIndexed,
161
+ jobsEnqueued: result.jobsEnqueued,
162
+ success: result.success,
163
+ },
164
+ },
165
+ ).catch(() => undefined)
166
+
167
+ return NextResponse.json({
168
+ ok: result.success,
169
+ recordsIndexed: result.recordsIndexed,
170
+ jobsEnqueued: result.jobsEnqueued,
171
+ entitiesProcessed: result.entitiesProcessed,
172
+ errors: result.errors.length > 0 ? result.errors : undefined,
173
+ })
174
+ } catch (error: unknown) {
175
+ const err = error as { message?: string; status?: number; statusCode?: number }
176
+ const status = typeof err?.status === 'number'
177
+ ? err.status
178
+ : (typeof err?.statusCode === 'number' ? err.statusCode : 500)
179
+ searchError('search.embeddings.reindex', 'failed', {
180
+ error: error instanceof Error ? error.message : error,
181
+ stack: error instanceof Error ? error.stack : undefined,
182
+ status,
183
+ })
184
+ return NextResponse.json(
185
+ { error: t('search.api.errors.reindexFailed', 'Vector reindex failed. Please try again or contact support.') },
186
+ { status: status >= 400 ? status : 500 }
187
+ )
188
+ } finally {
189
+ // Do NOT clear lock here - vector reindex always uses queue mode
190
+ // Workers update heartbeat and stale detection handles cleanup when done
191
+
192
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
193
+ if (typeof disposable.dispose === 'function') {
194
+ await disposable.dispose()
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,304 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
6
+ import { envDisablesAutoIndexing, resolveAutoIndexingEnabled, SEARCH_AUTO_INDEX_CONFIG_KEY } from '../../lib/auto-indexing'
7
+ import {
8
+ resolveEmbeddingConfig,
9
+ saveEmbeddingConfig,
10
+ getConfiguredProviders,
11
+ detectConfigChange,
12
+ getEffectiveDimension,
13
+ } from '../../lib/embedding-config'
14
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
15
+ import type { EmbeddingProviderConfig, EmbeddingProviderId, VectorDriver } from '../../../../vector'
16
+ import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG, EmbeddingService } from '../../../../vector'
17
+ import { searchDebug, searchDebugWarn, searchError } from '../../../../lib/debug'
18
+
19
+ const embeddingConfigSchema = z.object({
20
+ providerId: z.enum(['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama']),
21
+ model: z.string(),
22
+ dimension: z.number(),
23
+ outputDimensionality: z.number().optional(),
24
+ baseUrl: z.string().optional(),
25
+ })
26
+
27
+ const updateSchema = z.object({
28
+ autoIndexingEnabled: z.boolean().optional(),
29
+ embeddingConfig: embeddingConfigSchema.optional(),
30
+ })
31
+
32
+ export const metadata = {
33
+ GET: { requireAuth: true, requireFeatures: ['search.embeddings.view'] },
34
+ POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },
35
+ }
36
+
37
+ type SettingsResponse = {
38
+ settings: {
39
+ openaiConfigured: boolean
40
+ autoIndexingEnabled: boolean
41
+ autoIndexingLocked: boolean
42
+ lockReason: string | null
43
+ embeddingConfig: EmbeddingProviderConfig | null
44
+ configuredProviders: EmbeddingProviderId[]
45
+ indexedDimension: number | null
46
+ reindexRequired: boolean
47
+ documentCount: number | null
48
+ }
49
+ }
50
+
51
+ const openAiConfigured = () => Boolean(process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.trim().length > 0)
52
+
53
+ const toJson = (payload: SettingsResponse, 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
+ const configUnavailable = async () => {
61
+ const { t } = await resolveTranslations()
62
+ return NextResponse.json({ error: t('search.api.errors.configUnavailable', 'Configuration service unavailable') }, { status: 503 })
63
+ }
64
+
65
+ async function getIndexedDimension(container: { resolve: <T = unknown>(name: string) => T }): Promise<number | null> {
66
+ try {
67
+ const drivers = container.resolve<VectorDriver[]>('vectorDrivers')
68
+ const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')
69
+ if (pgvectorDriver?.getTableDimension) {
70
+ return await pgvectorDriver.getTableDimension()
71
+ }
72
+ return null
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ async function getVectorDocumentCount(
79
+ container: { resolve: <T = unknown>(name: string) => T },
80
+ tenantId: string,
81
+ organizationId?: string | null,
82
+ ): Promise<number | null> {
83
+ try {
84
+ const drivers = container.resolve<VectorDriver[]>('vectorDrivers')
85
+ const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')
86
+ if (pgvectorDriver?.count) {
87
+ return await pgvectorDriver.count({ tenantId, organizationId: organizationId ?? undefined })
88
+ }
89
+ return null
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ export async function GET(req: Request) {
96
+ const auth = await getAuthFromRequest(req)
97
+ if (!auth?.sub) return await unauthorized()
98
+
99
+ const container = await createRequestContainer()
100
+ try {
101
+ const lockedByEnv = envDisablesAutoIndexing()
102
+ let autoIndexingEnabled = !lockedByEnv
103
+ if (!lockedByEnv) {
104
+ try {
105
+ autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })
106
+ } catch {
107
+ autoIndexingEnabled = true
108
+ }
109
+ }
110
+
111
+ const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
112
+ const configuredProviders = getConfiguredProviders()
113
+ const indexedDimension = await getIndexedDimension(container)
114
+
115
+ const effectiveDimension = embeddingConfig
116
+ ? getEffectiveDimension(embeddingConfig)
117
+ : DEFAULT_EMBEDDING_CONFIG.dimension
118
+
119
+ const reindexRequired = Boolean(
120
+ indexedDimension &&
121
+ embeddingConfig &&
122
+ indexedDimension !== effectiveDimension
123
+ )
124
+
125
+ // Get document count for vector index
126
+ const documentCount = auth.tenantId
127
+ ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)
128
+ : null
129
+
130
+ return toJson({
131
+ settings: {
132
+ openaiConfigured: openAiConfigured(),
133
+ autoIndexingEnabled: lockedByEnv ? false : autoIndexingEnabled,
134
+ autoIndexingLocked: lockedByEnv,
135
+ lockReason: lockedByEnv ? 'env' : null,
136
+ embeddingConfig,
137
+ configuredProviders,
138
+ indexedDimension,
139
+ reindexRequired,
140
+ documentCount,
141
+ },
142
+ })
143
+ } finally {
144
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
145
+ if (typeof disposable.dispose === 'function') {
146
+ await disposable.dispose()
147
+ }
148
+ }
149
+ }
150
+
151
+ export async function POST(req: Request) {
152
+ const { t } = await resolveTranslations()
153
+ const auth = await getAuthFromRequest(req)
154
+ if (!auth?.sub) return await unauthorized()
155
+
156
+ let body: unknown
157
+ try {
158
+ body = await req.json()
159
+ } catch {
160
+ return NextResponse.json({ error: t('api.errors.invalidJson', 'Invalid JSON payload.') }, { status: 400 })
161
+ }
162
+ const parsed = updateSchema.safeParse(body)
163
+ if (!parsed.success) {
164
+ return NextResponse.json({ error: t('api.errors.invalidPayload', 'Invalid payload.') }, { status: 400 })
165
+ }
166
+
167
+ const container = await createRequestContainer()
168
+ try {
169
+ let service: ModuleConfigService
170
+ try {
171
+ service = (container.resolve('moduleConfigService') as ModuleConfigService)
172
+ } catch {
173
+ return await configUnavailable()
174
+ }
175
+
176
+ if (parsed.data.autoIndexingEnabled !== undefined) {
177
+ if (envDisablesAutoIndexing()) {
178
+ return NextResponse.json(
179
+ { error: t('search.api.errors.autoIndexingDisabled', 'Auto-indexing is disabled via DISABLE_VECTOR_SEARCH_AUTOINDEXING.') },
180
+ { status: 409 },
181
+ )
182
+ }
183
+ await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled)
184
+ }
185
+
186
+ let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
187
+ let reindexRequired = false
188
+ let indexedDimension = await getIndexedDimension(container)
189
+
190
+ if (parsed.data.embeddingConfig) {
191
+ const newConfig = parsed.data.embeddingConfig
192
+ const providerInfo = EMBEDDING_PROVIDERS[newConfig.providerId]
193
+
194
+ if (!providerInfo) {
195
+ return NextResponse.json(
196
+ { error: t('search.api.errors.invalidProvider', 'Invalid embedding provider.') },
197
+ { status: 400 },
198
+ )
199
+ }
200
+
201
+ const configuredProviders = getConfiguredProviders()
202
+ if (!configuredProviders.includes(newConfig.providerId)) {
203
+ return NextResponse.json(
204
+ { error: t('search.api.errors.providerNotConfigured', `Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`) },
205
+ { status: 400 },
206
+ )
207
+ }
208
+
209
+ const change = detectConfigChange(
210
+ embeddingConfig,
211
+ {
212
+ ...newConfig,
213
+ updatedAt: new Date().toISOString(),
214
+ },
215
+ indexedDimension
216
+ )
217
+
218
+ if (change.requiresReindex) {
219
+ const newDimension = getEffectiveDimension(change.newConfig)
220
+ searchDebug('search.embeddings.update', 'config change detected, recreating table', {
221
+ requiresReindex: change.requiresReindex,
222
+ reason: change.reason,
223
+ oldDimension: indexedDimension,
224
+ newDimension,
225
+ })
226
+ try {
227
+ const drivers = container.resolve<VectorDriver[]>('vectorDrivers')
228
+ const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')
229
+ if (pgvectorDriver?.recreateWithDimension) {
230
+ await pgvectorDriver.recreateWithDimension(newDimension)
231
+ // Query the actual dimension from the database to confirm
232
+ if (pgvectorDriver.getTableDimension) {
233
+ indexedDimension = await pgvectorDriver.getTableDimension()
234
+ } else {
235
+ indexedDimension = newDimension
236
+ }
237
+ searchDebug('search.embeddings.update', 'table recreated successfully', { indexedDimension })
238
+ } else {
239
+ searchDebugWarn('search.embeddings.update', 'pgvector driver does not have recreateWithDimension method')
240
+ }
241
+ } catch (error) {
242
+ searchError('search.embeddings.update', 'failed to recreate table', {
243
+ error: error instanceof Error ? error.message : error,
244
+ })
245
+ return NextResponse.json(
246
+ { error: t('search.api.errors.recreateFailed', 'Failed to recreate vector table with new dimension.') },
247
+ { status: 500 },
248
+ )
249
+ }
250
+ }
251
+
252
+ await saveEmbeddingConfig(container, change.newConfig)
253
+ embeddingConfig = change.newConfig
254
+
255
+ try {
256
+ const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')
257
+ embeddingService.updateConfig(embeddingConfig)
258
+ } catch {
259
+ // Embedding service may not be available in all contexts
260
+ }
261
+
262
+ reindexRequired = change.requiresReindex
263
+ }
264
+
265
+ const lockedByEnv = envDisablesAutoIndexing()
266
+ let autoIndexingEnabled = !lockedByEnv
267
+ if (!lockedByEnv) {
268
+ try {
269
+ autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })
270
+ } catch {
271
+ autoIndexingEnabled = true
272
+ }
273
+ }
274
+
275
+ // Get updated document count
276
+ const updatedDocumentCount = auth.tenantId
277
+ ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)
278
+ : null
279
+
280
+ return toJson({
281
+ settings: {
282
+ openaiConfigured: openAiConfigured(),
283
+ autoIndexingEnabled: lockedByEnv ? false : autoIndexingEnabled,
284
+ autoIndexingLocked: lockedByEnv,
285
+ lockReason: lockedByEnv ? 'env' : null,
286
+ embeddingConfig,
287
+ configuredProviders: getConfiguredProviders(),
288
+ indexedDimension,
289
+ reindexRequired,
290
+ documentCount: updatedDocumentCount,
291
+ },
292
+ })
293
+ } catch (error) {
294
+ searchError('search.embeddings.update', 'failed', {
295
+ error: error instanceof Error ? error.message : error,
296
+ })
297
+ return NextResponse.json({ error: t('search.api.errors.updateFailed', 'Failed to update embedding settings.') }, { status: 500 })
298
+ } finally {
299
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
300
+ if (typeof disposable.dispose === 'function') {
301
+ await disposable.dispose()
302
+ }
303
+ }
304
+ }