Migrating from 3.x to 4.0
4.0 is a major release. It moves to NestJS 12, ES modules and Node.js 22.12 or later, and gives the event and snapshot stores a new contract: global positions and readAll for the read side, metadata and headers, and a stored schema (schema v2) that 3.x data is migrated to once. Publishers are awaited, the command and query buses are typed, and a JSON serializer is the default. This guide lists every breaking change, in the order in which you apply them.
3.x keeps receiving security and critical fixes until at least 2027-03-31, so you can plan the move: see Versioning and support. The 3.x documentation is at ocoda.github.io/event-sourcing/v3.
Before you start
Section titled “Before you start”The move has two parts: code changes, which you make and test on a branch while 3.x keeps running, and a one-time, offline migration of the stored events and snapshots, which runs when you deploy 4.0. 3.x and 4.0 can’t run against the same database at once: plan a maintenance window for the cutover.
| If your application | Read |
|---|---|
| uses the PostgreSQL, MariaDB or MongoDB store | every step, including the data migration |
| only uses the in-memory stores | every step except the data migration |
| has its own event or snapshot store | also Custom stores |
| uses class-transformer decorators on events, or on the values they hold | Serialization before you deploy |
| uses DynamoDB | DynamoDB is not part of 4.0 first |
Most 3.x APIs that 4.0 replaces keep working until 5.0, with a deprecation warning where it helps: the positional appendEvents, commit(), forRootAsync({ useValue }), the static exception factories and the command and query metadata helpers. So the code can move step by step, but the data migration has to be done before 4.0 runs against a 3.x database.
Upgrade steps
Section titled “Upgrade steps”- If the database runs PostgreSQL 13 or MongoDB 6, upgrade the server while the application is still on 3.x.
- Upgrade the application to NestJS 12, Node.js 22.12 and TypeScript 5.4 or later.
- Install the 4.0 packages and the driver of every integration you use, and replace imports of paths inside the packages.
- Adapt to the module configuration: move code that appends or reads events, executes commands or queries, or publishes from a provider factory or a constructor to
onModuleInitor later, and fix every problem that the bootstrap reports. - Match library errors on their code and fields instead of their message or constructor.
- Adapt to the typed buses:
execute()rejects instead of throwing, and theexecute()of every decorated handler returns a promise. - Save aggregates in three steps, with the options form of
appendEvents, and adapt to the aggregate, snapshot repository and id changes. - Adapt to the publishing changes: publishers are awaited, and tests wait with
eventBus.whenIdle(). - Choose the event serializer:
JsonEventSerializeris the default, andClassTransformerEventSerializerserializes as 3.x did. - Replace
getAllEnvelopes()withreadAll(), and read the envelope metadata the 4.0 way, also in the consumers of envelopes sent as JSON. - Adapt code that calls a snapshot store directly and, on MariaDB, code that depends on the order of
loadAll. - Port custom stores to the driver methods, and check them with the conformance suites.
- Rehearse the data migration on a copy of the production data: run the dry runs and review their reports.
- Cut over: take a backup, stop every 3.x instance, migrate the events and then the snapshots, and deploy 4.0.
- After the cutover, use the stream ids that the MariaDB migration reports, append after the actual version of streams with gaps when they conflict, and drop the MariaDB backups when you are satisfied.
1. Database server versions
Section titled “1. Database server versions”4.0 is tested on PostgreSQL 14 and later and on MongoDB 7 and later; see Tested versions for the full list, MariaDB included, and MySQL from 4.1. PostgreSQL 13 and MongoDB 6 are no longer tested: their vendors ended support for them in 2025 (PostgreSQL, MongoDB). 4.0 doesn’t refuse them, but 4.x isn’t tested on them, so a later 4.x release can stop working on them.
If you run one of them, upgrade the database server while the application is still on 3.x, which is tested on PostgreSQL 14 to 17 and on MongoDB 7 and 8 too. The server upgrade and the move to 4.0 then stay separate steps, and the data migration runs on the upgraded server.
2. NestJS 12, Node.js 22.12 and TypeScript 5.4
Section titled “2. NestJS 12, Node.js 22.12 and TypeScript 5.4”The core peers on @nestjs/common and @nestjs/core ^12.0.0, on rxjs ^7.8.0 and on reflect-metadata ^0.2.0. All packages require Node.js 22.12 or later (engines.node is >=22.12). The type declarations of the buses use NoInfer, so TypeScript projects need TypeScript 5.4 or later (see Typed buses).
Upgrade the application itself first, following the NestJS migration guide. NestJS 11 applications stay on 3.x.
3. Install the 4.0 packages
Section titled “3. Install the 4.0 packages”Install the core and the integrations you use, together with the database driver of each integration. For example, for PostgreSQL:
pnpm add @ocoda/event-sourcing@4 @ocoda/event-sourcing-postgres@4pnpm add pg pg-cursornpm install @ocoda/event-sourcing@4 @ocoda/event-sourcing-postgres@4npm install pg pg-cursoryarn add @ocoda/event-sourcing@4 @ocoda/event-sourcing-postgres@4yarn add pg pg-cursorThe core and the integrations are released together with the same version number: keep them on the same version.
vitest 4 or 5 is an optional peer dependency of the core, for the conformance suites. With npm, a project that has another major of Vitest gets ERESOLVE from this install until it moves to Vitest 4 or 5, or installs with --legacy-peer-deps. pnpm and Yarn only warn. class-transformer is an optional peer dependency too: see Serialization.
ES modules only
Section titled “ES modules only”Every package ships a single ES module build ("type": "module"). The exports map points import, require and default at the same file, so there is no separate CommonJS build.
- ES module applications import the packages as before.
- CommonJS applications, including TypeScript compiled to CommonJS, keep using
require()and need no code changes. Node.js 22.12 and later canrequire()an ES module natively. - Jest has its own module loader. It loads these packages with the same setup it needs for NestJS 12, which is ESM-only as well, so there is nothing extra to configure for this library.
Because there is only one build, Nest never sees two copies of a class such as EventStore, which could happen when a package shipped both a CommonJS and an ES module build.
Database drivers are peer dependencies
Section titled “Database drivers are peer dependencies”In 3.x each integration installed its own driver. In 4.0 the driver is a peer dependency: install it next to the integration and choose its version within the supported range.
| Integration | Driver | Supported range |
|---|---|---|
@ocoda/event-sourcing-postgres |
pg, pg-cursor |
pg ^8.15.0 (the first release with an ES module entry), pg-cursor ^2.14.0 |
@ocoda/event-sourcing-mongodb |
mongodb |
^6.10.0 || ^7.0.0; MongoDB 8.3 and 9.0 need 7.3 or later |
@ocoda/event-sourcing-mariadb |
mariadb |
^3.5.3: older connectors are affected by security advisories |
PostgreSQL
pnpm add pg pg-cursorpnpm add -D @types/pg @types/pg-cursorMongoDB
pnpm add mongodbMariaDB
pnpm add mariadbPostgreSQL
npm install pg pg-cursornpm install -D @types/pg @types/pg-cursorMongoDB
npm install mongodbMariaDB
npm install mariadbPostgreSQL
yarn add pg pg-cursoryarn add -D @types/pg @types/pg-cursorMongoDB
yarn add mongodbMariaDB
yarn add mariadbThe @types/pg and @types/pg-cursor packages are only needed in TypeScript projects. If the application already depends on one of these drivers, check that its version is within the supported range.
Integrations peer on the core with a caret range
Section titled “Integrations peer on the core with a caret range”In 3.x each integration required one exact version of @ocoda/event-sourcing. In 4.0 the integrations peer on @ocoda/event-sourcing with a caret range of their own version and on @nestjs/common ^12.0.0. They no longer list @nestjs/core, rxjs or reflect-metadata, which they don’t import.
The packages are still released together, so upgrade the core and the integrations at the same time.
Import from the package name
Section titled “Import from the package name”The root entry shims (the index.js, index.d.ts and index.ts files next to each package.json) are gone. exports exposes the package root, package.json and, for the core, two subpaths: @ocoda/event-sourcing/class-transformer (see Serialization) and @ocoda/event-sourcing/testing (see Testing a custom store). Paths into a package, such as @ocoda/event-sourcing/dist/..., no longer resolve:
import { ... } from '@ocoda/event-sourcing/dist/...';import { ... } from '@ocoda/event-sourcing';The same applies to the integrations: import from @ocoda/event-sourcing-postgres, @ocoda/event-sourcing-mongodb or @ocoda/event-sourcing-mariadb. EventRegistry, ExplorerService and getOptionsToken(), which were only reachable through such paths, are removed: see Module configuration.
4. Module configuration
Section titled “4. Module configuration”See Module configuration.
- Registration moves to
onModuleInit. The handlers, subscribers, publishers and serializers are registered in the module’sonModuleInit, or on the first use of the event store or a bus if a provider of another module (your own@Global()module, say) uses them from itsonModuleInitfirst. 3.x registered them inonApplicationBootstrap, so events appended fromonModuleInitwere never published. Commands, queries and appends now work fromonModuleIniton. - Provider factories can’t use the library yet. Appending events (pre-built envelopes and
publish: falseincluded), reading events (getEvent,getEvents), executing or publishing from a provider factory or a constructor fails with anEventSourcingNotReadyException(ES_EVENT_SOURCING_NOT_READY), before any I/O: the handlers don’t exist yet. Move that code toonModuleInitor later. Reading envelopes (getEnvelope,getEnvelopes,readAll) andgetStreamVersionstill work there. - The stores connect while the providers are instantiated, the snapshot store after the event store, and create their default pools right after. A wrong connection, or a 3.x table that still needs its data migration, fails the bootstrap before any
onModuleInitruns, and a store that fails is disconnected again, as is the event store when the snapshot store fails. The error is thrown whileNestFactory.create()instantiates the providers: with Nest’s defaultabortOnError: true, Nest logs it and exits with code 1 instead of rejectingapp.listen(). PassNestFactory.create(AppModule, { abortOnError: false })to catch it;Test.createTestingModule().compile()rejects with it. The stores disconnect in their ownonApplicationShutdown, after theEventBushas drained. - Misconfiguration fails the bootstrap with one
EventSourcingConfigurationException(ES_EVENT_SOURCING_CONFIGURATION) whoseissueslist every problem: two event classes with one name, two handlers for one command or query, two serializers for one event, a serializer or subscriber for an unregistered event, missing decorator metadata, request-scoped or transient subscribers, publishers and serializers, and events with class-transformer decorators on the JSON serializer (see Serialization). 3.x kept one of the duplicates, or dropped the provider, silently. Providing one class in several modules, or listing an event twice, is still fine. See Configuration checks. - Request-scoped command and query handlers work: the bus resolves them per call, and
execute(message, { request })lets them inject the request with@Inject(REQUEST). 3.x registered their bare prototype. A request-scoped handler provided withuseFactorymust use the handler class as its token. See request-scoped handlers. - Handlers provided with
useFactoryoruseValueare discovered, by the class of their instance. forFeatureis no longer global state. Its events belong to the applications that import the feature module; 3.x put them in a registry shared by the whole process, so several applications in one process (tests) now each see only their own events.forFeature({ serializers, imports })provides the serializers of the feature’s events.- One global module.
forRootandforRootAsyncreturn the globalEventSourcingModuleitself, built with Nest’sConfigurableModuleBuilder; there is no separate core module to import or reference. The options token (EVENT_SOURCING_OPTIONS),@InjectEventSourcingOptions(), the flat{ driver, ...driverOptions }store configs and the typedforRoot<PostgresEventStoreConfig, …>()stay the same. The snapshot store’s driver no longer receivesuseDefaultPool. forRootAsync({ useValue })is deprecated (DeprecationWarningOCODA_ES_FOR_ROOT_ASYNC_USE_VALUE): useforRoot(options). WithoutuseFactory,useClassoruseExisting,forRootAsyncthrows anEventSourcingConfigurationExceptioninstead of a plainError.- Implicit in-memory stores warn in production. When
NODE_ENVisproductionandeventStoreorsnapshotStoreis left out, the module logs a warning. - Removed:
EventRegistry,ExplorerServiceandgetOptionsToken(), which were reachable through paths inside the package in 3.x. Inject the options with@InjectEventSourcingOptions().
To migrate:
-
Move code that appends, reads, executes or publishes from a provider factory or a constructor to
onModuleInitor a later lifecycle hook:// 3.x: appended from a provider factory, never published{ provide: 'SEED', inject: [EventStore], useFactory: (store: EventStore) => seed(store) }// 4.0: from a lifecycle hook@Injectable()export class Seeder implements OnModuleInit {constructor(private readonly eventStore: EventStore) {}onModuleInit() {return seed(this.eventStore);}} -
Fix what the bootstrap reports in
EventSourcingConfigurationException.issues: keep one handler per command or query and one serializer per event, register the events your subscribers and serializers name, and make subscribers, publishers and serializers singletons (resolve request-scoped dependencies withModuleRef.resolve()). -
Pass the request to request-scoped handlers:
commandBus.execute(command, { request }). -
Replace
forRootAsync({ useValue: options })withforRoot(options), andgetOptionsToken()withEVENT_SOURCING_OPTIONSor@InjectEventSourcingOptions(). -
Tests that create several applications in one process no longer share the events of
forFeature: import the feature module in each application that needs its events.
5. Errors have a code and fields
Section titled “5. Errors have a code and fields”Every exception class the library exports, except DomainException (the base class for your own errors), is an EventSourcingError: it extends the new abstract class, or, for InvalidIdException, is branded as one. Each one has a stable code, the fields that describe the failure, and the original error as its cause.
- Codes. Each class has a literal
name, which survives minification, and a uniquecodefromEventSourcingErrorCode, for exampleEventSourcingErrorCode.EventStoreVersionConflict('ES_EVENT_STORE_VERSION_CONFLICT'). The class names are unchanged, soinstanceofchecks keep working. isEventSourcingError(error, code?)narrows an unknown error, to the class of the code when you pass one. It recognises errors from a second copy of the package, and so doeserror instanceof EventSourcingError.- Fields.
EventStoreVersionConflictExceptionhasstreamId,aggregateId,pool,expectedVersion(the version the stream had to have before the append) andactualVersion(absent when the append lost a race on the unique key).EventStorePersistenceExceptionhascollectionand anoutcome:'not-persisted'when nothing was written,'unknown'when the events may have been stored. Retrying with the same expected version is safe in both cases, because a duplicate append conflicts. The not-found and metadata exceptions name the class or stream they concern, andCommandHandlerNotFoundExceptionnames the command class instead of an internal id. - Stores. A version conflict from the PostgreSQL, MariaDB and MongoDB stores carries the pool and the version the stream was at, and leaves
actualVersionout when the append lost a race on the unique key. MongoDB throws anEventStorePersistenceExceptionwithoutcome: 'unknown'(itscauseis anAggregateError) instead of a version conflict when an append that lost a race can’t remove the events it already stored. Not-found exceptions name the pool they searched. - Messages changed, for version conflicts in particular.
- The stack is kept. A wrapping exception no longer replaces its own stack with the stack of the driver error. The driver error is its
cause. UnsupportedOperationExceptionreplacesNotImplementedException. In 3.x,SnapshotRepository.loadMany()andloadAll()threw theNotImplementedExceptionof@nestjs/commonwhen the snapshot store lacked the method they need.loadMany()now works with every store (see Snapshot stores).loadAll()throwsUnsupportedOperationException, with the method name inoperation, when the store keeps the basegetLastEnvelopesForAggregate. An HTTP exception filter that relied on the 501 status of the Nest exception should map the new code.DomainExceptiongets aname, the class name of your subclass, and takes the standard error options as a third argument:super(message, id, { cause }). It has nocode, so your subclasses compile unchanged.InvalidIdExceptionstill extends it, and is also anEventSourcingErrorwith a code.- One object argument. Every exported exception is constructed with a single details object, plus the error options for the
cause. The 3.x static factories, such asInvalidIdException.becauseInvalid()andIdNotFoundException.withId(), still work, but are deprecated and will be removed in 5.0. ExpectedVersionis a new export:ExpectedVersion.NoStream(0) andExpectedVersion.Any, and the type of an expected version.
To migrate:
-
Match errors on their code and fields rather than their message or constructor:
// 3.xif (error.constructor === EventStoreVersionConflictException) { … }// 4.0if (isEventSourcingError(error, EventSourcingErrorCode.EventStoreVersionConflict)) {console.log(error.streamId, error.expectedVersion, error.actualVersion);} -
Code that constructs library exceptions, such as a custom store, passes one object and puts the underlying error in the options:
// 3.xthrow new EventStoreVersionConflictException(stream, aggregateVersion, currentVersion, error);throw new EventStorePersistenceException(collection, error);// 4.0throw new EventStoreVersionConflictException({ stream, expectedVersion: aggregateVersion - events.length, actualVersion: currentVersion, pool },{ cause: error },);throw new EventStorePersistenceException({ collection, outcome: 'unknown' }, { cause: error });The
expectedVersionof a conflict is the version the stream had to have before the append, not the aggregate version after it. -
Where you catch the errors of
SnapshotRepository.loadAll(), replaceNotImplementedExceptionwithUnsupportedOperationException, or matchEventSourcingErrorCode.UnsupportedOperation.loadMany()no longer throws it. -
Read the underlying error of a wrapping exception from
error.causeinstead of from its stack.
6. Typed command and query buses
Section titled “6. Typed command and query buses”commandBus.execute() and queryBus.execute() take their result type from the command or query, and reject instead of throwing. See Commands and Queries.
Command<TResult>andQuery<TResult>are new base classes. ForOpenAccountCommand extends Command<AccountId>,commandBus.execute(new OpenAccountCommand())resolves to anAccountIdwithout a type argument,ICommandHandler<OpenAccountCommand>resolves to it, and@CommandHandler(OpenAccountCommand)rejects a handler whoseexecute()resolves to something else.Commandwithout a type argument isCommand<void>, andQueryneeds one. The result type exists for the compiler only: the classes add no property, so the payloads are unchanged.ResultOf<T>is the result type of a command or query class.- 3.x commands and queries keep compiling. A class that doesn’t extend
CommandorQuerystill works, andexecute()resolves toanyfor it. Both 3.x generic forms compile:execute<AddBookCommand>(command)resolves toany, andexecute<OpenAccountCommand, AccountId>(command)to anAccountId. The result type is not inferred from the variable it is assigned to, soconst id: Promise<string> = commandBus.execute(new OpenAccountCommand())is a compile error for a typed command. - TypeScript 5.4 or later. The type declarations of
execute()useNoInfer, which TypeScript 5.4 introduced. An older compiler reportsCannot find name 'NoInfer', or, withskipLibCheck, types every result asany. ICommandandIQueryareobject, notany, so a primitive,nullorundefinedno longer type-checks as a command or query. Neither does a value typedunknownor an unconstrained type parameter: constrain it, as indispatch<C extends ICommand>(command: C).execute()rejects instead of throwing. A command or query without a handler rejects with aCommandHandlerNotFoundExceptionorQueryHandlerNotFoundExceptionand publishes nothing, and a handler that throws synchronously rejects too.- Handlers are registered by class. The buses route a command or query to the handler registered for its class or, if there is none, for its nearest parent class. A subclass without a handler of its own still reaches the handler of its parent, as in 3.x. 3.x routed by an id that
@CommandHandler()and@QueryHandler()store on the class, and a subclass inherited that id: when a class and its subclass both had a handler, the handler registered last handled both. Each now reaches its own handler. A class that no handler was ever declared for rejects with the not-found exception, not with aMissingCommandMetadataExceptionorMissingQueryMetadataException. Those two exceptions,getCommandMetadata(),getQueryMetadata(),CommandMetadataandQueryMetadataare deprecated and will be removed in 5.0.commandBus.bind(handler, command)andqueryBus.bind(handler, query)take the class instead of the id. - The decorators take classes.
@CommandHandler(),@QueryHandler(),@EventSubscriber()and@EventSerializer()no longer accept other values, and the class you pass needs a public constructor.@CommandHandler()and@QueryHandler()also check the class they decorate: it needs anexecute()method that returns a promise. They return a decorator typed for the handler class instead of aClassDecorator, soapplyDecorators()of@nestjs/commondoesn’t accept them understrictFunctionTypes. Apply them directly, or cast them:CommandHandler(OpenAccountCommand) as ClassDecorator, which skips the check of the result type. execute()takes an optional second argument,{ request }, for request-scoped handlers: the bus resolves them in the DI context of that request, so they can inject it. See Module configuration.
To migrate:
-
Assert the failures of
execute()as rejections, for example in tests:// 3.xexpect(() => commandBus.execute(new UnhandledCommand())).toThrow(CommandHandlerNotFoundException);// 4.0await expect(commandBus.execute(new UnhandledCommand())).rejects.toThrow(CommandHandlerNotFoundException); -
Where you catch a
MissingCommandMetadataExceptionorMissingQueryMetadataException, catch the not-found exception, or matchEventSourcingErrorCode.CommandHandlerNotFoundandEventSourcingErrorCode.QueryHandlerNotFound. -
Where a command or query class and its subclass both have a handler, check that each handler expects only its own class: 3.x ran the handler registered last for both.
-
Make the
execute()of every decorated handler return a promise, for example by making itasync. -
Fix the compile errors of code that passed other values: constrain the type parameters you pass to
execute(), make the constructors of command and query classes public (a private constructor with a static factory doesn’t type-check), and cast@CommandHandler()and@QueryHandler()where you pass them toapplyDecorators(). -
Optionally, extend
CommandandQuery, and drop the type arguments ofexecute(). A constructor then callssuper():// 3.xexport class OpenAccountCommand implements ICommand {constructor(public readonly accountOwnerIds?: string[]) {}}const accountId = await commandBus.execute<OpenAccountCommand, AccountId>(new OpenAccountCommand());// 4.0export class OpenAccountCommand extends Command<AccountId> {constructor(public readonly accountOwnerIds?: string[]) {super();}}const accountId = await commandBus.execute(new OpenAccountCommand());In a file that also imports the
@Query()decorator of@nestjs/common, import one of the two under another name, such asimport { Query as QueryParam } from '@nestjs/common'.
7. Aggregates, appends and ids
Section titled “7. Aggregates, appends and ids”See Aggregates, Repositories, Event Store and Ids.
Saving aggregates
Section titled “Saving aggregates”AggregateRoot gets committedVersion, getUncommittedEvents() and markCommitted(). A repository saves in three steps, and marks the events as committed only once the append succeeded:
// 3.xconst events = account.commit();await this.eventStore.appendEvents(stream, account.version, events, pool);// 4.0const events = account.getUncommittedEvents();await this.eventStore.appendEvents(stream, events, { expectedVersion: account.committedVersion, pool });account.markCommitted(events);markCommitted(events)marks the events it is given as committed: pass the events ofgetUncommittedEvents(). An event applied to the aggregate while the append ran stays uncommitted, for the next save. Events that aren’t the first uncommitted events, in order, throw anUncommittedEventsExceptionand change nothing.markCommitted()without arguments marks every uncommitted event as committed, also one that the append didn’t store.commit()is deprecated and will be removed in 5.0. It still works, but it clears the events before they are appended, so they are lost when the append fails. With the three steps, a failed save keeps the events, and can be retried with the same aggregate or after reloading it.- Saving an unchanged aggregate is a no-op: it has no uncommitted events, and an empty append does nothing.
applyEvent()runs the event handler first, then counts the event. 3.x counted the event first, so a handler that threw left the aggregate one version ahead with the failed event recorded, and the next save stored it or conflicted. Now the version and the uncommitted events stay as they were. A handler that readsthis.versiongets the version before its event, one less than in 3.x.- An event that an event handler applies is applied once the handler returns, after the handler’s event, and when the aggregate is loaded from its events, the call is ignored. The events are recorded in the same order and with the same versions as in 3.x. 3.x ran the handler of the applied event in the middle of the handler that applied it, and applied the event a second time when the aggregate was loaded: it counted it twice and recorded it as uncommitted, so the next save stored it again. Streams that 3.x wrote this way load correctly now. Where a handler reads state after its
applyEvent()call, that state doesn’t include the applied event yet: move that code to the handler of the applied event. See Event handlers that apply events. loadFromHistory()and theversionsetter throw anUncommittedEventsException(ES_UNCOMMITTED_EVENTS) while the aggregate has uncommitted events, and so doesapplyEvent(event, true): load the aggregate before you apply new events.loadFromHistory()also takes an array of events.@Aggregate({ missingHandler: 'ignore' })applies the events the aggregate has no handler for, where'throw', the default, throws aMissingEventHandlerExceptionas before.
Appending events
Section titled “Appending events”appendEvents takes the events and an options object. expectedVersion is the version of the stream before the append; with the three steps above, that is account.committedVersion:
// 3.x: the version of the aggregate after the appendawait this.eventStore.appendEvents(stream, account.version, events, pool);// 4.0: the version of the stream before the appendawait this.eventStore.appendEvents(stream, events, { expectedVersion: account.committedVersion, pool });ExpectedVersion.NoStream (0) expects a new stream. ExpectedVersion.Any appends whatever the version is: it makes up to 16 attempts while concurrent appends take the versions, with the same event ids, and can still conflict under sustained contention on one stream, so retry such a conflict in your application. publish: false stores without publishing, for imports and migrations. The returned envelopes carry their globalPosition.
The 3.x form still works, but is deprecated and will be removed in 5.0. It emits a DeprecationWarning with the code OCODA_ES_POSITIONAL_APPEND once per process. In both forms, on every built-in store:
- An empty append does nothing. It returns
[]without reading or writing, and publishes nothing. 3.x could conflict on an empty save. - An append that leaves a gap conflicts. 3.x accepted an append whose first version was above the next version of the stream. The version check now requires the stream to be exactly at the expected version, and the conflict carries its
actualVersion. - Pre-built envelopes must continue the stream: the aggregate id of the stream, the versions
expectedVersion + 1,+ 2, …, and a numeric expected version. Otherwise the append throws anInvalidEventEnvelopeException. They keep their id,occurredOnand metadata. - Invalid arguments throw before any I/O: an
InvalidAppendOptionsExceptionfor a negative or fractional expected version (in the 3.x form, an aggregate version below the number of events) or an empty pool name, and anInvalidEventEnvelopeExceptionfor a stream id, aggregate id or event name over 255 characters. - The failure of the version check’s read is an
EventStorePersistenceExceptionwithoutcome: 'not-persisted'.
Publishing. The module hands the store the EventBus when it creates it, and eventStore.publish = … is gone. Events appended before the application bootstrapped are no longer held back with a warning. The store calls eventBus.publishAll(envelopes), so a test double that replaces the EventBus must implement it, and a spy on eventBus.publish sees nothing: see Publishing.
Reads. getEvent() and getEnvelope() always return a promise, also on the in-memory store. Every read of a pool whose collection doesn’t exist throws an EventCollectionNotFoundException, where the in-memory store’s getEvents() and getEnvelopes() yielded nothing in 3.x and its getEvent() threw an EventNotFoundException. An append to such a pool throws an EventStorePersistenceException with outcome: 'not-persisted', whose cause is the EventCollectionNotFoundException.
Snapshot repositories
Section titled “Snapshot repositories”SnapshotRepository.save()never rejects. When the snapshot can’t be serialized or stored, it logs the failure: a version conflict, which means that the stream already has that snapshot or a newer one, as a warning, and anything else as an error. The events are stored by then, and the aggregate still loads from them. 3.x rejected, which failed a command whose events were already stored.- The versions a save covers come from
markCommitted()(the deprecatedcommit()calls it), so call it beforesave(). With an interval of 10, a save from version 9 to 11 still takes a snapshot, and a save whosemarkCommitted()committed nothing takes none, even at a multiple of the interval. While the aggregate has uncommitted events,save()logs a warning and takes no snapshot: 3.x stored a snapshot ahead of the stored events, and every later save of the stream conflicted.
generate(),from()andULID.factory()create an instance of the class they are called on.AccountId.generate()returns anAccountId, where 3.x returned aUUID, also when the factory is passed around, as invalues.map(AccountId.from). They callnew this(value), so an id class whose constructor takes anything other than the id alone must declare its owngenerate(),from()andfactory(). TypeScript doesn’t catch this when the first parameter is a string:from()then passes your value to that parameter and returns an id with a different value. Make aprivateconstructorprotected, or the inherited factories no longer compile. Passed around, a factory is typed as the base class (values.map(AccountId.from)is aUUID[]that holdsAccountIds): writevalues.map((value) => AccountId.from(value))for anAccountId[].- Ids of different classes are never equal, even with the same value:
AccountId.from(value).equals(UUID.from(value))isfalse, where 3.x compared twoUUIDs. Compare thevalues instead. ULID.from()requires Crockford’s base32: 26 digits and letters without I, L, O and U, in either case, starting with 0 to 7. 3.x accepted any 26 letters and digits. The event ids that a store reads are not validated, so stored events stay readable, and the dry run of the data migration counts the event ids that aren’t canonical.InvalidIdException.idTypenames the class the value was given to, such as'AccountId', instead of'UUID', and so does the message.ULID.yearMonthis removed, also onEventId: useid.date.toISOString().slice(0, 7).ulidFactoryis deprecated: it has always been a no-op. UseULID.factory(). It is removed in 5.0.ValueObject.equals()returnsfalsefornullandundefinedinstead of throwing aTypeError, and compares the keys of the props, not only their number.
To migrate:
- Save aggregates in the three steps above, and call
markCommitted()beforesnapshotRepository.save(). - Load an aggregate (
loadFromHistory(), or theversionof a snapshot) before you apply new events to it. - Where a handler reads
this.version, add one to get the version of its event, as in 3.x. Where a handler applies an event and then reads state that the handler of that event changes, move that code into the handler of the applied event. - Where you compared ids of different classes with
equals(), compare theirvalues. Where you matchedInvalidIdException.idTypeagainst'UUID'or'ULID', expect the name of your id class. - Give an id class whose constructor takes more than the id its own
generate(),from()andfactory(). - If you relied on
SnapshotRepository.save()rejecting, watch the logs instead.
8. Publishing
Section titled “8. Publishing”See Event Publishers and Subscribers.
- Publishers are awaited. The bus awaits the promise that a publisher’s
publishreturns before it passes that publisher the next envelope of the append, andappendEventsresolves once every publisher is done. 3.x fired them and forgot. The publishers run concurrently, and each call may take at mostpublishing.publisherTimeoutmilliseconds (30 s by default,0disables it): after that the bus logs and reports a timeout and moves on. Each publisher gets the appends of a stream in the order in which they were stored, one after the other, even when they run concurrently; appends to other streams don’t wait for each other. A slow publisher now slows down your commands: each call up to that timeout, and an append to a stream also waits for the calls of the earlier appends to that stream that the publisher still handles. A publisher may implementpublishAll(envelopes)to get the envelopes of an append in one call. eventBus.publish()returns a promise, which resolves once the publishers are done and never rejects because of a publisher or subscriber. The envelopes of an append go througheventBus.publishAll(), not throughpublish(): to see what an append published in a test, register a publisher or subscribe to the bus instead of spying oneventBus.publish.eventBus.whenIdle()resolves once no publisher or subscriber is running. In tests,await eventBus.whenIdle()replaces waiting for a fixed time for the subscribers.eventBus.deliveryErrors$emits every publisher and subscriber failure, a timeout included. They are still logged.- Shutdown.
app.close()waits for the running publishers and subscribers, for at mostpublishing.shutdownTimeoutmilliseconds (10 s by default), then unsubscribes the subscribers and disconnects the stores. 3.x unsubscribed and disconnected inonModuleDestroy, which dropped the deliveries in flight. NestJS still runsonModuleDestroyfirst, so if your subscribers use providers that clean up there,await eventBus.whenIdle()beforeapp.close(). IEventPublisher.publishreturnsunknowninstead ofanyand no longer declares rest parameters, which the bus never passed.IEventBus.publishreturnsPromise<void>.- Invalid timeouts (negative,
NaN, not a number) fail the bootstrap with aRangeError.
To migrate:
-
In tests, wait for the subscribers with
await eventBus.whenIdle()instead of a fixed delay:// 3.xawait commandBus.execute(command);await new Promise((resolve) => setTimeout(resolve, 50));// 4.0await commandBus.execute(command);await eventBus.whenIdle(); -
Replace spies on
eventBus.publishwith a publisher of your own or a subscription to the bus, andawait eventBus.publish(envelope)where you publish directly. -
If a publisher can take longer than 30 s, raise
publishing.publisherTimeout, or set it to0:EventSourcingModule.forRoot({events: [AccountOpenedEvent],publishing: { publisherTimeout: 60_000, shutdownTimeout: 30_000 },}); -
If your subscribers use providers that clean up in
onModuleDestroy, which NestJS runs before the bus drains, callawait eventBus.whenIdle()beforeapp.close().
9. Serialization
Section titled “9. Serialization”See Event Serialization.
-
A JSON serializer is the default.
JsonEventSerializerreplaces class-transformer as the serializer of every event without an@EventSerializer()of its own. For events that use no class-transformer decorators, neither on the event class nor on the classes of the values it holds, it stores the same payloads and reads back the same events as 3.x, so stored events need no migration: nested class instances, value objects such as ids included, are stored and read back as plain objects, aDatestays aDatein the payload (the SQL stores read back its ISO string), aSetbecomes an array and aMapan object. The values that the MongoDB driver returns as instances of its classes (aBinaryfor a buffer, aLongfor an integer beyond 2^53, anObjectId) read back as those instances, as in 3.x; so does aDecimal128, which 3.x failed to read. -
class-transformer decorators can’t lose their effect silently. The JSON serializer ignores them, so the events whose payload they would change fail with an error that names the event, the decorators and the fix:
- at bootstrap, an event class or a parent class with
@Type,@Transform,@Exposeor@Exclude: anEventSourcingConfigurationExceptionwith aclass-transformer-decoratorsissue for every such event (see Module configuration); - when you append an event that holds an instance of a class whose decorators changed the payload that 3.x stored: a value object or DTO with
@Expose,@Exclude,@Transform, or a@Typewith a discriminator. The append fails with anEventSerializationException(reason: 'class-transformer-decorators') before it writes anything, and the exception’spathnames the property that holds the instance. The bootstrap can’t find these classes, because nothing refers to them until an event holds one. Without this check, an@Exclude()d field would be stored.
Serialize those events with class-transformer, as 3.x did:
import { ClassTransformerEventSerializer } from '@ocoda/event-sourcing/class-transformer';EventSourcingModule.forRoot({ events, defaultEventSerializer: ClassTransformerEventSerializer });The checks see the decorators that reach class-transformer the way Node loads it. A bundler that resolves class-transformer’s ES module build keeps a second copy of its metadata, which the checks can’t see.
- at bootstrap, an event class or a parent class with
-
class-transformer is an optional peer dependency. The core no longer installs it: install
class-transformer@^0.5.1if you useClassTransformerEventSerializeror class-transformer’s decorators. -
DefaultEventSerializeris removed from@ocoda/event-sourcing. UseJsonEventSerializer.for(MyEvent)orClassTransformerEventSerializer.for(MyEvent)instead. -
A circular reference in an event fails the append with an
EventSerializationException(reason: 'circular-reference') before anything is written. 3.x overflowed the stack. -
The JSON serializer accepts a few inputs that class-transformer threw a
TypeErrorfor: a payload key that matches a getter without a setter inherited from a parent class is skipped, and so is a nestedconstructorkey.
To migrate:
- Replace
DefaultEventSerializer.for(MyEvent)withJsonEventSerializer.for(MyEvent), or withClassTransformerEventSerializer.for(MyEvent)for an event with class-transformer decorators. - Look for class-transformer decorators on your event classes and on the classes of the values they hold (value objects, DTOs). If you find any, install
class-transformer@^0.5.1and setdefaultEventSerializer: ClassTransformerEventSerializer, or give those events an@EventSerializer()of their own. The bootstrap fails for an event class with decorators, and an append fails for an event that holds an instance of a class with decorators; both name the event. - If you bundle the application, the checks may not see the decorators: rely on step 2.
10. Reading all events
Section titled “10. Reading all events”See Reading all events.
readAll({ fromPosition, batch, pool }) replaces getAllEnvelopes({ since, until }). Every stored event has a globalPosition, a bigint per pool, starting at 1n; readAll yields a pool in that order, from fromPosition on (inclusive), in batches of batch (a positive integer, or a RangeError). Store the position of the last envelope you processed and resume at checkpoint + 1n. To read a period, filter on metadata.occurredOn. The data migration gives the stored 3.x events their positions, in the order in which getAllEnvelopes returned them.
// 3.xfor await (const envelopes of eventStore.getAllEnvelopes({ since: { year: 2024, month: 1 } })) { … }// 4.0for await (const envelopes of eventStore.readAll({ fromPosition: checkpoint + 1n })) { … }getAllEnvelopes, IAllEventsFilter, EventStore.getYearMonthRange and ULID.yearMonth are removed. For ULID.yearMonth, also on EventId, use id.date.toISOString().slice(0, 7). capabilities.globalOrder says whether a reader that tails the store can miss an event ('best-effort') or not ('gap-safe'); the in-memory store, PostgreSQL, MariaDB (and, from 4.1, MySQL) on a single InnoDB server or primary, and MongoDB replica sets are gap-safe. Multi-writer and distributed deployments, such as a Galera cluster, TiDB or Vitess, and the engines that speak MongoDB’s protocol without being MongoDB report 'best-effort': see Other databases.
11. Envelope metadata and JSON
Section titled “11. Envelope metadata and JSON”See Envelopes.
An append can set a correlationId, a causationId and headers for all its events:
await eventStore.appendEvents(stream, events, { expectedVersion: account.committedVersion, metadata: { correlationId: request.id, causationId: command.id, headers: { tenant: 'acme' } },});- The envelopes store and return them, together with the new
eventVersionandglobalPositionfields of the metadata. JSON.stringify(envelope)writes the event id as a string, such as"01JA50F56AM0CCDBNVQW3TTWNY", where 3.x wrote{ "props": { "value": … } }, and theglobalPositionas a decimal string. If you send envelopes as JSON, readmetadata.eventIdinstead ofmetadata.eventId.props.valuein the consumers, and turn it back into an id withEventId.from(). What the stores write is unchanged: see Envelopes.- Header keys are not empty and don’t start with
$, which is reserved for the library; values are strings, finite numbers, booleans ornull; the headers of an event are at most 8 KiB of JSON. The correlation and causation ids are at most 255 characters. Invalid metadata throws anInvalidEventMetadataExceptionbefore any I/O. - A store without the
headerscapability rejects headers with anUnsupportedOperationExceptioninstead of dropping them. Every built-in store has it. - Pre-built envelopes keep their own metadata,
$headers included; the metadata of the append only fills the fields they lack. EventEnvelope.create()keeps the generated event id wheneventIdisundefined, and takes an optionaloccurredOn.
12. Snapshot stores
Section titled “12. Snapshot stores”See Snapshot store for the whole contract. SnapshotRepository already awaited the store, so a repository needs no change for this step; code that calls a snapshot store directly does.
- Promises only. Every method of a snapshot store returns a promise (or an async generator), and fails by rejecting. The in-memory store’s
getSnapshot,getLastSnapshot,getLastSnapshots,getLastEnvelope,getEnvelopeandgetManyLastSnapshotEnvelopesreturned their result directly in 3.x:awaitthem. A failure, such as theSnapshotNotFoundExceptionofgetSnapshot, is now a rejection instead of a synchronous throw. SnapshotStoreDriveris the class of a store,new (options) => SnapshotStore, andSnapshotStoreConfig.driverhas that type. In 3.x it was an interface for the store instance, withconnect,disconnectandensureCollection. Type a store instance asSnapshotStoreinstead.- Base defaults.
getManyLastSnapshotEnvelopeshas a default in the base class, which reads the streams one by one withgetLastEnvelope, soSnapshotRepository.loadMany()works with every store.getLastEnvelopesForAggregatehas a default that rejects with anUnsupportedOperationExceptionwhen it’s read, whichloadAll()passes on. The repository no longer checks whether the store has these methods. - The last snapshot is the highest version.
getLastSnapshot,getLastEnvelopeand their bulk forms read the snapshot with the highest version of the stream, and appends to one stream keep a single latest snapshot even when they race: the PostgreSQL, MariaDB and MongoDB stores enforce it with a unique index of their schema v2. loadAllpages in binary order.getLastEnvelopesForAggregate, and soloadAll, reads the streams in descending binary order of their aggregate ids, which is case-sensitive: uppercase letters come before lowercase ones. TheaggregateIdof the filter is an exclusive cursor: pass the aggregate id of the last envelope of a page to read the next page. The in-memory store ordered the ids ignoring case in 3.x, and the PostgreSQL, MariaDB and MongoDB stores ignored the cursor.
// 3.xconst snapshot = snapshotStore.getSnapshot(stream, 10);// 4.0const snapshot = await snapshotStore.getSnapshot(stream, 10);MariaDB: snapshot order of loadAll
Section titled “MariaDB: snapshot order of loadAll”On MariaDB, getLastEnvelopesForAggregate, and so SnapshotRepository.loadAll, returns the streams in descending order of their stream id, like the other stores. The 3.x query had no ORDER BY, so MariaDB returned them in index order, which in practice was ascending.
This reverses the order of loadAll on MariaDB, and a limit now returns the streams with the highest stream ids instead of the lowest. If you relied on the old order, sort the results yourself.
13. Custom stores
Section titled “13. Custom stores”See Custom Event and Snapshot Stores.
- Event stores implement driver methods. The store is constructed with
(context, options), wherecontextholds the event map and the publisher, andoptionsno longer includesuseDefaultPool.EventStoreDriveris now the type of that constructor rather than an interface of the store instance. A store implements the driver methods (getStreamVersion,getEnvelope,getEnvelopes,readAll,persistEvents) and declares itscapabilities; the base class implementsappendEvents,getEventandgetEvents, and a store that overrides one of them fails the bootstrap with anInvalidEventStoreImplementationException.this.eventMapis gone: the base class serializes and deserializes the events. - Snapshot stores are async. Every method returns a promise,
getEnvelopeandgetEnvelopesare required, andgetLastEnvelopesForAggregateorders by the aggregate id in binary, descending order, withfilter.aggregateIdas an exclusive cursor. See Snapshot stores. - Exceptions are constructed with one object argument: see Errors.
To migrate an event store:
- The constructor becomes
(context, options). Remove yours, or pass both arguments on. - Delete
appendEvents,getEvents,getEventand every use ofthis.eventMap. - Implement
getStreamVersion,getEnvelope,getEnvelopes,readAllandpersistEvents.readAllreplacesgetAllEnvelopes, and with it the year-month column,getYearMonthRangeandIAllEventsFilter.persistEventsstores the envelopes all or nothing, assigns their global positions, and maps a duplicate(stream, version)key to{ status: 'conflict' }. - Make every method
async, store the correlation id, causation id, headers and event version, and declare yourcapabilities. - To decorate the appends of a built-in store, for tracing for instance, extend it and override
persistEventsinstead ofappendEvents.
To migrate a snapshot store:
- Make every method
async. ImplementgetEnvelopeandgetEnvelopes. - Drop your
getManyLastSnapshotEnvelopesif it only looped overgetLastEnvelope: the base class does that. - If you implement
getLastEnvelopesForAggregate, order by the aggregate id in binary, descending order, and treatfilter.aggregateIdas an exclusive cursor. - Replace uses of the
SnapshotStoreDriverinterface withSnapshotStore.
Testing a custom store
Section titled “Testing a custom store”The conformance suites that every built-in store runs are published as @ocoda/event-sourcing/testing. They register Vitest tests, so vitest (4 or 5) is an optional peer dependency of @ocoda/event-sourcing: install it to use the subpath. The suites import Vitest’s API, so they need no globals: true, and the root entry point never loads Vitest.
npm checks an optional peer whenever the project has that package: a project on another major of Vitest gets ERESOLVE from npm install @ocoda/event-sourcing, even if it never imports the subpath, until it moves to Vitest 4 or 5 or installs with --legacy-peer-deps. pnpm and Yarn only warn.
As the last step of moving a custom store to 4.0, run the suites against it:
import { describeEventStoreConformance, describeSnapshotStoreConformance } from '@ocoda/event-sourcing/testing';
describeEventStoreConformance('FooEventStore', async (context) => { const store = new FooEventStore(context, { ... }); await store.connect(); return { store, cleanup: async (collections) => { /* drop them, then disconnect */ } };});
describeSnapshotStoreConformance('FooSnapshotStore', async () => { const store = new FooSnapshotStore({ ... }); await store.connect(); return { store, cleanup: async (collections) => { /* drop them, then disconnect */ } };});- Declare
capabilitiesinstead of skipping cases: a case that needs a guarantee your store doesn’t claim is skipped withcapability: <flag>.skipremains for real gaps, and each skip needs a reason. - A snapshot store that keeps the base class’s
getLastEnvelopesForAggregateskips the cases of that read the same way. - The subpath also has
createInMemoryEventStore(),createInMemorySnapshotStore(),createTestEventStoreContext()and theRecordingPublisher, for tests that use the in-memory stores or construct a store without the module.
14. Schema v2 and the data migration
Section titled “14. Schema v2 and the data migration”The PostgreSQL, MariaDB and MongoDB stores keep their events and snapshots in schema v2: the event tables gain a global position per pool, headers and event_version, and lose the 3.x year-month column; a catalog, event_sourcing_collections, registers every table and counts the positions of each pool; and a unique index keeps one latest snapshot per stream. The design is in ADR 0002.
The stores never migrate on their own. The event store refuses a 3.x event table with an EventStoreSchemaException (found: 'v1'), so an application whose default pool is a 3.x table fails its bootstrap until the table is migrated. A 3.x snapshot table keeps working, with a warning, until it is migrated. Each store has a static migrate(config, options) that needs no Nest application (and store.migrate(options) on a connected store), with a dryRun that writes nothing, and each package ships the same migration as a script for DBAs: migrations/4.0.sql, or migrations/4.0.mongosh.js for MongoDB. A migration that stopped halfway can be run again: it continues where it stopped.
For every store, the cutover has the same shape:
- Rehearse. On a copy of the production data, run the dry runs with the 4.0 package and review the reports: what blocks a table (
blocking), the streams with gaps in their versions (gappedStreams), the time and space the migration needs, and the exact statements. - Take a backup.
- Stop every 3.x instance. The migration is offline, and a 3.x instance that still writes afterwards fails instead of writing events without positions.
- Migrate the events, then the snapshots. Stop the cutover while a table is reported
blocked. - Deploy 4.0.
The events keep 3.x’s order as their global positions, 1n, 2n, …, with every stream in version order, so readAll returns them the way getAllEnvelopes did.
PostgreSQL
Section titled “PostgreSQL”See PostgreSQL, with the runbook, the schema, the privileges and the throughput per pool.
import { PostgresEventStore, PostgresSnapshotStore } from '@ocoda/event-sourcing-postgres';
// Rehearse: writes nothing. legacyTimeZone is the time zone your 3.x instances ran in.await PostgresEventStore.migrate(config, { dryRun: true });await PostgresSnapshotStore.migrate(config, { dryRun: true, legacyTimeZone: 'Europe/Brussels' });// Back up, stop every 3.x instance, then:await PostgresEventStore.migrate(config);await PostgresSnapshotStore.migrate(config, { legacyTimeZone: 'Europe/Brussels' });- Events. The tables gain
global_position(aBIGINT, unique per pool),headers(JSONB) andevent_version, and loseevent_dateand its index. The text columns becomeTEXT. - Snapshots.
registered_onbecomes aTIMESTAMPTZ, converted fromlegacyTimeZone, and a unique index keeps onelatestflag per stream. - The migration runs one transaction per table under an exclusive lock, and keeps the table’s OID (grants and publications survive) and the columns you added. A
blockedtable is reported, not thrown. Plan for about 0.5 minutes per million events under the lock (a provisional figure, extrapolated from 100,000 events), 3 times the table’s size in free disk, and aTRUNCATEplus an insert of every row in publications; migrate the tables of a logical replication subscriber first. A 3.x instance that still writes afterwards fails (column "event_date" does not exist). - The catalog.
listCollections()lists the tables the catalog registers, so 3.x tables no longer show up until they are migrated. - New option
ddl:'auto'(default) creates missing tables and the catalog;'none'never runs DDL and throws with the statements to run instead. With'none', grantUSAGEon the schema,SELECT,INSERTandUPDATEonevent_sourcing_collectionsand the snapshot tables, andSELECTandINSERTon the event tables. - Reads of a pool without a table throw an
EventCollectionNotFoundException, and an append to a pool that isn’t registered throws anEventStorePersistenceExceptionwithoutcome: 'not-persisted'. A pool name whose table name is over 63 bytes is rejected. - A
bigintin an event payload fails the append (not-persisted); store large integers as strings. - Throughput. Appends to one pool serialize on the pool’s position counter. Spread heavy write loads over pools, and set an
idle_in_transaction_session_timeoutfor the store’s role, so that a writer that stalls in an append can’t hold up its pool for long. An append is no longer limited to about 6,500 events.
MariaDB
Section titled “MariaDB”See MariaDB and MySQL, with the runbook, the schema, the capabilities and the privileges.
import { MariaDBEventStore, MariaDBSnapshotStore } from '@ocoda/event-sourcing-mariadb';
// Rehearse: writes nothing. Review blocking, gappedStreams and canonicalizedStreams.await MariaDBEventStore.migrate(config, { dryRun: true });await MariaDBSnapshotStore.migrate(config, { dryRun: true });// Back up, stop every 3.x instance, then:await MariaDBEventStore.migrate(config);await MariaDBSnapshotStore.migrate(config);// Deploy 4.0. Once satisfied, drop the 3.x backups:await MariaDBEventStore.migrate(config, { keepBackup: false });- Events. The event tables are copied into schema v2 and swapped in. They gain
global_position,headersandevent_versionand loseevent_date. The 3.x tables are kept as<table>__es_v1backups until a run withkeepBackup: falsedrops them: keep them until the snapshots are migrated, which take the stream ids of their events from them.occurred_onkeeps milliseconds (3.x truncated it to the second) as the UTC wall time; the migration restores it to the millisecond from the event ids, which also corrects the hours 3.x was off when the application ran in another time zone than the database server. - Snapshots. The snapshot tables are converted in place, and every stream gets exactly one latest snapshot, its highest version.
registered_onkeeps milliseconds. - Stream ids are case-sensitive. The tables compare text in binary (
utf8mb4_bin), like the other stores; the 3.x tables used the database’s default collation, which usually ignores case. The migration gives a 3.x stream whose rows have ids that differ in case only (account-Acc-1version 1,account-acc-1version 2) one stream id, the id of its lowest version, and its snapshots the id of its events; from then on the application must use those ids. The dry run lists them (canonicalizedStreams, see Stream ids that differ in case only). Stream ids, aggregate ids and event names can be up to 255 characters. - The migration needs no server settings changed, only room for the copy: plan for about 3 minutes per million events (a provisional estimate), and free space of 1.5 times the event tables in the data directory and again in
tmpdir. A user withALL PRIVILEGESon the database runs it; a missing privilege of the copy or the swap fails it before the copy. On Galera it sizes its replication fragments forwsrep_max_ws_size. After the migration, a 3.x instance fails to append events (error 1136), but its snapshot writes still succeed, so stop every 3.x instance before you migrate. - The migration runs on MariaDB only. On MySQL,
migrate()throws anUnsupportedOperationExceptionbefore it changes anything: see Migrating 3.x tables. - The catalog,
event_sourcing_collections, registers every event and snapshot table and holds each event pool’s position counter.listCollections()reads it, so it lists 3.x tables only once they are migrated. - A new
ddloption:'auto'(the default) creates the tables,'none'only checks and registers them, for users withoutCREATEprivileges, and reports the statements to run. - Capabilities:
{ atomicAppend: true, headers: true, globalOrder: 'gap-safe' }, and'best-effort'on a Galera cluster and the other deployments listed under Topologies and capabilities. - Appends to one pool are serialized by the pool’s position counter, one commit at a time. Use pools to scale writes (see Throughput).
- The stores’ sessions run in
READ COMMITTED, added after yourinitSql.readAllmust read from the server the appends run on: don’t route the event store through a proxy that sends reads to replicas.
MongoDB
Section titled “MongoDB”See MongoDB, with the migration steps, the schema, the capabilities per topology and the privileges.
import { MongoDBEventStore, MongoDBSnapshotStore } from '@ocoda/event-sourcing-mongodb';
// Rehearse: writes nothing.await MongoDBEventStore.migrate(config, { dryRun: true });await MongoDBSnapshotStore.migrate(config, { dryRun: true });// Back up, stop every 3.x instance, then:await MongoDBEventStore.migrate(config);await MongoDBSnapshotStore.migrate(config);- Events. Event documents gain a 64-bit
globalPosition,headersandeventVersion, and loseeventDate. Absent metadata is left out instead of stored asnull. A validator requires the position, so 3.x writers fail loudly on a migrated collection. The events keep 3.x’s order,(eventDate, _id). - Snapshots. A unique index keeps a single latest snapshot per stream, also when appends race. An unflagged snapshot has no
latestfield (3.x storednull). - The migration took about a minute per million events, measured with 100,000 events (larger collections sort on disk and take longer), and writes about half a GB of oplog per million; a run that stopped resumes. A sharded collection, a server before MongoDB 5.0 and ids that aren’t strings block it: see Migrating from 3.x for the room it needs and everything that blocks a collection.
- Capabilities depend on the topology, which
connect()detects: a replica set (a single-node one is enough) is{ atomicAppend: true, globalOrder: 'gap-safe' }, a sharded cluster is atomic but'best-effort', and a standalone server is{ atomicAppend: false, globalOrder: 'best-effort' }, with a warning when the store connects. Run MongoDB as a replica set. - A catalog, the
event_sourcing_collectionscollection, holds the position counter of each pool and the schema version of each collection.listCollections()reads it, so it lists only collections thatensureCollectionormigrate()registered. ddl: 'none'is a new option of both stores:ensureCollectionthen only checks and registers collections, and names the mongosh statements that create a missing one.disconnect()can be called more than once, and beforeconnect().
After the migration
Section titled “After the migration”- Streams with a gap in their versions conflict on their next append: 3.x accepted appends that left a gap, and 4.0 requires the stream to be at the expected version. The dry runs list them (
gappedStreams), and the conflict’sactualVersionis the version to append after. - MariaDB stream ids: use the ids that the report lists in
canonicalizedStreams. 4.0 reads a stream id that differs from them in case only as another, empty stream. - MariaDB backups: once you are satisfied, run
MariaDBEventStore.migrate(config, { keepBackup: false })to drop the<table>__es_v1backups. - Event ids that aren’t canonical ULIDs stay readable; the dry runs count them.
DynamoDB is not part of 4.0
Section titled “DynamoDB is not part of 4.0”The DynamoDB store (@ocoda/event-sourcing-dynamodb) is not released for 4.0. DynamoDB can’t give the events a gap-free global order, which the 4.0 read side relies on. You have two options:
- Stay on 3.x. 3.x keeps receiving security and critical fixes until at least 2027-03-31. Install
@ocoda/event-sourcing@3with@ocoda/event-sourcing-dynamodb@3, on NestJS 11. - Move your data to another store: PostgreSQL, MariaDB, MySQL (with the MariaDB store, from 4.1) or MongoDB; for managed services and compatible servers, see Other databases. There is no tool that exports the events from DynamoDB. A 4.0 store can import them as pre-built envelopes: build the envelope of each event with
EventEnvelope.create(), and append the envelopes of a stream in version order, with a numericexpectedVersionandpublish: false. They keep their event ids,occurredOnand metadata, and get global positions in the order you append them. See Appending events.
Further reading
Section titled “Further reading”- The changelog of each package has an entry per change, with its migration steps: the core, PostgreSQL, MariaDB and MongoDB.
- ADR 0001: v4 core API and ADR 0002: schema v2 record the design and the reasons behind these changes.
- The example application is a NestJS 12 application on PostgreSQL that uses the 4.0 API throughout.
MIT 2026 © Ocoda.