@open-mercato/search 0.6.6-develop.6290.1.4bb5a8ba3f → 0.6.6-develop.6299.1.29224e2d86

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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +16 -2
  3. package/dist/modules/search/__integration__/TC-SEARCH-010.spec.js +45 -0
  4. package/dist/modules/search/__integration__/TC-SEARCH-010.spec.js.map +7 -0
  5. package/dist/modules/search/__integration__/TC-SEARCH-011.spec.js +25 -0
  6. package/dist/modules/search/__integration__/TC-SEARCH-011.spec.js.map +7 -0
  7. package/dist/modules/search/api/embeddings/route.js +50 -6
  8. package/dist/modules/search/api/embeddings/route.js.map +2 -2
  9. package/dist/modules/search/api/search/global/route.js +1 -1
  10. package/dist/modules/search/api/search/global/route.js.map +2 -2
  11. package/dist/modules/search/api/settings/global-search/route.js +8 -5
  12. package/dist/modules/search/api/settings/global-search/route.js.map +2 -2
  13. package/dist/modules/search/di.js +3 -1
  14. package/dist/modules/search/di.js.map +2 -2
  15. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +6 -3
  16. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +2 -2
  17. package/dist/modules/search/i18n/de.json +3 -0
  18. package/dist/modules/search/i18n/en.json +3 -0
  19. package/dist/modules/search/i18n/es.json +3 -0
  20. package/dist/modules/search/i18n/pl.json +3 -0
  21. package/dist/modules/search/lib/auto-indexing.js +4 -1
  22. package/dist/modules/search/lib/auto-indexing.js.map +2 -2
  23. package/dist/modules/search/lib/embedding-config.js +40 -3
  24. package/dist/modules/search/lib/embedding-config.js.map +2 -2
  25. package/dist/modules/search/lib/global-search-config.js +25 -3
  26. package/dist/modules/search/lib/global-search-config.js.map +2 -2
  27. package/dist/modules/search/lib/provider-probe.js +108 -0
  28. package/dist/modules/search/lib/provider-probe.js.map +7 -0
  29. package/package.json +4 -4
  30. package/src/modules/search/__integration__/TC-SEARCH-010.spec.ts +74 -0
  31. package/src/modules/search/__integration__/TC-SEARCH-011.spec.ts +46 -0
  32. package/src/modules/search/api/embeddings/__tests__/route.ollama-base-url.test.ts +3 -0
  33. package/src/modules/search/api/embeddings/route.ts +62 -6
  34. package/src/modules/search/api/search/global/route.ts +2 -2
  35. package/src/modules/search/api/settings/global-search/route.ts +9 -4
  36. package/src/modules/search/di.ts +2 -0
  37. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +21 -2
  38. package/src/modules/search/i18n/de.json +3 -0
  39. package/src/modules/search/i18n/en.json +3 -0
  40. package/src/modules/search/i18n/es.json +3 -0
  41. package/src/modules/search/i18n/pl.json +3 -0
  42. package/src/modules/search/lib/__tests__/provider-probe.test.ts +137 -0
  43. package/src/modules/search/lib/__tests__/search-settings-scope.test.ts +152 -0
  44. package/src/modules/search/lib/auto-indexing.ts +6 -3
  45. package/src/modules/search/lib/embedding-config.ts +58 -5
  46. package/src/modules/search/lib/global-search-config.ts +36 -4
  47. package/src/modules/search/lib/provider-probe.ts +143 -0
