Skip to content

createEventStore

createEventStore is the central orchestrator of the library. It connects your aggregate roots to the underlying storage, manages event distribution to projections, process managers, and event listeners, and handles initialization and replay.

import { createEventStore } from '@requence/event-sourcing'
function createEventStore<Root extends AnyAggregateRoot>(
params: EventStoreParams<Root>
): EventStore<Event, Root>

The function accepts a single configuration object:

ParameterTypeRequiredDescription
loadEventsLoadEventsAsync generator that loads events from the underlying storage.
appendEventsAppendEventsFunction that persists new events and returns them with assigned positions.
aggregateRootsRoot[]Array of aggregate root instances to register.
checkpointCheckpointMethodsMethods for persisting projection/process manager checkpoints.
lockLockCreatorCustom locking strategy for aggregate root streams. Defaults to an in-memory lock with a 5-second TTL. Use redisLock from @requence/event-sourcing/redis for distributed deployments.
aggregateRootSnapshotAggregateRootSnapshotMethodsEnables snapshot support for aggregate roots.
projectionSnapshotProjectionSnapshotMethodsEnables snapshot support for projections.
postProcessEvent(event, aggregateRoot) => WrappedEventHook to mutate or decorate events before they are appended.
onProjectionReplayOnProgressCallbacks for tracking projection replay progress.
onProcessManagerRefreshOnProgressCallbacks for tracking process manager refresh progress.
autoInitbooleanWhen set to false, the event store will not initialize automatically. You must call .init() manually. Defaults to true.

Aggregate root streams are automatically locked before any command is executed. This prevents concurrent modifications to the same stream and eliminates event stream version collisions.

By default, the library uses an in-memory mutex (via async-mutex) with a 5-second TTL. This is suitable for single-process deployments.

For multi-instance deployments, pass redisLock as the lock parameter to coordinate across all instances via Redis:

import { createEventStore } from '@requence/event-sourcing/drizzle'
import { redisLock } from '@requence/event-sourcing/redis'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
const eventStore = createEventStore({
database: db,
aggregateRoots: [user, project],
lock: redisLock(redis), // distributed lock via Redis
})
ParameterTypeDefaultDescription
clientRedis (ioredis)An ioredis client instance.
defaultTtlnumber5000Lock time-to-live in milliseconds.

The implementation uses atomic SET NX PX for acquisition and Lua scripts for safe extend/release, preventing split-brain issues.

The lock and the storage adapter’s version check are two layers of the same story: the lock avoids the race by serializing writers, and the version check catches it if a race slips through (raising a ConcurrencyError). A race can still slip through when a lock’s TTL lapses mid-command, or in single-instance deployments where no distributed lock is configured.

For those residual cases, opt in to recovery at the call site with settled({ maxRetries }): the stream is reloaded and the commands re-applied against the current version instead of the error surfacing.

await settingsRoot.loadStream('global').update({ theme: 'dark' }).settled({
maxRetries: 5,
})

Retry is opt-in per call (default 0) rather than a global setting, because it is only safe for pure command handlers and some flows — such as a fixed-id newStream(...) used for idempotent create — deliberately rely on the ConcurrencyError surfacing.

Inside a process manager or a transaction, settled() cannot be called — the surrounding scope settles dispatched streams automatically when it completes. Declare the retry defaults on that scope instead: createProcessManager(name, { settled }) or transaction(handler, { settled }).

redisLock makes the write path safe to scale: any number of instances can accept commands, because stream appends are guarded by both the distributed lock and the storage adapter’s optimistic-concurrency check (a ConcurrencyError is raised if two instances race on the same stream version).

The read/reaction path works differently, and it is important to understand how events reach your handlers when scaling:

  • Events are delivered to projections, process managers, and event listeners only on the instance that appended them. There is no built-in cross-instance event bus. On startup, each instance also catches up from its checkpoint, but after that it only sees events it writes itself.
  • Because each event is therefore processed by exactly one instance, projections and listeners that write to a shared store (a SQL read model, Redis, a search index) or perform idempotent side effects stay consistent — every event is applied exactly once, with no duplication.

Given that model, the recommended topology is:

