Core Concepts
rapiq has four moving parts: Query, Schema, Parser, Adapter. Everything else in the package family is a variation of one of them. Understanding these four once makes every other page in these docs a detail.
Everything meets in the middle:
Query: the shared language
A Query is a small, typed object tree (an AST) with one collection per parameter:
class Query {
readonly fields: IFields; // which fields to return
readonly filters: IFilters; // and/or tree of conditions
readonly relations: IRelations; // which related resources to load
readonly sorts: ISorts; // order keys + direction
readonly pagination: IPagination; // { limit, offset }
}It is the only thing the packages share: parsers produce it, codecs transport it, schemas constrain it, adapters consume it. That single meeting point is what makes the pieces swappable: the TypeORM adapter neither knows nor cares whether a query arrived as a URL, an expression string, a MongoDB-style document or was built in code.
There are three ways to obtain one:
| You have… | Use | Where |
|---|---|---|
| Typed input in code | defineQuery + condition helpers | caller side |
| Raw wire input | a parser or URL decoder | receiver side |
| Special needs | hand-assembled AST nodes | rarely |
One query, four notations
All of these produce the same Query:
defineQuery<User>({
filters: { age: { $gte: 18 }, name: { $contains: 'jo' } },
});filter[age]=>=18&filter[name]=~jo~codec=url-expression&filter=and(gte(age, '18'), contains(name, 'jo')){ filters: { age: { $gte: 18 }, name: { $contains: 'jo' } } }Dialects are input syntax only; semantics live in the AST.
Schema: the server's contract
A Schema<RECORD> declares what a client may request, per parameter: allowed keys, defaults, alias mappings, a pagination cap:
const userSchema = defineSchema<User>({
name: 'user',
fields: { allowed: ['id', 'name', 'email'], default: ['id', 'name'] },
filters: { allowed: ['id', 'name', 'age'] },
relations: { allowed: ['realm'] },
sorts: { allowed: ['id', 'age'], default: { id: 'DESC' } },
pagination: { maxLimit: 50 },
schemaMapping: { realm: 'realm' },
});Parsers consult the schema while parsing: disallowed input is dropped by default, or throws when throwOnFailure is set. A SchemaRegistry stores schemas by name so relation paths (realm.name) validate against the related record's schema. Details in Schemas & Validation.
Parser: from input to Query
Parsers turn raw, untrusted input into a validated Query. Each speaks one input dialect:
SimpleParser: plain objects/arrays, URL-query-like ({ age: '>=18' })ExpressionParser: expression strings (and(eq(name, 'John'), gte(age, '18')))MongoParser: MongoDB-style documents ({ age: { $gte: 18 } })
Parsers are transport-agnostic: they read canonical parameter keys (fields, filters, …) and don't know about URLs. The URLCodec façade maps URL wire names (filter, page, include, …) onto the matching parser and performs the reverse trip. It hides expression/simple dialect dispatch from callers.
Adapter: from Query to results
Adapters execute a Query against a backend:
@rapiq/adapter-typeorm: mutates a TypeORMSelectQueryBuilder@rapiq/adapter-sql: renders parameterized SQL fragments for any driver@rapiq/adapter-prisma: serializes the query into a PrismafindManyargs object@rapiq/adapter-drizzle: serializes the query into a drizzle relational-queriesfindManyconfig@rapiq/adapter-memory: compiles the query into plain functions over in-memory data
They all consume the same AST: the SQL-facing pair walk it through the visitor pattern (every node has accept(visitor)), prisma and drizzle serialize it into plain config values, and memory compiles it into functions. New backends never require changes to core; see The Query AST if you want to build one.
Composition: queries are values
Because queries are plain immutable-ish values, they compose after construction, regardless of where each part came from:
import { mergeQueries } from '@rapiq/core';
const query = mergeQueries(userInput, componentDefaults, appDefaults);And the server can add a condition that remains a conjunct alongside client input:
const scoped = new Query({
...query,
filters: query.filters.and(eq('realm_id', actor.realmId)),
});Both patterns are covered in Merging & Composition.
Where each package sits
| Stage | Packages |
|---|---|
| Build | @rapiq/core (defineQuery, helpers) |
| Transport | @rapiq/codec-url |
| Parse & validate | @rapiq/parser-simple, @rapiq/parser-expression, @rapiq/parser-mongo + Schema from core |
| Execute | @rapiq/adapter-typeorm, @rapiq/adapter-sql, @rapiq/adapter-prisma, @rapiq/adapter-drizzle, @rapiq/adapter-memory |
Next steps
Follow the pipeline in order: