Skip to content
cascade-deletes logo

Cascade Delete Patterns

cascade-deletes

Normalized schema cascade delete patterns. Handles junction tables, parent-child ordering, nullify vs delete strategies. Triggers on "cascade", "delete", "cleanup", "junction", "related records", "deleteConversation", "deleteUser".

SKILL.md

Full skill instructions

Cascade Delete Patterns

Normalized schema requires explicit cascade deletes. Convex has no foreign key constraints or automatic cascades - must handle manually.

Why Cascade Deletes Needed

Normalized schema separates related data into multiple tables. Deleting a conversation must also delete:

  • Junction tables (projectConversations, conversationParticipants)
  • Child records (messages, attachments, toolCalls, sources)
  • Dependent entities (bookmarks, shares, canvasDocuments)

Leaving orphaned records wastes storage, breaks queries, violates data integrity.

Core Strategy

  1. Parallel queries: Fetch all related records with Promise.all
  2. Batch deletions: Delete independent tables in parallel
  3. Sequential for dependencies: Delete children before parents when order matters
  4. Nullify reusable entities: Files/​memories can exist without conversation

Conversation Cascade Delete

From packages/​backend/​convex/​lib/​utils/​cascade.ts:

export async function cascadeDeleteConversation(
  ctx: MutationCtx,
  conversationId: Id<"conversations">,
  options?: { deleteMessages?: boolean; deleteConversation?: boolean },
): Promise<void> {
  const deleteMessages = options?.deleteMessages ?? true;
  const deleteConversation = options?.deleteConversation ?? true;

  // Step 1: Parallel queries for all related records
  const [
    bookmarks,
    shares,
    files,
    memories,
    junctions,
    participants,
    tokenUsage,
    attachments,
    toolCalls,
    sources,
    canvasDocs,
  ] = await Promise.all([
    ctx.db
      .query("bookmarks")
      .withIndex("by_conversation", (q) =>
        q.eq("conversationId", conversationId),
      )
      .collect(),
    ctx.db
      .query("shares")
      .withIndex("by_conversation", (q) =>
        q.eq("conversationId", conversationId),
      )
      .collect(),
    // ... more queries
  ]);

  // Step 2: Parallel deletions for independent tables
  await Promise.all([
    ...bookmarks.map((b) => ctx.db.delete(b._id)),
    ...shares.map((s) => ctx.db.delete(s._id)),
    ...junctions.map((j) => ctx.db.delete(j._id)),
    ...participants.map((p) => ctx.db.delete(p._id)),
    ...tokenUsage.map((t) => ctx.db.delete(t._id)),
    ...attachments.map((a) => ctx.db.delete(a._id)),
    ...toolCalls.map((tc) => ctx.db.delete(tc._id)),
    ...sources.map((src) => ctx.db.delete(src._id)),
  ]);

  // Step 3: Nullify files/​memories (can exist independently)
  await Promise.all([
    ...files.map((f) => ctx.db.patch(f._id, { conversationId: undefined })),
    ...memories.map((m) => ctx.db.patch(m._id, { conversationId: undefined })),
  ]);

  // Step 4: Handle parent-child relationships (history before documents)
  for (const doc of canvasDocs) {
    const history = await ctx.db
      .query("canvasHistory")
      .withIndex("by_document", (q) => q.eq("documentId", doc._id))
      .collect();
    await Promise.all(history.map((h) => ctx.db.delete(h._id)));
    await ctx.db.delete(doc._id);
  }

  // Step 5: Delete messages (optional)
  if (deleteMessages) {
    const messages = await ctx.db
      .query("messages")
      .withIndex("by_conversation", (q) =>
        q.eq("conversationId", conversationId),
      )
      .collect();
    await Promise.all(messages.map((msg) => ctx.db.delete(msg._id)));
  }

  // Step 6: Delete conversation itself (optional)
  if (deleteConversation) await ctx.db.delete(conversationId);
}

Usage in mutations:

export const deleteConversation = mutation({
  args: { conversationId: v.id("conversations") },
  handler: async (ctx, args) => {
    const user = await getCurrentUserOrCreate(ctx);
    const conv = await ctx.db.get(args.conversationId);
    if (!conv || conv.userId !== user._id) throw new Error("Not found");

    await cascadeDeleteConversation(ctx, args.conversationId);
  },
});

Junction Table Cleanup

Junction tables represent many-to-many relationships. Must delete before parent entities.

