TinyKit Pro Docs

Database Triggers

TinyKit Pro uses convex-helpers/server/triggers to automatically maintain data integrity and consistency across database operations. The trigger system inter...

TinyKit Pro uses convex-helpers/server/triggers to automatically maintain data integrity and consistency across database operations. The trigger system intercepts database writes and executes registered handlers to update related data, timestamps, and aggregates.

🎯 Purpose

Database triggers provide:

  • Automatic Timestamp Tracking: updatedAt/updatedTime fields updated on every modification
  • Data Consistency: Ensures related data stays synchronized
  • Aggregate Maintenance: Automatic updates to counts, rankings, and statistics
  • Reduced Boilerplate: Eliminates manual timestamp and relationship management
  • Guaranteed Execution: Triggers fire on every database write operation

🏗️ Architecture

How Triggers Work

  1. Registration: Triggers are registered for specific tables during system initialization
  2. Interception: When a mutation modifies a document, the trigger system intercepts it
  3. Execution: All registered triggers for that table execute with the change context
  4. Propagation: Changes from triggers are applied to the database

Trigger Lifecycle

// 1. Mutation starts
await ctx.db.patch(userId, { name: "New Name" });

// 2. Trigger intercepts the operation
// change.oldDoc: { _id, name: "Old Name", updatedTime: 1704000000 }
// change.newDoc: { _id, name: "New Name", updatedTime: 1704000000 }

// 3. Trigger handler modifies newDoc
change.newDoc.updatedTime = Date.now(); // 1704067200000

// 4. Final write includes trigger modifications
// Final doc: { _id, name: "New Name", updatedTime: 1704067200000 }

🔄 Automatic Timestamp Updates

TinyKit Pro automatically maintains updatedAt and updatedTime fields for all configured tables.

Configured Tables

The following tables have automatic timestamp updates:

Users:

  • users

Billing:

  • subscriptions
  • products
  • prices

Organizations:

  • orgs
  • orgMembers
  • orgInvitations

Site:

  • siteBanners
  • siteSettings

How It Works

// In convex/lib/triggers/triggers.ts
const TABLES_WITH_UPDATED_TIME = [
  "users",
  "subscriptions",
  // ... other tables
] as const;

// Automatic registration for all configured tables
TABLES_WITH_UPDATED_TIME.forEach((table) => {
  triggers.register(table, async (_ctx, change) => {
    // Only on UPDATE (not INSERT)
    if (change.newDoc && change.oldDoc) {
      Object.assign(change.newDoc, updateTimestamp(change.newDoc));
    }
  });
});

Usage Example

// No manual timestamp management needed!
export const updateUser = mutation({
  args: { userId: v.string(), name: v.string() },
  handler: async (ctx, args) => {
    // updatedTime automatically set to Date.now()
    await ctx.db.patch(args.userId, { name: args.name });

    // No need for:
    // await ctx.db.patch(args.userId, {
    //   name: args.name,
    //   updatedTime: Date.now() // ❌ Redundant
    // });
  },
});

📊 Trigger Change Object

Every trigger receives a change object with information about the operation:

interface TriggerChange<TableName extends string> {
  // The new document after modification (null on DELETE)
  newDoc: Doc<TableName> | null;

  // The old document before modification (null on INSERT)
  oldDoc: Doc<TableName> | null;

  // Operation type: "INSERT", "UPDATE", or "DELETE"
  operation: "INSERT" | "UPDATE" | "DELETE";
}

Operation Detection

triggers.register("users", async (ctx, change) => {
  // INSERT: oldDoc is null, newDoc exists
  if (!change.oldDoc && change.newDoc) {
    console.log("New user created", change.newDoc._id);
  }

  // UPDATE: both oldDoc and newDoc exist
  if (change.oldDoc && change.newDoc) {
    console.log("User updated", {
      before: change.oldDoc.name,
      after: change.newDoc.name,
    });
  }

  // DELETE: oldDoc exists, newDoc is null
  if (change.oldDoc && !change.newDoc) {
    console.log("User deleted", change.oldDoc._id);
  }
});

🔨 Custom Trigger Registration

Basic Custom Trigger

// In a dedicated triggers file (e.g., convex/customTriggers.ts)
import { triggers } from "./lib/triggers/triggers";

// Register a custom trigger
triggers.register("posts", async (ctx, change) => {
  // Only run on new posts
  if (!change.oldDoc && change.newDoc) {
    const post = change.newDoc;

    // Update user's post count
    const user = await ctx.db.get(post.userId);
    if (user) {
      await ctx.db.patch(post.userId, {
        postCount: (user.postCount ?? 0) + 1,
      });
    }
  }
});

Validation Trigger

triggers.register("users", async (ctx, change) => {
  if (change.newDoc && !change.newDoc.email.includes("@")) {
    throw new Error("Invalid email format");
  }
});

⚠️ Note: Validation triggers should be used sparingly. Prefer validation in mutation arguments using Convex validators.

Cascade Delete Trigger

