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}/${service}/${resource}, in kebab-case (see URL Scheme):
| Action | Route | Input |
|---|---|---|
page.list | POST /api/pages/page/query | { select?, where?, orderBy?, limit?, offset?, includeTotal? } |
page.get | GET /api/pages/page/:id | { id, select? } |
page.findOne | POST /api/pages/page/find-one | Same as list; returns the first row or null |
page.create | POST /api/pages/page | Fields with create: true |
page.update | PATCH /api/pages/page/:id | { id, data } with fields that have update: true |
page.delete | DELETE /api/pages/page/:id | { id }; returns { success: true } |
page.count | POST /api/pages/page/count | { where? }; returns { count } |
page.exists | POST /api/pages/page/exists | { where? }; returns { exists } |
The bulk operations are off until you enable them (see Bulk Operations):
| Action | Route | Input |
|---|---|---|
page.bulkCreate | POST /api/pages/page/bulk-create | { items }, each item as for create |
page.bulkUpdate | POST /api/pages/page/bulk-update | { items }, each item { id, data } |
page.bulkDelete | POST /api/pages/page/bulk-delete | { ids }; returns { count } |
The action names appear in architecture introspection and client contracts; OpenAPI summaries use service.action (for example, pages.page.list), and every route is tagged pages · page. The typed client nests them under the service: api.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 (relative to /api/pages/page) 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 },
},
});Bulk Operations
bulkCreate, bulkUpdate and bulkDelete write many rows in one call. They are off by default; turn each on with true:
crud({
access: { authorize, scope },
operations: { bulkCreate: true, bulkUpdate: true, bulkDelete: true },
maxBatchSize: 200, // Default 100
fields: {
id: { read: true },
name: { read: true, create: true, update: true },
},
});
await contact.bulkCreate.run({ items: [{ name: "Ada" }, { name: "Lin" }] });
// { items: [{ id: 1, name: "Ada" }, { id: 2, name: "Lin" }] }
await contact.bulkUpdate.run({ items: [{ id: 1, data: { name: "Ada L." } }] });
await contact.bulkDelete.run({ ids: [1, 2] }); // { count: 2 }Each item goes through the same path as its single-row operation:
- It is validated with the same schema, so the
createandupdatefield allow-lists apply per item. A validation error names the item in its path, such as/items/2/name. authorizeis called once per item with the single-row operation and input (operation: "update",input: { id, data }), so a policy written forupdatealso coversbulkUpdate.scopeis applied to every row. A row outside the scope fails as a missing row would, withNOT_FOUND, and does not reveal that it exists.- Lifecycle hooks and events fire once per row, as for single writes.
The whole batch runs in one transaction. If any item fails, nothing is kept and the error names the failing item: its code and status are the item's, its message starts with Item <index>:, and details is { index, code, message }. The result lists the written rows in input order, limited to readable fields as for create and update. A batch must hold between 1 and maxBatchSize items, and bulkDelete rejects repeated IDs.
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.
Resource Actions
CRUD covers reads and writes of whole rows. For anything else on a resource, such as an import or a state change, declare a resource action with resource.action(). It takes everything a service action takes (name, description, input, output, errors, authorize, method, path, tags, emits) plus record and access.
A collection-level action works on the resource as a whole:
// POST /api/orders/order/import-csv
order.action({
name: "importCsv",
input: t.Object({ csv: t.String() }),
output: t.Object({ imported: t.Integer() }),
execute: async ({ input, ctx }) => {
// …
},
});A record-level action works on one row. Set record: true:
// POST /api/orders/order/:id/archive
order.action({
name: "archive",
record: true, // adds :id and loads the row
access: { scope: ({ ctx }) => eq(orders.owner, ctx.user.id) },
input: t.Object({ reason: t.String() }),
output: OrderRow,
execute: async ({ row, input, ctx }) => {
// row is already loaded
},
});The action is registered as <resource>.<name> (here order.importCsv and order.archive), like CRUD's order.list. The typed client, OpenAPI and MCP show it under its resource: api.orders.order.archive({ id, reason }). The method defaults to POST; method: "GET" is allowed. An explicit path is relative to /api/<service>/<resource>, and a record action's path must contain /:id.
With record: true, Brick:
- Adds
idto the input, typed from the resource's id column and read from the path.inputmust be at.Object(or omitted). - Runs the action's
authorize. - Loads the full table row, hidden columns included, through the action's
access. - Answers
404 NOT_FOUNDwhen the row is missing or the caller can't see it. - Calls
execute({ input, row, ctx, error }).
A record action must declare access, or startup fails: { scope } returns a condition that limits which rows it can load, and "public" loads any row by id. access is only for record actions.
A resource action can't be named after a CRUD route segment (query, count, exists, find-one, bulk-create, bulk-update, bulk-delete, compared in kebab-case, so findOne is refused too) or after a CRUD action on that resource, such as list. Startup fails with Brick: duplicate name "<name>" in service "<service>".
For a state change with rules and events, such as shipping an order, declare a command instead: a record action whose body is a pure decide function, run in one transaction with the events it emits.
Plugins that need the same row loading can call loadRecord(resource, { id, ctx, db?, access }) from @brickkit/core.