├── .editorconfig ├── .github └── workflows │ ├── audit.yml │ ├── coveralls.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SUMMARY.md ├── book.json ├── docs ├── README.md ├── entities │ ├── Aggregate │ │ ├── CommandHandlers.md │ │ ├── Dependencies.md │ │ ├── README.md │ │ ├── Snapshots.md │ │ └── State.md │ ├── EventReceptor │ │ └── README.md │ ├── Messages │ │ └── README.md │ ├── Projection │ │ ├── InMemoryView.md │ │ └── README.md │ ├── README.md │ └── Saga │ │ └── README.md ├── images │ ├── README.md │ └── node-cqrs-components.png ├── infrastructure │ └── README.md └── middleware │ ├── AggregateCommandHandler.md │ ├── DIContainer.md │ └── README.md ├── eslint.config.mjs ├── examples ├── user-domain-tests │ ├── .eslintrc.json │ └── index.test.js └── user-domain │ ├── UserAggregate.js │ ├── UsersProjection.js │ └── index.js ├── jest.config.ts ├── jsconfig.json ├── package-lock.json ├── package.json ├── scripts └── changelog │ ├── commits.json │ ├── index.js │ └── templates │ ├── commit.hbs │ ├── header.hbs │ └── template.hbs ├── src ├── AbstractAggregate.ts ├── AbstractProjection.ts ├── AbstractSaga.ts ├── AggregateCommandHandler.ts ├── CommandBus.ts ├── CqrsContainerBuilder.ts ├── Event.ts ├── EventDispatcher.ts ├── EventStore.ts ├── EventValidationProcessor.ts ├── SagaEventHandler.ts ├── in-memory │ ├── InMemoryEventStorage.ts │ ├── InMemoryLock.ts │ ├── InMemoryMessageBus.ts │ ├── InMemorySnapshotStorage.ts │ ├── InMemoryView.ts │ ├── index.ts │ └── utils │ │ ├── index.ts │ │ └── nextCycle.ts ├── index.ts ├── interfaces │ ├── IAggregate.ts │ ├── IAggregateSnapshotStorage.ts │ ├── ICommand.ts │ ├── ICommandBus.ts │ ├── IContainer.ts │ ├── IDispatchPipelineProcessor.ts │ ├── IEvent.ts │ ├── IEventBus.ts │ ├── IEventDispatcher.ts │ ├── IEventLocker.ts │ ├── IEventReceptor.ts │ ├── IEventSet.ts │ ├── IEventStorage.ts │ ├── IEventStore.ts │ ├── IEventStream.ts │ ├── IIdentifierProvider.ts │ ├── ILogger.ts │ ├── IMessage.ts │ ├── IMessageBus.ts │ ├── IObjectStorage.ts │ ├── IObservable.ts │ ├── IObserver.ts │ ├── IProjection.ts │ ├── ISaga.ts │ ├── IViewLocker.ts │ ├── Identifier.ts │ ├── index.ts │ └── isObject.ts ├── rabbitmq │ ├── IContainer.ts │ ├── RabbitMqEventBus.ts │ ├── RabbitMqEventInjector.ts │ ├── RabbitMqGateway.ts │ ├── TerminationHandler.ts │ ├── constants.ts │ └── index.ts ├── sqlite │ ├── AbstractSqliteAccessor.ts │ ├── AbstractSqliteObjectProjection.ts │ ├── AbstractSqliteView.ts │ ├── IContainer.ts │ ├── SqliteEventLocker.ts │ ├── SqliteObjectStorage.ts │ ├── SqliteObjectView.ts │ ├── SqliteProjectionDataParams.ts │ ├── SqliteViewLocker.ts │ ├── index.ts │ ├── queries │ │ ├── eventLockTableInit.ts │ │ ├── index.ts │ │ └── viewLockTableInit.ts │ └── utils │ │ ├── getEventId.ts │ │ ├── guid.ts │ │ └── index.ts └── utils │ ├── Deferred.ts │ ├── Lock.ts │ ├── MapAssertable.ts │ ├── delay.ts │ ├── getClassName.ts │ ├── getHandler.ts │ ├── getMessageHandlerNames.ts │ ├── index.ts │ ├── isClass.ts │ ├── iteratorToArray.ts │ ├── notEmpty.ts │ ├── setupOneTimeEmitterSubscription.ts │ ├── subscribe.ts │ └── validateHandlers.ts ├── tests ├── integration │ ├── rabbitmq │ │ ├── RabbitMqEventBus.test.ts │ │ ├── RabbitMqEventInjector.test.ts │ │ ├── RabbitMqGateway.test.ts │ │ └── docker-compose.yml │ └── sqlite │ │ └── SqliteView.test.ts └── unit │ ├── AbstractAggregate.test.ts │ ├── AbstractProjection.test.ts │ ├── AbstractSaga.test.ts │ ├── AggregateCommandHandler.test.ts │ ├── CommandBus.test.ts │ ├── CqrsContainerBuilder.test.ts │ ├── EventDispatcher.test.ts │ ├── EventStore.test.ts │ ├── Lock.test.ts │ ├── SagaEventHandler.test.ts │ ├── dispatch-pipeline.test.ts │ ├── memory │ ├── InMemoryEventStorage.test.ts │ ├── InMemoryLock.test.ts │ ├── InMemoryMessageBus.test.ts │ └── InMemoryView.test.ts │ └── sqlite │ ├── SqliteEventLocker.test.ts │ ├── SqliteObjectStorage.test.ts │ ├── SqliteObjectView.test.ts │ └── SqliteViewLocker.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # How-to with your editor: http://editorconfig.org/#download 4 | 5 | # top-most EditorConfig file 6 | root = true 7 | 8 | # Unix-style newlines with a newline ending every file 9 | [*] 10 | end_of_line = lf 11 | indent_style = tab 12 | insert_final_newline = true 13 | 14 | [{Dockerfile,Procfile}] 15 | trim_trailing_whitespace = true 16 | 17 | # Standard at: https://github.com/felixge/node-style-guide 18 | [{*.js,*.json}] 19 | trim_trailing_whitespace = true 20 | quote_type = single 21 | curly_bracket_next_line = false 22 | spaces_around_operators = true 23 | space_after_control_statements = true 24 | space_after_anonymous_functions = false 25 | spaces_in_brackets = false 26 | 27 | [Dockerfile] 28 | indent_style = space 29 | indent_size = 4 30 | 31 | [*.md] 32 | indent_style = space 33 | indent_size = 2 34 | 35 | [*.json] 36 | indent_style = space 37 | indent_size = 4 38 | insert_final_newline = false 39 | 40 | [*.yml] 41 | indent_style = space 42 | indent_size = 2 43 | 44 | [package.json] 45 | indent_style = space 46 | indent_size = 2 47 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Audit 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | audit: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [22.x] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm audit --parseable --production --audit-level=moderate 22 | -------------------------------------------------------------------------------- /.github/workflows/coveralls.yml: -------------------------------------------------------------------------------- 1 | name: Coveralls 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [22.x] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm ci --no-optional 21 | env: 22 | CI: true 23 | - run: npm run test:coverage 24 | - name: Coveralls 25 | uses: coverallsapp/github-action@v2 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '22' 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - name: Install dependencies 23 | run: npm ci --no-optional 24 | 25 | - name: Publish pre-release to NPM 26 | if: contains(github.ref_name, '-') 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | run: npm publish --tag next 30 | 31 | - name: Publish release to NPM 32 | if: "!contains(github.ref_name, '-')" 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | run: npm publish 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 20.x, 22.x] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci --no-optional 22 | env: 23 | CI: true 24 | - run: npm run test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node and related ecosystem 2 | # ========================== 3 | .nodemonignore 4 | .node-xmlhttprequest-* 5 | .sass-cache/ 6 | npm-debug.log 7 | node_modules/ 8 | app/tests/coverage/ 9 | .bower-*/ 10 | coverage/ 11 | .nyc_output/ 12 | dist/ 13 | types/ 14 | *.tgz 15 | 16 | # IDE's 17 | # ===== 18 | .project 19 | .settings/ 20 | .*.md.html 21 | .metadata 22 | *~.nib 23 | local.properties 24 | *.iml 25 | .c9/ 26 | .vs/ 27 | .idea/ 28 | .vscode/ 29 | data/ 30 | mongod 31 | *.sublime-project 32 | *.sublime-workspace 33 | 34 | # OS-specific 35 | # =========== 36 | .DS_Store 37 | ehthumbs.db 38 | Icon? 39 | Thumbs.db 40 | 41 | # General 42 | # ======= 43 | *.log 44 | *.csv 45 | *.dat 46 | *.out 47 | *.pid 48 | *.gz 49 | *.tmp 50 | *.bak 51 | *.swp 52 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .nyc_output/ 3 | .vscode/ 4 | coverage/ 5 | docs/ 6 | examples/ 7 | scripts/ 8 | tests/ 9 | .editorconfig 10 | .eslintrc.json 11 | .eslintignore 12 | .gitignore 13 | book.json 14 | tsconfig.json 15 | jsconfig.json 16 | jest.config.ts 17 | *.tgz 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2017 Stanislav Natalenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | ./docs/README.md -------------------------------------------------------------------------------- /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "node-cqrs", 3 | "gitbook": "3.2.2", 4 | "plugins": [ 5 | "edit-link", 6 | "github", 7 | "anchorjs" 8 | ], 9 | "pluginsConfig": { 10 | "edit-link": { 11 | "base": "https://github.com/snatalenko/node-cqrs/tree/master", 12 | "label": "Edit This Page" 13 | }, 14 | "github": { 15 | "url": "https://github.com/snatalenko/node-cqrs/" 16 | }, 17 | "theme-default": { 18 | "styles": { 19 | "website": "build/gitbook.css" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | * [ReadMe](/README.md) 4 | * [Entities](/docs/entities/README.md) 5 | * [Messages](/docs/entities/Messages/README.md) 6 | * [Aggregate](/docs/entities/Aggregate/README.md) 7 | * [State](/docs/entities/Aggregate/State.md) 8 | * [Command Handlers](/docs/entities/Aggregate/CommandHandlers.md) 9 | * [External Dependencies](/docs/entities/Aggregate/Dependencies.md) 10 | * [Snapshots](/docs/entities/Aggregate/Snapshots.md) 11 | * [Projection](/docs/entities/Projection/README.md) 12 | * [InMemoryView](/docs/entities/Projection/InMemoryView.md) 13 | * [Saga](/docs/entities/Saga/README.md) 14 | * [Event Receptor](/docs/entities/EventReceptor/README.md) 15 | * [Middleware](/docs/middleware/README.md) 16 | * [DI Container](/docs/middleware/DIContainer.md) 17 | * [AggregateCommandHandler](/docs/middleware/AggregateCommandHandler.md) 18 | * [Infrastructure](/docs/infrastructure/README.md) 19 | -------------------------------------------------------------------------------- /docs/entities/Aggregate/CommandHandlers.md: -------------------------------------------------------------------------------- 1 | # Aggregate Command Handlers 2 | 3 | At minimum Aggregates are expected to implement the following interface: 4 | 5 | ```ts 6 | declare interface IAggregate { 7 | /** Main entry point for aggregate commands */ 8 | handle(command: ICommand): void | Promise; 9 | 10 | /** List of events emitted by Aggregate as a result of handling command(s) */ 11 | readonly changes: IEventStream; 12 | } 13 | ``` 14 | 15 | In a such aggregate all commands will be passed to the `handle` method and emitted events will be read from the `changes` property. 16 | 17 | Note that the event state restoring need to be handled separately and corresponding event stream will be passed either to Aggregate constructor or Aggregate factory. 18 | 19 | Most of this boilerplate code is already implemented in the AbstractAggregate class: 20 | 21 | ## AbstractAggregate 22 | 23 | `AbstractAggregate` class implements `IAggregate` interface and separates command handling and state mutations (see [Aggregate State](./State.md)). 24 | 25 | After AbstractAggregate is inherited, a separate command handler method needs to be declared for each command. Method name should match the `command.type`. Events can be produced using either `emit` or `emitRaw` methods. 26 | 27 | 28 | ```js 29 | const { AbstractAggregate } = require('node-cqrs'); 30 | 31 | class UserAggregate extends AbstractAggregate { 32 | 33 | get state() { 34 | return this._state || (this._state = new UserAggregateState()); 35 | } 36 | 37 | /** 38 | * "signupUser" command handler. 39 | * Being invoked by the AggregateCommandHandler service. 40 | * Should emit events. Must not modify the state directly. 41 | * 42 | * @param {any} payload - command payload 43 | * @param {any} context - command context 44 | */ 45 | signupUser(payload, context) { 46 | if (this.version !== 0) 47 | throw new Error('command executed on existing aggregate'); 48 | 49 | const { profile, password } = payload; 50 | 51 | // emitted event will mutate the state and will be committed to the EventStore 52 | this.emit('userSignedUp', { 53 | profile, 54 | passwordHash: hash(password) 55 | }); 56 | } 57 | 58 | /** 59 | * "changePassword" command handler 60 | */ 61 | changePassword(payload, context) { 62 | if (this.version === 0) 63 | throw new Error('command executed on non-existing aggregate'); 64 | 65 | const { oldPassword, newPassword } = payload; 66 | 67 | // all business logic validations should happen in the command handlers 68 | if (!compareHash(this.state.passwordHash, oldPassword)) 69 | throw new Error('old password does not match'); 70 | 71 | this.emit('userPasswordChanged', { 72 | passwordHash: hash(newPassword) 73 | }); 74 | } 75 | } 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/entities/Aggregate/Dependencies.md: -------------------------------------------------------------------------------- 1 | # External Dependencies 2 | 3 | If you are going to use a built-in [DI container](../../middleware/DIContainer.md), your aggregate constructor can accept instances of the services it depends on, they will be injected automatically upon each aggregate instance creation: 4 | 5 | ```js 6 | class UserAggregate extends AbstractAggregate { 7 | 8 | constructor({ id, events, authService }) { 9 | super({ id, events, state: new UserAggregateState() }); 10 | 11 | // save injected service for use in command handlers 12 | this._authService = authService; 13 | } 14 | 15 | async signupUser(payload, context) { 16 | // use the injected service 17 | await this._authService.registerUser(payload); 18 | } 19 | } 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/entities/Aggregate/README.md: -------------------------------------------------------------------------------- 1 | # Aggregate 2 | 3 | At minimum Aggregates are expected to implement the following interface: 4 | 5 | ```ts 6 | declare interface IAggregate { 7 | /** Main entry point for aggregate commands */ 8 | handle(command: ICommand): void | Promise; 9 | 10 | /** List of events emitted by Aggregate as a result of handling command(s) */ 11 | readonly changes: IEventStream; 12 | } 13 | ``` 14 | 15 | In a such aggregate all commands will be passed to the `handle` method and emitted events will be read from the `changes` property. 16 | 17 | Note that the event state restoring need to be handled separately and corresponding event stream will be passed either to Aggregate constructor or Aggregate factory. 18 | 19 | Most of this boilerplate code is already implemented in the [AbstractAggregate class](https://github.com/snatalenko/node-cqrs/blob/master/types/classes/AbstractAggregate.d.ts). 20 | 21 | It separates [command handling](./CommandHandlers.md), internal [state mutation](./State.md), and handles aggregate state restoring from event stream. It also provides a boilerplate code to simplify work with [Aggregate Snapshots](Snapshots.md) 22 | -------------------------------------------------------------------------------- /docs/entities/Aggregate/Snapshots.md: -------------------------------------------------------------------------------- 1 | # Aggregate Snapshots 2 | 3 | Snapshotting functionality involves the following methods: 4 | 5 | * `get snapshotVersion(): number` - `version` of the latest snapshot 6 | * `get shouldTakeSnapshot(): boolean` - defines whether a snapshot should be taken 7 | * `takeSnapshot(): void` - adds state snapshot to the `changes` collection, being invoked automatically by the [AggregateCommandHandler](#aggregatecommandhandler) 8 | * `makeSnapshot(): object` - protected method used to snapshot an aggregate state 9 | * `restoreSnapshot(snapshotEvent): void` - protected method used to restore state from a snapshot 10 | 11 | If you are going to use aggregate snapshots, you either need to keep the state structure simple (it should be possible to clone it using `JSON.parse(JSON.stringify(state))`) or override `makeSnapshots` and `restoreSnapshot` methods with your own serialization mechanisms. 12 | 13 | In the following sample a state snapshot will be taken every 50 events and added to the aggregate `changes` queue: 14 | 15 | ```js 16 | class UserAggregate extends AbstractAggregate { 17 | get shouldTakeSnapshot() { 18 | return this.version - this.snapshotVersion > 50; 19 | } 20 | } 21 | ``` 22 | 23 | If your state is too complex and cannot be restored with `JSON.parse` or you have data stored outside of aggregate `state`, you should define your own serialization and restoring functions: 24 | 25 | ```js 26 | class UserAggregate extends AbstractAggregate { 27 | makeSnapshot() { 28 | // return a field, stored outside of this.state 29 | return { trickyField: this.trickyField }; 30 | } 31 | restoreSnapshot({ payload }) { 32 | this.trickyField = payload.trickyField; 33 | } 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/entities/Aggregate/State.md: -------------------------------------------------------------------------------- 1 | # Aggregate State 2 | 3 | [EventStore]: ../../middleware/README.md 4 | [AbstractAggregate.js]: https://github.com/snatalenko/node-cqrs/blob/master/src/AbstractAggregate.js 5 | 6 | 7 | Aggregate state is an internal aggregate property, which is used for domain logic validations in [Aggregate Command Handlers](CommandHandlers.md). 8 | 9 | ## Implementation 10 | 11 | Typically aggregate state is expected to be managed separately from the aggregate command handlers and should be a projection of events emitted by the aggregate. 12 | 13 | User aggregate state implementation could look like this: 14 | 15 | ```js 16 | class UserAggregateState { 17 | userSignedUp({ payload }) { 18 | this.profile = payload.profile; 19 | this.passwordHash = payload.passwordHash; 20 | } 21 | 22 | userPasswordChanged({ payload }) { 23 | this.passwordHash = payload.passwordHash; 24 | } 25 | } 26 | ``` 27 | 28 | Each event handler is defined as a separate method, which modifies the state. Alternatively, a common `mutate(event)` handler can be defined, which will handle all aggregate events instead. 29 | 30 | Aggregate state **should NOT throw any exceptions**, all type and business logic validations should be performed in the [aggregate command handlers](CommandHandlers.md). 31 | 32 | ## Using in Aggregate 33 | 34 | `AbstractAggregate` restores aggregate state automatically in [its constructor][AbstractAggregate.js] from events, retrieved from the [EventStore][EventStore]. 35 | 36 | In order to make Aggregate use your state implementation, pass its instance as a property to the AbstractAggregate constructor, or define it as a read-only stateful property in your aggregate class: 37 | 38 | ```js 39 | class UserAggregate extends AbstractAggregate { 40 | // option 1 41 | get state() { 42 | return this._state || (this._state = new UserAggregateState()); 43 | } 44 | 45 | constructor(props) { 46 | // option 2 47 | super({ state: new UserAggregateState(), ...props }); 48 | } 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/entities/EventReceptor/README.md: -------------------------------------------------------------------------------- 1 | # Event Receptor 2 | 3 | Event receptor is an Observer that subscribes to events and performs operations non-related to core domain logic (i.e. send welcome email to a new user upon signup). 4 | 5 | ```js 6 | const { subscribe } = require('node-cqrs'); 7 | 8 | class MyReceptor { 9 | static get handles() { 10 | return [ 11 | 'userSignedUp' 12 | ]; 13 | } 14 | 15 | subscribe(observable) { 16 | subscribe(observable, this); 17 | } 18 | 19 | userSignedUp({ payload }) { 20 | // send welcome email to payload.email 21 | } 22 | } 23 | ``` 24 | 25 | If you are creating/registering a receptor manually: 26 | 27 | ```js 28 | const receptor = new MyReceptor(); 29 | receptor.subscribe(eventStore); 30 | ``` 31 | 32 | 33 | To register a receptor in the [DI Container](../../middleware/DIContainer.md): 34 | 35 | ```js 36 | container.registerEventReceptor(MyReceptor); 37 | container.createUnexposedInstances(); 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/entities/Messages/README.md: -------------------------------------------------------------------------------- 1 | # Messages 2 | 3 | [Middleware]: ../../middleware/README.md "Middleware" 4 | [Aggregate]: ../Aggregate/README.md "Aggregate" 5 | [Saga]: ../Saga/README.md 6 | [Projection]: ../Projection/README.md 7 | [Receptor]: ../EventReceptor/README.md 8 | 9 | All messages flowing thru the system are loosely typed objects with a minimal set of required fields: 10 | 11 | * `type: string` - command or event type. for commands it's recommended to name it as a call to action (i.e. "createUser"), while for events it should describe what happened in a past tense (i.e. "userCreated"). 12 | * `payload: any` - command or event data 13 | * `context: object` - key-value object with information on context (i.e. logged in user ID). context must be specified when a command is being triggered by a user action and then it's being copied to events, sagas and subsequent commands 14 | 15 | Other fields are used for message routing and their usage depends on the flow: 16 | 17 | * `aggregateId: string|number|undefined` - unique aggregate identifier 18 | * `aggregateVersion: number` 19 | * `sagaId: string|number|undefined` 20 | * `sagaVersion: number` 21 | 22 | 23 | ## Commands 24 | 25 | * sent to [CommandBus][Middleware] manually 26 | * being handled by [Aggregates][Aggregate] 27 | * may be enqueued by [Sagas][Saga] 28 | 29 | 30 | Command example: 31 | 32 | ```json 33 | { 34 | "type": "signupUser", 35 | "aggregateId": null, 36 | "payload": { 37 | "profile": { 38 | "name": "John Doe", 39 | "email": "john@example.com" 40 | }, 41 | "password": "test" 42 | }, 43 | "context": { 44 | "ip": "127.0.0.1", 45 | "ts": 1503509747154 46 | } 47 | } 48 | ``` 49 | 50 | 51 | ## Events 52 | 53 | * produced by [Aggregates][Aggregate] 54 | * persisted to [EventStore][Middleware] 55 | * may be handled by [Projections][Projection], [Sagas][Saga] and [Event Receptors][Receptor] 56 | 57 | Event example: 58 | 59 | ```json 60 | { 61 | "type": "userSignedUp", 62 | "aggregateId": 1, 63 | "aggregateVersion": 0, 64 | "payload": { 65 | "profile": { 66 | "name": "John Doe", 67 | "email": "john@example.com" 68 | }, 69 | "passwordHash": "098f6bcd4621d373cade4e832627b4f6" 70 | }, 71 | "context": { 72 | "ip": "127.0.0.1", 73 | "ts": 1503509747154 74 | } 75 | } 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/entities/Projection/InMemoryView.md: -------------------------------------------------------------------------------- 1 | InMemoryView 2 | ============ 3 | 4 | By default, AbstractProjection instances get created with an instance of InMemoryView associated. 5 | 6 | The associted view can be accessed thru the `view` property and provides a set of methods for view manipulation: 7 | 8 | * `get ready(): boolean` - indicates if the view state is restored 9 | * `once('ready'): Promise` - allows to await until the view is restored 10 | * operations with data 11 | * `get(key: string, options?: object): Promise` 12 | * `create(key: string, record: any)` 13 | * `update(key: string, callback: any => any)` 14 | * `updateEnforcingNew(key: string, callback: any => any)` 15 | * `delete(key: string)` 16 | 17 | 18 | In case you are using the [DI container](../middleware/DIContainer.md), projection view will be exposed on the container automatically: 19 | 20 | ```js 21 | container.registerProjection(MyProjection, 'myView'); 22 | 23 | // @type {InMemoryView} 24 | const view = container.myView; 25 | 26 | // @type {{ profile: object, passwordHash: string }} 27 | const aggregateRecord = await view.get('my-aggregate-id'); 28 | ``` 29 | 30 | Since the view keeps state in memory, upon creation it needs to be restored from the EventStore. 31 | This is [handled by the AbstractProjection](./README.md) automatically. 32 | 33 | All queries to the `view.get(..)` get suspended, until the view state is restored. Alternatively, you can either check the `ready` flag or subscribe to the "ready" event manually: 34 | 35 | ```js 36 | // wait until the view state is restored 37 | await view.once('ready'); 38 | 39 | // query data 40 | const record = await view.get('my-key'); 41 | ``` 42 | 43 | In case you need to access the view from a projection event handler (which also happens during the view restoring), to prevent the deadlock, invoke the `get` method with a `nowait` flag: 44 | 45 | ```js 46 | // accessing view record from a projection event handler 47 | const record = await this.view.get('my-key', { nowait: true }); 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/entities/Projection/README.md: -------------------------------------------------------------------------------- 1 | # Projection 2 | 3 | Projection is an Observer, that listens to events and updates an associated View. 4 | 5 | ## Projection View Restoring 6 | 7 | By default, an [InMemoryView](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryViewStorage.js) is used. That means that upon application start, Projection queries all known events from the EventStore and projects them to the view. Once this process is complete, the view's `ready` property gets switched from *false* to *true*. 8 | 9 | ## Projection Event Handlers 10 | 11 | All projection event types must be listed in the static `handles` getter and event type must have a handler defined: 12 | 13 | ```js 14 | 15 | const { AbstractProjection } = require('node-cqrs'); 16 | 17 | class MyProjection extends AbstractProjection { 18 | static get handles() { 19 | return [ 20 | 'userSignedUp', 21 | 'userPasswordChanged' 22 | ]; 23 | } 24 | 25 | async userSignedUp({ aggregateId, payload }) { 26 | const { profile, passwordHash } = payload; 27 | 28 | await this.view.create(aggregateId, { 29 | profile, 30 | passwordHash 31 | }); 32 | } 33 | 34 | async userPasswordChanged({ aggregateId, payload }) { 35 | const { passwordHash } = payload; 36 | await this.view.update(aggregateId, view => { 37 | view.passwordHash = passwordHash; 38 | }); 39 | } 40 | } 41 | 42 | ``` 43 | 44 | ## Accessing Projection View 45 | 46 | Associated view is exposed on a projection instance as `view` property. 47 | 48 | By default, AbstractProjection instances get created with an instance of [InMemoryView](./InMemoryView.md) associated. 49 | -------------------------------------------------------------------------------- /docs/entities/README.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | * [Messages](Messages/README.md) 4 | * [Aggregate](Aggregate/README.md) 5 | * [Projection](Projection/README.md) 6 | * [Saga](Saga/README.md) 7 | * [Event Receptor](EventReceptor/README.md) -------------------------------------------------------------------------------- /docs/entities/Saga/README.md: -------------------------------------------------------------------------------- 1 | # Saga 2 | 3 | [AbstractSaga.d.ts]: https://github.com/snatalenko/node-cqrs/blob/master/types/classes/AbstractSaga.d.ts 4 | 5 | 6 | Saga can be used to control operations where multiple aggregates are involved. 7 | 8 | ## SagaEventReceptor 9 | 10 | `SagaEventReceptor` instance is needed for each Saga type, it 11 | 12 | 1. Subscribes to event store and awaits events handled by Saga 13 | 2. Instantiates Saga with corresponding event stream 14 | 3. Passes events to saga 15 | 4. Sends enqueued commands to the CommandBus 16 | 17 | Saga event receptor can be created manually: 18 | 19 | ```js 20 | const sagaEventReceptor = new SagaEventReceptor({ 21 | sagaType: MySaga, 22 | eventStore, 23 | commandBus 24 | }); 25 | 26 | sagaEventReceptor.subscribe(eventStore); 27 | ``` 28 | 29 | or using the [DI container](../../middleware/DIContainer.md) : 30 | 31 | ```js 32 | builder.registerSaga(MySaga); 33 | ``` 34 | 35 | ## Saga Interface 36 | 37 | At minimum Sagas should implement the following interface: 38 | 39 | ```ts 40 | declare interface ISaga { 41 | /** List of event types that trigger new Saga start */ 42 | static readonly startsWith: string[]; 43 | 44 | /** List of event types being handled by Saga */ 45 | static readonly handles?: string[]; 46 | 47 | /** List of commands emitted by Saga */ 48 | readonly uncommittedMessages: ICommand[]; 49 | 50 | /** Main entry point for Saga events */ 51 | apply(event: IEvent): void | Promise; 52 | 53 | /** Reset emitted commands when they are not longer needed */ 54 | resetUncommittedMessages(): void; 55 | } 56 | ``` 57 | 58 | Also, it needs to handle saga internal state restoring from the `events` property passed either to the Saga constructor or as a Saga factory attribute. 59 | 60 | 61 | ## AbstractSaga 62 | 63 | Most of the above logic is implemented in the [AbstractSaga class][AbstractSaga.d.ts] and it can be extended with saga business logic only. 64 | 65 | Event handles should be defined as a separate methods, where method name correspond to `event.type`. Commands can be sent using the `enqueue` (or `enqueueRaw`) method 66 | 67 | ```ts 68 | const { AbstractSaga } = require('node-cqrs'); 69 | 70 | class SupportNotificationSaga extends AbstractSaga { 71 | 72 | static get startsWith() { 73 | return ['userLockedOut']; 74 | } 75 | 76 | /** 77 | * "userLockedOut" event handler which also starts the Saga 78 | */ 79 | userLockedOut({ aggregateId }) { 80 | 81 | // We use empty aggregate ID as we target a new aggregate here 82 | const targetAggregateId = undefined; 83 | 84 | const commandPayload = { 85 | subject: 'Account locked out', 86 | message: `User account ${aggregateId} is locked out for 15min because of multiple unsuccessful login attempts` 87 | }; 88 | 89 | // Enqueue command, which will be sent to the CommandBus 90 | // after method execution is complete 91 | this.enqueue('createTicket', targetAggregateId, commandPayload); 92 | } 93 | } 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/images/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snatalenko/node-cqrs/efc980cc0f7f7fc021a8fcadd913806fb5af2707/docs/images/README.md -------------------------------------------------------------------------------- /docs/images/node-cqrs-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snatalenko/node-cqrs/efc980cc0f7f7fc021a8fcadd913806fb5af2707/docs/images/node-cqrs-components.png -------------------------------------------------------------------------------- /docs/infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # Infrastructure 2 | 3 | node-cqrs comes with a set of In-Memory infrastructure service implementations. They are suitable for test purposes, since all data is persisted in process memory only: 4 | 5 | * [InMemoryEventStorage](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryEventStorage.js) 6 | * [InMemoryMessageBus](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryMessageBus.js) 7 | * [InMemoryView](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryView.js) 8 | 9 | 10 | The following storage/bus implementations persist data in external storages and can be used in production: 11 | 12 | * [MongoDB Event Storage](https://github.com/snatalenko/node-cqrs-mongo) 13 | * [RabbitMQ Message Bus](https://github.com/snatalenko/node-cqrs-rabbitmq) 14 | -------------------------------------------------------------------------------- /docs/middleware/AggregateCommandHandler.md: -------------------------------------------------------------------------------- 1 | # AggregateCommandHandler 2 | 3 | AggregateCommandHandler instance is needed for every aggregate type, it does the following: 4 | 5 | 1. Subscribes to CommandBus and awaits commands handled by Aggregate 6 | 2. Upon command receiving creates an instance of Aggregate using the corresponding event stream 7 | 3. Passes the command to the created Aggregate instance 8 | 4. Commits events emitted by the Aggregate instance to the EventStore 9 | 10 | Aggregate command handler can be created manually: 11 | 12 | ```js 13 | const myAggregateCommandHandler = new AggregateCommandHandler({ 14 | eventStore, 15 | aggregateType: MyAggregate 16 | }); 17 | myAggregateCommandHandler.subscribe(commandBus); 18 | ``` 19 | 20 | Or using the [DI container](DIContainer.md) (preferred method): 21 | 22 | ```js 23 | container.registerAggregate(MyAggregate); 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/middleware/DIContainer.md: -------------------------------------------------------------------------------- 1 | # DI Container 2 | 3 | DI Container intended to make components wiring easier. 4 | 5 | All named component instances are exposed on container thru getters and get created upon accessing a getter. Default `EventStore` and `CommandBus` components are registered upon container instance creation: 6 | 7 | ```js 8 | const { ContainerBuilder } = require('node-cqrs'); 9 | const builder = new ContainerBuilder(); 10 | const container = builder.container(); 11 | 12 | container.eventStore; // instance of EventStore 13 | container.commandBus; // instance of CommandBus 14 | ``` 15 | 16 | Other components can be registered either as classes or as factories: 17 | 18 | ```js 19 | // class with automatic dependency injection 20 | builder.register(SomeService).as('someService'); 21 | 22 | // OR factory with more precise control 23 | builder.register(container => new SomeService(container.commandBus)).as('someService'); 24 | ``` 25 | 26 | Container scans class constructors (or constructor functions) for dependencies and injects them, where possible: 27 | 28 | ```js 29 | class SomeRepository { /* ... */ } 30 | 31 | class ServiceA { 32 | // dependency definition, as a parameter object property 33 | constructor(options) { 34 | this._repository = options.repository; 35 | } 36 | } 37 | 38 | class ServiceB { 39 | // dependency defined thru parameter object destructuring 40 | constructor({ repository, a }) { /* ... */ } 41 | } 42 | 43 | class ServiceC { 44 | constructor(repository, a, b) { /* ... */ } 45 | } 46 | 47 | // dependencies passed thru factory 48 | const serviceFactory = ({ repository, a, b }) => new ServiceC(repository, a, b); 49 | 50 | container.register(SomeRepository, 'repository'); 51 | container.register(ServiceA, 'a'); 52 | container.register(ServiceB, 'b'); 53 | container.register(serviceFactory, 'c'); 54 | ``` 55 | 56 | Components that aren't going to be accessed directly by name can also be registered in the builder. Their instances will be created after invoking `container()` method: 57 | 58 | ```js 59 | builder.register(SomeEventObserver); 60 | // at this point the registered observer does not exist 61 | 62 | const container = builder.container(); 63 | // now it exists and got all its constructor dependencies 64 | ``` 65 | 66 | 67 | DI container has a set of methods for CQRS components registration: 68 | 69 | * __registerAggregate(AggregateType)__ - registers aggregateCommandHandler, subscribes it to commandBus and wires Aggregate dependencies 70 | * __registerSaga(SagaType)__ - registers sagaEventHandler, subscribes it to eventStore and wires Saga dependencies 71 | * __registerProjection(ProjectionType, exposedViewName)__ - registers projection, subscribes it to eventStore and exposes associated projection view on the container 72 | * __registerCommandHandler(typeOrFactory)__ - registers command handler and subscribes it to commandBus 73 | * __registerEventReceptor(typeOrFactory)__ - registers event receptor and subscribes it to eventStore 74 | 75 | 76 | Altogether: 77 | 78 | ```js 79 | const { ContainerBuilder, InMemoryEventStorage } = require('node-cqrs'); 80 | const builder = new ContainerBuilder(); 81 | 82 | builder.registerAggregate(UserAggregate); 83 | 84 | // we are using non-persistent in-memory event storage, 85 | // for a permanent storage you can look at https://www.npmjs.com/package/node-cqrs-mongo 86 | builder.register(InMemoryEventStorage) 87 | .as('storage'); 88 | 89 | // as an example of UserAggregate dependency 90 | builder.register(AuthService) 91 | .as('authService'); 92 | 93 | // setup command and event handler listeners 94 | const container = builder.container(); 95 | 96 | // send a command 97 | const aggregateId = undefined; 98 | const payload = { profile: {}, password: '...' }; 99 | const context = {}; 100 | container.commandBus.send('signupUser', aggregateId, { payload, context }); 101 | 102 | container.eventStore.once('userSignedUp', event => { 103 | console.log(`user aggregate created with ID ${event.aggregateId}`); 104 | }); 105 | ``` 106 | 107 | In the above example, the command will be passed to an aggregate command handler, which will either restore an aggregate, or create a new one, and will invoke a corresponding method on the aggregate. 108 | 109 | After command processing is done, produced events will be committed to the eventStore, and emitted to subscribed projections and/or event receptors. 110 | -------------------------------------------------------------------------------- /docs/middleware/README.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | for wiring components together 4 | 5 | * [DI Container](DIContainer.md) 6 | 7 | for delivering messages to corresponding domain objects 8 | 9 | * [AggregateCommandHandler](AggregateCommandHandler.md) 10 | * SagaEventHandler 11 | 12 | Messaging API to interact with: 13 | 14 | * EventStore 15 | * CommandBus 16 | -------------------------------------------------------------------------------- /examples/user-domain-tests/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "no-unused-expressions": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/user-domain-tests/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const { createContainer, createBaseInstances } = require('../user-domain'); 5 | 6 | describe('user-domain example', () => { 7 | 8 | const testEventFlow = async container => { 9 | 10 | const { commandBus, eventStore } = container; 11 | 12 | // we send a command to an aggregate that does not exist yet (userAggregateId = undefined), 13 | // a new instance will be created automatically 14 | let userAggregateId; 15 | 16 | // send(..) returns a promise, but we'll await for an eventStore event 17 | commandBus.send('createUser', userAggregateId, { 18 | payload: { 19 | username: 'sherlock', 20 | password: 'magic' 21 | } 22 | }); 23 | 24 | const userCreatedEvent = await eventStore.once('userCreated'); 25 | 26 | expect(userCreatedEvent).to.have.property('aggregateId').that.is.not.undefined; 27 | expect(userCreatedEvent).to.have.property('aggregateVersion', 0); 28 | expect(userCreatedEvent).to.have.property('type', 'userCreated'); 29 | expect(userCreatedEvent).to.have.nested.property('payload.username', 'sherlock'); 30 | expect(userCreatedEvent).to.have.nested.property('payload.passwordHash').that.does.not.eq('magic'); 31 | 32 | // created user aggregateId can be retrieved from "userCreated" event 33 | userAggregateId = userCreatedEvent.aggregateId; 34 | 35 | commandBus.send('changeUserPassword', userAggregateId, { 36 | payload: { 37 | oldPassword: 'magic', 38 | password: 'no-magic' 39 | } 40 | }); 41 | 42 | const userPasswordChanged = await eventStore.once('userPasswordChanged'); 43 | 44 | expect(userPasswordChanged).to.have.property('aggregateId', userAggregateId); 45 | expect(userPasswordChanged).to.have.property('aggregateVersion', 1); 46 | expect(userPasswordChanged).to.have.property('type', 'userPasswordChanged'); 47 | expect(userPasswordChanged).to.have.nested.property('payload.passwordHash').that.does.not.eq('no-magic'); 48 | }; 49 | 50 | const testProjection = async container => { 51 | 52 | const { commandBus, eventStore, users } = container; 53 | 54 | const userCreatedPromise = eventStore.once('userCreated'); 55 | 56 | await commandBus.send('createUser', undefined, { 57 | payload: { 58 | username: 'sherlock', 59 | password: 'test' 60 | } 61 | }); 62 | 63 | const userCreated = await userCreatedPromise; 64 | 65 | const viewRecord = await users.get(userCreated.aggregateId); 66 | 67 | expect(viewRecord).to.exist; 68 | expect(viewRecord).to.have.property('username', 'sherlock'); 69 | }; 70 | 71 | describe('with DI container', () => { 72 | 73 | it('handles user aggregate commands, emits events', 74 | () => testEventFlow(createContainer())); 75 | 76 | it('updates Users projection view', 77 | () => testProjection(createContainer())); 78 | }); 79 | 80 | describe('with manual instantiation', () => { 81 | 82 | it('handles user aggregate commands, emits events', () => 83 | testEventFlow(createBaseInstances())); 84 | 85 | it('updates Users projection view', () => 86 | testProjection(createBaseInstances())); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /examples/user-domain/UserAggregate.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict'; 3 | 4 | const { AbstractAggregate } = require('../..'); // node-cqrs 5 | 6 | const crypto = require('crypto'); 7 | 8 | function md5Hash(data) { 9 | return crypto.createHash('md5').update(data).digest('hex'); 10 | } 11 | 12 | /** 13 | * User aggregate state event handlers. 14 | * Being restored from event stream upon aggregate instance creation 15 | * 16 | * @class UserAggregateState 17 | */ 18 | class UserAggregateState { 19 | 20 | /** 21 | * userCreated event handler 22 | * 23 | * @param {object} event 24 | * @param {object} event.payload 25 | * @param {string} event.payload.username 26 | * @param {string} event.payload.passwordHash 27 | * @memberof UserAggregateState 28 | */ 29 | userCreated(event) { 30 | this.username = event.payload.username; 31 | this.passwordHash = event.payload.passwordHash; 32 | } 33 | 34 | /** 35 | * userPasswordChanged event handler 36 | * 37 | * @param {object} event 38 | * @param {object} event.payload 39 | * @param {string} event.payload.passwordHash 40 | * @memberof UserAggregateState 41 | */ 42 | userPasswordChanged(event) { 43 | this.passwordHash = event.payload.passwordHash; 44 | } 45 | } 46 | 47 | /** 48 | * User Aggregate - defines all user-related command handlers 49 | * 50 | * @class UserAggregate 51 | * @extends {AbstractAggregate} 52 | */ 53 | class UserAggregate extends AbstractAggregate { 54 | 55 | /** 56 | * Optional list of commands supported by User Aggregate 57 | * 58 | * @type {string[]} 59 | * @readonly 60 | * @static 61 | * @memberof UserAggregate 62 | */ 63 | static get handles() { 64 | return [ 65 | 'createUser', 66 | 'changeUserPassword' 67 | ]; 68 | } 69 | 70 | /** 71 | * Creates an instance of UserAggregate 72 | */ 73 | constructor({ id, events }) { 74 | super({ id, events, state: new UserAggregateState() }); 75 | } 76 | 77 | /** 78 | * createUser command handler 79 | * 80 | * @param {object} commandPayload 81 | * @param {string} commandPayload.username 82 | * @param {string} commandPayload.password 83 | * @memberof UserAggregate 84 | */ 85 | createUser(commandPayload) { 86 | // validate command format 87 | if (!commandPayload) throw new TypeError('commandPayload argument required'); 88 | if (!commandPayload.username) throw new TypeError('commandPayload.username argument required'); 89 | if (!commandPayload.password) throw new TypeError('commandPayload.password argument required'); 90 | 91 | // validate aggregate state 92 | if (this.version !== 0) throw new Error(`User ${this.id} already created`); 93 | 94 | const { username, password } = commandPayload; 95 | 96 | this.emit('userCreated', { 97 | username, 98 | passwordHash: md5Hash(password) 99 | }); 100 | } 101 | 102 | /** 103 | * changeUserPassword command handler 104 | * 105 | * @param {object} commandPayload 106 | * @param {string} commandPayload.oldPassword 107 | * @param {string} commandPayload.password 108 | * @memberof UserAggregate 109 | */ 110 | changeUserPassword(commandPayload) { 111 | // validate command format 112 | if (!commandPayload) throw new TypeError('commandPayload argument required'); 113 | if (!commandPayload.oldPassword) throw new TypeError('commandPayload.oldPassword argument required'); 114 | if (!commandPayload.password) throw new TypeError('commandPayload.password argument required'); 115 | 116 | // validate aggregate state 117 | if (this.version === 0) throw new Error(`User ${this.id} does not exist`); 118 | 119 | const { oldPassword, password } = commandPayload; 120 | if (md5Hash(oldPassword) !== this.state.passwordHash) 121 | throw new Error('Old password does not match'); 122 | 123 | this.emit('userPasswordChanged', { 124 | passwordHash: md5Hash(password) 125 | }); 126 | } 127 | } 128 | 129 | module.exports = UserAggregate; 130 | -------------------------------------------------------------------------------- /examples/user-domain/UsersProjection.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict'; 3 | 4 | const { AbstractProjection } = require('../..'); // node-cqrs 5 | 6 | /** 7 | * Users projection listens to events and updates associated view (read model) 8 | * 9 | * @class UsersProjection 10 | * @extends {AbstractProjection} 11 | */ 12 | class UsersProjection extends AbstractProjection { 13 | 14 | /** 15 | * Optional list of events being handled by Projection 16 | * 17 | * @type {string[]} 18 | * @readonly 19 | * @static 20 | * @memberof UsersProjection 21 | */ 22 | static get handles() { 23 | return [ 24 | 'userCreated' 25 | ]; 26 | } 27 | 28 | /** 29 | * userCreated event handler 30 | * 31 | * @param {object} event 32 | * @param {import('../../src').Identifier} event.aggregateId 33 | * @param {object} event.payload 34 | * @param {string} event.payload.username 35 | * @param {string} event.payload.passwordHash 36 | * @memberof UsersProjection 37 | */ 38 | async userCreated(event) { 39 | const { aggregateId, payload } = event; 40 | 41 | await this.view.create(aggregateId, { 42 | username: payload.username 43 | }); 44 | } 45 | } 46 | 47 | module.exports = UsersProjection; 48 | -------------------------------------------------------------------------------- /examples/user-domain/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | ContainerBuilder, 5 | InMemoryEventStorage, 6 | CommandBus, 7 | EventStore, 8 | AggregateCommandHandler, 9 | InMemoryMessageBus, 10 | EventDispatcher 11 | } = require('../..'); // node-cqrs 12 | const UserAggregate = require('./UserAggregate'); 13 | const UsersProjection = require('./UsersProjection'); 14 | 15 | /** 16 | * DI container factory 17 | */ 18 | exports.createContainer = () => { 19 | const builder = new ContainerBuilder(); 20 | 21 | // register infrastructure services 22 | builder.register(InMemoryEventStorage).as('eventStorageReader').as('eventStorageWriter'); 23 | builder.register(InMemoryMessageBus).as('eventBus'); 24 | 25 | // register domain entities 26 | builder.registerAggregate(UserAggregate); 27 | builder.registerProjection(UsersProjection, 'users'); 28 | 29 | // create instances of command/event handlers and related subscriptions 30 | return builder.container(); 31 | }; 32 | 33 | /** 34 | * Same as above, but without the DI container 35 | */ 36 | exports.createBaseInstances = () => { 37 | // create infrastructure services 38 | const eventBus = new InMemoryMessageBus(); 39 | const storage = new InMemoryEventStorage(); 40 | const eventDispatcher = new EventDispatcher({ eventBus }) 41 | eventDispatcher.addPipelineProcessor(storage); 42 | 43 | const eventStore = new EventStore({ eventStorageReader: storage, eventBus, eventDispatcher }); 44 | const commandBus = new CommandBus(); 45 | 46 | /** @type {import('../..').IAggregateConstructor} */ 47 | const aggregateType = UserAggregate; 48 | 49 | /** @type {import('../..').ICommandHandler} */ 50 | const userCommandHandler = new AggregateCommandHandler({ eventStore, aggregateType }); 51 | userCommandHandler.subscribe(commandBus); 52 | 53 | /** @type {import('../..').IProjection} */ 54 | const usersProjection = new UsersProjection(); 55 | usersProjection.subscribe(eventStore); 56 | 57 | return { 58 | eventStore, 59 | commandBus, 60 | users: usersProjection.view 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | export default { 7 | // Indicates whether the coverage information should be collected while executing the test 8 | collectCoverage: false, 9 | 10 | // An array of glob patterns indicating a set of files for which coverage information should be collected 11 | collectCoverageFrom: [ 12 | 'src/**/*.ts', // Only collect coverage from TypeScript source 13 | '!src/**/*.d.ts' // Ignore TypeScript type declaration files 14 | ], 15 | 16 | // The directory where Jest should output its coverage files 17 | coverageDirectory: 'coverage', 18 | 19 | // An array of regexp pattern strings used to skip coverage collection 20 | coveragePathIgnorePatterns: [ 21 | '/dist/', 22 | '/examples/', 23 | '/node_modules/', 24 | '/src/rabbitmq/', 25 | '/tests/' 26 | ], 27 | 28 | // Indicates which provider should be used to instrument code for coverage 29 | // coverageProvider: "v8", 30 | 31 | // A set of global variables that need to be available in all test environments 32 | globals: { 33 | }, 34 | 35 | // The test environment that will be used for testing 36 | testEnvironment: 'node', 37 | 38 | // A map from regular expressions to paths to transformers 39 | transform: { 40 | '^.+\\.tsx?$': [ 41 | 'ts-jest', 42 | { 43 | isolatedModules: true 44 | } 45 | ] 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "allowSyntheticDefaultImports": true, 6 | "checkJs": true, 7 | "resolveJsonModule": true, 8 | "lib": [ 9 | "es2018" 10 | ] 11 | }, 12 | "exclude": [ 13 | "node_modules" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-cqrs", 3 | "version": "1.0.0-rc.11", 4 | "description": "Basic ES6 backbone for CQRS app development", 5 | "keywords": [ 6 | "cqrs", 7 | "eventsourcing" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/snatalenko/node-cqrs.git" 12 | }, 13 | "main": "./dist/index.js", 14 | "types": "./types/index.d.ts", 15 | "typesVersions": { 16 | "*": { 17 | "rabbitmq": [ 18 | "types/rabbitmq/index.d.ts" 19 | ], 20 | "sqlite": [ 21 | "types/sqlite/index.d.ts" 22 | ] 23 | } 24 | }, 25 | "exports": { 26 | ".": { 27 | "require": "./dist/index.js", 28 | "import": "./dist/index.js", 29 | "types": "./types/index.d.ts" 30 | }, 31 | "./rabbitmq": { 32 | "require": "./dist/rabbitmq/index.js", 33 | "import": "./dist/rabbitmq/index.js", 34 | "types": "./types/rabbitmq/index.d.ts" 35 | }, 36 | "./sqlite": { 37 | "require": "./dist/sqlite/index.js", 38 | "import": "./dist/sqlite/index.js", 39 | "types": "./types/sqlite/index.d.ts" 40 | } 41 | }, 42 | "directories": { 43 | "doc": "docs", 44 | "example": "examples", 45 | "test": "tests" 46 | }, 47 | "engines": { 48 | "node": ">=18.0.0" 49 | }, 50 | "scripts": { 51 | "pretest": "npm run build", 52 | "test": "jest tests/unit", 53 | "test:coverage": "jest --collect-coverage tests/unit", 54 | "pretest:integration": "npm run build", 55 | "test:integration": "jest --verbose examples/user-domain-tests tests/integration", 56 | "changelog": "conventional-changelog -n ./scripts/changelog -i CHANGELOG.md -s", 57 | "clean": "tsc --build --clean", 58 | "build": "tsc --build", 59 | "prepare": "npm run build", 60 | "preversion": "npm test", 61 | "version": "npm run changelog && git add CHANGELOG.md", 62 | "lint": "eslint" 63 | }, 64 | "author": "@snatalenko", 65 | "license": "MIT", 66 | "homepage": "https://github.com/snatalenko/node-cqrs#readme", 67 | "dependencies": { 68 | "async-iterable-buffer": "^1.0.0", 69 | "async-parallel-pipe": "^1.0.2", 70 | "di0": "^1.0.0" 71 | }, 72 | "devDependencies": { 73 | "@stylistic/eslint-plugin-ts": "^4.2.0", 74 | "@types/amqplib": "^0.10.7", 75 | "@types/better-sqlite3": "^7.6.11", 76 | "@types/chai": "^4.3.20", 77 | "@types/jest": "^29.5.13", 78 | "@types/md5": "^2.3.5", 79 | "@types/node": "^20.16.9", 80 | "@types/sinon": "^17.0.4", 81 | "@typescript-eslint/eslint-plugin": "^8.29.0", 82 | "@typescript-eslint/parser": "^8.29.0", 83 | "chai": "^4.5.0", 84 | "conventional-changelog": "^3.1.25", 85 | "eslint": "^9.24.0", 86 | "eslint-plugin-jest": "^28.11.0", 87 | "globals": "^16.1.0", 88 | "jest": "^29.7.0", 89 | "sinon": "^19.0.2", 90 | "ts-jest": "^29.2.5", 91 | "ts-node": "^10.9.2", 92 | "typescript": "^5.6.2", 93 | "typescript-eslint": "^8.29.0" 94 | }, 95 | "peerDependencies": { 96 | "amqplib": "^0.10.5", 97 | "better-sqlite3": "^11.3.0", 98 | "md5": "^2.3.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /scripts/changelog/commits.json: -------------------------------------------------------------------------------- 1 | { 2 | "bae5ac41b52172446149af04c0b98796e3ff81e4": { 3 | "tag": null, 4 | "message": null 5 | }, 6 | "ef01cc33b63a95a8783a83b34c4fcb3f4830fe52": { 7 | "tag": "Change", 8 | "message": "Upgrade dev dependencies to fix audit script" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/changelog/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { promisify } = require('util'); 4 | const readFile = promisify(require('fs').readFile); 5 | const { resolve } = require('path'); 6 | const known = require('./commits.json'); 7 | 8 | const TITLES = [ 9 | { title: 'Features', tags: ['+', 'new', 'feature'] }, 10 | { title: 'Fixes', tags: ['-', 'fix', 'fixes'] }, 11 | { title: 'Changes', tags: ['*', 'change'] }, 12 | { title: 'Performance Improvements', tags: ['perf', 'performance'] }, 13 | { title: 'Refactoring', tags: ['!', 'refactor', 'refactoring'] }, 14 | { title: 'Documentation', tags: ['doc', 'docs'] }, 15 | { title: 'Tests', tags: ['test', 'tests'] }, 16 | { title: 'Build System', tags: ['build', 'ci'] }, 17 | { title: 'Reverts', tags: ['reverts'] } 18 | ]; 19 | 20 | function transform(commit) { 21 | if (known[commit.hash]) 22 | 23 | commit = { ...commit, ...known[commit.hash] }; 24 | if (!commit.tag) 25 | return undefined; 26 | 27 | let { tag, message } = commit; 28 | 29 | if (commit.revert) 30 | tag = 'Revert'; 31 | 32 | if (message) 33 | message = message[0].toUpperCase() + message.substr(1); 34 | 35 | const matchingTitle = TITLES.find(t => t.tags.includes(tag.toLowerCase())); 36 | if (matchingTitle) 37 | tag = matchingTitle.title; 38 | else 39 | tag = 'Changes'; 40 | 41 | return { 42 | ...commit, 43 | tag, 44 | message, 45 | shortHash: commit.hash.substring(0, 7) 46 | }; 47 | } 48 | 49 | function commitGroupsSort(a, b) { 50 | const gRankA = TITLES.findIndex(t => t.title === a.title); 51 | const gRankB = TITLES.findIndex(t => t.title === b.title); 52 | return gRankA - gRankB; 53 | } 54 | 55 | async function presetOpts(cb) { 56 | const parserOpts = { 57 | headerPattern: /^(\w*):\s*(.*)$/, // /^(\w*:|[+\-*!])\s*(.*)$/, 58 | headerCorrespondence: [ 59 | 'tag', 60 | 'message' 61 | ] 62 | }; 63 | 64 | const mainTemplate = await readFile(resolve(__dirname, './templates/template.hbs'), 'utf-8'); 65 | const headerPartial = await readFile(resolve(__dirname, './templates/header.hbs'), 'utf-8'); 66 | const commitPartial = await readFile(resolve(__dirname, './templates/commit.hbs'), 'utf-8'); 67 | 68 | const writerOpts = { 69 | transform, 70 | groupBy: 'tag', 71 | commitGroupsSort, 72 | commitsSort: ['tag', 'committerDate'], 73 | mainTemplate, 74 | headerPartial, 75 | commitPartial, 76 | merges: true 77 | }; 78 | 79 | cb(null, { 80 | gitRawCommitsOpts: { 81 | merges: null, 82 | noMerges: null 83 | }, 84 | parserOpts, 85 | writerOpts 86 | }); 87 | } 88 | 89 | module.exports = presetOpts; 90 | -------------------------------------------------------------------------------- /scripts/changelog/templates/commit.hbs: -------------------------------------------------------------------------------- 1 | * {{#if message}}{{message}}{{else}}{{header}}{{/if}} 2 | 3 | {{~!-- commit hash --}} {{#if @root.linkReferences}}([{{shortHash}}]({{#if @root.host}}{{@root.host}}/{{/if}}{{#if @root.owner}}{{@root.owner}}/{{/if}}{{@root.repository}}/{{@root.commit}}/{{hash}})){{else}}{{hash~}}{{/if}} 4 | 5 | {{~!-- commit references --}}{{#if references}}, closes{{~#each references}} {{#if @root.linkReferences}}[{{#if this.owner}}{{this.owner}}/{{/if}}{{this.repository}}#{{this.issue}}]({{#if @root.host}}{{@root.host}}/{{/if}}{{#if this.repository}}{{#if this.owner}}{{this.owner}}/{{/if}}{{this.repository}}{{else}}{{#if @root.owner}}{{@root.owner}}/{{/if}}{{@root.repository}}{{/if}}/{{@root.issue}}/{{this.issue}}){{else}}{{#if this.owner}}{{this.owner}}/{{/if}}{{this.repository}}#{{this.issue}}{{/if}}{{/each}}{{/if}} 6 | -------------------------------------------------------------------------------- /scripts/changelog/templates/header.hbs: -------------------------------------------------------------------------------- 1 | {{#if isPatch}}##{{else}}#{{/if}} {{#if @root.linkCompare}}[{{version}}]({{@root.host}}/{{#if @root.owner}}{{@root.owner}}/{{/if}}{{@root.repository}}/compare/{{previousTag}}...{{currentTag}}){{else}}{{version}}{{/if}}{{#if title}} "{{title}}"{{/if}}{{#if date}} ({{date}}){{/if}} 2 | -------------------------------------------------------------------------------- /scripts/changelog/templates/template.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 | {{#each commitGroups}} 4 | 5 | {{#if title}} 6 | ### {{title}} 7 | 8 | {{/if}} 9 | {{#each commits}} 10 | {{> commit root=@root}} 11 | {{/each}} 12 | {{/each}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/AbstractSaga.ts: -------------------------------------------------------------------------------- 1 | import { ICommand, Identifier, IEvent, ISaga, ISagaConstructorParams } from './interfaces'; 2 | 3 | import { getClassName, validateHandlers, getHandler } from './utils'; 4 | 5 | /** 6 | * Base class for Saga definition 7 | */ 8 | export abstract class AbstractSaga implements ISaga { 9 | 10 | /** List of events that start new saga, must be overridden in Saga implementation */ 11 | static get startsWith(): string[] { 12 | throw new Error('startsWith must be overridden to return a list of event types that start saga'); 13 | } 14 | 15 | /** List of event types being handled by Saga, must be overridden in Saga implementation */ 16 | static get handles(): string[] { 17 | return []; 18 | } 19 | 20 | /** Saga ID */ 21 | get id(): Identifier { 22 | return this.#id; 23 | } 24 | 25 | /** Saga version */ 26 | get version(): number { 27 | return this.#version; 28 | } 29 | 30 | /** Command execution queue */ 31 | get uncommittedMessages(): ICommand[] { 32 | return Array.from(this.#messages); 33 | } 34 | 35 | #id: Identifier; 36 | #version = 0; 37 | #messages: ICommand[] = []; 38 | 39 | /** 40 | * Creates an instance of AbstractSaga 41 | */ 42 | constructor(options: ISagaConstructorParams) { 43 | if (!options) 44 | throw new TypeError('options argument required'); 45 | if (!options.id) 46 | throw new TypeError('options.id argument required'); 47 | 48 | this.#id = options.id; 49 | 50 | validateHandlers(this, 'startsWith'); 51 | validateHandlers(this, 'handles'); 52 | 53 | if (options.events) { 54 | options.events.forEach(e => this.apply(e)); 55 | this.resetUncommittedMessages(); 56 | } 57 | 58 | Object.defineProperty(this, 'restored', { value: true }); 59 | } 60 | 61 | /** Modify saga state by applying an event */ 62 | apply(event: IEvent): Promise | void { 63 | if (!event) 64 | throw new TypeError('event argument required'); 65 | if (!event.type) 66 | throw new TypeError('event.type argument required'); 67 | 68 | const handler = getHandler(this, event.type); 69 | if (!handler) 70 | throw new Error(`'${event.type}' handler is not defined or not a function`); 71 | 72 | const r = handler.call(this, event); 73 | if (r instanceof Promise) { 74 | return r.then(() => { 75 | this.#version += 1; 76 | }); 77 | } 78 | 79 | this.#version += 1; 80 | return undefined; 81 | } 82 | 83 | /** Format a command and put it to the execution queue */ 84 | protected enqueue(commandType: string, aggregateId: Identifier | undefined, payload: object) { 85 | if (typeof commandType !== 'string' || !commandType.length) 86 | throw new TypeError('commandType argument must be a non-empty String'); 87 | if (!['string', 'number', 'undefined'].includes(typeof aggregateId)) 88 | throw new TypeError('aggregateId argument must be either string, number or undefined'); 89 | 90 | this.enqueueRaw({ 91 | aggregateId, 92 | sagaId: this.id, 93 | sagaVersion: this.version, 94 | type: commandType, 95 | payload 96 | }); 97 | } 98 | 99 | /** Put a command to the execution queue */ 100 | protected enqueueRaw(command: ICommand) { 101 | if (typeof command !== 'object' || !command) 102 | throw new TypeError('command argument must be an Object'); 103 | if (typeof command.type !== 'string' || !command.type.length) 104 | throw new TypeError('command.type argument must be a non-empty String'); 105 | 106 | this.#messages.push(command); 107 | } 108 | 109 | /** Clear the execution queue */ 110 | resetUncommittedMessages() { 111 | this.#messages.length = 0; 112 | } 113 | 114 | /** Get human-readable Saga name */ 115 | toString(): string { 116 | return `${getClassName(this)} ${this.id} (v${this.version})`; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/AggregateCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { getClassName, Lock, MapAssertable } from './utils'; 2 | import { 3 | IAggregate, 4 | IAggregateConstructor, 5 | IAggregateFactory, 6 | ICommand, 7 | ICommandHandler, 8 | IContainer, 9 | Identifier, 10 | IEventSet, 11 | IEventStore, 12 | ILogger, 13 | IObservable, 14 | isIObservable 15 | } from './interfaces'; 16 | 17 | /** 18 | * Aggregate command handler. 19 | * 20 | * Subscribes to event store and awaits aggregate commands. 21 | * Upon command receiving creates an instance of aggregate, 22 | * restores its state, passes command and commits emitted events to event store. 23 | */ 24 | export class AggregateCommandHandler implements ICommandHandler { 25 | 26 | #eventStore: IEventStore; 27 | #logger?: ILogger; 28 | #aggregateFactory: IAggregateFactory; 29 | #handles: string[]; 30 | 31 | /** Aggregate instances cache for concurrent command handling */ 32 | #aggregatesCache: MapAssertable> = new MapAssertable(); 33 | 34 | /** Lock for sequential aggregate command execution */ 35 | #executionLock = new Lock(); 36 | 37 | constructor({ 38 | eventStore, 39 | aggregateType, 40 | aggregateFactory, 41 | handles, 42 | logger 43 | }: Pick & { 44 | aggregateType?: IAggregateConstructor, 45 | aggregateFactory?: IAggregateFactory, 46 | handles?: string[] 47 | }) { 48 | if (!eventStore) 49 | throw new TypeError('eventStore argument required'); 50 | 51 | this.#eventStore = eventStore; 52 | this.#logger = logger && 'child' in logger ? 53 | logger.child({ service: getClassName(this) }) : 54 | logger; 55 | 56 | if (aggregateType) { 57 | const AggregateType = aggregateType; 58 | this.#aggregateFactory = params => new AggregateType(params); 59 | this.#handles = AggregateType.handles; 60 | } 61 | else if (aggregateFactory) { 62 | if (!Array.isArray(handles) || !handles.length) 63 | throw new TypeError('handles argument must be an non-empty Array'); 64 | 65 | this.#aggregateFactory = aggregateFactory; 66 | this.#handles = handles; 67 | } 68 | else { 69 | throw new TypeError('either aggregateType or aggregateFactory is required'); 70 | } 71 | } 72 | 73 | /** Subscribe to all command types handled by aggregateType */ 74 | subscribe(commandBus: IObservable) { 75 | if (!commandBus) 76 | throw new TypeError('commandBus argument required'); 77 | if (!isIObservable(commandBus)) 78 | throw new TypeError('commandBus argument must implement IObservable interface'); 79 | 80 | for (const commandType of this.#handles) 81 | commandBus.on(commandType, (cmd: ICommand) => this.execute(cmd)); 82 | } 83 | 84 | /** Restore aggregate from event store events */ 85 | async #restoreAggregate(id: Identifier): Promise { 86 | if (!id) 87 | throw new TypeError('id argument required'); 88 | 89 | const eventsIterable = this.#eventStore.getAggregateEvents(id); 90 | const aggregate = this.#aggregateFactory({ id }); 91 | 92 | let eventCount = 0; 93 | for await (const event of eventsIterable) { 94 | aggregate.mutate(event); 95 | eventCount += 1; 96 | } 97 | 98 | this.#logger?.info(`${aggregate} state restored from ${eventCount} event(s)`); 99 | 100 | return aggregate; 101 | } 102 | 103 | /** Create new aggregate with new Id generated by event store */ 104 | async #createAggregate(): Promise { 105 | const id = await this.#eventStore.getNewId(); 106 | const aggregate = this.#aggregateFactory({ id }); 107 | this.#logger?.info(`${aggregate} created`); 108 | 109 | return aggregate; 110 | } 111 | 112 | async #getAggregateInstance(aggregateId?: Identifier) { 113 | if (!aggregateId) 114 | return this.#createAggregate(); 115 | else 116 | return this.#aggregatesCache.assert(aggregateId, () => this.#restoreAggregate(aggregateId)); 117 | } 118 | 119 | /** Pass a command to corresponding aggregate */ 120 | async execute(cmd: ICommand): Promise { 121 | if (!cmd) 122 | throw new TypeError('cmd argument required'); 123 | if (!cmd.type) 124 | throw new TypeError('cmd.type argument required'); 125 | 126 | // create new or get cached aggregate instance promise 127 | // multiple concurrent calls to #getAggregateInstance will return the same promise 128 | const aggregate = await this.#getAggregateInstance(cmd.aggregateId); 129 | 130 | try { 131 | // multiple concurrent commands to a same aggregateId will execute sequentially 132 | if (cmd.aggregateId) 133 | this.#executionLock.acquire(String(cmd.aggregateId)); 134 | 135 | // pass command to aggregate instance 136 | const events = await aggregate.handle(cmd); 137 | 138 | this.#logger?.info(`${aggregate} "${cmd.type}" command processed, ${events.length} event(s) produced`); 139 | 140 | if (events.length) 141 | await this.#eventStore.dispatch(events); 142 | 143 | return events; 144 | } 145 | finally { 146 | if (cmd.aggregateId) { 147 | this.#executionLock.release(String(cmd.aggregateId)); 148 | this.#aggregatesCache.release(cmd.aggregateId); 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/CommandBus.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryMessageBus } from './in-memory'; 2 | import { 3 | ICommand, 4 | ICommandBus, 5 | IEventSet, 6 | IExtendableLogger, 7 | ILogger, 8 | IMessageBus, 9 | IMessageHandler 10 | } from './interfaces'; 11 | 12 | export class CommandBus implements ICommandBus { 13 | 14 | #logger?: ILogger; 15 | #bus: IMessageBus; 16 | 17 | constructor(o?: { 18 | messageBus?: IMessageBus, 19 | logger?: ILogger | IExtendableLogger 20 | }) { 21 | this.#bus = o?.messageBus ?? new InMemoryMessageBus(); 22 | 23 | this.#logger = o?.logger && 'child' in o.logger ? 24 | o.logger.child({ service: 'CommandBus' }) : 25 | o?.logger; 26 | } 27 | 28 | /** 29 | * Set up a command handler 30 | */ 31 | on(commandType: string, handler: IMessageHandler) { 32 | if (typeof commandType !== 'string' || !commandType.length) 33 | throw new TypeError('commandType argument must be a non-empty String'); 34 | if (typeof handler !== 'function') 35 | throw new TypeError('handler argument must be a Function'); 36 | 37 | return this.#bus.on(commandType, handler); 38 | } 39 | 40 | /** 41 | * Remove previously installed command handler 42 | */ 43 | off(commandType: string, handler: IMessageHandler) { 44 | if (typeof commandType !== 'string' || !commandType.length) 45 | throw new TypeError('commandType argument must be a non-empty String'); 46 | if (typeof handler !== 'function') 47 | throw new TypeError('handler argument must be a Function'); 48 | 49 | return this.#bus.off(commandType, handler); 50 | } 51 | 52 | /** 53 | * Format and send a command for execution 54 | */ 55 | send( 56 | type: string, 57 | aggregateId: string, 58 | options: { payload: TPayload, context: object }, 59 | ...otherArgs: object[] 60 | ): Promise { 61 | if (typeof type !== 'string' || !type.length) 62 | throw new TypeError('type argument must be a non-empty String'); 63 | if (options && typeof options !== 'object') 64 | throw new TypeError('options argument, when defined, must be an Object'); 65 | if (otherArgs.length > 1) 66 | throw new TypeError('more than expected arguments supplied'); 67 | 68 | // obsolete. left for backward compatibility 69 | const optionsContainContext = options && !('context' in options) && !('payload' in options); 70 | if (otherArgs.length || optionsContainContext) { 71 | const context = options; 72 | const payload = otherArgs.length ? otherArgs[0] : undefined; 73 | return this.sendRaw({ type, aggregateId, context, payload }); 74 | } 75 | 76 | return this.sendRaw({ type, aggregateId, ...options }); 77 | } 78 | 79 | /** 80 | * Send a command for execution 81 | */ 82 | sendRaw(command: ICommand): Promise { 83 | if (!command) 84 | throw new TypeError('command argument required'); 85 | if (!command.type) 86 | throw new TypeError('command.type argument required'); 87 | 88 | this.#logger?.debug(`sending '${command.type}' command${command.aggregateId ? ` to ${command.aggregateId}` : ''}...`); 89 | 90 | return this.#bus.send(command).then(r => { 91 | this.#logger?.debug(`'${command.type}' ${command.aggregateId ? `on ${command.aggregateId}` : ''} processed`); 92 | return r; 93 | }, error => { 94 | this.#logger?.warn(`'${command.type}' ${command.aggregateId ? `on ${command.aggregateId}` : ''} processing has failed: ${error.message}`, { 95 | stack: error.stack 96 | }); 97 | throw error; 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/CqrsContainerBuilder.ts: -------------------------------------------------------------------------------- 1 | import { ContainerBuilder, TypeConfig, TClassOrFactory } from 'di0'; 2 | import { AggregateCommandHandler } from './AggregateCommandHandler'; 3 | import { CommandBus } from './CommandBus'; 4 | import { EventStore } from './EventStore'; 5 | import { SagaEventHandler } from './SagaEventHandler'; 6 | import { EventDispatcher } from './EventDispatcher'; 7 | import { InMemoryEventStorage, InMemoryMessageBus, InMemorySnapshotStorage } from './in-memory'; 8 | import { EventValidationProcessor } from './EventValidationProcessor'; 9 | import { isClass } from './utils'; 10 | import { 11 | IAggregateConstructor, 12 | ICommandHandler, 13 | IContainer, 14 | IEventReceptor, 15 | IProjection, 16 | IProjectionConstructor, 17 | ISagaConstructor 18 | } from './interfaces'; 19 | 20 | export class CqrsContainerBuilder extends ContainerBuilder { 21 | 22 | constructor(options?: { 23 | types: Readonly[]>, 24 | singletones: object 25 | }) { 26 | super(options); 27 | super.register(InMemoryMessageBus).as('eventBus'); 28 | super.register(EventStore).as('eventStore'); 29 | super.register(CommandBus).as('commandBus'); 30 | super.register(EventDispatcher).as('eventDispatcher'); 31 | 32 | super.register(InMemoryEventStorage).as('eventStorageWriter'); 33 | super.register(InMemorySnapshotStorage).as('snapshotStorage'); 34 | 35 | // Register default event dispatch pipeline: 36 | // validate events, write to event storage, write to snapshot storage. 37 | // If any of the processors is not defined, it will be skipped. 38 | super.register((container: IContainer) => [ 39 | new EventValidationProcessor(), 40 | container.eventStorageWriter, 41 | container.snapshotStorage 42 | ]).as('eventDispatchPipeline'); 43 | } 44 | 45 | /** Register command handler, which will be subscribed to commandBus upon instance creation */ 46 | registerCommandHandler(typeOrFactory: TClassOrFactory) { 47 | return super.register( 48 | (container: IContainer) => { 49 | const handler = container.createInstance(typeOrFactory); 50 | handler.subscribe(container.commandBus); 51 | return handler; 52 | }) 53 | .asSingleInstance(); 54 | } 55 | 56 | /** Register event receptor, which will be subscribed to eventStore upon instance creation */ 57 | registerEventReceptor(typeOrFactory: TClassOrFactory) { 58 | return super.register( 59 | (container: IContainer) => { 60 | const receptor = container.createInstance(typeOrFactory); 61 | receptor.subscribe(container.eventStore); 62 | return receptor; 63 | }) 64 | .asSingleInstance(); 65 | } 66 | 67 | /** 68 | * Register projection, which will expose view and will be subscribed 69 | * to eventStore and will restore its state upon instance creation 70 | */ 71 | registerProjection(ProjectionType: IProjectionConstructor, exposedViewAlias?: string) { 72 | if (!isClass(ProjectionType)) 73 | throw new TypeError('ProjectionType argument must be a constructor function'); 74 | 75 | const projectionFactory = (container: IContainer): IProjection => { 76 | const projection = container.createInstance(ProjectionType); 77 | projection.subscribe(container.eventStore); 78 | 79 | if (exposedViewAlias) 80 | return projection.view; 81 | 82 | return projection; 83 | }; 84 | 85 | const t = super.register(projectionFactory).asSingleInstance(); 86 | 87 | if (exposedViewAlias) 88 | t.as(exposedViewAlias); 89 | 90 | return t; 91 | } 92 | 93 | /** Register aggregate type in the container */ 94 | registerAggregate(AggregateType: IAggregateConstructor) { 95 | if (!isClass(AggregateType)) 96 | throw new TypeError('AggregateType argument must be a constructor function'); 97 | 98 | const commandHandlerFactory = (container: IContainer): ICommandHandler => 99 | container.createInstance(AggregateCommandHandler, { 100 | aggregateFactory: (options: any) => 101 | container.createInstance(AggregateType, options), 102 | handles: AggregateType.handles 103 | }); 104 | 105 | return this.registerCommandHandler(commandHandlerFactory); 106 | } 107 | 108 | 109 | /** Register saga type in the container */ 110 | registerSaga(SagaType: ISagaConstructor) { 111 | if (!isClass(SagaType)) 112 | throw new TypeError('SagaType argument must be a constructor function'); 113 | 114 | const eventReceptorFactory = (container: IContainer): IEventReceptor => 115 | container.createInstance(SagaEventHandler, { 116 | sagaFactory: (options: any) => container.createInstance(SagaType, options), 117 | handles: SagaType.handles, 118 | startsWith: SagaType.startsWith, 119 | queueName: SagaType.name 120 | }); 121 | 122 | return this.registerEventReceptor(eventReceptorFactory); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from './interfaces'; 2 | 3 | /** 4 | * Get text description of an event for logging purposes 5 | */ 6 | export function describe(event: IEvent): string { 7 | return `'${event.type}' of ${event.aggregateId} (v${event.aggregateVersion})`; 8 | } 9 | 10 | /** 11 | * Get text description of a set of events for logging purposes 12 | */ 13 | export function describeMultiple(events: ReadonlyArray): string { 14 | if (events.length === 1) 15 | return describe(events[0]); 16 | 17 | return `${events.length} events`; 18 | } 19 | 20 | /** 21 | * Validate event structure 22 | */ 23 | export function validate(event: IEvent) { 24 | if (typeof event !== 'object' || !event) 25 | throw new TypeError('event must be an Object'); 26 | if (typeof event.type !== 'string' || !event.type.length) 27 | throw new TypeError('event.type must be a non-empty String'); 28 | if (!event.aggregateId && !event.sagaId) 29 | throw new TypeError('either event.aggregateId or event.sagaId is required'); 30 | if (event.sagaId && typeof event.sagaVersion === 'undefined') 31 | throw new TypeError('event.sagaVersion is required, when event.sagaId is defined'); 32 | } 33 | -------------------------------------------------------------------------------- /src/EventDispatcher.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DispatchPipelineBatch, 3 | IEvent, 4 | IEventDispatcher, 5 | IDispatchPipelineProcessor, 6 | IEventSet, 7 | IEventBus, 8 | isEventSet, 9 | IContainer, 10 | isDispatchPipelineProcessor 11 | } from './interfaces'; 12 | import { parallelPipe } from 'async-parallel-pipe'; 13 | import { AsyncIterableBuffer } from 'async-iterable-buffer'; 14 | import { getClassName, notEmpty } from './utils'; 15 | import { InMemoryMessageBus } from './in-memory'; 16 | 17 | type EventBatchEnvelope = { 18 | data: DispatchPipelineBatch<{ event?: IEvent }>; 19 | error?: Error; 20 | resolve: (event: IEvent[]) => void; 21 | reject: (error: Error) => void; 22 | } 23 | 24 | export class EventDispatcher implements IEventDispatcher { 25 | 26 | #pipelineInput = new AsyncIterableBuffer(); 27 | #processors: Array = []; 28 | #pipeline: AsyncIterableIterator | IterableIterator = this.#pipelineInput; 29 | 30 | /** 31 | * Event bus where dispatched messages are delivered after processing. 32 | * 33 | * If not provided in the constructor, defaults to an instance of `InMemoryMessageBus`. 34 | */ 35 | eventBus: IEventBus; 36 | 37 | /** 38 | * Maximum number of event batches that each pipeline processor can handle in parallel. 39 | */ 40 | concurrentLimit: number; 41 | 42 | constructor(o?: Pick & { 43 | eventDispatcherConfig?: { 44 | concurrentLimit?: number 45 | } 46 | }) { 47 | this.eventBus = o?.eventBus ?? new InMemoryMessageBus(); 48 | this.concurrentLimit = o?.eventDispatcherConfig?.concurrentLimit ?? 100; 49 | 50 | if (o?.eventDispatchPipeline) 51 | this.addPipelineProcessors(o.eventDispatchPipeline); 52 | } 53 | 54 | addPipelineProcessors(eventDispatchPipeline: IDispatchPipelineProcessor[]) { 55 | if (!Array.isArray(eventDispatchPipeline)) 56 | throw new TypeError('eventDispatchPipeline argument must be an Array'); 57 | 58 | for (const processor of eventDispatchPipeline) { 59 | if (processor) 60 | this.addPipelineProcessor(processor); 61 | } 62 | } 63 | 64 | /** 65 | * Adds a preprocessor to the event dispatch pipeline. 66 | * 67 | * Preprocessors run in order they are added but process separate batches in parallel, maintaining FIFO order. 68 | */ 69 | addPipelineProcessor(preprocessor: IDispatchPipelineProcessor) { 70 | if (!isDispatchPipelineProcessor(preprocessor)) 71 | throw new TypeError(`preprocessor ${getClassName(preprocessor)} does not implement IDispatchPipelineProcessor`); 72 | if (this.#pipelineProcessing) 73 | throw new Error('pipeline processing already started'); 74 | 75 | this.#processors.push(preprocessor); 76 | 77 | // Build a processing pipeline that runs preprocessors concurrently, preserving FIFO ordering 78 | this.#pipeline = parallelPipe(this.#pipeline, this.concurrentLimit, async envelope => { 79 | if (envelope.error) 80 | return envelope; 81 | 82 | try { 83 | return { 84 | ...envelope, 85 | data: await preprocessor.process(envelope.data) 86 | }; 87 | } 88 | catch (error: any) { 89 | return { 90 | ...envelope, 91 | error 92 | }; 93 | } 94 | }); 95 | } 96 | 97 | #pipelineProcessing = false; 98 | 99 | /** 100 | * Consume the pipeline, publish events, and resolve/reject each batch 101 | */ 102 | async #startPipelineProcessing() { 103 | if (this.#pipelineProcessing) // should never happen 104 | throw new Error('pipeline processing already started'); 105 | 106 | this.#pipelineProcessing = true; 107 | 108 | for await (const { error, reject, data, resolve } of this.#pipeline) { 109 | if (error) { // some of the preprocessors failed 110 | await this.#revert(data); 111 | reject(error); 112 | continue; 113 | } 114 | 115 | const events = data.map(e => e.event).filter(notEmpty); 116 | 117 | try { 118 | for (const batch of data) { 119 | const { event, ...meta } = batch; 120 | if (event) 121 | this.eventBus.publish(event, meta); 122 | } 123 | 124 | resolve(events); 125 | } 126 | catch (publishError: any) { 127 | reject(publishError); 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Revert side effects made by pipeline processors in case of a batch processing failure 134 | */ 135 | async #revert(batch: DispatchPipelineBatch) { 136 | for (const processor of this.#processors) 137 | await processor.revert?.(batch); 138 | } 139 | 140 | /** 141 | * Dispatch a set of events through the processing pipeline. 142 | * 143 | * Returns a promise that resolves after all events are processed and published. 144 | */ 145 | async dispatch(events: IEventSet, meta?: Record) { 146 | if (!isEventSet(events) || events.length === 0) 147 | throw new Error('dispatch requires a non-empty array of events'); 148 | 149 | // const { promise, resolve, reject } = Promise.withResolvers(); 150 | let resolve!: (value: IEventSet | PromiseLike) => void; 151 | let reject!: (reason?: any) => void; 152 | const promise = new Promise((res, rej) => { 153 | resolve = res; 154 | reject = rej; 155 | }); 156 | 157 | const envelope: EventBatchEnvelope = { 158 | data: events.map(event => ({ 159 | event, 160 | ...meta 161 | })), 162 | resolve, 163 | reject 164 | }; 165 | 166 | if (!this.#pipelineProcessing) 167 | this.#startPipelineProcessing(); 168 | 169 | this.#pipelineInput.push(envelope); 170 | 171 | return promise; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/EventValidationProcessor.ts: -------------------------------------------------------------------------------- 1 | import { DispatchPipelineBatch, IEvent, IDispatchPipelineProcessor } from './interfaces'; 2 | import { validate as defaultValidator } from './Event'; 3 | 4 | export type EventValidator = (event: IEvent) => void; 5 | 6 | /** 7 | * Processor that validates the format of events. 8 | * Rejects the batch if any event fails validation. 9 | */ 10 | export class EventValidationProcessor implements IDispatchPipelineProcessor { 11 | 12 | #validate: EventValidator; 13 | 14 | constructor(o?: { 15 | eventFormatValidator?: EventValidator 16 | }) { 17 | this.#validate = o?.eventFormatValidator ?? defaultValidator; 18 | } 19 | 20 | /** 21 | * Processes a batch of dispatch pipeline items by validating each event within the batch. 22 | * It iterates through the batch and calls the private `#validate` method for each event found. 23 | * 24 | * This method is part of the `IDispatchPipelineProcessor` interface. 25 | */ 26 | async process(batch: DispatchPipelineBatch): Promise { 27 | for (const { event } of batch) { 28 | if (event) 29 | this.#validate(event); 30 | } 31 | return batch; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/SagaEventHandler.ts: -------------------------------------------------------------------------------- 1 | import * as Event from './Event'; 2 | import { 3 | ICommandBus, 4 | IContainer, 5 | IEvent, 6 | IEventReceptor, 7 | IEventStore, 8 | ILogger, 9 | IObservable, 10 | ISaga, 11 | ISagaConstructor, 12 | ISagaFactory 13 | } from './interfaces'; 14 | 15 | import { 16 | subscribe, 17 | getClassName, 18 | iteratorToArray 19 | } from './utils'; 20 | 21 | /** 22 | * Listens to Saga events, 23 | * creates new saga or restores it from event store, 24 | * applies new events 25 | * and passes command(s) to command bus 26 | */ 27 | export class SagaEventHandler implements IEventReceptor { 28 | 29 | #eventStore: IEventStore; 30 | #commandBus: ICommandBus; 31 | #queueName?: string; 32 | #logger?: ILogger; 33 | #sagaFactory: (params: any) => ISaga; 34 | #startsWith: string[]; 35 | #handles: string[]; 36 | 37 | constructor(options: Pick & { 38 | sagaType?: ISagaConstructor, 39 | sagaFactory?: ISagaFactory, 40 | queueName?: string, 41 | startsWith?: string[], 42 | handles?: string[] 43 | }) { 44 | if (!options) 45 | throw new TypeError('options argument required'); 46 | if (!options.eventStore) 47 | throw new TypeError('options.eventStore argument required'); 48 | if (!options.commandBus) 49 | throw new TypeError('options.commandBus argument required'); 50 | 51 | this.#eventStore = options.eventStore; 52 | this.#commandBus = options.commandBus; 53 | this.#queueName = options.queueName; 54 | this.#logger = options.logger && 'child' in options.logger ? 55 | options.logger.child({ service: getClassName(this) }) : 56 | options.logger; 57 | 58 | if (options.sagaType) { 59 | const SagaType = options.sagaType as ISagaConstructor; 60 | 61 | this.#sagaFactory = params => new SagaType(params); 62 | this.#startsWith = SagaType.startsWith; 63 | this.#handles = SagaType.handles; 64 | } 65 | else if (options.sagaFactory) { 66 | if (!Array.isArray(options.startsWith)) 67 | throw new TypeError('options.startsWith argument must be an Array'); 68 | if (!Array.isArray(options.handles)) 69 | throw new TypeError('options.handles argument must be an Array'); 70 | 71 | this.#sagaFactory = options.sagaFactory; 72 | this.#startsWith = options.startsWith; 73 | this.#handles = options.handles; 74 | } 75 | else { 76 | throw new Error('Either sagaType or sagaFactory is required'); 77 | } 78 | 79 | this.#eventStore.registerSagaStarters(options.startsWith); 80 | } 81 | 82 | /** Overrides observer subscribe method */ 83 | subscribe(eventStore: IObservable) { 84 | subscribe(eventStore, this, { 85 | messageTypes: [...this.#startsWith, ...this.#handles], 86 | masterHandler: e => this.handle(e), 87 | queueName: this.#queueName 88 | }); 89 | } 90 | 91 | /** Handle saga event */ 92 | async handle(event: IEvent): Promise { 93 | if (!event) 94 | throw new TypeError('event argument required'); 95 | if (!event.type) 96 | throw new TypeError('event.type argument required'); 97 | 98 | const isSagaStarterEvent = this.#startsWith.includes(event.type); 99 | const saga = isSagaStarterEvent ? 100 | await this.#createSaga() : 101 | await this.#restoreSaga(event); 102 | 103 | const r = saga.apply(event); 104 | if (r instanceof Promise) 105 | await r; 106 | 107 | await this.#sendCommands(saga, event); 108 | 109 | // additional commands can be added by the saga.onError handler 110 | if (saga.uncommittedMessages.length) 111 | await this.#sendCommands(saga, event); 112 | } 113 | 114 | async #sendCommands(saga: ISaga, event: IEvent) { 115 | const commands = saga.uncommittedMessages; 116 | saga.resetUncommittedMessages(); 117 | 118 | this.#logger?.debug(`"${Event.describe(event)}" processed, ${commands.map(c => c.type).join(',') || 'no commands'} produced`); 119 | 120 | for (const command of commands) { 121 | 122 | // attach event context to produced command 123 | if (command.context === undefined && event.context !== undefined) 124 | command.context = event.context; 125 | 126 | try { 127 | await this.#commandBus.sendRaw(command); 128 | } 129 | catch (err: any) { 130 | if (typeof saga.onError === 'function') { 131 | // let saga to handle the error 132 | saga.onError(err, { event, command }); 133 | } 134 | else { 135 | throw err; 136 | } 137 | } 138 | } 139 | } 140 | 141 | /** Start new saga */ 142 | async #createSaga(): Promise { 143 | const id = await this.#eventStore.getNewId(); 144 | return this.#sagaFactory.call(null, { id }); 145 | } 146 | 147 | /** Restore saga from event store */ 148 | async #restoreSaga(event: IEvent): Promise { 149 | if (!event.sagaId) 150 | throw new TypeError(`${Event.describe(event)} does not contain sagaId`); 151 | 152 | const eventsIterable = this.#eventStore.getSagaEvents(event.sagaId, { beforeEvent: event }); 153 | const events = await iteratorToArray(eventsIterable); 154 | 155 | const saga = this.#sagaFactory.call(null, { id: event.sagaId, events }); 156 | this.#logger?.info(`Saga state restored from ${events.length} event(s)`); 157 | 158 | return saga; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/in-memory/InMemoryEventStorage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IIdentifierProvider, 3 | IEvent, 4 | IEventSet, 5 | EventQueryAfter, 6 | IEventStorageReader, 7 | IEventStream, 8 | IEventStorageWriter, 9 | Identifier, 10 | IDispatchPipelineProcessor, 11 | DispatchPipelineBatch 12 | } from '../interfaces'; 13 | import { nextCycle } from './utils'; 14 | 15 | /** 16 | * A simple event storage implementation intended to use for tests only. 17 | * Storage content resets on each app restart. 18 | */ 19 | export class InMemoryEventStorage implements 20 | IEventStorageReader, 21 | IEventStorageWriter, 22 | IIdentifierProvider, 23 | IDispatchPipelineProcessor { 24 | 25 | #nextId: number = 0; 26 | #events: IEventSet = []; 27 | 28 | getNewId(): string { 29 | this.#nextId += 1; 30 | return String(this.#nextId); 31 | } 32 | 33 | async commitEvents(events: IEventSet): Promise { 34 | await nextCycle(); 35 | 36 | this.#events = this.#events.concat(events); 37 | 38 | await nextCycle(); 39 | 40 | return events; 41 | } 42 | 43 | async* getAggregateEvents(aggregateId: Identifier, options?: { snapshot: IEvent }): IEventStream { 44 | await nextCycle(); 45 | 46 | const afterVersion = options?.snapshot?.aggregateVersion; 47 | const results = !afterVersion ? 48 | this.#events.filter(e => e.aggregateId === aggregateId) : 49 | this.#events.filter(e => 50 | e.aggregateId === aggregateId && 51 | e.aggregateVersion !== undefined && 52 | e.aggregateVersion > afterVersion); 53 | 54 | await nextCycle(); 55 | 56 | yield* results; 57 | } 58 | 59 | async* getSagaEvents(sagaId: Identifier, { beforeEvent }: { beforeEvent: IEvent }): IEventStream { 60 | await nextCycle(); 61 | 62 | const results = this.#events.filter(e => 63 | e.sagaId === sagaId && 64 | e.sagaVersion !== undefined && 65 | beforeEvent.sagaVersion !== undefined && 66 | e.sagaVersion < beforeEvent.sagaVersion); 67 | 68 | await nextCycle(); 69 | 70 | yield* results; 71 | } 72 | 73 | async* getEventsByTypes(eventTypes: Readonly, options?: EventQueryAfter): IEventStream { 74 | await nextCycle(); 75 | 76 | const lastEventId = options?.afterEvent?.id; 77 | if (options?.afterEvent && !lastEventId) 78 | throw new TypeError('options.afterEvent.id is required'); 79 | 80 | let offsetFound = !lastEventId; 81 | for (const event of this.#events) { 82 | if (!offsetFound) 83 | offsetFound = event.id === lastEventId; 84 | else if (!eventTypes || eventTypes.includes(event.type)) 85 | yield event; 86 | } 87 | } 88 | 89 | /** 90 | * Processes a batch of dispatch pipeline items, extracts the events, 91 | * commits them to the in-memory storage, and returns the original batch. 92 | * 93 | * This method is part of the `IDispatchPipelineProcessor` interface. 94 | */ 95 | async process(batch: DispatchPipelineBatch): Promise { 96 | const events: IEvent[] = []; 97 | for (const { event } of batch) { 98 | if (!event) 99 | throw new Error('Event batch does not contain `event`'); 100 | 101 | events.push(event); 102 | } 103 | 104 | await this.commitEvents(events); 105 | 106 | return batch; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/in-memory/InMemoryLock.ts: -------------------------------------------------------------------------------- 1 | import { Deferred } from '../utils'; 2 | 3 | export class InMemoryLock { 4 | 5 | #lockMarker: Deferred | undefined; 6 | 7 | /** 8 | * Indicates if lock is acquired 9 | */ 10 | get locked(): boolean { 11 | return !!this.#lockMarker; 12 | } 13 | 14 | /** 15 | * Acquire the lock on the current instance. 16 | * Resolves when the lock is successfully acquired 17 | */ 18 | async lock(): Promise { 19 | while (this.locked) 20 | await this.once('unlocked'); 21 | 22 | this.#lockMarker = new Deferred(); 23 | } 24 | 25 | /** 26 | * Release the lock acquired earlier 27 | */ 28 | async unlock(): Promise { 29 | this.#lockMarker?.resolve(); 30 | this.#lockMarker = undefined; 31 | } 32 | 33 | /** 34 | * Wait until the lock is released. 35 | * Resolves immediately if the lock is not acquired 36 | */ 37 | once(event: 'unlocked'): Promise { 38 | if (event !== 'unlocked') 39 | throw new TypeError(`Unexpected event type: ${event}`); 40 | 41 | return this.#lockMarker?.promise ?? Promise.resolve(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/in-memory/InMemoryMessageBus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DispatchPipelineBatch, 3 | ICommand, 4 | IDispatchPipelineProcessor, 5 | IEvent, 6 | IMessageBus, 7 | IMessageHandler, 8 | IObservable 9 | } from '../interfaces'; 10 | 11 | /** 12 | * Default implementation of the message bus. 13 | * Keeps all subscriptions and messages in memory. 14 | */ 15 | export class InMemoryMessageBus implements IMessageBus, IDispatchPipelineProcessor { 16 | 17 | protected handlers: Map> = new Map(); 18 | protected uniqueEventHandlers: boolean; 19 | protected queueName: string | undefined; 20 | protected queues: Map = new Map(); 21 | 22 | constructor({ queueName, uniqueEventHandlers = !!queueName }: { 23 | queueName?: string, 24 | uniqueEventHandlers?: boolean 25 | } = {}) { 26 | this.queueName = queueName; 27 | this.uniqueEventHandlers = uniqueEventHandlers; 28 | } 29 | 30 | /** 31 | * Subscribe to message type 32 | */ 33 | on(messageType: string, handler: IMessageHandler) { 34 | if (typeof messageType !== 'string' || !messageType.length) 35 | throw new TypeError('messageType argument must be a non-empty String'); 36 | if (typeof handler !== 'function') 37 | throw new TypeError('handler argument must be a Function'); 38 | if (arguments.length !== 2) 39 | throw new TypeError(`2 arguments are expected, but ${arguments.length} received`); 40 | 41 | // Events published to a named queue must be consumed only once. 42 | // For example, for sending a welcome email, NotificationReceptor will subscribe to "notifications:userCreated". 43 | // Since we use an in-memory bus, there is no need to track message handling by multiple distributed 44 | // subscribers, and we only need to make sure that no more than 1 such subscriber will be created 45 | if (!this.handlers.has(messageType)) 46 | this.handlers.set(messageType, new Set()); 47 | else if (this.uniqueEventHandlers) 48 | throw new Error(`"${messageType}" handler is already set up on the "${this.queueName}" queue`); 49 | 50 | this.handlers.get(messageType)?.add(handler); 51 | } 52 | 53 | /** 54 | * Get or create a named queue. 55 | * Named queues support only one handler per event type. 56 | */ 57 | queue(queueName: string): IObservable { 58 | let queue = this.queues.get(queueName); 59 | if (!queue) { 60 | queue = new InMemoryMessageBus({ queueName, uniqueEventHandlers: true }); 61 | this.queues.set(queueName, queue); 62 | } 63 | 64 | return queue; 65 | } 66 | 67 | /** 68 | * Remove subscription 69 | */ 70 | off(messageType: string, handler: IMessageHandler) { 71 | if (typeof messageType !== 'string' || !messageType.length) 72 | throw new TypeError('messageType argument must be a non-empty String'); 73 | if (typeof handler !== 'function') 74 | throw new TypeError('handler argument must be a Function'); 75 | if (arguments.length !== 2) 76 | throw new TypeError(`2 arguments are expected, but ${arguments.length} received`); 77 | if (!this.handlers.has(messageType)) 78 | throw new Error(`No ${messageType} subscribers found`); 79 | 80 | this.handlers.get(messageType)?.delete(handler); 81 | } 82 | 83 | /** 84 | * Send command to exactly 1 command handler 85 | */ 86 | async send(command: ICommand): Promise { 87 | if (typeof command !== 'object' || !command) 88 | throw new TypeError('command argument must be an Object'); 89 | if (typeof command.type !== 'string' || !command.type.length) 90 | throw new TypeError('command.type argument must be a non-empty String'); 91 | 92 | const handlers = this.handlers.get(command.type); 93 | if (!handlers || !handlers.size) 94 | throw new Error(`No '${command.type}' subscribers found`); 95 | if (handlers.size > 1) 96 | throw new Error(`More than one '${command.type}' subscriber found`); 97 | 98 | const commandHandler = handlers.values().next().value; 99 | 100 | return commandHandler!(command); 101 | } 102 | 103 | /** 104 | * Publish event to all subscribers (if any) 105 | */ 106 | async publish(event: IEvent, meta?: Record): Promise { 107 | if (typeof event !== 'object' || !event) 108 | throw new TypeError('event argument must be an Object'); 109 | if (typeof event.type !== 'string' || !event.type.length) 110 | throw new TypeError('event.type argument must be a non-empty String'); 111 | 112 | const handlers = [ 113 | ...this.handlers.get(event.type) || [], 114 | ...Array.from(this.queues.values()).map(namedQueue => 115 | (e: IEvent, m?: Record) => namedQueue.publish(e, m)) 116 | ]; 117 | 118 | return Promise.all(handlers.map(handler => handler(event, meta))); 119 | } 120 | 121 | /** 122 | * Processes a batch of events and publishes them to the fanout exchange. 123 | * 124 | * This method is part of the `IDispatchPipelineProcessor` interface. 125 | */ 126 | async process(batch: DispatchPipelineBatch): Promise { 127 | for (const { event, origin } of batch) { 128 | // Skip publishing if the event was dispatched from external source 129 | if (origin === 'external') 130 | continue; 131 | 132 | if (!event) 133 | throw new Error('Event batch does not contain `event`'); 134 | 135 | await this.publish(event); 136 | } 137 | 138 | return batch; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/in-memory/InMemorySnapshotStorage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DispatchPipelineBatch, 3 | IAggregateSnapshotStorage, 4 | IContainer, 5 | Identifier, 6 | IDispatchPipelineProcessor, 7 | IEvent, 8 | ILogger 9 | } from '../interfaces'; 10 | import * as Event from '../Event'; 11 | 12 | const SNAPSHOT_EVENT_TYPE = 'snapshot'; 13 | const isSnapshotEvent = (event?: IEvent): event is IEvent & { type: 'snapshot' } => 14 | (!!event && event.type === SNAPSHOT_EVENT_TYPE); 15 | 16 | /** 17 | * In-memory storage for aggregate snapshots. 18 | * Storage content resets on app restart 19 | */ 20 | export class InMemorySnapshotStorage implements IAggregateSnapshotStorage, IDispatchPipelineProcessor { 21 | 22 | #snapshots: Map = new Map(); 23 | #logger: ILogger | undefined; 24 | 25 | constructor(c?: Partial>) { 26 | this.#logger = c?.logger && 'child' in c?.logger ? 27 | c?.logger.child({ service: new.target.name }) : 28 | c?.logger; 29 | } 30 | 31 | /** 32 | * Get latest aggregate snapshot 33 | */ 34 | async getAggregateSnapshot(aggregateId: string): Promise { 35 | return this.#snapshots.get(aggregateId); 36 | } 37 | 38 | /** 39 | * Save new aggregate snapshot 40 | */ 41 | async saveAggregateSnapshot(snapshotEvent: IEvent) { 42 | if (!snapshotEvent.aggregateId) 43 | throw new TypeError('event.aggregateId is required'); 44 | 45 | this.#logger?.debug(`Persisting ${Event.describe(snapshotEvent)}`); 46 | 47 | this.#snapshots.set(snapshotEvent.aggregateId, snapshotEvent); 48 | } 49 | 50 | /** 51 | * Delete aggregate snapshot 52 | */ 53 | deleteAggregateSnapshot(snapshotEvent: IEvent): Promise | void { 54 | if (!snapshotEvent.aggregateId) 55 | throw new TypeError('snapshotEvent.aggregateId argument required'); 56 | 57 | this.#logger?.debug(`Removing ${Event.describe(snapshotEvent)}`); 58 | 59 | this.#snapshots.delete(snapshotEvent.aggregateId); 60 | } 61 | 62 | /** 63 | * Processes a batch of events, saves any snapshot events found, and returns the batch 64 | * without the snapshot events. 65 | * 66 | * This method is part of the `IDispatchPipelineProcessor` interface. 67 | */ 68 | async process(batch: DispatchPipelineBatch): Promise { 69 | const snapshotEvents = batch.map(e => e.event).filter(isSnapshotEvent); 70 | for (const event of snapshotEvents) 71 | await this.saveAggregateSnapshot(event); 72 | 73 | return batch.filter(e => !isSnapshotEvent(e.event)); 74 | } 75 | 76 | /** 77 | * Reverts the snapshots associated with the events in the given batch. 78 | * It filters the batch for snapshot events and deletes the corresponding aggregate snapshots. 79 | * 80 | * This method is part of the `IDispatchPipelineProcessor` interface. 81 | * 82 | * @param batch The batch of events to revert snapshots for. 83 | */ 84 | async revert(batch: DispatchPipelineBatch): Promise { 85 | const snapshotEvents = batch.map(e => e.event).filter(isSnapshotEvent); 86 | for (const snapshotEvent of snapshotEvents) 87 | await this.deleteAggregateSnapshot(snapshotEvent); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/in-memory/index.ts: -------------------------------------------------------------------------------- 1 | export * from './InMemoryEventStorage'; 2 | export * from './InMemoryLock'; 3 | export * from './InMemoryMessageBus'; 4 | export * from './InMemorySnapshotStorage'; 5 | export * from './InMemoryView'; 6 | -------------------------------------------------------------------------------- /src/in-memory/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './nextCycle'; 2 | -------------------------------------------------------------------------------- /src/in-memory/utils/nextCycle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @returns Promise that resolves on next event loop cycle 3 | */ 4 | export const nextCycle = (): Promise => new Promise(rs => setImmediate(rs)); 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { CqrsContainerBuilder as ContainerBuilder } from './CqrsContainerBuilder'; 2 | 3 | export * from './CommandBus'; 4 | export * from './EventStore'; 5 | 6 | export * from './AbstractAggregate'; 7 | export * from './AggregateCommandHandler'; 8 | export * from './AbstractSaga'; 9 | export * from './SagaEventHandler'; 10 | export * from './AbstractProjection'; 11 | export * from './EventDispatcher'; 12 | export * from './EventValidationProcessor'; 13 | 14 | export * from './in-memory'; 15 | 16 | export * as Event from './Event'; 17 | export { 18 | getMessageHandlerNames, 19 | subscribe 20 | } from './utils'; 21 | 22 | export * from './interfaces'; 23 | -------------------------------------------------------------------------------- /src/interfaces/IAggregate.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from './ICommand'; 2 | import { Identifier } from './Identifier'; 3 | import { IEvent } from './IEvent'; 4 | import { IEventSet } from './IEventSet'; 5 | 6 | /** 7 | * Minimum aggregate interface, as it's used by default `AggregateCommandHandler` 8 | */ 9 | export interface IAggregate { 10 | 11 | /** 12 | * Apply a single event to mutate the aggregate's state. 13 | * 14 | * Used by `AggregateCommandHandler` when restoring the aggregate state from the event store. 15 | */ 16 | mutate(event: IEvent): void; 17 | 18 | /** 19 | * Process a command sent to the aggregate. 20 | * 21 | * This is the main entry point for handling aggregate commands. 22 | */ 23 | handle(command: ICommand): IEventSet | Promise; 24 | } 25 | 26 | export interface IMutableAggregateState { 27 | 28 | /** 29 | * Apply a single event to mutate the aggregate's state. 30 | */ 31 | mutate(event: IEvent): void; 32 | } 33 | 34 | export type IAggregateConstructorParams = { 35 | 36 | /** Unique aggregate identifier */ 37 | id: Identifier, 38 | 39 | /** 40 | * @deprecated The aggregate no longer receives all events in the constructor. 41 | * Instead, events are loaded and passed to the `mutate` method after instantiation. 42 | */ 43 | events?: IEventSet, 44 | 45 | /** Aggregate state instance */ 46 | state?: TState 47 | }; 48 | 49 | export interface IAggregateConstructor< 50 | TAggregate extends IAggregate, 51 | TState extends IMutableAggregateState | object | void 52 | > { 53 | readonly handles: string[]; 54 | new(options: IAggregateConstructorParams): TAggregate; 55 | } 56 | 57 | export type IAggregateFactory< 58 | TAggregate extends IAggregate, 59 | TState extends IMutableAggregateState | object | void 60 | > = (options: IAggregateConstructorParams) => TAggregate; 61 | -------------------------------------------------------------------------------- /src/interfaces/IAggregateSnapshotStorage.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from './Identifier'; 2 | import { IEvent } from './IEvent'; 3 | 4 | export interface IAggregateSnapshotStorage { 5 | getAggregateSnapshot(aggregateId: Identifier): 6 | Promise | undefined> | IEvent | undefined; 7 | 8 | saveAggregateSnapshot(snapshotEvent: IEvent): Promise | void; 9 | 10 | deleteAggregateSnapshot(snapshotEvent: IEvent): Promise | void; 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces/ICommand.ts: -------------------------------------------------------------------------------- 1 | import { IMessage } from './IMessage'; 2 | 3 | export type ICommand = IMessage; 4 | -------------------------------------------------------------------------------- /src/interfaces/ICommandBus.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from './ICommand'; 2 | import { IEventSet } from './IEventSet'; 3 | import { IObservable } from './IObservable'; 4 | import { IObserver } from './IObserver'; 5 | 6 | export interface ICommandBus extends IObservable { 7 | send(commandType: string, aggregateId: string | undefined, options: { payload?: object, context?: object }): 8 | Promise; 9 | 10 | sendRaw(command: ICommand): 11 | Promise; 12 | } 13 | 14 | export interface ICommandHandler extends IObserver { 15 | subscribe(commandBus: ICommandBus): void; 16 | } 17 | -------------------------------------------------------------------------------- /src/interfaces/IContainer.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'di0'; 2 | import { ICommandBus } from './ICommandBus'; 3 | import { IEventDispatcher } from './IEventDispatcher'; 4 | import { IEventStore } from './IEventStore'; 5 | import { IEventBus } from './IEventBus'; 6 | import { IDispatchPipelineProcessor } from './IDispatchPipelineProcessor'; 7 | import { IEventStorageReader, IEventStorageWriter } from './IEventStorage'; 8 | import { IAggregateSnapshotStorage } from './IAggregateSnapshotStorage'; 9 | import { IIdentifierProvider } from './IIdentifierProvider'; 10 | import { IExtendableLogger, ILogger } from './ILogger'; 11 | 12 | export interface IContainer extends Container { 13 | eventBus: IEventBus; 14 | eventStore: IEventStore 15 | eventStorageReader: IEventStorageReader; 16 | eventStorageWriter?: IEventStorageWriter; 17 | identifierProvider?: IIdentifierProvider; 18 | snapshotStorage?: IAggregateSnapshotStorage; 19 | 20 | commandBus: ICommandBus; 21 | eventDispatcher: IEventDispatcher; 22 | eventDispatchPipeline?: IDispatchPipelineProcessor[]; 23 | 24 | logger?: ILogger | IExtendableLogger; 25 | 26 | process?: NodeJS.Process 27 | } 28 | -------------------------------------------------------------------------------- /src/interfaces/IDispatchPipelineProcessor.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from './IEvent'; 2 | import { isObject } from './isObject'; 3 | 4 | /** 5 | * Represents a wrapper for an event that can optionally contain additional metadata. 6 | * Used to extend event processing with context-specific data required by processors. 7 | */ 8 | export type DispatchPipelineEnvelope = { 9 | 10 | /** 11 | * Origin of the event. Can be used to distinguish between events coming from different sources. 12 | */ 13 | origin?: 'external' | 'internal'; 14 | 15 | event?: IEvent; 16 | } 17 | 18 | /** 19 | * A batch of event envelopes. Can contain custom envelope types extending EventEnvelope. 20 | */ 21 | export type DispatchPipelineBatch = Readonly>; 22 | 23 | /** 24 | * Defines a processor that operates on a batch of event envelopes. 25 | * Allows transformations, side-effects, or filtering of events during dispatch. 26 | */ 27 | export interface IDispatchPipelineProcessor { 28 | process(batch: DispatchPipelineBatch): Promise>; 29 | revert?(batch: DispatchPipelineBatch): Promise; 30 | } 31 | 32 | export const isDispatchPipelineProcessor = (obj: unknown): obj is IDispatchPipelineProcessor => 33 | isObject(obj) 34 | && 'process' in obj 35 | && typeof (obj as IDispatchPipelineProcessor).process === 'function'; 36 | -------------------------------------------------------------------------------- /src/interfaces/IEvent.ts: -------------------------------------------------------------------------------- 1 | import { IMessage } from './IMessage'; 2 | import { isObject } from './isObject'; 3 | 4 | export type IEvent = IMessage & { 5 | 6 | /** Unique event identifier */ 7 | id?: string; 8 | }; 9 | 10 | export const isEvent = (event: unknown): event is IEvent => 11 | isObject(event) 12 | && 'type' in event 13 | && typeof event.type === 'string'; 14 | -------------------------------------------------------------------------------- /src/interfaces/IEventBus.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from './IEvent'; 2 | import { IObservable, isIObservable } from './IObservable'; 3 | 4 | export interface IEventBus extends IObservable { 5 | publish(event: IEvent, meta?: Record): Promise; 6 | } 7 | 8 | export const isIEventBus = (obj: unknown) => 9 | isIObservable(obj) 10 | && 'publish' in obj 11 | && typeof obj.publish === 'function'; 12 | -------------------------------------------------------------------------------- /src/interfaces/IEventDispatcher.ts: -------------------------------------------------------------------------------- 1 | import { IEventSet } from './IEventSet'; 2 | import { IEventBus } from './IEventBus'; 3 | 4 | export interface IEventDispatcher { 5 | readonly eventBus: IEventBus; 6 | dispatch(events: IEventSet, meta?: Record): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/IEventLocker.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from './IEvent'; 2 | import { isObject } from './isObject'; 3 | 4 | /** 5 | * Interface for tracking event processing state to prevent concurrent processing 6 | * by multiple processes. 7 | */ 8 | export interface IEventLocker { 9 | 10 | /** 11 | * Retrieves the last projected event, 12 | * allowing the projection state to be restored from subsequent events. 13 | */ 14 | getLastEvent(): Promise | IEvent | undefined; 15 | 16 | /** 17 | * Marks an event as projecting to prevent it from being processed 18 | * by another projection instance using the same storage. 19 | * 20 | * @returns `false` if the event is already being processed or has been processed. 21 | */ 22 | tryMarkAsProjecting(event: IEvent): Promise | boolean; 23 | 24 | /** 25 | * Marks an event as projected. 26 | */ 27 | markAsProjected(event: IEvent): Promise | void; 28 | } 29 | 30 | export const isEventLocker = (view: unknown): view is IEventLocker => 31 | isObject(view) 32 | && 'getLastEvent' in view 33 | && 'tryMarkAsProjecting' in view 34 | && 'markAsProjected' in view; 35 | -------------------------------------------------------------------------------- /src/interfaces/IEventReceptor.ts: -------------------------------------------------------------------------------- 1 | import { IEventStore } from './IEventStore'; 2 | import { IObserver } from './IObserver'; 3 | 4 | export interface IEventReceptor extends IObserver { 5 | subscribe(eventStore: IEventStore): void; 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/IEventSet.ts: -------------------------------------------------------------------------------- 1 | import { IEvent, isEvent } from './IEvent'; 2 | 3 | export type IEventSet = ReadonlyArray>; 4 | 5 | export const isEventSet = (arr: unknown): arr is IEventSet => 6 | Array.isArray(arr) 7 | && arr.every(isEvent); 8 | -------------------------------------------------------------------------------- /src/interfaces/IEventStorage.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from './Identifier'; 2 | import { IEvent } from './IEvent'; 3 | import { IEventSet } from './IEventSet'; 4 | import { IEventStream } from './IEventStream'; 5 | import { isObject } from './isObject'; 6 | 7 | export type EventQueryAfter = { 8 | 9 | /** Get events emitted after this specific event */ 10 | afterEvent?: IEvent; 11 | } 12 | 13 | export type EventQueryBefore = { 14 | 15 | /** Get events emitted before this specific event */ 16 | beforeEvent?: IEvent; 17 | } 18 | 19 | export interface IEventStorageReader { 20 | 21 | /** 22 | * Retrieves events of specified types that were emitted after a given event. 23 | */ 24 | getEventsByTypes(eventTypes: Readonly, options?: EventQueryAfter): IEventStream; 25 | 26 | /** 27 | * Retrieves all events (and optionally a snapshot) associated with a specific aggregate. 28 | */ 29 | getAggregateEvents(aggregateId: Identifier, options?: { snapshot?: IEvent }): IEventStream; 30 | 31 | /** 32 | * Retrieves events associated with a saga, with optional filtering by version or timestamp. 33 | */ 34 | getSagaEvents(sagaId: Identifier, options: EventQueryBefore): IEventStream; 35 | } 36 | 37 | export interface IEventStorageWriter { 38 | 39 | /** 40 | * Persists a set of events to the event store. 41 | * Returns the persisted event set (potentially enriched or normalized). 42 | */ 43 | commitEvents(events: IEventSet): Promise; 44 | } 45 | 46 | export const isIEventStorageReader = (storage: unknown): storage is IEventStorageReader => 47 | isObject(storage) 48 | && 'getEventsByTypes' in storage 49 | && typeof storage.getEventsByTypes === 'function' 50 | && 'getAggregateEvents' in storage 51 | && typeof storage.getAggregateEvents === 'function' 52 | && 'getSagaEvents' in storage 53 | && typeof storage.getSagaEvents === 'function'; 54 | -------------------------------------------------------------------------------- /src/interfaces/IEventStore.ts: -------------------------------------------------------------------------------- 1 | import { IEventDispatcher } from './IEventDispatcher'; 2 | import { IEvent } from './IEvent'; 3 | import { IEventStorageReader } from './IEventStorage'; 4 | import { IIdentifierProvider } from './IIdentifierProvider'; 5 | import { IMessageHandler, IObservable } from './IObservable'; 6 | 7 | export interface IEventStore 8 | extends IObservable, IEventDispatcher, IEventStorageReader, IIdentifierProvider { 9 | 10 | registerSagaStarters(startsWith: string[] | undefined): void; 11 | 12 | once(messageTypes: string | string[], handler?: IMessageHandler, filter?: (e: IEvent) => boolean): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/interfaces/IEventStream.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from './IEvent'; 2 | 3 | export type IEventStream = AsyncIterableIterator>; 4 | -------------------------------------------------------------------------------- /src/interfaces/IIdentifierProvider.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from './Identifier'; 2 | import { isObject } from './isObject'; 3 | 4 | export interface IIdentifierProvider { 5 | 6 | /** 7 | * Generates and returns a new unique identifier suitable for aggregates, sagas, and events. 8 | * 9 | * @returns A promise resolving to an identifier or an identifier itself. 10 | */ 11 | getNewId(): Identifier | Promise; 12 | } 13 | 14 | export const isIdentifierProvider = (obj: any): obj is IIdentifierProvider => 15 | isObject(obj) 16 | && 'getNewId' in obj 17 | && typeof obj.getNewId === 'function'; 18 | -------------------------------------------------------------------------------- /src/interfaces/ILogger.ts: -------------------------------------------------------------------------------- 1 | export interface ILogger { 2 | log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: { [key: string]: any }): void; 3 | debug(message: string, meta?: { [key: string]: any }): void; 4 | info(message: string, meta?: { [key: string]: any }): void; 5 | warn(message: string, meta?: { [key: string]: any }): void; 6 | error(message: string, meta?: { [key: string]: any }): void; 7 | } 8 | 9 | export interface IExtendableLogger extends ILogger { 10 | child(meta?: { [key: string]: any }): IExtendableLogger; 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces/IMessage.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from './Identifier'; 2 | import { isObject } from './isObject'; 3 | 4 | export interface IMessage { 5 | 6 | /** Event or command type */ 7 | type: string; 8 | 9 | aggregateId?: Identifier; 10 | aggregateVersion?: number; 11 | 12 | sagaId?: Identifier; 13 | sagaVersion?: number; 14 | 15 | payload?: TPayload; 16 | context?: any; 17 | } 18 | 19 | export const isMessage = (obj: unknown): obj is IMessage => 20 | isObject(obj) 21 | && 'type' in obj 22 | && typeof obj.type === 'string'; 23 | -------------------------------------------------------------------------------- /src/interfaces/IMessageBus.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from './ICommand'; 2 | import { IEvent } from './IEvent'; 3 | import { IObservable } from './IObservable'; 4 | 5 | export interface IMessageBus extends IObservable { 6 | send(command: ICommand): Promise; 7 | publish(event: IEvent, meta?: Record): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/IObjectStorage.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from './Identifier'; 2 | 3 | export interface IObjectStorage { 4 | get(id: Identifier): Promise | TRecord | undefined; 5 | 6 | create(id: Identifier, r: TRecord): Promise | any; 7 | 8 | update(id: Identifier, cb: (r: TRecord) => TRecord): Promise | any; 9 | 10 | updateEnforcingNew(id: Identifier, cb: (r?: TRecord) => TRecord): Promise | any; 11 | 12 | delete(id: Identifier): Promise | any; 13 | } 14 | -------------------------------------------------------------------------------- /src/interfaces/IObservable.ts: -------------------------------------------------------------------------------- 1 | import { IMessage } from './IMessage'; 2 | import { isObject } from './isObject'; 3 | 4 | export interface IMessageHandler { 5 | (message: IMessage, meta?: Record): any | Promise 6 | } 7 | 8 | export interface IObservable { 9 | 10 | /** 11 | * Setup a listener for a specific event type 12 | */ 13 | on(type: string, handler: IMessageHandler): void; 14 | 15 | /** 16 | * Remove previously installed listener 17 | */ 18 | off(type: string, handler: IMessageHandler): void; 19 | 20 | /** 21 | * Get or create a named queue, which delivers events to a single handler only 22 | */ 23 | queue?(name: string): IObservable; 24 | } 25 | 26 | export const isIObservable = (obj: unknown): obj is IObservable => 27 | isObject(obj) 28 | && 'on' in obj 29 | && typeof obj.on === 'function' 30 | && 'off' in obj 31 | && typeof obj.off === 'function'; 32 | -------------------------------------------------------------------------------- /src/interfaces/IObserver.ts: -------------------------------------------------------------------------------- 1 | import { IObservable } from './IObservable'; 2 | 3 | export interface IObserver { 4 | subscribe(observable: IObservable): void; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/IProjection.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from './IEvent'; 2 | import { IEventStore } from './IEventStore'; 3 | import { IObserver } from './IObserver'; 4 | 5 | export interface IProjection extends IObserver { 6 | readonly view: TView; 7 | 8 | subscribe(eventStore: IEventStore): Promise; 9 | 10 | project(event: IEvent): Promise; 11 | } 12 | 13 | export interface IProjectionConstructor { 14 | new(c?: any): IProjection; 15 | readonly handles?: string[]; 16 | } 17 | 18 | export interface IViewFactory { 19 | (options: { schemaVersion: string }): TView; 20 | } 21 | -------------------------------------------------------------------------------- /src/interfaces/ISaga.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from './ICommand'; 2 | import { Identifier } from './Identifier'; 3 | import { IEvent } from './IEvent'; 4 | import { IEventSet } from './IEventSet'; 5 | 6 | export interface ISaga { 7 | 8 | /** Unique Saga ID */ 9 | readonly id: Identifier; 10 | 11 | /** List of commands emitted by Saga */ 12 | readonly uncommittedMessages: ICommand[]; 13 | 14 | /** Main entry point for Saga events */ 15 | apply(event: IEvent): void | Promise; 16 | 17 | /** Reset emitted commands when they are not longer needed */ 18 | resetUncommittedMessages(): void; 19 | 20 | onError?(error: Error, options: { event: IEvent, command: ICommand }): void; 21 | } 22 | 23 | export type ISagaConstructorParams = { 24 | id: Identifier, 25 | events?: IEventSet 26 | }; 27 | 28 | export type ISagaFactory = (options: ISagaConstructorParams) => ISaga; 29 | 30 | export interface ISagaConstructor { 31 | new(options: ISagaConstructorParams): ISaga; 32 | 33 | /** List of event types that trigger new saga start */ 34 | readonly startsWith: string[]; 35 | 36 | /** List of events being handled by Saga */ 37 | readonly handles: string[]; 38 | } 39 | -------------------------------------------------------------------------------- /src/interfaces/IViewLocker.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from './isObject'; 2 | 3 | /** 4 | * Interface for managing view restoration state to prevent early access to an inconsistent view 5 | * or concurrent restoration by another process. 6 | */ 7 | export interface IViewLocker { 8 | 9 | /** 10 | * Indicates whether the view is fully restored and ready to accept new event projections. 11 | */ 12 | ready: boolean; 13 | 14 | /** 15 | * Locks the view to prevent external read/write operations. 16 | * 17 | * @returns `true` if the lock is successfully acquired, `false` otherwise. 18 | */ 19 | lock(): Promise | boolean; 20 | 21 | /** 22 | * Unlocks the view, allowing external read/write operations to resume. 23 | */ 24 | unlock(): Promise | void; 25 | 26 | /** 27 | * Waits until the view is fully restored and ready to accept new events. 28 | * 29 | * @param eventType The event type to listen for (`"ready"`). 30 | * @returns A promise that resolves when the view is ready. 31 | */ 32 | once(eventType: 'ready'): Promise; 33 | } 34 | 35 | /** 36 | * Checks if a given object conforms to the `IViewLocker` interface. 37 | * 38 | * @param view The object to check. 39 | * @returns `true` if the object implements `IViewLocker`, `false` otherwise. 40 | */ 41 | export const isViewLocker = (view: unknown): view is IViewLocker => 42 | isObject(view) 43 | && 'ready' in view 44 | && 'lock' in view 45 | && 'unlock' in view 46 | && 'once' in view; 47 | -------------------------------------------------------------------------------- /src/interfaces/Identifier.ts: -------------------------------------------------------------------------------- 1 | export type Identifier = string | number; 2 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IAggregate'; 2 | export * from './IAggregateSnapshotStorage'; 3 | export * from './ICommand'; 4 | export * from './ICommandBus'; 5 | export * from './IContainer'; 6 | export * from './Identifier'; 7 | export * from './IDispatchPipelineProcessor'; 8 | export * from './IEvent'; 9 | export * from './IEventBus'; 10 | export * from './IEventDispatcher'; 11 | export * from './IEventLocker'; 12 | export * from './IEventReceptor'; 13 | export * from './IEventSet'; 14 | export * from './IEventStorage'; 15 | export * from './IEventStore'; 16 | export * from './IEventStream'; 17 | export * from './IIdentifierProvider'; 18 | export * from './ILogger'; 19 | export * from './IMessage'; 20 | export * from './IMessageBus'; 21 | export * from './IObjectStorage'; 22 | export * from './IObservable'; 23 | export * from './IObserver'; 24 | export * from './IProjection'; 25 | export * from './ISaga'; 26 | export * from './IViewLocker'; 27 | -------------------------------------------------------------------------------- /src/interfaces/isObject.ts: -------------------------------------------------------------------------------- 1 | export const isObject = (obj: unknown): obj is {} => 2 | typeof obj === 'object' 3 | && obj !== null 4 | && !(obj instanceof Date) 5 | && !Array.isArray(obj); 6 | -------------------------------------------------------------------------------- /src/rabbitmq/IContainer.ts: -------------------------------------------------------------------------------- 1 | import { IEventBus } from '../interfaces'; 2 | import { RabbitMqEventInjector } from './RabbitMqEventInjector'; 3 | import { RabbitMqGateway } from './RabbitMqGateway'; 4 | 5 | declare module '../interfaces/IContainer' { 6 | interface IContainer { 7 | rabbitMqGateway?: RabbitMqGateway; 8 | rabbitMqEventInjector?: RabbitMqEventInjector; 9 | rabbitMqEventBus?: RabbitMqEventInjector; 10 | 11 | /** 12 | * Optional external event bus for publishing events to an external system. 13 | */ 14 | externalEventBus?: IEventBus; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/rabbitmq/RabbitMqEventBus.ts: -------------------------------------------------------------------------------- 1 | import { IEvent, IEventBus, IDispatchPipelineProcessor, IMessageHandler, IObservable, DispatchPipelineBatch } from '../interfaces'; 2 | import { DEFAULT_EXCHANGE } from './constants'; 3 | import { RabbitMqGateway } from './RabbitMqGateway'; 4 | 5 | const ALL_EVENTS_WILDCARD = '*'; 6 | 7 | export class RabbitMqEventBus implements IEventBus, IDispatchPipelineProcessor { 8 | 9 | static get allEventsWildcard(): '*' { 10 | return ALL_EVENTS_WILDCARD; 11 | } 12 | 13 | #gateway: RabbitMqGateway; 14 | #queues = new Map(); 15 | #exchange: string; 16 | #queueName: string | undefined; 17 | 18 | constructor(o: { 19 | rabbitMqGateway: RabbitMqGateway, 20 | exchange?: string, 21 | queueName?: string 22 | }) { 23 | this.#gateway = o.rabbitMqGateway; 24 | this.#exchange = o.exchange ?? DEFAULT_EXCHANGE; 25 | this.#queueName = o.queueName; 26 | } 27 | 28 | 29 | /** 30 | * Publishes an event to the fanout exchange. 31 | * The event will be delivered to all subscribers, except this instance's own consumer. 32 | */ 33 | async publish(event: IEvent): Promise { 34 | await this.#gateway.publish(this.#exchange, event); 35 | } 36 | 37 | /** 38 | * Registers a message handler for a specific event type. 39 | * 40 | * @param eventType The event type to listen for. 41 | * @param handler The function to handle incoming messages of the specified type. 42 | */ 43 | async on(eventType: string, handler: IMessageHandler): Promise { 44 | await this.#gateway.subscribe({ 45 | exchange: this.#exchange, 46 | queueName: this.#queueName, 47 | eventType, 48 | handler, 49 | ignoreOwn: !this.#queueName 50 | }); 51 | } 52 | 53 | /** 54 | * Removes a previously registered message handler for a specific event type. 55 | */ 56 | off(eventType: string, handler: IMessageHandler): void { 57 | this.#gateway.unsubscribe({ 58 | exchange: this.#exchange, 59 | queueName: this.#queueName, 60 | eventType, 61 | handler 62 | }); 63 | } 64 | 65 | /** 66 | * Returns a new instance of RabbitMqGateway that uses a durable queue with the given name. 67 | * This ensures that all messages published to the fanout exchange are also delivered to this queue. 68 | * 69 | * @param name The name of the durable queue. 70 | * @returns A new RabbitMqGateway instance configured to use the specified queue. 71 | */ 72 | queue(name: string): IObservable { 73 | let queue = this.#queues.get(name); 74 | if (!queue) { 75 | queue = new RabbitMqEventBus({ 76 | rabbitMqGateway: this.#gateway, 77 | exchange: this.#exchange, 78 | queueName: name 79 | }); 80 | this.#queues.set(name, queue); 81 | } 82 | return queue; 83 | } 84 | 85 | /** 86 | * Processes a batch of events and publishes them to the fanout exchange. 87 | * 88 | * This method is part of the `IDispatchPipelineProcessor` interface. 89 | */ 90 | async process(batch: DispatchPipelineBatch): Promise { 91 | for (const { event, origin } of batch) { 92 | // Skip publishing if the event was dispatched from external source 93 | if (origin === 'external') 94 | continue; 95 | 96 | if (!event) 97 | throw new Error('Event batch does not contain `event`'); 98 | 99 | await this.publish(event); 100 | } 101 | 102 | return batch; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/rabbitmq/RabbitMqEventInjector.ts: -------------------------------------------------------------------------------- 1 | import { IContainer } from '../interfaces/IContainer'; 2 | import { IMessage } from '../interfaces/IMessage'; 3 | import { RabbitMqGateway } from './RabbitMqGateway'; 4 | import { IEventDispatcher } from '../interfaces'; 5 | import * as Event from '../Event'; 6 | import { DEFAULT_EXCHANGE } from './constants'; 7 | 8 | /** 9 | * Injects events received from a RabbitMQ exchange into the local event dispatcher. 10 | * 11 | * It subscribes to a specified fanout exchange on RabbitMQ and dispatches 12 | * any received messages as events using the provided event dispatcher. 13 | */ 14 | export class RabbitMqEventInjector { 15 | #rabbitMqGateway: RabbitMqGateway; 16 | #messageHandler: (message: IMessage) => Promise; 17 | #eventDispatcher: IEventDispatcher; 18 | #logger: IContainer['logger']; 19 | 20 | constructor(container: Partial>) { 21 | if (!container.eventDispatcher) 22 | throw new Error('eventDispatcher is required in the container.'); 23 | if (!container.rabbitMqGateway) 24 | throw new Error('rabbitMqGateway is required in the container.'); 25 | 26 | this.#rabbitMqGateway = container.rabbitMqGateway; 27 | this.#messageHandler = (msg: IMessage) => this.#handleMessage(msg); 28 | this.#eventDispatcher = container.eventDispatcher; 29 | this.#logger = container.logger && 'child' in container.logger ? 30 | container.logger.child({ service: new.target.name }) : 31 | container.logger; 32 | } 33 | 34 | async start(exchange: string = DEFAULT_EXCHANGE): Promise { 35 | this.#logger?.debug(`Subscribing to messages from exchange "${exchange}"...`); 36 | 37 | await this.#rabbitMqGateway.subscribeToFanout(exchange, this.#messageHandler); 38 | 39 | this.#logger?.debug(`Listening to messages from exchange "${exchange}"`); 40 | } 41 | 42 | async stop(exchange: string = DEFAULT_EXCHANGE): Promise { 43 | this.#logger?.debug(`Unsubscribing from messages from exchange "${exchange}"...`); 44 | 45 | await this.#rabbitMqGateway.unsubscribe({ 46 | exchange, 47 | handler: this.#messageHandler 48 | }); 49 | 50 | this.#logger?.debug(`Stopped listening to messages from exchange "${exchange}"`); 51 | } 52 | 53 | async #handleMessage(message: IMessage): Promise { 54 | this.#logger?.debug(`"${Event.describe(message)}" received`); 55 | try { 56 | await this.#eventDispatcher.dispatch([message], { origin: 'external' }); 57 | 58 | this.#logger?.debug(`${Event.describe(message)} dispatched successfully`); 59 | } 60 | catch (error: any) { 61 | this.#logger?.error(`Failed to dispatch event ${message.type}: ${error.message}`, { stack: error.stack }); 62 | 63 | throw error; // Re-throw to ensure message is nack'd by the gateway 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/rabbitmq/TerminationHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles graceful termination of a Node.js process. 3 | * Listens for SIGINT and executes a cleanup routine before allowing the process to exit. 4 | */ 5 | export class TerminationHandler { 6 | 7 | #process: NodeJS.Process; 8 | #cleanupHandler: () => Promise; 9 | #terminationHandler: () => Promise; 10 | 11 | constructor(process: NodeJS.Process, cleanupHandler: () => Promise) { 12 | this.#process = process; 13 | this.#cleanupHandler = cleanupHandler; 14 | this.#terminationHandler = this.#onProcessTermination.bind(this); 15 | } 16 | 17 | on() { 18 | this.#process.once('SIGINT', this.#terminationHandler); 19 | this.#process.once('SIGTERM', this.#terminationHandler); 20 | } 21 | 22 | off() { 23 | this.#process.off('SIGINT', this.#terminationHandler); 24 | this.#process.off('SIGTERM', this.#terminationHandler); 25 | } 26 | 27 | async #onProcessTermination() { 28 | this.off(); 29 | await this.#cleanupHandler(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/rabbitmq/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_EXCHANGE = 'node-cqrs.events'; 2 | export const HANDLER_PROCESS_TIMEOUT = 60 * 60 * 1000; // 1 hour 3 | -------------------------------------------------------------------------------- /src/rabbitmq/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RabbitMqEventBus'; 2 | export * from './RabbitMqEventInjector'; 3 | export * from './RabbitMqGateway'; 4 | -------------------------------------------------------------------------------- /src/sqlite/AbstractSqliteAccessor.ts: -------------------------------------------------------------------------------- 1 | import { IContainer } from '../interfaces'; 2 | import { Lock } from '../utils'; 3 | import { Database } from 'better-sqlite3'; 4 | 5 | /** 6 | * Abstract base class for accessing a SQLite database. 7 | * 8 | * Manages the database connection lifecycle, ensuring initialization via `assertDb`. 9 | * Supports providing a database instance directly or a factory function for lazy initialization. 10 | * 11 | * Subclasses must implement the `initialize` method for specific setup tasks. 12 | */ 13 | export abstract class AbstractSqliteAccessor { 14 | 15 | protected db: Database | undefined; 16 | #dbFactory: (() => Promise | Database) | undefined; 17 | #initLocker = new Lock(); 18 | #initialized = false; 19 | 20 | constructor(c: Partial>) { 21 | if (!c.viewModelSqliteDb && !c.viewModelSqliteDbFactory) 22 | throw new TypeError('either viewModelSqliteDb or viewModelSqliteDbFactory argument required'); 23 | 24 | this.db = c.viewModelSqliteDb; 25 | this.#dbFactory = c.viewModelSqliteDbFactory; 26 | } 27 | 28 | protected abstract initialize(db: Database): Promise | void; 29 | 30 | /** 31 | * Ensures that the database connection is initialized. 32 | * Uses a lock to prevent race conditions during concurrent initialization attempts. 33 | * If the database is not already initialized, it creates the database connection 34 | * using the provided factory and calls the `initialize` method. 35 | * 36 | * This method is idempotent and safe to call multiple times. 37 | */ 38 | async assertConnection() { 39 | if (this.#initialized) 40 | return; 41 | 42 | try { 43 | this.#initLocker.acquire(); 44 | if (this.#initialized) 45 | return; 46 | 47 | if (!this.db) 48 | this.db = await this.#dbFactory!(); 49 | 50 | await this.initialize(this.db); 51 | 52 | this.#initialized = true; 53 | } 54 | finally { 55 | this.#initLocker.release(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/sqlite/AbstractSqliteObjectProjection.ts: -------------------------------------------------------------------------------- 1 | import { AbstractProjection } from '../AbstractProjection'; 2 | import { IContainer } from '../interfaces'; 3 | import { SqliteObjectView } from './SqliteObjectView'; 4 | 5 | export abstract class AbstractSqliteObjectProjection extends AbstractProjection> { 6 | 7 | static get tableName(): string { 8 | throw new Error('tableName is not defined'); 9 | } 10 | 11 | static get schemaVersion(): string { 12 | throw new Error('schemaVersion is not defined'); 13 | } 14 | 15 | constructor({ viewModelSqliteDb, viewModelSqliteDbFactory, logger }: Pick) { 20 | super({ logger }); 21 | 22 | this.view = new SqliteObjectView({ 23 | schemaVersion: new.target.schemaVersion, 24 | projectionName: new.target.name, 25 | viewModelSqliteDb, 26 | viewModelSqliteDbFactory, 27 | tableNamePrefix: new.target.tableName, 28 | logger 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/sqlite/AbstractSqliteView.ts: -------------------------------------------------------------------------------- 1 | import { IContainer, IEvent, IEventLocker, ILogger, IViewLocker } from '../interfaces'; 2 | import { SqliteViewLocker, SqliteViewLockerParams } from './SqliteViewLocker'; 3 | import { SqliteEventLocker, SqliteEventLockerParams } from './SqliteEventLocker'; 4 | import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; 5 | 6 | export abstract class AbstractSqliteView extends AbstractSqliteAccessor implements IViewLocker, IEventLocker { 7 | 8 | protected readonly schemaVersion: string; 9 | protected readonly viewLocker: SqliteViewLocker; 10 | protected readonly eventLocker: SqliteEventLocker; 11 | protected logger: ILogger | undefined; 12 | 13 | get ready(): boolean { 14 | return this.viewLocker.ready; 15 | } 16 | 17 | constructor(options: Partial> 18 | & SqliteEventLockerParams 19 | & SqliteViewLockerParams) { 20 | super(options); 21 | 22 | this.schemaVersion = options.schemaVersion; 23 | this.viewLocker = new SqliteViewLocker(options); 24 | this.eventLocker = new SqliteEventLocker(options); 25 | this.logger = options.logger && 'child' in options.logger ? 26 | options.logger.child({ serviceName: new.target.name }) : 27 | options.logger; 28 | } 29 | 30 | async lock() { 31 | return this.viewLocker.lock(); 32 | } 33 | 34 | unlock(): void { 35 | this.viewLocker.unlock(); 36 | } 37 | 38 | once(event: 'ready') { 39 | return this.viewLocker.once(event); 40 | } 41 | 42 | getLastEvent() { 43 | return this.eventLocker.getLastEvent(); 44 | } 45 | 46 | tryMarkAsProjecting(event: IEvent) { 47 | return this.eventLocker.tryMarkAsProjecting(event); 48 | } 49 | 50 | markAsProjected(event: IEvent) { 51 | return this.eventLocker.markAsProjected(event); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/sqlite/IContainer.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'better-sqlite3'; 2 | 3 | declare module '../interfaces/IContainer' { 4 | interface IContainer { 5 | viewModelSqliteDbFactory?: () => Promise | Database; 6 | viewModelSqliteDb?: Database; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/sqlite/SqliteEventLocker.ts: -------------------------------------------------------------------------------- 1 | import { Database, Statement } from 'better-sqlite3'; 2 | import { IContainer, IEvent, IEventLocker } from '../interfaces'; 3 | import { getEventId } from './utils'; 4 | import { viewLockTableInit, eventLockTableInit } from './queries'; 5 | import { SqliteViewLockerParams } from './SqliteViewLocker'; 6 | import { SqliteProjectionDataParams } from './SqliteProjectionDataParams'; 7 | import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; 8 | 9 | export type SqliteEventLockerParams = 10 | SqliteProjectionDataParams 11 | & Pick 12 | & { 13 | 14 | /** 15 | * (Optional) SQLite table name where event locks are stored 16 | * 17 | * @default "tbl_event_lock" 18 | */ 19 | eventLockTableName?: string; 20 | 21 | /** 22 | * (Optional) Time-to-live (TTL) duration in milliseconds 23 | * for which an event remains in the "processing" state until released. 24 | * 25 | * @default 15_000 26 | */ 27 | eventLockTtl?: number; 28 | }; 29 | 30 | export class SqliteEventLocker extends AbstractSqliteAccessor implements IEventLocker { 31 | 32 | #projectionName: string; 33 | #schemaVersion: string; 34 | #viewLockTableName: string; 35 | #eventLockTableName: string; 36 | #eventLockTtl: number; 37 | 38 | #upsertLastEventQuery!: Statement<[string, string, string], void>; 39 | #getLastEventQuery!: Statement<[string, string], { last_event: string }>; 40 | #lockEventQuery!: Statement<[string, string, Buffer], void>; 41 | #finalizeEventLockQuery!: Statement<[string, string, Buffer], void>; 42 | 43 | constructor(o: Pick & SqliteEventLockerParams) { 44 | super(o); 45 | 46 | if (!o.projectionName) 47 | throw new TypeError('projectionName argument required'); 48 | if (!o.schemaVersion) 49 | throw new TypeError('schemaVersion argument required'); 50 | 51 | this.#projectionName = o.projectionName; 52 | this.#schemaVersion = o.schemaVersion; 53 | this.#viewLockTableName = o.viewLockTableName ?? 'tbl_view_lock'; 54 | this.#eventLockTableName = o.eventLockTableName ?? 'tbl_event_lock'; 55 | this.#eventLockTtl = o.eventLockTtl ?? 15_000; 56 | } 57 | 58 | protected initialize(db: Database) { 59 | db.exec(viewLockTableInit(this.#viewLockTableName)); 60 | db.exec(eventLockTableInit(this.#eventLockTableName)); 61 | 62 | this.#upsertLastEventQuery = db.prepare(` 63 | INSERT INTO ${this.#viewLockTableName} (projection_name, schema_version, last_event) 64 | VALUES (?, ?, ?) 65 | ON CONFLICT (projection_name, schema_version) 66 | DO UPDATE SET 67 | last_event = excluded.last_event 68 | `); 69 | 70 | this.#getLastEventQuery = db.prepare(` 71 | SELECT 72 | last_event 73 | FROM ${this.#viewLockTableName} 74 | WHERE 75 | projection_name = ? 76 | AND schema_version =? 77 | `); 78 | 79 | this.#lockEventQuery = db.prepare(` 80 | INSERT INTO ${this.#eventLockTableName} (projection_name, schema_version, event_id) 81 | VALUES (?, ?, ?) 82 | ON CONFLICT (projection_name, schema_version, event_id) 83 | DO UPDATE SET 84 | processing_at = cast(strftime('%f', 'now') * 1000 as INTEGER) 85 | WHERE 86 | processed_at IS NULL 87 | AND processing_at <= cast(strftime('%f', 'now') * 1000 as INTEGER) - ${this.#eventLockTtl} 88 | `); 89 | 90 | this.#finalizeEventLockQuery = db.prepare(` 91 | UPDATE ${this.#eventLockTableName} 92 | SET 93 | processed_at = (cast(strftime('%f', 'now') * 1000 as INTEGER)) 94 | WHERE 95 | projection_name = ? 96 | AND schema_version = ? 97 | AND event_id = ? 98 | AND processed_at IS NULL 99 | `); 100 | } 101 | 102 | async tryMarkAsProjecting(event: IEvent) { 103 | await this.assertConnection(); 104 | 105 | const eventId = getEventId(event); 106 | 107 | const r = this.#lockEventQuery.run(this.#projectionName, this.#schemaVersion, eventId); 108 | 109 | return r.changes !== 0; 110 | } 111 | 112 | async markAsProjected(event: IEvent) { 113 | await this.assertConnection(); 114 | 115 | const eventId = getEventId(event); 116 | 117 | const transaction = this.db!.transaction(() => { 118 | const updateResult = this.#finalizeEventLockQuery.run(this.#projectionName, this.#schemaVersion, eventId); 119 | if (updateResult.changes === 0) 120 | throw new Error(`Event ${event.id} could not be marked as processed`); 121 | 122 | this.#upsertLastEventQuery.run(this.#projectionName, this.#schemaVersion, JSON.stringify(event)); 123 | }); 124 | 125 | transaction(); 126 | } 127 | 128 | async getLastEvent(): Promise | undefined> { 129 | await this.assertConnection(); 130 | 131 | const viewInfoRecord = this.#getLastEventQuery.get(this.#projectionName, this.#schemaVersion); 132 | if (!viewInfoRecord?.last_event) 133 | return undefined; 134 | 135 | return JSON.parse(viewInfoRecord.last_event); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/sqlite/SqliteObjectStorage.ts: -------------------------------------------------------------------------------- 1 | import { Statement, Database } from 'better-sqlite3'; 2 | import { guid } from './utils'; 3 | import { IContainer, IObjectStorage } from '../interfaces'; 4 | import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; 5 | 6 | export class SqliteObjectStorage extends AbstractSqliteAccessor implements IObjectStorage { 7 | 8 | #tableName: string; 9 | #getQuery!: Statement<[Buffer], { data: string, version: number }>; 10 | #insertQuery!: Statement<[Buffer, string], void>; 11 | #updateByIdAndVersionQuery!: Statement<[string, Buffer, number], void>; 12 | #deleteQuery!: Statement<[Buffer], void>; 13 | 14 | constructor(o: Pick & { 15 | tableName: string 16 | }) { 17 | super(o); 18 | 19 | this.#tableName = o.tableName; 20 | } 21 | 22 | protected initialize(db: Database) { 23 | db.exec(`CREATE TABLE IF NOT EXISTS ${this.#tableName} ( 24 | id BLOB PRIMARY KEY, 25 | version INTEGER DEFAULT 1, 26 | data TEXT NOT NULL 27 | );`); 28 | 29 | this.#getQuery = db.prepare(` 30 | SELECT data, version 31 | FROM ${this.#tableName} 32 | WHERE id = ? 33 | `); 34 | 35 | this.#insertQuery = db.prepare(` 36 | INSERT INTO ${this.#tableName} (id, data) 37 | VALUES (?, ?) 38 | `); 39 | 40 | this.#updateByIdAndVersionQuery = db.prepare(` 41 | UPDATE ${this.#tableName} 42 | SET 43 | data = ?, 44 | version = version + 1 45 | WHERE 46 | id = ? 47 | AND version = ? 48 | `); 49 | 50 | this.#deleteQuery = db.prepare(` 51 | DELETE FROM ${this.#tableName} 52 | WHERE id = ? 53 | `); 54 | } 55 | 56 | async get(id: string): Promise { 57 | if (typeof id !== 'string' || !id.length) 58 | throw new TypeError('id argument must be a non-empty String'); 59 | 60 | await this.assertConnection(); 61 | 62 | const r = this.#getQuery.get(guid(id)); 63 | if (!r) 64 | return undefined; 65 | 66 | return JSON.parse(r.data); 67 | } 68 | 69 | getSync(id: string): TRecord | undefined { 70 | if (typeof id !== 'string' || !id.length) 71 | throw new TypeError('id argument must be a non-empty String'); 72 | 73 | const r = this.#getQuery.get(guid(id)); 74 | if (!r) 75 | return undefined; 76 | 77 | return JSON.parse(r.data); 78 | } 79 | 80 | async create(id: string, data: TRecord) { 81 | if (typeof id !== 'string' || !id.length) 82 | throw new TypeError('id argument must be a non-empty String'); 83 | 84 | await this.assertConnection(); 85 | 86 | const r = this.#insertQuery.run(guid(id), JSON.stringify(data)); 87 | if (r.changes !== 1) 88 | throw new Error(`Record '${id}' could not be created`); 89 | } 90 | 91 | async update(id: string, update: (r: TRecord) => TRecord) { 92 | if (typeof id !== 'string' || !id.length) 93 | throw new TypeError('id argument must be a non-empty String'); 94 | if (typeof update !== 'function') 95 | throw new TypeError('update argument must be a Function'); 96 | 97 | await this.assertConnection(); 98 | 99 | const gid = guid(id); 100 | const record = this.#getQuery.get(gid); 101 | if (!record) 102 | throw new Error(`Record '${id}' does not exist`); 103 | 104 | const data = JSON.parse(record.data); 105 | const updatedData = update(data); 106 | const updatedJson = JSON.stringify(updatedData); 107 | 108 | // Version check is implemented to ensure the record isn't modified by another process. 109 | // A conflict resolution strategy could potentially be passed as an option to this method, 110 | // but for now, conflict resolution should happen outside this class. 111 | const r = this.#updateByIdAndVersionQuery.run(updatedJson, gid, record.version); 112 | if (r.changes !== 1) 113 | throw new Error(`Record '${id}' could not be updated`); 114 | } 115 | 116 | async updateEnforcingNew(id: string, update: (r?: TRecord) => TRecord) { 117 | if (typeof id !== 'string' || !id.length) 118 | throw new TypeError('id argument must be a non-empty String'); 119 | if (typeof update !== 'function') 120 | throw new TypeError('update argument must be a Function'); 121 | 122 | await this.assertConnection(); 123 | 124 | // Due to better-sqlite3 sync nature, 125 | // it's safe to get then modify within this process 126 | const record = this.#getQuery.get(guid(id)); 127 | if (record) 128 | this.update(id, update); 129 | else 130 | this.create(id, update()); 131 | } 132 | 133 | async delete(id: string): Promise { 134 | if (typeof id !== 'string' || !id.length) 135 | throw new TypeError('id argument must be a non-empty String'); 136 | 137 | await this.assertConnection(); 138 | 139 | const r = this.#deleteQuery.run(guid(id)); 140 | return r.changes === 1; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/sqlite/SqliteObjectView.ts: -------------------------------------------------------------------------------- 1 | import { AbstractSqliteView } from './AbstractSqliteView'; 2 | import { IObjectStorage, IEventLocker } from '../interfaces'; 3 | import { SqliteObjectStorage } from './SqliteObjectStorage'; 4 | import { Database } from 'better-sqlite3'; 5 | 6 | export class SqliteObjectView extends AbstractSqliteView implements IObjectStorage, IEventLocker { 7 | 8 | #sqliteObjectStorage: SqliteObjectStorage; 9 | 10 | constructor(options: ConstructorParameters[0] & { 11 | tableNamePrefix: string 12 | }) { 13 | if (typeof options.tableNamePrefix !== 'string' || !options.tableNamePrefix.length) 14 | throw new TypeError('tableNamePrefix argument must be a non-empty String'); 15 | if (typeof options.schemaVersion !== 'string' || !options.schemaVersion.length) 16 | throw new TypeError('schemaVersion argument must be a non-empty String'); 17 | 18 | super(options); 19 | 20 | this.#sqliteObjectStorage = new SqliteObjectStorage({ 21 | viewModelSqliteDb: options.viewModelSqliteDb, 22 | viewModelSqliteDbFactory: options.viewModelSqliteDbFactory, 23 | tableName: `${options.tableNamePrefix}_${options.schemaVersion}` 24 | }); 25 | } 26 | 27 | // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars 28 | protected initialize(db: Database): Promise | void { 29 | // No need to initialize the table here, it's done in SqliteObjectStorage 30 | } 31 | 32 | async get(id: string): Promise { 33 | if (!this.ready) 34 | await this.once('ready'); 35 | 36 | return this.#sqliteObjectStorage.get(id); 37 | } 38 | 39 | getSync(id: string) { 40 | return this.#sqliteObjectStorage.getSync(id); 41 | } 42 | 43 | async create(id: string, data: TRecord) { 44 | await this.#sqliteObjectStorage.create(id, data); 45 | } 46 | 47 | async update(id: string, update: (r: TRecord) => TRecord) { 48 | await this.#sqliteObjectStorage.update(id, update); 49 | } 50 | 51 | async updateEnforcingNew(id: string, update: (r?: TRecord) => TRecord) { 52 | await this.#sqliteObjectStorage.updateEnforcingNew(id, update); 53 | } 54 | 55 | async delete(id: string): Promise { 56 | return this.#sqliteObjectStorage.delete(id); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/sqlite/SqliteProjectionDataParams.ts: -------------------------------------------------------------------------------- 1 | export type SqliteProjectionDataParams = { 2 | 3 | /** 4 | * Unique identifier for the projection, used with the schema version to distinguish data ownership. 5 | */ 6 | projectionName: string; 7 | 8 | /** 9 | * The version of the schema used for data produced by the projection. 10 | * When the projection's output format changes, this version should be incremented. 11 | * A version change indicates that previously stored data is obsolete and must be rebuilt. 12 | * 13 | * @example "20250519", "1.0.0" 14 | */ 15 | schemaVersion: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/sqlite/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AbstractSqliteAccessor'; 2 | export * from './AbstractSqliteObjectProjection'; 3 | export * from './AbstractSqliteView'; 4 | export * from './SqliteEventLocker'; 5 | export * from './SqliteObjectStorage'; 6 | export * from './SqliteObjectView'; 7 | export * from './SqliteViewLocker'; 8 | export * from './utils'; 9 | -------------------------------------------------------------------------------- /src/sqlite/queries/eventLockTableInit.ts: -------------------------------------------------------------------------------- 1 | export const eventLockTableInit = (eventLockTableName: string) => ` 2 | CREATE TABLE IF NOT EXISTS ${eventLockTableName} ( 3 | projection_name TEXT NOT NULL, 4 | schema_version TEXT NOT NULL, 5 | event_id BLOB NOT NULL, 6 | processing_at INTEGER NOT NULL DEFAULT (cast(strftime('%f', 'now') * 1000 as INTEGER)), 7 | processed_at INTEGER, 8 | PRIMARY KEY (projection_name, schema_version, event_id) 9 | ); 10 | `; 11 | -------------------------------------------------------------------------------- /src/sqlite/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './eventLockTableInit'; 2 | export * from './viewLockTableInit'; 3 | -------------------------------------------------------------------------------- /src/sqlite/queries/viewLockTableInit.ts: -------------------------------------------------------------------------------- 1 | export const viewLockTableInit = (viewLockTableName: string): string => ` 2 | CREATE TABLE IF NOT EXISTS ${viewLockTableName} ( 3 | projection_name TEXT NOT NULL, 4 | schema_version TEXT NOT NULL, 5 | locked_till INTEGER, 6 | last_event TEXT, 7 | PRIMARY KEY (projection_name, schema_version) 8 | ); 9 | `; 10 | -------------------------------------------------------------------------------- /src/sqlite/utils/getEventId.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from '../../interfaces'; 2 | import { guid } from './guid'; 3 | import md5 = require('md5'); 4 | 5 | /** 6 | * Get assigned or generate new event ID from event content 7 | */ 8 | export const getEventId = (event: IEvent): Buffer => guid(event.id ?? md5(JSON.stringify(event))); 9 | -------------------------------------------------------------------------------- /src/sqlite/utils/guid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert Guid to Buffer for storing in Sqlite BLOB 3 | */ 4 | export const guid = (str: string) => Buffer.from(str.replaceAll('-', ''), 'hex'); 5 | -------------------------------------------------------------------------------- /src/sqlite/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guid'; 2 | export * from './getEventId'; 3 | -------------------------------------------------------------------------------- /src/utils/Deferred.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deferred promise that must be resolved from outside 3 | */ 4 | export class Deferred { 5 | 6 | readonly promise: Promise; 7 | 8 | get resolved() { 9 | return this.#resolved; 10 | } 11 | 12 | get rejected() { 13 | return this.#rejected; 14 | } 15 | 16 | get settled() { 17 | return this.#resolved || this.#rejected; 18 | } 19 | 20 | #resolve!: (value?: TDeferredValue | PromiseLike) => void; 21 | #resolved: boolean = false; 22 | #reject!: (reason?: any) => void; 23 | #rejected: boolean = false; 24 | 25 | constructor() { 26 | this.promise = new Promise((resolve, reject) => { 27 | this.#resolve = resolve; 28 | this.#reject = reject; 29 | }); 30 | } 31 | 32 | resolve(value?: TDeferredValue) { 33 | this.#resolve(value); 34 | this.#resolved = true; 35 | } 36 | 37 | reject(reason?: any) { 38 | this.#reject(reason); 39 | this.#rejected = true; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/Lock.ts: -------------------------------------------------------------------------------- 1 | import { Deferred } from './Deferred'; 2 | 3 | export class Lock { 4 | 5 | /** 6 | * Indicates that global lock acquiring is started, 7 | * so all other locks should wait to ensure that named lock raised after global don't squeeze before it 8 | */ 9 | #globalLockAcquiringLock?: Deferred; 10 | 11 | /** 12 | * Indicates that global lock is acquired, all others should wait 13 | */ 14 | #globalLock?: Deferred; 15 | 16 | /** 17 | * Hash of named locks. Each named lock block locks with same name and the global one 18 | */ 19 | #namedLocks: Map> = new Map(); 20 | 21 | #getAnyBlockingLock(id?: string): Deferred | undefined { 22 | return this.#globalLock ?? ( 23 | id ? 24 | this.#namedLocks.get(id) : 25 | this.#namedLocks.values().next().value 26 | ); 27 | } 28 | 29 | 30 | isLocked(name?: string): boolean { 31 | return !!this.#getAnyBlockingLock(name); 32 | } 33 | 34 | /** 35 | * Acquire named or global lock 36 | * 37 | * @returns Promise that resolves once lock is acquired 38 | */ 39 | async acquire(name?: string): Promise { 40 | 41 | while (this.#globalLockAcquiringLock) 42 | await this.#globalLockAcquiringLock.promise; 43 | 44 | const isGlobal = !name; 45 | if (isGlobal) 46 | this.#globalLockAcquiringLock = new Deferred(); 47 | 48 | // the below code cannot be replaced with `await this.waitForUnlock()` 49 | // since check of `isLocked` and `this.#deferred` assignment should happen within 1 callback 50 | // while `async waitForUnlock(..) await..` creates one extra promise callback 51 | while (this.isLocked(name)) 52 | await this.#getAnyBlockingLock(name)?.promise; 53 | 54 | if (name) 55 | this.#namedLocks.set(name, new Deferred()); 56 | else 57 | this.#globalLock = new Deferred(); 58 | 59 | if (isGlobal) { 60 | this.#globalLockAcquiringLock?.resolve(); 61 | this.#globalLockAcquiringLock = undefined; 62 | } 63 | } 64 | 65 | /** 66 | * @returns Promise that resolves once lock is released 67 | */ 68 | async waitForUnlock(name?: string): Promise { 69 | while (this.isLocked(name)) 70 | await this.#getAnyBlockingLock(name)?.promise; 71 | } 72 | 73 | /** 74 | * Release named or global lock 75 | */ 76 | release(name?: string): void { 77 | if (name) { 78 | this.#namedLocks.get(name)?.resolve(); 79 | this.#namedLocks.delete(name); 80 | } 81 | else { 82 | this.#globalLock?.resolve(); 83 | this.#globalLock = undefined; 84 | } 85 | } 86 | 87 | /** 88 | * Execute callback with lock acquired, then release lock 89 | */ 90 | async runExclusively(name: string | undefined, callback: () => Promise | T): Promise { 91 | try { 92 | await this.acquire(name); 93 | return await callback(); 94 | } 95 | finally { 96 | this.release(name); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/utils/MapAssertable.ts: -------------------------------------------------------------------------------- 1 | export class MapAssertable extends Map { 2 | 3 | #usageCounter = new Map(); 4 | 5 | /** 6 | * Ensures the key exists in the map, creating it with the factory if needed, and increments its usage counter. 7 | */ 8 | assert(key: K, factory: () => V): V { 9 | if (!this.has(key)) 10 | this.set(key, factory()); 11 | 12 | this.#usageCounter.set(key, (this.#usageCounter.get(key) ?? 0) + 1); 13 | 14 | return super.get(key)!; 15 | } 16 | 17 | /** 18 | * Decrements the usage counter for the key and removes it from the map if no longer used. 19 | */ 20 | release(key: K) { 21 | const count = (this.#usageCounter.get(key) ?? 0) - 1; 22 | if (count > 0) { 23 | this.#usageCounter.set(key, count); 24 | } 25 | else { 26 | this.#usageCounter.delete(key); 27 | this.delete(key); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/delay.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a promise that resolves after the specified number of milliseconds. 3 | * The internal timeout is unref'd to avoid blocking Node.js process termination. 4 | */ 5 | export const delay = (ms: number) => new Promise(resolve => { 6 | const t = setTimeout(resolve, ms); 7 | t.unref(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/getClassName.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get instance class name 3 | */ 4 | export function getClassName(instance: object): string { 5 | return Object.getPrototypeOf(instance).constructor.name; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/getHandler.ts: -------------------------------------------------------------------------------- 1 | import { IMessageHandler } from '../interfaces'; 2 | 3 | /** 4 | * Gets a handler for a specific message type, prefers a public (w\o _ prefix) method, if available 5 | */ 6 | export function getHandler(context: { [key: string]: any }, messageType: string): IMessageHandler | null { 7 | if (!context || typeof context !== 'object') 8 | throw new TypeError('context argument required'); 9 | if (typeof messageType !== 'string' || !messageType.length) 10 | throw new TypeError('messageType argument must be a non-empty string'); 11 | 12 | if (messageType in context && typeof context[messageType] === 'function') 13 | return context[messageType].bind(context); 14 | 15 | const privateHandlerName = `_${messageType}`; 16 | if (privateHandlerName in context && typeof context[privateHandlerName] === 'function') 17 | return context[privateHandlerName].bind(context); 18 | 19 | return null; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/getMessageHandlerNames.ts: -------------------------------------------------------------------------------- 1 | function getInheritedPropertyNames(prototype: object): string[] { 2 | const parentPrototype = prototype && Object.getPrototypeOf(prototype); 3 | if (!parentPrototype) 4 | return []; 5 | 6 | const propDescriptors = Object.getOwnPropertyDescriptors(parentPrototype); 7 | const propNames = Object.keys(propDescriptors); 8 | 9 | return [ 10 | ...propNames, 11 | ...getInheritedPropertyNames(parentPrototype) 12 | ]; 13 | } 14 | 15 | /** 16 | * Get message handler names from a command/event handler class. 17 | * Assumes all private method names start from underscore ("_"). 18 | */ 19 | export function getMessageHandlerNames(observerInstanceOrClass: (object | Function)): string[] { 20 | if (!observerInstanceOrClass) 21 | throw new TypeError('observerInstanceOrClass argument required'); 22 | 23 | const prototype = typeof observerInstanceOrClass === 'function' ? 24 | observerInstanceOrClass.prototype : 25 | Object.getPrototypeOf(observerInstanceOrClass); 26 | 27 | if (!prototype) 28 | throw new TypeError('prototype cannot be resolved'); 29 | 30 | const inheritedProperties = getInheritedPropertyNames(prototype); 31 | const propDescriptors = Object.getOwnPropertyDescriptors(prototype); 32 | const propNames = Object.keys(propDescriptors); 33 | 34 | return propNames.filter(key => 35 | !key.startsWith('_') && 36 | !inheritedProperties.includes(key) && 37 | typeof propDescriptors[key].value === 'function'); 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Deferred'; 2 | export * from './delay'; 3 | export * from './getClassName'; 4 | export * from './getHandler'; 5 | export * from './getMessageHandlerNames'; 6 | export * from './isClass'; 7 | export * from './iteratorToArray'; 8 | export * from './Lock'; 9 | export * from './MapAssertable'; 10 | export * from './notEmpty'; 11 | export * from './setupOneTimeEmitterSubscription'; 12 | export * from './subscribe'; 13 | export * from './validateHandlers'; 14 | -------------------------------------------------------------------------------- /src/utils/isClass.ts: -------------------------------------------------------------------------------- 1 | export function isClass(func: Function) { 2 | return typeof func === 'function' 3 | && Function.prototype.toString.call(func).startsWith('class'); 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/iteratorToArray.ts: -------------------------------------------------------------------------------- 1 | export async function iteratorToArray(input: AsyncIterable | Iterable): Promise { 2 | const result: T[] = []; 3 | for await (const item of input) 4 | result.push(item); 5 | return result; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/notEmpty.ts: -------------------------------------------------------------------------------- 1 | export const notEmpty = (t: T): t is Exclude => t !== undefined && t !== null; 2 | -------------------------------------------------------------------------------- /src/utils/setupOneTimeEmitterSubscription.ts: -------------------------------------------------------------------------------- 1 | import { IEvent, ILogger, IObservable } from '../interfaces'; 2 | 3 | /** 4 | * Create one-time eventEmitter subscription for one or multiple events that match a filter 5 | * 6 | * @param {IObservable} emitter 7 | * @param {string[]} messageTypes Array of event type to subscribe to 8 | * @param {function(IEvent):any} [handler] Optional handler to execute for a first event received 9 | * @param {function(IEvent):boolean} [filter] Optional filter to apply before executing a handler 10 | * @param {ILogger} logger 11 | * @return {Promise} Resolves to first event that passes filter 12 | */ 13 | export function setupOneTimeEmitterSubscription( 14 | emitter: IObservable, 15 | messageTypes: string[], 16 | filter?: (e: IEvent) => boolean, 17 | handler?: (e: IEvent) => void, 18 | logger?: ILogger 19 | ): Promise { 20 | if (typeof emitter !== 'object' || !emitter) 21 | throw new TypeError('emitter argument must be an Object'); 22 | if (!Array.isArray(messageTypes) || messageTypes.some(m => !m || typeof m !== 'string')) 23 | throw new TypeError('messageTypes argument must be an Array of non-empty Strings'); 24 | if (handler && typeof handler !== 'function') 25 | throw new TypeError('handler argument, when specified, must be a Function'); 26 | if (filter && typeof filter !== 'function') 27 | throw new TypeError('filter argument, when specified, must be a Function'); 28 | 29 | return new Promise(resolve => { 30 | 31 | // handler will be invoked only once, 32 | // even if multiple events have been emitted before subscription was destroyed 33 | // https://nodejs.org/api/events.html#events_emitter_removelistener_eventname_listener 34 | let handled = false; 35 | 36 | function filteredHandler(event: IEvent) { 37 | if (filter && !filter(event)) 38 | return; 39 | if (handled) 40 | return; 41 | handled = true; 42 | 43 | for (const messageType of messageTypes) 44 | emitter.off(messageType, filteredHandler); 45 | 46 | logger?.debug(`'${event.type}' received, one-time subscription to '${messageTypes.join(',')}' removed`); 47 | 48 | if (handler) 49 | handler(event); 50 | 51 | resolve(event); 52 | } 53 | 54 | for (const messageType of messageTypes) 55 | emitter.on(messageType, filteredHandler); 56 | 57 | logger?.debug(`set up one-time ${filter ? 'filtered subscription' : 'subscription'} to '${messageTypes.join(',')}'`); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { IMessageHandler, IObservable } from '../interfaces'; 2 | import { getHandler } from './getHandler'; 3 | import { getMessageHandlerNames } from './getMessageHandlerNames'; 4 | 5 | const unique = (arr: T[]): T[] => [...new Set(arr)]; 6 | 7 | /** 8 | * Get a list of message types handled by observer 9 | */ 10 | export function getHandledMessageTypes(observerInstanceOrClass: (object | Function)): string[] { 11 | if (!observerInstanceOrClass) 12 | throw new TypeError('observerInstanceOrClass argument required'); 13 | 14 | const prototype = Object.getPrototypeOf(observerInstanceOrClass); 15 | if (prototype && prototype.constructor && prototype.constructor.handles) 16 | return prototype.constructor.handles; 17 | 18 | return getMessageHandlerNames(observerInstanceOrClass); 19 | } 20 | 21 | /** 22 | * Subscribe observer to observable 23 | */ 24 | export function subscribe( 25 | observable: IObservable, 26 | observer: object, 27 | options: { 28 | messageTypes?: string[], 29 | masterHandler?: IMessageHandler, 30 | queueName?: string 31 | } = {} 32 | ) { 33 | if (typeof observable !== 'object' || !observable) 34 | throw new TypeError('observable argument must be an Object'); 35 | if (typeof observable.on !== 'function') 36 | throw new TypeError('observable.on must be a Function'); 37 | if (typeof observer !== 'object' || !observer) 38 | throw new TypeError('observer argument must be an Object'); 39 | 40 | const { masterHandler, messageTypes, queueName } = options; 41 | if (masterHandler && typeof masterHandler !== 'function') 42 | throw new TypeError('masterHandler parameter, when provided, must be a Function'); 43 | if (queueName && typeof observable.queue !== 'function') 44 | throw new TypeError('observable.queue, when queueName is specified, must be a Function'); 45 | 46 | const subscribeTo = messageTypes || getHandledMessageTypes(observer); 47 | if (!Array.isArray(subscribeTo)) 48 | throw new TypeError('either options.messageTypes, observer.handles or ObserverType.handles is required'); 49 | 50 | for (const messageType of unique(subscribeTo)) { 51 | const handler = masterHandler || getHandler(observer, messageType); 52 | if (!handler) 53 | throw new Error(`'${messageType}' handler is not defined or not a function`); 54 | 55 | if (queueName) { 56 | if (!observable.queue) 57 | throw new TypeError('Observer does not support named queues'); 58 | 59 | observable.queue(queueName).on(messageType, handler); 60 | } 61 | else { 62 | observable.on(messageType, handler); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/validateHandlers.ts: -------------------------------------------------------------------------------- 1 | import { getHandler } from './getHandler'; 2 | 3 | /** 4 | * Ensure instance has handlers declared for all handled message types 5 | */ 6 | export function validateHandlers(instance: object, handlesFieldName = 'handles') { 7 | if (!instance) 8 | throw new TypeError('instance argument required'); 9 | 10 | const messageTypes = Object.getPrototypeOf(instance).constructor[handlesFieldName]; 11 | if (messageTypes === undefined) 12 | return; 13 | if (!Array.isArray(messageTypes)) 14 | throw new TypeError('handles getter, when defined, must return an Array of Strings'); 15 | 16 | for (const type of messageTypes) { 17 | if (!getHandler(instance, type)) 18 | throw new Error(`'${type}' handler is not defined or not a function`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/integration/rabbitmq/RabbitMqEventBus.test.ts: -------------------------------------------------------------------------------- 1 | import * as amqplib from 'amqplib'; 2 | import { RabbitMqGateway } from '../../../src/rabbitmq/RabbitMqGateway'; 3 | import { RabbitMqEventBus } from '../../../src/rabbitmq/RabbitMqEventBus'; 4 | import { IMessage, IEvent } from '../../../src/interfaces'; 5 | 6 | const delay = (ms: number) => new Promise(res => { 7 | const t = setTimeout(res, ms); 8 | t.unref(); 9 | }); 10 | 11 | describe('RabbitMqEventBus', () => { 12 | 13 | let gateway1: RabbitMqGateway; 14 | let gateway2: RabbitMqGateway; 15 | let gateway3: RabbitMqGateway; 16 | let eventBus1: RabbitMqEventBus; 17 | let eventBus2: RabbitMqEventBus; 18 | let eventBus3: RabbitMqEventBus; 19 | 20 | const queueName = 'test-bus-queue'; 21 | const exchangeName = 'test-bus-exchange'; 22 | const eventType = 'test-bus-event'; 23 | 24 | beforeEach(async () => { 25 | const rabbitMqConnectionFactory = () => amqplib.connect('amqp://localhost'); 26 | gateway1 = new RabbitMqGateway({ rabbitMqConnectionFactory }); 27 | gateway2 = new RabbitMqGateway({ rabbitMqConnectionFactory }); 28 | gateway3 = new RabbitMqGateway({ rabbitMqConnectionFactory }); 29 | eventBus1 = new RabbitMqEventBus({ rabbitMqGateway: gateway1, exchange: exchangeName }); 30 | eventBus2 = new RabbitMqEventBus({ rabbitMqGateway: gateway2, exchange: exchangeName }); 31 | eventBus3 = new RabbitMqEventBus({ rabbitMqGateway: gateway3, exchange: exchangeName }); 32 | }); 33 | 34 | afterEach(async () => { 35 | const ch = await gateway1.connection.createChannel(); 36 | await ch.deleteQueue(queueName); 37 | await ch.deleteQueue(`${queueName}.failed`); 38 | await ch.deleteExchange(exchangeName); 39 | await gateway1.disconnect(); 40 | await gateway2.disconnect(); 41 | await gateway3.disconnect(); 42 | }); 43 | 44 | describe('publish()', () => { 45 | 46 | it('publishes without throwing', async () => { 47 | 48 | await eventBus1.publish({ type: eventType }); 49 | }); 50 | }); 51 | 52 | describe('on()', () => { 53 | 54 | it('subscribes to events so that they are delivered to every subscriber except sender', async () => { 55 | 56 | const received1: IMessage[] = []; 57 | const received2: IMessage[] = []; 58 | const received3: IMessage[] = []; 59 | 60 | await eventBus1.on(eventType, e => { 61 | received1.push(e); 62 | }); 63 | 64 | await eventBus2.on(eventType, e => { 65 | received2.push(e); 66 | }); 67 | 68 | await eventBus3.on(eventType, e => { 69 | received3.push(e); 70 | }); 71 | 72 | const event: IEvent = { 73 | type: eventType, 74 | payload: { ok: true } 75 | }; 76 | 77 | await eventBus2.publish(event); 78 | await delay(50); 79 | 80 | expect(received1).toEqual([event]); 81 | expect(received2).toEqual([]); 82 | expect(received3).toEqual([event]); 83 | }); 84 | 85 | it('allows to subscribe to all events', async () => { 86 | 87 | const received1: IMessage[] = []; 88 | 89 | await eventBus1.on(RabbitMqEventBus.allEventsWildcard, e => { 90 | received1.push(e); 91 | }); 92 | 93 | const event1: IEvent = { type: `${eventType}1` }; 94 | const event2: IEvent = { type: `${eventType}2` }; 95 | 96 | await eventBus2.publish(event1); 97 | await eventBus3.publish(event2); 98 | 99 | await delay(50); 100 | 101 | expect(received1).toEqual([event1, event2]); 102 | }); 103 | }); 104 | 105 | describe('queue()', () => { 106 | 107 | it('creates an isolated queue where published messages delivered to only one recipient', async () => { 108 | 109 | const received1: IMessage[] = []; 110 | const received2: IMessage[] = []; 111 | 112 | await eventBus1.queue(queueName).on(eventType, msg => { 113 | received1.push(msg); 114 | }); 115 | 116 | await eventBus2.queue(queueName).on(eventType, msg => { 117 | received2.push(msg); 118 | }); 119 | 120 | const event: IEvent = { 121 | type: eventType, 122 | payload: { ok: true } 123 | }; 124 | 125 | await eventBus1.publish(event); 126 | await delay(50); 127 | 128 | expect([...received1, ...received2]).toEqual([ 129 | event 130 | ]); 131 | }); 132 | 133 | it('allows to subscribe to all events in the queue', async () => { 134 | 135 | const received1: IMessage[] = []; 136 | const received2: IMessage[] = []; 137 | 138 | await eventBus1.queue(queueName).on(RabbitMqEventBus.allEventsWildcard, msg => { 139 | received1.push(msg); 140 | }); 141 | 142 | await eventBus2.queue(queueName).on(RabbitMqEventBus.allEventsWildcard, msg => { 143 | received2.push(msg); 144 | }); 145 | 146 | const event1: IEvent = { 147 | type: `${eventType}1` 148 | }; 149 | 150 | const event2: IEvent = { 151 | type: `${eventType}2` 152 | }; 153 | 154 | await eventBus1.publish(event1); 155 | await eventBus1.publish(event2); 156 | 157 | await delay(50); 158 | 159 | expect([...received1, ...received2]).toEqual([ 160 | event1, 161 | event2 162 | ]); 163 | }); 164 | 165 | }); 166 | 167 | describe('off()', () => { 168 | 169 | it('removes previously added handler', async () => { 170 | 171 | const received1: IMessage[] = []; 172 | const handler1 = (msg: IMessage) => received1.push(msg); 173 | await eventBus1.on(eventType, handler1); 174 | 175 | const received2: IMessage[] = []; 176 | const handler2 = (msg: IMessage) => received2.push(msg); 177 | await eventBus2.on(eventType, handler2); 178 | 179 | eventBus2.off(eventType, handler2); 180 | 181 | const event = { type: eventType, payload: { removed: true } }; 182 | await eventBus3.publish(event); 183 | 184 | await delay(50); 185 | 186 | expect(received1).toEqual([event]); 187 | expect(received2).toEqual([]); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /tests/integration/rabbitmq/RabbitMqEventInjector.test.ts: -------------------------------------------------------------------------------- 1 | import * as amqplib from 'amqplib'; 2 | import { RabbitMqGateway } from '../../../src/rabbitmq/RabbitMqGateway'; 3 | import { RabbitMqEventInjector } from '../../../src/rabbitmq/RabbitMqEventInjector'; 4 | import { IEvent, IEventDispatcher } from '../../../src/interfaces'; 5 | import { jest } from '@jest/globals'; 6 | import { delay } from '../../../src/utils'; 7 | 8 | describe('RabbitMqEventInjector', () => { 9 | let rabbitMqGateway: RabbitMqGateway; 10 | let rabbitMqGateway2: RabbitMqGateway; 11 | let eventDispatcher: jest.Mocked; 12 | 13 | const exchange = 'node-cqrs.events'; 14 | const eventType = 'test-injector-event'; 15 | 16 | beforeEach(async () => { 17 | const rabbitMqConnectionFactory = () => amqplib.connect('amqp://localhost'); 18 | rabbitMqGateway = new RabbitMqGateway({ rabbitMqConnectionFactory }); 19 | rabbitMqGateway2 = new RabbitMqGateway({ rabbitMqConnectionFactory }); 20 | 21 | eventDispatcher = { 22 | dispatch: jest.fn().mockResolvedValue(undefined) 23 | } as unknown as jest.Mocked; 24 | 25 | const injector = new RabbitMqEventInjector({ rabbitMqGateway, eventDispatcher }); 26 | 27 | await injector.start(exchange); 28 | }); 29 | 30 | afterEach(async () => { 31 | const ch = await rabbitMqGateway.connection?.createChannel(); 32 | await ch.deleteExchange(exchange); 33 | await ch.close(); 34 | await rabbitMqGateway.disconnect(); 35 | await rabbitMqGateway2.disconnect(); 36 | }); 37 | 38 | it('does not receive messages published to own gateway', async () => { 39 | const testEvent: IEvent = { 40 | type: eventType, 41 | payload: { data: 'test-payload' }, 42 | id: 'test-id-123' 43 | }; 44 | 45 | await rabbitMqGateway.publish(exchange, testEvent); 46 | 47 | await delay(50); 48 | 49 | expect(eventDispatcher.dispatch).not.toHaveBeenCalled(); 50 | }); 51 | 52 | it('receives messages published to other gateway, dispatches to eventDispatcher', async () => { 53 | const testEvent: IEvent = { 54 | type: eventType, 55 | payload: { data: 'test-payload' }, 56 | id: 'test-id-123' 57 | }; 58 | 59 | await rabbitMqGateway2.publish(exchange, testEvent); 60 | 61 | await delay(50); 62 | 63 | expect(eventDispatcher.dispatch).toHaveBeenCalledTimes(1); 64 | expect(eventDispatcher.dispatch).toHaveBeenCalledWith([testEvent], { origin: 'external' }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/integration/rabbitmq/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | rabbitmq: 5 | image: rabbitmq:3-management 6 | container_name: rabbitmq 7 | ports: 8 | - "5672:5672" # AMQP 9 | - "15672:15672" # Management UI 10 | environment: 11 | RABBITMQ_DEFAULT_USER: guest 12 | RABBITMQ_DEFAULT_PASS: guest 13 | volumes: 14 | - rabbitmq_data:/var/lib/rabbitmq 15 | 16 | volumes: 17 | rabbitmq_data: 18 | -------------------------------------------------------------------------------- /tests/integration/sqlite/SqliteView.test.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, unlinkSync } from 'fs'; 2 | import { AbstractProjection, IEvent } from '../../../src'; 3 | import { SqliteObjectView } from '../../../src/sqlite'; 4 | import * as createDb from 'better-sqlite3'; 5 | 6 | type UserPayload = { 7 | name: string; 8 | } 9 | 10 | class MyDumbProjection extends AbstractProjection> { 11 | 12 | async userCreated(e: IEvent) { 13 | if (typeof e.aggregateId !== 'string') 14 | throw new TypeError('e.aggregateId is required'); 15 | if (!e.payload) 16 | throw new TypeError('e.payload is required'); 17 | 18 | await this.view.create(e.aggregateId, e.payload); 19 | } 20 | 21 | async userModified(e: IEvent) { 22 | if (typeof e.aggregateId !== 'string') 23 | throw new TypeError('e.aggregateId is required'); 24 | if (!e.payload) 25 | throw new TypeError('e.payload is required'); 26 | 27 | await this.view.update(e.aggregateId, _u => e.payload); 28 | } 29 | } 30 | 31 | describe('SqliteView', () => { 32 | 33 | let viewModelSqliteDb: import('better-sqlite3').Database; 34 | 35 | const fileName = './test.sqlite'; 36 | 37 | beforeEach(() => { 38 | viewModelSqliteDb = createDb(fileName); 39 | 40 | // Write-Ahead Logging (WAL) mode allows reads and writes to happen concurrently and reduces contention 41 | // on the database. It keeps changes in a separate log file before they are flushed to the main database file 42 | viewModelSqliteDb.pragma('journal_mode = WAL'); 43 | 44 | // The synchronous pragma controls how often SQLite synchronizes writes to the filesystem. Lowering this can 45 | // boost performance but increases the risk of data loss in the event of a crash. 46 | viewModelSqliteDb.pragma('synchronous = NORMAL'); 47 | 48 | // Limit WAL journal size to 5MB to manage disk usage in high-write scenarios. 49 | // With WAL mode and NORMAL sync, this helps prevent excessive file growth during transactions. 50 | viewModelSqliteDb.pragma(`journal_size_limit = ${5 * 1024 * 1024}`); 51 | }); 52 | 53 | afterEach(() => { 54 | if (viewModelSqliteDb) 55 | viewModelSqliteDb.close(); 56 | if (existsSync(fileName)) 57 | unlinkSync(fileName); 58 | }); 59 | 60 | // project 10_000 events (5_000 create new, 5_000 read, update, put back) 61 | // in memory - 113 ms (88_500 events/second) 62 | // on file system - 44_396 ms (225 events/second) 63 | // on file system with WAL and NORMAL sync - 551 ms (18_148 events/second) 64 | 65 | it('handles 1_000 events within 0.5 seconds', async () => { 66 | 67 | const p = new MyDumbProjection({ 68 | view: new SqliteObjectView({ 69 | schemaVersion: '1', 70 | viewModelSqliteDb, 71 | projectionName: 'tbl_test', 72 | tableNamePrefix: 'tbl_test' 73 | }) 74 | }); 75 | 76 | await p.view.lock(); 77 | await p.view.unlock(); 78 | 79 | const aggregateIds = Array.from({ length: 1_000 }, (v, i) => ({ 80 | aggregateId: `${i}A`.padStart(32, '0'), 81 | eventId: `${i}B`.padStart(32, '0') 82 | })); 83 | 84 | const startTs = Date.now(); 85 | 86 | for (const { aggregateId, eventId } of aggregateIds) { 87 | await p.project({ 88 | type: 'userCreated', 89 | id: eventId, 90 | aggregateId, 91 | payload: { 92 | name: 'Jon' 93 | } 94 | }); 95 | 96 | await p.project({ 97 | type: 'userModified', 98 | aggregateId, 99 | payload: { 100 | name: 'Jon Doe' 101 | } 102 | }); 103 | } 104 | 105 | const totalMs = Date.now() - startTs; 106 | expect(totalMs).toBeLessThan(500); 107 | 108 | const user = await p.view.get('0000000000000000000000000000999A'); 109 | expect(user).toEqual({ 110 | name: 'Jon Doe' 111 | }); 112 | 113 | // console.log({ 114 | // tbl_view_lock: viewModelSqliteDb.prepare(`SELECT * FROM tbl_view_lock LIMIT 3`).all(), 115 | // tbl_test_1_event_lock: viewModelSqliteDb.prepare(`SELECT * FROM tbl_event_lock LIMIT 3`).all(), 116 | // tbl_test_1: viewModelSqliteDb.prepare(`SELECT * FROM tbl_test_1 LIMIT 3`).all() 117 | // }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /tests/unit/AbstractSaga.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { AbstractSaga } from '../../src/AbstractSaga'; 3 | 4 | class Saga extends AbstractSaga { 5 | static get startsWith() { 6 | return ['somethingHappened']; 7 | } 8 | _somethingHappened(_event) { 9 | super.enqueue('doSomething', undefined, { foo: 'bar' }); 10 | } 11 | } 12 | 13 | describe('AbstractSaga', function () { 14 | 15 | let s; 16 | 17 | beforeEach(() => s = new Saga({ 18 | id: 1 19 | })); 20 | 21 | describe('constructor', () => { 22 | 23 | it('throws exception if "static get handles" is not overridden', () => { 24 | 25 | class SagaWithoutHandles extends AbstractSaga { } 26 | 27 | expect(() => s = new SagaWithoutHandles({ id: 1 })).to.throw('startsWith must be overridden to return a list of event types that start saga'); 28 | }); 29 | 30 | it('throws exception if event handler is not defined', () => { 31 | 32 | class SagaWithoutHandler extends AbstractSaga { 33 | static get startsWith() { 34 | return ['somethingHappened']; 35 | } 36 | } 37 | 38 | expect(() => s = new SagaWithoutHandler({ id: 1 })).to.throw('\'somethingHappened\' handler is not defined or not a function'); 39 | }); 40 | 41 | it('sets \'restored\' flag, after saga restored from eventStore', () => { 42 | 43 | const s2 = new Saga({ id: 1, events: [{ type: 'somethingHappened', payload: 'test' }] }); 44 | expect(s2).to.have.property('restored', true); 45 | }); 46 | }); 47 | 48 | describe('id', () => { 49 | 50 | it('returns immutable saga id', () => { 51 | 52 | expect(s).to.have.property('id', 1); 53 | expect(() => s.id = 2).to.throw(); 54 | }); 55 | }); 56 | 57 | describe('version', () => { 58 | 59 | it('returns immutable saga version', () => { 60 | 61 | expect(s).to.have.property('version', 0); 62 | expect(() => s.version = 2).to.throw(); 63 | }); 64 | }); 65 | 66 | describe('uncommittedMessages', () => { 67 | 68 | it('returns immutable list of uncommitted commands enqueued by saga', () => { 69 | 70 | expect(s).to.have.property('uncommittedMessages'); 71 | expect(() => { 72 | s.uncommittedMessages = null; 73 | }).to.throw(); 74 | 75 | expect(s.uncommittedMessages).to.be.an('Array'); 76 | expect(s.uncommittedMessages).to.be.empty; 77 | 78 | s.uncommittedMessages.push({}); 79 | expect(s.uncommittedMessages).to.be.empty; 80 | }); 81 | }); 82 | 83 | describe('apply(event)', () => { 84 | 85 | it('passes event to saga event handler', () => { 86 | 87 | let receivedEvent; 88 | s._somethingHappened = event => { 89 | receivedEvent = event; 90 | }; 91 | 92 | s.apply({ type: 'somethingHappened', payload: 'test' }); 93 | 94 | expect(receivedEvent).to.be.not.empty; 95 | expect(receivedEvent).to.have.nested.property('type', 'somethingHappened'); 96 | }); 97 | 98 | it('throws exception if no handler defined', () => { 99 | 100 | expect(() => s.apply({ type: 'anotherHappened' })).to.throw('\'anotherHappened\' handler is not defined or not a function'); 101 | }); 102 | }); 103 | 104 | describe('enqueue(commandType, aggregateId, commandPayload)', () => { 105 | 106 | it('adds command to saga.uncommittedMessages list', () => { 107 | 108 | s.apply({ type: 'somethingHappened' }); 109 | 110 | const { uncommittedMessages } = s; 111 | 112 | expect(uncommittedMessages).to.have.length(1); 113 | expect(uncommittedMessages[0]).to.have.property('sagaId', s.id); 114 | expect(uncommittedMessages[0]).to.have.property('sagaVersion', s.version - 1); 115 | expect(uncommittedMessages[0]).to.have.property('type', 'doSomething'); 116 | expect(uncommittedMessages[0]).to.have.nested.property('payload.foo', 'bar'); 117 | }); 118 | }); 119 | 120 | describe('resetUncommittedMessages()', () => { 121 | 122 | it('clears saga.uncommittedMessages list', () => { 123 | 124 | s.apply({ type: 'somethingHappened' }); 125 | expect(s.uncommittedMessages).to.have.length(1); 126 | 127 | s.resetUncommittedMessages(); 128 | expect(s.uncommittedMessages).to.be.empty; 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /tests/unit/CommandBus.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | import { InMemoryMessageBus, CommandBus } from '../../src'; 4 | 5 | describe('CommandBus', function () { 6 | 7 | let messageBus; 8 | let bus; 9 | 10 | beforeEach(() => { 11 | messageBus = new InMemoryMessageBus(); 12 | sinon.spy(messageBus, 'on'); 13 | sinon.spy(messageBus, 'send'); 14 | bus = new CommandBus({ messageBus }); 15 | }); 16 | 17 | describe('on(commandType, handler)', () => { 18 | 19 | it('validates parameters', () => { 20 | 21 | expect(() => bus.on()).to.throw(TypeError); 22 | expect(() => bus.on('test')).to.throw(TypeError); 23 | expect(() => bus.on('test', () => { })).to.not.throw(); 24 | }); 25 | 26 | it('sets up a handler on messageBus for a given commandType', () => { 27 | 28 | bus.on('doSomething', () => { }); 29 | 30 | expect(messageBus.on).to.have.property('calledOnce', true); 31 | expect(messageBus.on).to.have.nested.property('firstCall.args[0]', 'doSomething'); 32 | expect(messageBus.on).to.have.nested.property('firstCall.args[1]').that.is.a('Function'); 33 | }); 34 | }); 35 | 36 | describe('sendRaw(command)', () => { 37 | 38 | beforeEach(() => { 39 | bus.on('doSomething', () => { }); 40 | }); 41 | 42 | it('briefly validates parameters', () => { 43 | 44 | expect(() => bus.sendRaw()).to.throw('command argument required'); 45 | expect(() => bus.sendRaw({})).to.throw('command.type argument required'); 46 | }); 47 | 48 | it('passes a formatted command to messageBus', () => { 49 | 50 | const command = { 51 | type: 'doSomething', 52 | aggregateId: 0, 53 | context: {}, 54 | payload: {} 55 | }; 56 | 57 | return bus.sendRaw(command) 58 | .then(() => { 59 | expect(messageBus.send).to.have.nested.property('lastCall.args[0]', command); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('send(commandType, aggregateId, options)', () => { 65 | 66 | beforeEach(() => { 67 | bus.on('doSomething', () => { }); 68 | }); 69 | 70 | it('validates parameters', () => { 71 | 72 | expect(() => bus.send(undefined)).to.throw('type argument must be a non-empty String'); 73 | expect(() => bus.send('test', 1, {}, {}, {})).to.throw('more than expected arguments supplied'); 74 | }); 75 | 76 | it('formats a command and passes it to sendRaw', async () => { 77 | 78 | sinon.spy(bus, 'sendRaw'); 79 | 80 | const type = 'doSomething'; 81 | const aggregateId = 1; 82 | const payload = {}; 83 | const context = {}; 84 | const customParameter = '123'; 85 | 86 | await bus.send(type, aggregateId, { context }); 87 | 88 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].type', type); 89 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].aggregateId', aggregateId); 90 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].context', context); 91 | expect(bus.sendRaw).to.not.have.nested.property('lastCall.args[0].payload'); 92 | 93 | await bus.send(type, aggregateId, { context, payload, customParameter }); 94 | 95 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].type', type); 96 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].aggregateId', aggregateId); 97 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].context', context); 98 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].payload', payload); 99 | expect(bus.sendRaw).to.have.nested.property('lastCall.args[0].customParameter', customParameter); 100 | }); 101 | 102 | it('supports obsolete syntax', async () => { 103 | 104 | const aggregateId = 1; 105 | const context = {}; 106 | const payload = {}; 107 | 108 | await bus.send('doSomething', aggregateId, context, payload); 109 | 110 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].type', 'doSomething'); 111 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].aggregateId', aggregateId); 112 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].context', context); 113 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].payload', payload); 114 | 115 | await bus.send('doSomething', undefined, context, payload); 116 | 117 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].type', 'doSomething'); 118 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].aggregateId', undefined); 119 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].context', context); 120 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].payload', payload); 121 | 122 | await bus.send('doSomething', undefined, context); 123 | 124 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].type', 'doSomething'); 125 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].aggregateId', undefined); 126 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].context', context); 127 | expect(messageBus.send).to.have.nested.property('lastCall.args[0].payload', undefined); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /tests/unit/CqrsContainerBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { 3 | InMemoryEventStorage, 4 | InMemoryMessageBus, 5 | InMemoryView, 6 | ContainerBuilder, 7 | AbstractAggregate, 8 | AbstractSaga, 9 | AbstractProjection 10 | } from '../../src'; 11 | 12 | describe('CqrsContainerBuilder', function () { 13 | 14 | let builder: ContainerBuilder; 15 | 16 | beforeEach(() => { 17 | builder = new ContainerBuilder(); 18 | builder.register(InMemoryEventStorage).as('eventStorageWriter').as('eventStorageReader').as('identifierProvider'); 19 | builder.register(InMemoryMessageBus).as('eventBus'); 20 | }); 21 | 22 | describe('registerAggregate(aggregateType) extension', () => { 23 | 24 | it('registers aggregate command handler for a given aggregate type', () => { 25 | 26 | class Aggregate extends AbstractAggregate { 27 | /** Command handler */ 28 | doSomething() { } 29 | } 30 | 31 | builder.registerAggregate(Aggregate); 32 | }); 33 | 34 | it('injects aggregate dependencies into aggregate constructor upon initialization', async () => { 35 | 36 | let dependencyMet; 37 | 38 | class SomeService { } 39 | 40 | class MyAggregate extends AbstractAggregate { 41 | constructor(options) { 42 | super(options); 43 | dependencyMet = (options.aggregateDependency instanceof SomeService); 44 | } 45 | 46 | /** Command handler */ 47 | doSomething() { } 48 | } 49 | 50 | builder.registerAggregate(MyAggregate); 51 | 52 | await builder.container().commandBus.sendRaw({ type: 'doSomething' }); 53 | expect(dependencyMet).to.equal(false); 54 | 55 | builder.register(SomeService, 'aggregateDependency'); 56 | 57 | await builder.container().commandBus.sendRaw({ type: 'doSomething' }); 58 | expect(dependencyMet).to.equal(true); 59 | }); 60 | }); 61 | 62 | describe('registerSaga(sagaType) extension', () => { 63 | 64 | it('sets up saga event handler', done => { 65 | 66 | class Saga extends AbstractSaga { 67 | static get startsWith() { 68 | return ['somethingHappened']; 69 | } 70 | somethingHappened() { 71 | super.enqueue('doSomething', undefined, { foo: 'bar' }); 72 | } 73 | } 74 | 75 | builder.registerSaga(Saga); 76 | const container = builder.container(); 77 | 78 | container.commandBus.on('doSomething', () => done()); 79 | 80 | const events = [ 81 | { type: 'somethingHappened', aggregateId: 1 } 82 | ]; 83 | 84 | container.eventStore.dispatch(events).catch(done); 85 | }); 86 | }); 87 | 88 | describe('registerProjection(typeOrFactory, exposedViewName) extension', () => { 89 | 90 | class MyProjection extends AbstractProjection { 91 | static get handles() { 92 | return ['somethingHappened']; 93 | } 94 | _somethingHappened(event) { 95 | this.view.create(event.aggregateId, event.payload); 96 | } 97 | } 98 | 99 | it('exists', () => { 100 | expect(builder).to.respondTo('registerProjection'); 101 | }); 102 | 103 | it('exposes projection view thru getter', () => { 104 | 105 | builder.registerProjection(MyProjection, 'myView'); 106 | 107 | const container = builder.container(); 108 | 109 | expect(container).to.have.property('myView').that.is.instanceOf(InMemoryView); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/unit/EventDispatcher.test.ts: -------------------------------------------------------------------------------- 1 | import { IEvent, IEventBus, IDispatchPipelineProcessor } from '../../src'; 2 | import { EventDispatcher } from '../../src/EventDispatcher'; 3 | 4 | describe('EventDispatcher', () => { 5 | let dispatcher: EventDispatcher; 6 | let eventBus: jest.Mocked; 7 | 8 | beforeEach(() => { 9 | eventBus = { publish: jest.fn() }; 10 | dispatcher = new EventDispatcher({ eventBus }); 11 | }); 12 | 13 | it('dispatches events through processors and dispatches', async () => { 14 | 15 | const event1: IEvent = { type: 'test-event-1' }; 16 | const event2: IEvent = { type: 'test-event-2' }; 17 | 18 | const processorMock: IDispatchPipelineProcessor = { 19 | process: jest.fn(batch => Promise.resolve(batch)) 20 | }; 21 | 22 | dispatcher.addPipelineProcessor(processorMock); 23 | const result = await dispatcher.dispatch([event1, event2]); 24 | 25 | expect(processorMock.process).toHaveBeenCalledTimes(1); 26 | expect(eventBus.publish).toHaveBeenCalledTimes(2); 27 | expect(eventBus.publish).toHaveBeenCalledWith(event1, {}); 28 | expect(eventBus.publish).toHaveBeenCalledWith(event2, {}); 29 | expect(result).toEqual([event1, event2]); 30 | }); 31 | 32 | it('handles processor errors and invokes revert', async () => { 33 | 34 | const event: IEvent = { type: 'failing-event' }; 35 | const error = new Error('processor error'); 36 | 37 | const processorMock: IDispatchPipelineProcessor = { 38 | process: jest.fn().mockRejectedValue(error), 39 | revert: jest.fn().mockResolvedValue(undefined) 40 | }; 41 | 42 | dispatcher.addPipelineProcessor(processorMock); 43 | 44 | await expect(dispatcher.dispatch([event])).rejects.toThrow('processor error'); 45 | 46 | expect(processorMock.process).toHaveBeenCalledTimes(1); 47 | expect(processorMock.revert).toHaveBeenCalledTimes(1); 48 | expect(eventBus.publish).not.toHaveBeenCalled(); 49 | }); 50 | 51 | it('throws if dispatch called with empty event array', async () => { 52 | 53 | await expect(dispatcher.dispatch([])).rejects.toThrow('dispatch requires a non-empty array of events'); 54 | }); 55 | 56 | it('runs multiple processors sequentially while processing batches in parallel', async () => { 57 | 58 | const executionOrder: string[] = []; 59 | 60 | const processorA: IDispatchPipelineProcessor = { 61 | process: jest.fn(async batch => { 62 | executionOrder.push(`A-start-${batch[0].event.type}`); 63 | await new Promise(res => setTimeout(res, 5)); 64 | executionOrder.push(`A-end-${batch[0].event.type}`); 65 | return batch; 66 | }) 67 | }; 68 | 69 | const processorB: IDispatchPipelineProcessor = { 70 | process: jest.fn(async batch => { 71 | executionOrder.push(`B-start-${batch[0].event.type}`); 72 | await new Promise(res => setTimeout(res, 5)); 73 | executionOrder.push(`B-end-${batch[0].event.type}`); 74 | return batch; 75 | }) 76 | }; 77 | 78 | dispatcher.addPipelineProcessor(processorA); 79 | dispatcher.addPipelineProcessor(processorB); 80 | 81 | const event1: IEvent = { type: 'event-1' }; 82 | const event2: IEvent = { type: 'event-2' }; 83 | 84 | await Promise.all([ 85 | dispatcher.dispatch([event1]), 86 | dispatcher.dispatch([event2]) 87 | ]); 88 | 89 | expect(executionOrder).toEqual([ 90 | 'A-start-event-1', 91 | 'A-start-event-2', 92 | 'A-end-event-1', 93 | 'B-start-event-1', 94 | 'A-end-event-2', 95 | 'B-start-event-2', 96 | 'B-end-event-1', 97 | 'B-end-event-2' 98 | ]); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/unit/SagaEventHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | import { 4 | SagaEventHandler, 5 | InMemoryEventStorage, 6 | EventStore, 7 | CommandBus, 8 | AbstractSaga, 9 | InMemoryMessageBus, 10 | EventDispatcher 11 | } from '../../src'; 12 | import { Deferred } from '../../src/utils'; 13 | 14 | class Saga extends AbstractSaga { 15 | static get startsWith() { 16 | return ['somethingHappened']; 17 | } 18 | static get handles(): string[] { 19 | return ['followingHappened']; 20 | } 21 | somethingHappened(_event) { 22 | super.enqueue('doSomething', undefined, { foo: 'bar' }); 23 | } 24 | followingHappened() { 25 | super.enqueue('complete', undefined, { foo: 'bar' }); 26 | } 27 | onError(error, { command, event }) { 28 | super.enqueue('fixError', undefined, { error, command, event }); 29 | } 30 | } 31 | 32 | const triggeringEvent = { 33 | type: 'somethingHappened', 34 | aggregateId: 1, 35 | sagaId: 1, 36 | sagaVersion: 0 37 | }; 38 | 39 | describe('SagaEventHandler', function () { 40 | 41 | let commandBus: CommandBus; 42 | let eventStore: EventStore; 43 | let sagaEventHandler: SagaEventHandler; 44 | 45 | beforeEach(() => { 46 | const eventBus = new InMemoryMessageBus(); 47 | const eventDispatcher = new EventDispatcher({ eventBus }); 48 | const eventStorageReader = new InMemoryEventStorage(); 49 | commandBus = new CommandBus({}); 50 | eventStore = new EventStore({ 51 | eventStorageReader, 52 | identifierProvider: eventStorageReader, 53 | eventBus, 54 | eventDispatcher 55 | }); 56 | sagaEventHandler = new SagaEventHandler({ sagaType: Saga, eventStore, commandBus }); 57 | }); 58 | 59 | it('exists', () => { 60 | expect(SagaEventHandler).to.be.a('Function'); 61 | }); 62 | 63 | it('restores saga state, passes in received event and sends emitted commands', async () => { 64 | 65 | const deferred = new Deferred(); 66 | 67 | commandBus.on('complete', () => { 68 | deferred.resolve(undefined); 69 | }); 70 | 71 | sinon.spy(eventStore, 'getSagaEvents'); 72 | 73 | expect(eventStore.getSagaEvents).to.have.property('callCount', 0); 74 | 75 | await sagaEventHandler.handle({ 76 | type: 'followingHappened', 77 | aggregateId: 1, 78 | sagaId: 1, 79 | sagaVersion: 0 80 | }); 81 | 82 | expect(eventStore.getSagaEvents).to.have.property('callCount', 1); 83 | 84 | await deferred.promise; 85 | }); 86 | 87 | it('passes command execution errors to saga.onError', async () => { 88 | 89 | let resolvePromise; 90 | const pendingPromise = new Promise(resolve => { 91 | resolvePromise = resolve; 92 | }); 93 | 94 | commandBus.on('fixError', command => { 95 | resolvePromise(command); 96 | }); 97 | commandBus.on('doSomething', _command => { 98 | throw new Error('command execution failed'); 99 | }); 100 | 101 | sagaEventHandler.handle(triggeringEvent); 102 | 103 | const fixConfirmationCommand = await pendingPromise; 104 | 105 | expect(fixConfirmationCommand).to.have.property('type', 'fixError'); 106 | expect(fixConfirmationCommand).to.have.nested.property('payload.event', triggeringEvent); 107 | expect(fixConfirmationCommand).to.have.nested.property('payload.command.type', 'doSomething'); 108 | expect(fixConfirmationCommand).to.have.nested.property('payload.error.message', 'command execution failed'); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /tests/unit/dispatch-pipeline.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ContainerBuilder, 3 | EventValidationProcessor, 4 | IContainer, 5 | InMemoryEventStorage, 6 | InMemoryMessageBus 7 | } from '../../src'; 8 | 9 | describe('eventDispatchPipeline', () => { 10 | 11 | let container: IContainer; 12 | 13 | const testEvent = { 14 | type: 'test-event', 15 | aggregateId: '123', 16 | payload: { data: 'test-payload' }, 17 | id: 'test-id-123' 18 | }; 19 | 20 | beforeEach(() => { 21 | const builder = new ContainerBuilder(); 22 | 23 | builder.register(InMemoryMessageBus).as('externalEventBus'); 24 | builder.register(InMemoryEventStorage).as('eventStorageWriter'); 25 | builder.register((c: IContainer) => [ 26 | new EventValidationProcessor(), 27 | c.externalEventBus, 28 | c.eventStorageWriter, 29 | c.snapshotStorage 30 | ]).as('eventDispatchPipeline'); 31 | 32 | container = builder.container() as IContainer; 33 | }); 34 | 35 | it('delivers locally dispatched events to externalEventBus', async () => { 36 | 37 | const { eventDispatcher, externalEventBus } = container; 38 | 39 | jest.spyOn(externalEventBus, 'publish'); 40 | 41 | await eventDispatcher.dispatch([testEvent], { origin: 'internal' }); 42 | 43 | expect(externalEventBus.publish).toHaveBeenCalledTimes(1); 44 | }); 45 | 46 | it('does not deliver externally dispatched events to externalEventBus', async () => { 47 | 48 | const { eventDispatcher, externalEventBus } = container; 49 | 50 | jest.spyOn(externalEventBus, 'publish'); 51 | 52 | await eventDispatcher.dispatch([testEvent], { origin: 'external' }); 53 | 54 | expect(externalEventBus.publish).toHaveBeenCalledTimes(0); 55 | }); 56 | 57 | it('delivers all events to eventStorageWriter', async () => { 58 | 59 | const { eventDispatcher, eventStorageWriter } = container; 60 | 61 | jest.spyOn(eventStorageWriter, 'commitEvents'); 62 | 63 | await eventDispatcher.dispatch([testEvent], { origin: 'internal' }); 64 | await eventDispatcher.dispatch([testEvent], { origin: 'external' }); 65 | 66 | expect(eventStorageWriter.commitEvents).toHaveBeenCalledTimes(2); 67 | expect(eventStorageWriter.commitEvents).toHaveBeenNthCalledWith(1, [testEvent]); 68 | expect(eventStorageWriter.commitEvents).toHaveBeenNthCalledWith(2, [testEvent]); 69 | }); 70 | 71 | 72 | it('delivers all events to eventBus', async () => { 73 | 74 | const { eventDispatcher, eventBus } = container; 75 | 76 | jest.spyOn(eventBus, 'publish'); 77 | 78 | await eventDispatcher.dispatch([testEvent], { origin: 'internal' }); 79 | await eventDispatcher.dispatch([testEvent], { origin: 'external' }); 80 | 81 | expect(eventBus.publish).toHaveBeenCalledTimes(2); 82 | expect(eventBus.publish).toHaveBeenNthCalledWith(1, testEvent, { origin: 'internal' }); 83 | expect(eventBus.publish).toHaveBeenNthCalledWith(2, testEvent, { origin: 'external' }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/unit/memory/InMemoryEventStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { InMemoryEventStorage } from '../../../src'; 3 | 4 | describe('InMemoryEventStorage', () => { 5 | let storage; 6 | 7 | beforeEach(() => { 8 | storage = new InMemoryEventStorage(); 9 | }); 10 | 11 | describe('commitEvents', () => { 12 | it('commits events and returns them', async () => { 13 | const events = [ 14 | { id: '1', aggregateId: 'agg1', aggregateVersion: 1, type: 'TestEvent' } 15 | ]; 16 | const result = await storage.commitEvents(events); 17 | expect(result).to.deep.equal(events); 18 | }); 19 | }); 20 | 21 | describe('getAggregateEvents', () => { 22 | 23 | it('yields events with matching aggregateId', async () => { 24 | 25 | const event1 = { id: '1', aggregateId: 'agg1', aggregateVersion: 1, type: 'TestEvent' }; 26 | const event2 = { id: '2', aggregateId: 'agg2', aggregateVersion: 1, type: 'TestEvent' }; 27 | await storage.commitEvents([event1, event2]); 28 | 29 | const results = []; 30 | for await (const event of storage.getAggregateEvents('agg1')) 31 | results.push(event); 32 | 33 | expect(results).to.deep.equal([event1]); 34 | }); 35 | 36 | it('yields events with aggregateVersion greater than snapshot.aggregateVersion', async () => { 37 | 38 | const event1 = { id: '1', aggregateId: 'agg1', aggregateVersion: 1, type: 'TestEvent' }; 39 | const event2 = { id: '2', aggregateId: 'agg1', aggregateVersion: 2, type: 'TestEvent' }; 40 | await storage.commitEvents([event1, event2]); 41 | 42 | const snapshot = { aggregateVersion: 1 }; 43 | const results = []; 44 | for await (const event of storage.getAggregateEvents('agg1', { snapshot })) 45 | results.push(event); 46 | 47 | expect(results).to.deep.equal([event2]); 48 | }); 49 | }); 50 | 51 | describe('getSagaEvents', () => { 52 | 53 | it('yields saga events with sagaVersion less than beforeEvent.sagaVersion', async () => { 54 | 55 | const event1 = { id: '1', sagaId: 'saga1', sagaVersion: 1, type: 'SagaEvent' }; 56 | const event2 = { id: '2', sagaId: 'saga1', sagaVersion: 2, type: 'SagaEvent' }; 57 | const event3 = { id: '3', sagaId: 'saga1', sagaVersion: 3, type: 'SagaEvent' }; 58 | await storage.commitEvents([event1, event2, event3]); 59 | 60 | const beforeEvent = { sagaVersion: 3 }; 61 | const results = []; 62 | for await (const event of storage.getSagaEvents('saga1', { beforeEvent })) 63 | results.push(event); 64 | 65 | expect(results).to.deep.equal([event1, event2]); 66 | }); 67 | }); 68 | 69 | describe('getEventsByTypes', () => { 70 | 71 | it('yields events matching the provided types', async () => { 72 | 73 | const event1 = { id: '1', type: 'A' }; 74 | const event2 = { id: '2', type: 'B' }; 75 | const event3 = { id: '3', type: 'A' }; 76 | await storage.commitEvents([event1, event2, event3]); 77 | 78 | const results = []; 79 | for await (const event of storage.getEventsByTypes(['A'])) 80 | results.push(event); 81 | 82 | expect(results).to.deep.equal([event1, event3]); 83 | }); 84 | 85 | it('yields events only after the given afterEvent id', async () => { 86 | 87 | const event1 = { id: '1', type: 'A' }; 88 | const event2 = { id: '2', type: 'A' }; 89 | const event3 = { id: '3', type: 'A' }; 90 | await storage.commitEvents([event1, event2, event3]); 91 | 92 | const options = { afterEvent: { id: '1' } }; 93 | const results = []; 94 | for await (const event of storage.getEventsByTypes(['A'], options)) 95 | results.push(event); 96 | 97 | expect(results).to.deep.equal([event2, event3]); 98 | }); 99 | 100 | it('throws error if afterEvent is provided without id', async () => { 101 | 102 | const event1 = { id: '1', type: 'A' }; 103 | await storage.commitEvents([event1]); 104 | const options = { afterEvent: {} }; 105 | 106 | const gen = storage.getEventsByTypes(['A'], options); 107 | try { 108 | await gen.next(); 109 | throw new Error('Expected error was not thrown'); 110 | } 111 | catch (err) { 112 | expect(err).to.be.instanceOf(TypeError); 113 | expect(err.message).to.equal('options.afterEvent.id is required'); 114 | } 115 | }); 116 | }); 117 | 118 | describe('getNewId', () => { 119 | 120 | it('returns sequential string ids', () => { 121 | 122 | const id1 = storage.getNewId(); 123 | const id2 = storage.getNewId(); 124 | expect(id1).to.equal('1'); 125 | expect(id2).to.equal('2'); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /tests/unit/memory/InMemoryLock.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { InMemoryLock } from '../../../src'; 3 | 4 | describe('InMemoryLock', () => { 5 | let lock: InMemoryLock; 6 | 7 | beforeEach(() => { 8 | lock = new InMemoryLock(); 9 | }); 10 | 11 | it('should call each method explicitly to satisfy coverage', async () => { 12 | await lock.lock(); 13 | await lock.unlock(); 14 | await lock.once('unlocked'); // Even if tested elsewhere, call it directly 15 | }); 16 | 17 | it('starts unlocked', () => { 18 | expect(lock.locked).to.be.false; 19 | }); 20 | 21 | it('acquires a lock', async () => { 22 | await lock.lock(); 23 | expect(lock.locked).to.be.true; 24 | }); 25 | 26 | it('blocks second lock() call until unlocked', async () => { 27 | await lock.lock(); 28 | let secondLockAcquired = false; 29 | 30 | // Try acquiring the lock again, but in a separate async operation 31 | const secondLock = lock.lock().then(() => { 32 | secondLockAcquired = true; 33 | }); 34 | 35 | // Ensure second lock() is still waiting 36 | await new Promise(resolve => setTimeout(resolve, 100)); 37 | expect(secondLockAcquired).to.be.false; 38 | 39 | // Unlock and allow second lock to proceed 40 | await lock.unlock(); 41 | await secondLock; 42 | expect(secondLockAcquired).to.be.true; 43 | }); 44 | 45 | it('unlocks the lock', async () => { 46 | await lock.lock(); 47 | expect(lock.locked).to.be.true; 48 | 49 | await lock.unlock(); 50 | expect(lock.locked).to.be.false; 51 | }); 52 | 53 | it('resolves once() immediately if not locked', async () => { 54 | let resolved = false; 55 | 56 | await lock.once('unlocked').then(() => { 57 | resolved = true; 58 | }); 59 | 60 | expect(resolved).to.be.true; 61 | }); 62 | 63 | it('resolves once() only after unlocking', async () => { 64 | await lock.lock(); 65 | let resolved = false; 66 | 67 | const waitForUnlock = lock.once('unlocked').then(() => { 68 | resolved = true; 69 | }); 70 | 71 | // Ensure it's still waiting 72 | await new Promise(resolve => setTimeout(resolve, 100)); 73 | expect(resolved).to.be.false; 74 | 75 | // Unlock and verify resolution 76 | await lock.unlock(); 77 | await waitForUnlock; 78 | expect(resolved).to.be.true; 79 | }); 80 | 81 | it('handles multiple unlock() calls gracefully', async () => { 82 | await lock.lock(); 83 | await lock.unlock(); 84 | await lock.unlock(); // Should not throw or change state 85 | expect(lock.locked).to.be.false; 86 | }); 87 | 88 | it('throws an error for unexpected event types in once()', () => { 89 | expect(() => lock.once('invalid_event')).to.throw(TypeError); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/unit/memory/InMemoryMessageBus.test.ts: -------------------------------------------------------------------------------- 1 | import { IMessageBus, InMemoryMessageBus } from '../../../src'; 2 | import { expect, AssertionError } from 'chai'; 3 | import { spy } from 'sinon'; 4 | 5 | describe('InMemoryMessageBus', function () { 6 | 7 | let bus: IMessageBus; 8 | beforeEach(() => { 9 | bus = new InMemoryMessageBus(); 10 | }); 11 | 12 | describe('send(command)', function () { 13 | 14 | it('passes command to a command handler', done => { 15 | 16 | bus.on('doSomething', cmd => { 17 | try { 18 | expect(cmd).to.have.nested.property('payload.message', 'test'); 19 | done(); 20 | } 21 | catch (err) { 22 | done(err); 23 | } 24 | }); 25 | 26 | const result = bus.send({ 27 | type: 'doSomething', 28 | payload: { 29 | message: 'test' 30 | } 31 | }); 32 | 33 | expect(result).is.instanceOf(Promise); 34 | }); 35 | 36 | it('fails if no handlers found', async () => { 37 | try { 38 | await bus.send({ type: 'doSomething' }); 39 | throw new AssertionError('did not fail'); 40 | } 41 | catch (err) { 42 | if (err.message !== 'No \'doSomething\' subscribers found') 43 | throw err; 44 | } 45 | }); 46 | 47 | it('fails if more than one handler found', async () => { 48 | 49 | bus.on('doSomething', () => { }); 50 | bus.on('doSomething', () => { }); 51 | 52 | try { 53 | await bus.send({ type: 'doSomething' }); 54 | throw new AssertionError('did not fail'); 55 | } 56 | catch (err) { 57 | if (err.message !== 'More than one \'doSomething\' subscriber found') 58 | throw err; 59 | } 60 | }); 61 | }); 62 | 63 | describe('publish(event)', function () { 64 | 65 | it('exists', () => { 66 | expect(bus).to.respondTo('publish'); 67 | }); 68 | 69 | it('publishes a message to all handlers', async () => { 70 | 71 | const handler1 = spy(); 72 | const handler2 = spy(); 73 | 74 | bus.on('somethingHappened', handler1); 75 | bus.on('somethingHappened', handler2); 76 | 77 | await bus.publish({ type: 'somethingHappened' }); 78 | 79 | expect(handler1).to.have.property('calledOnce', true); 80 | expect(handler2).to.have.property('calledOnce', true); 81 | }); 82 | 83 | it('does not allow to setup multiple subscriptions for same event + queueName combination', () => { 84 | 85 | bus.queue?.('notifications').on('somethingHappened', () => { }); 86 | 87 | try { 88 | bus.queue?.('notifications').on('somethingHappened', () => { }); 89 | throw new AssertionError('did not fail'); 90 | } 91 | catch (err) { 92 | if (err.message !== '"somethingHappened" handler is already set up on the "notifications" queue') 93 | throw err; 94 | } 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /tests/unit/sqlite/SqliteEventLocker.test.ts: -------------------------------------------------------------------------------- 1 | import * as createDb from 'better-sqlite3'; 2 | import { SqliteEventLocker } from '../../../src/sqlite/SqliteEventLocker'; 3 | import { IEvent } from '../../../src/interfaces'; 4 | import { guid } from '../../../src/sqlite'; 5 | import { promisify } from 'util'; 6 | const delay = promisify(setTimeout); 7 | 8 | describe('SqliteEventLocker', () => { 9 | 10 | let db: import('better-sqlite3').Database; 11 | let locker: SqliteEventLocker; 12 | const testEvent: IEvent = { id: 'event1', type: 'TEST_EVENT', payload: {} }; 13 | 14 | beforeEach(() => { 15 | db = createDb(':memory:'); 16 | locker = new SqliteEventLocker({ 17 | viewModelSqliteDb: db, 18 | projectionName: 'test', 19 | schemaVersion: '1.0', 20 | eventLockTableName: 'test_event_lock', 21 | viewLockTableName: 'test_view_lock', 22 | eventLockTtl: 50 // ms 23 | }); 24 | }); 25 | 26 | afterEach(() => { 27 | db.close(); 28 | }); 29 | 30 | it('allows marking an event as projecting', async () => { 31 | const result = await locker.tryMarkAsProjecting(testEvent); 32 | expect(result).toBe(true); 33 | }); 34 | 35 | it('prevents re-locking an already locked event', async () => { 36 | await locker.tryMarkAsProjecting(testEvent); 37 | const result = await locker.tryMarkAsProjecting(testEvent); 38 | expect(result).toBe(false); 39 | }); 40 | 41 | it('marks an event as projected', async () => { 42 | await locker.tryMarkAsProjecting(testEvent); 43 | await locker.markAsProjected(testEvent); // Assuming markAsProjected might become async 44 | 45 | // DB query remains synchronous with better-sqlite3 46 | const row = db.prepare('SELECT processed_at FROM test_event_lock WHERE event_id = ?') 47 | .get(guid(testEvent.id)) as any; 48 | 49 | expect(row).toBeDefined(); 50 | expect(row.processed_at).not.toBeNull(); 51 | }); 52 | 53 | it('retrieves the last projected event', async () => { 54 | await locker.tryMarkAsProjecting(testEvent); 55 | await locker.markAsProjected(testEvent); 56 | 57 | const lastEvent = await locker.getLastEvent(); // Assuming getLastEvent might become async 58 | 59 | expect(lastEvent).toEqual(testEvent); 60 | }); 61 | 62 | it('returns undefined if no event has been projected', async () => { 63 | const lastEvent = await locker.getLastEvent(); 64 | expect(lastEvent).toBeUndefined(); 65 | }); 66 | 67 | it('fails to mark an event as projected if it was never locked', async () => { 68 | await expect(() => locker.markAsProjected(testEvent)) 69 | .rejects.toThrow(`Event ${testEvent.id} could not be marked as processed`); 70 | }); 71 | 72 | it('allows re-locking after TTL expires', async () => { 73 | await locker.tryMarkAsProjecting(testEvent); 74 | 75 | await delay(51); // Wait for TTL to expire 76 | 77 | const result = await locker.tryMarkAsProjecting(testEvent); 78 | expect(result).toBe(true); 79 | }); 80 | 81 | it('fails to update an event if its version is modified in DB', async () => { 82 | await locker.tryMarkAsProjecting(testEvent); 83 | 84 | db.prepare('UPDATE test_event_lock SET processed_at = ? WHERE event_id = ?') 85 | .run(Date.now(), guid(testEvent.id)); 86 | 87 | await expect(() => locker.markAsProjected(testEvent)) 88 | .rejects.toThrow(`Event ${testEvent.id} could not be marked as processed`); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /tests/unit/sqlite/SqliteObjectStorage.test.ts: -------------------------------------------------------------------------------- 1 | import * as createDb from 'better-sqlite3'; 2 | import { guid, SqliteObjectStorage } from '../../../src/sqlite'; 3 | 4 | describe('SqliteObjectStorage', function () { 5 | let db: import('better-sqlite3').Database; 6 | let storage: SqliteObjectStorage<{ name: string; value: number }>; 7 | 8 | beforeEach(async () => { 9 | db = createDb(':memory:'); 10 | storage = new SqliteObjectStorage<{ name: string; value: number }>({ 11 | viewModelSqliteDb: db, 12 | tableName: 'test_objects' 13 | }); 14 | await storage.assertConnection(); 15 | }); 16 | 17 | afterEach(() => { 18 | db.close(); 19 | }); 20 | 21 | it('stores and retrieves an object', async function () { 22 | 23 | const obj = { name: 'Test Object', value: 42 }; 24 | await storage.create('0001', obj); 25 | 26 | const retrieved = await storage.get('0001'); 27 | expect(retrieved).toEqual(obj); 28 | }); 29 | 30 | it('returns undefined for a non-existent object', async function () { 31 | const retrieved = await storage.get('nonexistent'); 32 | expect(retrieved).not.toBeDefined(); 33 | }); 34 | 35 | it('updates an existing object', async function () { 36 | 37 | await storage.create('0002', { name: 'Old Data', value: 5 }); 38 | 39 | await storage.update('0002', r => ({ ...r, value: 99 })); 40 | 41 | const updated = await storage.get('0002'); 42 | expect(updated).toEqual({ name: 'Old Data', value: 99 }); 43 | }); 44 | 45 | it('throws an error when updating a non-existent object', async function () { 46 | 47 | await expect(() => storage.update('nonexistent', r => ({ ...r, value: 99 }))) 48 | .rejects.toThrow("Record 'nonexistent' does not exist"); 49 | }); 50 | 51 | it('deletes an object', async function () { 52 | 53 | storage.create('0003', { name: 'To be deleted', value: 10 }); 54 | const deleted = storage.delete('0003'); 55 | expect(deleted).toBeTruthy(); 56 | 57 | const retrieved = storage.get('0003'); 58 | expect(retrieved).toBeDefined(); 59 | }); 60 | 61 | it('returns false when deleting a non-existent object', async function () { 62 | 63 | const deleted = await storage.delete('0000'); 64 | expect(deleted).toBeFalsy(); 65 | }); 66 | 67 | it('enforces updating or creating a new object', async function () { 68 | 69 | await storage.updateEnforcingNew('0004', () => ({ name: 'Created', value: 1 })); 70 | 71 | let retrieved = await storage.get('0004'); 72 | expect(retrieved).toEqual({ name: 'Created', value: 1 }); 73 | 74 | await storage.updateEnforcingNew('0004', r => ({ ...r!, value: 100 })); 75 | 76 | retrieved = await storage.get('0004'); 77 | expect(retrieved).toEqual({ name: 'Created', value: 100 }); 78 | }); 79 | 80 | it('fails if invalid JSON is recorded', async function () { 81 | db.prepare('INSERT INTO test_objects (id, data) VALUES (?, ?)') 82 | .run(guid('0005'), 'INVALID_JSON'); 83 | 84 | await expect(() => storage.get('0005')).rejects.toThrow(); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /tests/unit/sqlite/SqliteObjectView.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as createDb from 'better-sqlite3'; 3 | import { SqliteObjectView } from '../../../src/sqlite'; 4 | import { promisify } from 'util'; 5 | const delay = promisify(setTimeout); 6 | 7 | describe('SqliteObjectView', function () { 8 | let viewModelSqliteDb: import('better-sqlite3').Database; 9 | let sqliteObjectView: SqliteObjectView; 10 | 11 | beforeEach(() => { 12 | viewModelSqliteDb = createDb(':memory:'); 13 | sqliteObjectView = new SqliteObjectView({ 14 | viewModelSqliteDb, 15 | projectionName: 'test', 16 | tableNamePrefix: 'tbl_test', 17 | schemaVersion: '1' 18 | }); 19 | }); 20 | 21 | describe('get', () => { 22 | 23 | it('throws an error if id is not a non-empty string', async () => { 24 | 25 | let error; 26 | try { 27 | error = null; 28 | await sqliteObjectView.get(''); 29 | } 30 | catch (err) { 31 | error = err; 32 | } 33 | expect(error).to.exist; 34 | expect(error).to.have.property('message', 'id argument must be a non-empty String'); 35 | 36 | }); 37 | 38 | it('waits for readiness before returning data', async () => { 39 | 40 | await sqliteObjectView.lock(); 41 | 42 | expect(sqliteObjectView).to.have.property('ready', false); 43 | 44 | let resultObtained = false; 45 | const resultPromise = sqliteObjectView.get('test').then(() => { 46 | resultObtained = true; 47 | }); 48 | 49 | await delay(5); 50 | expect(resultObtained).to.eq(false); 51 | 52 | sqliteObjectView.unlock(); 53 | 54 | 55 | await resultPromise; 56 | expect(resultObtained).to.eq(true); 57 | }); 58 | 59 | it('returns stored record if ready', async () => { 60 | 61 | sqliteObjectView.create('1', { foo: 'bar' }); 62 | 63 | const r = await sqliteObjectView.get('1'); 64 | expect(r).to.eql({ foo: 'bar' }); 65 | }); 66 | 67 | it('returns undefined if record does not exist', async () => { 68 | 69 | const r = await sqliteObjectView.get('1'); 70 | expect(r).to.eql(undefined); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/unit/sqlite/SqliteViewLocker.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as createDb from 'better-sqlite3'; 3 | import { SqliteViewLocker } from '../../../src/sqlite'; 4 | 5 | describe('SqliteViewLocker', function () { 6 | 7 | const viewLockTtl = 1_000; // 1sec 8 | let viewModelSqliteDb: import('better-sqlite3').Database; 9 | let firstLock: SqliteViewLocker; 10 | let secondLock: SqliteViewLocker; 11 | 12 | beforeEach(() => { 13 | viewModelSqliteDb = createDb(':memory:'); 14 | firstLock = new SqliteViewLocker({ 15 | viewModelSqliteDb, 16 | projectionName: 'test', 17 | schemaVersion: '1.0', 18 | viewLockTtl 19 | }); 20 | secondLock = new SqliteViewLocker({ 21 | viewModelSqliteDb, 22 | projectionName: 'test', 23 | schemaVersion: '1.0', 24 | viewLockTtl 25 | }); 26 | 27 | jest.useFakeTimers(); 28 | }); 29 | 30 | afterEach(() => { 31 | viewModelSqliteDb.close(); 32 | }); 33 | 34 | it('locks a view successfully', async function () { 35 | const result = await firstLock.lock(); 36 | expect(result).to.be.true; 37 | }); 38 | 39 | it('unlocks a view successfully', async function () { 40 | await firstLock.lock(); 41 | firstLock.unlock(); 42 | 43 | const lockResult = await secondLock.lock(); 44 | expect(lockResult).to.be.true; 45 | }); 46 | 47 | it('sets ready flag to `false` when locked', async () => { 48 | 49 | await firstLock.lock(); 50 | expect(firstLock).to.have.property('ready', false); 51 | }); 52 | 53 | it('sets ready flag to `true` when unlocked', async () => { 54 | 55 | await firstLock.lock(); 56 | await firstLock.unlock(); 57 | expect(firstLock).to.have.property('ready', true); 58 | }); 59 | 60 | it('waits for the lock to be released if already locked', async function () { 61 | await firstLock.lock(); 62 | 63 | let secondLockAcquired = false; 64 | 65 | // Try locking, but it should wait 66 | const secondLockAcquiring = secondLock.lock().then(() => { 67 | secondLockAcquired = true; 68 | }); 69 | 70 | // Wait briefly to check if it resolves too soon 71 | await jest.advanceTimersByTimeAsync(viewLockTtl); 72 | expect(secondLockAcquired).to.be.false; 73 | 74 | firstLock.unlock(); 75 | 76 | await secondLockAcquiring; 77 | expect(secondLockAcquired).to.be.true; 78 | }); 79 | 80 | 81 | it('prolongs the lock while active', async function () { 82 | await firstLock.lock(); 83 | 84 | const initial = viewModelSqliteDb.prepare('SELECT * FROM tbl_view_lock WHERE projection_name = ? AND schema_version = ?') 85 | .get('test', '1.0') as any; 86 | 87 | expect(initial).to.have.property('locked_till').that.is.gt(Date.now()); 88 | 89 | await jest.advanceTimersByTimeAsync(viewLockTtl); 90 | 91 | const updated = viewModelSqliteDb.prepare('SELECT * FROM tbl_view_lock WHERE projection_name = ? AND schema_version = ?') 92 | .get('test', '1.0') as any; 93 | 94 | expect(updated).to.have.property('locked_till').that.is.gt(initial.locked_till); 95 | }); 96 | 97 | it('should release the lock upon unlock()', async function () { 98 | await firstLock.lock(); 99 | await firstLock.unlock(); 100 | 101 | const row = viewModelSqliteDb.prepare('SELECT * FROM tbl_view_lock WHERE projection_name = ? AND schema_version = ?') 102 | .get('test', '1.0') as any; 103 | 104 | expect(row.locked_till).to.be.null; 105 | }); 106 | 107 | it('should fail to prolong the lock if already released', async function () { 108 | await firstLock.lock(); 109 | await firstLock.unlock(); 110 | 111 | let error; 112 | try { 113 | await (firstLock as any).prolongLock(); 114 | } 115 | catch (err) { 116 | error = err; 117 | } 118 | 119 | expect(error).to.exist; 120 | expect(error).to.have.property('message', '"test" lock could not be prolonged'); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "removeComments": false, 6 | "sourceMap": true, 7 | "alwaysStrict": false, 8 | "outDir": "./dist", 9 | "target": "ES2022", 10 | "declaration": true, 11 | "declarationDir": "./types", 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "strict": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "strictBindCallApply": true, 18 | "strictPropertyInitialization": true, 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ], 23 | "exclude": [ 24 | "node_modules", 25 | "**/*.spec.ts" 26 | ] 27 | } --------------------------------------------------------------------------------