Stack logo
Sync up on the latest from Convex.
Lee Danilek's avatar
Lee Danilek
5 days ago

Database Triggers

Lightning bolt to represent trigger and alert on computer

Triggers automatically run code whenever data in a table changes. A library in the convex-helpers npm package allows you to attach trigger functions to your Convex database.

Triggers run within the same mutation that changes the data, so they run atomically with the data changing. Queries running in parallel will never see a state where the data has changed but the trigger didn’t run.

Check out the docs for details on how they work and be sure to check out the best practices below. In this article we’ll explore use-cases.

Triggers are wonderful thingsTriggers are wonderful things

TL;DR Show me the code

npm i convex-helpers@latest

Define a function that runs automatically when the “users” table changes

In convex/functions.ts:

/* eslint-disable no-restricted-imports */
import { mutation as rawMutation, internalMutation as rawInternalMutation } from "./_generated/server";
/* eslint-enable no-restricted-imports */
import { DataModel } from "./_generated/dataModel";
import { Triggers } from "convex-helpers/server/triggers";
import { customCtx, customMutation } from "convex-helpers/server/customFunctions";

// start using Triggers, with table types from schema.ts
const triggers = new Triggers<DataModel>();

// register a function to run when a `ctx.db.insert`, `ctx.db.patch`, `ctx.db.replace`, or `ctx.db.delete` changes the "users" table
triggers.register("users", async (ctx, change) => {
  console.log("user changed", change);
});

// create wrappers that replace the built-in `mutation` and `internalMutation`
// the wrappers override `ctx` so that `ctx.db.insert`, `ctx.db.patch`, etc. run registered trigger functions
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
export const internalMutation = customMutation(rawInternalMutation, customCtx(triggers.wrapDB));

Elsewhere:

import { mutation } from "./functions";

export const myMutation = mutation({
  handler: async (ctx, args) => {
	  // This will cause the user triggers to run automatically.
	  await ctx.db.insert("users", { ... });
	},
});

Again, check out the docs for details on how they work and be sure to check out the best practices below.

Use-Cases of Triggers

Logging

Suppose users are getting into a bad state. You want to add logging to debug when it’s happening. With a trigger you can log whenever a user changes, which will give you a timeline and tell which mutation is changing it.

triggers.register("users", async (ctx, change) => {
  console.log("user changed", change);
});

Or suppose you need to keep an audit log of what happens to a team, so admins can look at the history to see who did what when. You can store the logs in a separate table.

triggers.register("teams", async (ctx, change) => {
  const tokenIdentifier = (await ctx.auth.getUserIdentity())?.tokenIdentifier;
  await ctx.db.insert("teamAuditLog", { teamId: change.id, change, tokenIdentifier });
});

Denormalizing a field

Indexes are great for organizing data, but sometimes you want to organize based on a derived field. You can use a trigger to calculate that field.

Suppose you’re generating a list of airplane trips, where each trip can have layovers in various cities. When a user scrolls through their options, they want to see the trips with fewest layovers first. So the schema is like this:

