@open-mercato/shared 0.6.8-develop.6924.1.a8d208fcdc → 0.6.8-develop.6930.1.1e5976efc3

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.
@@ -124,6 +124,149 @@ describe('CommandBus', () => {
124
124
  expect(execute).toHaveBeenCalledTimes(1)
125
125
  })
126
126
 
127
+ it('merges command interceptor beforeExecute returned metadata.logContext into logged context with correct precedence', async () => {
128
+ const logMock = jest.fn(async () => ({ id: 'log-entry' }))
129
+ registerCommand({
130
+ id: 'test.command.interceptor-context',
131
+ execute: jest.fn(async () => ({ ok: true })),
132
+ buildLog: jest.fn(() => ({
133
+ actionLabel: 'Test',
134
+ resourceKind: 'test',
135
+ resourceId: '123',
136
+ context: {
137
+ original: 'buildlog-original-value',
138
+ interceptorOverridden: 'buildlog-takes-precedence',
139
+ },
140
+ })),
141
+ })
142
+
143
+ registerCommandInterceptors([
144
+ {
145
+ moduleId: 'test-module',
146
+ interceptors: [
147
+ {
148
+ id: 'test-interceptor-priority-2',
149
+ targetCommand: 'test.command.*',
150
+ priority: 60, // runs second
151
+ beforeExecute: async () => ({
152
+ ok: true,
153
+ metadata: {
154
+ logContext: {
155
+ ip: '127.0.0.1',
156
+ requestId: 'req-second',
157
+ interceptorOverlap: 'second-wins',
158
+ },
159
+ },
160
+ }),
161
+ },
162
+ {
163
+ id: 'test-interceptor-priority-1',
164
+ targetCommand: 'test.command.*',
165
+ priority: 40, // runs first
166
+ beforeExecute: async () => ({
167
+ ok: true,
168
+ metadata: {
169
+ logContext: {
170
+ requestId: 'req-first',
171
+ interceptorOverlap: 'first-loss',
172
+ baseOverridden: 'interceptor-wins-over-base',
173
+ interceptorOverridden: 'interceptor-loss-to-buildlog',
174
+ },
175
+ },
176
+ }),
177
+ },
178
+ ],
179
+ },
180
+ ])
181
+
182
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
183
+ container.register({ actionLogService: asValue({ log: logMock }) })
184
+
185
+ const bus = new CommandBus()
186
+ const ctx = {
187
+ container,
188
+ auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
189
+ organizationScope: null,
190
+ selectedOrganizationId: null,
191
+ organizationIds: null,
192
+ }
193
+
194
+ await bus.execute('test.command.interceptor-context', {
195
+ input: {},
196
+ ctx,
197
+ metadata: {
198
+ context: {
199
+ original: 'base-original-value', // will be overridden by buildLog
200
+ baseOverridden: 'base-original-to-be-overridden',
201
+ untouched: 'base-untouched',
202
+ },
203
+ },
204
+ })
205
+
206
+ expect(logMock).toHaveBeenCalledWith(
207
+ expect.objectContaining({
208
+ context: {
209
+ untouched: 'base-untouched',
210
+ baseOverridden: 'interceptor-wins-over-base',
211
+ requestId: 'req-second',
212
+ interceptorOverlap: 'second-wins',
213
+ ip: '127.0.0.1',
214
+ original: 'buildlog-original-value',
215
+ interceptorOverridden: 'buildlog-takes-precedence',
216
+ },
217
+ })
218
+ )
219
+ })
220
+
221
+ it('does not promote a generic interceptor metadata.context key into the logged context', async () => {
222
+ const logMock = jest.fn(async () => ({ id: 'log-entry' }))
223
+ registerCommand({
224
+ id: 'test.command.private-metadata',
225
+ execute: jest.fn(async () => ({ ok: true })),
226
+ buildLog: jest.fn(() => ({
227
+ actionLabel: 'Test',
228
+ resourceKind: 'test',
229
+ resourceId: '123',
230
+ })),
231
+ })
232
+
233
+ registerCommandInterceptors([
234
+ {
235
+ moduleId: 'test-module',
236
+ interceptors: [
237
+ {
238
+ id: 'test-interceptor-private-metadata',
239
+ targetCommand: 'test.command.*',
240
+ beforeExecute: async () => ({
241
+ ok: true,
242
+ // `context` here is the interceptor's own after-hook state, not audit input.
243
+ metadata: { context: { internalHandle: 'must-stay-private' } },
244
+ }),
245
+ },
246
+ ],
247
+ },
248
+ ])
249
+
250
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
251
+ container.register({ actionLogService: asValue({ log: logMock }) })
252
+
253
+ await new CommandBus().execute('test.command.private-metadata', {
254
+ input: {},
255
+ ctx: {
256
+ container,
257
+ auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
258
+ organizationScope: null,
259
+ selectedOrganizationId: null,
260
+ organizationIds: null,
261
+ },
262
+ metadata: { context: { untouched: 'base-untouched' } },
263
+ })
264
+
265
+ expect(logMock).toHaveBeenCalledWith(
266
+ expect.objectContaining({ context: { untouched: 'base-untouched' } })
267
+ )
268
+ })
269
+
127
270
  describe('interceptor rejections', () => {
128
271
  const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
129
272
  id: 'test.block',
@@ -267,6 +267,30 @@ export class CommandBus {
267
267
  const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
268
268
  const logMeta = await this.buildLog(handler, effectiveOptions, result, snapshotsWithAfter)
269
269
  let mergedMeta = this.mergeMetadata(effectiveOptions.metadata, logMeta)
270
+ // Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
271
+ // than the generic `context` one, so the metadata an interceptor already passes to its
272
+ // own afterExecute hook is never silently promoted into audit storage.
273
+ // Map iteration order is interceptor priority order (see collectMatching), so a
274
+ // later-priority interceptor overrides an earlier one on key collisions.
275
+ let interceptorContextMerged: Record<string, unknown> = {}
276
+ for (const meta of interceptorMetadata.values()) {
277
+ const logContextRecord = asRecord(asRecord(meta)?.logContext)
278
+ if (!logContextRecord) continue
279
+ interceptorContextMerged = {
280
+ ...interceptorContextMerged,
281
+ ...logContextRecord,
282
+ }
283
+ }
284
+ const baseContext = asRecord(effectiveOptions.metadata?.context) ?? {}
285
+ const logMetaContext = asRecord(logMeta?.context) ?? {}
286
+ if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
287
+ mergedMeta = mergedMeta ?? {}
288
+ mergedMeta.context = {
289
+ ...baseContext,
290
+ ...interceptorContextMerged,
291
+ ...logMetaContext,
292
+ }
293
+ }
270
294
  const undoable = this.isUndoable(handler)
271
295
  if (undoable) {
272
296
  mergedMeta = mergedMeta ?? {}
@@ -77,7 +77,15 @@ export interface CommandInterceptorBeforeResult {
77
77
  body?: Record<string, unknown>
78
78
  /** Modified input — shallow-merged into command input if ok:true */
79
79
  modifiedInput?: Record<string, unknown>
80
- /** Metadata passed to the corresponding after hook */
80
+ /**
81
+ * Metadata passed to the corresponding after hook.
82
+ *
83
+ * Reserved key — `logContext`: a record here (e.g. `{ logContext: { ip: '...' } }`) is
84
+ * shallow-merged into the persisted `ActionLog.context_json`. Every other key stays
85
+ * private to the after hook. Contributed keys take precedence over
86
+ * `options.metadata.context` but yield to `buildLog().context` on conflicts; when two
87
+ * interceptors contribute the same key, the higher `priority` (later-running) one wins.
88
+ */
81
89
  metadata?: Record<string, unknown>
82
90
  }
83
91
 
@@ -484,3 +484,97 @@ describe('BasicQueryEngine — multi-field $or grouping', () => {
484
484
  expect(hasNullTenant).toBe(true)
485
485
  })
486
486
  })
487
+
488
+ describe('BasicQueryEngine — custom-field leaves inside an $or group (#5039)', () => {
489
+ const customFieldDb = () => createFakeKysely({
490
+ scheduled_jobs: [],
491
+ custom_field_defs: [
492
+ { key: 'priority', entity_id: 'scheduler:scheduled_job', is_active: true, tenant_id: null, kind: 'text' },
493
+ ],
494
+ custom_field_values: [],
495
+ 'information_schema.columns': [
496
+ { table_name: 'scheduled_jobs', column_name: 'organization_id' },
497
+ { table_name: 'scheduled_jobs', column_name: 'tenant_id' },
498
+ { table_name: 'scheduled_jobs', column_name: 'scope_type' },
499
+ ],
500
+ })
501
+
502
+ test('two cf:* values joined by $or become one OR of two disjuncts, not two ANDed filters', async () => {
503
+ const fakeDb = customFieldDb()
504
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
505
+ await engine.query('scheduler:scheduled_job', {
506
+ tenantId: 't1',
507
+ fields: ['id', 'cf:priority'],
508
+ omitAutomaticTenantOrgScope: true,
509
+ filters: {
510
+ $or: [
511
+ { 'cf:priority': { $eq: 'high' } },
512
+ { 'cf:priority': { $eq: 'low' } },
513
+ ],
514
+ },
515
+ })
516
+
517
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
518
+ expect(baseCall).toBeTruthy()
519
+
520
+ // Before the fix each value was applied as its own `.where()`, so the query asked
521
+ // for priority = 'high' AND priority = 'low' and could never match a row.
522
+ const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
523
+ expect(orEntry).toBeTruthy()
524
+ const orParts: Node[] = orEntry[1]
525
+ expect(orParts.length).toBe(2)
526
+
527
+ const values = orParts.flatMap((part) => flattenCmps(part)).map((cmp) => cmp.value)
528
+ expect(values).toEqual(expect.arrayContaining(['high', 'low']))
529
+
530
+ const standaloneCfFilters = baseCall._ops.wheres.filter(
531
+ (w: any) => Array.isArray(w) && w.length === 3 && (w[2] === 'high' || w[2] === 'low'),
532
+ )
533
+ expect(standaloneCfFilters).toHaveLength(0)
534
+ })
535
+
536
+ test('a base column OR a cf:* value unites both legs in the same disjunction', async () => {
537
+ const fakeDb = customFieldDb()
538
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
539
+ await engine.query('scheduler:scheduled_job', {
540
+ tenantId: 't1',
541
+ fields: ['id', 'cf:priority'],
542
+ omitAutomaticTenantOrgScope: true,
543
+ filters: {
544
+ $or: [
545
+ { scope_type: { $eq: 'system' } },
546
+ { 'cf:priority': { $eq: 'high' } },
547
+ ],
548
+ },
549
+ })
550
+
551
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
552
+ const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
553
+ expect(orEntry).toBeTruthy()
554
+ const orParts: Node[] = orEntry[1]
555
+ expect(orParts.length).toBe(2)
556
+
557
+ const cmps = orParts.flatMap((part) => flattenCmps(part))
558
+ expect(cmps.some((cmp) => String(cmp.column).endsWith('scope_type') && cmp.value === 'system')).toBe(true)
559
+ expect(cmps.some((cmp) => cmp.value === 'high')).toBe(true)
560
+ })
561
+
562
+ test('an ungrouped cf:* filter still applies as its own AND predicate', async () => {
563
+ const fakeDb = customFieldDb()
564
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
565
+ await engine.query('scheduler:scheduled_job', {
566
+ tenantId: 't1',
567
+ fields: ['id', 'cf:priority'],
568
+ omitAutomaticTenantOrgScope: true,
569
+ filters: { 'cf:priority': { $eq: 'high' } },
570
+ })
571
+
572
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
573
+ const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
574
+ expect(orEntry).toBeFalsy()
575
+ const directFilter = baseCall._ops.wheres.some(
576
+ (w: any) => Array.isArray(w) && w.length === 3 && w[1] === '=' && w[2] === 'high',
577
+ )
578
+ expect(directFilter).toBe(true)
579
+ })
580
+ })
@@ -316,6 +316,10 @@ export class BasicQueryEngine implements QueryEngine {
316
316
  }
317
317
  const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
318
318
  const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
319
+ // Custom-field leaves carrying an orGroup belong to an OR disjunct; applying them
320
+ // one `.where()` at a time would AND them onto every disjunct (#5039).
321
+ const regularCfFilters = cfFilters.filter((filter) => !filter.orGroup)
322
+ const orGroupCfFilters = cfFilters.filter((filter) => filter.orGroup)
319
323
  const searchConfig = resolveSearchConfig()
320
324
  const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op))
