Skip to content

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.

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.

  1. If the database runs PostgreSQL 13 or MongoDB 6, upgrade the server while the application is still on 3.x.
  2. Upgrade the application to NestJS 12, Node.js 22.12 and TypeScript 5.4 or later.
  3. Install the 4.0 packages and the driver of every integration you use, and replace imports of paths inside the packages.
  4. 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 onModuleInit or later, and fix every problem that the bootstrap reports.
  5. Match library errors on their code and fields instead of their message or constructor.
  6. Adapt to the typed buses: execute() rejects instead of throwing, and the execute() of every decorated handler returns a promise.
  7. Save aggregates in three steps, with the options form of appendEvents, and adapt to the aggregate, snapshot repository and id changes.
  8. Adapt to the publishing changes: publishers are awaited, and tests wait with eventBus.whenIdle().
  9. Choose the event serializer: JsonEventSerializer is the default, and ClassTransformerEventSerializer serializes as 3.x did.
  10. Replace getAllEnvelopes() with readAll(), and read the envelope metadata the 4.0 way, also in the consumers of envelopes sent as JSON.
  11. Adapt code that calls a snapshot store directly and, on MariaDB, code that depends on the order of loadAll.
  12. Port custom stores to the driver methods, and check them with the conformance suites.
  13. Rehearse the data migration on a copy of the production data: run the dry runs and review their reports.
  14. Cut over: take a backup, stop every 3.x instance, migrate the events and then the snapshots, and deploy 4.0.
  15. 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.

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.

Install the core and the integrations you use, together with the database driver of each integration. For example, for PostgreSQL:

Terminal window
pnpm add @ocoda/event-sourcing@4 @ocoda/event-sourcing-postgres@4
pnpm add pg pg-cursor

The 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.

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 can require() 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.

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

Terminal window
pnpm add pg pg-cursor
pnpm add -D @types/pg @types/pg-cursor

MongoDB

Terminal window
pnpm add mongodb

MariaDB

Terminal window
pnpm add mariadb

The @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.

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.

See Module configuration.

  • Registration moves to onModuleInit. The handlers, subscribers, publishers and serializers are registered in the module’s onModuleInit, 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 its onModuleInit first. 3.x registered them in onApplicationBootstrap, so events appended from onModuleInit were never published. Commands, queries and appends now work from onModuleInit on.
  • Provider factories can’t use the library yet. Appending events (pre-built envelopes and publish: false included), reading events (getEvent, getEvents), executing or publishing from a provider factory or a constructor fails with an EventSourcingNotReadyException (ES_EVENT_SOURCING_NOT_READY), before any I/O: the handlers don’t exist yet. Move that code to onModuleInit or later. Reading envelopes (getEnvelope, getEnvelopes, readAll) and getStreamVersion still 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 onModuleInit runs, and a store that fails is disconnected again, as is the event store when the snapshot store fails. The error is thrown while NestFactory.create() instantiates the providers: with Nest’s default abortOnError: true, Nest logs it and exits with code 1 instead of rejecting app.listen(). Pass NestFactory.create(AppModule, { abortOnError: false }) to catch it; Test.createTestingModule().compile() rejects with it. The stores disconnect in their own onApplicationShutdown, after the EventBus has drained.
  • Misconfiguration fails the bootstrap with one EventSourcingConfigurationException (ES_EVENT_SOURCING_CONFIGURATION) whose issues list 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 with useFactory must use the handler class as its token. See request-scoped handlers.
  • Handlers provided with useFactory or useValue are discovered, by the class of their instance.
  • forFeature is 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. forRoot and forRootAsync return the global EventSourcingModule itself, built with Nest’s ConfigurableModuleBuilder; 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 typed forRoot<PostgresEventStoreConfig, …>() stay the same. The snapshot store’s driver no longer receives useDefaultPool.
  • forRootAsync({ useValue }) is deprecated (DeprecationWarning OCODA_ES_FOR_ROOT_ASYNC_USE_VALUE): use forRoot(options). Without useFactory, useClass or useExisting, forRootAsync throws an EventSourcingConfigurationException instead of a plain Error.
  • Implicit in-memory stores warn in production. When NODE_ENV is production and eventStore or snapshotStore is left out, the module logs a warning.
  • Removed: EventRegistry, ExplorerService and getOptionsToken(), which were reachable through paths inside the package in 3.x. Inject the options with @InjectEventSourcingOptions().

