@open-mercato/shared 0.6.8-develop.7029.1.a1bb3363af → 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.
@@ -178,6 +178,13 @@ function createFakeKysely(overrides?: FakeData) {
178
178
  return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
179
179
  }
180
180
  if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
181
+ // An aggregate over a recorded subquery (the capped-count probe) counts
182
+ // the subquery's source rows bounded by its LIMIT, mirroring Postgres.
183
+ if (localOps.subquery) {
184
+ const sourceRows = (data[localOps.subquery.table] || []).length
185
+ const innerLimit = localOps.subquery.limits
186
+ return { count: String(innerLimit ? Math.min(sourceRows, innerLimit) : sourceRows) }
187
+ }
181
188
  return { count: String((data[localOps.table] || []).length) }
182
189
  }
183
190
  const rows = data[localOps.table] || []
@@ -190,6 +197,22 @@ function createFakeKysely(overrides?: FakeData) {
190
197
  }
191
198
 
192
199
  function builderFor(tableArg: any): any {
200
+ if (tableArg && typeof tableArg === 'object' && tableArg._ops) {
201
+ // selectFrom(subquery.as(alias)) — the capped-count probe shape.
202
+ const ops = {
203
+ table: '__subquery__',
204
+ alias: tableArg._ops.alias ?? null,
205
+ subquery: tableArg._ops,
206
+ wheres: [] as any[],
207
+ joins: [] as any[],
208
+ selects: [] as any[],
209
+ orderBys: [] as any[],
210
+ groups: [] as any[],
211
+ limits: 0,
212
+ offsets: 0,
213
+ }
214
+ return makeBuilder(ops, true)
215
+ }
193
216
  const parsed = parseTableSpec(tableArg)
194
217
  const ops = {
195
218
  table: parsed.table,
@@ -695,10 +718,10 @@ describe('BasicQueryEngine (Kysely)', () => {
695
718
 
696
719
  expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
697
720
  const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
698
- expect(baseCalls.length).toBe(2)
699
- // qFull ('full' projection) is built first (used for count + phase 2);
700
- // qSort ('sortKeys' projection) is built second (phase 1).
701
- const [phase2Call, phase1Call] = baseCalls
721
+ expect(baseCalls.length).toBe(3)
722
+ // qFull ('full' projection) is built first (used for phase 2), then the
723
+ // 'count' projection (the bounded count probe), then 'sortKeys' (phase 1).
724
+ const [phase2Call, , phase1Call] = baseCalls
702
725
  // Phase 1 (slim id+sort-column scan): no SQL order/limit — the full candidate
703
726
  // set is fetched, decrypted, and sorted in memory.
704
727
  expect(phase1Call._ops.orderBys).toEqual([])
@@ -880,7 +903,7 @@ describe('BasicQueryEngine (Kysely)', () => {
880
903
  page: { page: 1, pageSize: 2 },
881
904
  })
882
905
  expect(result.meta?.encryptedSortRowCapWarning).toBeUndefined()
883
- const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
906
+ const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
884
907
  expect(phase1Call._ops.limits).toBe(0)
885
908
  })
886
909
 
@@ -914,10 +937,37 @@ describe('BasicQueryEngine (Kysely)', () => {
914
937
  maxRows: 3,
915
938
  totalMatched: 5,
916
939
  })
917
- const [, phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
918
- expect(phase1Call._ops.limits).toBe(3)
940
+ const [, , phase1Call] = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
941
+ // cap + 1 probe: truncation is detected from the candidate scan itself,
942
+ // not by comparing against a (possibly capped) total.
943
+ expect(phase1Call._ops.limits).toBe(4)
919
944
  expect(phase1Call._ops.orderBys).toEqual([['customer_entities.id', 'asc']])
920
945
  })
946
+
947
+ test('still warns when the list count itself is capped below the matched set', async () => {
948
+ // The old detection compared `total > sortCap`; with OM_LIST_COUNT_CAP at or
949
+ // below the sort cap that comparison can never fire. The probe must warn anyway.
950
+ process.env.OM_ENCRYPTED_SORT_MAX_ROWS = '3'
951
+ process.env.OM_LIST_COUNT_CAP = '3'
952
+ try {
953
+ const { engine } = buildFixture()
954
+ const result = await engine.query('customers:customer_entity', {
955
+ tenantId: 't1',
956
+ organizationId: 'org1',
957
+ fields: ['id', 'display_name'],
958
+ sort: [{ field: 'display_name', dir: SortDir.Asc }],
959
+ page: { page: 1, pageSize: 2 },
960
+ })
961
+ expect(result.total).toBe(3)
962
+ expect(result.meta?.listCountCapWarning).toEqual({ entity: 'customers:customer_entity', cap: 3 })
963
+ expect(result.meta?.encryptedSortRowCapWarning).toMatchObject({
964
+ entity: 'customers:customer_entity',
965
+ maxRows: 3,
966
+ })
967
+ } finally {
968
+ delete process.env.OM_LIST_COUNT_CAP
969
+ }
970
+ })
921
971
  })
922
972
 
