@othree.io/chisel
v7.0.0
Published
Event sourcing made easy
Readme
@othree.io/chisel
A functional, dependency-injected event sourcing library for TypeScript. Chisel handles state loading with snapshot replay, command handling, event persistence, and event publishing — all composed from pure, curried functions that return Optional instead of throwing.
Install
npm install @othree.io/chiselPeer dependencies:
npm install @othree.io/optional @othree.io/cerilloQuick Start
import { actor, eventSource, configuration, ChiselEvent, TriggeredEvent, Command } from '@othree.io/chisel'
import { Optional } from '@othree.io/optional'
import { match } from '@othree.io/cerillo'
// 1. Define your aggregate root
type Counter = Readonly<{
value: number
}>
// 2. Define your domain event
type CounterEvent = Readonly<{
contextId: string
type: string
body: number
}>
// 3. Write a reducer
const reduce = (state: Counter, event: TriggeredEvent<CounterEvent>): Counter => {
switch (event.type) {
case 'Incremented':
return { value: state.value + event.body }
case 'Decremented':
return { value: state.value - event.body }
default:
return state
}
}
// 4. Wire up the actor
const config = configuration.getEnvVarConfiguration<Counter>({
getEnv: (key) => process.env[key],
}).get()
const load = eventSource.loadState<Counter, CounterEvent>({
configuration: config,
getEvents: async (contextId) => { /* fetch from your event store */ },
getInitialState: async () => Optional({ value: 0 }),
reduce,
match,
})
const calculate = eventSource.calculateNewState<Counter, CounterEvent>({
configuration: config,
newId: () => crypto.randomUUID(),
now: () => Date.now(),
persistEvent: async (event) => { /* save to your event store */ },
reduce,
})
const handleCommand = async (state: Counter, command: Command) => {
if (command.type === 'Increment' && 'body' in command) {
return Optional({
contextId: 'contextId' in command ? command.contextId : crypto.randomUUID(),
type: 'Incremented',
body: command.body,
})
}
return Optional<ChiselEvent>(undefined)
}
const handler = actor.handle<Counter>({
loadState: load,
handleCommand,
calculateNewState: calculate,
publishEvent: async (event, state) => { /* notify subscribers */ },
match,
})
// 5. Handle a command
const result = await handler({
type: 'Increment',
body: 1,
})
result.map(({ state, events }) => {
console.log(state) // { value: 1 }
console.log(events) // [{ type: 'Incremented', body: 1, ... }]
})API
configuration.getEnvVarConfiguration<AggregateRoot>(deps)
Creates an EventSourceConfiguration from environment variables.
const config = configuration.getEnvVarConfiguration<MyAggregate>({
getEnv: (key) => process.env[key],
})
// Returns Optional<EventSourceConfiguration<MyAggregate>>| Env Variable | Default | Description |
|---|---|---|
| CHISEL_SNAPSHOT_EVENT_TYPE | $SNAPSHOT$ | Event type used for snapshot events |
| CHISEL_SNAPSHOT_FREQUENCY | 100 | Number of events between snapshots |
The default SnapshotSerializer uses JSON.stringify / JSON.parse.
eventSource.loadState<AggregateRoot, Event>(deps)
Returns a LoadState function that reconstructs the current aggregate state by replaying events from the last snapshot.
const load = eventSource.loadState<Counter, CounterEvent>({
configuration, // EventSourceConfiguration<AggregateRoot>
getEvents, // (contextId: string) => Promise<Optional<Array<InternalTriggeredEvent>>>
getInitialState, // (contextId: Optional<string>) => Promise<Optional<AggregateRoot>>
reduce, // (state, event) => state
match, // from @othree.io/cerillo
})
// Returns: (contextId: Optional<string>) => Promise<Optional<State<AggregateRoot>>>eventSource.calculateNewState<AggregateRoot, Event>(deps)
Returns a CalculateNewState function that persists new events, applies the reducer, and automatically creates snapshots when SnapshotFrequency is reached.
const calculate = eventSource.calculateNewState<Counter, CounterEvent>({
configuration, // EventSourceConfiguration<AggregateRoot>
newId, // () => string
now, // () => number
persistEvent, // (event) => Promise<Optional<InternalTriggeredEvent>>
reduce, // (state, event) => state
})
// Returns: (command, state, newEvents) => Promise<Optional<NewState<AggregateRoot>>>actor.handle<AggregateRoot>(deps)
Returns a Handle function that orchestrates the full command lifecycle: load state, handle command, calculate new state, and publish events.
const handler = actor.handle<Counter>({
loadState, // LoadState<AggregateRoot>
handleCommand, // (state, command) => Promise<Optional<ChiselEvent | Array<ChiselEvent>>>
calculateNewState, // CalculateNewState<AggregateRoot>
publishEvent, // (event, state) => Promise<Optional<boolean>>
match, // from @othree.io/cerillo
})
// Returns: (command: Command) => Promise<Optional<CommandResult<AggregateRoot>>>Types
Commands
Commands come in three variants, unified as Command:
| Type | Fields | Use case |
|---|---|---|
| InitialCommand | type, body | Creating a new aggregate (no contextId yet) |
| EmptyCommand | contextId, type | Commands that carry no payload |
| FullCommand | contextId, type, body | Standard commands with payload |
All commands optionally accept emitter and metadata.
Events
| Type | Description |
|---|---|
| ChiselEvent | Base event shape: contextId, type, body, snapshot? |
| InternalTriggeredEvent | Persisted event with eventId, eventDate, emitter?, metadata? |
| TriggeredEvent<T> | Your domain event T merged with internal event fields |
State
| Type | Description |
|---|---|
| State<AggregateRoot> | Current aggregate state and its events |
| NewState<AggregateRoot> | Same as State plus newEvents produced by the current command |
| CommandResult<AggregateRoot> | Final result: state and the events that were published |
Snapshots
Chisel automatically manages snapshots to avoid replaying the full event history:
- After every
SnapshotFrequencyevents, a snapshot event is persisted containing the serialized aggregate state - On load, the most recent snapshot is found and only events after it are replayed
- Snapshot serialization is configurable via
EventSourceConfiguration.SnapshotSerializer
Peer Dependencies
| Package | Description |
|---|---|
| @othree.io/optional | Optional/Maybe monad for null-safe chaining |
| @othree.io/cerillo | Functional pattern matching |
License
ISC
