Triggers
Schema triggers for automatic side effects.
In this guide, you'll set up triggers that run automatically on insert/update/delete operations, wire them through your ORM context, and apply battle-tested patterns like aggregate maintenance, cascade updates, and data synchronization.
Overview
Triggers are declared in convexTable(..., extraConfig) and run when a row changes:
| Feature | Description |
|---|---|
| Aggregate maintenance | Keep counts and rankings in sync |
| Cascade updates | Update related rows automatically |
| Activity tracking | Record last activity, touch timestamps |
| Data synchronization | Keep denormalized fields in sync |
| Data validation | Reject invalid writes before commit |
Triggers are schema-level and execute on:
- insert
- update
- delete
- change (all operations)
Trigger APIs
import { onInsert, onUpdate, onDelete, onChange } from 'better-convex/orm';onInsert(handler)runs for inserts only.onUpdate(handler)runs for updates only.onDelete(handler)runs for deletes only.onChange(handler)runs for all operations.
Each handler receives:
ctx: wrapped mutation context (ctx.db,ctx.innerDb, plus your context fields such asctx.orm,ctx.scheduler)change:{ id, operation, oldDoc, newDoc }
Setup in Schema
Define triggers directly on table definitions, next to indexes and constraints.
import { convexTable, onChange, onDelete, onInsert, onUpdate } from 'better-convex/orm';
export const userTable = convexTable(
'user',
{
name: text().notNull(),
email: text().notNull(),
},
(t) => [
index('email').on(t.email),
onInsert(async (_ctx, change) => {
console.log('user created', change.newDoc._id);
}),
onUpdate(async (_ctx, change) => {
console.log('user updated', change.oldDoc._id, change.newDoc._id);
}),
onDelete(async (_ctx, change) => {
console.log('user deleted', change.oldDoc._id);
}),
onChange(async (_ctx, change) => {
console.log('any user change', change.operation, change.id);
}),
]
);Triggers are schema-level. There is no .create({ triggers }) wiring step.
Wiring with cRPC
Hooks run through wrapped mutation contexts. Keep withOrm(ctx) in mutation context setup:
const orm = createOrm({ schema: relations });
export function withOrm<Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) {
return orm.with(ctx);
}const c = initCRPC
.dataModel<DataModel>()
.context({
query: (ctx) => withOrm(ctx),
mutation: (ctx) => withOrm(ctx),
})
.create();Trigger Types
The change payload is operation-aware:
onChange(async (_ctx, change) => {
switch (change.operation) {
case 'insert':
// newDoc is present, oldDoc is null
break;
case 'update':
// both oldDoc and newDoc are present
break;
case 'delete':
// oldDoc is present, newDoc is null
break;
}
change.id; // always present
});Reference shape:
type OrmLifecycleChange<TDoc> = {
id: unknown;
} & (
| { operation: 'insert'; oldDoc: null; newDoc: TDoc }
| { operation: 'update'; oldDoc: TDoc; newDoc: TDoc }
| { operation: 'delete'; oldDoc: TDoc; newDoc: null }
);Aggregate Integration
Trigger-style callbacks (including TableAggregate.trigger()) are accepted directly in table config.
import { convexTable } from 'better-convex/orm';
import { aggregatePostLikes } from './aggregates';
export const postLikesTable = convexTable(
'postLikes',
{
postId: text().notNull(),
userId: text().notNull(),
},
() => [aggregatePostLikes.trigger()]
);You can register multiple aggregate triggers on the same table:
() => [
aggregateFollowers.trigger(),
aggregateFollowing.trigger(),
]Common Patterns
Audit Logging
import { onChange } from 'better-convex/orm';
export const teamsTable = convexTable('teams', {/* ... */}, () => [
onChange(async (ctx, change) => {
await ctx.orm.insert(auditLog).values({
table: 'teams',
operation: change.operation,
documentId: change.id,
oldDoc: change.oldDoc,
newDoc: change.newDoc,
timestamp: Date.now(),
});
}),
]);Authorization Rules
import { onChange } from 'better-convex/orm';
export const messagesTable = convexTable('messages', {/* ... */}, () => [
onChange(async (ctx, change) => {
const userId = await getAuthUserId(ctx);
const ownerId = change.oldDoc?.userId ?? change.newDoc?.userId;
if (ownerId !== userId) {
throw new Error(`User ${userId} cannot modify message owned by ${ownerId}`);
}
}),
]);Activity Tracking
import { onChange } from 'better-convex/orm';
export const postsTable = convexTable('posts', {/* ... */}, () => [
onChange(async (ctx, change) => {
if (change.operation === 'delete') return;
if (
change.operation === 'update' &&
change.oldDoc.updatedAt === change.newDoc.updatedAt
) {
return;
}
await ctx.orm
.update(userActivity)
.set({ lastActivityAt: change.newDoc.updatedAt })
.where(eq(userActivity.userId, change.newDoc.authorId));
}),
]);Cascade Updates
import { onDelete } from 'better-convex/orm';
export const organizationTable = convexTable('organization', {/* ... */}, () => [
onDelete(async (ctx, change) => {
// Index user.activeOrganizationId.
const usersWithThisOrg = await ctx.orm.query.user.findMany({
where: { activeOrganizationId: change.id },
limit: 1000,
});
for (const userRow of usersWithThisOrg) {
await ctx.orm
.update(user)
.set({ activeOrganizationId: null })
.where(eq(user.id, userRow.id));
}
}),
]);Denormalized Data Sync
import { onChange } from 'better-convex/orm';
export const postTagsTable = convexTable('postTag', {/* ... */}, () => [
onChange(async (ctx, change) => {
const postId =
change.operation === 'delete' ? change.oldDoc.postId : change.newDoc.postId;
if (
change.operation === 'update' &&
change.oldDoc.tagId === change.newDoc.tagId
) {
return;
}
// Index postTag.postId.
const postTags = await ctx.orm.query.postTag.findMany({
where: { postId },
limit: 1000,
});
const tagIds = postTags.map((row) => row.tagId);
const tags = await Promise.all(
tagIds.map((id) => ctx.orm.query.tag.findFirst({ where: { id } }))
);
const tagNames = tags
.filter((tag) => tag !== null)
.map((tag) => tag!.name.toLowerCase())
.sort();
await ctx.orm.update(post).set({ tagNames }).where(eq(post.id, postId));
}),
]);Data Validation
import { onInsert, onUpdate } from 'better-convex/orm';
export const userTable = convexTable('user', {/* ... */}, () => [
onInsert(async (_ctx, change) => {
if (!change.newDoc.email.includes('@')) {
throw new Error(`Invalid email: ${change.newDoc.email}`);
}
}),
onUpdate(async (_ctx, change) => {
if (!change.newDoc.email.includes('@')) {
throw new Error(`Invalid email: ${change.newDoc.email}`);
}
}),
]);Async Processing
import { onInsert } from 'better-convex/orm';
export const userTable = convexTable('user', {/* ... */}, () => [
onInsert(async (ctx, change) => {
await ctx.scheduler.runAfter(0, internal.user.sendWelcomeEmail, {
userId: change.id,
});
}),
]);Runtime Requirements
- Triggers run for ORM-wrapped mutation contexts (
withOrm(ctx)). - Errors thrown by triggers roll back the mutation.
- Expensive work should be deferred (
ctx.scheduler.runAfter) when possible. - Add indexes for trigger query paths.
Better Auth Note
Auth triggers (triggers: { user, session }) are separate from DB triggers.
For DB-level side effects, keep schema triggers and use context: withOrm in auth setup.
Best Practices
- Keep triggers idempotent where possible.
- Keep trigger logic table-local and deterministic.
- Avoid full-table scans inside triggers.
- Index every lookup path used in a trigger.
- Use
onInsert/onUpdate/onDeletewhen possible, not alwaysonChange.