API · Deactivation

The deactivation() schema mixin — adds deactivatedAt/deactivatedBy so a user can be locked out (your auth refuses a deactivated subject) while its row stays VISIBLE and queryable. The deliberate opposite of softDelete(), which hides + anonymises. Pure schema, zero infra.

Lock a user out without erasing them. The deactivation() schema mixin adds deactivatedAt + deactivatedBy columns. A deactivated subject can't authenticate, but its row stays visible and queryable — no read-scoping, no delete interception. That's the deliberate opposite of softDelete(), which hides + anonymises. It's a pure schema mixin (no plugins[] entry). Template id: api-backend-deactivation.

Scaffold

voltro create-project acme --api=api-backend-deactivation

The mixin

// database/schema.ts
import { deactivation } from '@voltro/plugin-deactivation'

export const users = table('users', {
  id: id(), email: text().unique(), name: text(),
})
  // adds deactivatedAt + deactivatedBy (→ actors); pulls audit() transitively.
  // NO defaultWhere — a deactivated user still shows up in queries.
  .with(deactivation())
  .reactive()

Deactivate with a normal update — deactivatedAt is just a column:

// users.deactivate.mutation.server.ts
export default async ({ id }, ctx) =>
  ctx.store.update('users', id, { deactivatedAt: new Date() })

Visible-but-locked-out

The point is what happens on a READ. After deactivation the row is STILL there and readable, just stamped:

ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"users.create","input":{"email":"ada@acme.com","name":"Ada"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')

curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d "{\"tag\":\"users.deactivate\",\"input\":{\"id\":\"$ID\"}}" >/dev/null

curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d "{\"tag\":\"users.get\",\"input\":{\"id\":\"$ID\"}}"
# → { ok:true, result:{ …, deactivatedAt:"2026-…" } }   (NOT null → still visible)

With softDelete() instead, that users.get would return null — the row would be hidden.

deactivation vs softDelete

You want… Use
Hide + anonymise a row (GDPR, "delete my account") softDelete()
Keep the row visible but mark the subject as locked out deactivation() (this)

Compose the full lifecycle: users.with(audit(), softDelete(), deactivation()).

Anti-patterns

  • Expecting deactivation to hide the row. It doesn't — that's softDelete(). If a deactivated user must disappear from a list, filter on deactivatedAt IS NULL in that query yourself.
  • Forgetting to enforce it in auth. The mixin only stamps the column; YOUR auth resolver must refuse a subject whose deactivatedAt is set. The data layer keeps the row queryable on purpose.