@open-mercato/search 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7076.1.13d0af067b
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/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/strategies/token.strategy.js +8 -2
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/presenter-enricher.test.ts +234 -0
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +99 -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/strategies/token.strategy.ts +15 -2
package/AGENTS.md
CHANGED
|
@@ -557,6 +557,7 @@ curl "https://your-app.com/api/search?q=john%20doe&limit=20" \
|
|
|
557
557
|
| `OM_SEARCH_MAX_TOKENS_PER_FIELD` | When bounding token fan-out for one field | Default: `5000`. Maximum distinct token rows across all values of one field; `0` disables the limit. **Token strategy only** |
|
|
558
558
|
| `OM_SEARCH_MAX_TOKENS_PER_RECORD` | When bounding token fan-out for one record | Default: `20000`. Maximum token rows across all fields in one indexed record; `0` disables the limit. **Token strategy only** |
|
|
559
559
|
| `OM_SEARCH_TOKEN_PRESENCE_CACHE_MS` | When tuning the query engines' "does this entity have search tokens?" probe | Default: `30000` (ms); `0` disables. Process-level TTL cache in `@open-mercato/shared/lib/search/availability` — bounds how long a token purge or first-index is invisible to like/ilike routing. **Token strategy only** |
|
|
560
|
+
| `OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY` | When base `customers:customer_entity` rows should stay searchable | Default: `false`. A customer is found through their person/company profile, which carries the detail-page link; the base row adds only a navigation-less duplicate, and one that superadmins saw while nobody else did (#5046). `false` filters the type out of token results; `true` restores the previous behavior, where both rows are returned and the presenter enricher merges the pair. **Read-side only** — the `search_tokens` rows are still written, because `findEntityIdsBySearchTokens` (People/Companies list search) and the query engines' encrypted `like`/`ilike` rewrite resolve ids through them. No reindex needed either way. Policy lives in `packages/core/src/modules/query_index/lib/search-entity-policy.ts`. **Token strategy only** |
|
|
560
561
|
| `SEARCH_EXCLUDE_ENCRYPTED_FIELDS` | When you need to keep encrypted fields out of fulltext | Set to `true` to exclude encrypted fields from fulltext index |
|
|
561
562
|
| `OM_LOG_LEVEL` | When debugging presenter enrichment | Set to `debug` to surface presenter enricher diagnostics (replaces the former `DEBUG_SEARCH_ENRICHER` flag) |
|
|
562
563
|
|
|
@@ -48,6 +48,75 @@ async function fetchDocsBatch(db, byEntityType, tenantId, organizationId) {
|
|
|
48
48
|
}
|
|
49
49
|
return allDocs;
|
|
50
50
|
}
|
|
51
|
+
function primaryNavigationHref(result) {
|
|
52
|
+
if (typeof result.url === "string" && result.url.trim().length > 0) {
|
|
53
|
+
return result.url.trim();
|
|
54
|
+
}
|
|
55
|
+
const primaryLink = result.links?.find((link) => link.kind === "primary" && link.href.trim().length > 0);
|
|
56
|
+
return primaryLink?.href.trim() ?? null;
|
|
57
|
+
}
|
|
58
|
+
function directNavigationRecordId(href) {
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(href, "http://search.local");
|
|
61
|
+
if (url.search || url.hash) return null;
|
|
62
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
63
|
+
const lastSegment = segments.at(-1);
|
|
64
|
+
return lastSegment ? decodeURIComponent(lastSegment) : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function presenterTitle(result) {
|
|
70
|
+
const title = result.presenter?.title?.trim();
|
|
71
|
+
return title?.length ? title : null;
|
|
72
|
+
}
|
|
73
|
+
function resultScopeKey(result, recordId) {
|
|
74
|
+
return `${result.organizationId ?? ""}:${recordId}`;
|
|
75
|
+
}
|
|
76
|
+
function mergeResultMetadata(targetMetadata, linkedMetadata) {
|
|
77
|
+
if (!targetMetadata && !linkedMetadata) return void 0;
|
|
78
|
+
return {
|
|
79
|
+
...targetMetadata,
|
|
80
|
+
...linkedMetadata
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function mergeLinkedDuplicateResults(results) {
|
|
84
|
+
const indexesByRecord = /* @__PURE__ */ new Map();
|
|
85
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
86
|
+
const result = results[index];
|
|
87
|
+
const key = resultScopeKey(result, result.recordId);
|
|
88
|
+
const indexes = indexesByRecord.get(key) ?? [];
|
|
89
|
+
indexes.push(index);
|
|
90
|
+
indexesByRecord.set(key, indexes);
|
|
91
|
+
}
|
|
92
|
+
const replacements = /* @__PURE__ */ new Map();
|
|
93
|
+
const removedIndexes = /* @__PURE__ */ new Set();
|
|
94
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
95
|
+
const linkedResult = results[index];
|
|
96
|
+
const href = primaryNavigationHref(linkedResult);
|
|
97
|
+
const targetRecordId = href ? directNavigationRecordId(href) : null;
|
|
98
|
+
if (!targetRecordId || targetRecordId === linkedResult.recordId) continue;
|
|
99
|
+
const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? []).filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex));
|
|
100
|
+
if (targetIndexes.length !== 1) continue;
|
|
101
|
+
const targetIndex = targetIndexes[0];
|
|
102
|
+
const targetResult = replacements.get(targetIndex) ?? results[targetIndex];
|
|
103
|
+
if (primaryNavigationHref(targetResult)) continue;
|
|
104
|
+
const linkedTitle = presenterTitle(linkedResult);
|
|
105
|
+
const targetTitle = presenterTitle(targetResult);
|
|
106
|
+
if (!linkedTitle || linkedTitle !== targetTitle) continue;
|
|
107
|
+
replacements.set(targetIndex, {
|
|
108
|
+
...targetResult,
|
|
109
|
+
score: Math.max(targetResult.score, linkedResult.score),
|
|
110
|
+
source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,
|
|
111
|
+
presenter: linkedResult.presenter ?? targetResult.presenter,
|
|
112
|
+
url: linkedResult.url ?? targetResult.url,
|
|
113
|
+
links: linkedResult.links ?? targetResult.links,
|
|
114
|
+
metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata)
|
|
115
|
+
});
|
|
116
|
+
removedIndexes.add(index);
|
|
117
|
+
}
|
|
118
|
+
return results.map((result, index) => replacements.get(index) ?? result).filter((_, index) => !removedIndexes.has(index)).sort((left, right) => right.score - left.score);
|
|
119
|
+
}
|
|
51
120
|
async function computePresenterAndLinks(doc, entityId, recordId, config, tenantId, organizationId, queryEngine) {
|
|
52
121
|
let presenter = null;
|
|
53
122
|
let url;
|
|
@@ -161,7 +230,7 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
|
|
|
161
230
|
for (const { key, presenter, url, links } of computed) {
|
|
162
231
|
enrichmentMap.set(key, { presenter, url, links });
|
|
163
232
|
}
|
|
164
|
-
|
|
233
|
+
const enrichedResults = results.map((result) => {
|
|
165
234
|
if (!shouldEnrich(result)) return result;
|
|
166
235
|
const key = `${result.entityId}:${result.recordId}`;
|
|
167
236
|
const enriched = enrichmentMap.get(key);
|
|
@@ -173,6 +242,7 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
|
|
|
173
242
|
links: enriched.links ?? result.links
|
|
174
243
|
};
|
|
175
244
|
});
|
|
245
|
+
return mergeLinkedDuplicateResults(enrichedResults);
|
|
176
246
|
};
|
|
177
247
|
}
|
|
178
248
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/presenter-enricher.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n db: Kysely<any>,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n if (config?.formatResult || config?.buildSource) {\n if (config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning('formatResult failed', { entityId, recordId, err })\n }\n }\n\n if (!presenter && config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning('buildSource failed', { entityId, recordId, err })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n db: Kysely<any>,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(db, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n return results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAYA,SAAS,gCAAgC;AACzC,SAAS,oBAAoB;AAC7B,SAAS,gCAAgC;AACzC,SAAS,mCAAmC;AAG5C,MAAM,aAAa;AAGnB,MAAM,iBAAiB,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AACvF,MAAM,aAAa,CAAC,SAAiB,YAAsC;AACzE,iBAAe,MAAM,SAAS,OAAO;AACvC;AAKA,SAAS,MAAS,OAAY,MAAqB;AACjD,QAAM,SAAgB,CAAC;AACvB,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,MAAM;AAC3C,WAAO,KAAK,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC;AAAA,EACtC;AACA,SAAO;AACT;AAMA,eAAe,eACb,IACA,cACA,UACA,gBAC0F;AAC1F,QAAM,UAA2F,CAAC;AAGlG,QAAM,WAA4D,CAAC;AACnE,aAAW,CAAC,YAAY,OAAO,KAAK,cAAc;AAChD,eAAW,UAAU,SAAS;AAC5B,eAAS,KAAK,EAAE,YAAY,UAAU,OAAO,SAAS,CAAC;AAAA,IACzD;AAAA,EACF;AAEA,MAAI,SAAS,WAAW,EAAG,QAAO;AAGlC,QAAM,SAAS,MAAM,UAAU,UAAU;AAEzC,aAAW,aAAa,QAAQ;AAE9B,UAAM,cAAc,oBAAI,IAAsB;AAC9C,eAAW,EAAE,YAAY,SAAS,KAAK,WAAW;AAChD,YAAM,MAAM,YAAY,IAAI,UAAU,KAAK,CAAC;AAC5C,UAAI,KAAK,QAAQ;AACjB,kBAAY,IAAI,YAAY,GAAG;AAAA,IACjC;AAGA,QAAI,QAAQ,GACT,WAAW,gBAAuB,EAClC,OAAO,CAAC,eAAsB,aAAoB,KAAY,CAAC,EAC/D,MAAM,aAAoB,KAAK,QAAQ,EACvC,MAAM,cAAqB,MAAM,IAAI,EACrC,MAAM,CAAC,OAAY,GAAG;AAAA,MACrB,MAAM,KAAK,YAAY,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC,YAAY,SAAS,MAAM,GAAG,IAAI;AAAA,QACxE,GAAG,eAAsB,KAAK,UAAU;AAAA,QACxC,GAAG,aAAoB,MAAM,SAAS;AAAA,MACxC,CAAC,CAAC;AAAA,IACJ,CAAC;AAGH,QAAI,gBAAgB;AAClB,cAAQ,MAAM,MAAM,CAAC,OAAY,GAAG,GAAG;AAAA,QACrC,GAAG,mBAA0B,KAAK,cAAc;AAAA,QAChD,GAAG,mBAA0B,MAAM,IAAI;AAAA,MACzC,CAAC,CAAC;AAAA,IACJ;AAEA,UAAM,OAAO,MAAM,MAAM,QAAQ;AACjC,YAAQ,KAAK,GAAG,IAAI;AAAA,EACtB;AAEA,SAAO;AACT;
|
|
4
|
+
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n db: Kysely<any>,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\nfunction primaryNavigationHref(result: SearchResult): string | null {\n if (typeof result.url === 'string' && result.url.trim().length > 0) {\n return result.url.trim()\n }\n const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)\n return primaryLink?.href.trim() ?? null\n}\n\nfunction directNavigationRecordId(href: string): string | null {\n try {\n const url = new URL(href, 'http://search.local')\n if (url.search || url.hash) return null\n const segments = url.pathname.split('/').filter(Boolean)\n const lastSegment = segments.at(-1)\n return lastSegment ? decodeURIComponent(lastSegment) : null\n } catch {\n return null\n }\n}\n\nfunction presenterTitle(result: SearchResult): string | null {\n const title = result.presenter?.title?.trim()\n return title?.length ? title : null\n}\n\nfunction resultScopeKey(result: SearchResult, recordId: string): string {\n return `${result.organizationId ?? ''}:${recordId}`\n}\n\nfunction mergeResultMetadata(\n targetMetadata: SearchResult['metadata'],\n linkedMetadata: SearchResult['metadata'],\n): SearchResult['metadata'] {\n if (!targetMetadata && !linkedMetadata) return undefined\n return {\n ...targetMetadata,\n ...linkedMetadata,\n }\n}\n\nfunction mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {\n const indexesByRecord = new Map<string, number[]>()\n for (let index = 0; index < results.length; index += 1) {\n const result = results[index]\n const key = resultScopeKey(result, result.recordId)\n const indexes = indexesByRecord.get(key) ?? []\n indexes.push(index)\n indexesByRecord.set(key, indexes)\n }\n\n const replacements = new Map<number, SearchResult>()\n const removedIndexes = new Set<number>()\n\n for (let index = 0; index < results.length; index += 1) {\n const linkedResult = results[index]\n const href = primaryNavigationHref(linkedResult)\n const targetRecordId = href ? directNavigationRecordId(href) : null\n if (!targetRecordId || targetRecordId === linkedResult.recordId) continue\n\n const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])\n .filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))\n if (targetIndexes.length !== 1) continue\n\n const targetIndex = targetIndexes[0]\n const targetResult = replacements.get(targetIndex) ?? results[targetIndex]\n if (primaryNavigationHref(targetResult)) continue\n\n const linkedTitle = presenterTitle(linkedResult)\n const targetTitle = presenterTitle(targetResult)\n if (!linkedTitle || linkedTitle !== targetTitle) continue\n\n replacements.set(targetIndex, {\n ...targetResult,\n score: Math.max(targetResult.score, linkedResult.score),\n source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,\n presenter: linkedResult.presenter ?? targetResult.presenter,\n url: linkedResult.url ?? targetResult.url,\n links: linkedResult.links ?? targetResult.links,\n metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),\n })\n removedIndexes.add(index)\n }\n\n return results\n .map((result, index) => replacements.get(index) ?? result)\n .filter((_, index) => !removedIndexes.has(index))\n .sort((left, right) => right.score - left.score)\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n if (config?.formatResult || config?.buildSource) {\n if (config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning('formatResult failed', { entityId, recordId, err })\n }\n }\n\n if (!presenter && config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning('buildSource failed', { entityId, recordId, err })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n db: Kysely<any>,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(db, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n const enrichedResults = results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n\n return mergeLinkedDuplicateResults(enrichedResults)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
3
|
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
createCompanyFixture,
|
|
6
|
+
createPersonFixture,
|
|
7
|
+
deleteEntityIfExists
|
|
8
|
+
} from "@open-mercato/core/helpers/integration/crmFixtures";
|
|
4
9
|
const DEFAULT_STRATEGIES = ["fulltext", "vector", "tokens"];
|
|
10
|
+
const CUSTOMER_ENTITY = "customers:customer_entity";
|
|
11
|
+
const PERSON_PROFILE = "customers:customer_person_profile";
|
|
12
|
+
const COMPANY_PROFILE = "customers:customer_company_profile";
|
|
13
|
+
function presenterTitle(result) {
|
|
14
|
+
const title = result.presenter?.title;
|
|
15
|
+
return typeof title === "string" && title.trim().length > 0 ? title.trim() : null;
|
|
16
|
+
}
|
|
17
|
+
async function searchResults(request, token, path) {
|
|
18
|
+
const response = await apiRequest(request, "GET", path, { token });
|
|
19
|
+
if (!response.ok()) return { ok: false, status: response.status(), results: [] };
|
|
20
|
+
const body = await readJsonSafe(response) ?? {};
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
status: response.status(),
|
|
24
|
+
results: Array.isArray(body.results) ? body.results : []
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function customerSearchPath(query, entityType) {
|
|
28
|
+
const params = new URLSearchParams({
|
|
29
|
+
q: query,
|
|
30
|
+
limit: "20",
|
|
31
|
+
strategies: "tokens",
|
|
32
|
+
entityTypes: entityType
|
|
33
|
+
});
|
|
34
|
+
return `/api/search/search?${params.toString()}`;
|
|
35
|
+
}
|
|
36
|
+
function globalSearchPath(query) {
|
|
37
|
+
const params = new URLSearchParams({ q: query, limit: "20" });
|
|
38
|
+
return `/api/search/search/global?${params.toString()}`;
|
|
39
|
+
}
|
|
40
|
+
function hasCanonicalNavigation(result, expectedPrefix) {
|
|
41
|
+
if (typeof result.url !== "string") return false;
|
|
42
|
+
if (!result.url.startsWith(`${expectedPrefix}/`)) return false;
|
|
43
|
+
const target = result.url.slice(expectedPrefix.length + 1);
|
|
44
|
+
return target.length > 0 && !/[/?#]/.test(target);
|
|
45
|
+
}
|
|
5
46
|
test.describe("TC-SEARCH-006: global search honors saved strategy config over URL override", () => {
|
|
6
47
|
test("persisted enabledStrategies wins over the strategies URL parameter", async ({ request }) => {
|
|
7
48
|
test.slow();
|
|
@@ -44,5 +85,107 @@ test.describe("TC-SEARCH-006: global search honors saved strategy config over UR
|
|
|
44
85
|
}
|
|
45
86
|
}
|
|
46
87
|
});
|
|
88
|
+
test("returns one navigable profile result per customer and no base-entity duplicate", async ({ request }) => {
|
|
89
|
+
test.slow();
|
|
90
|
+
test.setTimeout(12e4);
|
|
91
|
+
const stamp = Date.now();
|
|
92
|
+
const personName = `QASRCH006P${stamp}`;
|
|
93
|
+
const companyName = `QASRCH006C${stamp}`;
|
|
94
|
+
let token = null;
|
|
95
|
+
let originalStrategies = DEFAULT_STRATEGIES;
|
|
96
|
+
let personId = null;
|
|
97
|
+
let companyId = null;
|
|
98
|
+
let personGlobalResults = [];
|
|
99
|
+
let companyGlobalResults = [];
|
|
100
|
+
try {
|
|
101
|
+
token = await getAuthToken(request, "admin");
|
|
102
|
+
const currentRes = await apiRequest(request, "GET", "/api/search/settings/global-search", { token });
|
|
103
|
+
expect(currentRes.ok(), "GET global-search settings should succeed").toBeTruthy();
|
|
104
|
+
const current = await readJsonSafe(currentRes) ?? {};
|
|
105
|
+
originalStrategies = Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0 ? current.enabledStrategies : DEFAULT_STRATEGIES;
|
|
106
|
+
const updateRes = await apiRequest(request, "POST", "/api/search/settings/global-search", {
|
|
107
|
+
token,
|
|
108
|
+
data: { enabledStrategies: ["tokens"] }
|
|
109
|
+
});
|
|
110
|
+
expect(updateRes.status(), "POST global-search settings should return 200").toBe(200);
|
|
111
|
+
personId = await createPersonFixture(request, token, {
|
|
112
|
+
firstName: "QA",
|
|
113
|
+
lastName: `Search 006 ${stamp}`,
|
|
114
|
+
displayName: personName
|
|
115
|
+
});
|
|
116
|
+
companyId = await createCompanyFixture(request, token, companyName);
|
|
117
|
+
await expect.poll(
|
|
118
|
+
async () => {
|
|
119
|
+
const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] = await Promise.all([
|
|
120
|
+
searchResults(request, token, customerSearchPath(personName, CUSTOMER_ENTITY)),
|
|
121
|
+
searchResults(request, token, customerSearchPath(personName, PERSON_PROFILE)),
|
|
122
|
+
searchResults(request, token, customerSearchPath(companyName, CUSTOMER_ENTITY)),
|
|
123
|
+
searchResults(request, token, customerSearchPath(companyName, COMPANY_PROFILE)),
|
|
124
|
+
searchResults(request, token, globalSearchPath(personName)),
|
|
125
|
+
searchResults(request, token, globalSearchPath(companyName))
|
|
126
|
+
]);
|
|
127
|
+
const queries = [
|
|
128
|
+
["person-entity", personEntity],
|
|
129
|
+
["person-profile", personProfile],
|
|
130
|
+
["company-entity", companyEntity],
|
|
131
|
+
["company-profile", companyProfile],
|
|
132
|
+
["person-global", personGlobal],
|
|
133
|
+
["company-global", companyGlobal]
|
|
134
|
+
];
|
|
135
|
+
const failedQuery = queries.find(([, result]) => !result.ok);
|
|
136
|
+
if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`;
|
|
137
|
+
const indexedQueries = [
|
|
138
|
+
["person-profile", personProfile.results, personName, PERSON_PROFILE],
|
|
139
|
+
["company-profile", companyProfile.results, companyName, COMPANY_PROFILE]
|
|
140
|
+
];
|
|
141
|
+
for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {
|
|
142
|
+
const matches = results.filter(
|
|
143
|
+
(result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId
|
|
144
|
+
);
|
|
145
|
+
if (matches.length === 0) return `${label}:matches:0`;
|
|
146
|
+
}
|
|
147
|
+
const baseEntityQueries = [
|
|
148
|
+
["person-entity", personEntity.results, personName],
|
|
149
|
+
["company-entity", companyEntity.results, companyName]
|
|
150
|
+
];
|
|
151
|
+
for (const [label, results, expectedTitle] of baseEntityQueries) {
|
|
152
|
+
const matches = results.filter((result) => presenterTitle(result) === expectedTitle);
|
|
153
|
+
if (matches.length !== 0) return `${label}:matches:${matches.length}`;
|
|
154
|
+
}
|
|
155
|
+
personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName);
|
|
156
|
+
companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName);
|
|
157
|
+
if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`;
|
|
158
|
+
if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`;
|
|
159
|
+
if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {
|
|
160
|
+
return `person-global:entity:${personGlobalResults[0]?.entityId ?? "missing"}`;
|
|
161
|
+
}
|
|
162
|
+
if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {
|
|
163
|
+
return `company-global:entity:${companyGlobalResults[0]?.entityId ?? "missing"}`;
|
|
164
|
+
}
|
|
165
|
+
if (!hasCanonicalNavigation(personGlobalResults[0], "/backend/customers/people-v2")) {
|
|
166
|
+
return `person-global:navigation:${personGlobalResults[0]?.url ?? "missing"}`;
|
|
167
|
+
}
|
|
168
|
+
if (!hasCanonicalNavigation(companyGlobalResults[0], "/backend/customers/companies-v2")) {
|
|
169
|
+
return `company-global:navigation:${companyGlobalResults[0]?.url ?? "missing"}`;
|
|
170
|
+
}
|
|
171
|
+
return "ready";
|
|
172
|
+
},
|
|
173
|
+
{ timeout: 1e4 }
|
|
174
|
+
).toBe("ready");
|
|
175
|
+
expect(personGlobalResults).toHaveLength(1);
|
|
176
|
+
expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE);
|
|
177
|
+
expect(companyGlobalResults).toHaveLength(1);
|
|
178
|
+
expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE);
|
|
179
|
+
} finally {
|
|
180
|
+
await deleteEntityIfExists(request, token, "/api/customers/people", personId);
|
|
181
|
+
await deleteEntityIfExists(request, token, "/api/customers/companies", companyId);
|
|
182
|
+
if (token && originalStrategies) {
|
|
183
|
+
await apiRequest(request, "POST", "/api/search/settings/global-search", {
|
|
184
|
+
token,
|
|
185
|
+
data: { enabledStrategies: originalStrategies }
|
|
186
|
+
}).catch(() => void 0);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
47
190
|
});
|
|
48
191
|
//# sourceMappingURL=TC-SEARCH-006.spec.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-006.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\ntype GlobalSearchSettings = { enabledStrategies?: string[] }\ntype GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }\ntype GlobalSearchResponse = { strategiesEnabled?: string[] }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\n\n/**\n * TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a\n * URL override. Source: issue #2483.\n *\n * Routes:\n * - GET/POST /api/search/settings/global-search (POST requires search.manage)\n * - GET /api/search/search/global (ignores any `strategies` URL param)\n *\n * Saves enabledStrategies = ['tokens'], then calls global search with a\n * conflicting ?strategies=fulltext,vector and asserts the response's\n * strategiesEnabled reflects the SAVED config, not the URL. The original config\n * is restored in `finally`. `admin` holds both search.view and search.manage.\n */\ntest.describe('TC-SEARCH-006: global search honors saved strategy config over URL override', () => {\n test('persisted enabledStrategies wins over the strategies URL parameter', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n let originalStrategies: string[] | null = DEFAULT_STRATEGIES\n\n try {\n token = await getAuthToken(request, 'admin')\n\n const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })\n expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()\n const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}\n expect(Array.isArray(current.enabledStrategies), 'settings expose an enabledStrategies array').toBe(true)\n originalStrategies =\n Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0\n ? current.enabledStrategies\n : DEFAULT_STRATEGIES\n\n const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: ['tokens'] },\n })\n expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)\n const updated = (await readJsonSafe<GlobalSearchUpdate>(updateRes)) ?? {}\n expect(updated.ok, 'update reports ok').toBe(true)\n expect(updated.enabledStrategies, 'update echoes the saved strategies').toEqual(['tokens'])\n\n const globalRes = await apiRequest(\n request,\n 'GET',\n `/api/search/search/global?q=qa-search-006-${Date.now()}&strategies=fulltext,vector`,\n { token },\n )\n expect(globalRes.ok(), 'GET global search should succeed').toBeTruthy()\n const globalBody = (await readJsonSafe<GlobalSearchResponse>(globalRes)) ?? {}\n expect(\n globalBody.strategiesEnabled,\n 'global search must use the saved config (tokens), ignoring the strategies URL override',\n ).toEqual(['tokens'])\n } finally {\n if (token && originalStrategies) {\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: originalStrategies },\n }).catch(() => undefined)\n }\n }\n })\n})\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,
|
|
4
|
+
"sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n createCompanyFixture,\n createPersonFixture,\n deleteEntityIfExists,\n} from '@open-mercato/core/helpers/integration/crmFixtures'\n\ntype GlobalSearchSettings = { enabledStrategies?: string[] }\ntype GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }\ntype SearchResultItem = {\n entityId?: string\n recordId?: string\n presenter?: { title?: string } | null\n url?: string | null\n}\ntype GlobalSearchResponse = { strategiesEnabled?: string[]; results?: SearchResultItem[] }\ntype SearchQueryResult = { ok: boolean; status: number; results: SearchResultItem[] }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\nconst CUSTOMER_ENTITY = 'customers:customer_entity'\nconst PERSON_PROFILE = 'customers:customer_person_profile'\nconst COMPANY_PROFILE = 'customers:customer_company_profile'\n\nfunction presenterTitle(result: SearchResultItem): string | null {\n const title = result.presenter?.title\n return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null\n}\n\nasync function searchResults(\n request: APIRequestContext,\n token: string,\n path: string,\n): Promise<SearchQueryResult> {\n const response = await apiRequest(request, 'GET', path, { token })\n if (!response.ok()) return { ok: false, status: response.status(), results: [] }\n const body = (await readJsonSafe<GlobalSearchResponse>(response)) ?? {}\n return {\n ok: true,\n status: response.status(),\n results: Array.isArray(body.results) ? body.results : [],\n }\n}\n\nfunction customerSearchPath(query: string, entityType: string): string {\n const params = new URLSearchParams({\n q: query,\n limit: '20',\n strategies: 'tokens',\n entityTypes: entityType,\n })\n return `/api/search/search?${params.toString()}`\n}\n\nfunction globalSearchPath(query: string): string {\n const params = new URLSearchParams({ q: query, limit: '20' })\n return `/api/search/search/global?${params.toString()}`\n}\n\n/**\n * A profile result navigates to the customer's v2 detail page, whose path segment is the base\n * customer entity id \u2014 not the profile's own `recordId`. So this asserts the shape of a direct\n * detail link (prefix + one non-empty id segment, no query string or anchor) rather than\n * equality with `recordId`.\n */\nfunction hasCanonicalNavigation(result: SearchResultItem, expectedPrefix: string): boolean {\n if (typeof result.url !== 'string') return false\n if (!result.url.startsWith(`${expectedPrefix}/`)) return false\n const target = result.url.slice(expectedPrefix.length + 1)\n return target.length > 0 && !/[/?#]/.test(target)\n}\n\n/**\n * TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a\n * URL override. Source: issue #2483.\n *\n * Routes:\n * - GET/POST /api/search/settings/global-search (POST requires search.manage)\n * - GET /api/search/search/global (ignores any `strategies` URL param)\n *\n * Saves enabledStrategies = ['tokens'], then calls global search with a\n * conflicting ?strategies=fulltext,vector and asserts the response's\n * strategiesEnabled reflects the SAVED config, not the URL. The original config\n * is restored in `finally`. `admin` holds both search.view and search.manage.\n */\ntest.describe('TC-SEARCH-006: global search honors saved strategy config over URL override', () => {\n test('persisted enabledStrategies wins over the strategies URL parameter', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n let originalStrategies: string[] | null = DEFAULT_STRATEGIES\n\n try {\n token = await getAuthToken(request, 'admin')\n\n const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })\n expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()\n const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}\n expect(Array.isArray(current.enabledStrategies), 'settings expose an enabledStrategies array').toBe(true)\n originalStrategies =\n Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0\n ? current.enabledStrategies\n : DEFAULT_STRATEGIES\n\n const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: ['tokens'] },\n })\n expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)\n const updated = (await readJsonSafe<GlobalSearchUpdate>(updateRes)) ?? {}\n expect(updated.ok, 'update reports ok').toBe(true)\n expect(updated.enabledStrategies, 'update echoes the saved strategies').toEqual(['tokens'])\n\n const globalRes = await apiRequest(\n request,\n 'GET',\n `/api/search/search/global?q=qa-search-006-${Date.now()}&strategies=fulltext,vector`,\n { token },\n )\n expect(globalRes.ok(), 'GET global search should succeed').toBeTruthy()\n const globalBody = (await readJsonSafe<GlobalSearchResponse>(globalRes)) ?? {}\n expect(\n globalBody.strategiesEnabled,\n 'global search must use the saved config (tokens), ignoring the strategies URL override',\n ).toEqual(['tokens'])\n } finally {\n if (token && originalStrategies) {\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: originalStrategies },\n }).catch(() => undefined)\n }\n }\n })\n\n test('returns one navigable profile result per customer and no base-entity duplicate', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n const stamp = Date.now()\n const personName = `QASRCH006P${stamp}`\n const companyName = `QASRCH006C${stamp}`\n let token: string | null = null\n let originalStrategies: string[] | null = DEFAULT_STRATEGIES\n let personId: string | null = null\n let companyId: string | null = null\n let personGlobalResults: SearchResultItem[] = []\n let companyGlobalResults: SearchResultItem[] = []\n\n try {\n token = await getAuthToken(request, 'admin')\n\n const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })\n expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()\n const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}\n originalStrategies =\n Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0\n ? current.enabledStrategies\n : DEFAULT_STRATEGIES\n\n const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: ['tokens'] },\n })\n expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)\n\n personId = await createPersonFixture(request, token, {\n firstName: 'QA',\n lastName: `Search 006 ${stamp}`,\n displayName: personName,\n })\n companyId = await createCompanyFixture(request, token, companyName)\n\n await expect\n .poll(\n async () => {\n const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] =\n await Promise.all([\n searchResults(request, token!, customerSearchPath(personName, CUSTOMER_ENTITY)),\n searchResults(request, token!, customerSearchPath(personName, PERSON_PROFILE)),\n searchResults(request, token!, customerSearchPath(companyName, CUSTOMER_ENTITY)),\n searchResults(request, token!, customerSearchPath(companyName, COMPANY_PROFILE)),\n searchResults(request, token!, globalSearchPath(personName)),\n searchResults(request, token!, globalSearchPath(companyName)),\n ])\n\n const queries = [\n ['person-entity', personEntity],\n ['person-profile', personProfile],\n ['company-entity', companyEntity],\n ['company-profile', companyProfile],\n ['person-global', personGlobal],\n ['company-global', companyGlobal],\n ] as const\n const failedQuery = queries.find(([, result]) => !result.ok)\n if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`\n\n const indexedQueries = [\n ['person-profile', personProfile.results, personName, PERSON_PROFILE],\n ['company-profile', companyProfile.results, companyName, COMPANY_PROFILE],\n ] as const\n for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {\n const matches = results.filter(\n (result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId,\n )\n if (matches.length === 0) return `${label}:matches:0`\n }\n\n // Under the default OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=false the token strategy\n // refuses to return base customer rows, so an explicit query for that entity type is\n // empty even though the same customers' profiles are already indexed above. (The rows\n // themselves stay in search_tokens \u2014 the list-search id lookup still needs them.)\n const baseEntityQueries = [\n ['person-entity', personEntity.results, personName],\n ['company-entity', companyEntity.results, companyName],\n ] as const\n for (const [label, results, expectedTitle] of baseEntityQueries) {\n const matches = results.filter((result) => presenterTitle(result) === expectedTitle)\n if (matches.length !== 0) return `${label}:matches:${matches.length}`\n }\n\n personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName)\n companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName)\n if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`\n if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`\n if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {\n return `person-global:entity:${personGlobalResults[0]?.entityId ?? 'missing'}`\n }\n if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {\n return `company-global:entity:${companyGlobalResults[0]?.entityId ?? 'missing'}`\n }\n if (!hasCanonicalNavigation(personGlobalResults[0], '/backend/customers/people-v2')) {\n return `person-global:navigation:${personGlobalResults[0]?.url ?? 'missing'}`\n }\n if (!hasCanonicalNavigation(companyGlobalResults[0], '/backend/customers/companies-v2')) {\n return `company-global:navigation:${companyGlobalResults[0]?.url ?? 'missing'}`\n }\n\n return 'ready'\n },\n { timeout: 10_000 },\n )\n .toBe('ready')\n\n expect(personGlobalResults).toHaveLength(1)\n expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE)\n expect(companyGlobalResults).toHaveLength(1)\n expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE)\n } finally {\n await deleteEntityIfExists(request, token, '/api/customers/people', personId)\n await deleteEntityIfExists(request, token, '/api/customers/companies', companyId)\n if (token && originalStrategies) {\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: originalStrategies },\n }).catch(() => undefined)\n }\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -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.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7076.1.13d0af067b",
|
|
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.
|
|
131
|
-
"@open-mercato/queue": "0.6.8-develop.
|
|
132
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
130
|
+
"@open-mercato/core": "0.6.8-develop.7076.1.13d0af067b",
|
|
131
|
+
"@open-mercato/queue": "0.6.8-develop.7076.1.13d0af067b",
|
|
132
|
+
"@open-mercato/shared": "0.6.8-develop.7076.1.13d0af067b"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|