@open-mercato/shared 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2694.732417c5ec

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 (34) hide show
  1. package/dist/lib/api/crud.js +1 -1
  2. package/dist/lib/api/crud.js.map +2 -2
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/data/engine.js +68 -27
  6. package/dist/lib/data/engine.js.map +2 -2
  7. package/dist/lib/db/mikro.js +18 -22
  8. package/dist/lib/db/mikro.js.map +2 -2
  9. package/dist/lib/indexers/error-log.js +10 -12
  10. package/dist/lib/indexers/error-log.js.map +2 -2
  11. package/dist/lib/indexers/status-log.js +14 -16
  12. package/dist/lib/indexers/status-log.js.map +2 -2
  13. package/dist/lib/query/engine.js +220 -228
  14. package/dist/lib/query/engine.js.map +3 -3
  15. package/dist/lib/query/join-utils.js +28 -23
  16. package/dist/lib/query/join-utils.js.map +2 -2
  17. package/dist/lib/version.js +1 -1
  18. package/dist/lib/version.js.map +1 -1
  19. package/jest.config.cjs +4 -2
  20. package/package.json +1 -1
  21. package/src/lib/api/__tests__/crud.test.ts +5 -3
  22. package/src/lib/api/crud.ts +1 -1
  23. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +10 -4
  24. package/src/lib/auth/server.ts +1 -1
  25. package/src/lib/bootstrap/types.ts +2 -2
  26. package/src/lib/crud/__tests__/crud-factory.test.ts +27 -17
  27. package/src/lib/data/engine.ts +95 -47
  28. package/src/lib/db/mikro.ts +26 -25
  29. package/src/lib/indexers/error-log.ts +23 -23
  30. package/src/lib/indexers/status-log.ts +36 -33
  31. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +253 -114
  32. package/src/lib/query/__tests__/engine.test.ts +206 -139
  33. package/src/lib/query/engine.ts +306 -263
  34. package/src/lib/query/join-utils.ts +38 -30
@@ -1,5 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import type { Knex } from 'knex'
2
+ import { type Kysely, sql } from 'kysely'
3
3
  import type { IndexerErrorSource } from './error-log'
4
4
 
5
5
  export type IndexerLogLevel = 'info' | 'warn'
@@ -18,7 +18,7 @@ export type RecordIndexerLogInput = {
18
18
 
19
19
  type RecordIndexerLogDeps = {
20
20
  em?: EntityManager
21
- knex?: Knex
21
+ db?: Kysely<any>
22
22
  }
23
23
 
24
24
  const MAX_MESSAGE_LENGTH = 4_096
@@ -43,14 +43,11 @@ function safeJson(value: unknown): unknown {
43
43
  }
44
44
  }
45
45
 