321
325
  // Callers that opt out of automatic tenant/org scoping own the full
@@ -576,51 +580,41 @@ export class BasicQueryEngine implements QueryEngine {
576
580
  }
577
581
 
578
582
  // OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
579
- if (orGroupFilters.length > 0) {
580
- const groups = new Map<string, typeof orGroupFilters>()
581
- for (const f of orGroupFilters) {
583
+ // Resolution happens here (it needs async column lookups); the WHERE itself is
584
+ // applied further down, once the cf:* value expressions exist — an OR group may
585
+ // contain custom-field leaves whose SQL is only available then (#5039).
586
+ type ResolvedOrClause =
587
+ | { kind: 'column'; qualified: string; op: NormalizedFilter['op']; value: unknown }
588
+ | { kind: 'doc'; field: string; op: NormalizedFilter['op']; value: unknown }
589
+ | { kind: 'cf'; key: string; op: NormalizedFilter['op']; value: unknown }
590
+ const resolvedGroupFilters: ResolvedOrClause[][] = []
591
+ if (orGroupFilters.length > 0 || orGroupCfFilters.length > 0) {
592
+ const groups = new Map<string, NormalizedFilter[]>()
593
+ for (const f of [...orGroupFilters, ...orGroupCfFilters]) {
582
594
  const group = groups.get(f.orGroup!) ?? []
583
- group.push(f)
595
+ group.push(f as NormalizedFilter)
584
596
  groups.set(f.orGroup!, group)
585
597
  }
586
- type ResolvedOrClause =
587
- | { kind: 'column'; qualified: string; op: NormalizedFilter['op']; value: unknown }
588
- | { kind: 'doc'; field: string; op: NormalizedFilter['op']; value: unknown }
589
- const resolvedGroupFilters: ResolvedOrClause[][] = []
590
598
  for (const [, groupFilters] of groups) {
591
599
  const resolved: ResolvedOrClause[] = []
592
600
  for (const filter of groupFilters) {
593
- const column = await this.resolveBaseColumn(table, String(filter.field))
601
+ const field = String(filter.field)
602
+ if (field.startsWith('cf:')) {
603
+ resolved.push({ kind: 'cf', key: field.slice(3), op: filter.op, value: filter.value })
604
+ continue
605
+ }
606
+ const column = await this.resolveBaseColumn(table, field)
594
607
  if (column) {
595
608
  resolved.push({ kind: 'column', qualified: qualify(column), op: filter.op, value: filter.value })
596
609
  } else {
597
610
  // Field is not a base column — for custom-entity records it lives in
598
611
  // entity_indexes.doc. Build an EXISTS sub-filter so `$or` searches
599
612
  // across doc fields resolve instead of being silently dropped (#3229).
600
- resolved.push({ kind: 'doc', field: String(filter.field), op: filter.op, value: filter.value })
613
+ resolved.push({ kind: 'doc', field, op: filter.op, value: filter.value })
601
614
  }
602
615
  }
603
616
  if (resolved.length > 0) resolvedGroupFilters.push(resolved)
604
617
  }
605
- if (resolvedGroupFilters.length > 0) {
606
- q = q.where((eb: any) => eb.or(
607
- resolvedGroupFilters.map((group) => {
608
- const parts = group.map((rf) => rf.kind === 'column'
609
- ? this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
610
- : this.buildIndexDocOpExpression(eb, {
611
- entity: String(entity),
612
- field: rf.field,
613
- op: rf.op,
614
- value: rf.value,
615
- recordIdColumn,
616
- tenantId: opts.tenantId ?? null,
617
- organizationScope: orgScope,
618
- withDeleted: opts.withDeleted === true,
619
- }))
620
- return parts.length === 1 ? parts[0] : eb.and(parts)
621
- })
622
- ))
623
- }
624
618
  }
625
619
 
626
620
  q = await applyJoinFilters({
@@ -869,8 +863,9 @@ export class BasicQueryEngine implements QueryEngine {
869
863
  }
870
864
  }
871
865
 
872
- // Apply cf:* filters (on raw expressions)
873
- for (const f of cfFilters) {
866
+ // Apply cf:* filters (on raw expressions). OR-grouped ones are excluded here and
867
+ // combined with their disjunct's other leaves right below.
868
+ for (const f of regularCfFilters) {
874
869
  if (!f.field.startsWith('cf:')) continue
875
870
  const key = f.field.slice(3)
876
871
  const expr = cfValueExprByKey[key]
@@ -912,6 +907,43 @@ export class BasicQueryEngine implements QueryEngine {
912
907
  q = this.applyColumnOp(q, expr, f.op, f.value)
913
908
  }
914
909
 
910
+ // OR groups are applied here, after the cf:* value expressions exist, so a
911
+ // disjunct mixing base/doc and custom-field leaves is united rather than
912
+ // intersected. A cf leaf whose key resolved no value expression yields no
913
+ // predicate and is dropped; a disjunct left empty by that is dropped too,
914
+ // because an empty AND would read as TRUE and widen the result.
915
+ //
916
+ // Known limitation, shared with the `doc` clause kind above: a leaf inside an OR
917
+ // group compares against the stored value directly and does not route `like` /
918
+ // `ilike` through the search-token index the way the ungrouped path does. On a
919
+ // field covered by an encryption map such a leaf therefore compares against
920
+ // ciphertext and will not match.
921
+ const applicableGroupFilters = resolvedGroupFilters
922
+ .map((group) => group.filter((rf) => rf.kind !== 'cf' || Boolean(cfValueExprByKey[rf.key])))
923
+ .filter((group) => group.length > 0)
924
+ if (applicableGroupFilters.length > 0) {
925
+ q = q.where((eb: any) => {
926
+ const disjuncts = applicableGroupFilters.map((group) => {
927
+ const parts = group.map((rf) => {
928
+ if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
929
+ if (rf.kind === 'cf') return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
930
+ return this.buildIndexDocOpExpression(eb, {
931
+ entity: String(entity),
932
+ field: rf.field,
933
+ op: rf.op,
934
+ value: rf.value,
935
+ recordIdColumn,
936
+ tenantId: opts.tenantId ?? null,
937
+ organizationScope: orgScope,
938
+ withDeleted: opts.withDeleted === true,
939
+ })
940
+ })
941
+ return parts.length === 1 ? parts[0] : eb.and(parts)
942
+ })
943
+ return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts)
944
+ })
945
+ }
946
+
915
947
  // Entity extensions joins (no selection yet; enables future filters/projections)
916
948
  if (opts.includeExtensions) {
917
949
  const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
@@ -1154,7 +1186,7 @@ export class BasicQueryEngine implements QueryEngine {
1154
1186
  }
1155
1187
  }
1156
1188
 
1157
- private buildColumnOpExpression(eb: any, column: string, op: string, value: unknown): any {
1189
+ private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
1158
1190
  switch (op) {
1159
1191
  case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
1160
1192
  case 'ne': return value === null ? eb(column, 'is not', null) : eb(column, '!=', value)