Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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<RECORD>(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. 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(<leaf>, <scope>)` 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(...)` it to keep that true across a `flatten()`) 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.
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(<leaf>, seal(<scope>))` 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.
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.

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/errors/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export enum ErrorCode {

SCHEMA_NAME_INVALID = 'schemaNameInvalid',

SCHEMA_SEALED_CONDITION_PRUNED = 'schemaSealedConditionPruned',

SCHEMA_UNRESOLVABLE = 'schemaUnresolvable',

SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER = 'schemaValidatorAsyncRequiresAsyncParser',
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/errors/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export class SchemaError extends BaseError {
});
}

static sealedConditionPruned(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,
});
}

static validatorAsyncRequiresAsyncParser() {
return new this({
message: 'Asynchronous schema validators require parseAsync().',
Expand Down
77 changes: 68 additions & 9 deletions packages/core/src/parser/relation-prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* view the LICENSE file that was distributed with this source code.
*/

import { SchemaError } from '../errors';
import {
Field,
Fields,
Expand All @@ -29,14 +30,22 @@ import type { FiltersSchema, SortSchema } from '../schema';
import { parseKey } from '../utils';
import { buildFiltersDefaults } from './parameter/filters/validate';

/**
* The rejected relation governing a canonical relation/field path: the path
* is the relation itself or lives underneath it.
*/
function matchRelationRejected(path: string, rejected: string[]) : string | undefined {
return rejected.find(
(name) => path === name || path.startsWith(`${name}.`),
);
}

/**
* Whether a canonical relation/field path is governed by a rejected relation —
* the path is the relation itself or lives underneath it.
*/
export function isRelationRejected(path: string, rejected: string[]) : boolean {
return rejected.some(
(name) => path === name || path.startsWith(`${name}.`),
);
return typeof matchRelationRejected(path, rejected) !== 'undefined';
}

function joinPath(prefix: string, segment: string) : string {
Expand Down Expand Up @@ -110,6 +119,17 @@ export function pruneRelationsByRelations(relations: IRelations, rejected: strin
* `elemMatch` conditions are addressed relative to the array element, so a
* 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(<leaf>, <scope>)` 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
* happen to fail open.
*/
export function pruneFiltersByRelations(
filters: IFilters,
Expand All @@ -120,7 +140,7 @@ export function pruneFiltersByRelations(
return filters;
}

const pruned = pruneCondition(filters, rejected, '');
const pruned = pruneCondition(filters, rejected, '', false);
if (pruned && isFilters(pruned)) {
return pruned;
}
Expand All @@ -130,28 +150,65 @@ export function pruneFiltersByRelations(
conditions = [pruned];
} else {
conditions = schema ? buildFiltersDefaults(schema) : [];

// 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
// 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);
}
}

return new Filters(FilterCompoundOperator.AND, conditions);
}

/**
* Raise the sealed-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 {
pruneCondition(condition, rejected, '', false);
}

/**
* Drop the condition at `field`, unless it is protected: a drop inside a sealed
* 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);
}

return undefined;
}

function pruneCondition(
node: ICondition,
rejected: string[],
prefix: string,
sealed: 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;

if (isFilter(node)) {
const field = joinPath(prefix, node.field);
const rejectedBy = matchRelationRejected(field, rejected);

if (
node.operator === FilterFieldOperator.ELEM_MATCH &&
isConditionValue(node.value)
) {
if (isRelationRejected(field, rejected)) {
return undefined;
if (typeof rejectedBy === 'string') {
return dropUnlessSealed(rejectedBy, field, sealed2);
}

const interior = pruneCondition(node.value, rejected, field);
const interior = pruneCondition(node.value, rejected, field, sealed2);
if (!interior) {
return undefined;
}
Expand All @@ -163,7 +220,9 @@ function pruneCondition(
return node;
}

return isRelationRejected(field, rejected) ? undefined : node;
return typeof rejectedBy === 'string' ?
dropUnlessSealed(rejectedBy, field, sealed2) :
node;
}

if (!isFilters(node)) {
Expand All @@ -172,7 +231,7 @@ function pruneCondition(

const conditions : ICondition[] = [];
for (const child of node.value) {
const child2 = pruneCondition(child, rejected, prefix);
const child2 = pruneCondition(child, rejected, prefix, sealed2);
if (child2) {
conditions.push(child2);
}
Expand Down
134 changes: 134 additions & 0 deletions packages/core/test/unit/parser/relation-prune.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import {
ErrorCode,
Field,
Fields,
Filter,
Expand All @@ -24,6 +25,7 @@ import {
pruneFiltersByRelations,
pruneRelationsByRelations,
pruneSortsByRelations,
seal,
} from '../../../src';
import type { IFilters } from '../../../src';

Expand Down Expand Up @@ -197,4 +199,136 @@ describe('src/parser/relation-prune.ts', () => {
expect(pruneFiltersByRelations(filters, ['items.owner']).value).toEqual([]);
});
});

describe('pruneFiltersByRelations (sealed conditions)', () => {
const eq = (field: string) => new Filter(FilterFieldOperator.EQUAL, field, 'x');

it('throws instead of dropping a sealed leaf', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(eq('user.name')),
]);

expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

it('names the rejected relation and the sealed field', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(eq('realm.id')),
]);

expect(() => pruneFiltersByRelations(filters, ['realm']))
.toThrowError(/"realm".+"realm\.id"/);
});

it('throws instead of dropping a policy residual out of a sealed group', () => {
// the shape a filters validate hook produces:
// seal(and(<client leaf>, <policy residual>))
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filters(FilterCompoundOperator.AND, [
eq('name'),
eq('realm.id'),
])),
eq('realm.name'),
]);

expect(() => pruneFiltersByRelations(filters, ['realm']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

it('throws for a sealed condition nested below an unsealed group', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
new Filters(FilterCompoundOperator.OR, [
eq('id'),
seal(eq('user.name')),
]),
]);

expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

it('throws instead of dropping a sealed elemMatch', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filter(
FilterFieldOperator.ELEM_MATCH,
'items',
new Filter(FilterFieldOperator.EQUAL, 'id', 1),
)),
]);

expect(() => pruneFiltersByRelations(filters, ['items']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

it('throws instead of pruning the interior of a sealed elemMatch', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filter(
FilterFieldOperator.ELEM_MATCH,
'items',
new Filters(FilterCompoundOperator.AND, [
eq('owner.name'),
new Filter(FilterFieldOperator.EQUAL, 'id', 1),
]),
)),
]);

expect(() => pruneFiltersByRelations(filters, ['items.owner']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_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', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])),
]);

expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});
Comment on lines +280 to +291

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

Exercise a sealed OR arm, not only a sealed OR group.

Line 286 calls seal() on the entire OR node. The test does not verify that a sealed user.name arm is rejected while the surrounding OR remains unsealed. Move seal() to the rejected arm, or rename the test and comments to describe a sealed group.

Proposed test shape
 const filters = new Filters(FilterCompoundOperator.AND, [
-    seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])),
+    new Filters(FilterCompoundOperator.OR, [eq('id'), seal(eq('user.name'))]),
 ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])),
]);
expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_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', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
new Filters(FilterCompoundOperator.OR, [eq('id'), seal(eq('user.name'))]),
]);
expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});
🤖 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/parser/relation-prune.spec.ts` around lines 280 -
291, Update the test around seal and the OR filter so only the user.name arm is
sealed while the surrounding OR node remains unsealed, then retain the
expectation that pruning for user throws SCHEMA_SEALED_CONDITION_PRUNED. Adjust
the test name and comments to describe a sealed OR arm rather than a sealed OR
group.


it('throws for a sealed condition below a NOT', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
new Filters(FilterCompoundOperator.NOT, [seal(eq('user.name'))]),
]);

expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

it('keeps pruning around a sealed condition it does not touch', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(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);
});

it('re-applies an UNSEALED 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')]);
const schema = defineFiltersSchema({ default: eq('user.b') });

expect(filterFields(pruneFiltersByRelations(filters, ['user'], schema))).toEqual(['user.b']);
});

it('throws for a SEALED 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')) });

expect(() => pruneFiltersByRelations(filters, ['user'], schema))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});
Comment on lines +323 to +332

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  'SCHEMA_SEALED_CONDITION_PRUNED|default: seal|defineFiltersSchema' \
  packages/core/test/unit/parser/relation-prune.spec.ts

Repository: tada5hi/rapiq

Length of output: 8732


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate source definitions =="
rg -n "function pruneFiltersByRelations|export .*pruneFiltersByRelations|SCHEMA_SEALED_CONDITION_PRUNED|defineFiltersSchema" packages/core/src packages/core/test -g '*.ts' | sed -n '1,220p'

echo
echo "== relevant source snippets =="
for file in $(rg -l "pruneFiltersByRelations|SCHEMA_SEALED_CONDITION_PRUNED|defineFiltersSchema" packages/core/src packages/core/test -g '*.ts' | head -20); do
  echo "--- $file ---"
  rg -n -C 20 "pruneFiltersByRelations|SCHEMA_SEALED_CONDITION_PRUNED|defineFiltersSchema" "$file" | sed -n '1,220p'
done

Repository: tada5hi/rapiq

Length of output: 44603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path('packages/core/src/parser/relation-prune.ts')
text = path.read_text()

m = re.search(r'export function pruneFiltersByRelations[\s\S]+?^}', text, re.M)
print(m.group(0) if m else 'function not found')

print()
print('uses buildFiltersDefaults:')
print('buildFiltersDefaults' in (m.group(0) if m else ''))

print('check all calls to pruneFiltersByRelations in tracked TS:')
for p in Path('packages/core').glob('**/*.ts'):
    if any(part.endswith(('.test', '.spec')) for part in p.parts):
        lines = p.read_text(errors='ignore').splitlines()
        for i, line in enumerate(lines, 1):
            if 'pruneFiltersByRelations(' in line or 'buildFiltersDefaults(' in line:
                print(f'{p}:{i}:{line.strip()}')
PY

Repository: tada5hi/rapiq

Length of output: 1448


Cover the no-client-filter sealed-default path.

The existing case only prunes a client leaf and then checks the default fallback. Add the opposite call shape with an empty client filters tree, or cover that exact sealed default in another sealed-default test.

[loweffort_and_high_reward]

🤖 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/parser/relation-prune.spec.ts` around lines 323 -
332, The sealed-default coverage currently only exercises pruning after a client
filter is present. Extend the relation-pruning tests around
pruneFiltersByRelations to also call it with an empty client filters tree and a
sealed default referencing the rejected relation, asserting
ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED; preserve the existing non-empty filter
case.

});
});
1 change: 1 addition & 0 deletions packages/docs/guide/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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_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) |

Expand Down
Loading
Loading