@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
@@ -104,95 +104,6 @@ type EnrichmentResult = {
104
104
  links?: SearchResultLink[]
105
105
  }
106
106
 
107
- function primaryNavigationHref(result: SearchResult): string | null {
108
- if (typeof result.url === 'string' && result.url.trim().length > 0) {
109
- return result.url.trim()
110
- }
111
- const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)
112
- return primaryLink?.href.trim() ?? null
113
- }
114
-
115
- function directNavigationRecordId(href: string): string | null {
116
- try {
117
- const url = new URL(href, 'http://search.local')
118
- if (url.search || url.hash) return null
119
- const segments = url.pathname.split('/').filter(Boolean)
120
- const lastSegment = segments.at(-1)
121
- return lastSegment ? decodeURIComponent(lastSegment) : null
122
- } catch {
123
- return null
124
- }
125
- }
126
-
127
- function presenterTitle(result: SearchResult): string | null {
128
- const title = result.presenter?.title?.trim()
129
- return title?.length ? title : null
130
- }
131
-
132
- function resultScopeKey(result: SearchResult, recordId: string): string {
133
- return `${result.organizationId ?? ''}:${recordId}`
134
- }
135
-
136
- function mergeResultMetadata(
137
- targetMetadata: SearchResult['metadata'],
138
- linkedMetadata: SearchResult['metadata'],
139
- ): SearchResult['metadata'] {
140
- if (!targetMetadata && !linkedMetadata) return undefined
141
- return {
142
- ...targetMetadata,
143
- ...linkedMetadata,
144
- }
145
- }
146
-
147
- function mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {
148
- const indexesByRecord = new Map<string, number[]>()
149
- for (let index = 0; index < results.length; index += 1) {
150
- const result = results[index]
151
- const key = resultScopeKey(result, result.recordId)
152
- const indexes = indexesByRecord.get(key) ?? []
153
- indexes.push(index)
154
- indexesByRecord.set(key, indexes)
155
- }
156
-
157
- const replacements = new Map<number, SearchResult>()
158
- const removedIndexes = new Set<number>()
159
-
160
- for (let index = 0; index < results.length; index += 1) {
161
- const linkedResult = results[index]
162
- const href = primaryNavigationHref(linkedResult)
163
- const targetRecordId = href ? directNavigationRecordId(href) : null
164
- if (!targetRecordId || targetRecordId === linkedResult.recordId) continue
165
-
166
- const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])
167
- .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))
168
- if (targetIndexes.length !== 1) continue
169
-
170
- const targetIndex = targetIndexes[0]
171
- const targetResult = replacements.get(targetIndex) ?? results[targetIndex]
172
- if (primaryNavigationHref(targetResult)) continue
173
-
174
- const linkedTitle = presenterTitle(linkedResult)
175
- const targetTitle = presenterTitle(targetResult)
176
- if (!linkedTitle || linkedTitle !== targetTitle) continue
177
-
178
- replacements.set(targetIndex, {
179
- ...targetResult,
180
- score: Math.max(targetResult.score, linkedResult.score),
181
- source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,
182
- presenter: linkedResult.presenter ?? targetResult.presenter,
183
- url: linkedResult.url ?? targetResult.url,
184
- links: linkedResult.links ?? targetResult.links,
185
- metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),
186
- })
187
- removedIndexes.add(index)
188
- }
189
-
190
- return results
191
- .map((result, index) => replacements.get(index) ?? result)
192
- .filter((_, index) => !removedIndexes.has(index))
193
- .sort((left, right) => right.score - left.score)
194
- }
195
-
196
107
  /**
197
108
  * Compute presenter, URL, and links for a single doc using config or fallback.
198
109
  * Returns presenter (null if cannot be computed), and optionally URL/links from config.
@@ -374,7 +285,7 @@ export function createPresenterEnricher(
374
285
  }
375
286
 
376
287
  // Enrich results with computed presenter, URL, and links
377
- const enrichedResults = results.map((result) => {
288
+ return results.map((result) => {
378
289
  if (!shouldEnrich(result)) return result
379
290
  const key = `${result.entityId}:${result.recordId}`
380
291
  const enriched = enrichmentMap.get(key)
@@ -386,7 +297,5 @@ export function createPresenterEnricher(
386
297
  links: enriched.links ?? result.links,
387
298
  }
388
299
  })
389
-
390
- return mergeLinkedDuplicateResults(enrichedResults)
391
300
  }
392
301
  }
@@ -1,75 +1,12 @@
1
- import { expect, test, type APIRequestContext } from '@playwright/test'
1
+ import { expect, test } from '@playwright/test'
2
2
  import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
3
  import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
- import {
5
- createCompanyFixture,
6
- createPersonFixture,
7
- deleteEntityIfExists,
8
- } from '@open-mercato/core/helpers/integration/crmFixtures'
9
4
 
10
5
  type GlobalSearchSettings = { enabledStrategies?: string[] }
11
6
  type GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }
12
- type SearchResultItem = {
13
- entityId?: string
14
- recordId?: string
15
- presenter?: { title?: string } | null
16
- url?: string | null
17
- }
18
- type GlobalSearchResponse = { strategiesEnabled?: string[]; results?: SearchResultItem[] }
19
- type SearchQueryResult = { ok: boolean; status: number; results: SearchResultItem[] }
7
+ type GlobalSearchResponse = { strategiesEnabled?: string[] }
20
8
 
21
9
  const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
22
- const CUSTOMER_ENTITY = 'customers:customer_entity'
23
- const PERSON_PROFILE = 'customers:customer_person_profile'
24
- const COMPANY_PROFILE = 'customers:customer_company_profile'
25
-
26
- function presenterTitle(result: SearchResultItem): string | null {
27
- const title = result.presenter?.title
28
- return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null
29
- }
30
-
31
- async function searchResults(
32
- request: APIRequestContext,
33
- token: string,
34
- path: string,
35
- ): Promise<SearchQueryResult> {
36
- const response = await apiRequest(request, 'GET', path, { token })
37
- if (!response.ok()) return { ok: false, status: response.status(), results: [] }
38
- const body = (await readJsonSafe<GlobalSearchResponse>(response)) ?? {}
39
- return {
40
- ok: true,
41
- status: response.status(),
42
- results: Array.isArray(body.results) ? body.results : [],
43
- }
44
- }
45
-
46
- function customerSearchPath(query: string, entityType: string): string {
47
- const params = new URLSearchParams({
48
- q: query,
49
- limit: '20',
50
- strategies: 'tokens',
51
- entityTypes: entityType,
52
- })
53
- return `/api/search/search?${params.toString()}`
54
- }
55
-
56
- function globalSearchPath(query: string): string {
57
- const params = new URLSearchParams({ q: query, limit: '20' })
58
- return `/api/search/search/global?${params.toString()}`
59
- }
60
-
61
- /**
62
- * A profile result navigates to the customer's v2 detail page, whose path segment is the base
63
- * customer entity id — not the profile's own `recordId`. So this asserts the shape of a direct
64
- * detail link (prefix + one non-empty id segment, no query string or anchor) rather than
65
- * equality with `recordId`.
66
- */
67
- function hasCanonicalNavigation(result: SearchResultItem, expectedPrefix: string): boolean {
68
- if (typeof result.url !== 'string') return false
69
- if (!result.url.startsWith(`${expectedPrefix}/`)) return false
70
- const target = result.url.slice(expectedPrefix.length + 1)
71
- return target.length > 0 && !/[/?#]/.test(target)
72
- }
73
10
 
74
11
  /**
75
12
  * TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a
@@ -134,129 +71,4 @@ test.describe('TC-SEARCH-006: global search honors saved strategy config over UR
134
71
  }
135
72
  }
136
73
  })
137
-
138
- test('returns one navigable profile result per customer and no base-entity duplicate', async ({ request }) => {
139
- test.slow()
140
- test.setTimeout(120_000)
141
-
142
- const stamp = Date.now()
143
- const personName = `QASRCH006P${stamp}`
144
- const companyName = `QASRCH006C${stamp}`
145
- let token: string | null = null
146
- let originalStrategies: string[] | null = DEFAULT_STRATEGIES
147
- let personId: string | null = null
148
- let companyId: string | null = null
149
- let personGlobalResults: SearchResultItem[] = []
150
- let companyGlobalResults: SearchResultItem[] = []
151
-
152
- try {
153
- token = await getAuthToken(request, 'admin')
154
-
155
- const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
156
- expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()
157
- const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}
158
- originalStrategies =
159
- Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0
160
- ? current.enabledStrategies
161
- : DEFAULT_STRATEGIES
162
-
163
- const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {
164
- token,
165
- data: { enabledStrategies: ['tokens'] },
166
- })
167
- expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)
168
-
169
- personId = await createPersonFixture(request, token, {
170
- firstName: 'QA',
171
- lastName: `Search 006 ${stamp}`,
172
- displayName: personName,
173
- })
174
- companyId = await createCompanyFixture(request, token, companyName)
175
-
176
- await expect
177
- .poll(
178
- async () => {
179
- const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] =
180
- await Promise.all([
181
- searchResults(request, token!, customerSearchPath(personName, CUSTOMER_ENTITY)),
182
- searchResults(request, token!, customerSearchPath(personName, PERSON_PROFILE)),
183
- searchResults(request, token!, customerSearchPath(companyName, CUSTOMER_ENTITY)),
184
- searchResults(request, token!, customerSearchPath(companyName, COMPANY_PROFILE)),
185
- searchResults(request, token!, globalSearchPath(personName)),
186
- searchResults(request, token!, globalSearchPath(companyName)),
187
- ])
188
-
189
- const queries = [
190
- ['person-entity', personEntity],
191
- ['person-profile', personProfile],
192
- ['company-entity', companyEntity],
193
- ['company-profile', companyProfile],
194
- ['person-global', personGlobal],
195
- ['company-global', companyGlobal],
196
- ] as const
197
- const failedQuery = queries.find(([, result]) => !result.ok)
198
- if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`
199
-
200
- const indexedQueries = [
201
- ['person-profile', personProfile.results, personName, PERSON_PROFILE],
202
- ['company-profile', companyProfile.results, companyName, COMPANY_PROFILE],
203
- ] as const
204
- for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {
205
- const matches = results.filter(
206
- (result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId,
207
- )
208
- if (matches.length === 0) return `${label}:matches:0`
209
- }
210
-
211
- // Under the default OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=false the token strategy
212
- // refuses to return base customer rows, so an explicit query for that entity type is
213
- // empty even though the same customers' profiles are already indexed above. (The rows
214
- // themselves stay in search_tokens — the list-search id lookup still needs them.)
215
- const baseEntityQueries = [
216
- ['person-entity', personEntity.results, personName],
217
- ['company-entity', companyEntity.results, companyName],
218
- ] as const
219
- for (const [label, results, expectedTitle] of baseEntityQueries) {
220
- const matches = results.filter((result) => presenterTitle(result) === expectedTitle)
221
- if (matches.length !== 0) return `${label}:matches:${matches.length}`
222
- }
223
-
224
- personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName)
225
- companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName)
226
- if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`
227
- if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`
228
- if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {
229
- return `person-global:entity:${personGlobalResults[0]?.entityId ?? 'missing'}`
230
- }
231
- if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {
232
- return `company-global:entity:${companyGlobalResults[0]?.entityId ?? 'missing'}`
233
- }
234
- if (!hasCanonicalNavigation(personGlobalResults[0], '/backend/customers/people-v2')) {
235
- return `person-global:navigation:${personGlobalResults[0]?.url ?? 'missing'}`
236
- }
237
- if (!hasCanonicalNavigation(companyGlobalResults[0], '/backend/customers/companies-v2')) {
238
- return `company-global:navigation:${companyGlobalResults[0]?.url ?? 'missing'}`
239
- }
240
-
241
- return 'ready'
242
- },
243
- { timeout: 10_000 },
244
- )
245
- .toBe('ready')
246
-
247
- expect(personGlobalResults).toHaveLength(1)
248
- expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE)
249
- expect(companyGlobalResults).toHaveLength(1)
250
- expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE)
251
- } finally {
252
- await deleteEntityIfExists(request, token, '/api/customers/people', personId)
253
- await deleteEntityIfExists(request, token, '/api/customers/companies', companyId)
254
- if (token && originalStrategies) {
255
- await apiRequest(request, 'POST', '/api/search/settings/global-search', {
256
- token,
257
- data: { enabledStrategies: originalStrategies },
258
- }).catch(() => undefined)
259
- }
260
- }
261
- })
262
74
  })
@@ -198,113 +198,6 @@ 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
- })
308
201
  })
309
202
 
310
203
  describe('GET /api/search/search/global per-entity access control', () => {
@@ -1,6 +1,132 @@
1
+ import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'
2
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
+
1
4
  /**
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.
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`.
5
8
  */
6
- export * from '@open-mercato/shared/lib/search/entityAccess'
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
+ }
@@ -78,9 +78,8 @@ 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
- * 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.
81
+ * All indexing operations (single and batch) use searchIndexer.indexRecordById() to load
82
+ * fresh data, ensuring consistency with the vector worker pattern.
84
83
  *
