@open-mercato/search 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
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.
|
@@ -7,6 +7,22 @@ function normalizeOrganizationIds(options) {
|
|
|
7
7
|
options.organizationIds.map((value) => typeof value === "string" ? value.trim() : "").filter((value) => value.length > 0)
|
|
8
8
|
));
|
|
9
9
|
}
|
|
10
|
+
const CF_ALIAS_PREFIX = "cf_";
|
|
11
|
+
const CF_CANONICAL_PREFIX = "cf:";
|
|
12
|
+
function normalizeCustomFieldKeys(fields) {
|
|
13
|
+
if (!fields) return fields;
|
|
14
|
+
const normalized = {};
|
|
15
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
16
|
+
if (!key.startsWith(CF_ALIAS_PREFIX)) {
|
|
17
|
+
normalized[key] = value;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const canonical = `${CF_CANONICAL_PREFIX}${key.slice(CF_ALIAS_PREFIX.length)}`;
|
|
21
|
+
if (canonical in fields) continue;
|
|
22
|
+
normalized[canonical] = value;
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
10
26
|
class TokenSearchStrategy {
|
|
11
27
|
constructor(db, config) {
|
|
12
28
|
this.db = db;
|
|
@@ -70,7 +86,7 @@ class TokenSearchStrategy {
|
|
|
70
86
|
recordId: record.recordId,
|
|
71
87
|
tenantId: record.tenantId,
|
|
72
88
|
organizationId: record.organizationId,
|
|
73
|
-
doc: record.fields
|
|
89
|
+
doc: normalizeCustomFieldKeys(record.fields)
|
|
74
90
|
});
|
|
75
91
|
}
|
|
76
92
|
async delete(entityId, recordId, tenantId) {
|
|
@@ -89,7 +105,7 @@ class TokenSearchStrategy {
|
|
|
89
105
|
recordId: record.recordId,
|
|
90
106
|
tenantId: record.tenantId,
|
|
91
107
|
organizationId: record.organizationId,
|
|
92
|
-
doc: record.fields
|
|
108
|
+
doc: normalizeCustomFieldKeys(record.fields)
|
|
93
109
|
}));
|
|
94
110
|
await replaceSearchTokensForBatch(this.db, payloads);
|
|
95
111
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/strategies/token.strategy.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, sql, type SqlBool } from 'kysely'\nimport type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n IndexableRecord,\n} from '../types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\n\n/**\n * Configuration for TokenSearchStrategy.\n */\nexport type TokenStrategyConfig = {\n /** Minimum number of query tokens that must match (0-1 ratio, default 0.5) */\n minMatchRatio?: number\n /** Default limit for search results */\n defaultLimit?: number\n}\n\nfunction normalizeOrganizationIds(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n return Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n}\n\n/**\n * TokenSearchStrategy provides hash-based search using the existing search_tokens table.\n * This strategy is always available and serves as a fallback when other strategies fail.\n *\n * It tokenizes queries into hashes and matches against pre-indexed token hashes,\n * enabling search on encrypted fields without exposing plaintext to external services.\n */\nexport class TokenSearchStrategy implements SearchStrategy {\n readonly id: SearchStrategyId = 'tokens'\n readonly name = 'Token Search'\n readonly priority = 10 // Lowest priority, always available as fallback\n\n private readonly minMatchRatio: number\n private readonly defaultLimit: number\n\n constructor(\n private readonly db: Kysely<any>,\n config?: TokenStrategyConfig,\n ) {\n this.minMatchRatio = config?.minMatchRatio ?? 0.5\n this.defaultLimit = config?.defaultLimit ?? 50\n }\n\n async isAvailable(): Promise<boolean> {\n return true // Always available\n }\n\n async ensureReady(): Promise<void> {\n // No initialization needed\n }\n\n async search(query: string, options: SearchOptions): Promise<SearchResult[]> {\n const organizationIds = normalizeOrganizationIds(options)\n if (organizationIds && organizationIds.length === 0) return []\n\n // Dynamically import tokenization to avoid circular dependencies\n const { tokenizeText } = await import('@open-mercato/shared/lib/search/tokenize')\n const { resolveSearchConfig } = await import('@open-mercato/shared/lib/search/config')\n const { listSearchTokenExcludedEntityTypes } = await import(\n '@open-mercato/core/modules/query_index/lib/search-entity-policy'\n )\n\n const config = resolveSearchConfig()\n if (!config.enabled) return []\n\n // The rows themselves stay in `search_tokens` \u2014 list routes and the query engines' encrypted\n // like/ilike rewrite depend on them \u2014 so the exclusion is enforced here, at read time.\n const excludedEntityTypes = listSearchTokenExcludedEntityTypes()\n const requestedEntityTypes = options.entityTypes?.length\n ? options.entityTypes.filter((entityType) => !excludedEntityTypes.includes(entityType))\n : undefined\n if (options.entityTypes?.length && !requestedEntityTypes?.length) return []\n\n const { hashes } = tokenizeText(query, config)\n if (hashes.length === 0) return []\n\n const minMatches = Math.max(1, Math.ceil(hashes.length * this.minMatchRatio))\n const limit = options.limit ?? this.defaultLimit\n\n let queryBuilder = this.db\n .selectFrom('search_tokens' as any)\n .select([\n 'entity_type' as any,\n 'entity_id' as any,\n 'organization_id' as any,\n sql<string>`count(*)`.as('match_count'),\n ])\n .where('token_hash' as any, 'in', hashes)\n .where('tenant_id' as any, '=', options.tenantId)\n .groupBy(['entity_type' as any, 'entity_id' as any, 'organization_id' as any])\n .having(sql<SqlBool>`count(distinct token_hash) >= ${minMatches}`)\n .orderBy(sql`count(distinct token_hash) desc`)\n .limit(limit)\n\n if (organizationIds) {\n queryBuilder = queryBuilder.where('organization_id' as any, 'in', organizationIds)\n }\n\n if (requestedEntityTypes?.length) {\n queryBuilder = queryBuilder.where('entity_type' as any, 'in', requestedEntityTypes)\n } else if (excludedEntityTypes.length) {\n queryBuilder = queryBuilder.where('entity_type' as any, 'not in', excludedEntityTypes)\n }\n\n const rows = await queryBuilder.execute() as Array<{\n entity_type: string\n entity_id: string\n organization_id: string | null\n match_count: string | number\n }>\n\n return rows.map((row) => {\n const matchCount = typeof row.match_count === 'string'\n ? parseInt(row.match_count, 10)\n : row.match_count\n // Calculate score based on match ratio\n const score = matchCount / hashes.length\n\n return {\n entityId: row.entity_type as EntityId,\n recordId: row.entity_id,\n score,\n source: this.id,\n organizationId: row.organization_id ?? null,\n }\n })\n }\n\n async index(record: IndexableRecord): Promise<void> {\n // Dynamically import to avoid circular dependencies\n const { replaceSearchTokensForRecord } = await import(\n '@open-mercato/core/modules/query_index/lib/search-tokens'\n )\n\n await replaceSearchTokensForRecord(this.db, {\n entityType: record.entityId,\n recordId: record.recordId,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n doc: record.fields,\n })\n }\n\n async delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void> {\n // Dynamically import to avoid circular dependencies\n const { deleteSearchTokensForRecord } = await import(\n '@open-mercato/core/modules/query_index/lib/search-tokens'\n )\n\n await deleteSearchTokensForRecord(this.db, {\n entityType: entityId,\n recordId,\n tenantId,\n })\n }\n\n async bulkIndex(records: IndexableRecord[]): Promise<void> {\n if (records.length === 0) return\n\n const { replaceSearchTokensForBatch } = await import(\n '@open-mercato/core/modules/query_index/lib/search-tokens'\n )\n\n const payloads = records.map((record) => ({\n entityType: record.entityId,\n recordId: record.recordId,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n doc: record.fields as Record<string, unknown>,\n }))\n\n await replaceSearchTokensForBatch(this.db, payloads)\n }\n\n async purge(entityId: EntityId, tenantId: string, organizationId?: string | null): Promise<void> {\n const normalizedOrganizationId =\n typeof organizationId === 'string' && organizationId.trim().length > 0 ? organizationId.trim() : null\n let query = this.db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityId)\n .where('tenant_id' as any, '=', tenantId)\n if (normalizedOrganizationId !== null) {\n query = query.where('organization_id' as any, '=', normalizedOrganizationId)\n }\n await query.execute()\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { type Kysely, sql, type SqlBool } from 'kysely'\nimport type {\n SearchStrategy,\n SearchStrategyId,\n SearchOptions,\n SearchResult,\n IndexableRecord,\n} from '../types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\n\n/**\n * Configuration for TokenSearchStrategy.\n */\nexport type TokenStrategyConfig = {\n /** Minimum number of query tokens that must match (0-1 ratio, default 0.5) */\n minMatchRatio?: number\n /** Default limit for search results */\n defaultLimit?: number\n}\n\nfunction normalizeOrganizationIds(options: SearchOptions): string[] | null {\n const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''\n if (single) return [single]\n if (!Array.isArray(options.organizationIds)) return null\n return Array.from(new Set(\n options.organizationIds\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ))\n}\n\nconst CF_ALIAS_PREFIX = 'cf_'\nconst CF_CANONICAL_PREFIX = 'cf:'\n\n/**\n * Rewrites the query engine's aliased custom-field keys back to the spelling `search_tokens` is\n * meant to carry.\n *\n * The engine cannot label a column `cf:<key>` \u2014 `:` is not a valid SQL identifier \u2014 so it sanitizes\n * the alias down to `cf_<key>`. Core's token writer builds from `entity_indexes.doc`, which keeps\n * `cf:<key>`, and both writers replace a record's tokens by deleting only the `(entity_id, field)`\n * pairs their own document carries. Under two spellings neither deletes the other's custom-field\n * rows: every custom field is tokenized twice under names that carry the same hashes, while the\n * base-field rows the two documents share are alternately deleted and re-inserted on every write.\n *\n * `cf:` is the side to converge on because it is the side that is read \u2014 the query engine's search\n * predicate and every caller of `findEntityIdsBySearchTokens` ask for `cf:<key>`.\n *\n * Deliberately scoped to the rows this strategy writes rather than applied to\n * `IndexableRecord.fields` upstream: the same object is handed to the fulltext driver, and\n * Meilisearch rejects an attribute name containing `:`.\n *\n * The reversal assumes word-character keys. The engine's alias sanitizer is\n * `[^a-zA-Z0-9_] -> _` and a custom-field key is an unconstrained `z.string()`, so a key named\n * `order-ref` arrives as `cf_order_ref` and is rewritten to `cf:order_ref` \u2014 a name nothing reads,\n * which leaves that one field's double-write unfixed. Inverting the sanitizer would need the\n * field-definition key list, which this strategy has not got.\n */\nfunction normalizeCustomFieldKeys(\n fields: Record<string, unknown> | null | undefined,\n): Record<string, unknown> | null | undefined {\n if (!fields) return fields\n const normalized: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(fields)) {\n if (!key.startsWith(CF_ALIAS_PREFIX)) {\n normalized[key] = value\n continue\n }\n const canonical = `${CF_CANONICAL_PREFIX}${key.slice(CF_ALIAS_PREFIX.length)}`\n // A document carrying both spellings meant the explicit one; the alias is the sanitizer's\n // output for the same field.\n if (canonical in fields) continue\n normalized[canonical] = value\n }\n return normalized\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: normalizeCustomFieldKeys(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: normalizeCustomFieldKeys(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.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7121.1.734c263bda",
|
|
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.1-develop.
|
|
131
|
-
"@open-mercato/queue": "0.7.1-develop.
|
|
132
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
130
|
+
"@open-mercato/core": "0.7.1-develop.7121.1.734c263bda",
|
|
131
|
+
"@open-mercato/queue": "0.7.1-develop.7121.1.734c263bda",
|
|
132
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely'
|
|
2
|
+
import { replaceSearchTokensForRecord } from '@open-mercato/core/modules/query_index/lib/search-tokens'
|
|
3
|
+
import { TokenSearchStrategy } from '../strategies/token.strategy'
|
|
4
|
+
import type { IndexableRecord } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The search module reaches `search_tokens` through the query engine, which cannot label a column
|
|
8
|
+
* `cf:<key>` and returns the sanitized alias `cf_<key>` instead. Core's own writer builds from
|
|
9
|
+
* `entity_indexes.doc` and keeps `cf:<key>`. Both replace a record's tokens by deleting only the
|
|
10
|
+
* `(entity_id, field)` pairs their own document carries, so under two spellings neither deletes the
|
|
11
|
+
* other's custom-field rows.
|
|
12
|
+
*
|
|
13
|
+
* These tests drive the real core writer against an in-memory `search_tokens` so the two writers
|
|
14
|
+
* meet in one table, which is the only place the divergence is observable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type StoredRow = {
|
|
18
|
+
id: number
|
|
19
|
+
entity_type: string
|
|
20
|
+
entity_id: string
|
|
21
|
+
organization_id: string | null
|
|
22
|
+
tenant_id: string | null
|
|
23
|
+
field: string
|
|
24
|
+
token_hash: string
|
|
25
|
+
token: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type RawBuilderLike = { toOperationNode: () => { sqlFragments: string[]; parameters: Array<{ value?: unknown }> } }
|
|
29
|
+
type Matcher = (row: StoredRow) => boolean
|
|
30
|
+
|
|
31
|
+
const columnOf = (row: StoredRow, column: string): unknown => (row as unknown as Record<string, unknown>)[column]
|
|
32
|
+
|
|
33
|
+
function createSearchTokenStore() {
|
|
34
|
+
const rows: StoredRow[] = []
|
|
35
|
+
let nextId = 1
|
|
36
|
+
|
|
37
|
+
const expressionBuilder = () => {
|
|
38
|
+
const eb: any = (column: string, operator: string, value: unknown): Matcher => {
|
|
39
|
+
if (operator !== '=') throw new Error(`[internal] unsupported eb operator: ${operator}`)
|
|
40
|
+
return (row) => String(columnOf(row, column)) === String(value)
|
|
41
|
+
}
|
|
42
|
+
eb.and = (matchers: Matcher[]): Matcher => (row) => matchers.every((matches) => matches(row))
|
|
43
|
+
eb.or = (matchers: Matcher[]): Matcher => (row) => matchers.some((matches) => matches(row))
|
|
44
|
+
return eb
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const buildPredicate = (args: unknown[]): Matcher => {
|
|
48
|
+
if (args.length === 1) {
|
|
49
|
+
const arg = args[0]
|
|
50
|
+
if (typeof arg === 'function') return (arg as (eb: unknown) => Matcher)(expressionBuilder())
|
|
51
|
+
const node = (arg as RawBuilderLike).toOperationNode()
|
|
52
|
+
const column = String(node.sqlFragments[0]).trim().split(/\s+/)[0]
|
|
53
|
+
const expected = node.parameters[0]?.value ?? null
|
|
54
|
+
return (row) => (columnOf(row, column) ?? null) === expected
|
|
55
|
+
}
|
|
56
|
+
const [column, operator, value] = args as [string, string, unknown]
|
|
57
|
+
if (operator === '=') return (row) => String(columnOf(row, column)) === String(value)
|
|
58
|
+
if (operator === 'in') {
|
|
59
|
+
const allowed = new Set((value as unknown[]).map(String))
|
|
60
|
+
return (row) => allowed.has(String(columnOf(row, column)))
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`[internal] unsupported where operator: ${operator}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const selectChain = () => {
|
|
66
|
+
const predicates: Matcher[] = []
|
|
67
|
+
let columns: string[] | null = null
|
|
68
|
+
let groupedBy: string | null = null
|
|
69
|
+
let limit: number | null = null
|
|
70
|
+
const chain: any = {
|
|
71
|
+
// The batch path's count probe selects an aggregate builder inside a column array and pairs
|
|
72
|
+
// it with groupBy(); the per-record probe passes the aggregate on its own. Neither carries a
|
|
73
|
+
// parseable column name, so both branches key off what else was supplied.
|
|
74
|
+
select: (selection: unknown) => {
|
|
75
|
+
columns = Array.isArray(selection) ? selection.filter((col) => typeof col === 'string').map(String) : null
|
|
76
|
+
return chain
|
|
77
|
+
},
|
|
78
|
+
where: (...args: unknown[]) => {
|
|
79
|
+
predicates.push(buildPredicate(args))
|
|
80
|
+
return chain
|
|
81
|
+
},
|
|
82
|
+
groupBy: (column: unknown) => {
|
|
83
|
+
groupedBy = String(column)
|
|
84
|
+
return chain
|
|
85
|
+
},
|
|
86
|
+
limit: (count: number) => {
|
|
87
|
+
limit = count
|
|
88
|
+
return chain
|
|
89
|
+
},
|
|
90
|
+
execute: async () => {
|
|
91
|
+
const matched = rows.filter((row) => predicates.every((matches) => matches(row)))
|
|
92
|
+
if (groupedBy) {
|
|
93
|
+
const counts = new Map<string, number>()
|
|
94
|
+
for (const row of matched) {
|
|
95
|
+
const groupKey = String(columnOf(row, groupedBy))
|
|
96
|
+
counts.set(groupKey, (counts.get(groupKey) ?? 0) + 1)
|
|
97
|
+
}
|
|
98
|
+
return Array.from(counts.entries()).map(([groupKey, count]) => ({
|
|
99
|
+
[groupedBy as string]: groupKey,
|
|
100
|
+
token_count: String(count),
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
if (!columns) return [{ token_count: String(matched.length) }]
|
|
104
|
+
const limited = limit === null ? matched : matched.slice(0, limit)
|
|
105
|
+
return limited.map((row) => Object.fromEntries(columns!.map((column) => [column, columnOf(row, column)])))
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
return chain
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const deleteChain = () => {
|
|
112
|
+
const predicates: Matcher[] = []
|
|
113
|
+
const chain: any = {
|
|
114
|
+
where: (...args: unknown[]) => {
|
|
115
|
+
predicates.push(buildPredicate(args))
|
|
116
|
+
return chain
|
|
117
|
+
},
|
|
118
|
+
execute: async () => {
|
|
119
|
+
const kept = rows.filter((row) => !predicates.every((matches) => matches(row)))
|
|
120
|
+
rows.length = 0
|
|
121
|
+
rows.push(...kept)
|
|
122
|
+
return []
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
return chain
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const insertChain = () => {
|
|
129
|
+
const chain: any = {
|
|
130
|
+
values: (values: any[]) => {
|
|
131
|
+
for (const value of values) {
|
|
132
|
+
rows.push({
|
|
133
|
+
id: nextId++,
|
|
134
|
+
entity_type: String(value.entity_type),
|
|
135
|
+
entity_id: String(value.entity_id),
|
|
136
|
+
organization_id: value.organization_id ?? null,
|
|
137
|
+
tenant_id: value.tenant_id ?? null,
|
|
138
|
+
field: String(value.field),
|
|
139
|
+
token_hash: String(value.token_hash),
|
|
140
|
+
token: value.token ?? null,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return chain
|
|
144
|
+
},
|
|
145
|
+
execute: async () => [],
|
|
146
|
+
}
|
|
147
|
+
return chain
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const executor = {
|
|
151
|
+
selectFrom: selectChain,
|
|
152
|
+
deleteFrom: deleteChain,
|
|
153
|
+
insertInto: insertChain,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const db = {
|
|
157
|
+
...executor,
|
|
158
|
+
transaction: () => ({
|
|
159
|
+
execute: async (callback: (trx: unknown) => Promise<void>) => callback(executor),
|
|
160
|
+
}),
|
|
161
|
+
} as unknown as Kysely<any>
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
db,
|
|
165
|
+
rows,
|
|
166
|
+
fields: () => Array.from(new Set(rows.map((row) => row.field))).sort(),
|
|
167
|
+
rowIds: () => rows.map((row) => row.id).sort((a, b) => a - b),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ENTITY_ID = 'sales:sales_order'
|
|
172
|
+
const SCOPE = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
173
|
+
|
|
174
|
+
const indexableRecord = (fields: Record<string, unknown>): IndexableRecord => ({
|
|
175
|
+
entityId: ENTITY_ID,
|
|
176
|
+
recordId: 'order-1',
|
|
177
|
+
tenantId: SCOPE.tenantId,
|
|
178
|
+
organizationId: SCOPE.organizationId,
|
|
179
|
+
fields,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// What the query engine hands back: base columns plus custom fields under the sanitized alias.
|
|
183
|
+
const QUERY_ENGINE_RECORD = { id: 'order-1', title: 'alpha widget', cf_priority: 'urgent' }
|
|
184
|
+
// What core stores in `entity_indexes.doc` for the same record.
|
|
185
|
+
const INDEX_DOCUMENT = { id: 'order-1', title: 'alpha widget', 'cf:priority': 'urgent' }
|
|
186
|
+
|
|
187
|
+
const writeCoreTokens = (db: Kysely<any>) =>
|
|
188
|
+
replaceSearchTokensForRecord(db, {
|
|
189
|
+
entityType: ENTITY_ID,
|
|
190
|
+
recordId: 'order-1',
|
|
191
|
+
tenantId: SCOPE.tenantId,
|
|
192
|
+
organizationId: SCOPE.organizationId,
|
|
193
|
+
doc: INDEX_DOCUMENT,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('TokenSearchStrategy writes custom fields under the spelling core uses', () => {
|
|
197
|
+
const previousEnv = {
|
|
198
|
+
enabled: process.env.OM_SEARCH_ENABLED,
|
|
199
|
+
partials: process.env.OM_SEARCH_ENABLE_PARTIAL,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
beforeAll(() => {
|
|
203
|
+
process.env.OM_SEARCH_ENABLED = 'true'
|
|
204
|
+
// Partials off keeps the token set small; the field naming is independent of token fan-out.
|
|
205
|
+
process.env.OM_SEARCH_ENABLE_PARTIAL = 'false'
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
afterAll(() => {
|
|
209
|
+
if (previousEnv.enabled === undefined) delete process.env.OM_SEARCH_ENABLED
|
|
210
|
+
else process.env.OM_SEARCH_ENABLED = previousEnv.enabled
|
|
211
|
+
if (previousEnv.partials === undefined) delete process.env.OM_SEARCH_ENABLE_PARTIAL
|
|
212
|
+
else process.env.OM_SEARCH_ENABLE_PARTIAL = previousEnv.partials
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('stores the aliased custom-field key as cf:<key>, leaving base columns alone', async () => {
|
|
216
|
+
const store = createSearchTokenStore()
|
|
217
|
+
const strategy = new TokenSearchStrategy(store.db)
|
|
218
|
+
|
|
219
|
+
await strategy.index(indexableRecord(QUERY_ENGINE_RECORD))
|
|
220
|
+
|
|
221
|
+
expect(store.rows.length).toBeGreaterThan(0)
|
|
222
|
+
expect(store.fields()).toEqual(['cf:priority', 'title'])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('normalizes the same way on the batch path', async () => {
|
|
226
|
+
const store = createSearchTokenStore()
|
|
227
|
+
const strategy = new TokenSearchStrategy(store.db)
|
|
228
|
+
|
|
229
|
+
await strategy.bulkIndex([indexableRecord(QUERY_ENGINE_RECORD)])
|
|
230
|
+
|
|
231
|
+
expect(store.rows.length).toBeGreaterThan(0)
|
|
232
|
+
expect(store.fields()).toEqual(['cf:priority', 'title'])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('keeps an explicit cf: value when a document carries both spellings of one field', async () => {
|
|
236
|
+
const store = createSearchTokenStore()
|
|
237
|
+
const strategy = new TokenSearchStrategy(store.db)
|
|
238
|
+
|
|
239
|
+
await strategy.index(indexableRecord({ 'cf:priority': 'urgent', cf_priority: 'stale' }))
|
|
240
|
+
|
|
241
|
+
expect(store.fields()).toEqual(['cf:priority'])
|
|
242
|
+
const stored = new Set(store.rows.map((row) => row.token_hash))
|
|
243
|
+
const urgentOnly = createSearchTokenStore()
|
|
244
|
+
await new TokenSearchStrategy(urgentOnly.db).index(indexableRecord({ 'cf:priority': 'urgent' }))
|
|
245
|
+
expect(stored).toEqual(new Set(urgentOnly.rows.map((row) => row.token_hash)))
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('indexes a record once when both writers run over it, rather than under two field names', async () => {
|
|
249
|
+
const store = createSearchTokenStore()
|
|
250
|
+
const strategy = new TokenSearchStrategy(store.db)
|
|
251
|
+
|
|
252
|
+
await writeCoreTokens(store.db)
|
|
253
|
+
const afterCore = store.rowIds()
|
|
254
|
+
expect(afterCore.length).toBeGreaterThan(0)
|
|
255
|
+
|
|
256
|
+
await strategy.index(indexableRecord(QUERY_ENGINE_RECORD))
|
|
257
|
+
|
|
258
|
+
expect(store.fields()).toEqual(['cf:priority', 'title'])
|
|
259
|
+
// The second writer found exactly the rows it wanted to write and left them in place: two
|
|
260
|
+
// consecutive indexes of an unchanged record write nothing on either path.
|
|
261
|
+
expect(store.rowIds()).toEqual(afterCore)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('leaves the record alone when the two writers alternate over it', async () => {
|
|
265
|
+
const store = createSearchTokenStore()
|
|
266
|
+
const strategy = new TokenSearchStrategy(store.db)
|
|
267
|
+
|
|
268
|
+
await strategy.index(indexableRecord(QUERY_ENGINE_RECORD))
|
|
269
|
+
const afterFirstWrite = store.rowIds()
|
|
270
|
+
expect(afterFirstWrite.length).toBeGreaterThan(0)
|
|
271
|
+
|
|
272
|
+
await writeCoreTokens(store.db)
|
|
273
|
+
await strategy.index(indexableRecord(QUERY_ENGINE_RECORD))
|
|
274
|
+
await writeCoreTokens(store.db)
|
|
275
|
+
|
|
276
|
+
expect(store.rowIds()).toEqual(afterFirstWrite)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -29,6 +29,52 @@ function normalizeOrganizationIds(options: SearchOptions): string[] | null {
|
|
|
29
29
|
))
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const CF_ALIAS_PREFIX = 'cf_'
|
|
33
|
+
const CF_CANONICAL_PREFIX = 'cf:'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rewrites the query engine's aliased custom-field keys back to the spelling `search_tokens` is
|
|
37
|
+
* meant to carry.
|
|
38
|
+
*
|
|
39
|
+
* The engine cannot label a column `cf:<key>` — `:` is not a valid SQL identifier — so it sanitizes
|
|
40
|
+
* the alias down to `cf_<key>`. Core's token writer builds from `entity_indexes.doc`, which keeps
|
|
41
|
+
* `cf:<key>`, and both writers replace a record's tokens by deleting only the `(entity_id, field)`
|
|
42
|
+
* pairs their own document carries. Under two spellings neither deletes the other's custom-field
|
|
43
|
+
* rows: every custom field is tokenized twice under names that carry the same hashes, while the
|
|
44
|
+
* base-field rows the two documents share are alternately deleted and re-inserted on every write.
|
|
45
|
+
*
|
|
46
|
+
* `cf:` is the side to converge on because it is the side that is read — the query engine's search
|
|
47
|
+
* predicate and every caller of `findEntityIdsBySearchTokens` ask for `cf:<key>`.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately scoped to the rows this strategy writes rather than applied to
|
|
50
|
+
* `IndexableRecord.fields` upstream: the same object is handed to the fulltext driver, and
|
|
51
|
+
* Meilisearch rejects an attribute name containing `:`.
|
|
52
|
+
*
|
|
53
|
+
* The reversal assumes word-character keys. The engine's alias sanitizer is
|
|
54
|
+
* `[^a-zA-Z0-9_] -> _` and a custom-field key is an unconstrained `z.string()`, so a key named
|
|
55
|
+
* `order-ref` arrives as `cf_order_ref` and is rewritten to `cf:order_ref` — a name nothing reads,
|
|
56
|
+
* which leaves that one field's double-write unfixed. Inverting the sanitizer would need the
|
|
57
|
+
* field-definition key list, which this strategy has not got.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeCustomFieldKeys(
|
|
60
|
+
fields: Record<string, unknown> | null | undefined,
|
|
61
|
+
): Record<string, unknown> | null | undefined {
|
|
62
|
+
if (!fields) return fields
|
|
63
|
+
const normalized: Record<string, unknown> = {}
|
|
64
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
65
|
+
if (!key.startsWith(CF_ALIAS_PREFIX)) {
|
|
66
|
+
normalized[key] = value
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const canonical = `${CF_CANONICAL_PREFIX}${key.slice(CF_ALIAS_PREFIX.length)}`
|
|
70
|
+
// A document carrying both spellings meant the explicit one; the alias is the sanitizer's
|
|
71
|
+
// output for the same field.
|
|
72
|
+
if (canonical in fields) continue
|
|
73
|
+
normalized[canonical] = value
|
|
74
|
+
}
|
|
75
|
+
return normalized
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
/**
|
|
33
79
|
* TokenSearchStrategy provides hash-based search using the existing search_tokens table.
|
|
34
80
|
* This strategy is always available and serves as a fallback when other strategies fail.
|
|
@@ -148,7 +194,7 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
148
194
|
recordId: record.recordId,
|
|
149
195
|
tenantId: record.tenantId,
|
|
150
196
|
organizationId: record.organizationId,
|
|
151
|
-
doc: record.fields,
|
|
197
|
+
doc: normalizeCustomFieldKeys(record.fields),
|
|
152
198
|
})
|
|
153
199
|
}
|
|
154
200
|
|
|
@@ -177,7 +223,7 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
177
223
|
recordId: record.recordId,
|
|
178
224
|
tenantId: record.tenantId,
|
|
179
225
|
organizationId: record.organizationId,
|
|
180
|
-
doc: record.fields as Record<string, unknown>,
|
|
226
|
+
doc: normalizeCustomFieldKeys(record.fields) as Record<string, unknown>,
|
|
181
227
|
}))
|
|
182
228
|
|
|
183
229
|
await replaceSearchTokensForBatch(this.db, payloads)
|