@open-mercato/shared 0.6.4-develop.4038.1.91ce075c8a → 0.6.4-develop.4106.1.12c205a613

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.
@@ -516,4 +516,90 @@ describe('BasicQueryEngine (Kysely)', () => {
516
516
  : false
517
517
  expect(hasInFilter).toBe(true)
518
518
  })
519
+
520
+ test('sorts encrypted base fields after decryption before pagination', async () => {
521
+ const fakeDb = createFakeKysely({
522
+ customer_entities: [
523
+ { id: '3', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-c' },
524
+ { id: '1', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-a' },
525
+ { id: '5', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-e' },
526
+ { id: '2', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-b' },
527
+ { id: '4', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-d' },
528
+ ],
529
+ 'information_schema.columns': [
530
+ { table_name: 'customer_entities', column_name: 'id' },
531
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
532
+ { table_name: 'customer_entities', column_name: 'organization_id' },
533
+ { table_name: 'customer_entities', column_name: 'deleted_at' },
534
+ { table_name: 'customer_entities', column_name: 'display_name' },
535
+ ],
536
+ })
537
+ const namesById: Record<string, string> = {
538
+ '1': 'Alice',
539
+ '2': 'Bob',
540
+ '3': 'Charlie',
541
+ '4': 'Dave',
542
+ '5': 'Eve',
543
+ }
544
+ const engine = new BasicQueryEngine(
545
+ {} as any,
546
+ () => fakeDb as any,
547
+ () => ({
548
+ isEnabled: () => true,
549
+ getEncryptedFieldNames: async () => ['display_name'],
550
+ decryptEntityPayload: async (_entityId, payload) => ({
551
+ display_name: namesById[String(payload.id)],
552
+ }),
553
+ }),
554
+ )
555
+
556
+ const result = await engine.query('customers:customer_entity', {
557
+ tenantId: 't1',
558
+ organizationId: 'org1',
559
+ fields: ['id', 'display_name'],
560
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
561
+ page: { page: 2, pageSize: 2 },
562
+ })
563
+
564
+ expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
565
+ const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'customer_entities')
566
+ expect(baseCall._ops.orderBys).toEqual([])
567
+ expect(baseCall._ops.limits).toBe(0)
568
+ })
569
+
570
+ test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
571
+ const fakeDb = createFakeKysely({
572
+ customer_entities: [
573
+ { id: '1', tenant_id: 't1', organization_id: 'org1', display_name: 'Alice' },
574
+ ],
575
+ 'information_schema.columns': [
576
+ { table_name: 'customer_entities', column_name: 'id' },
577
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
578
+ { table_name: 'customer_entities', column_name: 'organization_id' },
579
+ { table_name: 'customer_entities', column_name: 'deleted_at' },
580
+ { table_name: 'customer_entities', column_name: 'display_name' },
581
+ ],
582
+ })
583
+ const engine = new BasicQueryEngine(
584
+ {} as any,
585
+ () => fakeDb as any,
586
+ () => ({
587
+ isEnabled: () => true,
588
+ getEncryptedFieldNames: async () => [],
589
+ }),
590
+ )
591
+
592
+ await engine.query('customers:customer_entity', {
593
+ tenantId: 't1',
594
+ organizationId: 'org1',
595
+ fields: ['id', 'display_name'],
596
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
597
+ page: { page: 2, pageSize: 10 },
598
+ })
599
+
600
+ const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'customer_entities')
601
+ expect(baseCall._ops.orderBys).toEqual([['customer_entities.display_name', 'asc']])
602
+ expect(baseCall._ops.limits).toBe(10)
603
+ expect(baseCall._ops.offsets).toBe(10)
604
+ })
519
605
  })