To migrate:

  1. Move code that appends, reads, executes or publishes from a provider factory or a constructor to onModuleInit or 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);
    }
    }
  2. 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 with ModuleRef.resolve()).

  3. Pass the request to request-scoped handlers: commandBus.execute(command, { request }).

  4. Replace forRootAsync({ useValue: options }) with forRoot(options), and getOptionsToken() with EVENT_SOURCING_OPTIONS or @InjectEventSourcingOptions().

  5. 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.

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 unique code from EventSourcingErrorCode, for example EventSourcingErrorCode.EventStoreVersionConflict ('ES_EVENT_STORE_VERSION_CONFLICT'). The class names are unchanged, so instanceof checks 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 does error instanceof EventSourcingError.
  • Fields. EventStoreVersionConflictException has streamId, aggregateId, pool, expectedVersion (the version the stream had to have before the append) and actualVersion (absent when the append lost a race on the unique key). EventStorePersistenceException has collection and an outcome: '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, and CommandHandlerNotFoundException names 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 actualVersion out when the append lost a race on the unique key. MongoDB throws an EventStorePersistenceException with outcome: 'unknown' (its cause is an AggregateError) 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.
  • UnsupportedOperationException replaces NotImplementedException. In 3.x, SnapshotRepository.loadMany() and loadAll() threw the NotImplementedException of @nestjs/common when the snapshot store lacked the method they need. loadMany() now works with every store (see Snapshot stores). loadAll() throws UnsupportedOperationException, with the method name in operation, when the store keeps the base getLastEnvelopesForAggregate. An HTTP exception filter that relied on the 501 status of the Nest exception should map the new code.
  • DomainException gets a name, the class name of your subclass, and takes the standard error options as a third argument: super(message, id, { cause }). It has no code, so your subclasses compile unchanged. InvalidIdException still extends it, and is also an EventSourcingError with 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 as InvalidIdException.becauseInvalid() and IdNotFoundException.withId(), still work, but are deprecated and will be removed in 5.0.
  • ExpectedVersion is a new export: ExpectedVersion.NoStream (0) and ExpectedVersion.Any, and the type of an expected version.