triggers.register("users", async (ctx, change) => {
  // On user deletion, delete related data
  if (change.oldDoc && !change.newDoc) {
    const userId = change.oldDoc._id;

    // Delete user's posts
    const posts = await ctx.db
      .query("posts")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .collect();

    for (const post of posts) {
      await ctx.db.delete(post._id);
    }

    // Delete user's comments
    const comments = await ctx.db
      .query("comments")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .collect();

    for (const comment of comments) {
      await ctx.db.delete(comment._id);
    }
  }
});

⚠️ Cascade Considerations:

  • Triggers fire for cascaded deletes too (watch for infinite loops)
  • Consider using scheduled functions for large cascades
  • Document cascade behavior for maintainability

📈 Aggregate Triggers

🚨 CRITICAL: ALWAYS use triggers for aggregate maintenance. Never update aggregates manually in mutations.

Aggregate Trigger Pattern

// 1. Define the aggregate (e.g., convex/aggregates.ts)
import { TableAggregate } from "convex-helpers/server/aggregates";
import { components } from "./_generated/api";

export const scoresAggregate = new TableAggregate(components.leaderboard, {
  namespace: (doc) => doc.gameId,
  sortKey: (doc) => doc.score,
});

// 2. Register the aggregate trigger (e.g., convex/customTriggers.ts)
import { triggers } from "./lib/triggers/triggers";

triggers.register("scores", scoresAggregate.trigger());

// 3. Use in mutations without manual updates
export const addScore = mutation({
  args: { gameId: v.string(), userId: v.string(), score: v.number() },
  handler: async (ctx, args) => {
    // Just insert - trigger handles aggregate automatically!
    await ctx.db.insert("scores", {
      gameId: args.gameId,
      userId: args.userId,
      score: args.score,
    });

    // ❌ NEVER manually update aggregates:
    // await scoresAggregate.insert(ctx, { ... }); // WRONG!
  },
});

Why use triggers for aggregates?

  • Guarantees consistency (no missed updates)
  • Simplifies mutation code (no aggregate management)
  • Atomic operations (aggregate updates part of transaction)
  • Automatic on INSERT, UPDATE, DELETE

For more details, see the Convex Best Practices guide.

⚙️ Using Triggers in Mutations

Required Import Pattern

🚨 CRITICAL: Always import mutation and internalMutation from lib/triggers/triggers, NOT from _generated/server.

// ✅ CORRECT: Import from triggers module
import { mutation, internalMutation } from "../../lib/triggers/triggers";

// ❌ WRONG: Importing from generated server bypasses triggers
import { mutation } from "../../_generated/server";

Why? The triggers module wraps standard mutations to enable trigger interception. Using raw mutations bypasses the trigger system entirely.

ESLint Enforcement

TinyKit Pro includes an ESLint rule to enforce correct imports:

// .eslintrc.js
{
  "no-restricted-imports": [
    "error",
    {
      "paths": [{
        "name": "../../_generated/server",
        "importNames": ["mutation", "internalMutation"],
        "message": "Import mutation and internalMutation from '../../lib/triggers/triggers' to enable automatic triggers"
      }]
    }
  ]
}

This prevents accidental bypass of the trigger system.

🎨 Advanced Patterns

Conditional Trigger Execution

triggers.register("subscriptions", async (ctx, change) => {
  // Only run when status changes from inactive to active
  if (
    change.oldDoc &&
    change.newDoc &&
    change.oldDoc.status !== "active" &&
    change.newDoc.status === "active"
  ) {
    // Send activation notification
    await sendNotification(ctx, {
      userId: change.newDoc.userId,
      type: "subscription_activated",
    });
  }
});

Multi-Table Trigger Coordination

// Track total organization member count
triggers.register("orgMembers", async (ctx, change) => {
  let orgId: Id<"orgs">;
  let countDelta = 0;

  // Member added
  if (!change.oldDoc && change.newDoc) {
    orgId = change.newDoc.orgId;
    countDelta = 1;
  }
  // Member removed
  else if (change.oldDoc && !change.newDoc) {
    orgId = change.oldDoc.orgId;
    countDelta = -1;
  }
  // Member updated (no count change)
  else {
    return;
  }

  // Update organization member count
  const org = await ctx.db.get(orgId);
  if (org) {
    await ctx.db.patch(orgId, {
      memberCount: (org.memberCount ?? 0) + countDelta,
    });
  }
});

Audit Trail Trigger

triggers.register("users", async (ctx, change) => {
  // Log all user changes
  if (change.oldDoc && change.newDoc) {
    await ctx.db.insert("auditLog", {
      tableName: "users",
      documentId: change.newDoc._id,
      operation: "UPDATE",
      oldValues: change.oldDoc,
      newValues: change.newDoc,
      timestamp: Date.now(),
    });
  }
});

Derived Field Updates

triggers.register("posts", async (ctx, change) => {
  // Auto-generate slug from title
  if (
    change.newDoc &&
    (!change.oldDoc || change.oldDoc.title !== change.newDoc.title)
  ) {
    const slug = change.newDoc.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "");

    change.newDoc.slug = slug;
  }
});

🚨 Common Mistakes

// ❌ DON'T: Import mutations from _generated/server
import { mutation } from "../../_generated/server";

