Triggers
Schema-level database hooks for insert/update/delete lifecycle.
Triggers are declared once in schema.ts and wired automatically when you export triggers.
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 with:
import { defineTriggers } from "better-convex/orm";API Shape
export const triggers = defineTriggers(relations, {
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,
defineRelations,
defineTriggers,
text,
} from "better-convex/orm";
export const userTable = convexTable("user", {
name: text().notNull(),
email: text().notNull(),
});
export const relations = defineRelations({ user: userTable }, () => ({
user: {},
}));
export const triggers = defineTriggers(relations, {
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 const triggers = defineTriggers(relations, {
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 const triggers = defineTriggers(relations, {
users: {
update: {
before: async (data) => ({
data: {
...data,
updatedAt: Date.now(),
},
}),
},
},
});Change Auditing
export const triggers = defineTriggers(relations, {
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.
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).