Triggers
Schema-level database hooks for insert/update/delete lifecycle.
In this guide, you'll learn how to use triggers — schema-level hooks that run automatically on insert, update, and delete. Triggers let you normalize payloads before writes, fire audit events after writes, update aggregates, and cancel writes based on business rules.
Triggers are declared once in schema.ts and wired automatically when you chain .triggers(...) on the default schema export.
Overview
Use triggers for:
- payload normalization before writes
- audit/event side effects after writes
- aggregate updates via
change - write cancellation based on business rules
Define them on the schema chain with .triggers({ ... }).
API Shape
export default defineSchema({ todos })
.relations(() => ({
todos: {},
}))
.triggers({
todos: {
create: {
before: async (data, ctx) => {
return { data: { ...data, title: data.title.trim() } };
},
after: async (doc, ctx) => {},
},
update: {
before: async (data, ctx) => {},
after: async (doc, ctx) => {},
},
delete: {
before: async (doc, ctx) => {},
after: async (doc, ctx) => {},
},
change: async (change, ctx) => {},
},
});change runs for all operations and receives { id, operation, oldDoc, newDoc }.
For hook signatures and return types, see API Reference below.
Setup In Schema
import {
convexTable,
defineSchema,
text,
} from "kitcn/orm";
import type { MutationCtx } from "./generated/server";
import { createUserCaller } from "./generated/user.runtime";
export const userTable = convexTable("user", {
name: text().notNull(),
email: text().notNull(),
});
export default defineSchema({ user: userTable })
.relations(() => ({
user: {},
}))
.triggers<MutationCtx>({
user: {
create: {
before: async (data) => ({
data: { ...data, email: data.email.toLowerCase() },
}),
after: async (doc, ctx) => {
const caller = createUserCaller(ctx);
await caller.schedule.now.sendWelcomeEmail({
userId: doc.id,
});
},
},
change: async (change, ctx) => {
await ctx.orm.insert(auditLog).values({
table: "user",
operation: change.operation,
documentId: change.id,
});
},
},
});Trigger definitions are schema-level only. convexTable(..., extraConfig) no longer accepts trigger callbacks.
Aggregate Integration
aggregateIndex and rankIndex backfill automatically — no trigger wiring needed for ORM-managed aggregates. Use change triggers for custom side effects that need to react to writes:
Common Patterns
Validation + Cancellation
export default defineSchema({ users })
.relations(() => ({
users: {},
}))
.triggers({
users: {
create: {
before: async (data) => {
if (!data.email.includes("@")) return false;
},
},
update: {
before: async (data) => {
if (data.email && !data.email.includes("@")) return false;
},
},
},
});Payload Mutation
export default defineSchema({ users })
.relations(() => ({
users: {},
}))
.triggers({
users: {
update: {
before: async (data) => ({
data: {
...data,
updatedAt: Date.now(),
},
}),
},
},
});Change Auditing
export default defineSchema({ teams })
.relations(() => ({
teams: {},
}))
.triggers({
teams: {
change: async (change, ctx) => {
await ctx.orm.insert(auditLog).values({
table: "teams",
operation: change.operation,
documentId: change.id,
oldDoc: change.oldDoc,
newDoc: change.newDoc,
});
},
},
});For runtime behavior, see API Reference below.
Best Practices
- Keep hooks deterministic and table-local.
- Keep expensive work in
caller.schedule.after()orcaller.schedule.now. - Index every query path used inside hooks.
- Prefer
create/update/deletefor specific rules; usechangefor cross-operation logic. - If you only need scheduling and not a typed caller,
ctx.scheduleralso works directly.
API Reference
Hook Signatures
create.before(data, ctx)create.after(doc, ctx)update.before(data, ctx)update.after(doc, ctx)delete.before(doc, ctx)delete.after(doc, ctx)change(change, ctx)
Context (ctx) includes:
ctx.db: wrapped writer (triggers enabled)ctx.innerDb: raw writer (bypasses trigger recursion)ctx.orm: typed ORM writer- your custom context fields (
scheduler, request ids, etc.)
Before Return Behavior
before hooks support exactly:
void: continue unchanged{ data }: shallow-merge into the write payloadfalse: cancel write and throwTriggerCancelledError
after hooks always return void.
withoutTriggers
Bypass all trigger hooks for a block of operations. Useful for bulk resets, migrations, and data seeding where triggers would interfere or cause infinite recursion.
await ctx.orm.withoutTriggers(async (orm) => {
// These writes skip all before/after/change hooks
await orm.delete(todosTable).allowFullScan();
await orm.insert(todosTable).values({ title: "Seed", completed: false, userId });
});The callback receives a trigger-free ORM instance scoped to the same transaction. Reads and mutations work identically — only hook dispatch is disabled.
Runtime Guarantees
- Hooks run only for wrapped mutation contexts (
ctx.orm/ generated server context). - Trigger errors fail the mutation.
- Recursive writes are queued deterministically.
ctx.innerDbbypasses recursive dispatch (low-level; preferwithoutTriggersfor most cases).