@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
@@ -1,4 +1,4 @@
1
- import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, ListCountCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
1
+ import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, 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,8 +20,6 @@ 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'
25
23
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
26
24
  import {
27
25
  buildCustomFieldDefinitionIndexFromRows,
@@ -31,9 +29,7 @@ import {
31
29
  } from '../crud/custom-field-definition-index'
32
30
  import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
33
31
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
34
- import { resolveListCountCap } from './count-cap'
35
32
  import { mapWithConcurrency } from './bounded-decrypt'
36
- import { parseNumberWithDefault } from '../number'
37
33
  import { createLogger } from '../logger'
38
34
 
39
35
  const logger = createLogger('shared').child({ component: 'query' })
@@ -47,112 +43,15 @@ const entityTableCache = new Map<string, string>()
47
43
 
48
44
  type EncryptionResolver = () => {
49
45
  decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
50
- getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null, options?: { ignoreRuntimeHealth?: boolean }) => Promise<readonly string[]>
46
+ getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
51
47
  isEnabled?: () => boolean
52
48
  } | null
53
49
 
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
-
145
50
  type ResolvedCustomFieldSource = {
146
51
  entityId: EntityId
147
52
  alias: string
148
53
  table: string
149
54
  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' }
156
55
  }
157
56
 
158
57
  type ResultRow = Record<string, unknown>
@@ -324,6 +223,7 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
324
223
  * {@link HybridQueryEngine} when the query index is unavailable or incomplete.
325
224
  */