923
973
  test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
@@ -956,6 +1006,80 @@ describe('BasicQueryEngine (Kysely)', () => {
956
1006
  expect(baseCall._ops.offsets).toBe(10)
957
1007
  })
958
1008
 
1009
+ describe('OM_LIST_COUNT_CAP boundary', () => {
1010
+ const originalCap = process.env.OM_LIST_COUNT_CAP
1011
+ afterEach(() => {
1012
+ if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
1013
+ else process.env.OM_LIST_COUNT_CAP = originalCap
1014
+ })
1015
+
1016
+ function buildFixture(rowCount: number) {
1017
+ const rows = Array.from({ length: rowCount }, (_, i) => ({
1018
+ id: String(i + 1), tenant_id: 't1', organization_id: 'org1', display_name: `Row ${i + 1}`,
1019
+ }))
1020
+ const fakeDb = createFakeKysely({
1021
+ customer_entities: rows,
1022
+ 'information_schema.columns': [
1023
+ { table_name: 'customer_entities', column_name: 'id' },
1024
+ { table_name: 'customer_entities', column_name: 'tenant_id' },
1025
+ { table_name: 'customer_entities', column_name: 'organization_id' },
1026
+ { table_name: 'customer_entities', column_name: 'deleted_at' },
1027
+ { table_name: 'customer_entities', column_name: 'display_name' },
1028
+ ],
1029
+ })
1030
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
1031
+ return { fakeDb, engine }
1032
+ }
1033
+
1034
+ const query = (engine: BasicQueryEngine) => engine.query('customers:customer_entity', {
1035
+ tenantId: 't1',
1036
+ organizationId: 'org1',
1037
+ fields: ['id'],
1038
+ page: { page: 1, pageSize: 2 },
1039
+ })
1040
+
1041
+ test('cap - 1 matching rows: exact total, no flag', async () => {
1042
+ process.env.OM_LIST_COUNT_CAP = '5'
1043
+ const { engine } = buildFixture(4)
1044
+ const result = await query(engine)
1045
+ expect(result.total).toBe(4)
1046
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
1047
+ })
1048
+
1049
+ test('exactly cap matching rows: exact total, no flag — a genuine total of cap is not mislabeled', async () => {
1050
+ process.env.OM_LIST_COUNT_CAP = '5'
1051
+ const { engine } = buildFixture(5)
1052
+ const result = await query(engine)
1053
+ expect(result.total).toBe(5)
1054
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
1055
+ })
1056
+
1057
+ test('cap + 1 matching rows: total reports cap (not the probe value) with the warning', async () => {
1058
+ process.env.OM_LIST_COUNT_CAP = '5'
1059
+ const { engine } = buildFixture(6)
1060
+ const result = await query(engine)
1061
+ expect(result.total).toBe(5)
1062
+ expect(result.meta?.listCountCapWarning).toEqual({ entity: 'customers:customer_entity', cap: 5 })
1063
+ })
1064
+
1065
+ test('OM_LIST_COUNT_CAP=0: exact totals however large the set', async () => {
1066
+ process.env.OM_LIST_COUNT_CAP = '0'
1067
+ const { engine } = buildFixture(7)
1068
+ const result = await query(engine)
1069
+ expect(result.total).toBe(7)
1070
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
1071
+ })
1072
+
1073
+ test('unparseable value falls back to the default cap rather than disabling it', async () => {
1074
+ process.env.OM_LIST_COUNT_CAP = 'not-a-number'
1075
+ const { fakeDb, engine } = buildFixture(3)
1076
+ const result = await query(engine)
1077
+ expect(result.total).toBe(3)
1078
+ const outer = fakeDb._calls.find((call: any) => call._ops.table === '__subquery__')
1079
+ expect(outer._ops.subquery.limits).toBe(10_001)
1080
+ })
1081
+ })
1082
+
959
1083
  // A tiebreak sort is only worth configuring if the engine actually emits it.
960
1084
  // `list.tiebreakSortField` (used by the sales line routes to keep lines with an
961
1085
  // equal `line_number` in a repeatable order) is the first caller to pass more
@@ -0,0 +1,19 @@
1
+ import { parseNumberWithDefault } from '../number'
2
+
3
+ export const DEFAULT_LIST_COUNT_CAP = 10_000
4
+
5
+ /**
6
+ * Cap on how many matching rows a list COUNT may visit before reporting
7
+ * `total: cap` with `meta.listCountCapWarning` (surfaced to clients as
8
+ * `totalIsCapped`). Returns the cap as a number, or `null` when capping is
9
+ * disabled.
10
+ *
11
+ * Resolution of `OM_LIST_COUNT_CAP`:
12
+ * - unset / blank / unparseable → `DEFAULT_LIST_COUNT_CAP` (the cap is on by
13
+ * default, and bad input must not silently disable it)
14
+ * - `0` (or negative) → `null` — capping disabled, exact counts everywhere
15
+ */
16
+ export function resolveListCountCap(): number | null {
17
+ const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })
18
+ return parsed <= 0 ? null : parsed
19
+ }