v27.0.0
27.0.0 (2026-06-09)
⚠ BREAKING CHANGES
- permissions: the singular
entity(where: …)query arg is now
${name}WhereLookup!(was${name}WhereUnique!in v24.0.0).
Consumers' GraphQL queries don't need to change shape — the cascade
fields they had to add at v24.0.0 are still required — but generated
TypeScript types for query variables now reference${name}WhereLookup
instead of${name}WhereUnique. Mutations are unaffected by this
commit;${name}WhereUniqueis back to v23 behavior and existing
mutation strings (where: { id: $id }) work again.
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
- feat(permissions): skip joined-entity WHERE on filter joins covered by root scope
When a query's WHERE traverses a relation only through field paths that
are already constrained by the queried entity's own permission WHERE,
the joined entity's Entity.WHERE adds nothing: every row it would
exclude has already been excluded by the root scope, and every row that
survives the root scope is one the user could have learned about by
reading the root entity anyway. In that case we skip the joined-entity
permission check on the filter join.
Concretely, with
Portfolio: { WHERE: { goal: { status:[ACTIVE], relation:{ status:[ACTIVE] } } } }
Goal: { WHERE: { demo: true } }
Relation: { WHERE: { demo: true } }
portfolios(where: { goal: { status:[ACTIVE], relation:{ status:[ACTIVE] } } })
no longer AND-s in goal.demo:true / relation.demo:true on the filter
join — the user's leaves are exactly the leaves the root scope already
pins, so the result is the full intersection the role is entitled to
without further demo-gating. A portfolios(where: { goal: { demo: false } }),
by contrast, falls back to strict per-table permission application
because the goal.demo leaf is not in the root's covered set.
The check is deliberately narrow:
- It fires only for filter-joined aliases (
…__W__<rel>/…__WS__<rel>).
Direct top-level<entity>(where: …)queries hit the root alias and
applyEntity.WHEREexactly as before. - It fires only when every leaf the user supplied at or below the join
is in the root permission's covered leaf-path set. A single
uncovered leaf at any depth re-enables the strict path. - Coverage is derived solely from each chain's root link's
where; no
per-field flags, no field-metadata coupling — adding aqueriable
annotation can never accidentally widen filterability. - Field-selection sub-queries (
portfolio.goal { name }) go through
applySubQueries, which re-runsapplyPermissionson the joined
entity for the field read. Filtering at the join skips Goal.WHERE;
reading goal fields on the result still gates on Goal.READ.
Adds:
collectLeafPaths(where)+collectCoveredLeafPaths(stack)in
permissions/check.ts— walks AND / OR / NOT, treats arrays /
primitives as leaves and plain objects as relation traversals,
collapses_<OP>filter-operator suffixes (_IN,_GT,_SOME,
…) so the path comparison is on the field, not the operator.collectUserLeavesByAlias(model, where)inresolvers/filters.ts
— walks the user's WHERE with the same alias scheme as
applyWhere(${model.name}__W__${key}) and reports the leaves
each filter-join alias carries at or below.- resolver loop in
buildQueryconsults both before calling
applyPermissionson each joined table; an unbounded fallback
preserves the existing strict behaviour whenever any precondition
fails.
Tests in tests/unit/permission-filter-coverage.spec.ts cover the
collectors (AND / OR / NOT, _<OP> collapse, empty input) and the
decision matrix (fully-covered → skip, mixed → no skip, deeper-only
covered path → skip both joins).
- Revert "feat(permissions): skip joined-entity WHERE on filter joins covered by root scope"
This reverts commit fcf0273.
- feat(permissions): skip joined-table READ on schema-mandated filter joins
filterable: { nonNull: true } is a schema-level contract that every
client MUST filter by the field. Requiring read permission on the
joined entity at runtime would make the contract unsatisfiable for any
role that lacks that permission — they cannot opt out at the schema
layer and cannot supply the field at runtime either. So on filter-join
aliases produced by a fully-mandatory cascade, applyPermissions for
the joined table is skipped.
The rule (collectMandatoryFilterAliases in resolvers/filters.ts):
- The relation field carrying the join is
filterable: { nonNull: true }, - the user's WHERE contents at that position are minimal (every leaf
is itself afilterable: { nonNull: true }field — relations are
recursively checked the same way), - and the whole chain from the root entity to this alias is built
from nonNull-filterable relations (no opt-infilterable: true
relation upstream).
Any filterable: true field, _<OP> / _SOME / _NONE suffix, or
optional traversal upstream drops the alias to the strict per-table
permission stack — those are the client's free choice and are
permission-gated as before. Top-level <entity>(where: …) queries are
unaffected: they hit applyPermissions on the root alias, never on a
__W__ / __WS__ alias.
Replaces the earlier "covered leaf paths" rule (reverted in the
preceding commit), which keyed off the permission WHERE and could
relax the joined check based on a permission addition — semantically
the wrong direction. The new rule keys off the schema and bypasses
the joined check only where the schema mandates the filter.
Tests in tests/unit/mandatory-filter-aliases.spec.ts cover the
positive case (full mandatory cascade), the inner-only minimal case,
optional-relation fallback (filter: true), opt-in suffixes
(_IN / _SOME), and AND/OR/NOT distribution.
- fix(lint): satisfy curly and prettier rules in collectMandatoryFilterAliases