@open-mercato/shared 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -1
  3. package/dist/lib/auth/jwt.js +6 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/mfaPendingAccess.js +42 -0
  6. package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
  7. package/dist/lib/auth/organizationAccess.js +7 -4
  8. package/dist/lib/auth/organizationAccess.js.map +2 -2
  9. package/dist/lib/auth/principal-service.js +1 -0
  10. package/dist/lib/auth/principal-service.js.map +7 -0
  11. package/dist/lib/auth/server.js +38 -7
  12. package/dist/lib/auth/server.js.map +2 -2
  13. package/dist/lib/commands/command-bus.js +6 -1
  14. package/dist/lib/commands/command-bus.js.map +2 -2
  15. package/dist/lib/crud/factory.js +29 -12
  16. package/dist/lib/crud/factory.js.map +2 -2
  17. package/dist/lib/crud/ids.js +6 -3
  18. package/dist/lib/crud/ids.js.map +2 -2
  19. package/dist/lib/crud/query-params.js +33 -0
  20. package/dist/lib/crud/query-params.js.map +7 -0
  21. package/dist/lib/data/engine.js +8 -2
  22. package/dist/lib/data/engine.js.map +2 -2
  23. package/dist/lib/html/htmlToPlainText.js +16 -0
  24. package/dist/lib/html/htmlToPlainText.js.map +7 -0
  25. package/dist/lib/location/countries.js +12 -0
  26. package/dist/lib/location/countries.js.map +2 -2
  27. package/dist/lib/openapi/crud.js +4 -1
  28. package/dist/lib/openapi/crud.js.map +2 -2
  29. package/dist/lib/query/count-cap.js +11 -0
  30. package/dist/lib/query/count-cap.js.map +7 -0
  31. package/dist/lib/query/engine.js +270 -34
  32. package/dist/lib/query/engine.js.map +3 -3
  33. package/dist/lib/query/types.js.map +1 -1
  34. package/dist/lib/queue/dispatchOrigin.js +20 -0
  35. package/dist/lib/queue/dispatchOrigin.js.map +7 -0
  36. package/dist/lib/search/config.js +1 -0
  37. package/dist/lib/search/config.js.map +2 -2
  38. package/dist/lib/search/entityAccess.js +44 -0
  39. package/dist/lib/search/entityAccess.js.map +7 -0
  40. package/dist/lib/version.js +1 -1
  41. package/dist/lib/version.js.map +1 -1
  42. package/dist/modules/events/factory.js +69 -15
  43. package/dist/modules/events/factory.js.map +2 -2
  44. package/dist/modules/registry.js +15 -0
  45. package/dist/modules/registry.js.map +2 -2
  46. package/dist/modules/widgets/component-registry.js.map +2 -2
  47. package/package.json +10 -3
  48. package/src/lib/auth/__tests__/jwt.test.ts +13 -0
  49. package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
  50. package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
  51. package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
  52. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
  53. package/src/lib/auth/__tests__/server.test.ts +104 -0
  54. package/src/lib/auth/jwt.ts +17 -0
  55. package/src/lib/auth/mfaPendingAccess.ts +70 -0
  56. package/src/lib/auth/organizationAccess.ts +11 -3
  57. package/src/lib/auth/principal-service.ts +110 -0
  58. package/src/lib/auth/server.ts +78 -8
  59. package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
  60. package/src/lib/commands/command-bus.ts +8 -1
  61. package/src/lib/crud/__tests__/crud-factory.test.ts +236 -0
  62. package/src/lib/crud/__tests__/ids.test.ts +29 -0
  63. package/src/lib/crud/__tests__/query-params.test.ts +98 -0
  64. package/src/lib/crud/factory.ts +38 -11
  65. package/src/lib/crud/ids.ts +11 -8
  66. package/src/lib/crud/query-params.ts +75 -0
  67. package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
  68. package/src/lib/data/engine.ts +7 -1
  69. package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
  70. package/src/lib/html/htmlToPlainText.ts +17 -0
  71. package/src/lib/location/__tests__/countries.test.ts +15 -0
  72. package/src/lib/location/countries.ts +17 -0
  73. package/src/lib/openapi/crud.ts +3 -0
  74. package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
  75. package/src/lib/query/__tests__/count-cap.test.ts +41 -0
  76. package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
  77. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
  78. package/src/lib/query/__tests__/engine.test.ts +445 -7
  79. package/src/lib/query/count-cap.ts +19 -0
  80. package/src/lib/query/engine.ts +434 -54
  81. package/src/lib/query/types.ts +15 -0
  82. package/src/lib/queue/dispatchOrigin.ts +35 -0
  83. package/src/lib/search/config.ts +10 -0
  84. package/src/lib/search/entityAccess.ts +132 -0
  85. package/src/modules/events/__tests__/factory.test.ts +88 -0
  86. package/src/modules/events/factory.ts +111 -19
  87. package/src/modules/events/types.ts +17 -0
  88. package/src/modules/registry.ts +40 -0
  89. package/src/modules/widgets/component-registry.ts +14 -0
