@open-mercato/search 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0

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 (29) hide show
  1. package/AGENTS.md +0 -1
  2. package/dist/indexer/search-indexer.js +0 -66
  3. package/dist/indexer/search-indexer.js.map +2 -2
  4. package/dist/lib/presenter-enricher.js +1 -71
  5. package/dist/lib/presenter-enricher.js.map +2 -2
  6. package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +0 -143
  7. package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
  8. package/dist/modules/search/lib/entity-access.js +43 -1
  9. package/dist/modules/search/lib/entity-access.js.map +2 -2
  10. package/dist/modules/search/workers/fulltext-index.worker.js +24 -7
  11. package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
  12. package/dist/service.js +1 -18
  13. package/dist/service.js.map +2 -2
  14. package/dist/strategies/token.strategy.js +2 -8
  15. package/dist/strategies/token.strategy.js.map +2 -2
  16. package/package.json +5 -6
  17. package/src/__tests__/presenter-enricher.test.ts +0 -234
  18. package/src/__tests__/service.test.ts +0 -24
  19. package/src/__tests__/workers.test.ts +17 -46
  20. package/src/indexer/search-indexer.ts +0 -84
  21. package/src/lib/presenter-enricher.ts +1 -92
  22. package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +2 -190
  23. package/src/modules/search/api/__tests__/global-search.routes.test.ts +0 -107
  24. package/src/modules/search/lib/entity-access.ts +130 -4
  25. package/src/modules/search/workers/fulltext-index.worker.ts +29 -13
  26. package/src/service.ts +2 -37
  27. package/src/strategies/token.strategy.ts +2 -15
  28. package/src/__tests__/search-indexer-batch.test.ts +0 -214
  29. package/src/__tests__/token-strategy-entity-exclusion.test.ts +0 -99
package/src/service.ts CHANGED
@@ -25,38 +25,6 @@ const DEFAULT_MERGE_CONFIG: ResultMergeConfig = {
25
25
  */
26
26
  const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000
27
27
 
28
- /**
29
- * Maximum records indexed at once when bulkIndex falls back to per-record writes
30
- * for a strategy that has no bulkIndex implementation (currently the vector
31
- * strategy, whose index() performs an embedding-provider round trip per record).
32
- * A whole reindex page arrives in one bulkIndex call, so an unbounded fan-out
33
- * would burst hundreds of concurrent provider requests from a single job.
34
- */
35
- const BULK_INDEX_FALLBACK_CONCURRENCY = 4
36
-
37
- /**
38
- * Map items through an async worker with a fixed number of in-flight calls.
39
- * Rejects with the first error, matching Promise.all semantics.
40
- */
41
- async function mapWithConcurrency<T>(
42
- items: T[],
43
- limit: number,
44
- worker: (item: T) => Promise<void>,
45
- ): Promise<void> {
46
- if (items.length === 0) return
47
-
48
- let nextIndex = 0
49
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
50
- for (;;) {
51
- const currentIndex = nextIndex++
52
- if (currentIndex >= items.length) return
53
- await worker(items[currentIndex])
54
- }
55
- })
56
-
57
- await Promise.all(runners)
58
- }
59
-
60
28
  function normalizeOrganizationFilter(options: SearchOptions): string[] | null {
61
29
  const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''
62
30
  if (single) return [single]
@@ -267,11 +235,8 @@ export class SearchService {
267
235
  if (strategy.bulkIndex) {
268
236
  return strategy.bulkIndex(records)
269
237
  }
270
- // Fallback to individual indexing, bounded so a strategy without a batch
271
- // implementation cannot turn one batch job into hundreds of concurrent writes.
272
- return mapWithConcurrency(records, BULK_INDEX_FALLBACK_CONCURRENCY, (record) =>
273
- this.executeStrategyIndex(strategy, record),
274
- )
238
+ // Fallback to individual indexing
239
+ return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))
275
240
  }),
276
241
  )
277
242
 
