@zeroxsolutions/query
v0.5.1
Published
The query contract for a read - one permit-list declaration and one guard for a collection and a single resource, in the JSON:API spelling (page, sort, filter, fields, include); the guard answers every permitted fieldset, and a permit-list declared as con
Readme
@zeroxsolutions/query
What a client may ask of a read: page, sort and filter a collection, and select and include on either kind - declared once, enforced once, in the JSON:API spelling.
Install
pnpm add @zeroxsolutions/queryPeer dep: zod. Nothing else - no framework, no envelope, no driver.
One declaration, one guard, one spelling
| Holds | Wire | Read by |
| --- | --- | --- |
| the permit-list, the guard, the parsed shape, the errors | ?page[limit]=20&filter[f0][condition][path]=name&fields[classes]=name&include=subject | a bracket tokenizer, by hand |
One wire spelling, so a call site has nothing to choose. A second encoding of the same
GuardedCollectionQuery bought no capability and made every schema file a decision nobody had a rule
for; renderCollectionQuery puts the same families on a service-to-service hop.
import { collectionQuerySchema } from '@zeroxsolutions/query';
import type { CollectionQueryPermits } from '@zeroxsolutions/query';
const classQueryPermits = {
sortable: ['name', 'createdAt'],
filterable: { name: { operators: ['equals', 'contains'] } },
filterItem: filterValueSchema, // the executor's vocabulary - this package names no operator
} satisfies CollectionQueryPermits<typeof filterValueSchema, keyof ClassRow & string, FilterOperator>;
createRoute({ request: { query: collectionQuerySchema(classQueryPermits) } });
const { offset, limit, sort, filter } = c.req.valid('query'); // already narrowedThe schema pipes through the guard, so there is no second call to remember and no way to read the query while skipping the permit-list.
collectionLinks answers the same query back as the five links a page carries, each the caller's own
query at a different offset - so the filter, sort and fieldset that produced the page ride along, and
the page a collection has none of is null rather than a link to itself.
import { collectionLinks } from '@zeroxsolutions/query';
const links = collectionLinks(c.req.path, query, total); // { self, first, prev, next, last }A read-one has no page to walk and no ordering over one row, so resourceQuerySchema declares the
two families that are left - the ones that shape the document rather than the page.
CollectionQueryPermits extends ResourceQueryPermits, so a type gaining an attribute cannot become
selectable on one endpoint and not the other.
import { resourceQuerySchema } from '@zeroxsolutions/query';
const classQueryPermits = {
fields: { classes: ['name', 'subject'], subjects: ['name'] }, // columns and relations, as the table names them
computed: { classes: ['openState'] },
include: ['subject'],
} as const satisfies TableQuery<typeof schema, 'classesTable'> & ResourceQueryPermits;
createRoute({ request: { query: resourceQuerySchema(classQueryPermits) } });
const { fields, include } = c.req.valid('query'); // PermittedResourceQuery<typeof classQueryPermits>The guard answers every permitted type: one the request gave no fieldset reads under its whole permitted list, so
a finder handed the query loads what the resource publishes and nothing the permit-list left out. computed names
the fields a route computes rather than reads - a client may name one in a fieldset, and the guarded query never
carries it, so a finder never looks for a column of that name. include is never filled: an unsent include stays unsent.
Declared as const, or checked with satisfies against a type naming each field such as @zeroxsolutions/db's
TableQuery, the permit-list types the result: each fieldset holds only the names it permits, so the guarded
query passes to @zeroxsolutions/db's finders without a cast. A caller forwarding a query to another service guards it
again under that service's permit-list with guardResourceQuery, which narrows the fieldsets the same way.
Why the reading is hand-written
The grammar puts its information in the query-string key - filter[f0][condition][path]=name -
which no schema can express, so bracket-params and filter-params tokenize it by hand. That reading
stops the moment it has produced a tree.
Everything after the tree is guardCollectionQuery, and it is where every bound lives: a maxDepth
enforced inside the reader would be enforced against the shape the reader happened to build, rather
than against the permit-list the endpoint declared.
An omitted permit-list permits nothing
A column added to a table later is refused until someone names it, rather than exposed until someone
hides it. sortable carries the same weight as filterable: paging a hidden column's order reveals
it without ever reading the field. Bind CollectionQueryPermits's Field and Op type parameters
and a dropped column turns every collection read naming it red, while a mistyped operator is a
compile error rather than a 400 nobody sees until a client sends that filter.
Two kinds of refusal, answered differently
A shape breach - a family that is not readable, a parameter the permit-list never declared -
is a ZodError a validation hook renders field by field. A
permit-list breach raises QueryNotPermitted (carrying the offending parameter), which
travels past safeParse so the transport maps it by type to its own status and code. Neither
error carries a status or a wire code of its own, so both work under any error shape - pair them
with @zeroxsolutions/response's defineErrorMap where that is what you answer with.
The page window is the one family that does not refuse: a limit past maxLimit is clamped to
it, so a client asking for more is served fewer without being told.
API reference
The version-accurate API is the shipped types - every export carries TSDoc, compiled into
dist/**/*.d.ts.
Building & testing
pnpm nx build @zeroxsolutions/query
pnpm nx test @zeroxsolutions/query