Cascading Delete
A Bijection component for managing cascading deletes across related documents with atomic and batched deletion modes.
git clone https://github.com/bijectionhq/cascading-delete.git
cd cascading-delete
git checkout 68b7f60fd6bc1b333d1a71f4093e2153e40992eeBijection repository access is required. Request access.
Description
A Bijection component for managing cascading deletes across related documents with atomic and batched deletion modes.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/cascading-delete. Based on akshatsinha0/convex-cascading-delete. License: Apache-2.0. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
A Convex component for managing cascading deletes across related documents with atomic and batched deletion modes.
Explore the demo ↗Upstream overview
From the upstream package documentation.
Convex Cascading Delete lets you define declarative relationship rules between tables and then delete a root document with all its dependents automatically. It supports two deletion modes: inline (single atomic transaction) for small trees and batched (scheduled across multiple transactions) for large trees. The component works with existing schemas and indexes without requiring any schema migrations, and returns a per-table deletion summary for observability.
Use cases
From the upstream package documentation.
- Deleting a user account and automatically removing all their posts, comments, and other associated records without writing manual cleanup logic
- Removing an organization record and all nested teams, members, projects, and files where the total document count could exceed Convex transaction limits
- Enforcing referential integrity in a Convex app that lacks foreign key constraints at the database level
- Tracking progress of a large multi-batch deletion job in a React UI using the returned job ID and the useDeletionJobStatus hook
- Guarding critical mutations against accidental direct db.delete calls by wrapping the database context with the patchDb safety proxy
How it works
From the upstream package documentation.
You define cascade rules using defineCascadeRules, mapping source tables to target tables via existing index names and field names. These rules are passed to a CascadingDelete instance constructed with the component reference from components.convexCascadingDelete. The component validates that all referenced indexes exist at startup via validateRules, catching configuration errors before they surface at delete time.
For small to medium deletion trees, deleteWithCascade performs a depth-first post-order traversal, deleting children before parents, within a single Convex mutation transaction. A visited set prevents infinite loops in circular or diamond dependency graphs. For larger trees, deleteWithCascadeBatched first collects all deletion targets via a read-only traversal, deletes the first batch inline, then schedules remaining batches through the component's internal job system using a function reference you export via makeBatchDeleteHandler.
The batched mode requires you to export a _cascadeBatchHandler internal mutation created by makeBatchDeleteHandler, which the component's scheduler invokes with each batch of { table, id } targets. This handler calls ctx.db.delete for each target and reports completion back to the component. Each batch runs as its own Convex transaction, so consistency is atomic per batch but eventual across batches.
A React hook, useDeletionJobStatus, accepts the jobId returned by deleteWithCascadeBatched and reactively subscribes to job state, exposing fields like status, completedCount, totalTargetCount, and a serialized completedSummary for rendering progress UI.
Upstream package FAQ
Does convex-cascading-delete work with existing Convex indexes?
Yes, convex-cascading-delete leverages your existing Convex indexes to define document relationships. You don't need to create new indexes or modify your schema - the component uses the indexes you've already defined to understand how documents are related and cascade deletions accordingly.
What's the difference between atomic and batched deletion modes?
Atomic mode ensures all related documents are deleted in a single transaction, providing strict consistency but with potential performance limitations for large relationship graphs. Batched mode processes deletions in chunks, offering better performance for large datasets while maintaining eventual consistency.
Can I configure which relationships should cascade delete?
Yes, convex-cascading-delete allows you to configure specific relationships for cascading deletion rather than automatically deleting all related records. You define which index-based relationships should trigger cascades, giving you fine-grained control over the deletion behavior.
Does this component handle circular references between documents?
The convex-cascading-delete component includes safeguards to detect and handle circular references in your document relationships. It prevents infinite deletion loops while ensuring all appropriate related documents are still cleaned up properly.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Cascading Delete with Bijection.
Read: https://bijection.com/components/cascading-delete/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from akshatsinha0/convex-cascading-delete, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
Convex Cascading Delete
A Convex component for managing cascading deletes across related documents. Configure relationships via existing indexes, then delete documents safely knowing all related records will be cleaned up automatically with clear consistency guarantees.
Why Use This Component?
- Works with existing schemas - No migration to special schema definitions; uses your existing
defineTableand indexes - Explicit configuration - Clear, declarative rules for cascade relationships defined in one place
- Two deletion modes - Inline (atomic, single transaction) for small deletes, batched (scheduled) for large trees
- Progress tracking - React hook for real-time batch deletion progress with reactive updates
- Safety guards - Optional
patchDbhelper prevents accidental directdb.deletecalls - Index validation - Catch configuration errors at startup, not at delete time
- Circular handling - Automatically handles circular and diamond dependencies via visited set
- Full observability - Returns deletion summary with per-table document counts
- Non-invasive - Drop-in component that doesn't replace your schema builder or require code changes beyond deletion calls
Pre-requisite: Convex
You'll need an existing Convex project to use this component. Convex is a hosted backend platform, including a database, serverless functions, and a bunch more you can learn about here.
Run npm create convex or follow any of the Convex quickstarts to set one up.
Installation
Step 1: Install the package
npm install @00akshatsinha00/convex-cascading-delete
Step 2: Add the component to your Convex app
// convex/convex.config.ts
import { defineApp } from "convex/server";
import convexCascadingDelete from "@00akshatsinha00/convex-cascading-delete/convex.config";
const app = defineApp();
app.use(convexCascadingDelete);
export default app;
Step 3: Configure cascade rules and instantiate
// convex/cascading.ts
import {
CascadingDelete,
defineCascadeRules,
makeBatchDeleteHandler
} from "@00akshatsinha00/convex-cascading-delete";
import { components } from "./_generated/api";
import { internalMutation } from "./_generated/server";
export const cascadeRules = defineCascadeRules({
users: [
{ to: "posts", via: "byAuthorId", field: "authorId" },
{ to: "comments", via: "byAuthorId", field: "authorId" }
],
posts: [
{ to: "comments", via: "byPostId", field: "postId" }
]
});
export const cd = new CascadingDelete(components.convexCascadingDelete, {
rules: cascadeRules
});
// Required for batched mode - exports an internal mutation that processes deletion batches
export const _cascadeBatchHandler = makeBatchDeleteHandler(
internalMutation,
components.convexCascadingDelete
);
Quick Start
Use the configured cd instance in your mutations:
// convex/users.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { cd } from "./cascading";
export const deleteUser = mutation({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
// Deletes user + all their posts + all comments on those posts
const summary = await cd.deleteWithCascade(ctx, "users", userId);
console.log("Deleted:", summary);
// Returns: { users: 1, posts: 5, comments: 23 }
}
});
For large deletion trees, use batched mode:
// convex/organizations.ts
import { mutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { cd } from "./cascading";
export const deleteOrganization = mutation({
args: { orgId: v.id("organizations") },
handler: async (ctx, { orgId }) => {
const result = await cd.deleteWithCascadeBatched(
ctx,
"organizations",
orgId,
{
batchHandlerRef: internal.cascading._cascadeBatchHandler,
batchSize: 2000
}
);
// result.jobId can be used to track progress via useDeletionJobStatus hook
// result.initialSummary contains counts from the first inline batch
return result;
}
});
API Reference
defineCascadeRules(config)
Defines and validates cascade relationships between tables. Returns a frozen configuration object.
const rules = defineCascadeRules({
[sourceTable: string]: [
{
to: string, // Target table name to cascade to
via: string, // Index name on target table
field: string // Field in index used for equality matching (holds parent ID)
}
]
});
Requirements:
- The index specified by
viamust exist on the target table - The index must include the field specified by
field - The
fieldmust contain IDs from the source table
Validation performed:
- All properties (
to,via,field) must be present and be strings - Duplicate rules (same
to:via:fieldcombination) are rejected - Configuration must be a non-null object
CascadingDelete Class
Main interface for deletion operations.
Constructor
const cd = new CascadingDelete(components.convexCascadingDelete, { rules });
| Parameter | Type | Description |
|---|---|---|
component | ComponentApi | Component reference from components.convexCascadingDelete |
options.rules | CascadeConfig | Rules from defineCascadeRules() |
deleteWithCascade(ctx, table, id)
Deletes a document and all its cascading dependents in a single transaction. Uses depth-first post-order traversal (children deleted before parents) with a visited set for cycle detection.
const summary: DeletionSummary = await cd.deleteWithCascade(ctx, "users", userId);
// Returns: { users: 1, posts: 5, comments: 23 }
| Parameter | Type | Description |
|---|---|---|
ctx | MutationCtx | Convex mutation context |
table | string | Source table name |
id | string | Document ID to delete |
| Returns | DeletionSummary | Map of table names to deleted document counts |
Best for: Small to medium deletion trees (fewer than 4,000 documents)
Consistency: Fully atomic - all deletes succeed or all fail within a single Convex transaction
deleteWithCascadeBatched(ctx, table, id, options)
Deletes a document and its dependents across multiple batched transactions. Collects all targets first via read-only traversal, deletes the first batch inline, then schedules remaining batches via the component's job system.
const result = await cd.deleteWithCascadeBatched(
ctx,
"organizations",
orgId,
{
batchHandlerRef: internal.cascading._cascadeBatchHandler,
batchSize: 2000 // Optional, defaults to 2000
}
);
// Returns: { jobId: "j57a...", initialSummary: { organizations: 1, teams: 3 } }
// jobId is null if all targets fit in the first batch
| Parameter | Type | Description |
|---|---|---|
ctx | MutationCtx | Convex mutation context |
table | string | Source table name |
id | string | Document ID to delete |
options.batchHandlerRef | FunctionReference<"mutation"> | Reference to your exported batch handler (from makeBatchDeleteHandler) |
options.batchSize | number (optional) | Documents per batch, defaults to 2000 |
| Returns | { jobId: string | null, initialSummary: DeletionSummary } | Job ID for tracking (null if all deleted inline) and first-batch summary |
Best for: Large deletion trees (any size)
Consistency: Per-batch atomic, inter-batch eventual. Each batch is a separate Convex transaction.
Progress tracking: Pass the returned jobId to the useDeletionJobStatus React hook
validateRules(ctx)
Validates that all configured indexes exist by probing each index with a test query. Should be called once during app initialization or in a dev-only check.
await cd.validateRules(ctx);
// Throws descriptive error if any index is missing or misconfigured
| Parameter | Type | Description |
|---|---|---|
ctx | QueryCtx | Convex query or mutation context |
patchDb(db)
Returns a proxied database writer that throws on direct .delete() calls, forcing all deletions to go through deleteWithCascade. Useful as a safety guard in critical mutations.
export const safeDeleteUser = mutation({
handler: async (ctx, args) => {
const safeDb = cd.patchDb(ctx.db);
// safeDb.delete(id) --> throws "Direct db.delete() is disabled"
// safeDb.query(...) --> works normally
// safeDb.insert(...) --> works normally
// safeDb.patch(...) --> works normally
}
});
makeBatchDeleteHandler(internalMutationBuilder, componentRef)
Factory function that creates the app-side internal mutation for processing deletion batches. This function must be exported from your convex code so the component's scheduler can invoke it via a function handle.
import { makeBatchDeleteHandler } from "@00akshatsinha00/convex-cascading-delete";
import { components } from "./_generated/api";
import { internalMutation } from "./_generated/server";
export const _cascadeBatchHandler = makeBatchDeleteHandler(
internalMutation,
components.convexCascadingDelete
);
| Parameter | Type | Description |
|---|---|---|
internalMutationBuilder | InternalMutation | Your app's internalMutation builder from _generated/server |
componentRef | ComponentApi | Component reference from components.convexCascadingDelete |
| Returns | FunctionReference<"mutation"> | Internal mutation to pass as batchHandlerRef |
How it works: The returned mutation receives a batch of { table, id } targets, deletes each one via ctx.db.delete(id), then reports completion back to the component via reportBatchComplete. The component's scheduler calls this function handle with each batch.
React Hook
useDeletionJobStatus(api, jobId)
Monitors batch deletion progress with reactive updates. Wraps the component's getJobStatus query.
import { useDeletionJobStatus } from "@00akshatsinha00/convex-cascading-delete/react";
import { api } from "../convex/_generated/api";
function DeletionProgress({ jobId }: { jobId: string | null }) {
const status = useDeletionJobStatus(api, jobId);
if (!status) return null;
const progress = (status.completedCount / status.totalTargetCount) * 100;
return (
<div>
<progress value={progress} max={100} />
<p>{status.status}: {status.completedCount} / {status.totalTargetCount}</p>
{status.status === "completed" && (
<pre>{JSON.stringify(JSON.parse(status.completedSummary), null, 2)}</pre>
)}
</div>
);
}
| Parameter | Type | Description |
|---|---|---|
api | any | Your app's api object from _generated/api |
jobId | string | null | Job ID from deleteWithCascadeBatched, or null to skip |
| Returns | BatchJobStatus | null | Current job status, or null if no job / job not found |
BatchJobStatus shape:
| Field | Type | Description |
|---|---|---|
status | "pending" | "processing" | "completed" | "failed" | Current job state |
totalTargetCount | number | Total documents to delete |
completedCount | number | Documents deleted so far |
completedSummary | string | JSON string mapping table names to deleted counts |
error | string | undefined | Error message if job failed |
Exported Types
All types are importable from the main entry point:
import type {
CascadeRule, // { to: string; via: string; field: string }
CascadeConfig, // { [sourceTable: string]: CascadeRule[] }
DeletionSummary, // { [tableName: string]: number }
DeletionTarget, // { table: string; id: string }
BatchJobStatus, // { status, totalTargetCount, completedCount, completedSummary, error? }
} from "@00akshatsinha00/convex-cascading-delete";
Schema Requirements
Your schema must have indexes that match your cascade rules. Each rule's via must correspond to an index on the to table, and the field must be the first field in that index.
// 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({
authorId: v.id("users"),
title: v.string(),
content: v.string(),
}).index("byAuthorId", ["authorId"]), // Required for cascade from users
comments: defineTable({
authorId: v.id("users"),
postId: v.id("posts"),
text: v.string(),
})
.index("byAuthorId", ["authorId"]) // For user → comments cascade
.index("byPostId", ["postId"]), // For post → comments cascade
});
The corresponding cascade rules would be:
const rules = defineCascadeRules({
users: [
{ to: "posts", via: "byAuthorId", field: "authorId" },
{ to: "comments", via: "byAuthorId", field: "authorId" }
],
posts: [
{ to: "comments", via: "byPostId", field: "postId" }
]
});
Examples
Multi-Level Hierarchy
const rules = defineCascadeRules({
organizations: [
{ to: "teams", via: "byOrganizationId", field: "organizationId" }
],
teams: [
{ to: "members", via: "byTeamId", field: "teamId" },
{ to: "projects", via: "byTeamId", field: "teamId" }
],
projects: [
{ to: "tasks", via: "byProjectId", field: "projectId" }
],
tasks: [
{ to: "comments", via: "byTaskId", field: "taskId" }
]
});
// Deleting an organization cascades through 5 levels
const summary = await cd.deleteWithCascade(ctx, "organizations", orgId);
// Returns: { organizations: 1, teams: 5, members: 23, projects: 12, tasks: 67, comments: 234 }
Branching Cascades
A single parent table can cascade to multiple dependent tables:
const rules = defineCascadeRules({
users: [
{ to: "posts", via: "byAuthorId", field: "authorId" },
{ to: "comments", via: "byAuthorId", field: "authorId" },
{ to: "likes", via: "byUserId", field: "userId" },
{ to: "follows", via: "byFollowerId", field: "followerId" }
]
});
Circular Dependencies
The component handles circular references automatically via a visited set. No infinite loops:
const rules = defineCascadeRules({
users: [
{ to: "friendships", via: "byUserId", field: "userId" }
],
friendships: [
{ to: "users", via: "byFriendId", field: "friendId" }
]
});
// Safe - visited set prevents re-processing already-seen documents
const summary = await cd.deleteWithCascade(ctx, "users", userId);
Using patchDb as a Safety Guard
import { mutation } from "./_generated/server";
import { cd } from "./cascading";
export const processUser = mutation({
handler: async (ctx, args) => {
// Replace ctx.db with a guarded version for this mutation
const safeCtx = { ...ctx, db: cd.patchDb(ctx.db) };
// All reads work normally
const user = await safeCtx.db.get(args.userId);
// Direct deletes are blocked - forces cascade usage
// safeCtx.db.delete(args.userId) --> throws Error
// Must use cascade delete instead
await cd.deleteWithCascade(ctx, "users", args.userId);
}
});
Best Practices
- Start with inline mode - Use
deleteWithCascadefor most cases; it's simpler and fully atomic - Switch to batched for large trees - Use
deleteWithCascadeBatchedwhen deleting more than 4,000 documents to avoid transaction limits - Validate rules on startup - Call
validateRules()in a dev-only initialization function to catch misconfigured indexes early - Use patchDb in critical mutations - Prevent accidental direct deletes that would leave orphaned records
- Monitor batch progress - Use the
useDeletionJobStatushook to show users real-time deletion feedback - Test cascade rules - Verify relationships work as expected before production using the testing helpers
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ YOUR APP │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Your Mutation │ │
│ │ │ │
│ │ const cd = new CascadingDelete( │ │
│ │ components.convexCascadingDel,│ │
│ │ { rules: cascadeRules } │ │
│ │ ); │ │
│ │ │ ctx.db (YOUR tables) │
│ │ // Inline mode: │─────► .query(table) │
│ │ cd.deleteWithCascade(ctx, │ .withIndex(idx,...) │
│ │ "teams", teamId) │ .collect() │
│ │ │ .delete(id) │
│ │ // Batched mode: │ │
│ │ cd.deleteWithCascadeBatched(ctx,│ │
│ │ "teams", teamId, opts) │ │
│ │ │ │
│ └──────────┬───────────────────────┘ │
│ │ │
│ │ ctx.runMutation(component.lib.createBatchJob, ...)│
│ │ ctx.runQuery(component.lib.getJobStatus, ...) │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ COMPONENT (Isolated — own DB, own transactions) │ │
│ │ │ │
│ │ Table: deletionJobs │ │
│ │ { status, targets, deleteHandle, batchSize, summary } │ │
│ │ │ │
│ │ Functions: │ │
│ │ createBatchJob(targets, handle, batchSize) │ │
│ │ processNextBatch(jobId) │ │
│ │ ├─ scheduler.runAfter(0, deleteHandle, batch) ──┼──►│
│ │ └─ scheduler.runAfter(200ms, self, jobId) │ │
│ │ getJobStatus(jobId) → reactive query │ │
│ │ reportBatchComplete(jobId, summary) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Function handle callback │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Your Batch Delete Handler (via makeBatchDeleteHandler) │ │
│ │ │ │
│ │ handler: async (ctx, { targets, jobId }) => { │ │
│ │ for (t of targets) await ctx.db.delete(t.id); │ │
│ │ await ctx.runMutation(component.reportBatchComplete, │ │
│ │ { jobId, summary }); │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key architectural constraint: Convex components cannot access your app's tables. All document traversal and deletion runs in your app's mutation context using ctx.db. The component only manages batch job state (creation, progress, completion) in its own isolated database.
Consistency Guarantees
Inline Mode (deleteWithCascade)
- Fully atomic - All deletes succeed or all fail within a single Convex transaction
- ACID compliant - Leverages Convex's built-in transactional guarantees
- Immediate - Returns complete
DeletionSummarysynchronously
Batched Mode (deleteWithCascadeBatched)
- Per-batch atomic - Each batch is a separate Convex transaction
- Inter-batch eventual - Batches process asynchronously with 200ms delay between them
- First batch inline - Initial batch is deleted in the calling mutation for immediate feedback
- Remaining batches scheduled - Processed via the component's scheduler using function handles
- Progress observable - Use
useDeletionJobStatushook orgetJobStatusquery for real-time status
Performance Characteristics
| Characteristic | Detail |
|---|---|
| Inline mode limit | ~4,000 documents (based on Convex's 16K write limit per transaction) |
| Batch size | Configurable, defaults to 2,000 documents per batch |
| Traversal algorithm | Depth-first, post-order (children deleted before parents) |
| Cycle detection | O(1) lookup per document via Set<string> |
| Index usage | Efficient .withIndex() queries — no table scans |
| Batch scheduling delay | 200ms between batches to prevent scheduler flooding |
| Convex limits respected | 16K writes, 32K document scans, 4,096 index reads, 1s execution per transaction |
Testing
The package exports a test helper for use with convex-test:
import { convexTest } from "convex-test";
import { register } from "@00akshatsinha00/convex-cascading-delete/test";
import schema from "./schema";
const modules = import.meta.glob("./convex/**/*.ts");
test("cascading delete works", async () => {
const t = convexTest(schema, modules);
register(t, "convexCascadingDelete");
// ... your test code using the component
});
The register function registers the component's schema and modules with the test instance. The second argument must match the component name in your convex.config.ts.
Running the Example
The example/ directory contains a full working application demonstrating both inline and batched deletion modes with a 5-level organizational hierarchy.
# Clone the repository
git clone https://github.com/akshatsinha0/convex-cascading-delete.git
cd convex-cascading-delete
# Install dependencies
npm install
# Start the dev server (backend + frontend + build watcher)
npm run dev
The example app includes:
- Seed data buttons - Create sample organizations with teams, members, projects, tasks, and comments
- Inline delete - Delete an organization atomically in a single transaction
- Batched delete - Delete an organization across multiple batched transactions with real-time progress
- Document counters - See counts update reactively across all 6 tables
- REST API - HTTP endpoint at
/api/deletion-job-status?jobId=...for external job monitoring
Troubleshooting
"Index does not exist" error
Run validateRules() to identify missing indexes:
await cd.validateRules(ctx);
// Error: Cascade validation failed: Index "byAuthorId" with field "authorId"
// does not exist on table "posts". Define it in your schema.
// Source table: "users"
Add the missing index to your schema with .index("indexName", ["fieldName"]).
Batch deletion stuck
Check job status directly:
const status = await ctx.runQuery(
components.convexCascadingDelete.lib.getJobStatus,
{ jobId }
);
console.log(status);
// { status: "processing", totalTargetCount: 500, completedCount: 200, ... }
If a job is stuck in "processing" state, it may be due to the batch handler function not being properly exported or a deployment mismatch.
Transaction limit exceeded
If inline mode fails with a transaction limit error, switch to batched mode:
// Instead of:
await cd.deleteWithCascade(ctx, "organizations", orgId);
// Use:
await cd.deleteWithCascadeBatched(ctx, "organizations", orgId, {
batchHandlerRef: internal.cascading._cascadeBatchHandler,
batchSize: 1000 // Reduce batch size if needed
});
Type errors with table names
Use type assertions for dynamic table access:
const summary = await cd.deleteWithCascade(ctx, "users", userId as any);
Live Demo
Try the interactive demo: https://convex-cascading-delete.vercel.app
Found a bug? Feature request?
License
Apache-2.0
Built For
Convex Components Authoring Challenge - Full-Stack Drop-In Features