@open-mercato/shared 0.6.6-develop.5834.1.dc9eb0615e → 0.6.6-develop.5876.1.6a45c370ac

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, 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,7 +20,10 @@ import {
20
20
  type CustomFieldDefinitionRow,
21
21
  type ResolvedCustomFieldDefinitions,
22
22
  } from '../crud/custom-field-definition-index'
23
- import { resolveEncryptedSortFields, sortRowsInMemory } from './encrypted-sort'
23
+ import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
24
+ import { mapWithConcurrency } from './bounded-decrypt'
25
+
26
+ const DECRYPT_CONCURRENCY = 8
24
27
 
25
28
  type AnyDb = Kysely<any>
26
29
  type AnyBuilder = any
@@ -261,7 +264,6 @@ export class BasicQueryEngine implements QueryEngine {
261
264
  const table = resolveEntityTableName(this.em, entity)
262
265
  const db = this.getDb()
263
266
 
264
- let q: AnyBuilder = db.selectFrom(table as any)
265
267
  const qualify = (col: string) => `${table}.${col}`
266
268
  const orgScope = this.resolveOrganizationScope(opts)
267
269
  this.searchAliasSeq = 0
@@ -274,18 +276,6 @@ export class BasicQueryEngine implements QueryEngine {
274
276
  )
275
277
  }
276
278
  const skipAutoScope = opts.omitAutomaticTenantOrgScope === true
277
- // Optional organization filter (when present in schema)
278
- if (!skipAutoScope && orgScope && await this.columnExists(table, 'organization_id')) {
279
- q = this.applyOrganizationScope(q, qualify('organization_id'), orgScope)
280
- }
281
- // Tenant guard (required) when present in schema
282
- if (!skipAutoScope && await this.columnExists(table, 'tenant_id')) {
283
- q = q.where(qualify('tenant_id'), '=', opts.tenantId)
284
- }
285
- // Default soft-delete guard: exclude rows with deleted_at when column exists
286
- if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
287
- q = q.where(qualify('deleted_at'), 'is', null)
288
- }
289
279
 
290
280
  const normalizedFilters = normalizeFilters(opts.filters)
