Auto CRUD
Generated oRPC procedures with row-level access control
Bunderstack generates secured oRPC procedures from your Drizzle schema. The same procedures are available through the typed client and ordinary HTTP.
Procedure graph and HTTP routes
| Client procedure | HTTP | Description |
|---|---|---|
api.<table>.list | GET /api/:table | List and paginate |
api.<table>.get | GET /api/:table/:id | Get by id |
api.<table>.create | POST /api/:table | Create |
api.<table>.update | PATCH /api/:table/:id | Update |
api.<table>.delete | DELETE /api/:table/:id | Delete |
List query
Every parameter below is part of the procedure's schema, so REST and RPC accept exactly the same thing and query strings are coerced to the column types.
| Param | Example | Description |
|---|---|---|
limit | ?limit=20 | Page size (default 20, clamped to 200) |
offset | ?offset=0 | Skip rows (offset mode) |
sort | ?sort=createdAt | Sort column (must be in sortableColumns) |
order | ?order=desc | asc or desc |
q | ?q=hello | Text search on searchableColumns |
count | ?count=true | Include total in response |
cursor | ?cursor=... | Keyset pagination (cannot combine with offset) |
filters | ?filters[replyToId]=5 | Equality filter on a filterableColumns column |
filters | ?filters[id][]=a&filters[id][]=b | IN (...) — pass a list |
filters | ?filters[replyToId]=null | IS NULL |
Anything else is rejected with 400: a bare ?replyToId=5 is not a filter, and an
unknown filter column or a value the column cannot hold fails validation with a
details entry naming the field.
List response:
{
"items": [],
"limit": 20,
"offset": 0,
"hasMore": true,
"total": 42,
"sort": "createdAt",
"order": "desc",
"nextCursor": "..."
}hasMore is always returned. Use count=true when you need an exact total.
Cursor vs offset
- Offset — simple, good for admin UIs and small datasets
- Cursor — stable for feeds; pass
nextCursorfrom the previous response with the samesortandorder
GET /api/posts?limit=20&sort=createdAt&order=desc
GET /api/posts?limit=20&sort=createdAt&order=desc&cursor=<nextCursor>Typed calls use structured inputs rather than URL encoding:
const page = await api.posts.list.call({
filters: { replyToId: null },
sort: 'createdAt',
order: 'desc',
limit: 20,
})
const created = await api.posts.create.call({ title: 'Hello' })Which tables get routes
A table gets CRUD routes when it has a userId column (convention) or when you explicitly configure it in access. Auth tables (user, session, account, verification) are excluded by default.
Access configuration
// access.ts
import { defineAccess } from 'bunderstack/access'
import * as schema from './schema'
export const access = defineAccess(schema, {
posts: {
ownerColumn: 'userId',
list: 'public',
get: 'public',
create: 'authenticated',
update: 'owner',
delete: 'owner',
searchableColumns: ['title', 'body'],
filterableColumns: ['replyToId', 'userId'],
sortableColumns: ['createdAt', 'id'],
defaultSort: { column: 'createdAt', order: 'desc' },
},
comments: {
ownerColumn: 'userId',
list: 'authenticated',
update: (ctx) => ctx.user?.id === ctx.row?.userId,
},
})defineAccess validates all column names against the schema at startup, so typos fail fast.
Rule values
'public'— no session required'authenticated'— session required'owner'— session required; row owner must matchownerColumn'deny'— always forbidden(ctx: AccessContext) => boolean | Promise<boolean>— custom check
Default rules (when ownerColumn is set)
| Operation | Default |
|---|---|
GET list / by id | Public |
POST create | Public — owner column is server-set from session, never trusted from body |
PATCH / DELETE | Owner only |
Column guards
readonlyColumns— stripped from create/update bodies (defaults:id,createdAt,updatedAt,userId)writableColumns— optional allow-list; any field not in this list is ignored on write
Full-text search
Add searchableColumns to enable ?q= on list:
GET /api/posts?q=hello&limit=20&offset=0
GET /api/posts?filters[replyToId]=5&sort=createdAt&order=ascFilters and sorting
Add filterableColumns to allow filtering on a column (?filters[column]=value). Add sortableColumns and optional defaultSort:
posts: {
filterableColumns: ['replyToId', 'userId'],
sortableColumns: ['createdAt', 'id'],
defaultSort: { column: 'createdAt', order: 'desc' },
}Filter values are typed by the column: ?filters[likes]=5 arrives as a number,
?filters[createdAt]=2026-06-01 as a Date, and ?filters[replyToId]=null
matches top-level posts. Typed clients get the same shape with autocomplete:
await api.posts.list.call({ filters: { replyToId: null }, limit: 20 })Error responses
Errors return { error, code?, details? }:
| Code | Status | When |
|---|---|---|
BAD_REQUEST | 400 | Bad query params or JSON body |
INVALID_CURSOR | 400 | Malformed or mismatched cursor |
UNAUTHORIZED | 401 | Authentication required |
FORBIDDEN | 403 | Access denied |
NOT_FOUND | 404 | Missing record |
CONFLICT | 409 | Idempotency key reused with different body |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
Codes are oRPC's own, so the HTTP status always matches the code. Sub-codes that
carry extra meaning (INVALID_CURSOR, IDEMPOTENCY_CONFLICT) arrive in
details.code.
Rate limiting (opt-in)
bunderstack({
schema,
rateLimit: { windowMs: 60_000, max: 100 },
})In-memory per process — use a shared store for multi-instance deployments.
POST idempotency (opt-in)
bunderstack({
schema,
idempotency: true,
})Send Idempotency-Key: <uuid> on POST creates. Replays return the original response with Idempotency-Replayed: true.
Exposing the user table
The user auth table can be opted into CRUD (for public profiles, avatar updates, etc.):
export const access = defineAccess(schema, {
user: {
exposeAuthTable: true,
ownerColumn: 'id',
list: 'public',
get: 'public',
create: 'deny',
update: 'owner',
delete: 'deny',
writableColumns: ['image', 'about'],
searchableColumns: ['name'],
},
})Disabling CRUD for a table
export const access = defineAccess(schema, {
session: { crud: false },
account: { crud: false },
verification: { crud: false },
})Application-specific behavior
Use context.db inside an API procedure when generated
CRUD is not the right domain operation:
api: (o) => ({
archiveOwnPosts: o.protected.handler(({ context }) =>
context.db
.update(schema.posts)
.set({ archived: true })
.where(eq(schema.posts.userId, context.user.id)),
),
})