BETTER-CONVEX

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:

FeatureDescription
Aggregate maintenanceKeep counts and rankings in sync
Cascade updatesUpdate related rows automatically
Activity trackingRecord last activity, touch timestamps
Data synchronizationKeep denormalized fields in sync
Data validationReject 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 as ctx.orm, ctx.scheduler)
  • change: { id, operation, oldDoc, newDoc }

Setup in Schema

Define triggers directly on table definitions, next to indexes and constraints.

convex/functions/schema.ts
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:

convex/lib/orm.ts
const orm = createOrm({ schema: relations });

export function withOrm<Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) {
  return orm.with(ctx);
}
convex/lib/crpc.ts
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.

convex/functions/schema.ts
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

convex/functions/schema.ts
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

convex/functions/schema.ts
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

convex/functions/schema.ts
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

convex/functions/schema.ts
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

convex/functions/schema.ts
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

convex/functions/schema.ts
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

convex/functions/schema.ts
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

  1. Triggers run for ORM-wrapped mutation contexts (withOrm(ctx)).
  2. Errors thrown by triggers roll back the mutation.
  3. Expensive work should be deferred (ctx.scheduler.runAfter) when possible.
  4. 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

  1. Keep triggers idempotent where possible.
  2. Keep trigger logic table-local and deterministic.
  3. Avoid full-table scans inside triggers.
  4. Index every lookup path used in a trigger.
  5. Use onInsert/onUpdate/onDelete when possible, not always onChange.

Next Steps

On this page