Deactivation
A schema mixin that marks a subject as deactivated (can't log in) while keeping its data fully visible — the deliberate opposite of soft-delete.
@voltro/plugin-deactivation ships a single surface: the deactivation() schema mixin. A deactivated subject — typically a user who can no longer log in — has its data stay fully visible (assignments, history, audit trail). That's the deliberate opposite of softDelete(), which hides the row from default reads and anonymises PII for GDPR.
Status: ✓ shipped.
What it adds
The mixin contributes two columns:
| Column | Type | Meaning |
|---|---|---|
deactivatedAt |
nullable timestamp | set ⇒ the subject is deactivated; null ⇒ active |
deactivatedBy |
nullable reference → Actor | who performed the deactivation |
It's a pure schema mixin (columns only), exactly like audit() — no read scoping, no delete interception. You set deactivatedAt with a normal ctx.store.update(...).
deactivation() transitively requires audit() (a deactivation is an audit-worthy event, and the timestamps sit together on the row), so audit() auto-stamps updatedAt / updatedBy alongside — the WHO/WHEN of the change is captured for free. Its stable id is voltro/deactivation.
Composing it
import { id, table, text } from '@voltro/database'
import { deactivation } from '@voltro/plugin-deactivation'
export const users = table('users', {
id: id(),
email: text().unique(),
name: text(),
}).with(deactivation())The dep resolver dedupes, so listing audit() explicitly alongside is harmless:
import { audit } from '@voltro/plugin-audit/mixin'
import { deactivation } from '@voltro/plugin-deactivation'
table('users', { /* … */ }).with(audit(), deactivation())A project that doesn't want the barrel can import from the ./mixin subpath:
import { deactivation } from '@voltro/plugin-deactivation/mixin'Deactivating + reactivating
Both are plain updates — audit() records the actor and time:
// deactivate — update(table, primaryKey, patch)
await ctx.store.update('users', userId, {
deactivatedAt: new Date(),
deactivatedBy: ctx.request.subject.id,
})
// reactivate
await ctx.store.update('users', userId, {
deactivatedAt: null,
deactivatedBy: null,
})Because the row is never hidden, your own queries decide what "active" means — the user stays visible everywhere in the app while their login is refused.
Enforcing "a deactivated user can't log in"
The mixin's promise is self-enforcing through @voltro/plugin-auth's post-authentication subject-guard seam — you do not hand-roll a deactivatedAt === null check in an auth resolver. Import deactivationGuard() from the /guard subpath and wire it onto the auth plugin in one line:
import { authRoutesPlugin } from '@voltro/plugin-auth/plugin'
import { deactivationGuard } from '@voltro/plugin-deactivation/guard'
authRoutesPlugin({
store,
secret: process.env.VOLTRO_SESSION_SECRET!,
defaultTenantId: 'acme',
subjectGuards: [deactivationGuard()],
})The auth pipeline runs each subject guard after the credential check (password / MFA / magic-link / passkey) but before it issues a session. When the resolved user's deactivatedAt is set, deactivationGuard() vetoes: the login returns a 403 whose body error is account_deactivated, and no session cookie is issued — on every sign-in path. Reactivate the user (deactivatedAt = null) and the next login proceeds normally. See the auth plugin's subject-guard seam.
AccountDeactivated — a Schema.TaggedError on the same /guard subpath — is the typed form of the rejection for code that prefers matching on _tag === 'AccountDeactivated' over the wire body. The /guard subpath is server-only and is not imported by the mixin, so a *.entity.ts importing deactivation() never drags the auth surface into the browser bundle.
Deactivation vs soft-delete
deactivation() |
softDelete() |
|
|---|---|---|
| Row visible in default reads | yes | no (hidden) |
| PII | untouched | anonymised |
| Typical use | user can't log in, data stays | GDPR erasure / "delete" |
| Read scoping | none | filters hidden rows out |
The two are orthogonal and compose. A user can be deactivated (visible) and later soft-deleted (hidden) — apply both mixins.
When to use which
- Deactivation — revoke access but keep the subject's contributions intact and attributable (the common "disable an employee account" case).
- Soft-delete — the user must disappear from the app and their PII must be scrubbed.
- Both — start with deactivation, escalate to soft-delete if an erasure request arrives.