@@ -68,7 +68,15 @@ describe('DataEngine event contract validation (issue #1421)', () => {
68
68
  })
69
69
 
70
70
  expect(emitted).toEqual([
71
- expect.objectContaining({ name: 'issue1421_test.widget.created' }),
71
+ expect.objectContaining({
72
+ name: 'issue1421_test.widget.created',
73
+ options: {
74
+ persistent: false,
75
+ tenantId: 'tenant-1',
76
+ organizationId: 'org-1',
77
+ emitterModuleId: 'issue1421_test',
78
+ },
79
+ }),
72
80
  ])
73
81
  expect(loggerWarn).not.toHaveBeenCalled()
74
82
  } finally {
@@ -226,7 +226,12 @@ export class DefaultDataEngine implements DataEngine {
226
226
  const eventName = `${mod}.${ent}.updated`
227
227
  warnIfUndeclaredEvent(eventName, 'setCustomFields')
228
228
  try {
229
- await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, { persistent: true })
229
+ await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
230
+ persistent: true,
231
+ tenantId,
232
+ organizationId,
233
+ emitterModuleId: mod,
234
+ })
230
235
  } catch {
231
236
  // non-blocking
232
237
  }
@@ -627,6 +632,7 @@ export class DefaultDataEngine implements DataEngine {
627
632
  persistent: !!events.persistent,
628
633
  tenantId: ctx.identifiers.tenantId ?? null,
629
634
  organizationId: ctx.identifiers.organizationId ?? null,
635
+ emitterModuleId: events.module,
630
636
  })
