@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.
Files changed (47) 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/db/buildIlikeTerm.js +17 -0
  12. package/dist/lib/db/buildIlikeTerm.js.map +7 -0
  13. package/dist/lib/di/container.js +1 -1
  14. package/dist/lib/di/container.js.map +1 -1
  15. package/dist/lib/query/advanced-filter-tree.js +5 -5
  16. package/dist/lib/query/advanced-filter-tree.js.map +2 -2
  17. package/dist/lib/query/advanced-filter.js +5 -5
  18. package/dist/lib/query/advanced-filter.js.map +2 -2
  19. package/dist/lib/query/engine.js +3 -1
  20. package/dist/lib/query/engine.js.map +2 -2
  21. package/dist/lib/version.js +1 -1
  22. package/dist/lib/version.js.map +1 -1
  23. package/dist/modules/overrides.js +1 -1
  24. package/dist/modules/overrides.js.map +1 -1
  25. package/dist/modules/search.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/lib/ai/llm-provider-registry.ts +1 -1
  28. package/src/lib/ai/llm-provider.ts +1 -1
  29. package/src/lib/crud/__tests__/custom-fields.test.ts +91 -0
  30. package/src/lib/crud/custom-fields.ts +30 -17
  31. package/src/lib/crud/factory.ts +1 -1
  32. package/src/lib/crud/optimistic-lock-command.ts +1 -1
  33. package/src/lib/crud/optimistic-lock-headers.ts +1 -1
  34. package/src/lib/crud/optimistic-lock-store.ts +1 -1
  35. package/src/lib/crud/optimistic-lock.ts +1 -1
  36. package/src/lib/db/__tests__/buildIlikeTerm.test.ts +40 -0
  37. package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
  38. package/src/lib/db/buildIlikeTerm.ts +16 -0
  39. package/src/lib/di/container.ts +1 -1
  40. package/src/lib/query/__tests__/engine.count-distinct.test.ts +229 -0
  41. package/src/lib/query/advanced-filter-tree.ts +5 -5
  42. package/src/lib/query/advanced-filter.ts +5 -5
  43. package/src/lib/query/engine.ts +13 -2
  44. package/src/modules/__tests__/overrides.test.ts +1 -1
  45. package/src/modules/__tests__/route-overrides.test.ts +1 -1
  46. package/src/modules/overrides.ts +3 -3
  47. 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
- for (const row of cfRows) {
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
- const decrypted = encrypted
647
- ? await decryptCustomFieldValue(
648
- value,
649
- resolvedTenantId ?? tenantId ?? null,
650
- getEncryptionService(),
651
- encryptionCache,
652
- { kind: def?.kind ?? null },
653
- )
654
- : value
655
- const existing = buckets.get(bucketKey)
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()) {
@@ -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
+ }
@@ -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 { 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