@open-mercato/core 0.8.1-develop.7294.1.0ef99fc518 → 0.8.1-develop.7295.1.d0e0e33014

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.8.1-develop.7294.1.0ef99fc518",
3
+ "version": "0.8.1-develop.7295.1.d0e0e33014",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -253,16 +253,16 @@
253
253
  "zod": "^4.4.3"
254
254
  },
255
255
  "peerDependencies": {
256
- "@open-mercato/ai-assistant": "0.8.1-develop.7294.1.0ef99fc518",
257
- "@open-mercato/shared": "0.8.1-develop.7294.1.0ef99fc518",
258
- "@open-mercato/ui": "0.8.1-develop.7294.1.0ef99fc518",
256
+ "@open-mercato/ai-assistant": "0.8.1-develop.7295.1.d0e0e33014",
257
+ "@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014",
258
+ "@open-mercato/ui": "0.8.1-develop.7295.1.d0e0e33014",
259
259
  "react": "^19.0.0",
260
260
  "react-dom": "^19.0.0"
261
261
  },
262
262
  "devDependencies": {
263
- "@open-mercato/ai-assistant": "0.8.1-develop.7294.1.0ef99fc518",
264
- "@open-mercato/shared": "0.8.1-develop.7294.1.0ef99fc518",
265
- "@open-mercato/ui": "0.8.1-develop.7294.1.0ef99fc518",
263
+ "@open-mercato/ai-assistant": "0.8.1-develop.7295.1.d0e0e33014",
264
+ "@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014",
265
+ "@open-mercato/ui": "0.8.1-develop.7295.1.d0e0e33014",
266
266
  "@testing-library/dom": "^10.4.1",
267
267
  "@testing-library/jest-dom": "^7.0.1",
268
268
  "@testing-library/react": "^16.3.3",
@@ -39,10 +39,12 @@ import {
39
39
  type SearchTokenProbeQueryBuilder,
40
40
  } from '@open-mercato/shared/lib/search/availability'
41
41
  import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
42
+ import { buildContainmentPatterns } from '@open-mercato/shared/lib/search/containment'
42
43
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from '@open-mercato/shared/lib/query/query-extension-runner'
43
44
  import { warnOnCiphertextLikeFallback } from '@open-mercato/shared/lib/query/ciphertext-search-warning'
44
45
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
45
46
  import { resolveListCountCap } from '@open-mercato/shared/lib/query/count-cap'
47
+ import { resolveCfDefIndexOrgCandidates } from '@open-mercato/shared/lib/crud/custom-field-definition-index'
46
48
  import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
47
49
  import { createLogger } from '@open-mercato/shared/lib/logger'
48
50
  import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
@@ -71,6 +73,9 @@ const CF_FILTER_SUPPORTED_OPS = new Set<FilterOp>([
71
73
  'eq', 'ne', 'in', 'nin', 'like', 'ilike', 'exists', 'gt', 'gte', 'lt', 'lte',
72
74
  ])
73
75
 
76
+ /** Custom field kinds stored numerically (`custom_field_values.value_int`/`value_float`) — sort numerically, not as text (#5674). */
77
+ const NUMERIC_CF_SORT_KINDS = new Set(['integer', 'float'])
78
+
74
79
  const DECRYPT_CONCURRENCY = 8
75
80
  const AUTO_REINDEX_DEBOUNCE_DEFAULT_MS = 30_000
76
81
  const AUTO_REINDEX_DEBOUNCE_MAX_SCOPES = 10_000
@@ -245,6 +250,7 @@ export class HybridQueryEngine implements QueryEngine {
245
250
  private coverageStatsTtlMs: number
246
251
  private customFieldDefsCache = new Map<string, { expiresAt: number; value: CustomFieldDefRow[] }>()
247
252
  private customFieldKeysTtlMs: number
253
+ private customFieldKindCache = new Map<string, { expiresAt: number; value: Map<string, string> }>()
248
254
  private columnCache = new Map<string, boolean>()
249
255
  private customEntityCache = new Map<string, boolean>()
250
256
  private debugVerbosity: boolean | null = null
@@ -568,6 +574,8 @@ export class HybridQueryEngine implements QueryEngine {
568
574
  searchConfig.useIlikeForNonEncryptedFields === true &&
569
575
  sourceSearchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
570
576
  ) {
577
+ // Gated on OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS (default false per #5383; set it
578
+ // to true to opt into the #5803 fix ahead of that follow-up).
571
579
  // `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
572
580
  // the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
573
581
  // `organizationId: null` is deliberate, not an omission: the service then unions in every
@@ -781,6 +789,18 @@ export class HybridQueryEngine implements QueryEngine {
781
789
  )
782
790
  const requiresPlaintextSort = encryptedSortFields.size > 0
783
791
 
792
+ const cfSortKeys = Array.from(new Set(
793
+ resolvedSorts.filter((sort) => sort.field.startsWith('cf:')).map((sort) => sort.field.slice(3))
794
+ ))
795
+ const cfSortKinds = cfSortKeys.length
796
+ ? await this.resolveCustomFieldSortKinds(
797
+ indexSources.map((source) => String(source.entityId)),
798
+ opts.tenantId ?? null,
799
+ resolveCfDefIndexOrgCandidates(opts.organizationIds, fallbackOrgId),
800
+ cfSortKeys,
801
+ )
802
+ : new Map<string, string>()
803
+
784
804
  // ────────────────────────────────────────────────────────────────
785
805
  // Build a reusable "applyQueryShape" function that applies every
786
806
  // WHERE/JOIN/scope to a fresh SelectQueryBuilder. We use this in
@@ -1188,7 +1208,22 @@ export class HybridQueryEngine implements QueryEngine {
1188
1208
  const textExpr = this.buildCfTextExprSql(fieldName, indexSources)
1189
1209
  if (textExpr) {
1190
1210
  const direction = sql.raw(coerceSortDirection(s.dir))
1191
- next = next.orderBy(sql`${textExpr} ${direction}`)
1211
+ const kind = cfSortKinds.get(fieldName.slice(3))
1212
+ let sortExpr: RawBuilder<unknown> = textExpr
1213
+ if (kind && NUMERIC_CF_SORT_KINDS.has(kind)) {
1214
+ const jsonExpr = this.buildCfJsonExprSql(fieldName, indexSources)
1215
+ // The index doc does not guarantee a numeric scalar for a
1216
+ // numeric-kind field (ciphertext on an encrypted field, a JSON
1217
+ // array from a multi-value/duplicated-row source, or a stale
1218
+ // value from before the kind changed) — casting unconditionally
1219
+ // turns one bad row into a 500 for the whole list. Only cast
1220
+ // when the doc value is actually a JSON number; anything else
1221
+ // sorts as NULL, same as an unset field (#5674 review).
1222
+ sortExpr = jsonExpr
1223
+ ? sql`CASE WHEN jsonb_typeof(${jsonExpr}) = 'number' THEN (${textExpr})::numeric END`
1224
+ : textExpr
1225
+ }
1226
+ next = next.orderBy(sql`${sortExpr} ${direction} NULLS LAST`)
1192
1227
  }
1193
1228
  } else {
1194
1229
  const baseField = resolveBaseColumn(fieldName)
@@ -1196,6 +1231,10 @@ export class HybridQueryEngine implements QueryEngine {
1196
1231
  next = next.orderBy(qualify(baseField), s.dir ?? SortDir.Asc)
1197
1232
  }
1198
1233
  }
1234
+ const lastSortField = resolvedSorts.length ? String(resolvedSorts[resolvedSorts.length - 1].field) : null
1235
+ if (resolvedSorts.length && lastSortField !== 'id') {
1236
+ next = next.orderBy(qualify('id'), SortDir.Asc)
1237
+ }
1199
1238
  return next
1200
1239
  }
1201
1240
 
@@ -1995,6 +2034,20 @@ export class HybridQueryEngine implements QueryEngine {
1995
2034
  ? sql<boolean>`false`
1996
2035
  : sql<boolean>`true`
1997
2036
  }
2037
+ // Reaching here with a resolved encryption map means this is a PLAINTEXT column the gate above
2038
+ // deliberately kept off the token path. Apply the declared containment per word so the token
2039
+ // path's word-order-independent matching survives the reroute (#5803 / TC-RESO-009).
2040
+ if (
2041
+ (filter.op === 'like' || filter.op === 'ilike') &&
2042
+ typeof filter.value === 'string' &&
2043
+ searchRuntime?.enabled &&
2044
+ searchRuntime.encryptedFields != null
2045
+ ) {
2046
+ const patterns = buildContainmentPatterns(filter.value)
2047
+ if (patterns.length > 1) {
2048
+ return eb.and(patterns.map((pattern) => eb(qualify(baseField), filter.op, pattern)))
2049
+ }
2050
+ }
1998
2051
  return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
1999
2052
  }
2000
2053
 
@@ -2277,6 +2330,64 @@ export class HybridQueryEngine implements QueryEngine {
2277
2330
  return result.slice()
2278
2331
  }
2279
2332
 
2333
+ /**
2334
+ * Resolve the declared `kind` for a set of `cf:` sort keys so `applySort` can
2335
+ * cast numeric kinds instead of ordering by jsonb text (#5674). A same-org
2336
+ * definition wins over a tenant-wide one, which wins over a global one, for
2337
+ * the same key — a same-key definition scoped to a *different* organization
2338
+ * in the tenant never gets to decide the kind (#5674 review).
2339
+ */
2340
+ private async resolveCustomFieldSortKinds(
2341
+ entityIds: string[],
2342
+ tenantId: string | null,
2343
+ organizationIds: string[],
2344
+ keys: string[]
2345
+ ): Promise<Map<string, string>> {
2346
+ if (!entityIds.length || !keys.length) return new Map()
2347
+ const sortedOrgIds = organizationIds.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
2348
+ const cacheKey = `${this.customFieldKeysCacheKey(entityIds, tenantId)}|org:${sortedOrgIds.join(',')}|${keys.slice().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(',')}`
2349
+ const now = Date.now()
2350
+ const cached = this.customFieldKindCache.get(cacheKey)
2351
+ if (cached && cached.expiresAt > now) return new Map(cached.value)
2352
+
2353
+ const db = this.getDb() as any
2354
+ const rows = await db
2355
+ .selectFrom('custom_field_defs')
2356
+ .select(['key', 'kind', 'organization_id', 'tenant_id'])
2357
+ .where('entity_id', 'in', entityIds)
2358
+ .where('key', 'in', keys)
2359
+ .where('is_active', '=', true)
2360
+ .where((eb: any) => eb.or([
2361
+ eb('tenant_id', '=', tenantId),
2362
+ eb('tenant_id', 'is', null),
2363
+ ]))
2364
+ .where((eb: any) => sortedOrgIds.length
2365
+ ? eb.or([
2366
+ eb('organization_id', 'is', null),
2367
+ eb('organization_id', 'in', sortedOrgIds),
2368
+ ])
2369
+ : eb('organization_id', 'is', null))
2370
+ .execute() as Array<{ key: unknown; kind: unknown; organization_id: unknown; tenant_id: unknown }>
2371
+
2372
+ const winners = new Map<string, { kind: string; specificity: number }>()
2373
+ for (const row of rows) {
2374
+ const key = typeof row.key === 'string' ? row.key : String(row.key)
2375
+ const kind = typeof row.kind === 'string' ? row.kind : null
2376
+ if (!kind) continue
2377
+ // Org-scoped beats tenant-wide beats global for the same key; rows from
2378
+ // an unrelated org were already excluded by the WHERE clause above.
2379
+ const specificity = row.organization_id != null ? 2 : row.tenant_id != null ? 1 : 0
2380
+ const existing = winners.get(key)
2381
+ if (!existing || specificity >= existing.specificity) winners.set(key, { kind, specificity })
2382
+ }
2383
+ const result = new Map<string, string>()
2384
+ for (const [key, entry] of winners) result.set(key, entry.kind)
2385
+ if (this.customFieldKeysTtlMs > 0) {
2386
+ this.customFieldKindCache.set(cacheKey, { expiresAt: now + this.customFieldKeysTtlMs, value: new Map(result) })
2387
+ }
2388
+ return result
2389
+ }
2390
+
2280
2391
  private async resolveAvailableCustomFieldKeys(entityIds: string[], tenantId: string | null): Promise<string[]> {
2281
2392
  const defs = await this.resolveCustomFieldDefs(entityIds, tenantId)
2282
2393
  return Array.from(new Set(defs.map((def) => def.key)))
@@ -2735,6 +2846,21 @@ export class HybridQueryEngine implements QueryEngine {
2735
2846
  return q
2736
2847
  }
2737
2848
  const col: any = column
2849
+ // A PLAINTEXT column the gate kept off the token path: AND one containment predicate per word,
2850
+ // which is what the token subquery matched (#5803 / TC-RESO-009). Chained `where`s ARE the AND.
2851
+ if (
2852
+ (filter.op === 'like' || filter.op === 'ilike') &&
2853
+ typeof filter.value === 'string' &&
2854
+ search?.enabled &&
2855
+ search.encryptedFields != null
2856
+ ) {
2857
+ const patterns = buildContainmentPatterns(filter.value)
2858
+ if (patterns.length > 1) {
2859
+ let next = q
2860
+ for (const pattern of patterns) next = next.where(col, filter.op, pattern as any)
2861
+ return next
2862
+ }
2863
+ }
2738
2864
  switch (filter.op) {
2739
2865
  case 'eq':
2740
2866
  return filter.value === null