19 KiB
Capability Spec Delta: event-sourcing (event-sourcing-completion)
Package: @suaveplan/event-sourcing
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Transactional outbox pattern
The package SHALL expose an EventOutbox interface and a MemoryEventOutbox reference implementation that lets callers persist domain events alongside aggregate writes and forward them to external consumers in a separate, retry-safe step. The interface SHALL define append(events), relay(publisher), pendingCount(), failedCount(), and cleanup(beforeIso). Outbox entries SHALL preserve insertion order, carry a per-row attempts counter, and transition through pending → published (success) or pending → failed (retry exhaustion).
Scenario: append preserves insertion order
- WHEN three events are appended via
outbox.append([e1, e2, e3]) - THEN a subsequent
relay(publisher)invokespublisherwith the events in the same[e1, e2, e3]order
Scenario: relay marks rows published on success
- WHEN
relayis called and the suppliedpublisherresolves - THEN every drained row transitions from
pendingtopublishedANDrelay()returns the count of relayed events ANDpendingCount()decreases by that count
Scenario: relay increments attempts on publisher failure
- WHEN
relayis called and the publisher rejects - THEN every drained row's
attemptscounter increments AND rows below the retry cap remainpendingAND rows at the retry cap transition tofailedANDrelay()returns0
Scenario: invalid OutboxConfig raises OUTBOX_TABLE_INIT_FAILED
- WHEN an outbox is constructed with a non-Zod-conformant
OutboxConfig(e.g. negativebatchSize) - THEN the constructor throws
EventSourcingErrorwith codeES.OUTBOX_TABLE_INIT_FAILEDand the offending field incontext.field
Verified by: packages/events/event-sourcing/src/outbox/event-outbox.test.ts::"preserves insertion order across append and relay" AND packages/events/event-sourcing/src/outbox/event-outbox.test.ts::"marks rows published on successful relay" AND packages/events/event-sourcing/src/outbox/event-outbox.test.ts::"increments attempts on publisher failure" AND packages/events/event-sourcing/src/outbox/event-outbox.spec-conformance.test.ts::"raises OUTBOX_TABLE_INIT_FAILED on invalid config"
Requirement: Outbox relay worker with cancellation
The package SHALL expose an OutboxRelay worker class that drains pending outbox rows on a configurable interval (default 1000 ms) using an injectable Clock from @suaveplan/types. The worker SHALL accept an AbortSignal; when the signal aborts mid-drain, the worker SHALL reject pending publishes with EventSourcingError(ES.OUTBOX_RELAY_INTERRUPTED) and stop scheduling further iterations.
Scenario: AbortSignal terminates the relay loop
- WHEN an
OutboxRelayis started with anAbortController's signal andcontroller.abort()fires mid-drain - THEN the active
relay()call rejects withEventSourcingError(ES.OUTBOX_RELAY_INTERRUPTED)AND no furtherClock.schedulecalls are made
Scenario: injected Clock controls poll interval
- WHEN an
OutboxRelayis constructed with a fakeClockadvancing 100 ms per tick andpollIntervalMs: 500 - THEN the relay loop fires once every five fake ticks AND OTel counter
outbox.relayedincrements byevents.lengthper successful drain
Verified by: packages/events/event-sourcing/src/outbox/relay.test.ts::"rejects pending publish with OUTBOX_RELAY_INTERRUPTED when signal aborts" AND packages/events/event-sourcing/src/outbox/relay.test.ts::"polls on the injected Clock interval"
Requirement: Database-backed event store with optimistic concurrency
The package SHALL expose a DatabaseEventStore<TEvent extends DomainEvent> class that satisfies the EventStore interface and persists events through a DatabaseAdapter from @suaveplan/db. Every append(aggregateId, events, expectedVersion) SHALL run inside adapter.transaction(); if the current max version does not match expectedVersion, the store SHALL raise EventSourcingError(ES.CONCURRENCY_CONFLICT) and roll back the transaction. The events table SHALL be auto-created when autoCreate: true (default).
Scenario: optimistic-concurrency conflict raises CONCURRENCY_CONFLICT
- WHEN
append("agg-1", events, expectedVersion: 0)is called and the stored stream is already at version 2 - THEN the call rejects with
EventSourcingError(ES.CONCURRENCY_CONFLICT)ANDcontextcontains{ aggregateId: "agg-1", expectedVersion: 0, currentVersion: 2 }AND no rows are inserted
Scenario: append + load round-trip
- WHEN events are appended successfully and
getEvents("agg-1")is called - THEN the returned array preserves insertion order AND each event's
payloadandmetadataround-trip through the configured serializer
Scenario: getEventsByType honours limit
- WHEN
getEventsByType("OrderPlaced", limit: 5)is called - THEN at most 5 events are returned AND they are ordered by
created_atascending
Scenario: table auto-create gated on autoCreate flag
- WHEN the store is constructed with
autoCreate: falseand the underlying table does not exist - THEN
appendrejects withEventSourcingError(ES.DATABASE_TABLE_INIT_FAILED)rather than silently creating the table
Verified by: packages/events/event-sourcing/src/store/database-event-store.test.ts::"raises CONCURRENCY_CONFLICT when expectedVersion does not match" AND packages/events/event-sourcing/src/store/database-event-store.test.ts::"appends and loads in stream order" AND packages/events/event-sourcing/src/store/database-event-store.test.ts::"limits getEventsByType results" AND packages/events/event-sourcing/src/store/database-event-store.spec-conformance.test.ts::"gates table creation on autoCreate flag"
Requirement: Database-backed snapshot store
The package SHALL expose a DatabaseSnapshotStore<TState> class that persists the latest snapshot per aggregate via a DatabaseAdapter. The store SHALL accept optional serializer / deserializer config so non-JSON state types round-trip. The shouldSnapshot(eventsSinceLastSnapshot) method SHALL return true when eventsSinceLastSnapshot >= interval.
Scenario: latest snapshot per aggregate
- WHEN two snapshots are saved for
aggregateId: "agg-1"(versions 5 and 10) - THEN
load("agg-1")returns the version-10 snapshot AND a third call tosaveupdates rather than inserts
Scenario: serializer round-trips non-JSON state
- WHEN the store is configured with a
MessagePackserializer / deserializer pair and aMap-typed state is saved - THEN
load(aggregateId)returns aMapwith the same entries
Scenario: delete returns false for unknown aggregate
- WHEN
delete("never-saved")is called - THEN the result is
falseAND no SQL DELETE is dispatched
Verified by: packages/events/event-sourcing/src/store/database-snapshot-store.test.ts::"returns the latest snapshot per aggregate" AND packages/events/event-sourcing/src/store/database-snapshot-store.test.ts::"round-trips non-JSON state via injected serializer" AND packages/events/event-sourcing/src/store/database-snapshot-store.test.ts::"returns false when deleting an unknown aggregate"
Requirement: Event replayer with progress / pause / resume / cancel
The package SHALL expose an EventReplayer class that replays events from an EventStore through a handler with batch processing, progress callbacks, pause / resume / cancel control, and AbortSignal support. ReplayFilter SHALL narrow by eventTypes, aggregateId, fromDate, toDate. ReplayProgress SHALL expose total, processed, percentage, paused, completed. The terminal onProgress tick SHALL carry completed: true.
Scenario: replay invokes handler in stream order
- WHEN
replay("agg-1", handler)is called against a stream of 100 events - THEN
handleris invoked exactly 100 times in stream order AND the finalonProgresstick hasprocessed: 100, percentage: 100, completed: true
Scenario: filter narrows by eventTypes
- WHEN
replay("agg-1", handler, { eventTypes: ["OrderPlaced"] })is called against a stream that mixesOrderPlacedandOrderShipped - THEN
handleris invoked only forOrderPlacedevents ANDtotalreflects the filtered count
Scenario: pause halts processing until resume
- WHEN
pause()is called mid-replay andresume()is called 50 ms later - THEN the handler stops being invoked between
pauseandresumeAND apaused: trueprogress tick is emitted at the pause boundary
Scenario: AbortSignal mid-batch rejects with REPLAY_CANCELLED
- WHEN
replay("agg-1", handler, undefined, signal)is in flight and the signal aborts - THEN the call rejects with
EventSourcingError(ES.REPLAY_CANCELLED)AND in-flight handler invocations are not awaited past the abort point
Scenario: invalid filter rejected up front
- WHEN
replay("agg-1", handler, { fromDate: "not-an-iso-date" })is called - THEN the call rejects with
EventSourcingError(ES.REPLAY_FILTER_INVALID)andcontext.fromDateis the offending value
Verified by: packages/events/event-sourcing/src/replay/event-replayer.test.ts::"invokes handler in stream order across batches" AND packages/events/event-sourcing/src/replay/event-replayer.test.ts::"narrows replayed events by eventTypes filter" AND packages/events/event-sourcing/src/replay/event-replayer.test.ts::"halts processing between pause and resume" AND packages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts::"rejects with REPLAY_CANCELLED when signal aborts mid-batch" AND packages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts::"rejects invalid filter with REPLAY_FILTER_INVALID"
Requirement: Event bus with subscription filter and dead-letter sink
The package SHALL expose an EventBus class implementing typed publish/subscribe over DomainEvent. subscribe(handler, options?) SHALL accept a SubscriptionFilter (eventTypes, aggregateIds, aggregateType) and an optional group string; multiple subscribers in the same group receive events in round-robin order, while ungrouped subscribers all receive every matching event. Handler exceptions SHALL be routed to a DeadLetterSink with the original cause preserved on EventSourcingError(ES.EVENT_HANDLER_FAILED). Subscription IDs SHALL come from an injectable IdGenerator so tests can pin them.
Scenario: subscribe and publish round-trip
- WHEN a handler subscribes without a filter and an event is published
- THEN the handler is invoked exactly once with the published event
Scenario: filter narrows by aggregateType
- WHEN a subscriber's filter sets
aggregateType: "Order"and anAccountevent is published - THEN the handler is not invoked AND
subscriberCountis unchanged
Scenario: group round-robin distribution
- WHEN three subscribers join group
"workers"and four events are published - THEN subscriber A handles events 1 and 4, subscriber B handles event 2, subscriber C handles event 3
Scenario: handler exception routes to DeadLetterSink
- WHEN a subscribed handler throws synchronously and a
MemoryDeadLetterSinkis configured - THEN the sink captures the failed event AND the captured entry's
cause.code === ES.EVENT_HANDLER_FAILEDANDcause.context.subscriptionIdmatches the failing subscription
Scenario: unsubscribe removes record
- WHEN
subscription.unsubscribe()is called - THEN
subscription.active === falseANDsubscriberCountdecreases by 1 AND subsequent publishes do not invoke the handler
Verified by: packages/events/event-sourcing/src/subscription/event-bus.test.ts::"invokes handler exactly once on publish without filter" AND packages/events/event-sourcing/src/subscription/event-bus.test.ts::"narrows delivery by aggregateType filter" AND packages/events/event-sourcing/src/subscription/event-bus.test.ts::"distributes events round-robin within a group" AND packages/events/event-sourcing/src/subscription/event-bus.spec-conformance.test.ts::"routes handler exceptions to DeadLetterSink with EVENT_HANDLER_FAILED cause" AND packages/events/event-sourcing/src/subscription/event-bus.test.ts::"removes record when unsubscribe is called" AND packages/events/event-sourcing/src/subscription/dead-letter.test.ts::"captures failed events in insertion order"
Requirement: Snapshot manager with reducer and pruning
The package SHALL expose a SnapshotManager<TState> class that automates snapshot creation at a configurable interval and prunes per-aggregate history to maxSnapshots. load(aggregateId, initialState, reducer) SHALL fetch the latest snapshot, replay tail events through reducer, and return an AggregateLoadResult<TState> carrying state, version, restoredFromSnapshot, eventsReplayed. takenAt timestamps SHALL come from an injectable Clock.
Scenario: load rebuilds state from snapshot plus tail events
- WHEN a snapshot exists at version 10 and 3 newer events are in the store
- THEN
load(...)invokes the reducer 3 times (not 13) ANDresult.eventsReplayed === 3ANDresult.restoredFromSnapshot === true
Scenario: auto-create snapshot at configured interval
- WHEN
loadreplays >=intervalevents past the last snapshot - THEN
SnapshotManagercallssnapshotStore.savewith the rebuilt state AND asnapshot.createdlifecycle event fires ANDgetSnapshotHistory(aggregateId)includes the new entry
Scenario: pruning enforces maxSnapshots
- WHEN snapshots
maxSnapshots + 2exist for one aggregate - THEN the oldest 2 are pruned from the in-memory history AND
getSnapshotHistory(aggregateId).length === maxSnapshots
Scenario: reducer exception raises SNAPSHOT_HANDLER_FAILED
- WHEN the supplied
reducerthrows while replaying tail events - THEN
loadrejects withEventSourcingError(ES.SNAPSHOT_HANDLER_FAILED)ANDcauseis the original exception
Scenario: injected Clock stamps takenAt deterministically
- WHEN a fake
Clockreturning a fixed instant is injected and a snapshot is created - THEN the saved snapshot's
takenAtequals the fake instant
Verified by: packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts::"rebuilds state from snapshot plus tail events" AND packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts::"auto-creates snapshot at the configured interval" AND packages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts::"prunes per-aggregate history to maxSnapshots" AND packages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts::"raises SNAPSHOT_HANDLER_FAILED when reducer throws" AND packages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts::"stamps takenAt from the injected Clock"
Requirement: Versioned upcaster chain with schema-version detection
The package SHALL expose an UpcasterChain class that holds an ordered registry of EventUpcaster entries and applies them in fromVersion order during upcast(event). detectSchemaVersion(event) SHALL read metadata.schemaVersion, defaulting to 1 when absent. createUpcastedEvent and createRenamedEvent SHALL stamp schemaVersion: targetVersion on the returned event's metadata. createFieldRenameUpcaster, createDefaultFieldUpcaster, and createRemoveFieldUpcaster SHALL produce upcasters that handle a single (eventType, fromVersion) step. Duplicate (eventType, fromVersion) registrations SHALL raise EventSourcingError(ES.UPCASTER_DUPLICATE).
Scenario: chain walks v1 to v3 in order
- WHEN upcasters for
(OrderPlaced, 1→2)and(OrderPlaced, 2→3)are registered out of order - THEN
chain.upcast(v1Event)returns a v3 event AND the upcasters fire infromVersionorder regardless of registration order
Scenario: detectSchemaVersion defaults to 1
- WHEN an event has no
metadata.schemaVersion - THEN
detectSchemaVersion(event)returns1
Scenario: getUpcastersForType filters by eventType
- WHEN the chain holds upcasters for
OrderPlacedandOrderShipped - THEN
getUpcastersForType("OrderPlaced")returns only theOrderPlacedentries infromVersionorder
Scenario: createFieldRenameUpcaster round-trip
- WHEN
createFieldRenameUpcaster("OrderPlaced", 1, 2, { customer: "customerId" })is registered and applied to a v1 payload{ customer: "c-1", total: 100 } - THEN the upcasted payload is
{ customerId: "c-1", total: 100 }ANDmetadata.schemaVersion === 2
Scenario: duplicate registration rejected
- WHEN two upcasters are registered with the same
(eventType, fromVersion)pair - THEN the second
register()call raisesEventSourcingError(ES.UPCASTER_DUPLICATE)and the chain remains at one entry for that key
Verified by: packages/events/event-sourcing/src/upcasting/chain.test.ts::"walks the chain in fromVersion order regardless of registration order" AND packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts::"detectSchemaVersion defaults to 1 when metadata is absent" AND packages/events/event-sourcing/src/upcasting/chain.test.ts::"filters upcasters by eventType" AND packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts::"createFieldRenameUpcaster renames the field and stamps schemaVersion" AND packages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts::"raises UPCASTER_DUPLICATE on collision"
Requirement: Backwards-compatible flat upcaster API delegates to UpcasterChain
The package SHALL preserve the existing flat registerUpcaster(options), clearUpcasters(), and upcastEvents(events) exports. These functions SHALL delegate to a single module-scoped UpcasterChain instance so consumers calling either API observe the same registry.
Scenario: flat register and class chain see the same upcasters
- WHEN
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 1, toVersion: 2, upcast })is called - THEN the module-scoped
UpcasterChain.getUpcasters()includes that entry AND a subsequentupcastEvents([v1Event])invocation produces the same v2 result aschain.upcastAll([v1Event])
Scenario: clearUpcasters resets the shared chain
- WHEN
clearUpcasters()is called - THEN both
upcastEvents([anyEvent])andchain.getUpcasters()reflect an empty registry
Verified by: packages/events/event-sourcing/src/upcasting/chain.test.ts::"flat registerUpcaster delegates to the module chain" AND packages/events/event-sourcing/src/upcasting/chain.test.ts::"clearUpcasters empties the shared chain"