@open-mercato/shared 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/lib/ai/llm-provider-registry.js.map +1 -1
  4. package/dist/lib/crud/custom-fields.js +23 -15
  5. package/dist/lib/crud/custom-fields.js.map +2 -2
  6. package/dist/lib/crud/factory.js.map +1 -1
  7. package/dist/lib/crud/optimistic-lock-command.js.map +1 -1
  8. package/dist/lib/crud/optimistic-lock-headers.js.map +1 -1
  9. package/dist/lib/crud/optimistic-lock-store.js.map +1 -1
  10. package/dist/lib/crud/optimistic-lock.js.map +1 -1
  11. package/dist/lib/data/engine.js +25 -1
  12. package/dist/lib/data/engine.js.map +2 -2
  13. package/dist/lib/db/buildIlikeTerm.js +17 -0
  14. package/dist/lib/db/buildIlikeTerm.js.map +7 -0
  15. package/dist/lib/db/mikro.js +38 -9
  16. package/dist/lib/db/mikro.js.map +2 -2
  17. package/dist/lib/di/container.js +1 -1
  18. package/dist/lib/di/container.js.map +1 -1
  19. package/dist/lib/encryption/kms.js +41 -6
  20. package/dist/lib/encryption/kms.js.map +2 -2
  21. package/dist/lib/query/advanced-filter-tree.js +5 -5
  22. package/dist/lib/query/advanced-filter-tree.js.map +2 -2
  23. package/dist/lib/query/advanced-filter.js +5 -5
  24. package/dist/lib/query/advanced-filter.js.map +2 -2
  25. package/dist/lib/query/engine.js +3 -1
  26. package/dist/lib/query/engine.js.map +2 -2
  27. package/dist/lib/query/types.js.map +1 -1
  28. package/dist/lib/version.js +1 -1
  29. package/dist/lib/version.js.map +1 -1
  30. package/dist/modules/overrides.js +1 -1
  31. package/dist/modules/overrides.js.map +1 -1
  32. package/dist/modules/search.js.map +1 -1
  33. package/package.json +4 -5
  34. package/src/lib/ai/llm-provider-registry.ts +1 -1
  35. package/src/lib/ai/llm-provider.ts +1 -1
  36. package/src/lib/crud/__tests__/custom-fields.test.ts +91 -0
  37. package/src/lib/crud/custom-fields.ts +30 -17
  38. package/src/lib/crud/factory.ts +1 -1
  39. package/src/lib/crud/optimistic-lock-command.ts +1 -1
  40. package/src/lib/crud/optimistic-lock-headers.ts +1 -1
  41. package/src/lib/crud/optimistic-lock-store.ts +1 -1
  42. package/src/lib/crud/optimistic-lock.ts +1 -1
  43. package/src/lib/data/__tests__/engine.custom-entity-storage-guard.test.ts +78 -0
  44. package/src/lib/data/engine.ts +40 -0
  45. package/src/lib/db/__tests__/buildIlikeTerm.test.ts +40 -0
  46. package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
  47. package/src/lib/db/__tests__/mikro.test.ts +82 -0
  48. package/src/lib/db/buildIlikeTerm.ts +16 -0
  49. package/src/lib/db/mikro.ts +55 -16
  50. package/src/lib/di/container.ts +1 -1
  51. package/src/lib/encryption/__tests__/kms.test.ts +80 -0
  52. package/src/lib/encryption/kms.ts +55 -7
  53. package/src/lib/query/__tests__/engine.count-distinct.test.ts +229 -0
  54. package/src/lib/query/advanced-filter-tree.ts +5 -5
  55. package/src/lib/query/advanced-filter.ts +5 -5
  56. package/src/lib/query/engine.ts +13 -2
  57. package/src/lib/query/types.ts +10 -0
  58. package/src/modules/__tests__/overrides.test.ts +1 -1
  59. package/src/modules/__tests__/route-overrides.test.ts +1 -1
  60. package/src/modules/navigation/backendChrome.ts +9 -0
  61. package/src/modules/overrides.ts +3 -3
  62. package/src/modules/search.ts +1 -1
@@ -35,6 +35,47 @@ export function getOrmEntities(): any[] {
35
35
  return entities
36
36
  }
37
37
 