To migrate:

  1. Match errors on their code and fields rather than their message or constructor:

    // 3.x
    if (error.constructor === EventStoreVersionConflictException) { … }
    // 4.0
    if (isEventSourcingError(error, EventSourcingErrorCode.EventStoreVersionConflict)) {
    console.log(error.streamId, error.expectedVersion, error.actualVersion);
    }
  2. Code that constructs library exceptions, such as a custom store, passes one object and puts the underlying error in the options:

    // 3.x
    throw new EventStoreVersionConflictException(stream, aggregateVersion, currentVersion, error);
    throw new EventStorePersistenceException(collection, error);
    // 4.0
    throw new EventStoreVersionConflictException(
    { stream, expectedVersion: aggregateVersion - events.length, actualVersion: currentVersion, pool },
    { cause: error },
    );
    throw new EventStorePersistenceException({ collection, outcome: 'unknown' }, { cause: error });

    The expectedVersion of a conflict is the version the stream had to have before the append, not the aggregate version after it.

  3. Where you catch the errors of SnapshotRepository.loadAll(), replace NotImplementedException with UnsupportedOperationException, or match EventSourcingErrorCode.UnsupportedOperation. loadMany() no longer throws it.

  4. Read the underlying error of a wrapping exception from error.cause instead of from its stack.

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> and Query<TResult> are new base classes. For OpenAccountCommand extends Command<AccountId>, commandBus.execute(new OpenAccountCommand()) resolves to an AccountId without a type argument, ICommandHandler<OpenAccountCommand> resolves to it, and @CommandHandler(OpenAccountCommand) rejects a handler whose execute() resolves to something else. Command without a type argument is Command<void>, and Query needs 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 Command or Query still works, and execute() resolves to any for it. Both 3.x generic forms compile: execute<AddBookCommand>(command) resolves to any, and execute<OpenAccountCommand, AccountId>(command) to an AccountId. The result type is not inferred from the variable it is assigned to, so const 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() use NoInfer, which TypeScript 5.4 introduced. An older compiler reports Cannot find name 'NoInfer', or, with skipLibCheck, types every result as any.
  • ICommand and IQuery are object, not any, so a primitive, null or undefined no longer type-checks as a command or query. Neither does a value typed unknown or an unconstrained type parameter: constrain it, as in dispatch<C extends ICommand>(command: C).
  • execute() rejects instead of throwing. A command or query without a handler rejects with a CommandHandlerNotFoundException or QueryHandlerNotFoundException and 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 a MissingCommandMetadataException or MissingQueryMetadataException. Those two exceptions, getCommandMetadata(), getQueryMetadata(), CommandMetadata and QueryMetadata are deprecated and will be removed in 5.0. commandBus.bind(handler, command) and queryBus.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 an execute() method that returns a promise. They return a decorator typed for the handler class instead of a ClassDecorator, so applyDecorators() of @nestjs/common doesn’t accept them under strictFunctionTypes. 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:

  1. Assert the failures of execute() as rejections, for example in tests:

    // 3.x
    expect(() => commandBus.execute(new UnhandledCommand())).toThrow(CommandHandlerNotFoundException);
    // 4.0
    await expect(commandBus.execute(new UnhandledCommand())).rejects.toThrow(CommandHandlerNotFoundException);
  2. Where you catch a MissingCommandMetadataException or MissingQueryMetadataException, catch the not-found exception, or match EventSourcingErrorCode.CommandHandlerNotFound and EventSourcingErrorCode.QueryHandlerNotFound.

  3. 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.

  4. Make the execute() of every decorated handler return a promise, for example by making it async.

  5. 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 to applyDecorators().

  6. Optionally, extend Command and Query, and drop the type arguments of execute(). A constructor then calls super():

    // 3.x
    export class OpenAccountCommand implements ICommand {
    constructor(public readonly accountOwnerIds?: string[]) {}
    }
    const accountId = await commandBus.execute<OpenAccountCommand, AccountId>(new OpenAccountCommand());
    // 4.0
    export 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 as import { Query as QueryParam } from '@nestjs/common'.

See Aggregates, Repositories, Event Store and Ids.

AggregateRoot gets committedVersion, getUncommittedEvents() and markCommitted(). A repository saves in three steps, and marks the events as committed only once the append succeeded:

