Queries over the Wire
A Query crosses process boundaries through @rapiq/codec-url: the façade encodes the AST as a self-described URL query string and decodes incoming wire input back into the AST, validating against a schema along the way.
Encoding (caller)
import { createURLCodec } from '@rapiq/codec-url';
const codec = createURLCodec();
const queryString = codec.encode(query);
// codec=url-expression&fields=id,name&filter=gte(age%2C'18')&page[limit]=25&...
await fetch(`/users?${queryString}`);New payloads use the expression filter dialect and carry codec=url-expression. Pass { stamp: false } to omit the reserved stamp for receivers outside rapiq (e.g. strict JSON:API endpoints); untagged output is still recognized structurally on decode. The remaining URL parameters use JSON:API-style names:
| Parameter | URL key | Example |
|---|---|---|
| fields | fields | fields=id,name / fields[items]=id |
| filters | filter | filter=and(gte(age,'18'),eq(active,'true')) |
| pagination | page | page[limit]=25&page[offset]=50 |
| relations | include | include=realm,items |
| sorts | sort | sort=name,-age |
When root and relation fieldsets encode together, the root group is spelled with the reserved $root token: fields[$root]=id,name&fields[items]=id. A lone root group keeps the bare form (fields=id,name). Decoding accepts $root and the legacy __DEFAULT__ spelling written by early 2.0 betas.
Decoding (receiver)
The same façade accepts a raw query string or an already-parsed object such as Express' req.query:
const codec = createURLCodec(registry);
const query = codec.decode(
"codec=url-expression&filter=gte(age,'18')&sort=-age",
{ schema: 'user' },
);
app.get('/users', (req, res) => {
const query = codec.decode(req.query, { schema: 'user' });
if (!query) {
return res.status(400).end();
}
});Input that isn't URL-shaped
If your input already uses canonical parameter keys (filters, pagination, …), use a parser directly. MongoDB-style filter documents have their own parser.
Decoding a subset of parameters
Sometimes only part of a query should apply: a bulk delete, for example, must honor the request's filters but never its pagination. Pass parameters to decode only the listed parameters; everything else stays empty and, importantly, schema defaults such as pagination.maxLimit do not materialize for masked parameters:
app.delete('/sessions', (req, res) => {
const query = codec.decode(req.query, {
schema: 'session',
parameters: ['filters'],
});
// query.pagination is empty: the schema's maxLimit cannot
// silently truncate the delete's row selection.
});Migration dispatch
The v2 codec follows a read-both/write-expression migration:
- A stamped payload dispatches to its named dialect.
- An unstamped non-empty string
filteris parsed as an expression. - Any other unstamped defined
filter(bracket/object input, or an emptyfilter=) is parsed as legacy simple input. - An unknown stamped codec throws
CodecErrorwithCODEC_UNRESOLVABLE.
This lets receiving applications upgrade before callers. Existing URLs such as filter[age]=>=18 continue to decode, while upgraded callers begin producing expressions.
Simple encoding is available temporarily and explicitly:
import { URL_SIMPLE_CODEC } from '@rapiq/codec-url';
codec.encode(query, { codec: URL_SIMPLE_CODEC });
// codec=url-simple&filter[age]=>=18URL_SIMPLE_CODEC and simple encoding are deprecated for removal in v3.
What fits on the wire
Every wire dialect expresses a subset of the query AST. Inside that subset, decode(encode(query)) restores the same query up to scalar normalization ('5' returns as 5). Outside it, encoding throws a typed error instead of changing the query's meaning.
| legacy simple | expression | |
|---|---|---|
| Wire shape | filter[age]=>=18 per field | one filter=and(gte(age,'18'),…) expression |
| Flat AND filters | ✓ | ✓ |
or(...), not(...), nested groups | ✗ throws | ✓ |
| Several conditions on one field | ✗ throws | ✓ |
| Commas / simple operator markers in values | ✗ throws | ✓ (quoted) |
elemMatch (incl. the ITSELF element marker, wire spelling $this) | ✗ throws | ✓ |
regex / mod / exists | ✗ throws | ✗ throws |
Field.condition (row-scoped fields) | ✗ throws | ✗ throws |
Schema-aware transport
Use the same registry on both sides and select the schema per operation:
const codec = createURLCodec(registry);
const wire = codec.encode(query, { schema: 'user' });
const decoded = codec.decode(wire!, { schema: 'user' });Disallowed keys are dropped or thrown according to the schema, aliases resolve, pagination limits clamp, and filter validators run with parser-exact semantics. Parameters absent from the source query remain absent from the wire.
When validators are asynchronous, use await codec.encodeAsync(...) and await codec.decodeAsync(...).
Next steps
- Executing Queries: what the receiver does with the decoded query.
- @rapiq/codec-url: complete dialect, migration and extension reference.