Triggers
Database triggers for automatic side effects.
In this guide, we'll explore database triggers in Convex. You'll learn to set up triggers that run automatically on insert/update/delete operations, wire them with cRPC, and implement common patterns like audit logging, cascade updates, and data synchronization.
Overview
Convex Helpers Triggers provide database-level hooks that run automatically when data changes:
| Feature | Description |
|---|---|
| Aggregate maintenance | Auto-update counts when rows change |
| Cascade updates | Update related records automatically |
| Activity tracking | Log changes or update timestamps |
| Data synchronization | Keep denormalized data in sync |
| Data validation | Reject invalid mutations at the database level |
Let's set them up.
Installation
First, install the convex-helpers package:
bun add convex-helpersSetup
Now let's create a triggers registration file. This is where you'll define all your database hooks:
import { Triggers } from 'convex-helpers/server/triggers';
import type { DataModel } from '../_generated/dataModel';
export const registerTriggers = () => {
const triggers = new Triggers<DataModel>();
// Register triggers here
triggers.register('user', async (ctx, change) => {
if (change.operation === 'insert') {
console.log('User created:', change.newDoc.name);
}
});
return triggers;
};Wiring with cRPC
Here's where triggers shine. Wrap mutations to apply triggers automatically:
import { registerTriggers } from './triggers';
const triggers = registerTriggers();
const c = initCRPC
.dataModel<DataModel>()
.create({
query,
internalQuery,
// Wrap mutations with triggers
mutation: (handler: any) =>
mutation({
...handler,
handler: async (ctx: any, args: any) => {
const wrappedCtx = triggers.wrapDB(ctx);
return handler.handler(wrappedCtx, args);
},
}),
internalMutation: (handler: any) =>
internalMutation({
...handler,
handler: async (ctx: any, args: any) => {
const wrappedCtx = triggers.wrapDB(ctx);
return handler.handler(wrappedCtx, args);
},
}),
action,
internalAction,
});Note: The any types are required because the handler wrapper intercepts the internal cRPC types. This is safe as the types flow through correctly at runtime.
Trigger Types
The change object contains operation info. Let's explore what's available:
triggers.register('user', async (ctx, change) => {
switch (change.operation) {
case 'insert':
// change.newDoc is available
// change.oldDoc is undefined
break;
case 'update':
// Both change.oldDoc and change.newDoc available
break;
case 'delete':
// change.oldDoc is available
// change.newDoc is undefined
break;
}
// Always available
change.id; // Document ID
change.operation; // 'insert' | 'update' | 'delete'
});Change Object Type
Here's the full type definition for reference:
interface Change<TableName> {
operation: 'insert' | 'update' | 'delete';
id: Id<TableName>;
newDoc: Doc<TableName> | undefined; // Present for insert/update
oldDoc: Doc<TableName> | undefined; // Present for update/delete
}Common Patterns
Now let's look at battle-tested trigger patterns you can copy into your project.
Audit Logging
Log all changes with user context:
triggers.register('teams', async (ctx, change) => {
const user = await getAuthUserId(ctx);
await ctx.db.insert('auditLog', {
table: 'teams',
operation: change.operation,
documentId: change.id,
userId: user,
timestamp: Date.now(),
oldDoc: change.oldDoc,
newDoc: change.newDoc,
});
});Authorization Rules
Enforce ownership at the database level:
triggers.register('messages', async (ctx, change) => {
const userId = await getAuthUserId(ctx);
const owner = change.oldDoc?.userId ?? change.newDoc?.userId;
if (userId !== owner) {
throw new Error(`User ${userId} cannot modify message owned by ${owner}`);
}
});Activity Tracking
Track user activity when posts are created or updated:
triggers.register('post', async (ctx, change) => {
if (
change.operation === 'insert' ||
(change.operation === 'update' &&
change.oldDoc?.updatedAt !== change.newDoc?.updatedAt)
) {
const post = change.newDoc!;
if (!post.authorId) return;
// Upsert activity record
const table = entsTableFactory(ctx, entDefinitions);
const existing = await table('userActivity').get(
'userId_postId',
post.authorId,
post._id
);
if (existing) {
await existing.patch({ lastActivityAt: post.updatedAt });
} else {
await table('userActivity').insert({
postId: post._id,
lastActivityAt: post.updatedAt,
userId: post.authorId,
});
}
}
});Cascade Updates
Clear references when related documents are deleted:
// Clear reference when related document is deleted
triggers.register('organization', async (ctx, change) => {
if (change.operation === 'delete') {
const organizationId = change.id;
const table = entsTableFactory(ctx, entDefinitions);
// Clear activeOrganizationId from user records
const usersWithThisOrg = await table('user', 'activeOrganizationId', (q) =>
q.eq('activeOrganizationId', organizationId)
);
for (const user of usersWithThisOrg) {
await user.patch({ activeOrganizationId: undefined });
}
}
});Denormalized Data Sync
Keep denormalized data in sync with source of truth:
// Keep tags array in sync with postTags junction table
triggers.register('postTag', async (ctx, change) => {
if (
change.operation === 'insert' ||
change.operation === 'update' ||
change.operation === 'delete'
) {
const postId =
change.operation === 'delete'
? change.oldDoc!.postId
: change.newDoc.postId;
// Skip if nothing relevant changed
if (change.operation === 'update') {
if (change.oldDoc?.tagId === change.newDoc.tagId) {
return;
}
}
const table = entsTableFactory(ctx, entDefinitions);
// Recalculate tags from all postTags
const postTags = await table('postTag', 'postId', (q) =>
q.eq('postId', postId)
);
const tagIds = postTags.map((pt) => pt.tagId);
const tags = await Promise.all(tagIds.map((id) => table('tag').get(id)));
const tagNames = tags
.filter((t) => t !== null)
.map((t) => t.name.toLowerCase())
.sort();
await table('post').getX(postId).patch({ tagNames });
}
});Data Validation
Throw errors to reject invalid mutations:
triggers.register('user', async (ctx, change) => {
if (change.newDoc) {
const email = change.newDoc.email;
if (!email.includes('@')) {
throw new Error(`Invalid email: ${email}`);
}
}
});Async Processing
Schedule heavy work to run after the mutation:
triggers.register('user', async (ctx, change) => {
if (change.operation === 'insert') {
// Schedule async processing
await ctx.scheduler.runAfter(0, internal.user.sendWelcomeEmail, {
userId: change.id,
});
}
});Async Debounced Processing
When a document changes multiple times in one mutation, only process the final state:
// Track scheduled function within mutation scope
let scheduledSync: Id<'_scheduled_functions'> | null = null;
triggers.register('user', async (ctx, change) => {
if (change.operation === 'delete') return;
// Cancel previously scheduled sync from this mutation
if (scheduledSync) {
await ctx.scheduler.cancel(scheduledSync);
}
// Schedule new sync - only final state will be sent
scheduledSync = await ctx.scheduler.runAfter(0, internal.sync.syncUser, {
userId: change.id,
});
});Use this pattern when:
- Processing has side effects (external API calls)
- Document changes multiple times in one mutation
- You only care about final state
Component Integration
Many Convex components provide trigger helpers for automatic maintenance. Let's see how to use them.
Aggregates
Use with Aggregates for efficient counts:
import { aggregatePostLikes, aggregateTotalUsers } from '../aggregates';
export const registerTriggers = () => {
const triggers = new Triggers<DataModel>();
// Auto-maintain like counts
triggers.register('postLikes', aggregatePostLikes.trigger());
// Auto-maintain user counts
triggers.register('user', aggregateTotalUsers.trigger());
// Multiple triggers on same table
triggers.register('follows', aggregateFollowerCount.trigger());
triggers.register('follows', aggregateFollowingCount.trigger());
return triggers;
};Sharded Counter
Use with @convex-dev/sharded-counter for distributed counters:
import { ShardedCounter } from '@convex-dev/sharded-counter';
const counter = new ShardedCounter(components.shardedCounter);
// Auto-increment on insert, decrement on delete
triggers.register('votes', counter.trigger('voteCount'));Better Auth Integration
For auth operations to trigger side effects, pass the wrapped mutation:
import { customCtx, customMutation } from 'convex-helpers/server/customFunctions';
import { internalMutation } from './_generated/server';
import { registerTriggers } from './lib/triggers';
const triggers = registerTriggers();
// Wrap internalMutation with triggers for Better Auth
export const internalMutationWithTriggers = customMutation(
internalMutation,
customCtx(async (ctx) => ({
db: triggers.wrapDB(ctx).db,
}))
);
// Use in createClient and createApi
export const authClient = createClient({
// ...
internalMutation: internalMutationWithTriggers,
});
export const { create, findOne, ... } = createApi(schema, createAuth, {
internalMutation: internalMutationWithTriggers,
});Best Practices
Here are key practices to follow when using triggers.
Always Use Wrapped Mutations
Triggers only run when using wrapped mutations:
// ✅ CORRECT: Import from your functions file
import { mutation } from './lib/crpc';
// ❌ WRONG: Direct import bypasses triggers
import { mutation } from './_generated/server';When Triggers DON'T Run
Warning: Triggers will NOT execute when:
- Using unwrapped mutations (direct
_generated/serverimports) - Modifying data in the Convex dashboard
- Using
npx convex import - Using streaming import
Avoid Infinite Recursion
Always check if an update is needed before patching:
triggers.register('session', async (ctx, change) => {
if (change.newDoc) {
const isExpired = change.newDoc.expiresAt < Date.now();
// Only patch if different - prevents infinite loop
if (change.newDoc.isExpired !== isExpired) {
await ctx.db.patch(change.id, { isExpired });
}
}
});triggers.register('session', async (ctx, change) => {
if (change.newDoc) {
const isExpired = change.newDoc.expiresAt < Date.now();
// Only patch if different - prevents infinite loop
if (change.newDoc.isExpired !== isExpired) {
const table = entsTableFactory(ctx, entDefinitions);
await table('session').getX(change.id).patch({ isExpired });
}
}
});Error Handling Warning
Important: Caught errors don't roll back the mutation. Let trigger errors bubble up.
// ❌ BAD: Mutation succeeds even if trigger fails
export const updateSession = authMutation
.input(z.object({ id: zid('session'), token: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
await ctx.db.patch(input.id, { token: input.token }); // Succeeds
} catch (e) {
console.error('failed'); // Trigger error caught
}
// Session is still updated!
});
// ✅ GOOD: Let trigger errors bubble up
export const updateSession = authMutation
.input(z.object({ id: zid('session'), token: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.patch(input.id, { token: input.token }); // Trigger errors will fail mutation
});// ❌ BAD: Mutation succeeds even if trigger fails
export const updateSession = authMutation
.input(z.object({ id: zid('session'), token: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
await ctx.table('session').getX(input.id).patch({ token: input.token }); // Succeeds
} catch (e) {
console.error('failed'); // Trigger error caught
}
// Session is still updated!
});
// ✅ GOOD: Let trigger errors bubble up
export const updateSession = authMutation
.input(z.object({ id: zid('session'), token: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.table('session').getX(input.id).patch({ token: input.token }); // Trigger errors will fail mutation
});Atomic Execution
Triggers run within the same transaction as the mutation. Queries running in parallel will never see a state where the data changed but the trigger didn't run.
Prefer Explicit Functions
Consider explicit wrapper functions over implicit triggers to avoid "spooky action at a distance":
// ✅ Explicit and discoverable
async function createUser(ctx: MutationCtx, data: UserData) {
const userId = await ctx.db.insert('user', data);
await ctx.db.insert('profile', { userId });
return userId;
}
// ❌ Hidden side effects - harder to trace
triggers.register('user', async (ctx, change) => {
if (change.operation === 'insert') {
await ctx.db.insert('profile', { userId: change.id });
}
});// ✅ Explicit and discoverable
async function createUser(ctx: MutationCtx, data: UserData) {
const user = await ctx.table('user').insert(data).get();
await ctx.table('profile').insert({ userId: user._id });
return user._id;
}
// ❌ Hidden side effects - harder to trace
triggers.register('user', async (ctx, change) => {
if (change.operation === 'insert') {
await ctx.table('profile').insert({ userId: change.id });
}
});