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

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:

  • deletedAttimestamp().nullable()
  • deletedByreference() 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.
// 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:

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() 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.