# Soft delete

> The softDelete() schema mixin — deletedAt / deletedBy columns, delete() redirected to an UPDATE, default reads filtered, hardDelete() bypass.



---

<!-- source: en/plugins/soft-delete.md -->
## Soft delete

_The softDelete() schema mixin — deletedAt / deletedBy columns, delete() redirected to an UPDATE, default reads filtered, hardDelete() bypass._

`@voltro/plugin-soft-delete` ships a single surface: the `softDelete()` schema mixin. A "deleted" row stays in the database but disappears from default reads — the audit trail is preserved and the delete is reversible.

**Status:** ✓ shipped.

## The `softDelete()` mixin

```ts
import { table, id, text } from '@voltro/database'
import { softDelete } from '@voltro/plugin-soft-delete'

export const todos = table('todos', {
  id:    id(),
  title: text(),
}).with(softDelete())
```

It adds two columns:

- `deletedAt` — `timestamp().nullable()`
- `deletedBy` — `reference()` to the `actors` table, nullable

`softDelete()` transitively requires `audit()`, so composing it also pulls in the `createdAt` / `updatedAt` / `createdBy` / `updatedBy` columns. The mixin is also available on the `./mixin` subpath (`@voltro/plugin-soft-delete/mixin`).

## Runtime behaviour

The mixin's stable id (`voltro/softDelete`) drives two automatic behaviours in the runtime:

- **`ctx.store.delete(table, pk)` becomes an UPDATE.** On a softDelete-mixed table, a delete fills `deletedAt` + `deletedBy` (from `ctx.actor`) instead of removing the row.
- **Default reads filter `deletedAt IS NULL`.** The mixin's `defaultWhere` documents the intent; the runtime wires the actual filter at subscribe time, so soft-deleted rows are invisible to normal queries without any per-query effort.

```ts
// Soft-delete — row stays, just hidden from default reads.
await ctx.store.delete('todos', todoId)

// Hard-delete escape hatch — actually removes the row, bypassing the mixin.
await ctx.store.hardDelete('todos', todoId)
```

Use `hardDelete()` sparingly — the whole point of soft-delete is preserving the audit trail.

## Reading + restoring deleted rows

Default reads hide soft-deleted rows. Opt out with `.withDeleted()` to build a trash view, and `restore()` to undelete:

```ts
import { restore } from '@voltro/plugin-soft-delete'

// List tombstones (deleted + live) for a restore UI.
const rows = await ctx.store.select('todos').withDeleted().all()

// Undelete — clears deletedAt + deletedBy by primary key; the row reappears.
await restore(ctx.store, 'todos', todoId)
```

`restore()` patches by primary key, so it works even though default reads hide the row (you only need `.withDeleted()` to *find* what to restore). It's idempotent. There's no runtime "undelete" redirect — restore is a plain patch of the two columns, wrapped so a trash UI doesn't hand-roll it.

## Soft-delete vs deactivation

`softDelete()` **hides** a row from default reads (and is the GDPR-anonymisation path). [`deactivation()`](/docs/plugins/deactivation) is the deliberate opposite: it marks a subject as unable to log in while keeping its data fully **visible**. Compose `.with(audit(), softDelete(), deactivation())` for the full user-lifecycle column set.