trips: defineTable({
  flights: v.array(v.object({
    sourceAirport: v.string(),
    destAirport: v.string(),
    startTime: v.number(),
    ...
  }),
  layovers: v.number(),
  price: v.number(),
}).index("fewestStops", ["layovers", "price"])

The layovers field is necessary to define the index, but it’s derived from the flights field and you don’t want it to be incorrect. So you can keep it updated with a trigger:

triggers.register("trips", async (ctx, change) => {
  if (change.newDoc) {
    const layovers = change.newDoc.flights.length;
    if (change.newDoc.layovers !== layovers) {
      await ctx.db.patch(change.id, { layovers });
    }
  }
});

Note we have to check that the denormalized field isn’t already correct, because otherwise the ctx.db.patch will trigger an infinite recursion of triggers.

As another example, you may recall one restriction of text search indexes is they can only search on one field. But we don’t have to let that stop us.

books: defineTable({
  title: v.string(),
  author: v.string(),
  summary: v.string(),
  allFields: v.string(),
}).searchIndex("allFields", { searchField: "allFields" })

If we want a universal search bar, to search the book’s title, author, and summary all at once, you can denormalize all of those into a separate field, updated by a trigger.

triggers.register("books", async (ctx, change) => {
  if (change.newDoc) {
    const allFields = change.newDoc.title + " " + change.newDoc.author + " " + change.newDoc.summary;
    if (change.newDoc.allFields !== allFields) {
      await ctx.db.patch(change.id, { allFields });
    }
  }
});

Validating data

Not all data is good data. Convex performs schema validation to do basic typechecks, but sometimes there are constraints that can’t be represented with types.

For example, an email address is always a string, but there are more constraints on what makes a valid email address. Triggers can throw errors to abort mutations that try to write invalid data.

triggers.register("users", async (ctx, change) => {
  if (change.newDoc) {
    // logic can be arbitrarily complex, including importing from npm libraries
    const emailRegex = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i;
    if (!emailRegex.test(change.newDoc.email)) {
      throw new Error(`invalid email ${change.newDoc.email}`);
    }
  }
});

When using this pattern, make sure you don't catch errors.

Authorizing writes

You can implement the write side of row-level security with triggers. For example, here’s a rule that a message may only be modified by the user who created it.

triggers.register("messages", async (ctx, change) => {
  const user = await getAuthedUser(ctx);
  const owner = change.oldDoc?.owner ?? change.newDoc?.owner;
  if (user !== owner) {
    throw new Error(`user ${user} is not allowed to modify message owned by ${owner}`); 
  }
});

When using this pattern, make sure you don't catch errors.

Warning: beware error catching

Triggers are called after the data has been modified. If the trigger throws an error, it can cause the whole mutation to be rolled back. But if the mutation catches the error, the data modification will still be committed.

export const tryToUpdateMessage = mutation({
  handler: async (ctx, { id, body }) => {
    try {
      await ctx.db.patch(id, { body });
    } catch (e) {
      console.error("failed to update message");
    }
  },
});

If tryToUpdateMessage does a write that conflicts with an authorization or validation trigger and the trigger throws an error, the mutation will print "failed to update message". However, the message will still be updated. The trigger runs after the document is patched, so if the mutation returns without throwing any error, the patch will commit.

Cascade deletes

Sometimes when there are foreign references between documents, a delete should cascade across the link.

For example, when a user gets deleted, you can delete all of the messages they own.

triggers.register("users", async (ctx, change) => {
  if (change.operation === "delete") {
    for await (const message of ctx.db.query("messages")
        .withIndex("owner", q=>q.eq("owner", change.id))) {
      await ctx.db.delete(message._id);
    }
  }
});

Note that like all mutations, triggers are bounded by size limits, so cascading deletes will fail if there are too many links. In this case you’ll probably want to schedule the deletes to run async.

Because triggers can trigger other triggers recursively, you can have a graph of foreign references and deletes can cascade through the graph. e.g. deleting a team can delete all of its users, which will then delete all of their messages.

Asynchronous debounced processing

Running code transactionally when it changes is great, but sometimes you want to process the change asynchronously. This could be because the processing is a Convex action (i.e. it has side effects). Or maybe documents change often within a mutation and you want to only process the final change.

You can schedule the processing with ctx.scheduler, and use a global variable to cancel functions that were previously scheduled from the same mutation. In this example, we want to send the final user document to Clerk after it has been committed to Convex.

const scheduled: Record<Id<"users">, Id<"_scheduled_functions">> = {};
triggers.register("users", async (ctx, change) => {
  if (scheduled[change.id]) {
    await ctx.scheduler.cancel(scheduled[change.id]);
  }
  scheduled[change.id] = await ctx.scheduler.runAfter(
    0,
    internal.users.updateClerkUser,
    { id: change.id, user: change.newDoc },
  );
});

Denormalizing a count

You may want to keep track of a denormalized value that accumulates all documents.

For example, here’s how you would keep track of the number of users in a single document, for fast querying.

triggers.register("users", async (ctx, change) => {
  // Note writing the count to a single document increases write contention.
  // There are more scalable methods if you need high write throughput.
  const countDoc = (await ctx.db.query("userCount").unique())!;
  if (change.operation === "insert") {
    await ctx.db.patch(countDoc._id, { count: countDoc.count + 1 });
  } else if (change.operation === "delete") {
    await ctx.db.patch(countDoc._id, { count: countDoc.count - 1 });
  }
});

Note that storing the count in a single document means if users are modified frequently, the mutations will slow down due to OCC conflicts.

Triggers are isolated

Denormalizing a count demonstrates how triggers have an unexpected beneficial property: triggers are serializable.

Contrast with an explicit wrapper, where you are likely to write code like this:

async function insertUser(ctx: MutationCtx, name: string) {
  await ctx.db.insert("users", { name });
  const countDoc = (await ctx.query("userCount").unique())!;
  await ctx.db.patch(countDoc._id, { value: countDoc.value + 1 });
}
export const addTwo = mutation({
  handler: async (ctx) => {
    await Promise.all([
      insertUser(ctx, "foo"),
      insertUser(ctx, "bar"),
    ]);
  },
});

If you run this code, you'll discover that TypeScript can run async code however it wants. In this case, the userCount ends up as 1 even though there are two users. See if you can figure out why. The triggers library protects against race conditions so you can register the trigger above and see that userCount ends up with the correct value of 2.

export const addTwo = mutation({
  handler: async (ctx) => {
    await Promise.all([
      ctx.db.insert("users", { name: "foo" }),
      ctx.db.insert("users", { name: "bar" }),
    ]);
  },
});

The triggers library isn't magical; you can add similar locking semantics to your own insertUser wrapper and it will work just as well.

Syncing a table into a component

Convex components are still in beta, but you can start using them to super-charge your Convex deployment. Some components latch onto your tables, adding extra structure for efficient querying of counts, sums, or even geospatial data.

Sign up for access to the initial components.

Best practices

Consider explicit function calls instead

Attaching triggers to your data can seem magical. Calling ctx.db.insert in one file can call a trigger registered in a different file. You may call this "spooky action at a distance," because your code has side effects that aren't obvious. Using triggers too much can result in surprising effects when your code runs. Think of it similar to language patterns like modifying Array.prototype.map in JavaScript, or overriding an operator in C++. You are effectively overriding the built-in Convex functions ctx.db.insert, ctx.db.patch, ctx.db.replace, and ctx.db.delete, so proceed with caution.

A more idiomatic way of running code when data changes, so as not to violate the principle of least astonishment, is to make the wrapper explicit. Instead of doing this:

triggers.register("users", async (ctx, change) => {
  console.log("user changed", change);
});

export const createMultipleUsers = mutation({
  handler: async (ctx) => {
    // this implicitly calls all triggers registered on the "users" table.
    await ctx.db.insert("users", { name: "foo" });
    await ctx.db.insert("users", { name: "bar" });
  }
}

Do this:

async function createUser(ctx, name) {
  await ctx.db.insert("users", { name });
  console.log("user created", name);
}

export const createMultipleUsers = mutation({
  handler: async (ctx) => {
    await createUser(ctx, "foo");
    await createUser(ctx, "bar");
  }
}

By encapsulating all writes to a table in distinct functions like createUser, you get the same benefit of running custom code whenever the table changes. But now you can command-click to follow createUser to its definition and see what code is getting called.

The advantage of triggers is they allow you to attach custom code without refactoring all usages of ctx.db.insert. Use them with caution.

Always use the wrapper

Triggers are attached to mutations with custom functions. This works by replacing ctx.db in the mutation with a wrapped version that has the same interface but also calls the trigger functions. Therefore, trigger functions will only run if the mutation is wrapped in the custom function.

That means triggers do not run in these cases:

  • If you forget the wrapper and declare a plain mutation.
  • When data is changed directly in the Convex dashboard.
  • When data is uploaded through npx convex import.
  • When data is uploaded through streaming import.

Here are tips for ensuring your mutations always run trigger functions:

  1. Define triggers, call triggers.register, and call customMutation in a file convex/functions.ts
  • You may register triggers across multiple files, but when you call customMutation all triggers should be registered, so it's easiest to do in one file.
  1. By declaring our wrapped customMutations to have names mutation and internalMutation, they become drop-in replacements for the built-ins of the same name. Just import from './functions' instead of './_generated/server'.
  2. To make sure you always remember the correct imports, use an eslint rule.
"no-restricted-imports": [
  "error",
  {
    patterns: [
      {
        group: ["*/_generated/server"],
        importNames: ["mutation", "internalMutation"],
        message: "Use functions.ts for mutation",
      },
    ],
  },
],

Recap

A simple db.insert or db.delete can cause many changes, to the same document with field denormalization, to other documents with cascading deletes and count denormalization, or to a separate Convex component. The same db.insert or db.delete can kick off an async function with side effects like sending the data to a third party service. Or it can abort the mutation entirely, to block unauthorized access or make sure invalid data never reaches the database.

Triggers allow you to implement Dataflow algorithms with Convex: whenever data changes, some auxiliary code runs to handle it, and the change propagates through the system.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started