bunderstackbunderstack/ docs

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 procedureHTTPDescription
api.<table>.listGET /api/:tableList and paginate
api.<table>.getGET /api/:table/:idGet by id
api.<table>.createPOST /api/:tableCreate
api.<table>.updatePATCH /api/:table/:idUpdate
api.<table>.deleteDELETE /api/:table/:idDelete

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.

ParamExampleDescription
limit?limit=20Page size (default 20, clamped to 200)
offset?offset=0Skip rows (offset mode)
sort?sort=createdAtSort column (must be in sortableColumns)
order?order=descasc or desc
q?q=helloText search on searchableColumns
count?count=trueInclude total in response
cursor?cursor=...Keyset pagination (cannot combine with offset)
filters?filters[replyToId]=5Equality filter on a filterableColumns column
filters?filters[id][]=a&filters[id][]=bIN (...) — pass a list
filters?filters[replyToId]=nullIS 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 nextCursor from the previous response with the same sort and order
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 match ownerColumn
  • 'deny' — always forbidden
  • (ctx: AccessContext) => boolean | Promise<boolean> — custom check

Default rules (when ownerColumn is set)

OperationDefault
GET list / by idPublic
POST createPublic — owner column is server-set from session, never trusted from body
PATCH / DELETEOwner 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

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=asc

Filters 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? }:

CodeStatusWhen
BAD_REQUEST400Bad query params or JSON body
INVALID_CURSOR400Malformed or mismatched cursor
UNAUTHORIZED401Authentication required
FORBIDDEN403Access denied
NOT_FOUND404Missing record
CONFLICT409Idempotency key reused with different body
TOO_MANY_REQUESTS429Rate 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)),
  ),
})

On this page