38
+ export type ResolvedPoolConfig = {
39
+ poolMin: number
40
+ poolMax: number
41
+ poolIdleTimeout: number
42
+ poolAcquireTimeout: number
43
+ idleSessionTimeoutMs: number | undefined
44
+ idleInTransactionTimeoutMs: number | undefined
45
+ statementTimeoutMs: number | undefined
46
+ lockTimeoutMs: number | undefined
47
+ }
48
+
49
+ // Parse an optional positive-millisecond env var. Returns undefined when unset,
50
+ // non-numeric, or non-positive so callers treat "no value" as "no timeout".
51
+ function parsePositiveIntEnv(raw: string | undefined): number | undefined {
52
+ const parsed = parseInt(raw || '')
53
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
54
+ }
55
+
56
+ export function resolvePoolConfig(env: NodeJS.ProcessEnv = process.env): ResolvedPoolConfig {
57
+ const idleSessionTimeoutEnv = parseInt(env.DB_IDLE_SESSION_TIMEOUT_MS || '')
58
+ const idleInTxTimeoutEnv = parseInt(env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || '')
59
+ return {
60
+ poolMin: parseInt(env.DB_POOL_MIN || '2'),
61
+ poolMax: parseInt(env.DB_POOL_MAX || '20'),
62
+ poolIdleTimeout: parseInt(env.DB_POOL_IDLE_TIMEOUT || '3000'),
63
+ poolAcquireTimeout: parseInt(env.DB_POOL_ACQUIRE_TIMEOUT || '6000'),
64
+ idleSessionTimeoutMs: Number.isFinite(idleSessionTimeoutEnv)
65
+ ? idleSessionTimeoutEnv
66
+ : env.NODE_ENV === 'production'
67
+ ? undefined
68
+ : 600_000,
69
+ // Finite default in every environment (including production) so a leaked or idle
70
+ // open transaction cannot pin a pool connection indefinitely and exhaust the pool.
71
+ // Mirrors the long-standing dev value; override (incl. 0 to disable) via env.
72
+ idleInTransactionTimeoutMs: Number.isFinite(idleInTxTimeoutEnv) ? idleInTxTimeoutEnv : 120_000,
73
+ // Opt-in guards against runaway statements and lock waits. No timeout when unset.
74
+ statementTimeoutMs: parsePositiveIntEnv(env.DB_STATEMENT_TIMEOUT_MS),
75
+ lockTimeoutMs: parsePositiveIntEnv(env.DB_LOCK_TIMEOUT_MS),
76
+ }
77
+ }
78
+
38
79
  export async function getOrm() {
39
80
  if (ormInstance) {
40
81
  return ormInstance
@@ -47,22 +88,16 @@ export async function getOrm() {
47
88
  }
48
89
 
49
90
  // Parse connection pool settings from environment
50
- const poolMin = parseInt(process.env.DB_POOL_MIN || '2')
51
- const poolMax = parseInt(process.env.DB_POOL_MAX || '20')
52
- const poolIdleTimeout = parseInt(process.env.DB_POOL_IDLE_TIMEOUT || '3000')
53
- const poolAcquireTimeout = parseInt(process.env.DB_POOL_ACQUIRE_TIMEOUT || '6000')
54
- const idleSessionTimeoutEnv = parseInt(process.env.DB_IDLE_SESSION_TIMEOUT_MS || '')
55
- const idleInTxTimeoutEnv = parseInt(process.env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || '')
56
- const idleSessionTimeoutMs = Number.isFinite(idleSessionTimeoutEnv)
57
- ? idleSessionTimeoutEnv
58
- : process.env.NODE_ENV === 'production'
59
- ? undefined
60
- : 600_000
61
- const idleInTransactionTimeoutMs = Number.isFinite(idleInTxTimeoutEnv)
62
- ? idleInTxTimeoutEnv
63
- : process.env.NODE_ENV === 'production'
64
- ? undefined
65
- : 120_000
91
+ const {
92
+ poolMin,
93
+ poolMax,
94
+ poolIdleTimeout,
95
+ poolAcquireTimeout,
96
+ idleSessionTimeoutMs,
97
+ idleInTransactionTimeoutMs,
98
+ statementTimeoutMs,
99
+ lockTimeoutMs,
100
+ } = resolvePoolConfig()
66
101
  const connectionOptions =
67
102
  idleSessionTimeoutMs && idleSessionTimeoutMs > 0
68
103
  ? `-c idle_session_timeout=${idleSessionTimeoutMs}`
@@ -78,6 +113,8 @@ export async function getOrm() {
78
113
  poolAcquireTimeout,
79
114
  idleSessionTimeoutMs,
80
115
  idleInTransactionTimeoutMs,
116
+ statementTimeoutMs,
117
+ lockTimeoutMs,
81
118
  nodeEnv: process.env.NODE_ENV,
82
119
  })
83
120
  }