@@ -67,21 +67,10 @@ export class TokenSearchStrategy implements SearchStrategy {
67
67
  // Dynamically import tokenization to avoid circular dependencies
68
68
  const { tokenizeText } = await import('@open-mercato/shared/lib/search/tokenize')
69
69
  const { resolveSearchConfig } = await import('@open-mercato/shared/lib/search/config')
70
- const { listSearchTokenExcludedEntityTypes } = await import(
71
- '@open-mercato/core/modules/query_index/lib/search-entity-policy'
72
- )
73
70
 
74
71
  const config = resolveSearchConfig()
75
72
  if (!config.enabled) return []
76
73
 
77
- // The rows themselves stay in `search_tokens` — list routes and the query engines' encrypted
78
- // like/ilike rewrite depend on them — so the exclusion is enforced here, at read time.
79
- const excludedEntityTypes = listSearchTokenExcludedEntityTypes()
80
- const requestedEntityTypes = options.entityTypes?.length
81
- ? options.entityTypes.filter((entityType) => !excludedEntityTypes.includes(entityType))
82
- : undefined
83
- if (options.entityTypes?.length && !requestedEntityTypes?.length) return []
84
-
85
74
  const { hashes } = tokenizeText(query, config)
86
75
  if (hashes.length === 0) return []
87
76
 
@@ -107,10 +96,8 @@ export class TokenSearchStrategy implements SearchStrategy {
107
96
  queryBuilder = queryBuilder.where('organization_id' as any, 'in', organizationIds)
108
97
  }
109
98
 
110
- if (requestedEntityTypes?.length) {
111
- queryBuilder = queryBuilder.where('entity_type' as any, 'in', requestedEntityTypes)
112
- } else if (excludedEntityTypes.length) {
113
- queryBuilder = queryBuilder.where('entity_type' as any, 'not in', excludedEntityTypes)
99
+ if (options.entityTypes?.length) {
100
+ queryBuilder = queryBuilder.where('entity_type' as any, 'in', options.entityTypes)
114
101
  }
115
102
 
116
103
  const rows = await queryBuilder.execute() as Array<{
@@ -1,214 +0,0 @@
1
- import { SearchIndexer } from '../indexer/search-indexer'
2
- import type { SearchModuleConfig } from '../types'
3
- import type { QueryEngine, QueryResult } from '@open-mercato/shared/lib/query/types'
4
-
5
- describe('SearchIndexer.indexRecordsById', () => {
6
- const moduleConfigs: SearchModuleConfig[] = [
7
- {
8
- entities: [
9
- {
10
- entityId: 'test:entity',
11
- enabled: true,
12
- formatResult: async (ctx) => ({ title: String(ctx.record.name ?? ctx.record.id) }),
13
- },
14
- {
15
- entityId: 'test:other',
16
- enabled: true,
17
- formatResult: async (ctx) => ({ title: String(ctx.record.name ?? ctx.record.id) }),
18
- },
19
- ],
20
- },
21
- ]
22
-
23
- function makeQueryEngine(recordsByEntity: Record<string, Record<string, unknown>[]>): QueryEngine {
24
- return {
25
- query: jest.fn(async (entity, opts) => {
26
- const wantedId = (opts?.filters as { id?: string } | undefined)?.id
27
- const items = (recordsByEntity[entity as string] ?? []).filter((r) => r.id === wantedId)
28
- return { items, total: items.length } as QueryResult
29
- }),
30
- }
31
- }
32
-
33
- it('writes N queued records through exactly one bulkIndex call, not N', async () => {
34
- const records = [
35
- { id: 'rec-1', name: 'Alpha' },
36
- { id: 'rec-2', name: 'Beta' },
37
- { id: 'rec-3', name: 'Gamma' },
38
- ]
39
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
40
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
41
- queryEngine: makeQueryEngine({ 'test:entity': records }),
42
- })
43
-
44
- const result = await indexer.indexRecordsById({
45
- items: [
46
- { entityId: 'test:entity', recordId: 'rec-1' },
47
- { entityId: 'test:entity', recordId: 'rec-2' },
48
- { entityId: 'test:entity', recordId: 'rec-3' },
49
- ],
50
- tenantId: 'tenant-123',
51
- organizationId: 'org-456',
52
- })
53
-
54
- expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
55
- expect(searchService.bulkIndex).toHaveBeenCalledWith([
56
- expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-1', tenantId: 'tenant-123', organizationId: 'org-456' }),
57
- expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-2', tenantId: 'tenant-123', organizationId: 'org-456' }),
58
- expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-3', tenantId: 'tenant-123', organizationId: 'org-456' }),
59
- ])
60
- expect(result).toEqual({ indexed: 3, skipped: 0 })
61
- })
62
-
63
- it('collapses records spanning multiple entities into one bulkIndex call', async () => {
64
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
65
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
66
- queryEngine: makeQueryEngine({
67
- 'test:entity': [{ id: 'rec-1', name: 'Alpha' }],
68
- 'test:other': [{ id: 'other-1', name: 'Delta' }],
69
- }),
70
- })
71
-
72
- const result = await indexer.indexRecordsById({
73
- items: [
74
- { entityId: 'test:entity', recordId: 'rec-1' },
75
- { entityId: 'test:other', recordId: 'other-1' },
76
- ],
77
- tenantId: 'tenant-123',
78
- organizationId: null,
79
- })
80
-
81
- expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
82
- expect(searchService.bulkIndex).toHaveBeenCalledWith([
83
- expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-1' }),
84
- expect.objectContaining({ entityId: 'test:other', recordId: 'other-1' }),
85
- ])
86
- expect(result).toEqual({ indexed: 2, skipped: 0 })
87
- })
88
-
89
- it('loads each record with custom fields and without triggering auto-reindex', async () => {
90
- const queryEngine = makeQueryEngine({ 'test:entity': [{ id: 'rec-1', name: 'Alpha' }] })
91
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
92
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, { queryEngine })
93
-
94
- await indexer.indexRecordsById({
95
- items: [{ entityId: 'test:entity', recordId: 'rec-1' }],
96
- tenantId: 'tenant-123',
97
- organizationId: 'org-456',
98
- })
99
-
100
- expect(queryEngine.query).toHaveBeenCalledWith(
101
- 'test:entity',
102
- expect.objectContaining({
103
- tenantId: 'tenant-123',
104
- organizationId: 'org-456',
105
- filters: { id: 'rec-1' },
106
- includeCustomFields: true,
107
- skipAutoReindex: true,
108
- }),
109
- )
110
- })
111
-
112
- it('skips records that no longer exist without failing the batch write', async () => {
113
- const records = [{ id: 'rec-1', name: 'Alpha' }]
114
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
115
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
116
- queryEngine: makeQueryEngine({ 'test:entity': records }),
117
- })
118
-
119
- const result = await indexer.indexRecordsById({
120
- items: [
121
- { entityId: 'test:entity', recordId: 'rec-1' },
122
- { entityId: 'test:entity', recordId: 'missing' },
123
- ],
124
- tenantId: 'tenant-123',
125
- organizationId: null,
126
- })
127
-
128
- expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
129
- expect(result).toEqual({ indexed: 1, skipped: 1 })
130
- })
131
-
132
- it('keeps indexing the batch when loading one record throws', async () => {
133
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
134
- const queryEngine: QueryEngine = {
135
- query: jest.fn(async (_entity, opts) => {
136
- const wantedId = (opts?.filters as { id?: string } | undefined)?.id
137
- if (wantedId === 'boom') throw new Error('connection lost')
138
- return { items: [{ id: wantedId, name: 'Alpha' }], total: 1 } as QueryResult
139
- }),
140
- }
141
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, { queryEngine })
142
-
143
- const result = await indexer.indexRecordsById({
144
- items: [
145
- { entityId: 'test:entity', recordId: 'rec-1' },
146
- { entityId: 'test:entity', recordId: 'boom' },
147
- { entityId: 'test:entity', recordId: 'rec-2' },
148
- ],
149
- tenantId: 'tenant-123',
150
- organizationId: null,
151
- })
152
-
153
- expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
154
- expect(searchService.bulkIndex).toHaveBeenCalledWith([
155
- expect.objectContaining({ recordId: 'rec-1' }),
156
- expect.objectContaining({ recordId: 'rec-2' }),
157
- ])
158
- expect(result).toEqual({ indexed: 2, skipped: 1 })
159
- })
160
-
161
- it('propagates a bulkIndex failure so the queue can retry the job', async () => {
162
- const searchService = { bulkIndex: jest.fn().mockRejectedValue(new Error('meilisearch unavailable')) }
163
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
164
- queryEngine: makeQueryEngine({ 'test:entity': [{ id: 'rec-1', name: 'Alpha' }] }),
165
- })
166
-
167
- await expect(
168
- indexer.indexRecordsById({
169
- items: [{ entityId: 'test:entity', recordId: 'rec-1' }],
170
- tenantId: 'tenant-123',
171
- organizationId: null,
172
- }),
173
- ).rejects.toThrow('meilisearch unavailable')
174
- })
175
-
176
- it('skips entities that are not configured and never calls bulkIndex when nothing is indexable', async () => {
177
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
178
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
179
- queryEngine: makeQueryEngine({}),
180
- })
181
-
182
- const result = await indexer.indexRecordsById({
183
- items: [{ entityId: 'unknown:entity', recordId: 'rec-1' }],
184
- tenantId: 'tenant-123',
185
- organizationId: null,
186
- })
187
-
188
- expect(searchService.bulkIndex).not.toHaveBeenCalled()
189
- expect(result).toEqual({ indexed: 0, skipped: 1 })
190
- })
191
-
192
- it('counts records dropped for a missing id as skipped so the totals add up', async () => {
193
- const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
194
- const queryEngine: QueryEngine = {
195
- query: jest.fn(async (_entity, opts) => {
196
- const wantedId = (opts?.filters as { id?: string } | undefined)?.id
197
- const item = wantedId === 'no-id' ? { name: 'Ghost' } : { id: wantedId, name: 'Alpha' }
198
- return { items: [item], total: 1 } as QueryResult
199
- }),
200
- }
201
- const indexer = new SearchIndexer(searchService as any, moduleConfigs, { queryEngine })
202
-
203
- const result = await indexer.indexRecordsById({
204
- items: [
205
- { entityId: 'test:entity', recordId: 'rec-1' },
206
- { entityId: 'test:entity', recordId: 'no-id' },
207
- ],
208
- tenantId: 'tenant-123',
209
- organizationId: null,
210
- })
211
-
212
- expect(result).toEqual({ indexed: 1, skipped: 1 })
213
- })
214
- })
@@ -1,99 +0,0 @@
1
- import { TokenSearchStrategy } from '../strategies/token.strategy'
2
-
3
- /**
4
- * Coverage for `OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY` on the read path (#5046).
5
- *
6
- * The exclusion is read-side by design and the writer deliberately ignores the flag:
7
- * `search_tokens` doubles as the encrypted-column lookup index, so the People and Companies list
8
- * search resolves ids through the very `customers:customer_entity` rows this flag hides from
9
- * search results. Dropping them write-side would turn that list search into a silent empty page.
10
- * These tests therefore pin the SQL predicate the strategy issues, never the writer's behavior —
11
- * `packages/core/src/modules/query_index/__tests__/search-entity-policy.test.ts` is the other half
12
- * of the pair and asserts the writer emits byte-identical rows in both flag states.
13
- */
14
-
15
- const CUSTOMER_ENTITY = 'customers:customer_entity'
16
- const PERSON_PROFILE = 'customers:customer_person_profile'
17
-
18
- type RecordedWhere = [string, string, unknown]
19
-
20
- function createMockDb() {
21
- const wheres: RecordedWhere[] = []
22
- const builder: Record<string, unknown> = {
23
- select: jest.fn(() => builder),
24
- where: jest.fn((column: unknown, op?: unknown, value?: unknown) => {
25
- if (typeof column === 'string' && typeof op === 'string') wheres.push([column, op, value])
26
- return builder
27
- }),
28
- groupBy: jest.fn(() => builder),
29
- having: jest.fn(() => builder),
30
- orderBy: jest.fn(() => builder),
31
- limit: jest.fn(() => builder),
32
- execute: jest.fn().mockResolvedValue([]),
33
- }
34
- const db = { selectFrom: jest.fn(() => builder) }
35
- return { db, wheres, builder }
36
- }
37
-
38
- const entityTypePredicates = (wheres: RecordedWhere[]) => wheres.filter(([column]) => column === 'entity_type')
39
-
40
- const originalFlag = process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
41
-
42
- afterEach(() => {
43
- if (originalFlag === undefined) delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
44
- else process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY = originalFlag
45
- })
46
-
47
- describe('TokenSearchStrategy excludes base customer entities by default', () => {
48
- it('adds a NOT IN predicate when the caller requests no specific entity types', async () => {
49
- delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
50
- const { db, wheres } = createMockDb()
51
- const strategy = new TokenSearchStrategy(db as never)
52
-
53
- await strategy.search('ada lovelace', { tenantId: 'tenant-1' })
54
-
55
- expect(entityTypePredicates(wheres)).toEqual([['entity_type', 'not in', [CUSTOMER_ENTITY]]])
56
- })
57
-
58
- it('drops the excluded type from an explicit entityTypes filter and keeps the rest', async () => {
59
- delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
60
- const { db, wheres } = createMockDb()
61
- const strategy = new TokenSearchStrategy(db as never)
62
-
63
- await strategy.search('ada lovelace', {
64
- tenantId: 'tenant-1',
65
- entityTypes: [CUSTOMER_ENTITY, PERSON_PROFILE],
66
- })
67
-
68
- expect(entityTypePredicates(wheres)).toEqual([['entity_type', 'in', [PERSON_PROFILE]]])
69
- })
70
-
71
- it('returns no results — and issues no query — when only excluded types were requested', async () => {
72
- delete process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY
73
- const { db } = createMockDb()
74
- const strategy = new TokenSearchStrategy(db as never)
75
-
76
- const results = await strategy.search('ada lovelace', {
77
- tenantId: 'tenant-1',
78
- entityTypes: [CUSTOMER_ENTITY],
79
- })
80
-
81
- expect(results).toEqual([])
82
- expect(db.selectFrom).not.toHaveBeenCalled()
83
- })
84
-
85
- it('leaves the query untouched when the flag re-enables base customer entities', async () => {
86
- process.env.OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY = 'true'
87
- const { db, wheres } = createMockDb()
88
- const strategy = new TokenSearchStrategy(db as never)
89
-
90
- await strategy.search('ada lovelace', { tenantId: 'tenant-1' })
91
- expect(entityTypePredicates(wheres)).toEqual([])
92
-
93
- await strategy.search('ada lovelace', {
94
- tenantId: 'tenant-1',
95
- entityTypes: [CUSTOMER_ENTITY, PERSON_PROFILE],
96
- })
97
- expect(entityTypePredicates(wheres)).toEqual([['entity_type', 'in', [CUSTOMER_ENTITY, PERSON_PROFILE]]])
98
- })
99
- })