# Cron を使わない古いデータの削除

> 保持期間（既定は 180 日）を過ぎた問い合わせを、定期実行のジョブなしで削除するしくみを説明します。

問い合わせは保持期間（既定は 180 日）を過ぎると削除されます。定期実行のジョブは使わず、ふだんのリクエストの中で、多くても 1 時間に 1 回削除します。

## 何をするか

- **新しい問い合わせの後に削除します。** 問い合わせを保存した後、Worker は `ctx.waitUntil`（応答の後も処理を続けるしくみ）の中で `settings.last_cleanup_at` を確認します。前回から 1 時間以上たっていれば、保持期間を過ぎた問い合わせを削除し、`last_cleanup_at` を更新します。
- **管理画面とログインのときも削除します。** 問い合わせの一覧を開いたときと、Passkey でログインしようとしたときにも、同じ削除の処理が動きます。新しい問い合わせが来なくても、古いものは消えます。リクエストがまったく来ないサイトでは、次のリクエストまで何も削除されません。
- **削除は速く済みます。** `created_at` にインデックスがあるからです。
- **期限切れのチャレンジも消します。** Passkey でログインしようとするたびに、Better Auth の `verification` テーブルにチャレンジを書き込みます。使ったチャレンジはすぐ消えますが、途中でやめたものは残り続けます。同じ削除の処理で、期限切れの行も消します。チャレンジは数分で期限が切れ、ログインには回数制限があるので、インデックスがなくてもテーブルは小さいままです。
- **期限切れのセッションも消します。** 期限切れの管理者のセッションは、ブラウザがそのトークンをもう一度送るまで、トークンとブラウザ名を `session` テーブルに残します。送られないままのこともあります。同じ削除の処理で、期限を過ぎたセッションも消します。セッションを持つのは管理者だけなので、このテーブルも小さいままです。

この自動の削除とは別に、管理者は問い合わせを手動で削除したり、CSV で書き出したりもできます。書き出しは 1 回のクエリで 200 行ずつ読んで順に送り出すので、テーブルが大きくても同じように動きます。

## コードでは

問い合わせ API は、応答を返す直前に削除の処理を始めます。`runInBackground()` は処理を `ctx.waitUntil` に渡します。失敗してもログに書くだけで、応答は変えません。

```ts title="packages/server/src/features/contact/contact-routes.ts"
runInBackground(c, 'prune', () => pruneIfDue(env.DB, now));
return c.json({ ok: true });
```

`pruneIfDue()` は、まず削除を実行する権利を取ります。条件付きの upsert（行がなければ追加し、あれば更新）を使い、保存されている `last_cleanup_at` が 1 時間以上前のときだけ現在の時刻に書き換えます。書き換えたリクエストだけが削除に進むので、2 つのリクエストが同時に来ても、削除するのは片方だけです。

保持期間は `settings.retention_days` から読みます。範囲は 1〜3,650 日で、ないときや不正な値のときは 180 日です。

```ts title="packages/server/src/features/retention/prune.ts"
const orm = getDatabase(db);
const claim = await orm
  .insert(settings)
  .values({ key: 'last_cleanup_at', value: String(now) })
  .onConflictDoUpdate({
    target: settings.key,
    set: { value: sql`excluded.value` },
    setWhere: lte(
      sql`cast(${settings.value} as integer)`,
      now - PRUNE_INTERVAL_MS,
    ),
  });
if (claim.meta.changes === 0) return { ran: false };
const retentionDays = await getRetentionDays(db);
const result = await orm
  .delete(submissions)
  .where(lt(submissions.createdAt, now - retentionDays * DAY_MS));
const verifications = await orm
  .delete(verification)
  .where(lt(verification.expiresAt, new Date(now)));
const sessions = await orm
  .delete(session)
  .where(lt(session.expiresAt, new Date(now)));
```

## なぜこう作るか

goodnight-web は Cron Triggers を使いません。設定して理解するものを、Worker 1 つとデータベース 1 つだけにするためです。

古いデータは決まった時刻に消す必要はなく、いずれ消えれば十分です。そのため、もともと来るリクエストの中で削除します。1 時間に 1 回までに間引くので、増える処理はわずかです。

削除は応答の後に動くので、問い合わせの送信を遅くしたり、失敗させたりすることはありません。

削除した行も、しばらくは D1 の Time Travel で復元できます。[プライバシーについて](https://goodnight-web.dev/ja/docs/privacy.md)を参照してください。
