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/updatedTimefields 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
- Registration: Triggers are registered for specific tables during system initialization
- Interception: When a mutation modifies a document, the trigger system intercepts it
- Execution: All registered triggers for that table execute with the change context
- 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:
subscriptionsproductsprices
Organizations:
orgsorgMembersorgInvitations
Site:
siteBannerssiteSettings
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:
- Table must have
updatedAt: v.number()ORupdatedTime: v.number()in schema - Add table name to
TABLES_WITH_UPDATED_TIMEarray - 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.tsImport in convex.config.ts or main entry point:
// Ensure triggers are registered on startup
import "./customTriggers";
import "./aggregateTriggers";🎯 Best Practices
- Use triggers for: Timestamps, aggregates, cascade operations, audit trails
- Avoid triggers for: Complex business logic better suited for mutations
- Always import correctly: Use
lib/triggers/triggersnot_generated/server - Document cascades: Clearly document trigger behavior for maintenance
- Test thoroughly: Verify triggers fire correctly in all scenarios
- Avoid infinite loops: Don't trigger writes that recursively call the same trigger
- Keep triggers fast: Heavy operations should use scheduled functions instead
- 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>
): voidParameters:
tableName: Name of the table to register trigger forhandler: 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
📚 Related Documentation
- Architecture - Database schema design and indexes
- Convex Best Practices - Optimizing database operations
- convex-helpers Documentation - Trigger system details
Rate Limiting
TinyKit Pro includes a comprehensive rate limiting system using @convex-dev/rate-limiter to protect against abuse, prevent excessive resource usage, and ensu...
Testing Strategy
TinyKit Pro includes a comprehensive testing infrastructure using Vitest for unit and integration tests, and Playwright for end-to-end tests.