@open-mercato/search 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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 +1 -0
  2. package/dist/indexer/search-indexer.js +66 -0
  3. package/dist/indexer/search-indexer.js.map +2 -2
  4. package/dist/lib/presenter-enricher.js +71 -1
  5. package/dist/lib/presenter-enricher.js.map +2 -2
  6. package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +143 -0
  7. package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
  8. package/dist/modules/search/lib/entity-access.js +1 -43
  9. package/dist/modules/search/lib/entity-access.js.map +2 -2
  10. package/dist/modules/search/workers/fulltext-index.worker.js +7 -24
  11. package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
  12. package/dist/service.js +18 -1
  13. package/dist/service.js.map +2 -2
  14. package/dist/strategies/token.strategy.js +8 -2
  15. package/dist/strategies/token.strategy.js.map +2 -2
  16. package/package.json +6 -5
  17. package/src/__tests__/presenter-enricher.test.ts +234 -0
  18. package/src/__tests__/search-indexer-batch.test.ts +214 -0
  19. package/src/__tests__/service.test.ts +24 -0
  20. package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -0
  21. package/src/__tests__/workers.test.ts +46 -17
  22. package/src/indexer/search-indexer.ts +84 -0
  23. package/src/lib/presenter-enricher.ts +92 -1
  24. package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +190 -2
  25. package/src/modules/search/api/__tests__/global-search.routes.test.ts +107 -0
  26. package/src/modules/search/lib/entity-access.ts +4 -130
  27. package/src/modules/search/workers/fulltext-index.worker.ts +13 -29
  28. package/src/service.ts +37 -2
  29. package/src/strategies/token.strategy.ts +15 -2
@@ -198,6 +198,113 @@ describe('GET /api/search/search/global presenter localization', () => {
198
198
  expect(result.links?.[0]?.label).toBe('Otwórz osobę')
199
199
  }
200
200
  })
201
+
202
+ // Covers OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=true, where base customer rows are tokenized and
203
+ // the route can therefore receive both halves of a pair. Under the default the token strategy
204
+ // never returns the base rows at all (see token-strategy-entity-exclusion.test.ts); the strategy
205
+ // here is a stub that yields them regardless, so this pins the merge itself.
206
+ it('merges a customer entity and profile pair into one navigable result when both are indexed', async () => {
207
+ const rows = [
208
+ {
209
+ entity_type: 'customers:customer_entity',
210
+ entity_id: 'person-entity',
211
+ doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
212
+ },
213
+ {
214
+ entity_type: 'customers:customer_person_profile',
215
+ entity_id: 'person-profile',
216
+ doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
217
+ },
218
+ {
219
+ entity_type: 'customers:customer_entity',
220
+ entity_id: 'company-entity',
221
+ doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
222
+ },
223
+ {
224
+ entity_type: 'customers:customer_company_profile',
225
+ entity_id: 'company-profile',
226
+ doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
227
+ },
228
+ {
229
+ entity_type: 'orders:order',
230
+ entity_id: 'order-1',
231
+ doc: { id: 'order-1', title: 'Order 1' },
232
+ },
233
+ ]
234
+ const personEntityId = 'customers:customer_person_profile' as EntityId
235
+ const companyEntityId = 'customers:customer_company_profile' as EntityId
236
+ const configMap = new Map<EntityId, SearchEntityConfig>([
237
+ [personEntityId, {
238
+ entityId: personEntityId,
239
+ formatResult: async (context) => ({ title: String(context.record.display_name) }),
240
+ resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
241
+ }],
242
+ [companyEntityId, {
243
+ entityId: companyEntityId,
244
+ formatResult: async (context) => ({ title: String(context.record.display_name) }),
245
+ resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
246
+ }],
247
+ ])
248
+ const scoresByRecordId: Record<string, number> = {
249
+ 'person-profile': 0.95,
250
+ 'company-profile': 0.85,
251
+ 'order-1': 0.5,
252
+ 'person-entity': 0.2,
253
+ 'company-entity': 0.1,
254
+ }
255
+ const results: SearchResult[] = rows
256
+ .map((row) => ({
257
+ entityId: row.entity_type as EntityId,
258
+ recordId: row.entity_id,
259
+ organizationId: 'org-1',
260
+ score: scoresByRecordId[row.entity_id] ?? 0,
261
+ source: 'tokens' as const,
262
+ }))
263
+ .sort((left, right) => right.score - left.score)
264
+ const strategy: SearchStrategy = {
265
+ id: 'tokens',
266
+ name: 'tokens',
267
+ priority: 10,
268
+ isAvailable: async () => true,
269
+ ensureReady: async () => undefined,
270
+ search: async () => results,
271
+ index: async () => undefined,
272
+ delete: async () => undefined,
273
+ }
274
+ const searchService = new SearchService({
275
+ strategies: [strategy],
276
+ defaultStrategies: ['tokens'],
277
+ presenterEnricher: createPresenterEnricher(createDatabase(rows), configMap),
278
+ })
279
+ mockCreateRequestContainer.mockResolvedValue(
280
+ createContainer(searchService, configMap, { features: ['search.global'], isSuperAdmin: true }),
281
+ )
282
+
283
+ const response = await GET(new Request('http://localhost/api/search/search/global?q=customer'))
284
+ const body = await response.json() as { results: SearchResult[] }
285
+
286
+ expect(response.status).toBe(200)
287
+ expect(body.results).toEqual([
288
+ expect.objectContaining({
289
+ entityId: 'customers:customer_entity',
290
+ recordId: 'person-entity',
291
+ presenter: expect.objectContaining({ title: 'Ada Lovelace' }),
292
+ url: '/backend/customers/people-v2/person-entity',
293
+ }),
294
+ expect.objectContaining({
295
+ entityId: 'customers:customer_entity',
296
+ recordId: 'company-entity',
297
+ presenter: expect.objectContaining({ title: 'Analytical Engines' }),
298
+ url: '/backend/customers/companies-v2/company-entity',
299
+ }),
300
+ expect.objectContaining({
301
+ entityId: 'orders:order',
302
+ recordId: 'order-1',
303
+ }),
304
+ ])
305
+ expect(body.results[0]?.score).toBeGreaterThan(body.results[1]?.score ?? 0)
306
+ expect(body.results[1]?.score).toBeGreaterThan(body.results[2]?.score ?? 0)
307
+ })
201
308
  })
