Writing your own Adapter
The v3 adapter contract, what the core guarantees before an adapter runs, and why third-party adapters are not a supported extension point yet.
Not a supported extension point
In v3 the adapter identity is a closed union. Both
RestQueryAdapterV3.id and QuerySource.kind are typed
'typeorm' | 'prisma' | 'drizzle', so an adapter for a fourth ORM cannot be
typed without reusing one of those three ids or writing a cast — and "no cast in
documented usage" is a release gate for this library. There is also no
forRoot({ adapter }) to register one.
So this page documents the contract as it exists, for reading the three implementations and for contributing a fourth. It is not a recipe for shipping a private adapter.
What the core does before an adapter runs
An adapter never sees a query string, and never has to guess a type. By the time
compile() is called, the core has already:
- Parsed the input against a safe path alphabet.
*,;,.., an empty path and anything that could escape into SQL are rejected asQUERY_SYNTAX_INVALID— which is what keeps the projection wildcard out of a client's reach. A parameter outside the eight-name grammar (filter,sort,fields,includes,search,page,perPage,paginate) is refused with400 QUERY_SYNTAX_UNKNOWN_PARAM, never ignored; the key name travels indetails, the value never does. - Authorised every path against the endpoint's compiled rules, by exact match, and every operator against the per-field list.
- Coerced every value by the field's declared
kind— never by how the text looks. Bad input isFILTER_VALUE_INVALID;decimalarrives as an exactDecimalValue,dateas aCivilDate,datetimeas aDate,bigintas abigint. - Resolved the effective column: the field itself, its
foldedFieldforilike/notIlike, or itsportableOrderFieldfor order comparisons. - Marked existential terms. A filter or
searchtarget whose path crosses amanyrelation carriesexistential: true, so "some related row matches" is decided by the plan, not by each adapter's cleverness. - Built and frozen the plan. Data and count derive from the same frozen plan, so the two queries always describe the same question.
After execute(), the core normalises: internal columns and unrequested primary
keys are dropped, and each kind is serialised the same way regardless of what
the driver returned. Normalisation is not the adapter's job — that is
deliberate, because three normalisations would be three chances to diverge.
The contract
export interface RestQueryAdapterV3<
TSource,
TCompiled,
TRow,
TNative = TCompiled,
> {
readonly id: 'typeorm' | 'prisma' | 'drizzle';
/** Derive the logical schema from the source's metadata. */
describe(source: TSource): Promise<QuerySchema>;
capabilities(source: TSource): AdapterCapabilities;
compile(plan: TypedQueryPlan, source: TSource): TCompiled;
/**
* Apply the callback to the native context, once per query in scope.
* Calling once per query — rather than handing both over together — is what
* makes 'both', the safe default, actually reach data and count.
*/
customize(
compiled: TCompiled,
callback: (native: TNative) => void,
scope?: CustomizeScope
): void;
execute(compiled: TCompiled): Promise<AdapterResult<TRow>>;
}
export interface AdapterResult<TRow> {
/** Rows already hydrated in nested form, internal columns still present. */
readonly rows: readonly TRow[];
/**
* Absent when the plan asked for paginate=false. In that case fetch at most
* `plan.pagination.maxRows + 1` roots: the service refuses a result above
* `maxRows` with a 400, and the extra row is how it tells "at the cap" from
* "over it" without a COUNT.
*/
readonly total?: number;
/** How many queries were emitted; used by the budget tests. */
readonly queryCount?: number;
}TNative is the context customize hands to the consumer, and it is distinct
from TCompiled because one plan compiles to two queries: the callback acts
on one at a time. Under TypeORM TNative is the SelectQueryBuilder; under
Prisma it is { kind: 'data' | 'count', args }.
describe() is load-bearing
Before compiling, the core compares the schema the adapter derives with the
schema the endpoint rules declare, field by field: model, primaryKey, and
per field kind, nullable, primaryKey, foldedField,
portableOrderField, internal; per relation target, cardinality,
nullable. Any difference is SOURCE_CONFIGURATION_INVALID. Only the root
schema is compared.
An adapter that cannot derive metadata honestly weakens that guarantee rather than the check: the Prisma adapter returns its manifest's schema verbatim, and the consequence is documented on its page — nothing there validates the declaration against the database.
capabilities() is a promise the core enforces
export interface AdapterCapabilities {
readonly dialect: 'postgres' | 'mysql' | 'mssql' | 'sqlite';
readonly transactionalConsistency: boolean;
readonly escapeCharacter: string;
readonly patternEscape: 'clause' | 'native' | 'unsupported';
}patternEscape is the one that matters most, and it exists because a character
alone cannot distinguish "I emit the clause" from "I trust the database
default":
| Value | Meaning |
|---|---|
'clause' | Emits ESCAPE '<escapeCharacter>' explicitly. Correct in every dialect. |
'native' | Trusts the dialect's default escape character, with no clause. Only correct on Postgres and MySQL. |
'unsupported' | No default escape and no clause possible: the pattern operators are refused with CAPABILITY_UNAVAILABLE rather than returning wrong rows. |
Declaring escapeCharacter while never emitting ESCAPE is exactly the bug
this split was introduced to make impossible.
transactionalConsistency: false makes consistency: 'transactional' fail
early instead of running data and count under different snapshots. All three
shipped adapters report false today, which is why forRoot refuses
consistency: 'transactional' at startup with SOURCE_CONFIGURATION_INVALID.
The per-source capability check stays: it is what decides the case for a fourth
adapter that does offer the guarantee.
The source
export interface QuerySource<TSource, TCompiled, TRow, TNative = TCompiled> {
readonly kind: 'typeorm' | 'prisma' | 'drizzle';
readonly adapter: RestQueryAdapterV3<TSource, TCompiled, TRow, TNative>;
readonly input: TSource;
/** Certified profile facts, collected at startup — never in the request path. */
readonly portabilityProfile?: ProfileFacts;
}Carrying adapter, native input and discriminant together is what lets
execute() infer the row type and the customize callback type with no cast at
the call site. It is also why there is no global adapter setting: the source
already knows.
Contributing an adapter
If you want a fourth ORM supported, the path is a pull request, not a plugin:
- Open a Discussion describing the ORM.
- Follow the structure in
src/infra/adapters/—typeorm/is the reference implementation,prisma/anddrizzle/show two different compilation strategies. - Make the parity corpus in
tests/v3/corpus/pass on the reference dialect first, then on the real cells. A divergence has to be declared as data on the case itself, with a mandatory reason — the corpus inventory refuses a new one that nobody reviewed. - Add the adapter's id to the unions and export it as a subpath, e.g.
nestjs-rest-query/kysely.
The branch coverage floor for adapter directories is 100%, on a ratchet: parity runs through the adapters, and a ratchet is the only regime that stops silent erosion.