Skip to content

fix(core)!: make query filter merges conjunctive - #890

Merged
tada5hi merged 13 commits into
masterfrom
fix/conjunctive-query-filter-merge-889
Aug 7, 2026
Merged

tada5hi merged 13 commits into
masterfrom
fix/conjunctive-query-filter-merge-889

Conversation

@tada5hi

@tada5hi tada5hi commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • make mergeQueries compose filters as an ordered logical AND so every predicate survives, including same-field ranges and contradictions
  • separate live condition identity from relation-pruning protection with CONDITION_MARKER and preserve()
  • remove the public seal, sealed, and seal() contracts
  • keep preserved policy residuals fail-closed through validation, elemMatch, relation pruning, and schema defaults
  • update public and agent documentation for conjunctive composition and pre-IR replacement

Breaking changes

  • same-field filters no longer replace one another during Filters.merge() or mergeQueries(); they remain conjuncts
  • custom conditions must extend Condition or provide CONDITION_MARKER
  • seal(), sealed, and condition seal methods are removed
  • SCHEMA_SEALED_CONDITION_PRUNED is renamed to SCHEMA_PRESERVED_CONDITION_PRUNED
  • the deprecated simple URL codec can reject a merged tree with duplicate fields or compound filters; the expression codec supports these trees

Validation

  • npm run lint -- --ignore-pattern '.claude/worktrees/**'
  • NX_SKIP_NX_CACHE=true npm run build
  • NX_SKIP_NX_CACHE=true npm test (2,132 tests)
  • git diff --check origin/master...HEAD

Closes #889

Summary by CodeRabbit

  • New Features
    • Added preserve() for protecting filter conditions during validation and relation pruning.
    • Added mergeFiltersInput() for predictable, field-level filter input merging.
    • Improved recognition of custom query conditions.
  • Behavior Changes
    • Filters now combine using ordered logical AND, retaining all predicates.
    • Server-provided conditions remain conjunctive with client filters.
    • Other query parameters retain left-priority merging.
  • Breaking Changes
    • Replaced seal() and sealed-condition terminology with preservation semantics.
    • Renamed the related schema error and public error method.
  • Documentation
    • Updated guides, examples, and migration guidance.

Copilot AI lite review requested due to automatic review settings August 7, 2026 10:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR replaces sealed conditions with branded Condition instances and preserve() metadata. Filter merges now use ordered logical AND. Validation and relation pruning propagate preservation state and use the renamed schema error. It also adds mergeFiltersInput for pre-query field replacement.

Changes

Condition composition and preservation

