@open-mercato/shared 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0

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 +1 -4
  3. package/dist/lib/auth/jwt.js +0 -6
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationAccess.js +4 -7
  6. package/dist/lib/auth/organizationAccess.js.map +2 -2
  7. package/dist/lib/auth/server.js +7 -38
  8. package/dist/lib/auth/server.js.map +2 -2
  9. package/dist/lib/commands/command-bus.js +1 -6
  10. package/dist/lib/commands/command-bus.js.map +2 -2
  11. package/dist/lib/crud/factory.js +8 -24
  12. package/dist/lib/crud/factory.js.map +2 -2
  13. package/dist/lib/data/engine.js +2 -8
  14. package/dist/lib/data/engine.js.map +2 -2
  15. package/dist/lib/location/countries.js +0 -12
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/openapi/crud.js +1 -4
  18. package/dist/lib/openapi/crud.js.map +2 -2
  19. package/dist/lib/query/engine.js +34 -270
  20. package/dist/lib/query/engine.js.map +3 -3
  21. package/dist/lib/query/types.js.map +1 -1
  22. package/dist/lib/search/config.js +0 -1
  23. package/dist/lib/search/config.js.map +2 -2
  24. package/dist/lib/version.js +1 -1
  25. package/dist/lib/version.js.map +1 -1
  26. package/dist/modules/events/factory.js +15 -69
  27. package/dist/modules/events/factory.js.map +2 -2
  28. package/dist/modules/registry.js +0 -15
  29. package/dist/modules/registry.js.map +2 -2
  30. package/dist/modules/widgets/component-registry.js.map +2 -2
  31. package/package.json +3 -10
  32. package/src/lib/auth/__tests__/jwt.test.ts +0 -13
  33. package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
  34. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
  35. package/src/lib/auth/__tests__/server.test.ts +0 -104
  36. package/src/lib/auth/jwt.ts +0 -17
  37. package/src/lib/auth/organizationAccess.ts +3 -11
  38. package/src/lib/auth/server.ts +8 -78
  39. package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
  40. package/src/lib/commands/command-bus.ts +1 -8
  41. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
  42. package/src/lib/crud/factory.ts +7 -33
  43. package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
  44. package/src/lib/data/engine.ts +1 -7
  45. package/src/lib/location/__tests__/countries.test.ts +0 -15
  46. package/src/lib/location/countries.ts +0 -17
  47. package/src/lib/openapi/crud.ts +0 -3
  48. package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
  49. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
  50. package/src/lib/query/__tests__/engine.test.ts +7 -445
  51. package/src/lib/query/engine.ts +54 -434
  52. package/src/lib/query/types.ts +0 -15
  53. package/src/lib/search/config.ts +0 -10
  54. package/src/modules/events/factory.ts +19 -111
  55. package/src/modules/events/types.ts +0 -17
  56. package/src/modules/registry.ts +0 -40
  57. package/src/modules/widgets/component-registry.ts +0 -14
  58. package/dist/lib/auth/mfaPendingAccess.js +0 -42
  59. package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
  60. package/dist/lib/auth/principal-service.js +0 -1
  61. package/dist/lib/auth/principal-service.js.map +0 -7
  62. package/dist/lib/html/htmlToPlainText.js +0 -16
  63. package/dist/lib/html/htmlToPlainText.js.map +0 -7
  64. package/dist/lib/query/count-cap.js +0 -11
  65. package/dist/lib/query/count-cap.js.map +0 -7
  66. package/dist/lib/queue/dispatchOrigin.js +0 -20
  67. package/dist/lib/queue/dispatchOrigin.js.map +0 -7
  68. package/dist/lib/search/entityAccess.js +0 -44
  69. package/dist/lib/search/entityAccess.js.map +0 -7
  70. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
  71. package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
  72. package/src/lib/auth/mfaPendingAccess.ts +0 -70
  73. package/src/lib/auth/principal-service.ts +0 -110
  74. package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
  75. package/src/lib/html/htmlToPlainText.ts +0 -17
  76. package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
  77. package/src/lib/query/__tests__/count-cap.test.ts +0 -41
  78. package/src/lib/query/count-cap.ts +0 -19
  79. package/src/lib/queue/dispatchOrigin.ts +0 -35
  80. package/src/lib/search/entityAccess.ts +0 -132
  81. package/src/modules/events/__tests__/factory.test.ts +0 -88