326
225
  export class BasicQueryEngine implements QueryEngine {
226
+ private columnCache = new Map<string, boolean>()
327
227
  private searchAliasSeq = 0
328
228
  private searchAvailabilityInstance: SearchTokenAvailability | null = null
329
229
 
@@ -445,62 +345,6 @@ export class BasicQueryEngine implements QueryEngine {
445
345
  ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
446
346
  : false
447
347
  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
- }
504
348
  if (searchFilters.length) {
505
349
  const fields = searchFilters.map((filter) => String(filter.field))
506
350
  this.logSearchDebug('search:init', {
@@ -578,13 +422,7 @@ export class BasicQueryEngine implements QueryEngine {
578
422
  searchActive &&
579
423
  typeof value === 'string' &&
580
424
  fieldName &&
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))
425
+ typeof column === 'string'
588
426
  ) {
589
427
  const tokens = tokenizeText(String(value), searchConfig)
590
428
  const hashes = tokens.hashes
@@ -708,16 +546,11 @@ export class BasicQueryEngine implements QueryEngine {
708
546
  // Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
709
547
  // today's complete selection (base fields + CF projections + extension joins).
710
548
  // `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
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> => {
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> => {
719
553
  const isSortKeysProjection = projection === 'sortKeys'
720
- const isCountProjection = projection === 'count'
721
554
  let q: AnyBuilder = db.selectFrom(table as any)
722
555
 
723
556
  // Tenant/org/soft-delete scope
@@ -809,10 +642,7 @@ export class BasicQueryEngine implements QueryEngine {
809
642
  })
810
643
 
811
644
  // Selection (base columns only here; cf:* handled later)
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) {
645
+ if (isSortKeysProjection) {
816
646
  q = q.select(sql.ref(qualify('id')).as('id'))
817
647
  if (await this.columnExists(table, 'tenant_id')) {
818
648
  q = q.select(sql.ref(qualify('tenant_id')).as('tenant_id'))
@@ -838,14 +668,14 @@ export class BasicQueryEngine implements QueryEngine {
838
668
  }
839
669
 
840
670
  // Resolve which custom fields to include
841
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection)
671
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
842
672
  q = cfSourcesResult.builder
843
673
  const cfSources = cfSourcesResult.sources
844
674
  const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
845
675
  for (const source of cfSources) {
846
676
  entityIdToSource.set(String(source.entityId), source)
847
677
  }
848
- const requestedCustomFieldKeys = (projection === 'full' && Array.isArray(opts.includeCustomFields))
678
+ const requestedCustomFieldKeys = (!isSortKeysProjection && Array.isArray(opts.includeCustomFields))
849
679
  ? opts.includeCustomFields.map((key) => String(key))
850
680
  : []
851
681
  const cfKeys = new Set<string>()
@@ -855,7 +685,7 @@ export class BasicQueryEngine implements QueryEngine {
855
685
  // Output-only — never resolved for the slim sortKeys projection.
856
686
  let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
857
687
  // Explicit in fields/filters
858
- if (projection === 'full') {
688
+ if (!isSortKeysProjection) {
859
689
  for (const f of (opts.fields || [])) {
860
690
  if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
861
691
  }
@@ -863,7 +693,7 @@ export class BasicQueryEngine implements QueryEngine {
863
693
  for (const f of cfFilters) {
864
694
  if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
865
695
  }
866
- if (projection === 'full' && opts.includeCustomFields === true) {
696
+ if (!isSortKeysProjection && opts.includeCustomFields === true) {
867
697
  if (entityIdToSource.size > 0) {
868
698
  const entityIdList = Array.from(entityIdToSource.keys())
869
699
  const entityOrder = new Map<string, number>()
@@ -990,9 +820,6 @@ export class BasicQueryEngine implements QueryEngine {
990
820
  for (const key of cfKeys) {
991
821
  const source = keySource.get(key)
992
822
  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
996
823
  const entityIdForKey = source.entityId
997
824
  const recordIdExpr = source.recordIdExpr
998
825
  const sourceAliasSafe = sanitize(source.alias || 'src')
@@ -1046,16 +873,13 @@ export class BasicQueryEngine implements QueryEngine {
1046
873
  }
1047
874
  }
1048
875
 
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.
876
+ // Apply cf:* filters (on raw expressions). OR-grouped ones are excluded here and
877
+ // combined with their disjunct's other leaves right below.
1052
878
  for (const f of regularCfFilters) {
1053
879
  if (!f.field.startsWith('cf:')) continue
1054
880
  const key = f.field.slice(3)
1055
- const filterSource = keySource.get(key)
1056
- if (!filterSource) continue
1057
881
  const expr = cfValueExprByKey[key]
1058
- if (!isCountProjection && !expr) continue
882
+ if (!expr) continue
1059
883
  if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
1060
884
  const tokens = tokenizeText(String(f.value), searchConfig)
1061
885
  const hashes = tokens.hashes
@@ -1090,17 +914,6 @@ export class BasicQueryEngine implements QueryEngine {
1090
914
  })
1091
915
  }
1092
916
  }
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
- }
1104
917
  q = this.applyColumnOp(q, expr, f.op, f.value)
1105
918
  }
1106
919
 
@@ -1115,35 +928,15 @@ export class BasicQueryEngine implements QueryEngine {
1115
928
  // `ilike` through the search-token index the way the ungrouped path does. On a
1116
929
  // field covered by an encryption map such a leaf therefore compares against
1117
930
  // 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])
1126
931
  const applicableGroupFilters = resolvedGroupFilters
1127
- .map((group) => group.filter((rf) => rf.kind !== 'cf' || cfLeafApplicable(rf.key)))
932
+ .map((group) => group.filter((rf) => rf.kind !== 'cf' || Boolean(cfValueExprByKey[rf.key])))
1128
933
  .filter((group) => group.length > 0)
1129
934
  if (applicableGroupFilters.length > 0) {
1130
935
  q = q.where((eb: any) => {
1131
936
  const disjuncts = applicableGroupFilters.map((group) => {
1132
937
  const parts = group.map((rf) => {
1133
938
  if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, 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
- }
939
+ if (rf.kind === 'cf') return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1147
940
  return this.buildIndexDocOpExpression(eb, {
1148
941
  entity: String(entity),
1149
942
  field: rf.field,
@@ -1161,9 +954,8 @@ export class BasicQueryEngine implements QueryEngine {
1161
954
  })
1162
955
  }
1163
956
 
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) {
957
+ // Entity extensions joins (no selection yet; enables future filters/projections)
958
+ if (opts.includeExtensions) {
1167
959
  const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
1168
960
  const allMods = getModules() as any[]
1169
961
  const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
@@ -1190,7 +982,7 @@ export class BasicQueryEngine implements QueryEngine {
1190
982
  }
1191
983
 
1192
984
  // Sorting: base fields and cf:* (use aggregated alias for cf)
1193
- for (const s of isCountProjection ? [] : resolvedSorts) {
985
+ for (const s of resolvedSorts) {
1194
986
  if (s.field.startsWith('cf:')) {
1195
987
  const key = s.field.slice(3)
1196
988
  const alias = sanitize(`cf:${key}`)
@@ -1208,12 +1000,8 @@ export class BasicQueryEngine implements QueryEngine {
1208
1000
  }
1209
1001
  }
1210
1002
 
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
- )
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
1217
1005
  if (hasJoinedAggregates) {
1218
1006
  q = q.groupBy(`${table}.id`)
1219
1007
  }
@@ -1233,35 +1021,22 @@ export class BasicQueryEngine implements QueryEngine {
1233
1021
  resolvedCustomFieldDefinitions,
1234
1022
  } = await buildQuery('full')
1235
1023
 
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
- }
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)
1265
1040
 
1266
1041
  const svc = encryptionService
1267
1042
  const decryptPayload =
@@ -1322,14 +1097,9 @@ export class BasicQueryEngine implements QueryEngine {
1322
1097
  const cap = resolveEncryptedSortMaxRows()
1323
1098
  let qSort = (await buildQuery('sortKeys')).builder
1324
1099
  if (cap !== null) {
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)
1100
+ qSort = qSort.limit(cap).orderBy(qualify('id'), 'asc' as any)
1329
1101
  }
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
1102
+ const candidateRows = await qSort.execute() as ResultRow[]
1333
1103
  const decryptedCandidates = decryptPayload
1334
1104
  ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
1335
1105
  : candidateRows
@@ -1338,7 +1108,7 @@ export class BasicQueryEngine implements QueryEngine {
1338
1108
  .slice((page - 1) * pageSize, page * pageSize)
1339
1109
  .map((row) => row.id)
1340
1110
 
1341
- if (sortTruncated && cap !== null) {
1111
+ if (cap !== null && total > cap) {
1342
1112
  encryptedSortRowCapWarning = {
1343
1113
  entity,
1344
1114
  sortFields: resolvedSorts.map((s) => s.field),
@@ -1374,10 +1144,8 @@ export class BasicQueryEngine implements QueryEngine {
1374
1144
 
1375
1145
  let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
1376
1146
 
1377
- if (encryptedSortRowCapWarning || listCountCapWarning) {
1378
- const meta: QueryResultMeta = {}
1379
- if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
1380
- if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning
1147
+ if (encryptedSortRowCapWarning) {
1148
+ const meta: QueryResultMeta = { encryptedSortRowCapWarning }
1381
1149
  queryResult.meta = meta
1382
1150
  }
1383
1151
 
@@ -1436,143 +1204,6 @@ export class BasicQueryEngine implements QueryEngine {
1436
1204
  }
1437
1205
  }
1438
1206
 
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
-
1576
1207
  private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
1577
1208
  switch (op) {
1578
1209
  case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
@@ -1598,9 +1229,11 @@ export class BasicQueryEngine implements QueryEngine {
1598
1229
 
1599
1230
  private async columnExists(table: string, column: string): Promise<boolean> {
1600
1231
  const key = `${table}.${column}`
1601
- const ttlMs = resolveColumnExistsCacheTtlMs()
1602
- const cached = columnExistsCache.get(key)
1603
- if (cached && cached.expiresAt > Date.now()) return cached.value
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
+ }
1604
1237
  const db = this.getDb()
1605
1238
  const exists = await db
1606
1239
  .selectFrom('information_schema.columns' as any)
@@ -1610,7 +1243,8 @@ export class BasicQueryEngine implements QueryEngine {
1610
1243
  .limit(1)
1611
1244
  .executeTakeFirst()
1612
1245
  const present = !!exists
1613
- if (ttlMs > 0) storeColumnExists(key, present, ttlMs)
1246
+ if (present) this.columnCache.set(key, true)
1247
+ else this.columnCache.delete(key)
1614
1248
  return present
1615
1249
  }
1616
1250
 
@@ -1817,7 +1451,6 @@ export class BasicQueryEngine implements QueryEngine {
1817
1451
  db: AnyDb,
1818
1452
  opts: QueryOptions,
1819
1453
  qualify: (column: string) => string,
1820
- attachJoins: boolean = true,
1821
1454
  ): { builder: AnyBuilder; sources: ResolvedCustomFieldSource[] } {
1822
1455
  const sources: ResolvedCustomFieldSource[] = [
1823
1456
  {
@@ -1836,28 +1469,15 @@ export class BasicQueryEngine implements QueryEngine {
1836
1469
  if (!join) {
1837
1470
  throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
1838
1471
  }
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
- }
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)))
1854
1475
  const recordColumn = srcOpt.recordIdColumn ?? 'id'
1855
1476
  sources.push({
1856
1477
  entityId: srcOpt.entityId,
1857
1478
  alias,
1858
1479
  table: joinTable,
1859
1480
  recordIdExpr: sql<string>`${sql.ref(`${alias}.${recordColumn}`)}::text`,
1860
- hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType },
1861
1481
  })
1862
1482
  })
1863
1483
  return { builder: next, sources }