291
281
  const resolvedJoins = resolveJoins(
@@ -428,65 +418,6 @@ export class BasicQueryEngine implements QueryEngine {
428
418
  const regularBaseFilters = baseFilters.filter((f) => !f.orGroup)
429
419
  const orGroupFilters = baseFilters.filter((f) => f.orGroup)
430
420
 
431
- for (const filter of regularBaseFilters) {
432
- const fieldName = String(filter.field)
433
- let qualified = filter.qualified ?? null
434
- if (!qualified) {
435
- const column = await this.resolveBaseColumn(table, fieldName)
436
- if (!column) {
437
- q = this.applyIndexDocFilter(q, {
438
- entity: String(entity),
439
- field: fieldName,
440
- op: filter.op,
441
- value: filter.value,
442
- recordIdColumn,
443
- tenantId: opts.tenantId ?? null,
444
- organizationScope: orgScope,
445
- withDeleted: opts.withDeleted === true,
446
- searchActive,
447
- searchConfig,
448
- })
449
- continue
450
- }
451
- qualified = qualify(column)
452
- }
453
- q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName)
454
- }
455
-
456
- // OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
457
- if (orGroupFilters.length > 0) {
458
- const groups = new Map<string, typeof orGroupFilters>()
459
- for (const f of orGroupFilters) {
460
- const group = groups.get(f.orGroup!) ?? []
461
- group.push(f)
462
- groups.set(f.orGroup!, group)
463
- }
464
- const resolvedGroupFilters: Array<Array<{ qualified: string; op: string; value: unknown; fieldName: string }>> = []
465
- for (const [, groupFilters] of groups) {
466
- const resolved: Array<{ qualified: string; op: string; value: unknown; fieldName: string }> = []
467
- for (const filter of groupFilters) {
468
- const column = await this.resolveBaseColumn(table, String(filter.field))
469
- if (column) {
470
- resolved.push({
471
- qualified: qualify(column),
472
- op: filter.op,
473
- value: filter.value,
474
- fieldName: String(filter.field),
475
- })
476
- }
477
- }
478
- if (resolved.length > 0) resolvedGroupFilters.push(resolved)
479
- }
480
- if (resolvedGroupFilters.length > 0) {
481
- q = q.where((eb: any) => eb.or(
482
- resolvedGroupFilters.map((group) => {
483
- const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
484
- return parts.length === 1 ? parts[0] : eb.and(parts)
485
- })
486
- ))
487
- }
488
- }
489
-
490
421
  const applyAliasScopes = async (builder: AnyBuilder, aliasName: string): Promise<AnyBuilder> => {
491
422
  const targetTable = aliasTables.get(aliasName)
492
423
  if (!targetTable) return builder
@@ -499,19 +430,6 @@ export class BasicQueryEngine implements QueryEngine {
499
430
  }
500
431
  return next
501
432
  }
502
- q = await applyJoinFilters({
503
- db,
504
- baseTable: table,
505
- builder: q,
506
- joinMap,
507
- joinFilters,
508
- aliasTables,
509
- qualifyBase: (column) => qualify(column),
510
- applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
511
- applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
512
- applyJoinFilterOp,
513
- columnExists: (tbl, column) => this.columnExists(tbl, column),
514
- })
515
433
 
516
434
  const fallbackOrgId =
517
435
  opts.organizationId
@@ -535,315 +453,439 @@ export class BasicQueryEngine implements QueryEngine {
535
453
  )
536
454
  const requiresPlaintextSort = encryptedSortFields.size > 0
537
455
 
538
- // Selection (base columns only here; cf:* handled later)
539
- if (opts.fields && opts.fields.length) {
540
- const cols = new Set(opts.fields.filter((f) => !f.startsWith('cf:')))
541
- if (requiresPlaintextSort) {
542
- for (const field of encryptedSortFields) cols.add(field)
543
- }
544
- for (const c of cols) {
545
- // Qualify and alias to base names to avoid ambiguity
546
- q = q.select(sql.ref(qualify(c)).as(c))
547
- }
548
- } else {
549
- // Default to selecting only base table columns to avoid ambiguity when joining
550
- q = q.select(sql`${sql.ref(table)}.*`.as('__all'))
551
- }
552
-
553
- // Resolve which custom fields to include
554
456
  const tenantId = opts.tenantId
555
457
  const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
556
- const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
557
- q = cfSourcesResult.builder
558
- const cfSources = cfSourcesResult.sources
559
- const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
560
- for (const source of cfSources) {
561
- entityIdToSource.set(String(source.entityId), source)
562
- }
563
- const requestedCustomFieldKeys = Array.isArray(opts.includeCustomFields)
564
- ? opts.includeCustomFields.map((key) => String(key))
565
- : []
566
- const cfKeys = new Set<string>()
567
- const keySource = new Map<string, ResolvedCustomFieldSource>()
568
- // Custom-field definition index threaded onto the result so the CRUD factory
569
- // can decorate list rows without reloading definitions from the DB (#2133).
570
- let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
571
- // Explicit in fields/filters
572
- for (const f of (opts.fields || [])) {
573
- if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
574
- }
575
- for (const f of cfFilters) {
576
- if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
458
+
459
+ type BuiltQuery = {
460
+ builder: AnyBuilder
461
+ hasJoinedAggregates: boolean
462
+ cfJsonAliases: Set<string>
463
+ cfMultiAliasByAlias: Map<string, string>
464
+ resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
577
465
  }
578
- if (opts.includeCustomFields === true) {
579
- if (entityIdToSource.size > 0) {
580
- const entityIdList = Array.from(entityIdToSource.keys())
581
- const entityOrder = new Map<string, number>()
582
- entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
583
- const rows = await db
584
- .selectFrom('custom_field_defs' as any)
585
- .select([
586
- 'key' as any,
587
- 'entity_id' as any,
588
- 'config_json' as any,
589
- 'kind' as any,
590
- 'organization_id' as any,
591
- 'tenant_id' as any,
592
- 'updated_at' as any,
593
- 'deleted_at' as any,
594
- ])
595
- .where('entity_id' as any, 'in', entityIdList)
596
- .where('is_active' as any, '=', true)
597
- .where((eb: any) => eb.or([
598
- eb('tenant_id' as any, '=', tenantId),
599
- eb('tenant_id' as any, 'is', null),
600
- ]))
601
- .execute() as Array<{
602
- key: string
603
- entity_id: string
604
- config_json: unknown
605
- kind: string
606
- organization_id: string | null
607
- tenant_id: string | null
608
- updated_at: Date | string | number | null
609
- deleted_at: Date | string | number | null
610
- }>
611
- // Build the decoration index from the same rows, scoped exactly like the
612
- // factory's loadCustomFieldDefinitionIndex (tenant + is_active already
613
- // applied in SQL; org + soft-delete applied in the shared builder).
614
- const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null)
615
- const definitionRows: CustomFieldDefinitionRow[] = rows.map((row) => ({
616
- key: String(row.key),
617
- entityId: String(row.entity_id),
618
- kind: row.kind == null ? null : String(row.kind),
619
- configJson: row.config_json,
620
- organizationId: row.organization_id == null ? null : String(row.organization_id),
621
- tenantId: row.tenant_id == null ? null : String(row.tenant_id),
622
- deletedAt: row.deleted_at ?? null,
623
- updatedAt: row.updated_at ?? null,
624
- }))
625
- resolvedCustomFieldDefinitions = {
626
- index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
627
- entityIds: entityIdList,
628
- tenantId: tenantId ?? null,
629
- organizationIds: orgCandidates,
466
+
467
+ // Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
468
+ // today's complete selection (base fields + CF projections + extension joins).
469
+ // `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
470
+ // candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
471
+ // twice is cheap: every `columnExists`/`tableExists` check is memoized on
472
+ // `this.columnCache`/`this.tableCache`, so the second pass hits no extra DB calls.
473
+ const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
474
+ const isSortKeysProjection = projection === 'sortKeys'
475
+ let q: AnyBuilder = db.selectFrom(table as any)
476
+
477
+ // Tenant/org/soft-delete scope
478
+ if (!skipAutoScope && orgScope && await this.columnExists(table, 'organization_id')) {
479
+ q = this.applyOrganizationScope(q, qualify('organization_id'), orgScope)
480
+ }
481
+ if (!skipAutoScope && await this.columnExists(table, 'tenant_id')) {
482
+ q = q.where(qualify('tenant_id'), '=', opts.tenantId)
483
+ }
484
+ if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
485
+ q = q.where(qualify('deleted_at'), 'is', null)
486
+ }
487
+
488
+ for (const filter of regularBaseFilters) {
489
+ const fieldName = String(filter.field)
490
+ let qualified = filter.qualified ?? null
491
+ if (!qualified) {
492
+ const column = await this.resolveBaseColumn(table, fieldName)
493
+ if (!column) {
494
+ q = this.applyIndexDocFilter(q, {
495
+ entity: String(entity),
496
+ field: fieldName,
497
+ op: filter.op,
498
+ value: filter.value,
499
+ recordIdColumn,
500
+ tenantId: opts.tenantId ?? null,
501
+ organizationScope: orgScope,
502
+ withDeleted: opts.withDeleted === true,
503
+ searchActive,
504
+ searchConfig,
505
+ })
506
+ continue
507
+ }
508
+ qualified = qualify(column)
630
509
  }
631
- type ScoredCustomFieldRow = {
632
- key: string
633
- entityId: string
634
- kind: string
635
- config: Record<string, unknown>
510
+ q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName)
511
+ }
512
+
513
+ // OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
514
+ if (orGroupFilters.length > 0) {
515
+ const groups = new Map<string, typeof orGroupFilters>()
516
+ for (const f of orGroupFilters) {
517
+ const group = groups.get(f.orGroup!) ?? []
518
+ group.push(f)
519
+ groups.set(f.orGroup!, group)
636
520
  }
637
- const sorted: ScoredCustomFieldRow[] = rows.map((row) => {
638
- const raw = row.config_json
639
- let cfg: Record<string, any> = {}
640
- if (raw && typeof raw === 'string') {
641
- try { cfg = JSON.parse(raw) } catch { cfg = {} }
642
- } else if (raw && typeof raw === 'object') {
643
- cfg = raw as Record<string, any>
521
+ const resolvedGroupFilters: Array<Array<{ qualified: string; op: string; value: unknown; fieldName: string }>> = []
522
+ for (const [, groupFilters] of groups) {
523
+ const resolved: Array<{ qualified: string; op: string; value: unknown; fieldName: string }> = []
524
+ for (const filter of groupFilters) {
525
+ const column = await this.resolveBaseColumn(table, String(filter.field))
526
+ if (column) {
527
+ resolved.push({
528
+ qualified: qualify(column),
529
+ op: filter.op,
530
+ value: filter.value,
531
+ fieldName: String(filter.field),
532
+ })
533
+ }
644
534
  }
645
- return {
535
+ if (resolved.length > 0) resolvedGroupFilters.push(resolved)
536
+ }
537
+ if (resolvedGroupFilters.length > 0) {
538
+ q = q.where((eb: any) => eb.or(
539
+ resolvedGroupFilters.map((group) => {
540
+ const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
541
+ return parts.length === 1 ? parts[0] : eb.and(parts)
542
+ })
543
+ ))
544
+ }
545
+ }
546
+
547
+ q = await applyJoinFilters({
548
+ db,
549
+ baseTable: table,
550
+ builder: q,
551
+ joinMap,
552
+ joinFilters,
553
+ aliasTables,
554
+ qualifyBase: (column) => qualify(column),
555
+ applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
556
+ applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
557
+ applyJoinFilterOp,
558
+ columnExists: (tbl, column) => this.columnExists(tbl, column),
559
+ })
560
+
561
+ // Selection (base columns only here; cf:* handled later)
562
+ if (isSortKeysProjection) {
563
+ q = q.select(sql.ref(qualify('id')).as('id'))
564
+ for (const s of resolvedSorts) {
565
+ if (!s.field.startsWith('cf:')) q = q.select(sql.ref(qualify(s.field)).as(s.field))
566
+ }
567
+ } else if (opts.fields && opts.fields.length) {
568
+ const cols = new Set(opts.fields.filter((f) => !f.startsWith('cf:')))
569
+ if (requiresPlaintextSort) {
570
+ for (const field of encryptedSortFields) cols.add(field)
571
+ }
572
+ for (const c of cols) {
573
+ // Qualify and alias to base names to avoid ambiguity
574
+ q = q.select(sql.ref(qualify(c)).as(c))
575
+ }
576
+ } else {
577
+ // Default to selecting only base table columns to avoid ambiguity when joining
578
+ q = q.select(sql`${sql.ref(table)}.*`.as('__all'))
579
+ }
580
+
581
+ // Resolve which custom fields to include
582
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
583
+ q = cfSourcesResult.builder
584
+ const cfSources = cfSourcesResult.sources
585
+ const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
586
+ for (const source of cfSources) {
587
+ entityIdToSource.set(String(source.entityId), source)
588
+ }
589
+ const requestedCustomFieldKeys = (!isSortKeysProjection && Array.isArray(opts.includeCustomFields))
590
+ ? opts.includeCustomFields.map((key) => String(key))
591
+ : []
592
+ const cfKeys = new Set<string>()
593
+ const keySource = new Map<string, ResolvedCustomFieldSource>()
594
+ // Custom-field definition index threaded onto the result so the CRUD factory
595
+ // can decorate list rows without reloading definitions from the DB (#2133).
596
+ // Output-only — never resolved for the slim sortKeys projection.
597
+ let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
598
+ // Explicit in fields/filters
599
+ if (!isSortKeysProjection) {
600
+ for (const f of (opts.fields || [])) {
601
+ if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
602
+ }
603
+ }
604
+ for (const f of cfFilters) {
605
+ if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
606
+ }
607
+ if (!isSortKeysProjection && opts.includeCustomFields === true) {
608
+ if (entityIdToSource.size > 0) {
609
+ const entityIdList = Array.from(entityIdToSource.keys())
610
+ const entityOrder = new Map<string, number>()
611
+ entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
612
+ const rows = await db
613
+ .selectFrom('custom_field_defs' as any)
614
+ .select([
615
+ 'key' as any,
616
+ 'entity_id' as any,
617
+ 'config_json' as any,
618
+ 'kind' as any,
619
+ 'organization_id' as any,
620
+ 'tenant_id' as any,
621
+ 'updated_at' as any,
622
+ 'deleted_at' as any,
623
+ ])
624
+ .where('entity_id' as any, 'in', entityIdList)
625
+ .where('is_active' as any, '=', true)
626
+ .where((eb: any) => eb.or([
627
+ eb('tenant_id' as any, '=', tenantId),
628
+ eb('tenant_id' as any, 'is', null),
629
+ ]))
630
+ .execute() as Array<{
631
+ key: string
632
+ entity_id: string
633
+ config_json: unknown
634
+ kind: string
635
+ organization_id: string | null
636
+ tenant_id: string | null
637
+ updated_at: Date | string | number | null
638
+ deleted_at: Date | string | number | null
639
+ }>
640
+ // Build the decoration index from the same rows, scoped exactly like the
641
+ // factory's loadCustomFieldDefinitionIndex (tenant + is_active already
642
+ // applied in SQL; org + soft-delete applied in the shared builder).
643
+ const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null)
644
+ const definitionRows: CustomFieldDefinitionRow[] = rows.map((row) => ({
646
645
  key: String(row.key),
647
646
  entityId: String(row.entity_id),
648
- kind: String(row.kind || ''),
649
- config: cfg,
647
+ kind: row.kind == null ? null : String(row.kind),
648
+ configJson: row.config_json,
649
+ organizationId: row.organization_id == null ? null : String(row.organization_id),
650
+ tenantId: row.tenant_id == null ? null : String(row.tenant_id),
651
+ deletedAt: row.deleted_at ?? null,
652
+ updatedAt: row.updated_at ?? null,
653
+ }))
654
+ resolvedCustomFieldDefinitions = {
655
+ index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
656
+ entityIds: entityIdList,
657
+ tenantId: tenantId ?? null,
658
+ organizationIds: orgCandidates,
650
659
  }
651
- })
652
- sorted.sort((a, b) => {
653
- const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER
654
- const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER
655
- if (ai !== bi) return ai - bi
656
- return a.key.localeCompare(b.key)
657
- })
658
- const selectedSources = new Map<string, { source: ResolvedCustomFieldSource; score: number; penalty: number; entityIndex: number }>()
659
- for (const row of sorted) {
660
- const source = entityIdToSource.get(row.entityId)
661
- if (!source) continue
662
- const cfg = row.config || {}
663
- const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER
664
- const scores = computeCustomFieldScore(cfg, row.kind, entityIndex)
665
- const existing = selectedSources.get(row.key)
666
- if (!existing || scores.base > existing.score || (scores.base === existing.score && (scores.penalty < existing.penalty || (scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)))) {
667
- selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex })
660
+ type ScoredCustomFieldRow = {
661
+ key: string
662
+ entityId: string
663
+ kind: string
664
+ config: Record<string, unknown>
665
+ }
666
+ const sorted: ScoredCustomFieldRow[] = rows.map((row) => {
667
+ const raw = row.config_json
668
+ let cfg: Record<string, any> = {}
669
+ if (raw && typeof raw === 'string') {
670
+ try { cfg = JSON.parse(raw) } catch { cfg = {} }
671
+ } else if (raw && typeof raw === 'object') {
672
+ cfg = raw as Record<string, any>
673
+ }
674
+ return {
675
+ key: String(row.key),
676
+ entityId: String(row.entity_id),
677
+ kind: String(row.kind || ''),
678
+ config: cfg,
679
+ }
680
+ })
681
+ sorted.sort((a, b) => {
682
+ const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER
683
+ const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER
684
+ if (ai !== bi) return ai - bi
685
+ return a.key.localeCompare(b.key)
686
+ })
687
+ const selectedSources = new Map<string, { source: ResolvedCustomFieldSource; score: number; penalty: number; entityIndex: number }>()
688
+ for (const row of sorted) {
689
+ const source = entityIdToSource.get(row.entityId)
690
+ if (!source) continue
691
+ const cfg = row.config || {}
692
+ const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER
693
+ const scores = computeCustomFieldScore(cfg, row.kind, entityIndex)
694
+ const existing = selectedSources.get(row.key)
695
+ if (!existing || scores.base > existing.score || (scores.base === existing.score && (scores.penalty < existing.penalty || (scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)))) {
696
+ selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex })
697
+ }
698
+ cfKeys.add(row.key)
699
+ }
700
+ for (const [key, entry] of selectedSources.entries()) {
701
+ keySource.set(key, entry.source)
668
702
  }
669
- cfKeys.add(row.key)
670
- }
671
- for (const [key, entry] of selectedSources.entries()) {
672
- keySource.set(key, entry.source)
673
703
  }
704
+ } else if (!isSortKeysProjection && requestedCustomFieldKeys.length > 0) {
705
+ for (const key of requestedCustomFieldKeys) cfKeys.add(key)
674
706
  }
675
- } else if (requestedCustomFieldKeys.length > 0) {
676
- for (const key of requestedCustomFieldKeys) cfKeys.add(key)
677
- }
678
- const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key))
679
- if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
680
- const rows = await db
681
- .selectFrom('custom_field_defs' as any)
682
- .select(['key' as any, 'entity_id' as any])
683
- .where('entity_id' as any, 'in', Array.from(entityIdToSource.keys()))
684
- .where('key' as any, 'in', unresolvedKeys)
685
- .where('is_active' as any, '=', true)
686
- .where((eb: any) => eb.or([
687
- eb('tenant_id' as any, '=', tenantId),
688
- eb('tenant_id' as any, 'is', null),
689
- ]))
690
- .execute() as Array<{ key: string; entity_id: string }>
691
- for (const row of rows) {
692
- const source = entityIdToSource.get(String(row.entity_id))
693
- if (!source) continue
694
- if (!keySource.has(row.key)) keySource.set(row.key, source)
707
+ const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key))
708
+ if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
709
+ const rows = await db
710
+ .selectFrom('custom_field_defs' as any)
711
+ .select(['key' as any, 'entity_id' as any])
712
+ .where('entity_id' as any, 'in', Array.from(entityIdToSource.keys()))
713
+ .where('key' as any, 'in', unresolvedKeys)
714
+ .where('is_active' as any, '=', true)
715
+ .where((eb: any) => eb.or([
716
+ eb('tenant_id' as any, '=', tenantId),
717
+ eb('tenant_id' as any, 'is', null),
718
+ ]))
719
+ .execute() as Array<{ key: string; entity_id: string }>
720
+ for (const row of rows) {
721
+ const source = entityIdToSource.get(String(row.entity_id))
722
+ if (!source) continue
723
+ if (!keySource.has(row.key)) keySource.set(row.key, source)
724
+ }
695
725
  }
