@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.
@@ -27,10 +27,12 @@ import {
27
27
  hasSearchFilter
28
28
  } from "@open-mercato/shared/lib/search/availability";
29
29
  import { tokenizeText } from "@open-mercato/shared/lib/search/tokenize";
30
+ import { buildContainmentPatterns } from "@open-mercato/shared/lib/search/containment";
30
31
  import { runBeforeQueryPipeline, runAfterQueryPipeline } from "@open-mercato/shared/lib/query/query-extension-runner";
31
32
  import { warnOnCiphertextLikeFallback } from "@open-mercato/shared/lib/query/ciphertext-search-warning";
32
33
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "@open-mercato/shared/lib/query/encrypted-sort";
33
34
  import { resolveListCountCap } from "@open-mercato/shared/lib/query/count-cap";
35
+ import { resolveCfDefIndexOrgCandidates } from "@open-mercato/shared/lib/crud/custom-field-definition-index";
34
36
  import { mapWithConcurrency } from "@open-mercato/shared/lib/query/bounded-decrypt";
35
37
  import { createLogger } from "@open-mercato/shared/lib/logger";
36
38
  import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
@@ -49,6 +51,7 @@ const CF_FILTER_SUPPORTED_OPS = /* @__PURE__ */ new Set([
49
51
  "lt",
50
52
  "lte"
51
53
  ]);
54
+ const NUMERIC_CF_SORT_KINDS = /* @__PURE__ */ new Set(["integer", "float"]);
52
55
  const DECRYPT_CONCURRENCY = 8;
53
56
  const AUTO_REINDEX_DEBOUNCE_DEFAULT_MS = 3e4;
54
57
  const AUTO_REINDEX_DEBOUNCE_MAX_SCOPES = 1e4;
@@ -145,6 +148,7 @@ class HybridQueryEngine {
145
148
  this.vectorServiceResolver = vectorServiceResolver;
146
149
  this.encryptionResolver = encryptionResolver;
147
150
  this.customFieldDefsCache = /* @__PURE__ */ new Map();
151
+ this.customFieldKindCache = /* @__PURE__ */ new Map();
148
152
  this.columnCache = /* @__PURE__ */ new Map();
149
153
  this.customEntityCache = /* @__PURE__ */ new Map();
150
154
  this.debugVerbosity = null;
@@ -590,6 +594,15 @@ class HybridQueryEngine {
590
594
  fallbackOrgId
591
595
  );
592
596
  const requiresPlaintextSort = encryptedSortFields.size > 0;
597
+ const cfSortKeys = Array.from(new Set(
598
+ resolvedSorts.filter((sort) => sort.field.startsWith("cf:")).map((sort) => sort.field.slice(3))
599
+ ));
600
+ const cfSortKinds = cfSortKeys.length ? await this.resolveCustomFieldSortKinds(
601
+ indexSources.map((source) => String(source.entityId)),
602
+ opts.tenantId ?? null,
603
+ resolveCfDefIndexOrgCandidates(opts.organizationIds, fallbackOrgId),
604
+ cfSortKeys
605
+ ) : /* @__PURE__ */ new Map();
593
606
  const applyBaseScope = (q) => {
594
607
  let next = q;
595
608
  if (orgScope && hasOrganizationColumn) {
@@ -935,7 +948,13 @@ class HybridQueryEngine {
935
948
  const textExpr = this.buildCfTextExprSql(fieldName, indexSources);
936
949
  if (textExpr) {
937
950
  const direction = sql.raw(coerceSortDirection(s.dir));
938
- next = next.orderBy(sql`${textExpr} ${direction}`);
951
+ const kind = cfSortKinds.get(fieldName.slice(3));
952
+ let sortExpr = textExpr;
953
+ if (kind && NUMERIC_CF_SORT_KINDS.has(kind)) {
954
+ const jsonExpr = this.buildCfJsonExprSql(fieldName, indexSources);
955
+ sortExpr = jsonExpr ? sql`CASE WHEN jsonb_typeof(${jsonExpr}) = 'number' THEN (${textExpr})::numeric END` : textExpr;
956
+ }
957
+ next = next.orderBy(sql`${sortExpr} ${direction} NULLS LAST`);
939
958
  }
940
959
  } else {
941
960
  const baseField = resolveBaseColumn(fieldName);
@@ -943,6 +962,10 @@ class HybridQueryEngine {
943
962
  next = next.orderBy(qualify(baseField), s.dir ?? SortDir.Asc);
944
963
  }
945
964
  }
965
+ const lastSortField = resolvedSorts.length ? String(resolvedSorts[resolvedSorts.length - 1].field) : null;
966
+ if (resolvedSorts.length && lastSortField !== "id") {
967
+ next = next.orderBy(qualify("id"), SortDir.Asc);
968
+ }
946
969
  return next;
947
970
  };
948
971
  const page = opts.page?.page ?? 1;
@@ -1554,6 +1577,12 @@ class HybridQueryEngine {
1554
1577
  }
1555
1578
  return searchRuntime?.encryptedFields != null && isEncryptedLikeField(searchRuntime.encryptedFields, fieldName) ? sql`false` : sql`true`;
1556
1579
  }
1580
+ if ((filter.op === "like" || filter.op === "ilike") && typeof filter.value === "string" && searchRuntime?.enabled && searchRuntime.encryptedFields != null) {
1581
+ const patterns = buildContainmentPatterns(filter.value);
1582
+ if (patterns.length > 1) {
1583
+ return eb.and(patterns.map((pattern) => eb(qualify(baseField), filter.op, pattern)));
1584
+ }
1585
+ }
1557
1586
  return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value);
1558
1587
  }
1559
1588
  buildColumnFilterExpression(eb, column, op, value) {
@@ -1789,6 +1818,44 @@ class HybridQueryEngine {
1789
1818
  }
1790
1819
  return result.slice();
1791
1820
  }
1821
+ /**
1822
+ * Resolve the declared `kind` for a set of `cf:` sort keys so `applySort` can
1823
+ * cast numeric kinds instead of ordering by jsonb text (#5674). A same-org
1824
+ * definition wins over a tenant-wide one, which wins over a global one, for
1825
+ * the same key — a same-key definition scoped to a *different* organization
1826
+ * in the tenant never gets to decide the kind (#5674 review).
1827
+ */
1828
+ async resolveCustomFieldSortKinds(entityIds, tenantId, organizationIds, keys) {
1829
+ if (!entityIds.length || !keys.length) return /* @__PURE__ */ new Map();
1830
+ const sortedOrgIds = organizationIds.slice().sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1831
+ const cacheKey = `${this.customFieldKeysCacheKey(entityIds, tenantId)}|org:${sortedOrgIds.join(",")}|${keys.slice().sort((a, b) => a < b ? -1 : a > b ? 1 : 0).join(",")}`;
1832
+ const now = Date.now();
1833
+ const cached = this.customFieldKindCache.get(cacheKey);
1834
+ if (cached && cached.expiresAt > now) return new Map(cached.value);
1835
+ const db = this.getDb();
1836
+ const rows = await db.selectFrom("custom_field_defs").select(["key", "kind", "organization_id", "tenant_id"]).where("entity_id", "in", entityIds).where("key", "in", keys).where("is_active", "=", true).where((eb) => eb.or([
1837
+ eb("tenant_id", "=", tenantId),
1838
+ eb("tenant_id", "is", null)
1839
+ ])).where((eb) => sortedOrgIds.length ? eb.or([
1840
+ eb("organization_id", "is", null),
1841
+ eb("organization_id", "in", sortedOrgIds)
1842
+ ]) : eb("organization_id", "is", null)).execute();
1843
+ const winners = /* @__PURE__ */ new Map();
1844
+ for (const row of rows) {
1845
+ const key = typeof row.key === "string" ? row.key : String(row.key);
1846
+ const kind = typeof row.kind === "string" ? row.kind : null;
1847
+ if (!kind) continue;
1848
+ const specificity = row.organization_id != null ? 2 : row.tenant_id != null ? 1 : 0;
1849
+ const existing = winners.get(key);
1850
+ if (!existing || specificity >= existing.specificity) winners.set(key, { kind, specificity });
1851
+ }
1852
+ const result = /* @__PURE__ */ new Map();
1853
+ for (const [key, entry] of winners) result.set(key, entry.kind);
1854
+ if (this.customFieldKeysTtlMs > 0) {
1855
+ this.customFieldKindCache.set(cacheKey, { expiresAt: now + this.customFieldKeysTtlMs, value: new Map(result) });
1856
+ }
1857
+ return result;
1858
+ }
1792
1859
  async resolveAvailableCustomFieldKeys(entityIds, tenantId) {
1793
1860
  const defs = await this.resolveCustomFieldDefs(entityIds, tenantId);
1794
1861
  return Array.from(new Set(defs.map((def) => def.key)));
@@ -2200,6 +2267,14 @@ class HybridQueryEngine {
2200
2267
  return q;
2201
2268
  }
2202
2269
  const col = column;
2270
+ if ((filter.op === "like" || filter.op === "ilike") && typeof filter.value === "string" && search?.enabled && search.encryptedFields != null) {
2271
+ const patterns = buildContainmentPatterns(filter.value);
2272
+ if (patterns.length > 1) {
2273
+ let next = q;
2274
+ for (const pattern of patterns) next = next.where(col, filter.op, pattern);
2275
+ return next;
2276
+ }
2277
+ }
2203
2278
  switch (filter.op) {
2204
2279
  case "eq":
2205
2280
  return filter.value === null ? q.where(col, "is", null) : q.where(col, "=", filter.value);