@@ -44,7 +44,9 @@ type EmbeddingSettings = {
44
44
  autoIndexingLocked: boolean
45
45
  lockReason: string | null
46
46
  embeddingConfig: EmbeddingProviderConfig | null
47
+ embeddingConfigSource?: 'tenant' | 'instance' | 'env'
47
48
  configuredProviders: EmbeddingProviderId[]
49
+ providerAvailability?: { providerId: EmbeddingProviderId; available: boolean; reason?: string; models?: number }[]
48
50
  indexedDimension: number | null
49
51
  reindexRequired: boolean
50
52
  documentCount: number | null
@@ -531,11 +533,24 @@ export function VectorSearchSection({
531
533
  {/* Embedding Provider Selection */}
532
534
  <div>
533
535
  <h3 className="text-sm font-semibold mb-2">{t('search.settings.vector.providers', 'Embedding Provider')}</h3>
534
- <p className="text-xs text-muted-foreground mb-3">{t('search.settings.vector.providersHint', 'Select a provider to generate embeddings. Only providers with configured API keys can be selected.')}</p>
536
+ <p className="text-xs text-muted-foreground mb-1">{t('search.settings.vector.providersHint', 'Select a provider to generate embeddings. Only reachable providers can be selected.')}</p>
537
+ {embeddingSettings?.embeddingConfigSource && embeddingSettings.embeddingConfigSource !== 'tenant' && (
538
+ <p className="text-xs text-muted-foreground mb-3">
539
+ {t('search.settings.vector.inheritingDefault', 'This tenant is inheriting the instance/environment default. Saving a provider creates an override for this tenant.')}
540
+ </p>
541
+ )}
542
+ {embeddingSettings?.embeddingConfigSource === 'tenant' && (
543
+ <p className="text-xs text-muted-foreground mb-3">
544
+ {t('search.settings.vector.usingTenantConfig', 'This tenant is using its own embedding settings.')}
545
+ </p>
546
+ )}
535
547
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 items-start">
536
548
  {providerOptions.map((providerId) => {
537
549
  const info = EMBEDDING_PROVIDERS[providerId]
538
- const isConfigured = embeddingSettings?.configuredProviders?.includes(providerId)
550
+ const availability = embeddingSettings?.providerAvailability?.find((entry) => entry.providerId === providerId)
551
+ const isConfigured = availability
552
+ ? availability.available
553
+ : embeddingSettings?.configuredProviders?.includes(providerId)
539
554
  const isSelected = displayProvider === providerId
540
555
  const isCurrentlySaved = savedProvider === providerId
541
556
  return (
@@ -568,6 +583,10 @@ export function VectorSearchSection({
568
583
  <p className="text-xs text-muted-foreground mt-1">
569
584
  {info.models.length} {t('search.settings.vector.modelsAvailable', 'models available')}
570
585
  </p>
586
+ ) : availability?.reason ? (
587
+ <p className="text-xs text-status-warning-text mt-1">
588
+ {availability.reason}
589
+ </p>
571
590
  ) : (
572
591
  <p className="text-xs text-muted-foreground mt-1">
573
592
  {t('search.settings.vector.setEnvVar', 'Set')} <code className="font-mono text-overline bg-muted px-1 rounded">{info.envKeyRequired}</code>
@@ -12,6 +12,7 @@
12
12
  "search.api.errors.missingQuery": "Suchanfrage ist erforderlich.",
13
13
  "search.api.errors.noIndexableStrategy": "Keine indizierbare Strategie verfügbar.",
14
14
  "search.api.errors.providerNotConfigured": "Anbieter ist nicht konfiguriert. Setzen Sie die erforderliche Umgebungsvariable.",
15
+ "search.api.errors.providerUnavailable": "Der ausgewählte Anbieter ist nicht verfügbar.",
15
16
  "search.api.errors.purgeFailed": "Suchindex konnte nicht bereinigt werden.",
16
17
  "search.api.errors.recreateFailed": "Fehler beim Neuerstellen der Vektortabelle mit neuer Dimension.",
17
18
  "search.api.errors.reindexFailed": "Neuindizierung fehlgeschlagen.",
@@ -156,6 +157,7 @@
156
157
  "search.settings.vector.comingSoon": "Demnächst verfügbar",
157
158
  "search.settings.vector.howTo": "Konfigurationsanleitung",
158
159
  "search.settings.vector.howToDescription": "Embedding-Anbieter für die Vektorsuche konfigurieren.",
160
+ "search.settings.vector.inheritingDefault": "Dieser Mandant erbt die Instanz-/Umgebungsvorgabe. Das Speichern eines Anbieters erstellt eine Überschreibung für diesen Mandanten.",
159
161
  "search.settings.vector.modelsAvailable": "Verfügbare Modelle",
160
162
  "search.settings.vector.providers": "Anbieter",
161
163
  "search.settings.vector.providersHint": "Embedding-Anbieter über Umgebungsvariablen konfigurieren.",
@@ -163,6 +165,7 @@
163
165
  "search.settings.vector.sectionTitle": "Vektorsuche",
164
166
  "search.settings.vector.setEnvVar": "Umgebungsvariable setzen",
165
167
  "search.settings.vector.store": "Vektorspeicher",
168
+ "search.settings.vector.usingTenantConfig": "Dieser Mandant verwendet eigene Embedding-Einstellungen.",
166
169
  "search.settings.vectorDocumentsLabel": "Vektordokumente",
167
170
  "search.settings.vectorNotConfigured": "Vektorsuche ist nicht konfiguriert.",
168
171
  "search.settings.vectorNotConfiguredHint": "Embedding-Anbieter-API-Schlüssel für die Vektorsuche setzen.",
@@ -12,6 +12,7 @@
12
12
  "search.api.errors.missingQuery": "Search query is required.",
13
13
  "search.api.errors.noIndexableStrategy": "No indexable strategy is available.",
14
14
  "search.api.errors.providerNotConfigured": "Provider is not configured. Set the required environment variable.",
15
+ "search.api.errors.providerUnavailable": "The selected provider is not available.",
15
16
  "search.api.errors.purgeFailed": "Failed to purge search index.",
16
17
  "search.api.errors.recreateFailed": "Failed to recreate vector table with new dimension.",
17
18
  "search.api.errors.reindexFailed": "Reindex operation failed.",
@@ -156,6 +157,7 @@
156
157
  "search.settings.vector.comingSoon": "Coming soon",
157
158
  "search.settings.vector.howTo": "How to configure",
158
159
  "search.settings.vector.howToDescription": "Set up an embedding provider to enable vector search.",
160
+ "search.settings.vector.inheritingDefault": "This tenant is inheriting the instance/environment default. Saving a provider creates an override for this tenant.",
159
161
  "search.settings.vector.modelsAvailable": "Models available",
160
162
  "search.settings.vector.providers": "Providers",
161
163
  "search.settings.vector.providersHint": "Configure an embedding provider via environment variables.",
@@ -163,6 +165,7 @@
163
165
  "search.settings.vector.sectionTitle": "Vector Search",
164
166
  "search.settings.vector.setEnvVar": "Set environment variable",
165
167
  "search.settings.vector.store": "Vector store",
168
+ "search.settings.vector.usingTenantConfig": "This tenant is using its own embedding settings.",
166
169
  "search.settings.vectorDocumentsLabel": "Vector documents",
167
170
  "search.settings.vectorNotConfigured": "Vector search is not configured.",
168
171
  "search.settings.vectorNotConfiguredHint": "Set an embedding provider API key to enable vector search.",
@@ -12,6 +12,7 @@
12
12
  "search.api.errors.missingQuery": "Se requiere una consulta de búsqueda.",
13
13
  "search.api.errors.noIndexableStrategy": "No hay estrategia indexable disponible.",
14
14
  "search.api.errors.providerNotConfigured": "El proveedor no está configurado. Configure la variable de entorno requerida.",
15
+ "search.api.errors.providerUnavailable": "El proveedor seleccionado no está disponible.",
15
16
  "search.api.errors.purgeFailed": "Error al purgar el índice de búsqueda.",
16
17
  "search.api.errors.recreateFailed": "No se pudo recrear la tabla vectorial con la nueva dimensión.",
17
18
  "search.api.errors.reindexFailed": "La operación de reindexación falló.",
@@ -156,6 +157,7 @@
156
157
  "search.settings.vector.comingSoon": "Próximamente",
157
158
  "search.settings.vector.howTo": "Cómo configurar",
158
159
  "search.settings.vector.howToDescription": "Configure un proveedor de embeddings para habilitar la búsqueda vectorial.",
160
+ "search.settings.vector.inheritingDefault": "Este inquilino hereda el valor predeterminado de la instancia/entorno. Guardar un proveedor crea una anulación para este inquilino.",
159
161
  "search.settings.vector.modelsAvailable": "Modelos disponibles",
160
162
  "search.settings.vector.providers": "Proveedores",
161
163
  "search.settings.vector.providersHint": "Configure un proveedor de embeddings mediante variables de entorno.",
@@ -163,6 +165,7 @@
163
165
  "search.settings.vector.sectionTitle": "Búsqueda vectorial",
164
166
  "search.settings.vector.setEnvVar": "Configurar variable de entorno",
165
167
  "search.settings.vector.store": "Almacén vectorial",
168
+ "search.settings.vector.usingTenantConfig": "Este inquilino usa su propia configuración de incrustaciones.",
166
169
  "search.settings.vectorDocumentsLabel": "Documentos vectoriales",
167
170
  "search.settings.vectorNotConfigured": "La búsqueda vectorial no está configurada.",
168
171
  "search.settings.vectorNotConfiguredHint": "Configure una clave API del proveedor de embeddings para la búsqueda vectorial.",
@@ -12,6 +12,7 @@
12
12
  "search.api.errors.missingQuery": "Zapytanie wyszukiwania jest wymagane.",
13
13
  "search.api.errors.noIndexableStrategy": "Brak dostępnej strategii indeksowania.",
14
14
  "search.api.errors.providerNotConfigured": "Dostawca nie jest skonfigurowany. Ustaw wymaganą zmienną środowiskową.",
15
+ "search.api.errors.providerUnavailable": "Wybrany dostawca jest niedostępny.",
15
16
  "search.api.errors.purgeFailed": "Nie udało się wyczyścić indeksu wyszukiwania.",
16
17
  "search.api.errors.recreateFailed": "Nie udało się odtworzyć tabeli wektorowej z nowym wymiarem.",
17
18
  "search.api.errors.reindexFailed": "Operacja reindeksacji nie powiodła się.",
@@ -156,6 +157,7 @@
156
157
  "search.settings.vector.comingSoon": "Wkrótce",
157
158
  "search.settings.vector.howTo": "Jak skonfigurować",
158
159
  "search.settings.vector.howToDescription": "Skonfiguruj dostawcę embeddingów, aby włączyć wyszukiwanie wektorowe.",
160
+ "search.settings.vector.inheritingDefault": "Ten najemca dziedziczy domyślne ustawienia instancji/środowiska. Zapisanie dostawcy tworzy nadpisanie dla tego najemcy.",
159
161
  "search.settings.vector.modelsAvailable": "Dostępne modele",
160
162
  "search.settings.vector.providers": "Dostawcy",
161
163
  "search.settings.vector.providersHint": "Skonfiguruj dostawcę embeddingów za pomocą zmiennych środowiskowych.",
@@ -163,6 +165,7 @@
163
165
  "search.settings.vector.sectionTitle": "Wyszukiwanie wektorowe",
164
166
  "search.settings.vector.setEnvVar": "Ustaw zmienną środowiskową",
165
167
  "search.settings.vector.store": "Magazyn wektorów",
168
+ "search.settings.vector.usingTenantConfig": "Ten najemca używa własnych ustawień osadzania.",
166
169
  "search.settings.vectorDocumentsLabel": "Dokumenty wektorowe",
167
170
  "search.settings.vectorNotConfigured": "Wyszukiwanie wektorowe nie jest skonfigurowane.",
168
171
  "search.settings.vectorNotConfiguredHint": "Ustaw klucz API dostawcy embeddingów, aby włączyć wyszukiwanie wektorowe.",
@@ -0,0 +1,137 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+ import {
3
+ createEmbeddingProviderProbe,
4
+ probeOllama,
5
+ checkAllProviders,
6
+ } from '../provider-probe'
7
+
8
+ function createContainerWithCache() {
9
+ const store = new Map<string, unknown>()
10
+ let sets = 0
11
+ const cache = {
12
+ get: async (key: string) => store.get(key) ?? null,
13
+ set: async (key: string, value: unknown) => {
14
+ sets += 1
15
+ store.set(key, value)
16
+ },
17
+ delete: async () => {},
18
+ deleteByTags: async () => {},
19
+ }
20
+ const container = {
21
+ resolve: (token: string) => {
22
+ if (token === 'cache') return cache
23
+ throw new Error(`unknown token ${token}`)
24
+ },
25
+ } as unknown as AppContainer
26
+ return { container, sets: () => sets }
27
+ }
28
+
29
+ function mockFetch(impl: (input: string) => Promise<Response>) {
30
+ return jest.spyOn(globalThis, 'fetch' as never).mockImplementation(impl as never)
31
+ }
32
+
33
+ const okTags = (models: number) =>
34
+ ({
35
+ ok: true,
36
+ status: 200,
37
+ json: async () => ({ models: Array.from({ length: models }, (_, index) => ({ name: `m${index}` })) }),
38
+ }) as unknown as Response
39
+
40
+ describe('probeOllama', () => {
41
+ afterEach(() => jest.restoreAllMocks())
42
+
43
+ it('reports available with model count on a successful /api/tags', async () => {
44
+ const spy = mockFetch(async () => okTags(3))
45
+ const result = await probeOllama('http://localhost:11434')
46
+ expect(result.available).toBe(true)
47
+ expect(result.models).toBe(3)
48
+ expect(spy).toHaveBeenCalledWith('http://localhost:11434/api/tags', expect.objectContaining({ method: 'GET' }))
49
+ })
50
+
51
+ it('reports unavailable on a non-ok response', async () => {
52
+ mockFetch(async () => ({ ok: false, status: 500 }) as unknown as Response)
53
+ const result = await probeOllama('http://localhost:11434')
54
+ expect(result.available).toBe(false)
55
+ expect(result.reason).toContain('500')
56
+ })
57
+
58
+ it('reports unavailable (timed out) when the request aborts', async () => {
59
+ mockFetch(async () => {
60
+ throw Object.assign(new Error('aborted'), { name: 'AbortError' })
61
+ })
62
+ const result = await probeOllama('http://localhost:11434')
63
+ expect(result.available).toBe(false)
64
+ expect(result.reason).toContain('timed out')
65
+ })
66
+
67
+ it('reports unavailable when the host is unreachable', async () => {
68
+ mockFetch(async () => {
69
+ throw new Error('ECONNREFUSED')
70
+ })
71
+ const result = await probeOllama('http://localhost:11434')
72
+ expect(result.available).toBe(false)
73
+ expect(result.reason).toContain('not reachable')
74
+ })
75
+ })
76
+
77
+ describe('createEmbeddingProviderProbe', () => {
78
+ afterEach(() => jest.restoreAllMocks())
79
+
80
+ it('caches results across calls and only probes once', async () => {
81
+ const spy = mockFetch(async () => okTags(1))
82
+ const { container, sets } = createContainerWithCache()
83
+ const probe = createEmbeddingProviderProbe(container)
84
+
85
+ const first = await probe.checkAvailability('ollama')
86
+ const second = await probe.checkAvailability('ollama')
87
+
88
+ expect(first.available).toBe(true)
89
+ expect(second.available).toBe(true)
90
+ expect(spy).toHaveBeenCalledTimes(1)
91
+ expect(sets()).toBe(1)
92
+ })
93
+
94
+ it('re-probes when force is set', async () => {
95
+ const spy = mockFetch(async () => okTags(1))
96
+ const { container } = createContainerWithCache()
97
+ const probe = createEmbeddingProviderProbe(container)
98
+
99
+ await probe.checkAvailability('ollama')
100
+ await probe.checkAvailability('ollama', { force: true })
101
+
102
+ expect(spy).toHaveBeenCalledTimes(2)
103
+ })
104
+
105
+ it('gates key-based providers on env-key presence', async () => {
106
+ const { container } = createContainerWithCache()
107
+ const probe = createEmbeddingProviderProbe(container)
108
+ const previous = process.env.OPENAI_API_KEY
109
+
110
+ try {
111
+ delete process.env.OPENAI_API_KEY
112
+ const missing = await probe.checkAvailability('openai')
113
+ expect(missing.available).toBe(false)
114
+ expect(missing.reason).toContain('OPENAI_API_KEY')
115
+
116
+ process.env.OPENAI_API_KEY = 'present'
117
+ const present = await probe.checkAvailability('openai', { force: true })
118
+ expect(present.available).toBe(true)
119
+ } finally {
120
+ if (previous === undefined) delete process.env.OPENAI_API_KEY
121
+ else process.env.OPENAI_API_KEY = previous
122
+ }
123
+ })
124
+ })
125
+
126
+ describe('checkAllProviders', () => {
127
+ it('returns an entry for every provider with its id', async () => {
128
+ const probe = {
129
+ checkAvailability: async () => ({ available: false, reason: 'stub' }),
130
+ }
131
+ const entries = await checkAllProviders(probe)
132
+ expect(entries.map((entry) => entry.providerId).sort()).toEqual(
133
+ ['bedrock', 'cohere', 'google', 'mistral', 'ollama', 'openai'],
134
+ )
135
+ expect(entries.every((entry) => entry.available === false)).toBe(true)
136
+ })
137
+ })
@@ -0,0 +1,152 @@
1
+ import { createModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import {
4
+ resolveEmbeddingConfigResult,
5
+ saveEmbeddingConfig,
6
+ } from '../embedding-config'
7
+ import {
8
+ resolveGlobalSearchStrategiesResult,
9
+ saveGlobalSearchStrategies,
10
+ } from '../global-search-config'
11
+ import type { EmbeddingProviderConfig } from '../../../../vector'
12
+
13
+ type Row = {
14
+ id: string
15
+ moduleId: string
16
+ name: string
17
+ valueJson: unknown
18
+ tenantId: string | null
19
+ organizationId: string | null
20
+ createdAt: Date
21
+ updatedAt: Date
22
+ }
23
+
24
+ function matches(row: Row, where: Record<string, unknown>): boolean {
25
+ return Object.entries(where).every(([key, value]) => {
26
+ const current = (row as Record<string, unknown>)[key]
27
+ return value === null ? current === null || current === undefined : current === value
28
+ })
29
+ }
30
+
31
+ function createServiceResolver() {
32
+ const rows: Row[] = []
33
+ let seq = 0
34
+ const store = new Map<string, unknown>()
35
+ const repo = {
36
+ async findOne(where: Record<string, unknown>): Promise<Row | null> {
37
+ return rows.find((row) => matches(row, where)) ?? null
38
+ },
39
+ create(data: Partial<Row>): Row {
40
+ const now = new Date()
41
+ return {
42
+ id: `row-${seq++}`,
43
+ moduleId: data.moduleId!,
44
+ name: data.name!,
45
+ valueJson: data.valueJson ?? null,
46
+ tenantId: data.tenantId ?? null,
47
+ organizationId: data.organizationId ?? null,
48
+ createdAt: data.createdAt ?? now,
49
+ updatedAt: data.updatedAt ?? now,
50
+ }
51
+ },
52
+ }
53
+ const em = {
54
+ getRepository: () => repo,
55
+ persist: (entity: Row) => rows.push(entity),
56
+ flush: async () => {},
57
+ }
58
+ const cache = {
59
+ get: async (key: string) => store.get(key) ?? null,
60
+ set: async (key: string, value: unknown) => {
61
+ store.set(key, value)
62
+ },
63
+ delete: async (key: string) => {
64
+ store.delete(key)
65
+ },
66
+ deleteByTags: async () => {
67
+ store.clear()
68
+ },
69
+ }
70
+ const container = {
71
+ resolve: (token: string) => {
72
+ if (token === 'em') return em
73
+ if (token === 'cache') return cache
74
+ throw new Error(`unknown token ${token}`)
75
+ },
76
+ } as unknown as AppContainer
77
+ const service = createModuleConfigService(container)
78
+ const resolver = {
79
+ resolve: <T = unknown>(name: string): T => {
80
+ if (name === 'moduleConfigService') return service as unknown as T
81
+ throw new Error(`unknown token ${name}`)
82
+ },
83
+ }
84
+ return { resolver, rows }
85
+ }
86
+
87
+ const TENANT_A = '11111111-1111-1111-1111-111111111111'
88
+ const TENANT_B = '22222222-2222-2222-2222-222222222222'
89
+
90
+ const TENANT_A_CONFIG: EmbeddingProviderConfig = {
91
+ providerId: 'openai',
92
+ model: 'text-embedding-3-large',
93
+ dimension: 3072,
94
+ updatedAt: new Date().toISOString(),
95
+ }
96
+
97
+ describe('search settings tenant scoping (helper-level integration)', () => {
98
+ const previousKey = process.env.OPENAI_API_KEY
99
+ beforeAll(() => {
100
+ // Make the env-derived default deterministic across phases (key-presence gate).
101
+ process.env.OPENAI_API_KEY = 'test-key'
102
+ })
103
+ afterAll(() => {
104
+ if (previousKey === undefined) delete process.env.OPENAI_API_KEY
105
+ else process.env.OPENAI_API_KEY = previousKey
106
+ })
107
+
108
+ it('embedding config: Tenant A save does not change Tenant B (B inherits env default)', async () => {
109
+ const { resolver } = createServiceResolver()
110
+
111
+ await saveEmbeddingConfig(resolver, TENANT_A_CONFIG, { scope: { tenantId: TENANT_A } })
112
+
113
+ const a = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_A } })
114
+ expect(a.source).toBe('tenant')
115
+ expect(a.config?.model).toBe('text-embedding-3-large')
116
+
117
+ const b = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_B } })
118
+ expect(b.source).toBe('env')
119
+ expect(b.config?.providerId).toBe('openai')
120
+ expect(b.config?.model).toBe('text-embedding-3-small')
121
+ })
122
+
123
+ it('embedding config: a tenant inherits the instance row until it overrides', async () => {
124
+ const { resolver } = createServiceResolver()
125
+
126
+ // Instance default (no scope -> global row)
127
+ await saveEmbeddingConfig(resolver, { ...TENANT_A_CONFIG, model: 'instance-model' })
128
+
129
+ const inherited = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_A } })
130
+ expect(inherited.source).toBe('instance')
131
+ expect(inherited.config?.model).toBe('instance-model')
132
+
133
+ await saveEmbeddingConfig(resolver, TENANT_A_CONFIG, { scope: { tenantId: TENANT_A } })
134
+ const overridden = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_A } })
135
+ expect(overridden.source).toBe('tenant')
136
+ expect(overridden.config?.model).toBe('text-embedding-3-large')
137
+ })
138
+
139
+ it('global-search strategies: Tenant A save does not change Tenant B', async () => {
140
+ const { resolver } = createServiceResolver()
141
+
142
+ await saveGlobalSearchStrategies(resolver, ['fulltext'], { scope: { tenantId: TENANT_A } })
143
+
144
+ const a = await resolveGlobalSearchStrategiesResult(resolver, { scope: { tenantId: TENANT_A } })
145
+ expect(a.source).toBe('tenant')
146
+ expect(a.strategies).toEqual(['fulltext'])
147
+
148
+ const b = await resolveGlobalSearchStrategiesResult(resolver, { scope: { tenantId: TENANT_B } })
149
+ expect(b.source).toBe('env')
150
+ expect(b.strategies).toEqual(['fulltext', 'vector', 'tokens'])
151
+ })
152
+ })
@@ -1,4 +1,4 @@
1
- import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
1
+ import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
2
2
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
3
3
 
