@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +7 -1
- package/dist/lib/presenter-enricher.js +12 -12
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/ai-tools.js +5 -2
- package/dist/modules/search/ai-tools.js.map +2 -2
- package/dist/modules/search/api/openapi.js +2 -0
- package/dist/modules/search/api/openapi.js.map +2 -2
- package/dist/modules/search/api/settings/vector-store/route.js +34 -0
- package/dist/modules/search/api/settings/vector-store/route.js.map +2 -2
- package/dist/modules/search/events.js +13 -0
- package/dist/modules/search/events.js.map +7 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +2 -9
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +2 -2
- package/dist/modules/search/frontend/components/HybridSearchTable.js +2 -11
- package/dist/modules/search/frontend/components/HybridSearchTable.js.map +2 -2
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +2 -2
- package/dist/modules/search/frontend/components/TopbarSearchInline.js +2 -9
- package/dist/modules/search/frontend/components/TopbarSearchInline.js.map +2 -2
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +15 -3
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +2 -2
- package/dist/modules/search/frontend/lib/entityTypeLabel.js +19 -0
- package/dist/modules/search/frontend/lib/entityTypeLabel.js.map +7 -0
- package/dist/modules/search/i18n/de.json +49 -0
- package/dist/modules/search/i18n/en.json +49 -0
- package/dist/modules/search/i18n/es.json +49 -0
- package/dist/modules/search/i18n/ko.json +239 -0
- package/dist/modules/search/i18n/pl.json +49 -0
- package/dist/modules/search/lib/auto-indexing.js +7 -8
- package/dist/modules/search/lib/auto-indexing.js.map +2 -2
- package/dist/service.js +2 -5
- package/dist/service.js.map +2 -2
- package/dist/strategies/vector.strategy.js +3 -1
- package/dist/strategies/vector.strategy.js.map +2 -2
- package/dist/vector/drivers/pgvector/index.js +55 -1
- package/dist/vector/drivers/pgvector/index.js.map +2 -2
- package/dist/vector/types.js.map +2 -2
- package/package.json +11 -10
- package/src/__tests__/pgvector-extension-availability.test.ts +158 -0
- package/src/__tests__/presenter-enricher.test.ts +48 -0
- package/src/__tests__/presenter-locale.test.ts +76 -0
- package/src/lib/presenter-enricher.ts +14 -14
- package/src/modules/search/README.md +4 -1
- package/src/modules/search/ai-tools.ts +5 -2
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +167 -0
- package/src/modules/search/api/openapi.ts +2 -0
- package/src/modules/search/api/settings/vector-store/route.ts +43 -0
- package/src/modules/search/events.ts +10 -0
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +2 -15
- package/src/modules/search/frontend/components/HybridSearchTable.tsx +2 -17
- package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +2 -0
- package/src/modules/search/frontend/components/TopbarSearchInline.tsx +2 -15
- package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +32 -2
- package/src/modules/search/frontend/components/sections/__tests__/VectorSearchSection.test.tsx +36 -1
- package/src/modules/search/frontend/lib/__tests__/entityTypeLabel.test.ts +25 -0
- package/src/modules/search/frontend/lib/entityTypeLabel.ts +22 -0
- package/src/modules/search/i18n/de.json +49 -0
- package/src/modules/search/i18n/en.json +49 -0
- package/src/modules/search/i18n/es.json +49 -0
- package/src/modules/search/i18n/ko.json +239 -0
- package/src/modules/search/i18n/pl.json +49 -0
- package/src/modules/search/lib/auto-indexing.ts +7 -12
- package/src/service.ts +2 -7
- package/src/strategies/vector.strategy.ts +7 -1
- package/src/vector/drivers/pgvector/index.ts +77 -1
- 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.
|
|
141
|
+
if (config.formatResult) {
|
|
143
142
|
try {
|
|
144
|
-
|
|
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('
|
|
145
|
+
logWarning('formatResult failed', { entityId, recordId, err })
|
|
149
146
|
}
|
|
150
147
|
}
|
|
151
148
|
|
|
152
|
-
if (!presenter && config.
|
|
149
|
+
if (!presenter && config.buildSource) {
|
|
153
150
|
try {
|
|
154
|
-
|
|
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('
|
|
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
|
-
|
|
207
|
-
|
|
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 (!
|
|
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:
|
|
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
|
|
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 {
|
|
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 (!
|
|
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
|