Audit
The answer to "who did what, when". There are no Django-style signals on this stack —
mutations call recordAudit() explicitly. That is a feature: the audit trail is
visible in the code path that causes it, not hidden in a receiver three files away.
The write side
Live from the source:
export async function recordAudit(args: {
actorUserId: string | null;
actorName: string | null;
action: string;
entityType: string;
entityId?: number | null;
projectId?: number | null;
summary: string;
meta?: Record<string, unknown> | null;
}): Promise<void> {
try {
await db.insert(auditLog).values({
actorUserId: args.actorUserId,
actorName: args.actorName,
action: args.action,
entityType: args.entityType,
entityId: args.entityId ?? null,
projectId: args.projectId ?? null,
summary: args.summary,
meta: args.meta ?? null,
});
} catch {
// Best-effort: never let history-keeping fail real work.
}
}Design decisions baked in:
- History outlives its subjects.
actorNameis denormalized (survives user deletion);projectIdis a plain integer scope with no foreign key (survives deletion of the thing it scopes to). Deleting a project does not erase the history of what happened in it. - Best-effort by contract. A failed history write must never fail the real work — the catch swallows, deliberately.
The read side
listAudit({ projectId?, limit? }) returns wire-friendly rows with a live-name
fallback (current user name when the account still exists, the denormalized snapshot
otherwise). The example renders it project-scoped for project admins and globally in
/admin/audit.
Conventions
- Action keys are dotted:
task.create,task.status,admin.users.deactivate,workspace.create— stable, greppable, filterable. - Summaries are for humans. One readable sentence per row; structured details go
in
meta. - Every admin-surface mutation records a row — bulk operations record one row with
the count and the affected ids in
meta.