@@ -107,6 +144,8 @@ export async function getOrm() {
107
144
  driverOptions: {
108
145
  connectionTimeoutMillis: poolAcquireTimeout,
109
146
  idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,
147
+ statement_timeout: statementTimeoutMs,
148
+ lock_timeout: lockTimeoutMs,
110
149
  options: connectionOptions,
111
150
  ssl: sslConfig,
112
151
  onPoolCreated: (pool: any) => {
@@ -164,7 +164,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
164
164
  // sent) it short-circuits at validateMutation. Module-level di.ts
165
165
  // registrations override this default via Awilix replace semantics —
166
166
  // see the enterprise `record_locks` module for the canonical override.
167
- // Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
167
+ // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md
168
168
  crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>
169
169
  createOptimisticLockGuardService({
170
170
  getEm: () => scopedEm,
@@ -126,3 +126,83 @@ describe('kms timeout handling', () => {
126
126
  expect(dek?.key).toBeTruthy()
127
127
  })
128
128
  })
129
+
130
+ describe('kms self-healing circuit breaker (#2661)', () => {
131
+ const vaultAddr = 'http://vault.test'
132
+ const vaultToken = 'token'
133
+
134
+ afterEach(() => {
135
+ process.env = { ...originalEnv }
136
+ jest.restoreAllMocks()
137
+ })
138
+
139
+ it('re-probes Vault and recovers after the cooldown window instead of staying unhealthy forever', async () => {
140
+ let clock = 1_000_000
141
+ jest.spyOn(Date, 'now').mockImplementation(() => clock)
142
+
143
+ let calls = 0
144
+ const fetchMock = jest.fn(() => {
145
+ calls += 1
146
+ if (calls === 1) {
147
+ // First read hits a transient 5xx (Vault hiccup) → breaker opens.
148
+ return Promise.resolve({ ok: false, status: 503, json: async () => ({}) })
149
+ }
150
+ // Vault is back: subsequent reads succeed.
151
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { data: { key: 'recovered-key' } } }) })
152
+ })
153
+ ;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as typeof fetch
154
+
155
+ const service = new HashicorpVaultKmsService({ vaultAddr, vaultToken, recoveryCooldownMs: 5_000 })
156
+
157
+ // Transient failure flips the instance unhealthy.
158
+ await expect(service.getTenantDek('tenant-1')).resolves.toBeNull()
159
+ expect(service.isHealthy()).toBe(false)
160
+
161
+ // Still within the cooldown: the breaker stays open.
162
+ clock += 4_000
163
+ expect(service.isHealthy()).toBe(false)
164
+
165
+ // Past the cooldown: half-open — report healthy so the next call re-probes.
166
+ clock += 2_000
167
+ expect(service.isHealthy()).toBe(true)
168
+
169
+ // The re-probe succeeds and the breaker fully closes (the never-recover bug).
170
+ const dek = await service.getTenantDek('tenant-1')
171
+ expect(dek?.key).toBe('recovered-key')
172
+ expect(service.isHealthy()).toBe(true)
173
+ })
174
+
175
+ it('treats missing VAULT_ADDR/VAULT_TOKEN as a terminal failure that never self-heals', () => {
176
+ let clock = 2_000_000
177
+ jest.spyOn(Date, 'now').mockImplementation(() => clock)
178
+
179
+ const service = new HashicorpVaultKmsService({ vaultAddr: '', vaultToken: '', recoveryCooldownMs: 5_000 })
180
+ expect(service.isHealthy()).toBe(false)
181
+
182
+ // No cooldown re-probe should ever revive a misconfigured instance.
183
+ clock += 10_000_000
184
+ expect(service.isHealthy()).toBe(false)
185
+ })
186
+
187
+ it('keeps Vault healthy on a 404 read so read-before-write DEK creation can proceed', async () => {
188
+ jest.spyOn(Date, 'now').mockReturnValue(3_000_000)
189
+
190
+ const fetchMock = jest.fn((_url: string, init?: RequestInit) => {
191
+ const method = (init?.method || 'GET').toUpperCase()
192
+ if (method === 'GET') {
193
+ // KV v2 returns 404 for a not-yet-created tenant key — Vault is reachable.
194
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) })
195
+ }
196
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({}) })
197
+ })
198
+ ;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as typeof fetch
199
+
200
+ const service = new HashicorpVaultKmsService({ vaultAddr, vaultToken })
201
+
202
+ const dek = await service.createTenantDek('tenant-2')
203
+ expect(typeof dek?.key).toBe('string')
204
+ expect(dek?.key).toBeTruthy()
205
+ expect(service.isHealthy()).toBe(true)
206
+ expect(fetchMock).toHaveBeenCalledTimes(2) // 404 read probe + the write
207
+ })
208
+ })
@@ -5,6 +5,7 @@ import { parseBooleanToken } from '../boolean'
5
5
  import { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'
6
6
 
7
7
  const DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000
8
+ const DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 30_000
8
9
 
9
10
  function resolveVaultRequestTimeoutMs(): number {
10
11
  const raw = process.env.VAULT_REQUEST_TIMEOUT_MS
@@ -12,6 +13,12 @@ function resolveVaultRequestTimeoutMs(): number {
12
13
  return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)
13
14
  }
14
15
 
16
+ function resolveVaultRecoveryCooldownMs(): number {
17
+ const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS
18
+ const parsed = raw ? Number.parseInt(raw, 10) : undefined
19
+ return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS)
20
+ }
21
+
15
22
  export type TenantDek = {
16
23
  tenantId: string
17
24
  key: string // base64
@@ -90,6 +97,7 @@ type VaultClientOpts = {
90
97
  mountPath?: string
91
98
  ttlMs?: number
92
99
  requestTimeoutMs?: number
100
+ recoveryCooldownMs?: number
93
101
  }
94
102
 
95
103
  type VaultReadResponse = {
@@ -166,7 +174,16 @@ export class HashicorpVaultKmsService implements KmsService {
166
174
  private readonly mountPath: string
167
175
  private readonly ttlMs: number
168
176
  private readonly requestTimeoutMs: number
177
+ private readonly recoveryCooldownMs: number
169
178
  private healthy = true
179
+ // Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of
180
+ // re-probing fixes a misconfiguration, so this never self-heals — only a
181
+ // restart with corrected config does.
182
+ private misconfigured = false
183
+ // Timestamp of the last transient failure (timeout / network blip / 5xx).
184
+ // Drives the half-open circuit breaker in isHealthy(): after the cooldown the
185
+ // instance reports healthy again so the next call re-probes Vault.
186
+ private lastTransientFailureAt: number | null = null
170
187
  private readonly debugEnabled: boolean
171
188
  private static loggedInit = false
172
189
 
@@ -176,9 +193,11 @@ export class HashicorpVaultKmsService implements KmsService {
176
193
  this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\/+$/, '')
177
194
  this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000
178
195
  this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())
196
+ this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs())
179
197
  this.debugEnabled = isEncryptionDebugEnabled()
180
198
  if (!this.vaultAddr || !this.vaultToken) {
181
199
  this.healthy = false
200
+ this.misconfigured = true
182
201
  if (this.debugEnabled) {
183
202
  console.warn('⚠️ [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')
184
203
  }
@@ -192,13 +211,34 @@ export class HashicorpVaultKmsService implements KmsService {
192
211
  }
193
212
 
194
213
  isHealthy(): boolean {
195
- return this.healthy
214
+ // A missing-config failure is terminal — never report healthy again.
215
+ if (this.misconfigured) return false
216
+ if (this.healthy) return true
217
+ // Half-open circuit breaker: once the cooldown since the last transient
218
+ // failure has elapsed, report healthy so the next read/write re-probes
219
+ // Vault. A successful probe flips `healthy` back on; a failing one records a
220
+ // fresh failure timestamp and re-opens the breaker for another cooldown.
221
+ if (this.lastTransientFailureAt === null) return false
222
+ return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs
196
223
  }
197
224
 
198
225
  private now(): number {
199
226
  return Date.now()
200
227
  }
201
228
 
229
+ // Vault responded successfully (or is provably reachable): close the breaker.
230
+ private markHealthy(): void {
231
+ this.healthy = true
232
+ this.lastTransientFailureAt = null
233
+ }
234
+
235
+ // Transient infra failure (timeout / network blip / 5xx): open the breaker and
236
+ // start the recovery cooldown so a later call can re-probe and self-heal.
237
+ private markTransientFailure(): void {
238
+ this.healthy = false
239
+ this.lastTransientFailureAt = this.now()
240
+ }
241
+
202
242
  private cacheHit(tenantId: string): TenantDek | null {
203
243
  const entry = this.cache.get(tenantId)
204
244
  if (!entry) return null
@@ -212,6 +252,7 @@ export class HashicorpVaultKmsService implements KmsService {
212
252
  private async readVault(path: string): Promise<VaultReadResponse | null> {
213
253
  if (!this.vaultAddr || !this.vaultToken) {
214
254
  this.healthy = false
255
+ this.misconfigured = true
215
256
  return null
216
257
  }
217
258
  try {
@@ -221,16 +262,21 @@ export class HashicorpVaultKmsService implements KmsService {
221
262
  timeoutMs: this.requestTimeoutMs,
222
263
  })
223
264
  if (!res.ok) {
224
- this.healthy = res.status < 500
265
+ // 5xx = Vault down/erroring (transient). <500 (auth/not-found/etc.) means
266
+ // Vault is reachable and answered, so keep it healthy — a 404 for a
267
+ // not-yet-created tenant DEK is the normal read-before-write path.
268
+ if (res.status >= 500) this.markTransientFailure()
269
+ else this.markHealthy()
225
270
  console.warn('⚠️ [encryption][kms] Vault read failed', { path, status: res.status })
226
271
  return null
227
272
  }
273
+ this.markHealthy()
228
274
  if (this.debugEnabled) {
229
275
  console.info('🔍 [encryption][kms] Vault read ok', { path })
230
276
  }
231
277
  return (await res.json()) as VaultReadResponse
232
278
  } catch (err) {
233
- this.healthy = false
279
+ this.markTransientFailure()
234
280
  console.warn('⚠️ [encryption][kms] Vault read error', {
235
281
  path,
236
282
  error: (err as Error)?.message || String(err),
@@ -243,6 +289,7 @@ export class HashicorpVaultKmsService implements KmsService {
243
289
  private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {
244
290
  if (!this.vaultAddr || !this.vaultToken) {
245
291
  this.healthy = false
292
+ this.misconfigured = true
246
293
  return 'error'
247
294
  }
248
295
  const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }
@@ -258,21 +305,22 @@ export class HashicorpVaultKmsService implements KmsService {
258
305
  timeoutMs: this.requestTimeoutMs,
259
306
  })
260
307
  if (res.ok) {
261
- this.healthy = true
308
+ this.markHealthy()
262
309
  return 'ok'
263
310
  }
264
311
  // KV v2 returns 400 when a check-and-set write loses to a concurrent
265
312
  // writer (path already at a newer version). That is a normal race outcome,
266
- // not an unhealthy Vault — don't flip `healthy`.
313
+ // not an unhealthy Vault — Vault is reachable, so close the breaker.
267
314
  if (typeof opts?.cas === 'number' && res.status === 400) {
315
+ this.markHealthy()
268
316
  console.warn('⚠️ [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })
269
317
  return 'conflict'
270
318
  }
271
- this.healthy = false
319
+ this.markTransientFailure()
272
320
  console.warn('⚠️ [encryption][kms] Vault write failed', { path, status: res.status })
273
321
  return 'error'
274
322
  } catch (err) {
275
- this.healthy = false
323
+ this.markTransientFailure()
276
324
  console.warn('⚠️ [encryption][kms] Vault write error', {
277
325
  path,
278
326
  error: (err as Error)?.message || String(err),
@@ -0,0 +1,229 @@
1
+ import { BasicQueryEngine } from '../engine'
2
+ import { registerModules } from '../../i18n/server'
3
+
4
+ // One entity extension on auth:user so includeExtensions exercises the joined-aggregate path.
5
+ registerModules([
6
+ { id: 'auth', entityExtensions: [{ base: 'auth:user', extension: 'my_module:user_profile', join: { baseKey: 'id', extensionKey: 'user_id' } }] },
7
+ ] as any)
8
+
9
+ type FakeData = Record<string, any[]>
10
+
11
+ function cloneRows(rows: any[] | undefined): any[] {
12
+ if (!rows) return []
13
+ return rows.map((row) => ({ ...row }))
14
+ }
15
+
16
+ // Reads the SQL text of a Kysely raw/aliased expression by walking its operation node.
17
+ function rawSqlText(expr: any): string {
18
+ const node = typeof expr?.toOperationNode === 'function' ? expr.toOperationNode() : expr
19
+ const inner = node?.node ?? node
20
+ const fragments = inner?.sqlFragments
21
+ return Array.isArray(fragments) ? fragments.join(' ? ') : ''
22
+ }
23
+
24
+ function aliasName(expr: any): string | undefined {
25
+ const node = typeof expr?.toOperationNode === 'function' ? expr.toOperationNode() : expr
26
+ const alias = node?.alias
27
+ return alias?.name ?? alias?.column?.name
28
+ }
29
+
30
+ function createFakeKysely(selectsSink: any[], overrides?: FakeData) {
31
+ const calls: any[] = []
32
+ const defaultData: FakeData = { custom_field_defs: [], custom_field_values: [] }
33
+ const sourceData = { ...defaultData, ...(overrides || {}) }
34
+ const data: FakeData = Object.fromEntries(
35
+ Object.entries(sourceData).map(([table, rows]) => [table, cloneRows(rows)]),
36
+ )
37
+
38
+ function parseTableSpec(spec: unknown): { table: string; alias: string | null } {
39
+ if (typeof spec !== 'string') return { table: String(spec || ''), alias: null }
40
+ const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(spec)
41
+ if (asMatch) return { table: asMatch[1].trim(), alias: asMatch[2].trim() }
42
+ return { table: spec, alias: null }
43
+ }
44
+
45
+ function createExpressionBuilder() {
46
+ const eb: any = (column: any, op: any, value: any) => ({ kind: 'cmp', column, op, value })
47
+ eb.and = (parts: any[]) => ({ kind: 'and', parts })
48
+ eb.or = (parts: any[]) => ({ kind: 'or', parts })
49
+ eb.not = (part: any) => ({ kind: 'not', part })
50
+ eb.exists = (sub: any) => ({ kind: 'exists', sub })
51
+ eb.val = (value: any) => ({ kind: 'val', value })
52
+ eb.ref = (name: string) => ({ kind: 'ref', name })
53
+ eb.selectFrom = (spec: any) => builderFor(spec)
54
+ return eb
55
+ }
56
+
57
+ function normalizeWhereArgs(args: any[]): any[] {
58
+ if (args.length === 1 && typeof args[0] === 'function') {
59
+ const produced = args[0](createExpressionBuilder())
60
+ if (produced && produced.kind === 'or') return ['or', produced.parts]
61
+ if (produced && produced.kind === 'and') return ['and', produced.parts]
62
+ if (produced && produced.kind === 'exists') return ['exists', produced.sub]
63
+ if (produced && produced.kind === 'not' && produced.part?.kind === 'exists') return ['notExists', produced.part.sub]
64
+ return ['expr', produced]
65
+ }
66
+ return args
67
+ }
68
+
69
+ function recordJoin(ops: any, type: 'left' | 'inner', spec: any, fn: Function) {
70
+ const parsed = parseTableSpec(spec)
71
+ const aliasObj = parsed.alias ? { [parsed.alias]: parsed.table } : { [parsed.table]: parsed.table }
72
+ const entry: any = { type, aliasObj, conditions: [] as any[] }
73
+ const ctx: any = {}
74
+ ctx.on = (left: any, op?: any, right?: any) => {
75
+ if (typeof left === 'function') entry.conditions.push({ method: 'on', expr: left(createExpressionBuilder()) })
76
+ else entry.conditions.push({ method: 'on', args: [left, op, right] })
77
+ return ctx
78
+ }
79
+ ctx.onRef = (left: any, op: any, right: any) => {
80
+ entry.conditions.push({ method: 'on', args: [left, op, right] })
81
+ return ctx
82
+ }
83
+ fn(ctx)
84
+ ops.joins.push(entry)
85
+ }
86
+
87
+ function makeBuilder(ops: any, record: boolean): any {
88
+ const b: any = {
89
+ _ops: ops,
90
+ select(this: any, ...cols: any[]) {
91
+ const flat = cols.length === 1 && Array.isArray(cols[0]) ? cols[0] : cols
92
+ this._ops.selects.push(...flat)
93
+ selectsSink.push(...flat)
94
+ return this
95
+ },
96
+ distinct(this: any) { return this },
97
+ where(this: any, ...args: any[]) { this._ops.wheres.push(normalizeWhereArgs(args)); return this },
98
+ whereRef(this: any, left: any, op: any, right: any) { this._ops.wheres.push(['ref', left, op, right]); return this },
99
+ leftJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'left', spec, fn); return this },
100
+ innerJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'inner', spec, fn); return this },
101
+ groupBy(this: any, arg: any) {
102
+ if (Array.isArray(arg)) this._ops.groups.push(...arg)
103
+ else this._ops.groups.push(arg)
104
+ return this
105
+ },
106
+ having(this: any) { return this },
107
+ orderBy(this: any, col: any, dir?: any) { this._ops.orderBys.push([col, dir]); return this },
108
+ limit(this: any, n: number) { this._ops.limits = n; return this },
109
+ offset(this: any, n: number) { this._ops.offsets = n; return this },
110
+ clearSelect(this: any) { return makeBuilder({ ...this._ops, selects: [] }, false) },
111
+ clearOrderBy(this: any) { return makeBuilder({ ...this._ops, orderBys: [] }, false) },
112
+ clearGroupBy(this: any) { return makeBuilder({ ...this._ops, groups: [] }, false) },
113
+ as(this: any, alias: string) { this._ops.alias = alias; return this },
114
+ async execute(this: any) { return cloneRows(data[this._ops.table]) },
115
+ async executeTakeFirst(this: any) {
116
+ const localOps = this._ops
117
+ if (localOps.table === 'information_schema.columns') {
118
+ const infoRows = data['information_schema.columns']
119
+ if (!Array.isArray(infoRows)) return undefined
120
+ const targetTable = extractEqValue(localOps.wheres, 'table_name')
121
+ const targetColumn = extractEqValue(localOps.wheres, 'column_name')
122
+ return infoRows.find((row: any) =>
123
+ (!targetTable || row.table_name === targetTable) && (!targetColumn || row.column_name === targetColumn))
124
+ }
125
+ if (localOps.table === 'information_schema.tables') {
126
+ const infoRows = data['information_schema.tables']
127
+ if (!Array.isArray(infoRows)) return undefined
128
+ const targetTable = extractEqValue(localOps.wheres, 'table_name')
129
+ return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
130
+ }
131
+ if (localOps.selects.some((s: any) => aliasName(s) === 'count')) return { count: '0' }
132
+ const rows = data[localOps.table] || []
133
+ if (rows.length === 0) return { count: '0' }
134
+ return rows[0]
135
+ },
136
+ }
137
+ if (record) calls.push(b)
138
+ return b
139
+ }
140
+
141
+ function builderFor(tableArg: any): any {
142
+ const parsed = parseTableSpec(tableArg)
143
+ const ops = {
144
+ table: parsed.table,
145
+ alias: parsed.alias,
146
+ wheres: [] as any[],
147
+ joins: [] as any[],
148
+ selects: [] as any[],
149
+ orderBys: [] as any[],
150
+ groups: [] as any[],
151
+ limits: 0,
152
+ offsets: 0,
153
+ }
154
+ return makeBuilder(ops, true)
155
+ }
156
+
157
+ function extractEqValue(wheres: any[], column: string): any {
158
+ for (const entry of wheres) {
159
+ if (!Array.isArray(entry)) continue
160
+ if (entry[0] === column && entry[1] === '=') return entry[2]
161
+ }
162
+ return undefined
163
+ }
164
+
165
+ const db: any = { selectFrom(spec: any) { return builderFor(spec) } }
166
+ db._calls = calls
167
+ return db
168
+ }
169
+
170
+ function findCountSql(selectsSink: any[]): string {
171
+ const countExprs = selectsSink.filter((s) => aliasName(s) === 'count')
172
+ expect(countExprs.length).toBeGreaterThan(0)
173
+ return rawSqlText(countExprs[countExprs.length - 1]).toLowerCase()
174
+ }
175
+
176
+ describe('BasicQueryEngine — list COUNT query (issue #2227)', () => {
177
+ test('uses count(*) and no group-by when no joins can multiply base rows', async () => {
178
+ const selects: any[] = []
179
+ const fakeDb = createFakeKysely(selects)
180
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
181
+ await engine.query('scheduler:scheduled_job', { tenantId: 't1', fields: ['id'], page: { page: 1, pageSize: 20 } })
182
+
183
+ const countSql = findCountSql(selects)
184
+ expect(countSql).toContain('count(*)')
185
+ expect(countSql).not.toContain('distinct')
186
+
187
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
188
+ expect(baseCall._ops.groups.length).toBe(0)
189
+ })
190
+
191
+ test('keeps count(distinct base.id) with group-by when extensions are joined', async () => {
192
+ const selects: any[] = []
193
+ const fakeDb = createFakeKysely(selects)
194
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
195
+ await engine.query('auth:user', {
196
+ tenantId: 't1',
197
+ organizationId: '1',
198
+ fields: ['id'],
199
+ includeExtensions: true,
200
+ page: { page: 1, pageSize: 20 },
201
+ })
202
+
203
+ const countSql = findCountSql(selects)
204
+ expect(countSql).toContain('count(distinct')
205
+ expect(countSql).not.toContain('count(*)')
206
+
207
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'users')
208
+ expect(baseCall._ops.groups.length).toBeGreaterThan(0)
209
+ })
210
+
211
+ test('keeps count(distinct base.id) without group-by when an explicit relation join is configured', async () => {
212
+ const selects: any[] = []
213
+ const fakeDb = createFakeKysely(selects)
214
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
215
+ await engine.query('scheduler:scheduled_job', {
216
+ tenantId: 't1',
217
+ fields: ['id'],
218
+ joins: [{ alias: 'owner', table: 'users', from: { field: 'owner_id' }, to: { field: 'id' } }],
219
+ page: { page: 1, pageSize: 20 },
220
+ })
221
+
222
+ const countSql = findCountSql(selects)
223
+ expect(countSql).toContain('count(distinct')
224
+ expect(countSql).not.toContain('count(*)')
225
+
226
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
227
+ expect(baseCall._ops.groups.length).toBe(0)
228
+ })
229
+ })
@@ -1,7 +1,7 @@
1
1
  // packages/shared/src/lib/query/advanced-filter-tree.ts
2
2
  import type { FilterOperator } from './advanced-filter'
3
3
  import { isValuelessOperator } from './advanced-filter'
4
- import { escapeLikePattern } from '../db/escapeLikePattern'
4
+ import { buildIlikeTerm } from '../db/buildIlikeTerm'
5
5
 
6
6
  export type FilterCombinator = 'and' | 'or'
7
7
 
@@ -102,22 +102,22 @@ function compileRule(rule: FilterRule): Record<string, unknown> | null {
102
102
  case 'contains': {
103
103
  const v = normalizeSingleValue(rule.value)
104
104
  if (v === null) return null
105
- filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}%` }; break
105
+ filter[rule.field] = { $ilike: buildIlikeTerm(String(v)) }; break
106
106
  }
107
107
  case 'does_not_contain': {
108
108
  const v = normalizeSingleValue(rule.value)
109
109
  if (v === null) return null
110
- filter[rule.field] = { $not: { $ilike: `%${escapeLikePattern(String(v))}%` } }; break
110
+ filter[rule.field] = { $not: { $ilike: buildIlikeTerm(String(v)) } }; break
111
111
  }
112
112
  case 'starts_with': {
113
113
  const v = normalizeSingleValue(rule.value)
114
114
  if (v === null) return null
115
- filter[rule.field] = { $ilike: `${escapeLikePattern(String(v))}%` }; break
115
+ filter[rule.field] = { $ilike: buildIlikeTerm(String(v), 'startsWith') }; break
116
116
  }
117
117
  case 'ends_with': {
118
118
  const v = normalizeSingleValue(rule.value)
119
119
  if (v === null) return null
120
- filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}` }; break
120
+ filter[rule.field] = { $ilike: buildIlikeTerm(String(v), 'endsWith') }; break
121
121
  }
122
122
  case 'is_empty': filter[rule.field] = { $exists: false }; break
123
123
  case 'is_not_empty': filter[rule.field] = { $exists: true }; break
@@ -1,4 +1,4 @@
1
- import { escapeLikePattern } from '../db/escapeLikePattern'
1
+ import { buildIlikeTerm } from '../db/buildIlikeTerm'
2
2
 
3
3
  export type FilterOperator =
4
4
  | 'is' | 'is_not' | 'contains' | 'does_not_contain' | 'starts_with' | 'ends_with' | 'is_empty' | 'is_not_empty'
@@ -190,19 +190,19 @@ function buildConditionFilter(condition: FilterCondition): Record<string, unknow
190
190
  break
191
191
  case 'contains':
192
192
  if (normalizeSingleValue(condition.value) === null) return null
193
- filter[condition.field] = { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` }
193
+ filter[condition.field] = { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value))) }
194
194
  break
195
195
  case 'does_not_contain':
196
196
  if (normalizeSingleValue(condition.value) === null) return null
197
- filter[condition.field] = { $not: { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` } }
197
+ filter[condition.field] = { $not: { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value))) } }
198
198
  break