// ✅ CORRECT: Delete junction first
const junctions = await ctx.db
  .query("projectConversations")
  .withIndex("by_conversation", (q) =>
    q.eq("conversationId", conversationId),
  )
  .collect();

await Promise.all(junctions.map((j) => ctx.db.delete(j._id)));

Junction tables to handle:

  • projectConversations - projects ↔ conversations
  • projectNotes - projects ↔ notes
  • projectFiles - projects ↔ files
  • conversationParticipants - users ↔ conversations
  • bookmarkTags, snippetTags, noteTags, taskTags - tags ↔ entities

Nullify vs Delete Strategy

Delete - Entity only exists for this parent:

  • attachments - message-specific files
  • toolCalls - execution results tied to message
  • sources - citations for specific message
  • bookmarks - reference to message in conversation
  • shares - share link for conversation

Nullify - Entity can exist independently:

  • files - user uploads, can be reused across conversations
  • memories - extracted facts, retain even after conversation deleted
// Nullify pattern - preserve entity, remove association
await Promise.all([
  ...files.map((f) => ctx.db.patch(f._id, { conversationId: undefined })),
  ...memories.map((m) => ctx.db.patch(m._id, { conversationId: undefined })),
]);

Parent-Child Ordering

When parent-child relationship exists, delete children first to avoid orphans.

Example: canvasDocuments (parent) → canvasHistory (child)

// ❌ WRONG: Delete parent first, orphans children
await ctx.db.delete(doc._id);
const history = await ctx.db
  .query("canvasHistory")
  .withIndex("by_document", (q) => q.eq("documentId", doc._id))
  .collect();
await Promise.all(history.map((h) => ctx.db.delete(h._id))); // Already orphaned

// ✅ CORRECT: Delete children first
for (const doc of canvasDocs) {
  const history = await ctx.db
    .query("canvasHistory")
    .withIndex("by_document", (q) => q.eq("documentId", doc._id))
    .collect();
  await Promise.all(history.map((h) => ctx.db.delete(h._id)));
  await ctx.db.delete(doc._id);
}

Other parent-child relationships:

  • messages (parent) → attachments, toolCalls, sources (children)
  • knowledgeSources (parent) → knowledgeChunks (children)
  • files (parent) → fileChunks (children)

GDPR User Data Deletion

From packages/​backend/​convex/​lib/​utils/​cascade.ts, 5-phase approach:

export async function cascadeDeleteUserData(
  ctx: MutationCtx,
  userId: Id<"users">,
): Promise<void> {
  // Phase 1: Delete junction tables (many-to-many)
  const [bookmarkTags, snippetTags, noteTags, taskTags, ...] = await Promise.all([
    ctx.db.query("bookmarkTags").withIndex("by_user", (q) => q.eq("userId", userId)).collect(),
    // ...
  ]);
  await Promise.all([
    ...bookmarkTags.map((r) => ctx.db.delete(r._id)),
    // ...
  ]);

  // Phase 2: Delete child records (have FKs to parents deleted later)
  const [toolCalls, sources, attachments, ...] = await Promise.all([
    ctx.db.query("toolCalls").withIndex("by_user", (q) => q.eq("userId", userId)).collect(),
    // ...
  ]);
  await Promise.all([...toolCalls.map((r) => ctx.db.delete(r._id)), ...]);

  // Phase 3: Delete parent records (messages, canvasDocuments, knowledgeSources)
  const [messages, canvasDocuments, knowledgeSources] = await Promise.all([
    ctx.db.query("messages").withIndex("by_user", (q) => q.eq("userId", userId)).collect(),
    // ...
  ]);
  await Promise.all([...messages.map((r) => ctx.db.delete(r._id)), ...]);

  // Phase 4: Delete main content entities
  const [conversations, bookmarks, snippets, notes, tasks, ...] = await Promise.all([
    ctx.db.query("conversations").withIndex("by_user", (q) => q.eq("userId", userId)).collect(),
    // ...
  ]);
  await Promise.all([...conversations.map((r) => ctx.db.delete(r._id)), ...]);

  // Phase 5: Delete user config/​metadata
  const [userPreferences, userOnboarding, usageRecords, ...] = await Promise.all([
    ctx.db.query("userPreferences").withIndex("by_user", (q) => q.eq("userId", userId)).collect(),
    // ...
  ]);
  await Promise.all([...userPreferences.map((r) => ctx.db.delete(r._id)), ...]);
}

