FrameworkPrisma
Audit Trail
Automatic mutation logging with before/after state and diffs
The audit extension automatically logs all create, update, and delete operations with full before/after state, efficient diffs, and context awareness.
Installation
import { PrismaClient } from "@prisma/client"
import { createAuditedPrismaClient, getAuditContext } from "@lawhive/framework"
const basePrisma = new PrismaClient()
const prisma = createAuditedPrismaClient(basePrisma, {
getContext: getAuditContext,
excludeModels: ["AuditLog"],
writer: async (record, tx) => {
await tx.auditLog.create({
data: {
entity: record.entity,
entityId: record.entityId,
operation: record.operation,
before: record.before,
after: record.after,
diff: record.diff,
actorId: record.context.actorId ?? null,
requestId: record.context.requestId,
source: record.context.source,
},
})
},
})Schema Setup
Create an audit log table:
model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now())
entity String // e.g., "Task"
entityId String // e.g., "task_123"
operation String // "create" | "update" | "delete"
before Json? // State before mutation
after Json? // State after mutation
diff Json? // Detailed diff for updates
// Context from your app
userId String?
requestId String?
@@index([entity, entityId])
@@index([userId])
}Configuration
Options
| Option | Type | Description |
|---|---|---|
getContext | () => TContext | undefined | Get current request context |
writer | (record, tx) => Promise<void> | Write audit record to database |
shouldAudit | (ctx) => boolean | Optional filter for which contexts to audit |
excludeModels | string[] | Models to skip auditing |
Context Provider
The framework provides built-in audit context management via AsyncLocalStorage:
import {
runWithAuditContext,
getAuditContext,
createAuditContext,
} from "@lawhive/framework"
// Wrap request handlers with audit context
const baseProcedure = procedure.use(async ({ context, next }) => {
return runWithAuditContext(
{ requestId: context.requestId, actorId: context.userId, source: "api" },
() => next({ context }),
)
})
// For background jobs or scripts
const jobContext = createAuditContext("background", {
actorId: "system",
})
await runWithAuditContext(jobContext, async () => {
await prisma.task.update({ where: { id }, data: { status: "completed" } })
})
// Read the current context
const ctx = getAuditContext()
// { requestId: "...", actorId: "...", source: "api" | "background" }Conditional Auditing
Skip auditing for certain contexts:
const prisma = createAuditedPrismaClient(basePrisma, {
getContext: getAuditContext,
shouldAudit: (ctx) => {
// Only audit API requests, not background jobs
return ctx.source === "api"
},
writer: auditWriter,
})Audit Records
Create Operations
await prisma.task.create({ data: { name: "My Task" } })
// Audit record:
{
entity: "Task",
entityId: "task_123",
operation: "create",
before: null,
after: { id: "task_123", name: "My Task", createdAt: "..." },
diff: undefined,
context: { requestId: "req_789", actorId: "user_456", source: "api" }
}Update Operations
Updates include efficient diffs showing only changed fields:
await prisma.task.update({
where: { id: "task_123" },
data: { name: "Updated Name", status: "completed" },
})
// Audit record:
{
entity: "Task",
entityId: "task_123",
operation: "update",
before: { name: "My Task", status: "pending" }, // Only changed fields
after: { name: "Updated Name", status: "completed" },
diff: [
{ type: "CHANGE", path: ["name"], oldValue: "My Task", value: "Updated Name" },
{ type: "CHANGE", path: ["status"], oldValue: "pending", value: "completed" }
],
context: { ... }
}Delete Operations
await prisma.task.delete({ where: { id: "task_123" } })
// Audit record:
{
entity: "Task",
entityId: "task_123",
operation: "delete",
before: { id: "task_123", name: "My Task", ... },
after: null,
diff: undefined,
context: { ... }
}Soft Delete Integration
When used with soft delete, the audit correctly logs as delete:
// With soft delete enabled, this sets deletedAt
await prisma.task.delete({ where: { id: "task_123" } })
// Audit record shows operation: "delete", not "update"
{
operation: "delete", // Correctly identified
before: { id: "task_123", name: "My Task", deletedAt: null },
after: null, // Logically deleted
}Transaction Support
Audits are written in the same transaction as mutations:
await prisma.$transaction(async (tx) => {
await tx.task.create({ data: { name: "Task 1" } })
await tx.task.create({ data: { name: "Task 2" } })
// Both audits written atomically
})If the transaction fails, no audit records are created.
Querying Audit Logs
Build audit history views:
// Get history for an entity
const history = await prisma.auditLog.findMany({
where: {
entity: "Task",
entityId: "task_123",
},
orderBy: { createdAt: "desc" },
})
// Get all changes by a user
const userChanges = await prisma.auditLog.findMany({
where: { userId: "user_456" },
orderBy: { createdAt: "desc" },
take: 100,
})
// Get recent deletes
const recentDeletes = await prisma.auditLog.findMany({
where: {
operation: "delete",
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
})Diff Format
The diff field uses the microdiff format:
type Difference =
| { type: "CREATE"; path: (string | number)[]; value: unknown }
| { type: "CHANGE"; path: (string | number)[]; oldValue: unknown; value: unknown }
| { type: "REMOVE"; path: (string | number)[]; oldValue: unknown }Parse diffs for display:
const formatDiff = (diff: Difference[]) => {
return diff.map((d) => {
const path = d.path.join(".")
switch (d.type) {
case "CREATE":
return `Added ${path}: ${d.value}`
case "CHANGE":
return `Changed ${path}: ${d.oldValue} → ${d.value}`
case "REMOVE":
return `Removed ${path}`
}
})
}Best Practices
- Always exclude AuditLog - Prevent recursive auditing
- Index appropriately - Add indexes for common query patterns
- Retain strategically - Archive old audits to separate storage
- Include context - Capture who, when, and why
- Use transactions - Ensure audit atomicity with business operations