3030 * vocabulary; it does not get to drop what falls outside it.
3131 */
3232
33- import { FILTER_OPERATORS , LOGICAL_OPERATORS } from '@objectstack/spec/data' ;
33+ import { FILTER_OPERATORS , LOGICAL_OPERATORS , RETIRED_FILTER_OPERATORS } from '@objectstack/spec/data' ;
3434import { StandardErrorCode } from '@objectstack/spec/api' ;
3535
3636/**
@@ -148,18 +148,27 @@ export function emptyFieldConstraintError(field: string, path: string): Error {
148148 * `convertConditionToMongo`'s alias fold) — a list written out here would agree
149149 * with the spec on the day it was typed and never again.
150150 *
151- * Two additions the spec's list does not carry, both deliberate and both
152- * pre-existing behaviour rather than new capability:
151+ * ## [#5702] The two additions are GONE — nothing is added any more
153152 *
154- * - **`$regex`** — not in `FILTER_OPERATORS`, but really produced: plugin-auth's
155- * ObjectQL adapter emits `{ field: { $regex: value } }` for a `contains`
156- * search. `driver-sql` compiles it (to a substring LIKE), `objectql`'s
157- * `having` allows it, and this driver's matcher implements it. Refusing it
158- * here would break a live producer.
159- * - **`$options`** — the regex-flags companion `memory-matcher` reads
160- * (`new RegExp(target, condition.$options)`) and `objectql`'s `having` skips
161- * for the same reason. It is a modifier of `$regex`, not a predicate of its
162- * own.
153+ * This set used to be `[...FILTER_OPERATORS, '$regex', '$options']`. Both extra
154+ * members existed for one reason, recorded here verbatim at the time: *"Refusing
155+ * it here would break a live producer"* — plugin-auth's ObjectQL adapter emitted
156+ * `{ field: { $regex: value } }` for better-auth's `contains` search, on the
157+ * AUTHENTICATION path.
158+ *
159+ * That producer was flipped to `$contains` by #5710 (PR #5812), and a whole-repo
160+ * scan on `origin/main` found no other: every surviving `$regex` occurrence is a
161+ * consumer arm, a retirement prescription, or a refusal assertion. The reason the
162+ * two members existed is therefore gone, and #4706 retired both spellings — so
163+ * they are refused here like any other undeclared operator, with the spec's
164+ * prescription attached (see {@link retiredFilterOperatorError}).
165+ *
166+ * Note what this does NOT do: it does not add `$icontains`. That name is
167+ * declared by `StringOperatorSchema` but deliberately absent from
168+ * `FILTER_OPERATORS` (#5701), and this set is derived, so this driver refuses it
169+ * — fail-closed, an unimplemented capability rather than a silent widening. The
170+ * `$icontains` implementation for the JS faces is #5499-frozen; see the
171+ * `driver-memory` row of `scripts/check-driver-conformance.mjs`.
163172 *
164173 * Everything else is refused. That includes the mingo operators this driver used
165174 * to hand through by accident (`$elemMatch`, `$size`, `$type`, `$mod`, `$where`,
@@ -168,8 +177,6 @@ export function emptyFieldConstraintError(field: string, path: string): Error {
168177 */
169178export const SUPPORTED_FIELD_OPERATORS : ReadonlySet < string > = new Set < string > ( [
170179 ...FILTER_OPERATORS ,
171- '$regex' ,
172- '$options' ,
173180] ) ;
174181
175182/** The vocabulary as it appears in a refusal message, in declaration order. */
@@ -418,24 +425,53 @@ export function nonBooleanNullComparandError(field: string, value: unknown, path
418425}
419426
420427/**
421- * [#5324] `$options` without the `$regex` it modifies.
422- *
423- * `$options` is in {@link SUPPORTED_FIELD_OPERATORS} as a MODIFIER, not a
424- * predicate — it carries the regex flags (`memory-matcher` reads it as
425- * `new RegExp(target, condition.$options)`, and objectql's `having` skips it for
426- * the same reason). On its own it is not a filter at all, and the two faces
427- * proved it: mingo raised `unknown query operator $options` — uncoded, the very
428- * escape #5324 is about — while the matcher ignored it and matched EVERY row.
429- * Allowlisting the key without requiring its partner would have left exactly one
430- * operator still leaking out of the envelope.
428+ * [#5702] A RETIRED filter operator in a field constraint.
429+ *
430+ * Distinct from {@link unknownFieldOperatorError} on purpose, and the
431+ * distinction is the author's: `$sounds_like` is a name that never meant
432+ * anything, while `$regex` and `$options` are names this driver ANSWERED — with
433+ * a real `RegExp`, the only regex evaluator in the repo — until #4706 retired
434+ * them. Handing that author the fifteen-name vocabulary list is true and
435+ * useless; what they need is `$icontains`.
436+ *
437+ * The prescription is `RETIRED_FILTER_OPERATORS[op].why`, printed VERBATIM. The
438+ * spec table exists precisely so `driver-sql`, this driver, `driver-turso`'s
439+ * remote transport, `driver-mongodb` and `objectql`'s `having` stop each writing
440+ * their own sentence about one retirement (#5701).
441+ *
442+ * This subsumes the `$options`-with-no-`$regex` refusal #5324 added
443+ * (`danglingRegexOptionsError`, deleted with this change): while `$options` was
444+ * an allowlisted MODIFIER, a dangling one needed its own gate; now that both
445+ * spellings are refused outright there is no shape left for that gate to catch,
446+ * and the message it printed — which taught the reader to write
447+ * `{ "$regex": "abc", "$options": "i" }` — would be prescribing the retired form.
448+ *
449+ * `siblings` are the other keys of the SAME field constraint, and every retired
450+ * one among them is named too — `{ $regex: '^acme', $options: 'i' }` is ONE
451+ * mistake with ONE fix, and a message naming only the key iteration reached
452+ * first would send its author back for a second round-trip on the other.
453+ *
454+ * Returns `null` when `op` is not retired, so the caller falls through to the
455+ * ordinary unknown-operator refusal in one expression.
431456 */
432- export function danglingRegexOptionsError ( field : string , path : string ) : Error {
457+ export function retiredFilterOperatorError (
458+ op : string ,
459+ field : string ,
460+ path : string ,
461+ siblings : readonly string [ ] = [ ] ,
462+ ) : Error | null {
463+ const guidance = RETIRED_FILTER_OPERATORS [ op ] ;
464+ if ( ! guidance ) return null ;
465+ const replacement = guidance . to ? ` Write "${ guidance . to } " instead.` : '' ;
466+ const alsoRetired = siblings . filter ( ( key ) => key !== op && RETIRED_FILTER_OPERATORS [ key ] ) ;
467+ const also = alsoRetired . length
468+ ? ` The same field constraint also carries the retired ` +
469+ `${ alsoRetired . map ( ( key ) => `"${ key } "` ) . join ( ', ' ) } — one "${ guidance . to } " replaces the whole ` +
470+ `shape, so this is ONE mistake with ONE fix, not one per key.`
471+ : '' ;
433472 return unsupportedFilterError (
434- `Operator "$options" on field "${ field } " at ${ path } has no "$regex" to modify. "$options" ` +
435- `carries the flags of a regex predicate (e.g. { "${ field } ": { "$regex": "abc", "$options": "i" } }); ` +
436- `it is not a predicate on its own. It is refused rather than ignored because the two ` +
437- `evaluation paths answered it differently — one raised an uncoded engine error, the other ` +
438- `matched every row (#5324).` ,
473+ `Filter operator "${ op } " on field "${ field } " at ${ path } is RETIRED and is no longer evaluated ` +
474+ `by this driver.${ replacement } ${ guidance . why } ${ also } ` ,
439475 ) ;
440476}
441477
@@ -564,7 +600,13 @@ function assertFieldConstraintShape(
564600 const keys = Object . keys ( spec ) ;
565601 if ( ! keys . some ( ( key ) => key . startsWith ( '$' ) ) ) return ;
566602 for ( const op of keys ) {
567- if ( ! SUPPORTED_FIELD_OPERATORS . has ( op ) ) throw unknownFieldOperatorError ( op , field , path ) ;
603+ if ( ! SUPPORTED_FIELD_OPERATORS . has ( op ) ) {
604+ // [#5702] A RETIRED spelling gets the prescription; anything else gets
605+ // the vocabulary. Checked in this order because `$regex` satisfies both
606+ // descriptions ("not supported" and "retired") and only the second one
607+ // tells its author what to write.
608+ throw retiredFilterOperatorError ( op , field , path , keys ) ?? unknownFieldOperatorError ( op , field , path ) ;
609+ }
568610 // [#5345] Declared, but not by THIS face. Checked before the comparand-shape
569611 // rules below so a `$between` a face cannot compile is reported as
570612 // unsupported-here rather than as a malformed range the face would refuse
@@ -583,9 +625,12 @@ function assertFieldConstraintShape(
583625 throw nonBooleanNullComparandError ( field , spec [ op ] , `${ path } .$null` ) ;
584626 }
585627 }
586- // `$options` is the one entry in the vocabulary that is a modifier rather than
587- // a predicate, so it is the one that needs a companion.
588- if ( keys . includes ( '$options' ) && ! keys . includes ( '$regex' ) ) throw danglingRegexOptionsError ( field , path ) ;
628+ // [#5702] The `$options`-without-`$regex` companion check that stood here is
629+ // GONE. It was needed while `$options` was an allowlisted MODIFIER — a key the
630+ // vocabulary accepted but which is not a predicate on its own. Both spellings
631+ // are retired now, so the loop above refuses either of them on sight and there
632+ // is no surviving shape for a companion rule to judge. See
633+ // {@link retiredFilterOperatorError }.
589634}
590635
591636/**
0 commit comments