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
- Order matters - History is returned in timestamp ascending order (oldest first)
- Use transforms - Transform events to store exactly what you need for history
- Keep payloads small - Only include data needed for audit/replay
- Index properly - Ensure
aggregateTypeandaggregateIdare indexed