@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.7113.1.9d83dca10c",
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.7113.1.9d83dca10c",
131
- "@open-mercato/queue": "0.7.1-develop.7113.1.9d83dca10c",
132
- "@open-mercato/shared": "0.7.1-develop.7113.1.9d83dca10c"
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)