696
- }
697
726
 
698
- const cfValueExprByKey: Record<string, RawBuilder<string | null>> = {}
699
- const cfSelectedAliases: string[] = []
700
- const cfJsonAliases = new Set<string>()
701
- const cfMultiAliasByAlias = new Map<string, string>()
702
- for (const key of cfKeys) {
703
- const source = keySource.get(key)
704
- if (!source) continue
705
- const entityIdForKey = source.entityId
706
- const recordIdExpr = source.recordIdExpr
707
- const sourceAliasSafe = sanitize(source.alias || 'src')
708
- const keyAliasSafe = sanitize(key)
709
- const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`
710
- const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`
711
- // Join definitions for kind resolution
712
- q = q.leftJoin(`custom_field_defs as ${defAlias}` as any, (jb: any) =>
713
- jb.on(`${defAlias}.entity_id`, '=', String(entityIdForKey))
714
- .on(`${defAlias}.key`, '=', key)
715
- .on(`${defAlias}.is_active`, '=', true)
716
- .on((eb: any) => eb.or([
717
- eb(`${defAlias}.tenant_id`, '=', tenantId),
718
- eb(`${defAlias}.tenant_id`, 'is', null),
719
- ]))
720
- )
721
- // Join values with record match
722
- q = q.leftJoin(`custom_field_values as ${valAlias}` as any, (jb: any) =>
723
- jb.on(`${valAlias}.entity_id`, '=', String(entityIdForKey))
724
- .on(`${valAlias}.field_key`, '=', key)
725
- .onRef(`${valAlias}.record_id`, '=', recordIdExpr as any)
726
- .on((eb: any) => eb.or([
727
- eb(`${valAlias}.tenant_id`, '=', tenantId),
728
- eb(`${valAlias}.tenant_id`, 'is', null),
729
- ]))
730
- )
731
- // Force a common SQL type across branches to avoid Postgres CASE type conflicts
732
- const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
733
- WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
734
- WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
735
- WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
736
- WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
737
- ELSE (${sql.ref(`${valAlias}.value_text`)})::text
738
- END`
739
- cfValueExprByKey[key] = caseExpr
740
- const alias = sanitize(`cf:${key}`)
741
- // Project as aggregated to avoid duplicates when multi values exist
742
- if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
743
- const multiAlias = `${alias}__is_multi`
744
- const isMultiExpr = sql<boolean>`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`
745
- const aggregatedArray = sql<unknown>`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`
746
- const projExpr = sql<unknown>`CASE WHEN ${isMultiExpr}
747
- THEN to_jsonb(${aggregatedArray})
748
- ELSE to_jsonb(max(${caseExpr}))
727
+ const cfValueExprByKey: Record<string, RawBuilder<string | null>> = {}
728
+ const cfSelectedAliases: string[] = []
729
+ const cfJsonAliases = new Set<string>()
730
+ const cfMultiAliasByAlias = new Map<string, string>()
731
+ for (const key of cfKeys) {
732
+ const source = keySource.get(key)
733
+ if (!source) continue
734
+ const entityIdForKey = source.entityId
735
+ const recordIdExpr = source.recordIdExpr
736
+ const sourceAliasSafe = sanitize(source.alias || 'src')
737
+ const keyAliasSafe = sanitize(key)
738
+ const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`
739
+ const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`
740
+ // Join definitions for kind resolution
741
+ q = q.leftJoin(`custom_field_defs as ${defAlias}` as any, (jb: any) =>
742
+ jb.on(`${defAlias}.entity_id`, '=', String(entityIdForKey))
743
+ .on(`${defAlias}.key`, '=', key)
744
+ .on(`${defAlias}.is_active`, '=', true)
745
+ .on((eb: any) => eb.or([
746
+ eb(`${defAlias}.tenant_id`, '=', tenantId),
747
+ eb(`${defAlias}.tenant_id`, 'is', null),
748
+ ]))
749
+ )
750
+ // Join values with record match
751
+ q = q.leftJoin(`custom_field_values as ${valAlias}` as any, (jb: any) =>
752
+ jb.on(`${valAlias}.entity_id`, '=', String(entityIdForKey))
753
+ .on(`${valAlias}.field_key`, '=', key)
754
+ .onRef(`${valAlias}.record_id`, '=', recordIdExpr as any)
755
+ .on((eb: any) => eb.or([
756
+ eb(`${valAlias}.tenant_id`, '=', tenantId),
757
+ eb(`${valAlias}.tenant_id`, 'is', null),
758
+ ]))
759
+ )
760
+ // Force a common SQL type across branches to avoid Postgres CASE type conflicts
761
+ const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
762
+ WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
763
+ WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
764
+ WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
765
+ WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
766
+ ELSE (${sql.ref(`${valAlias}.value_text`)})::text
749
767
  END`
750
- q = q.select(projExpr.as(alias))
751
- q = q.select(isMultiExpr.as(multiAlias))
752
- cfSelectedAliases.push(alias)
753
- cfJsonAliases.add(alias)
754
- cfMultiAliasByAlias.set(alias, multiAlias)
768
+ cfValueExprByKey[key] = caseExpr
769
+ const alias = sanitize(`cf:${key}`)
770
+ // Project as aggregated to avoid duplicates when multi values exist
771
+ if (!isSortKeysProjection && ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key)))) {
772
+ const multiAlias = `${alias}__is_multi`
773
+ const isMultiExpr = sql<boolean>`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`
774
+ const aggregatedArray = sql<unknown>`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`
775
+ const projExpr = sql<unknown>`CASE WHEN ${isMultiExpr}
776
+ THEN to_jsonb(${aggregatedArray})
777
+ ELSE to_jsonb(max(${caseExpr}))
778
+ END`
779
+ q = q.select(projExpr.as(alias))
780
+ q = q.select(isMultiExpr.as(multiAlias))
781
+ cfSelectedAliases.push(alias)
782
+ cfJsonAliases.add(alias)
783
+ cfMultiAliasByAlias.set(alias, multiAlias)
784
+ }
755
785
  }
756
- }
757
786
 
758
- // Apply cf:* filters (on raw expressions)
759
- for (const f of cfFilters) {
760
- if (!f.field.startsWith('cf:')) continue
761
- const key = f.field.slice(3)
762
- const expr = cfValueExprByKey[key]
763
- if (!expr) continue
764
- if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
765
- const tokens = tokenizeText(String(f.value), searchConfig)
766
- const hashes = tokens.hashes
767
- if (hashes.length) {
768
- const result = this.applySearchTokens(q, {
769
- entity: String(entity),
770
- field: f.field,
771
- hashes,
772
- recordIdColumn,
773
- tenantId: opts.tenantId ?? null,
774
- organizationScope: orgScope,
775
- tokens: tokens.tokens,
776
- })
777
- this.logSearchDebug('search:cf-filter', {
778
- entity: String(entity),
779
- field: f.field,
780
- tokens: tokens.tokens,
781
- hashes,
782
- applied: result.applied,
783
- tenantId: opts.tenantId ?? null,
784
- organizationScope: orgScope,
785
- })
786
- if (result.applied) {
787
- q = result.builder
788
- continue
787
+ // Apply cf:* filters (on raw expressions)
788
+ for (const f of cfFilters) {
789
+ if (!f.field.startsWith('cf:')) continue
790
+ const key = f.field.slice(3)
791
+ const expr = cfValueExprByKey[key]
792
+ if (!expr) continue
793
+ if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
794
+ const tokens = tokenizeText(String(f.value), searchConfig)
795
+ const hashes = tokens.hashes
796
+ if (hashes.length) {
797
+ const result = this.applySearchTokens(q, {
798
+ entity: String(entity),
799
+ field: f.field,
800
+ hashes,
801
+ recordIdColumn,
802
+ tenantId: opts.tenantId ?? null,
803
+ organizationScope: orgScope,
804
+ tokens: tokens.tokens,
805
+ })
806
+ this.logSearchDebug('search:cf-filter', {
807
+ entity: String(entity),
808
+ field: f.field,
809
+ tokens: tokens.tokens,
810
+ hashes,
811
+ applied: result.applied,
812
+ tenantId: opts.tenantId ?? null,
813
+ organizationScope: orgScope,
814
+ })
815
+ if (result.applied) {
816
+ q = result.builder
817
+ continue
818
+ }
819
+ } else {
820
+ this.logSearchDebug('search:cf-skip-empty-hashes', {
821
+ entity: String(entity),
822
+ field: f.field,
823
+ value: f.value,
824
+ })
789
825
  }
790
- } else {
791
- this.logSearchDebug('search:cf-skip-empty-hashes', {
792
- entity: String(entity),
793
- field: f.field,
794
- value: f.value,
795
- })
796
826
  }
827
+ q = this.applyColumnOp(q, expr, f.op, f.value)
797
828
  }
798
- q = this.applyColumnOp(q, expr, f.op, f.value)
799
- }
800
829
 
801
- // Entity extensions joins (no selection yet; enables future filters/projections)
802
- if (opts.includeExtensions) {
803
- const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
804
- const allMods = getModules() as any[]
805
- const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
806
- const exts = allExts.filter((e: any) => e.base === entity)
807
- const chosen = Array.isArray(opts.includeExtensions)
808
- ? exts.filter((e: any) => (opts.includeExtensions as string[]).includes(e.extension))
809
- : exts
810
- for (const e of chosen) {
811
- const [, extName] = (e.extension as string).split(':')
812
- const extTable = extName.endsWith('s') ? extName : `${extName}s`
813
- const alias = `ext_${sanitize(extName)}`
814
- q = q.leftJoin(`${extTable} as ${alias}` as any, (jb: any) =>
815
- jb.onRef(`${alias}.${e.join.extensionKey}`, '=', `${table}.${e.join.baseKey}`)
816
- )
830
+ // Entity extensions joins (no selection yet; enables future filters/projections)
831
+ if (opts.includeExtensions) {
832
+ const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
833
+ const allMods = getModules() as any[]
834
+ const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
835
+ const exts = allExts.filter((e: any) => e.base === entity)
836
+ const chosen = Array.isArray(opts.includeExtensions)
837
+ ? exts.filter((e: any) => (opts.includeExtensions as string[]).includes(e.extension))
838
+ : exts
839
+ for (const e of chosen) {
840
+ const [, extName] = (e.extension as string).split(':')
841
+ const extTable = extName.endsWith('s') ? extName : `${extName}s`
842
+ const alias = `ext_${sanitize(extName)}`
843
+ q = q.leftJoin(`${extTable} as ${alias}` as any, (jb: any) =>
844
+ jb.onRef(`${alias}.${e.join.extensionKey}`, '=', `${table}.${e.join.baseKey}`)
845
+ )
846
+ }
817
847
  }
818
- }
819
848
 
820
- // Sorting: base fields and cf:* (use aggregated alias for cf)
821
- for (const s of resolvedSorts) {
822
- if (s.field.startsWith('cf:')) {
823
- const key = s.field.slice(3)
824
- const alias = sanitize(`cf:${key}`)
825
- // Ensure included in projection to sort by
826
- if (!cfSelectedAliases.includes(alias)) {
827
- const expr = cfValueExprByKey[key]
828
- if (expr) {
829
- q = q.select(sql<string | null>`max(${expr})`.as(alias))
830
- cfSelectedAliases.push(alias)
849
+ // Sorting: base fields and cf:* (use aggregated alias for cf)
850
+ for (const s of resolvedSorts) {
851
+ if (s.field.startsWith('cf:')) {
852
+ const key = s.field.slice(3)
853
+ const alias = sanitize(`cf:${key}`)
854
+ // Ensure included in projection to sort by
855
+ if (!cfSelectedAliases.includes(alias)) {
856
+ const expr = cfValueExprByKey[key]
857
+ if (expr) {
858
+ q = q.select(sql<string | null>`max(${expr})`.as(alias))
859
+ cfSelectedAliases.push(alias)
860
+ }
831
861
  }
862
+ if (!requiresPlaintextSort) q = q.orderBy(alias, (s.dir ?? 'asc') as any)
863
+ } else {
864
+ if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), (s.dir ?? 'asc') as any)
832
865
  }
833
- if (!requiresPlaintextSort) q = q.orderBy(alias, (s.dir ?? 'asc') as any)
834
- } else {
835
- if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), (s.dir ?? 'asc') as any)
836
866
  }
867
+
868
+ // Deduplicate if we joined CFs or extensions by grouping on base id
869
+ const hasJoinedAggregates = (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0
870
+ if (hasJoinedAggregates) {
871
+ q = q.groupBy(`${table}.id`)
872
+ }
873
+
874
+ return { builder: q, hasJoinedAggregates, cfJsonAliases, cfMultiAliasByAlias, resolvedCustomFieldDefinitions }
837
875
  }
838
876
 
839
877
  // Pagination
840
878
  const page = opts.page?.page ?? 1
841
879
  const pageSize = opts.page?.pageSize ?? 20
842
- // Deduplicate if we joined CFs or extensions by grouping on base id
843
- const hasJoinedAggregates = (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0
844
- if (hasJoinedAggregates) {
845
- q = q.groupBy(`${table}.id`)
846
- }
880
+
881
+ const {
882
+ builder: qFull,
883
+ hasJoinedAggregates,
884
+ cfJsonAliases,
885
+ cfMultiAliasByAlias,
886
+ resolvedCustomFieldDefinitions,
887
+ } = await buildQuery('full')
888
+
847
889
  // `count(distinct base.id)` is only required when a join can multiply base rows
848
890
  // (CF/extension aggregates, explicit relation joins, or custom-field sources).
849
891
  // Without such joins base.id is the unique PK, so `count(*)` is equivalent and
@@ -856,17 +898,41 @@ export class BasicQueryEngine implements QueryEngine {
856
898
  ? sql<string>`count(distinct ${sql.ref(`${table}.id`)})`
857
899
  : sql<string>`count(*)`
858
900
  const countBuilder = hasJoinedAggregates
859
- ? q.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as('count'))
860
- : q.clearSelect().clearOrderBy().select(countExpr.as('count'))
901
+ ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as('count'))
902
+ : qFull.clearSelect().clearOrderBy().select(countExpr.as('count'))
861
903
  const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
862
904
  const total = Number((countRow as any)?.count ?? 0)
863
- const dataQuery = requiresPlaintextSort
864
- ? q
865
- : q.limit(pageSize).offset((page - 1) * pageSize)
866
- const items = await dataQuery.execute() as any[]
867
905
 
868
- if (cfJsonAliases.size > 0) {
869
- for (const row of items) {
906
+ const svc = encryptionService
907
+ const decryptPayload =
908
+ svc?.decryptEntityPayload?.bind(svc) as
909
+ | ((
910
+ entityId: EntityId,
911
+ payload: Record<string, unknown>,
912
+ tenantId: string | null,
913
+ organizationId: string | null,
914
+ ) => Promise<Record<string, unknown>>)
915
+ | null
916
+
917
+ const decryptRow = async (item: ResultRow): Promise<ResultRow> => {
918
+ if (!decryptPayload) return item
919
+ try {
920
+ const decrypted = await decryptPayload(
921
+ entity,
922
+ item,
923
+ (item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null) as string | null,
924
+ (item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null) as string | null,
925
+ )
926
+ return { ...item, ...decrypted }
927
+ } catch (err) {
928
+ console.error('QueryEngine: error decrypting entity payload', err)
929
+ return item
930
+ }
931
+ }
932
+
933
+ const normalizeCfJsonAliases = (rows: ResultRow[]) => {
934
+ if (cfJsonAliases.size === 0) return
935
+ for (const row of rows) {
870
936
  for (const alias of cfJsonAliases) {
871
937
  const multiAlias = cfMultiAliasByAlias.get(alias)
872
938
  const isMulti = multiAlias ? Boolean(row[multiAlias]) : false
@@ -887,42 +953,66 @@ export class BasicQueryEngine implements QueryEngine {
887
953
  }
888
954
  }
889
955
 
890
- const svc = encryptionService
891
- const decryptPayload =
892
- svc?.decryptEntityPayload?.bind(svc) as
893
- | ((
894
- entityId: EntityId,
895
- payload: Record<string, unknown>,
896
- tenantId: string | null,
897
- organizationId: string | null,
898
- ) => Promise<Record<string, unknown>>)
899
- | null
900
- let decryptedItems = items
901
- if (decryptPayload) {
902
- decryptedItems = await Promise.all(
903
- (items as any[]).map(async (item) => {
904
- try {
905
- const decrypted = await decryptPayload(
906
- entity,
907
- item,
908
- item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
909
- item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null,
910
- )
911
- return { ...item, ...decrypted }
912
- } catch (err) {
913
- console.error('QueryEngine: error decrypting entity payload', err);
914
- return item
915
- }
916
- })
917
- )
956
+ let pagedItems: ResultRow[]
957
+ let encryptedSortRowCapWarning: EncryptedSortRowCapWarning | undefined
958
+
959
+ if (requiresPlaintextSort) {
960
+ // Phase 1: slim id+sort-columns candidate scan, bounded-concurrency decrypt,
961
+ // in-memory sort over the full candidate set, then slice to the page's ids.
962
+ const cap = resolveEncryptedSortMaxRows()
963
+ let qSort = (await buildQuery('sortKeys')).builder
964
+ if (cap !== null) {
965
+ qSort = qSort.limit(cap).orderBy(qualify('id'), 'asc' as any)
966
+ }
967
+ const candidateRows = await qSort.execute() as ResultRow[]
968
+ const decryptedCandidates = decryptPayload
969
+ ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
970
+ : candidateRows
971
+ const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts)
972
+ const pageIds = orderedCandidates
973
+ .slice((page - 1) * pageSize, page * pageSize)
974
+ .map((row) => row.id)
975
+
976
+ if (cap !== null && total > cap) {
977
+ encryptedSortRowCapWarning = {
978
+ entity,
979
+ sortFields: resolvedSorts.map((s) => s.field),
980
+ maxRows: cap,
981
+ totalMatched: total,
982
+ }
983
+ }
984
+
985
+ // Phase 2: fetch + decrypt full rows for just the page's ids, then reassemble
986
+ // in phase-1's order (SQL `WHERE id IN (...)` order is unspecified — never
987
+ // trust it for ordering).
988
+ if (pageIds.length === 0) {
989
+ pagedItems = []
990
+ } else {
991
+ const pageRows = await qFull.where(qualify('id'), 'in', pageIds).execute() as ResultRow[]
992
+ normalizeCfJsonAliases(pageRows)
993
+ const decryptedPageRows = decryptPayload
994
+ ? await mapWithConcurrency(pageRows, DECRYPT_CONCURRENCY, decryptRow)
995
+ : pageRows
996
+ const byId = new Map(decryptedPageRows.map((row) => [String(row.id), row]))
997
+ pagedItems = pageIds
998
+ .map((id) => byId.get(String(id)))
999
+ .filter((row): row is ResultRow => row != null)
1000
+ }
1001
+ } else {
1002
+ const dataQuery = qFull.limit(pageSize).offset((page - 1) * pageSize)
1003
+ const items = await dataQuery.execute() as ResultRow[]
1004
+ normalizeCfJsonAliases(items)
1005
+ pagedItems = decryptPayload
1006
+ ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow)
1007
+ : items
918
1008
  }
919
1009
 
920
- const pagedItems = requiresPlaintextSort
921
- ? sortRowsInMemory(decryptedItems as Record<string, unknown>[], resolvedSorts)
922
- .slice((page - 1) * pageSize, page * pageSize)
923
- : decryptedItems
1010
+ let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
924
1011
 
925
- let queryResult: QueryResult<T> = { items: pagedItems, page, pageSize, total }
1012
+ if (encryptedSortRowCapWarning) {
1013
+ const meta: QueryResultMeta = { encryptedSortRowCapWarning }
1014
+ queryResult.meta = meta
1015
+ }
926
1016
 
927
1017
  // --- UMES query extension: after-query pipeline ---
928
1018
  if (ext && extensionCtx) {