46
- function pickKnex(deps: RecordIndexerLogDeps): Knex | null {
47
- if (deps.knex) return deps.knex
46
+ function pickDb(deps: RecordIndexerLogDeps): Kysely<any> | null {
47
+ if (deps.db) return deps.db
48
48
  if (deps.em) {
49
49
  try {
50
- const connection = deps.em.getConnection()
51
- if (connection && typeof connection.getKnex === 'function') {
52
- return connection.getKnex()
53
- }
50
+ return deps.em.getKysely<any>()
54
51
  } catch {
55
52
  return null
56
53
  }
@@ -58,30 +55,33 @@ function pickKnex(deps: RecordIndexerLogDeps): Knex | null {
58
55
  return null
59
56
  }
60
57
 
61
- async function pruneExcessLogs(knex: Knex, source: IndexerErrorSource): Promise<void> {
62
- const rows = await knex('indexer_status_logs')
63
- .select('id')
64
- .where('source', source)
65
- .orderBy('occurred_at', 'desc')
66
- .orderBy('id', 'desc')
58
+ async function pruneExcessLogs(db: Kysely<any>, source: IndexerErrorSource): Promise<void> {
59
+ const rows = await db
60
+ .selectFrom('indexer_status_logs' as any)
61
+ .select('id' as any)
62
+ .where('source' as any, '=', source)
63
+ .orderBy('occurred_at' as any, 'desc')
64
+ .orderBy('id' as any, 'desc')
67
65
  .offset(MAX_LOGS_PER_SOURCE)
68
66
  .limit(MAX_DELETE_BATCH)
67
+ .execute()
69
68
 
70
69
  if (!rows.length) return
71
70
  const ids = rows.map((row: any) => row.id).filter(Boolean)
72
71
  if (!ids.length) return
73
- await knex('indexer_status_logs')
74
- .whereIn('id', ids)
75
- .del()
72
+ await db
73
+ .deleteFrom('indexer_status_logs' as any)
74
+ .where('id' as any, 'in', ids)
75
+ .execute()
76
76
  }
77
77
 
78
78
  export async function recordIndexerLog(
79
79
  deps: RecordIndexerLogDeps,
80
80
  input: RecordIndexerLogInput,
81
81
  ): Promise<void> {
82
- const knex = pickKnex(deps)
83
- if (!knex) {
84
- console.warn('[indexers] Unable to record indexer log (missing knex connection)', {
82
+ const db = pickDb(deps)
83
+ if (!db) {
84
+ console.warn('[indexers] Unable to record indexer log (missing db connection)', {
85
85
  source: input.source,
86
86
  handler: input.handler,
87
87
  })
@@ -94,25 +94,28 @@ export async function recordIndexerLog(
94
94
  const occurredAt = new Date()
95
95
 
96
96
  try {
97
- await knex('indexer_status_logs').insert({
98
- source: input.source,
99
- handler: input.handler,
100
- level,
101
- entity_type: input.entityType ?? null,
102
- record_id: input.recordId ?? null,
103
- tenant_id: input.tenantId ?? null,
104
- organization_id: input.organizationId ?? null,
105
- message,
106
- details,
107
- occurred_at: occurredAt,
108
- })
97
+ await db
98
+ .insertInto('indexer_status_logs' as any)
99
+ .values({
100
+ source: input.source,
101
+ handler: input.handler,
102
+ level,
103
+ entity_type: input.entityType ?? null,
104
+ record_id: input.recordId ?? null,
105
+ tenant_id: input.tenantId ?? null,
106
+ organization_id: input.organizationId ?? null,
107
+ message,
108
+ details: details === null ? null : sql`${JSON.stringify(details)}::jsonb`,
109
+ occurred_at: occurredAt,
110
+ } as any)
111
+ .execute()
109
112
  } catch (error) {
110
113
  console.error('[indexers] Failed to persist indexer log', error)
111
114
  return
112
115
  }
113
116
 
114
117
  try {
115
- await pruneExcessLogs(knex, input.source)
118
+ await pruneExcessLogs(db, input.source)
116
119
  } catch (pruneError) {
117
120
  console.warn('[indexers] Failed to prune indexer logs', pruneError)
118
121
  }
@@ -8,88 +8,182 @@ function cloneRows(rows: any[] | undefined): any[] {
8
8
  return rows.map((row) => ({ ...row }))
9
9
  }
10
10
 
11
- function createFakeKnex(overrides?: FakeData) {
11
+ function createFakeKysely(overrides?: FakeData) {
12
12
  const calls: any[] = []
13
13
  const defaultData: FakeData = { custom_field_defs: [], custom_field_values: [] }
14
14
  const sourceData = { ...defaultData, ...(overrides || {}) }
15
15
  const data: FakeData = Object.fromEntries(
16
16
  Object.entries(sourceData).map(([table, rows]) => [table, cloneRows(rows)]),
17
17
  )
18
- function raw(sql: string, params?: any[]) {
19
- return { toString: () => sql, sql, params }
18
+
19
+ function parseTableSpec(spec: unknown): { table: string; alias: string | null } {
20
+ if (typeof spec !== 'string') return { table: String(spec || ''), alias: null }
21
+ const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(spec)
22
+ if (asMatch) return { table: asMatch[1].trim(), alias: asMatch[2].trim() }
23
+ return { table: spec, alias: null }
20
24
  }
21
- function makeBuilder(table: string) {
22
- const ops: any = {
23
- table,
24
- wheres: [] as any[],
25
- joins: [] as any[],
26
- selects: [] as any[],
27
- orderBys: [] as any[],
28
- groups: [] as any[],
29
- limits: 0,
30
- offsets: 0,
31
- isCountDistinct: false,
25
+
26
+ function createExpressionBuilder() {
27
+ const eb: any = (column: any, op: any, value: any) => ({ kind: 'cmp', column, op, value })
28
+ eb.and = (parts: any[]) => ({ kind: 'and', parts })
29
+ eb.or = (parts: any[]) => ({ kind: 'or', parts })
30
+ eb.not = (part: any) => ({ kind: 'not', part })
31
+ eb.exists = (sub: any) => ({ kind: 'exists', sub })
32
+ eb.val = (value: any) => ({ kind: 'val', value })
33
+ eb.ref = (name: string) => ({ kind: 'ref', name })
34
+ eb.selectFrom = (spec: any) => builderFor(spec)
35
+ return eb
36
+ }
37
+
38
+ function normalizeWhereArgs(args: any[]): any[] {
39
+ if (args.length === 1 && typeof args[0] === 'function') {
40
+ const produced = args[0](createExpressionBuilder())
41
+ if (produced && produced.kind === 'or') return ['or', produced.parts]
42
+ if (produced && produced.kind === 'and') return ['and', produced.parts]
43
+ if (produced && produced.kind === 'exists') return ['exists', produced.sub]
44
+ if (produced && produced.kind === 'not' && produced.part?.kind === 'exists') return ['notExists', produced.part.sub]
45
+ return ['expr', produced]
46
+ }
47
+ return args
48
+ }
49
+
50
+ function recordJoin(ops: any, type: 'left' | 'inner', spec: any, fn: Function) {
51
+ const parsed = parseTableSpec(spec)
52
+ const aliasObj = parsed.alias ? { [parsed.alias]: parsed.table } : { [parsed.table]: parsed.table }
53
+ const entry: any = { type, aliasObj, conditions: [] as any[] }
54
+ const ctx: any = {}
55
+ ctx.on = (left: any, op?: any, right?: any) => {
56
+ if (typeof left === 'function') {
57
+ const expr = left(createExpressionBuilder())
58
+ entry.conditions.push({ method: 'on', expr })
59
+ } else {
60
+ entry.conditions.push({ method: 'on', args: [left, op, right] })
61
+ }
62
+ return ctx
32
63
  }
64
+ ctx.onRef = (left: any, op: any, right: any) => {
65
+ entry.conditions.push({ method: 'on', args: [left, op, right] })
66
+ return ctx
67
+ }
68
+ fn(ctx)
69
+ ops.joins.push(entry)
70
+ }
71
+
72
+ function makeBuilder(ops: any, record: boolean): any {
33
73
  const b: any = {
34
74
  _ops: ops,
35
- select: function (...cols: any[]) { ops.selects.push(cols); return this },
36
- where: function (...args: any[]) {
37
- if (args.length === 1 && typeof args[0] === 'function') {
38
- const nested = makeBuilder(`${table}::where-fn`)
39
- args[0].call(nested, nested)
40
- ops.wheres.push(['whereFn', nested._ops])
41
- return this
42
- }
43
- ops.wheres.push(args)
75
+ select(this: any, ...cols: any[]) {
76
+ if (cols.length === 1 && Array.isArray(cols[0])) this._ops.selects.push(...cols[0])
77
+ else this._ops.selects.push(...cols)
44
78
  return this
45
79
  },
46
- andWhere: function (...args: any[]) { ops.wheres.push(['and', ...args]); return this },
47
- orWhere: function (...args: any[]) {
48
- if (args.length === 1 && typeof args[0] === 'function') {
49
- const nested = makeBuilder(`${table}::orWhere-fn`)
50
- args[0].call(nested, nested)
51
- ops.wheres.push(['orWhereFn', nested._ops])
52
- return this
53
- }
54
- ops.wheres.push(['orWhere', ...args])
80
+ distinct(this: any) { return this },
81
+ where(this: any, ...args: any[]) {
82
+ this._ops.wheres.push(normalizeWhereArgs(args))
83
+ return this
84
+ },
85
+ whereRef(this: any, left: any, op: any, right: any) {
86
+ this._ops.wheres.push(['ref', left, op, right])
87
+ return this
88
+ },
89
+ leftJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'left', spec, fn); return this },
90
+ innerJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'inner', spec, fn); return this },
91
+ groupBy(this: any, arg: any) {
92
+ if (Array.isArray(arg)) this._ops.groups.push(...arg)
93
+ else this._ops.groups.push(arg)
55
94
  return this
56
95
  },
57
- whereIn: function (...args: any[]) { ops.wheres.push(['in', ...args]); return this },
58
- whereNotIn: function (...args: any[]) { ops.wheres.push(['notIn', ...args]); return this },
59
- whereNull: function (col: any) { ops.wheres.push(['isNull', col]); return this },
60
- whereNotNull: function (col: any) { ops.wheres.push(['notNull', col]); return this },
61
- whereExists: function (sub: any) { ops.wheres.push(['exists', sub]); return this },
62
- whereNotExists: function (sub: any) { ops.wheres.push(['notExists', sub]); return this },
63
- whereRaw: function (...args: any[]) { ops.wheres.push(['raw', ...args]); return this },
64
- orWhereNull: function (col: any) { ops.wheres.push(['orIsNull', col]); return this },
65
- leftJoin: function () { return this },
66
- join: function () { return this },
67
- orderBy: function (col: any, dir?: any) { ops.orderBys.push([col, dir]); return this },
68
- groupBy: function (col: any) { ops.groups.push(col); return this },
69
- limit: function (n: number) { ops.limits = n; return this },
70
- offset: function (n: number) { ops.offsets = n; return this },
71
- clone: function () { return this },
72
- countDistinct: function () { ops.isCountDistinct = true; return this },
73
- count: async function () { return [{ count: '0' }] },
74
- first: async function () { return ops.isCountDistinct ? { count: '0' } : (data[table] || [])[0] },
75
- modify: function () { return this },
76
- then: function (resolve: any) { return Promise.resolve(resolve(data[table] || [])) },
96
+ having(this: any) { return this },
97
+ orderBy(this: any, col: any, dir?: any) { this._ops.orderBys.push([col, dir]); return this },
98
+ limit(this: any, n: number) { this._ops.limits = n; return this },
99
+ offset(this: any, n: number) { this._ops.offsets = n; return this },
100
+ clearSelect(this: any) {
101
+ const nextOps = { ...this._ops, selects: [] }
102
+ return makeBuilder(nextOps, false)
103
+ },
104
+ clearOrderBy(this: any) {
105
+ const nextOps = { ...this._ops, orderBys: [] }
106
+ return makeBuilder(nextOps, false)
107
+ },
108
+ clearGroupBy(this: any) {
109
+ const nextOps = { ...this._ops, groups: [] }
110
+ return makeBuilder(nextOps, false)
111
+ },
112
+ as(this: any, alias: string) { this._ops.alias = alias; return this },
113
+ async execute(this: any) { return cloneRows(data[this._ops.table]) },
114
+ async executeTakeFirst(this: any) {
115
+ const localOps = this._ops
116
+ if (localOps.table === 'information_schema.columns') {
117
+ const infoRows = data['information_schema.columns']
118
+ if (!Array.isArray(infoRows)) return undefined
119
+ const targetTable = extractEqValue(localOps.wheres, 'table_name')
120
+ const targetColumn = extractEqValue(localOps.wheres, 'column_name')
121
+ return infoRows.find((row: any) =>
122
+ (!targetTable || row.table_name === targetTable) &&
123
+ (!targetColumn || row.column_name === targetColumn)
124
+ )
125
+ }
126
+ if (localOps.table === 'information_schema.tables') {
127
+ const infoRows = data['information_schema.tables']
128
+ if (!Array.isArray(infoRows)) return undefined
129
+ const targetTable = extractEqValue(localOps.wheres, 'table_name')
130
+ return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
131
+ }
132
+ if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
133
+ return { count: '0' }
134
+ }
135
+ const rows = data[localOps.table] || []
136
+ if (rows.length === 0) return { count: '0' }
137
+ return rows[0]
138
+ },
77
139
  }
78
- calls.push(b)
140
+ if (record) calls.push(b)
79
141
  return b
80
142
  }
81
- const fn: any = (tableArg: any) => {
82
- const t = typeof tableArg === 'string' ? tableArg : String(Object.values(tableArg || {})[0] || '')
83
- return makeBuilder(t)
143
+
144
+ function builderFor(tableArg: any): any {
145
+ const parsed = parseTableSpec(tableArg)
146
+ const ops = {
147
+ table: parsed.table,
148
+ alias: parsed.alias,
149
+ wheres: [] as any[],
150
+ joins: [] as any[],
151
+ selects: [] as any[],
152
+ orderBys: [] as any[],
153
+ groups: [] as any[],
154
+ limits: 0,
155
+ offsets: 0,
156
+ }
157
+ return makeBuilder(ops, true)
158
+ }
159
+
160
+ function extractEqValue(wheres: any[], column: string): any {
161
+ for (const entry of wheres) {
162
+ if (!Array.isArray(entry)) continue
163
+ if (entry[0] === column && entry[1] === '=') return entry[2]
164
+ }
165
+ return undefined
166
+ }
167
+
168
+ const db: any = {
169
+ selectFrom(spec: any) { return builderFor(spec) },
84
170
  }
85
- fn.raw = raw
86
- fn._calls = calls
87
- return fn
171
+ db._calls = calls
172
+ return db
88
173
  }
89
174
 
90
- function collectAllWheres(calls: any[]): any[] {
91
- const out: any[] = []
92
- for (const c of calls) out.push(...c._ops.wheres)
175
+ type Cmp = { kind: 'cmp'; column: string; op: string; value: unknown }
176
+ type And = { kind: 'and'; parts: Node[] }
177
+ type Or = { kind: 'or'; parts: Node[] }
178
+ type Node = Cmp | And | Or | { kind: string; [k: string]: any }
179
+
180
+ function flattenCmps(node: Node | unknown, out: Cmp[] = []): Cmp[] {
181
+ if (!node || typeof node !== 'object') return out
182
+ const n = node as Node
183
+ if (n.kind === 'cmp') out.push(n as Cmp)
184
+ else if (n.kind === 'and' || n.kind === 'or') {
185
+ for (const p of (n as And | Or).parts) flattenCmps(p, out)
186
+ }
93
187
  return out
94
188
  }
95
189
 
@@ -135,63 +229,68 @@ describe('normalizeFilters $or clause grouping', () => {
135
229
  })
136
230
 
137
231
  describe('BasicQueryEngine — null equality', () => {
138
- test('$eq null compiles to whereNull', async () => {
139
- const fakeKnex = createFakeKnex({
232
+ test('$eq null compiles to where(col, "is", null)', async () => {
233
+ const fakeDb = createFakeKysely({
140
234
  scheduled_jobs: [],
141
235
  'information_schema.columns': [
142
236
  { table_name: 'scheduled_jobs', column_name: 'organization_id' },
143
237
  ],
144
238
  })
145
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
239
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
146
240
  await engine.query('scheduler:scheduled_job', {
147
241
  tenantId: 't1',
148
242
  fields: ['id'],
149
243
  omitAutomaticTenantOrgScope: true,
150
244
  filters: { organization_id: { $eq: null } },
151
245
  })
152
- const wheres = collectAllWheres(fakeKnex._calls)
153
- const hasWhereNullOrgId = wheres.some(
154
- (w: any) => Array.isArray(w) && w[0] === 'isNull' && String(w[1]).endsWith('organization_id'),
246
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
247
+ expect(baseCall).toBeTruthy()
248
+ const wheres: any[] = baseCall._ops.wheres
249
+ const hasIsNullOrgId = wheres.some(
250
+ (w: any) => Array.isArray(w) && String(w[0]).endsWith('organization_id') && w[1] === 'is' && w[2] === null,
155
251
  )
156
- expect(hasWhereNullOrgId).toBe(true)
252
+ expect(hasIsNullOrgId).toBe(true)
157
253
  const hasEqualsLiteralNull = wheres.some(
158
- (w: any) => Array.isArray(w) && w.length >= 2 && String(w[0]).endsWith('organization_id') && w[1] === null,
254
+ (w: any) => Array.isArray(w) && String(w[0]).endsWith('organization_id') && w[1] === '=' && w[2] === null,
159
255
  )
160
256
  expect(hasEqualsLiteralNull).toBe(false)
161
257
  })
162
258
 
163
- test('$ne null compiles to whereNotNull', async () => {
164
- const fakeKnex = createFakeKnex({
259
+ test('$ne null compiles to where(col, "is not", null)', async () => {
260
+ const fakeDb = createFakeKysely({
165
261
  scheduled_jobs: [],
166
262
  'information_schema.columns': [
167
263
  { table_name: 'scheduled_jobs', column_name: 'organization_id' },
168
264
  ],
169
265
  })
170
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
266
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
171
267
  await engine.query('scheduler:scheduled_job', {
172
268
  tenantId: 't1',
173
269
  fields: ['id'],
174
270
  omitAutomaticTenantOrgScope: true,
175
271
  filters: { organization_id: { $ne: null } },
176
272
  })
177
- const wheres = collectAllWheres(fakeKnex._calls)
178
- const hasWhereNotNullOrgId = wheres.some(
179
- (w: any) => Array.isArray(w) && w[0] === 'notNull' && String(w[1]).endsWith('organization_id'),
273
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
274
+ expect(baseCall).toBeTruthy()
275
+ const wheres: any[] = baseCall._ops.wheres
276
+ const hasIsNotNullOrgId = wheres.some(
277
+ (w: any) => Array.isArray(w) && String(w[0]).endsWith('organization_id') && w[1] === 'is not' && w[2] === null,
180
278
  )
181
- expect(hasWhereNotNullOrgId).toBe(true)
279
+ expect(hasIsNotNullOrgId).toBe(true)
182
280
  })
183
281
  })
184
282
 
185
283
  describe('BasicQueryEngine — omitAutomaticTenantOrgScope', () => {
186
284
  test('skips automatic tenant and organization guards when flag is set', async () => {
187
- const fakeKnex = createFakeKnex({
285
+ const fakeDb = createFakeKysely({
188
286
  scheduled_jobs: [],
189
287
  'information_schema.columns': [
288
+ { table_name: 'scheduled_jobs', column_name: 'id' },
190
289
  { table_name: 'scheduled_jobs', column_name: 'organization_id' },
191
290
  { table_name: 'scheduled_jobs', column_name: 'tenant_id' },
192
291
  ],
193
292
  })
194
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
293
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
195
294
  await engine.query('scheduler:scheduled_job', {
196
295
  tenantId: 't1',
197
296
  organizationId: 'org-1',
@@ -199,35 +298,62 @@ describe('BasicQueryEngine — omitAutomaticTenantOrgScope', () => {
199
298
  omitAutomaticTenantOrgScope: true,
200
299
  filters: { id: { $eq: '11111111-1111-1111-1111-111111111111' } },
201
300
  })
202
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
301
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
203
302
  expect(baseCall).toBeTruthy()
204
- const serialized = JSON.stringify(baseCall._ops.wheres)
205
- expect(serialized).not.toMatch(/organization_id.*org-1/)
206
- expect(serialized).not.toMatch(/\["scheduled_jobs\.tenant_id","t1"\]/)
303
+ const wheres: any[] = baseCall._ops.wheres
304
+
305
+ const hasTenantGuard = wheres.some(
306
+ (w: any) => Array.isArray(w) && w[0] === 'scheduled_jobs.tenant_id' && w[1] === '=' && w[2] === 't1',
307
+ )
308
+ expect(hasTenantGuard).toBe(false)
309
+
310
+ const allCmps = wheres.flatMap((w: any) => {
311
+ if (!Array.isArray(w)) return []
312
+ if (w[0] === 'expr' || w[0] === 'and' || w[0] === 'or') {
313
+ const payload = w[0] === 'expr' ? w[1] : { kind: w[0], parts: w[1] }
314
+ return flattenCmps(payload)
315
+ }
316
+ if (w.length >= 3 && typeof w[0] === 'string' && typeof w[1] === 'string') {
317
+ return [{ kind: 'cmp', column: w[0], op: w[1], value: w[2] } as Cmp]
318
+ }
319
+ return []
320
+ })
321
+ const hasOrgGuard = allCmps.some(
322
+ (c) => String(c.column).endsWith('organization_id') && c.op === 'in' && Array.isArray(c.value) && (c.value as any[]).includes('org-1'),
323
+ )
324
+ expect(hasOrgGuard).toBe(false)
325
+
326
+ const hasIdFilter = wheres.some(
327
+ (w: any) => Array.isArray(w) && w[0] === 'scheduled_jobs.id' && w[1] === '=' && w[2] === '11111111-1111-1111-1111-111111111111',
328
+ )
329
+ expect(hasIdFilter).toBe(true)
207
330
  })
208
331
 
209
332
  test('applies automatic tenant guard when flag is absent (baseline)', async () => {
210
- const fakeKnex = createFakeKnex({
333
+ const fakeDb = createFakeKysely({
211
334
  scheduled_jobs: [],
212
335
  'information_schema.columns': [
213
336
  { table_name: 'scheduled_jobs', column_name: 'tenant_id' },
214
337
  ],
215
338
  })
216
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
339
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
217
340
  await engine.query('scheduler:scheduled_job', {
218
341
  tenantId: 't1',
219
342
  fields: ['id'],
220
343
  })
221
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
222
- const serialized = JSON.stringify(baseCall._ops.wheres)
223
- expect(serialized).toMatch(/tenant_id/)
224
- expect(serialized).toMatch(/t1/)
344
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
345
+ expect(baseCall).toBeTruthy()
346
+ const wheres: any[] = baseCall._ops.wheres
347
+ const hasTenantGuard = wheres.some(
348
+ (w: any) => Array.isArray(w) && w[0] === 'scheduled_jobs.tenant_id' && w[1] === '=' && w[2] === 't1',
349
+ )
350
+ expect(hasTenantGuard).toBe(true)
225
351
  })
226
352
  })
227
353
 
228
354
  describe('BasicQueryEngine — multi-field $or grouping', () => {
229
355
  test('AND within each $or clause, OR between clauses', async () => {
230
- const fakeKnex = createFakeKnex({
356
+ const fakeDb = createFakeKysely({
231
357
  scheduled_jobs: [],
232
358
  'information_schema.columns': [
233
359
  { table_name: 'scheduled_jobs', column_name: 'organization_id' },
@@ -235,7 +361,7 @@ describe('BasicQueryEngine — multi-field $or grouping', () => {
235
361
  { table_name: 'scheduled_jobs', column_name: 'scope_type' },
236
362
  ],
237
363
  })
238
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
364
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
239
365
  await engine.query('scheduler:scheduled_job', {
240
366
  tenantId: 't1',
241
367
  fields: ['id'],
@@ -247,32 +373,45 @@ describe('BasicQueryEngine — multi-field $or grouping', () => {
247
373
  ],
248
374
  },
249
375
  })
250
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
376
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
251
377
  expect(baseCall).toBeTruthy()
252
- const whereFn = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'whereFn')
253
- expect(whereFn).toBeTruthy()
254
- const nestedOps = whereFn[1]
255
- const nestedWheres = nestedOps.wheres
256
- const orWhereFn = nestedWheres.find((w: any) => Array.isArray(w) && w[0] === 'orWhereFn')
257
- expect(orWhereFn).toBeTruthy()
258
- const firstGroupWheres = nestedWheres.filter((w: any) => !Array.isArray(w) || w[0] !== 'orWhereFn')
259
- const firstGroupColumns = firstGroupWheres
260
- .map((w: any) => (Array.isArray(w) ? String(w[0]) : ''))
261
- .filter((col: string) => col.endsWith('organization_id') || col.endsWith('tenant_id'))
262
- expect(firstGroupColumns.length).toBeGreaterThanOrEqual(2)
263
- const secondGroupOps = orWhereFn[1]
264
- const secondGroupWheres = secondGroupOps.wheres
265
- const hasSystemScopeFilter = secondGroupWheres.some(
266
- (w: any) => Array.isArray(w) && String(w[0]).endsWith('scope_type') && w[1] === 'system',
378
+
379
+ const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
380
+ expect(orEntry).toBeTruthy()
381
+ const orParts: Node[] = orEntry[1]
382
+ expect(Array.isArray(orParts)).toBe(true)
383
+ expect(orParts.length).toBe(2)
384
+
385
+ const groupOne = orParts[0] as And
386
+ expect(groupOne.kind).toBe('and')
387
+ const groupOneCmps = flattenCmps(groupOne)
388
+ const groupOneCols = groupOneCmps
389
+ .map((c) => String(c.column))
390
+ .filter((col) => col.endsWith('organization_id') || col.endsWith('tenant_id'))
391
+ expect(groupOneCols.length).toBeGreaterThanOrEqual(2)
392
+ const groupOneHasOrgEq = groupOneCmps.some(
393
+ (c) => String(c.column).endsWith('organization_id') && c.op === '=' && c.value === 'org-1',
394
+ )
395
+ const groupOneHasTenantEq = groupOneCmps.some(
396
+ (c) => String(c.column).endsWith('tenant_id') && c.op === '=' && c.value === 't1',
397
+ )
398
+ expect(groupOneHasOrgEq).toBe(true)
399
+ expect(groupOneHasTenantEq).toBe(true)
400
+
401
+ const groupTwo = orParts[1] as And
402
+ expect(groupTwo.kind).toBe('and')
403
+ const groupTwoCmps = flattenCmps(groupTwo)
404
+ const hasSystemScope = groupTwoCmps.some(
405
+ (c) => String(c.column).endsWith('scope_type') && c.op === '=' && c.value === 'system',
267
406
  )
268
- const hasNullOrgInSecondGroup = secondGroupWheres.some(
269
- (w: any) => Array.isArray(w) && w[0] === 'isNull' && String(w[1]).endsWith('organization_id'),
407
+ const hasNullOrg = groupTwoCmps.some(
408
+ (c) => String(c.column).endsWith('organization_id') && c.op === 'is' && c.value === null,
270
409
  )
271
- const hasNullTenantInSecondGroup = secondGroupWheres.some(
272
- (w: any) => Array.isArray(w) && w[0] === 'isNull' && String(w[1]).endsWith('tenant_id'),
410
+ const hasNullTenant = groupTwoCmps.some(
411
+ (c) => String(c.column).endsWith('tenant_id') && c.op === 'is' && c.value === null,
273
412
  )
274
- expect(hasSystemScopeFilter).toBe(true)
275
- expect(hasNullOrgInSecondGroup).toBe(true)
276
- expect(hasNullTenantInSecondGroup).toBe(true)
413
+ expect(hasSystemScope).toBe(true)
414
+ expect(hasNullOrg).toBe(true)
415
+ expect(hasNullTenant).toBe(true)
277
416
  })
278
417
  })