Lifecycle Hooks
Intercept CRUD operations before and after writes to validate, transform, and log data.
Lifecycle hooks allow you to intercept CRUD operations before and after database mutations to normalize payloads, validate business logic, or trigger side effects.
Minimal Example
Configure hooks on a CRUD resource to normalize input before saving and log after saving. slug has no create permission, so clients can't send it; the hook always derives it:
import {
defineDatabase,
defineService,
sqliteTable,
text,
} from "@brickkit/core";
import { crud } from "@brickkit/crud";
export const articles = sqliteTable("articles", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
slug: text("slug").notNull(),
});
const database = defineDatabase({ tables: { articles } });
export const articlesService = defineService("articles", { database });
export const article = articlesService
.resource({ name: "article", table: articles, id: articles.id })
.use(
crud({
access: "public",
fields: {
id: { read: true },
title: { read: true, create: true, update: true },
slug: { read: true },
},
hooks: {
beforeCreate: async ({ data, error }) => {
// Normalize input data in place
const title = String(data.title ?? "").trim();
if (!title) {
return error.BAD_REQUEST("Article title cannot be blank");
}
data.title = title;
// Hooks may set columns clients can't write
data.slug = title.toLowerCase().replace(/\s+/g, "-");
},
afterCreate: async ({ row, ctx }) => {
ctx.logger.info(`Article created: ${row.id} (${row.title})`);
},
},
}),
);Schema
These columns match the Drizzle tables in the example. Defaults marked as application defaults run in Drizzle, rather than in SQL.
Table Name: articles
| Field | Type | Description |
|---|---|---|
id | text | Row identifier. PK Default: crypto.randomUUID() (application) |
title | text | Display title. |
slug | text | URL slug. |
Available CRUD Hooks
All hooks are declared inside the hooks configuration of crud():
Hooks run after input validation, authorize, and scope.
| Hook | Arguments | Timing | Common Use Cases |
|---|---|---|---|
beforeCreate | { data, ctx, error } | Before INSERT query | Data trimming, hashing, slug generation, permission validation |
afterCreate | { row, ctx, error } | After INSERT completes | Audit logging, notification dispatch, webhooks |
beforeUpdate | { id, data, ctx, error } | Before UPDATE query | Field mutation validation, updated timestamp stamping |
afterUpdate | { id, row, ctx, error } | After UPDATE completes | Cache invalidation, audit logging |
beforeDelete | { id, ctx, error } | Before DELETE query | Cascade checks, preventing deletion of active resources |
afterDelete | { id, ctx, error } | After DELETE completes | Cleanup associated files/assets, search index purging |
row in afterCreate and afterUpdate is the full database row, including columns without read permission. The response is still limited to readable fields.
Mutating Input in before* Hooks
Before hooks receive data, a copy of the validated write payload (the create input, or update's data object). You can mutate fields or add values for columns clients can't write. Every key must be a column of the table, or the call fails with a 500. An update whose data is empty after beforeUpdate is rejected with BAD_REQUEST.
hooks: {
beforeCreate: async ({ data, ctx }) => {
// Inject tenant and author from authenticated context
data.tenantId = ctx.tenantId;
data.createdBy = ctx.user?.id;
},
beforeUpdate: async ({ data }) => {
if (typeof data.displayName === "string") {
data.displayName = data.displayName.trim();
}
},
}Aborting Writes with Error Helpers
Hooks receive the standard error builder (BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, INTERNAL_SERVER_ERROR). Calling a helper throws an ActionExecutionError with that code and status.
CRUD doesn't wrap hooks in a transaction. Throwing in a before* hook stops the write before it runs. Throwing in an after* hook returns an error to the client, but the row has already been written and isn't rolled back.
hooks: {
beforeCreate: async ({ data, ctx, error }) => {
if (!ctx.user) {
return error.UNAUTHORIZED("Sign in to comment");
}
const trimmed = String(data.body ?? "").trim();
if (trimmed.length < 3) {
error.BAD_REQUEST("Comment must be at least 3 characters long");
}
},
}