@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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -1
  3. package/dist/lib/auth/jwt.js +6 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/mfaPendingAccess.js +42 -0
  6. package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
  7. package/dist/lib/auth/organizationAccess.js +7 -4
  8. package/dist/lib/auth/organizationAccess.js.map +2 -2
  9. package/dist/lib/auth/principal-service.js +1 -0
  10. package/dist/lib/auth/principal-service.js.map +7 -0
  11. package/dist/lib/auth/server.js +38 -7
  12. package/dist/lib/auth/server.js.map +2 -2
  13. package/dist/lib/commands/command-bus.js +6 -1
  14. package/dist/lib/commands/command-bus.js.map +2 -2
  15. package/dist/lib/crud/factory.js +24 -8
  16. package/dist/lib/crud/factory.js.map +2 -2
  17. package/dist/lib/data/engine.js +8 -2
  18. package/dist/lib/data/engine.js.map +2 -2
  19. package/dist/lib/html/htmlToPlainText.js +16 -0
  20. package/dist/lib/html/htmlToPlainText.js.map +7 -0
  21. package/dist/lib/location/countries.js +12 -0
  22. package/dist/lib/location/countries.js.map +2 -2
  23. package/dist/lib/openapi/crud.js +4 -1
  24. package/dist/lib/openapi/crud.js.map +2 -2
  25. package/dist/lib/query/count-cap.js +11 -0
  26. package/dist/lib/query/count-cap.js.map +7 -0
  27. package/dist/lib/query/engine.js +270 -34
  28. package/dist/lib/query/engine.js.map +3 -3
  29. package/dist/lib/query/types.js.map +1 -1
  30. package/dist/lib/queue/dispatchOrigin.js +20 -0
  31. package/dist/lib/queue/dispatchOrigin.js.map +7 -0
  32. package/dist/lib/search/config.js +1 -0
  33. package/dist/lib/search/config.js.map +2 -2
  34. package/dist/lib/search/entityAccess.js +44 -0
  35. package/dist/lib/search/entityAccess.js.map +7 -0
  36. package/dist/lib/version.js +1 -1
  37. package/dist/lib/version.js.map +1 -1
  38. package/dist/modules/events/factory.js +69 -15
  39. package/dist/modules/events/factory.js.map +2 -2
  40. package/dist/modules/registry.js +15 -0
  41. package/dist/modules/registry.js.map +2 -2
  42. package/dist/modules/widgets/component-registry.js.map +2 -2
  43. package/package.json +10 -3
  44. package/src/lib/auth/__tests__/jwt.test.ts +13 -0
  45. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
  46. package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
  47. package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
  48. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
  49. package/src/lib/auth/__tests__/server.test.ts +104 -0
  50. package/src/lib/auth/jwt.ts +17 -0
  51. package/src/lib/auth/mfaPendingAccess.ts +70 -0
  52. package/src/lib/auth/organizationAccess.ts +11 -3
  53. package/src/lib/auth/principal-service.ts +110 -0
  54. package/src/lib/auth/server.ts +78 -8
  55. package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
  56. package/src/lib/commands/command-bus.ts +8 -1
  57. package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
  58. package/src/lib/crud/factory.ts +33 -7
  59. package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
  60. package/src/lib/data/engine.ts +7 -1
  61. package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
  62. package/src/lib/html/htmlToPlainText.ts +17 -0
  63. package/src/lib/location/__tests__/countries.test.ts +15 -0
  64. package/src/lib/location/countries.ts +17 -0
  65. package/src/lib/openapi/crud.ts +3 -0
  66. package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
  67. package/src/lib/query/__tests__/count-cap.test.ts +41 -0
  68. package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
  69. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
  70. package/src/lib/query/__tests__/engine.test.ts +445 -7
  71. package/src/lib/query/count-cap.ts +19 -0
  72. package/src/lib/query/engine.ts +434 -54
  73. package/src/lib/query/types.ts +15 -0
  74. package/src/lib/queue/dispatchOrigin.ts +35 -0
  75. package/src/lib/search/config.ts +10 -0
  76. package/src/lib/search/entityAccess.ts +132 -0
  77. package/src/modules/events/__tests__/factory.test.ts +88 -0
  78. package/src/modules/events/factory.ts +111 -19
  79. package/src/modules/events/types.ts +17 -0
  80. package/src/modules/registry.ts +40 -0
  81. package/src/modules/widgets/component-registry.ts +14 -0
@@ -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 (isSortKeysProjection) {
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 = !isSortKeysProjection && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
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 (!isSortKeysProjection) {
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 (!isSortKeysProjection && opts.includeCustomFields === true) {
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 applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || Boolean(cfValueExprByKey[rf.key]))).filter((group) => group.length > 0);
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") return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
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 mayMultiplyBaseRows = hasJoinedAggregates || Array.isArray(opts.joins) && opts.joins.length > 0 || Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0;
755
- const countExpr = mayMultiplyBaseRows ? sql`count(distinct ${sql.ref(`${table}.id`)})` : sql`count(*)`;
756
- const countBuilder = hasJoinedAggregates ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as("count")) : qFull.clearSelect().clearOrderBy().select(countExpr.as("count"));
757
- const countRow = await countBuilder.executeTakeFirst();
758
- const total = Number(countRow?.count ?? 0);
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 candidateRows = await qSort.execute();
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 && total > cap) {
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 = { encryptedSortRowCapWarning };
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
- if (this.columnCache.has(key)) {
919
- const cached = this.columnCache.get(key);
920
- if (cached === true) return true;
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 (present) this.columnCache.set(key, true);
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 joinFn = (join.type ?? "left") === "inner" ? "innerJoin" : "leftJoin";
1083
- next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
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
  };