@open-mercato/search 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +7 -1
  3. package/dist/lib/presenter-enricher.js +12 -12
  4. package/dist/lib/presenter-enricher.js.map +2 -2
  5. package/dist/modules/search/ai-tools.js +5 -2
  6. package/dist/modules/search/ai-tools.js.map +2 -2
  7. package/dist/modules/search/api/openapi.js +2 -0
  8. package/dist/modules/search/api/openapi.js.map +2 -2
  9. package/dist/modules/search/api/settings/vector-store/route.js +34 -0
  10. package/dist/modules/search/api/settings/vector-store/route.js.map +2 -2
  11. package/dist/modules/search/events.js +13 -0
  12. package/dist/modules/search/events.js.map +7 -0
  13. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +2 -9
  14. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +2 -2
  15. package/dist/modules/search/frontend/components/HybridSearchTable.js +2 -11
  16. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +2 -2
  17. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +2 -2
  18. package/dist/modules/search/frontend/components/TopbarSearchInline.js +2 -9
  19. package/dist/modules/search/frontend/components/TopbarSearchInline.js.map +2 -2
  20. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +15 -3
  21. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +2 -2
  22. package/dist/modules/search/frontend/lib/entityTypeLabel.js +19 -0
  23. package/dist/modules/search/frontend/lib/entityTypeLabel.js.map +7 -0
  24. package/dist/modules/search/i18n/de.json +49 -0
  25. package/dist/modules/search/i18n/en.json +49 -0
  26. package/dist/modules/search/i18n/es.json +49 -0
  27. package/dist/modules/search/i18n/ko.json +239 -0
  28. package/dist/modules/search/i18n/pl.json +49 -0
  29. package/dist/modules/search/lib/auto-indexing.js +7 -8
  30. package/dist/modules/search/lib/auto-indexing.js.map +2 -2
  31. package/dist/service.js +2 -5
  32. package/dist/service.js.map +2 -2
  33. package/dist/strategies/vector.strategy.js +3 -1
  34. package/dist/strategies/vector.strategy.js.map +2 -2
  35. package/dist/vector/drivers/pgvector/index.js +55 -1
  36. package/dist/vector/drivers/pgvector/index.js.map +2 -2
  37. package/dist/vector/types.js.map +2 -2
  38. package/package.json +11 -10
  39. package/src/__tests__/pgvector-extension-availability.test.ts +158 -0
  40. package/src/__tests__/presenter-enricher.test.ts +48 -0
  41. package/src/__tests__/presenter-locale.test.ts +76 -0
  42. package/src/lib/presenter-enricher.ts +14 -14
  43. package/src/modules/search/README.md +4 -1
  44. package/src/modules/search/ai-tools.ts +5 -2
  45. package/src/modules/search/api/__tests__/global-search.routes.test.ts +167 -0
  46. package/src/modules/search/api/openapi.ts +2 -0
  47. package/src/modules/search/api/settings/vector-store/route.ts +43 -0
  48. package/src/modules/search/events.ts +10 -0
  49. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +2 -15
  50. package/src/modules/search/frontend/components/HybridSearchTable.tsx +2 -17
  51. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +2 -0
  52. package/src/modules/search/frontend/components/TopbarSearchInline.tsx +2 -15
  53. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +32 -2
  54. package/src/modules/search/frontend/components/sections/__tests__/VectorSearchSection.test.tsx +36 -1
  55. package/src/modules/search/frontend/lib/__tests__/entityTypeLabel.test.ts +25 -0
  56. package/src/modules/search/frontend/lib/entityTypeLabel.ts +22 -0
  57. package/src/modules/search/i18n/de.json +49 -0
  58. package/src/modules/search/i18n/en.json +49 -0
  59. package/src/modules/search/i18n/es.json +49 -0
  60. package/src/modules/search/i18n/ko.json +239 -0
  61. package/src/modules/search/i18n/pl.json +49 -0
  62. package/src/modules/search/lib/auto-indexing.ts +7 -12
  63. package/src/service.ts +2 -7
  64. package/src/strategies/vector.strategy.ts +7 -1
  65. package/src/vector/drivers/pgvector/index.ts +77 -1
  66. package/src/vector/types.ts +14 -0
