@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,323 @@
|
|
|
1
|
+
import type { Knex } from 'knex'
|
|
2
|
+
import type {
|
|
3
|
+
SearchBuildContext,
|
|
4
|
+
SearchResult,
|
|
5
|
+
SearchResultPresenter,
|
|
6
|
+
SearchResultLink,
|
|
7
|
+
SearchEntityConfig,
|
|
8
|
+
PresenterEnricherFn,
|
|
9
|
+
} from '../types'
|
|
10
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
11
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
12
|
+
import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
13
|
+
import { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'
|
|
14
|
+
import { extractFallbackPresenter } from './fallback-presenter'
|
|
15
|
+
|
|
16
|
+
/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */
|
|
17
|
+
const BATCH_SIZE = 500
|
|
18
|
+
|
|
19
|
+
/** Logger for debugging - uses console.warn to surface issues without breaking flow */
|
|
20
|
+
const logWarning = (message: string, context?: Record<string, unknown>) => {
|
|
21
|
+
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SEARCH_ENRICHER) {
|
|
22
|
+
console.warn(`[search:presenter-enricher] ${message}`, context ?? '')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a string looks like an encrypted value.
|
|
28
|
+
* Encrypted format: iv:ciphertext:authTag:v1
|
|
29
|
+
*/
|
|
30
|
+
function looksEncrypted(value: unknown): boolean {
|
|
31
|
+
if (typeof value !== 'string') return false
|
|
32
|
+
if (!value.includes(':')) return false
|
|
33
|
+
const parts = value.split(':')
|
|
34
|
+
// Encrypted strings end with :v1 and have at least 3 colon-separated parts
|
|
35
|
+
return parts.length >= 3 && parts[parts.length - 1] === 'v1'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a result needs enrichment (missing presenter, encrypted values, or missing URL/links)
|
|
40
|
+
*/
|
|
41
|
+
function needsEnrichment(result: SearchResult): boolean {
|
|
42
|
+
if (!result.presenter?.title) return true
|
|
43
|
+
// Also re-enrich if presenter looks encrypted
|
|
44
|
+
if (looksEncrypted(result.presenter.title)) return true
|
|
45
|
+
if (looksEncrypted(result.presenter.subtitle)) return true
|
|
46
|
+
// Also enrich if missing URL/links (needed for token search results)
|
|
47
|
+
if (!result.url && (!result.links || result.links.length === 0)) return true
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split an array into chunks of specified size.
|
|
53
|
+
*/
|
|
54
|
+
function chunk<T>(array: T[], size: number): T[][] {
|
|
55
|
+
const chunks: T[][] = []
|
|
56
|
+
for (let i = 0; i < array.length; i += size) {
|
|
57
|
+
chunks.push(array.slice(i, i + size))
|
|
58
|
+
}
|
|
59
|
+
return chunks
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a single batch query for multiple entity types and their record IDs.
|
|
64
|
+
* Uses OR conditions to fetch all needed docs in one round trip.
|
|
65
|
+
*/
|
|
66
|
+
async function fetchDocsBatch(
|
|
67
|
+
knex: Knex,
|
|
68
|
+
byEntityType: Map<string, SearchResult[]>,
|
|
69
|
+
tenantId: string,
|
|
70
|
+
organizationId?: string | null,
|
|
71
|
+
): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {
|
|
72
|
+
const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []
|
|
73
|
+
|
|
74
|
+
// Collect all entity type + record ID pairs
|
|
75
|
+
const allPairs: Array<{ entityType: string; recordId: string }> = []
|
|
76
|
+
for (const [entityType, results] of byEntityType) {
|
|
77
|
+
for (const result of results) {
|
|
78
|
+
allPairs.push({ entityType, recordId: result.recordId })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (allPairs.length === 0) return allDocs
|
|
83
|
+
|
|
84
|
+
// Process in chunks to avoid hitting DB parameter limits
|
|
85
|
+
const chunks = chunk(allPairs, BATCH_SIZE)
|
|
86
|
+
|
|
87
|
+
for (const pairChunk of chunks) {
|
|
88
|
+
// Group by entity type within this chunk for efficient OR query
|
|
89
|
+
const chunkByType = new Map<string, string[]>()
|
|
90
|
+
for (const { entityType, recordId } of pairChunk) {
|
|
91
|
+
const ids = chunkByType.get(entityType) ?? []
|
|
92
|
+
ids.push(recordId)
|
|
93
|
+
chunkByType.set(entityType, ids)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build query with OR conditions per entity type
|
|
97
|
+
const query = knex('entity_indexes')
|
|
98
|
+
.select('entity_type', 'entity_id', 'doc')
|
|
99
|
+
.where('tenant_id', tenantId)
|
|
100
|
+
.whereNull('deleted_at')
|
|
101
|
+
.where((builder) => {
|
|
102
|
+
for (const [entityType, recordIds] of chunkByType) {
|
|
103
|
+
builder.orWhere((sub) => {
|
|
104
|
+
sub.where('entity_type', entityType).whereIn('entity_id', recordIds)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Add organization filter if provided
|
|
110
|
+
if (organizationId) {
|
|
111
|
+
query.where((builder) => {
|
|
112
|
+
builder.where('organization_id', organizationId).orWhereNull('organization_id')
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rows = await query
|
|
117
|
+
allDocs.push(...(rows as typeof allDocs))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return allDocs
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Result type for presenter and links computation */
|
|
124
|
+
type EnrichmentResult = {
|
|
125
|
+
presenter: SearchResultPresenter | null
|
|
126
|
+
url?: string
|
|
127
|
+
links?: SearchResultLink[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compute presenter, URL, and links for a single doc using config or fallback.
|
|
132
|
+
* Returns presenter (null if cannot be computed), and optionally URL/links from config.
|
|
133
|
+
*/
|
|
134
|
+
async function computePresenterAndLinks(
|
|
135
|
+
doc: Record<string, unknown>,
|
|
136
|
+
entityId: string,
|
|
137
|
+
recordId: string,
|
|
138
|
+
config: SearchEntityConfig | undefined,
|
|
139
|
+
tenantId: string,
|
|
140
|
+
organizationId: string | null | undefined,
|
|
141
|
+
queryEngine: QueryEngine | undefined,
|
|
142
|
+
): Promise<EnrichmentResult> {
|
|
143
|
+
let presenter: SearchResultPresenter | null = null
|
|
144
|
+
let url: string | undefined
|
|
145
|
+
let links: SearchResultLink[] | undefined
|
|
146
|
+
|
|
147
|
+
// Build context for config functions
|
|
148
|
+
const customFields: Record<string, unknown> = {}
|
|
149
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
150
|
+
if (key.startsWith('cf:') || key.startsWith('cf_')) {
|
|
151
|
+
customFields[key.slice(3)] = value
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const buildContext: SearchBuildContext = {
|
|
156
|
+
record: doc,
|
|
157
|
+
customFields,
|
|
158
|
+
organizationId,
|
|
159
|
+
tenantId,
|
|
160
|
+
queryEngine,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// If search.ts config exists, use formatResult/buildSource for presenter
|
|
164
|
+
if (config?.formatResult || config?.buildSource) {
|
|
165
|
+
if (config.buildSource) {
|
|
166
|
+
try {
|
|
167
|
+
const source = await config.buildSource(buildContext)
|
|
168
|
+
if (source?.presenter) presenter = source.presenter
|
|
169
|
+
if (source?.links) links = source.links
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!presenter && config.formatResult) {
|
|
176
|
+
try {
|
|
177
|
+
presenter = (await config.formatResult(buildContext)) ?? null
|
|
178
|
+
} catch (err) {
|
|
179
|
+
logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Fallback presenter: extract from doc fields directly
|
|
185
|
+
if (!presenter) {
|
|
186
|
+
presenter = extractFallbackPresenter(doc, entityId, recordId)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Resolve URL from config
|
|
190
|
+
if (config?.resolveUrl) {
|
|
191
|
+
try {
|
|
192
|
+
url = (await config.resolveUrl(buildContext)) ?? undefined
|
|
193
|
+
} catch {
|
|
194
|
+
// Skip URL resolution errors
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Resolve links from config (if not already set from buildSource)
|
|
199
|
+
if (!links && config?.resolveLinks) {
|
|
200
|
+
try {
|
|
201
|
+
links = (await config.resolveLinks(buildContext)) ?? undefined
|
|
202
|
+
} catch {
|
|
203
|
+
// Skip link resolution errors
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { presenter, url, links }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Create a presenter enricher that loads data from entity_indexes and computes presenter.
|
|
212
|
+
* Uses formatResult from search.ts configs when available, otherwise falls back to extracting
|
|
213
|
+
* common fields like display_name, name, title from the doc.
|
|
214
|
+
*
|
|
215
|
+
* Optimizations:
|
|
216
|
+
* - Single batch DB query for all entity types (instead of one per type)
|
|
217
|
+
* - Parallel Promise.all for formatResult/buildSource calls
|
|
218
|
+
* - Tenant/organization scoping for security
|
|
219
|
+
* - Chunked queries to avoid DB parameter limits
|
|
220
|
+
* - Automatic decryption of encrypted fields when encryption service is provided
|
|
221
|
+
*/
|
|
222
|
+
export function createPresenterEnricher(
|
|
223
|
+
knex: Knex,
|
|
224
|
+
entityConfigMap: Map<EntityId, SearchEntityConfig>,
|
|
225
|
+
queryEngine?: QueryEngine,
|
|
226
|
+
encryptionService?: TenantDataEncryptionService | null,
|
|
227
|
+
): PresenterEnricherFn {
|
|
228
|
+
return async (results, tenantId, organizationId) => {
|
|
229
|
+
// Find results missing presenter OR with encrypted presenter
|
|
230
|
+
const missingResults = results.filter(needsEnrichment)
|
|
231
|
+
if (missingResults.length === 0) return results
|
|
232
|
+
|
|
233
|
+
// Group by entity type for config lookup
|
|
234
|
+
const byEntityType = new Map<string, SearchResult[]>()
|
|
235
|
+
for (const result of missingResults) {
|
|
236
|
+
const group = byEntityType.get(result.entityId) ?? []
|
|
237
|
+
group.push(result)
|
|
238
|
+
byEntityType.set(result.entityId, group)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Single batch query for all docs across all entity types
|
|
242
|
+
const rawDocs = await fetchDocsBatch(knex, byEntityType, tenantId, organizationId)
|
|
243
|
+
|
|
244
|
+
// Decrypt docs in parallel using DEK cache for efficiency
|
|
245
|
+
const dekCache = new Map<string | null, string | null>()
|
|
246
|
+
|
|
247
|
+
const decryptedDocs = await Promise.all(
|
|
248
|
+
rawDocs.map(async (row) => {
|
|
249
|
+
try {
|
|
250
|
+
// Use organization_id from the doc itself for proper encryption map lookup
|
|
251
|
+
// This is critical for global search where organizationId param is null
|
|
252
|
+
const docData = row.doc as Record<string, unknown>
|
|
253
|
+
const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId
|
|
254
|
+
const scope = { tenantId, organizationId: docOrgId }
|
|
255
|
+
|
|
256
|
+
const decryptedDoc = await decryptIndexDocForSearch(
|
|
257
|
+
row.entity_type,
|
|
258
|
+
row.doc,
|
|
259
|
+
scope,
|
|
260
|
+
encryptionService ?? null,
|
|
261
|
+
dekCache,
|
|
262
|
+
)
|
|
263
|
+
return { ...row, doc: decryptedDoc }
|
|
264
|
+
} catch (err) {
|
|
265
|
+
logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) })
|
|
266
|
+
return row // Return original doc if decryption fails
|
|
267
|
+
}
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
// Build doc lookup map for fast access
|
|
272
|
+
const docMap = new Map<string, Record<string, unknown>>()
|
|
273
|
+
for (const row of decryptedDocs) {
|
|
274
|
+
docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Compute presenters and links in parallel
|
|
278
|
+
const enrichmentPromises = missingResults.map(async (result) => {
|
|
279
|
+
const key = `${result.entityId}:${result.recordId}`
|
|
280
|
+
const doc = docMap.get(key)
|
|
281
|
+
|
|
282
|
+
if (!doc) {
|
|
283
|
+
logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId })
|
|
284
|
+
return { key, presenter: null, url: undefined, links: undefined }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const config = entityConfigMap.get(result.entityId as EntityId)
|
|
288
|
+
const enrichment = await computePresenterAndLinks(
|
|
289
|
+
doc,
|
|
290
|
+
result.entityId,
|
|
291
|
+
result.recordId,
|
|
292
|
+
config,
|
|
293
|
+
tenantId,
|
|
294
|
+
organizationId,
|
|
295
|
+
queryEngine,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return { key, ...enrichment }
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const computed = await Promise.all(enrichmentPromises)
|
|
302
|
+
|
|
303
|
+
// Build enrichment map from parallel results
|
|
304
|
+
const enrichmentMap = new Map<string, EnrichmentResult>()
|
|
305
|
+
for (const { key, presenter, url, links } of computed) {
|
|
306
|
+
enrichmentMap.set(key, { presenter, url, links })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Enrich results with computed presenter, URL, and links
|
|
310
|
+
return results.map((result) => {
|
|
311
|
+
if (!needsEnrichment(result)) return result
|
|
312
|
+
const key = `${result.entityId}:${result.recordId}`
|
|
313
|
+
const enriched = enrichmentMap.get(key)
|
|
314
|
+
if (!enriched) return result
|
|
315
|
+
return {
|
|
316
|
+
...result,
|
|
317
|
+
presenter: enriched.presenter ?? result.presenter,
|
|
318
|
+
url: result.url ?? enriched.url,
|
|
319
|
+
links: result.links ?? enriched.links,
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
}
|