@@ -11,8 +11,6 @@ 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";
16
14
  import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
17
15
  import {
18
16
  buildCustomFieldDefinitionIndexFromRows,
@@ -20,61 +18,11 @@ import {
20
18
  } from "../crud/custom-field-definition-index.js";
21
19
  import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
22
20
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
23
- import { resolveListCountCap } from "./count-cap.js";
24
21
  import { mapWithConcurrency } from "./bounded-decrypt.js";
25
- import { parseNumberWithDefault } from "../number.js";
26
22
  import { createLogger } from "../logger/index.js";
27
23
  const logger = createLogger("shared").child({ component: "query" });
28
24
  const DECRYPT_CONCURRENCY = 8;
29
25
  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
- }
78
26
  const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/;
79
27
  const isValidEntityIdShape = (value) => ENTITY_ID_PATTERN.test(value);
80
28
  const pluralizeBaseName = (name) => {
@@ -191,6 +139,7 @@ class BasicQueryEngine {
191
139
  this.em = em;
192
140
  this.getDbFn = getDbFn;
193
141
  this.resolveEncryptionService = resolveEncryptionService;
142
+ this.columnCache = /* @__PURE__ */ new Map();
194
143
  this.searchAliasSeq = 0;
195
144
  this.searchAvailabilityInstance = null;
196
145
  }
@@ -283,35 +232,6 @@ class BasicQueryEngine {
283
232
  const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled();
284
233
  const hasSearchTokens = searchEnabled && searchFilters.length ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
285
234
  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
- }
315
235
  if (searchFilters.length) {
316
236
  const fields = searchFilters.map((filter) => String(filter.field));
317
237
  this.logSearchDebug("search:init", {
@@ -373,12 +293,7 @@ class BasicQueryEngine {
373
293
  }
374
294
  const recordIdColumn = qualify("id");
375
295
  const applyFilterOp = (builder, column, op, value, fieldName) => {
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))) {
296
+ if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string") {
382
297
  const tokens = tokenizeText(String(value), searchConfig);
383
298
  const hashes = tokens.hashes;
384
299
  if (hashes.length) {
@@ -467,7 +382,6 @@ class BasicQueryEngine {
467
382
  const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
468
383
  const buildQuery = async (projection) => {
469
384
  const isSortKeysProjection = projection === "sortKeys";
470
- const isCountProjection = projection === "count";
471
385
  let q = db.selectFrom(table);
472
386
  if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
473
387
  q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
@@ -541,8 +455,7 @@ class BasicQueryEngine {
541
455
  applyJoinFilterOp,
542
456
  columnExists: (tbl, column) => this.columnExists(tbl, column)
543
457
  });
544
- if (isCountProjection) {
545
- } else if (isSortKeysProjection) {
458
+ if (isSortKeysProjection) {
546
459
  q = q.select(sql.ref(qualify("id")).as("id"));
547
460
  if (await this.columnExists(table, "tenant_id")) {
548
461
  q = q.select(sql.ref(qualify("tenant_id")).as("tenant_id"));
@@ -564,18 +477,18 @@ class BasicQueryEngine {
564
477
  } else {
565
478
  q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
566
479
  }
567
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection);
480
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
568
481
  q = cfSourcesResult.builder;
569
482
  const cfSources = cfSourcesResult.sources;
570
483
  const entityIdToSource = /* @__PURE__ */ new Map();
571
484
  for (const source of cfSources) {
572
485
  entityIdToSource.set(String(source.entityId), source);
573
486
  }
574
- const requestedCustomFieldKeys = projection === "full" && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
487
+ const requestedCustomFieldKeys = !isSortKeysProjection && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
575
488
  const cfKeys = /* @__PURE__ */ new Set();
576
489
  const keySource = /* @__PURE__ */ new Map();
577
490
  let resolvedCustomFieldDefinitions2;
578
- if (projection === "full") {
491
+ if (!isSortKeysProjection) {
579
492
  for (const f of opts.fields || []) {
580
493
  if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
581
494
  }
@@ -583,7 +496,7 @@ class BasicQueryEngine {
583
496
  for (const f of cfFilters) {
584
497
  if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
585
498
  }
586
- if (projection === "full" && opts.includeCustomFields === true) {
499
+ if (!isSortKeysProjection && opts.includeCustomFields === true) {
587
500
  if (entityIdToSource.size > 0) {
588
501
  const entityIdList = Array.from(entityIdToSource.keys());
589
502
  const entityOrder = /* @__PURE__ */ new Map();
@@ -682,7 +595,6 @@ class BasicQueryEngine {
682
595
  for (const key of cfKeys) {
683
596
  const source = keySource.get(key);
684
597
  if (!source) continue;
685
- if (isCountProjection) continue;
686
598
  const entityIdForKey = source.entityId;
687
599
  const recordIdExpr = source.recordIdExpr;
688
600
  const sourceAliasSafe = sanitize(source.alias || "src");
@@ -730,10 +642,8 @@ class BasicQueryEngine {
730
642
  for (const f of regularCfFilters) {
731
643
  if (!f.field.startsWith("cf:")) continue;
732
644
  const key = f.field.slice(3);
733
- const filterSource = keySource.get(key);
734
- if (!filterSource) continue;
735
645
  const expr = cfValueExprByKey[key];
736
- if (!isCountProjection && !expr) continue;
646
+ if (!expr) continue;
737
647
  if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
738
648
  const tokens = tokenizeText(String(f.value), searchConfig);
739
649
  const hashes = tokens.hashes;
@@ -768,39 +678,15 @@ class BasicQueryEngine {
768
678
  });
769
679
  }
770
680
  }
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
- }
782
681
  q = this.applyColumnOp(q, expr, f.op, f.value);
783
682
  }
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);
683
+ const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || Boolean(cfValueExprByKey[rf.key]))).filter((group) => group.length > 0);
786
684
  if (applicableGroupFilters.length > 0) {
787
685
  q = q.where((eb) => {
788
686
  const disjuncts = applicableGroupFilters.map((group) => {
789
687
  const parts = group.map((rf) => {
790
688
  if (rf.kind === "column") return this.buildColumnOpExpression(eb, rf.qualified, 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
- }
689
+ if (rf.kind === "cf") return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
804
690
  return this.buildIndexDocOpExpression(eb, {
805
691
  entity: String(entity),
806
692
  field: rf.field,
@@ -817,7 +703,7 @@ class BasicQueryEngine {
817
703
  return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts);
818
704
  });
819
705
  }
820
- if (opts.includeExtensions && !isCountProjection) {
706
+ if (opts.includeExtensions) {
821
707
  const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
822
708
  const allMods = getModules();
823
709
  const allExts = allMods.flatMap((m) => m.entityExtensions || []);
@@ -834,7 +720,7 @@ class BasicQueryEngine {
834
720
  );
835
721
  }
836
722
  }
837
- for (const s of isCountProjection ? [] : resolvedSorts) {
723
+ for (const s of resolvedSorts) {
838
724
  if (s.field.startsWith("cf:")) {
839
725
  const key = s.field.slice(3);
840
726
  const alias = sanitize(`cf:${key}`);
@@ -850,7 +736,7 @@ class BasicQueryEngine {
850
736
  if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
851
737
  }
852
738
  }
853
- const hasJoinedAggregates2 = !isCountProjection && (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0);
739
+ const hasJoinedAggregates2 = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
854
740
  if (hasJoinedAggregates2) {
855
741
  q = q.groupBy(`${table}.id`);
856
742
  }
@@ -865,24 +751,11 @@ class BasicQueryEngine {
865
751
  cfMultiAliasByAlias,
866
752
  resolvedCustomFieldDefinitions
867
753
  } = await buildQuery("full");
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
- }
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);
886
759
  const svc = encryptionService;
887
760
  const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
888
761
  const decryptRow = async (item) => {
@@ -931,15 +804,13 @@ class BasicQueryEngine {
931
804
  const cap = resolveEncryptedSortMaxRows();
932
805
  let qSort = (await buildQuery("sortKeys")).builder;
933
806
  if (cap !== null) {
934
- qSort = qSort.limit(cap + 1).orderBy(qualify("id"), "asc");
807
+ qSort = qSort.limit(cap).orderBy(qualify("id"), "asc");
935
808
  }
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;
809
+ const candidateRows = await qSort.execute();
939
810
  const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
940
811
  const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
941
812
  const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
942
- if (sortTruncated && cap !== null) {
813
+ if (cap !== null && total > cap) {
943
814
  encryptedSortRowCapWarning = {
944
815
  entity,
945
816
  sortFields: resolvedSorts.map((s) => s.field),
@@ -963,10 +834,8 @@ class BasicQueryEngine {
963
834
  pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
964
835
  }
965
836
  let queryResult = { items: pagedItems, page, pageSize, total };
966
- if (encryptedSortRowCapWarning || listCountCapWarning) {
967
- const meta = {};
968
- if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning;
969
- if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning;
837
+ if (encryptedSortRowCapWarning) {
838
+ const meta = { encryptedSortRowCapWarning };
970
839
  queryResult.meta = meta;
971
840
  }
972
841
  if (ext && extensionCtx) {
@@ -1011,101 +880,6 @@ class BasicQueryEngine {
1011
880
  return builder;
1012
881
  }
1013
882
  }
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
- }
1109
883
  buildColumnOpExpression(eb, column, op, value) {
1110
884
  switch (op) {
1111
885
  case "eq":
@@ -1141,13 +915,16 @@ class BasicQueryEngine {
1141
915
  }
1142
916
  async columnExists(table, column) {
1143
917
  const key = `${table}.${column}`;
1144
- const ttlMs = resolveColumnExistsCacheTtlMs();
1145
- const cached = columnExistsCache.get(key);
1146
- if (cached && cached.expiresAt > Date.now()) return cached.value;
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
+ }
1147
923
  const db = this.getDb();
1148
924
  const exists = await db.selectFrom("information_schema.columns").select(sql`1`.as("one")).where("table_name", "=", table).where("column_name", "=", column).limit(1).executeTakeFirst();
1149
925
  const present = !!exists;
1150
- if (ttlMs > 0) storeColumnExists(key, present, ttlMs);
926
+ if (present) this.columnCache.set(key, true);
927
+ else this.columnCache.delete(key);
1151
928
  return present;
1152
929
  }
1153
930
  applySearchTokens(q, opts) {
@@ -1284,7 +1061,7 @@ class BasicQueryEngine {
1284
1061
  return sub;
1285
1062
  })());
1286
1063
  }
1287
- configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify, attachJoins = true) {
1064
+ configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify) {
1288
1065
  const sources = [
1289
1066
  {
1290
1067
  entityId: baseEntity,
@@ -1302,22 +1079,14 @@ class BasicQueryEngine {
1302
1079
  if (!join) {
1303
1080
  throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
1304
1081
  }
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
- }
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)));
1314
1084
  const recordColumn = srcOpt.recordIdColumn ?? "id";
1315
1085
  sources.push({
1316
1086
  entityId: srcOpt.entityId,
1317
1087
  alias,
1318
1088
  table: joinTable,
1319
- recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`,
1320
- hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType }
1089
+ recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`
1321
1090
  });
1322
1091
  });
1323
1092
  return { builder: next, sources };
@@ -1357,12 +1126,7 @@ class BasicQueryEngine {
1357
1126
  export {
1358
1127
  BasicQueryEngine,
1359
1128
  ENTITY_ID_PATTERN,
1360
- clearColumnExistsCache,
1361
- clearEncryptedLikeFieldsCache,
1362
- columnExistsCacheSize,
1363
- isEncryptedLikeField,
1364
1129
  isValidEntityIdShape,
1365
- resolveEncryptedLikeFieldSet,
1366
1130
  resolveEntityTableName,
1367
1131
  resolveRegisteredEntityTableName
1368
1132
  };