@@ -0,0 +1,86 @@
1
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
2
+ import { SortDir, type Sort } from './types'
3
+
4
+ export type QueryEncryptionService = {
5
+ getEncryptedFieldNames?: (
6
+ entityId: EntityId,
7
+ tenantId?: string | null,
8
+ organizationId?: string | null,
9
+ ) => Promise<readonly string[]>
10
+ isEnabled?: () => boolean
11
+ }
12
+
13
+ const toSnakeCase = (value: string): string =>
14
+ value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()
15
+
16
+ const toCamelCase = (value: string): string =>
17
+ value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
18
+
19
+ export function fieldNameCandidates(field: string): string[] {
20
+ const raw = String(field || '').trim()
21
+ if (!raw) return []
22
+ const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)]
23
+ if (raw.startsWith('cf:')) candidates.push(raw.replace(/[^a-zA-Z0-9_]/g, '_'))
24
+ return Array.from(new Set(candidates))
25
+ }
26
+
27
+ export async function resolveEncryptedSortFields(
28
+ service: QueryEncryptionService | null | undefined,
29
+ entity: EntityId,
30
+ sortFields: readonly string[],
31
+ tenantId?: string | null,
32
+ organizationId?: string | null,
33
+ ): Promise<Set<string>> {
34
+ if (!service?.getEncryptedFieldNames) return new Set()
35
+ if (service.isEnabled && !service.isEnabled()) return new Set()
36
+ const encrypted = await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)
37
+ const encryptedCandidates = new Set<string>()
38
+ for (const field of encrypted) {
39
+ for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)
40
+ }
41
+ const result = new Set<string>()
42
+ for (const field of sortFields) {
43
+ if (field.startsWith('cf:')) continue
44
+ const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))
45
+ if (matches) result.add(field)
46
+ }
47
+ return result
48
+ }
49
+
50
+ function readField(row: Record<string, unknown>, field: string): unknown {
51
+ for (const candidate of fieldNameCandidates(field)) {
52
+ if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ function compareValues(left: unknown, right: unknown): number {
58
+ const leftMissing = left === null || left === undefined
59
+ const rightMissing = right === null || right === undefined
60
+ if (leftMissing && rightMissing) return 0
61
+ if (leftMissing) return 1
62
+ if (rightMissing) return -1
63
+ if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()
64
+ if (typeof left === 'number' && typeof right === 'number') return left - right
65
+ if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)
66
+ return String(left).localeCompare(String(right), undefined, {
67
+ sensitivity: 'base',
68
+ numeric: true,
69
+ })
70
+ }
71
+
72
+ export function sortRowsInMemory<T extends Record<string, unknown>>(
73
+ rows: readonly T[],
74
+ sorts: readonly Sort[],
75
+ ): T[] {
76
+ return [...rows].sort((left, right) => {
77
+ for (const sort of sorts) {
78
+ const direction = sort.dir === SortDir.Desc ? -1 : 1
79
+ const compared = compareValues(readField(left, sort.field), readField(right, sort.field))
80
+ if (compared !== 0) return compared * direction
81
+ }
82
+ const leftId = readField(left, 'id')
83
+ const rightId = readField(right, 'id')
84
+ return compareValues(leftId, rightId)
85
+ })
86
+ }
@@ -1,4 +1,4 @@
1
- import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource, QueryExtensionsConfig } from './types'
1
+ import type { QueryEngine, QueryOptions, QueryResult, 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'
@@ -14,6 +14,7 @@ import {
14
14
  import { resolveSearchConfig } from '../search/config'
15
15
  import { tokenizeText } from '../search/tokenize'
16
16
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
17
+ import { resolveEncryptedSortFields, sortRowsInMemory } from './encrypted-sort'
17
18
 
18
19
  type AnyDb = Kysely<any>
19
20
  type AnyBuilder = any
@@ -22,6 +23,7 @@ const entityTableCache = new Map<string, string>()
22
23
 
23
24
  type EncryptionResolver = () => {
24
25
  decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
26
+ getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
25
27
  isEnabled?: () => boolean
26
28
  } | null
27
29
 
@@ -475,9 +477,35 @@ export class BasicQueryEngine implements QueryEngine {
475
477
  applyJoinFilterOp,
476
478
  columnExists: (tbl, column) => this.columnExists(tbl, column),
477
479
  })
480
+
481
+ const fallbackOrgId =
482
+ opts.organizationId
483
+ ?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
484
+ const encryptionService = this.getEncryptionService()
485
+ const resolvedSorts: Sort[] = []
486
+ for (const s of opts.sort || []) {
487
+ if (s.field.startsWith('cf:')) {
488
+ resolvedSorts.push(s)
489
+ } else {
490
+ const column = await this.resolveBaseColumn(table, s.field)
491
+ if (column) resolvedSorts.push({ ...s, field: column })
492
+ }
493
+ }
494
+ const encryptedSortFields = await resolveEncryptedSortFields(
495
+ encryptionService,
496
+ entity,
497
+ resolvedSorts.filter((sort) => !sort.field.startsWith('cf:')).map((sort) => sort.field),
498
+ opts.tenantId ?? null,
499
+ fallbackOrgId,
500
+ )
501
+ const requiresPlaintextSort = encryptedSortFields.size > 0
502
+
478
503
  // Selection (base columns only here; cf:* handled later)
479
504
  if (opts.fields && opts.fields.length) {
480
- const cols = opts.fields.filter((f) => !f.startsWith('cf:'))
505
+ const cols = new Set(opts.fields.filter((f) => !f.startsWith('cf:')))
506
+ if (requiresPlaintextSort) {
507
+ for (const field of encryptedSortFields) cols.add(field)
508
+ }
481
509
  for (const c of cols) {
482
510
  // Qualify and alias to base names to avoid ambiguity
483
511
  q = q.select(sql.ref(qualify(c)).as(c))
@@ -714,7 +742,7 @@ export class BasicQueryEngine implements QueryEngine {
714
742
  }
715
743
 
716
744
  // Sorting: base fields and cf:* (use aggregated alias for cf)
717
- for (const s of opts.sort || []) {
745
+ for (const s of resolvedSorts) {
718
746
  if (s.field.startsWith('cf:')) {
719
747
  const key = s.field.slice(3)
720
748
  const alias = sanitize(`cf:${key}`)
@@ -726,11 +754,9 @@ export class BasicQueryEngine implements QueryEngine {
726
754
  cfSelectedAliases.push(alias)
727
755
  }
728
756
  }
729
- q = q.orderBy(alias, (s.dir ?? 'asc') as any)
757
+ if (!requiresPlaintextSort) q = q.orderBy(alias, (s.dir ?? 'asc') as any)
730
758
  } else {
731
- const column = await this.resolveBaseColumn(table, s.field)
732
- if (!column) continue
733
- q = q.orderBy(qualify(column), (s.dir ?? 'asc') as any)
759
+ if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), (s.dir ?? 'asc') as any)
734
760
  }
735
761
  }
736
762
 
@@ -747,7 +773,10 @@ export class BasicQueryEngine implements QueryEngine {
747
773
  : q.clearSelect().clearOrderBy().select(sql<string>`count(distinct ${sql.ref(`${table}.id`)})`.as('count'))
748
774
  const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
749
775
  const total = Number((countRow as any)?.count ?? 0)
750
- const items = await q.limit(pageSize).offset((page - 1) * pageSize).execute() as any[]
776
+ const dataQuery = requiresPlaintextSort
777
+ ? q
778
+ : q.limit(pageSize).offset((page - 1) * pageSize)
779
+ const items = await dataQuery.execute() as any[]
751
780
 
752
781
  if (cfJsonAliases.size > 0) {
753
782
  for (const row of items) {
@@ -771,7 +800,7 @@ export class BasicQueryEngine implements QueryEngine {
771
800
  }
772
801
  }
773
802
 
774
- const svc = this.getEncryptionService()
803
+ const svc = encryptionService
775
804
  const decryptPayload =
776
805
  svc?.decryptEntityPayload?.bind(svc) as
777
806
  | ((
@@ -783,9 +812,6 @@ export class BasicQueryEngine implements QueryEngine {
783
812
  | null
784
813
  let decryptedItems = items
785
814
  if (decryptPayload) {
786
- const fallbackOrgId =
787
- opts.organizationId
788
- ?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
789
815
  decryptedItems = await Promise.all(
790
816
  (items as any[]).map(async (item) => {
791
817
  try {
@@ -804,7 +830,12 @@ export class BasicQueryEngine implements QueryEngine {
804
830
  )
805
831
  }
806
832
 
807
- let queryResult: QueryResult<T> = { items: decryptedItems, page, pageSize, total }
833
+ const pagedItems = requiresPlaintextSort
834
+ ? sortRowsInMemory(decryptedItems as Record<string, unknown>[], resolvedSorts)
835
+ .slice((page - 1) * pageSize, page * pageSize)
836
+ : decryptedItems
837
+
838
+ let queryResult: QueryResult<T> = { items: pagedItems, page, pageSize, total }
808
839
 
809
840
  // --- UMES query extension: after-query pipeline ---
810
841
  if (ext && extensionCtx) {