From ffd9e7609e5f1cd47901d3d4211452f5661231fe Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 10:40:25 +0200 Subject: [PATCH 01/13] fix(core)!: make filter merges conjunctive --- .../parameter/filters/collection/module.ts | 56 +++++-------------- .../src/parameter/filters/collection/types.ts | 9 ++- packages/core/src/parameter/merge.ts | 9 ++- .../core/test/unit/parameter/merge.spec.ts | 54 ++++++++++++++---- 4 files changed, 67 insertions(+), 61 deletions(-) diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index d93efdaab..d6ffa9fdb 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -7,7 +7,6 @@ import { FilterCompoundOperator } from '../../../schema'; import type { ConditionOptions, ICondition } from '../condition'; -import { isFilter } from '../record'; import type { IFilters, IFiltersVisitor } from './types'; import { isFilters } from './check'; @@ -84,15 +83,9 @@ export class Filters< } /** - * Per-field replace (left/receiver priority): a conjunct of the other - * side is dropped when the receiver already constrains the same field. - * Only displaceable leaf conditions take part — a sealed condition and - * a nested group are inert: never displaced, never dropped, carried - * through as they are. A root that is not a displaceable AND counts as - * one such group, which makes the operation total. Every conjunct of - * the receiver survives into the result, so the outcome is never wider - * than the receiver; only the other side can lose conditions, which is - * what per-field replace is for. + * Ordered logical conjunction. Each condition from both sides survives + * in argument order. An unsealed root AND contributes its flattened + * child conjuncts, while every other root remains one conjunct. */ merge(other: IFilters) : IFilters { if (this.value.length === 0) { @@ -103,37 +96,17 @@ export class Filters< return this; } - const left = toConjuncts(this); - - const seen = new Set(); - for (const condition of left) { - if (isFilter(condition)) { - seen.add(condition.field); - } - } - - const output : ICondition[] = [...left]; - for (const condition of toConjuncts(other)) { - if ( - isFilter(condition) && - !condition.sealed && - seen.has(condition.field) - ) { - continue; - } - - output.push(condition); - } - - return new Filters(FilterCompoundOperator.AND, output); + return new Filters(FilterCompoundOperator.AND, [ + ...toConjuncts(this), + ...toConjuncts(other), + ]); } /** * Wrap & inject (immutable): the given conditions are sealed and * combined with the receiver under an AND group. Sealed conditions - * survive both normalization ({@link Filters.flatten}) and later - * replace-merges ({@link Filters.merge}), so a scoping condition - * injected here cannot be displaced by anything composed afterwards. + * survive normalization ({@link Filters.flatten}), so a scoping + * condition injected here remains an explicit conjunct. */ and(...conditions: ICondition[]) : IFilters { return this.wrap(FilterCompoundOperator.AND, conditions); @@ -160,8 +133,7 @@ export class Filters< } // a receiver already carrying that operator contributes its - // conditions directly (associativity), which keeps the group flat - // and its own conditions displaceable by a later merge. + // conditions directly (associativity), which keeps the group flat. if ( this.operator === operator && operator !== FilterCompoundOperator.NOT && @@ -188,16 +160,16 @@ function sealCondition(condition: ICondition) : ICondition { } /** - * The conjuncts a {@link Filters.merge} operates on: the children of a - * displaceable root-AND, or the whole node — a group, a sealed tree, any - * other compound operator — as one inert conjunct. + * The conjuncts a {@link Filters.merge} operates on: the flattened + * children of an unsealed root AND, or the whole node for a sealed tree + * or any other compound operator. */ function toConjuncts(input: IFilters) : ICondition[] { if ( input.operator === FilterCompoundOperator.AND && !input.sealed ) { - return input.value; + return input.flatten().value; } return [input]; diff --git a/packages/core/src/parameter/filters/collection/types.ts b/packages/core/src/parameter/filters/collection/types.ts index 24a924a8b..7f6dec1c1 100644 --- a/packages/core/src/parameter/filters/collection/types.ts +++ b/packages/core/src/parameter/filters/collection/types.ts @@ -19,9 +19,8 @@ export interface IFilters< readonly value : T[]; /** - * Displaceability marker: a sealed group is never dropped by a - * later {@link IFilters.merge} and never collapsed into its parent - * by {@link IFilters.flatten}. + * Protection marker: a sealed group is never collapsed into its parent + * by {@link IFilters.flatten}. Filter merges remain conjunctive. */ readonly sealed?: boolean; @@ -36,6 +35,10 @@ export interface IFilters< flatten(items?: T[]) : IFilters; + /** + * Combine both groups as an ordered logical AND. Empty groups are the + * identity; every condition from either non-empty group is retained. + */ merge(other: IFilters) : IFilters; and(...conditions: ICondition[]) : IFilters; diff --git a/packages/core/src/parameter/merge.ts b/packages/core/src/parameter/merge.ts index f2f772adb..d366f6ff8 100644 --- a/packages/core/src/parameter/merge.ts +++ b/packages/core/src/parameter/merge.ts @@ -9,11 +9,10 @@ import { Query } from './module'; import type { IQuery } from './types'; /** - * Merge queries (the IR) with left priority — the first argument wins. - * Fields, relations and sorts are keyed by name (first occurrence sets - * value and position), pagination merges limit/offset independently and - * filters use {@link IFilters.merge} (per-field replace over displaceable - * root conditions, everything else and-ed in unchanged). + * Merge queries (the IR). Fields, relations and sorts have left priority: + * the first occurrence sets value and position. Pagination merges + * limit/offset independently. Filters use {@link IFilters.merge} as an + * ordered logical AND, retaining every condition. * * Immutable — inputs stay untouched, a new {@link Query} is returned. */ diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index 4ffa2963c..b2f103208 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -73,15 +73,29 @@ describe('src/parameter/merge.ts', () => { expect(output.pagination.offset).toBe(20); }); - it('should replace same-field filters with left priority (search over defaults)', () => { - const searchQ = defineQuery({ filters: { name: { $contains: 'Jo' } } }); - const defaultsQ = defineQuery({ filters: { name: 'John', age: { $gte: 18 } } }); - - const output = mergeQueries(searchQ, defaultsQ); + it('should retain same-field range conditions from both queries', () => { + const output = mergeQueries( + defineQuery({ filters: { age: { $gte: 18 } } }), + defineQuery({ filters: { age: { $lt: 65 } } }), + ); expect(conditions(output.filters)).toEqual([ - ['name', 'contains', 'Jo'], ['age', 'gte', 18], + ['age', 'lt', 65], + ]); + }); + + it('should retain contradictory same-field conditions in argument order', () => { + const a = defineQuery({ filters: { name: 'a' } }); + const b = defineQuery({ filters: { name: 'b' } }); + + expect(conditions(mergeQueries(a, b).filters)).toEqual([ + ['name', 'eq', 'a'], + ['name', 'eq', 'b'], + ]); + expect(conditions(mergeQueries(b, a).filters)).toEqual([ + ['name', 'eq', 'b'], + ['name', 'eq', 'a'], ]); }); @@ -175,12 +189,11 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { expect(scoped.value[1].sealed).toBe(true); }); - it('should not allow a later replace-merge to displace an injected condition', () => { + it('should retain a later condition alongside an injected condition', () => { const parsed = defineQuery({ filters: { name: 'John' } }); const scoped = parsed.filters.and(eq('realm.id', 'master')); - // the injected condition is sealed, so a same-field condition - // narrows the result instead of displacing the scope. + // conjunction keeps both same-field conditions. const hostile = new Filters(FilterCompoundOperator.AND, [ eq('realm.id', 'evil'), ]); @@ -191,11 +204,11 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { ['realm.id', 'eq', 'master'], ]); - // the other direction displaces the hostile condition outright: - // the receiver already constrains that field. + // argument order is preserved in the other direction too. expect(conditions(scoped.merge(hostile))).toEqual([ ['name', 'eq', 'John'], ['realm.id', 'eq', 'master'], + ['realm.id', 'eq', 'evil'], ]); }); @@ -287,6 +300,25 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { expect((output.value[2] as IFilter).field).toBe('b'); }); + it('should preserve all leaves when merging nested and flattened root ANDs', () => { + const nested = and( + eq('name', 'John'), + and(gte('age', 18), eq('active', true)), + ); + const other = and(eq('realm.id', 'master')); + + const nestedOutput = nested.merge(other); + const flatOutput = nested.flatten().merge(other); + + expect(conditions(nestedOutput)).toEqual(conditions(flatOutput)); + expect(conditions(nestedOutput)).toEqual([ + ['name', 'eq', 'John'], + ['age', 'gte', 18], + ['active', 'eq', true], + ['realm.id', 'eq', 'master'], + ]); + }); + it('should treat a receiver that is not a root-AND as one conjunct', () => { const left = or(eq('a', 1), eq('b', 2)); const right = new Filters(FilterCompoundOperator.AND, [eq('a', 9)]); From 7cbfffd133351c7d194259b0b75886cbf8c12e31 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 10:46:09 +0200 Subject: [PATCH 02/13] refactor(core)!: brand live filter conditions --- .../parameter/filters/collection/module.ts | 18 ++---------- .../core/src/parameter/filters/condition.ts | 16 ++++++---- .../src/parameter/filters/record/module.ts | 18 ++---------- .../unit/parameter/condition-contract.spec.ts | 29 ++++++++++++------- .../parser/parameter/key-validation.spec.ts | 13 +++++---- 5 files changed, 42 insertions(+), 52 deletions(-) diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index d6ffa9fdb..f80ed6bc5 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -6,32 +6,20 @@ */ import { FilterCompoundOperator } from '../../../schema'; +import { Condition } from '../condition'; import type { ConditionOptions, ICondition } from '../condition'; import type { IFilters, IFiltersVisitor } from './types'; import { isFilters } from './check'; export class Filters< T extends ICondition = ICondition, -> implements IFilters { - readonly value: T[]; - - readonly operator: string; - - readonly sealed?: boolean; - +> extends Condition implements IFilters { constructor( operator: string, conditions: T[], options: ConditionOptions = {}, ) { - this.operator = operator; - this.value = conditions; - - // only set when sealed, so an unsealed group stays - // structurally identical to what earlier versions produced. - if (options.sealed) { - this.sealed = true; - } + super(operator, conditions, options); } accept(visitor: IFiltersVisitor) : R { diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index cca43a70b..fbadbd612 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -7,9 +7,13 @@ import { isObject } from '../../utils'; +export const CONDITION_MARKER: unique symbol = Symbol.for('@rapiq/core/condition') as never; + export interface ICondition< T = unknown, > { + readonly [CONDITION_MARKER]: true; + readonly operator: string; readonly value: T; @@ -26,15 +30,13 @@ export interface ICondition< } /** - * Identify a live structural condition through the behavior shared by every - * implementation. Visitor dispatch is deliberately not part of this check. + * Identify a live condition by its non-serializable marker. Visitor dispatch + * is deliberately not part of this check. */ export function isCondition(input: unknown) : input is ICondition { return ( isObject(input) && - typeof input.operator === 'string' && - 'value' in input && - typeof input.seal === 'function' + (input as { readonly [CONDITION_MARKER]?: unknown })[CONDITION_MARKER] === true ); } @@ -52,6 +54,10 @@ export type ConditionOptions = { export abstract class Condition< T = unknown, > implements ICondition { + get [CONDITION_MARKER]() : true { + return true; + } + readonly operator: string; readonly value: T; diff --git a/packages/core/src/parameter/filters/record/module.ts b/packages/core/src/parameter/filters/record/module.ts index 75d1a9208..1247089be 100644 --- a/packages/core/src/parameter/filters/record/module.ts +++ b/packages/core/src/parameter/filters/record/module.ts @@ -6,36 +6,24 @@ */ import type { FilterFieldOperator } from '../../../schema'; +import { Condition } from '../condition'; import type { ConditionOptions } from '../condition'; import type { IFilter, IFilterVisitor } from './types'; export class Filter< OPERATOR extends string = `${FilterFieldOperator}`, VALUE = unknown, -> implements IFilter { - readonly operator: string; - - readonly value: VALUE; - +> extends Condition implements IFilter { readonly field: string; - readonly sealed?: boolean; - constructor( operator: string, field: string, value: VALUE, options: ConditionOptions = {}, ) { - this.operator = operator; + super(operator, value, options); this.field = field; - this.value = value; - - // only set when sealed, so an unsealed condition stays - // structurally identical to what earlier versions produced. - if (options.sealed) { - this.sealed = true; - } } accept(visitor: IFilterVisitor) : R { diff --git a/packages/core/test/unit/parameter/condition-contract.spec.ts b/packages/core/test/unit/parameter/condition-contract.spec.ts index ca583ed76..0bb5dabfb 100644 --- a/packages/core/test/unit/parameter/condition-contract.spec.ts +++ b/packages/core/test/unit/parameter/condition-contract.spec.ts @@ -12,11 +12,13 @@ import type { IFilters, } from '../../../src'; import { + Condition, FilterCompoundOperator, Filters, and, elemMatch, eq, + isCondition, isFilter, isFilters, not, @@ -39,25 +41,17 @@ interface IUnsealedCustomCondition extends ICondition { seal(): ISealedCustomCondition; } -class CustomCondition implements ICondition { - readonly operator: string; - +class CustomCondition extends Condition { readonly field: string; - readonly sealed?: boolean; - constructor( - readonly value: CustomValue, + value: CustomValue, sealed?: boolean, operator = 'custom', field = 'not-a-built-in-leaf', ) { - this.operator = operator; + super(operator, value, { sealed }); this.field = field; - - if (sealed) { - this.sealed = true; - } } seal(): ICondition { @@ -70,6 +64,19 @@ class CustomCondition implements ICondition { } describe('src/parameter/filters condition contract', () => { + it('should identify conditions through a non-serializable brand', () => { + const condition = new CustomCondition({ scope: 'tenant-a' }); + const detached = JSON.parse(JSON.stringify(condition)); + + expect(isCondition(condition)).toBe(true); + expect(isCondition(detached)).toBe(false); + expect(isCondition({ + operator: 'custom', + value: {}, + seal() { return this; }, + })).toBe(false); + }); + it('should retain specialized visitor dispatch for built-in conditions', () => { const leaf = eq('name', 'Peter'); const group = and(eq('active', true)); diff --git a/packages/core/test/unit/parser/parameter/key-validation.spec.ts b/packages/core/test/unit/parser/parameter/key-validation.spec.ts index a92dcb49d..d2ffffd07 100644 --- a/packages/core/test/unit/parser/parameter/key-validation.spec.ts +++ b/packages/core/test/unit/parser/parameter/key-validation.spec.ts @@ -6,6 +6,7 @@ */ import { + Condition, ErrorCode, FieldsParseError, Parameter, @@ -51,13 +52,13 @@ function fieldsOptions( }; } -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - +class CustomCondition extends Condition<{ scope: string }> { constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} + value: { scope: string }, + sealed?: boolean, + ) { + super('custom', value, { sealed }); + } seal() : ICondition<{ scope: string }> { return this.sealed ? this : new CustomCondition(this.value, true); From 4e178d3f82f823ae4b8e4e014cbb93f6508d36b4 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 10:52:38 +0200 Subject: [PATCH 03/13] fix(core): validate branded condition shape --- .../codec-url/test/unit/simple-roundtrip.spec.ts | 15 +++++++++------ packages/core/src/parameter/filters/condition.ts | 4 +++- packages/core/test/unit/build/module.spec.ts | 13 +++++++------ .../unit/parameter/condition-contract.spec.ts | 7 +++++++ .../test/unit/parameter/filters-non-node.spec.ts | 13 +++++++------ 5 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/codec-url/test/unit/simple-roundtrip.spec.ts b/packages/codec-url/test/unit/simple-roundtrip.spec.ts index 5fc960159..05bce9677 100644 --- a/packages/codec-url/test/unit/simple-roundtrip.spec.ts +++ b/packages/codec-url/test/unit/simple-roundtrip.spec.ts @@ -7,6 +7,8 @@ import { AdapterError, + CONDITION_MARKER, + Condition, ErrorCode, Field, Fields, @@ -51,13 +53,13 @@ import type { } from '@rapiq/core'; import { SimpleURLDecoder, SimpleURLEncoder } from '../../src/simple'; -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - +class CustomCondition extends Condition<{ scope: string }> { constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} + value: { scope: string }, + sealed?: boolean, + ) { + super('custom', value, { sealed }); + } seal() : ICondition<{ scope: string }> { return this.sealed ? this : new CustomCondition(this.value, true); @@ -66,6 +68,7 @@ class CustomCondition implements ICondition<{ scope: string }> { function createStructuralFilter(field: string, value: unknown) : IFilter { const output : IFilter = { + [CONDITION_MARKER]: true, operator: 'eq', field, value, diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index fbadbd612..7b5583676 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -36,7 +36,9 @@ export interface ICondition< export function isCondition(input: unknown) : input is ICondition { return ( isObject(input) && - (input as { readonly [CONDITION_MARKER]?: unknown })[CONDITION_MARKER] === true + (input as { readonly [CONDITION_MARKER]?: unknown })[CONDITION_MARKER] === true && + typeof input.operator === 'string' && + 'value' in input ); } diff --git a/packages/core/test/unit/build/module.spec.ts b/packages/core/test/unit/build/module.spec.ts index 505ca088e..b48cd8cb5 100644 --- a/packages/core/test/unit/build/module.spec.ts +++ b/packages/core/test/unit/build/module.spec.ts @@ -16,6 +16,7 @@ import type { } from '../../../src'; import { BuildError, + Condition, ErrorCode, FieldOperator, Filter, @@ -39,13 +40,13 @@ import type { Client, User } from '../../data'; const leafs = (filters: { value: unknown[] }) => filters.value as IFilter[]; -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - +class CustomCondition extends Condition<{ scope: string }> { constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} + value: { scope: string }, + sealed?: boolean, + ) { + super('custom', value, { sealed }); + } seal() : ICondition<{ scope: string }> { return this.sealed ? this : new CustomCondition(this.value, true); diff --git a/packages/core/test/unit/parameter/condition-contract.spec.ts b/packages/core/test/unit/parameter/condition-contract.spec.ts index 0bb5dabfb..eaa89af41 100644 --- a/packages/core/test/unit/parameter/condition-contract.spec.ts +++ b/packages/core/test/unit/parameter/condition-contract.spec.ts @@ -12,6 +12,7 @@ import type { IFilters, } from '../../../src'; import { + CONDITION_MARKER, Condition, FilterCompoundOperator, Filters, @@ -70,6 +71,12 @@ describe('src/parameter/filters condition contract', () => { expect(isCondition(condition)).toBe(true); expect(isCondition(detached)).toBe(false); + expect(Object.hasOwn(condition, CONDITION_MARKER)).toBe(false); + expect(Object.getOwnPropertyDescriptor( + Condition.prototype, + CONDITION_MARKER, + )?.enumerable).toBe(false); + expect(isCondition({ [CONDITION_MARKER]: true })).toBe(false); expect(isCondition({ operator: 'custom', value: {}, diff --git a/packages/core/test/unit/parameter/filters-non-node.spec.ts b/packages/core/test/unit/parameter/filters-non-node.spec.ts index 7133068c8..a93196908 100644 --- a/packages/core/test/unit/parameter/filters-non-node.spec.ts +++ b/packages/core/test/unit/parameter/filters-non-node.spec.ts @@ -9,6 +9,7 @@ import type { ICondition } from '../../../src'; import { AdapterError, BuildError, + Condition, ErrorCode, FilterCompoundOperator, Filters, @@ -31,13 +32,13 @@ const NON_NODE = { value: 'ACME', } as unknown as ICondition; -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - +class CustomCondition extends Condition<{ scope: string }> { constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} + value: { scope: string }, + sealed?: boolean, + ) { + super('custom', value, { sealed }); + } seal() : ICondition<{ scope: string }> { return this.sealed ? this : new CustomCondition(this.value, true); From b031cfd97869f7f0bd0ff25a05628bee26083ffe Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 11:02:18 +0200 Subject: [PATCH 04/13] refactor(core)!: narrow filter pruning protection --- packages/core/src/errors/code.ts | 2 +- packages/core/src/errors/schema.ts | 10 +-- .../parameter/filters/collection/module.ts | 23 +++-- .../src/parameter/filters/collection/types.ts | 6 ++ .../core/src/parameter/filters/condition.ts | 7 ++ packages/core/src/parameter/filters/index.ts | 1 + .../core/src/parameter/filters/preserve.ts | 30 +++++++ .../src/parameter/filters/record/module.ts | 12 ++- .../src/parameter/filters/record/types.ts | 6 ++ .../src/parser/parameter/filters/validate.ts | 33 +++++-- packages/core/src/parser/relation-prune.ts | 62 ++++++++----- .../core/test/unit/parameter/merge.spec.ts | 45 ++++++++++ .../parser/parameter/filters/validate.spec.ts | 37 ++++++-- .../test/unit/parser/relation-prune.spec.ts | 90 +++++++++++-------- .../unit/parser/relations-traversal.spec.ts | 40 ++++----- 15 files changed, 293 insertions(+), 111 deletions(-) create mode 100644 packages/core/src/parameter/filters/preserve.ts diff --git a/packages/core/src/errors/code.ts b/packages/core/src/errors/code.ts index 9a7cfd97f..29a333ddd 100644 --- a/packages/core/src/errors/code.ts +++ b/packages/core/src/errors/code.ts @@ -42,7 +42,7 @@ export enum ErrorCode { SCHEMA_NAME_INVALID = 'schemaNameInvalid', - SCHEMA_SEALED_CONDITION_PRUNED = 'schemaSealedConditionPruned', + SCHEMA_PRESERVED_CONDITION_PRUNED = 'schemaPreservedConditionPruned', SCHEMA_UNRESOLVABLE = 'schemaUnresolvable', diff --git a/packages/core/src/errors/schema.ts b/packages/core/src/errors/schema.ts index e35b61cf7..0a21c2dc2 100644 --- a/packages/core/src/errors/schema.ts +++ b/packages/core/src/errors/schema.ts @@ -40,12 +40,12 @@ export class SchemaError extends BaseError { }); } - static sealedConditionPruned(relation: string, field: string) { + static preservedConditionPruned(relation: string, field: string) { return new this({ - message: `The relations validator rejected "${relation}", but the sealed filter condition on ` + - `"${field}" traverses it. A sealed condition must not be dropped, and a rejected relation ` + - 'must not be joined: align the relations validator with the filters validator that sealed it.', - code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED, + message: `The relations validator rejected "${relation}", but the preserved filter condition on ` + + `"${field}" traverses it. A preserved condition must not be dropped, and a rejected relation ` + + 'must not be joined: align the relations validator with the filters validator that preserved it.', + code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED, }); } diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index f80ed6bc5..763460c51 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -7,19 +7,25 @@ import { FilterCompoundOperator } from '../../../schema'; import { Condition } from '../condition'; -import type { ConditionOptions, ICondition } from '../condition'; +import type { BuiltInConditionOptions, ICondition } from '../condition'; import type { IFilters, IFiltersVisitor } from './types'; import { isFilters } from './check'; export class Filters< T extends ICondition = ICondition, > extends Condition implements IFilters { + readonly preserved?: true; + constructor( operator: string, conditions: T[], - options: ConditionOptions = {}, + options: BuiltInConditionOptions = {}, ) { super(operator, conditions, options); + + if (options.preserved) { + this.preserved = true; + } } accept(visitor: IFiltersVisitor) : R { @@ -31,7 +37,10 @@ export class Filters< return this; } - return new Filters(this.operator, this.value, { sealed: true }); + return new Filters(this.operator, this.value, { + preserved: this.preserved, + sealed: true, + }); } flatten(aggregatedResult?: T[]) : IFilters { @@ -40,7 +49,7 @@ export class Filters< return new Filters( this.operator, this.flattenInternal(this.value, this.operator, aggregatedResult), - { sealed: this.sealed }, + { preserved: this.preserved, sealed: this.sealed }, ); } @@ -59,7 +68,8 @@ export class Filters< if ( isFilters(currentNode, operator) && operator !== FilterCompoundOperator.NOT && - !currentNode.sealed + !currentNode.sealed && + !currentNode.preserved ) { currentNode.flatten(flatConditions); } else { @@ -155,7 +165,8 @@ function sealCondition(condition: ICondition) : ICondition { function toConjuncts(input: IFilters) : ICondition[] { if ( input.operator === FilterCompoundOperator.AND && - !input.sealed + !input.sealed && + !input.preserved ) { return input.flatten().value; } diff --git a/packages/core/src/parameter/filters/collection/types.ts b/packages/core/src/parameter/filters/collection/types.ts index 7f6dec1c1..18ff81e88 100644 --- a/packages/core/src/parameter/filters/collection/types.ts +++ b/packages/core/src/parameter/filters/collection/types.ts @@ -18,6 +18,12 @@ export interface IFilters< readonly value : T[]; + /** + * Relation-pruning protection marker. A preserved group stays atomic + * during normalization and pruning applies its contract to the subtree. + */ + readonly preserved?: true; + /** * Protection marker: a sealed group is never collapsed into its parent * by {@link IFilters.flatten}. Filter merges remain conjunctive. diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index 7b5583676..9c05d1572 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -49,6 +49,13 @@ export type ConditionOptions = { sealed?: boolean, }; +/** + * Construction options shared only by the built-in filter nodes. + */ +export type BuiltInConditionOptions = ConditionOptions & { + preserved?: boolean, +}; + /** * Optional implementation base for conditions; structural implementations * can implement {@link ICondition} without extending this class. diff --git a/packages/core/src/parameter/filters/index.ts b/packages/core/src/parameter/filters/index.ts index 8067a27e6..82bfac88a 100644 --- a/packages/core/src/parameter/filters/index.ts +++ b/packages/core/src/parameter/filters/index.ts @@ -10,6 +10,7 @@ export * from './condition'; export * from './constants'; export * from './helpers'; export * from './plan'; +export * from './preserve'; export * from './record'; export * from './regex'; export * from './seal'; diff --git a/packages/core/src/parameter/filters/preserve.ts b/packages/core/src/parameter/filters/preserve.ts new file mode 100644 index 000000000..81c12fac8 --- /dev/null +++ b/packages/core/src/parameter/filters/preserve.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { FilterCompoundOperator } from '../../schema'; +import type { ICondition } from './condition'; +import { Filters, isFilters } from './collection'; +import type { IFilters } from './collection'; +import { Filter, isFilter } from './record'; +import type { IFilter } from './record'; + +export function preserve(condition: IFilter): IFilter; +export function preserve(condition: IFilters): IFilters; +export function preserve(condition: ICondition): IFilters; +export function preserve(condition: ICondition): IFilter | IFilters { + if (isFilter(condition)) { + return condition.preserved ? condition : + new Filter(condition.operator, condition.field, condition.value, { preserved: true }); + } + + if (isFilters(condition)) { + return condition.preserved ? condition : + new Filters(condition.operator, condition.value, { preserved: true }); + } + + return new Filters(FilterCompoundOperator.AND, [condition], { preserved: true }); +} diff --git a/packages/core/src/parameter/filters/record/module.ts b/packages/core/src/parameter/filters/record/module.ts index 1247089be..1bf85d3a9 100644 --- a/packages/core/src/parameter/filters/record/module.ts +++ b/packages/core/src/parameter/filters/record/module.ts @@ -7,7 +7,7 @@ import type { FilterFieldOperator } from '../../../schema'; import { Condition } from '../condition'; -import type { ConditionOptions } from '../condition'; +import type { BuiltInConditionOptions } from '../condition'; import type { IFilter, IFilterVisitor } from './types'; export class Filter< @@ -16,14 +16,20 @@ export class Filter< > extends Condition implements IFilter { readonly field: string; + readonly preserved?: true; + constructor( operator: string, field: string, value: VALUE, - options: ConditionOptions = {}, + options: BuiltInConditionOptions = {}, ) { super(operator, value, options); this.field = field; + + if (options.preserved) { + this.preserved = true; + } } accept(visitor: IFilterVisitor) : R { @@ -39,7 +45,7 @@ export class Filter< this.operator, this.field, this.value, - { sealed: true }, + { preserved: this.preserved, sealed: true }, ); } } diff --git a/packages/core/src/parameter/filters/record/types.ts b/packages/core/src/parameter/filters/record/types.ts index bf9909d3f..570617b2a 100644 --- a/packages/core/src/parameter/filters/record/types.ts +++ b/packages/core/src/parameter/filters/record/types.ts @@ -29,6 +29,12 @@ export interface IFilter< readonly value: VALUE; + /** + * Relation-pruning protection marker. A preserved leaf must not be + * discarded when a relation validator rejects a path it traverses. + */ + readonly preserved?: true; + /** * Displaceability marker: a sealed leaf is never dropped by a * later `merge` and never hoisted out of its group by `flatten`. diff --git a/packages/core/src/parser/parameter/filters/validate.ts b/packages/core/src/parser/parameter/filters/validate.ts index 4a3fb303b..550879e72 100644 --- a/packages/core/src/parser/parameter/filters/validate.ts +++ b/packages/core/src/parser/parameter/filters/validate.ts @@ -11,6 +11,7 @@ import { isCondition, isFilter, isFilters, + preserve, seal, } from '../../../parameter'; import type { @@ -88,7 +89,10 @@ export function applyFiltersSchemaValidation( } if (interior !== input.value) { - leaf = new Filter(input.operator, input.field, interior, { sealed: input.sealed }); + leaf = new Filter(input.operator, input.field, interior, { + preserved: input.preserved, + sealed: input.sealed, + }); } } @@ -102,9 +106,11 @@ export function applyFiltersSchemaValidation( return undefined; } - // the replacement stands in for the leaf, so it inherits its - // protection: a validator must not silently unseal a condition. - return leaf.sealed ? seal(output) : output; + // The replacement stands in for the leaf, so it inherits both + // temporary sealing and relation-pruning preservation. + let replacement = leaf.preserved ? preserve(output) : output; + replacement = leaf.sealed ? seal(replacement) : replacement; + return replacement; } if (!isFilters(input)) { @@ -123,7 +129,10 @@ export function applyFiltersSchemaValidation( return undefined; } - return new Filters(input.operator, conditions, { sealed: input.sealed }); + return new Filters(input.operator, conditions, { + preserved: input.preserved, + sealed: input.sealed, + }); } /** @@ -162,7 +171,10 @@ export async function applyFiltersSchemaValidationAsync( } if (interior !== input.value) { - leaf = new Filter(input.operator, input.field, interior, { sealed: input.sealed }); + leaf = new Filter(input.operator, input.field, interior, { + preserved: input.preserved, + sealed: input.sealed, + }); } } @@ -171,7 +183,9 @@ export async function applyFiltersSchemaValidationAsync( return undefined; } - return leaf.sealed ? seal(output) : output; + let replacement = leaf.preserved ? preserve(output) : output; + replacement = leaf.sealed ? seal(replacement) : replacement; + return replacement; } if (!isFilters(input)) { @@ -190,5 +204,8 @@ export async function applyFiltersSchemaValidationAsync( return undefined; } - return new Filters(input.operator, conditions, { sealed: input.sealed }); + return new Filters(input.operator, conditions, { + preserved: input.preserved, + sealed: input.sealed, + }); } diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index 36aee90e5..6aeda0231 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -117,15 +117,15 @@ export function pruneRelationsByRelations(relations: IRelations, rejected: strin * running `prefix` reconstructs their absolute path before matching. Falls back * to the schema `default` when pruning empties the parameter. * - * A sealed condition is exempt from the drop, not from the gate: pruning - * anything out of a sealed subtree returns a query the sealed condition does - * not describe (wider under the `and(, )` shape a filters + * A preserved condition is exempt from the drop, not from the gate: pruning + * anything out of a preserved subtree returns a query the preserved condition + * does not describe (wider under the `and(, )` shape a filters * validator produces, narrower under an `or`), while keeping it would join a * relation the relations validator rejected. Neither outcome is correct, so the * contradiction between the two validators throws {@link SchemaError} - * (`SCHEMA_SEALED_CONDITION_PRUNED`) instead of resolving it silently. The - * decision is per node, not per operator: the seal says the condition survives - * composition intact, and pruning is not asked to reason about which shapes + * (`SCHEMA_PRESERVED_CONDITION_PRUNED`) instead of resolving it silently. The + * decision is per node, not per operator: preservation says the condition + * survives composition intact, and pruning is not asked to reason about which shapes * happen to fail open. */ export function pruneFiltersByRelations( @@ -150,13 +150,13 @@ export function pruneFiltersByRelations( // The default is the server's own baseline and is exempt from the gate: // it is re-applied here un-pruned, even when it names a rejected - // relation. A sealed default asserts the same must-survive contract as + // relation. A preserved default asserts the same must-survive contract as // a validator residual though, so it is checked: without this, the very // same default would throw or survive depending on whether the client // sent a filter of its own, which is what decides whether it was // materialized before this pass or after it. for (const condition of conditions) { - assertSealedSurvivesPruning(condition, rejected); + assertPreservedSurvivesPruning(condition, rejected); } } @@ -164,20 +164,20 @@ export function pruneFiltersByRelations( } /** - * Raise the sealed-condition contradiction for a condition that is kept rather + * Raise the preserved-condition contradiction for a condition that is kept rather * than pruned. The pruned copy is discarded: only the throw matters here. */ -function assertSealedSurvivesPruning(condition: ICondition, rejected: string[]) : void { +function assertPreservedSurvivesPruning(condition: ICondition, rejected: string[]) : void { pruneCondition(condition, rejected, '', false); } /** - * Drop the condition at `field`, unless it is protected: a drop inside a sealed + * Drop the condition at `field`, unless it is protected: a drop inside a preserved * subtree is the one case pruning must refuse rather than resolve. */ -function dropUnlessSealed(relation: string, field: string, sealed: boolean) : undefined { - if (sealed) { - throw SchemaError.sealedConditionPruned(relation, field); +function dropUnlessPreserved(relation: string, field: string, preserved: boolean) : undefined { + if (preserved) { + throw SchemaError.preservedConditionPruned(relation, field); } return undefined; @@ -187,11 +187,11 @@ function pruneCondition( node: ICondition, rejected: string[], prefix: string, - sealed: boolean, + preserved: boolean, ) : ICondition | undefined { - // the marker protects the whole subtree it heads: every condition below a - // seal is part of what the seal says must survive. - const sealed2 = sealed || !!node.sealed; + // The marker protects the whole subtree it heads. Custom conditions cannot + // carry it directly and are protected through preserve()'s built-in wrapper. + const preserved2 = preserved || isBuiltInConditionPreserved(node); if (isFilter(node)) { const field = joinPath(prefix, node.field); @@ -202,23 +202,26 @@ function pruneCondition( isCondition(node.value) ) { if (typeof rejectedBy === 'string') { - return dropUnlessSealed(rejectedBy, field, sealed2); + return dropUnlessPreserved(rejectedBy, field, preserved2); } - const interior = pruneCondition(node.value, rejected, field, sealed2); + const interior = pruneCondition(node.value, rejected, field, preserved2); if (!interior) { return undefined; } if (interior !== node.value) { - return new Filter(node.operator, node.field, interior, { sealed: node.sealed }); + return new Filter(node.operator, node.field, interior, { + preserved: node.preserved, + sealed: node.sealed, + }); } return node; } return typeof rejectedBy === 'string' ? - dropUnlessSealed(rejectedBy, field, sealed2) : + dropUnlessPreserved(rejectedBy, field, preserved2) : node; } @@ -228,7 +231,7 @@ function pruneCondition( const conditions : ICondition[] = []; for (const child of node.value) { - const child2 = pruneCondition(child, rejected, prefix, sealed2); + const child2 = pruneCondition(child, rejected, prefix, preserved2); if (child2) { conditions.push(child2); } @@ -238,7 +241,18 @@ function pruneCondition( return undefined; } - return new Filters(node.operator, conditions, { sealed: node.sealed }); + return new Filters(node.operator, conditions, { + preserved: node.preserved, + sealed: node.sealed, + }); +} + +function isBuiltInConditionPreserved(node: ICondition) : boolean { + if (isFilter(node) || isFilters(node)) { + return node.preserved === true; + } + + return false; } function buildSortDefaults(schema: SortSchema) : Sorts { diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index b2f103208..83d1245c2 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -7,6 +7,7 @@ import type { ICondition, IFilter } from '../../../src'; import { + Condition, ErrorCode, Field, Fields, @@ -19,8 +20,10 @@ import { defineQuery, eq, gte, + isFilters, mergeQueries, or, + preserve, seal, } from '../../../src'; import type { User } from '../../data'; @@ -28,6 +31,16 @@ import type { User } from '../../data'; const conditions = (filters: { value: unknown[] }) => (filters.value as IFilter[]) .map((el) => [el.field, el.operator, el.value]); +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); + } + + seal() : ICondition<{ scope: string }> { + return this; + } +} + describe('src/parameter/merge.ts', () => { it('should return an empty query without input', () => { const output = mergeQueries(); @@ -341,6 +354,38 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { }); }); +describe('src/parameter/filters/preserve.ts', () => { + it('should preserve a built-in leaf immutably and idempotently', () => { + const condition = eq('realm_id', 'master'); + const output = preserve(condition); + + expect(output).not.toBe(condition); + expect(output.preserved).toBe(true); + expect(preserve(output)).toBe(output); + }); + + it('should preserve a custom condition through a built-in wrapper', () => { + const condition = new CustomCondition({ scope: 'tenant-a' }); + const output = preserve(condition); + + expect(isFilters(output)).toBe(true); + expect(output.preserved).toBe(true); + expect(output.value).toEqual([condition]); + }); + + it('should keep a preserved root group atomic through flatten and merge', () => { + const preservedRoot = preserve(and(eq('a', 1))); + const other = new Filters(FilterCompoundOperator.AND, [eq('a', 9)]); + + expect(preservedRoot.flatten().preserved).toBe(true); + expect(and(eq('b', 2), preservedRoot).flatten().value[1]).toBe(preservedRoot); + + const output = other.merge(preservedRoot); + expect(output.value).toHaveLength(2); + expect(output.value[1]).toBe(preservedRoot); + }); +}); + describe('src/parameter/filters/helpers/module.ts seal', () => { it('should seal a leaf condition immutably', () => { const condition = eq('a', 1); diff --git a/packages/core/test/unit/parser/parameter/filters/validate.spec.ts b/packages/core/test/unit/parser/parameter/filters/validate.spec.ts index fbbaf0bb0..78b61922c 100644 --- a/packages/core/test/unit/parser/parameter/filters/validate.spec.ts +++ b/packages/core/test/unit/parser/parameter/filters/validate.spec.ts @@ -15,7 +15,7 @@ import { applyFiltersSchemaValidation, applyFiltersSchemaValidationAsync, defineFiltersSchema, - seal, + preserve, } from '../../../../../src'; import type { IFilter, Validator } from '../../../../../src'; @@ -284,10 +284,10 @@ describe('src/parser/parameter/filters/validate.ts', () => { .resolves.toBeUndefined(); }); - it('should keep a sealed condition sealed across validation', async () => { + it('should keep a preserved condition preserved across validation', async () => { const input = new Filters(FilterCompoundOperator.AND, [ - seal(new Filter(FilterFieldOperator.EQUAL, 'name', 'admin')), - ], { sealed: true }); + preserve(new Filter(FilterFieldOperator.EQUAL, 'name', 'admin')), + ], { preserved: true }); const schema = defineFiltersSchema({ // a replacement stands in for the leaf it replaces. validate: (filter) => new Filter(filter.operator, filter.field, 'ADMIN'), @@ -295,17 +295,38 @@ describe('src/parser/parameter/filters/validate.ts', () => { const output = applyFiltersSchemaValidation(input, schema)!; const [leaf] = output.value as IFilter[]; - expect(output.sealed).toBe(true); - expect(leaf!.sealed).toBe(true); + expect(output.preserved).toBe(true); + expect(leaf!.preserved).toBe(true); expect(leaf!.value).toBe('ADMIN'); const outputAsync = (await applyFiltersSchemaValidationAsync(input, schema))!; const [asyncLeaf] = outputAsync.value as IFilter[]; - expect(outputAsync.sealed).toBe(true); - expect(asyncLeaf!.sealed).toBe(true); + expect(outputAsync.preserved).toBe(true); + expect(asyncLeaf!.preserved).toBe(true); expect(asyncLeaf!.value).toBe('ADMIN'); }); + it('should preserve an elemMatch leaf rebuilt after interior validation', async () => { + const input = preserve(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'), + )); + const schema = defineFiltersSchema({ + validate: (filter) => (filter.field === 'name' ? + new Filter(filter.operator, filter.field, 'ADMIN') : + filter), + }); + + const output = applyFiltersSchemaValidation(input, schema) as IFilter; + expect(output.preserved).toBe(true); + expect((output.value as IFilter).value).toBe('ADMIN'); + + const outputAsync = await applyFiltersSchemaValidationAsync(input, schema) as IFilter; + expect(outputAsync.preserved).toBe(true); + expect((outputAsync.value as IFilter).value).toBe('ADMIN'); + }); + it('should propagate asynchronous validator failures', async () => { const input = new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'); const schema = defineFiltersSchema({ diff --git a/packages/core/test/unit/parser/relation-prune.spec.ts b/packages/core/test/unit/parser/relation-prune.spec.ts index 1c5932a7b..d67fb404c 100644 --- a/packages/core/test/unit/parser/relation-prune.spec.ts +++ b/packages/core/test/unit/parser/relation-prune.spec.ts @@ -21,11 +21,11 @@ import { defineFiltersSchema, defineSortSchema, isRelationRejected, + preserve, pruneFieldsByRelations, pruneFiltersByRelations, pruneRelationsByRelations, pruneSortsByRelations, - seal, } from '../../../src'; import type { IFilters } from '../../../src'; @@ -200,32 +200,32 @@ describe('src/parser/relation-prune.ts', () => { }); }); - describe('pruneFiltersByRelations (sealed conditions)', () => { + describe('pruneFiltersByRelations (preserved conditions)', () => { const eq = (field: string) => new Filter(FilterFieldOperator.EQUAL, field, 'x'); - it('throws instead of dropping a sealed leaf', () => { + it('throws instead of dropping a preserved leaf', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(eq('user.name')), + preserve(eq('user.name')), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('names the rejected relation and the sealed field', () => { + it('names the rejected relation and the preserved field', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(eq('realm.id')), + preserve(eq('realm.id')), ]); expect(() => pruneFiltersByRelations(filters, ['realm'])) .toThrowError(/"realm".+"realm\.id"/); }); - it('throws instead of dropping a policy residual out of a sealed group', () => { + it('throws instead of dropping a policy residual out of a preserved group', () => { // the shape a filters validate hook produces: - // seal(and(, )) + // preserve(and(, )) const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filters(FilterCompoundOperator.AND, [ + preserve(new Filters(FilterCompoundOperator.AND, [ eq('name'), eq('realm.id'), ])), @@ -233,24 +233,24 @@ describe('src/parser/relation-prune.ts', () => { ]); expect(() => pruneFiltersByRelations(filters, ['realm'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws for a sealed condition nested below an unsealed group', () => { + it('throws for a preserved condition nested below an unpreserved group', () => { const filters = new Filters(FilterCompoundOperator.AND, [ new Filters(FilterCompoundOperator.OR, [ eq('id'), - seal(eq('user.name')), + preserve(eq('user.name')), ]), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws instead of dropping a sealed elemMatch', () => { + it('throws instead of dropping a preserved elemMatch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filter( + preserve(new Filter( FilterFieldOperator.ELEM_MATCH, 'items', new Filter(FilterFieldOperator.EQUAL, 'id', 1), @@ -258,12 +258,12 @@ describe('src/parser/relation-prune.ts', () => { ]); expect(() => pruneFiltersByRelations(filters, ['items'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws instead of pruning the interior of a sealed elemMatch', () => { + it('throws instead of pruning the interior of a preserved elemMatch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filter( + preserve(new Filter( FilterFieldOperator.ELEM_MATCH, 'items', new Filters(FilterCompoundOperator.AND, [ @@ -274,44 +274,62 @@ describe('src/parser/relation-prune.ts', () => { ]); expect(() => pruneFiltersByRelations(filters, ['items.owner'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); // The two shapes below cannot fail open: dropping an OR arm narrows, - // and dropping the interior of a NOT removes a restriction the seal - // put there. Pruning still refuses, because the seal is a per-node - // marker and not a per-operator judgement call. - it('throws for a sealed OR arm, where a drop would narrow rather than widen', () => { + // and dropping the interior of a NOT removes a restriction that + // preserve() put there. Pruning still refuses, because preservation is + // a per-node marker and not a per-operator judgement call. + it('throws for a preserved OR arm, where a drop would narrow rather than widen', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])), + preserve(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws for a sealed condition below a NOT', () => { + it('throws for a preserved condition below a NOT', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - new Filters(FilterCompoundOperator.NOT, [seal(eq('user.name'))]), + new Filters(FilterCompoundOperator.NOT, [preserve(eq('user.name'))]), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('keeps pruning around a sealed condition it does not touch', () => { + it('keeps pruning around a preserved condition it does not touch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(eq('realm_id')), + preserve(eq('realm_id')), eq('user.name'), eq('id'), ]); const output = pruneFiltersByRelations(filters, ['user']); expect(filterFields(output)).toEqual(['realm_id', 'id']); - expect(output.value[0].sealed).toBe(true); + expect(output.value[0].preserved).toBe(true); }); - it('re-applies an UNSEALED default naming a rejected relation', () => { + it('preserves rebuilt groups and elemMatch leaves', () => { + const group = preserve(new Filters(FilterCompoundOperator.AND, [eq('realm_id')])); + const elemMatch = preserve(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [eq('id')]), + )); + const filters = new Filters(FilterCompoundOperator.AND, [ + group, + elemMatch, + eq('user.name'), + ]); + + const output = pruneFiltersByRelations(filters, ['user']); + expect(output.value[0].preserved).toBe(true); + expect(output.value[1].preserved).toBe(true); + }); + + it('re-applies an unpreserved default naming a rejected relation', () => { // the server-authored baseline is exempt from the gate, which is // why the default fallback is not pruned. const filters = new Filters(FilterCompoundOperator.AND, [eq('user.a')]); @@ -320,15 +338,15 @@ describe('src/parser/relation-prune.ts', () => { expect(filterFields(pruneFiltersByRelations(filters, ['user'], schema))).toEqual(['user.b']); }); - it('throws for a SEALED default naming a rejected relation', () => { + it('throws for a preserved default naming a rejected relation', () => { // otherwise the same default would throw when it is materialized // before this pass (client sent no filters) and survive when it is // materialized after it (client sent filters that all pruned away). const filters = new Filters(FilterCompoundOperator.AND, [eq('user.a')]); - const schema = defineFiltersSchema({ default: seal(eq('user.b')) }); + const schema = defineFiltersSchema({ default: preserve(eq('user.b')) }); expect(() => pruneFiltersByRelations(filters, ['user'], schema)) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); }); }); diff --git a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts index f2dbcfea4..9c265417e 100644 --- a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts +++ b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts @@ -13,7 +13,7 @@ import { and, defineSchema, eq, - seal, + preserve, } from '@rapiq/core'; import type { ICondition, @@ -323,7 +323,7 @@ describe('relations.validate for traversed relation paths (#815)', () => { * wider result set than the policy allows, keeping it would join a rejected * relation. The contradiction is a server misconfiguration, so it throws. */ - describe('sealed policy residuals vs. relation pruning (#877)', () => { + describe('preserved policy residuals vs. relation pruning (#877)', () => { function buildScopedRegistry( residual: (filter: IFilter) => ICondition, relationsValidate: (name: string) => boolean, @@ -349,11 +349,11 @@ describe('relations.validate for traversed relation paths (#815)', () => { return registry; } - const sealedResidual = (filter: IFilter) => seal(and(filter, eq('realm.id', 'SCOPE'))); + const preservedResidual = (filter: IFilter) => preserve(and(filter, eq('realm.id', 'SCOPE'))); const input = { filters: { name: 'John', 'realm.name': 'master' } }; it('keeps the residual when the relations hook permits the relation', () => { - const parser = new SimpleParser(buildScopedRegistry(sealedResidual, () => true)); + const parser = new SimpleParser(buildScopedRegistry(preservedResidual, () => true)); const query = parser.parse(input, { schema: 'user', context: actor }); expect(filterFields(query.filters)).toEqual(['name', 'realm.id', 'realm.name']); @@ -361,7 +361,7 @@ describe('relations.validate for traversed relation paths (#815)', () => { it('throws instead of pruning the residual when the relation is rejected', () => { const parser = new SimpleParser(buildScopedRegistry( - sealedResidual, + preservedResidual, (name) => name !== 'realm', )); @@ -370,39 +370,39 @@ describe('relations.validate for traversed relation paths (#815)', () => { parser.parse(input, { schema: 'user', context: actor }); } catch (e) { expect(e).toBeInstanceOf(SchemaError); - expect((e as SchemaError).code).toEqual(ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED); + expect((e as SchemaError).code).toEqual(ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED); } }); it('throws on the async path too', async () => { const parser = new SimpleParser(buildScopedRegistry( - sealedResidual, + preservedResidual, (name) => name !== 'realm', )); await expect(parser.parseAsync(input, { schema: 'user', context: actor })) - .rejects.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .rejects.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); it('throws for a standalone filters parse', () => { - const registry = buildScopedRegistry(sealedResidual, (name) => name !== 'realm'); + const registry = buildScopedRegistry(preservedResidual, (name) => name !== 'realm'); expect(() => new SimpleFiltersParser(registry).parse( input.filters, { schema: 'user', context: actor }, - )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); /** - * The shape the docs recommend: seal the residual, leave the client's + * The shape the docs recommend: preserve the residual, leave the client's * own leaf outside it. Both shapes resist a merge, but this one keeps * the client leaf prunable, so the gate stays a drop for the client and * the error stays reserved for a residual that itself names a rejected * relation. */ - describe('seal the residual, not the group', () => { - const localResidual = (filter: IFilter) => and(filter, seal(eq('realm_id', 'SCOPE'))); - const relationResidual = (filter: IFilter) => and(filter, seal(eq('realm.id', 'SCOPE'))); + describe('preserve the residual, not the group', () => { + const localResidual = (filter: IFilter) => and(filter, preserve(eq('realm_id', 'SCOPE'))); + const relationResidual = (filter: IFilter) => and(filter, preserve(eq('realm.id', 'SCOPE'))); function buildLocalRegistry(residual: (filter: IFilter) => ICondition) : SchemaRegistry { const registry = new SchemaRegistry(); @@ -428,7 +428,7 @@ describe('relations.validate for traversed relation paths (#815)', () => { expect(filterFields(query.filters)).toEqual(['realm_id']); }); - it('keeps the residual sealed, so a later merge cannot displace it', () => { + it('keeps the residual preserved through normalization', () => { const parser = new SimpleParser(buildLocalRegistry(localResidual)); const query = parser.parse({ filters: { name: 'John' } }, { schema: 'user', context: actor }); @@ -436,9 +436,9 @@ describe('relations.validate for traversed relation paths (#815)', () => { const residual = group.value[1] as IFilter; expect(residual.field).toBe('realm_id'); - expect(residual.sealed).toBe(true); + expect(residual.preserved).toBe(true); // and it stays marked once normalization hoists it into the root - expect(query.filters.flatten().value.some((c) => (c as IFilter).sealed)).toBe(true); + expect(query.filters.flatten().value.some((c) => (c as IFilter).preserved)).toBe(true); }); it('still throws when the residual itself names the rejected relation', () => { @@ -447,12 +447,12 @@ describe('relations.validate for traversed relation paths (#815)', () => { expect(() => parser.parse( { filters: { name: 'John', 'realm.name': 'master' } }, { schema: 'user', context: actor }, - )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); }); - it('prunes an UNSEALED residual, which is what the seal marks', () => { - // without seal() the residual is an ordinary displaceable condition: + it('prunes an unpreserved residual, which is what preserve marks', () => { + // without preserve() the residual is an ordinary condition: // pruning drops it like any other client-owned leaf. const parser = new SimpleParser(buildScopedRegistry( (filter) => and(filter, eq('realm.id', 'SCOPE')), From f9e91f1dce354fab73be3b72d70362e47acb73a1 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 11:08:30 +0200 Subject: [PATCH 05/13] fix(core): keep preserved receivers atomic --- .../parameter/filters/collection/module.ts | 3 ++- .../core/test/unit/parameter/merge.spec.ts | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index 763460c51..d522c1673 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -135,7 +135,8 @@ export class Filters< if ( this.operator === operator && operator !== FilterCompoundOperator.NOT && - !this.sealed + !this.sealed && + !this.preserved ) { return new Filters(operator, [...this.value, ...injected]); } diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index 83d1245c2..d9ba153b2 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -5,7 +5,7 @@ * view the LICENSE file that was distributed with this source code. */ -import type { ICondition, IFilter } from '../../../src'; +import type { ICondition, IFilter, IFilters } from '../../../src'; import { Condition, ErrorCode, @@ -24,6 +24,7 @@ import { mergeQueries, or, preserve, + pruneFiltersByRelations, seal, } from '../../../src'; import type { User } from '../../data'; @@ -384,6 +385,28 @@ describe('src/parameter/filters/preserve.ts', () => { expect(output.value).toHaveLength(2); expect(output.value[1]).toBe(preservedRoot); }); + + it('should keep a preserved AND receiver atomic when and() matches its operator', () => { + const receiver = preserve(and(eq('realm.id', 'SCOPE'))); + + const output = receiver.and(eq('name', 'John')); + + expect(output.value[0]).toBe(receiver); + expect((output.value[0] as IFilters).preserved).toBe(true); + expect(() => pruneFiltersByRelations(output, ['realm'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + + it('should keep a preserved OR receiver atomic when or() matches its operator', () => { + const receiver = preserve(or(eq('realm.id', 'SCOPE'), eq('id', 1))); + + const output = receiver.or(eq('name', 'John')); + + expect(output.value[0]).toBe(receiver); + expect((output.value[0] as IFilters).preserved).toBe(true); + expect(() => pruneFiltersByRelations(output, ['realm'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); }); describe('src/parameter/filters/helpers/module.ts seal', () => { From 65024123d9d9f658d42f9a48d730e2a2d8a88b02 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 11:18:16 +0200 Subject: [PATCH 06/13] refactor(core)!: remove filter sealing --- .../codec-url/test/unit/acceptance.spec.ts | 13 +- .../test/unit/simple-roundtrip.spec.ts | 15 +-- .../src/build/parameter/filters/module.ts | 10 +- .../src/parameter/fields/collection/module.ts | 6 +- .../parameter/filters/collection/module.ts | 58 ++------ .../src/parameter/filters/collection/types.ts | 13 -- .../core/src/parameter/filters/condition.ts | 28 +--- packages/core/src/parameter/filters/index.ts | 1 - .../src/parameter/filters/record/module.ts | 15 +-- .../src/parameter/filters/record/types.ts | 14 -- packages/core/src/parameter/filters/seal.ts | 32 ----- .../src/parser/parameter/filters/validate.ts | 33 ++--- packages/core/src/parser/relation-prune.ts | 10 +- packages/core/test/unit/build/module.spec.ts | 12 +- .../unit/parameter/condition-contract.spec.ts | 84 ++---------- .../unit/parameter/filters-non-node.spec.ts | 19 +-- .../core/test/unit/parameter/merge.spec.ts | 127 ++---------------- .../parser/parameter/key-validation.spec.ts | 11 +- 18 files changed, 79 insertions(+), 422 deletions(-) delete mode 100644 packages/core/src/parameter/filters/seal.ts diff --git a/packages/codec-url/test/unit/acceptance.spec.ts b/packages/codec-url/test/unit/acceptance.spec.ts index 2b7d3eda5..15977b8b9 100644 --- a/packages/codec-url/test/unit/acceptance.spec.ts +++ b/packages/codec-url/test/unit/acceptance.spec.ts @@ -104,13 +104,11 @@ describe('gateway controller (hub archetype)', () => { expect(downstream.pagination).toEqual(new Pagination(50, 0)); }); - it('should carry undisplaceable scoping downstream', () => { + it('should carry conjunctive scoping downstream', () => { const query = codec.decode(requestQuery, { schema: 'log' })!; - // filters.and() injects a sealed condition — no later merge can - // displace it. The seal is a server-side composition marker - // rather than wire grammar, so what crosses the URL is the - // condition itself; the facade stamps the codec identity so the + // filters.and() adds an ordinary conjunct. What crosses the URL is + // the condition itself; the facade stamps the codec identity so the // downstream service knows how to decode it. const scoped = new Query({ fields: query.fields, @@ -131,9 +129,8 @@ describe('gateway controller (hub archetype)', () => { '&page[limit]=50&sort=-created_at', ); - // downstream: dispatch on the stamp, same AST comes out — minus - // the seal, which no wire dialect carries. The downstream service - // re-injects its own scoping instead of trusting the transport. + // downstream: dispatch on the stamp and the same AST comes out. The + // downstream service injects its own scoping instead of trusting the transport. const downstream = codec.decode(wire!)!; expect(downstream.filters).toEqual( diff --git a/packages/codec-url/test/unit/simple-roundtrip.spec.ts b/packages/codec-url/test/unit/simple-roundtrip.spec.ts index 05bce9677..9123b5635 100644 --- a/packages/codec-url/test/unit/simple-roundtrip.spec.ts +++ b/packages/codec-url/test/unit/simple-roundtrip.spec.ts @@ -46,7 +46,6 @@ import { startsWith, } from '@rapiq/core'; import type { - ICondition, IFilter, IFilterVisitor, IQuery, @@ -54,15 +53,8 @@ import type { import { SimpleURLDecoder, SimpleURLEncoder } from '../../src/simple'; class CustomCondition extends Condition<{ scope: string }> { - constructor( - value: { scope: string }, - sealed?: boolean, - ) { - super('custom', value, { sealed }); - } - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); + constructor(value: { scope: string }) { + super('custom', value); } } @@ -75,9 +67,6 @@ function createStructuralFilter(field: string, value: unknown) : IFilter { accept(visitor: IFilterVisitor) : R { return visitor.visitFilter(output); }, - seal() : IFilter { - return output; - }, }; return output; diff --git a/packages/core/src/build/parameter/filters/module.ts b/packages/core/src/build/parameter/filters/module.ts index 1e766290e..bad971ac6 100644 --- a/packages/core/src/build/parameter/filters/module.ts +++ b/packages/core/src/build/parameter/filters/module.ts @@ -58,10 +58,10 @@ export function defineFilters(input: FiltersBuildInput | IConditi } /** - * Detect condition-shaped runtime data that lost its live behavior - * in a JSON/RPC/cache round trip. It has no `seal`, so it would - * otherwise be read as a record of field/value pairs and filter on - * columns literally named `operator` and `value`. + * Detect condition-shaped runtime data that lost its non-serializable brand + * in a JSON/RPC/cache round trip. Without the brand it would otherwise be + * read as a record of field/value pairs and filter on columns literally + * named `operator` and `value`. * * Deliberately narrow: only an object whose own keys are exactly the * condition properties, with a recognized operator, qualifies. A build @@ -79,7 +79,7 @@ function isDetachedCondition(input: unknown) : boolean { } for (const key of keys) { - if (key !== 'operator' && key !== 'value' && key !== 'sealed' && key !== 'field') { + if (key !== 'operator' && key !== 'value' && key !== 'preserved' && key !== 'field') { return false; } } diff --git a/packages/core/src/parameter/fields/collection/module.ts b/packages/core/src/parameter/fields/collection/module.ts index 34d525e20..78bf9f5f6 100644 --- a/packages/core/src/parameter/fields/collection/module.ts +++ b/packages/core/src/parameter/fields/collection/module.ts @@ -36,9 +36,9 @@ export class Fields implements IFields { * server-authored authorization decision, and dropping it silently * would widen disclosure. The surviving node keeping the identical * condition instance is fine; anything else refuses. Filters express - * the same protection differently: a sealed condition is carried - * through `Filters.merge()` instead of refusing it, because a filter - * can be and-ed in while a field is either gated or not. + * the same protection differently: a preserved condition stays atomic + * through `Filters.merge()` because a filter can be combined as a + * conjunct while a field is either gated or not. */ merge(other: IFields) : IFields { const output : IField[] = []; diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index d522c1673..69419bc7c 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -21,7 +21,7 @@ export class Filters< conditions: T[], options: BuiltInConditionOptions = {}, ) { - super(operator, conditions, options); + super(operator, conditions); if (options.preserved) { this.preserved = true; @@ -32,24 +32,13 @@ export class Filters< return visitor.visitFilters(this); } - seal() : IFilters { - if (this.sealed) { - return this; - } - - return new Filters(this.operator, this.value, { - preserved: this.preserved, - sealed: true, - }); - } - flatten(aggregatedResult?: T[]) : IFilters { // this.value.splice(0, this.value.length, ...next); return new Filters( this.operator, this.flattenInternal(this.value, this.operator, aggregatedResult), - { preserved: this.preserved, sealed: this.sealed }, + { preserved: this.preserved }, ); } @@ -61,14 +50,12 @@ export class Filters< const flatConditions: F[] = aggregatedResult || []; for (const currentNode of conditions) { - // merging same-operator children relies on associativity — - // NOT groups are not associative (not(not(x)) ≠ not(x)) — - // and a sealed group carries its protection in the node, so - // hoisting its children would strip that protection. + // Merging same-operator children relies on associativity. + // NOT groups are not associative (not(not(x)) is not not(x)). + // A preserved group stays atomic so pruning protection remains. if ( isFilters(currentNode, operator) && operator !== FilterCompoundOperator.NOT && - !currentNode.sealed && !currentNode.preserved ) { currentNode.flatten(flatConditions); @@ -82,7 +69,7 @@ export class Filters< /** * Ordered logical conjunction. Each condition from both sides survives - * in argument order. An unsealed root AND contributes its flattened + * in argument order. A non-preserved root AND contributes its flattened * child conjuncts, while every other root remains one conjunct. */ merge(other: IFilters) : IFilters { @@ -101,10 +88,8 @@ export class Filters< } /** - * Wrap & inject (immutable): the given conditions are sealed and - * combined with the receiver under an AND group. Sealed conditions - * survive normalization ({@link Filters.flatten}), so a scoping - * condition injected here remains an explicit conjunct. + * Wrap and append immutably: combine the given conditions with the + * receiver under an AND group while retaining their object identity. */ and(...conditions: ICondition[]) : IFilters { return this.wrap(FilterCompoundOperator.AND, conditions); @@ -122,12 +107,10 @@ export class Filters< return this; } - const injected = conditions.map(sealCondition); - // an empty receiver constrains nothing, so it must not become a // child: under OR it would widen the group to everything. if (this.value.length === 0) { - return new Filters(operator, injected); + return new Filters(operator, conditions); } // a receiver already carrying that operator contributes its @@ -135,38 +118,23 @@ export class Filters< if ( this.operator === operator && operator !== FilterCompoundOperator.NOT && - !this.sealed && !this.preserved ) { - return new Filters(operator, [...this.value, ...injected]); + return new Filters(operator, [...this.value, ...conditions]); } - return new Filters(operator, [this, ...injected]); + return new Filters(operator, [this, ...conditions]); } } -/** - * The `seal` helper, inlined for the conditions {@link Filters.and} / - * {@link Filters.or} inject: reaching for the helper here would make - * this module depend on one that depends back on it. - */ -function sealCondition(condition: ICondition) : ICondition { - if (typeof condition.seal === 'function') { - return condition.seal(); - } - - return condition; -} - /** * The conjuncts a {@link Filters.merge} operates on: the flattened - * children of an unsealed root AND, or the whole node for a sealed tree - * or any other compound operator. + * children of a non-preserved root AND, or the whole node for a preserved + * tree or any other compound operator. */ function toConjuncts(input: IFilters) : ICondition[] { if ( input.operator === FilterCompoundOperator.AND && - !input.sealed && !input.preserved ) { return input.flatten().value; diff --git a/packages/core/src/parameter/filters/collection/types.ts b/packages/core/src/parameter/filters/collection/types.ts index 18ff81e88..8cdd79c7a 100644 --- a/packages/core/src/parameter/filters/collection/types.ts +++ b/packages/core/src/parameter/filters/collection/types.ts @@ -24,21 +24,8 @@ export interface IFilters< */ readonly preserved?: true; - /** - * Protection marker: a sealed group is never collapsed into its parent - * by {@link IFilters.flatten}. Filter merges remain conjunctive. - */ - readonly sealed?: boolean; - accept(visitor: IFiltersVisitor) : R; - /** - * A copy of this group carrying the {@link IFilters.sealed} marker - * (the receiver itself when it is already sealed). Reach for the - * `seal` helper to seal any {@link ICondition}. - */ - seal() : IFilters; - flatten(items?: T[]) : IFilters; /** diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index 9c05d1572..7b59ef3c7 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -17,16 +17,6 @@ export interface ICondition< readonly operator: string; readonly value: T; - - /** - * Displaceability marker: a sealed condition is never dropped by - * {@link IFilters.merge} and never collapsed into its parent group by - * {@link IFilters.flatten}. Set it through the `seal` helper or by - * injecting the condition with {@link IFilters.and} / {@link IFilters.or}. - */ - readonly sealed?: boolean; - - seal(): ICondition; } /** @@ -42,17 +32,10 @@ export function isCondition(input: unknown) : input is ICondition { ); } -/** - * Construction options shared by built-in condition node implementations. - */ -export type ConditionOptions = { - sealed?: boolean, -}; - /** * Construction options shared only by the built-in filter nodes. */ -export type BuiltInConditionOptions = ConditionOptions & { +export type BuiltInConditionOptions = { preserved?: boolean, }; @@ -71,20 +54,11 @@ export abstract class Condition< readonly value: T; - readonly sealed?: boolean; - constructor( operator: string, value: T, - options: ConditionOptions = {}, ) { this.operator = operator; this.value = value; - - if (options.sealed) { - this.sealed = true; - } } - - abstract seal(): ICondition; } diff --git a/packages/core/src/parameter/filters/index.ts b/packages/core/src/parameter/filters/index.ts index 82bfac88a..b923f071e 100644 --- a/packages/core/src/parameter/filters/index.ts +++ b/packages/core/src/parameter/filters/index.ts @@ -13,4 +13,3 @@ export * from './plan'; export * from './preserve'; export * from './record'; export * from './regex'; -export * from './seal'; diff --git a/packages/core/src/parameter/filters/record/module.ts b/packages/core/src/parameter/filters/record/module.ts index 1bf85d3a9..398302bd8 100644 --- a/packages/core/src/parameter/filters/record/module.ts +++ b/packages/core/src/parameter/filters/record/module.ts @@ -24,7 +24,7 @@ export class Filter< value: VALUE, options: BuiltInConditionOptions = {}, ) { - super(operator, value, options); + super(operator, value); this.field = field; if (options.preserved) { @@ -35,17 +35,4 @@ export class Filter< accept(visitor: IFilterVisitor) : R { return visitor.visitFilter(this); } - - seal() : IFilter { - if (this.sealed) { - return this; - } - - return new Filter( - this.operator, - this.field, - this.value, - { preserved: this.preserved, sealed: true }, - ); - } } diff --git a/packages/core/src/parameter/filters/record/types.ts b/packages/core/src/parameter/filters/record/types.ts index 570617b2a..fb8879ab9 100644 --- a/packages/core/src/parameter/filters/record/types.ts +++ b/packages/core/src/parameter/filters/record/types.ts @@ -35,19 +35,5 @@ export interface IFilter< */ readonly preserved?: true; - /** - * Displaceability marker: a sealed leaf is never dropped by a - * later `merge` and never hoisted out of its group by `flatten`. - */ - readonly sealed?: boolean; - accept(visitor: IFilterVisitor) : R; - - /** - * A copy of this leaf carrying the {@link IFilter.sealed} marker - * (the receiver itself when it is already sealed). Reach for the - * `seal` helper to seal any {@link ICondition}; both methods expose - * interface types rather than concrete implementations. - */ - seal() : IFilter; } diff --git a/packages/core/src/parameter/filters/seal.ts b/packages/core/src/parameter/filters/seal.ts deleted file mode 100644 index 53e16f950..000000000 --- a/packages/core/src/parameter/filters/seal.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2026. - * Author Peter Placzek (tada5hi) - * For the full copyright and license information, - * view the LICENSE file that was distributed with this source code. - */ - -import type { ICondition } from './condition'; - -/** - * Mark a condition as non-displaceable: every live {@link ICondition} - * implementation owns its immutable sealed copy. A later `merge` never - * drops that copy and `flatten` never hoists it out of its group. This is - * what `Filters.and` / `Filters.or` apply to the conditions they inject; - * reach for it directly when a server-authored condition, such as a policy - * residual returned from a filters `validate` hook or a scoped default, - * has to survive composition by other code. - * - * The seal is a server-side composition marker, not part of any wire - * grammar: a sealed condition that is encoded and decoded again comes - * back displaceable. - * - * Detached runtime data without callable sealing behavior is left unchanged. - */ -export function seal(condition: T) : ReturnType; -export function seal(condition: ICondition) : ICondition { - if (typeof condition.seal === 'function') { - return condition.seal(); - } - - return condition; -} diff --git a/packages/core/src/parser/parameter/filters/validate.ts b/packages/core/src/parser/parameter/filters/validate.ts index 550879e72..51b93debf 100644 --- a/packages/core/src/parser/parameter/filters/validate.ts +++ b/packages/core/src/parser/parameter/filters/validate.ts @@ -12,7 +12,6 @@ import { isFilter, isFilters, preserve, - seal, } from '../../../parameter'; import type { ICondition, @@ -89,10 +88,7 @@ export function applyFiltersSchemaValidation( } if (interior !== input.value) { - leaf = new Filter(input.operator, input.field, interior, { - preserved: input.preserved, - sealed: input.sealed, - }); + leaf = new Filter(input.operator, input.field, interior, { preserved: input.preserved }); } } @@ -106,11 +102,9 @@ export function applyFiltersSchemaValidation( return undefined; } - // The replacement stands in for the leaf, so it inherits both - // temporary sealing and relation-pruning preservation. - let replacement = leaf.preserved ? preserve(output) : output; - replacement = leaf.sealed ? seal(replacement) : replacement; - return replacement; + // The replacement stands in for the leaf, so it inherits + // relation-pruning preservation. + return leaf.preserved ? preserve(output) : output; } if (!isFilters(input)) { @@ -129,10 +123,7 @@ export function applyFiltersSchemaValidation( return undefined; } - return new Filters(input.operator, conditions, { - preserved: input.preserved, - sealed: input.sealed, - }); + return new Filters(input.operator, conditions, { preserved: input.preserved }); } /** @@ -171,10 +162,7 @@ export async function applyFiltersSchemaValidationAsync( } if (interior !== input.value) { - leaf = new Filter(input.operator, input.field, interior, { - preserved: input.preserved, - sealed: input.sealed, - }); + leaf = new Filter(input.operator, input.field, interior, { preserved: input.preserved }); } } @@ -183,9 +171,7 @@ export async function applyFiltersSchemaValidationAsync( return undefined; } - let replacement = leaf.preserved ? preserve(output) : output; - replacement = leaf.sealed ? seal(replacement) : replacement; - return replacement; + return leaf.preserved ? preserve(output) : output; } if (!isFilters(input)) { @@ -204,8 +190,5 @@ export async function applyFiltersSchemaValidationAsync( return undefined; } - return new Filters(input.operator, conditions, { - preserved: input.preserved, - sealed: input.sealed, - }); + return new Filters(input.operator, conditions, { preserved: input.preserved }); } diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index 6aeda0231..ced3ace77 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -211,10 +211,7 @@ function pruneCondition( } if (interior !== node.value) { - return new Filter(node.operator, node.field, interior, { - preserved: node.preserved, - sealed: node.sealed, - }); + return new Filter(node.operator, node.field, interior, { preserved: node.preserved }); } return node; @@ -241,10 +238,7 @@ function pruneCondition( return undefined; } - return new Filters(node.operator, conditions, { - preserved: node.preserved, - sealed: node.sealed, - }); + return new Filters(node.operator, conditions, { preserved: node.preserved }); } function isBuiltInConditionPreserved(node: ICondition) : boolean { diff --git a/packages/core/test/unit/build/module.spec.ts b/packages/core/test/unit/build/module.spec.ts index b48cd8cb5..8902f4700 100644 --- a/packages/core/test/unit/build/module.spec.ts +++ b/packages/core/test/unit/build/module.spec.ts @@ -8,7 +8,6 @@ import type { FieldsBuildInput, FiltersBuildInput, - ICondition, IFilter, QueryBuildInput, RelationsBuildInput, @@ -41,15 +40,8 @@ import type { Client, User } from '../../data'; const leafs = (filters: { value: unknown[] }) => filters.value as IFilter[]; class CustomCondition extends Condition<{ scope: string }> { - constructor( - value: { scope: string }, - sealed?: boolean, - ) { - super('custom', value, { sealed }); - } - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); + constructor(value: { scope: string }) { + super('custom', value); } } diff --git a/packages/core/test/unit/parameter/condition-contract.spec.ts b/packages/core/test/unit/parameter/condition-contract.spec.ts index eaa89af41..0d86bcbd5 100644 --- a/packages/core/test/unit/parameter/condition-contract.spec.ts +++ b/packages/core/test/unit/parameter/condition-contract.spec.ts @@ -8,8 +8,6 @@ import { expectTypeOf } from 'vitest'; import type { ICondition, - IFilter, - IFilters, } from '../../../src'; import { CONDITION_MARKER, @@ -24,41 +22,24 @@ import { isFilters, not, or, - seal, } from '../../../src'; type CustomValue = { scope: string, }; -interface ISealedCustomCondition extends ICondition { - readonly stage: 'sealed'; - readonly sealed: true; - seal(): ISealedCustomCondition; -} - -interface IUnsealedCustomCondition extends ICondition { - readonly stage: 'unsealed'; - seal(): ISealedCustomCondition; -} - class CustomCondition extends Condition { readonly field: string; constructor( value: CustomValue, - sealed?: boolean, operator = 'custom', field = 'not-a-built-in-leaf', ) { - super(operator, value, { sealed }); + super(operator, value); this.field = field; } - seal(): ICondition { - return this.sealed ? this : new CustomCondition(this.value, true); - } - flatten(): this { return this; } @@ -80,7 +61,6 @@ describe('src/parameter/filters condition contract', () => { expect(isCondition({ operator: 'custom', value: {}, - seal() { return this; }, })).toBe(false); }); @@ -95,8 +75,12 @@ describe('src/parameter/filters condition contract', () => { it('should not require visitor dispatch from a structural custom condition', () => { const condition = new CustomCondition({ scope: 'tenant-a' }); type HasAccept = 'accept' extends keyof ICondition ? true : false; + type HasSeal = 'seal' extends keyof ICondition ? true : false; + type HasSealed = 'sealed' extends keyof ICondition ? true : false; expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expect(condition.value.scope).toBe('tenant-a'); }); @@ -116,51 +100,17 @@ describe('src/parameter/filters condition contract', () => { expect(elemMatch('items', condition).value).toBe(condition); }); - it('should expose interfaces from seal contracts', () => { - const leaf = eq('name', 'Peter'); - const group = and(leaf); - - expectTypeOf(leaf.seal()).toEqualTypeOf(); - expectTypeOf(group.seal()).toEqualTypeOf(); - expectTypeOf(seal(leaf)).toEqualTypeOf(); - expectTypeOf(seal(group)).toEqualTypeOf(); - }); - - it('should seal custom conditions polymorphically behind the interface', () => { - const condition = new CustomCondition({ scope: 'tenant-a' }); - const output = seal(condition); - - expectTypeOf(output).toEqualTypeOf>(); - expect(output).not.toBe(condition); - expect(output.sealed).toBe(true); - expect(seal(output)).toBe(output); - }); - - it('should derive the helper result from the declared seal interface', () => { - const assertReturnType = (condition: IUnsealedCustomCondition) => { - const output = seal(condition); - - expectTypeOf(output).toEqualTypeOf(); - }; - - expectTypeOf(assertReturnType).toBeFunction(); - }); - - it('should seal a custom condition injected into a built-in group', () => { + it('should combine custom conditions without cloning or preserving them', () => { const condition = new CustomCondition({ scope: 'tenant-a' }); - const receiver = new Filters(FilterCompoundOperator.AND, [eq('name', 'Peter')]); - - const withAnd = receiver.and(condition); - const withOr = receiver.or(condition); + const receiver = and(eq('name', 'Peter')); - expect(withAnd.value[1]).toEqual(condition.seal()); - expect(withOr.value[1]).toEqual(condition.seal()); - expect(withAnd.value[1]).not.toBe(condition); - expect(withOr.value[1]).not.toBe(condition); + expect(receiver.and(condition).value.at(-1)).toBe(condition); + expect(receiver.or(condition).value.at(-1)).toBe(condition); + expect('preserved' in condition).toBe(false); }); it('should keep a custom condition opaque while flattening built-in groups', () => { - const condition = new CustomCondition({ scope: 'tenant-a' }, false, 'and'); + const condition = new CustomCondition({ scope: 'tenant-a' }, 'and'); const output = and(and(condition)).flatten(); expect(output.value).toHaveLength(1); @@ -170,7 +120,6 @@ describe('src/parameter/filters condition contract', () => { it('should carry a custom condition through a built-in merge unchanged', () => { const condition = new CustomCondition( { scope: 'tenant-a' }, - false, 'custom', 'name', ); @@ -181,22 +130,13 @@ describe('src/parameter/filters condition contract', () => { expect(output.value[1]).toBe(condition); }); - it('should keep detached runtime data unchanged when it has no seal method', () => { - const detached = { - operator: 'custom', - value: { scope: 'tenant-a' }, - }; - - expect(seal(detached as unknown as ICondition)).toBe(detached); - }); - it('should reject detached data at typed condition boundaries', () => { const detached = { operator: 'custom', value: { scope: 'tenant-a' }, }; - // @ts-expect-error detached data has no sealing behavior + // @ts-expect-error detached data has no condition brand const condition: ICondition = detached; // @ts-expect-error helpers accept live ICondition implementations and(detached); diff --git a/packages/core/test/unit/parameter/filters-non-node.spec.ts b/packages/core/test/unit/parameter/filters-non-node.spec.ts index a93196908..22dc8184d 100644 --- a/packages/core/test/unit/parameter/filters-non-node.spec.ts +++ b/packages/core/test/unit/parameter/filters-non-node.spec.ts @@ -33,15 +33,8 @@ const NON_NODE = { } as unknown as ICondition; class CustomCondition extends Condition<{ scope: string }> { - constructor( - value: { scope: string }, - sealed?: boolean, - ) { - super('custom', value, { sealed }); - } - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); + constructor(value: { scope: string }) { + super('custom', value); } } @@ -151,4 +144,12 @@ describe('src/parameter/filters (non-node conditions)', () => { )) .toThrow(BuildError); }); + + it('should refuse a detached preserved condition', () => { + const detached = JSON.parse(JSON.stringify( + new Filters(FilterCompoundOperator.AND, [eq('name', 'John')], { preserved: true }), + )); + + expect(() => defineFilters(detached)).toThrow(BuildError); + }); }); diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index d9ba153b2..9b6b7511a 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -5,7 +5,7 @@ * view the LICENSE file that was distributed with this source code. */ -import type { ICondition, IFilter, IFilters } from '../../../src'; +import type { IFilter, IFilters } from '../../../src'; import { Condition, ErrorCode, @@ -25,7 +25,6 @@ import { or, preserve, pruneFiltersByRelations, - seal, } from '../../../src'; import type { User } from '../../data'; @@ -36,10 +35,6 @@ class CustomCondition extends Condition<{ scope: string }> { constructor(value: { scope: string }) { super('custom', value); } - - seal() : ICondition<{ scope: string }> { - return this; - } } describe('src/parameter/merge.ts', () => { @@ -173,34 +168,35 @@ describe('src/parameter/merge.ts', () => { }); describe('src/parameter/filters/collection/module.ts combinators', () => { - it('should inject sealed conditions via and() without mutating the receiver', () => { + it('should append the original condition via and() without mutating the receiver', () => { const query = defineQuery({ filters: { name: 'John' } }); + const condition = eq('realm.id', 'master'); - const scoped = query.filters.and(eq('realm.id', 'master')); + const scoped = query.filters.and(condition); // receiver untouched (immutable). expect(query.filters.value).toHaveLength(1); - // the receiver already carries the operator, so the group stays - // flat and only the injected condition is sealed. + // the receiver already carries the operator, so the group stays flat. expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(conditions(scoped)).toEqual([ ['name', 'eq', 'John'], ['realm.id', 'eq', 'master'], ]); - expect(scoped.value[0].sealed).toBeUndefined(); - expect(scoped.value[1].sealed).toBe(true); + expect(scoped.value[1]).toBe(condition); + expect((scoped.value[1] as IFilter).preserved).toBeUndefined(); }); it('should nest a receiver that carries another operator', () => { const receiver = or(eq('name', 'a'), eq('name', 'b')); - const scoped = receiver.and(eq('realm.id', 'master')); + const condition = eq('realm.id', 'master'); + const scoped = receiver.and(condition); expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(scoped.value).toHaveLength(2); expect(scoped.value[0]).toBe(receiver); - expect(scoped.value[1].sealed).toBe(true); + expect(scoped.value[1]).toBe(condition); }); it('should retain a later condition alongside an injected condition', () => { @@ -231,8 +227,7 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { const scoped = defineQuery().filters.and(scope); const hostile = defineQuery({ filters: { 'realm.id': 'evil' } }); - // flatten() collapses same-operator nests; the seal rides on the - // node, so normalizing cannot turn the scope displaceable. + // flatten() collapses same-operator nests while retaining both leaves. const flat = scoped.flatten(); expect(conditions(hostile.filters.merge(flat))).toEqual([ @@ -244,12 +239,12 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { it('should keep an empty receiver out of the group', () => { const query = defineQuery(); - // an empty receiver constrains nothing — as an OR child it would + // an empty receiver constrains nothing; as an OR child it would // widen the group to everything. const scoped = query.filters.and(eq('realm.id', 'master')); expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(conditions(scoped)).toEqual([['realm.id', 'eq', 'master']]); - expect(scoped.value[0].sealed).toBe(true); + expect(scoped.value[0]).toBeDefined(); const alternatives = query.filters.or(eq('name', 'a'), eq('name', 'b')); expect(alternatives.operator).toBe(FilterCompoundOperator.OR); @@ -408,99 +403,3 @@ describe('src/parameter/filters/preserve.ts', () => { .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); }); - -describe('src/parameter/filters/helpers/module.ts seal', () => { - it('should seal a leaf condition immutably', () => { - const condition = eq('a', 1); - const sealed = seal(condition); - - expect(condition.sealed).toBeUndefined(); - expect(sealed.sealed).toBe(true); - expect(sealed.field).toBe('a'); - expect(sealed.operator).toBe('eq'); - expect(sealed.value).toBe(1); - - // idempotent — an already sealed condition is returned as is. - expect(seal(sealed)).toBe(sealed); - }); - - it('should delegate to the node, which knows how to copy itself', () => { - const leaf = eq('a', 1); - const group = or(eq('a', 1), eq('b', 2)); - - // the helper is a dispatcher — each node kind owns its own copy. - const sealedLeaf = leaf.seal(); - const sealedGroup = group.seal(); - - expect(sealedLeaf.sealed).toBe(true); - expect(sealedGroup.sealed).toBe(true); - - // idempotent: an already sealed node returns itself. - expect(sealedLeaf.seal()).toBe(sealedLeaf); - expect(sealedGroup.seal()).toBe(sealedGroup); - expect(seal(leaf)).toEqual(sealedLeaf); - }); - - it('should seal a group without touching its interior', () => { - const group = or(eq('a', 1), eq('b', 2)); - const sealed = seal(group); - - expect(group.sealed).toBeUndefined(); - expect(sealed.sealed).toBe(true); - expect(sealed.value).toEqual(group.value); - }); - - it('should keep a sealed condition undisplaceable through a merge', () => { - const left = new Filters(FilterCompoundOperator.AND, [eq('realm_id', 'evil')]); - const right = new Filters(FilterCompoundOperator.AND, [seal(eq('realm_id', 'master'))]); - - expect(conditions(left.merge(right))).toEqual([ - ['realm_id', 'eq', 'evil'], - ['realm_id', 'eq', 'master'], - ]); - }); - - it('should not adopt the conditions of a sealed receiver', () => { - const receiver = seal(and(eq('a', 1))); - - // adopting them into a fresh (unsealed) group would strip the - // protection its children rely on, so it stays a child instead. - const output = receiver.and(eq('b', 2)); - - expect(output.value).toHaveLength(2); - expect(output.value[0]).toBe(receiver); - expect(output.value[1].sealed).toBe(true); - }); - - it('should treat a sealed root as one inert conjunct', () => { - const sealedRoot = seal(and(eq('a', 1))); - const other = new Filters(FilterCompoundOperator.AND, [eq('a', 9)]); - - // decomposing it would expose its (unsealed) conditions to - // per-field replace. - const output = other.merge(sealedRoot); - - expect(output.value).toHaveLength(2); - expect(output.value[1]).toBe(sealedRoot); - }); - - it('should leave detached runtime data without a seal method untouched', () => { - const detached = { operator: 'and', value: [] }; - - expect(seal(detached as unknown as ICondition)).toBe(detached); - }); - - it('should not hoist a sealed group out of its parent', () => { - const inner = seal(and(eq('a', 1))); - const outer = and(eq('b', 2), inner); - - const flat = outer.flatten(); - - // hoisting would strip the protection its children rely on. - expect(flat.value).toHaveLength(2); - expect(flat.value[1]).toBe(inner); - - // a sealed root keeps its marker across normalization. - expect(seal(and(and(eq('a', 1)))).flatten().sealed).toBe(true); - }); -}); diff --git a/packages/core/test/unit/parser/parameter/key-validation.spec.ts b/packages/core/test/unit/parser/parameter/key-validation.spec.ts index d2ffffd07..c6291d47c 100644 --- a/packages/core/test/unit/parser/parameter/key-validation.spec.ts +++ b/packages/core/test/unit/parser/parameter/key-validation.spec.ts @@ -53,15 +53,8 @@ function fieldsOptions( } class CustomCondition extends Condition<{ scope: string }> { - constructor( - value: { scope: string }, - sealed?: boolean, - ) { - super('custom', value, { sealed }); - } - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); + constructor(value: { scope: string }) { + super('custom', value); } } From 44a554a95bbe08e6cb45b1e2f020ca7c6cd64762 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 11:28:53 +0200 Subject: [PATCH 07/13] docs: explain conjunctive query composition --- .agents/architecture.md | 4 +- .agents/structure.md | 4 +- README.md | 2 +- packages/core/README.md | 4 +- packages/docs/guide/building-queries.md | 20 ++--- packages/docs/guide/errors.md | 4 +- packages/docs/guide/filters.md | 8 +- packages/docs/guide/merging-queries.md | 95 ++++++++++---------- packages/docs/guide/recipes/authorization.md | 16 ++-- packages/docs/guide/recipes/frontend.md | 8 +- packages/docs/guide/relations.md | 4 +- packages/docs/packages/codec-url.md | 2 +- packages/docs/packages/core.md | 4 +- 13 files changed, 86 insertions(+), 89 deletions(-) diff --git a/.agents/architecture.md b/.agents/architecture.md index 5f3b15007..f2ac62c3b 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -28,8 +28,8 @@ createURLCodec().encode (@rapiq/codec-url) The `Query` AST is an **intermediate representation (IR)**. Every package plays exactly one role around it: -1. **Define & interact** — client-side construction (plan 012): `defineQuery(QueryBuildInput)` + per-parameter `define*` fragment factories desugar typed input (scalars → `eq`, bare arrays → `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST — schema-free, no parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` → `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. `ICondition` is the open, structural filter extension contract: every live condition provides polymorphic immutable `seal()`, whose public return remains an interface. Visitor dispatch is intentionally not required; built-in `Filter` / `Filters` retain specialized visitors and are identified through that dispatch rather than member names, while custom kinds may define their own visitor contract. Helpers and injection accept `ICondition` directly; custom kinds remain opaque to built-in flatten/merge logic and require a consumer that understands them. Detached `{ operator, value }` data is not an `ICondition` until rehydrated. Queries compose immutably via `mergeQueries` (left-priority; fields/relations/sorts keyed by name, pagination per-property) and the `Filters` combinators: `merge()` = per-field replace over the displaceable leaves of a root-AND, total (issue #875 — a sealed condition, a nested group or a non-AND root is inert: never displaced, always and-ed in, so a merge only ever narrows); `and()`/`or()` = wrap & inject, sealing every injected condition (server scoping — displaceability rides on the node as `ICondition.sealed`, so no normalization pass can strip it; `flatten()` never hoists a sealed group, and `seal()` exposes the marker for validator residuals). The seal is server-side only: no wire dialect carries it. `$and`/`$or` object keys stay reserved for the mongo parser dialect (`@rapiq/parser-mongo`). `QueryBuilder` was removed — `defineQuery` replaces it. -2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept it, replace it with any `ICondition` (leaf or compound, issue #840 — per-leaf policy residuals like `and(, seal())` stay attached to the leaf; the simple URL dialect then throws its usual typed `FEATURE_UNSUPPORTED` on encode, and a later `merge()` carries the group through as one inert conjunct — seal the SCOPE, not the group, so the marker survives a `flatten()` while the client's own leaf stays prunable by the relations gate, issue #877) or reject it, all without flattening compound structure. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. +1. **Define & interact**: client-side construction (plan 012). `defineQuery(QueryBuildInput)` plus per-parameter `define*` fragment factories desugar typed input (scalars to `eq`, bare arrays to `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST, with no schema or parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` to `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. `ICondition` is the open structural filter extension contract. A live condition is identified by `CONDITION_MARKER`, supplied by extending `Condition` or by a structural implementation carrying the marker with its `operator` and `value`; detached `{ operator, value }` data is not an `ICondition` until rehydrated. Visitor dispatch is intentionally not required; built-in `Filter` / `Filters` retain specialized visitors and are identified through that dispatch rather than member names, while custom kinds may define their own visitor contract. Helpers and injection accept `ICondition` directly; custom kinds remain opaque to built-in flatten/merge logic and require a consumer that understands them. Queries compose immutably via `mergeQueries`: fields, relations and sorts use keyed left priority; pagination uses per-property left priority; filters use monotonic ordered logical conjunction, so every predicate survives. `and()` / `or()` build explicit condition trees, and complete queries are never OR-composed. `preserve()` is a separate built-in pruning-policy wrapper, not a composition rule: it keeps a condition subtree atomic for relation pruning and carries custom conditions through its wrapper. `$and`/`$or` object keys stay reserved for the mongo parser dialect (`@rapiq/parser-mongo`). `QueryBuilder` was removed; `defineQuery` replaces it. +2. **Parse to IR**: parsers transform a dialect, such as simple object shapes or expression strings, into the IR and validate it against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept it, replace it with any `ICondition` (leaf or compound, issue #840). A per-leaf policy residual is `and(, preserve())`: preserve the residual, not the client-containing group, so only a residual that itself needs a rejected relation raises `SCHEMA_PRESERVED_CONDITION_PRUNED`. Preservation is independent from filter merge and condition identity. The simple URL dialect throws its usual typed `FEATURE_UNSUPPORTED` when encoding compounds. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. 3. **Consume the IR** — either interpret/walk it directly (`@rapiq/adapter-sql`, `@rapiq/adapter-typeorm` via visitors; `@rapiq/adapter-prisma`/`@rapiq/adapter-drizzle` serialize it into plain args/config objects; `@rapiq/adapter-memory` compiles it into plain functions to evaluate in-memory objects/arrays), or… 4. **Transport the IR between application boundaries via a codec** — `@rapiq/codec-url` owns the complete HTTP URL wire format. The public `URLCodec` façade accepts a raw query string or a pre-parsed query object (Express `req.query`), maps wire names (`URLParameter`: `filter`, `page`, `include`, …) to canonical parameters and delegates to internal expression/simple strategies. Encoding writes stamped expression filters by default. Decoding dispatches stamped payloads and recognizes untagged expression strings or legacy simple bracket filters, so v2 follows a read-both/write-expression migration. App2 then works with the same IR. diff --git a/.agents/structure.md b/.agents/structure.md index b6aab827f..3aca3994f 100644 --- a/.agents/structure.md +++ b/.agents/structure.md @@ -52,12 +52,12 @@ Changes to `@rapiq/core` affect every other package. packages/core/src/ ├── parameter/ # Query AST node classes + visitor interfaces │ ├── fields/ # Fields/Field (include/exclude operators) -│ ├── filters/ # Filters (compound and/or) + Filter (field-op-value condition) +│ ├── filters/ # Filters (compound and/or) + Filter (field-op-value condition); CONDITION_MARKER/Condition identity and preserve() pruning wrapper │ │ └── helpers/ # typed condition helpers (eq, gte, inArray, and, or, …) │ ├── pagination/ # Pagination (limit/offset) │ ├── relations/ # Relations/Relation │ ├── sorts/ # Sorts/Sort (asc/desc) -│ ├── merge.ts # mergeQueries (left-priority IR merge; per-node merge/and/or methods live on the node classes) +│ ├── merge.ts # mergeQueries: keyed left priority for fields/relations/sorts, per-property pagination, monotonic filter conjunction │ └── module.ts # Query, IQueryVisitor (queries are built via defineQuery or parsed) ├── build/ # typed build layer: defineQuery + per-parameter define* factories │ └── parameter/ # Build*Input types + defineFields/defineFilters/… (schema-free, direct-to-AST) diff --git a/README.md b/README.md index 3016dbcb5..128ec361a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ which **relations**, which **page**, which **order**. rapiq turns them into one instead of ad-hoc string parsing. - 🧭 **Typed end to end**: every field path in `defineQuery` is checked against the record type; condition helpers (`eq`, `gte`, `and`, `or`, …) replace magic value strings. -- 🛡️ **The receiving side has the last word**: a `Schema` declares what a caller may request per parameter (allow-lists, defaults, mappings). Invalid input is dropped or rejected according to the parser dialect and schema policy, and injected conditions (`query.filters.and(...)`) can't be displaced by caller input. +- 🛡️ **The receiving side has the last word**: a `Schema` declares what a caller may request per parameter (allow-lists, defaults, mappings). Invalid input is dropped or rejected according to the parser dialect and schema policy, and server conditions (`query.filters.and(...)`) remain conjuncts alongside caller input. - 🔁 **Loss-free transport**: within each codec dialect, `decode(encode(query))` restores the same query; outside its subset, encoding fails loudly with a typed error instead of silently changing semantics. - 🔌 **Any backend**: the same AST executes everywhere: parameterized SQL fragments with presets for Postgres, MySQL, SQLite, MSSQL & Oracle, a TypeORM `SelectQueryBuilder`, a Prisma `findMany` args object, a drizzle relational-queries config, or compiled functions over in-memory data. - 📦 **Composable packages**: no monolith, install only what each side needs; `@rapiq/core` is the single shared foundation. diff --git a/packages/core/README.md b/packages/core/README.md index 2666388b4..761e05298 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -33,7 +33,7 @@ Part of [**rapiq**](https://github.com/tada5hi/rapiq). Typed REST queries: *buil - 🧭 **Typed end to end**: every field path in `defineQuery` is checked against the record type; condition helpers (`eq`, `gte`, `and`, `or`, …) replace magic value strings. - 🌳 **One AST, many consumers**: the same `Query` node graph is walked into SQL, a TypeORM builder or in-memory predicates through the visitor pattern; core never changes when a backend is added. - 🛡️ **Schema = the receiving side's allow-list**: declare per-parameter `allowed` / `default` / `mapping`; parsers and codecs validate against it and drop or throw per policy. -- 🧩 **Composable & immutable**: `mergeQueries` (left priority) and the `Filters` combinators (`merge`, `and`, `or`) let a gateway scope a query without a client being able to displace injected conditions. +- 🧩 **Composable & immutable**: `mergeQueries` uses keyed left priority for fields, relations and sorts, per-property left priority for pagination, and ordered logical AND for filters. The `Filters` combinators (`merge`, `and`, `or`) let a gateway add scope without losing predicates. ## Installation @@ -59,7 +59,7 @@ const query = defineQuery({ }); ``` -Filters accept scalars (`{ name: 'John' }`), bare arrays (`in`, `null` is a legal element), `$`-operator objects (`{ age: { $gte: 18 } }`) and condition helpers (`eq`, `gte`, `inArray`, `and`, `or`, …). Queries compose immutably with `mergeQueries` (left priority) and the `Filters` combinators (`merge`, `and`, `or`). +Filters accept scalars (`{ name: 'John' }`), bare arrays (`in`, `null` is a legal element), `$`-operator objects (`{ age: { $gte: 18 } }`) and condition helpers (`eq`, `gte`, `inArray`, `and`, `or`, …). Queries compose immutably with `mergeQueries`: filters are ordered logical AND, while keyed parameters use left priority. Use `or(...)` inside a filter tree for alternatives. ### Declare what a caller may request diff --git a/packages/docs/guide/building-queries.md b/packages/docs/guide/building-queries.md index db2e26d2c..ed4abc727 100644 --- a/packages/docs/guide/building-queries.md +++ b/packages/docs/guide/building-queries.md @@ -81,24 +81,16 @@ const query = defineQuery({ }); ``` -`eq` `ne` `lt` `lte` `gt` `gte` `inArray` `nin` `startsWith` `notStartsWith` `endsWith` `notEndsWith` `contains` `notContains` `regex` `mod` `size` `exists` `elemMatch`, plus the `and` / `or` / `not` compounds. `not(condition)` is the exact complement of its interior (see [Negation](/guide/filters#negation)); multiple arguments negate their conjunction. `seal(condition)` marks a condition undisplaceable by a later [`merge()`](/guide/merging-queries#seal-conditions-that-resist-replacement). +`eq` `ne` `lt` `lte` `gt` `gte` `inArray` `nin` `startsWith` `notStartsWith` `endsWith` `notEndsWith` `contains` `notContains` `regex` `mod` `size` `exists` `elemMatch`, plus the `and` / `or` / `not` compounds. `not(condition)` is the exact complement of its interior (see [Negation](/guide/filters#negation)); multiple arguments negate their conjunction. Use [`preserve()`](/guide/merging-queries#preservation-is-for-relation-pruning) only when a relation-pruning policy must retain a server-authored residual. #### Custom conditions ```typescript -import type { ICondition } from '@rapiq/core'; -import { and, eq } from '@rapiq/core'; +import { CONDITION_MARKER, Condition, and, eq } from '@rapiq/core'; -class GeoCondition implements ICondition<[number, number]> { - readonly operator = 'geo'; - - constructor( - readonly value: [number, number], - readonly sealed?: boolean, - ) {} - - seal(): ICondition<[number, number]> { - return this.sealed ? this : new GeoCondition(this.value, true); +class GeoCondition extends Condition<[number, number]> { + constructor(value: [number, number]) { + super('geo', value); } } @@ -108,7 +100,7 @@ const filters = and( ); ``` -`ICondition` is open and structural: a custom condition does not need to extend `Condition`, `IFilter`, or `IFilters`, and visitor dispatch is optional. Its `seal()` method returns the `ICondition` interface for an immutable protected copy, not the concrete implementation type. A custom condition may expose its own visitor contract when useful, and it needs a parser, adapter, or other consumer that understands its semantics. Built-in adapters intentionally reject unknown condition kinds. +`ICondition` is open and structural: extending `Condition` supplies the live-condition marker, but a custom class may instead implement `ICondition` with `readonly [CONDITION_MARKER] = true`. A condition must have the marker, an `operator`, and a `value`; detached serialized data is not a live condition. Visitor dispatch is optional. A custom condition may expose its own visitor contract when useful, and it needs a parser, adapter, or other consumer that understands its semantics. Built-in adapters intentionally reject unknown condition kinds. Use `isCondition(value)` to recognize the shared live contract. `isFilter(value)` and `isFilters(value)` deliberately identify only the built-in leaf and group kinds. diff --git a/packages/docs/guide/errors.md b/packages/docs/guide/errors.md index f1459a219..13cb13952 100644 --- a/packages/docs/guide/errors.md +++ b/packages/docs/guide/errors.md @@ -37,7 +37,7 @@ BaseError { code: ErrorCode } `MergeError` with `FIELDS_CONDITION_DISCARDED`: a fields merge collision would drop a [row-scoped visibility gate](/guide/fields#row-scoped-fields). Keep the gated query as the receiver instead of merging it underneath an ungated one. -Filters have no such code: [`merge()`](/guide/merging-queries#merge-per-field-replace) is total. A condition it cannot replace soundly (a group, a sealed condition) is and-ed in, so composition narrows instead of failing. +Filters have no such code: [`merge()`](/guide/merging-queries#filters-monotonic-conjunction) is total. It composes every predicate with ordered logical AND, so composition narrows instead of failing. ### Parse time (client input) @@ -83,7 +83,7 @@ The URL encoders throw these too; a codec never silently changes what a query me | `SCHEMA_NAME_INVALID` | `registry.add()` with a schema that has no `name` | | `SCHEMA_UNRESOLVABLE` | `registry.getOrFail()` for a name that isn't registered | | `SCHEMA_KEY_VALIDATOR_CONFLICT` | a `fields`/`relations`/`sort` sub-schema declares both [`validate` and `validateMany`](/guide/schemas#batched-validation-with-validatemany); thrown while the schema is constructed, since there is no sensible precedence between them | -| `SCHEMA_SEALED_CONDITION_PRUNED` | the [relations gate](/guide/relations#validate-hooks) rejected a relation that a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition needs; the two validators contradict each other, see [scoping a filterable field](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace) | +| `SCHEMA_PRESERVED_CONDITION_PRUNED` | the [relations gate](/guide/relations#validate-hooks) rejected a relation that a [`preserve()`](/guide/merging-queries#preservation-is-for-relation-pruning) filter condition needs; the two validators contradict each other, see [scoping a filterable field](/guide/recipes/authorization#scoping-server-conditions) | | `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` | `parse()` (or a synchronous codec method) encountered an async validator (a filter validator or a key validation hook); use the corresponding `Async` method | | `SCHEMA_ENTITY_MISMATCH` | `assertSchemaMatchesEntity` (`@rapiq/adapter-typeorm`) found schema keys unknown to the entity; thrown as `SchemaEntityMismatchError`, which carries the offending `schema`, `entity` and `keys`; see [validating schemas against entities](/packages/adapter-typeorm#validating-schemas-against-entities) | diff --git a/packages/docs/guide/filters.md b/packages/docs/guide/filters.md index 4148fb658..96ebf9a88 100644 --- a/packages/docs/guide/filters.md +++ b/packages/docs/guide/filters.md @@ -65,7 +65,7 @@ filter[id]=1,2,3 in list filter[email]=!null not null ``` -The v2 codec continues to decode this shape for existing clients. Multiple keys combine with **and**. Scalar coercion applies on decode: `'18'` → `18`, `'true'` → `true`, `'null'` → `null`. +The v2 codec continues to decode this shape for existing clients. Multiple keys combine with **and**. Scalar coercion applies on decode: `'18'` → `18`, `'true'` → `true`, `'null'` → `null`. The deprecated simple codec rejects duplicate same-field conditions and compound filters; use the default expression codec whenever a query needs either shape. ::: info Wire strings vs. code The `!`/`<`/`~` prefixes are the *wire* format. In code, use typed operator objects (`{ age: { $gte: 18 } }`) or condition helpers, never magic strings. @@ -207,11 +207,11 @@ defineSchema({ | `validate` | Sync or async per-filter hook: inspect a parsed `Filter`, replace it with any condition, or reject it. Receives the [parse context](/guide/schemas#validate-hooks-parse-context) as its second argument. | | `caseSensitive` | Fields whose equality comparisons stay exact instead of the [case-insensitive default](#case-sensitivity). | -`validate` runs after key resolution, mapping and value coercion, and receives the caller-supplied [`context`](/guide/schemas#validate-hooks-parse-context) (e.g. the authenticated actor) as its second argument. Return the original filter to accept it, another condition to replace it, or `undefined` to reject that leaf; an inspect-only hook must still `return` the filter, otherwise every leaf is rejected. The replacement may be any condition, including a compound: an authorization decision like "you may filter on `realm_id`, but only within your realms" stays attached to the leaf that triggered it, `and(filter, seal(inArray('realm_id', actor.realmIds)))`, instead of being injected separately after the parse. `$elemMatch` conditions are validated inside-out: every interior leaf passes the hook, then the `elemMatch` filter itself. The return value may also be a Promise of any of those results. On the server, [schema-aware encoding](/packages/codec-url) re-runs the schema-bound decoder, so a validator that is not idempotent (e.g. one that appends to the value) transforms a filter twice between a schema-aware `encode()` and the receiving `decode()`: keep validators idempotent. +`validate` runs after key resolution, mapping and value coercion, and receives the caller-supplied [`context`](/guide/schemas#validate-hooks-parse-context) (e.g. the authenticated actor) as its second argument. Return the original filter to accept it, another condition to replace it, or `undefined` to reject that leaf; an inspect-only hook must still `return` the filter, otherwise every leaf is rejected. The replacement may be any condition, including a compound: an authorization decision like "you may filter on `realm_id`, but only within your realms" stays attached to the leaf that triggered it, `and(filter, preserve(inArray('realm_id', actor.realmIds)))`, instead of being injected separately after the parse. `$elemMatch` conditions are validated inside-out: every interior leaf passes the hook, then the `elemMatch` filter itself. The return value may also be a Promise of any of those results. On the server, [schema-aware encoding](/packages/codec-url) re-runs the schema-bound decoder, so a validator that is not idempotent (e.g. one that appends to the value) transforms a filter twice between a schema-aware `encode()` and the receiving `decode()`: keep validators idempotent. -Two consequences of a compound replacement mirror [`and()` injection](/guide/merging-queries#and-or-wrap-inject). A later replace-merge carries the group through as one conjunct instead of displacing the injected scoping, and [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement)ing it keeps that true across a normalization pass. And the legacy simple URL dialect cannot express compounds: a schema-aware `encode()` through that dialect throws a typed `FEATURE_UNSUPPORTED` for a query whose validator produced one; the default expression dialect encodes it fine. +All filter composition is ordered logical AND, so a later merge carries the complete replacement as a conjunct and never removes it. The deprecated simple URL dialect cannot express compounds: schema-aware encoding through it throws `FEATURE_UNSUPPORTED` for a query whose validator produced one; the default expression dialect encodes it. -Seal the **residual**, not the whole group. Both shapes survive a merge, because a group is never displaced as a unit either, but the seal is what keeps the residual protected once `flatten()` hoists it into the root, and keeping the client's own leaf outside it matters for [relation pruning](/guide/relations#validate-hooks): a sealed condition is exempt from the gate, so `seal(and(filter, ...))` also protects the client leaf it wraps, and a client filtering on a relation it may not traverse then hits `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead of having that filter dropped. With `and(filter, seal(...))` the client leaf prunes away normally and only a residual that itself names a rejected relation raises the error, which is the genuine contradiction. An unsealed replacement is displaceable throughout and gets pruned like any other condition. +Preserve the **residual**, not the whole group. `preserve(and(filter, ...))` also keeps the client leaf from being pruned by a rejected relation. With `and(filter, preserve(...))`, the client leaf prunes normally and only a residual that itself needs a rejected relation raises `SchemaError` (`ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED`). That is the genuine validator contradiction. `preserve()` exists for this relation-pruning policy, not filter composition. Use the synchronous `parse()` / `decode()` / schema-aware `encode()` methods when every validator is synchronous. Use their `Async` counterparts when a validator may be asynchronous: diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index 761569b46..3654a4cfe 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -1,8 +1,8 @@ # Merging & Composition -Real queries rarely come from one place. A list view combines **user input** (a search box), **component state** (the current page), **props** (a parent-imposed scope) and **application defaults**, and the server may add conditions of its own. rapiq composes all of these on the `Query` itself, so every construction path (built, decoded, parsed by any dialect) combines the same way. +Real queries rarely come from one place. A list view combines user input, component state, parent scope and application defaults. rapiq composes these values on the `Query` itself, so built, decoded and parsed queries follow the same rules. -## `mergeQueries`: left priority +## `mergeQueries`: left priority where keys conflict ```typescript import { mergeQueries } from '@rapiq/core'; @@ -10,90 +10,93 @@ import { mergeQueries } from '@rapiq/core'; const query = mergeQueries(searchQuery, paginationQuery, propsQuery, defaultsQuery); ``` -The first argument wins conflicts. All operations are **immutable**: inputs stay untouched and new instances are returned, safe for reactivity systems and shared default fragments. +All operations are immutable. Inputs stay untouched and the result is a new query when composition changes it. Per-parameter semantics: | Parameter | Rule | |---|---| -| `fields` / `relations` / `sorts` | keyed by name, left-priority replace; order = first occurrence | +| `fields` / `relations` / `sorts` | keyed left priority | | `pagination` | per-property left priority (`limit` and `offset` merge independently) | -| `filters` | per-field replace via `Filters.merge` (see below) | +| `filters` | ordered logical AND; every predicate survives | -One guard applies to `fields`: a name collision that would discard a [row-scoped visibility gate](/guide/fields#row-scoped-fields) (`Field.condition`) throws a `MergeError` (`ErrorCode.FIELDS_CONDITION_DISCARDED`) instead of silently un-gating the column. A gate only ever arises server-side, from a schema hook; keep that parsed query as the receiver and its gates survive every collision. +For fields, a name collision that would discard a [row-scoped visibility gate](/guide/fields#row-scoped-fields) (`Field.condition`) throws `MergeError` (`ErrorCode.FIELDS_CONDITION_DISCARDED`) instead of silently un-gating the column. -## Filters: two explicit operations +## Filters: monotonic conjunction -Filters get two distinct operations, because a single "merge" would silently break one of the two use cases. - -### `merge()`: per-field replace - -A condition on a field **replaces** the other side's conditions on the same field (it is *not* and-ed). This is the list-view case: user search input overrides a default on the same field, while unrelated defaults survive: +`Filters.merge()` and `mergeQueries()` combine filters as an ordered logical AND. No predicate is replaced, including conditions on the same field: ```typescript -const searchQ = defineQuery({ filters: { name: { $contains: input } } }); -const defaultsQ = defineQuery({ filters: { name: 'John', age: { $gte: 18 } } }); +const clientQuery = defineQuery({ filters: { age: { $gte: 18 } } }); +const serverQuery = defineQuery({ filters: { age: { $lt: 65 } } }); -mergeQueries(searchQ, defaultsQ).filters; -// name contains (from searchQ), age >= 18 (from defaultsQ) +mergeQueries(clientQuery, serverQuery).filters; +// and(age >= 18, age < 65) ``` -Replacement applies to the **displaceable root conditions** of both sides: the leaf conditions directly under a root `and`. Everything else is inert, carried through as it is and combined with `and`: +The receiver's non-preserved root `and` is flattened into its conjuncts. A preserved root, an `or(...)` or `not(...)` root remains one conjunct, so its meaning stays intact. An empty side passes the other side through unchanged. -| Conjunct | Behavior in a merge | -|---|---| -| root-level leaf condition | replaced by a receiver condition on the same field | -| [sealed](#seal-conditions-that-resist-replacement) condition | never replaced, always carried through | -| nested group (`or(...)`, `not(...)`, a whole non-`and` root) | never replaced, always carried through as one conjunct | +This monotonic rule is useful for server scope: a client condition on `realm_id` can only narrow the server's `realm_id` condition, never widen or remove it. -```typescript -mergeQueries(flatQ, defineQuery({ filters: or(gte('age', 18), eq('email', null)) })).filters; -// and( , or(age >= 18, email = null) ) -``` +Complete queries are never OR-composed. When a filter needs alternatives, express them inside the filter tree with `or(...)`: -Per-field replace has no sound reading inside a disjunction: dropping a condition there would change the group non-locally, keeping it would ignore receiver priority. So the group is and-ed in untouched, which makes `merge()` **total**: it never throws. Every conjunct of the receiver survives into the result, so a merge never returns anything wider than its receiver; only the other side can lose conditions, and only to same-field replacement. Widening stays the explicit operation, `or()` below. +```typescript +import { defineQuery, eq, gte, or } from '@rapiq/core'; -An empty side passes the other side through unchanged. +const query = defineQuery({ + filters: or(gte('age', 18), eq('status', 'admin')), +}); +``` -### `seal`: conditions that resist replacement +### Replacing UI state -A **sealed** condition is never displaced by a merge and never collapsed into its parent group by `flatten()`. It is what `and()` / `or()` apply to everything they inject, and it is available on its own: +`mergeQueries` deliberately does not provide replacement semantics for filters. Replace transient UI state before building the query, or select the current filters node before query composition: ```typescript -import { eq, seal } from '@rapiq/core'; +const currentSearch = search.length > 0 ? + { name: { $contains: search } } : + undefined; -const scope = seal(eq('realm_id', actor.realmId)); +const userQuery = defineQuery({ filters: currentSearch }); +const query = mergeQueries(userQuery, parentScope, defaults); ``` -Sealing is immutable (a sealed copy is returned) and idempotent. It is a **server-side composition marker, not wire grammar**: a sealed condition that is encoded to a URL and decoded again comes back displaceable, which is why a receiving service re-injects its own scope instead of trusting the transport. - -Every live `ICondition` owns this operation through `seal()`. The standalone helper and `Filters.and()` / `Filters.or()` call that method polymorphically, so custom structural conditions receive the same non-displaceability guarantee as built-in leaves and groups. - -`ICondition` describes a live AST object, not serialized `{ operator, value }` data: it requires an immutable `seal()` operation. After JSON, RPC, or cache transport, parse, decode, or rebuild the condition before composition. Runtime data admitted through JavaScript, `any`, or an explicit cast is left unchanged when no callable `seal` exists; a built-in adapter rejects it rather than silently dropping it. A live custom condition is also rejected unless the consumer understands that custom kind. +For state already represented as AST nodes, choose the current `IFilters` value first, then place that value in `defineQuery`. This makes replacement a UI-state decision rather than a hidden query-composition rule. -The marker protects the whole subtree it heads, and it holds during parsing too: the [relations gate](/guide/relations#validate-hooks), which drops every key traversing a rejected relation, cannot silently prune a condition out of a sealed group. A rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead. Subtree-wide protection is why a [policy residual](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace) seals the *scope* rather than the group around it: sealing the group would protect the client's own condition too. +### `and()` / `or()`: build a condition tree -### `and()` / `or()`: wrap & inject - -Always defined, for combining condition trees. The injected conditions are sealed, so they are part of the tree rather than candidates for replacement. A receiver already carrying that operator contributes its own conditions to the group (which keeps them mergeable); anything else becomes a child of the new group. Calling `and()` / `or()` with no conditions returns the receiver unchanged. +`and()` and `or()` wrap condition trees immutably. Calling either method with no conditions returns the receiver unchanged. ```typescript import { Query, eq } from '@rapiq/core'; -// server-side, after parsing client input: const scoped = new Query({ ...query, filters: query.filters.and(eq('realm_id', actor.realmId)), }); ``` -The adapter output now contains the injected condition regardless of what the client sent, even when the client sent no filters at all. And because the injected condition is sealed, no later composition can drop it: a `merge()` carries it through and a client condition on the same field narrows within it, while `flatten()` and any other normalization leave the marker intact. That guarantee is unconditional, and it is a feature: injected security conditions cannot be merged away. See the [authorization recipe](/guide/recipes/authorization). +The scope is part of the resulting AND tree regardless of client input. A later query merge adds more conjuncts, so security scopes cannot be merged away. + +## Preservation is for relation pruning + +`preserve()` is not a merge or override tool. It marks a built-in condition subtree for the relation-pruning policy. If a relation validator rejects a relation that a preserved condition needs, parsing raises `SchemaError` (`ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED`) rather than silently changing the policy's meaning. + +Use it for a validator residual that must remain intact, and preserve the residual only: + +```typescript +import { and, inArray, preserve } from '@rapiq/core'; + +return and(filter, preserve(inArray('realm_id', actor.realmIds))); +``` + +Do not preserve the group containing the client filter. The client leaf must remain eligible for relation pruning; preserving only the server residual keeps the error reserved for a genuine contradiction in the residual itself. See [Relations](/guide/relations#validate-hooks) and [Authorization](/guide/recipes/authorization#scoping-server-conditions). ## Why not deep-merge input objects? -Generic object merging (smob, deepmerge, spread) on query *input* cannot express these semantics: same-field replace, null-preserving `in` arrays, injected conditions that resist displacement. Merge on the query instead: build fragments with [`defineQuery` / `define*`](/guide/building-queries#fragments), compose with `mergeQueries`, then encode or execute the result. +Generic object merging cannot express the AST rules: keyed left priority for fields, relations and sorts; per-property pagination; and ordered conjunction for filters. Build fragments with [`defineQuery` / `define*`](/guide/building-queries#fragments), compose them with `mergeQueries`, then encode or execute the result. ## Next steps -- [Recipes: Type-safe frontend queries](/guide/recipes/frontend): merging user input, props and defaults in a list view. -- [Recipes: Authorization & scoping](/guide/recipes/authorization): `and()` injection end-to-end. +- [Recipes: Type-safe frontend queries](/guide/recipes/frontend): UI state, scope and defaults. +- [Recipes: Authorization & scoping](/guide/recipes/authorization): server conditions and validator residuals. diff --git a/packages/docs/guide/recipes/authorization.md b/packages/docs/guide/recipes/authorization.md index 8536eb761..7a5a9a118 100644 --- a/packages/docs/guide/recipes/authorization.md +++ b/packages/docs/guide/recipes/authorization.md @@ -41,10 +41,10 @@ Hooks run against the **target schema** of each path segment (`include=client.re The `relations` hook is not scoped to `include=`. A dotted `filter[client.name]`, `fields[client]` or `sort=client.name` forces the same join, so the hook runs for those too, evaluated once per distinct relation across the query. An actor denied `client` cannot slip the join in through another parameter. ::: warning Authorization happens at the parse/decode boundary -The gate runs while **parsing untrusted input**: `parse()` / `decode()` and the per-parameter `decode*` helpers. It is a property of that boundary, not of the `Query` object itself: a query you assemble server-side with the schema-free [build layer](/guide/building-queries) (`defineQuery`, condition helpers) and hand straight to an adapter is **not** re-checked; the adapter joins what the query names. Feed client input through a parser/codec with a schema and `context`; keep server-authored queries (and schema `default`s) as the trusted baseline. See [Scoping](#scoping-inject-conditions-the-client-cannot-displace) for pinning server conditions the client cannot displace. +The gate runs while **parsing untrusted input**: `parse()` / `decode()` and the per-parameter `decode*` helpers. It is a property of that boundary, not of the `Query` object itself: a query you assemble server-side with the schema-free [build layer](/guide/building-queries) (`defineQuery`, condition helpers) and hand straight to an adapter is **not** re-checked; the adapter joins what the query names. Feed client input through a parser/codec with a schema and `context`; keep server-authored queries (and schema `default`s) as the trusted baseline. See [Scoping](#scoping-server-conditions) for adding server conditions. ::: -## Scoping: inject conditions the client cannot displace +## Scoping: server conditions After decoding the client's query, wrap its filters with your scope condition via `and()`: @@ -68,17 +68,17 @@ app.get('/users', async (req, res) => { }); ``` -Why `and()` and not a merge? `and()` injects the condition **sealed**, which marks it undisplaceable on the node itself: +Why `and()` and not a merge? Both produce conjunction, but `and()` makes the intent direct: - A client filter on `realm_id` narrows *within* the scope; it can never widen it. -- A later [`merge()`](/guide/merging-queries#merge-per-field-replace) carries the sealed condition through instead of replacing it, and `flatten()` (or any other normalization) leaves the marker in place. The guarantee is a property of the condition, not of the tree shape it happens to sit in, so it holds unconditionally: even when the client sent no filters at all. +- A later [`merge()`](/guide/merging-queries#filters-monotonic-conjunction) adds predicates by ordered logical AND. It cannot remove the scope, even if the client sent no filters at all. Belt and suspenders: also leave `realm_id` out of the schema's `filters.allowed` list, and clients can't even *mention* it. -When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `and(filter, seal(inArray('realm_id', actor.realmIds)))` and the policy residual stays attached to the leaf that triggered it, with the same displacement-proof effect on later merges. The [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement) is what makes the residual survive composition; without it the group is still never *replaced*, but a normalization pass may hoist its conditions into the root, where an unmarked residual becomes replaceable again. +When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `and(filter, preserve(inArray('realm_id', actor.realmIds)))`. The policy residual stays attached to the leaf that triggered it. `preserve()` is not needed for merge safety because every merge is conjunctive; it is needed only when the relations validator must reject a pruning contradiction. -::: tip Seal the residual, not the group -`seal(and(filter, ...))` protects the same residual, and additionally protects the client leaf it wraps, which is not something you want. A sealed condition is exempt from the relations gate above, so that shape turns "this actor may not traverse `realm`" into a thrown `SchemaError` the moment the client filters on `realm.name`, instead of simply dropping that filter. Sealing only the residual keeps the client's leaf prunable and reserves the error for the real contradiction: a residual that itself names a relation the gate rejects. Scoping through a local column (`realm_id`) never traverses a relation, so it never reaches the gate at all. +::: tip Preserve the residual, not the group +`preserve(and(filter, ...))` also protects the client leaf it wraps, which is not wanted. A preserved condition is exempt from relation pruning, so that shape turns "this actor may not traverse `realm`" into a thrown `SchemaError` when the client filters on `realm.name`, instead of dropping that client filter. Preserving only the residual keeps the client leaf prunable and reserves the error for a real contradiction: a residual that itself names a rejected relation. Scoping through a local column (`realm_id`) never traverses a relation, so it never reaches the gate. ::: ## Row-scoped column access @@ -188,5 +188,5 @@ compiled.apply(users); // apply the full query to loaded data ## Next steps -- [Merging & Composition](/guide/merging-queries): why injected trees resist merging. +- [Merging & Composition](/guide/merging-queries): conjunctive query composition and pruning preservation. - [@rapiq/adapter-memory](/packages/adapter-memory): the full in-memory semantics contract. diff --git a/packages/docs/guide/recipes/frontend.md b/packages/docs/guide/recipes/frontend.md index 560e1b6ff..2e9db7647 100644 --- a/packages/docs/guide/recipes/frontend.md +++ b/packages/docs/guide/recipes/frontend.md @@ -2,7 +2,7 @@ *Chapter 2 of the [recipes storyline](/guide/recipes/): the client layer of the realm/user API, where queries are built, composed and encoded. Next: the server baseline in [REST API with Express & TypeORM](/guide/recipes/express-typeorm).* -A list component typically owes its query to three sources at once: **defaults** it ships with, a **scope** its parent imposes via props, and **user input** from search/sort/pagination controls. This recipe composes the three declaratively: typed against the record, merged by rank, encoded on demand. +A list component typically owes its query to three sources at once: **defaults** it ships with, a **scope** its parent imposes via props, and **user input** from search/sort/pagination controls. This recipe composes the three declaratively: typed against the record, merged with explicit parameter rules, encoded on demand. Works the same in Vue, React or plain TypeScript; rapiq has no framework dependency. @@ -51,7 +51,7 @@ function buildQuery(realmId: string, search: string, page: number) { pagination: { offset: (page - 1) * 25 }, }); - // left wins: user input > scope > defaults + // keyed parameters use left priority; filters become an ordered AND return mergeQueries(userInput, defineQuery({ filters: scopeFor(realmId) }), defaults); } @@ -65,9 +65,11 @@ async function fetchUsers(realmId: string, search: string, page: number) { Three details doing quiet work here: - `{ $contains: search || undefined }`: an `undefined` operator value contributes **no condition**, so the empty search box simply doesn't filter. No `if`-shuffling around the query object. -- The merge is **per-field** for filters: the user's `name` filter never disturbs the scope's `realm.id` condition, and both survive alongside the defaults' sort and fields. +- Filters merge as an **ordered logical AND**: the user's `name` filter and the scope's `realm.id` condition both survive. Fields and sort retain keyed left priority. - Everything is **immutable**: merging never mutates its inputs, so `defaults` is safe as a module constant and fragments like the realm scope are safe to pass around as props. +When a UI control replaces a previous search or range, choose its current value before calling `defineQuery`; do not compose old and new UI values with `mergeQueries`. Once filters are part of a query, composition intentionally retains every predicate. + ## Framework flavors ::: code-group diff --git a/packages/docs/guide/relations.md b/packages/docs/guide/relations.md index 516b7aad6..98195338e 100644 --- a/packages/docs/guide/relations.md +++ b/packages/docs/guide/relations.md @@ -92,9 +92,9 @@ Two rules make the gate hard to bypass: - **Traversal counts.** The hook also gates relations reached through dotted [filter](/guide/filters), [field](/guide/fields) and [sort](/guide/sort) keys (`filter[items.id]`, `fields[items]`, `sort=items.name`), which the backends would otherwise auto-join. It runs **once per distinct relation** across the whole query, so a join has a single authorization point regardless of which parameter forced it. - **Rejection prunes deep.** Rejecting a relation drops every deeper relation reached through it and every dependent key in every parameter, at parse time; the pruned branch never enters the AST. Under [`throwOnFailure`](/guide/schemas#failure-behavior-drop-vs-throw) the rejection throws a `RelationsParseError` (`ErrorCode.KEY_VALIDATE_REJECTED`) instead. -One thing pruning never drops: a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) sealed a condition that names a relation this hook rejects, the two hooks contradict each other: pruning the condition returns a result set the policy did not describe (a *wider* one for the usual `and(, )` residual), keeping it joins a relation the gate refused. Parsing therefore throws a `SchemaError` with `ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED` rather than picking one. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace). +One thing pruning never drops: a [`preserve()`](/guide/merging-queries#preservation-is-for-relation-pruning) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) preserves a condition that names a relation this hook rejects, the two hooks contradict each other: pruning the condition returns a result set the policy did not describe (a *wider* one for the usual `and(, )` residual), keeping it joins a relation the gate refused. Parsing therefore throws a `SchemaError` with `ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED` rather than picking one. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-server-conditions). -A schema `default` filter is exempt from pruning: it is the server-authored baseline and is re-applied after the gate has run, even when it names a rejected relation. Sealing a default opts it back in, since a seal asserts the same must-survive contract as a residual and raises the same error. +A schema `default` filter is exempt from pruning: it is the server-authored baseline and is re-applied after the gate has run, even when it names a rejected relation. Preserving a default opts it back into the contradiction check, since it asserts the same must-survive contract as a residual and raises the same error. ## Interaction with other parameters diff --git a/packages/docs/packages/codec-url.md b/packages/docs/packages/codec-url.md index 4bbc051cc..45e09ba26 100644 --- a/packages/docs/packages/codec-url.md +++ b/packages/docs/packages/codec-url.md @@ -74,7 +74,7 @@ codec.encode(query, { codec: URL_SIMPLE_CODEC }); // codec=url-simple&filter[age]=>=18 ``` -The simple dialect supports flat root-AND filters only. Nested groups, repeated conditions on one field, and values that collide with its marker grammar throw during encoding. +The deprecated simple dialect supports flat root-AND filters only. Duplicate same-field conditions, compound filters, and values that collide with its marker grammar throw during encoding. ## Shared URL parameters diff --git a/packages/docs/packages/core.md b/packages/docs/packages/core.md index b06b3b503..183fcd73d 100644 --- a/packages/docs/packages/core.md +++ b/packages/docs/packages/core.md @@ -12,8 +12,8 @@ npm install @rapiq/core |---|---|---| | **Query AST** | `Query`, `Fields`/`Field`, `Filters`/`Filter`, `Relations`/`Relation`, `Sorts`/`Sort`, `Pagination`, operator constants (`FilterFieldOperator`, `FilterCompoundOperator`, `FieldOperator`, `SortDirection`, `Parameter`), visitor interfaces (`IQueryVisitor`, `IFiltersVisitor`, …), `hasFieldConditions` | [The Query AST](/guide/query-ast) | | **Build layer** | `defineQuery`, `defineFields`, `defineFilters`, `definePagination`, `defineRelations`, `defineSorts` | [Building Queries](/guide/building-queries) | -| **Condition helpers** | `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `inArray`, `nin`, `startsWith`, `notStartsWith`, `endsWith`, `notEndsWith`, `contains`, `notContains`, `regex`, `mod`, `size`, `exists`, `elemMatch`, `and`, `or`, `not`, `seal` | [Condition helpers](/guide/building-queries#condition-helpers) | -| **Composition** | `mergeQueries`, `seal` (or `.seal()` on a node), `Filters.merge` / `.and` / `.or` | [Merging & Composition](/guide/merging-queries) | +| **Condition helpers** | `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `inArray`, `nin`, `startsWith`, `notStartsWith`, `endsWith`, `notEndsWith`, `contains`, `notContains`, `regex`, `mod`, `size`, `exists`, `elemMatch`, `and`, `or`, `not`, `preserve` | [Condition helpers](/guide/building-queries#condition-helpers) | +| **Composition** | `mergeQueries`, `Filters.merge` / `.and` / `.or`; `preserve` for relation-pruning policy | [Merging & Composition](/guide/merging-queries) | | **Plan layer** | `planCondition`, `interpretPlan`, `distributeNegation`, `IPlanInterpreter`, `FILTER_OPERATOR_SEMANTICS`, plan types (`ConditionPlan`, `ComparePlan`, `CompoundPlan`, …) | [The visitor pattern](/guide/query-ast#the-visitor-pattern) | | **Schema system** | `defineSchema`, `Schema`, `SchemaRegistry`, per-parameter `define*Schema` factories, `ResolutionScope`, `Schema.describe()` | [Schemas & Validation](/guide/schemas) | | **Parser base** | `BaseParser`, per-parameter parse-option types | [Custom parsers](/guide/query-ast#writing-a-custom-parser-resolutionscope) | From 75f870690e4d2c98b96c0d94698d5795644e0664 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 11:34:07 +0200 Subject: [PATCH 08/13] docs: clarify filter replacement --- packages/docs/guide/concepts.md | 2 +- packages/docs/guide/merging-queries.md | 18 +++++++++++++++++- packages/docs/guide/recipes/express-typeorm.md | 2 +- packages/docs/guide/recipes/frontend.md | 11 +++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/docs/guide/concepts.md b/packages/docs/guide/concepts.md index 221069294..40646dc3b 100644 --- a/packages/docs/guide/concepts.md +++ b/packages/docs/guide/concepts.md @@ -108,7 +108,7 @@ import { mergeQueries } from '@rapiq/core'; const query = mergeQueries(userInput, componentDefaults, appDefaults); ``` -And the server can inject conditions a client can never displace: +And the server can add a condition that remains a conjunct alongside client input: ```typescript const scoped = new Query({ diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index 3654a4cfe..850994546 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -61,7 +61,23 @@ const userQuery = defineQuery({ filters: currentSearch }); const query = mergeQueries(userQuery, parentScope, defaults); ``` -For state already represented as AST nodes, choose the current `IFilters` value first, then place that value in `defineQuery`. This makes replacement a UI-state decision rather than a hidden query-composition rule. +For state already represented as AST nodes, choose the current `IFilters` value before query composition. For example, select the current filters against the defaults, then pass only the selected node to `defineQuery`: + +```typescript +const defaultFilters = defineFilters({ age: { $gte: 18 } }); +const currentFilters = search ? + defineFilters({ name: { $contains: search } }) : + undefined; +const selectedFilters = currentFilters ?? defaultFilters; + +const query = mergeQueries( + defineQuery({ filters: selectedFilters }), + parentScope, + defaults, +); +``` + +Putting `currentFilters` and `defaultFilters` in separate queries passed to `mergeQueries` would conjunct them. It does not replace the default. Selecting the current node first makes replacement a UI-state decision rather than a hidden query-composition rule. ### `and()` / `or()`: build a condition tree diff --git a/packages/docs/guide/recipes/express-typeorm.md b/packages/docs/guide/recipes/express-typeorm.md index 59a982201..62ff39344 100644 --- a/packages/docs/guide/recipes/express-typeorm.md +++ b/packages/docs/guide/recipes/express-typeorm.md @@ -118,6 +118,6 @@ GET /users?filter[secret]=x 400: key not allowed ## Variations -- **Scope by the authenticated user**: inject filters the client cannot displace; see [Authorization & Scoping](/guide/recipes/authorization). +- **Scope by the authenticated user**: add a server filter conjunct; see [Authorization & Scoping](/guide/recipes/authorization). - **No TypeORM**: the next chapter, [Swapping the Backend: Prisma & Drizzle](/guide/recipes/prisma-drizzle), keeps the contract, codec and routes of this recipe and changes only the execute layer. For raw SQL, swap the adapter for [`@rapiq/adapter-sql`](/packages/adapter-sql) fragments; the decode half stays identical either way. - **Migrate old clients gradually**: the codec writes expressions but continues to recognize legacy bracket-filter input; see [Queries over the Wire](/guide/wire). diff --git a/packages/docs/guide/recipes/frontend.md b/packages/docs/guide/recipes/frontend.md index 2e9db7647..e8ea7be17 100644 --- a/packages/docs/guide/recipes/frontend.md +++ b/packages/docs/guide/recipes/frontend.md @@ -38,6 +38,8 @@ const defaults = defineQuery({ pagination: { limit: 25 }, }); +const defaultFilters = defineFilters({ age: { $gte: 18 } }); + // 2. parent-imposed scope: realmId arrives as a prop / argument // (fragments are plain values, so they travel well as data) function scopeFor(realmId: string) { @@ -46,8 +48,13 @@ function scopeFor(realmId: string) { // 3. user input: from the search box & pager function buildQuery(realmId: string, search: string, page: number) { + const currentFilters = search ? + defineFilters({ name: { $contains: search } }) : + undefined; + const filters = currentFilters ?? defaultFilters; + const userInput = defineQuery({ - filters: { name: { $contains: search || undefined } }, + filters, pagination: { offset: (page - 1) * 25 }, }); @@ -64,7 +71,7 @@ async function fetchUsers(realmId: string, search: string, page: number) { Three details doing quiet work here: -- `{ $contains: search || undefined }`: an `undefined` operator value contributes **no condition**, so the empty search box simply doesn't filter. No `if`-shuffling around the query object. +- `currentFilters ?? defaultFilters` chooses the current filter node before query composition. Passing current and default filters as separate `mergeQueries` arguments would conjunct them; it would not replace the default. - Filters merge as an **ordered logical AND**: the user's `name` filter and the scope's `realm.id` condition both survive. Fields and sort retain keyed left priority. - Everything is **immutable**: merging never mutates its inputs, so `defaults` is safe as a module constant and fragments like the realm scope are safe to pass around as props. From 7925ff0bf1931fd71f8f3e034a68e1101c9a12f0 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 11:59:14 +0200 Subject: [PATCH 09/13] fix(core): protect preserved elemMatch descendants --- .../test/unit/acceptance.spec.ts | 4 +- .../test/unit/acceptance.spec.ts | 4 +- .../codec-url/test/unit/acceptance.spec.ts | 4 +- packages/core/src/parser/relation-prune.ts | 26 +++++++++- .../core/test/unit/parameter/merge.spec.ts | 22 ++++++--- .../test/unit/parser/relation-prune.spec.ts | 49 +++++++++++++++++++ .../unit/parser/relations-traversal.spec.ts | 8 +-- 7 files changed, 100 insertions(+), 17 deletions(-) diff --git a/packages/adapter-prisma/test/unit/acceptance.spec.ts b/packages/adapter-prisma/test/unit/acceptance.spec.ts index 82cddb7af..fd5b5b9b2 100644 --- a/packages/adapter-prisma/test/unit/acceptance.spec.ts +++ b/packages/adapter-prisma/test/unit/acceptance.spec.ts @@ -208,8 +208,8 @@ describe('acceptance: request query to prisma arguments', () => { const applyScoped = (input: string) => { const query = codec.decode(input, { schema: 'user' }); - // post-parse wrap & inject: a later replace-merge cannot - // displace the condition. + // post-parse injection retains the server condition as a + // conjunct alongside every client condition. const scoped = new Query({ ...query, filters: query!.filters.and(eq('realm_id', 1)), diff --git a/packages/adapter-typeorm/test/unit/acceptance.spec.ts b/packages/adapter-typeorm/test/unit/acceptance.spec.ts index 00b384c28..7f37f0e72 100644 --- a/packages/adapter-typeorm/test/unit/acceptance.spec.ts +++ b/packages/adapter-typeorm/test/unit/acceptance.spec.ts @@ -177,8 +177,8 @@ describe('acceptance: authup-style repository port (M2 gate)', () => { const applyScoped = (input: string) => { const query = codec.decode(input, { schema: 'user' }); - // plan 012 layer 3: post-parse wrap & inject — immutable, - // a later replace-merge cannot displace the condition. + // plan 012 layer 3: post-parse injection retains the server + // condition as a conjunct alongside every client condition. const scoped = new Query({ ...query, filters: query!.filters.and(eq('realm_id', masterRealmId)), diff --git a/packages/codec-url/test/unit/acceptance.spec.ts b/packages/codec-url/test/unit/acceptance.spec.ts index 15977b8b9..374f4f2ba 100644 --- a/packages/codec-url/test/unit/acceptance.spec.ts +++ b/packages/codec-url/test/unit/acceptance.spec.ts @@ -76,8 +76,8 @@ describe('gateway controller (hub archetype)', () => { it('should validate, pin a server condition and re-encode (flat forward)', () => { const query = codec.decode(requestQuery, { schema: 'log' })!; - // the gateway pins node_id with server priority: merge() is - // per-field replace, so a client-sent node_id could never win. + // the gateway pins node_id by conjunction: merge() retains the server + // constraint alongside any node_id constraint sent by the client. const forwarded = new Query({ fields: query.fields, filters: and(eq('node_id', 'node-1')).merge(query.filters), diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index ced3ace77..b1395870e 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -202,7 +202,11 @@ function pruneCondition( isCondition(node.value) ) { if (typeof rejectedBy === 'string') { - return dropUnlessPreserved(rejectedBy, field, preserved2); + return dropUnlessPreserved( + rejectedBy, + field, + preserved2 || hasPreservedBuiltInCondition(node.value), + ); } const interior = pruneCondition(node.value, rejected, field, preserved2); @@ -249,6 +253,26 @@ function isBuiltInConditionPreserved(node: ICondition) : boolean { return false; } +function hasPreservedBuiltInCondition(node: ICondition) : boolean { + if (isBuiltInConditionPreserved(node)) { + return true; + } + + if (isFilters(node)) { + return node.value.some((child) => hasPreservedBuiltInCondition(child)); + } + + if ( + isFilter(node) && + node.operator === FilterFieldOperator.ELEM_MATCH && + isCondition(node.value) + ) { + return hasPreservedBuiltInCondition(node.value); + } + + return false; +} + function buildSortDefaults(schema: SortSchema) : Sorts { const output = new Sorts(); if (!schema.default) { diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index 9b6b7511a..61ddec0ef 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -135,8 +135,8 @@ describe('src/parameter/merge.ts', () => { const output = mergeQueries(flat, nested); - // per-field replace has no meaning inside a disjunction, so the - // group is carried through as one inert conjunct. + // the disjunction remains atomic, so its alternatives keep their + // meaning while the group is carried through as one conjunct. expect(output.filters.operator).toBe(FilterCompoundOperator.AND); expect(output.filters.value).toHaveLength(2); expect((output.filters.value[0] as IFilter).field).toBe('name'); @@ -241,20 +241,30 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { // an empty receiver constrains nothing; as an OR child it would // widen the group to everything. - const scoped = query.filters.and(eq('realm.id', 'master')); + const scope = eq('realm.id', 'master'); + const scoped = query.filters.and(scope); expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(conditions(scoped)).toEqual([['realm.id', 'eq', 'master']]); - expect(scoped.value[0]).toBeDefined(); + expect(scoped.value).not.toContain(query.filters); + expect(scoped.value[0]).toBe(scope); + expect((scoped.value[0] as IFilter).preserved).toBeUndefined(); - const alternatives = query.filters.or(eq('name', 'a'), eq('name', 'b')); + const firstAlternative = eq('name', 'a'); + const secondAlternative = eq('name', 'b'); + const alternatives = query.filters.or(firstAlternative, secondAlternative); expect(alternatives.operator).toBe(FilterCompoundOperator.OR); expect(conditions(alternatives)).toEqual([ ['name', 'eq', 'a'], ['name', 'eq', 'b'], ]); + expect(alternatives.value).not.toContain(query.filters); + expect(alternatives.value[0]).toBe(firstAlternative); + expect((alternatives.value[0] as IFilter).preserved).toBeUndefined(); + expect(alternatives.value[1]).toBe(secondAlternative); + expect((alternatives.value[1] as IFilter).preserved).toBeUndefined(); }); - it('should not allow a replace-merge to displace a condition injected onto an empty receiver', () => { + it('should retain a condition injected onto an empty receiver during merge', () => { const scoped = new Query({ filters: defineQuery().filters.and(eq('realm.id', 'master')) }); const hostile = defineQuery({ filters: { 'realm.id': 'evil' } }); diff --git a/packages/core/test/unit/parser/relation-prune.spec.ts b/packages/core/test/unit/parser/relation-prune.spec.ts index d67fb404c..dfb561c99 100644 --- a/packages/core/test/unit/parser/relation-prune.spec.ts +++ b/packages/core/test/unit/parser/relation-prune.spec.ts @@ -6,6 +6,7 @@ */ import { + Condition, ErrorCode, Field, Fields, @@ -29,6 +30,12 @@ import { } from '../../../src'; import type { IFilters } from '../../../src'; +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); + } +} + function filterFields(input: IFilters) : string[] { const output : string[] = []; const walk = (node: any) => { @@ -261,6 +268,34 @@ describe('src/parser/relation-prune.ts', () => { .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); + it('throws instead of dropping an elemMatch with a preserved built-in descendant', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [preserve(eq('id'))]), + ), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + + it('throws instead of dropping an elemMatch with a preserved custom-condition wrapper', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [ + preserve(new CustomCondition({ scope: 'tenant-a' })), + ]), + ), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + it('throws instead of pruning the interior of a preserved elemMatch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ preserve(new Filter( @@ -348,5 +383,19 @@ describe('src/parser/relation-prune.ts', () => { expect(() => pruneFiltersByRelations(filters, ['user'], schema)) .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); + + it('throws for a default elemMatch with a preserved descendant after input pruning empties', () => { + const filters = new Filters(FilterCompoundOperator.AND, [eq('items.name')]); + const schema = defineFiltersSchema({ + default: new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [preserve(eq('id'))]), + ), + }); + + expect(() => pruneFiltersByRelations(filters, ['items'], schema)) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); }); }); diff --git a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts index 9c265417e..4d68747f0 100644 --- a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts +++ b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts @@ -395,10 +395,10 @@ describe('relations.validate for traversed relation paths (#815)', () => { /** * The shape the docs recommend: preserve the residual, leave the client's - * own leaf outside it. Both shapes resist a merge, but this one keeps - * the client leaf prunable, so the gate stays a drop for the client and - * the error stays reserved for a residual that itself names a rejected - * relation. + * own leaf outside it. Conjunctive composition retains both conditions, + * while this shape keeps the client leaf prunable, so the gate stays a + * drop for the client and the error stays reserved for a residual that + * itself names a rejected relation. */ describe('preserve the residual, not the group', () => { const localResidual = (filter: IFilter) => and(filter, preserve(eq('realm_id', 'SCOPE'))); From 71e7f28b00dab5dd143e1bb52c201d9791ae7547 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 12:21:52 +0200 Subject: [PATCH 10/13] fix: move preserved marker to condition --- .../parameter/filters/collection/module.ts | 6 +-- .../src/parameter/filters/collection/types.ts | 6 --- .../core/src/parameter/filters/condition.ts | 40 ++++++++++++++----- .../src/parameter/filters/record/module.ts | 6 +-- .../src/parameter/filters/record/types.ts | 6 --- packages/core/src/parser/relation-prune.ts | 20 ++++------ 6 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index 69419bc7c..0b4c6d057 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -7,19 +7,19 @@ import { FilterCompoundOperator } from '../../../schema'; import { Condition } from '../condition'; -import type { BuiltInConditionOptions, ICondition } from '../condition'; +import type { ConditionOptions, ICondition } from '../condition'; import type { IFilters, IFiltersVisitor } from './types'; import { isFilters } from './check'; export class Filters< T extends ICondition = ICondition, > extends Condition implements IFilters { - readonly preserved?: true; + readonly preserved?: boolean; constructor( operator: string, conditions: T[], - options: BuiltInConditionOptions = {}, + options: ConditionOptions = {}, ) { super(operator, conditions); diff --git a/packages/core/src/parameter/filters/collection/types.ts b/packages/core/src/parameter/filters/collection/types.ts index 8cdd79c7a..5cc4a4b16 100644 --- a/packages/core/src/parameter/filters/collection/types.ts +++ b/packages/core/src/parameter/filters/collection/types.ts @@ -18,12 +18,6 @@ export interface IFilters< readonly value : T[]; - /** - * Relation-pruning protection marker. A preserved group stays atomic - * during normalization and pruning applies its contract to the subtree. - */ - readonly preserved?: true; - accept(visitor: IFiltersVisitor) : R; flatten(items?: T[]) : IFilters; diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index 7b59ef3c7..746d7f978 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -7,12 +7,18 @@ import { isObject } from '../../utils'; -export const CONDITION_MARKER: unique symbol = Symbol.for('@rapiq/core/condition') as never; +export const CONDITION_MARKER: unique symbol = Symbol.for('@rapiq/core/condition'); export interface ICondition< T = unknown, > { - readonly [CONDITION_MARKER]: true; + readonly [CONDITION_MARKER]: boolean; + + /** + * Relation-pruning protection marker. A preserved group stays atomic + * during normalization and pruning applies its contract to the subtree. + */ + readonly preserved?: boolean; readonly operator: string; @@ -24,18 +30,30 @@ export interface ICondition< * is deliberately not part of this check. */ export function isCondition(input: unknown) : input is ICondition { - return ( - isObject(input) && - (input as { readonly [CONDITION_MARKER]?: unknown })[CONDITION_MARKER] === true && - typeof input.operator === 'string' && - 'value' in input - ); + if (!isObject(input)) { + return false; + } + + if (!(CONDITION_MARKER in input)) { + return false; + } + + if (typeof input.operator !== 'string') { + return false; + } + + if (!('value' in input)) { + return false; + } + + return typeof input.preserved === 'undefined' || + typeof input.preserved === 'boolean'; } /** * Construction options shared only by the built-in filter nodes. */ -export type BuiltInConditionOptions = { +export type ConditionOptions = { preserved?: boolean, }; @@ -46,7 +64,7 @@ export type BuiltInConditionOptions = { export abstract class Condition< T = unknown, > implements ICondition { - get [CONDITION_MARKER]() : true { + get [CONDITION_MARKER]() : boolean { return true; } @@ -54,7 +72,7 @@ export abstract class Condition< readonly value: T; - constructor( + protected constructor( operator: string, value: T, ) { diff --git a/packages/core/src/parameter/filters/record/module.ts b/packages/core/src/parameter/filters/record/module.ts index 398302bd8..fd34098e3 100644 --- a/packages/core/src/parameter/filters/record/module.ts +++ b/packages/core/src/parameter/filters/record/module.ts @@ -7,7 +7,7 @@ import type { FilterFieldOperator } from '../../../schema'; import { Condition } from '../condition'; -import type { BuiltInConditionOptions } from '../condition'; +import type { ConditionOptions } from '../condition'; import type { IFilter, IFilterVisitor } from './types'; export class Filter< @@ -16,13 +16,13 @@ export class Filter< > extends Condition implements IFilter { readonly field: string; - readonly preserved?: true; + readonly preserved?: boolean; constructor( operator: string, field: string, value: VALUE, - options: BuiltInConditionOptions = {}, + options: ConditionOptions = {}, ) { super(operator, value); this.field = field; diff --git a/packages/core/src/parameter/filters/record/types.ts b/packages/core/src/parameter/filters/record/types.ts index fb8879ab9..aed9d1ff7 100644 --- a/packages/core/src/parameter/filters/record/types.ts +++ b/packages/core/src/parameter/filters/record/types.ts @@ -29,11 +29,5 @@ export interface IFilter< readonly value: VALUE; - /** - * Relation-pruning protection marker. A preserved leaf must not be - * discarded when a relation validator rejects a path it traverses. - */ - readonly preserved?: true; - accept(visitor: IFilterVisitor) : R; } diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index b1395870e..7e791608e 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -191,7 +191,7 @@ function pruneCondition( ) : ICondition | undefined { // The marker protects the whole subtree it heads. Custom conditions cannot // carry it directly and are protected through preserve()'s built-in wrapper. - const preserved2 = preserved || isBuiltInConditionPreserved(node); + const preserved2 = preserved || isConditionPreservedAtRoot(node); if (isFilter(node)) { const field = joinPath(prefix, node.field); @@ -205,7 +205,7 @@ function pruneCondition( return dropUnlessPreserved( rejectedBy, field, - preserved2 || hasPreservedBuiltInCondition(node.value), + preserved2 || isConditionPreserved(node.value), ); } @@ -245,21 +245,17 @@ function pruneCondition( return new Filters(node.operator, conditions, { preserved: node.preserved }); } -function isBuiltInConditionPreserved(node: ICondition) : boolean { - if (isFilter(node) || isFilters(node)) { - return node.preserved === true; - } - - return false; +function isConditionPreservedAtRoot(node: ICondition) : boolean { + return node.preserved === true; } -function hasPreservedBuiltInCondition(node: ICondition) : boolean { - if (isBuiltInConditionPreserved(node)) { +function isConditionPreserved(node: ICondition) : boolean { + if (isConditionPreservedAtRoot(node)) { return true; } if (isFilters(node)) { - return node.value.some((child) => hasPreservedBuiltInCondition(child)); + return node.value.some((child) => isConditionPreserved(child)); } if ( @@ -267,7 +263,7 @@ function hasPreservedBuiltInCondition(node: ICondition) : boolean { node.operator === FilterFieldOperator.ELEM_MATCH && isCondition(node.value) ) { - return hasPreservedBuiltInCondition(node.value); + return isConditionPreserved(node.value); } return false; From 5db85beb73c51e42af7680314cde85a189d0e7a6 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 12:53:15 +0200 Subject: [PATCH 11/13] fix(core): close three audit findings on the preserved-condition contract Review findings on #890. `preserve()`'s catch-all overload promised `IFilters` for an `ICondition` argument, but overload resolution admits every subtype, so an argument merely *typed* as `ICondition` still reaches the leaf branch and comes back an `IFilter`. Measured: `preserve(c).and(...)` compiles and throws a TypeError. That is the shape the filters `validate` hook hands out, the one place the docs point at. The catch-all now returns `ICondition`; the precise `IFilter` / `IFilters` overloads are unaffected, so only code that relied on the wrapper claim needs `isFilter` / `isFilters` to narrow. Relation pruning searched for a preserved descendant only below `elemMatch`, while the drop it guards takes the whole subtree with it whatever the operator: `new Filter('customOp', 'items', preserve(...))` pruned silently where the `elemMatch` twin threw. The refusal is now decided once, over the subtree, at the single drop site; descending stays gated on `elemMatch`, which is the only operator known to address its interior relative to the element. `isCondition` tested only that the brand key was present, so `{ [CONDITION_MARKER]: false, ... }` passed as a live condition, and `ICondition` typed the brand `boolean`, which made that object legal at compile time too. The brand is now the literal `true` and the guard asserts the value. The footers below cover this branch as a whole: the earlier commits carry the `!` marker without a body, so this is where the migration notes for the release changelog live. BREAKING CHANGE: `Filters.merge()` and `mergeQueries()` compose filters as an ordered logical AND. Same-field conditions are no longer replaced by the receiver, they are retained as conjuncts, so two `eq` conditions on one field now select nothing instead of the receiver's value. Replace transient UI state before building the query, or select the current `IFilters` node and pass only that to `defineQuery`. BREAKING CHANGE: `seal()`, `ICondition.seal()`, `IFilter.seal()`, `IFilters.seal()`, the `sealed` marker and `ConditionOptions.sealed` are removed, together with `ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED` and `SchemaError.sealedConditionPruned()`. Sealing existed to survive a replace-merge, which no longer drops anything. Relation-pruning protection moves to `preserve()` / `ICondition.preserved`, reported as `ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED`. `Filters.and()` / `.or()` no longer mark what they inject, since composition cannot displace it. BREAKING CHANGE: `ICondition` requires the non-serializable `CONDITION_MARKER` brand, valued `true`. Extend the `Condition` base class or declare `readonly [CONDITION_MARKER] = true` on a structural implementation. A condition that made a JSON/RPC/cache round trip is no longer accepted as live. BREAKING CHANGE: `preserve()` returns `ICondition` for an argument typed as `ICondition`; narrow with `isFilter` / `isFilters` where the concrete kind is needed. `ICondition[CONDITION_MARKER]` is the literal `true` rather than `boolean`, so a structural implementation declaring `boolean` no longer satisfies the interface. --- .../core/src/parameter/filters/condition.ts | 10 +++-- .../core/src/parameter/filters/preserve.ts | 10 ++++- packages/core/src/parser/relation-prune.ts | 43 ++++++++++--------- .../unit/parameter/condition-contract.spec.ts | 7 +++ .../core/test/unit/parameter/merge.spec.ts | 14 +++++- .../test/unit/parser/relation-prune.spec.ts | 11 +++++ 6 files changed, 69 insertions(+), 26 deletions(-) diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index 746d7f978..6c9973611 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -12,7 +12,7 @@ export const CONDITION_MARKER: unique symbol = Symbol.for('@rapiq/core/condition export interface ICondition< T = unknown, > { - readonly [CONDITION_MARKER]: boolean; + readonly [CONDITION_MARKER]: true; /** * Relation-pruning protection marker. A preserved group stays atomic @@ -34,7 +34,11 @@ export function isCondition(input: unknown) : input is ICondition { return false; } - if (!(CONDITION_MARKER in input)) { + // the brand is asserted, not merely present: reading the value keeps a + // `[CONDITION_MARKER]: false` object out, which the interface now + // rejects at compile time too. + const marker : unknown = (input as { [CONDITION_MARKER]?: unknown })[CONDITION_MARKER]; + if (marker !== true) { return false; } @@ -64,7 +68,7 @@ export type ConditionOptions = { export abstract class Condition< T = unknown, > implements ICondition { - get [CONDITION_MARKER]() : boolean { + get [CONDITION_MARKER]() : true { return true; } diff --git a/packages/core/src/parameter/filters/preserve.ts b/packages/core/src/parameter/filters/preserve.ts index 81c12fac8..7ea81c5d1 100644 --- a/packages/core/src/parameter/filters/preserve.ts +++ b/packages/core/src/parameter/filters/preserve.ts @@ -14,8 +14,14 @@ import type { IFilter } from './record'; export function preserve(condition: IFilter): IFilter; export function preserve(condition: IFilters): IFilters; -export function preserve(condition: ICondition): IFilters; -export function preserve(condition: ICondition): IFilter | IFilters { +/** + * The catch-all admits every subtype too, so it cannot promise the + * {@link IFilters} wrapper a custom condition receives: an argument merely + * *typed* as {@link ICondition} may still be a leaf at runtime. Narrow the + * result with `isFilter` / `isFilters` when the kind matters. + */ +export function preserve(condition: ICondition): ICondition; +export function preserve(condition: ICondition): ICondition { if (isFilter(condition)) { return condition.preserved ? condition : new Filter(condition.operator, condition.field, condition.value, { preserved: true }); diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index 7e791608e..be0ad073b 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -189,26 +189,34 @@ function pruneCondition( prefix: string, preserved: boolean, ) : ICondition | undefined { - // The marker protects the whole subtree it heads. Custom conditions cannot - // carry it directly and are protected through preserve()'s built-in wrapper. + // The marker protects the whole subtree it heads. `preserve()` sets it on + // the built-in nodes and wraps anything else, but the marker itself is + // part of ICondition, so a custom condition setting it is honoured too. const preserved2 = preserved || isConditionPreservedAtRoot(node); if (isFilter(node)) { const field = joinPath(prefix, node.field); const rejectedBy = matchRelationRejected(field, rejected); + // Dropping a leaf drops everything addressed below it, so the refusal + // is decided over the whole subtree rather than this node alone: an + // interior condition (`elemMatch`, or any operator built the same way) + // can carry the marker its parent does not. + if (typeof rejectedBy === 'string') { + return dropUnlessPreserved( + rejectedBy, + field, + preserved2 || isConditionPreserved(node), + ); + } + + // Descending is gated on the operator, not on the value shape: only + // `elemMatch` is known to address its interior relative to the element, + // which is what `field` becomes the prefix for. if ( node.operator === FilterFieldOperator.ELEM_MATCH && isCondition(node.value) ) { - if (typeof rejectedBy === 'string') { - return dropUnlessPreserved( - rejectedBy, - field, - preserved2 || isConditionPreserved(node.value), - ); - } - const interior = pruneCondition(node.value, rejected, field, preserved2); if (!interior) { return undefined; @@ -217,13 +225,9 @@ function pruneCondition( if (interior !== node.value) { return new Filter(node.operator, node.field, interior, { preserved: node.preserved }); } - - return node; } - return typeof rejectedBy === 'string' ? - dropUnlessPreserved(rejectedBy, field, preserved2) : - node; + return node; } if (!isFilters(node)) { @@ -258,11 +262,10 @@ function isConditionPreserved(node: ICondition) : boolean { return node.value.some((child) => isConditionPreserved(child)); } - if ( - isFilter(node) && - node.operator === FilterFieldOperator.ELEM_MATCH && - isCondition(node.value) - ) { + // A leaf value can be a condition in its own right, and it goes away with + // the leaf. The addressing convention does not matter here: the question + // is only whether anything below is marked. + if (isFilter(node) && isCondition(node.value)) { return isConditionPreserved(node.value); } diff --git a/packages/core/test/unit/parameter/condition-contract.spec.ts b/packages/core/test/unit/parameter/condition-contract.spec.ts index 0d86bcbd5..a26364a0b 100644 --- a/packages/core/test/unit/parameter/condition-contract.spec.ts +++ b/packages/core/test/unit/parameter/condition-contract.spec.ts @@ -62,6 +62,13 @@ describe('src/parameter/filters condition contract', () => { operator: 'custom', value: {}, })).toBe(false); + + // the brand is asserted, not merely present. + expect(isCondition({ + [CONDITION_MARKER]: false, + operator: 'custom', + value: {}, + })).toBe(false); }); it('should retain specialized visitor dispatch for built-in conditions', () => { diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index 61ddec0ef..2b61ec325 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -5,7 +5,7 @@ * view the LICENSE file that was distributed with this source code. */ -import type { IFilter, IFilters } from '../../../src'; +import type { ICondition, IFilter, IFilters } from '../../../src'; import { Condition, ErrorCode, @@ -20,6 +20,7 @@ import { defineQuery, eq, gte, + isFilter, isFilters, mergeQueries, or, @@ -370,6 +371,17 @@ describe('src/parameter/filters/preserve.ts', () => { expect(preserve(output)).toBe(output); }); + it('should return the same kind it received rather than always wrapping', () => { + // the catch-all overload admits every subtype, so it promises only + // ICondition back: a leaf stays a leaf and has no group operations. + const condition: ICondition = eq('realm_id', 'master'); + const output = preserve(condition); + + expect(isFilter(output)).toBe(true); + expect(isFilters(output)).toBe(false); + expect((output as unknown as IFilters).and).toBeUndefined(); + }); + it('should preserve a custom condition through a built-in wrapper', () => { const condition = new CustomCondition({ scope: 'tenant-a' }); const output = preserve(condition); diff --git a/packages/core/test/unit/parser/relation-prune.spec.ts b/packages/core/test/unit/parser/relation-prune.spec.ts index dfb561c99..8e58ab47b 100644 --- a/packages/core/test/unit/parser/relation-prune.spec.ts +++ b/packages/core/test/unit/parser/relation-prune.spec.ts @@ -296,6 +296,17 @@ describe('src/parser/relation-prune.ts', () => { .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); + it('throws instead of dropping any leaf holding a preserved interior', () => { + // the drop takes the whole subtree with it, so the refusal cannot + // depend on which operator happens to address an interior. + const filters = new Filters(FilterCompoundOperator.AND, [ + new Filter('customOp', 'items', preserve(eq('id'))), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + it('throws instead of pruning the interior of a preserved elemMatch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ preserve(new Filter( From 1f1419132c6abce8fdc140e72b26386d426cfc3f Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 13:01:32 +0200 Subject: [PATCH 12/13] docs: select between alternatives, not across them Review findings on #890. The two snippets illustrating pre-IR replacement selected an `age >= 18` default against a `name contains ` current value. The fields are unrelated, so `currentFilters ?? defaultFilters` drops the age baseline the moment the user types in the search box: the example taught the opposite of the rule it was introduced to teach. Selection is only meaningful between the values one control can hold, so the guide now selects a status control against its own default and warns that an unrelated baseline is not an alternative to that choice. The frontend recipe keeps its baseline in `defaults`, where it merges in on every request, and its search box goes back to contributing a condition only when it is filled. Both snippets were run and type-checked against the current core. --- packages/docs/guide/merging-queries.md | 18 +++++++++++------- packages/docs/guide/recipes/frontend.md | 21 +++++++++------------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index 850994546..48cbf412c 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -61,23 +61,27 @@ const userQuery = defineQuery({ filters: currentSearch }); const query = mergeQueries(userQuery, parentScope, defaults); ``` -For state already represented as AST nodes, choose the current `IFilters` value before query composition. For example, select the current filters against the defaults, then pass only the selected node to `defineQuery`: +For state already represented as AST nodes, choose the current `IFilters` value before query composition. Select between the values **one control** can hold, then pass only the selected node to `defineQuery`: ```typescript -const defaultFilters = defineFilters({ age: { $gte: 18 } }); -const currentFilters = search ? - defineFilters({ name: { $contains: search } }) : +// a status control: `defaultStatus` is what it shows until the user picks. +const defaultStatus = defineFilters({ status: 'active' }); +const currentStatus = status ? + defineFilters({ status }) : undefined; -const selectedFilters = currentFilters ?? defaultFilters; const query = mergeQueries( - defineQuery({ filters: selectedFilters }), + defineQuery({ filters: currentStatus ?? defaultStatus }), parentScope, defaults, ); ``` -Putting `currentFilters` and `defaultFilters` in separate queries passed to `mergeQueries` would conjunct them. It does not replace the default. Selecting the current node first makes replacement a UI-state decision rather than a hidden query-composition rule. +Putting `currentStatus` and `defaultStatus` in separate queries passed to `mergeQueries` would conjunct them. It does not replace the default. Selecting the current node first makes replacement a UI-state decision rather than a hidden query-composition rule. + +::: warning Select between alternatives, never across them +Both branches of the selection must answer the same question. A baseline on an unrelated field is not an alternative to a status choice: fold `age >= 18` into `defaultStatus` and it disappears the moment the user touches the control. Keep such a baseline in its own query, where it merges in unconditionally. +::: ### `and()` / `or()`: build a condition tree diff --git a/packages/docs/guide/recipes/frontend.md b/packages/docs/guide/recipes/frontend.md index e8ea7be17..dcdc7cee6 100644 --- a/packages/docs/guide/recipes/frontend.md +++ b/packages/docs/guide/recipes/frontend.md @@ -31,15 +31,15 @@ import type { User } from 'my-api-types'; const codec = createURLCodec(); -// 1. component defaults: shipped with the list +// 1. component defaults: shipped with the list. A filter baseline belongs +// here, where it merges in on every request. const defaults = defineQuery({ fields: ['id', 'name', 'email'], + filters: { age: { $gte: 18 } }, sort: '-id', pagination: { limit: 25 }, }); -const defaultFilters = defineFilters({ age: { $gte: 18 } }); - // 2. parent-imposed scope: realmId arrives as a prop / argument // (fragments are plain values, so they travel well as data) function scopeFor(realmId: string) { @@ -48,13 +48,10 @@ function scopeFor(realmId: string) { // 3. user input: from the search box & pager function buildQuery(realmId: string, search: string, page: number) { - const currentFilters = search ? - defineFilters({ name: { $contains: search } }) : - undefined; - const filters = currentFilters ?? defaultFilters; - const userInput = defineQuery({ - filters, + // an empty search box contributes no condition; a filled one is + // and-ed alongside the baseline instead of replacing it. + filters: search ? { name: { $contains: search } } : undefined, pagination: { offset: (page - 1) * 25 }, }); @@ -71,11 +68,11 @@ async function fetchUsers(realmId: string, search: string, page: number) { Three details doing quiet work here: -- `currentFilters ?? defaultFilters` chooses the current filter node before query composition. Passing current and default filters as separate `mergeQueries` arguments would conjunct them; it would not replace the default. -- Filters merge as an **ordered logical AND**: the user's `name` filter and the scope's `realm.id` condition both survive. Fields and sort retain keyed left priority. +- `search ? … : undefined` contributes **no condition** for an empty box, so there is no `if`-shuffling around the query object. +- Filters merge as an **ordered logical AND**: the user's `name` filter, the scope's `realm.id` condition and the `age` baseline all survive. Fields and sort retain keyed left priority. - Everything is **immutable**: merging never mutates its inputs, so `defaults` is safe as a module constant and fragments like the realm scope are safe to pass around as props. -When a UI control replaces a previous search or range, choose its current value before calling `defineQuery`; do not compose old and new UI values with `mergeQueries`. Once filters are part of a query, composition intentionally retains every predicate. +Once filters are part of a query, composition intentionally retains every predicate. When one control has to *replace* a previous value (a status dropdown, a date range), choose between its alternatives before calling `defineQuery`, as in [Replacing UI state](/guide/merging-queries#replacing-ui-state). Select only between values that control can hold: a baseline like `age >= 18` is not one of them, which is why it stays in `defaults`. ## Framework flavors From e9fbf950b3d03a6d8e922676fb5400a302d44738 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Fri, 7 Aug 2026 14:05:31 +0200 Subject: [PATCH 13/13] feat(core): add mergeFiltersInput for per-field replace on build input Closes the third ask of #889. Making `Filters.merge` conjunctive removed per-field replace outright, and the issue asked for it to move to the build-input layer rather than disappear: overriding a default on the same field is a real need, it is just hazardous on a tree that may carry a server-authored scope. Object spread almost covers it, since build input is plain data, and it was the cheaper answer. Two measured failures are why it is not enough, both silent. A spread compares keys, and `FiltersBuildInput` addresses one field under two notations, so `{'realm.name': 'a'}` spread with `{realm: {name: 'b'}}` emits two conditions on one field. A spread is also shallow, so `{realm: {name: 'a', id: 1}}` spread with `{realm: {name: 'b'}}` drops the `realm.id` default with the branch that replaced it. The second is the same failure the previous commit fixed in the guide, which is the argument against leaving it to documentation. `mergeFiltersInput` reduces each input to canonical dotted paths and keeps the first value seen for each, so both notations replace each other and a nested record is replaced key by key. First-occurrence priority is applied per path rather than per argument, so it holds within one input too. Replacement is per field, not per operator, which is what the removed `Filters.merge` did; an `undefined` value claims no field, matching the build layer and `Pagination.merge`. What makes replacement safe here is the input type: no `ICondition` overload, a `CONDITION_MARKER` exclusion so an untyped call site is rejected at compile time too, and a `BuildError` at runtime. A build input cannot carry a server-authored scope, so per-field replace cannot displace one. The leaf-versus-branch rule is now single-sourced in an internal module that both the lowering and the canonicalizer consult, since a drift between them would be silent in exactly the way the two spread failures are. Verified load-bearing: breaking it fails three new merge specs and two pre-existing `defineFilters` specs. --- .../core/src/build/parameter/filters/index.ts | 1 + .../core/src/build/parameter/filters/merge.ts | 129 ++++++++++++++++ .../src/build/parameter/filters/module.ts | 7 +- .../core/src/build/parameter/filters/value.ts | 35 +++++ .../test/unit/build/filters-merge.spec.ts | 139 ++++++++++++++++++ packages/docs/guide/building-queries.md | 2 + packages/docs/guide/merging-queries.md | 49 ++++-- packages/docs/guide/recipes/frontend.md | 2 +- packages/docs/packages/core.md | 4 +- 9 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/build/parameter/filters/merge.ts create mode 100644 packages/core/src/build/parameter/filters/value.ts create mode 100644 packages/core/test/unit/build/filters-merge.spec.ts diff --git a/packages/core/src/build/parameter/filters/index.ts b/packages/core/src/build/parameter/filters/index.ts index 73806ac38..d507df512 100644 --- a/packages/core/src/build/parameter/filters/index.ts +++ b/packages/core/src/build/parameter/filters/index.ts @@ -5,5 +5,6 @@ * view the LICENSE file that was distributed with this source code. */ +export * from './merge'; export * from './module'; export * from './types'; diff --git a/packages/core/src/build/parameter/filters/merge.ts b/packages/core/src/build/parameter/filters/merge.ts new file mode 100644 index 000000000..dd8780986 --- /dev/null +++ b/packages/core/src/build/parameter/filters/merge.ts @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { BuildError } from '../../../errors'; +import { isCondition } from '../../../parameter'; +import type { CONDITION_MARKER } from '../../../parameter'; +import type { ObjectLiteral } from '../../../types'; +import { isObject } from '../../../utils'; +import type { FiltersBuildInput } from './types'; +import { isNestedRecordValue } from './value'; + +/** + * Excludes a live condition from an input position at compile time. The + * generic-less arm resolves `FiltersBuildInput` to a bag of + * `any` values, which a condition object structurally satisfies, so without + * this the type-level half of the guard would hold only for callers who + * supply the record generic. + */ +type NotACondition = { [CONDITION_MARKER]?: never }; + +/** + * Per-field replace over filter build input: the first input to constrain a + * field wins it, and every field only one side constrains survives. This is + * the override-a-default operation, and it lives here rather than on the + * `Query` because a build input is plain data: it cannot carry a + * server-authored scope, so replacement cannot displace one. Composition of + * queries is conjunction and never drops a predicate ({@link mergeQueries}). + * + * Both notations {@link FiltersBuildInput} admits address the same fields — + * `{ 'realm.name': v }` and `{ realm: { name: v } }` — so inputs are reduced + * to their canonical dotted paths before being compared. Two inputs written + * in different notations therefore still replace each other, and a nested + * record is replaced key by key instead of wholesale the way an object + * spread would. + * + * ```typescript + * mergeFiltersInput( + * { realm: { name: 'b' } }, + * { 'realm.name': 'a', 'realm.id': 1 }, + * ); + * // { 'realm.name': 'b', 'realm.id': 1 } + * ``` + * + * Replacement is per field, not per operator: `{ age: { $gte: 18 } }` beating + * `{ age: { $lt: 65 } }` yields the lower bound alone. Keeping both is + * conjunction, which is what composing two queries does. + * + * A `$elemMatch` interior is a value, replaced whole. An `undefined` value + * claims no field, so a later input still supplies it. The result is the flat + * notation, itself valid input for {@link defineFilters} or another merge. + */ +export function mergeFiltersInput( + ...input: (FiltersBuildInput & NotACondition)[] +) : FiltersBuildInput; +export function mergeFiltersInput< + RECORD extends ObjectLiteral, +>(...input: (FiltersBuildInput & NotACondition)[]) : FiltersBuildInput; +export function mergeFiltersInput( + ...input: FiltersBuildInput[] +) : FiltersBuildInput { + const output : Record = {}; + + for (const current of input) { + collectPaths(current, '', output); + } + + return output as FiltersBuildInput; +} + +/** + * Reduce one input to canonical dotted paths, keeping the first value seen + * for each. First-occurrence priority is applied here rather than by the + * caller so it holds within a single input too: an input naming one field + * under both notations resolves the same way two inputs do. + */ +function collectPaths( + input: unknown, + prefix: string, + output: Record, +) : void { + // a live condition is not build input. Admitting one would make this a + // second composition path over server-authored trees, which is the + // primitive filter composition was made conjunctive to remove. + if (isCondition(input)) { + throw BuildError.inputInvalid(); + } + + if (!isObject(input)) { + throw BuildError.inputInvalid(); + } + + for (const key of Object.keys(input)) { + const value = input[key]; + + // skipped rather than recorded, so `{ name: undefined }` states no + // opinion instead of claiming the field and blanking it. Mirrors the + // build layer, which lowers such a key to no condition at all. + if (typeof value === 'undefined') { + continue; + } + + const field = prefix ? `${prefix}.${key}` : key; + + // a `$`-prefixed root key is not a field. It is passed through + // unresolved so defineFilters still reports it, rather than being + // reinterpreted as a path segment here. + if (!prefix && key.substring(0, 1) === '$') { + if (!Object.hasOwn(output, key)) { + output[key] = value; + } + + continue; + } + + if (isNestedRecordValue(field, value)) { + collectPaths(value, field, output); + + continue; + } + + if (!Object.hasOwn(output, field)) { + output[field] = value; + } + } +} diff --git a/packages/core/src/build/parameter/filters/module.ts b/packages/core/src/build/parameter/filters/module.ts index bad971ac6..9ac5cad45 100644 --- a/packages/core/src/build/parameter/filters/module.ts +++ b/packages/core/src/build/parameter/filters/module.ts @@ -20,6 +20,7 @@ import type { ObjectLiteral } from '../../../types'; import { isObject } from '../../../utils'; import { isParameterNode } from '../../utils'; import type { FiltersBuildInput } from './types'; +import { isNestedRecordValue } from './value'; const OPERATORS : Record = {}; for (const operator of Object.values(FilterFieldOperator)) { @@ -188,8 +189,10 @@ function buildFieldConditions( } // nested record — relation traversal via dot-path prefixing; - // the element itself has no properties to traverse into. - if (field === ITSELF) { + // the element itself has no properties to traverse into. Asking the + // shared predicate rather than re-testing the marker here is what + // keeps lowering and path canonicalization from drifting apart. + if (!isNestedRecordValue(field, value)) { throw BuildError.keyValueInvalid(field); } diff --git a/packages/core/src/build/parameter/filters/value.ts b/packages/core/src/build/parameter/filters/value.ts new file mode 100644 index 000000000..fcd2cd1eb --- /dev/null +++ b/packages/core/src/build/parameter/filters/value.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { ITSELF, isCondition } from '../../../parameter'; +import { isObject } from '../../../utils'; +import { isParameterNode } from '../../utils'; + +/** + * Whether a field value opens a nested record to traverse into rather than + * describing the field itself. Everything else is a leaf: a scalar, `null`, + * a bare array (`in` sugar), a `RegExp`, a `Date`, or an operator object. + * + * Internal, and single-sourced deliberately. `buildFieldConditions` lowers a + * value with this rule and `mergeFiltersInput` canonicalizes key paths with + * it, so a value shape that moved between leaf and branch without both + * agreeing would silently yield a different set of paths than it yields + * conditions. It lives here rather than in either consumer so neither owns + * it, and it is kept out of the barrel because it is not public API. + */ +export function isNestedRecordValue(field: string, value: unknown) : boolean { + return ( + isObject(value) && + !Array.isArray(value) && + !(value instanceof RegExp) && + !(value instanceof Date) && + !isCondition(value) && + !isParameterNode(value) && + !Object.keys(value).some((key) => key.substring(0, 1) === '$') && + field !== ITSELF + ); +} diff --git a/packages/core/test/unit/build/filters-merge.spec.ts b/packages/core/test/unit/build/filters-merge.spec.ts new file mode 100644 index 000000000..161c2fb3b --- /dev/null +++ b/packages/core/test/unit/build/filters-merge.spec.ts @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + BuildError, + defineFilters, + eq, + mergeFiltersInput, +} from '../../../src'; +import type { IFilter } from '../../../src'; +import type { User } from '../../data'; + +const conditions = (input: unknown) => (defineFilters(input as never).value as IFilter[]) + .map((el) => [el.field, el.operator, el.value]); + +describe('src/build/parameter/filters/merge.ts', () => { + it('should let the first input win a field and keep the rest', () => { + const output = mergeFiltersInput( + { name: 'current' }, + { name: 'default', age: { $gte: 18 } }, + ); + + expect(conditions(output)).toEqual([ + ['name', 'eq', 'current'], + ['age', 'gte', 18], + ]); + }); + + it('should replace across both notations of the same field', () => { + // an object spread cannot see these are one field, and emits both. + const output = mergeFiltersInput( + { realm: { name: 'b' } }, + { 'realm.name': 'a', 'realm.id': 1 }, + ); + + expect(output).toEqual({ 'realm.name': 'b', 'realm.id': 1 }); + expect(conditions(output)).toEqual([ + ['realm.name', 'eq', 'b'], + ['realm.id', 'eq', 1], + ]); + }); + + it('should replace a nested record key by key rather than wholesale', () => { + // a shallow spread would drop `realm.id` with the branch it replaces. + const output = mergeFiltersInput( + { realm: { name: 'b' } }, + { realm: { name: 'a', id: 1 } }, + ); + + expect(output).toEqual({ 'realm.name': 'b', 'realm.id': 1 }); + }); + + it('should resolve both notations of one field within a single input', () => { + const output = mergeFiltersInput({ + 'realm.name': 'first', + realm: { name: 'second' }, + }); + + expect(output).toEqual({ 'realm.name': 'first' }); + }); + + it('should replace per field rather than per operator', () => { + const output = mergeFiltersInput( + { age: { $gte: 18 } }, + { age: { $lt: 65 } }, + ); + + // keeping both bounds is conjunction, which is mergeQueries' job. + expect(conditions(output)).toEqual([['age', 'gte', 18]]); + }); + + it('should treat an undefined value as no opinion', () => { + const output = mergeFiltersInput( + { name: undefined, age: 30 }, + { name: 'default' }, + ); + + expect(output).toEqual({ age: 30, name: 'default' }); + }); + + it('should treat a $elemMatch interior as one value', () => { + const output = mergeFiltersInput( + { items: { $elemMatch: { name: 'current' } } }, + { items: { $elemMatch: { name: 'default', id: '2' } } }, + ); + + expect(output).toEqual({ items: { $elemMatch: { name: 'current' } } }); + }); + + it('should keep every notation of a bare-array and regex leaf intact', () => { + const pattern = /^Jo/; + const output = mergeFiltersInput( + { id: ['1', '2', null] }, + { name: pattern }, + ); + + expect(conditions(output)).toEqual([ + ['id', 'in', ['1', '2', null]], + ['name', 'regex', pattern], + ]); + }); + + it('should not mutate its inputs', () => { + const left = { realm: { name: 'b' } }; + const right = { realm: { name: 'a', id: 1 } }; + + mergeFiltersInput(left, right); + + expect(left).toEqual({ realm: { name: 'b' } }); + expect(right).toEqual({ realm: { name: 'a', id: 1 } }); + }); + + it('should return an empty input without arguments', () => { + expect(mergeFiltersInput()).toEqual({}); + }); + + it('should refuse a live condition instead of replacing inside it', () => { + // The safety property, and it is a type error before it is a runtime + // one: replacement never sees a server-authored tree. + expect(() => mergeFiltersInput( + // @ts-expect-error a live condition is not build input + eq('realm_id', 'master'), + )).toThrow(BuildError); + }); + + it('should leave an invalid $-prefixed root key for defineFilters to report', () => { + // untyped call sites reach the permissive arm, where a `$` key is + // indistinguishable from a field name, so the report stays where it + // already was rather than being duplicated here. + const output = mergeFiltersInput({ $eq: 'x' }); + + expect(output).toEqual({ $eq: 'x' }); + expect(() => defineFilters(output)).toThrow(BuildError); + }); +}); diff --git a/packages/docs/guide/building-queries.md b/packages/docs/guide/building-queries.md index ed4abc727..c9b6d664a 100644 --- a/packages/docs/guide/building-queries.md +++ b/packages/docs/guide/building-queries.md @@ -152,6 +152,8 @@ const query = defineQuery({ Fragments and raw input mix freely; already-built AST nodes pass through unchanged. +To override a filter default on the same field, compose the *input* with [`mergeFiltersInput`](/guide/merging-queries#mergefiltersinput-per-field-replace-before-the-query) before it becomes a fragment. It replaces per field, where an object spread would emit both notations of one field and eat a nested sibling default. + ## Next steps - [Merging & Composition](/guide/merging-queries): combining queries from multiple sources. diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index 48cbf412c..0765f9f42 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -48,20 +48,49 @@ const query = defineQuery({ }); ``` -### Replacing UI state +### `mergeFiltersInput`: per-field replace, before the query -`mergeQueries` deliberately does not provide replacement semantics for filters. Replace transient UI state before building the query, or select the current filters node before query composition: +Overriding a default on the same field is a real need, and it belongs to the **input**, before a `Query` exists. `mergeFiltersInput` composes [filter build input](/guide/building-queries#filters) with per-field replace: the first input to constrain a field wins it, and every field only one side constrains survives. ```typescript -const currentSearch = search.length > 0 ? - { name: { $contains: search } } : - undefined; +import { defineQuery, mergeFiltersInput, mergeQueries } from '@rapiq/core'; -const userQuery = defineQuery({ filters: currentSearch }); -const query = mergeQueries(userQuery, parentScope, defaults); +const query = mergeQueries( + defineQuery({ + filters: mergeFiltersInput( + search ? { name: { $contains: search } } : {}, // user input wins + { name: 'John', age: { $gte: 18 } }, // component defaults + ), + }), + parentScope, +); +// name contains , age >= 18 ``` -For state already represented as AST nodes, choose the current `IFilters` value before query composition. Select between the values **one control** can hold, then pass only the selected node to `defineQuery`: +Replacement is per **field**, not per operator: `{ age: { $gte: 18 } }` beating `{ age: { $lt: 65 } }` yields the lower bound alone. Keeping both is conjunction, which is what merging two queries does. An `undefined` value claims no field, so a later input still supplies it, and a `$elemMatch` interior is one value, replaced whole. + +Because both [notations](/guide/building-queries#filters) address the same fields, inputs are reduced to canonical dotted paths first. That is the difference from an object spread, which sees only keys: + +```typescript +// spread: two conditions on one field, and a lost sibling default +{ ...{ 'realm.name': 'a', 'realm.id': 1 }, ...{ realm: { name: 'b' } } } +// -> realm.name = 'a' AND realm.name = 'b' (realm.id survives by luck) +{ ...{ realm: { name: 'a', id: 1 } }, ...{ realm: { name: 'b' } } } +// -> realm.name = 'b' (realm.id silently gone) + +mergeFiltersInput({ realm: { name: 'b' } }, { realm: { name: 'a', id: 1 } }); +// -> { 'realm.name': 'b', 'realm.id': 1 } +``` + +The result is the flat notation, itself valid input for `defineFilters` or another merge. + +::: tip Why replace is safe here and not on the query +`mergeFiltersInput` accepts build input only. Passing a condition (`eq(...)`, `and(...)`) is a type error and throws at runtime. A build input is plain data and can never carry a server-authored scope, so per-field replace cannot displace one. That is exactly what it could do on the IR, which is why [`Filters.merge`](#filters-monotonic-conjunction) is conjunction instead. +::: + +### Replacing a whole filters node + +When the alternatives are already AST nodes, select between them before query composition. Select between the values **one control** can hold: ```typescript // a status control: `defaultStatus` is what it shows until the user picks. @@ -77,10 +106,10 @@ const query = mergeQueries( ); ``` -Putting `currentStatus` and `defaultStatus` in separate queries passed to `mergeQueries` would conjunct them. It does not replace the default. Selecting the current node first makes replacement a UI-state decision rather than a hidden query-composition rule. +Putting `currentStatus` and `defaultStatus` in separate queries passed to `mergeQueries` would conjunct them. It does not replace the default. ::: warning Select between alternatives, never across them -Both branches of the selection must answer the same question. A baseline on an unrelated field is not an alternative to a status choice: fold `age >= 18` into `defaultStatus` and it disappears the moment the user touches the control. Keep such a baseline in its own query, where it merges in unconditionally. +Both branches of the selection must answer the same question. A baseline on an unrelated field is not an alternative to a status choice: fold `age >= 18` into `defaultStatus` and it disappears the moment the user touches the control. Keep such a baseline in its own query, where it merges in unconditionally, or express the override with `mergeFiltersInput` above, which is per field and has no such trap. ::: ### `and()` / `or()`: build a condition tree diff --git a/packages/docs/guide/recipes/frontend.md b/packages/docs/guide/recipes/frontend.md index dcdc7cee6..3f505c792 100644 --- a/packages/docs/guide/recipes/frontend.md +++ b/packages/docs/guide/recipes/frontend.md @@ -72,7 +72,7 @@ Three details doing quiet work here: - Filters merge as an **ordered logical AND**: the user's `name` filter, the scope's `realm.id` condition and the `age` baseline all survive. Fields and sort retain keyed left priority. - Everything is **immutable**: merging never mutates its inputs, so `defaults` is safe as a module constant and fragments like the realm scope are safe to pass around as props. -Once filters are part of a query, composition intentionally retains every predicate. When one control has to *replace* a previous value (a status dropdown, a date range), choose between its alternatives before calling `defineQuery`, as in [Replacing UI state](/guide/merging-queries#replacing-ui-state). Select only between values that control can hold: a baseline like `age >= 18` is not one of them, which is why it stays in `defaults`. +Once filters are part of a query, composition intentionally retains every predicate. When user input has to *replace* a default on the same field rather than narrow it, compose the **input** with [`mergeFiltersInput`](/guide/merging-queries#mergefiltersinput-per-field-replace-before-the-query) before calling `defineQuery`. It replaces per field, so a baseline like `age >= 18` survives a search the user types. ## Framework flavors diff --git a/packages/docs/packages/core.md b/packages/docs/packages/core.md index 183fcd73d..aa8bbd9f9 100644 --- a/packages/docs/packages/core.md +++ b/packages/docs/packages/core.md @@ -11,9 +11,9 @@ npm install @rapiq/core | Area | Exports (selection) | Guide | |---|---|---| | **Query AST** | `Query`, `Fields`/`Field`, `Filters`/`Filter`, `Relations`/`Relation`, `Sorts`/`Sort`, `Pagination`, operator constants (`FilterFieldOperator`, `FilterCompoundOperator`, `FieldOperator`, `SortDirection`, `Parameter`), visitor interfaces (`IQueryVisitor`, `IFiltersVisitor`, …), `hasFieldConditions` | [The Query AST](/guide/query-ast) | -| **Build layer** | `defineQuery`, `defineFields`, `defineFilters`, `definePagination`, `defineRelations`, `defineSorts` | [Building Queries](/guide/building-queries) | +| **Build layer** | `defineQuery`, `defineFields`, `defineFilters`, `definePagination`, `defineRelations`, `defineSorts`, `mergeFiltersInput` | [Building Queries](/guide/building-queries) | | **Condition helpers** | `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `inArray`, `nin`, `startsWith`, `notStartsWith`, `endsWith`, `notEndsWith`, `contains`, `notContains`, `regex`, `mod`, `size`, `exists`, `elemMatch`, `and`, `or`, `not`, `preserve` | [Condition helpers](/guide/building-queries#condition-helpers) | -| **Composition** | `mergeQueries`, `Filters.merge` / `.and` / `.or`; `preserve` for relation-pruning policy | [Merging & Composition](/guide/merging-queries) | +| **Composition** | `mergeQueries`, `Filters.merge` / `.and` / `.or`; `mergeFiltersInput` for per-field replace on build input; `preserve` for relation-pruning policy | [Merging & Composition](/guide/merging-queries) | | **Plan layer** | `planCondition`, `interpretPlan`, `distributeNegation`, `IPlanInterpreter`, `FILTER_OPERATOR_SEMANTICS`, plan types (`ConditionPlan`, `ComparePlan`, `CompoundPlan`, …) | [The visitor pattern](/guide/query-ast#the-visitor-pattern) | | **Schema system** | `defineSchema`, `Schema`, `SchemaRegistry`, per-parameter `define*Schema` factories, `ResolutionScope`, `Schema.describe()` | [Schemas & Validation](/guide/schemas) | | **Parser base** | `BaseParser`, per-parameter parse-option types | [Custom parsers](/guide/query-ast#writing-a-custom-parser-resolutionscope) |