Layer / File(s) Summary
Condition contract and preservation API
packages/core/src/parameter/filters/*, packages/core/src/errors/*
Conditions use CONDITION_MARKER. Filters use preserved metadata. The preserve() helper replaces seal().
Conjunctive filter trees and input merging
packages/core/src/parameter/filters/collection/*, packages/core/src/build/parameter/filters/*, packages/core/test/unit/build/filters-merge.spec.ts
Filter merging retains ordered predicates. mergeFiltersInput provides first-input-wins replacement for canonical field paths.
Validation and relation pruning
packages/core/src/parser/*, packages/parser-simple/test/unit/parser/relations-traversal.spec.ts, packages/core/test/unit/parser/*
Validation retains preservation metadata. Relation pruning raises SCHEMA_PRESERVED_CONDITION_PRUNED for rejected protected conditions.
Condition adoption and documentation
packages/codec-url/test/unit/*, packages/core/test/unit/*, packages/docs/*, .agents/*
Custom conditions extend Condition. Tests and documentation describe branded conditions, conjunctive scope, and preserve-based pruning.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: query filter merges now use conjunctive composition.
Linked Issues check ✅ Passed The changes implement issue #889: conjunctive IR filters, removal of seal(), preserve()-based pruning, and pre-IR replacement.
Out of Scope Changes check ✅ Passed The code, tests, and documentation changes support the linked issue objectives without unrelated scope.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/conjunctive-query-filter-merge-889

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/core/test/unit/parameter/merge.spec.ts (2)

382-413: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a merge case with preserved operands on both sides.

The suite covers a preserved right operand at line 389 and preserved receivers for and() and or(). Add a case where both operands are preserved. It confirms that the two markers survive as two separate conjuncts and that the new root AND stays unpreserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/unit/parameter/merge.spec.ts` around lines 382 - 413, Add
a unit test near the existing preserved merge coverage that merges two
separately preserved operands, asserting the result contains both preserved
operands as distinct conjuncts and that the resulting root AND is not preserved.
Reuse the existing preserve, and, and Filter merge APIs and marker assertions.

34-38: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider sharing one CustomCondition test fixture inside packages/core/test.

The same CustomCondition subclass now exists in several core test files, including packages/core/test/unit/parameter/condition-contract.spec.ts, packages/core/test/unit/build/module.spec.ts, and packages/core/test/unit/parser/parameter/key-validation.spec.ts. Move it into the shared packages/core/test/data helpers so the branded-condition contract has one definition. Keep the copy in packages/codec-url because it is a separate package.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/unit/parameter/merge.spec.ts` around lines 34 - 38, Move
the CustomCondition fixture from this test file into the shared
packages/core/test/data helper area, then update core tests such as
condition-contract.spec.ts, module.spec.ts, and key-validation.spec.ts to import
and reuse that single definition. Remove their local subclasses while preserving
the existing constructor and branded-condition behavior; leave the
packages/codec-url copy unchanged.
packages/core/src/parameter/filters/collection/module.ts (1)

35-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the leftover commented-out statement.

Line 36 holds a dead mutation statement from the previous mutable implementation. flatten() is now immutable, so the comment can mislead readers.

♻️ Proposed cleanup
     flatten(aggregatedResult?: T[]) : IFilters<T> {
-        // this.value.splice(0, this.value.length, ...next);
-
         return new Filters(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/parameter/filters/collection/module.ts` around lines 35 -
43, Remove the leftover commented-out splice statement from the immutable
flatten method in Filters.flatten, leaving the method’s current return logic
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.agents/architecture.md:
- Line 32: Update the validator residual documentation in the “Parse to IR”
section of .agents/architecture.md to use preserve(and(<client leaf>, <policy
residual>)), matching the implementation in validate.ts and the authoritative
relation-prune.spec.ts behavior; do not alter code or tests unless the intended
contract differs.

In @.agents/structure.md:
- Line 55: Update the filters tree entry in structure.md to include NOT
alongside compound AND/OR filters, using “compound and/or/not” or an explicitly
non-exhaustive description. Keep the summary consistent with the documented
filter operations in architecture.md and packages/core/README.md.

In `@packages/docs/guide/recipes/frontend.md`:
- Line 41: The frontend recipe must preserve the age >= 18 baseline when search
filters are present. Update the filter construction around currentFilters,
defaultFilters, and mergeQueries so defaultFilters is combined with
currentFilters rather than selected only via nullish fallback; otherwise,
explicitly rename and document it as an empty-search-only fallback.

---

Nitpick comments:
In `@packages/core/src/parameter/filters/collection/module.ts`:
- Around line 35-43: Remove the leftover commented-out splice statement from the
immutable flatten method in Filters.flatten, leaving the method’s current return
logic unchanged.

In `@packages/core/test/unit/parameter/merge.spec.ts`:
- Around line 382-413: Add a unit test near the existing preserved merge
coverage that merges two separately preserved operands, asserting the result
contains both preserved operands as distinct conjuncts and that the resulting
root AND is not preserved. Reuse the existing preserve, and, and Filter merge
APIs and marker assertions.
- Around line 34-38: Move the CustomCondition fixture from this test file into
the shared packages/core/test/data helper area, then update core tests such as
condition-contract.spec.ts, module.spec.ts, and key-validation.spec.ts to import
and reuse that single definition. Remove their local subclasses while preserving
the existing constructor and branded-condition behavior; leave the
packages/codec-url copy unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 58fb762b-7659-4646-ba4b-b873811f9462

📥 Commits

Reviewing files that changed from the base of the PR and between 8158ce2 and 7925ff0.

📒 Files selected for processing (42)
  • .agents/architecture.md
  • .agents/structure.md
  • README.md
  • packages/adapter-prisma/test/unit/acceptance.spec.ts
  • packages/adapter-typeorm/test/unit/acceptance.spec.ts
  • packages/codec-url/test/unit/acceptance.spec.ts
  • packages/codec-url/test/unit/simple-roundtrip.spec.ts
  • packages/core/README.md
  • packages/core/src/build/parameter/filters/module.ts
  • packages/core/src/errors/code.ts
  • packages/core/src/errors/schema.ts
  • packages/core/src/parameter/fields/collection/module.ts
  • packages/core/src/parameter/filters/collection/module.ts
  • packages/core/src/parameter/filters/collection/types.ts
  • packages/core/src/parameter/filters/condition.ts
  • packages/core/src/parameter/filters/index.ts
  • packages/core/src/parameter/filters/preserve.ts
  • packages/core/src/parameter/filters/record/module.ts
  • packages/core/src/parameter/filters/record/types.ts
  • packages/core/src/parameter/filters/seal.ts
  • packages/core/src/parameter/merge.ts
  • packages/core/src/parser/parameter/filters/validate.ts
  • packages/core/src/parser/relation-prune.ts
  • packages/core/test/unit/build/module.spec.ts
  • packages/core/test/unit/parameter/condition-contract.spec.ts
  • packages/core/test/unit/parameter/filters-non-node.spec.ts
  • packages/core/test/unit/parameter/merge.spec.ts
  • packages/core/test/unit/parser/parameter/filters/validate.spec.ts
  • packages/core/test/unit/parser/parameter/key-validation.spec.ts
  • packages/core/test/unit/parser/relation-prune.spec.ts
  • packages/docs/guide/building-queries.md
  • packages/docs/guide/concepts.md
  • packages/docs/guide/errors.md
  • packages/docs/guide/filters.md
  • packages/docs/guide/merging-queries.md
  • packages/docs/guide/recipes/authorization.md
  • packages/docs/guide/recipes/express-typeorm.md
  • packages/docs/guide/recipes/frontend.md
  • packages/docs/guide/relations.md
  • packages/docs/packages/codec-url.md
  • packages/docs/packages/core.md
  • packages/parser-simple/test/unit/parser/relations-traversal.spec.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/parameter/filters/seal.ts

Comment thread .agents/architecture.md
Comment thread .agents/structure.md
├── 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document NOT in the filters tree.

.agents/architecture.md and packages/core/README.md describe not as a supported filter operation. This entry still says compound and/or, so the package summary is incomplete. Change it to compound and/or/not or mark the list as non-exhaustive.

As per coding guidelines, keep this file and all corresponding files in .agents updated as the project evolves.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/structure.md at line 55, Update the filters tree entry in
structure.md to include NOT alongside compound AND/OR filters, using “compound
and/or/not” or an explicitly non-exhaustive description. Keep the summary
consistent with the documented filter operations in architecture.md and
packages/core/README.md.

Source: Coding guidelines

Comment thread packages/docs/guide/recipes/frontend.md Outdated
tada5hi added 4 commits August 7, 2026 12:21
…ract

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.
Review findings on #890.

The two snippets illustrating pre-IR replacement selected an `age >= 18`
default against a `name contains <search>` 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.
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.
@tada5hi

tada5hi commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@tada5hi

tada5hi commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 11 minutes.

@tada5hi

tada5hi commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/build/parameter/filters/merge.ts`:
- Around line 106-128: Update the merge path in mergeFiltersInput so the output
accumulator is created with a null prototype instead of a plain object literal,
preserving the existing key-merge logic in collectPaths and the root `$`
handling. This will let `__proto__` be stored as a normal own field without
triggering the inherited setter, while keeping `Object.keys`, `Object.hasOwn`,
and the downstream defineFilters spread behavior unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19632836-25df-42ce-ba33-a7e763eb9893

📥 Commits

Reviewing files that changed from the base of the PR and between 8158ce2 and e9fbf95.

📒 Files selected for processing (46)
  • .agents/architecture.md
  • .agents/structure.md
  • README.md
  • packages/adapter-prisma/test/unit/acceptance.spec.ts
  • packages/adapter-typeorm/test/unit/acceptance.spec.ts
  • packages/codec-url/test/unit/acceptance.spec.ts
  • packages/codec-url/test/unit/simple-roundtrip.spec.ts
  • packages/core/README.md
  • packages/core/src/build/parameter/filters/index.ts
  • packages/core/src/build/parameter/filters/merge.ts
  • packages/core/src/build/parameter/filters/module.ts
  • packages/core/src/build/parameter/filters/value.ts
  • packages/core/src/errors/code.ts
  • packages/core/src/errors/schema.ts
  • packages/core/src/parameter/fields/collection/module.ts
  • packages/core/src/parameter/filters/collection/module.ts
  • packages/core/src/parameter/filters/collection/types.ts
  • packages/core/src/parameter/filters/condition.ts
  • packages/core/src/parameter/filters/index.ts
  • packages/core/src/parameter/filters/preserve.ts
  • packages/core/src/parameter/filters/record/module.ts
  • packages/core/src/parameter/filters/record/types.ts
  • packages/core/src/parameter/filters/seal.ts
  • packages/core/src/parameter/merge.ts
  • packages/core/src/parser/parameter/filters/validate.ts
  • packages/core/src/parser/relation-prune.ts
  • packages/core/test/unit/build/filters-merge.spec.ts
  • packages/core/test/unit/build/module.spec.ts
  • packages/core/test/unit/parameter/condition-contract.spec.ts
  • packages/core/test/unit/parameter/filters-non-node.spec.ts
  • packages/core/test/unit/parameter/merge.spec.ts
  • packages/core/test/unit/parser/parameter/filters/validate.spec.ts
  • packages/core/test/unit/parser/parameter/key-validation.spec.ts
  • packages/core/test/unit/parser/relation-prune.spec.ts
  • packages/docs/guide/building-queries.md
  • packages/docs/guide/concepts.md
  • packages/docs/guide/errors.md
  • packages/docs/guide/filters.md
  • packages/docs/guide/merging-queries.md
  • packages/docs/guide/recipes/authorization.md
  • packages/docs/guide/recipes/express-typeorm.md
  • packages/docs/guide/recipes/frontend.md
  • packages/docs/guide/relations.md
  • packages/docs/packages/codec-url.md
  • packages/docs/packages/core.md
  • packages/parser-simple/test/unit/parser/relations-traversal.spec.ts
💤 Files with no reviewable changes (2)
  • packages/core/src/parameter/filters/seal.ts
  • packages/core/src/parameter/filters/record/types.ts

Comment on lines +106 to +128
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A __proto__ key is silently dropped instead of being merged as a field.

output is a plain object literal (Line 65). The write at Line 126 and Line 113 uses bracket assignment. If key is the string __proto__, Object.hasOwn(output, '__proto__') returns false, so the guard passes, but the assignment invokes the inherited __proto__ setter. No own property is created. The result: the field is lost, and the prototype of the returned object changes when the value is an object.

buildConditions in packages/core/src/build/parameter/filters/module.ts treats __proto__ as an ordinary field name and lowers it to a condition. So mergeFiltersInput and defineFilters disagree for that key. Global Object.prototype is not affected, so this is a correctness divergence rather than prototype pollution.

Give output a null prototype. Object.keys, Object.hasOwn, and the spread in defineFilters all still work.

🐛 Proposed fix
-    const output : Record<string, unknown> = {};
+    // a null prototype keeps `__proto__` an ordinary field name: a plain
+    // literal would route the assignment to the inherited setter and drop it.
+    const output : Record<string, unknown> = Object.create(null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/build/parameter/filters/merge.ts` around lines 106 - 128,
Update the merge path in mergeFiltersInput so the output accumulator is created
with a null prototype instead of a plain object literal, preserving the existing
key-merge logic in collectPaths and the root `$` handling. This will let
`__proto__` be stored as a normal own field without triggering the inherited
setter, while keeping `Object.keys`, `Object.hasOwn`, and the downstream
defineFilters spread behavior unchanged.

@tada5hi
tada5hi merged commit 489c9c0 into master Aug 7, 2026
9 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
tada5hi added a commit to authup/authup that referenced this pull request Aug 7, 2026
Filter composition is conjunctive now (tada5hi/rapiq#890, closing #889):
`IFilters.merge` and `mergeQueries` retain every conjunct of both sides
instead of replacing same-field conditions, so no composition step can drop
an injected scope.

That makes non-displaceability structural and retires the machinery built
for it over the previous releases: the beta.15 `FILTERS_NOT_FLAT` throw, the
beta.16 seal marker, and the beta.18 `ICondition.seal()` contract member are
all gone. `and()` no longer wraps an injection in a distinguishable subtree,
so the encoded filter goes back to the plain AND it reads as and the
collection expectations return to their pre-beta.16 shape.

Authup needs no code change beyond the notes. The kit already keeps
interactive filters in its own state and strips them before `mergeQueries`,
so the one caller-facing consequence, that two `eq` conditions on a field
now intersect to nothing rather than the receiver winning, cannot reach it.
Nothing here calls `preserve()` or merges a filter tree, so the surviving
pruning marker and its `SCHEMA_PRESERVED_CONDITION_PRUNED` contradiction
check are out of reach too.
tada5hi added a commit to authup/authup that referenced this pull request Aug 7, 2026
…ition

The collection manager stripped filters out of every `mergeQueries` call and
recombined them by hand afterwards, because the old `Filters.merge` did
per-field replace: a search input on a scoped field would have displaced an
injected realm or owner scope rather than narrowing it.

rapiq beta.19 (tada5hi/rapiq#890) made filter merging conjunctive, so that
hazard is gone and the workaround with it. Both call sites collapse to a
plain `mergeQueries`, and `stripFilters` and `combineScopedFilters` are
removed.

Behaviour is unchanged, including conjunct order: the whole collection suite
passes with no expectation edits, and the two cases that pin the property
that motivated the carve-out, *search input cannot displace the injected
scope* and *composes context query and props query, both non-displaceable*,
still assert the same filter strings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pre-GA: is displaceability worth its contract cost, or should IR filter composition just be conjunctive?

2 participants