@open-mercato/shared 0.6.6-develop.5757.1.f5cc26cf92 → 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.
@@ -158,7 +158,7 @@ function createFakeKysely(overrides?: FakeData) {
158
158
  return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
159
159
  }
160
160
  if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
161
- return { count: '0' }
161
+ return { count: String((data[localOps.table] || []).length) }
162
162
  }
163
163
  const rows = data[localOps.table] || []
164
164
  if (rows.length === 0) return { count: '0' }
@@ -642,9 +642,164 @@ describe('BasicQueryEngine (Kysely)', () => {
642
642
  })
643
643
 
644
644
  expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
645
- const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'customer_entities')
646
- expect(baseCall._ops.orderBys).toEqual([])
647
- expect(baseCall._ops.limits).toBe(0)
645
+ const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
646
+ expect(baseCalls.length).toBe(2)
647
+ // qFull ('full' projection) is built first (used for count + phase 2);
648
+ // qSort ('sortKeys' projection) is built second (phase 1).
649
+ const [phase2Call, phase1Call] = baseCalls
650
+ // Phase 1 (slim id+sort-column scan): no SQL order/limit — the full candidate
651
+ // set is fetched, decrypted, and sorted in memory.
652
+ expect(phase1Call._ops.orderBys).toEqual([])
653
+ expect(phase1Call._ops.limits).toBe(0)
654
+ // Phase 2 (full-row fetch for the page's ids): filtered by `id in [...]`, no
655
+ // SQL order/limit needed since the id list already bounds it to the page.
656
+ expect(phase2Call._ops.orderBys).toEqual([])
657
+ expect(phase2Call._ops.limits).toBe(0)
658
+ expect(phase2Call._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'customer_entities.id' && w[1] === 'in')).toBe(true)
659
+ })
660
+
661
+ test('paginates encrypted-sorted results correctly on page 1 and the tail page', async () => {
662
+ const fakeDb = createFakeKysely({
663
+ customer_entities: [
664
+ { id: '3', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-c' },
665
+ { id: '1', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-a' },
666
+ { id: '5', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-e' },
667
+ { id: '2', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-b' },
668
+ { id: '4', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-d' },
669
+ ],
670
+ 'information_schema.columns': [
671
+ { table_name: 'customer_entities', column_name: 'id' },
672
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
673
+ { table_name: 'customer_entities', column_name: 'organization_id' },
674
+ { table_name: 'customer_entities', column_name: 'deleted_at' },
675
+ { table_name: 'customer_entities', column_name: 'display_name' },
676
+ ],
677
+ })
678
+ const namesById: Record<string, string> = {
679
+ '1': 'Alice', '2': 'Bob', '3': 'Charlie', '4': 'Dave', '5': 'Eve',
680
+ }
681
+ const engine = new BasicQueryEngine(
682
+ {} as any,
683
+ () => fakeDb as any,
684
+ () => ({
685
+ isEnabled: () => true,
686
+ getEncryptedFieldNames: async () => ['display_name'],
687
+ decryptEntityPayload: async (_entityId, payload) => ({
688
+ display_name: namesById[String(payload.id)],
689
+ }),
690
+ }),
691
+ )
692
+
693
+ const page1 = await engine.query('customers:customer_entity', {
694
+ tenantId: 't1',
695
+ organizationId: 'org1',
696
+ fields: ['id', 'display_name'],
697
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
698
+ page: { page: 1, pageSize: 2 },
699
+ })
700
+ expect(page1.items.map((item: any) => item.display_name)).toEqual(['Alice', 'Bob'])
701
+
702
+ const page3 = await engine.query('customers:customer_entity', {
703
+ tenantId: 't1',
704
+ organizationId: 'org1',
705
+ fields: ['id', 'display_name'],
706
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
707
+ page: { page: 3, pageSize: 2 },
708
+ })
709
+ expect(page3.items.map((item: any) => item.display_name)).toEqual(['Eve'])
710
+ })
711
+
712
+ describe('OM_ENCRYPTED_SORT_MAX_ROWS cap', () => {
713
+ const originalEnv = process.env.OM_ENCRYPTED_SORT_MAX_ROWS
714
+
715
+ afterEach(() => {
716
+ if (originalEnv === undefined) delete process.env.OM_ENCRYPTED_SORT_MAX_ROWS
717
+ else process.env.OM_ENCRYPTED_SORT_MAX_ROWS = originalEnv
718
+ })
719
+
720
+ function buildFixture() {
721
+ const fakeDb = createFakeKysely({
722
+ customer_entities: [
723
+ { id: '3', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-c' },
724
+ { id: '1', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-a' },
725
+ { id: '5', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-e' },
726
+ { id: '2', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-b' },
727
+ { id: '4', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-d' },
728
+ ],
729
+ 'information_schema.columns': [
730
+ { table_name: 'customer_entities', column_name: 'id' },
731
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
732
+ { table_name: 'customer_entities', column_name: 'organization_id' },
733
+ { table_name: 'customer_entities', column_name: 'deleted_at' },
734
+ { table_name: 'customer_entities', column_name: 'display_name' },
735
+ ],
736
+ })
737
+ const namesById: Record<string, string> = {
738
+ '1': 'Alice', '2': 'Bob', '3': 'Charlie', '4': 'Dave', '5': 'Eve',
739
+ }
740
+ const engine = new BasicQueryEngine(
741
+ {} as any,
742
+ () => fakeDb as any,
743
+ () => ({
744
+ isEnabled: () => true,
745
+ getEncryptedFieldNames: async () => ['display_name'],
746
+ decryptEntityPayload: async (_entityId, payload) => ({
747
+ display_name: namesById[String(payload.id)],
748
+ }),
749
+ }),
750
+ )
751
+ return { fakeDb, engine }
752
+ }
753
+
754
+ test('unset: no limit on the phase-1 scan, no warning', async () => {
755
+ delete process.env.OM_ENCRYPTED_SORT_MAX_ROWS
756
+ const { fakeDb, engine } = buildFixture()
757
+ const result = await engine.query('customers:customer_entity', {
758
+ tenantId: 't1',
759
+ organizationId: 'org1',
760
+ fields: ['id', 'display_name'],
761
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
762
+ page: { page: 1, pageSize: 2 },
763
+ })
764
+ expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
765
+ const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
766
+ expect(phase1Call._ops.limits).toBe(0)
767
+ })
768
+
769
+ test('set but not exceeded: no warning, identical results to uncapped', async () => {
770
+ process.env.OM_ENCRYPTED_SORT_MAX_ROWS = '10'
771
+ const { fakeDb, engine } = buildFixture()
772
+ const result = await engine.query('customers:customer_entity', {
773
+ tenantId: 't1',
774
+ organizationId: 'org1',
775
+ fields: ['id', 'display_name'],
776
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
777
+ page: { page: 1, pageSize: 2 },
778
+ })
779
+ expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
780
+ expect(result.items.map((item: any) => item.display_name)).toEqual(['Alice', 'Bob'])
781
+ })
782
+
783
+ test('set and exceeded: caps + orders the phase-1 scan and attaches a warning', async () => {
784
+ process.env.OM_ENCRYPTED_SORT_MAX_ROWS = '3'
785
+ const { fakeDb, engine } = buildFixture()
786
+ const result = await engine.query('customers:customer_entity', {
787
+ tenantId: 't1',
788
+ organizationId: 'org1',
789
+ fields: ['id', 'display_name'],
790
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
791
+ page: { page: 1, pageSize: 2 },
792
+ })
793
+ expect(result.meta?.encryptedSortRowCapWarning).toEqual({
794
+ entity: 'customers:customer_entity',
795
+ sortFields: ['display_name'],
796
+ maxRows: 3,
797
+ totalMatched: 5,
798
+ })
799
+ const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
800
+ expect(phase1Call._ops.limits).toBe(3)
801
+ expect(phase1Call._ops.orderBys).toEqual([['customer_entities.id', 'asc']])
802
+ })
648
803
  })
649
804
 
650
805
  test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Maps `items` through `mapper` with at most `concurrency` calls in flight at
3
+ * once, preserving input order in the output. Chunks are processed
4
+ * sequentially; within a chunk, calls run in parallel via `Promise.all`.
5
+ *
6
+ * When `concurrency` is non-positive or `>= items.length`, this is
7
+ * equivalent to a plain `Promise.all(items.map(mapper))`.
8
+ */
9
+ export async function mapWithConcurrency<T, R>(
10
+ items: readonly T[],
11
+ concurrency: number,
12
+ mapper: (item: T, index: number) => Promise<R>,
13
+ ): Promise<R[]> {
14
+ if (concurrency <= 0 || items.length <= concurrency) {
15
+ return Promise.all(items.map((item, index) => mapper(item, index)))
16
+ }
17
+ const results: R[] = new Array(items.length)
18
+ for (let start = 0; start < items.length; start += concurrency) {
19
+ const chunk = items.slice(start, start + concurrency)
20
+ const chunkResults = await Promise.all(
21
+ chunk.map((item, offset) => mapper(item, start + offset)),
22
+ )
23
+ for (let i = 0; i < chunkResults.length; i++) results[start + i] = chunkResults[i]
24
+ }
25
+ return results
26
+ }
@@ -24,6 +24,19 @@ export function fieldNameCandidates(field: string): string[] {
24
24
  return Array.from(new Set(candidates))
25
25
  }
26
26
 
27
+ /**
28
+ * Opt-in cap on how many candidate rows the plaintext-sort path may fetch
29
+ * for sort-column decryption. Unset or invalid input means uncapped — the
30
+ * default must stay byte-identical to the pre-cap behavior.
31
+ */
32
+ export function resolveEncryptedSortMaxRows(): number | null {
33
+ const raw = process.env.OM_ENCRYPTED_SORT_MAX_ROWS
34
+ if (raw === undefined || raw === '') return null
35
+ const parsed = Number(raw)
36
+ if (!Number.isFinite(parsed) || parsed <= 0) return null
37
+ return Math.floor(parsed)
38
+ }
39
+
27
40
  export async function resolveEncryptedSortFields(
28
41
  service: QueryEncryptionService | null | undefined,
29
42
  entity: EntityId,