@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
package/AGENTS.md
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
# Search Module - Agent Guidelines
|
|
2
|
+
|
|
3
|
+
This document describes how to configure and use the search module for indexing and searching entities across the Open Mercato platform.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The search module provides unified search capabilities via three strategies:
|
|
8
|
+
- **Fulltext**: Fast, typo-tolerant search via Meilisearch
|
|
9
|
+
- **Vector**: Semantic/AI-powered search via embeddings (OpenAI, Ollama, etc.)
|
|
10
|
+
- **Tokens**: Exact keyword matching in PostgreSQL (always available)
|
|
11
|
+
|
|
12
|
+
## Global Search (Cmd+K)
|
|
13
|
+
|
|
14
|
+
The global search dialog strategies can be configured per-tenant via **Settings > Search** or the API:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// Get current config
|
|
18
|
+
GET /api/search/settings/global-search
|
|
19
|
+
// Response: { "enabledStrategies": ["fulltext", "vector", "tokens"] }
|
|
20
|
+
|
|
21
|
+
// Update config
|
|
22
|
+
POST /api/search/settings/global-search
|
|
23
|
+
// Body: { "enabledStrategies": ["fulltext", "tokens"] }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Strategies automatically become unavailable if their backend is not configured (e.g., no `MEILISEARCH_HOST` means fulltext is unavailable).
|
|
27
|
+
|
|
28
|
+
## Creating a Search Configuration
|
|
29
|
+
|
|
30
|
+
Every module with searchable entities **MUST** provide a `search.ts` file.
|
|
31
|
+
|
|
32
|
+
### File Location
|
|
33
|
+
```
|
|
34
|
+
src/modules/<module>/search.ts
|
|
35
|
+
# or
|
|
36
|
+
packages/<package>/src/modules/<module>/search.ts
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Basic Structure
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import type {
|
|
43
|
+
SearchModuleConfig,
|
|
44
|
+
SearchBuildContext,
|
|
45
|
+
SearchIndexSource,
|
|
46
|
+
SearchResultPresenter,
|
|
47
|
+
SearchResultLink,
|
|
48
|
+
} from '@open-mercato/shared/modules/search'
|
|
49
|
+
|
|
50
|
+
export const searchConfig: SearchModuleConfig = {
|
|
51
|
+
// Optional: Override default strategies for all entities in this module
|
|
52
|
+
defaultStrategies: ['fulltext', 'vector', 'tokens'],
|
|
53
|
+
|
|
54
|
+
entities: [
|
|
55
|
+
{
|
|
56
|
+
entityId: 'your_module:your_entity', // Must match entity registry
|
|
57
|
+
enabled: true, // Toggle search on/off (default: true)
|
|
58
|
+
priority: 10, // Higher = appears first in mixed results
|
|
59
|
+
|
|
60
|
+
// Strategy-specific configurations below...
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default searchConfig
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Strategy Configuration
|
|
69
|
+
|
|
70
|
+
### Fulltext Strategy
|
|
71
|
+
|
|
72
|
+
Uses `fieldPolicy` to control which fields are indexed in the fulltext engine.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
{
|
|
76
|
+
entityId: 'your_module:your_entity',
|
|
77
|
+
|
|
78
|
+
fieldPolicy: {
|
|
79
|
+
// Indexed and searchable with typo tolerance
|
|
80
|
+
searchable: ['name', 'description', 'title', 'notes'],
|
|
81
|
+
|
|
82
|
+
// Hashed for exact match only (e.g., for filtering, not fuzzy search)
|
|
83
|
+
hashOnly: ['email', 'phone', 'tax_id'],
|
|
84
|
+
|
|
85
|
+
// Never indexed (sensitive data)
|
|
86
|
+
excluded: ['password', 'ssn', 'bank_account', 'api_key'],
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Presenter**: Stored directly in the fulltext index during indexing.
|
|
92
|
+
|
|
93
|
+
### Vector Strategy
|
|
94
|
+
|
|
95
|
+
Uses `buildSource` to generate text for embeddings. The returned text is converted to vectors for semantic search.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
{
|
|
99
|
+
entityId: 'your_module:your_entity',
|
|
100
|
+
|
|
101
|
+
buildSource: async (ctx: SearchBuildContext): Promise<SearchIndexSource | null> => {
|
|
102
|
+
const lines: string[] = []
|
|
103
|
+
|
|
104
|
+
// Add searchable text - this gets embedded as vectors
|
|
105
|
+
lines.push(`Name: ${ctx.record.name}`)
|
|
106
|
+
lines.push(`Description: ${ctx.record.description}`)
|
|
107
|
+
|
|
108
|
+
// Include custom fields
|
|
109
|
+
if (ctx.customFields.notes) {
|
|
110
|
+
lines.push(`Notes: ${ctx.customFields.notes}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Load related data if needed
|
|
114
|
+
if (ctx.queryEngine) {
|
|
115
|
+
const related = await ctx.queryEngine.query('other:entity', {
|
|
116
|
+
tenantId: ctx.tenantId,
|
|
117
|
+
filters: { id: ctx.record.related_id },
|
|
118
|
+
})
|
|
119
|
+
if (related.items[0]?.name) {
|
|
120
|
+
lines.push(`Related: ${related.items[0].name}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!lines.length) return null
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
text: lines, // String or string[] - gets embedded
|
|
128
|
+
presenter: {
|
|
129
|
+
title: ctx.record.name,
|
|
130
|
+
subtitle: ctx.record.status,
|
|
131
|
+
icon: 'lucide:file',
|
|
132
|
+
badge: 'Your Entity',
|
|
133
|
+
},
|
|
134
|
+
links: [
|
|
135
|
+
{ href: `/backend/your-module/${ctx.record.id}`, label: 'View', kind: 'primary' },
|
|
136
|
+
{ href: `/backend/your-module/${ctx.record.id}/edit`, label: 'Edit', kind: 'secondary' },
|
|
137
|
+
],
|
|
138
|
+
// Used for change detection - only re-index if this changes
|
|
139
|
+
checksumSource: {
|
|
140
|
+
record: ctx.record,
|
|
141
|
+
customFields: ctx.customFields,
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Presenter**: Returned from `buildSource.presenter` and stored alongside vectors.
|
|
149
|
+
|
|
150
|
+
### Tokens (Keyword) Strategy
|
|
151
|
+
|
|
152
|
+
Indexes automatically from `entity_indexes` table. No special configuration needed for indexing.
|
|
153
|
+
|
|
154
|
+
**Presenter**: Resolved at **search time** using `formatResult`. If not defined, falls back to extracting common fields from the document.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
{
|
|
158
|
+
entityId: 'your_module:your_entity',
|
|
159
|
+
|
|
160
|
+
// REQUIRED for token search to show meaningful titles instead of UUIDs
|
|
161
|
+
formatResult: async (ctx: SearchBuildContext): Promise<SearchResultPresenter | null> => {
|
|
162
|
+
return {
|
|
163
|
+
title: ctx.record.display_name ?? ctx.record.name ?? 'Unknown',
|
|
164
|
+
subtitle: ctx.record.email ?? ctx.record.status,
|
|
165
|
+
icon: 'lucide:user',
|
|
166
|
+
badge: 'Customer',
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Fallback fields** (when `formatResult` is not defined):
|
|
173
|
+
1. `display_name`, `displayName`
|
|
174
|
+
2. `name`, `title`, `label`
|
|
175
|
+
3. `full_name`, `fullName`
|
|
176
|
+
4. `first_name`, `firstName`
|
|
177
|
+
5. `email`, `primary_email`
|
|
178
|
+
6. `code`, `sku`, `reference`
|
|
179
|
+
7. Any other non-system string field
|
|
180
|
+
|
|
181
|
+
## SearchBuildContext
|
|
182
|
+
|
|
183
|
+
The context object passed to all config functions:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
interface SearchBuildContext {
|
|
187
|
+
/** The database record being indexed */
|
|
188
|
+
record: Record<string, unknown>
|
|
189
|
+
|
|
190
|
+
/** Custom fields for the record (cf:* fields without prefix) */
|
|
191
|
+
customFields: Record<string, unknown>
|
|
192
|
+
|
|
193
|
+
/** Tenant ID (always available) */
|
|
194
|
+
tenantId?: string | null
|
|
195
|
+
|
|
196
|
+
/** Organization ID (if applicable) */
|
|
197
|
+
organizationId?: string | null
|
|
198
|
+
|
|
199
|
+
/** Query engine for loading related entities */
|
|
200
|
+
queryEngine?: QueryEngine
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Using QueryEngine in Config Functions
|
|
205
|
+
|
|
206
|
+
You can use `queryEngine` to load related data for richer search results:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
formatResult: async (ctx: SearchBuildContext): Promise<SearchResultPresenter | null> => {
|
|
210
|
+
// Load parent entity for better display
|
|
211
|
+
let parentName = 'Unknown'
|
|
212
|
+
if (ctx.queryEngine && ctx.record.parent_id) {
|
|
213
|
+
const result = await ctx.queryEngine.query('module:parent_entity', {
|
|
214
|
+
tenantId: ctx.tenantId,
|
|
215
|
+
organizationId: ctx.organizationId,
|
|
216
|
+
filters: { id: ctx.record.parent_id },
|
|
217
|
+
page: { page: 1, pageSize: 1 },
|
|
218
|
+
})
|
|
219
|
+
parentName = result.items[0]?.name ?? 'Unknown'
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
title: ctx.record.name,
|
|
224
|
+
subtitle: `Parent: ${parentName}`,
|
|
225
|
+
icon: 'lucide:folder',
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Complete Entity Config Reference
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
{
|
|
234
|
+
/** Entity identifier - must match entity registry */
|
|
235
|
+
entityId: 'module:entity_name',
|
|
236
|
+
|
|
237
|
+
/** Enable/disable search for this entity (default: true) */
|
|
238
|
+
enabled: true,
|
|
239
|
+
|
|
240
|
+
/** Result ordering priority - higher appears first (default: 0) */
|
|
241
|
+
priority: 10,
|
|
242
|
+
|
|
243
|
+
/** Override strategies for this specific entity */
|
|
244
|
+
strategies: ['fulltext', 'tokens'],
|
|
245
|
+
|
|
246
|
+
/** FOR VECTOR: Generate text for embeddings */
|
|
247
|
+
buildSource: async (ctx) => ({ text: [...], presenter: {...}, checksumSource: {...} }),
|
|
248
|
+
|
|
249
|
+
/** FOR TOKENS: Format result at search time */
|
|
250
|
+
formatResult: async (ctx) => ({ title: '...', subtitle: '...', icon: '...' }),
|
|
251
|
+
|
|
252
|
+
/** Primary URL when result is clicked */
|
|
253
|
+
resolveUrl: async (ctx) => `/backend/module/${ctx.record.id}`,
|
|
254
|
+
|
|
255
|
+
/** Additional action links */
|
|
256
|
+
resolveLinks: async (ctx) => [
|
|
257
|
+
{ href: `/backend/module/${ctx.record.id}`, label: 'View', kind: 'primary' },
|
|
258
|
+
{ href: `/backend/module/${ctx.record.id}/edit`, label: 'Edit', kind: 'secondary' },
|
|
259
|
+
],
|
|
260
|
+
|
|
261
|
+
/** FOR FULLTEXT: Control field indexing */
|
|
262
|
+
fieldPolicy: {
|
|
263
|
+
searchable: ['name', 'description'],
|
|
264
|
+
hashOnly: ['email'],
|
|
265
|
+
excluded: ['password'],
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## SearchResultPresenter
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
interface SearchResultPresenter {
|
|
274
|
+
/** Main display text (required) */
|
|
275
|
+
title: string
|
|
276
|
+
|
|
277
|
+
/** Secondary text shown below title */
|
|
278
|
+
subtitle?: string
|
|
279
|
+
|
|
280
|
+
/** Icon identifier (e.g., 'lucide:user', 'user', 'building') */
|
|
281
|
+
icon?: string
|
|
282
|
+
|
|
283
|
+
/** Badge/tag shown next to title (e.g., 'Customer', 'Deal') */
|
|
284
|
+
badge?: string
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## SearchResultLink
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
interface SearchResultLink {
|
|
292
|
+
/** URL to navigate to */
|
|
293
|
+
href: string
|
|
294
|
+
|
|
295
|
+
/** Link label text */
|
|
296
|
+
label: string
|
|
297
|
+
|
|
298
|
+
/** Link style: 'primary' (main action) or 'secondary' (additional) */
|
|
299
|
+
kind: 'primary' | 'secondary'
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Core Types
|
|
304
|
+
|
|
305
|
+
### SearchOptions
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
interface SearchOptions {
|
|
309
|
+
tenantId: string
|
|
310
|
+
organizationId?: string | null
|
|
311
|
+
limit?: number
|
|
312
|
+
offset?: number
|
|
313
|
+
strategies?: SearchStrategyId[] // 'fulltext' | 'vector' | 'tokens'
|
|
314
|
+
entityTypes?: string[] // Filter by entity types
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### SearchResult
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
interface SearchResult {
|
|
322
|
+
entityId: string // e.g., 'customers:customer_person_profile'
|
|
323
|
+
recordId: string // Primary key of the record
|
|
324
|
+
score: number // Relevance score (0-1)
|
|
325
|
+
source: SearchStrategyId // Which strategy returned this result
|
|
326
|
+
presenter?: SearchResultPresenter
|
|
327
|
+
url?: string
|
|
328
|
+
links?: SearchResultLink[]
|
|
329
|
+
metadata?: Record<string, unknown>
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### IndexableRecord
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
interface IndexableRecord {
|
|
337
|
+
entityId: string
|
|
338
|
+
recordId: string
|
|
339
|
+
tenantId: string
|
|
340
|
+
organizationId?: string | null
|
|
341
|
+
fields: Record<string, unknown> // Searchable field values
|
|
342
|
+
presenter?: SearchResultPresenter
|
|
343
|
+
url?: string
|
|
344
|
+
links?: SearchResultLink[]
|
|
345
|
+
text?: string | string[] // For vector embeddings
|
|
346
|
+
checksumSource?: unknown // For change detection
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Auto-Indexing via Events
|
|
351
|
+
|
|
352
|
+
When CRUD routes have `indexer: { entityType }` configured, the search module automatically:
|
|
353
|
+
1. Subscribes to entity create/update/delete events
|
|
354
|
+
2. Indexes new/updated records using the search.ts config
|
|
355
|
+
3. Removes deleted records from all indexes
|
|
356
|
+
|
|
357
|
+
No manual indexing code is needed for standard CRUD operations.
|
|
358
|
+
|
|
359
|
+
## Programmatic Integration (DI)
|
|
360
|
+
|
|
361
|
+
Other modules can use search functionality by resolving services from the DI container.
|
|
362
|
+
|
|
363
|
+
### SearchService
|
|
364
|
+
|
|
365
|
+
The primary service for executing searches and managing indexes:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import type { SearchService } from '@open-mercato/search'
|
|
369
|
+
|
|
370
|
+
const searchService = container.resolve('searchService') as SearchService
|
|
371
|
+
|
|
372
|
+
// Execute a search
|
|
373
|
+
const results = await searchService.search('john doe', {
|
|
374
|
+
tenantId: 'tenant-123',
|
|
375
|
+
organizationId: 'org-456',
|
|
376
|
+
limit: 20,
|
|
377
|
+
strategies: ['fulltext', 'vector'],
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// Index a record
|
|
381
|
+
await searchService.index({
|
|
382
|
+
entityId: 'customers:customer_person_profile',
|
|
383
|
+
recordId: 'rec-123',
|
|
384
|
+
tenantId: 'tenant-123',
|
|
385
|
+
organizationId: 'org-456',
|
|
386
|
+
fields: { name: 'John Doe', email: 'john@example.com' },
|
|
387
|
+
presenter: { title: 'John Doe', subtitle: 'Customer' },
|
|
388
|
+
url: '/backend/customers/people/rec-123',
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// Bulk index, delete, purge
|
|
392
|
+
await searchService.bulkIndex([record1, record2])
|
|
393
|
+
await searchService.delete('customers:customer_person_profile', 'rec-123', 'tenant-123')
|
|
394
|
+
await searchService.purge('customers:customer_person_profile', 'tenant-123')
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### SearchIndexer
|
|
398
|
+
|
|
399
|
+
Higher-level API for config-aware indexing with automatic presenter/URL resolution:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
import type { SearchIndexer } from '@open-mercato/search'
|
|
403
|
+
|
|
404
|
+
const searchIndexer = container.resolve('searchIndexer') as SearchIndexer
|
|
405
|
+
|
|
406
|
+
// Index with automatic config-based formatting
|
|
407
|
+
await searchIndexer.indexRecord({
|
|
408
|
+
entityId: 'customers:customer_person_profile',
|
|
409
|
+
recordId: 'rec-123',
|
|
410
|
+
tenantId: 'tenant-123',
|
|
411
|
+
organizationId: 'org-456',
|
|
412
|
+
record: { id: 'rec-123', name: 'John Doe', email: 'john@example.com' },
|
|
413
|
+
customFields: { priority: 'high' },
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Index by ID (loads record from database)
|
|
417
|
+
const result = await searchIndexer.indexRecordById({
|
|
418
|
+
entityId: 'customers:customer_person_profile',
|
|
419
|
+
recordId: 'rec-123',
|
|
420
|
+
tenantId: 'tenant-123',
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Check entity configuration
|
|
424
|
+
if (searchIndexer.isEntityEnabled('customers:customer_person_profile')) {
|
|
425
|
+
// Entity is configured for indexing
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Reindex operations
|
|
429
|
+
await searchIndexer.reindexEntity({ entityId, tenantId, purgeFirst: true })
|
|
430
|
+
await searchIndexer.reindexAll({ tenantId, purgeFirst: true })
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### DI Token Reference
|
|
434
|
+
|
|
435
|
+
| Token | Type | Description |
|
|
436
|
+
|-------|------|-------------|
|
|
437
|
+
| `searchService` | `SearchService` | Execute searches, index/delete records |
|
|
438
|
+
| `searchIndexer` | `SearchIndexer` | Config-aware indexing with presenter resolution |
|
|
439
|
+
| `searchStrategies` | `SearchStrategy[]` | Array of registered strategy instances |
|
|
440
|
+
| `fulltextIndexQueue` | `Queue` | Queue for fulltext indexing jobs |
|
|
441
|
+
| `vectorIndexQueue` | `Queue` | Queue for vector indexing jobs |
|
|
442
|
+
|
|
443
|
+
## REST API
|
|
444
|
+
|
|
445
|
+
### Search Endpoint
|
|
446
|
+
|
|
447
|
+
**`GET /api/search`**
|
|
448
|
+
|
|
449
|
+
| Parameter | Type | Required | Description |
|
|
450
|
+
|-----------|------|----------|-------------|
|
|
451
|
+
| `q` | string | Yes | Search query |
|
|
452
|
+
| `limit` | number | No | Max results (default: 50, max: 100) |
|
|
453
|
+
| `strategies` | string | No | Comma-separated: `fulltext,vector,tokens` |
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
curl "https://your-app.com/api/search?q=john%20doe&limit=20" \
|
|
457
|
+
-H "Authorization: Bearer <token>"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**Response:**
|
|
461
|
+
```json
|
|
462
|
+
{
|
|
463
|
+
"results": [
|
|
464
|
+
{
|
|
465
|
+
"entityId": "customers:customer_person_profile",
|
|
466
|
+
"recordId": "rec-123",
|
|
467
|
+
"score": 0.95,
|
|
468
|
+
"source": "fulltext",
|
|
469
|
+
"presenter": { "title": "John Doe", "subtitle": "Customer" },
|
|
470
|
+
"url": "/backend/customers/people/rec-123"
|
|
471
|
+
}
|
|
472
|
+
],
|
|
473
|
+
"strategiesUsed": ["fulltext", "vector"],
|
|
474
|
+
"timing": 45
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Other Endpoints
|
|
479
|
+
|
|
480
|
+
| Endpoint | Method | Permission | Description |
|
|
481
|
+
|----------|--------|------------|-------------|
|
|
482
|
+
| `/api/search/settings/global-search` | GET | `search.view` | Get enabled strategies for Cmd+K |
|
|
483
|
+
| `/api/search/settings/global-search` | POST | `search.manage` | Update enabled strategies |
|
|
484
|
+
| `/api/search/reindex` | POST | `search.manage` | Trigger fulltext reindex |
|
|
485
|
+
| `/api/search/embeddings/reindex` | POST | `search.manage` | Trigger vector reindex |
|
|
486
|
+
| `/api/search/embeddings/status` | GET | `search.view` | Get vector indexing status |
|
|
487
|
+
|
|
488
|
+
## Environment Variables
|
|
489
|
+
|
|
490
|
+
| Variable | Required For | Description |
|
|
491
|
+
|----------|--------------|-------------|
|
|
492
|
+
| `MEILISEARCH_HOST` | Fulltext | Fulltext search server URL |
|
|
493
|
+
| `MEILISEARCH_API_KEY` | Fulltext | API key for fulltext server |
|
|
494
|
+
| `OPENAI_API_KEY` | Vector | OpenAI API key for embeddings |
|
|
495
|
+
| `QUEUE_STRATEGY` | Queues | `local` (dev) or `async` (prod) |
|
|
496
|
+
| `REDIS_URL` | Async queues | Redis connection URL |
|
|
497
|
+
| `QUEUE_REDIS_URL` | Async queues | Alternative Redis URL for queues |
|
|
498
|
+
| `OM_SEARCH_ENABLED` | - | Enable/disable search module (default: `true`) |
|
|
499
|
+
| `OM_SEARCH_DEBUG` | Debug | Enable verbose debug logging |
|
|
500
|
+
| `SEARCH_EXCLUDE_ENCRYPTED_FIELDS` | Security | Exclude encrypted fields from fulltext index |
|
|
501
|
+
| `DEBUG_SEARCH_ENRICHER` | Debug | Enable presenter enricher debug logs |
|
|
502
|
+
|
|
503
|
+
## Running Queue Workers
|
|
504
|
+
|
|
505
|
+
For production with `QUEUE_STRATEGY=async`:
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
# Fulltext indexing worker
|
|
509
|
+
yarn mercato search worker fulltext-indexing --concurrency=5
|
|
510
|
+
|
|
511
|
+
# Vector embedding indexing worker
|
|
512
|
+
yarn mercato search worker vector-indexing --concurrency=10
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
For development with `QUEUE_STRATEGY=local`, jobs process from `.queue/` automatically.
|
|
516
|
+
|
|
517
|
+
## CLI Commands
|
|
518
|
+
|
|
519
|
+
### Status
|
|
520
|
+
```bash
|
|
521
|
+
yarn mercato search status
|
|
522
|
+
```
|
|
523
|
+
Shows search module status, available strategies, and configuration.
|
|
524
|
+
|
|
525
|
+
### Query
|
|
526
|
+
```bash
|
|
527
|
+
yarn mercato search query -q "search term" --tenant <id> [options]
|
|
528
|
+
```
|
|
529
|
+
Options:
|
|
530
|
+
- `--query, -q` - Search query (required)
|
|
531
|
+
- `--tenant` - Tenant ID (required)
|
|
532
|
+
- `--org` - Organization ID
|
|
533
|
+
- `--entity` - Entity types (comma-separated)
|
|
534
|
+
- `--strategy` - Strategies to use: `fulltext,vector,tokens`
|
|
535
|
+
- `--limit` - Max results (default: 20)
|
|
536
|
+
|
|
537
|
+
### Index Single Record
|
|
538
|
+
```bash
|
|
539
|
+
yarn mercato search index --entity <entityId> --record <recordId> --tenant <id>
|
|
540
|
+
```
|
|
541
|
+
Options:
|
|
542
|
+
- `--entity` - Entity ID (e.g., `customers:customer_person_profile`)
|
|
543
|
+
- `--record` - Record ID
|
|
544
|
+
- `--tenant` - Tenant ID
|
|
545
|
+
- `--org` - Organization ID
|
|
546
|
+
|
|
547
|
+
### Reindex
|
|
548
|
+
```bash
|
|
549
|
+
yarn mercato search reindex --tenant <id> [options]
|
|
550
|
+
```
|
|
551
|
+
Options:
|
|
552
|
+
- `--tenant` - Tenant scope (required)
|
|
553
|
+
- `--org` - Organization scope
|
|
554
|
+
- `--entity` - Single entity to reindex (defaults to all)
|
|
555
|
+
- `--force` - Force reindex even if another job is running
|
|
556
|
+
- `--purgeFirst` - Purge before reindexing
|
|
557
|
+
- `--partitions` - Number of parallel partitions
|
|
558
|
+
- `--batch` - Override batch size
|
|
559
|
+
|
|
560
|
+
### Test Meilisearch Connection
|
|
561
|
+
```bash
|
|
562
|
+
yarn mercato search test-meilisearch
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### Start Queue Worker
|
|
566
|
+
```bash
|
|
567
|
+
yarn mercato search worker <queue-name> --concurrency=<n>
|
|
568
|
+
```
|
|
569
|
+
Queues: `fulltext-indexing`, `vector-indexing`
|
|
570
|
+
|
|
571
|
+
### Help
|
|
572
|
+
```bash
|
|
573
|
+
yarn mercato search help
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## Example: Full Search Config
|
|
577
|
+
|
|
578
|
+
See `packages/core/src/modules/customers/search.ts` for a comprehensive real-world example with:
|
|
579
|
+
- Multiple entities (person, company, deal, activity, comment)
|
|
580
|
+
- Related entity loading via queryEngine
|
|
581
|
+
- Custom field handling
|
|
582
|
+
- Presenter with fallback logic
|
|
583
|
+
- Field policies for sensitive data
|
|
584
|
+
|
|
585
|
+
## Common Patterns
|
|
586
|
+
|
|
587
|
+
### Loading Parent Entity for Display
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
formatResult: async (ctx) => {
|
|
591
|
+
const parent = ctx.queryEngine
|
|
592
|
+
? await loadParent(ctx.queryEngine, ctx.record.parent_id, ctx.tenantId)
|
|
593
|
+
: null
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
title: ctx.record.name,
|
|
597
|
+
subtitle: parent?.display_name ?? 'No parent',
|
|
598
|
+
icon: 'lucide:file',
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### Handling Custom Fields
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
buildSource: async (ctx) => {
|
|
607
|
+
const lines: string[] = []
|
|
608
|
+
|
|
609
|
+
// Standard fields
|
|
610
|
+
lines.push(`Name: ${ctx.record.name}`)
|
|
611
|
+
|
|
612
|
+
// Custom fields (already extracted without cf: prefix)
|
|
613
|
+
for (const [key, value] of Object.entries(ctx.customFields)) {
|
|
614
|
+
if (value != null) {
|
|
615
|
+
lines.push(`${formatLabel(key)}: ${value}`)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return { text: lines, presenter: {...} }
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### Conditional Strategy Usage
|
|
624
|
+
|
|
625
|
+
```typescript
|
|
626
|
+
{
|
|
627
|
+
entityId: 'module:entity',
|
|
628
|
+
|
|
629
|
+
// Only fulltext - no vector embeddings
|
|
630
|
+
fieldPolicy: { searchable: ['name'] },
|
|
631
|
+
// NO buildSource = no vector search
|
|
632
|
+
|
|
633
|
+
// formatResult still needed for token search fallback
|
|
634
|
+
formatResult: async (ctx) => ({ title: ctx.record.name }),
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### Sensitive Data Handling
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
{
|
|
642
|
+
entityId: 'module:entity',
|
|
643
|
+
|
|
644
|
+
fieldPolicy: {
|
|
645
|
+
searchable: ['name', 'description'],
|
|
646
|
+
hashOnly: ['email', 'phone'], // Exact match only
|
|
647
|
+
excluded: ['ssn', 'password', 'token'], // Never indexed
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
// In buildSource, skip sensitive fields
|
|
651
|
+
buildSource: async (ctx) => {
|
|
652
|
+
const lines: string[] = []
|
|
653
|
+
lines.push(`Name: ${ctx.record.name}`)
|
|
654
|
+
// Do NOT include: ctx.record.ssn, ctx.record.password
|
|
655
|
+
return { text: lines, presenter: {...} }
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
## Architecture
|
|
661
|
+
|
|
662
|
+
```
|
|
663
|
+
packages/search/src/
|
|
664
|
+
├── modules/search/
|
|
665
|
+
│ ├── api/ # REST API routes
|
|
666
|
+
│ ├── cli.ts # CLI commands
|
|
667
|
+
│ ├── di.ts # DI registration
|
|
668
|
+
│ ├── subscribers/ # Event subscribers (fulltext_upsert, vector_upsert, delete)
|
|
669
|
+
│ └── workers/ # Queue workers (fulltext-index, vector-index)
|
|
670
|
+
├── fulltext/ # Fulltext drivers (Meilisearch)
|
|
671
|
+
├── indexer/ # SearchIndexer implementation
|
|
672
|
+
├── queue/ # Queue definitions
|
|
673
|
+
├── strategies/ # Strategy implementations
|
|
674
|
+
├── vector/ # Vector index service
|
|
675
|
+
└── service.ts # SearchService implementation
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
See `packages/search/src/modules/search/README.md` for complete API reference and advanced configuration.
|