Usage:

export const deleteMyData = mutation({
  args: { confirmationText: v.string() },
  handler: async (ctx, { confirmationText }) => {
    if (confirmationText !== "DELETE MY DATA") {
      throw new Error('Please type "DELETE MY DATA" to confirm');
    }

    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthorized");

    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .first();

    if (!user) throw new Error("User not found");

    await cascadeDeleteUserData(ctx, user._id);

    return { success: true };
  },
});

Performance Optimization

Parallel queries - Fetch all related records at once:

// ✅ GOOD: Single round-trip, parallel execution
const [bookmarks, shares, files] = await Promise.all([
  ctx.db.query("bookmarks").collect(),
  ctx.db.query("shares").collect(),
  ctx.db.query("files").collect(),
]);

// ❌ BAD: Sequential, 3x latency
const bookmarks = await ctx.db.query("bookmarks").collect();
const shares = await ctx.db.query("shares").collect();
const files = await ctx.db.query("files").collect();

Parallel deletions - Delete independent records simultaneously:

// ✅ GOOD: All deletions in parallel
await Promise.all([
  ...bookmarks.map((b) => ctx.db.delete(b._id)),
  ...shares.map((s) => ctx.db.delete(s._id)),
  ...attachments.map((a) => ctx.db.delete(a._id)),
]);

// ❌ BAD: Sequential deletions
for (const b of bookmarks) await ctx.db.delete(b._id);
for (const s of shares) await ctx.db.delete(s._id);

Index usage - Always use indexed queries for related records:

// ✅ GOOD: Uses index, fast lookup
ctx.db
  .query("messages")
  .withIndex("by_conversation", (q) => q.eq("conversationId", conversationId))
  .collect()

// ❌ BAD: Full table scan
ctx.db
  .query("messages")
  .filter((q) => q.eq(q.field("conversationId"), conversationId))
  .collect()

Key Files

  • packages/​backend/​convex/​lib/​utils/​cascade.ts - Cascade delete utilities
  • packages/​backend/​convex/​conversations.ts - Conversation deletion
  • packages/​backend/​convex/​users.ts - GDPR user data deletion

Common Patterns

Full cascade delete:

await cascadeDeleteConversation(ctx, conversationId);

Preserve conversation, delete messages only:

await cascadeDeleteConversation(ctx, conversationId, {
  deleteMessages: true,
  deleteConversation: false,
});

GDPR compliance:

await cascadeDeleteUserData(ctx, userId); // Keeps user account
await ctx.db.delete(userId); // Full account deletion

Anti-Patterns

Don't use nested promises:

// ❌ BAD: Nested promises, hard to reason about
await Promise.all(
  bookmarks.map(async (b) => {
    const tags = await ctx.db.query("tags").collect();
    await ctx.db.delete(b._id);
  })
);

// ✅ GOOD: Flat structure, clear dependencies
const bookmarks = await ctx.db.query("bookmarks").collect();
const tags = await ctx.db.query("tags").collect();
await Promise.all([
  ...bookmarks.map((b) => ctx.db.delete(b._id)),
  ...tags.map((t) => ctx.db.delete(t._id)),
]);

Don't skip indexes:

// ❌ BAD: Filter scans entire table
const messages = await ctx.db
  .query("messages")
  .filter((q) => q.eq(q.field("userId"), userId))
  .collect();

// ✅ GOOD: Index lookup, O(log n)
const messages = await ctx.db
  .query("messages")
  .withIndex("by_user", (q) => q.eq("userId", userId))
  .collect();

Don't forget junction tables:

// ❌ BAD: Orphans projectConversations records
await ctx.db.delete(conversationId);

// ✅ GOOD: Clean up junction first
const junctions = await ctx.db
  .query("projectConversations")
  .withIndex("by_conversation", (q) => q.eq("conversationId", conversationId))
  .collect();
await Promise.all(junctions.map((j) => ctx.db.delete(j._id)));
await ctx.db.delete(conversationId);

Don't delete parents before children:

// ❌ BAD: Orphans canvasHistory
await ctx.db.delete(doc._id);

// ✅ GOOD: Delete history first
const history = await ctx.db
  .query("canvasHistory")
  .withIndex("by_document", (q) => q.eq("documentId", doc._id))
  .collect();
await Promise.all(history.map((h) => ctx.db.delete(h._id)));
await ctx.db.delete(doc._id);

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View