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-deactivationThe 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 ondeactivatedAt IS NULLin that query yourself. - Forgetting to enforce it in auth. The mixin only stamps the column; YOUR auth resolver must refuse a subject whose
deactivatedAtis set. The data layer keeps the row queryable on purpose.