@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
@@ -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 (requestedEntityTypes?.length) {
49
- queryBuilder = queryBuilder.where("entity_type", "in", requestedEntityTypes);
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 const { listSearchTokenExcludedEntityTypes } = await import(\n '@open-mercato/core/modules/query_index/lib/search-entity-policy'\n )\n\n const config = resolveSearchConfig()\n if (!config.enabled) return []\n\n // The rows themselves stay in `search_tokens` \u2014 list routes and the query engines' encrypted\n // like/ilike rewrite depend on them \u2014 so the exclusion is enforced here, at read time.\n const excludedEntityTypes = listSearchTokenExcludedEntityTypes()\n const requestedEntityTypes = options.entityTypes?.length\n ? options.entityTypes.filter((entityType) => !excludedEntityTypes.includes(entityType))\n : undefined\n if (options.entityTypes?.length && !requestedEntityTypes?.length) 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 (requestedEntityTypes?.length) {\n queryBuilder = queryBuilder.where('entity_type' as any, 'in', requestedEntityTypes)\n } else if (excludedEntityTypes.length) {\n queryBuilder = queryBuilder.where('entity_type' as any, 'not in', excludedEntityTypes)\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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",
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.6.8-develop.7100.1.fbf66fca35",
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.6.8-develop.7100.1.fbf66fca35",
131
- "@open-mercato/queue": "0.6.8-develop.7100.1.fbf66fca35",
132
- "@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35"
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 the whole batch in a single indexRecordsById call when jobType is batch-index', async () => {
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 the whole batch is written through exactly one indexRecordsById
508
- // call, not one indexRecordById call per record.
509
- expect(mockSearchIndexer.indexRecordsById).toHaveBeenCalledTimes(1)
510
- expect(mockSearchIndexer.indexRecordsById).toHaveBeenCalledWith({
511
- items: [
512
- { entityId: 'test:entity', recordId: 'rec-1' },
513
- { entityId: 'test:entity', recordId: 'rec-2' },
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.indexRecordsById.mockResolvedValueOnce({ indexed: 0, skipped: 2 })
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.indexRecordsById).toHaveBeenCalledTimes(1)
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.indexRecordsById).toHaveBeenCalledTimes(1)
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
  */