// ✅ DO: Import from triggers module
import { mutation } from "../../lib/triggers/triggers";

// ❌ DON'T: Manually set updatedTime for trigger-managed tables
await ctx.db.patch(userId, {
  name: args.name,
  updatedTime: Date.now(), // Redundant!
});

// ✅ DO: Let triggers handle it automatically
await ctx.db.patch(userId, {
  name: args.name,
});

// ❌ DON'T: Manually update aggregates
await scoresAggregate.insert(ctx, { ... });
await ctx.db.insert("scores", { ... });

// ✅ DO: Use triggers for aggregate maintenance
triggers.register("scores", scoresAggregate.trigger());
// Then just do normal inserts
await ctx.db.insert("scores", { ... });

// ❌ DON'T: Create infinite trigger loops
triggers.register("users", async (ctx, change) => {
  if (change.newDoc) {
    // This creates an infinite loop!
    await ctx.db.patch(change.newDoc._id, { loopField: true });
  }
});

// ✅ DO: Modify change.newDoc directly or check for changes
triggers.register("users", async (ctx, change) => {
  if (change.newDoc && !change.newDoc.slug) {
    // Modify newDoc directly (no recursion)
    change.newDoc.slug = generateSlug(change.newDoc.name);
  }
});

🧪 Testing Triggers

Verify Timestamp Updates

export const testTimestampTrigger = mutation({
  handler: async (ctx) => {
    // Create user
    const userId = await ctx.db.insert("users", {
      name: "Test User",
      email: "test@example.com",
    });

    // Wait briefly
    await new Promise((resolve) => setTimeout(resolve, 10));

    // Update user
    await ctx.db.patch(userId, { name: "Updated Name" });

    // Verify updatedTime was set
    const user = await ctx.db.get(userId);
    console.assert(
      user?.updatedTime !== undefined,
      "updatedTime should be set",
    );
    console.assert(
      user.updatedTime > Date.now() - 1000,
      "updatedTime should be recent",
    );

    // Cleanup
    await ctx.db.delete(userId);
  },
});

Test Custom Triggers

export const testCustomTrigger = mutation({
  handler: async (ctx) => {
    // Assuming a trigger updates post count on user
    const userId = await ctx.db.insert("users", {
      name: "Test User",
      email: "test@example.com",
      postCount: 0,
    });

    // Create post (should trigger user.postCount increment)
    await ctx.db.insert("posts", {
      userId,
      title: "Test Post",
      content: "Content",
    });

    // Verify trigger fired
    const user = await ctx.db.get(userId);
    console.assert(user?.postCount === 1, "Post count should be 1");

    // Cleanup
    await ctx.db.delete(userId);
  },
});

📚 Configuration

Adding Tables to Automatic Timestamps

Edit convex/lib/triggers/triggers.ts:

const TABLES_WITH_UPDATED_TIME = [
  // ... existing tables
  "myNewTable", // Add your new table here
] as const;

Requirements:

  1. Table must have updatedAt: v.number() OR updatedTime: v.number() in schema
  2. Add table name to TABLES_WITH_UPDATED_TIME array
  3. Redeploy Convex functions

Organizing Custom Triggers

Recommended structure:

convex/
├── lib/
│   └── triggers/
│       ├── triggers.ts         # Core trigger setup
│       └── helpers.ts          # Trigger utilities
├── customTriggers.ts           # Custom business logic triggers
├── aggregateTriggers.ts        # Aggregate trigger registrations
└── schema.ts

Import in convex.config.ts or main entry point:

// Ensure triggers are registered on startup
import "./customTriggers";
import "./aggregateTriggers";

🎯 Best Practices

  1. Use triggers for: Timestamps, aggregates, cascade operations, audit trails
  2. Avoid triggers for: Complex business logic better suited for mutations
  3. Always import correctly: Use lib/triggers/triggers not _generated/server
  4. Document cascades: Clearly document trigger behavior for maintenance
  5. Test thoroughly: Verify triggers fire correctly in all scenarios
  6. Avoid infinite loops: Don't trigger writes that recursively call the same trigger
  7. Keep triggers fast: Heavy operations should use scheduled functions instead
  8. Use for consistency: Triggers guarantee data integrity across operations

📊 API Reference

triggers.register()

triggers.register<TableName>(
  tableName: TableName,
  handler: (ctx: MutationCtx, change: TriggerChange<TableName>) => Promise<void>
): void

Parameters:

  • tableName: Name of the table to register trigger for
  • handler: Async function that executes on document changes

Example:

triggers.register("users", async (ctx, change) => {
  if (change.newDoc && change.oldDoc) {
    console.log("User updated", change.newDoc._id);
  }
});

TriggerChange Object

interface TriggerChange<TableName> {
  newDoc: Doc<TableName> | null; // After modification
  oldDoc: Doc<TableName> | null; // Before modification
  operation: "INSERT" | "UPDATE" | "DELETE";
}

Detection patterns:

  • INSERT: !change.oldDoc && change.newDoc
  • UPDATE: change.oldDoc && change.newDoc
  • DELETE: change.oldDoc && !change.newDoc

← Previous: Logging | Next: Development Patterns →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro