@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
|
-
|
|
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);
|