brick.ts

Resources and CRUD

Expose database tables via CRUD resources with field-level access, filtering, and security policies.

llms.txt

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:

src/services/pages.ts
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

FieldTypeDescription
idtextRow identifier. PK Default: crypto.randomUUID() (application)
titletextDisplay title.
contenttextPage content. Optional
internal_notestextInternal 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):

ActionRouteInput
page.listPOST /api/pages/page/query{ select?, where?, orderBy?, limit?, offset?, includeTotal? }
page.getGET /api/pages/page/:id{ id, select? }
page.findOnePOST /api/pages/page/find-oneSame as list; returns the first row or null
page.createPOST /api/pages/pageFields with create: true
page.updatePATCH /api/pages/page/:id{ id, data } with fields that have update: true
page.deleteDELETE /api/pages/page/:id{ id }; returns { success: true }
page.countPOST /api/pages/page/count{ where? }; returns { count }
page.existsPOST /api/pages/page/exists{ where? }; returns { exists }

The bulk operations are off until you enable them (see Bulk Operations):

ActionRouteInput
page.bulkCreatePOST /api/pages/page/bulk-create{ items }, each item as for create
page.bulkUpdatePOST /api/pages/page/bulk-update{ items }, each item { id, data }
page.bulkDeletePOST /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:

src/services/users.ts
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

OptionTypeDescription
readbooleanAllows column to be returned in list, findOne, get, create, and update responses, and selected via select.
createbooleanAccepts column in create input payload.
updatebooleanAccepts column in update input payload.
filterFilterOperator[]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.
sortbooleanEnables ascending/descending ordering on this column.
schemaTSchemaCustom TypeBox schema to override automatic Drizzle column schema inference.
scrub({ value, ctx }) => unknownTransforms 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:

src/services/readonly.ts
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:

src/services/contacts.ts
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 create and update field allow-lists apply per item. A validation error names the item in its path, such as /items/2/name.
  • authorize is called once per item with the single-row operation and input (operation: "update", input: { id, data }), so a policy written for update also covers bulkUpdate. scope is applied to every row. A row outside the scope fails as a missing row would, with NOT_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 scope condition, and for any table with a column that uses $defaultFn or $onUpdateFn (such as the $defaultFn UUID in the example above).
  • For resources with no scope function and no such columns, brick() prepares the default list and get statements 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:

src/services/orders.ts
// 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:

src/services/orders.ts
// 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:

  1. Adds id to the input, typed from the resource's id column and read from the path. input must be a t.Object (or omitted).
  2. Runs the action's authorize.
  3. Loads the full table row, hidden columns included, through the action's access.
  4. Answers 404 NOT_FOUND when the row is missing or the caller can't see it.
  5. 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.