Resources and CRUD
Expose database tables via CRUD resources with field-level access, filtering, and security policies.
Resources link Drizzle database tables to services, while the @brickkit/crud plugin provides type-safe, field-level data operations.
Minimal Example
Attach a declarative CRUD resource to a table with field policies and public access:
import {
defineDatabase,
defineService,
sqliteTable,
text,
} from "@brickkit/core";
import { crud } from "@brickkit/crud";
export const pages = sqliteTable("pages", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
content: text("content"),
internalNotes: text("internal_notes"),
});
const database = defineDatabase({ tables: { pages } });
export const pagesService = defineService("pages", { database });
// Register resource and attach the crud() plugin
export const page = pagesService
.resource({ name: "page", table: pages, id: pages.id })
.use(
crud({
access: "public",
fields: {
id: { read: true, filter: ["eq", "in"] },
title: {
read: true,
create: true,
update: true,
filter: ["eq", "contains"],
sort: true,
},
content: { read: true, create: true, update: true },
// internalNotes is omitted: hidden from reads, writes, and filters
},
}),
);Schema
Brick does not generate owner or timestamp columns. Declare them in your table and populate them with Drizzle defaults or CRUD hooks when needed.
These columns match the Drizzle tables in the example. Defaults marked as application defaults run in Drizzle, rather than in SQL.
Table Name: pages
| Field | Type | Description |
|---|---|---|
id | text | Row identifier. PK Default: crypto.randomUUID() (application) |
title | text | Display title. |
content | text | Page content. Optional |
internal_notes | text | Internal notes, hidden by the CRUD field policy. Optional |
CRUD is Deny-by-Default: access is a required option. access: "public" allows every call. access: { authorize, scope } calls your authorize on every operation and adds your scope condition to queries. An access object without authorize rejects every call with FORBIDDEN (HTTP 403).
Public Action Names
When crud() installs onto pagesService.resource({ name: "page", ... }), actions are registered on the service under dot-separated names prefixed by the resource name. Routes are mounted under ${prefix}/${resource}, not under the service name:
| Action | Route | Input |
|---|---|---|
page.list | POST /api/page/query | { select?, where?, orderBy?, limit?, offset?, includeTotal? } |
page.get | GET /api/page/:id | { id, select? } |
page.findOne | POST /api/page/find-one | Same as list; returns the first row or null |
page.create | POST /api/page | Fields with create: true |
page.update | PATCH /api/page/:id | { id, data } with fields that have update: true |
page.delete | DELETE /api/page/:id | { id }; returns { success: true } |
page.count | POST /api/page/count | { where? }; returns { count } |
page.exists | POST /api/page/exists | { where? }; returns { exists } |
The action names appear in architecture introspection and client contracts; OpenAPI summaries use service.action (for example, pages.page.list). .use(crud(...)) also returns the resource with these actions attached, so you can call page.list({ input: {} }) in-process. Use the routes option to change a route's path or scope, or set a route to false to keep the action but not mount it over HTTP.
Field Policies
Each column requires explicit permission toggles in the fields object:
crud({
access: "public",
fields: {
id: { read: true, filter: ["eq", "in"] },
email: {
read: true,
create: true,
filter: ["eq"],
scrub: ({ value, ctx }) => {
// Redact email for unauthorized callers
return ctx.user?.role === "admin" ? value : "[REDACTED]";
},
},
passwordHash: {
create: true, // Write-only column
read: false,
},
},
});Field Policy Configuration Table
| Option | Type | Description |
|---|---|---|
read | boolean | Allows column to be returned in list, findOne, get, create, and update responses, and selected via select. |
create | boolean | Accepts column in create input payload. |
update | boolean | Accepts column in update input payload. |
filter | FilterOperator[] | Enables filtering on this column. Allowed operators: eq, ne, in, notIn, gt, gte, lt, lte, between, contains, startsWith, endsWith, isNull, isNotNull. contains/startsWith/endsWith require a string column. |
sort | boolean | Enables ascending/descending ordering on this column. |
schema | TSchema | Custom TypeBox schema to override automatic Drizzle column schema inference. |
scrub | ({ value, ctx }) => unknown | Transforms a readable field's value before it's returned. Scrubbed fields are typed unknown in the output schema. |
Every key in fields must be a column of the table, or crud() throws at setup. The primary key can't have update: true.
Operations Selection and Limits
You can turn off individual operations (or all of them with operations: false) and set pagination limits. defaultLimit defaults to 20 and maxLimit to 100; a limit above maxLimit fails validation:
crud({
access: "public",
operations: {
delete: false, // Disables page.delete endpoint entirely
update: false, // Disables page.update endpoint entirely
},
defaultLimit: 25, // Default page size for list queries
maxLimit: 100, // Maximum allowed limit
fields: {
id: { read: true },
title: { read: true },
},
});Performance & Prepared Statement Caching
When the database client supports prepare() (Bun SQLite), CRUD reuses prepared statements:
- Statements are cached per query shape (operation, filter structure, selected fields, ordering), up to 128 per database, with filter values bound as placeholders.
- Caching is skipped when a call has a non-empty
scopecondition, and for any table with a column that uses$defaultFnor$onUpdateFn(such as the$defaultFnUUID in the example above). - For resources with no
scopefunction and no such columns,brick()prepares the defaultlistandgetstatements when it builds the app.