Lawhive Framework
FrameworkEvents

History API

Query and replay typed event history for aggregates

The History API allows you to query the complete event history for an aggregate with full type safety.

Overview

Every sub-router in the event client has a $history method that returns all events for an aggregate:

const history = await eventClient.tasks.$history({ id: "task_123" })

for (const event of history) {
  console.log(event.key, event.payload, event.timestamp)
}

Type Safety

History items are typed as a discriminated union of all possible events:

const history = await eventClient.tasks.$history({ id: "task_123" })
// Type: Array<
//   | { key: "tasks.created"; payload: { id: string; name: string }; ... }
//   | { key: "tasks.renamed"; payload: { id: string; oldName: string; newName: string }; ... }
//   | { key: "tasks.deleted"; payload: { id: string }; ... }
// >

for (const event of history) {
  switch (event.key) {
    case "tasks.created":
      console.log(`Created: ${event.payload.name}`)
      break
    case "tasks.renamed":
      console.log(`Renamed: ${event.payload.oldName} → ${event.payload.newName}`)
      break
    case "tasks.deleted":
      console.log(`Deleted`)
      break
  }
}

Configuring History Storage

Provide a historyStorage implementation when creating the client:

const eventClient = createEventClient(events, {
  adapters: [eventStoreAdapter],
  createContext: async (fn) => prisma.$transaction((tx) => fn({ tx })),
  historyStorage: {
    getByAggregate: async ({ type, id }) => {
      const events = await prisma.event.findMany({
        where: {
          aggregateType: type,
          aggregateId: id,
        },
        orderBy: { timestamp: "asc" },
      })
      return events.map((e) => ({
        id: e.id,
        timestamp: e.timestamp,
        key: e.key,
        payload: e.payload,
      }))
    },
  },
})

The storage interface:

type EventHistoryStorage = {
  getByAggregate: (opts: {
    type: string  // e.g., "tasks"
    id: string    // e.g., "task_123"
  }) => Promise<EventHistoryItem[]>
}

type EventHistoryItem = {
  id: string
  timestamp: Date
  key: string
  payload: unknown
}

Type Inference Utilities

Extract history types from your router:

import type {
  InferHistoryItems,
  InferNestedHistoryItems,
  InferRouterHistoryItems,
} from "@lawhive/framework"

// Single router's history items
type TaskHistory = InferHistoryItems<typeof taskEvents, "tasks.">

// Nested router history
type TaskHistoryFromRoot = InferNestedHistoryItems<typeof events._def.record, "tasks">

// All history items per aggregate
type AllHistory = InferRouterHistoryItems<typeof events>
// { tasks: TaskHistoryItem; users: UserHistoryItem }

Building Timelines

Use history to build UI timelines:

const TaskTimeline = async ({ taskId }: { taskId: string }) => {
  const history = await eventClient.tasks.$history({ id: taskId })

  return (
    <ul>
      {history.map((event) => (
        <li key={event.id}>
          <time>{event.timestamp.toISOString()}</time>
          <span>{formatEvent(event)}</span>
        </li>
      ))}
    </ul>
  )
}

const formatEvent = (event: TaskHistoryItem) => {
  switch (event.key) {
    case "tasks.created":
      return `Task "${event.payload.name}" created`
    case "tasks.renamed":
      return `Renamed from "${event.payload.oldName}" to "${event.payload.newName}"`
    case "tasks.deleted":
      return "Task deleted"
  }
}

Event Replay

Reconstruct state by replaying events:

type TaskState = {
  id: string
  name: string
  status: "active" | "deleted"
}

const replayTaskState = async (taskId: string): Promise<TaskState | null> => {
  const history = await eventClient.tasks.$history({ id: taskId })

  if (history.length === 0) return null

  let state: TaskState | null = null

  for (const event of history) {
    switch (event.key) {
      case "tasks.created":
        state = {
          id: event.payload.id,
          name: event.payload.name,
          status: "active",
        }
        break
      case "tasks.renamed":
        if (state) state.name = event.payload.newName
        break
      case "tasks.deleted":
        if (state) state.status = "deleted"
        break
    }
  }

  return state
}

Best Practices

  1. Order matters - History is returned in timestamp ascending order (oldest first)
  2. Use transforms - Transform events to store exactly what you need for history
  3. Keep payloads small - Only include data needed for audit/replay
  4. Index properly - Ensure aggregateType and aggregateId are indexed