convex-cascading-delete-skill
Convex Cascading Delete
Automatic cascading deletion of related documents across Convex tables using the
@00akshatsinha00/convex-cascading-deletecomponent.
The component uses your existing Convex indexes to define relationships and provides both atomic and batched deletion modes. When you delete a parent document, all configured child documents are automatically removed, preventing orphaned records.
Installation
# 1. Install the package
npm install @00akshatsinha00/convex-cascading-delete
# 2. Install the Convex component
npx convex component install @00akshatsinha00/convex-cascading-delete
# 3. Verify — run dev and check for errors
npx convex dev
Setup — Three Steps
Step 1: Schema with indexes on foreign keys
Every foreign key field that participates in a cascade needs an index. Without the index, the component cannot efficiently look up child documents.
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
}),
posts: defineTable({
title: v.string(),
content: v.string(),
authorId: v.id("users"),
}).index("by_author", ["authorId"]),
comments: defineTable({
text: v.string(),
postId: v.id("posts"),
authorId: v.id("users"),
})
.index("by_post", ["postId"])
.index("by_author", ["authorId"]),
});
Step 2: Define cascade rules
Create a dedicated file for your rules. Each rule says: "when a document in this table is deleted,
also delete documents in the to table by querying the via index on the field that references
the parent."
// convex/cascadeRules.ts
import { defineCascadeRules } from "@00akshatsinha00/convex-cascading-delete";
export const cascadeRules = defineCascadeRules({
// Deleting a user cascades to their posts and comments
users: [
{ to: "posts", via: "by_author", field: "authorId" },
{ to: "comments", via: "by_author", field: "authorId" },
],
// Deleting a post cascades to its comments
posts: [
{ to: "comments", via: "by_post", field: "postId" },
],
});
Rule structure:
{ to: "childTable", via: "indexName", field: "foreignKeyField" }
to— the child table containing documents to deletevia— the index name on the child table (must exist in schema)field— the field in the child table that references the parent's_id
Step 3: Use CascadingDelete in mutations
// convex/mutations.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { CascadingDelete } from "@00akshatsinha00/convex-cascading-delete";
import { cascadeRules } from "./cascadeRules";
import { components } from "./_generated/api";
const cd = new CascadingDelete(components.convexCascadingDelete, {
rules: cascadeRules,
});
export const deleteUser = mutation({
args: { userId: v.id("users") },
returns: v.object({
users: v.number(),
posts: v.number(),
comments: v.number(),
}),
handler: async (ctx, { userId }) => {
const summary = await cd.deleteWithCascade(ctx, "users", userId);
// summary = { users: 1, posts: 5, comments: 23 }
return summary;
},
});
Deletion Modes
| Mode | Default | Best for |
|---|---|---|
atomic |
Yes | Small-to-medium graphs (< 100 docs), strict consistency |
batched |
No | Large graphs (> 1000 docs), background cleanup |
// Atomic (default) — all-or-nothing in a single transaction
const cd = new CascadingDelete(components.convexCascadingDelete, {
rules: cascadeRules,
});
// Batched — processes deletions in chunks to avoid transaction limits
const cd = new CascadingDelete(components.convexCascadingDelete, {
rules: cascadeRules,
mode: "batched",
batchSize: 500, // default is 100
});
Use batched mode when you hit "Transaction too large" errors — this means the relationship graph has too many documents for a single atomic transaction.
API Reference
defineCascadeRules(rules)
Defines the cascade configuration. Each key is a parent table name, each value is an array of
{ to, via, field } rules.
new CascadingDelete(component, options)
| Option | Type | Default | Description |
|---|---|---|---|
rules |
CascadeRules |
required | Output of defineCascadeRules() |
mode |
"atomic" | "batched" |
"atomic" |
Deletion strategy |
batchSize |
number |
100 |
Docs per batch (batched mode only) |
debug |
boolean |
false |
Enable verbose logging |
cd.deleteWithCascade(ctx, tableName, documentId)
Deletes the document and all related documents per cascade rules. Returns a DeleteSummary
(Record<string, number>) with counts of deleted documents per table.
Real-World Examples
Organization hierarchy
// Schema
organizations: defineTable({ name: v.string(), slug: v.string() }),
teams: defineTable({ name: v.string(), orgId: v.id("organizations") })
.index("by_org", ["orgId"]),
members: defineTable({ userId: v.string(), teamId: v.id("teams"), role: v.string() })
.index("by_team", ["teamId"]),
projects: defineTable({ name: v.string(), teamId: v.id("teams") })
.index("by_team", ["teamId"]),
// Rules — deleting an org removes its teams, which removes members and projects
const cascadeRules = defineCascadeRules({
organizations: [
{ to: "teams", via: "by_org", field: "orgId" },
],
teams: [
{ to: "members", via: "by_team", field: "teamId" },
{ to: "projects", via: "by_team", field: "teamId" },
],
});
Multiple cascade paths from one table
const cascadeRules = defineCascadeRules({
users: [
{ to: "posts", via: "by_author", field: "authorId" },
{ to: "comments", via: "by_author", field: "authorId" },
{ to: "teamMembers", via: "by_user", field: "userId" },
{ to: "likes", via: "by_user", field: "userId" },
{ to: "bookmarks", via: "by_user", field: "userId" },
],
});
Best Practices
-
Always index foreign key fields — the component queries these indexes to find children. Without indexes, cascading deletes won't work efficiently.
-
Keep cascade hierarchies clear and acyclic. The component handles circular references safely, but circular rules are confusing and usually indicate a design issue. Prefer a strict parent-child hierarchy:
org -> teams -> members. -
Choose atomic mode by default. Only switch to batched when you actually hit transaction size limits. Atomic gives you all-or-nothing consistency.
-
Log the deletion summary for auditing. The summary tells you exactly what was removed:
const summary = await cd.deleteWithCascade(ctx, "users", userId); console.log("Cascade delete result:", summary); -
Consider soft deletes for audit trails. Add a
deletedAtfield and a separatedeletionLogstable if you need to track what was deleted and by whom. -
Test cascade behavior. Use
convex-testto verify the full chain works:import { convexTest } from "convex-test"; const t = convexTest(schema); test("deleting user cascades to posts and comments", async () => { const userId = await t.run(async (ctx) => ctx.db.insert("users", { name: "Test", email: "t@t.com" }) ); const postId = await t.run(async (ctx) => ctx.db.insert("posts", { title: "P", content: "C", authorId: userId }) ); await t.run(async (ctx) => ctx.db.insert("comments", { text: "Hi", postId, authorId: userId }) ); const summary = await t.mutation(api.mutations.deleteUser, { userId }); expect(summary).toEqual({ users: 1, posts: 1, comments: 1 }); }); -
Monitor large cascades. If a single delete takes over 5 seconds, consider batched mode or restructuring your data model.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| "Index not found" | The via index doesn't exist in schema |
Add .index("indexName", ["field"]) to the child table |
| "Transaction too large" | Too many docs in one atomic delete | Switch to mode: "batched" |
| Documents not deleted | Wrong field name in rule |
Ensure field matches the actual field name in the child table schema |
| TypeScript errors | Stale generated types | Run npx convex dev or npx convex codegen |
Enable debug logging for detailed cascade operation traces:
const cd = new CascadingDelete(components.convexCascadingDelete, {
rules: cascadeRules,
debug: true,
});
Client Usage
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function DeleteUserButton({ userId }: { userId: Id<"users"> }) {
const deleteUser = useMutation(api.mutations.deleteUser);
return (
<button onClick={async () => {
const result = await deleteUser({ userId });
console.log(`Deleted ${result.posts} posts, ${result.comments} comments`);
}}>
Delete User
</button>
);
}