@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
|
@@ -26,8 +26,12 @@ 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");
|
|
29
30
|
const config = resolveSearchConfig();
|
|
30
31
|
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 [];
|
|
31
35
|
const { hashes } = tokenizeText(query, config);
|
|
32
36
|
if (hashes.length === 0) return [];
|
|
33
37
|
const minMatches = Math.max(1, Math.ceil(hashes.length * this.minMatchRatio));
|
|
@@ -41,8 +45,10 @@ class TokenSearchStrategy {
|
|
|
41
45
|
if (organizationIds) {
|
|
42
46
|
queryBuilder = queryBuilder.where("organization_id", "in", organizationIds);
|
|
43
47
|
}
|
|
44
|
-
if (
|
|
45
|
-
queryBuilder = queryBuilder.where("entity_type", "in",
|
|
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);
|
|
46
52
|
}
|
|
47
53
|
const rows = await queryBuilder.execute();
|
|
48
54
|
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\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 (
|
|
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 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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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1-develop.7103.1.41ff100d93",
|
|
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.7.
|
|
131
|
-
"@open-mercato/queue": "0.7.
|
|
132
|
-
"@open-mercato/shared": "0.7.
|
|
130
|
+
"@open-mercato/core": "0.7.1-develop.7103.1.41ff100d93",
|
|
131
|
+
"@open-mercato/queue": "0.7.1-develop.7103.1.41ff100d93",
|
|
132
|
+
"@open-mercato/shared": "0.7.1-develop.7103.1.41ff100d93"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|
|
@@ -144,5 +144,6 @@
|
|
|
144
144
|
"type": "git",
|
|
145
145
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
146
146
|
"directory": "packages/search"
|
|
147
|
-
}
|
|
147
|
+
},
|
|
148
|
+
"stableVersion": "0.7.0"
|
|
148
149
|
}
|
|
@@ -190,4 +190,238 @@ 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
|
+
})
|
|
193
427
|
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { SearchIndexer } from '../indexer/search-indexer'
|
|
2
|
+
import type { SearchModuleConfig } from '../types'
|
|
3
|
+
import type { QueryEngine, QueryResult } from '@open-mercato/shared/lib/query/types'
|
|
4
|
+
|
|
5
|
+
describe('SearchIndexer.indexRecordsById', () => {
|
|
6
|
+
const moduleConfigs: SearchModuleConfig[] = [
|
|
7
|
+
{
|
|
8
|
+
entities: [
|
|
9
|
+
{
|
|
10
|
+
entityId: 'test:entity',
|
|
11
|
+
enabled: true,
|
|
12
|
+
formatResult: async (ctx) => ({ title: String(ctx.record.name ?? ctx.record.id) }),
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
entityId: 'test:other',
|
|
16
|
+
enabled: true,
|
|
17
|
+
formatResult: async (ctx) => ({ title: String(ctx.record.name ?? ctx.record.id) }),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function makeQueryEngine(recordsByEntity: Record<string, Record<string, unknown>[]>): QueryEngine {
|
|
24
|
+
return {
|
|
25
|
+
query: jest.fn(async (entity, opts) => {
|
|
26
|
+
const wantedId = (opts?.filters as { id?: string } | undefined)?.id
|
|
27
|
+
const items = (recordsByEntity[entity as string] ?? []).filter((r) => r.id === wantedId)
|
|
28
|
+
return { items, total: items.length } as QueryResult
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('writes N queued records through exactly one bulkIndex call, not N', async () => {
|
|
34
|
+
const records = [
|
|
35
|
+
{ id: 'rec-1', name: 'Alpha' },
|
|
36
|
+
{ id: 'rec-2', name: 'Beta' },
|
|
37
|
+
{ id: 'rec-3', name: 'Gamma' },
|
|
38
|
+
]
|
|
39
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
40
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
|
|
41
|
+
queryEngine: makeQueryEngine({ 'test:entity': records }),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const result = await indexer.indexRecordsById({
|
|
45
|
+
items: [
|
|
46
|
+
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
47
|
+
{ entityId: 'test:entity', recordId: 'rec-2' },
|
|
48
|
+
{ entityId: 'test:entity', recordId: 'rec-3' },
|
|
49
|
+
],
|
|
50
|
+
tenantId: 'tenant-123',
|
|
51
|
+
organizationId: 'org-456',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
|
|
55
|
+
expect(searchService.bulkIndex).toHaveBeenCalledWith([
|
|
56
|
+
expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-1', tenantId: 'tenant-123', organizationId: 'org-456' }),
|
|
57
|
+
expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-2', tenantId: 'tenant-123', organizationId: 'org-456' }),
|
|
58
|
+
expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-3', tenantId: 'tenant-123', organizationId: 'org-456' }),
|
|
59
|
+
])
|
|
60
|
+
expect(result).toEqual({ indexed: 3, skipped: 0 })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('collapses records spanning multiple entities into one bulkIndex call', async () => {
|
|
64
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
65
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
|
|
66
|
+
queryEngine: makeQueryEngine({
|
|
67
|
+
'test:entity': [{ id: 'rec-1', name: 'Alpha' }],
|
|
68
|
+
'test:other': [{ id: 'other-1', name: 'Delta' }],
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const result = await indexer.indexRecordsById({
|
|
73
|
+
items: [
|
|
74
|
+
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
75
|
+
{ entityId: 'test:other', recordId: 'other-1' },
|
|
76
|
+
],
|
|
77
|
+
tenantId: 'tenant-123',
|
|
78
|
+
organizationId: null,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
|
|
82
|
+
expect(searchService.bulkIndex).toHaveBeenCalledWith([
|
|
83
|
+
expect.objectContaining({ entityId: 'test:entity', recordId: 'rec-1' }),
|
|
84
|
+
expect.objectContaining({ entityId: 'test:other', recordId: 'other-1' }),
|
|
85
|
+
])
|
|
86
|
+
expect(result).toEqual({ indexed: 2, skipped: 0 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('loads each record with custom fields and without triggering auto-reindex', async () => {
|
|
90
|
+
const queryEngine = makeQueryEngine({ 'test:entity': [{ id: 'rec-1', name: 'Alpha' }] })
|
|
91
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
92
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, { queryEngine })
|
|
93
|
+
|
|
94
|
+
await indexer.indexRecordsById({
|
|
95
|
+
items: [{ entityId: 'test:entity', recordId: 'rec-1' }],
|
|
96
|
+
tenantId: 'tenant-123',
|
|
97
|
+
organizationId: 'org-456',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(queryEngine.query).toHaveBeenCalledWith(
|
|
101
|
+
'test:entity',
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
tenantId: 'tenant-123',
|
|
104
|
+
organizationId: 'org-456',
|
|
105
|
+
filters: { id: 'rec-1' },
|
|
106
|
+
includeCustomFields: true,
|
|
107
|
+
skipAutoReindex: true,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('skips records that no longer exist without failing the batch write', async () => {
|
|
113
|
+
const records = [{ id: 'rec-1', name: 'Alpha' }]
|
|
114
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
115
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
|
|
116
|
+
queryEngine: makeQueryEngine({ 'test:entity': records }),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const result = await indexer.indexRecordsById({
|
|
120
|
+
items: [
|
|
121
|
+
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
122
|
+
{ entityId: 'test:entity', recordId: 'missing' },
|
|
123
|
+
],
|
|
124
|
+
tenantId: 'tenant-123',
|
|
125
|
+
organizationId: null,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
|
|
129
|
+
expect(result).toEqual({ indexed: 1, skipped: 1 })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('keeps indexing the batch when loading one record throws', async () => {
|
|
133
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
134
|
+
const queryEngine: QueryEngine = {
|
|
135
|
+
query: jest.fn(async (_entity, opts) => {
|
|
136
|
+
const wantedId = (opts?.filters as { id?: string } | undefined)?.id
|
|
137
|
+
if (wantedId === 'boom') throw new Error('connection lost')
|
|
138
|
+
return { items: [{ id: wantedId, name: 'Alpha' }], total: 1 } as QueryResult
|
|
139
|
+
}),
|
|
140
|
+
}
|
|
141
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, { queryEngine })
|
|
142
|
+
|
|
143
|
+
const result = await indexer.indexRecordsById({
|
|
144
|
+
items: [
|
|
145
|
+
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
146
|
+
{ entityId: 'test:entity', recordId: 'boom' },
|
|
147
|
+
{ entityId: 'test:entity', recordId: 'rec-2' },
|
|
148
|
+
],
|
|
149
|
+
tenantId: 'tenant-123',
|
|
150
|
+
organizationId: null,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(searchService.bulkIndex).toHaveBeenCalledTimes(1)
|
|
154
|
+
expect(searchService.bulkIndex).toHaveBeenCalledWith([
|
|
155
|
+
expect.objectContaining({ recordId: 'rec-1' }),
|
|
156
|
+
expect.objectContaining({ recordId: 'rec-2' }),
|
|
157
|
+
])
|
|
158
|
+
expect(result).toEqual({ indexed: 2, skipped: 1 })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('propagates a bulkIndex failure so the queue can retry the job', async () => {
|
|
162
|
+
const searchService = { bulkIndex: jest.fn().mockRejectedValue(new Error('meilisearch unavailable')) }
|
|
163
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
|
|
164
|
+
queryEngine: makeQueryEngine({ 'test:entity': [{ id: 'rec-1', name: 'Alpha' }] }),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
indexer.indexRecordsById({
|
|
169
|
+
items: [{ entityId: 'test:entity', recordId: 'rec-1' }],
|
|
170
|
+
tenantId: 'tenant-123',
|
|
171
|
+
organizationId: null,
|
|
172
|
+
}),
|
|
173
|
+
).rejects.toThrow('meilisearch unavailable')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('skips entities that are not configured and never calls bulkIndex when nothing is indexable', async () => {
|
|
177
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
178
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, {
|
|
179
|
+
queryEngine: makeQueryEngine({}),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const result = await indexer.indexRecordsById({
|
|
183
|
+
items: [{ entityId: 'unknown:entity', recordId: 'rec-1' }],
|
|
184
|
+
tenantId: 'tenant-123',
|
|
185
|
+
organizationId: null,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(searchService.bulkIndex).not.toHaveBeenCalled()
|
|
189
|
+
expect(result).toEqual({ indexed: 0, skipped: 1 })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('counts records dropped for a missing id as skipped so the totals add up', async () => {
|
|
193
|
+
const searchService = { bulkIndex: jest.fn().mockResolvedValue(undefined) }
|
|
194
|
+
const queryEngine: QueryEngine = {
|
|
195
|
+
query: jest.fn(async (_entity, opts) => {
|
|
196
|
+
const wantedId = (opts?.filters as { id?: string } | undefined)?.id
|
|
197
|
+
const item = wantedId === 'no-id' ? { name: 'Ghost' } : { id: wantedId, name: 'Alpha' }
|
|
198
|
+
return { items: [item], total: 1 } as QueryResult
|
|
199
|
+
}),
|
|
200
|
+
}
|
|
201
|
+
const indexer = new SearchIndexer(searchService as any, moduleConfigs, { queryEngine })
|
|
202
|
+
|
|
203
|
+
const result = await indexer.indexRecordsById({
|
|
204
|
+
items: [
|
|
205
|
+
{ entityId: 'test:entity', recordId: 'rec-1' },
|
|
206
|
+
{ entityId: 'test:entity', recordId: 'no-id' },
|
|
207
|
+
],
|
|
208
|
+
tenantId: 'tenant-123',
|
|
209
|
+
organizationId: null,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(result).toEqual({ indexed: 1, skipped: 1 })
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -431,6 +431,30 @@ 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
|
+
|
|
434
458
|
it('should do nothing when records array is empty', async () => {
|
|
435
459
|
const strategy = createMockStrategy({ id: 'test' })
|
|
436
460
|
const service = new SearchService({ strategies: [strategy] })
|