One contract.
Every table
endpoint.
drizzle-resource
gives your server a typed query layer for filters, search, sorting, pagination, row hydration,
and facets, all inferred from your Drizzle schema.
npm install drizzle-resourceconst result = await orders.query({
context: { orgId: "acme" },
request: {
context: {},
pagination: { mode: "offset", pageIndex: 1, pageSize: 25 },
sorting: [{ key: "createdAt", dir: "desc" }],
search: { value: "laptop", fields: [] },
filters: [
{
type: "condition",
key: "status",
operator: "isAnyOf",
value: ["pending", "processing"],
},
],
facets: [{ key: "status", mode: "exclude-self", limit: 10 }],
},
});
Validation integrations
Keep the resource contract
at the transport boundary
Derive strict request and relation-aware response schemas from the same resource your query runs. Zod and Valibot overrides keep their column inputs and parsed output types intact, so endpoint validation does not introduce a second contract to maintain.
Explore validationThe problem it solves
Every table API ends up with the same ad-hoc logic: parse sort params, build WHERE
clauses, run a count, hydrate rows, maybe group buckets for a filter sidebar. You rewrite it
slightly differently each time, and the client has to learn a different shape each time.
drizzle-resource standardizes that entirely.
Staged pipeline
Not a monolithic query
Every request runs five ordered stages — watch one flow through. Any stage can be replaced independently.
- 01Scope merge
Tenant filters merge in before any client filter can run.
- 02Field validation
Sort keys and filter paths are checked against your schema.
- 03ID select
One paginated primary-key query with every filter applied.
- 04Row hydration
Rows load by id with declared relations, order preserved.
- 05Facets
Bucket counts resolve only when the request asks for them.
Define once
The resource is
the contract
Declare relations, scope, search, sort, and facet policy next to your schema. Every endpoint speaks the same request shape — the server decides what is allowed.
- Scope filters merge server-side — clients cannot bypass tenancy
- Unknown sort keys and filter paths are rejected before any SQL runs
- The request shape never changes from one table to the next
export const ordersResource = engine.defineResource("orders", {
relations: {
customer: true,
orderLines: { with: { product: true } },
},
query: {
scope: (f, ctx) => f.is("customer.orgId", ctx.orgId),
search: {
allowed: ["reference", "customer.name", "orderLines.product.name"],
defaults: ["reference", "customer.name"],
},
sort: { defaults: [{ key: "createdAt", dir: "desc" }] },
facets: {
allowed: ["status", "customer.name", "orderLines.product.category"],
},
},
});
await orders.query({
context: { orgId: "acme" },
request: {
context: {},
pagination: { mode: "offset", pageIndex: 1, pageSize: 25 },
sorting: [{ key: "customer.name", dir: "asc" }],
search: { value: "laptop", fields: [] },
filters: [
{
type: "condition",
key: "orderLines.product.category",
operator: "isAnyOf",
value: ["laptops", "accessories"],
},
{
type: "condition",
key: "customer.billingCountry",
operator: "is",
value: "FR",
},
],
facets: [
{
key: "orderLines.product.category",
mode: "exclude-self",
limit: 10,
},
],
},
});
The contract
Six capabilities, one request
is, isAnyOf, contains, between, before, after, and more.allowed and defaults lists per resource.Granular execution
Four methods, one pipeline
resource.query()
The full pipeline — ids, rows, and optional facets in one call.
resource.queryIds()
Page ids and pagination metadata only — cache or batch the rest.
resource.queryRows()
Hydrate a known id list without re-running selection.
resource.queryFacets()
Resolve facet buckets independently from the page.
Use cases
Where it fits in your stack
Ready to standardize
your table APIs?
Define your engine, add a resource, run your first query.
::