@open-mercato/shared 0.6.5-develop.5309.1.be1df535b3 → 0.6.5-develop.5382.1.f542de69af
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/db/buildIlikeTerm.js +17 -0
- package/dist/lib/db/buildIlikeTerm.js.map +7 -0
- package/dist/lib/di/container.js +1 -1
- package/dist/lib/di/container.js.map +1 -1
- 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/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 +2 -2
- 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/db/__tests__/buildIlikeTerm.test.ts +40 -0
- package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
- package/src/lib/db/buildIlikeTerm.ts +16 -0
- package/src/lib/di/container.ts +1 -1
- 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/modules/__tests__/overrides.test.ts +1 -1
- package/src/modules/__tests__/route-overrides.test.ts +1 -1
- package/src/modules/overrides.ts +3 -3
- package/src/modules/search.ts +1 -1
|
@@ -243,6 +243,97 @@ describe('loadCustomFieldValues (encryption)', () => {
|
|
|
243
243
|
})
|
|
244
244
|
expect(values['rec-1'].cf_priority).toBe(42)
|
|
245
245
|
})
|
|
246
|
+
|
|
247
|
+
it('decrypts rows concurrently rather than sequentially (regression: issue #2229)', async () => {
|
|
248
|
+
const deks: Record<string, string> = {
|
|
249
|
+
'tenant-1': Buffer.alloc(32, 1).toString('base64'),
|
|
250
|
+
'tenant-2': Buffer.alloc(32, 2).toString('base64'),
|
|
251
|
+
'tenant-3': Buffer.alloc(32, 3).toString('base64'),
|
|
252
|
+
}
|
|
253
|
+
const rows = [
|
|
254
|
+
{ recordId: 'rec-1', tenantId: 'tenant-1', plaintext: 'note-1' },
|
|
255
|
+
{ recordId: 'rec-2', tenantId: 'tenant-2', plaintext: 'note-2' },
|
|
256
|
+
{ recordId: 'rec-3', tenantId: 'tenant-3', plaintext: 'note-3' },
|
|
257
|
+
].map((row) => ({
|
|
258
|
+
...row,
|
|
259
|
+
valueText: encryptWithAesGcm(row.plaintext, deks[row.tenantId]).value,
|
|
260
|
+
}))
|
|
261
|
+
const em = {
|
|
262
|
+
find: jest.fn().mockImplementation((_, where) => {
|
|
263
|
+
if ((where as any).recordId) {
|
|
264
|
+
return Promise.resolve(
|
|
265
|
+
rows.map((row) => ({
|
|
266
|
+
recordId: row.recordId,
|
|
267
|
+
fieldKey: 'note',
|
|
268
|
+
organizationId: null,
|
|
269
|
+
tenantId: row.tenantId,
|
|
270
|
+
valueText: row.valueText,
|
|
271
|
+
valueMultiline: null,
|
|
272
|
+
valueInt: null,
|
|
273
|
+
valueFloat: null,
|
|
274
|
+
valueBool: null,
|
|
275
|
+
deletedAt: null,
|
|
276
|
+
})),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
return Promise.resolve([
|
|
280
|
+
{ key: 'note', entityId: 'demo:entity', organizationId: null, tenantId: null, kind: 'text', configJson: { encrypted: true }, isActive: true },
|
|
281
|
+
])
|
|
282
|
+
}),
|
|
283
|
+
}
|
|
284
|
+
let inFlight = 0
|
|
285
|
+
let maxInFlight = 0
|
|
286
|
+
const mockService = {
|
|
287
|
+
isEnabled: () => true,
|
|
288
|
+
getDek: jest.fn(async (tenantId: string) => {
|
|
289
|
+
inFlight += 1
|
|
290
|
+
maxInFlight = Math.max(maxInFlight, inFlight)
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
292
|
+
inFlight -= 1
|
|
293
|
+
return { key: deks[tenantId] }
|
|
294
|
+
}),
|
|
295
|
+
}
|
|
296
|
+
const values = await loadCustomFieldValues({
|
|
297
|
+
em: em as any,
|
|
298
|
+
entityId: 'demo:entity',
|
|
299
|
+
recordIds: ['rec-1', 'rec-2', 'rec-3'],
|
|
300
|
+
tenantIdByRecord: { 'rec-1': 'tenant-1', 'rec-2': 'tenant-2', 'rec-3': 'tenant-3' },
|
|
301
|
+
encryptionService: mockService as any,
|
|
302
|
+
})
|
|
303
|
+
expect(values['rec-1'].cf_note).toBe('note-1')
|
|
304
|
+
expect(values['rec-2'].cf_note).toBe('note-2')
|
|
305
|
+
expect(values['rec-3'].cf_note).toBe('note-3')
|
|
306
|
+
// Sequential await-in-loop would cap concurrency at 1; batching lifts it.
|
|
307
|
+
expect(maxInFlight).toBeGreaterThan(1)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('preserves multi-value grouping order for encrypted fields (regression: issue #2229)', async () => {
|
|
311
|
+
const dek = Buffer.alloc(32, 2).toString('base64')
|
|
312
|
+
const first = encryptWithAesGcm('alpha', dek).value
|
|
313
|
+
const second = encryptWithAesGcm('beta', dek).value
|
|
314
|
+
const em = {
|
|
315
|
+
find: jest.fn().mockImplementation((_, where) => {
|
|
316
|
+
if ((where as any).recordId) {
|
|
317
|
+
return Promise.resolve([
|
|
318
|
+
{ recordId: 'rec-1', fieldKey: 'tags', organizationId: null, tenantId: 'tenant-1', valueText: first, valueMultiline: null, valueInt: null, valueFloat: null, valueBool: null, deletedAt: null },
|
|
319
|
+
{ recordId: 'rec-1', fieldKey: 'tags', organizationId: null, tenantId: 'tenant-1', valueText: second, valueMultiline: null, valueInt: null, valueFloat: null, valueBool: null, deletedAt: null },
|
|
320
|
+
])
|
|
321
|
+
}
|
|
322
|
+
return Promise.resolve([
|
|
323
|
+
{ key: 'tags', entityId: 'demo:entity', organizationId: null, tenantId: 'tenant-1', kind: 'text', configJson: { encrypted: true, multi: true }, isActive: true },
|
|
324
|
+
])
|
|
325
|
+
}),
|
|
326
|
+
}
|
|
327
|
+
const mockService = { isEnabled: () => true, getDek: async () => ({ key: dek }) }
|
|
328
|
+
const values = await loadCustomFieldValues({
|
|
329
|
+
em: em as any,
|
|
330
|
+
entityId: 'demo:entity',
|
|
331
|
+
recordIds: ['rec-1'],
|
|
332
|
+
tenantIdByRecord: { 'rec-1': 'tenant-1' },
|
|
333
|
+
encryptionService: mockService as any,
|
|
334
|
+
})
|
|
335
|
+
expect(values['rec-1'].cf_tags).toEqual(['alpha', 'beta'])
|
|
336
|
+
})
|
|
246
337
|
})
|
|
247
338
|
|
|
248
339
|
describe('decorateRecordWithCustomFields', () => {
|
|
@@ -632,7 +632,7 @@ export async function loadCustomFieldValues(opts: {
|
|
|
632
632
|
type Bucket = { orgId: string | null; tenantId: string | null; values: unknown[]; def?: CustomFieldDef | null; encrypted?: boolean }
|
|
633
633
|
const buckets = new Map<string, Bucket>()
|
|
634
634
|
|
|
635
|
-
|
|
635
|
+
const rowInfos = cfRows.map((row) => {
|
|
636
636
|
const recordId = String(row.recordId)
|
|
637
637
|
const key = String(row.fieldKey)
|
|
638
638
|
const bucketKey = `${recordId}::${key}`
|
|
@@ -643,26 +643,39 @@ export async function loadCustomFieldValues(opts: {
|
|
|
643
643
|
const def = pickDefinition(key, resolvedOrgId, resolvedTenantId)
|
|
644
644
|
const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)
|
|
645
645
|
const value = valueFromRow(row)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
646
|
+
return { bucketKey, resolvedOrgId, resolvedTenantId, tenantId, def, encrypted, value }
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
// Decrypt every encrypted value concurrently so a list of N rows × M encrypted
|
|
650
|
+
// fields costs the slowest single decryption rather than the sum (issue #2229).
|
|
651
|
+
// The shared encryptionCache keeps DEK lookups deduped per tenant.
|
|
652
|
+
const decryptedValues = await Promise.all(
|
|
653
|
+
rowInfos.map((info) =>
|
|
654
|
+
info.encrypted
|
|
655
|
+
? decryptCustomFieldValue(
|
|
656
|
+
info.value,
|
|
657
|
+
info.resolvedTenantId ?? info.tenantId ?? null,
|
|
658
|
+
getEncryptionService(),
|
|
659
|
+
encryptionCache,
|
|
660
|
+
{ kind: info.def?.kind ?? null },
|
|
661
|
+
)
|
|
662
|
+
: info.value,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
rowInfos.forEach((info, index) => {
|
|
667
|
+
const decrypted = decryptedValues[index]
|
|
668
|
+
const existing = buckets.get(info.bucketKey)
|
|
656
669
|
if (existing) {
|
|
657
|
-
if (existing.orgId == null && resolvedOrgId) existing.orgId = resolvedOrgId
|
|
658
|
-
if (existing.tenantId == null && resolvedTenantId) existing.tenantId = resolvedTenantId
|
|
659
|
-
if (existing.def == null && def) existing.def = def
|
|
660
|
-
existing.encrypted = existing.encrypted || encrypted
|
|
670
|
+
if (existing.orgId == null && info.resolvedOrgId) existing.orgId = info.resolvedOrgId
|
|
671
|
+
if (existing.tenantId == null && info.resolvedTenantId) existing.tenantId = info.resolvedTenantId
|
|
672
|
+
if (existing.def == null && info.def) existing.def = info.def
|
|
673
|
+
existing.encrypted = existing.encrypted || info.encrypted
|
|
661
674
|
existing.values.push(decrypted)
|
|
662
675
|
} else {
|
|
663
|
-
buckets.set(bucketKey, { orgId: resolvedOrgId, tenantId: resolvedTenantId, values: [decrypted], def: def ?? null, encrypted })
|
|
676
|
+
buckets.set(info.bucketKey, { orgId: info.resolvedOrgId, tenantId: info.resolvedTenantId, values: [decrypted], def: info.def ?? null, encrypted: info.encrypted })
|
|
664
677
|
}
|
|
665
|
-
}
|
|
678
|
+
})
|
|
666
679
|
|
|
667
680
|
const result: Record<string, Record<string, unknown>> = {}
|
|
668
681
|
for (const [compoundKey, bucket] of buckets.entries()) {
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -944,7 +944,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
944
944
|
|
|
945
945
|
// OSS opt-in optimistic locking — auto-register a generic reader for every
|
|
946
946
|
// CRUD entity using the factory's own ORM config (Step 13.3 of the spec at
|
|
947
|
-
// .ai/specs/2026-05-25-oss-optimistic-locking.md). Hand-wired readers
|
|
947
|
+
// .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md). Hand-wired readers
|
|
948
948
|
// registered earlier via module DI (customers/sales) always win because we
|
|
949
949
|
// use the `IfAbsent` variant. Skipped silently when the route has no
|
|
950
950
|
// resolvable resourceKind or no ORM entity class (e.g. virtual routes).
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* header keep working. Respects the same `OM_OPTIMISTIC_LOCK` env contract
|
|
25
25
|
* (default ON; `off` disables; allow-list scopes by `resourceKind`).
|
|
26
26
|
*
|
|
27
|
-
* Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
|
|
27
|
+
* Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md (§ command-level checks)
|
|
28
28
|
* .ai/specs/2026-05-28-optimistic-locking-coverage-completion.md (Phase 4)
|
|
29
29
|
*/
|
|
30
30
|
import { CrudHttpError } from './errors'
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* many HTTP intermediaries (nginx, some fetch implementations) strip
|
|
9
9
|
* underscored header names — see RFC 7230 §3.2.6.
|
|
10
10
|
*
|
|
11
|
-
* Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md §3.2
|
|
11
|
+
* Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md §3.2
|
|
12
12
|
*/
|
|
13
13
|
export const OPTIMISTIC_LOCK_MODULE_ID = 'optimistic_lock'
|
|
14
14
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Mirrors the `mutation-guard-store.ts` HMR-safe globalThis pattern.
|
|
14
14
|
*
|
|
15
|
-
* Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
|
|
15
|
+
* Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md
|
|
16
16
|
*/
|
|
17
17
|
import type { OptimisticLockCurrentReader } from './optimistic-lock'
|
|
18
18
|
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* container / em access. Stateful checks that need to read current DB state
|
|
29
29
|
* MUST go through the DI service path (this file).
|
|
30
30
|
*
|
|
31
|
-
* Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
|
|
31
|
+
* Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md
|
|
32
32
|
*/
|
|
33
33
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
34
34
|
import type {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { buildIlikeTerm } from '../buildIlikeTerm'
|
|
2
|
+
import { escapeLikePattern } from '../escapeLikePattern'
|
|
3
|
+
|
|
4
|
+
describe('buildIlikeTerm', () => {
|
|
5
|
+
it('wraps the escaped term in leading and trailing wildcards by default', () => {
|
|
6
|
+
expect(buildIlikeTerm('acme')).toBe('%acme%')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('matches the legacy inline contains pattern exactly', () => {
|
|
10
|
+
const term = 'Acme Corp'
|
|
11
|
+
expect(buildIlikeTerm(term)).toBe(`%${escapeLikePattern(term)}%`)
|
|
12
|
+
expect(buildIlikeTerm(term, 'contains')).toBe(`%${escapeLikePattern(term)}%`)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('builds a startsWith pattern with only a trailing wildcard', () => {
|
|
16
|
+
const term = 'user@example.com'
|
|
17
|
+
expect(buildIlikeTerm(term, 'startsWith')).toBe(`${escapeLikePattern(term)}%`)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('builds an endsWith pattern with only a leading wildcard', () => {
|
|
21
|
+
const term = 'example.com'
|
|
22
|
+
expect(buildIlikeTerm(term, 'endsWith')).toBe(`%${escapeLikePattern(term)}`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('escapes LIKE metacharacters so they match literally', () => {
|
|
26
|
+
expect(buildIlikeTerm('50%_off')).toBe('%50\\%\\_off%')
|
|
27
|
+
expect(buildIlikeTerm('back\\slash')).toBe('%back\\\\slash%')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps user wildcards literal across every mode', () => {
|
|
31
|
+
expect(buildIlikeTerm('a%b', 'startsWith')).toBe('a\\%b%')
|
|
32
|
+
expect(buildIlikeTerm('a_b', 'endsWith')).toBe('%a\\_b')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('handles an empty term as a wildcard-only pattern', () => {
|
|
36
|
+
expect(buildIlikeTerm('')).toBe('%%')
|
|
37
|
+
expect(buildIlikeTerm('', 'startsWith')).toBe('%')
|
|
38
|
+
expect(buildIlikeTerm('', 'endsWith')).toBe('%')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
3
|
+
import { escapeLikePattern } from '../escapeLikePattern'
|
|
4
|
+
|
|
5
|
+
describe('escapeLikePattern', () => {
|
|
6
|
+
it('escapes LIKE wildcards and the escape character', () => {
|
|
7
|
+
expect(escapeLikePattern('%')).toBe('\\%')
|
|
8
|
+
expect(escapeLikePattern('_')).toBe('\\_')
|
|
9
|
+
expect(escapeLikePattern('\\')).toBe('\\\\')
|
|
10
|
+
expect(escapeLikePattern('a%b_c')).toBe('a\\%b\\_c')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('escapes the backslash before the wildcards so the escape itself is literal', () => {
|
|
14
|
+
expect(escapeLikePattern('100%\\_')).toBe('100\\%\\\\\\_')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('leaves ordinary input untouched', () => {
|
|
18
|
+
expect(escapeLikePattern('hello world')).toBe('hello world')
|
|
19
|
+
expect(escapeLikePattern('')).toBe('')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Regression guard for #2932 (and the earlier #2734 fix).
|
|
25
|
+
*
|
|
26
|
+
* Every user-supplied value interpolated into a MikroORM `$ilike` pattern under
|
|
27
|
+
* any module's `api/**` tree MUST flow through `escapeLikePattern`, otherwise a
|
|
28
|
+
* caller can inject LIKE metacharacters (`%`, `_`, `\`) to broaden predicates or
|
|
29
|
+
* force pathological full scans. This scans the whole monorepo so a NEW unescaped
|
|
30
|
+
* `$ilike` interpolation in any package or app fails the build.
|
|
31
|
+
*/
|
|
32
|
+
function findRepoRoot(start: string): string {
|
|
33
|
+
let current = start
|
|
34
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
35
|
+
if (existsSync(join(current, 'packages')) && existsSync(join(current, 'apps'))) {
|
|
36
|
+
return current
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(current)
|
|
39
|
+
if (parent === current) break
|
|
40
|
+
current = parent
|
|
41
|
+
}
|
|
42
|
+
throw new Error('[internal] could not locate monorepo root for $ilike guard')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SKIP_DIRS = new Set(['node_modules', '__tests__', 'generated', 'dist', '.next', '.mercato'])
|
|
46
|
+
|
|
47
|
+
function collectApiSourceFiles(dir: string, acc: string[]): void {
|
|
48
|
+
let entries: string[]
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir)
|
|
51
|
+
} catch {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
for (const name of entries) {
|
|
55
|
+
const full = join(dir, name)
|
|
56
|
+
let stat
|
|
57
|
+
try {
|
|
58
|
+
stat = statSync(full)
|
|
59
|
+
} catch {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (stat.isDirectory()) {
|
|
63
|
+
if (SKIP_DIRS.has(name)) continue
|
|
64
|
+
collectApiSourceFiles(full, acc)
|
|
65
|
+
} else if (
|
|
66
|
+
(name.endsWith('.ts') || name.endsWith('.tsx')) &&
|
|
67
|
+
!name.endsWith('.test.ts') &&
|
|
68
|
+
!name.endsWith('.test.tsx') &&
|
|
69
|
+
full.split(sep).includes('api')
|
|
70
|
+
) {
|
|
71
|
+
acc.push(full)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const BARE_IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
77
|
+
|
|
78
|
+
function isPreEscapedVariable(expr: string, source: string): boolean {
|
|
79
|
+
if (!BARE_IDENTIFIER.test(expr)) return false
|
|
80
|
+
const assignedFromEscape = new RegExp(`\\b${expr}\\s*=\\s*escapeLikePattern\\(`)
|
|
81
|
+
return assignedFromEscape.test(source)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findUnescapedIlikeInterpolations(source: string): string[] {
|
|
85
|
+
const ilikeTemplate = /\$ilike:\s*`([^`]*)`/g
|
|
86
|
+
const interpolation = /\$\{([^}]*)\}/g
|
|
87
|
+
const offenders: string[] = []
|
|
88
|
+
let templateMatch: RegExpExecArray | null
|
|
89
|
+
while ((templateMatch = ilikeTemplate.exec(source)) !== null) {
|
|
90
|
+
const literal = templateMatch[1]
|
|
91
|
+
let exprMatch: RegExpExecArray | null
|
|
92
|
+
while ((exprMatch = interpolation.exec(literal)) !== null) {
|
|
93
|
+
const expr = exprMatch[1].trim()
|
|
94
|
+
if (expr.startsWith('escapeLikePattern(')) continue
|
|
95
|
+
if (isPreEscapedVariable(expr, source)) continue
|
|
96
|
+
offenders.push(`\`${templateMatch[1]}\``)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return offenders
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('$ilike user input must be escaped under api/** (#2932)', () => {
|
|
103
|
+
const repoRoot = findRepoRoot(__dirname)
|
|
104
|
+
const roots = [join(repoRoot, 'packages'), join(repoRoot, 'apps')]
|
|
105
|
+
const files: string[] = []
|
|
106
|
+
for (const root of roots) collectApiSourceFiles(root, files)
|
|
107
|
+
|
|
108
|
+
it('discovered api source files to scan', () => {
|
|
109
|
+
expect(files.length).toBeGreaterThan(20)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('every $ilike pattern interpolating a variable uses escapeLikePattern', () => {
|
|
113
|
+
const violations: string[] = []
|
|
114
|
+
for (const full of files) {
|
|
115
|
+
const source = readFileSync(full, 'utf8')
|
|
116
|
+
const offenders = findUnescapedIlikeInterpolations(source)
|
|
117
|
+
if (offenders.length === 0) continue
|
|
118
|
+
const rel = relative(repoRoot, full).split(sep).join('/')
|
|
119
|
+
for (const offender of offenders) violations.push(`${rel}: ${offender}`)
|
|
120
|
+
}
|
|
121
|
+
expect(violations).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { escapeLikePattern } from './escapeLikePattern'
|
|
2
|
+
|
|
3
|
+
export type IlikeMatchMode = 'contains' | 'startsWith' | 'endsWith'
|
|
4
|
+
|
|
5
|
+
export const buildIlikeTerm = (value: string, mode: IlikeMatchMode = 'contains'): string => {
|
|
6
|
+
const escaped = escapeLikePattern(value)
|
|
7
|
+
switch (mode) {
|
|
8
|
+
case 'startsWith':
|
|
9
|
+
return `${escaped}%`
|
|
10
|
+
case 'endsWith':
|
|
11
|
+
return `%${escaped}`
|
|
12
|
+
case 'contains':
|
|
13
|
+
default:
|
|
14
|
+
return `%${escaped}%`
|
|
15
|
+
}
|
|
16
|
+
}
|
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,
|
|
@@ -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
|