@open-mercato/search 0.4.2-canary-c02407ff85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +678 -0
- package/build.mjs +92 -0
- package/dist/di.js +157 -0
- package/dist/di.js.map +7 -0
- package/dist/fulltext/drivers/index.js +21 -0
- package/dist/fulltext/drivers/index.js.map +7 -0
- package/dist/fulltext/drivers/meilisearch/index.js +320 -0
- package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
- package/dist/fulltext/index.js +7 -0
- package/dist/fulltext/index.js.map +7 -0
- package/dist/fulltext/types.js +1 -0
- package/dist/fulltext/types.js.map +7 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +7 -0
- package/dist/indexer/index.js +8 -0
- package/dist/indexer/index.js.map +7 -0
- package/dist/indexer/search-indexer.js +848 -0
- package/dist/indexer/search-indexer.js.map +7 -0
- package/dist/indexer/subscribers/delete.js +41 -0
- package/dist/indexer/subscribers/delete.js.map +7 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/debug.js.map +7 -0
- package/dist/lib/fallback-presenter.js +107 -0
- package/dist/lib/fallback-presenter.js.map +7 -0
- package/dist/lib/field-policy.js +75 -0
- package/dist/lib/field-policy.js.map +7 -0
- package/dist/lib/index.js +19 -0
- package/dist/lib/index.js.map +7 -0
- package/dist/lib/merger.js +93 -0
- package/dist/lib/merger.js.map +7 -0
- package/dist/lib/presenter-enricher.js +192 -0
- package/dist/lib/presenter-enricher.js.map +7 -0
- package/dist/modules/search/acl.js +14 -0
- package/dist/modules/search/acl.js.map +7 -0
- package/dist/modules/search/ai-tools.js +284 -0
- package/dist/modules/search/ai-tools.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
- package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/route.js +246 -0
- package/dist/modules/search/api/embeddings/route.js.map +7 -0
- package/dist/modules/search/api/index/route.js +245 -0
- package/dist/modules/search/api/index/route.js.map +7 -0
- package/dist/modules/search/api/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/reindex/route.js +332 -0
- package/dist/modules/search/api/reindex/route.js.map +7 -0
- package/dist/modules/search/api/search/global/route.js +100 -0
- package/dist/modules/search/api/search/global/route.js.map +7 -0
- package/dist/modules/search/api/search/route.js +101 -0
- package/dist/modules/search/api/search/route.js.map +7 -0
- package/dist/modules/search/api/settings/fulltext/route.js +55 -0
- package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
- package/dist/modules/search/api/settings/global-search/route.js +80 -0
- package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
- package/dist/modules/search/api/settings/route.js +118 -0
- package/dist/modules/search/api/settings/route.js.map +7 -0
- package/dist/modules/search/api/settings/vector-store/route.js +77 -0
- package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.js +10 -0
- package/dist/modules/search/backend/config/search/page.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.meta.js +24 -0
- package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
- package/dist/modules/search/cli.js +698 -0
- package/dist/modules/search/cli.js.map +7 -0
- package/dist/modules/search/di.js +32 -0
- package/dist/modules/search/di.js.map +7 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/index.js +9 -0
- package/dist/modules/search/frontend/index.js.map +7 -0
- package/dist/modules/search/frontend/utils.js +41 -0
- package/dist/modules/search/frontend/utils.js.map +7 -0
- package/dist/modules/search/i18n/de.json +61 -0
- package/dist/modules/search/i18n/en.json +72 -0
- package/dist/modules/search/i18n/es.json +61 -0
- package/dist/modules/search/i18n/pl.json +61 -0
- package/dist/modules/search/index.js +11 -0
- package/dist/modules/search/index.js.map +7 -0
- package/dist/modules/search/lib/auto-indexing.js +29 -0
- package/dist/modules/search/lib/auto-indexing.js.map +7 -0
- package/dist/modules/search/lib/embedding-config.js +131 -0
- package/dist/modules/search/lib/embedding-config.js.map +7 -0
- package/dist/modules/search/lib/global-search-config.js +45 -0
- package/dist/modules/search/lib/global-search-config.js.map +7 -0
- package/dist/modules/search/lib/reindex-lock.js +99 -0
- package/dist/modules/search/lib/reindex-lock.js.map +7 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
- package/dist/modules/search/subscribers/vector_delete.js +58 -0
- package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
- package/dist/modules/search/subscribers/vector_purge.js +142 -0
- package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
- package/dist/modules/search/subscribers/vector_upsert.js +58 -0
- package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
- package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
- package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
- package/dist/modules/search/workers/vector-index.worker.js +234 -0
- package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
- package/dist/queue/fulltext-indexing.js +15 -0
- package/dist/queue/fulltext-indexing.js.map +7 -0
- package/dist/queue/index.js +3 -0
- package/dist/queue/index.js.map +7 -0
- package/dist/queue/vector-indexing.js +15 -0
- package/dist/queue/vector-indexing.js.map +7 -0
- package/dist/service.js +286 -0
- package/dist/service.js.map +7 -0
- package/dist/strategies/fulltext.strategy.js +116 -0
- package/dist/strategies/fulltext.strategy.js.map +7 -0
- package/dist/strategies/index.js +12 -0
- package/dist/strategies/index.js.map +7 -0
- package/dist/strategies/token.strategy.js +80 -0
- package/dist/strategies/token.strategy.js.map +7 -0
- package/dist/strategies/vector.strategy.js +137 -0
- package/dist/strategies/vector.strategy.js.map +7 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +7 -0
- package/dist/vector/drivers/chromadb/index.js +44 -0
- package/dist/vector/drivers/chromadb/index.js.map +7 -0
- package/dist/vector/drivers/index.js +9 -0
- package/dist/vector/drivers/index.js.map +7 -0
- package/dist/vector/drivers/pgvector/index.js +509 -0
- package/dist/vector/drivers/pgvector/index.js.map +7 -0
- package/dist/vector/drivers/qdrant/index.js +44 -0
- package/dist/vector/drivers/qdrant/index.js.map +7 -0
- package/dist/vector/index.js +4 -0
- package/dist/vector/index.js.map +7 -0
- package/dist/vector/lib/vector-logs.js +33 -0
- package/dist/vector/lib/vector-logs.js.map +7 -0
- package/dist/vector/services/checksum.js +20 -0
- package/dist/vector/services/checksum.js.map +7 -0
- package/dist/vector/services/embedding.js +222 -0
- package/dist/vector/services/embedding.js.map +7 -0
- package/dist/vector/services/index.js +4 -0
- package/dist/vector/services/index.js.map +7 -0
- package/dist/vector/services/vector-index.service.js +960 -0
- package/dist/vector/services/vector-index.service.js.map +7 -0
- package/dist/vector/types/pg.d.js +1 -0
- package/dist/vector/types/pg.d.js.map +7 -0
- package/dist/vector/types.js +75 -0
- package/dist/vector/types.js.map +7 -0
- package/jest.config.cjs +19 -0
- package/package.json +142 -0
- package/src/__tests__/queue.test.ts +148 -0
- package/src/__tests__/service.test.ts +345 -0
- package/src/__tests__/workers.test.ts +319 -0
- package/src/di.ts +291 -0
- package/src/fulltext/drivers/index.ts +41 -0
- package/src/fulltext/drivers/meilisearch/index.ts +410 -0
- package/src/fulltext/index.ts +13 -0
- package/src/fulltext/types.ts +115 -0
- package/src/index.ts +36 -0
- package/src/indexer/index.ts +13 -0
- package/src/indexer/search-indexer.ts +1141 -0
- package/src/indexer/subscribers/delete.ts +49 -0
- package/src/lib/debug.ts +46 -0
- package/src/lib/fallback-presenter.ts +106 -0
- package/src/lib/field-policy.ts +169 -0
- package/src/lib/index.ts +13 -0
- package/src/lib/merger.ts +159 -0
- package/src/lib/presenter-enricher.ts +323 -0
- package/src/modules/search/README.md +694 -0
- package/src/modules/search/acl.ts +10 -0
- package/src/modules/search/ai-tools.ts +467 -0
- package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
- package/src/modules/search/api/embeddings/route.ts +304 -0
- package/src/modules/search/api/index/route.ts +297 -0
- package/src/modules/search/api/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/reindex/route.ts +419 -0
- package/src/modules/search/api/search/global/route.ts +120 -0
- package/src/modules/search/api/search/route.ts +121 -0
- package/src/modules/search/api/settings/fulltext/route.ts +82 -0
- package/src/modules/search/api/settings/global-search/route.ts +91 -0
- package/src/modules/search/api/settings/route.ts +187 -0
- package/src/modules/search/api/settings/vector-store/route.ts +105 -0
- package/src/modules/search/backend/config/search/page.meta.ts +22 -0
- package/src/modules/search/backend/config/search/page.tsx +12 -0
- package/src/modules/search/cli.ts +818 -0
- package/src/modules/search/di.ts +50 -0
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
- package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
- package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
- package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
- package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
- package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
- package/src/modules/search/frontend/index.ts +3 -0
- package/src/modules/search/frontend/utils.ts +82 -0
- package/src/modules/search/i18n/de.json +61 -0
- package/src/modules/search/i18n/en.json +72 -0
- package/src/modules/search/i18n/es.json +61 -0
- package/src/modules/search/i18n/pl.json +61 -0
- package/src/modules/search/index.ts +9 -0
- package/src/modules/search/lib/auto-indexing.ts +35 -0
- package/src/modules/search/lib/embedding-config.ts +161 -0
- package/src/modules/search/lib/global-search-config.ts +69 -0
- package/src/modules/search/lib/reindex-lock.ts +201 -0
- package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
- package/src/modules/search/subscribers/vector_delete.ts +75 -0
- package/src/modules/search/subscribers/vector_purge.ts +161 -0
- package/src/modules/search/subscribers/vector_upsert.ts +75 -0
- package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
- package/src/modules/search/workers/vector-index.worker.ts +292 -0
- package/src/queue/fulltext-indexing.ts +87 -0
- package/src/queue/index.ts +2 -0
- package/src/queue/vector-indexing.ts +66 -0
- package/src/service.ts +397 -0
- package/src/strategies/fulltext.strategy.ts +155 -0
- package/src/strategies/index.ts +17 -0
- package/src/strategies/token.strategy.ts +153 -0
- package/src/strategies/vector.strategy.ts +234 -0
- package/src/types.ts +38 -0
- package/src/vector/drivers/chromadb/index.ts +49 -0
- package/src/vector/drivers/index.ts +4 -0
- package/src/vector/drivers/pgvector/index.ts +627 -0
- package/src/vector/drivers/qdrant/index.ts +49 -0
- package/src/vector/index.ts +3 -0
- package/src/vector/lib/vector-logs.ts +46 -0
- package/src/vector/services/checksum.ts +18 -0
- package/src/vector/services/embedding.ts +275 -0
- package/src/vector/services/index.ts +3 -0
- package/src/vector/services/vector-index.service.ts +1234 -0
- package/src/vector/types/pg.d.ts +1 -0
- package/src/vector/types.ts +220 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createQueue } from '@open-mercato/queue'
|
|
2
|
+
import type { Queue } from '@open-mercato/queue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Job types for vector indexing queue
|
|
6
|
+
*/
|
|
7
|
+
export type VectorIndexJobType = 'index' | 'delete' | 'batch-index'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Record reference for batch indexing
|
|
11
|
+
*/
|
|
12
|
+
export type VectorBatchRecord = {
|
|
13
|
+
entityId: string
|
|
14
|
+
recordId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Payload for vector indexing jobs
|
|
19
|
+
*/
|
|
20
|
+
export type VectorIndexJobPayload = {
|
|
21
|
+
jobType: VectorIndexJobType
|
|
22
|
+
tenantId: string
|
|
23
|
+
organizationId: string | null
|
|
24
|
+
// For single record jobs (index/delete)
|
|
25
|
+
entityType?: string
|
|
26
|
+
recordId?: string
|
|
27
|
+
// For batch-index jobs
|
|
28
|
+
records?: VectorBatchRecord[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Queue name for vector indexing
|
|
33
|
+
*/
|
|
34
|
+
export const VECTOR_INDEXING_QUEUE_NAME = 'vector-indexing'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a vector indexing queue instance.
|
|
38
|
+
*
|
|
39
|
+
* @param strategy - Queue strategy: 'local' for file-based, 'async' for BullMQ/Redis
|
|
40
|
+
* @param options - Strategy-specific options
|
|
41
|
+
* @returns Queue instance for vector indexing jobs
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* // Local queue for development
|
|
46
|
+
* const queue = createVectorIndexingQueue('local')
|
|
47
|
+
*
|
|
48
|
+
* // Async queue for production
|
|
49
|
+
* const queue = createVectorIndexingQueue('async', {
|
|
50
|
+
* connection: { url: process.env.REDIS_URL }
|
|
51
|
+
* })
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function createVectorIndexingQueue(
|
|
55
|
+
strategy: 'local' | 'async' = 'local',
|
|
56
|
+
options?: {
|
|
57
|
+
connection?: { url?: string; host?: string; port?: number }
|
|
58
|
+
},
|
|
59
|
+
): Queue<VectorIndexJobPayload> {
|
|
60
|
+
if (strategy === 'async') {
|
|
61
|
+
return createQueue<VectorIndexJobPayload>(VECTOR_INDEXING_QUEUE_NAME, 'async', {
|
|
62
|
+
connection: options?.connection,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return createQueue<VectorIndexJobPayload>(VECTOR_INDEXING_QUEUE_NAME, 'local')
|
|
66
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SearchStrategy,
|
|
3
|
+
SearchStrategyId,
|
|
4
|
+
SearchOptions,
|
|
5
|
+
SearchResult,
|
|
6
|
+
SearchServiceOptions,
|
|
7
|
+
ResultMergeConfig,
|
|
8
|
+
IndexableRecord,
|
|
9
|
+
PresenterEnricherFn,
|
|
10
|
+
} from './types'
|
|
11
|
+
import { mergeAndRankResults } from './lib/merger'
|
|
12
|
+
import { searchError } from './lib/debug'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default merge configuration.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_MERGE_CONFIG: ResultMergeConfig = {
|
|
18
|
+
duplicateHandling: 'highest_score',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SearchService orchestrates multiple search strategies, executing searches in parallel
|
|
23
|
+
* and merging results using the RRF algorithm.
|
|
24
|
+
*
|
|
25
|
+
* Features:
|
|
26
|
+
* - Parallel strategy execution for optimal performance
|
|
27
|
+
* - Graceful degradation when strategies fail
|
|
28
|
+
* - Result merging with configurable weights
|
|
29
|
+
* - Strategy availability checking
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const service = new SearchService({
|
|
34
|
+
* strategies: [tokenStrategy, vectorStrategy, fulltextStrategy],
|
|
35
|
+
* defaultStrategies: ['fulltext', 'vector', 'tokens'],
|
|
36
|
+
* mergeConfig: {
|
|
37
|
+
* duplicateHandling: 'highest_score',
|
|
38
|
+
* strategyWeights: { fulltext: 1.2, vector: 1.0, tokens: 0.8 },
|
|
39
|
+
* },
|
|
40
|
+
* })
|
|
41
|
+
*
|
|
42
|
+
* const results = await service.search('john doe', { tenantId: 'tenant-123' })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class SearchService {
|
|
46
|
+
private readonly strategies: Map<SearchStrategyId, SearchStrategy>
|
|
47
|
+
private readonly defaultStrategies: SearchStrategyId[]
|
|
48
|
+
private readonly fallbackStrategy: SearchStrategyId | undefined
|
|
49
|
+
private readonly mergeConfig: ResultMergeConfig
|
|
50
|
+
private readonly presenterEnricher?: PresenterEnricherFn
|
|
51
|
+
|
|
52
|
+
constructor(options: SearchServiceOptions = {}) {
|
|
53
|
+
this.strategies = new Map()
|
|
54
|
+
for (const strategy of options.strategies ?? []) {
|
|
55
|
+
this.strategies.set(strategy.id, strategy)
|
|
56
|
+
}
|
|
57
|
+
this.defaultStrategies = options.defaultStrategies ?? ['tokens']
|
|
58
|
+
this.fallbackStrategy = options.fallbackStrategy
|
|
59
|
+
this.mergeConfig = options.mergeConfig ?? DEFAULT_MERGE_CONFIG
|
|
60
|
+
this.presenterEnricher = options.presenterEnricher
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get all registered strategies.
|
|
65
|
+
*/
|
|
66
|
+
getStrategies(): SearchStrategy[] {
|
|
67
|
+
return Array.from(this.strategies.values())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Execute a search query across configured strategies.
|
|
72
|
+
*
|
|
73
|
+
* @param query - Search query string
|
|
74
|
+
* @param options - Search options with tenant, filters, etc.
|
|
75
|
+
* @returns Merged and ranked search results
|
|
76
|
+
*/
|
|
77
|
+
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
78
|
+
const strategyIds = options.strategies ?? this.defaultStrategies
|
|
79
|
+
const activeStrategies = await this.getAvailableStrategies(strategyIds)
|
|
80
|
+
|
|
81
|
+
if (activeStrategies.length === 0) {
|
|
82
|
+
// Try fallback strategy if defined
|
|
83
|
+
if (this.fallbackStrategy) {
|
|
84
|
+
const fallback = await this.getAvailableStrategies([this.fallbackStrategy])
|
|
85
|
+
if (fallback.length > 0) {
|
|
86
|
+
activeStrategies.push(...fallback)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (activeStrategies.length === 0) {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Execute searches in parallel with graceful degradation
|
|
96
|
+
const results = await Promise.allSettled(
|
|
97
|
+
activeStrategies.map((strategy) => this.executeStrategySearch(strategy, query, options)),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Collect successful results, log failures
|
|
101
|
+
const allResults: SearchResult[] = []
|
|
102
|
+
for (let i = 0; i < results.length; i++) {
|
|
103
|
+
const result = results[i]
|
|
104
|
+
if (result.status === 'fulfilled') {
|
|
105
|
+
allResults.push(...result.value)
|
|
106
|
+
} else {
|
|
107
|
+
const strategy = activeStrategies[i]
|
|
108
|
+
searchError('SearchService', 'Strategy search failed', {
|
|
109
|
+
strategyId: strategy?.id,
|
|
110
|
+
error: result.reason instanceof Error ? result.reason.message : result.reason,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Merge and rank results
|
|
116
|
+
const merged = mergeAndRankResults(allResults, this.mergeConfig)
|
|
117
|
+
|
|
118
|
+
// Enrich results missing presenter data
|
|
119
|
+
return this.enrichResultsWithPresenter(merged, options.tenantId, options.organizationId)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Enrich results that are missing presenter data using the configured enricher.
|
|
124
|
+
* This ensures token-only results get proper titles/subtitles for display.
|
|
125
|
+
*/
|
|
126
|
+
private async enrichResultsWithPresenter(
|
|
127
|
+
results: SearchResult[],
|
|
128
|
+
tenantId: string,
|
|
129
|
+
organizationId?: string | null,
|
|
130
|
+
): Promise<SearchResult[]> {
|
|
131
|
+
// If no enricher configured, return as-is
|
|
132
|
+
if (!this.presenterEnricher) return results
|
|
133
|
+
|
|
134
|
+
// Check if any results need enrichment (missing or encrypted presenter)
|
|
135
|
+
const needsEnrichment = (r: SearchResult) => {
|
|
136
|
+
if (!r.presenter?.title) return true
|
|
137
|
+
// Also enrich if presenter looks encrypted (format: iv:ciphertext:authTag:v1)
|
|
138
|
+
const title = r.presenter.title
|
|
139
|
+
if (typeof title === 'string' && title.includes(':')) {
|
|
140
|
+
const parts = title.split(':')
|
|
141
|
+
if (parts.length >= 3 && parts[parts.length - 1] === 'v1') return true
|
|
142
|
+
}
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
const hasMissing = results.some(needsEnrichment)
|
|
146
|
+
if (!hasMissing) return results
|
|
147
|
+
|
|
148
|
+
// Use the configured presenter enricher
|
|
149
|
+
try {
|
|
150
|
+
return await this.presenterEnricher(results, tenantId, organizationId)
|
|
151
|
+
} catch {
|
|
152
|
+
// Enrichment failed, return results as-is
|
|
153
|
+
return results
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Index a record across all available strategies.
|
|
159
|
+
*
|
|
160
|
+
* @param record - Record to index
|
|
161
|
+
*/
|
|
162
|
+
async index(record: IndexableRecord): Promise<void> {
|
|
163
|
+
const strategies = await this.getAvailableStrategies()
|
|
164
|
+
|
|
165
|
+
if (strategies.length === 0) {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const results = await Promise.allSettled(
|
|
170
|
+
strategies.map((strategy) => this.executeStrategyIndex(strategy, record)),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// Log any failures
|
|
174
|
+
for (let i = 0; i < results.length; i++) {
|
|
175
|
+
const result = results[i]
|
|
176
|
+
if (result.status === 'rejected') {
|
|
177
|
+
const strategy = strategies[i]
|
|
178
|
+
searchError('SearchService', 'Strategy index failed', {
|
|
179
|
+
strategyId: strategy?.id,
|
|
180
|
+
entityId: record.entityId,
|
|
181
|
+
recordId: record.recordId,
|
|
182
|
+
error: result.reason instanceof Error ? result.reason.message : result.reason,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Delete a record from all strategies.
|
|
190
|
+
*
|
|
191
|
+
* @param entityId - Entity type identifier
|
|
192
|
+
* @param recordId - Record primary key
|
|
193
|
+
* @param tenantId - Tenant for isolation
|
|
194
|
+
*/
|
|
195
|
+
async delete(entityId: string, recordId: string, tenantId: string): Promise<void> {
|
|
196
|
+
const strategies = await this.getAvailableStrategies()
|
|
197
|
+
|
|
198
|
+
const results = await Promise.allSettled(
|
|
199
|
+
strategies.map((strategy) => this.executeStrategyDelete(strategy, entityId, recordId, tenantId)),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// Log any failures
|
|
203
|
+
for (let i = 0; i < results.length; i++) {
|
|
204
|
+
const result = results[i]
|
|
205
|
+
if (result.status === 'rejected') {
|
|
206
|
+
const strategy = strategies[i]
|
|
207
|
+
searchError('SearchService', 'Strategy delete failed', {
|
|
208
|
+
strategyId: strategy?.id,
|
|
209
|
+
entityId,
|
|
210
|
+
recordId,
|
|
211
|
+
error: result.reason instanceof Error ? result.reason.message : result.reason,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Bulk index multiple records.
|
|
219
|
+
*
|
|
220
|
+
* @param records - Records to index
|
|
221
|
+
*/
|
|
222
|
+
async bulkIndex(records: IndexableRecord[]): Promise<void> {
|
|
223
|
+
if (records.length === 0) return
|
|
224
|
+
|
|
225
|
+
const strategies = await this.getAvailableStrategies()
|
|
226
|
+
|
|
227
|
+
const results = await Promise.allSettled(
|
|
228
|
+
strategies.map((strategy) => {
|
|
229
|
+
if (strategy.bulkIndex) {
|
|
230
|
+
return strategy.bulkIndex(records)
|
|
231
|
+
}
|
|
232
|
+
// Fallback to individual indexing
|
|
233
|
+
return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
// Log any failures
|
|
238
|
+
for (let i = 0; i < results.length; i++) {
|
|
239
|
+
const result = results[i]
|
|
240
|
+
if (result.status === 'rejected') {
|
|
241
|
+
const strategy = strategies[i]
|
|
242
|
+
searchError('SearchService', 'Strategy bulkIndex failed', {
|
|
243
|
+
strategyId: strategy?.id,
|
|
244
|
+
recordCount: records.length,
|
|
245
|
+
error: result.reason instanceof Error ? result.reason.message : result.reason,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Purge all records for an entity type.
|
|
253
|
+
*
|
|
254
|
+
* @param entityId - Entity type to purge
|
|
255
|
+
* @param tenantId - Tenant for isolation
|
|
256
|
+
*/
|
|
257
|
+
async purge(entityId: string, tenantId: string): Promise<void> {
|
|
258
|
+
const strategies = await this.getAvailableStrategies()
|
|
259
|
+
|
|
260
|
+
const results = await Promise.allSettled(
|
|
261
|
+
strategies.map((strategy) => {
|
|
262
|
+
if (strategy.purge) {
|
|
263
|
+
return strategy.purge(entityId, tenantId)
|
|
264
|
+
}
|
|
265
|
+
return Promise.resolve()
|
|
266
|
+
}),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// Log any failures
|
|
270
|
+
for (let i = 0; i < results.length; i++) {
|
|
271
|
+
const result = results[i]
|
|
272
|
+
if (result.status === 'rejected') {
|
|
273
|
+
const strategy = strategies[i]
|
|
274
|
+
searchError('SearchService', 'Strategy purge failed', {
|
|
275
|
+
strategyId: strategy?.id,
|
|
276
|
+
entityId,
|
|
277
|
+
error: result.reason instanceof Error ? result.reason.message : result.reason,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Register a new strategy at runtime.
|
|
285
|
+
*
|
|
286
|
+
* @param strategy - Strategy to register
|
|
287
|
+
*/
|
|
288
|
+
registerStrategy(strategy: SearchStrategy): void {
|
|
289
|
+
this.strategies.set(strategy.id, strategy)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Unregister a strategy.
|
|
294
|
+
*
|
|
295
|
+
* @param strategyId - Strategy ID to remove
|
|
296
|
+
*/
|
|
297
|
+
unregisterStrategy(strategyId: SearchStrategyId): void {
|
|
298
|
+
this.strategies.delete(strategyId)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get all registered strategy IDs.
|
|
303
|
+
*/
|
|
304
|
+
getRegisteredStrategies(): SearchStrategyId[] {
|
|
305
|
+
return Array.from(this.strategies.keys())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get a specific strategy by ID.
|
|
310
|
+
*
|
|
311
|
+
* @param strategyId - Strategy ID to retrieve
|
|
312
|
+
* @returns The strategy if registered, undefined otherwise
|
|
313
|
+
*/
|
|
314
|
+
getStrategy(strategyId: SearchStrategyId): SearchStrategy | undefined {
|
|
315
|
+
return this.strategies.get(strategyId)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get the default strategies list.
|
|
320
|
+
*/
|
|
321
|
+
getDefaultStrategies(): SearchStrategyId[] {
|
|
322
|
+
return [...this.defaultStrategies]
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check if a specific strategy is available.
|
|
327
|
+
*
|
|
328
|
+
* @param strategyId - Strategy ID to check
|
|
329
|
+
*/
|
|
330
|
+
async isStrategyAvailable(strategyId: SearchStrategyId): Promise<boolean> {
|
|
331
|
+
const strategy = this.strategies.get(strategyId)
|
|
332
|
+
if (!strategy) return false
|
|
333
|
+
return strategy.isAvailable()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get available strategies from the requested list.
|
|
338
|
+
* Filters out strategies that are not registered or not available.
|
|
339
|
+
*/
|
|
340
|
+
private async getAvailableStrategies(ids?: SearchStrategyId[]): Promise<SearchStrategy[]> {
|
|
341
|
+
const targetIds = ids ?? Array.from(this.strategies.keys())
|
|
342
|
+
const available: SearchStrategy[] = []
|
|
343
|
+
|
|
344
|
+
for (const id of targetIds) {
|
|
345
|
+
const strategy = this.strategies.get(id)
|
|
346
|
+
if (strategy) {
|
|
347
|
+
try {
|
|
348
|
+
const isAvailable = await strategy.isAvailable()
|
|
349
|
+
if (isAvailable) {
|
|
350
|
+
available.push(strategy)
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
// Strategy availability check failed, skip it
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Sort by priority (higher priority first)
|
|
359
|
+
return available.sort((a, b) => b.priority - a.priority)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Execute search on a single strategy with error handling.
|
|
364
|
+
*/
|
|
365
|
+
private async executeStrategySearch(
|
|
366
|
+
strategy: SearchStrategy,
|
|
367
|
+
query: string,
|
|
368
|
+
options: SearchOptions,
|
|
369
|
+
): Promise<SearchResult[]> {
|
|
370
|
+
await strategy.ensureReady()
|
|
371
|
+
return strategy.search(query, options)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Execute index on a single strategy with error handling.
|
|
376
|
+
*/
|
|
377
|
+
private async executeStrategyIndex(
|
|
378
|
+
strategy: SearchStrategy,
|
|
379
|
+
record: IndexableRecord,
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
await strategy.ensureReady()
|
|
382
|
+
return strategy.index(record)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Execute delete on a single strategy with error handling.
|
|
387
|
+
*/
|
|
388
|
+
private async executeStrategyDelete(
|
|
389
|
+
strategy: SearchStrategy,
|
|
390
|
+
entityId: string,
|
|
391
|
+
recordId: string,
|
|
392
|
+
tenantId: string,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
await strategy.ensureReady()
|
|
395
|
+
return strategy.delete(entityId, recordId, tenantId)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SearchStrategy,
|
|
3
|
+
SearchStrategyId,
|
|
4
|
+
SearchOptions,
|
|
5
|
+
SearchResult,
|
|
6
|
+
IndexableRecord,
|
|
7
|
+
} from '../types'
|
|
8
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
9
|
+
import type {
|
|
10
|
+
FullTextSearchDriver,
|
|
11
|
+
FullTextSearchDocument,
|
|
12
|
+
FullTextSearchHit,
|
|
13
|
+
DocumentLookupKey,
|
|
14
|
+
IndexStats,
|
|
15
|
+
} from '../fulltext/types'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* FullTextSearchStrategy provides full-text fuzzy search using a pluggable driver.
|
|
19
|
+
* Default driver is Meilisearch, but can be swapped for Algolia, Elasticsearch, etc.
|
|
20
|
+
*/
|
|
21
|
+
export class FullTextSearchStrategy implements SearchStrategy {
|
|
22
|
+
readonly id: SearchStrategyId = 'fulltext'
|
|
23
|
+
readonly name = 'Full-Text Search'
|
|
24
|
+
readonly priority = 30 // Highest priority when available
|
|
25
|
+
|
|
26
|
+
constructor(private readonly driver: FullTextSearchDriver) {}
|
|
27
|
+
|
|
28
|
+
async isAvailable(): Promise<boolean> {
|
|
29
|
+
return this.driver.isHealthy()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async ensureReady(): Promise<void> {
|
|
33
|
+
return this.driver.ensureReady()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
37
|
+
const hits = await this.driver.search(query, {
|
|
38
|
+
tenantId: options.tenantId,
|
|
39
|
+
organizationId: options.organizationId,
|
|
40
|
+
entityTypes: options.entityTypes,
|
|
41
|
+
limit: options.limit,
|
|
42
|
+
offset: options.offset,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return hits.map((hit) => this.mapHitToResult(hit))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async index(record: IndexableRecord): Promise<void> {
|
|
49
|
+
const doc = this.mapRecordToDocument(record)
|
|
50
|
+
await this.driver.index(doc)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void> {
|
|
54
|
+
return this.driver.delete(recordId, tenantId)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async bulkIndex(records: IndexableRecord[]): Promise<void> {
|
|
58
|
+
if (!this.driver.bulkIndex) {
|
|
59
|
+
// Fallback to sequential indexing
|
|
60
|
+
for (const record of records) {
|
|
61
|
+
await this.index(record)
|
|
62
|
+
}
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const docs = records.map((record) => this.mapRecordToDocument(record))
|
|
67
|
+
return this.driver.bulkIndex(docs)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async purge(entityId: EntityId, tenantId: string): Promise<void> {
|
|
71
|
+
if (!this.driver.purge) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
return this.driver.purge(entityId, tenantId)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Additional methods exposed for enrichment and admin purposes
|
|
78
|
+
// These delegate to optional driver methods
|
|
79
|
+
|
|
80
|
+
async clearIndex(tenantId: string): Promise<void> {
|
|
81
|
+
if (!this.driver.clearIndex) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
return this.driver.clearIndex(tenantId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async recreateIndex(tenantId: string): Promise<void> {
|
|
88
|
+
if (!this.driver.recreateIndex) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
return this.driver.recreateIndex(tenantId)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getDocuments(
|
|
95
|
+
ids: DocumentLookupKey[],
|
|
96
|
+
tenantId: string
|
|
97
|
+
): Promise<Map<string, SearchResult>> {
|
|
98
|
+
if (!this.driver.getDocuments) {
|
|
99
|
+
return new Map()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hits = await this.driver.getDocuments(ids, tenantId)
|
|
103
|
+
const result = new Map<string, SearchResult>()
|
|
104
|
+
|
|
105
|
+
for (const [key, hit] of hits) {
|
|
106
|
+
result.set(key, this.mapHitToResult(hit))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getIndexStats(tenantId: string): Promise<IndexStats | null> {
|
|
113
|
+
if (!this.driver.getIndexStats) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
return this.driver.getIndexStats(tenantId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {
|
|
120
|
+
if (!this.driver.getEntityCounts) {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
return this.driver.getEntityCounts(tenantId)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get driverId(): string {
|
|
127
|
+
return this.driver.id
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private mapHitToResult(hit: FullTextSearchHit): SearchResult {
|
|
131
|
+
return {
|
|
132
|
+
entityId: hit.entityId,
|
|
133
|
+
recordId: hit.recordId,
|
|
134
|
+
score: hit.score,
|
|
135
|
+
source: this.id,
|
|
136
|
+
presenter: hit.presenter,
|
|
137
|
+
url: hit.url,
|
|
138
|
+
links: hit.links,
|
|
139
|
+
metadata: hit.metadata,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private mapRecordToDocument(record: IndexableRecord): FullTextSearchDocument {
|
|
144
|
+
return {
|
|
145
|
+
recordId: record.recordId,
|
|
146
|
+
entityId: record.entityId,
|
|
147
|
+
tenantId: record.tenantId,
|
|
148
|
+
organizationId: record.organizationId,
|
|
149
|
+
fields: record.fields,
|
|
150
|
+
presenter: record.presenter,
|
|
151
|
+
url: record.url,
|
|
152
|
+
links: record.links,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { TokenSearchStrategy, type TokenStrategyConfig } from './token.strategy'
|
|
2
|
+
export { VectorSearchStrategy, type VectorStrategyConfig, type EmbeddingService } from './vector.strategy'
|
|
3
|
+
export { FullTextSearchStrategy } from './fulltext.strategy'
|
|
4
|
+
|
|
5
|
+
// Re-export fulltext driver types for convenience
|
|
6
|
+
export type {
|
|
7
|
+
FullTextSearchDriver,
|
|
8
|
+
FullTextSearchDriverId,
|
|
9
|
+
FullTextSearchDocument,
|
|
10
|
+
FullTextSearchQuery,
|
|
11
|
+
FullTextSearchHit,
|
|
12
|
+
FullTextSearchDriverConfig,
|
|
13
|
+
DocumentLookupKey,
|
|
14
|
+
IndexStats,
|
|
15
|
+
} from '../fulltext/types'
|
|
16
|
+
export { createMeilisearchDriver, createFulltextDriver } from '../fulltext/drivers'
|
|
17
|
+
export type { MeilisearchDriverOptions } from '../fulltext/drivers/meilisearch'
|