kitcn

Triggers

Schema-level database hooks for insert/update/delete lifecycle.

In this guide, you'll learn how to use triggers — schema-level hooks that run automatically on insert, update, and delete. Triggers let you normalize payloads before writes, fire audit events after writes, update aggregates, and cancel writes based on business rules.

Triggers are declared once in schema.ts and wired automatically when you chain .triggers(...) on the default schema export.

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 on the schema chain with .triggers({ ... }).

API Shape

convex/functions/schema.ts
export default defineSchema({ todos })
  .relations(() => ({
    todos: {},
  }))
  .triggers({
  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

convex/functions/schema.ts
import {
  convexTable,
  defineSchema,
  text,
} from "kitcn/orm";
import type { MutationCtx } from "./generated/server";
import { createUserCaller } from "./generated/user.runtime";

export const userTable = convexTable("user", {
  name: text().notNull(),
  email: text().notNull(),
});

export default defineSchema({ user: userTable })
  .relations(() => ({
    user: {},
  }))
  .triggers<MutationCtx>({
  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

convex/functions/schema.ts
export default defineSchema({ users })
  .relations(() => ({
    users: {},
  }))
  .triggers({
  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

convex/functions/schema.ts
export default defineSchema({ users })
  .relations(() => ({
    users: {},
  }))
  .triggers({
  users: {
    update: {
      before: async (data) => ({
        data: {
          ...data,
          updatedAt: Date.now(),
        },
      }),
    },
  },
});

Change Auditing

convex/functions/schema.ts
export default defineSchema({ teams })
  .relations(() => ({
    teams: {},
  }))
  .triggers({
  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

  1. Keep hooks deterministic and table-local.
  2. Keep expensive work in caller.schedule.after() or caller.schedule.now.
  3. Index every query path used inside hooks.
  4. Prefer create/update/delete for specific rules; use change for cross-operation logic.
  5. If you only need scheduling and not a typed caller, ctx.scheduler also works directly.

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 payload
  • false: cancel write and throw TriggerCancelledError

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

  1. Hooks run only for wrapped mutation contexts (ctx.orm / generated server context).
  2. Trigger errors fail the mutation.
  3. Recursive writes are queued deterministically.
  4. ctx.innerDb bypasses recursive dispatch (low-level; prefer withoutTriggers for most cases).

Next Steps

On this page