DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Claude CodeでSoft Deleteパターンを設計する:論理削除・一括復元・削除後TTL

はじめに

「間違えて消してしまった」を30日以内なら復元できる——deleted_atタイムスタンプで論理削除を実装し、誤削除からの復元・完全削除のスケジューリングをClaude Codeに設計させる。


CLAUDE.mdにSoft Delete設計ルールを書く

## Soft Delete設計ルール

### 実装方式
- deleted_at: TIMESTAMPTZ NOT NULL DEFAULT NULL
- Prismaミドルウェアで全クエリに WHERE deleted_at IS NULL を自動付与
- 物理削除は絶対禁止(ユーザー操作からは)

### 復元ポリシー
- 復元期間: 30日
- 30日後: 完全削除(物理削除・S3ファイルも削除)
- 復元は元のデータを完全に戻す(deleted_atをNULLに)

### カスケード削除
- 親を削除すると子も論理削除(同時刻のdeleted_atで識別)
- 復元時は子も同時に復元(同じdeleted_atのレコードを対象)
- 子のみの独立削除はdeleted_atが異なる
Enter fullscreen mode Exit fullscreen mode

Soft Delete実装の生成

Soft Delete(論理削除)システムを設計してください。

要件:
- Prismaミドルウェアで自動フィルタ
- カスケード論理削除
- 30日後の完全削除スケジュール
- 復元機能

生成ファイル: src/db/softDelete/
Enter fullscreen mode Exit fullscreen mode

生成されるSoft Delete実装

// src/db/softDelete/prismaMiddleware.ts — 全クエリに自動フィルタ

import { Prisma } from '@prisma/client';

// soft-deleteフィルタが必要なモデル一覧
const SOFT_DELETE_MODELS = new Set([
  'User', 'Post', 'Order', 'Product', 'File',
]);

export function softDeleteMiddleware(): Prisma.Middleware {
  return async (params, next) => {
    // soft-deleteモデルのみ処理
    if (!params.model || !SOFT_DELETE_MODELS.has(params.model)) {
      return next(params);
    }

    // findMany / findFirst / findUnique への自動フィルタ付与
    if (['findMany', 'findFirst', 'findUnique', 'findFirstOrThrow', 'findUniqueOrThrow'].includes(params.action)) {
      if (params.args?.where?.withDeleted) {
        delete params.args.where.withDeleted;
        return next(params);
      }

      params.args = params.args ?? {};
      params.args.where = params.args.where ?? {};

      if (params.action === 'findUnique' || params.action === 'findUniqueOrThrow') {
        params.action = params.action === 'findUniqueOrThrow' ? 'findFirstOrThrow' : 'findFirst';
        params.args.where.deletedAt = null;
      } else {
        params.args.where.deletedAt = null;
      }
    }

    if (params.action === 'count') {
      params.args = params.args ?? {};
      params.args.where = { ...(params.args.where ?? {}), deletedAt: null };
    }

    // delete → update(deletedAtを設定)
    if (params.action === 'delete') {
      params.action = 'update';
      params.args.data = { deletedAt: new Date() };
    }

    // deleteMany → updateMany
    if (params.action === 'deleteMany') {
      params.action = 'updateMany';
      params.args.data = { deletedAt: new Date() };
    }

    return next(params);
  };
}
Enter fullscreen mode Exit fullscreen mode
// src/db/softDelete/cascadeDelete.ts — カスケード論理削除

export async function softDeleteWithCascade(
  model: 'Post',
  id: string,
  userId: string
): Promise<void> {
  const deletedAt = new Date(); // 同一タイムスタンプで子も削除

  await prisma.$transaction(async (tx) => {
    const record = await tx.post.findFirst({ where: { id, userId, deletedAt: null } });
    if (!record) throw new NotFoundError(`Post ${id} not found`);

    await tx.post.update({ where: { id }, data: { deletedAt } });

    // 子コメントも同じタイムスタンプで論理削除(カスケード)
    await tx.comment.updateMany({
      where: { postId: id, deletedAt: null },
      data: { deletedAt },
    });

    await tx.file.updateMany({
      where: { postId: id, deletedAt: null },
      data: { deletedAt },
    });

    await tx.deletionLog.create({
      data: { modelName: 'Post', recordId: id, deletedBy: userId, deletedAt },
    });
  });

  logger.info({ postId: id, userId }, 'Post soft-deleted with cascade');
}

// カスケード復元(同一deletedAtのレコードを全て復元)
export async function restoreWithCascade(
  model: 'Post',
  id: string,
  userId: string
): Promise<void> {
  const record = await prisma.post.findFirst({
    where: { id, userId, withDeleted: true } as any,
  });

  if (!record?.deletedAt) throw new NotFoundError(`Deleted post ${id} not found`);

  const deletedDaysAgo = (Date.now() - record.deletedAt.getTime()) / (1000 * 60 * 60 * 24);
  if (deletedDaysAgo > 30) {
    throw new Error('Cannot restore: record deleted more than 30 days ago');
  }

  const deletedAt = record.deletedAt;

  await prisma.$transaction(async (tx) => {
    await (tx as any).post.update({ where: { id }, data: { deletedAt: null } });

    // 同じdeleted_atの子コメントも復元(独立削除分は対象外)
    await (tx as any).comment.updateMany({
      where: { postId: id, deletedAt },
      data: { deletedAt: null },
    });

    await (tx as any).file.updateMany({
      where: { postId: id, deletedAt },
      data: { deletedAt: null },
    });
  });

  logger.info({ postId: id, userId }, 'Post restored with cascade');
}
Enter fullscreen mode Exit fullscreen mode
// src/db/softDelete/cleanupJob.ts — 30日後の完全削除スケジュール

export async function permanentDeleteExpired(): Promise<void> {
  const RETENTION_DAYS = 30;
  const cutoffDate = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);

  logger.info({ cutoffDate }, 'Starting permanent delete of expired soft-deleted records');

  // 削除順: 子 → 親(FK制約の逆順)
  const fileResult = await prisma.file.deleteMany({
    where: { deletedAt: { lt: cutoffDate, not: null } },
  });

  const commentResult = await prisma.comment.deleteMany({
    where: { deletedAt: { lt: cutoffDate, not: null } },
  });

  const postResult = await prisma.post.deleteMany({
    where: { deletedAt: { lt: cutoffDate, not: null } },
  });

  logger.info({
    posts: postResult.count,
    comments: commentResult.count,
    files: fileResult.count,
  }, 'Permanent delete completed');
}
Enter fullscreen mode Exit fullscreen mode

まとめ

Claude CodeでSoft Deleteパターンを設計する:

  1. CLAUDE.md に全クエリへdeleted_at自動フィルタ・delete→updateに変換・30日後完全削除を明記
  2. Prismaミドルウェア でdelete/deleteManyをupdateに変換——アプリ側から物理削除を不可能に
  3. カスケード削除 は親子で同一のdeletedAtタイムスタンプを使用——復元時に同タイムスタンプで一括特定
  4. 完全削除CronJob で30日経過レコードをFK逆順(子→親)で物理削除しS3ファイルも合わせて削除

DB設計のレビューは **Code Review Pack(¥980)* の /code-review で確認できます。*

prompt-works.jp

みょうが (@myougatheaxo) — ウーパールーパーのVTuber。

Top comments (0)