2121
2222import { describe , it , expect } from 'vitest' ;
2323import { StandardErrorCode } from '../api/errors.zod' ;
24- import { parseFilterAST , RangeOperatorSchema , VALID_AST_OPERATORS } from './filter.zod' ;
24+ import {
25+ FieldOperatorsSchema ,
26+ parseFilterAST ,
27+ RangeOperatorSchema ,
28+ VALID_AST_OPERATORS ,
29+ } from './filter.zod' ;
2530import { assertListComparandShapes } from './filter-comparand-shape' ;
2631
2732type Refusal = Error & { code ?: string ; status ?: number } ;
@@ -39,10 +44,17 @@ const refusalOf = (run: () => unknown): Refusal => {
3944 * Which `$` operator an authoring spelling lowers to, derived by LOWERING one
4045 * rather than by reading a table this file would then be a second copy of.
4146 * A two-element array is legal for all three list operators, so the probe never
42- * trips the door it is used to find.
47+ * trips the door it is used to find. The EQUALITY spellings do refuse it, since
48+ * the 2026-09-23 arm (#19757) — and they answer `undefined` either way: before
49+ * that arm they lowered it to the implicit form, which carries no `$` key.
4350 */
4451const loweredOperatorOf = ( op : string ) : string | undefined => {
45- const lowered = parseFilterAST ( [ [ 'probe' , op , [ 'a' , 'b' ] ] ] ) as Record < string , unknown > | undefined ;
52+ let lowered : Record < string , unknown > | undefined ;
53+ try {
54+ lowered = parseFilterAST ( [ [ 'probe' , op , [ 'a' , 'b' ] ] ] ) as Record < string , unknown > | undefined ;
55+ } catch {
56+ return undefined ;
57+ }
4658 const spec = lowered ?. probe ;
4759 if ( spec === null || typeof spec !== 'object' || Array . isArray ( spec ) ) return undefined ;
4860 return Object . keys ( spec ) . find ( ( key ) => key . startsWith ( '$' ) ) ;
@@ -462,6 +474,159 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
462474 . toMatch ( / ^ F i l t e r c o m p a r a n d a t w h e r e \. n \. \$ g t i s u n d e f i n e d / ) ;
463475 } ) ;
464476
477+ // ── the EQUALITY-slot arm, ruled 2026-09-23 (#19757) ───────────────────
478+ //
479+ // Ruling 5793368540, letter 乙: an array in the implicit-equality slot is
480+ // refused at this face, for every driver at once; ⛔ no alias, ⛔ no grace
481+ // window. Before it, `{ tags: ['a'] }` passed this door and was refused by
482+ // the SQL family and `driver-memory`, excluded every row on
483+ // `@objectstack/formula`, and was ANSWERED by `driver-mongodb` as MongoDB's
484+ // array equality — the pins below were each measured RED on the face as it
485+ // stood (see the PR's firing control).
486+
487+ it . each ( [
488+ [ 'the lowered array form, "equals"' , [ [ 'tags' , 'equals' , [ 'a' ] ] ] , 'where.tags' ] ,
489+ [ 'the lowered array form, "="' , [ [ 'tags' , '=' , [ 'a' , 'b' ] ] ] , 'where.tags' ] ,
490+ [ 'the object passthrough — implicit' , { tags : [ 'a' ] } , 'where.tags' ] ,
491+ [ 'an EMPTY array — still an array in this slot' , { tags : [ ] } , 'where.tags' ] ,
492+ [ 'the object passthrough — explicit $eq' , { tags : { $eq : [ 'a' ] } } , 'where.tags.$eq' ] ,
493+ [ 'explicit $eq with an EMPTY array' , { tags : { $eq : [ ] } } , 'where.tags.$eq' ] ,
494+ [ 'nested under $and (lowered)' , [ 'and' , [ 'amount' , '>' , 5 ] , [ 'tags' , 'eq' , [ 'a' ] ] ] , 'where.$and[1].tags' ] ,
495+ [ 'nested under $or' , { $or : [ { stage : 'won' } , { tags : [ 'a' ] } ] } , 'where.$or[1].tags' ] ,
496+ [ 'nested under $not' , { $not : { tags : { $eq : [ 'a' ] } } } , 'where.$not.tags.$eq' ] ,
497+ ] ) ( 'refuses an ARRAY in the equality slot — %s' , ( _label , where , path ) => {
498+ const err = refusalOf ( ( ) => parseFilterAST ( where ) ) ;
499+ // ADR-0112 class 1, both halves: on the face as it stood, every one of
500+ // these RETURNED — there was no error at all to carry either field.
501+ expect ( err . code ) . toBe ( StandardErrorCode . enum . INVALID_FILTER ) ;
502+ expect ( err . status ) . toBe ( 400 ) ;
503+ expect ( err . message ) . toContain ( `at ${ path } .` ) ;
504+ } ) ;
505+
506+ it ( 'the implicit-equality refusal names the slot and prescribes $in and $contains by their spec spellings' , ( ) => {
507+ const err = refusalOf ( ( ) => parseFilterAST ( [ [ 'tags' , 'equals' , [ 'a' , 'b' ] ] ] ) ) ;
508+ // The leading sentence is driver-memory's own for this condition, kept
509+ // verbatim (one condition, one wording across packages).
510+ expect ( err . message ) . toMatch (
511+ / ^ T h e i m p l i c i t - e q u a l i t y c o m p a r a n d o n f i e l d " t a g s " r e q u i r e s a s i n g l e c o m p a r a b l e v a l u e , b u t r e c e i v e d a n a r r a y \( \[ " a " , " b " \] \) a t w h e r e \. t a g s \. / ,
512+ ) ;
513+ // The ruling's prescription: the declared list operator and the
514+ // array-containment operator, spec spelling AND authoring spelling.
515+ expect ( err . message ) . toContain ( '"one of these values" use {"$in": […]} (authoring: in)' ) ;
516+ expect ( err . message ) . toContain (
517+ '"the stored list holds a value" on a multi-value field, {"$contains": "…"} (authoring: contains)' ,
518+ ) ;
519+ expect ( err . message ) . toContain ( 'an $or of those for any-of' ) ;
520+ expect ( err . message ) . toMatch ( / T h e f i l t e r w a s N O T a p p l i e d , .* U N F I L T E R E D r e s u l t s e t \. $ / ) ;
521+ } ) ;
522+
523+ it ( 'the $eq refusal names the operator it was written with, and the same two remedies' , ( ) => {
524+ const err = refusalOf ( ( ) => parseFilterAST ( { tags : { $eq : [ 'a' ] } } ) ) ;
525+ expect ( err . message ) . toMatch (
526+ / ^ O p e r a t o r " \$ e q " o n f i e l d " t a g s " r e q u i r e s a s i n g l e c o m p a r a b l e v a l u e , b u t r e c e i v e d a n a r r a y \( \[ " a " \] \) a t w h e r e \. t a g s \. \$ e q \. / ,
527+ ) ;
528+ expect ( err . message ) . toContain ( '{"$in": […]} (authoring: in)' ) ;
529+ expect ( err . message ) . toContain ( '{"$contains": "…"} (authoring: contains)' ) ;
530+ // A caller-supplied context keeps its prefix, as on every sibling arm.
531+ expect ( refusalOf ( ( ) => assertListComparandShapes ( { tags : [ 'a' ] } , "find('deal')" ) ) . message )
532+ . toMatch ( / ^ f i n d \( ' d e a l ' \) : T h e i m p l i c i t - e q u a l i t y c o m p a r a n d o n f i e l d " t a g s " / ) ;
533+ } ) ;
534+
535+ it ( 'every AST spelling that lowers to EQUALITY refuses an array — the vocabulary, read at source' , ( ) => {
536+ // Derived by LOWERING a scalar rather than from a hand list: a spelling is
537+ // an equality spelling when `[f, op, 'x']` lowers to the implicit form.
538+ // (`in` / `between` refuse a scalar probe outright — they are list
539+ // operators, so a throw here is a "no", not a failure.)
540+ const equality = [ ...VALID_AST_OPERATORS ] . filter ( ( op ) => {
541+ try {
542+ const lowered = parseFilterAST ( [ [ 'probe' , op , 'x' ] ] ) as Record < string , unknown > | undefined ;
543+ return lowered ?. probe === 'x' ;
544+ } catch {
545+ return false ;
546+ }
547+ } ) ;
548+ // Guards the loop from passing vacuously.
549+ expect ( equality . sort ( ) ) . toEqual ( [ '=' , '==' , 'eq' , 'equals' ] ) ;
550+ for ( const op of equality ) {
551+ const err = refusalOf ( ( ) => parseFilterAST ( [ [ 'tags' , op , [ 'a' ] ] ] ) ) ;
552+ expect ( err . code , op ) . toBe ( StandardErrorCode . enum . INVALID_FILTER ) ;
553+ expect ( err . status , op ) . toBe ( 400 ) ;
554+ expect ( err . message , op ) . toMatch ( / ^ T h e i m p l i c i t - e q u a l i t y c o m p a r a n d o n f i e l d " t a g s " / ) ;
555+ }
556+ } ) ;
557+
558+ it ( 'the two prescribed operators are DECLARED — the refusal invents no spelling' , ( ) => {
559+ // The message spells `$in` / `in` and `$contains` / `contains` by hand
560+ // (`filter.zod.ts` imports the door, so deriving them would be a cycle).
561+ // This is what keeps them the vocabulary's own: both are keys of the
562+ // enforced operator schema, and each authoring spelling lowers to its `$`
563+ // spelling through the one AST table.
564+ const declared = Object . keys ( FieldOperatorsSchema . shape ) ;
565+ expect ( declared ) . toContain ( '$in' ) ;
566+ expect ( declared ) . toContain ( '$contains' ) ;
567+ expect ( loweredOperatorOf ( 'in' ) ) . toBe ( '$in' ) ;
568+ expect ( loweredOperatorOf ( 'contains' ) ) . toBe ( '$contains' ) ;
569+ // …and the prescribed spellings actually lower and pass this door.
570+ expect ( parseFilterAST ( [ [ 'tags' , 'in' , [ 'a' , 'b' ] ] ] ) ) . toEqual ( { tags : { $in : [ 'a' , 'b' ] } } ) ;
571+ expect ( parseFilterAST ( [ [ 'tags' , 'contains' , 'a' ] ] ) ) . toEqual ( { tags : { $contains : 'a' } } ) ;
572+ expect ( parseFilterAST ( { $or : [ { tags : { $contains : 'a' } } , { tags : { $contains : 'b' } } ] } ) )
573+ . toEqual ( { $or : [ { tags : { $contains : 'a' } } , { tags : { $contains : 'b' } } ] } ) ;
574+ } ) ;
575+
576+ it ( 'LIT CONTROL — every scalar equality comparand keeps passing, null predicate first' , ( ) => {
577+ // `{ f: null }` and `$eq: null` ARE the has-no-value predicate (#5332);
578+ // the arm is array-shaped and nothing wider.
579+ expect ( parseFilterAST ( { tags : null } ) ) . toEqual ( { tags : null } ) ;
580+ expect ( parseFilterAST ( { tags : { $eq : null } } ) ) . toEqual ( { tags : { $eq : null } } ) ;
581+ expect ( parseFilterAST ( [ [ 'tags' , 'equals' , null ] ] ) ) . toEqual ( { tags : null } ) ;
582+ expect ( parseFilterAST ( { tags : 'a' } ) ) . toEqual ( { tags : 'a' } ) ;
583+ expect ( parseFilterAST ( { n : 0 } ) ) . toEqual ( { n : 0 } ) ;
584+ expect ( parseFilterAST ( { on : false } ) ) . toEqual ( { on : false } ) ;
585+ expect ( parseFilterAST ( { tags : { $eq : '' } } ) ) . toEqual ( { tags : { $eq : '' } } ) ;
586+ const day = new Date ( '2026-07-01T00:00:00.000Z' ) ;
587+ expect ( parseFilterAST ( { at : day } ) ) . toEqual ( { at : day } ) ;
588+ expect ( parseFilterAST ( { at : { $eq : day } } ) ) . toEqual ( { at : { $eq : day } } ) ;
589+ // #7597's promotion: a `{ $field }` comparand on an equality spelling is
590+ // lowered to `$eq` and is not an array — untouched.
591+ expect ( parseFilterAST ( [ [ 'amount' , '=' , { $field : 'budget' } ] ] ) )
592+ . toEqual ( { amount : { $eq : { $field : 'budget' } } } ) ;
593+ } ) ;
594+
595+ it ( 'LIT CONTROL — every array-valued operator the vocabulary declares keeps its array' , ( ) => {
596+ // Read off the enforced schema rather than listed: the operators whose
597+ // declared comparand ACCEPTS an array. `$eq` / `$ne` are in that set only
598+ // because both are `z.any()` there — `$eq` is this arm's subject and
599+ // `$ne` is not judged at this door (see the todo below) — so the loop
600+ // covers exactly the list operators, and a fourth array-valued operator
601+ // added to the schema lands in it without an edit here.
602+ const arrayValued = Object . keys ( FieldOperatorsSchema . shape ) . filter ( ( op ) =>
603+ FieldOperatorsSchema . safeParse ( { [ op ] : [ 'a' , 'b' ] } ) . success ) ;
604+ expect ( arrayValued . sort ( ) ) . toEqual ( [ '$between' , '$eq' , '$in' , '$ne' , '$nin' ] ) ;
605+ for ( const op of arrayValued . filter ( ( o ) => o !== '$eq' && o !== '$ne' ) ) {
606+ expect ( parseFilterAST ( { tags : { [ op ] : [ 'a' , 'b' ] } } ) , op ) . toEqual ( { tags : { [ op ] : [ 'a' , 'b' ] } } ) ;
607+ }
608+ // The empty lists stay the declared predicates they are.
609+ expect ( parseFilterAST ( { tags : { $in : [ ] } } ) ) . toEqual ( { tags : { $in : [ ] } } ) ;
610+ expect ( parseFilterAST ( { tags : { $nin : [ ] } } ) ) . toEqual ( { tags : { $nin : [ ] } } ) ;
611+ // `$contains` is declared with a STRING comparand — the array-containment
612+ // operator takes ONE member, which is why the refusal says `"…"`.
613+ expect ( FieldOperatorsSchema . safeParse ( { $contains : [ 'a' ] } ) . success ) . toBe ( false ) ;
614+ } ) ;
615+
616+ it ( 'an array INSIDE a no-$-key field spec is still not descended into' , ( ) => {
617+ // The nested-relation / deep-equality boundary this door has always kept:
618+ // the arm judges the field's own slot, never the inside of a nested
619+ // condition it does not walk.
620+ expect ( parseFilterAST ( { author : { tags : [ 'a' ] } } ) ) . toEqual ( { author : { tags : [ 'a' ] } } ) ;
621+ } ) ;
622+
623+ it . todo (
624+ '$ne carrying an ARRAY — equality\'s negation measured the same split (refused by the SQL family '
625+ + 'and driver-memory, answered by driver-mongodb) but the 2026-09-23 ruling names implicit and '
626+ + 'explicit equality only. Reported for its own ruling; ⛔ not pinned green here, because a green '
627+ + 'pin would read as a ruling nobody made' ,
628+ ) ;
629+
465630 // ── the wording contract (#5346 / #5348), unchanged by the move ────────
466631
467632 it ( 'names the operator, the field, what arrived, where, and the fix' , ( ) => {
@@ -520,6 +685,14 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
520685 { close_date : { $lte : null } } ,
521686 { close_date : { $gt : null } } ,
522687 { close_date : { $lt : null } } ,
688+ // The 2026-09-23 equality-slot arm (#19757): two prescriptions plus the
689+ // received list. The long list is the tallest this arm assembles — its
690+ // preview is cut at the shared 60-char bound — so it is the row that
691+ // proves the "NOT applied" tail survives the wire.
692+ { close_date : [ 'a' ] } ,
693+ { close_date : { $eq : [ 'a' ] } } ,
694+ { close_date : [ 'aaaaaaaaaaaaaaaaaaaa' , 'bbbbbbbbbbbbbbbbbbbbbbb' , 'cccccccccccccccccccc' ] } ,
695+ { $or : [ { close_date : [ ] } ] } ,
523696 ] ) {
524697 const err = refusalOf ( ( ) => parseFilterAST ( where , "find('deal')" ) ) ;
525698 expect ( err . message . length , JSON . stringify ( where ) ) . toBeLessThan ( 500 ) ;
@@ -608,12 +781,12 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922
608781 // comparand, not an operator bag — descending into one would invent a
609782 // contract no backend agrees with.
610783 expect ( parseFilterAST ( { author : { name : 'x' } } ) ) . toEqual ( { author : { name : 'x' } } ) ;
611- // A scalar operator carrying an array is answered per driver, not here.
612- expect ( parseFilterAST ( { tags : { $eq : [ 'a' , 'b' ] } } ) ) . toEqual ( { tags : { $eq : [ 'a' , 'b' ] } } ) ;
613- // An implicit-equality array comparand is passed through for the driver to
614- // answer; what it MEANS is not this door's ruling (see the comparand door's
615- // own list of the cases it deliberately does not rule).
616- expect ( parseFilterAST ( { tags : [ 'a' , 'b' ] } ) ) . toEqual ( { tags : [ 'a' , 'b' ] } ) ;
784+ // ⚠️ Two rows used to sit HERE, pinning `{ tags: { $eq: ['a', 'b'] } }` and
785+ // ` { tags: ['a', 'b'] }` as shapes this door passes through for the driver
786+ // to answer. Both are INVERTED by the 2026-09-23 ruling (#19757) — they
787+ // pinned exactly the open slot that ruling closes — and now live, refused,
788+ // in the equality-slot section below. ⛔ Not re-spelled into some other
789+ // passing shape: what they asserted is no longer true of this door.
617790 // An unknown `$` key at node level belongs to the by-name refusals
618791 // downstream, which carry the specific prescription.
619792 expect ( parseFilterAST ( { $wat : [ { stage : { $in : [ 'won' ] } } ] } ) )
0 commit comments