631
637
  } catch {
632
638
  // non-blocking
@@ -0,0 +1,59 @@
1
+ import { htmlToPlainText } from '../htmlToPlainText'
2
+
3
+ describe('htmlToPlainText', () => {
4
+ it('returns an empty string for empty input without invoking the parser', () => {
5
+ expect(htmlToPlainText('')).toBe('')
6
+ })
7
+
8
+ it('renders block-level markup as readable prose', () => {
9
+ const html = '<html><body><p>Hello,</p><p>second paragraph.</p></body></html>'
10
+
11
+ const text = htmlToPlainText(html)
12
+
13
+ expect(text).toContain('Hello,')
14
+ expect(text).toContain('second paragraph.')
15
+ expect(text).not.toContain('<p>')
16
+ expect(text).not.toContain('</body>')
17
+ })
18
+
19
+ it('drops script and style content instead of inlining it as text', () => {
20
+ // Email HTML routinely carries a <style> block and a tracking <script>.
21
+ // Rendering either as text is what made ingested bodies unreadable.
22
+ const html = [
23
+ '<!DOCTYPE html><html><head>',
24
+ '<style>body { margin: 0; font-family: Helvetica; }</style>',
25
+ '<script>trackOpen("beacon")</script>',
26
+ '</head><body><p>Visible copy</p></body></html>',
27
+ ].join('')
28
+
29
+ const text = htmlToPlainText(html)
30
+
31
+ expect(text).toBe('Visible copy')
32
+ expect(text).not.toContain('font-family')
33
+ expect(text).not.toContain('trackOpen')
34
+ expect(text).not.toContain('<!DOCTYPE')
35
+ })
36
+
37
+ it('normalizes CRLF and lone CR line endings to LF', () => {
38
+ const text = htmlToPlainText('<pre>first\r\nsecond\rthird</pre>')
39
+
40
+ expect(text).not.toContain('\r')
41
+ expect(text).toBe('first\nsecond\nthird')
42
+ })
43
+
44
+ it('collapses runs of three or more blank lines down to one', () => {
45
+ const text = htmlToPlainText('<pre>top\n\n\n\n\nbottom</pre>')
46
+
47
+ expect(text).toBe('top\n\nbottom')
48
+ })
49
+
50
+ it('preserves a single blank line between paragraphs', () => {
51
+ const text = htmlToPlainText('<pre>top\n\nbottom</pre>')
52
+
53
+ expect(text).toBe('top\n\nbottom')
54
+ })
55
+
56
+ it('trims surrounding whitespace', () => {
57
+ expect(htmlToPlainText('<pre>\n\n padded \n\n</pre>')).toBe('padded')
58
+ })
59
+ })
@@ -0,0 +1,17 @@
1
+ import { convert } from 'html-to-text'
2
+
3
+ export function htmlToPlainText(html: string): string {
4
+ if (!html) return ''
5
+ return convert(html, {
6
+ wordwrap: false,
7
+ preserveNewlines: true,
8
+ selectors: [
9
+ { selector: 'script', format: 'skip' },
10
+ { selector: 'style', format: 'skip' },
11
+ ],
12
+ })
13
+ .replace(/\r\n/g, '\n')
14
+ .replace(/\r/g, '\n')
15
+ .replace(/\n{3,}/g, '\n\n')
16
+ .trim()
17
+ }
@@ -2,6 +2,7 @@ import {
2
2
  COUNTRY_PRIORITY,
3
3
  ISO_COUNTRIES,
4
4
  buildCountryOptions,
5
+ matchCountryCodes,
5
6
  resolveCountryName,
6
7
  } from '../countries'
7
8
 
@@ -80,3 +81,17 @@ describe('buildCountryOptions', () => {
80
81
  expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo (XK)' }]))
81
82
  })
82
83
  })
84
+
85
+ describe('matchCountryCodes', () => {
86
+ it('maps English country names to ISO codes so list search can find stored PL rows', () => {
87
+ expect(matchCountryCodes('Poland')).toContain('PL')
88
+ })
89
+
90
+ it('maps localized names when extra locales are provided', () => {
91
+ expect(matchCountryCodes('Polska', { locales: ['pl'] })).toContain('PL')
92
+ })
93
+
94
+ it('returns nothing for blank search terms', () => {
95
+ expect(matchCountryCodes(' ')).toEqual([])
96
+ })
97
+ })
@@ -106,3 +106,20 @@ export function buildCountryOptions(options: {
106
106
 
107
107
  return [...prioritized, ...remaining]
108
108
  }
109
+
110
+ export function matchCountryCodes(
111
+ term: string,
112
+ options: { locales?: string[] } = {},
113
+ ): string[] {
114
+ const needle = term.trim().toLowerCase()
115
+ if (!needle) return []
116
+ const searchLocales = options.locales?.length ? options.locales : ['en']
117
+ return ISO_COUNTRIES
118
+ .filter((entry) => {
119
+ if (entry.name.toLowerCase().includes(needle)) return true
120
+ return searchLocales.some((locale) =>
121
+ resolveCountryName(entry.code, { locale }).toLowerCase().includes(needle),
122
+ )
123
+ })
124
+ .map((entry) => entry.code)
125
+ }
@@ -17,6 +17,9 @@ export function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: P
17
17
  page: paginationMetaOptional ? z.number().optional() : z.number(),
18
18
  pageSize: paginationMetaOptional ? z.number().optional() : z.number(),
19
19
  totalPages: z.number(),
20
+ // Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:
21
+ // `total` is then a floor, not an exact value.
22
+ totalIsCapped: z.boolean().optional(),
20
23
  })
