@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.
- package/AGENTS.md +1 -0
- package/dist/indexer/search-indexer.js +66 -0
- package/dist/indexer/search-indexer.js.map +2 -2
- package/dist/lib/presenter-enricher.js +71 -1
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +143 -0
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
- package/dist/modules/search/lib/entity-access.js +1 -43
- package/dist/modules/search/lib/entity-access.js.map +2 -2
- package/dist/modules/search/workers/fulltext-index.worker.js +7 -24
- package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
- package/dist/service.js +18 -1
- package/dist/service.js.map +2 -2
- package/dist/strategies/token.strategy.js +8 -2
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +6 -5
- package/src/__tests__/presenter-enricher.test.ts +234 -0
- package/src/__tests__/search-indexer-batch.test.ts +214 -0
- package/src/__tests__/service.test.ts +24 -0
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -0
- package/src/__tests__/workers.test.ts +46 -17
- package/src/indexer/search-indexer.ts +84 -0
- package/src/lib/presenter-enricher.ts +92 -1
- package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +190 -2
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +107 -0
- package/src/modules/search/lib/entity-access.ts +4 -130
- package/src/modules/search/workers/fulltext-index.worker.ts +13 -29
- package/src/service.ts +37 -2
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
*
|
|
82
|
-
* fresh data,
|
|
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:
|
|
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
100
|
-
queryBuilder = queryBuilder.where('entity_type' as any, 'in',
|
|
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<{
|