Query API
Filter, sort, paginate & expand relations
Declaring an entity does more than create a table — it mounts a list endpoint that understands a full query language. The same StandardQueryParams shape is accepted by every generated collection route, parsed from the query string, validated, and translated into a safe parameterised Drizzle query.
Nothing here is hand-written per entity. Filtering, searching, sorting, field selection, pagination and nested relation loading all come for free the moment a table exists, and every response is wrapped in the same typed StandardReturn envelope so the client always knows what it's getting.
Parameters can be sent as structured objects (from the typed client) or as JSON strings (handy for raw URLs) — filters, sort and with all accept either form.
Pagination#
List endpoints are paginated by default. Ask for a page, or drive it with a raw offset — the response always carries a meta block with the totals and the next/prev cursors so a table or infinite list never has to compute them itself.
1GET /products?page=2&limit=50pagenumberOptional1-based page number. Combined with limit it derives the SQL offset for you. Out-of-range pages return an empty items array with an honest meta block rather than an error.
1limitnumberOptionalPage size — how many rows to return. A sane upper bound is enforced server-side (max 200 rows per request): values above 200 are clamped to 200, and NaN or ≤0 values fall back to the default of 20, so a client can never ask for the entire table in one request.
20offsetnumberOptionalExplicit row offset. When provided it takes precedence over page, which is useful for cursor-style or virtualised lists that think in absolute positions.
Filtering#
filters is an array of { field, operator, value } conditions combined with AND. Operators cover equality, comparison, pattern matching, set membership, null checks, array containment, and nested and/or groups. Every value is bound as a parameter — there is no string interpolation, so the surface is injection-safe by construction.
filtersFilterCondition[] | stringOptionalThe conditions to apply. Pass an array of { field, operator, value } objects, or a JSON-encoded string of the same. A field the caller isn't allowed to read (a sensitive column, or one outside a field-restricted read claim) returns 403. A field that simply doesn't exist on the table is silently ignored ONLY when the caller has an unrestricted read claim (or auth is disabled); under a field-restricted read claim, any field not in the claim's allowedFields — nonexistent or not — is treated as unreadable and returns 403.
Example: [{ "field": "status", "operator": "eq", "value": "active" }]
operatorFilterOperatorOptionaleq/neq compare equality; gt/gte/lt/lte compare order; like/ilike match SQL patterns (ilike is case-insensitive); in/notIn test set membership against an array value; isNull/isNotNull test for NULL and ignore value; arrayOverlaps/arrayContains/arrayEmpty operate on array columns; or/and nest a FilterCondition[] group as their value and omit field.
eq · neq— Equals / not equals.gt · gte · lt · lte— Greater / less than, with inclusive variants.like · ilike— SQL pattern match; ilike is case-insensitive.in · notIn— Membership test against an array of values.isNull · isNotNull— NULL presence check; the value field is ignored.arrayOverlaps · arrayContains · arrayEmpty— Array-column operators: overlap / contains against an array value, and an emptiness test.or · and— Group operators: value is a nested FilterCondition[]; field is omitted.
searchstringOptionalA single free-text term applied across searchFields with a case-insensitive partial match — the one-box search a UI usually wants, without composing filters by hand.
searchFieldsstring (comma-separated)OptionalWhich columns search scans, OR-combined with a case-insensitive ILIKE. Pass a COMMA-SEPARATED string (e.g. "name,description") — the server splits on commas and does not JSON-decode this field. It is required for search to do anything — with no searchFields the search term is ignored (there is no default column set). Note: the array form fails through the typed client (it JSON-encodes searchFields into a literal string), so use the comma-string form.
Example: "name,description"
Sorting & field selection#
Order results by one or many columns per request. sort drives the ORDER BY on every list query, and select projects the list down to the columns you ask for. distinct and distinctOn are still accepted by the parser but NOT applied to the list query today — they are reserved shape only; for unique values use the dedicated GET /distinct/:field route.
sortSortCondition[] | stringOptionalAn ordered list of { field, direction } where direction is 'asc' or 'desc'. Multiple entries sort by the first, then break ties with the next, and so on. Every sort field must be one the caller may read, or the list returns 403.
Example: [{ "field": "createdAt", "direction": "desc" }]
selectstring (comma-separated)OptionalProjects the returned columns down to the requested set. Sent as a COMMA-SEPARATED string (e.g. select=id,name,price); the server splits on commas and does not JSON-decode it. id is always retained so a trimmed row stays addressable. Every requested field passes the same read gate as filter/sort: a sensitive column or a field outside a field-restricted read claim returns 403, and a field that doesn't exist on the table returns 400. Note: the array form fails through the typed client — like searchFields, select is JSON-encoded into a literal string, which the comma-split then turns into garbage field names → 400. Independently of select, three server-side trims always run: (1) an UNCONDITIONAL auth-secret redaction that strips password / password_hash / refresh_token_hash and the email-verification + password-reset token columns from every returned row and included relation at every depth (regardless of claims or whether auth is enabled); (2) config-declared `sensitive: true` columns are stripped from every returned row and included relation (at all depths) for a caller holding only the table-level read claim — the column survives ONLY when the caller holds a field-restricted read claim whose allowedFields includes it, and this stripping silently drops the column rather than returning 403; (3) claim-based field-level trimming when a field-restricted read claim applies. The (2) sensitive-column trim is the response-side complement of the filter/sort/select 403 gate above: the same per-column registry that returns 403 when such a column is used in filter/search/sort/select/distinct without the claim also scrubs it from rows on the way out. with[].columns remains parsed but unhonored.
Example: "id,name,price"
distinctbooleanOptionalParsed but not applied to the list query. To fetch the unique values of a single column, call GET /distinct/:field instead.
falsedistinctOnstring[] | stringOptionalParsed but not applied to the list query. Reserved for a future Postgres DISTINCT ON; use GET /distinct/:field for unique values today.
Relation loading#
with eager-loads related records defined in your Drizzle relations file. The RelationQuery shape can describe filtered, column-trimmed, limited and nested includes — but see the callout for what the generated routes actually honor today.
1actions.GET_POSTS.start({2 payload: {3 page: 1,4 limit: 20,5 filters: [{ field: "published", operator: "eq", value: true }],6 sort: [{ field: "createdAt", direction: "desc" }],7 with: [8 { name: "author", columns: ["id", "name"] },9 { name: "comments", limit: 3, with: [{ name: "author" }] },10 ],11 },12 onAfterHandle: (res) => {13 console.log(res.data.items, res.data.meta.totalPages);14 },15});withRelationQuery[] | stringOptionalThe relations to expand. Each RelationQuery names a relation and may add its own columns, where, limit and a nested with for deeper graphs.
with[].namestringRequiredThe relation key as defined in relations.ts (e.g. 'author', 'comments').
with[].columnsstring[]OptionalRestrict which columns of the related rows come back.
with[].whereFilterCondition[]OptionalFilter the related rows using the same condition shape as top-level filters.
with[].limitnumberOptionalCap how many related rows load per parent record.
with[].withRelationQuery[]OptionalNest another relation query to load grandchildren — the structure is fully recursive.
The response envelope#
Every endpoint — query or mutation — resolves to a StandardReturn<T>. List endpoints specialise it to PaginatedReturn<T>, where data is a PaginatedResult with both the items and a ready-made meta block.
successbooleanOptionalWhether the operation succeeded. The client uses this to route into your onAfterHandle vs onErrorHandle callbacks.
dataT | PaginatedResult<T>OptionalThe payload. For lists it's { items, meta }; for single reads and mutations it's the record itself.
messagestringOptionalA human-readable status message, surfaced in toasts or logs.
errorError | stringOptionalPresent only on failure; describes what went wrong.
data.metaPaginationMetaOptionalOn lists: page, limit, offset, totalItems, totalPages, hasNextPage, hasPrevPage, nextPage and prevPage — everything a pager needs, pre-computed.
Related sections