@open-mercato/shared 0.6.8-develop.7030.1.b84302d973 → 0.6.8-develop.7031.1.005201cd70

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.
@@ -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'
@@ -31,6 +31,7 @@ import {
31
31
  } from '../crud/custom-field-definition-index'
32
32
  import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
33
33
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
34
+ import { resolveListCountCap } from './count-cap'
34
35
  import { mapWithConcurrency } from './bounded-decrypt'
35
36
  import { createLogger } from '../logger'
36
37
 
@@ -93,6 +94,12 @@ type ResolvedCustomFieldSource = {
93
94
  alias: string
94
95
  table: string
95
96
  recordIdExpr: RawBuilder<string>
97
+ /**
98
+ * The base→source join edge for joined sources (absent on the base source).
99
+ * The count projection uses it to correlate cf-value EXISTS subqueries
100
+ * without attaching the source join to the outer query.
101
+ */
102
+ hop?: { fromField: string; toField: string; recordIdColumn: string; type: 'left' | 'inner' }
96
103
  }
97
104
 
98
105
  type ResultRow = Record<string, unknown>
@@ -649,11 +656,15 @@ export class BasicQueryEngine implements QueryEngine {
649
656
  // Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
650
657
  // today's complete selection (base fields + CF projections + extension joins).
651
658
  // `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
652
- // candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
653
- // twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
654
- // so the second pass hits no extra DB calls.
655
- const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
659
+ // candidate scan used when `requiresPlaintextSort`. `projection: 'count'` carries
660
+ // scope + filters only: projection joins (CF defs/values, extensions) are omitted
661
+ // and cf filters are expressed as correlated EXISTS semi-joins, so nothing can
662
+ // multiply base rows and a LIMIT above the query is an enforceable bound.
663
+ // Re-running the WHERE/JOIN logic per projection is cheap: every `columnExists`
664
+ // check is memoized on `this.columnCache`, so later passes hit no extra DB calls.
665
+ const buildQuery = async (projection: 'full' | 'sortKeys' | 'count'): Promise<BuiltQuery> => {
656
666
  const isSortKeysProjection = projection === 'sortKeys'
667
+ const isCountProjection = projection === 'count'
657
668
  let q: AnyBuilder = db.selectFrom(table as any)
658
669
 
659
670
  // Tenant/org/soft-delete scope
@@ -745,7 +756,10 @@ export class BasicQueryEngine implements QueryEngine {
745
756
  })
746
757
 
747
758
  // Selection (base columns only here; cf:* handled later)
748
- if (isSortKeysProjection) {
759
+ if (isCountProjection) {
760
+ // The caller owns the count query's SELECT (a constant inside the
761
+ // bounded subquery, or the aggregate itself when the cap is off).
762
+ } else if (isSortKeysProjection) {
749
763
  q = q.select(sql.ref(qualify('id')).as('id'))
750
764
  if (await this.columnExists(table, 'tenant_id')) {
751
765
  q = q.select(sql.ref(qualify('tenant_id')).as('tenant_id'))
@@ -771,14 +785,14 @@ export class BasicQueryEngine implements QueryEngine {
771
785
  }
772
786
 
773
787
  // Resolve which custom fields to include
774
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
788
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection)
775
789
  q = cfSourcesResult.builder
776
790
  const cfSources = cfSourcesResult.sources
777
791
  const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
778
792
  for (const source of cfSources) {
779
793
  entityIdToSource.set(String(source.entityId), source)
780
794
  }
781
- const requestedCustomFieldKeys = (!isSortKeysProjection && Array.isArray(opts.includeCustomFields))
795
+ const requestedCustomFieldKeys = (projection === 'full' && Array.isArray(opts.includeCustomFields))
782
796
  ? opts.includeCustomFields.map((key) => String(key))
783
797
  : []
784
798
  const cfKeys = new Set<string>()
@@ -788,7 +802,7 @@ export class BasicQueryEngine implements QueryEngine {
788
802
  // Output-only — never resolved for the slim sortKeys projection.
789
803
  let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
790
804
  // Explicit in fields/filters
791
- if (!isSortKeysProjection) {
805
+ if (projection === 'full') {
792
806
  for (const f of (opts.fields || [])) {
793
807
  if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
794
808
  }
@@ -796,7 +810,7 @@ export class BasicQueryEngine implements QueryEngine {
796
810
  for (const f of cfFilters) {
797
811
  if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
798
812
  }
799
- if (!isSortKeysProjection && opts.includeCustomFields === true) {
813
+ if (projection === 'full' && opts.includeCustomFields === true) {
800
814
  if (entityIdToSource.size > 0) {
801
815
  const entityIdList = Array.from(entityIdToSource.keys())
802
816
  const entityOrder = new Map<string, number>()
@@ -923,6 +937,9 @@ export class BasicQueryEngine implements QueryEngine {
923
937
  for (const key of cfKeys) {
924
938
  const source = keySource.get(key)
925
939
  if (!source) continue
940
+ // The count shape never joins defs/values — cf filters are applied as
941
+ // correlated EXISTS semi-joins below, so no join can multiply base rows.
942
+ if (isCountProjection) continue
926
943
  const entityIdForKey = source.entityId
927
944
  const recordIdExpr = source.recordIdExpr
928
945
  const sourceAliasSafe = sanitize(source.alias || 'src')
@@ -976,13 +993,16 @@ export class BasicQueryEngine implements QueryEngine {
976
993
  }
977
994
  }
978
995
 
979
- // Apply cf:* filters (on raw expressions). OR-grouped ones are excluded here and
980
- // combined with their disjunct's other leaves right below.
996
+ // Apply cf:* filters (on raw expressions; as EXISTS semi-joins for the count
997
+ // shape). OR-grouped ones are excluded here and combined with their
998
+ // disjunct's other leaves right below.
981
999
  for (const f of regularCfFilters) {
982
1000
  if (!f.field.startsWith('cf:')) continue
983
1001
  const key = f.field.slice(3)
1002
+ const filterSource = keySource.get(key)
1003
+ if (!filterSource) continue
984
1004
  const expr = cfValueExprByKey[key]
985
- if (!expr) continue
1005
+ if (!isCountProjection && !expr) continue
986
1006
  if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
987
1007
  const tokens = tokenizeText(String(f.value), searchConfig)
988
1008
  const hashes = tokens.hashes
@@ -1017,6 +1037,17 @@ export class BasicQueryEngine implements QueryEngine {
1017
1037
  })
1018
1038
  }
1019
1039
  }
1040
+ if (isCountProjection) {
1041
+ q = this.applyCfValueExistsFilter(q, {
1042
+ source: filterSource,
1043
+ qualify,
1044
+ tenantId: tenantId ?? null,
1045
+ key,
1046
+ op: f.op,
1047
+ value: f.value,
1048
+ })
1049
+ continue
1050
+ }
1020
1051
  q = this.applyColumnOp(q, expr, f.op, f.value)
1021
1052
  }
1022
1053
 
@@ -1031,15 +1062,35 @@ export class BasicQueryEngine implements QueryEngine {
1031
1062
  // `ilike` through the search-token index the way the ungrouped path does. On a
1032
1063
  // field covered by an encryption map such a leaf therefore compares against
1033
1064
  // ciphertext and will not match.
1065
+ //
1066
+ // The count shape never populates cfValueExprByKey (it joins no cf tables), so
1067
+ // its applicability test is key resolution itself — the same condition that
1068
+ // gates the full shape's expression map — and a cf leaf compiles to a
1069
+ // correlated EXISTS instead of a value-expression comparison. Dropping it
1070
+ // instead would narrow the OR and undercount relative to the display query.
1071
+ const cfLeafApplicable = (key: string): boolean =>
1072
+ isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key])
1034
1073
  const applicableGroupFilters = resolvedGroupFilters
1035
- .map((group) => group.filter((rf) => rf.kind !== 'cf' || Boolean(cfValueExprByKey[rf.key])))
1074
+ .map((group) => group.filter((rf) => rf.kind !== 'cf' || cfLeafApplicable(rf.key)))
1036
1075
  .filter((group) => group.length > 0)
1037
1076
  if (applicableGroupFilters.length > 0) {
1038
1077
  q = q.where((eb: any) => {
1039
1078
  const disjuncts = applicableGroupFilters.map((group) => {
1040
1079
  const parts = group.map((rf) => {
1041
1080
  if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
1042
- if (rf.kind === 'cf') return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1081
+ if (rf.kind === 'cf') {
1082
+ if (isCountProjection) {
1083
+ return this.buildCfValueExistsExpression(eb, {
1084
+ source: keySource.get(rf.key)!,
1085
+ qualify,
1086
+ tenantId: tenantId ?? null,
1087
+ key: rf.key,
1088
+ op: rf.op,
1089
+ value: rf.value,
1090
+ })
1091
+ }
1092
+ return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
1093
+ }
1043
1094
  return this.buildIndexDocOpExpression(eb, {
1044
1095
  entity: String(entity),
1045
1096
  field: rf.field,
@@ -1057,8 +1108,9 @@ export class BasicQueryEngine implements QueryEngine {
1057
1108
  })
1058
1109
  }
1059
1110
 
1060
- // Entity extensions joins (no selection yet; enables future filters/projections)
1061
- if (opts.includeExtensions) {
1111
+ // Entity extensions joins (no selection yet; enables future filters/projections).
1112
+ // Projection-only, so the count shape omits them.
1113
+ if (opts.includeExtensions && !isCountProjection) {
1062
1114
  const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
1063
1115
  const allMods = getModules() as any[]
1064
1116
  const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
@@ -1085,7 +1137,7 @@ export class BasicQueryEngine implements QueryEngine {
1085
1137
  }
1086
1138
 
1087
1139
  // Sorting: base fields and cf:* (use aggregated alias for cf)
1088
- for (const s of resolvedSorts) {
1140
+ for (const s of isCountProjection ? [] : resolvedSorts) {
1089
1141
  if (s.field.startsWith('cf:')) {
1090
1142
  const key = s.field.slice(3)
1091
1143
  const alias = sanitize(`cf:${key}`)
@@ -1103,8 +1155,12 @@ export class BasicQueryEngine implements QueryEngine {
1103
1155
  }
1104
1156
  }
1105
1157
 
1106
- // Deduplicate if we joined CFs or extensions by grouping on base id
1107
- const hasJoinedAggregates = (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0
1158
+ // Deduplicate if we joined CFs or extensions by grouping on base id. The count
1159
+ // shape has neither, and must stay barrier-free for its LIMIT to bind.
1160
+ const hasJoinedAggregates = !isCountProjection && (
1161
+ (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) ||
1162
+ Object.keys(cfValueExprByKey).length > 0
1163
+ )
1108
1164
  if (hasJoinedAggregates) {
1109
1165
  q = q.groupBy(`${table}.id`)
1110
1166
  }
@@ -1124,22 +1180,35 @@ export class BasicQueryEngine implements QueryEngine {
1124
1180
  resolvedCustomFieldDefinitions,
1125
1181
  } = await buildQuery('full')
1126
1182
 
1127
- // `count(distinct base.id)` is only required when a join can multiply base rows
1128
- // (CF/extension aggregates, explicit relation joins, or custom-field sources).
1129
- // Without such joins base.id is the unique PK, so `count(*)` is equivalent and
1130
- // lets Postgres skip the redundant DISTINCT sort/hash for an index-only count (#2227).
1131
- const mayMultiplyBaseRows =
1132
- hasJoinedAggregates ||
1133
- (Array.isArray(opts.joins) && opts.joins.length > 0) ||
1134
- (Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0)
1135
- const countExpr = mayMultiplyBaseRows
1136
- ? sql<string>`count(distinct ${sql.ref(`${table}.id`)})`
1137
- : sql<string>`count(*)`
1138
- const countBuilder = hasJoinedAggregates
1139
- ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as('count'))
1140
- : qFull.clearSelect().clearOrderBy().select(countExpr.as('count'))
1141
- const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
1142
- const total = Number((countRow as any)?.count ?? 0)
1183
+ // The count is built independently of the display query (the `'count'`
1184
+ // projection): scope + filters only, cf filters as correlated EXISTS
1185
+ // semi-joins, no projection joins. Nothing can multiply base rows, so
1186
+ // `count(*)` needs no DISTINCT (completing #2227) and — when the cap is
1187
+ // active — the LIMIT sits on a row-producing inner query with no
1188
+ // aggregate/sort barrier below it, so it actually bounds the scan.
1189
+ const countCap = resolveListCountCap()
1190
+ const { builder: countShape } = await buildQuery('count')
1191
+ let total: number
1192
+ let listCountCapWarning: ListCountCapWarning | undefined
1193
+ if (countCap !== null) {
1194
+ const probe = countShape.select(sql<number>`1`.as('one')).limit(countCap + 1)
1195
+ const countRow = await db
1196
+ .selectFrom(probe.as('om_count_probe') as any)
1197
+ .select(sql<string>`count(*)`.as('count'))
1198
+ .executeTakeFirst() as { count: unknown } | undefined
1199
+ const probed = Number((countRow as any)?.count ?? 0)
1200
+ if (probed > countCap) {
1201
+ total = countCap
1202
+ listCountCapWarning = { entity, cap: countCap }
1203
+ } else {
1204
+ total = probed
1205
+ }
1206
+ } else {
1207
+ const countRow = await countShape
1208
+ .select(sql<string>`count(*)`.as('count'))
1209
+ .executeTakeFirst() as { count: unknown } | undefined
1210
+ total = Number((countRow as any)?.count ?? 0)
1211
+ }
1143
1212
 
1144
1213
  const svc = encryptionService
1145
1214
  const decryptPayload =
@@ -1200,9 +1269,14 @@ export class BasicQueryEngine implements QueryEngine {
1200
1269
  const cap = resolveEncryptedSortMaxRows()
1201
1270
  let qSort = (await buildQuery('sortKeys')).builder
1202
1271
  if (cap !== null) {
1203
- qSort = qSort.limit(cap).orderBy(qualify('id'), 'asc' as any)
1272
+ // Probe one row past the cap: truncation is detected from the candidate
1273
+ // scan itself, not by comparing against `total` — which may itself be
1274
+ // capped (`OM_LIST_COUNT_CAP`) and would then never exceed the sort cap.
1275
+ qSort = qSort.limit(cap + 1).orderBy(qualify('id'), 'asc' as any)
1204
1276
  }
1205
- const candidateRows = await qSort.execute() as ResultRow[]
1277
+ const candidateRowsRaw = await qSort.execute() as ResultRow[]
1278
+ const sortTruncated = cap !== null && candidateRowsRaw.length > cap
1279
+ const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw
1206
1280
  const decryptedCandidates = decryptPayload
1207
1281
  ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
1208
1282
  : candidateRows
@@ -1211,7 +1285,7 @@ export class BasicQueryEngine implements QueryEngine {
1211
1285
  .slice((page - 1) * pageSize, page * pageSize)
1212
1286
  .map((row) => row.id)
1213
1287
 
1214
- if (cap !== null && total > cap) {
1288
+ if (sortTruncated && cap !== null) {
1215
1289
  encryptedSortRowCapWarning = {
1216
1290
  entity,
1217
1291
  sortFields: resolvedSorts.map((s) => s.field),
@@ -1247,8 +1321,10 @@ export class BasicQueryEngine implements QueryEngine {
1247
1321
 
1248
1322
  let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
1249
1323
 
1250
- if (encryptedSortRowCapWarning) {
1251
- const meta: QueryResultMeta = { encryptedSortRowCapWarning }
1324
+ if (encryptedSortRowCapWarning || listCountCapWarning) {
1325
+ const meta: QueryResultMeta = {}
1326
+ if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
1327
+ if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning
1252
1328
  queryResult.meta = meta
1253
1329
  }
1254
1330
 
@@ -1307,6 +1383,143 @@ export class BasicQueryEngine implements QueryEngine {
1307
1383
  }
1308
1384
  }
1309
1385
 
1386
+ /**
1387
+ * Apply a `cf:*` filter as a correlated EXISTS semi-join over
1388
+ * `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
1389
+ * the count shape's equivalent of the projection path's leftJoin + WHERE.
1390
+ * A semi-join returns each base row at most once, so the count query needs
1391
+ * no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
1392
+ *
1393
+ * Predicates satisfied by the *absence* of a value row (`eq null`,
1394
+ * `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
1395
+ * matching the leftJoin form where a missing row yields a NULL expression.
1396
+ */
1397
+ private applyCfValueExistsFilter(
1398
+ q: AnyBuilder,
1399
+ opts: {
1400
+ source: ResolvedCustomFieldSource
1401
+ qualify: (column: string) => string
1402
+ tenantId: string | null
1403
+ key: string
1404
+ op: NormalizedFilter['op']
1405
+ value: unknown
1406
+ },
1407
+ ): AnyBuilder {
1408
+ return q.where((eb: any) => this.buildCfValueExistsExpression(eb, opts))
1409
+ }
1410
+
1411
+ /**
1412
+ * Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
1413
+ * inside an OR group can compile to an EXISTS predicate on the count shape
1414
+ * instead of being dropped for lacking a `cfValueExprByKey` entry.
1415
+ */
1416
+ private buildCfValueExistsExpression(
1417
+ eb: any,
1418
+ opts: {
1419
+ source: ResolvedCustomFieldSource
1420
+ qualify: (column: string) => string
1421
+ tenantId: string | null
1422
+ key: string
1423
+ op: NormalizedFilter['op']
1424
+ value: unknown
1425
+ },
1426
+ ): any {
1427
+ const { source, qualify, tenantId, key, op, value } = opts
1428
+ const seq = this.searchAliasSeq++
1429
+ const valAlias = `cfev_${seq}`
1430
+ const defAlias = `cfed_${seq}`
1431
+ const srcAlias = `cfes_${seq}`
1432
+ const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
1433
+ WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
1434
+ WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
1435
+ WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
1436
+ WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
1437
+ ELSE (${sql.ref(`${valAlias}.value_text`)})::text
1438
+ END`
1439
+
1440
+ const buildSub = (eb: any): AnyBuilder => {
1441
+ let sub: AnyBuilder = eb
1442
+ .selectFrom(`custom_field_values as ${valAlias}`)
1443
+ .select(sql<number>`1`.as('one'))
1444
+ .leftJoin(`custom_field_defs as ${defAlias}`, (jb: any) =>
1445
+ jb.on(`${defAlias}.entity_id`, '=', String(source.entityId))
1446
+ .on(`${defAlias}.key`, '=', key)
1447
+ .on(`${defAlias}.is_active`, '=', true)
1448
+ .on((jeb: any) => jeb.or([
1449
+ jeb(`${defAlias}.tenant_id`, '=', tenantId),
1450
+ jeb(`${defAlias}.tenant_id`, 'is', null),
1451
+ ])))
1452
+ .where(`${valAlias}.entity_id`, '=', String(source.entityId))
1453
+ .where(`${valAlias}.field_key`, '=', key)
1454
+ .where((web: any) => web.or([
1455
+ web(`${valAlias}.tenant_id`, '=', tenantId),
1456
+ web(`${valAlias}.tenant_id`, 'is', null),
1457
+ ]))
1458
+ if (source.hop) {
1459
+ sub = sub
1460
+ .innerJoin(`${source.table} as ${srcAlias}`, (jb: any) =>
1461
+ jb.on(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop!.recordIdColumn}`)})::text`))
1462
+ .whereRef(`${srcAlias}.${source.hop.toField}`, '=', qualify(source.hop.fromField))
1463
+ } else {
1464
+ sub = sub.where(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`)
1465
+ }
1466
+ return sub
1467
+ }
1468
+
1469
+ const absenceSatisfiable = (op === 'eq' && value === null) || (op === 'exists' && !value)
1470
+ if (absenceSatisfiable) {
1471
+ return eb.or([
1472
+ eb.not(eb.exists(buildSub(eb))),
1473
+ eb.exists(buildSub(eb).where(sql<boolean>`${caseExpr} is null`)),
1474
+ ])
1475
+ }
1476
+
1477
+ let predicate: RawBuilder<boolean> | null = null
1478
+ switch (op) {
1479
+ case 'eq':
1480
+ predicate = sql<boolean>`${caseExpr} = ${value}`
1481
+ break
1482
+ case 'ne':
1483
+ predicate = value === null
1484
+ ? sql<boolean>`${caseExpr} is not null`
1485
+ : sql<boolean>`${caseExpr} != ${value}`
1486
+ break
1487
+ case 'gt':
1488
+ case 'gte':
1489
+ case 'lt':
1490
+ case 'lte': {
1491
+ const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
1492
+ predicate = sql<boolean>`${caseExpr} ${operator} ${value}`
1493
+ break
1494
+ }
1495
+ case 'in': {
1496
+ const vals = Array.isArray(value) ? value : [value]
1497
+ predicate = sql<boolean>`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
1498
+ break
1499
+ }
1500
+ case 'nin': {
1501
+ const vals = Array.isArray(value) ? value : [value]
1502
+ predicate = sql<boolean>`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
1503
+ break
1504
+ }
1505
+ case 'like':
1506
+ predicate = sql<boolean>`${caseExpr} like ${value}`
1507
+ break
1508
+ case 'ilike':
1509
+ predicate = sql<boolean>`${caseExpr} ilike ${value}`
1510
+ break
1511
+ case 'exists':
1512
+ predicate = sql<boolean>`${caseExpr} is not null`
1513
+ break
1514
+ default:
1515
+ // Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
1516
+ // predicate, so full and count shapes drop the same leaves.
1517
+ return eb.val(true)
1518
+ }
1519
+ const captured = predicate
1520
+ return eb.exists(buildSub(eb).where(captured))
1521
+ }
1522
+
1310
1523
  private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
1311
1524
  switch (op) {
1312
1525
  case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
@@ -1554,6 +1767,7 @@ export class BasicQueryEngine implements QueryEngine {
1554
1767
  db: AnyDb,
1555
1768
  opts: QueryOptions,
1556
1769
  qualify: (column: string) => string,
1770
+ attachJoins: boolean = true,
1557
1771
  ): { builder: AnyBuilder; sources: ResolvedCustomFieldSource[] } {
1558
1772
  const sources: ResolvedCustomFieldSource[] = [
1559
1773
  {
@@ -1572,15 +1786,28 @@ export class BasicQueryEngine implements QueryEngine {
1572
1786
  if (!join) {
1573
1787
  throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
1574
1788
  }
1575
- const joinFn = (join.type ?? 'left') === 'inner' ? 'innerJoin' : 'leftJoin'
1576
- next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
1577
- jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
1789
+ const joinType: 'left' | 'inner' = (join.type ?? 'left') === 'inner' ? 'inner' : 'left'
1790
+ if (attachJoins) {
1791
+ const joinFn = joinType === 'inner' ? 'innerJoin' : 'leftJoin'
1792
+ next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
1793
+ jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
1794
+ } else if (joinType === 'inner') {
1795
+ // The count projection carries no projection joins, but an inner-typed
1796
+ // source join restricts the result set — preserve that as a semi-join.
1797
+ next = next.where((eb: any) => eb.exists(
1798
+ eb
1799
+ .selectFrom(`${joinTable} as ${alias}`)
1800
+ .select(sql<number>`1`.as('one'))
1801
+ .whereRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)),
1802
+ ))
1803
+ }
1578
1804
  const recordColumn = srcOpt.recordIdColumn ?? 'id'
1579
1805
  sources.push({
1580
1806
  entityId: srcOpt.entityId,
1581
1807
  alias,
1582
1808
  table: joinTable,
1583
1809
  recordIdExpr: sql<string>`${sql.ref(`${alias}.${recordColumn}`)}::text`,
1810
+ hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType },
1584
1811
  })
1585
1812
  })
1586
1813
  return { builder: next, sources }
@@ -151,12 +151,27 @@ export type EncryptedSortRowCapWarning = {
151
151
  entity: EntityId
152
152
  sortFields: string[]
153
153
  maxRows: number
154
+ /**
155
+ * Exact when `meta.listCountCapWarning` is absent; a floor when it is
156
+ * present (the list total itself was bounded at the cap).
157
+ */
154
158
  totalMatched: number
155
159
  }
156
160
 
161
+ /**
162
+ * Present when the list COUNT was bounded at `cap` matching rows
163
+ * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on
164
+ * CRUD list payloads as `totalIsCapped: true`.
165
+ */
166
+ export type ListCountCapWarning = {
167
+ entity: EntityId
168
+ cap: number
169
+ }
170
+
157
171
  export type QueryResultMeta = {
158
172
  partialIndexWarning?: PartialIndexWarning
159
173
  encryptedSortRowCapWarning?: EncryptedSortRowCapWarning
174
+ listCountCapWarning?: ListCountCapWarning
160
175
  }
161
176
 
162
177
  export type QueryResult<T = any> = {