// 3.x
const events = account.commit();
await this.eventStore.appendEvents(stream, account.version, events, pool);
// 4.0
const 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 of getUncommittedEvents(). 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 an UncommittedEventsException and 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 reads this.version gets 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 the version setter throw an UncommittedEventsException (ES_UNCOMMITTED_EVENTS) while the aggregate has uncommitted events, and so does applyEvent(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 a MissingEventHandlerException as before.

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 append
await this.eventStore.appendEvents(stream, account.version, events, pool);
// 4.0: the version of the stream before the append
await 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 an InvalidEventEnvelopeException. They keep their id, occurredOn and metadata.
  • Invalid arguments throw before any I/O: an InvalidAppendOptionsException for 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 an InvalidEventEnvelopeException for a stream id, aggregate id or event name over 255 characters.
  • The failure of the version check’s read is an EventStorePersistenceException with outcome: '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.

  • 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 deprecated commit() calls it), so call it before save(). With an interval of 10, a save from version 9 to 11 still takes a snapshot, and a save whose markCommitted() 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() and ULID.factory() create an instance of the class they are called on. AccountId.generate() returns an AccountId, where 3.x returned a UUID, also when the factory is passed around, as in values.map(AccountId.from). They call new this(value), so an id class whose constructor takes anything other than the id alone must declare its own generate(), from() and factory(). 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 a private constructor protected, or the inherited factories no longer compile. Passed around, a factory is typed as the base class (values.map(AccountId.from) is a UUID[] that holds AccountIds): write values.map((value) => AccountId.from(value)) for an AccountId[].
  • Ids of different classes are never equal, even with the same value: AccountId.from(value).equals(UUID.from(value)) is false, where 3.x compared two UUIDs. Compare the values 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.idType names the class the value was given to, such as 'AccountId', instead of 'UUID', and so does the message.
  • ULID.yearMonth is removed, also on EventId: use id.date.toISOString().slice(0, 7).
  • ulidFactory is deprecated: it has always been a no-op. Use ULID.factory(). It is removed in 5.0.
  • ValueObject.equals() returns false for null and undefined instead of throwing a TypeError, and compares the keys of the props, not only their number.

To migrate:

  1. Save aggregates in the three steps above, and call markCommitted() before snapshotRepository.save().
  2. Load an aggregate (loadFromHistory(), or the version of a snapshot) before you apply new events to it.
  3. 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.
  4. Where you compared ids of different classes with equals(), compare their values. Where you matched InvalidIdException.idType against 'UUID' or 'ULID', expect the name of your id class.
  5. Give an id class whose constructor takes more than the id its own generate(), from() and factory().
  6. If you relied on SnapshotRepository.save() rejecting, watch the logs instead.

