# Deleting old inquiries without Cron

> How inquiries older than the retention period (180 days by default) are deleted with no scheduled job.

Inquiries are deleted once they are older than the retention period, 180 days by default. There is no scheduled job: the cleanup runs during normal requests, at most once an hour.

## What it does

- **Cleanup runs after a new inquiry.** After an inquiry is saved, the Worker checks `settings.last_cleanup_at` inside `ctx.waitUntil`, which keeps work running after the response. If the last cleanup was more than an hour ago, it deletes inquiries older than the retention period and updates `last_cleanup_at`.
- **Cleanup also runs from the admin screen and from sign-in.** Opening the inquiry list, or a passkey sign-in attempt, runs the same cleanup, so old inquiries are deleted even when no new ones arrive. On a site that gets no requests at all, nothing is deleted until the next request.
- **The delete is fast.** There is an index on `created_at`.
- **Expired challenges are deleted too.** Each passkey sign-in attempt writes a challenge to Better Auth's `verification` table. A used challenge is deleted at once, but an abandoned one would stay forever, so the same cleanup deletes expired rows. Challenges expire after a few minutes and sign-in is rate-limited, so the table stays small without an index.
- **Expired sessions are deleted too.** An expired admin session would keep its token and browser name in the `session` table until the browser sent the token again, which may never happen. The same cleanup deletes sessions past their expiry time. Only admins have sessions, so this table is small too.

Apart from this automatic deletion, admins can delete inquiries manually and export them as CSV. The export reads 200 rows per query and streams them, so it works the same for a large table.

## In the code

The contact API starts the cleanup just before it returns the response. `runInBackground()` hands the task to `ctx.waitUntil`. A failure is logged and does not change the response:

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

`pruneIfDue()` first claims the run with one conditional upsert of `last_cleanup_at`. The value is replaced only if the stored timestamp is at least an hour old, so when two requests arrive at the same time, only one of them runs the cleanup.

The retention period comes from `settings.retention_days`. It can be 1 to 3,650 days, and is 180 when it is missing or invalid.

```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)));
```

## Why it is built this way

goodnight-web uses no Cron Triggers, so that one Worker and one database are all there is to set up and understand.

Old data does not need to be deleted at an exact time, only eventually. Normal traffic is enough for that, and the once-an-hour limit keeps the extra work small.

The cleanup runs after the response, so it never slows down or breaks a contact submission.

Deleted rows can still be restored from D1 Time Travel for a while. See [Privacy notes](https://goodnight-web.dev/docs/privacy.md).
