@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.
Files changed (237) hide show
  1. package/AGENTS.md +678 -0
  2. package/build.mjs +92 -0
  3. package/dist/di.js +157 -0
  4. package/dist/di.js.map +7 -0
  5. package/dist/fulltext/drivers/index.js +21 -0
  6. package/dist/fulltext/drivers/index.js.map +7 -0
  7. package/dist/fulltext/drivers/meilisearch/index.js +320 -0
  8. package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
  9. package/dist/fulltext/index.js +7 -0
  10. package/dist/fulltext/index.js.map +7 -0
  11. package/dist/fulltext/types.js +1 -0
  12. package/dist/fulltext/types.js.map +7 -0
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/indexer/index.js +8 -0
  16. package/dist/indexer/index.js.map +7 -0
  17. package/dist/indexer/search-indexer.js +848 -0
  18. package/dist/indexer/search-indexer.js.map +7 -0
  19. package/dist/indexer/subscribers/delete.js +41 -0
  20. package/dist/indexer/subscribers/delete.js.map +7 -0
  21. package/dist/lib/debug.js +34 -0
  22. package/dist/lib/debug.js.map +7 -0
  23. package/dist/lib/fallback-presenter.js +107 -0
  24. package/dist/lib/fallback-presenter.js.map +7 -0
  25. package/dist/lib/field-policy.js +75 -0
  26. package/dist/lib/field-policy.js.map +7 -0
  27. package/dist/lib/index.js +19 -0
  28. package/dist/lib/index.js.map +7 -0
  29. package/dist/lib/merger.js +93 -0
  30. package/dist/lib/merger.js.map +7 -0
  31. package/dist/lib/presenter-enricher.js +192 -0
  32. package/dist/lib/presenter-enricher.js.map +7 -0
  33. package/dist/modules/search/acl.js +14 -0
  34. package/dist/modules/search/acl.js.map +7 -0
  35. package/dist/modules/search/ai-tools.js +284 -0
  36. package/dist/modules/search/ai-tools.js.map +7 -0
  37. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
  38. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
  39. package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
  40. package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
  41. package/dist/modules/search/api/embeddings/route.js +246 -0
  42. package/dist/modules/search/api/embeddings/route.js.map +7 -0
  43. package/dist/modules/search/api/index/route.js +245 -0
  44. package/dist/modules/search/api/index/route.js.map +7 -0
  45. package/dist/modules/search/api/reindex/cancel/route.js +65 -0
  46. package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
  47. package/dist/modules/search/api/reindex/route.js +332 -0
  48. package/dist/modules/search/api/reindex/route.js.map +7 -0
  49. package/dist/modules/search/api/search/global/route.js +100 -0
  50. package/dist/modules/search/api/search/global/route.js.map +7 -0
  51. package/dist/modules/search/api/search/route.js +101 -0
  52. package/dist/modules/search/api/search/route.js.map +7 -0
  53. package/dist/modules/search/api/settings/fulltext/route.js +55 -0
  54. package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
  55. package/dist/modules/search/api/settings/global-search/route.js +80 -0
  56. package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
  57. package/dist/modules/search/api/settings/route.js +118 -0
  58. package/dist/modules/search/api/settings/route.js.map +7 -0
  59. package/dist/modules/search/api/settings/vector-store/route.js +77 -0
  60. package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
  61. package/dist/modules/search/backend/config/search/page.js +10 -0
  62. package/dist/modules/search/backend/config/search/page.js.map +7 -0
  63. package/dist/modules/search/backend/config/search/page.meta.js +24 -0
  64. package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
  65. package/dist/modules/search/cli.js +698 -0
  66. package/dist/modules/search/cli.js.map +7 -0
  67. package/dist/modules/search/di.js +32 -0
  68. package/dist/modules/search/di.js.map +7 -0
  69. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
  70. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
  71. package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
  72. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
  73. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
  74. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
  75. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
  76. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
  77. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
  78. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
  79. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
  80. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
  81. package/dist/modules/search/frontend/index.js +9 -0
  82. package/dist/modules/search/frontend/index.js.map +7 -0
  83. package/dist/modules/search/frontend/utils.js +41 -0
  84. package/dist/modules/search/frontend/utils.js.map +7 -0
  85. package/dist/modules/search/i18n/de.json +61 -0
  86. package/dist/modules/search/i18n/en.json +72 -0
  87. package/dist/modules/search/i18n/es.json +61 -0
  88. package/dist/modules/search/i18n/pl.json +61 -0
  89. package/dist/modules/search/index.js +11 -0
  90. package/dist/modules/search/index.js.map +7 -0
  91. package/dist/modules/search/lib/auto-indexing.js +29 -0
  92. package/dist/modules/search/lib/auto-indexing.js.map +7 -0
  93. package/dist/modules/search/lib/embedding-config.js +131 -0
  94. package/dist/modules/search/lib/embedding-config.js.map +7 -0
  95. package/dist/modules/search/lib/global-search-config.js +45 -0
  96. package/dist/modules/search/lib/global-search-config.js.map +7 -0
  97. package/dist/modules/search/lib/reindex-lock.js +99 -0
  98. package/dist/modules/search/lib/reindex-lock.js.map +7 -0
  99. package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
  100. package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
  101. package/dist/modules/search/subscribers/vector_delete.js +58 -0
  102. package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
  103. package/dist/modules/search/subscribers/vector_purge.js +142 -0
  104. package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
  105. package/dist/modules/search/subscribers/vector_upsert.js +58 -0
  106. package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
  107. package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
  108. package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
  109. package/dist/modules/search/workers/vector-index.worker.js +234 -0
  110. package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
  111. package/dist/queue/fulltext-indexing.js +15 -0
  112. package/dist/queue/fulltext-indexing.js.map +7 -0
  113. package/dist/queue/index.js +3 -0
  114. package/dist/queue/index.js.map +7 -0
  115. package/dist/queue/vector-indexing.js +15 -0
  116. package/dist/queue/vector-indexing.js.map +7 -0
  117. package/dist/service.js +286 -0
  118. package/dist/service.js.map +7 -0
  119. package/dist/strategies/fulltext.strategy.js +116 -0
  120. package/dist/strategies/fulltext.strategy.js.map +7 -0
  121. package/dist/strategies/index.js +12 -0
  122. package/dist/strategies/index.js.map +7 -0
  123. package/dist/strategies/token.strategy.js +80 -0
  124. package/dist/strategies/token.strategy.js.map +7 -0
  125. package/dist/strategies/vector.strategy.js +137 -0
  126. package/dist/strategies/vector.strategy.js.map +7 -0
  127. package/dist/types.js +1 -0
  128. package/dist/types.js.map +7 -0
  129. package/dist/vector/drivers/chromadb/index.js +44 -0
  130. package/dist/vector/drivers/chromadb/index.js.map +7 -0
  131. package/dist/vector/drivers/index.js +9 -0
  132. package/dist/vector/drivers/index.js.map +7 -0
  133. package/dist/vector/drivers/pgvector/index.js +509 -0
  134. package/dist/vector/drivers/pgvector/index.js.map +7 -0
  135. package/dist/vector/drivers/qdrant/index.js +44 -0
  136. package/dist/vector/drivers/qdrant/index.js.map +7 -0
  137. package/dist/vector/index.js +4 -0
  138. package/dist/vector/index.js.map +7 -0
  139. package/dist/vector/lib/vector-logs.js +33 -0
  140. package/dist/vector/lib/vector-logs.js.map +7 -0
  141. package/dist/vector/services/checksum.js +20 -0
  142. package/dist/vector/services/checksum.js.map +7 -0
  143. package/dist/vector/services/embedding.js +222 -0
  144. package/dist/vector/services/embedding.js.map +7 -0
  145. package/dist/vector/services/index.js +4 -0
  146. package/dist/vector/services/index.js.map +7 -0
  147. package/dist/vector/services/vector-index.service.js +960 -0
  148. package/dist/vector/services/vector-index.service.js.map +7 -0
  149. package/dist/vector/types/pg.d.js +1 -0
  150. package/dist/vector/types/pg.d.js.map +7 -0
  151. package/dist/vector/types.js +75 -0
  152. package/dist/vector/types.js.map +7 -0
  153. package/jest.config.cjs +19 -0
  154. package/package.json +142 -0
  155. package/src/__tests__/queue.test.ts +148 -0
  156. package/src/__tests__/service.test.ts +345 -0
  157. package/src/__tests__/workers.test.ts +319 -0
  158. package/src/di.ts +291 -0
  159. package/src/fulltext/drivers/index.ts +41 -0
  160. package/src/fulltext/drivers/meilisearch/index.ts +410 -0
  161. package/src/fulltext/index.ts +13 -0
  162. package/src/fulltext/types.ts +115 -0
  163. package/src/index.ts +36 -0
  164. package/src/indexer/index.ts +13 -0
  165. package/src/indexer/search-indexer.ts +1141 -0
  166. package/src/indexer/subscribers/delete.ts +49 -0
  167. package/src/lib/debug.ts +46 -0
  168. package/src/lib/fallback-presenter.ts +106 -0
  169. package/src/lib/field-policy.ts +169 -0
  170. package/src/lib/index.ts +13 -0
  171. package/src/lib/merger.ts +159 -0
  172. package/src/lib/presenter-enricher.ts +323 -0
  173. package/src/modules/search/README.md +694 -0
  174. package/src/modules/search/acl.ts +10 -0
  175. package/src/modules/search/ai-tools.ts +467 -0
  176. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
  177. package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
  178. package/src/modules/search/api/embeddings/route.ts +304 -0
  179. package/src/modules/search/api/index/route.ts +297 -0
  180. package/src/modules/search/api/reindex/cancel/route.ts +77 -0
  181. package/src/modules/search/api/reindex/route.ts +419 -0
  182. package/src/modules/search/api/search/global/route.ts +120 -0
  183. package/src/modules/search/api/search/route.ts +121 -0
  184. package/src/modules/search/api/settings/fulltext/route.ts +82 -0
  185. package/src/modules/search/api/settings/global-search/route.ts +91 -0
  186. package/src/modules/search/api/settings/route.ts +187 -0
  187. package/src/modules/search/api/settings/vector-store/route.ts +105 -0
  188. package/src/modules/search/backend/config/search/page.meta.ts +22 -0
  189. package/src/modules/search/backend/config/search/page.tsx +12 -0
  190. package/src/modules/search/cli.ts +818 -0
  191. package/src/modules/search/di.ts +50 -0
  192. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
  193. package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
  194. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
  195. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
  196. package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
  197. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
  198. package/src/modules/search/frontend/index.ts +3 -0
  199. package/src/modules/search/frontend/utils.ts +82 -0
  200. package/src/modules/search/i18n/de.json +61 -0
  201. package/src/modules/search/i18n/en.json +72 -0
  202. package/src/modules/search/i18n/es.json +61 -0
  203. package/src/modules/search/i18n/pl.json +61 -0
  204. package/src/modules/search/index.ts +9 -0
  205. package/src/modules/search/lib/auto-indexing.ts +35 -0
  206. package/src/modules/search/lib/embedding-config.ts +161 -0
  207. package/src/modules/search/lib/global-search-config.ts +69 -0
  208. package/src/modules/search/lib/reindex-lock.ts +201 -0
  209. package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
  210. package/src/modules/search/subscribers/vector_delete.ts +75 -0
  211. package/src/modules/search/subscribers/vector_purge.ts +161 -0
  212. package/src/modules/search/subscribers/vector_upsert.ts +75 -0
  213. package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
  214. package/src/modules/search/workers/vector-index.worker.ts +292 -0
  215. package/src/queue/fulltext-indexing.ts +87 -0
  216. package/src/queue/index.ts +2 -0
  217. package/src/queue/vector-indexing.ts +66 -0
  218. package/src/service.ts +397 -0
  219. package/src/strategies/fulltext.strategy.ts +155 -0
  220. package/src/strategies/index.ts +17 -0
  221. package/src/strategies/token.strategy.ts +153 -0
  222. package/src/strategies/vector.strategy.ts +234 -0
  223. package/src/types.ts +38 -0
  224. package/src/vector/drivers/chromadb/index.ts +49 -0
  225. package/src/vector/drivers/index.ts +4 -0
  226. package/src/vector/drivers/pgvector/index.ts +627 -0
  227. package/src/vector/drivers/qdrant/index.ts +49 -0
  228. package/src/vector/index.ts +3 -0
  229. package/src/vector/lib/vector-logs.ts +46 -0
  230. package/src/vector/services/checksum.ts +18 -0
  231. package/src/vector/services/embedding.ts +275 -0
  232. package/src/vector/services/index.ts +3 -0
  233. package/src/vector/services/vector-index.service.ts +1234 -0
  234. package/src/vector/types/pg.d.ts +1 -0
  235. package/src/vector/types.ts +220 -0
  236. package/tsconfig.json +9 -0
  237. 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.