@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
@@ -1,4 +1,4 @@
1
- import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
1
+ import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, ListCountCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
2
2
  import type { EntityId } from '@open-mercato/shared/modules/entities'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { type Kysely, sql, type RawBuilder } from 'kysely'
@@ -20,6 +20,8 @@ import {
20
20
  type SearchTokenProbeQueryBuilder,
21
21
  } from '../search/availability'
22
22
  import { tokenizeText } from '../search/tokenize'
23
+ import { fieldNameCandidates } from './encrypted-sort'
24
+ import { isTenantDataEncryptionEnabled } from '../encryption/toggles'
23
25
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
24
26
  import {
25
27
  buildCustomFieldDefinitionIndexFromRows,
@@ -29,7 +31,9 @@ import {
29
31
  } from '../crud/custom-field-definition-index'
30
32
  import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
31
33
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
34
+ import { resolveListCountCap } from './count-cap'
32
35
  import { mapWithConcurrency } from './bounded-decrypt'
36
+ import { parseNumberWithDefault } from '../number'
33
37
  import { createLogger } from '../logger'
34
38
 
35
39
  const logger = createLogger('shared').child({ component: 'query' })
@@ -43,15 +47,112 @@ const entityTableCache = new Map<string, string>()
43
47
 
44
48
  type EncryptionResolver = () => {
45
49
  decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
46
- getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
50
+ getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null, options?: { ignoreRuntimeHealth?: boolean }) => Promise<readonly string[]>
47
51
  isEnabled?: () => boolean
48
52
  } | null
49
53
 
54
+ /**
55
+ * Membership across name shapes: encryption maps may declare a field as `displayName` or
56
+ * `display_name` (TenantDataEncryptionService resolves both), while query filters carry real
57
+ * column names. Both sides expand through `fieldNameCandidates` at set-build and lookup time.
58
+ */
59
+ export function isEncryptedLikeField(encrypted: ReadonlySet<string>, field: string): boolean {
60
+ return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate))
61
+ }
62
+
63
+ // The all-orgs union behind `getEncryptedFieldNames(..., organizationId: null)` is an UNCACHED
64
+ // `encryption_maps` read. Encryption maps change on deploys, not per request, so a short TTL
65
+ // removes the per-search round-trip without meaningfully delaying a map rollout.
66
+ const ENCRYPTED_LIKE_FIELDS_TTL_MS = 60_000
67
+ const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500
68
+ const encryptedLikeFieldsCache = new Map<string, { at: number; fields: Set<string> }>()
69
+
70
+ export async function resolveEncryptedLikeFieldSet(
71
+ read: () => Promise<readonly string[]>,
72
+ entity: string,
73
+ tenantId: string | null,
74
+ ): Promise<Set<string>> {
75
+ const key = `${entity}|${tenantId ?? ''}`
76
+ const hit = encryptedLikeFieldsCache.get(key)
77
+ if (hit && Date.now() - hit.at < ENCRYPTED_LIKE_FIELDS_TTL_MS) return hit.fields
78
+ const names = await read()
79
+ const fields = new Set<string>()
80
+ for (const name of names ?? []) {
81
+ for (const candidate of fieldNameCandidates(String(name))) fields.add(candidate)
82
+ }
83
+ if (encryptedLikeFieldsCache.size >= ENCRYPTED_LIKE_FIELDS_CACHE_CAP) encryptedLikeFieldsCache.clear()
84
+ encryptedLikeFieldsCache.set(key, { at: Date.now(), fields })
85
+ return fields
86
+ }
87
+
88
+ /** Test-only: the TTL memo would otherwise leak state across specs. */
89
+ export function clearEncryptedLikeFieldsCache(): void {
90
+ encryptedLikeFieldsCache.clear()
91
+ }
92
+
93
+ // Module-scoped on purpose: `createRequestContainer` builds a fresh `BasicQueryEngine`
94
+ // per request, so an instance field alone re-pays the `information_schema` probe on
95
+ // every request (#5605). Schema shape (does a table have this column?) is not
96
+ // per-request state — unlike `tenantEncryptionService` — so sharing the answer across
97
+ // requests is safe. One map per module instance rather than a true process singleton:
98
+ // standalone builds can duplicate this package, which for a memo is harmless (two
99
+ // caches, both correct), so nothing may be built on top of singleton semantics here.
100
+ //
101
+ // Bounded and TTL'd for two reasons. `columnExists` is reached with caller-supplied
102
+ // field names via `resolveBaseColumn` (sort fields and base filter keys arrive raw from
103
+ // the HTTP layer), so an unbounded map would grow monotonically on request input. And a
104
+ // cached `false` is consumed where the tenant/organization/soft-delete predicates are
105
+ // applied, so a schema change that adds one of those columns must not stay invisible
106
+ // until the process restarts — a migration applied against a running `yarn dev` or
107
+ // not-yet-recycled pods converges within the TTL instead. The TTL still removes
108
+ // essentially all of the traffic: a hot column is probed twelve times an hour rather
109
+ // than tens of thousands. Set OM_QUERY_COLUMN_EXISTS_CACHE_MS=0 to disable and probe
110
+ // per request again; OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES tunes the bound.
111
+ const COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS = 300_000
112
+ const COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES = 10_000
113
+ const columnExistsCache = new Map<string, { value: boolean; expiresAt: number }>()
114
+
115
+ function resolveColumnExistsCacheTtlMs(): number {
116
+ return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS, COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })
117
+ }
118
+
119
+ function resolveColumnExistsCacheMaxEntries(): number {
120
+ return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES, COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES, { integer: true, min: 1 })
121
+ }
122
+
123
+ function storeColumnExists(key: string, value: boolean, ttlMs: number): void {
124
+ const maxEntries = resolveColumnExistsCacheMaxEntries()
125
+ if (columnExistsCache.size >= maxEntries) {
126
+ const now = Date.now()
127
+ for (const [entryKey, entry] of columnExistsCache) {
128
+ if (entry.expiresAt <= now) columnExistsCache.delete(entryKey)
129
+ }
130
+ if (columnExistsCache.size >= maxEntries) columnExistsCache.clear()
131
+ }
132
+ columnExistsCache.set(key, { value, expiresAt: Date.now() + ttlMs })
133
+ }
134
+
135
+ /** Test-only: the module-scoped memo would otherwise leak state across specs. */
136
+ export function clearColumnExistsCache(): void {
137
+ columnExistsCache.clear()
138
+ }
139
+
140
+ /** Test-only: entry count of the column-existence memo, for the cap regression test. */
141
+ export function columnExistsCacheSize(): number {
142
+ return columnExistsCache.size
143
+ }
144
+
50
145
  type ResolvedCustomFieldSource = {
51
146
  entityId: EntityId
52
147
  alias: string
53
148
  table: string
54
149
  recordIdExpr: RawBuilder<string>
150
+ /**
151
+ * The base→source join edge for joined sources (absent on the base source).
152
+ * The count projection uses it to correlate cf-value EXISTS subqueries
153
+ * without attaching the source join to the outer query.
154
+ */
155
+ hop?: { fromField: string; toField: string; recordIdColumn: string; type: 'left' | 'inner' }
55
156
  }