199
199
  case 'starts_with':
200
200
  if (normalizeSingleValue(condition.value) === null) return null
201
- filter[condition.field] = { $ilike: `${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` }
201
+ filter[condition.field] = { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value)), 'startsWith') }
202
202
  break
203
203
  case 'ends_with':
204
204
  if (normalizeSingleValue(condition.value) === null) return null
205
- filter[condition.field] = { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}` }
205
+ filter[condition.field] = { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value)), 'endsWith') }
206
206
  break
207
207
  case 'is_empty':
208
208
  filter[condition.field] = { $exists: false }
@@ -844,9 +844,20 @@ export class BasicQueryEngine implements QueryEngine {
844
844
  if (hasJoinedAggregates) {
845
845
  q = q.groupBy(`${table}.id`)
846
846
  }
847
+ // `count(distinct base.id)` is only required when a join can multiply base rows
848
+ // (CF/extension aggregates, explicit relation joins, or custom-field sources).
849
+ // Without such joins base.id is the unique PK, so `count(*)` is equivalent and
850
+ // lets Postgres skip the redundant DISTINCT sort/hash for an index-only count (#2227).
851
+ const mayMultiplyBaseRows =
852
+ hasJoinedAggregates ||
853
+ (Array.isArray(opts.joins) && opts.joins.length > 0) ||
854
+ (Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0)
855
+ const countExpr = mayMultiplyBaseRows
856
+ ? sql<string>`count(distinct ${sql.ref(`${table}.id`)})`
857
+ : sql<string>`count(*)`
847
858
  const countBuilder = hasJoinedAggregates
848
- ? q.clearSelect().clearOrderBy().clearGroupBy().select(sql<string>`count(distinct ${sql.ref(`${table}.id`)})`.as('count'))
849
- : q.clearSelect().clearOrderBy().select(sql<string>`count(distinct ${sql.ref(`${table}.id`)})`.as('count'))
859
+ ? q.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as('count'))
860
+ : q.clearSelect().clearOrderBy().select(countExpr.as('count'))
850
861
  const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
851
862
  const total = Number((countRow as any)?.count ?? 0)
852
863
  const dataQuery = requiresPlaintextSort