@@ -0,0 +1,158 @@
1
+ import { createPgVectorDriver, type PgPool } from '../vector/drivers/pgvector'
2
+ import { VectorSearchStrategy } from '../strategies/vector.strategy'
3
+ import { SearchService } from '../service'
4
+ import type { IndexableRecord } from '../types'
5
+
6
+ type QueryCall = { text: string; params?: unknown[] }
7
+
8
+ function pgError(code: string, message: string): Error & { code: string } {
9
+ return Object.assign(new Error(message), { code })
10
+ }
11
+
12
+ function createPool(options: {
13
+ extensionInstalled?: boolean
14
+ extensionInstallable?: boolean
15
+ calls: QueryCall[]
16
+ }): PgPool {
17
+ const { calls } = options
18
+ const run = async (text: string, params?: unknown[]) => {
19
+ calls.push({ text, params })
20
+ if (/pg_available_extensions/.test(text)) {
21
+ return {
22
+ rows: [{
23
+ installed: options.extensionInstalled ?? false,
24
+ installable: options.extensionInstallable ?? false,
25
+ }],
26
+ }
27
+ }
28
+ if (/CREATE EXTENSION IF NOT EXISTS vector/.test(text)) {
29
+ if (options.extensionInstalled || options.extensionInstallable) return { rows: [] }
30
+ throw pgError('58P01', 'extension "vector" is not available')
31
+ }
32
+ return { rows: [] }
33
+ }
34
+ return {
35
+ connect: async () => ({ query: run as PgPool['query'], release: () => {} }),
36
+ query: run as PgPool['query'],
37
+ end: async () => {},
38
+ }
39
+ }
40
+
41
+ describe('pgvector extension availability', () => {
42
+ it('reports the driver unhealthy when the extension cannot be installed', async () => {
43
+ const calls: QueryCall[] = []
44
+ const driver = createPgVectorDriver({ pool: createPool({ calls }) })
45
+
46
+ expect(await driver.isHealthy!()).toBe(false)
47
+ const status = await driver.getStatus!()
48
+ expect(status.available).toBe(false)
49
+ // Curated, not the raw Postgres message: the settings API surfaces this to the browser.
50
+ expect(status.reason).toBe('extension "vector" is not available on this PostgreSQL server')
51
+ })
52
+
53
+ it('probes the catalog once instead of running DDL for every record', async () => {
54
+ const calls: QueryCall[] = []
55
+ const driver = createPgVectorDriver({ pool: createPool({ calls }) })
56
+
57
+ for (let attempt = 0; attempt < 5; attempt += 1) {
58
+ expect(await driver.isHealthy!()).toBe(false)
59
+ }
60
+
61
+ const probes = calls.filter((call) => /pg_available_extensions/.test(call.text))
62
+ expect(probes).toHaveLength(1)
63
+ expect(calls.some((call) => /CREATE TABLE/.test(call.text))).toBe(false)
64
+ })
65
+
66
+ it('short-circuits ensureReady once the extension is known to be missing', async () => {
67
+ const calls: QueryCall[] = []
68
+ const driver = createPgVectorDriver({ pool: createPool({ calls }) })
69
+
70
+ await expect(driver.ensureReady()).rejects.toThrow(/vector/)
71
+ const afterFirst = calls.length
72
+ await expect(driver.ensureReady()).rejects.toThrow(/vector/)
73
+
74
+ expect(calls.length).toBe(afterFirst)
75
+ })
76
+
77
+ it('stays healthy when the extension is installable', async () => {
78
+ const calls: QueryCall[] = []
79
+ const driver = createPgVectorDriver({ pool: createPool({ calls, extensionInstallable: true }) })
80
+
81
+ expect(await driver.isHealthy!()).toBe(true)
82
+ await expect(driver.ensureReady()).resolves.toBeUndefined()
83
+ })
84
+
85
+ it('keeps the existing superuser-less degradation path', async () => {
86
+ const calls: QueryCall[] = []
87
+ const pool = createPool({ calls, extensionInstalled: true })
88
+ const originalQuery = pool.query
89
+ pool.query = (async (text: string, params?: unknown[]) => {
90
+ if (/CREATE EXTENSION IF NOT EXISTS vector/.test(text)) {
91
+ calls.push({ text, params })
92
+ throw pgError('42501', 'permission denied to create extension "vector"')
93
+ }
94
+ return originalQuery(text, params)
95
+ }) as PgPool['query']
96
+ pool.connect = async () => ({ query: pool.query as never, release: () => {} })
97
+
98
+ const driver = createPgVectorDriver({ pool })
99
+ await expect(driver.ensureReady()).resolves.toBeUndefined()
100
+ expect(await driver.isHealthy!()).toBe(true)
101
+ })
102
+ })
103
+
104
+ describe('vector strategy availability', () => {
105
+ const embeddingService = { available: true, createEmbedding: jest.fn() }
106
+
107
+ function createDriver(overrides: Partial<Record<string, unknown>> = {}) {
108
+ return {
109
+ id: 'pgvector' as const,
110
+ ensureReady: jest.fn().mockResolvedValue(undefined),
111
+ upsert: jest.fn().mockResolvedValue(undefined),
112
+ delete: jest.fn().mockResolvedValue(undefined),
113
+ query: jest.fn().mockResolvedValue([]),
114
+ getChecksum: jest.fn().mockResolvedValue(null),
115
+ ...overrides,
116
+ }
117
+ }
118
+
119
+ it('is unavailable when the store reports itself unhealthy', async () => {
120
+ const driver = createDriver({ isHealthy: jest.fn().mockResolvedValue(false) })
121
+ const strategy = new VectorSearchStrategy(embeddingService, driver)
122
+
123
+ expect(await strategy.isAvailable()).toBe(false)
124
+ })
125
+
126
+ it('stays available for drivers that expose no probe', async () => {
127
+ const strategy = new VectorSearchStrategy(embeddingService, createDriver())
128
+
129
+ expect(await strategy.isAvailable()).toBe(true)
130
+ })
131
+
132
+ it('never touches the store on delete when it is unhealthy', async () => {
133
+ const driver = createDriver({ isHealthy: jest.fn().mockResolvedValue(false) })
134
+ const strategy = new VectorSearchStrategy(embeddingService, driver)
135
+ const service = new SearchService({ strategies: [strategy] })
136
+
137
+ await expect(service.delete('customers:customer_deal', 'rec-1', 'tenant-1')).resolves.toBeUndefined()
138
+ expect(driver.ensureReady).not.toHaveBeenCalled()
139
+ expect(driver.delete).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('never touches the store on index when it is unhealthy', async () => {
143
+ const driver = createDriver({ isHealthy: jest.fn().mockResolvedValue(false) })
144
+ const strategy = new VectorSearchStrategy(embeddingService, driver)
145
+ const service = new SearchService({ strategies: [strategy] })
146
+ const record: IndexableRecord = {
147
+ entityId: 'customers:customer_deal',
148
+ recordId: 'rec-1',
149
+ tenantId: 'tenant-1',
150
+ fields: { name: 'Deal' },
151
+ text: 'Deal',
152
+ }
153
+
154
+ await expect(service.index(record)).resolves.toBeUndefined()
155
+ expect(driver.ensureReady).not.toHaveBeenCalled()
156
+ expect(driver.upsert).not.toHaveBeenCalled()
157
+ })
158
+ })
@@ -3,6 +3,7 @@ import type { Kysely } from 'kysely'
3
3
  import type { SearchEntityConfig } from '../types'
4
4
  import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
5
5
  import type { SearchResult } from '@open-mercato/shared/modules/search'
6
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
6
7
  import { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'
7
8
  import { createPresenterEnricher } from '../lib/presenter-enricher'
8
9
 
@@ -142,4 +143,51 @@ describe('createPresenterEnricher', () => {
142
143
  expect(resolveLinks).toHaveBeenCalled()
143
144
  expect(enriched.links).toEqual([{ href: '/backend/customers/person-1', label: 'View', kind: 'primary' }])
144
145
  })
146
+
147
+ it('re-renders a result that already has a stored presenter when the entity has a config', async () => {
148
+ const doc = { id: 'rec-1', display_name: 'Ada' }
149
+ mockedDecryptIndexDocForSearch.mockResolvedValue(doc)
150
+
151
+ const formatResult = jest.fn(async () => ({ title: 'Fresh Title', badge: 'Fresh' }))
152
+ const config = createConfig({
153
+ entityId: 'customers:customer_person_profile' as EntityId,
154
+ enabled: true,
155
+ formatResult,
156
+ })
157
+ const entityConfigMap = new Map<EntityId, SearchEntityConfig>([[config.entityId, config]])
158
+ const db = createKyselyMock([
159
+ { entity_type: 'customers:customer_person_profile', entity_id: 'rec-1', doc },
160
+ ])
161
+
162
+ const enrich = createPresenterEnricher(db, entityConfigMap)
163
+ const results: SearchResult[] = [{
164
+ entityId: 'customers:customer_person_profile' as EntityId,
165
+ recordId: 'rec-1',
166
+ score: 1,
167
+ source: 'fulltext',
168
+ presenter: { title: 'Stale English Title' },
169
+ url: '/x',
170
+ }]
171
+
172
+ const [enriched] = await enrich(results, 'tenant-1', null)
173
+
174
+ expect(formatResult).toHaveBeenCalledTimes(1)
175
+ expect(enriched.presenter?.title).toBe('Fresh Title')
176
+ })
177
+
178
+ it('keeps the stored presenter when the entity has no config', async () => {
179
+ const db = createKyselyMock([])
180
+ const enrich = createPresenterEnricher(db, new Map(), undefined)
181
+ const results: SearchResult[] = [{
182
+ entityId: 'unknown:thing' as EntityId,
183
+ recordId: 'rec-9',
184
+ score: 1,
185
+ source: 'fulltext',
186
+ presenter: { title: 'Stored' },
187
+ url: '/y',
188
+ }]
189
+
190
+ const [enriched] = await enrich(results, 'tenant-1', null)
191
+ expect(enriched.presenter?.title).toBe('Stored')
192
+ })
145
193
  })
@@ -0,0 +1,76 @@
1
+ jest.mock('next/headers', () => ({
2
+ cookies: async () => ({ get: (name: string) => (name === 'locale' ? { value: 'pl' } : undefined) }),
3
+ headers: async () => ({ get: () => '' }),
4
+ }))
5
+
6
+ import type { Kysely } from 'kysely'
7
+ import { createPresenterEnricher } from '../lib/presenter-enricher'
8
+ import { registerModules, resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
10
+ import type { Module } from '@open-mercato/shared/modules/registry'
11
+ import type { SearchEntityConfig, SearchResult } from '../types'
12
+
13
+ const DEMO_ENTITY_ID = 'demo:thing' as EntityId
14
+
15
+ function makeDbReturning(rows: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>) {
16
+ const chain = {
17
+ selectFrom: () => chain,
18
+ select: () => chain,
19
+ where: () => chain,
20
+ execute: async () => rows,
21
+ }
22
+ return chain as unknown as Kysely<Record<string, never>>
23
+ }
24
+
25
+ describe('request-time presenter localization', () => {
26
+ beforeAll(() => {
27
+ registerModules([
28
+ {
29
+ id: 'demo',
30
+ translations: {
31
+ en: { 'demo.search.badge': 'Person', 'demo.search.link.open': 'Open person' },
32
+ pl: { 'demo.search.badge': 'Osoba', 'demo.search.link.open': 'Otwórz osobę' },
33
+ },
34
+ },
35
+ ] satisfies Module[])
36
+ })
37
+
38
+ it('renders the presenter badge and link label in the request locale (pl)', async () => {
39
+ const { locale, t } = await resolveTranslations()
40
+ expect(locale).toBe('pl')
41
+ expect(t('demo.search.badge', 'Person')).toBe('Osoba')
42
+
43
+ const config: SearchEntityConfig = {
44
+ entityId: DEMO_ENTITY_ID,
45
+ enabled: true,
46
+ formatResult: async () => {
47
+ const { t: translate } = await resolveTranslations()
48
+ return { title: 'Ada', badge: translate('demo.search.badge', 'Person') }
49
+ },
50
+ resolveLinks: async () => {
51
+ const { t: translate } = await resolveTranslations()
52
+ return [{ href: '/x', label: translate('demo.search.link.open', 'Open person') }]
53
+ },
54
+ }
55
+
56
+ const entityConfigMap = new Map<EntityId, SearchEntityConfig>([[DEMO_ENTITY_ID, config]])
57
+ const db = makeDbReturning([
58
+ { entity_type: 'demo:thing', entity_id: 'rec-1', doc: { id: 'rec-1', display_name: 'Ada' } },
59
+ ])
60
+
61
+ const enrich = createPresenterEnricher(db, entityConfigMap)
62
+ const results: SearchResult[] = [{
63
+ entityId: DEMO_ENTITY_ID,
64
+ recordId: 'rec-1',
65
+ score: 1,
66
+ source: 'fulltext',
67
+ presenter: { title: 'Ada', badge: 'Person' },
68
+ links: [{ href: '/x', label: 'Open person', kind: 'primary' }],
69
+ }]
70
+
71
+ const [enriched] = await enrich(results, 'tenant-1', null)
72
+
73
+ expect(enriched.presenter?.badge).toBe('Osoba')
74
+ expect(enriched.links?.[0]?.label).toBe('Otwórz osobę')
75
+ })
76
+ })
@@ -137,23 +137,22 @@ async function computePresenterAndLinks(
137
137
  queryEngine,
138
138
  }
139
139
 
140
- // If search.ts config exists, use formatResult/buildSource for presenter
141
140
  if (config?.formatResult || config?.buildSource) {
142
- if (config.buildSource) {
141
+ if (config.formatResult) {
143
142
  try {
144
- const source = await config.buildSource(buildContext)
145
- if (source?.presenter) presenter = source.presenter
146
- if (source?.links) links = source.links
143
+ presenter = (await config.formatResult(buildContext)) ?? null
147
144
  } catch (err) {
148
- logWarning('buildSource failed', { entityId, recordId, err })
145
+ logWarning('formatResult failed', { entityId, recordId, err })
149
146
  }
150
147
  }
151
148
 
152
- if (!presenter && config.formatResult) {
149
+ if (!presenter && config.buildSource) {
153
150
  try {
154
- presenter = (await config.formatResult(buildContext)) ?? null
151
+ const source = await config.buildSource(buildContext)
152
+ if (source?.presenter) presenter = source.presenter
153
+ if (source?.links) links = source.links
155
154
  } catch (err) {
156
- logWarning('formatResult failed', { entityId, recordId, err })
155
+ logWarning('buildSource failed', { entityId, recordId, err })
157
156
  }
158
157
  }
159
158
  }
@@ -203,8 +202,10 @@ export function createPresenterEnricher(
203
202
  encryptionService?: TenantDataEncryptionService | null,
204
203
  ): PresenterEnricherFn {
205
204
  return async (results, tenantId, organizationId) => {
206
- // Find results missing presenter OR with encrypted presenter
207
- const missingResults = results.filter(needsSearchResultEnrichment)
205
+ const shouldEnrich = (result: SearchResult): boolean =>
206
+ needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)
207
+
208
+ const missingResults = results.filter(shouldEnrich)
208
209
  if (missingResults.length === 0) return results
209
210
 
210
211
  // Group by entity type for config lookup
@@ -285,16 +286,15 @@ export function createPresenterEnricher(
285
286
 
286
287
  // Enrich results with computed presenter, URL, and links
287
288
  return results.map((result) => {
288
- if (!needsSearchResultEnrichment(result)) return result
289
+ if (!shouldEnrich(result)) return result
289
290
  const key = `${result.entityId}:${result.recordId}`
290
291
  const enriched = enrichmentMap.get(key)
291
292
  if (!enriched) return result
292
- const hasExistingLinks = Array.isArray(result.links) && result.links.length > 0
293
293
  return {
294
294
  ...result,
295
295
  presenter: enriched.presenter ?? result.presenter,
296
296
  url: result.url ?? enriched.url,
297
- links: hasExistingLinks ? result.links : (enriched.links ?? result.links),
297
+ links: enriched.links ?? result.links,
298
298
  }
299
299
  })
300
300
  }
@@ -460,7 +460,10 @@ yarn mercato search worker fulltext-indexing --concurrency=5
460
460
  | `OM_SEARCH_ENABLE_PARTIAL` | Prefix/partial expansion for `search_tokens` (indexing "john" stores hashes for `joh`,`john`). Token/Postgres only — Meilisearch unaffected. Increases `search_tokens` size ~5–6× | `true` |
461
461
  | `OM_SEARCH_HASH_ALGO` | Hash algorithm for `search_tokens` tokens (`sha256`/`sha1`/`md5`); token strategy only | `sha256` |
462
462
  | `OM_SEARCH_STORE_RAW_TOKENS` | Store plaintext token alongside the hash in `search_tokens` — **security-sensitive** (retains plaintext of otherwise-hashed values); token strategy only | `false` |
463
- | `OM_SEARCH_FIELD_BLOCKLIST` | Comma-separated extra field names excluded from tokenization (merged with built-in `password,token,secret,hash`); token strategy only | - |
463
+ | `OM_SEARCH_FIELD_BLOCKLIST` | Comma-separated field-name substrings excluded from per-field tokens and aggregate `search_text` (merged with built-in `password,token,secret,hash`); prefix an entry with `entityType@` to scope it to one entity and reindex affected entities after changes; token strategy only | - |
464
+ | `OM_SEARCH_MAX_FIELD_CHARS` | Maximum input characters considered per field value before splitting or prefix expansion; `0` disables the limit; token strategy only | `20000` |
465
+ | `OM_SEARCH_MAX_TOKENS_PER_FIELD` | Maximum distinct token rows across all values of one field; `0` disables the limit; token strategy only | `5000` |
466
+ | `OM_SEARCH_MAX_TOKENS_PER_RECORD` | Maximum token rows across all fields in one indexed record; `0` disables the limit; token strategy only | `20000` |
464
467
  | `SEARCH_EXCLUDE_ENCRYPTED_FIELDS` | Exclude encrypted fields from Meilisearch indexing | `false` |
465
468
  | `QUEUE_STRATEGY` | Queue strategy (`local` or `async`) | `local` |
466
469
  | `REDIS_URL` | Redis connection URL for async queues | - |
@@ -4,7 +4,7 @@ import type {
4
4
  SearchResult,
5
5
  SearchStrategyId,
6
6
  } from '@open-mercato/shared/modules/search'
7
- import { hasAllFeatures } from '@open-mercato/shared/security/features'
7
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
8
8
 
9
9
  /**
10
10
  * AI Tools definitions for the Search module.
@@ -96,7 +96,10 @@ function authorizeEntityAccess(entityType: string, ctx: ToolContext): SearchEnti
96
96
  )
97
97
  }
98
98
 
99
- if (!hasAllFeatures(ctx.userFeatures, required)) {
99
+ if (!authorizeFeatures(required, {
100
+ grantedFeatures: ctx.userFeatures,
101
+ unrestricted: ctx.isSuperAdmin,
102
+ })) {
100
103
  throw new SearchToolAuthorizationError(
101
104
  `[internal] Insufficient permissions for entity "${entityType}". Required: ${required.join(', ')}`
102
105
  )
@@ -0,0 +1,167 @@
1
+ const mockGetAuthFromRequest = jest.fn()
2
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
3
+ getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
4
+ }))
5
+
6
+ const mockCreateRequestContainer = jest.fn()
7
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
8
+ createRequestContainer: (...args: unknown[]) => mockCreateRequestContainer(...args),
9
+ }))
10
+
11
+ const mockResolveOrganizationScopeForRequest = jest.fn()
12
+ jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
13
+ resolveOrganizationScopeForRequest: (...args: unknown[]) => mockResolveOrganizationScopeForRequest(...args),
14
+ }))
15
+
16
+ jest.mock('../../lib/embedding-config', () => ({
17
+ resolveEmbeddingConfig: jest.fn().mockResolvedValue(null),
18
+ }))
19
+
20
+ jest.mock('../../lib/global-search-config', () => ({
21
+ resolveGlobalSearchStrategies: jest.fn().mockResolvedValue(['fulltext', 'vector', 'tokens']),
22
+ }))
23
+
24
+ jest.mock('next/headers', () => ({
25
+ cookies: async () => ({ get: () => undefined }),
26
+ headers: async () => ({ get: (name: string) => (name === 'accept-language' ? 'pl-PL' : null) }),
27
+ }))
28
+
29
+ import type { Kysely } from 'kysely'
30
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
31
+ import type { Module } from '@open-mercato/shared/modules/registry'
32
+ import type { SearchEntityConfig, SearchResult, SearchStrategy, SearchStrategyId } from '../../../../types'
33
+ import { registerModules, resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
34
+ import { SearchService } from '../../../../service'
35
+ import { createPresenterEnricher } from '../../../../lib/presenter-enricher'
36
+ import { GET } from '../search/global/route'
37
+
38
+ const DEMO_ENTITY_ID = 'demo:thing' as EntityId
39
+
40
+ function createDatabase(rows: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>) {
41
+ const chain = {
42
+ selectFrom: () => chain,
43
+ select: () => chain,
44
+ where: () => chain,
45
+ execute: async () => rows,
46
+ }
47
+ return chain as unknown as Kysely<Record<string, never>>
48
+ }
49
+
50
+ function createStrategy(source: SearchStrategyId, recordId: string): SearchStrategy {
51
+ const result: SearchResult = {
52
+ entityId: DEMO_ENTITY_ID,
53
+ recordId,
54
+ organizationId: 'org-1',
55
+ score: 1,
56
+ source,
57
+ presenter: { title: recordId, badge: 'Person' },
58
+ links: [{ href: `/backend/demo/${recordId}`, label: 'Open person', kind: 'primary' }],
59
+ }
60
+
61
+ return {
62
+ id: source,
63
+ name: source,
64
+ priority: 10,
65
+ isAvailable: async () => true,
66
+ ensureReady: async () => undefined,
67
+ search: async () => [result],
68
+ index: async () => undefined,
69
+ delete: async () => undefined,
70
+ }
71
+ }
72
+
73
+ describe('GET /api/search/search/global presenter localization', () => {
74
+ beforeAll(() => {
75
+ registerModules([
76
+ {
77
+ id: 'demo',
78
+ translations: {
79
+ en: {
80
+ 'demo.search.badge': 'Person',
81
+ 'demo.search.link.open': 'Open person',
82
+ },
83
+ pl: {
84
+ 'demo.search.badge': 'Osoba',
85
+ 'demo.search.link.open': 'Otwórz osobę',
86
+ },
87
+ },
88
+ },
89
+ ] satisfies Module[])
90
+ })
91
+
92
+ beforeEach(() => {
93
+ jest.clearAllMocks()
94
+ mockGetAuthFromRequest.mockResolvedValue({
95
+ tenantId: 'tenant-1',
96
+ orgId: 'org-1',
97
+ sub: 'user-1',
98
+ isSuperAdmin: false,
99
+ })
100
+ mockResolveOrganizationScopeForRequest.mockResolvedValue({
101
+ selectedId: 'org-1',
102
+ filterIds: ['org-1'],
103
+ allowedIds: ['org-1'],
104
+ tenantId: 'tenant-1',
105
+ })
106
+ })
107
+
108
+ it('replaces frozen presenters and links for fulltext, vector, and tokens using Accept-Language', async () => {
109
+ const rows = ['fulltext-record', 'vector-record', 'tokens-record'].map((recordId) => ({
110
+ entity_type: DEMO_ENTITY_ID,
111
+ entity_id: recordId,
112
+ doc: { id: recordId, title: recordId },
113
+ }))
114
+ const config: SearchEntityConfig = {
115
+ entityId: DEMO_ENTITY_ID,
116
+ enabled: true,
117
+ formatResult: async (context) => {
118
+ const { t } = await resolveTranslations()
119
+ return {
120
+ title: String(context.record.title),
121
+ badge: t('demo.search.badge', 'Person'),
122
+ }
123
+ },
124
+ resolveLinks: async (context) => {
125
+ const { t } = await resolveTranslations()
126
+ return [{
127
+ href: `/backend/demo/${String(context.record.id)}`,
128
+ label: t('demo.search.link.open', 'Open person'),
129
+ kind: 'primary',
130
+ }]
131
+ },
132
+ }
133
+ const configMap = new Map<EntityId, SearchEntityConfig>([[DEMO_ENTITY_ID, config]])
134
+ const presenterEnricher = createPresenterEnricher(createDatabase(rows), configMap)
135
+ const searchService = new SearchService({
136
+ strategies: [
137
+ createStrategy('fulltext', 'fulltext-record'),
138
+ createStrategy('vector', 'vector-record'),
139
+ createStrategy('tokens', 'tokens-record'),
140
+ ],
141
+ defaultStrategies: ['fulltext', 'vector', 'tokens'],
142
+ presenterEnricher,
143
+ })
144
+ const container = {
145
+ resolve: jest.fn((name: string) => (name === 'searchService' ? searchService : undefined)),
146
+ dispose: jest.fn().mockResolvedValue(undefined),
147
+ }
148
+ mockCreateRequestContainer.mockResolvedValue(container)
149
+
150
+ const request = new Request('http://localhost/api/search/search/global?q=person', {
151
+ headers: { 'accept-language': 'pl-PL' },
152
+ })
153
+ const response = await GET(request)
154
+ const body = await response.json() as {
155
+ results: SearchResult[]
156
+ strategiesUsed: SearchStrategyId[]
157
+ }
158
+
159
+ expect(response.status).toBe(200)
160
+ expect(body.results).toHaveLength(3)
161
+ expect(body.strategiesUsed).toEqual(expect.arrayContaining(['fulltext', 'vector', 'tokens']))
162
+ for (const result of body.results) {
163
+ expect(result.presenter?.badge).toBe('Osoba')
164
+ expect(result.links?.[0]?.label).toBe('Otwórz osobę')
165
+ }
166
+ })
167
+ })
@@ -246,6 +246,8 @@ export const vectorDriverStatusSchema = z.object({
246
246
  name: z.string(),
247
247
  configured: z.boolean(),
248
248
  implemented: z.boolean(),
249
+ available: z.boolean().nullable(),
250
+ unavailableReason: z.string().nullable(),
249
251
  envVars: z.array(vectorDriverEnvVarSchema),
250
252
  })
251
253
 
@@ -1,7 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
5
  import type { VectorDriverId } from '@open-mercato/shared/modules/vector'
6
+ import type { VectorDriver } from '../../../../../vector'
5
7
  import { vectorStoreSettingsOpenApi } from '../../openapi'
6
8
 
7
9
  export const metadata = {
@@ -13,6 +15,9 @@ type DriverStatus = {
13
15
  name: string
14
16
  configured: boolean
15
17
  implemented: boolean
18
+ /** Runtime reachability of the store itself; `null` when the driver exposes no probe. */
19
+ available: boolean | null
20
+ unavailableReason: string | null
16
21
  envVars: {
17
22
  name: string
18
23
  set: boolean
@@ -26,6 +31,24 @@ type VectorStoreConfigResponse = {
26
31
  drivers: DriverStatus[]
27
32
  }
28
33
 
34
+ async function probeDriverStatus(
35
+ container: { resolve: <T = unknown>(name: string) => T },
36
+ driverId: VectorDriverId,
37
+ ): Promise<{ available: boolean | null; unavailableReason: string | null }> {
38
+ try {
39
+ const drivers = container.resolve<VectorDriver[]>('vectorDrivers')
40
+ const driver = drivers.find((entry) => entry.id === driverId)
41
+ if (!driver?.getStatus) return { available: null, unavailableReason: null }
42
+ const status = await driver.getStatus()
43
+ return {
44
+ available: status.available,
45
+ unavailableReason: status.available ? null : status.reason ?? null,
46
+ }
47
+ } catch {
48
+ return { available: null, unavailableReason: null }
49
+ }
50
+ }
51
+
29
52
  const unauthorized = async () => {
30
53
  const { t } = await resolveTranslations()
31
54
  return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
@@ -45,12 +68,28 @@ export async function GET(req: Request) {
45
68
  // Check chromadb - would need CHROMA_URL
46
69
  const chromaUrlSet = Boolean(process.env.CHROMA_URL?.trim())
47
70
 
71
+ let pgvectorStatus: { available: boolean | null; unavailableReason: string | null } = {
72
+ available: null,
73
+ unavailableReason: null,
74
+ }
75
+ if (databaseUrlSet) {
76
+ const container = await createRequestContainer()
77
+ try {
78
+ pgvectorStatus = await probeDriverStatus(container, 'pgvector')
79
+ } finally {
80
+ const disposable = container as { dispose?: () => Promise<unknown> }
81
+ if (typeof disposable.dispose === 'function') await disposable.dispose()
82
+ }
83
+ }
84
+
48
85
  const drivers: DriverStatus[] = [
49
86
  {
50
87
  id: 'pgvector',
51
88
  name: 'PostgreSQL (pgvector)',
52
89
  configured: databaseUrlSet,
53
90
  implemented: true,
91
+ available: pgvectorStatus.available,
92
+ unavailableReason: pgvectorStatus.unavailableReason,
54
93
  envVars: [
55
94
  {
56
95
  name: 'DATABASE_URL',
@@ -64,6 +103,8 @@ export async function GET(req: Request) {
64
103
  name: 'Qdrant',
65
104
  configured: qdrantUrlSet,
66
105
  implemented: false,
106
+ available: null,
107
+ unavailableReason: null,
67
108
  envVars: [
68
109
  {
69
110
  name: 'QDRANT_URL',
@@ -82,6 +123,8 @@ export async function GET(req: Request) {
82
123
  name: 'ChromaDB',
83
124
  configured: chromaUrlSet,
84
125
  implemented: false,
126
+ available: null,
127
+ unavailableReason: null,
85
128
  envVars: [
86
129
  {
87
130
  name: 'CHROMA_URL',
@@ -0,0 +1,10 @@
1
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
2
+
3
+ const events = [
4
+ { id: 'search.index_record', label: 'Index Record', category: 'lifecycle' },
5
+ ] as const
6
+
7
+ export const eventsConfig = createModuleEvents({ moduleId: 'search', events })
8
+ export const emitSearchEvent = eventsConfig.emit
9
+ export type SearchEventId = typeof events[number]['id']
10
+ export default eventsConfig