@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
@@ -0,0 +1,410 @@
1
+ import { MeiliSearch } from 'meilisearch'
2
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
3
+ import type { SearchFieldPolicy } from '@open-mercato/shared/modules/search'
4
+ import type {
5
+ FullTextSearchDriver,
6
+ FullTextSearchDocument,
7
+ FullTextSearchQuery,
8
+ FullTextSearchHit,
9
+ DocumentLookupKey,
10
+ IndexStats,
11
+ } from '../../types'
12
+ import { extractSearchableFields, type EncryptionMapEntry } from '../../../lib/field-policy'
13
+
14
+
15
+ export type MeilisearchDriverOptions = {
16
+ host?: string
17
+ apiKey?: string
18
+ indexPrefix?: string
19
+ defaultLimit?: number
20
+ encryptionMapResolver?: (entityId: EntityId) => Promise<EncryptionMapEntry[]>
21
+ fieldPolicyResolver?: (entityId: EntityId) => SearchFieldPolicy | undefined
22
+ }
23
+
24
+ export function createMeilisearchDriver(
25
+ options?: MeilisearchDriverOptions
26
+ ): FullTextSearchDriver {
27
+ const host = options?.host ?? process.env.MEILISEARCH_HOST ?? ''
28
+ const apiKey = options?.apiKey ?? process.env.MEILISEARCH_API_KEY ?? ''
29
+ const indexPrefix = options?.indexPrefix ?? process.env.MEILISEARCH_INDEX_PREFIX ?? 'om'
30
+ const defaultLimit = options?.defaultLimit ?? 20
31
+ const encryptionMapResolver = options?.encryptionMapResolver
32
+ const fieldPolicyResolver = options?.fieldPolicyResolver
33
+
34
+ let client: MeiliSearch | null = null
35
+ const initializedIndexes = new Set<string>()
36
+ const initializingIndexes = new Map<string, Promise<void>>()
37
+
38
+ function getClient(): MeiliSearch {
39
+ if (!client) {
40
+ client = new MeiliSearch({ host, apiKey })
41
+ }
42
+ return client
43
+ }
44
+
45
+ function buildIndexName(tenantId: string): string {
46
+ const sanitized = tenantId.replace(/[^a-zA-Z0-9_-]/g, '_')
47
+ return `${indexPrefix}_${sanitized}`
48
+ }
49
+
50
+ function escapeFilterValue(value: string): string {
51
+ return value.replace(/["\\]/g, '\\$&')
52
+ }
53
+
54
+ function buildFilters(options: FullTextSearchQuery): string[] {
55
+ const filters: string[] = []
56
+
57
+ if (options.organizationId) {
58
+ filters.push(`_organizationId = "${escapeFilterValue(options.organizationId)}"`)
59
+ }
60
+
61
+ if (options.entityTypes?.length) {
62
+ const entityFilter = options.entityTypes.map((t) => `"${escapeFilterValue(t)}"`).join(', ')
63
+ filters.push(`_entityId IN [${entityFilter}]`)
64
+ }
65
+
66
+ return filters
67
+ }
68
+
69
+ async function doEnsureIndex(indexName: string): Promise<void> {
70
+ const meiliClient = getClient()
71
+
72
+ try {
73
+ await meiliClient.createIndex(indexName, { primaryKey: '_id' })
74
+ } catch (error: unknown) {
75
+ const meilisearchError = error as { code?: string }
76
+ if (meilisearchError.code !== 'index_already_exists') {
77
+ throw error
78
+ }
79
+ }
80
+
81
+ const index = meiliClient.index(indexName)
82
+ await index.updateSettings({
83
+ searchableAttributes: ['*'],
84
+ filterableAttributes: ['_entityId', '_organizationId'],
85
+ sortableAttributes: ['_indexedAt'],
86
+ typoTolerance: {
87
+ enabled: true,
88
+ minWordSizeForTypos: {
89
+ oneTypo: 4,
90
+ twoTypos: 8,
91
+ },
92
+ },
93
+ })
94
+
95
+ initializedIndexes.add(indexName)
96
+ }
97
+
98
+ async function ensureIndex(indexName: string): Promise<void> {
99
+ if (initializedIndexes.has(indexName)) {
100
+ return
101
+ }
102
+
103
+ const existingPromise = initializingIndexes.get(indexName)
104
+ if (existingPromise) {
105
+ return existingPromise
106
+ }
107
+
108
+ const initPromise = doEnsureIndex(indexName)
109
+ initializingIndexes.set(indexName, initPromise)
110
+
111
+ try {
112
+ await initPromise
113
+ } finally {
114
+ initializingIndexes.delete(indexName)
115
+ }
116
+ }
117
+
118
+ async function prepareDocument(doc: FullTextSearchDocument): Promise<Record<string, unknown>> {
119
+ // When encryptionMapResolver is provided, SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled
120
+ const excludeEncrypted = Boolean(encryptionMapResolver)
121
+ const encryptedFields = encryptionMapResolver
122
+ ? await encryptionMapResolver(doc.entityId)
123
+ : []
124
+ const fieldPolicy = fieldPolicyResolver?.(doc.entityId)
125
+
126
+ const searchableFields = extractSearchableFields(doc.fields, {
127
+ encryptedFields,
128
+ fieldPolicy,
129
+ })
130
+
131
+ // When SEARCH_EXCLUDE_ENCRYPTED_FIELDS is enabled:
132
+ // - Exclude sensitive parts of presenter (title, subtitle) - these are derived from encrypted fields
133
+ // - Keep non-sensitive parts (icon, badge)
134
+ // - Sanitize link labels (they often contain names derived from encrypted fields)
135
+ // - Title/subtitle/link labels will be enriched at search time from the database
136
+ let presenter = doc.presenter
137
+ let links = doc.links
138
+ if (excludeEncrypted) {
139
+ if (presenter) {
140
+ presenter = {
141
+ ...presenter,
142
+ title: '', // Will be enriched at search time
143
+ subtitle: undefined, // Will be enriched at search time
144
+ }
145
+ }
146
+ // Sanitize link labels - they often contain sensitive data (names, etc.)
147
+ if (links && links.length > 0) {
148
+ links = links.map((link) => ({
149
+ ...link,
150
+ label: link.kind === 'primary' ? 'Open' : 'View', // Generic labels
151
+ }))
152
+ }
153
+ }
154
+
155
+ return {
156
+ _id: doc.recordId,
157
+ _entityId: doc.entityId,
158
+ _organizationId: doc.organizationId,
159
+ _presenter: presenter,
160
+ _url: doc.url,
161
+ _links: links,
162
+ _indexedAt: new Date().toISOString(),
163
+ ...searchableFields,
164
+ }
165
+ }
166
+
167
+ const driver: FullTextSearchDriver = {
168
+ id: 'meilisearch',
169
+
170
+ async ensureReady(): Promise<void> {
171
+ // Client is lazily initialized
172
+ },
173
+
174
+ async isHealthy(): Promise<boolean> {
175
+ if (!host) {
176
+ return false
177
+ }
178
+
179
+ try {
180
+ const meiliClient = getClient()
181
+ await meiliClient.health()
182
+ return true
183
+ } catch {
184
+ return false
185
+ }
186
+ },
187
+
188
+ async search(query: string, options: FullTextSearchQuery): Promise<FullTextSearchHit[]> {
189
+ const meiliClient = getClient()
190
+ const indexName = buildIndexName(options.tenantId)
191
+
192
+ try {
193
+ const index = meiliClient.index(indexName)
194
+ const filters = buildFilters(options)
195
+
196
+ const response = await index.search(query, {
197
+ limit: options.limit ?? defaultLimit,
198
+ offset: options.offset,
199
+ filter: filters.length > 0 ? filters.join(' AND ') : undefined,
200
+ showRankingScore: true,
201
+ })
202
+
203
+ return response.hits.map((hit: Record<string, unknown>) => ({
204
+ recordId: hit._id as string,
205
+ entityId: hit._entityId as EntityId,
206
+ score: (hit._rankingScore as number) ?? 0.5,
207
+ presenter: hit._presenter as FullTextSearchHit['presenter'],
208
+ url: hit._url as string | undefined,
209
+ links: hit._links as FullTextSearchHit['links'],
210
+ metadata: hit._metadata as Record<string, unknown> | undefined,
211
+ }))
212
+ } catch (error: unknown) {
213
+ const meilisearchError = error as { code?: string }
214
+ if (meilisearchError.code === 'index_not_found') {
215
+ return []
216
+ }
217
+ throw error
218
+ }
219
+ },
220
+
221
+ async index(doc: FullTextSearchDocument): Promise<void> {
222
+ const meiliClient = getClient()
223
+ const indexName = buildIndexName(doc.tenantId)
224
+
225
+ await ensureIndex(indexName)
226
+
227
+ const document = await prepareDocument(doc)
228
+
229
+ const index = meiliClient.index(indexName)
230
+ await index.addDocuments([document], { primaryKey: '_id' })
231
+ },
232
+
233
+ async delete(recordId: string, tenantId: string): Promise<void> {
234
+ const meiliClient = getClient()
235
+ const indexName = buildIndexName(tenantId)
236
+
237
+ try {
238
+ const index = meiliClient.index(indexName)
239
+ await index.deleteDocument(recordId)
240
+ } catch (error: unknown) {
241
+ const meilisearchError = error as { code?: string }
242
+ if (meilisearchError.code === 'index_not_found') {
243
+ return
244
+ }
245
+ throw error
246
+ }
247
+ },
248
+
249
+ async bulkIndex(docs: FullTextSearchDocument[]): Promise<void> {
250
+ if (docs.length === 0) return
251
+
252
+ // Group documents by tenant
253
+ const byTenant = new Map<string, FullTextSearchDocument[]>()
254
+ for (const doc of docs) {
255
+ const list = byTenant.get(doc.tenantId) ?? []
256
+ list.push(doc)
257
+ byTenant.set(doc.tenantId, list)
258
+ }
259
+
260
+ const meiliClient = getClient()
261
+
262
+ for (const [tenantId, tenantDocs] of byTenant) {
263
+ const indexName = buildIndexName(tenantId)
264
+ await ensureIndex(indexName)
265
+
266
+ const documents = await Promise.all(tenantDocs.map(prepareDocument))
267
+
268
+ const index = meiliClient.index(indexName)
269
+ await index.addDocuments(documents, { primaryKey: '_id' })
270
+ }
271
+ },
272
+
273
+ async purge(entityId: EntityId, tenantId: string): Promise<void> {
274
+ const meiliClient = getClient()
275
+ const indexName = buildIndexName(tenantId)
276
+
277
+ try {
278
+ const index = meiliClient.index(indexName)
279
+ await index.deleteDocuments({
280
+ filter: `_entityId = "${entityId}"`,
281
+ })
282
+ } catch (error: unknown) {
283
+ const meilisearchError = error as { code?: string }
284
+ if (meilisearchError.code === 'index_not_found') {
285
+ return
286
+ }
287
+ throw error
288
+ }
289
+ },
290
+
291
+ async clearIndex(tenantId: string): Promise<void> {
292
+ const meiliClient = getClient()
293
+ const indexName = buildIndexName(tenantId)
294
+
295
+ try {
296
+ const index = meiliClient.index(indexName)
297
+ await index.deleteAllDocuments()
298
+ } catch (error: unknown) {
299
+ const meilisearchError = error as { code?: string }
300
+ if (meilisearchError.code === 'index_not_found') {
301
+ return
302
+ }
303
+ throw error
304
+ }
305
+ },
306
+
307
+ async recreateIndex(tenantId: string): Promise<void> {
308
+ const meiliClient = getClient()
309
+ const indexName = buildIndexName(tenantId)
310
+
311
+ initializedIndexes.delete(indexName)
312
+
313
+ try {
314
+ await meiliClient.deleteIndex(indexName)
315
+ } catch (error: unknown) {
316
+ const meilisearchError = error as { code?: string }
317
+ if (meilisearchError.code !== 'index_not_found') {
318
+ throw error
319
+ }
320
+ }
321
+
322
+ await ensureIndex(indexName)
323
+ },
324
+
325
+ async getDocuments(
326
+ ids: DocumentLookupKey[],
327
+ tenantId: string
328
+ ): Promise<Map<string, FullTextSearchHit>> {
329
+ const result = new Map<string, FullTextSearchHit>()
330
+ if (ids.length === 0) return result
331
+
332
+ const meiliClient = getClient()
333
+ const indexName = buildIndexName(tenantId)
334
+
335
+ try {
336
+ const index = meiliClient.index(indexName)
337
+
338
+ const recordIds = ids.map((id) => id.recordId)
339
+ const documents = await index.getDocuments({
340
+ filter: `_id IN [${recordIds.map((id) => `"${id}"`).join(', ')}]`,
341
+ limit: recordIds.length,
342
+ })
343
+
344
+ for (const doc of documents.results) {
345
+ const hit = doc as Record<string, unknown>
346
+ const key = `${hit._entityId}:${hit._id}`
347
+ result.set(key, {
348
+ recordId: hit._id as string,
349
+ entityId: hit._entityId as EntityId,
350
+ score: 0,
351
+ presenter: hit._presenter as FullTextSearchHit['presenter'],
352
+ url: hit._url as string | undefined,
353
+ links: hit._links as FullTextSearchHit['links'],
354
+ })
355
+ }
356
+ } catch {
357
+ // Index not found or error, return empty map
358
+ }
359
+
360
+ return result
361
+ },
362
+
363
+ async getIndexStats(tenantId: string): Promise<IndexStats | null> {
364
+ const meiliClient = getClient()
365
+ const indexName = buildIndexName(tenantId)
366
+
367
+ try {
368
+ const index = meiliClient.index(indexName)
369
+ const stats = await index.getStats()
370
+ return {
371
+ numberOfDocuments: stats.numberOfDocuments,
372
+ isIndexing: stats.isIndexing,
373
+ fieldDistribution: stats.fieldDistribution,
374
+ }
375
+ } catch (error: unknown) {
376
+ const meilisearchError = error as { code?: string }
377
+ if (meilisearchError.code === 'index_not_found') {
378
+ return null
379
+ }
380
+ throw error
381
+ }
382
+ },
383
+
384
+ async getEntityCounts(tenantId: string): Promise<Record<string, number> | null> {
385
+ const meiliClient = getClient()
386
+ const indexName = buildIndexName(tenantId)
387
+
388
+ try {
389
+ const index = meiliClient.index(indexName)
390
+ const searchResult = await index.search('', {
391
+ limit: 0,
392
+ facets: ['_entityId'],
393
+ })
394
+ const facetDistribution = searchResult.facetDistribution?._entityId
395
+ if (!facetDistribution) {
396
+ return {}
397
+ }
398
+ return facetDistribution
399
+ } catch (error: unknown) {
400
+ const meilisearchError = error as { code?: string }
401
+ if (meilisearchError.code === 'index_not_found') {
402
+ return null
403
+ }
404
+ throw error
405
+ }
406
+ },
407
+ }
408
+
409
+ return driver
410
+ }
@@ -0,0 +1,13 @@
1
+ export type {
2
+ FullTextSearchDriverId,
3
+ FullTextSearchDocument,
4
+ FullTextSearchQuery,
5
+ FullTextSearchHit,
6
+ DocumentLookupKey,
7
+ IndexStats,
8
+ FullTextSearchDriverConfig,
9
+ FullTextSearchDriver,
10
+ } from './types'
11
+
12
+ export { createMeilisearchDriver, type MeilisearchDriverOptions } from './drivers/meilisearch'
13
+ export { createFulltextDriver } from './drivers'
@@ -0,0 +1,115 @@
1
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
2
+ import type {
3
+ SearchResultPresenter,
4
+ SearchResultLink,
5
+ SearchFieldPolicy,
6
+ } from '@open-mercato/shared/modules/search'
7
+ import type { EncryptionMapEntry } from '../lib/field-policy'
8
+
9
+ // =============================================================================
10
+ // Driver Identifiers
11
+ // =============================================================================
12
+
13
+ export type FullTextSearchDriverId =
14
+ | 'meilisearch'
15
+ | 'algolia'
16
+ | 'elasticsearch'
17
+ | 'typesense'
18
+ | (string & {})
19
+
20
+ // =============================================================================
21
+ // Document Types (for indexing)
22
+ // =============================================================================
23
+
24
+ export type FullTextSearchDocument = {
25
+ recordId: string
26
+ entityId: EntityId
27
+ tenantId: string
28
+ organizationId?: string | null
29
+ fields: Record<string, unknown>
30
+ presenter?: SearchResultPresenter
31
+ url?: string
32
+ links?: SearchResultLink[]
33
+ }
34
+
35
+ // =============================================================================
36
+ // Query Types
37
+ // =============================================================================
38
+
39
+ export type FullTextSearchQuery = {
40
+ tenantId: string
41
+ organizationId?: string | null
42
+ entityTypes?: EntityId[]
43
+ limit?: number
44
+ offset?: number
45
+ }
46
+
47
+ // =============================================================================
48
+ // Result Types
49
+ // =============================================================================
50
+
51
+ export type FullTextSearchHit = {
52
+ recordId: string
53
+ entityId: EntityId
54
+ score: number
55
+ presenter?: SearchResultPresenter
56
+ url?: string
57
+ links?: SearchResultLink[]
58
+ metadata?: Record<string, unknown>
59
+ }
60
+
61
+ export type DocumentLookupKey = {
62
+ entityId: EntityId
63
+ recordId: string
64
+ }
65
+
66
+ export type IndexStats = {
67
+ numberOfDocuments: number
68
+ isIndexing: boolean
69
+ fieldDistribution: Record<string, number>
70
+ }
71
+
72
+ // =============================================================================
73
+ // Driver Configuration
74
+ // =============================================================================
75
+
76
+ export type FullTextSearchDriverConfig = {
77
+ encryptionMapResolver?: (entityId: EntityId) => Promise<EncryptionMapEntry[]>
78
+ fieldPolicyResolver?: (entityId: EntityId) => SearchFieldPolicy | undefined
79
+ defaultLimit?: number
80
+ }
81
+
82
+ // =============================================================================
83
+ // Driver Interface
84
+ // =============================================================================
85
+
86
+ export interface FullTextSearchDriver {
87
+ readonly id: FullTextSearchDriverId
88
+
89
+ // Lifecycle methods (mandatory)
90
+ ensureReady(): Promise<void>
91
+ isHealthy(): Promise<boolean>
92
+
93
+ // Core operations (mandatory)
94
+ search(query: string, options: FullTextSearchQuery): Promise<FullTextSearchHit[]>
95
+ index(doc: FullTextSearchDocument): Promise<void>
96
+ delete(recordId: string, tenantId: string): Promise<void>
97
+
98
+ // Batch operations (optional)
99
+ bulkIndex?(docs: FullTextSearchDocument[]): Promise<void>
100
+ purge?(entityId: EntityId, tenantId: string): Promise<void>
101
+
102
+ // Index management (optional)
103
+ clearIndex?(tenantId: string): Promise<void>
104
+ recreateIndex?(tenantId: string): Promise<void>
105
+
106
+ // Document retrieval for enrichment (optional)
107
+ getDocuments?(
108
+ ids: DocumentLookupKey[],
109
+ tenantId: string
110
+ ): Promise<Map<string, FullTextSearchHit>>
111
+
112
+ // Stats/admin (optional)
113
+ getIndexStats?(tenantId: string): Promise<IndexStats | null>
114
+ getEntityCounts?(tenantId: string): Promise<Record<string, number> | null>
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @open-mercato/search
3
+ *
4
+ * Pluggable search module with multiple strategy support:
5
+ * - TokenSearchStrategy: Hash-based search for encrypted data
6
+ * - VectorSearchStrategy: Semantic AI-powered search
7
+ * - FullTextSearchStrategy: Full-text fuzzy search with pluggable drivers (Meilisearch, Algolia, etc.)
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { SearchService } from '@open-mercato/search'
12
+ *
13
+ * const results = await searchService.search('john doe', {
14
+ * tenantId: 'tenant-123',
15
+ * entityTypes: ['customers:customer_person_profile'],
16
+ * })
17
+ * ```
18
+ */
19
+
20
+ // Re-export types
21
+ export * from './types'
22
+
23
+ // Service
24
+ export { SearchService } from './service'
25
+
26
+ // Strategies
27
+ export * from './strategies'
28
+
29
+ // Lib utilities
30
+ export * from './lib'
31
+
32
+ // Indexer
33
+ export * from './indexer'
34
+
35
+ // DI registration
36
+ export { registerSearchModule, addSearchStrategy, type SearchContainer, type SearchModuleOptions } from './di'
@@ -0,0 +1,13 @@
1
+ export { SearchIndexer } from './search-indexer'
2
+ export type {
3
+ IndexRecordParams,
4
+ DeleteRecordParams,
5
+ PurgeEntityParams,
6
+ ReindexEntityParams,
7
+ ReindexAllParams,
8
+ ReindexProgress,
9
+ ReindexResult,
10
+ SearchIndexerOptions,
11
+ } from './search-indexer'
12
+
13
+ export { createSearchDeleteSubscriber, metadata as searchDeleteMetadata } from './subscribers/delete'