Soft Delete Projects

Our scheduled cascade delete works, but there is still a user experience (UX) problem. The delete is asynchronous. When the user clicks “Delete Project,” the mutation only schedules background cleanup. If a project does really have thousands of tasks, the cleanup might not happen instantly. The user might still see the project in the list of projects even after confirming the deletion.

We can solve this UX problem by implementing optimistic updates similar to what we did for updating the status of a task in the previous chapter (to make drag-and-drop feel instant). However, I will teach you a different pattern here: soft delete.

Instead of immediately removing documents from the database, we mark them as deleted. The UI instantly removes them from view, and a scheduled background job cleans them up later.

The Soft Delete Pattern

A soft delete works like this:

  1. When the user “deletes” a project, we set a deletedAt timestamp on it instead of actually removing it
  2. All queries filter out documents where deletedAt is set. They become invisible to the UI.
  3. A cron job runs periodically (e.g., every 24 hours) and permanently deletes the marked documents and their tasks

Update the Schema

Add an optional deletedAt field to the projects table in convex/schema.ts:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  projects: defineTable({
    name: v.string(),
    description: v.optional(v.string()),
    deletedAt: v.optional(v.number()),  // 👀
  }).index("by_deletedAt", ["deletedAt"]),  // 👀
  tasks: defineTable({
    // ... existing fields ...
  }),
});

The deletedAt field is v.optional(v.number()) — it is a timestamp (number of milliseconds) that is only present on deleted documents. When it is absent, the project is active.

We also add an index on deletedAt to make it efficient to query for deleted projects.

Update Queries to Filter Soft-Deleted Projects

Update the list and get queries in convex/projects.ts to exclude soft-deleted projects:

export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query("projects")
      .withIndex("by_deletedAt", (q) => q.eq("deletedAt", undefined))
      .collect();
  },
});

export const get = query({
  args: {
    id: v.id("projects"),
  },
  handler: async (ctx, args) => {
    const project = await ctx.db.get(args.id);
    return project && project.deletedAt === undefined ? project : null;
  },
});

Notice in both queries, we check that deletedAt is undefined. This means the project is not deleted. The list query uses the by_deletedAt index to efficiently filter out deleted projects.

Replace Hard Delete with a Soft Delete Mutation

Now that we are doing a soft delete, the remove mutation is simple. It just sets a timestamp.

export const remove = mutation({
  args: {
    id: v.id("projects"),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, { deletedAt: Date.now() });
  },
});

This is instant because it patches a single document. The project disappears from all queries immediately because our list query filters it out.

The Client Does not Change

The client code from the previous sections already uses useMutation(api.projects.remove), so there are no frontend changes here. We only changed what the mutation does on the server.

In the next section, we will implement the cron job that permanently deletes soft-deleted projects and their tasks.

Checkpoint: Commit your progress.

git add .
git commit -m "planner-11: Add soft delete with deletedAt timestamp"
git push