21
24
  }
22
25
 
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Plan-level guard for the capped list COUNT (#4552 Phase 2), run against a real
3
+ * PostgreSQL. This is the only test that fails if a future refactor reintroduces
4
+ * a blocking node (Aggregate/Sort/Unique) between the probe `Limit` and the base
5
+ * scan — a mocked total cannot catch a bound that does not bind. It doubles as
6
+ * the count-parity check: with the cap disabled, the rebuilt count must equal
7
+ * ground truth across the cf-filter / or-group / plain matrices.
8
+ *
9
+ * Gated on OM_COUNT_CAP_PG_URL (a throwaway database — the suite creates and
10
+ * drops its own tables). Example:
11
+ * docker run --rm -d -p 54329:5432 -e POSTGRES_PASSWORD=t postgres:16
12
+ * OM_COUNT_CAP_PG_URL=postgres://postgres:t@localhost:54329/postgres yarn jest count-cap-plan
13
+ */
14
+ import { Kysely, PostgresDialect, sql } from 'kysely'
15
+ import { BasicQueryEngine } from '../engine'
16
+
17
+ const PG_URL = process.env.OM_COUNT_CAP_PG_URL
18
+ const maybe = PG_URL ? describe : describe.skip
19
+
20
+ const TENANT = '11111111-1111-4111-8111-111111111111'
21
+ const ORG = '22222222-2222-4222-8222-222222222222'
22
+ const ENTITY = 'capcheck:om_capcheck_order'
23
+ const TABLE = 'om_capcheck_orders'
24
+ const ROWS = 120
25
+ const RED_ROWS = 30
26
+
27
+ maybe('capped list COUNT against PostgreSQL', () => {
28
+ jest.setTimeout(60_000)
29
+ let db: Kysely<any>
30
+ let sqlLog: string[] = []
31
+ let paramLog: unknown[][] = []
32
+ const originalCap = process.env.OM_LIST_COUNT_CAP
33
+
34
+ beforeAll(async () => {
35
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
36
+ const { Pool } = require('pg')
37
+ db = new Kysely<any>({
38
+ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }),
39
+ log(event) {
40
+ if (event.level === 'query' || event.level === 'error') {
41
+ sqlLog.push(event.query.sql)
42
+ paramLog.push([...event.query.parameters])
43
+ }
44
+ },
45
+ })
46
+ await sql`drop table if exists om_capcheck_orders`.execute(db)
47
+ await sql`create table om_capcheck_orders (
48
+ id uuid primary key,
49
+ tenant_id uuid not null,
50
+ organization_id uuid not null,
51
+ deleted_at timestamptz,
52
+ status text not null
53
+ )`.execute(db)
54
+ await sql`create table if not exists custom_field_defs (
55
+ id serial primary key,
56
+ key text not null,
57
+ entity_id text not null,
58
+ kind text,
59
+ config_json jsonb,
60
+ is_active boolean not null default true,
61
+ organization_id uuid,
62
+ tenant_id uuid,
63
+ updated_at timestamptz,
64
+ deleted_at timestamptz
65
+ )`.execute(db)
66
+ await sql`create table if not exists custom_field_values (
67
+ id serial primary key,
68
+ entity_id text not null,
69
+ field_key text not null,
70
+ record_id text not null,
71
+ value_text text,
72
+ value_multiline text,
73
+ value_int int,
74
+ value_float float,
75
+ value_bool boolean,
76
+ organization_id uuid,
77
+ tenant_id uuid
78
+ )`.execute(db)
79
+ await sql`delete from custom_field_defs where entity_id = ${ENTITY}`.execute(db)
80
+ await sql`delete from custom_field_values where entity_id = ${ENTITY}`.execute(db)
81
+ await sql`insert into custom_field_defs (key, entity_id, kind, is_active, tenant_id)
82
+ values ('color', ${ENTITY}, 'text', true, null)`.execute(db)
83
+ for (let i = 0; i < ROWS; i++) {
84
+ const id = `33333333-3333-4333-8333-${String(i).padStart(12, '0')}`
85
+ await sql`insert into om_capcheck_orders (id, tenant_id, organization_id, deleted_at, status)
86
+ values (${id}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, ${i % 2 === 0 ? 'open' : 'closed'})`.execute(db)
87
+ if (i < RED_ROWS) {
88
+ await sql`insert into custom_field_values (entity_id, field_key, record_id, value_text, tenant_id)
89
+ values (${ENTITY}, 'color', ${id}, 'red', ${TENANT}::uuid)`.execute(db)
90
+ }
91
+ }
92
+ })
93
+
94
+ afterAll(async () => {
95
+ await sql`drop table if exists om_capcheck_orders`.execute(db)
96
+ await sql`delete from custom_field_defs where entity_id = ${ENTITY}`.execute(db)
97
+ await sql`delete from custom_field_values where entity_id = ${ENTITY}`.execute(db)
98
+ await db.destroy()
99
+ })
100
+
101
+ afterEach(() => {
102
+ if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
103
+ else process.env.OM_LIST_COUNT_CAP = originalCap
104
+ })
105
+
106
+ const makeEngine = () => new BasicQueryEngine({} as any, () => db as any)
107
+ const baseOpts = {
108
+ tenantId: TENANT,
109
+ organizationId: ORG,
110
+ fields: ['id'],
111
+ page: { page: 1, pageSize: 10 },
112
+ }
113
+
114
+ function lastCountSql(): { text: string; params: unknown[] } {
115
+ for (let i = sqlLog.length - 1; i >= 0; i--) {
116
+ if (/count\(\*\)/i.test(sqlLog[i])) return { text: sqlLog[i], params: paramLog[i] }
117
+ }
118
+ throw new Error('no count SQL captured')
119
+ }
120
+
121
+ function collectNodes(node: any, out: any[] = []): any[] {
122
+ if (!node) return out
123
+ out.push(node)
124
+ for (const child of node.Plans ?? []) collectNodes(child, out)
125
+ return out
126
+ }
127
+
128
+ test('probe plan: no blocking node between the Limit and the base scan (cf-filtered)', async () => {
129
+ process.env.OM_LIST_COUNT_CAP = '50'
130
+ sqlLog = []; paramLog = []
131
+ const result = await makeEngine().query(ENTITY, {
132
+ ...baseOpts,
133
+ filters: { status: { $eq: 'open' } },
134
+ })
135
+ expect(result.total).toBe(50)
136
+ expect(result.meta?.listCountCapWarning).toEqual({ entity: ENTITY, cap: 50 })
137
+
138
+ const { text, params } = lastCountSql()
139
+ expect(text.toLowerCase()).toContain('limit')
140
+ const explained = await sql
141
+ .raw(`explain (format json) ${text.replace(/\$(\d+)/g, (_, n) => {
142
+ const value = params[Number(n) - 1]
143
+ return typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`
144
+ })}`)
145
+ .execute(db)
146
+ const plan = (explained.rows[0] as any)['QUERY PLAN'][0].Plan
147
+ const all = collectNodes(plan)
148
+ const limitNode = all.find((n) => n['Node Type'] === 'Limit')
149
+ expect(limitNode).toBeTruthy()
150
+ // Below the Limit: only row-producing nodes down to the scan. An Aggregate,
151
+ // Sort, or Unique here means the bound does not bind.
152
+ const belowLimit = collectNodes(limitNode).slice(1)
153
+ const blocking = belowLimit.filter((n) => /Aggregate|Sort|Unique/.test(String(n['Node Type'])))
154
+ expect(blocking).toEqual([])
155
+ // The base scan is inside the Limit subtree.
156
+ expect(belowLimit.some((n) => n['Relation Name'] === TABLE)).toBe(true)
157
+ })
158
+
159
+ test('cf filter compiles to EXISTS and stays cappable', async () => {
160
+ process.env.OM_LIST_COUNT_CAP = '10'
161
+ sqlLog = []; paramLog = []
162
+ const result = await makeEngine().query(ENTITY, {
163
+ ...baseOpts,
164
+ filters: { cf_color: { $eq: 'red' } },
165
+ })
166
+ expect(result.total).toBe(10)
167
+ expect(result.meta?.listCountCapWarning).toEqual({ entity: ENTITY, cap: 10 })
168
+ const { text } = lastCountSql()
169
+ expect(text.toLowerCase()).toContain('exists')
170
+ expect(text.toLowerCase()).not.toContain('group by')
171
+ })
172
+
173
+ test('parity with the cap disabled: rebuilt count matches the display query across the filter matrix', async () => {
174
+ process.env.OM_LIST_COUNT_CAP = '0'
175
+ const engine = makeEngine()
176
+ const wide = { page: { page: 1, pageSize: 1000 } }
177
+
178
+ // Each case asserts the rebuilt count against ground truth where the filter
179
+ // semantics are well-defined, and always against the display query's own
180
+ // row set — the regression guard for "count rebuild changing a total".
181
+ const expectParity = async (filters: any, expectedTotal?: number) => {
182
+ const result = await engine.query(ENTITY, { ...baseOpts, ...wide, filters })
183
+ expect(result.total).toBe(result.items.length)
184
+ if (expectedTotal !== undefined) expect(result.total).toBe(expectedTotal)
185
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
186
+ return result
187
+ }
188
+
189
+ await expectParity(undefined, ROWS)
190
+ await expectParity({ status: { $eq: 'open' } }, ROWS / 2)
191
+ await expectParity({ cf_color: { $eq: 'red' } }, RED_ROWS)
192
+ await expectParity({ $and: [{ status: { $eq: 'open' } }, { cf_color: { $eq: 'red' } }] }, RED_ROWS / 2)
193
+ // Base-column-only $or exercises the or-group path in the count shape.
194
+ await expectParity({ $or: [{ status: { $eq: 'open' } }, { status: { $eq: 'closed' } }] }, ROWS)
195
+ // Mixed base + cf disjunction (#5039): the count shape must compile the cf
196
+ // leaf to an EXISTS rather than dropping it — a dropped leaf narrows the OR
197
+ // and undercounts. closed (60) ∪ red (30, half of which are closed) = 75.
198
+ await expectParity({ $or: [{ status: { $eq: 'closed' } }, { cf_color: { $eq: 'red' } }] }, ROWS / 2 + RED_ROWS / 2)
199
+ // cf-only disjunction: red (30) ∪ blue (0) = 30.
200
+ await expectParity({ $or: [{ cf_color: { $eq: 'red' } }, { cf_color: { $eq: 'blue' } }] }, RED_ROWS)
201
+ await expectParity({ cf_color: { $eq: 'blue' } }, 0)
202
+ await expectParity({ cf_color: { $eq: null } }, ROWS - RED_ROWS)
203
+ await expectParity({ cf_color: { $ne: 'red' } }, 0)
204
+ await expectParity({ cf_color: { $in: ['red', 'blue'] } }, RED_ROWS)
205
+ })
206
+
207
+ test('kill switch (cap=0): the cf-filtered count still plans set-oriented, not per-row', async () => {
208
+ // OM_LIST_COUNT_CAP=0 restores exact totals but runs them through the
209
+ // rebuilt shape. The documented escape hatch must not be slower than the
210
+ // problem: the uncapped EXISTS should plan as a semi-join or a hashed
211
+ // subplan, never an un-hashed per-row subplan re-executed for every row.
212
+ process.env.OM_LIST_COUNT_CAP = '0'
213
+ sqlLog = []; paramLog = []
214
+ const result = await makeEngine().query(ENTITY, {
215
+ ...baseOpts,
216
+ filters: { cf_color: { $eq: 'red' } },
217
+ })
218
+ expect(result.total).toBe(RED_ROWS)
219
+
220
+ const { text, params } = lastCountSql()
221
+ const explained = await sql
222
+ .raw(`explain (format json) ${text.replace(/\$(\d+)/g, (_, n) => {
223
+ const value = params[Number(n) - 1]
224
+ return typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`
225
+ })}`)
226
+ .execute(db)
227
+ const plan = (explained.rows[0] as any)['QUERY PLAN'][0].Plan
228
+ const all = collectNodes(plan)
229
+ const semiJoin = all.some((n) => /Semi/.test(String(n['Join Type'] ?? '')))
230
+ const hashedSubplan = all.some((n) => /hashed/i.test(String(n['Subplan Name'] ?? '')))
231
+ expect(semiJoin || hashedSubplan).toBe(true)
232
+ })
233
+
234
+ test('sub-cap totals stay exact with the cap active', async () => {
235
+ process.env.OM_LIST_COUNT_CAP = '1000'
236
+ const result = await makeEngine().query(ENTITY, { ...baseOpts, filters: { cf_color: { $eq: 'red' } } })
237
+ expect(result.total).toBe(RED_ROWS)
238
+ expect(result.meta?.listCountCapWarning).toBeUndefined()
239
+ })
240
+ })
@@ -0,0 +1,41 @@
1
+ import { resolveListCountCap, DEFAULT_LIST_COUNT_CAP } from '../count-cap'
2
+
3
+ describe('resolveListCountCap', () => {
4
+ const original = process.env.OM_LIST_COUNT_CAP
5
+ afterEach(() => {
6
+ if (original === undefined) delete process.env.OM_LIST_COUNT_CAP
7
+ else process.env.OM_LIST_COUNT_CAP = original
8
+ })
9
+
10
+ test('unset: the default cap — the cap is on by default', () => {
11
+ delete process.env.OM_LIST_COUNT_CAP
12
+ expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
13
+ })
14
+
15
+ test('blank: the default cap', () => {
16
+ process.env.OM_LIST_COUNT_CAP = ' '
17
+ expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
18
+ })
19
+
20
+ test('0 disables capping', () => {
21
+ process.env.OM_LIST_COUNT_CAP = '0'
22
+ expect(resolveListCountCap()).toBeNull()
23
+ })
24
+
25
+ test('negative values disable capping like 0', () => {
26
+ process.env.OM_LIST_COUNT_CAP = '-5'
27
+ expect(resolveListCountCap()).toBeNull()
28
+ })
29
+
30
+ test('a positive integer is used as-is; floats floor', () => {
31
+ process.env.OM_LIST_COUNT_CAP = '500'
32
+ expect(resolveListCountCap()).toBe(500)
33
+ process.env.OM_LIST_COUNT_CAP = '500.9'
34
+ expect(resolveListCountCap()).toBe(500)
35
+ })
36
+
37
+ test('unparseable input falls back to the default — bad input must not silently disable the cap', () => {
38
+ process.env.OM_LIST_COUNT_CAP = 'unbounded'
39
+ expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
40
+ })
41
+ })