@@ -5,9 +5,11 @@ import {
55 validateActionParams ,
66 ACTION_PARAM_BUILTIN_KEYS ,
77 ActionSessionSchema ,
8+ type ActionEngineFacade ,
89 type ActionSession ,
910 type ResolvedActionParam ,
1011} from './action-params.zod' ;
12+ import type { FilterCondition } from '../data/filter.zod' ;
1113import { MIGRATIONS_BY_MAJOR } from '../migrations/registry' ;
1214
1315const codes = ( issues : ReturnType < typeof validateActionParams > ) => issues . map ( ( i ) => i . code ) . sort ( ) ;
@@ -392,3 +394,77 @@ describe('#5779 — ActionSession `positions` canonical + `roles` deprecated ali
392394 expect ( entry ! . acceptanceCriteria ) . toMatch ( / c t x \. s e s s i o n \. p o s i t i o n s / ) ;
393395 } ) ;
394396} ) ;
397+
398+ // ---------------------------------------------------------------------------
399+ // #14175 — `ActionEngineFacade.find` takes a FILTER, never an ObjectQL envelope
400+ // ---------------------------------------------------------------------------
401+
402+ type Eq < A , B > = ( < T > ( ) => T extends A ? 1 : 2 ) extends ( < T > ( ) => T extends B ? 1 : 2 ) ? true : false ;
403+ type Assert < T extends true > = T ;
404+
405+ // The declared slot, read off the interface — not a retyped copy of it, so a
406+ // re-widening back to an open record, or a rename of the type behind it, fails
407+ // HERE rather than in the first consumer to notice.
408+ type FindFilter = Parameters < ActionEngineFacade [ 'find' ] > [ 1 ] ;
409+
410+ // The type-level pin (the tsc channel, `tsc -p tsconfig.test.json`). `Eq` is
411+ // the strict mutual-assignability test, so `Record<string, unknown>` — the type
412+ // this slot carried before, and the one it must not drift back to — does not
413+ // satisfy it (measured: the same `Assert` against `Record<string, unknown>` is
414+ // a TS2344). Exported, as the sibling pins are, so `noUnusedLocals` does not
415+ // read a type that exists only to be checked as one that is never used.
416+ export type FindFilterIsFilterCondition = Assert < Eq < FindFilter , FilterCondition > > ;
417+
418+ describe ( '#14175 — ActionEngineFacade.find takes a FILTER (the `where` half), never an ObjectQL envelope' , ( ) => {
419+ it ( 'types the second parameter as the published `FilterCondition` (the tsc channel)' , ( ) => {
420+ // The value-level half of `FindFilterIsFilterCondition` above: a literal
421+ // annotated with the slot type, so the runtime run exercises the same
422+ // declaration the type pin reads.
423+ const filter : FindFilter = { position_code : 'qa_lead' , active : true } ;
424+ expect ( Object . keys ( filter ) ) . toEqual ( [ 'position_code' , 'active' ] ) ;
425+ } ) ;
426+
427+ it ( 'positive control — every shape a handler legitimately passes compiles, the empty filter included' , ( ) => {
428+ const implicitEquality : FindFilter = { status : 'completed' } ;
429+ const explicitOperator : FindFilter = { position_code : { $in : [ 'qa_lead' , 'qa_manager' ] } , active : true } ;
430+ const logical : FindFilter = {
431+ $and : [ { active : true } , { $or : [ { tier : 'a' } , { tier : 'b' } ] } ] ,
432+ $not : { archived : true } ,
433+ } ;
434+ // The runtime passes THIS one through unwrapped — the unfiltered read, and
435+ // the one call that kept working in the reporting app under either belief.
436+ const unfiltered : FindFilter = { } ;
437+
438+ expect ( [ implicitEquality , explicitOperator , logical , unfiltered ] . every ( ( f ) => typeof f === 'object' ) ) . toBe ( true ) ;
439+ } ) ;
440+
441+ it ( 'refuses at compile time what `FilterCondition` refuses — a primitive and a mistyped logical operator' , ( ) => {
442+ // Each `@ts-expect-error` is itself checked: if the type ever ADMITS one of
443+ // these, the directive goes unused and `tsc -p tsconfig.test.json` reds.
444+ // @ts -expect-error — a filter is an object; a bare string is not a `where` half.
445+ const primitive : FindFilter = 'position_code = qa_lead' ;
446+ // @ts -expect-error — `$and` is `FilterCondition[]`; a string is refused.
447+ const andNotArray : FindFilter = { $and : 'active' } ;
448+ // @ts -expect-error — `$or` is `FilterCondition[]`; a bare object is refused.
449+ const orNotArray : FindFilter = { $or : { active : true } } ;
450+ // @ts -expect-error — `$not` is a `FilterCondition`; a string is refused.
451+ const notNotFilter : FindFilter = { $not : 'archived' } ;
452+
453+ expect ( [ primitive , andNotArray , orNotArray , notNotFilter ] ) . toHaveLength ( 4 ) ;
454+ } ) ;
455+
456+ it ( 'MEASURED GAP — the exact envelope mistake still compiles; the doc comment, not the type, is the contract' , ( ) => {
457+ // `FilterCondition`'s string index signature is what lets a field NAME be a
458+ // key, and `where` is a string — so the shape that returned `[]` in silence
459+ // in the reporting app (`{ where: { position_code: 'qa_lead' } }`) is
460+ // admitted by the type, one level down too. This pin RECORDS that
461+ // measurement rather than hiding it: a later narrowing that refuses `where`
462+ // at the top level turns it red on purpose, so the member's "does NOT
463+ // refuse `{ where: … }`" sentence is updated with the type instead of
464+ // drifting from it.
465+ const envelope : FindFilter = { where : { position_code : 'qa_lead' } } ;
466+ const nested : FindFilter = { where : { where : { position_code : 'qa_lead' } } } ;
467+
468+ expect ( 'where' in envelope && 'where' in nested ) . toBe ( true ) ;
469+ } ) ;
470+ } ) ;
0 commit comments