BETTER-CONVEX

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:

FeatureDescription
Aggregate maintenanceAuto-update counts when rows change
Cascade updatesUpdate related records automatically
Activity trackingLog changes or update timestamps
Data synchronizationKeep denormalized data in sync
Data validationReject invalid mutations at the database level

Let's set them up.

Installation

First, install the convex-helpers package:

bun add convex-helpers

Setup

Now let's create a triggers registration file. This is where you'll define all your database hooks:

convex/lib/triggers.ts
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:

convex/lib/crpc.ts
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:

convex/lib/triggers.ts
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:

convex/lib/triggers.ts
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:

convex/lib/triggers.ts
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:

convex/lib/triggers.ts
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:

convex/lib/triggers.ts
// 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:

convex/lib/triggers.ts
// 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:

convex/lib/triggers.ts
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:

convex/lib/triggers.ts
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:

convex/lib/triggers.ts
// 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:

convex/lib/triggers.ts
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:

convex/functions/auth.ts
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/server imports)
  • 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 });
  }
});

Next Steps

On this page