56
157
 
57
158
  type ResultRow = Record<string, unknown>
@@ -223,7 +324,6 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
223
324
  * {@link HybridQueryEngine} when the query index is unavailable or incomplete.
224
325
  */
225
326
  export class BasicQueryEngine implements QueryEngine {
226
- private columnCache = new Map<string, boolean>()
227
327
  private searchAliasSeq = 0
228
328
  private searchAvailabilityInstance: SearchTokenAvailability | null = null
229
329
 
@@ -345,6 +445,62 @@ export class BasicQueryEngine implements QueryEngine {
345
445
  ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
346
446
  : false
347
447
  const searchActive = searchEnabled && hasSearchTokens
448
+ // Opt-in via OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS (default false: the pre-existing
449
+ // rewrite-everything behavior is kept). When enabled, base-column like/ilike is rerouted
450
+ // through search tokens ONLY for encrypted columns, where
451
+ // ILIKE against ciphertext cannot match. On a plaintext column SQL ILIKE is exact, and the token
452
+ // rewrite silently changes the result set: tokenization splits on non-alphanumerics and drops
453
+ // tokens shorter than minTokenLength, so a document-number search like "ZK 1/2026" degrades to
454
+ // the tokens {202, 2026} and matches every record from that year instead of the one document.
455
+ // `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while the
456
+ // KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
457
+ // `organizationId: null` is deliberate, not an omission: the service then unions in every
458
+ // organization's map (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays on
459
+ // the token path -- a wider set fails safe. Passing the request's org instead would silently
460
+ // break encrypted-column search for orgs without their own map. That union is an UNCACHED
461
+ // `encryption_maps` read, one extra round-trip per searched list request. `null` means
462
+ // the encryption service could not answer at all; keep the pre-existing rewrite-everything
463
+ // behavior then, because guessing "plaintext" would turn encrypted-column search into an
464
+ // ILIKE-on-ciphertext that matches nothing.
465
+ let encryptedLikeFields: Set<string> | null = null
466
+ if (
467
+ searchActive &&
468
+ searchConfig.useIlikeForNonEncryptedFields === true &&
469
+ searchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
470
+ ) {
471
+ try {
472
+ const service = this.getEncryptionService()
473
+ const readEncryptedFieldNames = service?.getEncryptedFieldNames?.bind(service)
474
+ if (readEncryptedFieldNames) {
475
+ encryptedLikeFields = await resolveEncryptedLikeFieldSet(
476
+ () => readEncryptedFieldNames(
477
+ String(entity),
478
+ opts.tenantId ?? null,
479
+ null,
480
+ { ignoreRuntimeHealth: true },
481
+ ),
482
+ String(entity),
483
+ opts.tenantId ?? null,
484
+ )
485
+ } else if (isTenantDataEncryptionEnabled()) {
486
+ // Encryption is on but the service is unreachable (a swallowed DI failure looks
487
+ // exactly like "no service"): treat the map as UNKNOWN and keep the token rewrite,
488
+ // rather than guessing "plaintext" and running ILIKE against ciphertext.
489
+ encryptedLikeFields = null
490
+ } else {
491
+ // Encryption disabled: nothing is ciphertext at rest, exact ILIKE is always right.
492
+ encryptedLikeFields = new Set()
493
+ }
494
+ } catch (err) {
495
+ // The fallback is safe (the old rewrite-everything behavior), but taking it silently
496
+ // would hide that the gate has stopped working.
497
+ logger.warn('search: encrypted-field map unavailable; keeping the token rewrite for all columns', {
498
+ entity: String(entity),
499
+ error: err instanceof Error ? err.message : String(err),
500
+ })
501
+ encryptedLikeFields = null
502
+ }
503
+ }
348
504
  if (searchFilters.length) {
349
505
  const fields = searchFilters.map((filter) => String(filter.field))
350
506
  this.logSearchDebug('search:init', {
@@ -422,7 +578,13 @@ export class BasicQueryEngine implements QueryEngine {
422
578
  searchActive &&
423
579
  typeof value === 'string' &&
424
580
  fieldName &&
425
- typeof column === 'string'
581
+ typeof column === 'string' &&
582
+ // Plaintext columns keep exact SQL ILIKE -- see the encryptedLikeFields note above. cf:*
583
+ // filters never reach this path (they are applied by the custom-field branches), so this
584
+ // gate only decides base columns. Membership is tested across name-shape candidates --
585
+ // encryption maps may declare `displayName` while the filter carries the column name
586
+ // `display_name`, and a raw comparison would misread that ciphertext column as plaintext.
587
+ (encryptedLikeFields === null || isEncryptedLikeField(encryptedLikeFields, fieldName))
426
588
  ) {
427
589
  const tokens = tokenizeText(String(value), searchConfig)
428
590
  const hashes = tokens.hashes
@@ -546,11 +708,16 @@ export class BasicQueryEngine implements QueryEngine {
546
708
  // Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
547
709
  // today's complete selection (base fields + CF projections + extension joins).
548
710
  // `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
549
- // candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
550
- // twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
551
- // so the second pass hits no extra DB calls.
552
- const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
711
+ // candidate scan used when `requiresPlaintextSort`. `projection: 'count'` carries
712
+ // scope + filters only: projection joins (CF defs/values, extensions) are omitted
713
+ // and cf filters are expressed as correlated EXISTS semi-joins, so nothing can
714
+ // multiply base rows and a LIMIT above the query is an enforceable bound.
715
+ // Re-running the WHERE/JOIN logic per projection is cheap: every `columnExists`
716
+ // check is memoized on the module-scoped `columnExistsCache`, so later passes
717
+ // hit no extra DB calls.
718
+ const buildQuery = async (projection: 'full' | 'sortKeys' | 'count'): Promise<BuiltQuery> => {
553
719
  const isSortKeysProjection = projection === 'sortKeys'
720
+ const isCountProjection = projection === 'count'
554
721
  let q: AnyBuilder = db.selectFrom(table as any)
555
722
 
556
723
  // Tenant/org/soft-delete scope
@@ -642,7 +809,10 @@ export class BasicQueryEngine implements QueryEngine {
642
809
  })
643
810
 
644
811
  // Selection (base columns only here; cf:* handled later)
645
- if (isSortKeysProjection) {
812
+ if (isCountProjection) {
813
+ // The caller owns the count query's SELECT (a constant inside the
814
+ // bounded subquery, or the aggregate itself when the cap is off).
815
+ } else if (isSortKeysProjection) {
646
816
  q = q.select(sql.ref(qualify('id')).as('id'))
647
817
  if (await this.columnExists(table, 'tenant_id')) {
648
818
  q = q.select(sql.ref(qualify('tenant_id')).as('tenant_id'))
@@ -668,14 +838,14 @@ export class BasicQueryEngine implements QueryEngine {
668
838
  }
669
839
 
670
840
  // Resolve which custom fields to include
671
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
841
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection)
672
842
  q = cfSourcesResult.builder
673
843
  const cfSources = cfSourcesResult.sources
674
844
  const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
675
845
  for (const source of cfSources) {
676
846
  entityIdToSource.set(String(source.entityId), source)
677
847
  }
678
- const requestedCustomFieldKeys = (!isSortKeysProjection && Array.isArray(opts.includeCustomFields))
848
+ const requestedCustomFieldKeys = (projection === 'full' && Array.isArray(opts.includeCustomFields))
679
849
  ? opts.includeCustomFields.map((key) => String(key))
680
850
  : []
681
851
  const cfKeys = new Set<string>()
@@ -685,7 +855,7 @@ export class BasicQueryEngine implements QueryEngine {
685
855
  // Output-only — never resolved for the slim sortKeys projection.
686
856
  let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
687
857
  // Explicit in fields/filters
688
- if (!isSortKeysProjection) {
858
+ if (projection === 'full') {
689
859
  for (const f of (opts.fields || [])) {
690
860
  if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
691
861
  }
@@ -693,7 +863,7 @@ export class BasicQueryEngine implements QueryEngine {
693
863
  for (const f of cfFilters) {
694
864
  if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
695
865
  }
696
- if (!isSortKeysProjection && opts.includeCustomFields === true) {
866
+ if (projection === 'full' && opts.includeCustomFields === true) {
697
867
  if (entityIdToSource.size > 0) {
698
868
  const entityIdList = Array.from(entityIdToSource.keys())
699
869
  const entityOrder = new Map<string, number>()
@@ -820,6 +990,9 @@ export class BasicQueryEngine implements QueryEngine {
820
990
  for (const key of cfKeys) {
821
991
  const source = keySource.get(key)
822
992
  if (!source) continue
993
+ // The count shape never joins defs/values — cf filters are applied as
994
+ // correlated EXISTS semi-joins below, so no join can multiply base rows.
995
+ if (isCountProjection) continue
823
996
  const entityIdForKey = source.entityId
824
997
  const recordIdExpr = source.recordIdExpr
825
998
  const sourceAliasSafe = sanitize(source.alias || 'src')
@@ -873,13 +1046,16 @@ export class BasicQueryEngine implements QueryEngine {
873
1046
  }
874
1047
  }
875
1048
 
876
- // Apply cf:* filters (on raw expressions). OR-grouped ones are excluded here and
877
- // combined with their disjunct's other leaves right below.
1049
+ // Apply cf:* filters (on raw expressions; as EXISTS semi-joins for the count
1050
+ // shape). OR-grouped ones are excluded here and combined with their
1051
+ // disjunct's other leaves right below.
878
1052
  for (const f of regularCfFilters) {
879
1053
  if (!f.field.startsWith('cf:')) continue
880
1054
  const key = f.field.slice(3)
1055
+ const filterSource = keySource.get(key)
1056
+ if (!filterSource) continue
881
1057
  const expr = cfValueExprByKey[key]
882
- if (!expr) continue
1058
+ if (!isCountProjection && !expr) continue
883
1059
  if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
884
1060
  const tokens = tokenizeText(String(f.value), searchConfig)
885
1061
  const hashes = tokens.hashes
@@ -914,6 +1090,17 @@ export class BasicQueryEngine implements QueryEngine {
914
1090
  })
915
1091
  }
916
1092
  }
1093
+ if (isCountProjection) {
1094
+ q = this.applyCfValueExistsFilter(q, {
1095
+ source: filterSource,
1096
+ qualify,
1097
+ tenantId: tenantId ?? null,
1098
+ key,
1099
+ op: f.op,
1100
+ value: f.value,
1101
+ })
1102
+ continue
1103
+ }
917
1104
  q = this.applyColumnOp(q, expr, f.op, f.value)
918
1105
  }
919
1106
 
@@ -928,15 +1115,35 @@ export class BasicQueryEngine implements QueryEngine {
928
1115
  // `ilike` through the search-token index the way the ungrouped path does. On a
929
1116
  // field covered by an encryption map such a leaf therefore compares against
930
1117
  // ciphertext and will not match.
1118
+ //
1119
+ // The count shape never populates cfValueExprByKey (it joins no cf tables), so
1120
+ // its applicability test is key resolution itself — the same condition that
1121
+ // gates the full shape's expression map — and a cf leaf compiles to a
1122
+ // correlated EXISTS instead of a value-expression comparison. Dropping it
1123
+ // instead would narrow the OR and undercount relative to the display query.
1124
+ const cfLeafApplicable = (key: string): boolean =>
1125
+ isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key])
931
1126
  const applicableGroupFilters = resolvedGroupFilters
932
- .map((group) => group.filter((rf) => rf.kind !== 'cf' || Boolean(cfValueExprByKey[rf.key])))
1127
+ .map((group) => group.filter((rf) => rf.kind !== 'cf' || cfLeafApplicable(rf.key)))
933
1128
  .filter((group) => group.length > 0)
934
1129
  if (applicableGroupFilters.length > 0) {
935
1130
  q = q.where((eb: any) => {
936
1131
  const disjuncts = applicableGroupFilters.map((group) => {
937
1132
  const parts = group.map((rf) => {
938
1133
  if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
939
- if (rf.kind === 'cf') return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1134
+ if (rf.kind === 'cf') {
1135
+ if (isCountProjection) {
1136
+ return this.buildCfValueExistsExpression(eb, {
1137
+ source: keySource.get(rf.key)!,
1138
+ qualify,
1139
+ tenantId: tenantId ?? null,
1140
+ key: rf.key,
1141
+ op: rf.op,
1142
+ value: rf.value,
1143
+ })
1144
+ }
1145
+ return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1146
+ }
940
1147
  return this.buildIndexDocOpExpression(eb, {
941
1148
  entity: String(entity),
942
1149
  field: rf.field,
@@ -954,8 +1161,9 @@ export class BasicQueryEngine implements QueryEngine {
954
1161
  })
955
1162
  }
956
1163
 
957
- // Entity extensions joins (no selection yet; enables future filters/projections)
958
- if (opts.includeExtensions) {
1164
+ // Entity extensions joins (no selection yet; enables future filters/projections).
1165
+ // Projection-only, so the count shape omits them.
1166
+ if (opts.includeExtensions && !isCountProjection) {
959
1167
  const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
960
1168
  const allMods = getModules() as any[]
961
1169
  const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
@@ -982,7 +1190,7 @@ export class BasicQueryEngine implements QueryEngine {
982
1190
  }
983
1191
 
984
1192
  // Sorting: base fields and cf:* (use aggregated alias for cf)
985
- for (const s of resolvedSorts) {
1193
+ for (const s of isCountProjection ? [] : resolvedSorts) {
986
1194
  if (s.field.startsWith('cf:')) {
987
1195
  const key = s.field.slice(3)
988
1196
  const alias = sanitize(`cf:${key}`)
@@ -1000,8 +1208,12 @@ export class BasicQueryEngine implements QueryEngine {
1000
1208
  }
1001
1209
  }
1002
1210
 
1003
- // Deduplicate if we joined CFs or extensions by grouping on base id
1004
- const hasJoinedAggregates = (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0
1211
+ // Deduplicate if we joined CFs or extensions by grouping on base id. The count
1212
+ // shape has neither, and must stay barrier-free for its LIMIT to bind.
1213
+ const hasJoinedAggregates = !isCountProjection && (
1214
+ (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) ||
1215
+ Object.keys(cfValueExprByKey).length > 0
1216
+ )
1005
1217
  if (hasJoinedAggregates) {
1006
1218
  q = q.groupBy(`${table}.id`)
1007
1219
  }
@@ -1021,22 +1233,35 @@ export class BasicQueryEngine implements QueryEngine {
1021
1233
  resolvedCustomFieldDefinitions,
1022
1234
  } = await buildQuery('full')
1023
1235
 
1024
- // `count(distinct base.id)` is only required when a join can multiply base rows
1025
- // (CF/extension aggregates, explicit relation joins, or custom-field sources).
1026
- // Without such joins base.id is the unique PK, so `count(*)` is equivalent and
1027
- // lets Postgres skip the redundant DISTINCT sort/hash for an index-only count (#2227).
1028
- const mayMultiplyBaseRows =
1029
- hasJoinedAggregates ||
1030
- (Array.isArray(opts.joins) && opts.joins.length > 0) ||
1031
- (Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0)
1032
- const countExpr = mayMultiplyBaseRows
1033
- ? sql<string>`count(distinct ${sql.ref(`${table}.id`)})`
1034
- : sql<string>`count(*)`
1035
- const countBuilder = hasJoinedAggregates
1036
- ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as('count'))
1037
- : qFull.clearSelect().clearOrderBy().select(countExpr.as('count'))
1038
- const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
1039
- const total = Number((countRow as any)?.count ?? 0)
1236
+ // The count is built independently of the display query (the `'count'`
1237
+ // projection): scope + filters only, cf filters as correlated EXISTS
1238
+ // semi-joins, no projection joins. Nothing can multiply base rows, so
1239
+ // `count(*)` needs no DISTINCT (completing #2227) and when the cap is
1240
+ // active — the LIMIT sits on a row-producing inner query with no
1241
+ // aggregate/sort barrier below it, so it actually bounds the scan.
1242
+ const countCap = resolveListCountCap()
1243
+ const { builder: countShape } = await buildQuery('count')
1244
+ let total: number
1245
+ let listCountCapWarning: ListCountCapWarning | undefined
1246
+ if (countCap !== null) {
1247
+ const probe = countShape.select(sql<number>`1`.as('one')).limit(countCap + 1)
1248
+ const countRow = await db
1249
+ .selectFrom(probe.as('om_count_probe') as any)
1250
+ .select(sql<string>`count(*)`.as('count'))
1251
+ .executeTakeFirst() as { count: unknown } | undefined
1252
+ const probed = Number((countRow as any)?.count ?? 0)
1253
+ if (probed > countCap) {
1254
+ total = countCap
1255
+ listCountCapWarning = { entity, cap: countCap }
1256
+ } else {
1257
+ total = probed
1258
+ }
1259
+ } else {
1260
+ const countRow = await countShape
1261
+ .select(sql<string>`count(*)`.as('count'))
1262
+ .executeTakeFirst() as { count: unknown } | undefined
1263
+ total = Number((countRow as any)?.count ?? 0)
1264
+ }
1040
1265
 
1041
1266
  const svc = encryptionService
1042
1267
  const decryptPayload =
@@ -1097,9 +1322,14 @@ export class BasicQueryEngine implements QueryEngine {
1097
1322
  const cap = resolveEncryptedSortMaxRows()
1098
1323
  let qSort = (await buildQuery('sortKeys')).builder
1099
1324
  if (cap !== null) {
1100
- qSort = qSort.limit(cap).orderBy(qualify('id'), 'asc' as any)
1325
+ // Probe one row past the cap: truncation is detected from the candidate
1326
+ // scan itself, not by comparing against `total` — which may itself be
1327
+ // capped (`OM_LIST_COUNT_CAP`) and would then never exceed the sort cap.
1328
+ qSort = qSort.limit(cap + 1).orderBy(qualify('id'), 'asc' as any)
1101
1329
  }
1102
- const candidateRows = await qSort.execute() as ResultRow[]
1330
+ const candidateRowsRaw = await qSort.execute() as ResultRow[]
1331
+ const sortTruncated = cap !== null && candidateRowsRaw.length > cap
1332
+ const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw
1103
1333
  const decryptedCandidates = decryptPayload
1104
1334
  ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
1105
1335
  : candidateRows
@@ -1108,7 +1338,7 @@ export class BasicQueryEngine implements QueryEngine {
1108
1338
  .slice((page - 1) * pageSize, page * pageSize)
1109
1339
  .map((row) => row.id)
1110
1340
 
1111
- if (cap !== null && total > cap) {
1341
+ if (sortTruncated && cap !== null) {
1112
1342
  encryptedSortRowCapWarning = {
1113
1343
  entity,
1114
1344
  sortFields: resolvedSorts.map((s) => s.field),
@@ -1144,8 +1374,10 @@ export class BasicQueryEngine implements QueryEngine {
1144
1374
 
1145
1375
  let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
1146
1376
 
1147
- if (encryptedSortRowCapWarning) {
1148
- const meta: QueryResultMeta = { encryptedSortRowCapWarning }
1377
+ if (encryptedSortRowCapWarning || listCountCapWarning) {
1378
+ const meta: QueryResultMeta = {}
1379
+ if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
1380
+ if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning
1149
1381
  queryResult.meta = meta
1150
1382
  }
1151
1383
 
@@ -1204,6 +1436,143 @@ export class BasicQueryEngine implements QueryEngine {
1204
1436
  }
1205
1437
  }
1206
1438
 
1439
+ /**
1440
+ * Apply a `cf:*` filter as a correlated EXISTS semi-join over
1441
+ * `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
1442
+ * the count shape's equivalent of the projection path's leftJoin + WHERE.
1443
+ * A semi-join returns each base row at most once, so the count query needs
1444
+ * no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
1445
+ *
1446
+ * Predicates satisfied by the *absence* of a value row (`eq null`,
1447
+ * `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
1448
+ * matching the leftJoin form where a missing row yields a NULL expression.
1449
+ */
1450
+ private applyCfValueExistsFilter(
1451
+ q: AnyBuilder,
1452
+ opts: {
1453
+ source: ResolvedCustomFieldSource
1454
+ qualify: (column: string) => string
1455
+ tenantId: string | null
1456
+ key: string
1457
+ op: NormalizedFilter['op']
1458
+ value: unknown
1459
+ },
1460
+ ): AnyBuilder {
1461
+ return q.where((eb: any) => this.buildCfValueExistsExpression(eb, opts))
1462
+ }
1463
+
1464
+ /**
1465
+ * Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
1466
+ * inside an OR group can compile to an EXISTS predicate on the count shape
1467
+ * instead of being dropped for lacking a `cfValueExprByKey` entry.
1468
+ */
1469
+ private buildCfValueExistsExpression(
1470
+ eb: any,
1471
+ opts: {
1472
+ source: ResolvedCustomFieldSource
1473
+ qualify: (column: string) => string
1474
+ tenantId: string | null
1475
+ key: string
1476
+ op: NormalizedFilter['op']
1477
+ value: unknown
1478
+ },
1479
+ ): any {
1480
+ const { source, qualify, tenantId, key, op, value } = opts
1481
+ const seq = this.searchAliasSeq++
1482
+ const valAlias = `cfev_${seq}`
1483
+ const defAlias = `cfed_${seq}`
1484
+ const srcAlias = `cfes_${seq}`
1485
+ const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
1486
+ WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
1487
+ WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
1488
+ WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
1489
+ WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
1490
+ ELSE (${sql.ref(`${valAlias}.value_text`)})::text
1491
+ END`
1492
+
1493
+ const buildSub = (eb: any): AnyBuilder => {
1494
+ let sub: AnyBuilder = eb
1495
+ .selectFrom(`custom_field_values as ${valAlias}`)
1496
+ .select(sql<number>`1`.as('one'))
1497
+ .leftJoin(`custom_field_defs as ${defAlias}`, (jb: any) =>
1498
+ jb.on(`${defAlias}.entity_id`, '=', String(source.entityId))
1499
+ .on(`${defAlias}.key`, '=', key)
1500
+ .on(`${defAlias}.is_active`, '=', true)
1501
+ .on((jeb: any) => jeb.or([
1502
+ jeb(`${defAlias}.tenant_id`, '=', tenantId),
1503
+ jeb(`${defAlias}.tenant_id`, 'is', null),
1504
+ ])))
1505
+ .where(`${valAlias}.entity_id`, '=', String(source.entityId))
1506
+ .where(`${valAlias}.field_key`, '=', key)
1507
+ .where((web: any) => web.or([
1508
+ web(`${valAlias}.tenant_id`, '=', tenantId),
1509
+ web(`${valAlias}.tenant_id`, 'is', null),
1510
+ ]))
1511
+ if (source.hop) {
1512
+ sub = sub
1513
+ .innerJoin(`${source.table} as ${srcAlias}`, (jb: any) =>
1514
+ jb.on(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop!.recordIdColumn}`)})::text`))
1515
+ .whereRef(`${srcAlias}.${source.hop.toField}`, '=', qualify(source.hop.fromField))
1516
+ } else {
1517
+ sub = sub.where(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`)
1518
+ }
1519
+ return sub
1520
+ }
1521
+
1522
+ const absenceSatisfiable = (op === 'eq' && value === null) || (op === 'exists' && !value)
1523
+ if (absenceSatisfiable) {
1524
+ return eb.or([
1525
+ eb.not(eb.exists(buildSub(eb))),
1526
+ eb.exists(buildSub(eb).where(sql<boolean>`${caseExpr} is null`)),
1527
+ ])
1528
+ }
1529
+
1530
+ let predicate: RawBuilder<boolean> | null = null
1531
+ switch (op) {
1532
+ case 'eq':
1533
+ predicate = sql<boolean>`${caseExpr} = ${value}`
1534
+ break
1535
+ case 'ne':
1536
+ predicate = value === null
1537
+ ? sql<boolean>`${caseExpr} is not null`
1538
+ : sql<boolean>`${caseExpr} != ${value}`
1539
+ break
1540
+ case 'gt':
1541
+ case 'gte':
1542
+ case 'lt':
1543
+ case 'lte': {
1544
+ const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
1545
+ predicate = sql<boolean>`${caseExpr} ${operator} ${value}`
1546
+ break
1547
+ }
1548
+ case 'in': {
1549
+ const vals = Array.isArray(value) ? value : [value]
1550
+ predicate = sql<boolean>`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
1551
+ break
1552
+ }
1553
+ case 'nin': {
1554
+ const vals = Array.isArray(value) ? value : [value]
1555
+ predicate = sql<boolean>`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
1556
+ break
1557
+ }
1558
+ case 'like':
1559
+ predicate = sql<boolean>`${caseExpr} like ${value}`
1560
+ break
1561
+ case 'ilike':
1562
+ predicate = sql<boolean>`${caseExpr} ilike ${value}`
1563
+ break
1564
+ case 'exists':
1565
+ predicate = sql<boolean>`${caseExpr} is not null`
1566
+ break
1567
+ default:
1568
+ // Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
1569
+ // predicate, so full and count shapes drop the same leaves.
1570
+ return eb.val(true)
1571
+ }
1572
+ const captured = predicate
1573
+ return eb.exists(buildSub(eb).where(captured))
1574
+ }
1575
+
1207
1576
  private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
1208
1577
  switch (op) {
1209
1578
  case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
@@ -1229,11 +1598,9 @@ export class BasicQueryEngine implements QueryEngine {
1229
1598
 
1230
1599
  private async columnExists(table: string, column: string): Promise<boolean> {
1231
1600
  const key = `${table}.${column}`
1232
- if (this.columnCache.has(key)) {
1233
- const cached = this.columnCache.get(key)
1234
- if (cached === true) return true
1235
- this.columnCache.delete(key)
1236
- }
1601
+ const ttlMs = resolveColumnExistsCacheTtlMs()
1602
+ const cached = columnExistsCache.get(key)
1603
+ if (cached && cached.expiresAt > Date.now()) return cached.value
1237
1604
  const db = this.getDb()
1238
1605
  const exists = await db
1239
1606
  .selectFrom('information_schema.columns' as any)
@@ -1243,8 +1610,7 @@ export class BasicQueryEngine implements QueryEngine {
1243
1610
  .limit(1)
1244
1611
  .executeTakeFirst()
1245
1612
  const present = !!exists
1246
- if (present) this.columnCache.set(key, true)
1247
- else this.columnCache.delete(key)
1613
+ if (ttlMs > 0) storeColumnExists(key, present, ttlMs)
1248
1614
  return present
1249
1615
  }
1250
1616
 
@@ -1451,6 +1817,7 @@ export class BasicQueryEngine implements QueryEngine {
1451
1817
  db: AnyDb,
1452
1818
  opts: QueryOptions,
1453
1819
  qualify: (column: string) => string,
1820
+ attachJoins: boolean = true,
1454
1821
  ): { builder: AnyBuilder; sources: ResolvedCustomFieldSource[] } {
1455
1822
  const sources: ResolvedCustomFieldSource[] = [
1456
1823
  {
@@ -1469,15 +1836,28 @@ export class BasicQueryEngine implements QueryEngine {
1469
1836
  if (!join) {
1470
1837
  throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
1471
1838
  }
1472
- const joinFn = (join.type ?? 'left') === 'inner' ? 'innerJoin' : 'leftJoin'
1473
- next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
1474
- jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
1839
+ const joinType: 'left' | 'inner' = (join.type ?? 'left') === 'inner' ? 'inner' : 'left'
1840
+ if (attachJoins) {
1841
+ const joinFn = joinType === 'inner' ? 'innerJoin' : 'leftJoin'
1842
+ next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
1843
+ jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
1844
+ } else if (joinType === 'inner') {
1845
+ // The count projection carries no projection joins, but an inner-typed
1846
+ // source join restricts the result set — preserve that as a semi-join.
1847
+ next = next.where((eb: any) => eb.exists(
1848
+ eb
1849
+ .selectFrom(`${joinTable} as ${alias}`)
1850
+ .select(sql<number>`1`.as('one'))
1851
+ .whereRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)),
1852
+ ))
1853
+ }
1475
1854
  const recordColumn = srcOpt.recordIdColumn ?? 'id'
1476
1855
  sources.push({
1477
1856
  entityId: srcOpt.entityId,
1478
1857
  alias,
1479
1858
  table: joinTable,
1480
1859
  recordIdExpr: sql<string>`${sql.ref(`${alias}.${recordColumn}`)}::text`,
1860
+ hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType },
1481
1861
  })
1482
1862
  })
1483
1863
  return { builder: next, sources }