20 KiB
Event-sourcing completion
Package: @suaveplan/event-sourcing
Status: PROPOSED
Type: spec-conformance + feature
Why
The 2026-05-02 cross-monorepo audit confirmed that @suaveplan/event-sourcing shipped on genesis with only the inner-loop pieces — Aggregate, aggregate-repository, MemoryEventStore, the Projection builder, and a flat registerUpcaster / upcastEvents pair. The legacy packages/universal/event-sourcing/src package on ../suaveplan/ carries six additional capabilities — EventOutbox, DatabaseEventStore, DatabaseSnapshotStore, EventReplayer with progress / pause / resume, a SubscriptionFilter + EventBus pair, a SnapshotManager with state reducer + pruning, and a versioned UpcasterChain with detectSchemaVersion + helper builders. None of those were archived as out-of-scope in the original migration; they were silently dropped.
Genesis policy (CLAUDE.md §Workflow rule 5) is "preserve the full public surface during rewrites — trimming is OK ONLY when code is duplicated elsewhere in-repo or can be replaced by an existing @suaveplan/* library." None of the six dropped capabilities are duplicated elsewhere on genesis, so each one must be ported. This change closes that gap in one coordinated pass so the next consumer (drive-network, the upcoming projection adapters, and any service that needs reliable event publishing) has a coherent surface to build on.
What changes
- ADDED:
EventOutboxinterface — durable transactional outbox; methodsappend(events),relay(publisher),pendingCount(),failedCount(),cleanup(beforeIso). - ADDED:
MemoryEventOutboxclass — in-memory implementation ofEventOutboxfor tests and single-process services; preserves insertion order and per-rowattemptscounter. - ADDED:
OutboxPublishertype alias —(events: DomainEvent[]) => Promise<void>. - ADDED:
OutboxRelayworker class — drains pending records on a configurable interval through an injectableClock; honoursAbortSignal; emitsoutbox.relayed/outbox.failedOTel counters. - ADDED:
DatabaseEventStore<TEvent>class —EventStoreadapter backed by aDatabaseAdapterfrom@suaveplan/db; optimistic-concurrency check via expected version insidetransaction(); ledger-style append-only events table with auto-create. - ADDED:
DatabaseSnapshotStore<TState>class —SnapshotStoreLikeadapter; latest-snapshot-per-aggregate persistence; configurableserializer/deserializerso non-JSON state types round-trip. - ADDED:
EventReplayerclass —replay({ aggregateId | eventType, filter, batchSize, onProgress, signal }),pause(),resume(),cancel(),reset();ReplayProgresswithtotal/processed/percentage/paused/completed;ReplayFilter(eventTypes, fromDate, toDate, aggregateId). - ADDED:
EventBusclass withSubscriptionFilter— typedsubscribe(handler, { filter, group }),publish(event),publishAll(events), round-robin group delivery,subscriberCount,clear(); subscription returns anEventSubscription { id, unsubscribe(), active }handle. - ADDED:
DeadLetterSinkinterface +MemoryDeadLetterSink— receives events whose handler threw; preserves the originalcauseonEventSourcingError(EVENT_HANDLER_FAILED). - ADDED:
SnapshotManager<TState>class — automatic snapshot creation at configurable intervals;load(aggregateId, initialState, reducer)returnsAggregateLoadResult<TState>(state,version,restoredFromSnapshot,eventsReplayed); pruning tomaxSnapshotsper aggregate; emitssnapshot.createdandaggregate.loadedOTel events. - ADDED:
StateReducer<TState>type alias —(state: TState, event: DomainEvent) => TState. - ADDED:
UpcasterChainordered registry —register(upcaster),registerAll(upcasters),upcast(event),upcastAll(events),getUpcasters(),getUpcastersForType(type),hasUpcast(event),clear(). Upcasters are sorted byfromVersionso consumers do not depend on registration order. - ADDED:
detectSchemaVersion(event)— readsmetadata.schemaVersion, defaults to1. - ADDED:
createUpcastedEvent(event, newPayload, targetVersion)andcreateRenamedEvent(event, newType, newPayload, targetVersion)helpers. - ADDED:
createFieldRenameUpcaster,createDefaultFieldUpcaster,createRemoveFieldUpcasterbuilder functions. - ADDED:
EventSubscriptionOptions,EventSubscription,SubscriptionFilter,OutboxConfig,OutboxEntryStatus,OutboxEntry,DatabaseEventStoreConfig,DatabaseSnapshotStoreConfig,ReplayConfig,ReplayFilter,ReplayProgress,ProgressCallback,ReplayHandler,SnapshotManagerConfig,AggregateLoadResult,VersionedEventMetadata,VersionedEventtypes — all colocated with their implementation modules. - MODIFIED:
ES_CODESregistry — addsOUTBOX_TABLE_INIT_FAILED,OUTBOX_PUBLISH_FAILED,OUTBOX_RELAY_INTERRUPTED,EVENT_INVALID,EVENT_HANDLER_FAILED,REPLAY_CANCELLED,REPLAY_FILTER_INVALID,SUBSCRIPTION_INVALID,DATABASE_TABLE_INIT_FAILED,SNAPSHOT_HANDLER_FAILED,UPCASTER_DUPLICATEcodes; the existingCONCURRENCY_CONFLICT,AGGREGATE_NOT_FOUND,SNAPSHOT_INVALID,UPCASTER_MISSING,STREAM_CORRUPT,APPEND_FAILEDcodes are preserved. - MODIFIED:
index.tsbarrel — re-exports every new symbol; legacyregisterUpcaster/clearUpcasters/upcastEventscontinue to be exported and now delegate to a module-scopedUpcasterChainso both APIs operate over the same registry. - MODIFIED:
package.jsonexportsmap — adds subpaths./outbox/event-outbox,./outbox/memory-outbox,./outbox/relay,./store/database-event-store,./store/database-snapshot-store,./replay/event-replayer,./subscription/event-bus,./subscription/dead-letter,./snapshot/snapshot-manager,./upcasting/chain. - MODIFIED:
package.jsondependencies— adds@suaveplan/db(workspace:^) for the database-backed adapters.
Out of scope
- SurrealDB / PostgreSQL / SQLite-specific event-store and snapshot-store adapters — each lands as its own package (
@suaveplan/event-sourcing-surrealdb,@suaveplan/event-sourcing-postgres,@suaveplan/event-sourcing-sqlite) so the engine-pluggable matrix mirrors@suaveplan/crypto/@suaveplan/crypto-sodium/@suaveplan/crypto-browser.DatabaseEventStorehere only needs to satisfy the universalDatabaseAdaptercontract; per-engine quirks (RANGE locks,RETURNINGclauses, BLOB serialization) belong with the engine adapter. - CQRS read-model framework —
Projectionbuilder +EventReplayercover the foundation; a full read-model registry with view-versioning, gap detection, and dead-letter rebuild is its own change (@suaveplan/cqrs). - Saga / process-manager pattern — long-running workflow coordination has dependencies (
@suaveplan/fsm,@suaveplan/idempotency, distributed lock provider) that warrant a separate proposal. - Distributed event bus (Kafka / NATS / RabbitMQ adapters) — the
EventBusclass shipped here is in-process publish/subscribe; cross-process distribution is its own change once the wire-format spec is firm. registerUpcasterlegacy free-function deprecation — kept and delegated; removing it is a breaking change that requires its own deprecation window.- Re-folding of
openspec/specs/event-sourcing/spec.mdif a canonical spec file does not yet exist, this change creates it for the cap; full spec folding lands once all event-sourcing changes archive.
Source mapping
This change derives from ../suaveplan/packages/universal/event-sourcing/src/. Genesis enterprise/event-sourcing.md is a generic enterprise template (no per-feature requirements), so source mapping cites the upstream package source directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
EventOutbox interface + MemoryEventOutbox class |
packages/universal/event-sourcing/src/outbox/outbox-pattern.ts |
L62-L197 | Class-based upstream is database-coupled; split into interface + memory impl on genesis so the abstraction is testable without @suaveplan/db |
OutboxPublisher type |
packages/universal/event-sourcing/src/outbox/outbox-pattern.ts |
L15 | Verbatim |
OutboxConfig, OutboxEntryStatus, OutboxRow (renamed OutboxEntry) types |
packages/universal/event-sourcing/src/outbox/outbox-pattern.ts |
L18-L42 | Verbatim shape; tableName/autoCreate only apply to the database-backed variant |
OutboxRelay worker |
packages/universal/event-sourcing/src/outbox/outbox-pattern.ts |
L103-L140 | Extracted from upstream relay() method into a standalone worker class with injectable Clock and AbortSignal |
DatabaseEventStore class |
packages/universal/event-sourcing/src/store/database-event-store.ts |
L44-L191 | Verbatim; EventStoreDatabaseAdapter upstream replaced by DatabaseAdapter from @suaveplan/db |
DatabaseEventStoreConfig |
packages/universal/event-sourcing/src/store/database-event-store.ts |
L21-L28 | Verbatim |
| Optimistic-concurrency append | packages/universal/event-sourcing/src/store/database-event-store.ts |
L56-L104 | Verbatim; emits EventSourcingError(CONCURRENCY_CONFLICT) |
DatabaseSnapshotStore<TState> class |
packages/universal/event-sourcing/src/store/database-snapshot-store.ts |
L37-L137 | Verbatim; serializer / deserializer hooks added so non-JSON state types round-trip |
EventReplayer class with progress / pause / resume / cancel |
packages/universal/event-sourcing/src/replay/event-replayer.ts |
L56-L250 | Verbatim; AbortSignal threaded through replay / replayByType / replayEvents |
ReplayFilter, ReplayProgress, ReplayConfig, ReplayHandler, ProgressCallback types |
packages/universal/event-sourcing/src/replay/event-replayer.ts |
L13-L50 | Verbatim |
EventBus class with SubscriptionFilter |
packages/universal/event-sourcing/src/subscription/event-subscription.ts |
L93-L200 | Verbatim; subscriptionId generation switched to injectable IdGenerator to drop module-scope counter |
SubscriptionFilter, SubscriptionOptions, EventSubscription, EventHandler types |
packages/universal/event-sourcing/src/subscription/event-subscription.ts |
L13-L41 | Verbatim |
matchesFilter predicate helper |
packages/universal/event-sourcing/src/subscription/event-subscription.ts |
L60-L85 | Verbatim |
SnapshotManager<TState> class |
packages/universal/event-sourcing/src/snapshot/snapshot-manager.ts |
L49-L177 | Verbatim; new Date() replaced with injectable Clock |
AggregateLoadResult<TState>, StateReducer<TState>, SnapshotManagerConfig |
packages/universal/event-sourcing/src/snapshot/snapshot-manager.ts |
L19-L42 | Verbatim |
UpcasterChain ordered registry |
packages/universal/event-sourcing/src/versioning/event-upcaster.ts |
L91-L142 | Verbatim |
detectSchemaVersion, createUpcastedEvent, createRenamedEvent |
packages/universal/event-sourcing/src/versioning/event-upcaster.ts |
L42-L82 | Verbatim |
createFieldRenameUpcaster, createDefaultFieldUpcaster |
packages/universal/event-sourcing/src/versioning/event-upcaster.ts |
L147-L203 | Verbatim |
VersionedEventMetadata, VersionedEvent, EventUpcaster types |
packages/universal/event-sourcing/src/versioning/event-upcaster.ts |
L13-L36 | Verbatim |
eventSourcingEvents lifecycle emitter (event:appended, snapshot:created, aggregate:loaded) |
packages/universal/event-sourcing/src/event-sourcing-events.ts |
L14-L30 | Verbatim type map; emitter rewired to OTel counters/spans on genesis to avoid coupling tests to a global emitter |
Requirements with no source line
| Requirement | Justification |
|---|---|
Replace upstream EventStoreDatabaseAdapter with DatabaseAdapter from @suaveplan/db |
@suaveplan/db already exists on genesis (packages/database/db/src/) and is the canonical adapter contract; introducing a second EventStoreDatabaseAdapter would fork the abstraction. |
Inject Clock and IdGenerator from @suaveplan/types everywhere upstream uses Date.now() / new Date() / module-scope counters |
Genesis-wide CLAUDE.md determinism rule: "no Math.random() / Date.now() at module scope; injectable clock and RNG" (enterprise/event-sourcing.md L40). Applies to outbox id generation, snapshot takenAt, OTel histograms, subscription IDs. |
AbortSignal threaded through EventReplayer.replay*, OutboxRelay.start, EventBus.publishAll |
Genesis-wide CLAUDE.md rule: "Backpressure & cancellation — AbortSignal everywhere async". Upstream lacks signal support in these surfaces. |
DeadLetterSink interface + MemoryDeadLetterSink implementation |
Upstream EventBus.publish swallows handler errors silently when grouped subscriptions throw. Genesis policy (CLAUDE.md §Workflow rule 4: professional honesty + no silent failures) requires a structured dead-letter path with the original cause preserved on EventSourcingError(EVENT_HANDLER_FAILED). |
Split EventOutbox into interface + MemoryEventOutbox + DatabaseEventStore-backed wiring |
Upstream couples the outbox class to a database adapter. Genesis policy (CLAUDE.md §Workflow rule 3: enterprise-grade modularity) requires the contract to be testable in pure memory before any persistence engine binds. |
Zod schemas for OutboxConfig, ReplayFilter, SubscriptionOptions, SnapshotManagerConfig, DatabaseEventStoreConfig, DatabaseSnapshotStoreConfig |
enterprise/event-sourcing.md L37: "Zod schemas for all public types". Upstream validates through TypeScript only. |
New ES_* codes for outbox / database / replay / subscription / dead-letter / snapshot / upcaster duplicate failures |
Genesis-wide policy (CLAUDE.md §Workflow rule 2) that every thrown error route through @suaveplan/error. Upstream throws plain Error for several of these paths. |
Verification approach
Each spec delta requirement carries a **Verified by:** line pointing at concrete it(...) titles. The test files that will exist after this change ships:
packages/events/event-sourcing/src/outbox/event-outbox.test.ts— interface contract,MemoryEventOutboxinsertion order + attempts counterpackages/events/event-sourcing/src/outbox/event-outbox.spec-conformance.test.ts— Zod-rejected configs emitOUTBOX_TABLE_INIT_FAILED; injectedIdGeneratordeterministic; OTel counterspackages/events/event-sourcing/src/outbox/relay.test.ts— successful relay marks rows published; failure increments attempts;AbortSignalaborts in-flight loops withOUTBOX_RELAY_INTERRUPTEDpackages/events/event-sourcing/src/store/database-event-store.test.ts— append + load round-trip; optimistic-concurrency conflict raisesCONCURRENCY_CONFLICT;getEventsByTypehonourslimitpackages/events/event-sourcing/src/store/database-event-store.spec-conformance.test.ts— table auto-create gated onautoCreate: true;transaction()rollback on adapter exception; emitsevent:appendedon successpackages/events/event-sourcing/src/store/database-snapshot-store.test.ts— save / load latest-per-aggregate; configurable serializer round-trips a non-JSON state;deletereturnsfalsefor unknown aggregatepackages/events/event-sourcing/src/replay/event-replayer.test.ts— full replay invokes handler in stream order; filter narrows byeventTypes/aggregateId/fromDate/toDate; pause / resume / cancel / reset state machine;onProgressfinal tick hascompleted: truepackages/events/event-sourcing/src/replay/event-replayer.spec-conformance.test.ts— abort signal mid-batch rejects withREPLAY_CANCELLED; bad filter rejected withREPLAY_FILTER_INVALIDpackages/events/event-sourcing/src/subscription/event-bus.test.ts—subscribe/publishround-trip; filter narrows byeventTypes/aggregateIds/aggregateType; group round-robin distribution;unsubscriberemoves recordpackages/events/event-sourcing/src/subscription/event-bus.spec-conformance.test.ts— handler exceptions route toDeadLetterSinkwithEVENT_HANDLER_FAILEDcause; injectedIdGeneratorproduces deterministic subscription IDs;subscriberCountreflectsunsubscribe()packages/events/event-sourcing/src/subscription/dead-letter.test.ts—MemoryDeadLetterSinkcapture; iteration order;clear()resetspackages/events/event-sourcing/src/snapshot/snapshot-manager.test.ts—loadrebuilds from snapshot + tail events; auto-create atinterval; pruning atmaxSnapshots;shouldCreateSnapshotpredicatepackages/events/event-sourcing/src/snapshot/snapshot-manager.spec-conformance.test.ts— injectedClockstampstakenAtdeterministically;aggregate:loadedOTel event carriesrestoredFromSnapshot; reducer exception raisesSNAPSHOT_HANDLER_FAILEDpackages/events/event-sourcing/src/upcasting/chain.test.ts—registerorders byfromVersion;upcastwalks chain v1→v2→v3;getUpcastersForTypefilters;clear()empties registrypackages/events/event-sourcing/src/upcasting/chain.spec-conformance.test.ts—detectSchemaVersiondefaults to1; duplicate(eventType, fromVersion)registration raisesUPCASTER_DUPLICATE;createFieldRenameUpcasterround-triptests/dep-graph.test.ts(cross-package) —@suaveplan/event-sourcingdeclares@suaveplan/dbas a runtime dep; the package still has zero browser-only deps
Migration impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/event-sourcing to enumerate. As of grep -rl "@suaveplan/event-sourcing" packages/ --include="package.json" (2026-05-02), the only match is the package's own package.json — there are no in-repo dependents on genesis yet.
- (no in-repo dependents) — additive only; existing barrel exports (
Aggregate,MemoryEventStore,createAggregateRepository,createProjection,registerUpcaster,upcastEvents) are preserved unchanged. New exports (EventOutbox,DatabaseEventStore,DatabaseSnapshotStore,EventReplayer,EventBus,SnapshotManager,UpcasterChain,detectSchemaVersion,createFieldRenameUpcaster, etc.) are additive.
The legacy registerUpcaster / clearUpcasters / upcastEvents flat API continues to work and now delegates to a module-scoped UpcasterChain so both APIs operate over the same registry. No breaking changes; consumers can adopt the new class API at their own pace.
For foundation-package safety, a bunx turbo build typecheck test --filter=...@suaveplan/event-sourcing sweep runs in §6 of tasks.md.
Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
Aggregate base class |
yes | yes | Already on genesis |
aggregate-repository factory |
yes | yes | Already on genesis |
MemoryEventStore |
yes | yes | Already on genesis |
DatabaseEventStore (DB-backed) |
yes | yes (this change) | Ported to @suaveplan/db adapter |
Snapshot type |
yes | yes | Already on genesis |
DatabaseSnapshotStore |
yes | yes (this change) | Adds serializer hooks |
SnapshotManager<TState> |
yes | yes (this change) | new Date() → injectable Clock |
Projection builder |
yes | yes | Already on genesis |
EventBus + SubscriptionFilter |
yes | yes (this change) | Adds dead-letter sink |
EventReplayer (progress / pause / resume) |
yes | yes (this change) | Adds AbortSignal |
EventOutbox + OutboxRelay |
yes | yes (this change) | Split interface / memory / DB |
UpcasterChain ordered registry |
yes | yes (this change) | Replaces flat registerUpcaster |
detectSchemaVersion, createUpcastedEvent, createRenamedEvent |
yes | yes (this change) | Verbatim |
createFieldRenameUpcaster, createDefaultFieldUpcaster |
yes | yes (this change) | Adds createRemoveFieldUpcaster |
eventSourcingEvents lifecycle emitter |
yes | yes (this change) | Rewired to OTel; back-compat type map preserved |
Flat registerUpcaster / upcastEvents |
yes | yes | Already on genesis; now delegates to UpcasterChain |
No upstream capabilities are dropped.