See Event Publishers and Subscribers.

  • Publishers are awaited. The bus awaits the promise that a publisher’s publish returns before it passes that publisher the next envelope of the append, and appendEvents resolves once every publisher is done. 3.x fired them and forgot. The publishers run concurrently, and each call may take at most publishing.publisherTimeout milliseconds (30 s by default, 0 disables 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 implement publishAll(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 through eventBus.publishAll(), not through publish(): to see what an append published in a test, register a publisher or subscribe to the bus instead of spying on eventBus.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 most publishing.shutdownTimeout milliseconds (10 s by default), then unsubscribes the subscribers and disconnects the stores. 3.x unsubscribed and disconnected in onModuleDestroy, which dropped the deliveries in flight. NestJS still runs onModuleDestroy first, so if your subscribers use providers that clean up there, await eventBus.whenIdle() before app.close().
  • IEventPublisher.publish returns unknown instead of any and no longer declares rest parameters, which the bus never passed. IEventBus.publish returns Promise<void>.
  • Invalid timeouts (negative, NaN, not a number) fail the bootstrap with a RangeError.

To migrate:

  1. In tests, wait for the subscribers with await eventBus.whenIdle() instead of a fixed delay:

    // 3.x
    await commandBus.execute(command);
    await new Promise((resolve) => setTimeout(resolve, 50));
    // 4.0
    await commandBus.execute(command);
    await eventBus.whenIdle();
  2. Replace spies on eventBus.publish with a publisher of your own or a subscription to the bus, and await eventBus.publish(envelope) where you publish directly.

  3. If a publisher can take longer than 30 s, raise publishing.publisherTimeout, or set it to 0:

    EventSourcingModule.forRoot({
    events: [AccountOpenedEvent],
    publishing: { publisherTimeout: 60_000, shutdownTimeout: 30_000 },
    });
  4. If your subscribers use providers that clean up in onModuleDestroy, which NestJS runs before the bus drains, call await eventBus.whenIdle() before app.close().

See Event Serialization.

  • A JSON serializer is the default. JsonEventSerializer replaces 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, a Date stays a Date in the payload (the SQL stores read back its ISO string), a Set becomes an array and a Map an object. The values that the MongoDB driver returns as instances of its classes (a Binary for a buffer, a Long for an integer beyond 2^53, an ObjectId) read back as those instances, as in 3.x; so does a Decimal128, 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, @Expose or @Exclude: an EventSourcingConfigurationException with a class-transformer-decorators issue 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 @Type with a discriminator. The append fails with an EventSerializationException (reason: 'class-transformer-decorators') before it writes anything, and the exception’s path names 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.

  • class-transformer is an optional peer dependency. The core no longer installs it: install class-transformer@^0.5.1 if you use ClassTransformerEventSerializer or class-transformer’s decorators.

  • DefaultEventSerializer is removed from @ocoda/event-sourcing. Use JsonEventSerializer.for(MyEvent) or ClassTransformerEventSerializer.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 TypeError for: a payload key that matches a getter without a setter inherited from a parent class is skipped, and so is a nested constructor key.

To migrate:

  1. Replace DefaultEventSerializer.for(MyEvent) with JsonEventSerializer.for(MyEvent), or with ClassTransformerEventSerializer.for(MyEvent) for an event with class-transformer decorators.
  2. 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.1 and set defaultEventSerializer: 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.
  3. If you bundle the application, the checks may not see the decorators: rely on step 2.

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.x
for await (const envelopes of eventStore.getAllEnvelopes({ since: { year: 2024, month: 1 } })) { … }
// 4.0
for 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.

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 eventVersion and globalPosition fields of the metadata.
  • JSON.stringify(envelope) writes the event id as a string, such as "01JA50F56AM0CCDBNVQW3TTWNY", where 3.x wrote { "props": { "value": … } }, and the globalPosition as a decimal string. If you send envelopes as JSON, read metadata.eventId instead of metadata.eventId.props.value in the consumers, and turn it back into an id with EventId.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 or null; 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 an InvalidEventMetadataException before any I/O.
  • A store without the headers capability rejects headers with an UnsupportedOperationException instead 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 when eventId is undefined, and takes an optional occurredOn.

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, getEnvelope and getManyLastSnapshotEnvelopes returned their result directly in 3.x: await them. A failure, such as the SnapshotNotFoundException of getSnapshot, is now a rejection instead of a synchronous throw.
  • SnapshotStoreDriver is the class of a store, new (options) => SnapshotStore, and SnapshotStoreConfig.driver has that type. In 3.x it was an interface for the store instance, with connect, disconnect and ensureCollection. Type a store instance as SnapshotStore instead.
  • Base defaults. getManyLastSnapshotEnvelopes has a default in the base class, which reads the streams one by one with getLastEnvelope, so SnapshotRepository.loadMany() works with every store. getLastEnvelopesForAggregate has a default that rejects with an UnsupportedOperationException when it’s read, which loadAll() passes on. The repository no longer checks whether the store has these methods.
  • The last snapshot is the highest version. getLastSnapshot, getLastEnvelope and 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.
  • loadAll pages in binary order. getLastEnvelopesForAggregate, and so loadAll, reads the streams in descending binary order of their aggregate ids, which is case-sensitive: uppercase letters come before lowercase ones. The aggregateId of 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.x
const snapshot = snapshotStore.getSnapshot(stream, 10);
// 4.0
const snapshot = await snapshotStore.getSnapshot(stream, 10);

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.

See Custom Event and Snapshot Stores.

  • Event stores implement driver methods. The store is constructed with (context, options), where context holds the event map and the publisher, and options no longer includes useDefaultPool. EventStoreDriver is 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 its capabilities; the base class implements appendEvents, getEvent and getEvents, and a store that overrides one of them fails the bootstrap with an InvalidEventStoreImplementationException. this.eventMap is gone: the base class serializes and deserializes the events.
  • Snapshot stores are async. Every method returns a promise, getEnvelope and getEnvelopes are required, and getLastEnvelopesForAggregate orders by the aggregate id in binary, descending order, with filter.aggregateId as an exclusive cursor. See Snapshot stores.
  • Exceptions are constructed with one object argument: see Errors.

To migrate an event store:

  1. The constructor becomes (context, options). Remove yours, or pass both arguments on.
  2. Delete appendEvents, getEvents, getEvent and every use of this.eventMap.
  3. Implement getStreamVersion, getEnvelope, getEnvelopes, readAll and persistEvents. readAll replaces getAllEnvelopes, and with it the year-month column, getYearMonthRange and IAllEventsFilter. persistEvents stores the envelopes all or nothing, assigns their global positions, and maps a duplicate (stream, version) key to { status: 'conflict' }.
  4. Make every method async, store the correlation id, causation id, headers and event version, and declare your capabilities.
  5. To decorate the appends of a built-in store, for tracing for instance, extend it and override persistEvents instead of appendEvents.

To migrate a snapshot store:

  1. Make every method async. Implement getEnvelope and getEnvelopes.
  2. Drop your getManyLastSnapshotEnvelopes if it only looped over getLastEnvelope: the base class does that.
  3. If you implement getLastEnvelopesForAggregate, order by the aggregate id in binary, descending order, and treat filter.aggregateId as an exclusive cursor.
  4. Replace uses of the SnapshotStoreDriver interface with SnapshotStore.

See Test your Event 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 capabilities instead of skipping cases: a case that needs a guarantee your store doesn’t claim is skipped with capability: <flag>. skip remains for real gaps, and each skip needs a reason.
  • A snapshot store that keeps the base class’s getLastEnvelopesForAggregate skips the cases of that read the same way.
  • The subpath also has createInMemoryEventStore(), createInMemorySnapshotStore(), createTestEventStoreContext() and the RecordingPublisher, for tests that use the in-memory stores or construct a store without the module.

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:

  1. 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.
  2. Take a backup.
  3. 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.
  4. Migrate the events, then the snapshots. Stop the cutover while a table is reported blocked.
  5. 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.

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 (a BIGINT, unique per pool), headers (JSONB) and event_version, and lose event_date and its index. The text columns become TEXT.
  • Snapshots. registered_on becomes a TIMESTAMPTZ, converted from legacyTimeZone, and a unique index keeps one latest flag 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 blocked table 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 a TRUNCATE plus 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', grant USAGE on the schema, SELECT, INSERT and UPDATE on event_sourcing_collections and the snapshot tables, and SELECT and INSERT on the event tables.
  • Reads of a pool without a table throw an EventCollectionNotFoundException, and an append to a pool that isn’t registered throws an EventStorePersistenceException with outcome: 'not-persisted'. A pool name whose table name is over 63 bytes is rejected.
  • A bigint in 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_timeout for 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.

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, headers and event_version and lose event_date. The 3.x tables are kept as <table>__es_v1 backups until a run with keepBackup: false drops them: keep them until the snapshots are migrated, which take the stream ids of their events from them. occurred_on keeps 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_on keeps 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-1 version 1, account-acc-1 version 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 with ALL PRIVILEGES on 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 for wsrep_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 an UnsupportedOperationException before 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 ddl option: 'auto' (the default) creates the tables, 'none' only checks and registers them, for users without CREATE privileges, 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 your initSql. readAll must read from the server the appends run on: don’t route the event store through a proxy that sends reads to replicas.

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, headers and eventVersion, and lose eventDate. Absent metadata is left out instead of stored as null. 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 latest field (3.x stored null).
  • 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_collections collection, holds the position counter of each pool and the schema version of each collection. listCollections() reads it, so it lists only collections that ensureCollection or migrate() registered.
  • ddl: 'none' is a new option of both stores: ensureCollection then only checks and registers collections, and names the mongosh statements that create a missing one.
  • disconnect() can be called more than once, and before connect().
  • 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’s actualVersion is 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_v1 backups.
  • Event ids that aren’t canonical ULIDs stay readable; the dry runs count them.

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@3 with @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 numeric expectedVersion and publish: false. They keep their event ids, occurredOn and metadata, and get global positions in the order you append them. See Appending events.