ComponentSafe to run on N instances?Notes
Command / write handlers✅ YesUse redisLock. Concurrency is enforced by the lock + storage adapter.
Projections → shared read model✅ YesMake writes idempotent (upsert by id). The checkpoint cursor is shared and advances monotonically.
Event listeners (side effects)✅ YesEach event fires its side effect once, on the writing instance.
Projections holding in-memory state⚠️ NoOnly the writing instance’s copy updates. Back the projection with a shared store instead.
Stateful process managers (.withState())⚠️ With careState is folded into a shared checkpoint under optimistic concurrency (see below).
Push delivery (WebSocket/SSE) to connected clients⚠️ Needs a busUse onEventsAppended to fan out to a message broker; the writing instance only reaches its own clients.

Stateful process managers across instances

Section titled “Stateful process managers across instances”

Process-manager state lives in the shared checkpoint and is reloaded at the start of every processing session. When two instances fold events into the same process manager concurrently, writes use optimistic concurrency: if another instance advanced the checkpoint in the meantime, the losing write is rejected, the latest state is reloaded, and the event is re-folded on top of it. This prevents lost updates without a lock.

Returns an EventStore object with the following methods:

Creates and registers a new Projection with the given name. Each name must be unique within the event store.

const userList = eventStore.createProjection('user-list')
.withEventHandlers({
async onUserCreated({ streamId, payload }) { /* ... */ }
})

Creates and registers a new Process Manager with the given name.

const cascadeDelete = eventStore.createProcessManager('cascade-delete')
.withEventHandlers({
async onOrganizationDeleted({ payload }) { /* ... */ }
})

Streams that handlers dispatch commands on are settled automatically when the handler completes (explicit settled() calls are not allowed inside a process manager). The optional second parameter declares defaults for those auto-settles:

// Unattended cascades: recover from residual concurrency races
// instead of surfacing them, up to 3 reload-and-re-apply attempts.
const cascadeDelete = eventStore.createProcessManager('cascade-delete', {
settled: { maxRetries: 3 },
})
OptionTypeDescription
settledSettledOptionsDefaults applied when the process manager auto-settles dispatched streams — see Retrying on concurrency conflicts.

Creates and registers a new Event Listener with the given name.

const auditLog = eventStore.createEventListener('audit-log')
.withEventHandlers({
async onUserCreated({ payload }) { /* ... */ }
})

Returns a registered aggregate root by its type name. Fully type-safe — the return type narrows based on the provided type string.

const userRoot = eventStore.getAggregateRoot('user')

Executes the given async handler inside a transaction. Events from all aggregate roots within the handler are batched and committed atomically.

await eventStore.transaction(async () => {
const org = orgRoot.newStream()
org.create({ name: 'Acme' })
const user = userRoot.newStream()
user.create({ name: 'Alice', orgId: org.streamId })
})

Streams dispatched inside the handler are settled automatically when the transaction completes (explicit settled() calls are not allowed there). The optional second parameter declares defaults for those auto-settles:

await eventStore.transaction(async () => {
// ...
}, { settled: { maxRetries: 3 } })
OptionTypeDescription
settledSettledOptionsDefaults applied when the transaction auto-settles dispatched streams — see Retrying on concurrency conflicts.

Manually initializes the event store. Required when autoInit is set to false. Returns the event store instance for chaining.

const store = createEventStore({ autoInit: false, /* ... */ })
await store.init()

Returns a promise that resolves when all projections and process managers have finished their initial catch-up hydration.

await eventStore.isReady()
// All projections and process managers are now up to date

Performs a full rebuild of all replayable projections and stateful process managers. This re-reads every event from the store and re-applies them from scratch.

await eventStore.rebuild()
import { createEventStore } from '@requence/event-sourcing'
import { createDrizzleAdapter } from '@requence/event-sourcing/drizzle'
import userRoot from './aggregates/user.ts'
import orgRoot from './aggregates/organization.ts'
const adapter = createDrizzleAdapter(db)
const eventStore = createEventStore({
loadEvents: adapter.loadEvents,
appendEvents: adapter.appendEvents,
checkpoint: adapter.checkpoint,
aggregateRoots: [userRoot, orgRoot],
})
await eventStore.isReady()

The onProjectionReplay and onProcessManagerRefresh parameters accept an object with the following optional callbacks:

type OnProgress = {
begin?: (name: string) => void
progress?: (name: string, index: number, event: BaseOutputEvent) => void
end?: (name: string) => void
}