@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
package/dist/lib/query/engine.js
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
isSearchFilterOp
|
|
12
12
|
} from "../search/availability.js";
|
|
13
13
|
import { tokenizeText } from "../search/tokenize.js";
|
|
14
|
+
import { fieldNameCandidates } from "./encrypted-sort.js";
|
|
15
|
+
import { isTenantDataEncryptionEnabled } from "../encryption/toggles.js";
|
|
14
16
|
import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
|
|
15
17
|
import {
|
|
16
18
|
buildCustomFieldDefinitionIndexFromRows,
|
|
@@ -18,11 +20,61 @@ import {
|
|
|
18
20
|
} from "../crud/custom-field-definition-index.js";
|
|
19
21
|
import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
|
|
20
22
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
|
|
23
|
+
import { resolveListCountCap } from "./count-cap.js";
|
|
21
24
|
import { mapWithConcurrency } from "./bounded-decrypt.js";
|
|
25
|
+
import { parseNumberWithDefault } from "../number.js";
|
|
22
26
|
import { createLogger } from "../logger/index.js";
|
|
23
27
|
const logger = createLogger("shared").child({ component: "query" });
|
|
24
28
|
const DECRYPT_CONCURRENCY = 8;
|
|
25
29
|
const entityTableCache = /* @__PURE__ */ new Map();
|
|
30
|
+
function isEncryptedLikeField(encrypted, field) {
|
|
31
|
+
return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate));
|
|
32
|
+
}
|
|
33
|
+
const ENCRYPTED_LIKE_FIELDS_TTL_MS = 6e4;
|
|
34
|
+
const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500;
|
|
35
|
+
const encryptedLikeFieldsCache = /* @__PURE__ */ new Map();
|
|
36
|
+
async function resolveEncryptedLikeFieldSet(read, entity, tenantId) {
|
|
37
|
+
const key = `${entity}|${tenantId ?? ""}`;
|
|
38
|
+
const hit = encryptedLikeFieldsCache.get(key);
|
|
39
|
+
if (hit && Date.now() - hit.at < ENCRYPTED_LIKE_FIELDS_TTL_MS) return hit.fields;
|
|
40
|
+
const names = await read();
|
|
41
|
+
const fields = /* @__PURE__ */ new Set();
|
|
42
|
+
for (const name of names ?? []) {
|
|
43
|
+
for (const candidate of fieldNameCandidates(String(name))) fields.add(candidate);
|
|
44
|
+
}
|
|
45
|
+
if (encryptedLikeFieldsCache.size >= ENCRYPTED_LIKE_FIELDS_CACHE_CAP) encryptedLikeFieldsCache.clear();
|
|
46
|
+
encryptedLikeFieldsCache.set(key, { at: Date.now(), fields });
|
|
47
|
+
return fields;
|
|
48
|
+
}
|
|
49
|
+
function clearEncryptedLikeFieldsCache() {
|
|
50
|
+
encryptedLikeFieldsCache.clear();
|
|
51
|
+
}
|
|
52
|
+
const COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS = 3e5;
|
|
53
|
+
const COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES = 1e4;
|
|
54
|
+
const columnExistsCache = /* @__PURE__ */ new Map();
|
|
55
|
+
function resolveColumnExistsCacheTtlMs() {
|
|
56
|
+
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS, COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 });
|
|
57
|
+
}
|
|
58
|
+
function resolveColumnExistsCacheMaxEntries() {
|
|
59
|
+
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES, COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES, { integer: true, min: 1 });
|
|
60
|
+
}
|
|
61
|
+
function storeColumnExists(key, value, ttlMs) {
|
|
62
|
+
const maxEntries = resolveColumnExistsCacheMaxEntries();
|
|
63
|
+
if (columnExistsCache.size >= maxEntries) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [entryKey, entry] of columnExistsCache) {
|
|
66
|
+
if (entry.expiresAt <= now) columnExistsCache.delete(entryKey);
|
|
67
|
+
}
|
|
68
|
+
if (columnExistsCache.size >= maxEntries) columnExistsCache.clear();
|
|
69
|
+
}
|
|
70
|
+
columnExistsCache.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
71
|
+
}
|
|
72
|
+
function clearColumnExistsCache() {
|
|
73
|
+
columnExistsCache.clear();
|
|
74
|
+
}
|
|
75
|
+
function columnExistsCacheSize() {
|
|
76
|
+
return columnExistsCache.size;
|
|
77
|
+
}
|
|
26
78
|
const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/;
|
|
27
79
|
const isValidEntityIdShape = (value) => ENTITY_ID_PATTERN.test(value);
|
|
28
80
|
const pluralizeBaseName = (name) => {
|
|
@@ -139,7 +191,6 @@ class BasicQueryEngine {
|
|
|
139
191
|
this.em = em;
|
|
140
192
|
this.getDbFn = getDbFn;
|
|
141
193
|
this.resolveEncryptionService = resolveEncryptionService;
|
|
142
|
-
this.columnCache = /* @__PURE__ */ new Map();
|
|
143
194
|
this.searchAliasSeq = 0;
|
|
144
195
|
this.searchAvailabilityInstance = null;
|
|
145
196
|
}
|
|
@@ -232,6 +283,35 @@ class BasicQueryEngine {
|
|
|
232
283
|
const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled();
|
|
233
284
|
const hasSearchTokens = searchEnabled && searchFilters.length ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
|
|
234
285
|
const searchActive = searchEnabled && hasSearchTokens;
|
|
286
|
+
let encryptedLikeFields = null;
|
|
287
|
+
if (searchActive && searchConfig.useIlikeForNonEncryptedFields === true && searchFilters.some((filter) => !String(filter.field).startsWith("cf:"))) {
|
|
288
|
+
try {
|
|
289
|
+
const service = this.getEncryptionService();
|
|
290
|
+
const readEncryptedFieldNames = service?.getEncryptedFieldNames?.bind(service);
|
|
291
|
+
if (readEncryptedFieldNames) {
|
|
292
|
+
encryptedLikeFields = await resolveEncryptedLikeFieldSet(
|
|
293
|
+
() => readEncryptedFieldNames(
|
|
294
|
+
String(entity),
|
|
295
|
+
opts.tenantId ?? null,
|
|
296
|
+
null,
|
|
297
|
+
{ ignoreRuntimeHealth: true }
|
|
298
|
+
),
|
|
299
|
+
String(entity),
|
|
300
|
+
opts.tenantId ?? null
|
|
301
|
+
);
|
|
302
|
+
} else if (isTenantDataEncryptionEnabled()) {
|
|
303
|
+
encryptedLikeFields = null;
|
|
304
|
+
} else {
|
|
305
|
+
encryptedLikeFields = /* @__PURE__ */ new Set();
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
logger.warn("search: encrypted-field map unavailable; keeping the token rewrite for all columns", {
|
|
309
|
+
entity: String(entity),
|
|
310
|
+
error: err instanceof Error ? err.message : String(err)
|
|
311
|
+
});
|
|
312
|
+
encryptedLikeFields = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
235
315
|
if (searchFilters.length) {
|
|
236
316
|
const fields = searchFilters.map((filter) => String(filter.field));
|
|
237
317
|
this.logSearchDebug("search:init", {
|
|
@@ -293,7 +373,12 @@ class BasicQueryEngine {
|
|
|
293
373
|
}
|
|
294
374
|
const recordIdColumn = qualify("id");
|
|
295
375
|
const applyFilterOp = (builder, column, op, value, fieldName) => {
|
|
296
|
-
if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string"
|
|
376
|
+
if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string" && // Plaintext columns keep exact SQL ILIKE -- see the encryptedLikeFields note above. cf:*
|
|
377
|
+
// filters never reach this path (they are applied by the custom-field branches), so this
|
|
378
|
+
// gate only decides base columns. Membership is tested across name-shape candidates --
|
|
379
|
+
// encryption maps may declare `displayName` while the filter carries the column name
|
|
380
|
+
// `display_name`, and a raw comparison would misread that ciphertext column as plaintext.
|
|
381
|
+
(encryptedLikeFields === null || isEncryptedLikeField(encryptedLikeFields, fieldName))) {
|
|
297
382
|
const tokens = tokenizeText(String(value), searchConfig);
|
|
298
383
|
const hashes = tokens.hashes;
|
|
299
384
|
if (hashes.length) {
|
|
@@ -382,6 +467,7 @@ class BasicQueryEngine {
|
|
|
382
467
|
const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
383
468
|
const buildQuery = async (projection) => {
|
|
384
469
|
const isSortKeysProjection = projection === "sortKeys";
|
|
470
|
+
const isCountProjection = projection === "count";
|
|
385
471
|
let q = db.selectFrom(table);
|
|
386
472
|
if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
|
|
387
473
|
q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
|
|
@@ -455,7 +541,8 @@ class BasicQueryEngine {
|
|
|
455
541
|
applyJoinFilterOp,
|
|
456
542
|
columnExists: (tbl, column) => this.columnExists(tbl, column)
|
|
457
543
|
});
|
|
458
|
-
if (
|
|
544
|
+
if (isCountProjection) {
|
|
545
|
+
} else if (isSortKeysProjection) {
|
|
459
546
|
q = q.select(sql.ref(qualify("id")).as("id"));
|
|
460
547
|
if (await this.columnExists(table, "tenant_id")) {
|
|
461
548
|
q = q.select(sql.ref(qualify("tenant_id")).as("tenant_id"));
|
|
@@ -477,18 +564,18 @@ class BasicQueryEngine {
|
|
|
477
564
|
} else {
|
|
478
565
|
q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
|
|
479
566
|
}
|
|
480
|
-
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
|
|
567
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection);
|
|
481
568
|
q = cfSourcesResult.builder;
|
|
482
569
|
const cfSources = cfSourcesResult.sources;
|
|
483
570
|
const entityIdToSource = /* @__PURE__ */ new Map();
|
|
484
571
|
for (const source of cfSources) {
|
|
485
572
|
entityIdToSource.set(String(source.entityId), source);
|
|
486
573
|
}
|
|
487
|
-
const requestedCustomFieldKeys =
|
|
574
|
+
const requestedCustomFieldKeys = projection === "full" && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
|
|
488
575
|
const cfKeys = /* @__PURE__ */ new Set();
|
|
489
576
|
const keySource = /* @__PURE__ */ new Map();
|
|
490
577
|
let resolvedCustomFieldDefinitions2;
|
|
491
|
-
if (
|
|
578
|
+
if (projection === "full") {
|
|
492
579
|
for (const f of opts.fields || []) {
|
|
493
580
|
if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
|
|
494
581
|
}
|
|
@@ -496,7 +583,7 @@ class BasicQueryEngine {
|
|
|
496
583
|
for (const f of cfFilters) {
|
|
497
584
|
if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
|
|
498
585
|
}
|
|
499
|
-
if (
|
|
586
|
+
if (projection === "full" && opts.includeCustomFields === true) {
|
|
500
587
|
if (entityIdToSource.size > 0) {
|
|
501
588
|
const entityIdList = Array.from(entityIdToSource.keys());
|
|
502
589
|
const entityOrder = /* @__PURE__ */ new Map();
|
|
@@ -595,6 +682,7 @@ class BasicQueryEngine {
|
|
|
595
682
|
for (const key of cfKeys) {
|
|
596
683
|
const source = keySource.get(key);
|
|
597
684
|
if (!source) continue;
|
|
685
|
+
if (isCountProjection) continue;
|
|
598
686
|
const entityIdForKey = source.entityId;
|
|
599
687
|
const recordIdExpr = source.recordIdExpr;
|
|
600
688
|
const sourceAliasSafe = sanitize(source.alias || "src");
|
|
@@ -642,8 +730,10 @@ class BasicQueryEngine {
|
|
|
642
730
|
for (const f of regularCfFilters) {
|
|
643
731
|
if (!f.field.startsWith("cf:")) continue;
|
|
644
732
|
const key = f.field.slice(3);
|
|
733
|
+
const filterSource = keySource.get(key);
|
|
734
|
+
if (!filterSource) continue;
|
|
645
735
|
const expr = cfValueExprByKey[key];
|
|
646
|
-
if (!expr) continue;
|
|
736
|
+
if (!isCountProjection && !expr) continue;
|
|
647
737
|
if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
|
|
648
738
|
const tokens = tokenizeText(String(f.value), searchConfig);
|
|
649
739
|
const hashes = tokens.hashes;
|
|
@@ -678,15 +768,39 @@ class BasicQueryEngine {
|
|
|
678
768
|
});
|
|
679
769
|
}
|
|
680
770
|
}
|
|
771
|
+
if (isCountProjection) {
|
|
772
|
+
q = this.applyCfValueExistsFilter(q, {
|
|
773
|
+
source: filterSource,
|
|
774
|
+
qualify,
|
|
775
|
+
tenantId: tenantId ?? null,
|
|
776
|
+
key,
|
|
777
|
+
op: f.op,
|
|
778
|
+
value: f.value
|
|
779
|
+
});
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
681
782
|
q = this.applyColumnOp(q, expr, f.op, f.value);
|
|
682
783
|
}
|
|
683
|
-
const
|
|
784
|
+
const cfLeafApplicable = (key) => isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key]);
|
|
785
|
+
const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || cfLeafApplicable(rf.key))).filter((group) => group.length > 0);
|
|
684
786
|
if (applicableGroupFilters.length > 0) {
|
|
685
787
|
q = q.where((eb) => {
|
|
686
788
|
const disjuncts = applicableGroupFilters.map((group) => {
|
|
687
789
|
const parts = group.map((rf) => {
|
|
688
790
|
if (rf.kind === "column") return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value);
|
|
689
|
-
if (rf.kind === "cf")
|
|
791
|
+
if (rf.kind === "cf") {
|
|
792
|
+
if (isCountProjection) {
|
|
793
|
+
return this.buildCfValueExistsExpression(eb, {
|
|
794
|
+
source: keySource.get(rf.key),
|
|
795
|
+
qualify,
|
|
796
|
+
tenantId: tenantId ?? null,
|
|
797
|
+
key: rf.key,
|
|
798
|
+
op: rf.op,
|
|
799
|
+
value: rf.value
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
|
|
803
|
+
}
|
|
690
804
|
return this.buildIndexDocOpExpression(eb, {
|
|
691
805
|
entity: String(entity),
|
|
692
806
|
field: rf.field,
|
|
@@ -703,7 +817,7 @@ class BasicQueryEngine {
|
|
|
703
817
|
return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts);
|
|
704
818
|
});
|
|
705
819
|
}
|
|
706
|
-
if (opts.includeExtensions) {
|
|
820
|
+
if (opts.includeExtensions && !isCountProjection) {
|
|
707
821
|
const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
|
|
708
822
|
const allMods = getModules();
|
|
709
823
|
const allExts = allMods.flatMap((m) => m.entityExtensions || []);
|
|
@@ -720,7 +834,7 @@ class BasicQueryEngine {
|
|
|
720
834
|
);
|
|
721
835
|
}
|
|
722
836
|
}
|
|
723
|
-
for (const s of resolvedSorts) {
|
|
837
|
+
for (const s of isCountProjection ? [] : resolvedSorts) {
|
|
724
838
|
if (s.field.startsWith("cf:")) {
|
|
725
839
|
const key = s.field.slice(3);
|
|
726
840
|
const alias = sanitize(`cf:${key}`);
|
|
@@ -736,7 +850,7 @@ class BasicQueryEngine {
|
|
|
736
850
|
if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
|
|
737
851
|
}
|
|
738
852
|
}
|
|
739
|
-
const hasJoinedAggregates2 = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
|
|
853
|
+
const hasJoinedAggregates2 = !isCountProjection && (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0);
|
|
740
854
|
if (hasJoinedAggregates2) {
|
|
741
855
|
q = q.groupBy(`${table}.id`);
|
|
742
856
|
}
|
|
@@ -751,11 +865,24 @@ class BasicQueryEngine {
|
|
|
751
865
|
cfMultiAliasByAlias,
|
|
752
866
|
resolvedCustomFieldDefinitions
|
|
753
867
|
} = await buildQuery("full");
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
868
|
+
const countCap = resolveListCountCap();
|
|
869
|
+
const { builder: countShape } = await buildQuery("count");
|
|
870
|
+
let total;
|
|
871
|
+
let listCountCapWarning;
|
|
872
|
+
if (countCap !== null) {
|
|
873
|
+
const probe = countShape.select(sql`1`.as("one")).limit(countCap + 1);
|
|
874
|
+
const countRow = await db.selectFrom(probe.as("om_count_probe")).select(sql`count(*)`.as("count")).executeTakeFirst();
|
|
875
|
+
const probed = Number(countRow?.count ?? 0);
|
|
876
|
+
if (probed > countCap) {
|
|
877
|
+
total = countCap;
|
|
878
|
+
listCountCapWarning = { entity, cap: countCap };
|
|
879
|
+
} else {
|
|
880
|
+
total = probed;
|
|
881
|
+
}
|
|
882
|
+
} else {
|
|
883
|
+
const countRow = await countShape.select(sql`count(*)`.as("count")).executeTakeFirst();
|
|
884
|
+
total = Number(countRow?.count ?? 0);
|
|
885
|
+
}
|
|
759
886
|
const svc = encryptionService;
|
|
760
887
|
const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
|
|
761
888
|
const decryptRow = async (item) => {
|
|
@@ -804,13 +931,15 @@ class BasicQueryEngine {
|
|
|
804
931
|
const cap = resolveEncryptedSortMaxRows();
|
|
805
932
|
let qSort = (await buildQuery("sortKeys")).builder;
|
|
806
933
|
if (cap !== null) {
|
|
807
|
-
qSort = qSort.limit(cap).orderBy(qualify("id"), "asc");
|
|
934
|
+
qSort = qSort.limit(cap + 1).orderBy(qualify("id"), "asc");
|
|
808
935
|
}
|
|
809
|
-
const
|
|
936
|
+
const candidateRowsRaw = await qSort.execute();
|
|
937
|
+
const sortTruncated = cap !== null && candidateRowsRaw.length > cap;
|
|
938
|
+
const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw;
|
|
810
939
|
const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
|
|
811
940
|
const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
|
|
812
941
|
const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
|
|
813
|
-
if (cap !== null
|
|
942
|
+
if (sortTruncated && cap !== null) {
|
|
814
943
|
encryptedSortRowCapWarning = {
|
|
815
944
|
entity,
|
|
816
945
|
sortFields: resolvedSorts.map((s) => s.field),
|
|
@@ -834,8 +963,10 @@ class BasicQueryEngine {
|
|
|
834
963
|
pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
|
|
835
964
|
}
|
|
836
965
|
let queryResult = { items: pagedItems, page, pageSize, total };
|
|
837
|
-
if (encryptedSortRowCapWarning) {
|
|
838
|
-
const meta = {
|
|
966
|
+
if (encryptedSortRowCapWarning || listCountCapWarning) {
|
|
967
|
+
const meta = {};
|
|
968
|
+
if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning;
|
|
969
|
+
if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning;
|
|
839
970
|
queryResult.meta = meta;
|
|
840
971
|
}
|
|
841
972
|
if (ext && extensionCtx) {
|
|
@@ -880,6 +1011,101 @@ class BasicQueryEngine {
|
|
|
880
1011
|
return builder;
|
|
881
1012
|
}
|
|
882
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Apply a `cf:*` filter as a correlated EXISTS semi-join over
|
|
1016
|
+
* `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
|
|
1017
|
+
* the count shape's equivalent of the projection path's leftJoin + WHERE.
|
|
1018
|
+
* A semi-join returns each base row at most once, so the count query needs
|
|
1019
|
+
* no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
|
|
1020
|
+
*
|
|
1021
|
+
* Predicates satisfied by the *absence* of a value row (`eq null`,
|
|
1022
|
+
* `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
|
|
1023
|
+
* matching the leftJoin form where a missing row yields a NULL expression.
|
|
1024
|
+
*/
|
|
1025
|
+
applyCfValueExistsFilter(q, opts) {
|
|
1026
|
+
return q.where((eb) => this.buildCfValueExistsExpression(eb, opts));
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
|
|
1030
|
+
* inside an OR group can compile to an EXISTS predicate on the count shape
|
|
1031
|
+
* instead of being dropped for lacking a `cfValueExprByKey` entry.
|
|
1032
|
+
*/
|
|
1033
|
+
buildCfValueExistsExpression(eb, opts) {
|
|
1034
|
+
const { source, qualify, tenantId, key, op, value } = opts;
|
|
1035
|
+
const seq = this.searchAliasSeq++;
|
|
1036
|
+
const valAlias = `cfev_${seq}`;
|
|
1037
|
+
const defAlias = `cfed_${seq}`;
|
|
1038
|
+
const srcAlias = `cfes_${seq}`;
|
|
1039
|
+
const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
1040
|
+
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
1041
|
+
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
1042
|
+
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
1043
|
+
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
1044
|
+
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
1045
|
+
END`;
|
|
1046
|
+
const buildSub = (eb2) => {
|
|
1047
|
+
let sub = eb2.selectFrom(`custom_field_values as ${valAlias}`).select(sql`1`.as("one")).leftJoin(`custom_field_defs as ${defAlias}`, (jb) => jb.on(`${defAlias}.entity_id`, "=", String(source.entityId)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((jeb) => jeb.or([
|
|
1048
|
+
jeb(`${defAlias}.tenant_id`, "=", tenantId),
|
|
1049
|
+
jeb(`${defAlias}.tenant_id`, "is", null)
|
|
1050
|
+
]))).where(`${valAlias}.entity_id`, "=", String(source.entityId)).where(`${valAlias}.field_key`, "=", key).where((web) => web.or([
|
|
1051
|
+
web(`${valAlias}.tenant_id`, "=", tenantId),
|
|
1052
|
+
web(`${valAlias}.tenant_id`, "is", null)
|
|
1053
|
+
]));
|
|
1054
|
+
if (source.hop) {
|
|
1055
|
+
sub = sub.innerJoin(`${source.table} as ${srcAlias}`, (jb) => jb.on(sql`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop.recordIdColumn}`)})::text`)).whereRef(`${srcAlias}.${source.hop.toField}`, "=", qualify(source.hop.fromField));
|
|
1056
|
+
} else {
|
|
1057
|
+
sub = sub.where(sql`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`);
|
|
1058
|
+
}
|
|
1059
|
+
return sub;
|
|
1060
|
+
};
|
|
1061
|
+
const absenceSatisfiable = op === "eq" && value === null || op === "exists" && !value;
|
|
1062
|
+
if (absenceSatisfiable) {
|
|
1063
|
+
return eb.or([
|
|
1064
|
+
eb.not(eb.exists(buildSub(eb))),
|
|
1065
|
+
eb.exists(buildSub(eb).where(sql`${caseExpr} is null`))
|
|
1066
|
+
]);
|
|
1067
|
+
}
|
|
1068
|
+
let predicate = null;
|
|
1069
|
+
switch (op) {
|
|
1070
|
+
case "eq":
|
|
1071
|
+
predicate = sql`${caseExpr} = ${value}`;
|
|
1072
|
+
break;
|
|
1073
|
+
case "ne":
|
|
1074
|
+
predicate = value === null ? sql`${caseExpr} is not null` : sql`${caseExpr} != ${value}`;
|
|
1075
|
+
break;
|
|
1076
|
+
case "gt":
|
|
1077
|
+
case "gte":
|
|
1078
|
+
case "lt":
|
|
1079
|
+
case "lte": {
|
|
1080
|
+
const operator = sql.raw(op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=");
|
|
1081
|
+
predicate = sql`${caseExpr} ${operator} ${value}`;
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
case "in": {
|
|
1085
|
+
const vals = Array.isArray(value) ? value : [value];
|
|
1086
|
+
predicate = sql`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
case "nin": {
|
|
1090
|
+
const vals = Array.isArray(value) ? value : [value];
|
|
1091
|
+
predicate = sql`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
case "like":
|
|
1095
|
+
predicate = sql`${caseExpr} like ${value}`;
|
|
1096
|
+
break;
|
|
1097
|
+
case "ilike":
|
|
1098
|
+
predicate = sql`${caseExpr} ilike ${value}`;
|
|
1099
|
+
break;
|
|
1100
|
+
case "exists":
|
|
1101
|
+
predicate = sql`${caseExpr} is not null`;
|
|
1102
|
+
break;
|
|
1103
|
+
default:
|
|
1104
|
+
return eb.val(true);
|
|
1105
|
+
}
|
|
1106
|
+
const captured = predicate;
|
|
1107
|
+
return eb.exists(buildSub(eb).where(captured));
|
|
1108
|
+
}
|
|
883
1109
|
buildColumnOpExpression(eb, column, op, value) {
|
|
884
1110
|
switch (op) {
|
|
885
1111
|
case "eq":
|
|
@@ -915,16 +1141,13 @@ class BasicQueryEngine {
|
|
|
915
1141
|
}
|
|
916
1142
|
async columnExists(table, column) {
|
|
917
1143
|
const key = `${table}.${column}`;
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
this.columnCache.delete(key);
|
|
922
|
-
}
|
|
1144
|
+
const ttlMs = resolveColumnExistsCacheTtlMs();
|
|
1145
|
+
const cached = columnExistsCache.get(key);
|
|
1146
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
923
1147
|
const db = this.getDb();
|
|
924
1148
|
const exists = await db.selectFrom("information_schema.columns").select(sql`1`.as("one")).where("table_name", "=", table).where("column_name", "=", column).limit(1).executeTakeFirst();
|
|
925
1149
|
const present = !!exists;
|
|
926
|
-
if (
|
|
927
|
-
else this.columnCache.delete(key);
|
|
1150
|
+
if (ttlMs > 0) storeColumnExists(key, present, ttlMs);
|
|
928
1151
|
return present;
|
|
929
1152
|
}
|
|
930
1153
|
applySearchTokens(q, opts) {
|
|
@@ -1061,7 +1284,7 @@ class BasicQueryEngine {
|
|
|
1061
1284
|
return sub;
|
|
1062
1285
|
})());
|
|
1063
1286
|
}
|
|
1064
|
-
configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify) {
|
|
1287
|
+
configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify, attachJoins = true) {
|
|
1065
1288
|
const sources = [
|
|
1066
1289
|
{
|
|
1067
1290
|
entityId: baseEntity,
|
|
@@ -1079,14 +1302,22 @@ class BasicQueryEngine {
|
|
|
1079
1302
|
if (!join) {
|
|
1080
1303
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
|
|
1081
1304
|
}
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1305
|
+
const joinType = (join.type ?? "left") === "inner" ? "inner" : "left";
|
|
1306
|
+
if (attachJoins) {
|
|
1307
|
+
const joinFn = joinType === "inner" ? "innerJoin" : "leftJoin";
|
|
1308
|
+
next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
|
|
1309
|
+
} else if (joinType === "inner") {
|
|
1310
|
+
next = next.where((eb) => eb.exists(
|
|
1311
|
+
eb.selectFrom(`${joinTable} as ${alias}`).select(sql`1`.as("one")).whereRef(`${alias}.${join.toField}`, "=", qualify(join.fromField))
|
|
1312
|
+
));
|
|
1313
|
+
}
|
|
1084
1314
|
const recordColumn = srcOpt.recordIdColumn ?? "id";
|
|
1085
1315
|
sources.push({
|
|
1086
1316
|
entityId: srcOpt.entityId,
|
|
1087
1317
|
alias,
|
|
1088
1318
|
table: joinTable,
|
|
1089
|
-
recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text
|
|
1319
|
+
recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`,
|
|
1320
|
+
hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType }
|
|
1090
1321
|
});
|
|
1091
1322
|
});
|
|
1092
1323
|
return { builder: next, sources };
|
|
@@ -1126,7 +1357,12 @@ class BasicQueryEngine {
|
|
|
1126
1357
|
export {
|
|
1127
1358
|
BasicQueryEngine,
|
|
1128
1359
|
ENTITY_ID_PATTERN,
|
|
1360
|
+
clearColumnExistsCache,
|
|
1361
|
+
clearEncryptedLikeFieldsCache,
|
|
1362
|
+
columnExistsCacheSize,
|
|
1363
|
+
isEncryptedLikeField,
|
|
1129
1364
|
isValidEntityIdShape,
|
|
1365
|
+
resolveEncryptedLikeFieldSet,
|
|
1130
1366
|
resolveEntityTableName,
|
|
1131
1367
|
resolveRegisteredEntityTableName
|
|
1132
1368
|
};
|