Lawhive Framework
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

OptionTypeDescription
getContext() => TContext | undefinedGet current request context
writer(record, tx) => Promise<void>Write audit record to database
shouldAudit(ctx) => booleanOptional filter for which contexts to audit
excludeModelsstring[]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

  1. Always exclude AuditLog - Prevent recursive auditing
  2. Index appropriately - Add indexes for common query patterns
  3. Retain strategically - Archive old audits to separate storage
  4. Include context - Capture who, when, and why
  5. Use transactions - Ensure audit atomicity with business operations