85
84
  * @param job - The queued job containing payload
86
85
  * @param jobCtx - Queue job context with job ID and attempt info
@@ -203,7 +202,7 @@ export async function handleFulltextIndexJob(
203
202
  return
204
203
  }
205
204
 
206
- // ========== BATCH-INDEX: Load fresh data, write the whole batch in one call ==========
205
+ // ========== BATCH-INDEX: Use searchIndexer.indexRecordById() for fresh data ==========
207
206
  if (jobType === 'batch-index') {
208
207
  const { records, organizationId } = job.payload
209
208
  if (!records || records.length === 0) {
@@ -217,13 +216,30 @@ export async function handleFulltextIndexJob(
217
216
  throw new Error('searchIndexer not available for batch indexing')
218
217
  }
219
218
 
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
- })
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
+ }
227
243
 
228
244
  await advanceFulltextReindexProgress({
229
245
  db,
@@ -239,7 +255,7 @@ export async function handleFulltextIndexJob(
239
255
  tenantId,
240
256
  requestedCount: records.length,
241
257
  successCount,
242
- skippedCount,
258
+ failCount,
243
259
  })
244
260
 
245
261
  await recordIndexerLog(
@@ -249,7 +265,7 @@ export async function handleFulltextIndexJob(
249
265
  handler: 'worker:fulltext:batch-index',
250
266
  message: `Indexed ${successCount}/${records.length} records to fulltext`,
251
267
  tenantId,
252
- details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, skippedCount },
268
+ details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, failCount },
253
269
  },
254
270
  )
255
271
  return