@open-mercato/shared 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2699.f8b50c8046

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
@@ -17,7 +17,18 @@ function cloneRows(rows: any[] | undefined): any[] {
17
17
  return rows.map((row) => ({ ...row }))
18
18
  }
19
19
 
20
- function createFakeKnex(overrides?: FakeData) {
20
+ /**
21
+ * Build a fake Kysely that mimics the fluent API used by BasicQueryEngine.
22
+ * Records operations on each SelectQueryBuilder so tests can inspect:
23
+ * - _ops.table / _ops.alias — starting table (selectFrom target)
24
+ * - _ops.wheres — `[type, ...args]` tuples
25
+ * - _ops.joins — `[{ type, aliasObj, conditions }]`
26
+ * - _ops.orderBys — `[[column, dir]]`
27
+ * - _ops.groups — grouped columns
28
+ * - _ops.selects — select arguments
29
+ * - _ops.limits / _ops.offsets — pagination knobs
30
+ */
31
+ function createFakeKysely(overrides?: FakeData) {
21
32
  const calls: any[] = []
22
33
  const defaultData: FakeData = {
23
34
  custom_field_defs: [
@@ -30,115 +41,177 @@ function createFakeKnex(overrides?: FakeData) {
30
41
  const data: FakeData = Object.fromEntries(
31
42
  Object.entries(sourceData).map(([table, rows]) => [table, cloneRows(rows)])
32
43
  )
33
- function raw(sql: string, params?: any[]) { return { toString: () => sql, sql, params } }
34
- function builderFor(tableArg: any) {
35
- let table = ''
36
- let alias: string | null = null
37
- if (typeof tableArg === 'string') {
38
- table = tableArg
39
- } else if (tableArg && typeof tableArg === 'object') {
40
- const first = Object.entries(tableArg)[0]
41
- if (first) {
42
- alias = String(first[0])
43
- table = String(first[1])
44
- }
45
- } else {
46
- table = String(tableArg || '')
44
+
45
+ function parseTableSpec(spec: unknown): { table: string; alias: string | null } {
46
+ if (typeof spec !== 'string') return { table: String(spec || ''), alias: null }
47
+ const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(spec)
48
+ if (asMatch) return { table: asMatch[1].trim(), alias: asMatch[2].trim() }
49
+ return { table: spec, alias: null }
50
+ }
51
+
52
+ function createExpressionBuilder() {
53
+ const eb: any = (column: any, op: any, value: any) => ({ kind: 'cmp', column, op, value })
54
+ eb.and = (parts: any[]) => ({ kind: 'and', parts })
55
+ eb.or = (parts: any[]) => ({ kind: 'or', parts })
56
+ eb.not = (part: any) => ({ kind: 'not', part })
57
+ eb.exists = (sub: any) => ({ kind: 'exists', sub })
58
+ eb.val = (value: any) => ({ kind: 'val', value })
59
+ eb.ref = (name: string) => ({ kind: 'ref', name })
60
+ eb.selectFrom = (spec: any) => builderFor(spec)
61
+ return eb
62
+ }
63
+
64
+ function normalizeWhereArgs(args: any[]): any[] {
65
+ if (args.length === 1 && typeof args[0] === 'function') {
66
+ const produced = args[0](createExpressionBuilder())
67
+ if (produced && produced.kind === 'or') return ['or', produced.parts]
68
+ if (produced && produced.kind === 'exists') return ['exists', produced.sub]
69
+ if (produced && produced.kind === 'not' && produced.part?.kind === 'exists') return ['notExists', produced.part.sub]
70
+ return ['expr', produced]
47
71
  }
48
- const ops = { table, alias, wheres: [] as any[], joins: [] as any[], selects: [] as any[], orderBys: [] as any[], groups: [] as any[], limits: 0, offsets: 0, isCountDistinct: false }
49
- function recordJoin(type: 'left' | 'inner', aliasObj: any, fn: Function, ctxBuilder: () => any) {
50
- const entry: any = { type, aliasObj, conditions: [] as any[] }
51
- const ctx = ctxBuilder()
52
- ctx.on = (left: any, op: any, right: any) => {
72
+ // (col, op, value) or sql template
73
+ return args
74
+ }
75
+
76
+ function recordJoin(ops: any, type: 'left' | 'inner', spec: any, fn: Function) {
77
+ const parsed = parseTableSpec(spec)
78
+ const aliasObj = parsed.alias ? { [parsed.alias]: parsed.table } : { [parsed.table]: parsed.table }
79
+ const entry: any = { type, aliasObj, conditions: [] as any[] }
80
+ const ctx: any = {}
81
+ ctx.on = (left: any, op?: any, right?: any) => {
82
+ if (typeof left === 'function') {
83
+ const expr = left(createExpressionBuilder())
84
+ entry.conditions.push({ method: 'on', expr })
85
+ } else {
53
86
  entry.conditions.push({ method: 'on', args: [left, op, right] })
54
- return ctx
55
87
  }
56
- ctx.andOn = (left: any, op: any, right: any) => {
57
- entry.conditions.push({ method: 'andOn', args: [left, op, right] })
58
- return ctx
59
- }
60
- fn.call(ctx)
61
- ops.joins.push(entry)
88
+ return ctx
89
+ }
90
+ ctx.onRef = (left: any, op: any, right: any) => {
91
+ entry.conditions.push({ method: 'on', args: [left, op, right] })
92
+ return ctx
62
93
  }
94
+ const result = fn(ctx)
95
+ // onRef/on chain returns ctx; nothing else to do
96
+ void result
97
+ ops.joins.push(entry)
98
+ }
99
+
100
+ function makeBuilder(ops: any, record: boolean): any {
63
101
  const b: any = {
64
102
  _ops: ops,
65
- select: function (...cols: any[]) { ops.selects.push(cols); return this },
66
- where: function (...args: any[]) { ops.wheres.push(args); return this },
67
- andWhere: function (...args: any[]) { ops.wheres.push(args); return this },
68
- whereIn: function (...args: any[]) { ops.wheres.push(['in', ...args]); return this },
69
- whereNotIn: function (...args: any[]) { ops.wheres.push(['notIn', ...args]); return this },
70
- whereNull: function (col: any) { ops.wheres.push(['isNull', col]); return this },
71
- whereNotNull: function (col: any) { ops.wheres.push(['notNull', col]); return this },
72
- whereExists: function (sub: any) { ops.wheres.push(['exists', sub]); return this },
73
- whereNotExists: function (sub: any) { ops.wheres.push(['notExists', sub]); return this },
74
- whereRaw: function (...args: any[]) { ops.wheres.push(['raw', ...args]); return this },
75
- orWhereNull: function (col: any) { ops.wheres.push(['orWhereNull', col]); return this },
76
- leftJoin: function (aliasObj: any, fn: Function) {
77
- recordJoin('left', aliasObj, fn, () => ({}))
103
+ select(this: any, ...cols: any[]) {
104
+ if (cols.length === 1 && Array.isArray(cols[0])) this._ops.selects.push(...cols[0])
105
+ else this._ops.selects.push(...cols)
78
106
  return this
79
107
  },
80
- join: function (aliasObj: any, fn: Function) {
81
- recordJoin('inner', aliasObj, fn, () => ({}))
108
+ distinct(this: any) { return this },
109
+ where(this: any, ...args: any[]) {
110
+ this._ops.wheres.push(normalizeWhereArgs(args))
82
111
  return this
83
112
  },
84
- orderBy: function (col: any, dir?: any) { ops.orderBys.push([col, dir]); return this },
85
- groupBy: function (col: any) { ops.groups.push(col); return this },
86
- limit: function (n: number) { ops.limits = n; return this },
87
- offset: function (n: number) { ops.offsets = n; return this },
88
- clone: function () { return this },
89
- countDistinct: function () { ops.isCountDistinct = true; return this },
90
- count: async function () { return [{ count: '0' }] },
91
- first: async function () {
92
- // If this is called after countDistinct, return count data
93
- if (ops.isCountDistinct) {
94
- return { count: '0' }
95
- }
96
- const rows = data[table] || [];
97
- return rows[0]
113
+ whereRef(this: any, left: any, op: any, right: any) {
114
+ this._ops.wheres.push(['ref', left, op, right])
115
+ return this
98
116
  },
99
- modify: function (fn: Function) {
100
- const qb: any = {
101
- andWhere: (arg: any) => {
102
- if (typeof arg === 'function') {
103
- const inner: any = {
104
- where: (obj: any) => ({
105
- orWhereNull: (col: any) => { ops.wheres.push(['andWhereFn', obj, ['orWhereNull', col]]); return inner },
106
- }),
107
- }
108
- arg(inner)
109
- } else {
110
- ops.wheres.push(['andWhere', arg])
111
- }
112
- return qb
113
- },
114
- whereNull: (col: any) => { ops.wheres.push(['isNull', col]); return qb },
115
- }
116
- fn(qb)
117
+ leftJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'left', spec, fn); return this },
118
+ innerJoin(this: any, spec: any, fn: Function) { recordJoin(this._ops, 'inner', spec, fn); return this },
119
+ groupBy(this: any, arg: any) {
120
+ if (Array.isArray(arg)) this._ops.groups.push(...arg)
121
+ else this._ops.groups.push(arg)
117
122
  return this
118
123
  },
119
- then: function (resolve: any) { const res = data[table] || []; return Promise.resolve(resolve(res)) },
124
+ having(this: any) { return this },
125
+ orderBy(this: any, col: any, dir?: any) { this._ops.orderBys.push([col, dir]); return this },
126
+ limit(this: any, n: number) { this._ops.limits = n; return this },
127
+ offset(this: any, n: number) { this._ops.offsets = n; return this },
128
+ clearSelect(this: any) {
129
+ const nextOps = { ...this._ops, selects: [] }
130
+ return makeBuilder(nextOps, false)
131
+ },
132
+ clearOrderBy(this: any) {
133
+ const nextOps = { ...this._ops, orderBys: [] }
134
+ return makeBuilder(nextOps, false)
135
+ },
136
+ clearGroupBy(this: any) {
137
+ const nextOps = { ...this._ops, groups: [] }
138
+ return makeBuilder(nextOps, false)
139
+ },
140
+ as(this: any, alias: string) { this._ops.alias = alias; return this },
141
+ async execute(this: any) { return cloneRows(data[this._ops.table]) },
142
+ async executeTakeFirst(this: any) {
143
+ const localOps = this._ops
144
+ if (localOps.table === 'information_schema.columns') {
145
+ const infoRows = data['information_schema.columns']
146
+ if (!Array.isArray(infoRows)) return undefined
147
+ const targetTable = extractEqValue(localOps.wheres, 'table_name')
148
+ const targetColumn = extractEqValue(localOps.wheres, 'column_name')
149
+ return infoRows.find((row: any) =>
150
+ (!targetTable || row.table_name === targetTable) &&
151
+ (!targetColumn || row.column_name === targetColumn)
152
+ )
153
+ }
154
+ if (localOps.table === 'information_schema.tables') {
155
+ const infoRows = data['information_schema.tables']
156
+ if (!Array.isArray(infoRows)) return undefined
157
+ const targetTable = extractEqValue(localOps.wheres, 'table_name')
158
+ return infoRows.find((row: any) => !targetTable || row.table_name === targetTable)
159
+ }
160
+ if (localOps.selects.some((s: any) => s && typeof s === 'object' && (s.__isCount || String(s?.alias || '') === 'count'))) {
161
+ return { count: '0' }
162
+ }
163
+ const rows = data[localOps.table] || []
164
+ if (rows.length === 0) return { count: '0' }
165
+ return rows[0]
166
+ },
120
167
  }
121
- calls.push(b)
168
+ if (record) calls.push(b)
122
169
  return b
123
170
  }
124
- const fn: any = (table: any) => builderFor(table)
125
- fn.raw = raw
126
- fn._calls = calls
127
- return fn
171
+
172
+ function builderFor(tableArg: any): any {
173
+ const parsed = parseTableSpec(tableArg)
174
+ const ops = {
175
+ table: parsed.table,
176
+ alias: parsed.alias,
177
+ wheres: [] as any[],
178
+ joins: [] as any[],
179
+ selects: [] as any[],
180
+ orderBys: [] as any[],
181
+ groups: [] as any[],
182
+ limits: 0,
183
+ offsets: 0,
184
+ }
185
+ return makeBuilder(ops, true)
186
+ }
187
+
188
+ function extractEqValue(wheres: any[], column: string): any {
189
+ for (const entry of wheres) {
190
+ if (!Array.isArray(entry)) continue
191
+ if (entry[0] === column && entry[1] === '=') return entry[2]
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ const db: any = {
197
+ selectFrom(spec: any) { return builderFor(spec) },
198
+ }
199
+ db._calls = calls
200
+ return db
128
201
  }
129
202
 
130
- describe('BasicQueryEngine', () => {
203
+ describe('BasicQueryEngine (Kysely)', () => {
131
204
  test('pluralizes entity names ending with y correctly', async () => {
132
- const fakeKnex = createFakeKnex()
133
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
205
+ const fakeDb = createFakeKysely()
206
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
134
207
  await engine.query('customers:customer_entity', { tenantId: 't1' })
135
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
208
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
136
209
  expect(baseCall).toBeTruthy()
137
210
  })
138
211
 
139
212
  test('includeCustomFields true discovers keys and allows sort on cf:*; joins extensions', async () => {
140
- const fakeKnex = createFakeKnex()
141
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
213
+ const fakeDb = createFakeKysely()
214
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
142
215
  const res = await engine.query('auth:user', {
143
216
  includeCustomFields: true,
144
217
  fields: ['id','email','cf:vip'],
@@ -149,25 +222,29 @@ describe('BasicQueryEngine', () => {
149
222
  page: { page: 1, pageSize: 10 },
150
223
  })
151
224
  expect(res).toMatchObject({ page: 1, pageSize: 10, total: 0, items: [] })
152
- // Assert that custom_field_defs was queried for this entity and org
153
- const defsCall = fakeKnex._calls.find((b: any) => b._ops.table === 'custom_field_defs')
225
+ const defsCall = fakeDb._calls.find((b: any) => b._ops.table === 'custom_field_defs')
154
226
  expect(defsCall).toBeTruthy()
155
- const hasEntityFilter = defsCall._ops.wheres.some((w: any) => JSON.stringify(w).includes('entity_id'))
227
+ // Tenant filter (OR tenant_id is null) is expressed as an OR expression in Kysely
228
+ const hasEntityFilter = defsCall._ops.wheres.some((w: any) =>
229
+ Array.isArray(w) && w[0] === 'entity_id' && w[1] === 'in'
230
+ )
156
231
  expect(hasEntityFilter).toBe(true)
157
- // Organization-level scoping is intentionally disabled for custom field definitions; ensure tenant filter is present
158
- const hasTenantFilter = defsCall._ops.wheres.some((w: any) => JSON.stringify(w).includes('tenant_id'))
232
+ const hasTenantFilter = defsCall._ops.wheres.some((w: any) => {
233
+ if (!Array.isArray(w)) return false
234
+ const [kind, parts] = w
235
+ if (kind !== 'or' || !Array.isArray(parts)) return false
236
+ return parts.some((part: any) => part?.column === 'tenant_id' && part?.op === '=')
237
+ })
159
238
  expect(hasTenantFilter).toBe(true)
160
- // Assert base ordering by cf alias was recorded
161
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'users')
239
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'users')
162
240
  const hasCfOrder = baseCall._ops.orderBys.some((o: any) => o[0] === 'cf_vip')
163
241
  expect(hasCfOrder).toBe(true)
164
- // Assert an extension leftJoin was attempted
165
242
  const hasExtJoin = baseCall._ops.joins.length > 0
166
243
  expect(hasExtJoin).toBe(true)
167
244
  })
168
245
 
169
246
  test('customFieldSources join additional profiles for custom fields', async () => {
170
- const fakeKnex = createFakeKnex({
247
+ const fakeDb = createFakeKysely({
171
248
  custom_field_defs: [
172
249
  { key: 'birthday', entity_id: 'customers:customer_person_profile', is_active: true, config_json: JSON.stringify({ listVisible: true }), kind: 'text' },
173
250
  { key: 'sector', entity_id: 'customers:customer_company_profile', is_active: true, config_json: JSON.stringify({ listVisible: true }), kind: 'select' },
@@ -177,7 +254,7 @@ describe('BasicQueryEngine', () => {
177
254
  customer_people: [],
178
255
  customer_companies: [],
179
256
  })
180
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
257
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
181
258
  await engine.query('customers:customer_entity', {
182
259
  tenantId: 't1',
183
260
  includeCustomFields: ['birthday', 'sector'],
@@ -200,7 +277,7 @@ describe('BasicQueryEngine', () => {
200
277
  ],
201
278
  page: { page: 1, pageSize: 10 },
202
279
  })
203
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
280
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
204
281
  expect(baseCall).toBeTruthy()
205
282
  const joinAliases = baseCall._ops.joins.map((j: any) => Object.keys(j.aliasObj)[0])
206
283
  expect(joinAliases).toEqual(expect.arrayContaining([
@@ -212,19 +289,22 @@ describe('BasicQueryEngine', () => {
212
289
  'cfv_company_profile_sector',
213
290
  ]))
214
291
  const personProfileJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.person_profile)
215
- expect(personProfileJoin?.conditions.some((c: any) => c.args[0] === 'person_profile.entity_id' && c.args[2] === 'customer_entities.id')).toBe(true)
292
+ expect(personProfileJoin?.conditions.some((c: any) => c.args?.[0] === 'person_profile.entity_id' && c.args?.[2] === 'customer_entities.id')).toBe(true)
216
293
  const companyProfileJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.company_profile)
217
- expect(companyProfileJoin?.conditions.some((c: any) => c.args[0] === 'company_profile.entity_id' && c.args[2] === 'customer_entities.id')).toBe(true)
294
+ expect(companyProfileJoin?.conditions.some((c: any) => c.args?.[0] === 'company_profile.entity_id' && c.args?.[2] === 'customer_entities.id')).toBe(true)
295
+ // cfv joins use onRef(`${valAlias}.record_id`, '=', recordIdExpr) where recordIdExpr is a sql template referencing person_profile.id
218
296
  const cfvPersonJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.cfv_person_profile_birthday)
219
- expect(cfvPersonJoin?.conditions.some((c: any) => c.args[0] === 'cfv_person_profile_birthday.record_id' && c.args[2]?.params?.[0] === 'person_profile.id')).toBe(true)
297
+ expect(cfvPersonJoin).toBeTruthy()
298
+ expect(cfvPersonJoin.conditions.some((c: any) => c.args?.[0] === 'cfv_person_profile_birthday.record_id')).toBe(true)
220
299
  const cfvCompanyJoin = baseCall._ops.joins.find((j: any) => j.aliasObj.cfv_company_profile_sector)
221
- expect(cfvCompanyJoin?.conditions.some((c: any) => c.args[0] === 'cfv_company_profile_sector.record_id' && c.args[2]?.params?.[0] === 'company_profile.id')).toBe(true)
222
- const defsEntityWhere = fakeKnex._calls
300
+ expect(cfvCompanyJoin).toBeTruthy()
301
+ expect(cfvCompanyJoin.conditions.some((c: any) => c.args?.[0] === 'cfv_company_profile_sector.record_id')).toBe(true)
302
+ const defsInFilter = fakeDb._calls
223
303
  .filter((b: any) => b._ops.table === 'custom_field_defs')
224
304
  .flatMap((b: any) => b._ops.wheres)
225
- .find((w: any) => Array.isArray(w) && w[0] === 'in' && w[1] === 'entity_id')
226
- expect(defsEntityWhere).toBeTruthy()
227
- const entityTargets = defsEntityWhere?.[2] || []
305
+ .find((w: any) => Array.isArray(w) && w[0] === 'entity_id' && w[1] === 'in')
306
+ expect(defsInFilter).toBeTruthy()
307
+ const entityTargets = defsInFilter?.[2] || []
228
308
  expect(entityTargets).toEqual(expect.arrayContaining([
229
309
  'customers:customer_entity',
230
310
  'customers:customer_person_profile',
@@ -233,7 +313,7 @@ describe('BasicQueryEngine', () => {
233
313
  })
234
314
 
235
315
  test('customFieldSources aliases support object equality filters', async () => {
236
- const fakeKnex = createFakeKnex({
316
+ const fakeDb = createFakeKysely({
237
317
  customer_entities: [],
238
318
  customer_people: [],
239
319
  'information_schema.columns': [
@@ -242,7 +322,7 @@ describe('BasicQueryEngine', () => {
242
322
  { table_name: 'customer_people', column_name: 'tenant_id' },
243
323
  ],
244
324
  })
245
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
325
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
246
326
  await engine.query('customers:customer_entity', {
247
327
  tenantId: 't1',
248
328
  fields: ['id'],
@@ -259,20 +339,20 @@ describe('BasicQueryEngine', () => {
259
339
  },
260
340
  page: { page: 1, pageSize: 10 },
261
341
  })
262
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
342
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
263
343
  expect(baseCall).toBeTruthy()
264
344
  const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
265
345
  expect(existsFilter).toBeTruthy()
266
346
  const subQuery = existsFilter[1]
267
347
  expect(subQuery?._ops?.table).toBe('customer_people')
268
348
  const hasEqualityFilter = Array.isArray(subQuery?._ops?.wheres)
269
- ? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === 'profile-1')
349
+ ? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === '=' && w[2] === 'profile-1')
270
350
  : false
271
351
  expect(hasEqualityFilter).toBe(true)
272
352
  })
273
353
 
274
- test('customFieldSources equality filters use search tokens when available', async () => {
275
- const fakeKnex = createFakeKnex({
354
+ test('customFieldSources equality filters stay exact when search tokens are available', async () => {
355
+ const fakeDb = createFakeKysely({
276
356
  customer_entities: [],
277
357
  customer_people: [],
278
358
  'information_schema.columns': [
@@ -281,7 +361,7 @@ describe('BasicQueryEngine', () => {
281
361
  { table_name: 'customer_people', column_name: 'tenant_id' },
282
362
  ],
283
363
  })
284
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
364
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
285
365
  jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
286
366
  jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
287
367
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
@@ -303,23 +383,10 @@ describe('BasicQueryEngine', () => {
303
383
  page: { page: 1, pageSize: 10 },
304
384
  })
305
385
 
306
- expect(applySearchTokensSpy).toHaveBeenCalledTimes(1)
307
- // Assert the search-tokens adapter was invoked with the expected join-scoped options
308
- expect(applySearchTokensSpy).toHaveBeenCalledWith(
309
- expect.anything(),
310
- expect.objectContaining({
311
- entity: 'customers:customer_person_profile',
312
- field: 'id',
313
- recordIdColumn: 'person_profile.id',
314
- tenantId: 't1',
315
- tokens: expect.arrayContaining(['profile']),
316
- hashes: expect.any(Array),
317
- }),
318
- )
319
- const callArgs = applySearchTokensSpy.mock.calls[0][1]
320
- expect(callArgs.tokens.length).toBeGreaterThan(0)
321
- expect(callArgs.hashes.length).toBe(callArgs.tokens.length)
322
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
386
+ // When search tokens are available, equality filters on joined fields should stay exact
387
+ // (not use tokenized matching) and route through EXISTS subquery
388
+ expect(applySearchTokensSpy).not.toHaveBeenCalled()
389
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
323
390
  expect(baseCall).toBeTruthy()
324
391
  // The join subquery that the parent whereExists wraps MUST still target customer_people
325
392
  const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
@@ -332,7 +399,7 @@ describe('BasicQueryEngine', () => {
332
399
  // absent (searchEnabled=false), $eq must route through the exact EXISTS
333
400
  // subquery path, producing the pre-change `person_profile.id = 'profile-1'`
334
401
  // filter — not the tokenized OR across search-tokens columns.
335
- const fakeKnex = createFakeKnex({
402
+ const fakeDb = createFakeKysely({
336
403
  customer_entities: [],
337
404
  customer_people: [],
338
405
  'information_schema.columns': [
@@ -341,7 +408,7 @@ describe('BasicQueryEngine', () => {
341
408
  { table_name: 'customer_people', column_name: 'tenant_id' },
342
409
  ],
343
410
  })
344
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
411
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
345
412
  jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
346
413
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
347
414
 
@@ -363,23 +430,23 @@ describe('BasicQueryEngine', () => {
363
430
  })
364
431
 
365
432
  expect(applySearchTokensSpy).not.toHaveBeenCalled()
366
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
433
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
367
434
  expect(baseCall).toBeTruthy()
368
435
  const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
369
436
  expect(existsFilter).toBeTruthy()
370
437
  const subQuery = existsFilter[1]
371
438
  expect(subQuery?._ops?.table).toBe('customer_people')
372
439
  const hasEqualityFilter = Array.isArray(subQuery?._ops?.wheres)
373
- ? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === 'profile-1')
440
+ ? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'person_profile.id' && w[1] === '=' && w[2] === 'profile-1')
374
441
  : false
375
442
  expect(hasEqualityFilter).toBe(true)
376
443
  })
377
444
 
378
445
  test('uses search tokens for index document fields on base entities', async () => {
379
- const fakeKnex = createFakeKnex({
446
+ const fakeDb = createFakeKysely({
380
447
  todos: [],
381
448
  })
382
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
449
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
383
450
  const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
384
451
  const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
385
452
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
@@ -411,7 +478,7 @@ describe('BasicQueryEngine', () => {
411
478
  })
412
479
 
413
480
  test('join filters use whereExists with configured alias', async () => {
414
- const fakeKnex = createFakeKnex({
481
+ const fakeDb = createFakeKysely({
415
482
  customer_entities: [],
416
483
  customer_tag_assignments: [],
417
484
  'information_schema.columns': [
@@ -420,7 +487,7 @@ describe('BasicQueryEngine', () => {
420
487
  { table_name: 'customer_entities', column_name: 'tenant_id' },
421
488
  ],
422
489
  })
423
- const engine = new BasicQueryEngine({} as any, () => fakeKnex as any)
490
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
424
491
  await engine.query('customers:customer_entity', {
425
492
  tenantId: 't1',
426
493
  fields: ['id'],
@@ -438,14 +505,14 @@ describe('BasicQueryEngine', () => {
438
505
  },
439
506
  page: { page: 1, pageSize: 10 },
440
507
  })
441
- const baseCall = fakeKnex._calls.find((b: any) => b._ops.table === 'customer_entities')
508
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'customer_entities')
442
509
  expect(baseCall).toBeTruthy()
443
510
  const existsFilter = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'exists')
444
511
  expect(existsFilter).toBeTruthy()
445
512
  const subQuery = existsFilter[1]
446
513
  expect(subQuery?._ops?.table).toBe('customer_tag_assignments')
447
514
  const hasInFilter = Array.isArray(subQuery?._ops?.wheres)
448
- ? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'in' && w[1] === 'tag_assignments.tag_id')
515
+ ? subQuery._ops.wheres.some((w: any) => Array.isArray(w) && w[0] === 'tag_assignments.tag_id' && w[1] === 'in')
449
516
  : false
450
517
  expect(hasInFilter).toBe(true)
451
518
  })