4
4
  export const SEARCH_AUTO_INDEX_CONFIG_KEY = 'auto_index_enabled'
@@ -17,7 +17,7 @@ type Resolver = {
17
17
 
18
18
  export async function resolveAutoIndexingEnabled(
19
19
  resolver: Resolver,
20
- options?: { defaultValue?: boolean },
20
+ options?: { defaultValue?: boolean; scope?: ConfigScope },
21
21
  ): Promise<boolean> {
22
22
  if (envDisablesAutoIndexing()) return false
23
23
  const fallback = options?.defaultValue ?? true
@@ -29,7 +29,10 @@ export async function resolveAutoIndexingEnabled(
29
29
  }
30
30
  try {
31
31
  // Still use 'vector' module key for backwards compatibility
32
- const value = await service.getValue<boolean>('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, { defaultValue: fallback })
32
+ const value = await service.getValue<boolean>('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, {
33
+ defaultValue: fallback,
34
+ scope: options?.scope,
35
+ })
33
36
  return value !== false
34
37
  } catch {
35
38
  return fallback
@@ -1,4 +1,4 @@
1
- import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
1
+ import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
2
2
  import type { EmbeddingProviderConfig, EmbeddingProviderId } from '../../../vector'
3
3
  import { EMBEDDING_CONFIG_KEY, EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../../../vector'
4
4
 
@@ -123,7 +123,7 @@ type Resolver = {
123
123
 
124
124
  export async function resolveEmbeddingConfig(
125
125
  resolver: Resolver,
126
- options?: { defaultValue?: EmbeddingProviderConfig | null }
126
+ options?: { defaultValue?: EmbeddingProviderConfig | null; scope?: ConfigScope }
127
127
  ): Promise<EmbeddingProviderConfig | null> {
128
128
  const fallback = options?.defaultValue ?? null
129
129
  let service: ModuleConfigService
@@ -133,7 +133,10 @@ export async function resolveEmbeddingConfig(
133
133
  return fallback
134
134
  }
135
135
  try {
136
- const value = await service.getValue<EmbeddingProviderConfig>('vector', EMBEDDING_CONFIG_KEY, { defaultValue: fallback })
136
+ const value = await service.getValue<EmbeddingProviderConfig>('vector', EMBEDDING_CONFIG_KEY, {
137
+ defaultValue: fallback,
138
+ scope: options?.scope,
139
+ })
137
140
  return value
138
141
  } catch {
139
142
  return fallback
@@ -142,7 +145,8 @@ export async function resolveEmbeddingConfig(
142
145
 
143
146
  export async function saveEmbeddingConfig(
144
147
  resolver: Resolver,
145
- config: EmbeddingProviderConfig
148
+ config: EmbeddingProviderConfig,
149
+ options?: { scope?: ConfigScope }
146
150
  ): Promise<void> {
147
151
  let service: ModuleConfigService
148
152
  try {
@@ -153,9 +157,58 @@ export async function saveEmbeddingConfig(
153
157
  await service.setValue('vector', EMBEDDING_CONFIG_KEY, {
154
158
  ...config,
155
159
  updatedAt: new Date().toISOString(),
156
- })
160
+ }, options?.scope)
157
161
  }
158
162
 
159
163
  export function createDefaultConfig(): EmbeddingProviderConfig {
160
164
  return { ...DEFAULT_EMBEDDING_CONFIG, updatedAt: new Date().toISOString() }
161
165
  }
166
+
167
+ export type EmbeddingConfigSource = 'tenant' | 'instance' | 'env'
168
+
169
+ /**
170
+ * Compute the env-derived default embedding config: the platform default narrowed
171
+ * to the first env-configured provider. Returns null when no provider is configured.
172
+ */
173
+ export function getEnvDerivedEmbeddingConfig(): EmbeddingProviderConfig | null {
174
+ const configured = getConfiguredProviders()
175
+ if (configured.length === 0) return null
176
+ const providerId = configured.includes(DEFAULT_EMBEDDING_CONFIG.providerId)
177
+ ? DEFAULT_EMBEDDING_CONFIG.providerId
178
+ : configured[0]
179
+ if (providerId === DEFAULT_EMBEDDING_CONFIG.providerId) {
180
+ return { ...DEFAULT_EMBEDDING_CONFIG, updatedAt: new Date().toISOString() }
181
+ }
182
+ const info = EMBEDDING_PROVIDERS[providerId]
183
+ const model = info.models.find((entry) => entry.id === info.defaultModel) ?? info.models[0]
184
+ return {
185
+ providerId,
186
+ model: info.defaultModel,
187
+ dimension: model?.dimension ?? DEFAULT_EMBEDDING_CONFIG.dimension,
188
+ updatedAt: new Date().toISOString(),
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolve the embedding config together with its source discriminator:
194
+ * `tenant` (own scoped row), `instance` (inherited global row), or `env`
195
+ * (no stored row -> env-derived default).
196
+ */
197
+ export async function resolveEmbeddingConfigResult(
198
+ resolver: Resolver,
199
+ options?: { scope?: ConfigScope },
200
+ ): Promise<{ config: EmbeddingProviderConfig | null; source: EmbeddingConfigSource }> {
201
+ let service: ModuleConfigService
202
+ try {
203
+ service = resolver.resolve<ModuleConfigService>('moduleConfigService')
204
+ } catch {
205
+ return { config: getEnvDerivedEmbeddingConfig(), source: 'env' }
206
+ }
207
+ try {
208
+ const record = await service.getRecord('vector', EMBEDDING_CONFIG_KEY, options?.scope)
209
+ if (record && record.value) {
210
+ return { config: record.value as EmbeddingProviderConfig, source: record.source }
211
+ }
212
+ } catch {}
213
+ return { config: getEnvDerivedEmbeddingConfig(), source: 'env' }
214
+ }
@@ -1,4 +1,4 @@
1
- import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
1
+ import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
2
2
  import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
3
3
 
4
4
  export const GLOBAL_SEARCH_STRATEGIES_KEY = 'global_search_strategies'
@@ -16,7 +16,7 @@ type Resolver = {
16
16
  */
17
17
  export async function resolveGlobalSearchStrategies(
18
18
  resolver: Resolver,
19
- options?: { defaultValue?: SearchStrategyId[] },
19
+ options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },
20
20
  ): Promise<SearchStrategyId[]> {
21
21
  const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES
22
22
  let service: ModuleConfigService
@@ -26,7 +26,10 @@ export async function resolveGlobalSearchStrategies(
26
26
  return fallback
27
27
  }
28
28
  try {
29
- const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, { defaultValue: fallback })
29
+ const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, {
30
+ defaultValue: fallback,
31
+ scope: options?.scope,
32
+ })
30
33
  // Ensure we always return a non-empty array
31
34
  if (!Array.isArray(value) || value.length === 0) {
32
35
  return fallback
@@ -43,6 +46,7 @@ export async function resolveGlobalSearchStrategies(
43
46
  export async function saveGlobalSearchStrategies(
44
47
  resolver: Resolver,
45
48
  strategies: SearchStrategyId[],
49
+ options?: { scope?: ConfigScope },
46
50
  ): Promise<void> {
47
51
  let service: ModuleConfigService
48
52
  try {
@@ -65,5 +69,33 @@ export async function saveGlobalSearchStrategies(
65
69
  throw new Error('At least one valid search strategy must be enabled')
66
70
  }
67
71
 
68
- await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies)
72
+ await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies, options?.scope)
73
+ }
74
+
75
+ export type GlobalSearchSource = 'tenant' | 'instance' | 'env'
76
+
77
+ /**
78
+ * Resolve the enabled strategies together with their source discriminator:
79
+ * `tenant` (own scoped row), `instance` (inherited global row), or `env`
80
+ * (no stored row -> default strategies).
81
+ */
82
+ export async function resolveGlobalSearchStrategiesResult(
83
+ resolver: Resolver,
84
+ options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },
85
+ ): Promise<{ strategies: SearchStrategyId[]; source: GlobalSearchSource }> {
86
+ const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES
87
+ let service: ModuleConfigService
88
+ try {
89
+ service = resolver.resolve<ModuleConfigService>('moduleConfigService')
90
+ } catch {
91
+ return { strategies: fallback, source: 'env' }
92
+ }
93
+ try {
94
+ const record = await service.getRecord('search', GLOBAL_SEARCH_STRATEGIES_KEY, options?.scope)
95
+ const value = record?.value
96
+ if (Array.isArray(value) && value.length > 0) {
97
+ return { strategies: value as SearchStrategyId[], source: record!.source }
98
+ }
99
+ } catch {}
100
+ return { strategies: fallback, source: 'env' }
69
101
  }