@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -1
- package/dist/lib/ai/llm-provider-registry.js.map +1 -1
- package/dist/lib/crud/custom-fields.js +23 -15
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/factory.js.map +1 -1
- package/dist/lib/crud/optimistic-lock-command.js.map +1 -1
- package/dist/lib/crud/optimistic-lock-headers.js.map +1 -1
- package/dist/lib/crud/optimistic-lock-store.js.map +1 -1
- package/dist/lib/crud/optimistic-lock.js.map +1 -1
- package/dist/lib/data/engine.js +25 -1
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/buildIlikeTerm.js +17 -0
- package/dist/lib/db/buildIlikeTerm.js.map +7 -0
- package/dist/lib/db/mikro.js +38 -9
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/di/container.js +1 -1
- package/dist/lib/di/container.js.map +1 -1
- package/dist/lib/encryption/kms.js +41 -6
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/query/advanced-filter-tree.js +5 -5
- package/dist/lib/query/advanced-filter-tree.js.map +2 -2
- package/dist/lib/query/advanced-filter.js +5 -5
- package/dist/lib/query/advanced-filter.js.map +2 -2
- package/dist/lib/query/engine.js +3 -1
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/overrides.js +1 -1
- package/dist/modules/overrides.js.map +1 -1
- package/dist/modules/search.js.map +1 -1
- package/package.json +4 -5
- package/src/lib/ai/llm-provider-registry.ts +1 -1
- package/src/lib/ai/llm-provider.ts +1 -1
- package/src/lib/crud/__tests__/custom-fields.test.ts +91 -0
- package/src/lib/crud/custom-fields.ts +30 -17
- package/src/lib/crud/factory.ts +1 -1
- package/src/lib/crud/optimistic-lock-command.ts +1 -1
- package/src/lib/crud/optimistic-lock-headers.ts +1 -1
- package/src/lib/crud/optimistic-lock-store.ts +1 -1
- package/src/lib/crud/optimistic-lock.ts +1 -1
- package/src/lib/data/__tests__/engine.custom-entity-storage-guard.test.ts +78 -0
- package/src/lib/data/engine.ts +40 -0
- package/src/lib/db/__tests__/buildIlikeTerm.test.ts +40 -0
- package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
- package/src/lib/db/__tests__/mikro.test.ts +82 -0
- package/src/lib/db/buildIlikeTerm.ts +16 -0
- package/src/lib/db/mikro.ts +55 -16
- package/src/lib/di/container.ts +1 -1
- package/src/lib/encryption/__tests__/kms.test.ts +80 -0
- package/src/lib/encryption/kms.ts +55 -7
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +229 -0
- package/src/lib/query/advanced-filter-tree.ts +5 -5
- package/src/lib/query/advanced-filter.ts +5 -5
- package/src/lib/query/engine.ts +13 -2
- package/src/lib/query/types.ts +10 -0
- package/src/modules/__tests__/overrides.test.ts +1 -1
- package/src/modules/__tests__/route-overrides.test.ts +1 -1
- package/src/modules/navigation/backendChrome.ts +9 -0
- package/src/modules/overrides.ts +3 -3
- package/src/modules/search.ts +1 -1
package/src/lib/db/mikro.ts
CHANGED
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) => {
|
package/src/lib/di/container.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 —
|
|
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.
|
|
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.
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 }
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -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(
|
|
849
|
-
: q.clearSelect().clearOrderBy().select(
|
|
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
|