@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.
- package/AGENTS.md +0 -1
- package/dist/indexer/search-indexer.js +0 -66
- package/dist/indexer/search-indexer.js.map +2 -2
- package/dist/lib/presenter-enricher.js +1 -71
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +0 -143
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
- package/dist/modules/search/lib/entity-access.js +43 -1
- package/dist/modules/search/lib/entity-access.js.map +2 -2
- package/dist/modules/search/workers/fulltext-index.worker.js +24 -7
- package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
- package/dist/service.js +1 -18
- package/dist/service.js.map +2 -2
- package/dist/strategies/token.strategy.js +2 -8
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +5 -6
- package/src/__tests__/presenter-enricher.test.ts +0 -234
- package/src/__tests__/service.test.ts +0 -24
- package/src/__tests__/workers.test.ts +17 -46
- package/src/indexer/search-indexer.ts +0 -84
- package/src/lib/presenter-enricher.ts +1 -92
- package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +2 -190
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +0 -107
- package/src/modules/search/lib/entity-access.ts +130 -4
- package/src/modules/search/workers/fulltext-index.worker.ts +29 -13
- package/src/service.ts +2 -37
- package/src/strategies/token.strategy.ts +2 -15
- package/src/__tests__/search-indexer-batch.test.ts +0 -214
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +0 -99
|
@@ -26,12 +26,8 @@ class TokenSearchStrategy {
|
|
|
26
26
|
if (organizationIds && organizationIds.length === 0) return [];
|
|
27
27
|
const { tokenizeText } = await import("@open-mercato/shared/lib/search/tokenize");
|
|
28
28
|
const { resolveSearchConfig } = await import("@open-mercato/shared/lib/search/config");
|
|
29
|
-
const { listSearchTokenExcludedEntityTypes } = await import("@open-mercato/core/modules/query_index/lib/search-entity-policy");
|
|
30
29
|
const config = resolveSearchConfig();
|
|
31
30
|
if (!config.enabled) return [];
|
|
32
|
-
const excludedEntityTypes = listSearchTokenExcludedEntityTypes();
|
|
33
|
-
const requestedEntityTypes = options.entityTypes?.length ? options.entityTypes.filter((entityType) => !excludedEntityTypes.includes(entityType)) : void 0;
|
|
34
|
-
if (options.entityTypes?.length && !requestedEntityTypes?.length) return [];
|
|
35
31
|
const { hashes } = tokenizeText(query, config);
|
|
36
32
|
if (hashes.length === 0) return [];
|
|
37
33
|
const minMatches = Math.max(1, Math.ceil(hashes.length * this.minMatchRatio));
|
|
@@ -45,10 +41,8 @@ class TokenSearchStrategy {
|
|
|
45
41
|
if (organizationIds) {
|
|
46
42
|
queryBuilder = queryBuilder.where("organization_id", "in", organizationIds);
|
|
47
43
|
}
|
|
48
|
-
if (
|
|
49
|
-
queryBuilder = queryBuilder.where("entity_type", "in",
|
|
50
|
-
} else if (excludedEntityTypes.length) {
|
|
51
|
-
queryBuilder = queryBuilder.where("entity_type", "not in", excludedEntityTypes);
|
|
44
|
+
if (options.entityTypes?.length) {
|
|
45
|
+
queryBuilder = queryBuilder.where("entity_type", "in", options.entityTypes);
|
|
52
46
|
}
|
|
53
47
|
const rows = await queryBuilder.execute();
|
|
54
48
|
return rows.map((row) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/strategies/token.strategy.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, sql, type SqlBool } from 'kysely'\nimport type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n IndexableRecord,\n} from '../types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\n\n/**\n * Configuration for TokenSearchStrategy.\n */\nexport type TokenStrategyConfig = {\n /** Minimum number of query tokens that must match (0-1 ratio, default 0.5) */\n minMatchRatio?: number\n /** Default limit for search results */\n defaultLimit?: number\n}\n\nfunction normalizeOrganizationIds(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n return Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n}\n\n/**\n * TokenSearchStrategy provides hash-based search using the existing search_tokens table.\n * This strategy is always available and serves as a fallback when other strategies fail.\n *\n * It tokenizes queries into hashes and matches against pre-indexed token hashes,\n * enabling search on encrypted fields without exposing plaintext to external services.\n */\nexport class TokenSearchStrategy implements SearchStrategy {\n readonly id: SearchStrategyId = 'tokens'\n readonly name = 'Token Search'\n readonly priority = 10 // Lowest priority, always available as fallback\n\n private readonly minMatchRatio: number\n private readonly defaultLimit: number\n\n constructor(\n private readonly db: Kysely<any>,\n config?: TokenStrategyConfig,\n ) {\n this.minMatchRatio = config?.minMatchRatio ?? 0.5\n this.defaultLimit = config?.defaultLimit ?? 50\n }\n\n async isAvailable(): Promise<boolean> {\n return true // Always available\n }\n\n async ensureReady(): Promise<void> {\n // No initialization needed\n }\n\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationIds(options)\n if (organizationIds && organizationIds.length === 0) return []\n\n // Dynamically import tokenization to avoid circular dependencies\n const { tokenizeText } = await import('@open-mercato/shared/lib/search/tokenize')\n const { resolveSearchConfig } = await import('@open-mercato/shared/lib/search/config')\n
|
|
5
|
-
"mappings": "AAAA,SAAsB,WAAyB;AAoB/C,SAAS,yBAAyB,SAAyC;AACzE,QAAM,SAAS,OAAO,QAAQ,mBAAmB,WAAW,QAAQ,eAAe,KAAK,IAAI;AAC5F,MAAI,OAAQ,QAAO,CAAC,MAAM;AAC1B,MAAI,CAAC,MAAM,QAAQ,QAAQ,eAAe,EAAG,QAAO;AACpD,SAAO,MAAM,KAAK,IAAI;AAAA,IACpB,QAAQ,gBACL,IAAI,CAAC,UAAW,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI,EAAG,EAC9D,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AAAA,EACvC,CAAC;AACH;AASO,MAAM,oBAA8C;AAAA,EAQzD,YACmB,IACjB,QACA;AAFiB;AARnB,SAAS,KAAuB;AAChC,SAAS,OAAO;AAChB,SAAS,WAAW;AASlB,SAAK,gBAAgB,QAAQ,iBAAiB;AAC9C,SAAK,eAAe,QAAQ,gBAAgB;AAAA,EAC9C;AAAA,EAEA,MAAM,cAAgC;AACpC,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,cAA6B;AAAA,EAEnC;AAAA,EAEA,MAAM,OAAO,OAAe,SAAiD;AAC3E,UAAM,kBAAkB,yBAAyB,OAAO;AACxD,QAAI,mBAAmB,gBAAgB,WAAW,EAAG,QAAO,CAAC;AAG7D,UAAM,EAAE,aAAa,IAAI,MAAM,OAAO,0CAA0C;AAChF,UAAM,EAAE,oBAAoB,IAAI,MAAM,OAAO,wCAAwC;
|
|
4
|
+
"sourcesContent": ["import { type Kysely, sql, type SqlBool } from 'kysely'\nimport type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n IndexableRecord,\n} from '../types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\n\n/**\n * Configuration for TokenSearchStrategy.\n */\nexport type TokenStrategyConfig = {\n /** Minimum number of query tokens that must match (0-1 ratio, default 0.5) */\n minMatchRatio?: number\n /** Default limit for search results */\n defaultLimit?: number\n}\n\nfunction normalizeOrganizationIds(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n return Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n}\n\n/**\n * TokenSearchStrategy provides hash-based search using the existing search_tokens table.\n * This strategy is always available and serves as a fallback when other strategies fail.\n *\n * It tokenizes queries into hashes and matches against pre-indexed token hashes,\n * enabling search on encrypted fields without exposing plaintext to external services.\n */\nexport class TokenSearchStrategy implements SearchStrategy {\n readonly id: SearchStrategyId = 'tokens'\n readonly name = 'Token Search'\n readonly priority = 10 // Lowest priority, always available as fallback\n\n private readonly minMatchRatio: number\n private readonly defaultLimit: number\n\n constructor(\n private readonly db: Kysely<any>,\n config?: TokenStrategyConfig,\n ) {\n this.minMatchRatio = config?.minMatchRatio ?? 0.5\n this.defaultLimit = config?.defaultLimit ?? 50\n }\n\n async isAvailable(): Promise<boolean> {\n return true // Always available\n }\n\n async ensureReady(): Promise<void> {\n // No initialization needed\n }\n\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationIds(options)\n if (organizationIds && organizationIds.length === 0) return []\n\n // Dynamically import tokenization to avoid circular dependencies\n const { tokenizeText } = await import('@open-mercato/shared/lib/search/tokenize')\n const { resolveSearchConfig } = await import('@open-mercato/shared/lib/search/config')\n\n const config = resolveSearchConfig()\n if (!config.enabled) return []\n\n const { hashes } = tokenizeText(query, config)\n if (hashes.length === 0) return []\n\n const minMatches = Math.max(1, Math.ceil(hashes.length * this.minMatchRatio))\n const limit = options.limit ?? this.defaultLimit\n\n let queryBuilder = this.db\n .selectFrom('search_tokens' as any)\n .select([\n 'entity_type' as any,\n 'entity_id' as any,\n 'organization_id' as any,\n sql<string>`count(*)`.as('match_count'),\n ])\n .where('token_hash' as any, 'in', hashes)\n .where('tenant_id' as any, '=', options.tenantId)\n .groupBy(['entity_type' as any, 'entity_id' as any, 'organization_id' as any])\n .having(sql<SqlBool>`count(distinct token_hash) >= ${minMatches}`)\n .orderBy(sql`count(distinct token_hash) desc`)\n .limit(limit)\n\n if (organizationIds) {\n queryBuilder = queryBuilder.where('organization_id' as any, 'in', organizationIds)\n }\n\n if (options.entityTypes?.length) {\n queryBuilder = queryBuilder.where('entity_type' as any, 'in', options.entityTypes)\n }\n\n const rows = await queryBuilder.execute() as Array<{\n entity_type: string\n entity_id: string\n organization_id: string | null\n match_count: string | number\n }>\n\n return rows.map((row) => {\n const matchCount = typeof row.match_count === 'string'\n ? parseInt(row.match_count, 10)\n : row.match_count\n // Calculate score based on match ratio\n const score = matchCount / hashes.length\n\n return {\n entityId: row.entity_type as EntityId,\n recordId: row.entity_id,\n score,\n source: this.id,\n organizationId: row.organization_id ?? null,\n }\n })\n }\n\n async index(record: IndexableRecord): Promise<void> {\n // Dynamically import to avoid circular dependencies\n const { replaceSearchTokensForRecord } = await import(\n '@open-mercato/core/modules/query_index/lib/search-tokens'\n )\n\n await replaceSearchTokensForRecord(this.db, {\n entityType: record.entityId,\n recordId: record.recordId,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n doc: record.fields,\n })\n }\n\n async delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void> {\n // Dynamically import to avoid circular dependencies\n const { deleteSearchTokensForRecord } = await import(\n '@open-mercato/core/modules/query_index/lib/search-tokens'\n )\n\n await deleteSearchTokensForRecord(this.db, {\n entityType: entityId,\n recordId,\n tenantId,\n })\n }\n\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const { replaceSearchTokensForBatch } = await import(\n '@open-mercato/core/modules/query_index/lib/search-tokens'\n )\n\n const payloads = records.map((record) => ({\n entityType: record.entityId,\n recordId: record.recordId,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n doc: record.fields as Record<string, unknown>,\n }))\n\n await replaceSearchTokensForBatch(this.db, payloads)\n }\n\n async purge(entityId: EntityId, tenantId: string, organizationId?: string | null): Promise<void> {\n const normalizedOrganizationId =\n typeof organizationId === 'string' && organizationId.trim().length > 0 ? organizationId.trim() : null\n let query = this.db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityId)\n .where('tenant_id' as any, '=', tenantId)\n if (normalizedOrganizationId !== null) {\n query = query.where('organization_id' as any, '=', normalizedOrganizationId)\n }\n await query.execute()\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -127,9 +127,9 @@
|
|
|
127
127
|
"zod": "^4.4.3"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
|
-
"@open-mercato/core": "0.
|
|
131
|
-
"@open-mercato/queue": "0.
|
|
132
|
-
"@open-mercato/shared": "0.
|
|
130
|
+
"@open-mercato/core": "0.7.0",
|
|
131
|
+
"@open-mercato/queue": "0.7.0",
|
|
132
|
+
"@open-mercato/shared": "0.7.0"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|
|
@@ -144,6 +144,5 @@
|
|
|
144
144
|
"type": "git",
|
|
145
145
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
146
146
|
"directory": "packages/search"
|
|
147
|
-
}
|
|
148
|
-
"stableVersion": "0.6.7"
|
|
147
|
+
}
|
|
149
148
|
}
|
|
@@ -190,238 +190,4 @@ describe('createPresenterEnricher', () => {
|
|
|
190
190
|
const [enriched] = await enrich(results, 'tenant-1', null)
|
|
191
191
|
expect(enriched.presenter?.title).toBe('Stored')
|
|
192
192
|
})
|
|
193
|
-
|
|
194
|
-
it('merges person and company profile hits into their matching customer entities', async () => {
|
|
195
|
-
mockedDecryptIndexDocForSearch.mockImplementation(async (_entityId, doc) => doc)
|
|
196
|
-
|
|
197
|
-
const rows: IndexRow[] = [
|
|
198
|
-
{
|
|
199
|
-
entity_type: 'customers:customer_entity',
|
|
200
|
-
entity_id: 'person-entity',
|
|
201
|
-
doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
entity_type: 'customers:customer_person_profile',
|
|
205
|
-
entity_id: 'person-profile',
|
|
206
|
-
doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
|
|
207
|
-
},
|
|
208
|
-
{
|
|
209
|
-
entity_type: 'customers:customer_entity',
|
|
210
|
-
entity_id: 'company-entity',
|
|
211
|
-
doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
entity_type: 'customers:customer_company_profile',
|
|
215
|
-
entity_id: 'company-profile',
|
|
216
|
-
doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
|
|
217
|
-
},
|
|
218
|
-
]
|
|
219
|
-
const personConfig = createConfig({
|
|
220
|
-
entityId: 'customers:customer_person_profile' as EntityId,
|
|
221
|
-
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
222
|
-
resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
|
|
223
|
-
})
|
|
224
|
-
const companyConfig = createConfig({
|
|
225
|
-
entityId: 'customers:customer_company_profile' as EntityId,
|
|
226
|
-
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
227
|
-
resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
|
|
228
|
-
})
|
|
229
|
-
const enrich = createPresenterEnricher(
|
|
230
|
-
createKyselyMock(rows),
|
|
231
|
-
new Map([
|
|
232
|
-
[personConfig.entityId, personConfig],
|
|
233
|
-
[companyConfig.entityId, companyConfig],
|
|
234
|
-
]),
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
const enriched = await enrich([
|
|
238
|
-
createResult({
|
|
239
|
-
entityId: 'customers:customer_entity' as EntityId,
|
|
240
|
-
recordId: 'person-entity',
|
|
241
|
-
presenter: undefined,
|
|
242
|
-
score: 0.9,
|
|
243
|
-
}),
|
|
244
|
-
createResult({
|
|
245
|
-
entityId: 'customers:customer_person_profile' as EntityId,
|
|
246
|
-
recordId: 'person-profile',
|
|
247
|
-
presenter: undefined,
|
|
248
|
-
score: 0.8,
|
|
249
|
-
}),
|
|
250
|
-
createResult({
|
|
251
|
-
entityId: 'customers:customer_entity' as EntityId,
|
|
252
|
-
recordId: 'company-entity',
|
|
253
|
-
presenter: undefined,
|
|
254
|
-
score: 0.7,
|
|
255
|
-
}),
|
|
256
|
-
createResult({
|
|
257
|
-
entityId: 'customers:customer_company_profile' as EntityId,
|
|
258
|
-
recordId: 'company-profile',
|
|
259
|
-
presenter: undefined,
|
|
260
|
-
score: 0.6,
|
|
261
|
-
}),
|
|
262
|
-
], 'tenant-1', null)
|
|
263
|
-
|
|
264
|
-
expect(enriched).toEqual([
|
|
265
|
-
expect.objectContaining({
|
|
266
|
-
entityId: 'customers:customer_entity',
|
|
267
|
-
recordId: 'person-entity',
|
|
268
|
-
presenter: { title: 'Ada Lovelace' },
|
|
269
|
-
url: '/backend/customers/people-v2/person-entity',
|
|
270
|
-
}),
|
|
271
|
-
expect.objectContaining({
|
|
272
|
-
entityId: 'customers:customer_entity',
|
|
273
|
-
recordId: 'company-entity',
|
|
274
|
-
presenter: { title: 'Analytical Engines' },
|
|
275
|
-
url: '/backend/customers/companies-v2/company-entity',
|
|
276
|
-
}),
|
|
277
|
-
])
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('re-sorts merged results when linked profiles outrank their customer entities', async () => {
|
|
281
|
-
mockedDecryptIndexDocForSearch.mockImplementation(async (_entityId, doc) => doc)
|
|
282
|
-
|
|
283
|
-
const rows: IndexRow[] = [
|
|
284
|
-
{
|
|
285
|
-
entity_type: 'customers:customer_entity',
|
|
286
|
-
entity_id: 'person-entity',
|
|
287
|
-
doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
entity_type: 'customers:customer_person_profile',
|
|
291
|
-
entity_id: 'person-profile',
|
|
292
|
-
doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
|
|
293
|
-
},
|
|
294
|
-
{
|
|
295
|
-
entity_type: 'customers:customer_entity',
|
|
296
|
-
entity_id: 'company-entity',
|
|
297
|
-
doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
entity_type: 'customers:customer_company_profile',
|
|
301
|
-
entity_id: 'company-profile',
|
|
302
|
-
doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
|
|
303
|
-
},
|
|
304
|
-
]
|
|
305
|
-
const personConfig = createConfig({
|
|
306
|
-
entityId: 'customers:customer_person_profile' as EntityId,
|
|
307
|
-
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
308
|
-
resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
|
|
309
|
-
})
|
|
310
|
-
const companyConfig = createConfig({
|
|
311
|
-
entityId: 'customers:customer_company_profile' as EntityId,
|
|
312
|
-
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
313
|
-
resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
|
|
314
|
-
})
|
|
315
|
-
const enrich = createPresenterEnricher(
|
|
316
|
-
createKyselyMock(rows),
|
|
317
|
-
new Map([
|
|
318
|
-
[personConfig.entityId, personConfig],
|
|
319
|
-
[companyConfig.entityId, companyConfig],
|
|
320
|
-
]),
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
const enriched = await enrich([
|
|
324
|
-
createResult({
|
|
325
|
-
entityId: 'customers:customer_person_profile' as EntityId,
|
|
326
|
-
recordId: 'person-profile',
|
|
327
|
-
organizationId: 'org-1',
|
|
328
|
-
presenter: undefined,
|
|
329
|
-
score: 0.95,
|
|
330
|
-
source: 'fulltext',
|
|
331
|
-
}),
|
|
332
|
-
createResult({
|
|
333
|
-
entityId: 'customers:customer_company_profile' as EntityId,
|
|
334
|
-
recordId: 'company-profile',
|
|
335
|
-
organizationId: 'org-1',
|
|
336
|
-
presenter: undefined,
|
|
337
|
-
score: 0.85,
|
|
338
|
-
source: 'fulltext',
|
|
339
|
-
}),
|
|
340
|
-
createResult({
|
|
341
|
-
entityId: 'orders:order' as EntityId,
|
|
342
|
-
recordId: 'order-1',
|
|
343
|
-
organizationId: 'org-1',
|
|
344
|
-
presenter: { title: 'Order 1' },
|
|
345
|
-
url: '/backend/sales/orders/order-1',
|
|
346
|
-
score: 0.5,
|
|
347
|
-
}),
|
|
348
|
-
createResult({
|
|
349
|
-
entityId: 'customers:customer_entity' as EntityId,
|
|
350
|
-
recordId: 'person-entity',
|
|
351
|
-
organizationId: 'org-1',
|
|
352
|
-
presenter: undefined,
|
|
353
|
-
score: 0.2,
|
|
354
|
-
}),
|
|
355
|
-
createResult({
|
|
356
|
-
entityId: 'customers:customer_entity' as EntityId,
|
|
357
|
-
recordId: 'company-entity',
|
|
358
|
-
organizationId: 'org-1',
|
|
359
|
-
presenter: undefined,
|
|
360
|
-
score: 0.1,
|
|
361
|
-
}),
|
|
362
|
-
], 'tenant-1', 'org-1')
|
|
363
|
-
|
|
364
|
-
expect(enriched).toEqual([
|
|
365
|
-
expect.objectContaining({
|
|
366
|
-
entityId: 'customers:customer_entity',
|
|
367
|
-
recordId: 'person-entity',
|
|
368
|
-
score: 0.95,
|
|
369
|
-
url: '/backend/customers/people-v2/person-entity',
|
|
370
|
-
}),
|
|
371
|
-
expect.objectContaining({
|
|
372
|
-
entityId: 'customers:customer_entity',
|
|
373
|
-
recordId: 'company-entity',
|
|
374
|
-
score: 0.85,
|
|
375
|
-
url: '/backend/customers/companies-v2/company-entity',
|
|
376
|
-
}),
|
|
377
|
-
expect.objectContaining({
|
|
378
|
-
entityId: 'orders:order',
|
|
379
|
-
recordId: 'order-1',
|
|
380
|
-
score: 0.5,
|
|
381
|
-
}),
|
|
382
|
-
])
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
it('keeps linked content hits when their navigation includes a page anchor', async () => {
|
|
386
|
-
mockedDecryptIndexDocForSearch.mockImplementation(async (_entityId, doc) => doc)
|
|
387
|
-
|
|
388
|
-
const entityId = 'person-entity'
|
|
389
|
-
const rows: IndexRow[] = [
|
|
390
|
-
{
|
|
391
|
-
entity_type: 'customers:customer_entity',
|
|
392
|
-
entity_id: entityId,
|
|
393
|
-
doc: { id: entityId, display_name: 'Ada Lovelace' },
|
|
394
|
-
},
|
|
395
|
-
{
|
|
396
|
-
entity_type: 'customers:customer_comment',
|
|
397
|
-
entity_id: 'comment-1',
|
|
398
|
-
doc: { id: 'comment-1', body: 'Ada Lovelace', entity_id: entityId },
|
|
399
|
-
},
|
|
400
|
-
]
|
|
401
|
-
const commentConfig = createConfig({
|
|
402
|
-
entityId: 'customers:customer_comment' as EntityId,
|
|
403
|
-
formatResult: async () => ({ title: 'Ada Lovelace' }),
|
|
404
|
-
resolveUrl: async () => `/backend/customers/people-v2/${entityId}#notes`,
|
|
405
|
-
})
|
|
406
|
-
const enrich = createPresenterEnricher(
|
|
407
|
-
createKyselyMock(rows),
|
|
408
|
-
new Map([[commentConfig.entityId, commentConfig]]),
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
const enriched = await enrich([
|
|
412
|
-
createResult({
|
|
413
|
-
entityId: 'customers:customer_entity' as EntityId,
|
|
414
|
-
recordId: entityId,
|
|
415
|
-
presenter: undefined,
|
|
416
|
-
}),
|
|
417
|
-
createResult({
|
|
418
|
-
entityId: 'customers:customer_comment' as EntityId,
|
|
419
|
-
recordId: 'comment-1',
|
|
420
|
-
presenter: undefined,
|
|
421
|
-
}),
|
|
422
|
-
], 'tenant-1', null)
|
|
423
|
-
|
|
424
|
-
expect(enriched).toHaveLength(2)
|
|
425
|
-
expect(enriched[1]?.url).toBe(`/backend/customers/people-v2/${entityId}#notes`)
|
|
426
|
-
})
|
|
427
193
|
})
|
|
@@ -431,30 +431,6 @@ describe('SearchService', () => {
|
|
|
431
431
|
expect(strategy.index).toHaveBeenCalledTimes(2)
|
|
432
432
|
})
|
|
433
433
|
|
|
434
|
-
it('should bound in-flight writes when falling back to individual indexing', async () => {
|
|
435
|
-
let inFlight = 0
|
|
436
|
-
let peakInFlight = 0
|
|
437
|
-
const strategy = createMockStrategy({
|
|
438
|
-
id: 'test',
|
|
439
|
-
bulkIndex: undefined,
|
|
440
|
-
index: jest.fn(async () => {
|
|
441
|
-
inFlight++
|
|
442
|
-
peakInFlight = Math.max(peakInFlight, inFlight)
|
|
443
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
444
|
-
inFlight--
|
|
445
|
-
}),
|
|
446
|
-
})
|
|
447
|
-
const service = new SearchService({ strategies: [strategy] })
|
|
448
|
-
const records = Array.from({ length: 50 }, (_, index) =>
|
|
449
|
-
createMockRecord({ recordId: `rec-${index}` }),
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
await service.bulkIndex(records)
|
|
453
|
-
|
|
454
|
-
expect(strategy.index).toHaveBeenCalledTimes(50)
|
|
455
|
-
expect(peakInFlight).toBeLessThanOrEqual(4)
|
|
456
|
-
})
|
|
457
|
-
|
|
458
434
|
it('should do nothing when records array is empty', async () => {
|
|
459
435
|
const strategy = createMockStrategy({ id: 'test' })
|
|
460
436
|
const service = new SearchService({ strategies: [strategy] })
|
|
@@ -451,7 +451,6 @@ describe('Fulltext Index Worker', () => {
|
|
|
451
451
|
const mockSearchIndexer = {
|
|
452
452
|
getEntityConfig: jest.fn().mockReturnValue(null),
|
|
453
453
|
indexRecordById: jest.fn().mockResolvedValue({ action: 'indexed', created: true }),
|
|
454
|
-
indexRecordsById: jest.fn().mockResolvedValue({ indexed: 0, skipped: 0 }),
|
|
455
454
|
}
|
|
456
455
|
|
|
457
456
|
const mockEm = {
|
|
@@ -472,7 +471,6 @@ describe('Fulltext Index Worker', () => {
|
|
|
472
471
|
;(hasActiveReindexProgress as jest.Mock).mockResolvedValue(true)
|
|
473
472
|
mockFulltextStrategy.isAvailable.mockResolvedValue(true)
|
|
474
473
|
mockSearchIndexer.indexRecordById.mockResolvedValue({ action: 'indexed', created: true })
|
|
475
|
-
mockSearchIndexer.indexRecordsById.mockResolvedValue({ indexed: 0, skipped: 0 })
|
|
476
474
|
})
|
|
477
475
|
|
|
478
476
|
it('should skip job with missing tenantId', async () => {
|
|
@@ -488,13 +486,12 @@ describe('Fulltext Index Worker', () => {
|
|
|
488
486
|
expect(mockFulltextStrategy.bulkIndex).not.toHaveBeenCalled()
|
|
489
487
|
})
|
|
490
488
|
|
|
491
|
-
it('should index
|
|
489
|
+
it('should index records via searchIndexer when jobType is batch-index', async () => {
|
|
492
490
|
// Use minimal record format (just entityId + recordId)
|
|
493
491
|
const records = [
|
|
494
492
|
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
495
493
|
{ entityId: 'test:entity', recordId: 'rec-2' },
|
|
496
494
|
]
|
|
497
|
-
mockSearchIndexer.indexRecordsById.mockResolvedValueOnce({ indexed: 2, skipped: 0 })
|
|
498
495
|
const job = createMockJob<FulltextIndexJobPayload>({
|
|
499
496
|
jobType: 'batch-index',
|
|
500
497
|
tenantId: 'tenant-123',
|
|
@@ -504,22 +501,26 @@ describe('Fulltext Index Worker', () => {
|
|
|
504
501
|
|
|
505
502
|
await handleFulltextIndexJob(job, ctx, mockContainer)
|
|
506
503
|
|
|
507
|
-
// Verify
|
|
508
|
-
|
|
509
|
-
expect(mockSearchIndexer.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
504
|
+
// Verify indexRecordById was called for each record
|
|
505
|
+
expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(2)
|
|
506
|
+
expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
|
|
507
|
+
entityId: 'test:entity',
|
|
508
|
+
recordId: 'rec-1',
|
|
509
|
+
tenantId: 'tenant-123',
|
|
510
|
+
organizationId: undefined,
|
|
511
|
+
})
|
|
512
|
+
expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledWith({
|
|
513
|
+
entityId: 'test:entity',
|
|
514
|
+
recordId: 'rec-2',
|
|
515
515
|
tenantId: 'tenant-123',
|
|
516
516
|
organizationId: undefined,
|
|
517
517
|
})
|
|
518
|
-
expect(mockSearchIndexer.indexRecordById).not.toHaveBeenCalled()
|
|
519
518
|
})
|
|
520
519
|
|
|
521
520
|
it('counts handled fulltext batch records as processed so progress can complete', async () => {
|
|
522
|
-
mockSearchIndexer.
|
|
521
|
+
mockSearchIndexer.indexRecordById
|
|
522
|
+
.mockResolvedValueOnce({ action: 'skipped' })
|
|
523
|
+
.mockResolvedValueOnce({ action: 'skipped' })
|
|
523
524
|
const records = [
|
|
524
525
|
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
525
526
|
{ entityId: 'test:entity', recordId: 'rec-2' },
|
|
@@ -542,7 +543,7 @@ describe('Fulltext Index Worker', () => {
|
|
|
542
543
|
|
|
543
544
|
await handleFulltextIndexJob(job, createMockJobContext(), containerWithProgress)
|
|
544
545
|
|
|
545
|
-
expect(mockSearchIndexer.
|
|
546
|
+
expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(2)
|
|
546
547
|
expect(updateReindexProgress).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 2, 'org-456')
|
|
547
548
|
expect(incrementReindexProgress).toHaveBeenCalledWith(
|
|
548
549
|
expect.objectContaining({ type: 'fulltext', tenantId: 'tenant-123', delta: 2 }),
|
|
@@ -550,36 +551,6 @@ describe('Fulltext Index Worker', () => {
|
|
|
550
551
|
expect(clearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 'org-456')
|
|
551
552
|
})
|
|
552
553
|
|
|
553
|
-
it('re-throws a failed fulltext batch write without advancing reindex progress so the queue retries it', async () => {
|
|
554
|
-
mockSearchIndexer.indexRecordsById.mockRejectedValueOnce(new Error('meilisearch unavailable'))
|
|
555
|
-
const records = [
|
|
556
|
-
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
557
|
-
{ entityId: 'test:entity', recordId: 'rec-2' },
|
|
558
|
-
]
|
|
559
|
-
const containerWithProgress: HandlerContext = {
|
|
560
|
-
resolve: jest.fn((name: string) => {
|
|
561
|
-
if (name === 'searchStrategies') return [mockFulltextStrategy]
|
|
562
|
-
if (name === 'em') return mockEm
|
|
563
|
-
if (name === 'searchIndexer') return mockSearchIndexer
|
|
564
|
-
if (name === 'progressService') return { id: 'progress' }
|
|
565
|
-
throw new Error(`Unknown service: ${name}`)
|
|
566
|
-
}) as HandlerContext['resolve'],
|
|
567
|
-
}
|
|
568
|
-
const job = createMockJob<FulltextIndexJobPayload>({
|
|
569
|
-
jobType: 'batch-index',
|
|
570
|
-
tenantId: 'tenant-123',
|
|
571
|
-
organizationId: 'org-456',
|
|
572
|
-
records,
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
await expect(
|
|
576
|
-
handleFulltextIndexJob(job, createMockJobContext(), containerWithProgress),
|
|
577
|
-
).rejects.toThrow('meilisearch unavailable')
|
|
578
|
-
|
|
579
|
-
expect(updateReindexProgress).not.toHaveBeenCalled()
|
|
580
|
-
expect(incrementReindexProgress).not.toHaveBeenCalled()
|
|
581
|
-
})
|
|
582
|
-
|
|
583
554
|
it('clears an orphaned fulltext reindex lock instead of recreating it when no progress job is active', async () => {
|
|
584
555
|
;(hasActiveReindexProgress as jest.Mock).mockResolvedValueOnce(false)
|
|
585
556
|
const records = [{ entityId: 'test:entity', recordId: 'rec-1' }]
|
|
@@ -601,7 +572,7 @@ describe('Fulltext Index Worker', () => {
|
|
|
601
572
|
|
|
602
573
|
await handleFulltextIndexJob(job, createMockJobContext(), containerWithProgress)
|
|
603
574
|
|
|
604
|
-
expect(mockSearchIndexer.
|
|
575
|
+
expect(mockSearchIndexer.indexRecordById).toHaveBeenCalledTimes(1)
|
|
605
576
|
expect(updateReindexProgress).not.toHaveBeenCalled()
|
|
606
577
|
expect(incrementReindexProgress).not.toHaveBeenCalled()
|
|
607
578
|
expect(clearReindexLock).toHaveBeenCalledWith(mockDb, 'tenant-123', 'fulltext', 'org-456')
|
|
@@ -33,15 +33,6 @@ export type IndexRecordParams = {
|
|
|
33
33
|
customFields?: Record<string, unknown>
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Parameters for indexing a batch of records by id in a single bulk write.
|
|
38
|
-
*/
|
|
39
|
-
export type IndexRecordsByIdParams = {
|
|
40
|
-
items: Array<{ entityId: EntityId; recordId: string }>
|
|
41
|
-
tenantId: string
|
|
42
|
-
organizationId?: string | null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
/**
|
|
46
37
|
* Parameters for deleting a record from the search index.
|
|
47
38
|
*/
|
|
@@ -351,81 +342,6 @@ export class SearchIndexer {
|
|
|
351
342
|
}
|
|
352
343
|
}
|
|
353
344
|
|
|
354
|
-
/**
|
|
355
|
-
* Index a batch of records by id in a single bulk write.
|
|
356
|
-
* Unlike calling indexRecordById() in a loop, this loads each record fresh
|
|
357
|
-
* (same as indexRecordById) but flushes the whole batch through a single
|
|
358
|
-
* searchService.bulkIndex() call. Strategies that implement bulkIndex then
|
|
359
|
-
* collapse the batch into one write; strategies without it still write per
|
|
360
|
-
* record, at the bounded concurrency SearchService applies.
|
|
361
|
-
*/
|
|
362
|
-
async indexRecordsById(params: IndexRecordsByIdParams): Promise<{ indexed: number; skipped: number }> {
|
|
363
|
-
const { items, tenantId, organizationId } = params
|
|
364
|
-
if (!this.queryEngine || items.length === 0) {
|
|
365
|
-
return { indexed: 0, skipped: items.length }
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const recordIdsByEntity = new Map<EntityId, string[]>()
|
|
369
|
-
for (const item of items) {
|
|
370
|
-
const list = recordIdsByEntity.get(item.entityId) ?? []
|
|
371
|
-
list.push(item.recordId)
|
|
372
|
-
recordIdsByEntity.set(item.entityId, list)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const allRecords: IndexableRecord[] = []
|
|
376
|
-
let skipped = 0
|
|
377
|
-
|
|
378
|
-
for (const [entityId, recordIds] of recordIdsByEntity) {
|
|
379
|
-
const config = this.entityConfigMap.get(entityId)
|
|
380
|
-
if (!config || config.enabled === false) {
|
|
381
|
-
skipped += recordIds.length
|
|
382
|
-
continue
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
for (const recordId of recordIds) {
|
|
386
|
-
try {
|
|
387
|
-
const result = await this.queryEngine.query(entityId, {
|
|
388
|
-
tenantId,
|
|
389
|
-
organizationId: organizationId ?? undefined,
|
|
390
|
-
filters: { id: recordId },
|
|
391
|
-
includeCustomFields: true,
|
|
392
|
-
page: { page: 1, pageSize: 1 },
|
|
393
|
-
skipAutoReindex: true,
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
const record = result.items[0] as Record<string, unknown> | undefined
|
|
397
|
-
if (!record) {
|
|
398
|
-
skipped++
|
|
399
|
-
continue
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const { records: built, dropped } = await this.buildIndexableRecords(
|
|
403
|
-
entityId,
|
|
404
|
-
tenantId,
|
|
405
|
-
organizationId ?? null,
|
|
406
|
-
[record],
|
|
407
|
-
config,
|
|
408
|
-
)
|
|
409
|
-
skipped += dropped
|
|
410
|
-
allRecords.push(...built)
|
|
411
|
-
} catch (error) {
|
|
412
|
-
skipped++
|
|
413
|
-
searchError('SearchIndexer', 'Failed to load record for batch indexing', {
|
|
414
|
-
entityId,
|
|
415
|
-
recordId,
|
|
416
|
-
error: error instanceof Error ? error.message : error,
|
|
417
|
-
})
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (allRecords.length > 0) {
|
|
423
|
-
await this.searchService.bulkIndex(allRecords)
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
return { indexed: allRecords.length, skipped }
|
|
427
|
-
}
|
|
428
|
-
|
|
429
345
|
/**
|
|
430
346
|
* Delete a record from the search index.
|
|
431
347
|
*/
|