202
309
 
203
310
  describe('GET /api/search/search/global per-entity access control', () => {
@@ -1,132 +1,6 @@
1
- import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'
2
- import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
-
4
1
  /**
5
- * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL
6
- * resolution. Kept structural so callers and tests can pass a plain object
7
- * instead of constructing a full `SearchIndexer`.
2
+ * Re-export shared entity-access helpers so existing imports keep working.
3
+ * The canonical implementation lives in @open-mercato/shared to be reusable
4
+ * from ai-assistant without introducing a search↔ai-assistant dependency cycle.
8
5
  */
9
- export type SearchEntityConfigLookup = {
10
- getEntityConfig: (entityId: string) => SearchEntityConfig | undefined
11
- getAllEntityConfigs: () => SearchEntityConfig[]
12
- }
13
-
14
- export type SearchEntityAccessSubject = {
15
- grantedFeatures: readonly string[]
16
- isSuperAdmin?: boolean
17
- }
18
-
19
- export type SearchEntityDenyReason =
20
- /** No module declares this entity in a `search.ts` config. */
21
- | 'unconfigured'
22
- /** The entity is configured for search but declares no `aclFeatures`. */
23
- | 'no-acl-features'
24
- /** The caller does not hold the entity's declared view feature(s). */
25
- | 'insufficient-features'
26
-
27
- export type SearchEntityAccessOptions = {
28
- /**
29
- * Called once per denied entity type. Exists so a silent drop is diagnosable:
30
- * results disappearing because a module forgot to declare `aclFeatures` looks
31
- * identical, from the palette, to results that simply did not match.
32
- */
33
- onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void
34
- }
35
-
36
- /**
37
- * Decide whether a caller may see results for one entity type.
38
- *
39
- * The single `search.global` gate on the palette only says "this user may use
40
- * global search"; it says nothing about which records they may read. Each entity
41
- * declares the owning module's view feature(s) in `aclFeatures`, and those are
42
- * what actually authorize the read — the same rule the `search_get` /
43
- * `search_aggregate` AI tools already apply.
44
- *
45
- * Fails closed: an entity that is not registered for search, or that declares no
46
- * `aclFeatures`, is never exposed to a non-superadmin caller.
47
- */
48
- export function canReadSearchEntity(
49
- entityId: string,
50
- lookup: SearchEntityConfigLookup,
51
- subject: SearchEntityAccessSubject,
52
- options: SearchEntityAccessOptions = {},
53
- ): boolean {
54
- if (subject.isSuperAdmin) return true
55
-
56
- const config = lookup.getEntityConfig(entityId)
57
- if (!config) {
58
- options.onDeny?.(entityId, 'unconfigured')
59
- return false
60
- }
61
-
62
- const required = config.aclFeatures
63
- if (!required || required.length === 0) {
64
- options.onDeny?.(entityId, 'no-acl-features')
65
- return false
66
- }
67
-
68
- const allowed = authorizeFeatures(required, {
69
- grantedFeatures: subject.grantedFeatures,
70
- unrestricted: false,
71
- })
72
- if (!allowed) options.onDeny?.(entityId, 'insufficient-features')
73
- return allowed
74
- }
75
-
76
- /**
77
- * The entity types this caller may read, narrowed to `requestedEntityTypes` when
78
- * the caller asked for specific ones.
79
- *
80
- * Restricting the query up front is what keeps `limit` meaningful. Filtering only
81
- * after the search would spend the whole result budget on records the caller
82
- * cannot see: an employee granted just `customers.people.view` would get the top
83
- * 50 hits across every entity type, then watch most of them be dropped, and the
84
- * palette would look empty even with hundreds of matching people behind it.
85
- *
86
- * Returns `undefined` when no restriction applies (superadmin with no explicit
87
- * request), and an empty array when nothing is readable — callers should
88
- * short-circuit on that rather than pass it down as "no filter".
89
- */
90
- export function resolveReadableEntityTypes(
91
- lookup: SearchEntityConfigLookup,
92
- subject: SearchEntityAccessSubject,
93
- requestedEntityTypes?: string[],
94
- ): string[] | undefined {
95
- if (subject.isSuperAdmin) return requestedEntityTypes
96
-
97
- const readable = lookup
98
- .getAllEntityConfigs()
99
- .filter((config) => config.enabled !== false)
100
- .map((config) => config.entityId)
101
- .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))
102
-
103
- if (!requestedEntityTypes) return readable
104
- const requested = new Set(requestedEntityTypes)
105
- return readable.filter((entityId) => requested.has(entityId))
106
- }
107
-
108
- /**
109
- * Drop the results whose entity type the caller is not allowed to read.
110
- *
111
- * Filtering happens server-side so an under-privileged caller never receives the
112
- * presenter title, subtitle or deep link of a record they cannot open. Decisions
113
- * are memoized per entity type because a single response commonly mixes dozens of
114
- * results across a handful of types.
115
- */
116
- export function filterSearchResultsByEntityAccess<T extends { entityId: string }>(
117
- results: readonly T[],
118
- lookup: SearchEntityConfigLookup,
119
- subject: SearchEntityAccessSubject,
120
- options: SearchEntityAccessOptions = {},
121
- ): T[] {
122
- if (subject.isSuperAdmin) return [...results]
123
-
124
- const decisions = new Map<string, boolean>()
125
- return results.filter((result) => {
126
- const cached = decisions.get(result.entityId)
127
- if (cached !== undefined) return cached
128
- const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)
129
- decisions.set(result.entityId, allowed)
130
- return allowed
131
- })
132
- }
6
+ export * from '@open-mercato/shared/lib/search/entityAccess'
@@ -78,8 +78,9 @@ async function advanceFulltextReindexProgress(params: {
78
78
  * This handler processes single record indexing, batch indexing, deletion, and purge
79
79
  * operations for the fulltext search strategy.
80
80
  *
81
- * All indexing operations (single and batch) use searchIndexer.indexRecordById() to load
82
- * fresh data, ensuring consistency with the vector worker pattern.
81
+ * Single-record jobs load fresh data via searchIndexer.indexRecordById(). Batch jobs load
82
+ * fresh data per record via searchIndexer.indexRecordsById(), which flushes the whole batch
83
+ * through a single bulk write instead of one write per record.
83
84
  *
84
85
  * @param job - The queued job containing payload
85
86
  * @param jobCtx - Queue job context with job ID and attempt info
@@ -202,7 +203,7 @@ export async function handleFulltextIndexJob(
202
203
  return
203
204
  }
204
205
 
205
- // ========== BATCH-INDEX: Use searchIndexer.indexRecordById() for fresh data ==========
206
+ // ========== BATCH-INDEX: Load fresh data, write the whole batch in one call ==========
206
207
  if (jobType === 'batch-index') {
207
208
  const { records, organizationId } = job.payload
208
209
  if (!records || records.length === 0) {
@@ -216,30 +217,13 @@ export async function handleFulltextIndexJob(
216
217
  throw new Error('searchIndexer not available for batch indexing')
217
218
  }
218
219
 
219
- // Process each record using indexRecordById (same pattern as vector worker)
220
- let successCount = 0
221
- let failCount = 0
222
-
223
- for (const { entityId, recordId } of records) {
224
- try {
225
- const result = await searchIndexer.indexRecordById({
226
- entityId: entityId as EntityId,
227
- recordId,
228
- tenantId,
229
- organizationId,
230
- })
231
- if (result.action === 'indexed') {
232
- successCount++
233
- }
234
- } catch (error) {
235
- failCount++
236
- searchDebugWarn('fulltext-index.worker', 'Failed to index record in batch', {
237
- entityId,
238
- recordId,
239
- error: error instanceof Error ? error.message : error,
240
- })
241
- }
242
- }
220
+ // Load and index the whole batch through a single bulk write instead of
221
+ // one indexRecordById() call per record.
222
+ const { indexed: successCount, skipped: skippedCount } = await searchIndexer.indexRecordsById({
223
+ items: records.map(({ entityId, recordId }) => ({ entityId: entityId as EntityId, recordId })),
224
+ tenantId,
225
+ organizationId,
226
+ })
243
227
 
244
228
  await advanceFulltextReindexProgress({
245
229
  db,
@@ -255,7 +239,7 @@ export async function handleFulltextIndexJob(
255
239
  tenantId,
256
240
  requestedCount: records.length,
257
241
  successCount,
258
- failCount,
242
+ skippedCount,
259
243
  })
260
244
 
261
245
  await recordIndexerLog(
@@ -265,7 +249,7 @@ export async function handleFulltextIndexJob(
265
249
  handler: 'worker:fulltext:batch-index',
266
250
  message: `Indexed ${successCount}/${records.length} records to fulltext`,
267
251
  tenantId,
268
- details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, failCount },
252
+ details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, skippedCount },
269
253
  },
270
254
  )
271
255
  return
package/src/service.ts CHANGED
@@ -25,6 +25,38 @@ 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
+
28
60
  function normalizeOrganizationFilter(options: SearchOptions): string[] | null {
29
61
  const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''
30
62
  if (single) return [single]
@@ -235,8 +267,11 @@ export class SearchService {
235
267
  if (strategy.bulkIndex) {
236
268
  return strategy.bulkIndex(records)
237
269
  }
238
- // Fallback to individual indexing
239
- return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))
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
+ )
240
275
  }),
241
276
  )
242
277
 
@@ -67,10 +67,21 @@ 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
+ )
70
73
 
71
74
  const config = resolveSearchConfig()
72
75
  if (!config.enabled) return []
73
76
 
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
+
74
85
  const { hashes } = tokenizeText(query, config)
75
86
  if (hashes.length === 0) return []
76
87
 
@@ -96,8 +107,10 @@ export class TokenSearchStrategy implements SearchStrategy {
96
107
  queryBuilder = queryBuilder.where('organization_id' as any, 'in', organizationIds)
97
108
  }
98
109
 
99
- if (options.entityTypes?.length) {
100
- queryBuilder = queryBuilder.where('entity_type' as any, 'in', options.entityTypes)
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)
101
114
  }
102
115
 
103
116
  const rows = await queryBuilder.execute() as Array<{