diff --git a/.gitignore b/.gitignore index 041d411..98badc4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ data/ mongod *.sublime-project *.sublime-workspace +.obsidian/ # OS-specific # =========== diff --git a/README.md b/README.md index 7750413..e8b19eb 100644 --- a/README.md +++ b/README.md @@ -9,179 +9,441 @@ node-cqrs ## Overview -The package provides building blocks for making a CQRS-ES application. It was inspired by Lokad.CQRS, but not tied to a specific storage implementation or infrastructure. It favors ES6 classes and dependency injection, so any components can be modified or replaced with your own implementations without hacks to the package codebase. +This package provides building blocks for CQRS/ES applications. It was inspired by Lokad.CQRS, +but it isn’t tied to any specific storage implementation or infrastructure. +It favors ES6/TS classes and dependency injection, so you can modify or replace components with your own implementations without patching the library. -[Documentation at node-cqrs.org](https://www.node-cqrs.org) +CQRS/ES itself can be implemented with surprisingly little code in a single process. +For a minimal, framework-free example, see [examples/user-domain-own-implementation/index.ts](examples/user-domain-own-implementation/index.ts). +This library exists to cover the "boring but hard" parts that are usually missing from a plain implementation, including: -Your app is expected to operate with loosely typed commands and events that match the following interface: +- async command/event processing and safer wiring/subscriptions +- persistent views and catch-up on restart (checkpointing, view readiness/locking) +- aggregate snapshots +- extensible event dispatching pipelines (encoding, persistence, distribution, etc.) + +At a high level, the command/event flow looks like: + +![Overview](docs/images/node-cqrs-flow.png) + + +Commands and events are loosely typed objects that implement the [IMessage](src/interfaces/IMessage.ts) interface: ```ts -declare interface IMessage { - type: string, +interface IMessage { + type: string; - aggregateId?: string|number, - aggregateVersion?: number, + aggregateId?: string | number; + aggregateVersion?: number; - sagaId?: string|number, - sagaVersion?: number, + sagaId?: string | number; + sagaVersion?: number; - payload?: any, - context?: any + payload?: TPayload; + context?: any; } ``` -Domain business logic should be placed in Aggregate, Saga and Projection classes: +Domain business logic typically lives in aggregates, sagas, and projections: -- [Aggregates](entities/Aggregate/README.MD) handle commands and emit events -- [Sagas](entities/Saga/README.MD) handle events and enqueue commands -- [Projections](entities/Projection/README.md) listen to events and update views +- **[Aggregates](#aggregates-write-model)** handle commands and emit events +- **[Projections](#projections-and-views-read-model)** listen to events and update views +- **Sagas** handle events and enqueue commands +Message delivery is handled by the following components (in order of appearance): -Message delivery is being handled by the following services (in order of appearance): +- **[Command Bus](src/CommandBus.ts)** delivers commands to command handlers +- **[Aggregate Command Handler](src/AggregateCommandHandler.ts)** restores aggregate state and executes the command +- **[Event Store](src/EventStore.ts)** runs the event dispatching process: + - persists events (via the configured dispatch pipeline) + - then delivers them to event handlers (sagas, projections, custom services) +- **[Saga Event Handler](src/SagaEventHandler.ts)** restores saga state and applies events -- **Command Bus** delivers commands to command handlers -- [Aggregate Command Handler](middleware/AggregateCommandHandler.md) restores an aggregate state, executes a command -- **Event Store** persists events and deliver them to event handlers (saga event handlers, projections or any other custom services) -- **Saga Event Handler** restores saga state and applies event +**Tip**: the codebase is intentionally small and readable - `src/`, `tests/`, `examples/` are a good reference if you want to explore behavior in more detail. +### Examples -From a high level, this is how the command/event flow looks like: +- [examples/user-domain](examples/user-domain) basic CJS implementation +- [examples/user-domain-ts](examples/user-domain-ts) similar implementation in TS +- [examples/worker-projection](examples/worker-projection) projection in a worker thread +- [examples/user-domain-own-implementation](examples/user-domain-own-implementation/index.ts) minimal, framework-free CQRS/ES example in 1 file -![Overview](docs/images/node-cqrs-components.png) +## Installation +```bash +npm i node-cqrs +``` -## Getting Started +Tested under +- Node 18 +- Node 20 +- Node 22 + +### Peer Dependencies + +If you want to use SQLite, RabbitMQ, or worker threads, the following peer dependencies may be needed: + +- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) +- [amqplib](https://github.com/amqp-node/amqplib) +- [comlink](https://github.com/GoogleChromeLabs/comlink) + +## Commands + +* sent to CommandBus manually +* being handled by [Aggregates](#aggregates-write-model) +* may be enqueued by Sagas + +Command example: + +```json +{ + "type": "signupUser", + "aggregateId": null, + "payload": { + "profile": { + "name": "John Doe", + "email": "john@example.com" + }, + "password": "test" + }, + "context": { + "ip": "127.0.0.1", + "ts": 1503509747154 + } +} +``` -You can find sample code of a User domain in the **/examples** folder. +## Events + +* produced by [Aggregates](#aggregates-write-model) +* persisted to EventStore +* may be handled by [Projections](#projections-and-views-read-model), Sagas and Event Receptors + +Event example: + +```json +{ + "type": "userSignedUp", + "aggregateId": 1, + "aggregateVersion": 0, + "payload": { + "profile": { + "name": "John Doe", + "email": "john@example.com" + }, + "passwordHash": "098f6bcd4621d373cade4e832627b4f6" + }, + "context": { + "ip": "127.0.0.1", + "ts": 1503509747154 + } +} +``` +## ContainerBuilder -### Your App → Command → Aggregate +The "happy path" is to use `ContainerBuilder` to wire buses, the event store, and your aggregates/projections/sagas. -Describe an aggregate that handles a command: +All named component instances are exposed on container through getters and get created upon accessing a getter. +Default `EventStore` and `CommandBus` components are registered upon container instance creation: -```js -const { AbstractAggregate } = require('node-cqrs'); +```ts +import { ContainerBuilder, InMemoryEventStorage, type IContainer } from 'node-cqrs'; -class UserAggregate extends AbstractAggregate { - static get handles() { - return ['createUser']; - } - - createUser(commandPayload) { - // ... - } +interface MyDiContainer extends IContainer { + /* Any custom services or projection view for typing purposes */ } + +const builder = new ContainerBuilder(); + +// In-memory implementations for local dev/tests +builder.register(InMemoryEventStorage) + .as('eventStorageReader') + .as('eventStorageWriter'); + +const container = builder.container(); + +container.eventStore; // instance of EventStore +container.commandBus; // instance of CommandBus ``` -Then register aggregate in the [DI container](middleware/DIContainer.md). -All the wiring can be done manually, without a DI container (you can find it in samples), but with container it’s just easier: +Other components can be registered either as classes or as factories: -```js -const { ContainerBuilder, InMemoryEventStorage } = require('node-cqrs'); +```ts +// class with automatic dependency injection +builder.register(SomeService).as('someService'); -const builder = new ContainerBuilder(); -builder.register(InMemoryEventStorage).as('storage'); -builder.registerAggregate(UserAggregate); +// OR factory with more precise control +builder.register(container => new SomeService(container.commandBus)).as('someService'); +``` + +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: + +```js +builder.register(SomeEventObserver); +// at this point the registered observer does not exist const container = builder.container(); +// now it exists and got all its constructor dependencies ``` -Then send a command: +DI container has a set of methods for CQRS components registration: -```js -const userAggregateId = undefined; -const payload = { - username: 'john', - password: 'test' -}; +* `registerAggregate(AggregateType)` - registers aggregateCommandHandler, subscribes it to commandBus and wires Aggregate dependencies +* `registerSaga(SagaType)` - registers sagaEventHandler, subscribes it to eventStore and wires Saga dependencies +* `registerProjection(ProjectionType, exposedViewName)` - registers projection, subscribes it to eventStore and exposes associated projection view on the container +* `registerCommandHandler(typeOrFactory)` - registers command handler and subscribes it to commandBus +* `registerEventReceptor(typeOrFactory)` - registers event receptor and subscribes it to eventStore + +## Aggregates (write model) + +### IAggregate + +Aggregates handle commands and emit events. The minimal aggregate contract is [IAggregate](src/interfaces/IAggregate.ts): -container.commandBus.send('createUser', userAggregateId, { payload }); +```ts +export interface IAggregate { + + /** + * Applies a single event to update the aggregate's internal state. + * + * This method is used primarily when rehydrating the aggregate + * from the persisted sequence of events + * + * @param event - The event to be applied + */ + mutate(event: IEvent): void; + + /** + * Processes a command by executing the aggregate's business logic, + * resulting in new events that capture the state changes. + * It serves as the primary entry point for invoking aggregate behavior + * + * @param command - The command to be processed + * @returns A set of events produced by the command + */ + handle(command: ICommand): IEventSet | Promise; +} ``` -Behind the scene, an AggregateCommandHandler will catch the command, -try to load an aggregate event stream and project it to aggregate state, -then it will pass the command payload to the `createUser` handler we’ve defined earlier. +### AbstractAggregate -The `createUser` implementation can look like this: +[AbstractAggregate](src/AbstractAggregate.ts) is optional but recommended base class that provides the CQRS/ES wiring and covers common edge cases: state restoring, command routing, validation, snapshots. -```js -createUser(commandPayload) { - const { username, password } = commandPayload; +Without an internal state it can be as simple as this: - this.emit('userCreated', { - username, - passwordHash: md5Hash(password) - }); -} +```ts +import { AbstractAggregate } from 'node-cqrs'; + +type CreateUserCommandPayload = { username: string }; +type UserCreatedEventPayload = { username: string }; + +class UserAggregate extends AbstractAggregate { + createUser(payload: CreateUserCommandPayload) { + this.emit('userCreated', { username: payload.username }); + } +} ``` -Once the above method is executed, the emitted userCreated event will be persisted and delivered to event handlers (sagas, projections or any other custom event receptors). +By default, `node-cqrs` infers handled message types from public method names (so `createUser()` handles the `createUser` command). +### Aggregate State -### Aggregate → Event → Projection → View +Typically, it's simplest to keep aggregate state separate from command handlers and derive it by projecting the aggregate's emitted events. -Now it’s time to work on a read model. We’ll need a projection that will handle our events. Projection must implement 2 methods: `subscribe(eventStore)` and `project(event)` . -To make it easier, you can extend an `AbstractProjection`: +User aggregate state implementation could look like this: ```js -const { AbstractProjection } = require('node-cqrs'); +class UserAggregateState { + passwordHash: string; -class UsersProjection extends AbstractProjection { - static get handles() { - return ['userCreated']; - } - - userCreated(event) { - // ... - } + passwordChanged(event: IEvent) { + this.passwordHash = event.payload.passwordHash; + } } ``` -By default, projection uses async `InMemoryView` for inner view, but we’ll use `Map` to make it more familiar: +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. + +Aggregate state **should NOT throw any exceptions**, all type and business logic validations should be performed in the Aggregate during the command processing. + +Pass the state instance as a property to the AbstractAggregate constructor, or define it as a read-only stateful property in your aggregate class. State will be restored from past events upon new command delivery and will be ready for the business logic validations: ```js -class UsersProjection extends AbstractProjection { - get view() { - return this._view || (this._view = new Map()); - } +class UserAggregate extends AbstractAggregate { + + protected readonly state = new UserAggregateState(); + + changePassword(payload: ChangePasswordCommandPayload) { + if (md5(payload.oldPassword) !== this.state.passwordHash) + throw new Error('Invalid password'); - // ... + this.emit('passwordChanged', { + passwordHash: md5(payload.newPassword) + }); + } } ``` -With `Map` view, our event handler can look this way: +### External Dependencies + +If you are going to use a built-in [DI container](#containerbuilder), your aggregate constructor can accept instances of the services it depends on, they will be injected automatically upon each aggregate instance creation: ```js -class UsersProjection extends AbstractProjection { - // ... +import { ContainerBuilder, AbstractAggregate } from 'node-cqrs'; + +class UserAggregate extends AbstractAggregate { - userCreated(event) { - this.view.set(event.aggregateId, { - username: event.payload.username - }); + constructor({ id, authService }) { + super({ id }); + + // save injected service for use in command handlers + this._authService = authService; + } + + async signupUser(payload) { + // use the injected service + await this._authService.registerUser(payload); } } + +const builder = new ContainerBuilder(); +builder.register(AuthService).as('authService'); +builder.registerAggregate(UserAggregate); ``` -Once the projection is ready, it can be registered in the DI container: +## Projections and Views (read model) -```js -builder.registerProjection(UsersProjection, 'users'); +Projection is an Observer, that listens to events and updates an associated View. + +### IProjection (minimal contract) + +The minimal projection contract is [IProjection](src/interfaces/IProjection.ts): + +```ts +interface IProjection extends IObserver { + readonly view: TView; + + /** Subscribe to new events */ + subscribe(eventStore: IObservable): Promise | void; + + /** Restore view state from not-yet-projected events */ + restore(eventStore: IEventStorageReader): Promise | void; + + /** Project new event */ + project(event: IEvent): Promise | void; +} ``` -And accessed from anywhere in your app: +### AbstractProjection -```js -container.users -// Map { 1 => { username: 'John' } } +[AbstractProjection](src/AbstractProjection.ts) is the recommended base class for implementing projections with handler methods and built-in subscribe/restore behavior: + +```ts +import { AbstractProjection, type IEvent } from 'node-cqrs'; + +type UsersView = Map; + +class UsersProjection extends AbstractProjection { + + constructor() { + super(); + this.view = new Map(); + } + + userCreated(event: IEvent) { + this.view.set(event.aggregateId as string, { + username: event.payload!.username + }); + } +} +``` + +Same rule applies as for AbstractAggregate: `userCreated()` handles the `userCreated` event unless you override `handles`. + +### View restoring on start + +For persistent views and safe restarts, a default projection `view` can implement [IViewLocker](src/interfaces/IViewLocker.ts) and [IEventLocker](src/interfaces/IEventLocker.ts) to support catch-up and last-processed checkpoints. + +### Accessing views + +When projection is being registered in the [DI container](#containerbuilder), the default `view` can be automatically exposed with a given name: + +```ts +import { ContainerBuilder, IContainer } from 'node-cqrs'; + +interface MyDiContainer extends IContainer { + usersView: UsersView; +} + +const builder = new ContainerBuilder(); +builder.registerProjection(UsersProjection, 'usersView'); + +const container = builder.container(); +const userRecord = container.usersView.get('1'); ``` -## Contribution +In case projection manages multiple views, those views can be exposed to container instance manually: + +```ts +builder.registerProjection(UsersProjection).as('usersProjection'); +builder.register(c => c.usersProjection.users).as('usersView'); +builder.register(c => c.usersProjection.connections).as('connectionsView'); +``` + +## Infrastructure modules + +### In-memory + +In-memory implementations intended for tests and local development. + +* [InMemoryEventStorage](src/in-memory/InMemoryEventStorage.ts) +* [InMemoryMessageBus](src/in-memory/InMemoryMessageBus.ts) +* [InMemoryView](src/in-memory/InMemoryView.ts) + +### SQLite + +Persistent views + catch-up/checkpoint tooling. + +```ts +import { AbstractSqliteView, SqliteObjectView } from 'node-cqrs/sqlite'; +``` + +- [AbstractSqliteView](src/sqlite/AbstractSqliteView.ts) - Base class for SQLite-backed projection views with restore locking and last-processed-event tracking +- [SqliteObjectView]() - SQLite-backed object view with restore locking and last-processed-event tracking + +### RabbitMQ + +Cross-process event distribution. + +```ts +import { RabbitMqEventBus, RabbitMqGateway } from 'node-cqrs/rabbitmq'; +``` + +- [RabbitMqGateway](src/rabbitmq/RabbitMqGateway.ts) - implements the IObservable interface using RabbitMQ +- [RabbitMqEventBus](src/rabbitmq/RabbitMqEventBus.ts) - RabbitMQ-backed `IEventBus` with named queues support + +### Workers + +Run projections and corresponding views in `worker_threads` to isolate CPU-heavy work and keep the main thread responsive. + +```ts +import { AbstractWorkerProjection } from 'node-cqrs/workers'; +``` + +- [AbstractWorkerProjection](src/workers/AbstractWorkerProjection.ts) - Projection base class that can run projection handlers and the associated view in a worker thread. + +## Testing and Contribution + +```bash +npm test +npm run lint +``` -* [editorconfig](http://editorconfig.org) -* [eslint](http://eslint.org) -* `npm test -- --watch` +- [editorconfig](http://editorconfig.org) +- [eslint](http://eslint.org) ## License diff --git a/SUMMARY.md b/SUMMARY.md deleted file mode 120000 index 0b731df..0000000 --- a/SUMMARY.md +++ /dev/null @@ -1 +0,0 @@ -./docs/README.md \ No newline at end of file diff --git a/book.json b/book.json deleted file mode 100644 index 0622b56..0000000 --- a/book.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "title": "node-cqrs", - "gitbook": "3.2.2", - "plugins": [ - "edit-link", - "github", - "anchorjs" - ], - "pluginsConfig": { - "edit-link": { - "base": "https://github.com/snatalenko/node-cqrs/tree/master", - "label": "Edit This Page" - }, - "github": { - "url": "https://github.com/snatalenko/node-cqrs/" - }, - "theme-default": { - "styles": { - "website": "build/gitbook.css" - } - } - } -} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f1bcfed..0000000 --- a/docs/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Table of Contents - -* [ReadMe](/README.md) -* [Entities](/docs/entities/README.md) - * [Messages](/docs/entities/Messages/README.md) - * [Aggregate](/docs/entities/Aggregate/README.md) - * [State](/docs/entities/Aggregate/State.md) - * [Command Handlers](/docs/entities/Aggregate/CommandHandlers.md) - * [External Dependencies](/docs/entities/Aggregate/Dependencies.md) - * [Snapshots](/docs/entities/Aggregate/Snapshots.md) - * [Projection](/docs/entities/Projection/README.md) - * [InMemoryView](/docs/entities/Projection/InMemoryView.md) - * [Saga](/docs/entities/Saga/README.md) - * [Event Receptor](/docs/entities/EventReceptor/README.md) -* [Middleware](/docs/middleware/README.md) - * [DI Container](/docs/middleware/DIContainer.md) - * [AggregateCommandHandler](/docs/middleware/AggregateCommandHandler.md) -* [Infrastructure](/docs/infrastructure/README.md) diff --git a/docs/entities/Aggregate/CommandHandlers.md b/docs/entities/Aggregate/CommandHandlers.md deleted file mode 100644 index d10db40..0000000 --- a/docs/entities/Aggregate/CommandHandlers.md +++ /dev/null @@ -1,76 +0,0 @@ -# Aggregate Command Handlers - -At minimum Aggregates are expected to implement the following interface: - -```ts -declare interface IAggregate { - /** Main entry point for aggregate commands */ - handle(command: ICommand): void | Promise; - - /** List of events emitted by Aggregate as a result of handling command(s) */ - readonly changes: IEventStream; -} -``` - -In a such aggregate all commands will be passed to the `handle` method and emitted events will be read from the `changes` property. - -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. - -Most of this boilerplate code is already implemented in the AbstractAggregate class: - -## AbstractAggregate - -`AbstractAggregate` class implements `IAggregate` interface and separates command handling and state mutations (see [Aggregate State](./State.md)). - -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. - - -```js -const { AbstractAggregate } = require('node-cqrs'); - -class UserAggregate extends AbstractAggregate { - - get state() { - return this._state || (this._state = new UserAggregateState()); - } - - /** - * "signupUser" command handler. - * Being invoked by the AggregateCommandHandler service. - * Should emit events. Must not modify the state directly. - * - * @param {any} payload - command payload - * @param {any} context - command context - */ - signupUser(payload, context) { - if (this.version !== 0) - throw new Error('command executed on existing aggregate'); - - const { profile, password } = payload; - - // emitted event will mutate the state and will be committed to the EventStore - this.emit('userSignedUp', { - profile, - passwordHash: hash(password) - }); - } - - /** - * "changePassword" command handler - */ - changePassword(payload, context) { - if (this.version === 0) - throw new Error('command executed on non-existing aggregate'); - - const { oldPassword, newPassword } = payload; - - // all business logic validations should happen in the command handlers - if (!compareHash(this.state.passwordHash, oldPassword)) - throw new Error('old password does not match'); - - this.emit('userPasswordChanged', { - passwordHash: hash(newPassword) - }); - } -} -``` diff --git a/docs/entities/Aggregate/Dependencies.md b/docs/entities/Aggregate/Dependencies.md deleted file mode 100644 index f66adeb..0000000 --- a/docs/entities/Aggregate/Dependencies.md +++ /dev/null @@ -1,20 +0,0 @@ -# External Dependencies - -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: - -```js -class UserAggregate extends AbstractAggregate { - - constructor({ id, events, authService }) { - super({ id, events, state: new UserAggregateState() }); - - // save injected service for use in command handlers - this._authService = authService; - } - - async signupUser(payload, context) { - // use the injected service - await this._authService.registerUser(payload); - } -} -``` diff --git a/docs/entities/Aggregate/README.md b/docs/entities/Aggregate/README.md deleted file mode 100644 index 80341cf..0000000 --- a/docs/entities/Aggregate/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Aggregate - -At minimum Aggregates are expected to implement the following interface: - -```ts -declare interface IAggregate { - /** Main entry point for aggregate commands */ - handle(command: ICommand): void | Promise; - - /** List of events emitted by Aggregate as a result of handling command(s) */ - readonly changes: IEventStream; -} -``` - -In a such aggregate all commands will be passed to the `handle` method and emitted events will be read from the `changes` property. - -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. - -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). - -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) diff --git a/docs/entities/Aggregate/Snapshots.md b/docs/entities/Aggregate/Snapshots.md deleted file mode 100644 index 75a545f..0000000 --- a/docs/entities/Aggregate/Snapshots.md +++ /dev/null @@ -1,35 +0,0 @@ -# Aggregate Snapshots - -Snapshotting functionality involves the following methods: - -* `get snapshotVersion(): number` - `version` of the latest snapshot -* `get shouldTakeSnapshot(): boolean` - defines whether a snapshot should be taken -* `takeSnapshot(): void` - adds state snapshot to the `changes` collection, being invoked automatically by the [AggregateCommandHandler](#aggregatecommandhandler) -* `makeSnapshot(): object` - protected method used to snapshot an aggregate state -* `restoreSnapshot(snapshotEvent): void` - protected method used to restore state from a snapshot - -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. - -In the following sample a state snapshot will be taken every 50 events and added to the aggregate `changes` queue: - -```js -class UserAggregate extends AbstractAggregate { - get shouldTakeSnapshot() { - return this.version - this.snapshotVersion > 50; - } -} -``` - -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: - -```js -class UserAggregate extends AbstractAggregate { - makeSnapshot() { - // return a field, stored outside of this.state - return { trickyField: this.trickyField }; - } - restoreSnapshot({ payload }) { - this.trickyField = payload.trickyField; - } -} -``` diff --git a/docs/entities/Aggregate/State.md b/docs/entities/Aggregate/State.md deleted file mode 100644 index 88da3b4..0000000 --- a/docs/entities/Aggregate/State.md +++ /dev/null @@ -1,50 +0,0 @@ -# Aggregate State - -[EventStore]: ../../middleware/README.md -[AbstractAggregate.js]: https://github.com/snatalenko/node-cqrs/blob/master/src/AbstractAggregate.js - - -Aggregate state is an internal aggregate property, which is used for domain logic validations in [Aggregate Command Handlers](CommandHandlers.md). - -## Implementation - -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. - -User aggregate state implementation could look like this: - -```js -class UserAggregateState { - userSignedUp({ payload }) { - this.profile = payload.profile; - this.passwordHash = payload.passwordHash; - } - - userPasswordChanged({ payload }) { - this.passwordHash = payload.passwordHash; - } -} -``` - -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. - -Aggregate state **should NOT throw any exceptions**, all type and business logic validations should be performed in the [aggregate command handlers](CommandHandlers.md). - -## Using in Aggregate - -`AbstractAggregate` restores aggregate state automatically in [its constructor][AbstractAggregate.js] from events, retrieved from the [EventStore][EventStore]. - -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: - -```js -class UserAggregate extends AbstractAggregate { - // option 1 - get state() { - return this._state || (this._state = new UserAggregateState()); - } - - constructor(props) { - // option 2 - super({ state: new UserAggregateState(), ...props }); - } -} -``` diff --git a/docs/entities/EventReceptor/README.md b/docs/entities/EventReceptor/README.md deleted file mode 100644 index d502fb0..0000000 --- a/docs/entities/EventReceptor/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Event Receptor - -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). - -```js -const { subscribe } = require('node-cqrs'); - -class MyReceptor { - static get handles() { - return [ - 'userSignedUp' - ]; - } - - subscribe(observable) { - subscribe(observable, this); - } - - userSignedUp({ payload }) { - // send welcome email to payload.email - } -} -``` - -If you are creating/registering a receptor manually: - -```js -const receptor = new MyReceptor(); -receptor.subscribe(eventStore); -``` - - -To register a receptor in the [DI Container](../../middleware/DIContainer.md): - -```js -container.registerEventReceptor(MyReceptor); -container.createUnexposedInstances(); -``` diff --git a/docs/entities/Messages/README.md b/docs/entities/Messages/README.md deleted file mode 100644 index 9b698ae..0000000 --- a/docs/entities/Messages/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Messages - -[Middleware]: ../../middleware/README.md "Middleware" -[Aggregate]: ../Aggregate/README.md "Aggregate" -[Saga]: ../Saga/README.md -[Projection]: ../Projection/README.md -[Receptor]: ../EventReceptor/README.md - -All messages flowing thru the system are loosely typed objects with a minimal set of required fields: - -* `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"). -* `payload: any` - command or event data -* `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 - -Other fields are used for message routing and their usage depends on the flow: - -* `aggregateId: string|number|undefined` - unique aggregate identifier -* `aggregateVersion: number` -* `sagaId: string|number|undefined` -* `sagaVersion: number` - - -## Commands - -* sent to [CommandBus][Middleware] manually -* being handled by [Aggregates][Aggregate] -* may be enqueued by [Sagas][Saga] - - -Command example: - -```json -{ - "type": "signupUser", - "aggregateId": null, - "payload": { - "profile": { - "name": "John Doe", - "email": "john@example.com" - }, - "password": "test" - }, - "context": { - "ip": "127.0.0.1", - "ts": 1503509747154 - } -} -``` - - -## Events - -* produced by [Aggregates][Aggregate] -* persisted to [EventStore][Middleware] -* may be handled by [Projections][Projection], [Sagas][Saga] and [Event Receptors][Receptor] - -Event example: - -```json -{ - "type": "userSignedUp", - "aggregateId": 1, - "aggregateVersion": 0, - "payload": { - "profile": { - "name": "John Doe", - "email": "john@example.com" - }, - "passwordHash": "098f6bcd4621d373cade4e832627b4f6" - }, - "context": { - "ip": "127.0.0.1", - "ts": 1503509747154 - } -} -``` diff --git a/docs/entities/Projection/InMemoryView.md b/docs/entities/Projection/InMemoryView.md deleted file mode 100644 index 86707a6..0000000 --- a/docs/entities/Projection/InMemoryView.md +++ /dev/null @@ -1,48 +0,0 @@ -InMemoryView -============ - -By default, AbstractProjection instances get created with an instance of InMemoryView associated. - -The associted view can be accessed thru the `view` property and provides a set of methods for view manipulation: - -* `get ready(): boolean` - indicates if the view state is restored -* `once('ready'): Promise` - allows to await until the view is restored -* operations with data - * `get(key: string, options?: object): Promise` - * `create(key: string, record: any)` - * `update(key: string, callback: any => any)` - * `updateEnforcingNew(key: string, callback: any => any)` - * `delete(key: string)` - - -In case you are using the [DI container](../middleware/DIContainer.md), projection view will be exposed on the container automatically: - -```js -container.registerProjection(MyProjection, 'myView'); - -// @type {InMemoryView} -const view = container.myView; - -// @type {{ profile: object, passwordHash: string }} -const aggregateRecord = await view.get('my-aggregate-id'); -``` - -Since the view keeps state in memory, upon creation it needs to be restored from the EventStore. -This is [handled by the AbstractProjection](./README.md) automatically. - -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: - -```js -// wait until the view state is restored -await view.once('ready'); - -// query data -const record = await view.get('my-key'); -``` - -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: - -```js -// accessing view record from a projection event handler -const record = await this.view.get('my-key', { nowait: true }); -``` diff --git a/docs/entities/Projection/README.md b/docs/entities/Projection/README.md deleted file mode 100644 index 0a94cf6..0000000 --- a/docs/entities/Projection/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Projection - -Projection is an Observer, that listens to events and updates an associated View. - -## Projection View Restoring - -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*. - -## Projection Event Handlers - -All projection event types must be listed in the static `handles` getter and event type must have a handler defined: - -```js - -const { AbstractProjection } = require('node-cqrs'); - -class MyProjection extends AbstractProjection { - static get handles() { - return [ - 'userSignedUp', - 'userPasswordChanged' - ]; - } - - async userSignedUp({ aggregateId, payload }) { - const { profile, passwordHash } = payload; - - await this.view.create(aggregateId, { - profile, - passwordHash - }); - } - - async userPasswordChanged({ aggregateId, payload }) { - const { passwordHash } = payload; - await this.view.update(aggregateId, view => { - view.passwordHash = passwordHash; - }); - } -} - -``` - -## Accessing Projection View - -Associated view is exposed on a projection instance as `view` property. - -By default, AbstractProjection instances get created with an instance of [InMemoryView](./InMemoryView.md) associated. diff --git a/docs/entities/README.md b/docs/entities/README.md deleted file mode 100644 index 4fc89d3..0000000 --- a/docs/entities/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Entities - -* [Messages](Messages/README.md) -* [Aggregate](Aggregate/README.md) -* [Projection](Projection/README.md) -* [Saga](Saga/README.md) -* [Event Receptor](EventReceptor/README.md) \ No newline at end of file diff --git a/docs/entities/Saga/README.md b/docs/entities/Saga/README.md deleted file mode 100644 index 2dc3759..0000000 --- a/docs/entities/Saga/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# Saga - -[AbstractSaga.d.ts]: https://github.com/snatalenko/node-cqrs/blob/master/types/classes/AbstractSaga.d.ts - - -Saga can be used to control operations where multiple aggregates are involved. - -## SagaEventReceptor - -`SagaEventReceptor` instance is needed for each Saga type, it - -1. Subscribes to event store and awaits events handled by Saga -2. Instantiates Saga with corresponding event stream -3. Passes events to saga -4. Sends enqueued commands to the CommandBus - -Saga event receptor can be created manually: - -```js -const sagaEventReceptor = new SagaEventReceptor({ - sagaType: MySaga, - eventStore, - commandBus -}); - -sagaEventReceptor.subscribe(eventStore); -``` - -or using the [DI container](../../middleware/DIContainer.md) : - -```js -builder.registerSaga(MySaga); -``` - -## Saga Interface - -At minimum Sagas should implement the following interface: - -```ts -declare interface ISaga { - /** List of event types that trigger new Saga start */ - static readonly startsWith: string[]; - - /** List of event types being handled by Saga */ - static readonly handles?: string[]; - - /** List of commands emitted by Saga */ - readonly uncommittedMessages: ICommand[]; - - /** Main entry point for Saga events */ - apply(event: IEvent): void | Promise; - - /** Reset emitted commands when they are not longer needed */ - resetUncommittedMessages(): void; -} -``` - -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. - - -## AbstractSaga - -Most of the above logic is implemented in the [AbstractSaga class][AbstractSaga.d.ts] and it can be extended with saga business logic only. - -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 - -```ts -const { AbstractSaga } = require('node-cqrs'); - -class SupportNotificationSaga extends AbstractSaga { - - static get startsWith() { - return ['userLockedOut']; - } - - /** - * "userLockedOut" event handler which also starts the Saga - */ - userLockedOut({ aggregateId }) { - - // We use empty aggregate ID as we target a new aggregate here - const targetAggregateId = undefined; - - const commandPayload = { - subject: 'Account locked out', - message: `User account ${aggregateId} is locked out for 15min because of multiple unsuccessful login attempts` - }; - - // Enqueue command, which will be sent to the CommandBus - // after method execution is complete - this.enqueue('createTicket', targetAggregateId, commandPayload); - } -} -``` diff --git a/docs/images/README.md b/docs/images/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/images/node-cqrs-components.png b/docs/images/node-cqrs-components.png deleted file mode 100644 index 3cb7bb8..0000000 Binary files a/docs/images/node-cqrs-components.png and /dev/null differ diff --git a/docs/images/node-cqrs-flow.png b/docs/images/node-cqrs-flow.png new file mode 100644 index 0000000..2d1cd07 Binary files /dev/null and b/docs/images/node-cqrs-flow.png differ diff --git a/docs/infrastructure/README.md b/docs/infrastructure/README.md deleted file mode 100644 index 93a194c..0000000 --- a/docs/infrastructure/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Infrastructure - -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: - -* [InMemoryEventStorage](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryEventStorage.js) -* [InMemoryMessageBus](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryMessageBus.js) -* [InMemoryView](https://github.com/snatalenko/node-cqrs/blob/master/src/in-memory/InMemoryView.js) - - -The following storage/bus implementations persist data in external storages and can be used in production: - -* [MongoDB Event Storage](https://github.com/snatalenko/node-cqrs-mongo) -* [RabbitMQ Message Bus](https://github.com/snatalenko/node-cqrs-rabbitmq) diff --git a/docs/middleware/AggregateCommandHandler.md b/docs/middleware/AggregateCommandHandler.md deleted file mode 100644 index ce181f8..0000000 --- a/docs/middleware/AggregateCommandHandler.md +++ /dev/null @@ -1,24 +0,0 @@ -# AggregateCommandHandler - -AggregateCommandHandler instance is needed for every aggregate type, it does the following: - -1. Subscribes to CommandBus and awaits commands handled by Aggregate -2. Upon command receiving creates an instance of Aggregate using the corresponding event stream -3. Passes the command to the created Aggregate instance -4. Commits events emitted by the Aggregate instance to the EventStore - -Aggregate command handler can be created manually: - -```js -const myAggregateCommandHandler = new AggregateCommandHandler({ - eventStore, - aggregateType: MyAggregate -}); -myAggregateCommandHandler.subscribe(commandBus); -``` - -Or using the [DI container](DIContainer.md) (preferred method): - -```js -container.registerAggregate(MyAggregate); -``` diff --git a/docs/middleware/DIContainer.md b/docs/middleware/DIContainer.md deleted file mode 100644 index a1a9a5d..0000000 --- a/docs/middleware/DIContainer.md +++ /dev/null @@ -1,109 +0,0 @@ -# DI Container - -DI Container intended to make components wiring easier. - -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: - -```js -const { ContainerBuilder } = require('node-cqrs'); -const builder = new ContainerBuilder(); -const container = builder.container(); - -container.eventStore; // instance of EventStore -container.commandBus; // instance of CommandBus -``` - -Other components can be registered either as classes or as factories: - -```js -// class with automatic dependency injection -builder.register(SomeService).as('someService'); - -// OR factory with more precise control -builder.register(container => new SomeService(container.commandBus)).as('someService'); -``` - -Container scans class constructors (or constructor functions) for dependencies and injects them, where possible: - -```js -class SomeRepository { /* ... */ } - -class ServiceA { - // dependency definition, as a parameter object property - constructor(options) { - this._repository = options.repository; - } -} - -class ServiceB { - // dependency defined thru parameter object destructuring - constructor({ repository, a }) { /* ... */ } -} - -class ServiceC { - constructor(repository, a, b) { /* ... */ } -} - -// dependencies passed thru factory -const serviceFactory = ({ repository, a, b }) => new ServiceC(repository, a, b); - -container.register(SomeRepository, 'repository'); -container.register(ServiceA, 'a'); -container.register(ServiceB, 'b'); -container.register(serviceFactory, 'c'); -``` - -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: - -```js -builder.register(SomeEventObserver); -// at this point the registered observer does not exist - -const container = builder.container(); -// now it exists and got all its constructor dependencies -``` - - -DI container has a set of methods for CQRS components registration: - -* __registerAggregate(AggregateType)__ - registers aggregateCommandHandler, subscribes it to commandBus and wires Aggregate dependencies -* __registerSaga(SagaType)__ - registers sagaEventHandler, subscribes it to eventStore and wires Saga dependencies -* __registerProjection(ProjectionType, exposedViewName)__ - registers projection, subscribes it to eventStore and exposes associated projection view on the container -* __registerCommandHandler(typeOrFactory)__ - registers command handler and subscribes it to commandBus -* __registerEventReceptor(typeOrFactory)__ - registers event receptor and subscribes it to eventStore - - -Altogether: - -```js -const { ContainerBuilder, InMemoryEventStorage } = require('node-cqrs'); -const builder = new ContainerBuilder(); - -builder.registerAggregate(UserAggregate); - -// we are using non-persistent in-memory event storage, -// for a permanent storage you can look at https://www.npmjs.com/package/node-cqrs-mongo -builder.register(InMemoryEventStorage) - .as('storage'); - -// as an example of UserAggregate dependency -builder.register(AuthService) - .as('authService'); - -// setup command and event handler listeners -const container = builder.container(); - -// send a command -const aggregateId = undefined; -const payload = { profile: {}, password: '...' }; -const context = {}; -container.commandBus.send('signupUser', aggregateId, { payload, context }); - -container.eventStore.once('userSignedUp', event => { - console.log(`user aggregate created with ID ${event.aggregateId}`); -}); -``` - -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. - -After command processing is done, produced events will be committed to the eventStore, and emitted to subscribed projections and/or event receptors. diff --git a/docs/middleware/README.md b/docs/middleware/README.md deleted file mode 100644 index 5b49487..0000000 --- a/docs/middleware/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Middleware - -for wiring components together - -* [DI Container](DIContainer.md) - -for delivering messages to corresponding domain objects - -* [AggregateCommandHandler](AggregateCommandHandler.md) -* SagaEventHandler - -Messaging API to interact with: - -* EventStore -* CommandBus diff --git a/examples/user-domain-own-implementation/index.ts b/examples/user-domain-own-implementation/index.ts new file mode 100644 index 0000000..57bfe51 --- /dev/null +++ b/examples/user-domain-own-implementation/index.ts @@ -0,0 +1,198 @@ +/** + * Plain user domain implementation example without using the framework classes. + * + * - `UserAggregate` implementing write model + * - `UserProjection` implementing read model + * - In-memory `EventStore` + * - `CommandHandler` + * - Main function wiring everything together and sending test commands + */ + +import EventEmitter from 'events'; +import crypto from 'crypto'; +import type { + IAggregate, ICommand, ICommandHandler, Identifier, IEvent, IEventSet, IEventStorageReader, IEventStorageWriter, + IObservable, IProjection +} from '../../types'; + +const md5 = (v: string): string => crypto.createHash('md5').update(v).digest('hex'); + +/** + * Sample aggregate (write interface) + */ +class UserAggregate implements IAggregate { + + readonly id: any; + + /* inner aggregate state used for write operation validation */ + #state: { passwordHash?: string } = {}; + + constructor(id) { + this.id = id; + } + + /** Restore aggregate state from past events */ + mutate(event: IEvent): void { + if (event.type === 'userCreated' || event.type === 'userPasswordChanged') + this.#state.passwordHash = event.payload.passwordHash; + } + + /** Redirect command execution to a command handler */ + handle(command: ICommand): IEventSet { + return this[command.type](command.payload); + } + + createUser({ username, password }): IEventSet { + return [{ + type: 'userCreated', + aggregateId: this.id, + payload: { + username, + passwordHash: md5(password) + } + }]; + } + + changePassword({ oldPassword, newPassword }): IEventSet { + if (md5(oldPassword) !== this.#state.passwordHash) + throw new Error('Old password is incorrect'); + + return [{ + type: 'userPasswordChanged', + aggregateId: this.id, + payload: { + passwordHash: md5(newPassword) + } + }]; + } +} + +/** + * Sample projection (read model) + */ +class UserProjection implements IProjection> { + + /** View model */ + readonly view: Map = new Map(); + + /** Subscribe only to the event types that affect the read model */ + subscribe(eventStore: IObservable) { + eventStore.on('userCreated', e => this.userCreated(e.aggregateId, e.payload)); + } + + /** If the view is not persistent, restore it from past events */ + async restore(eventStore: IEventStorageReader) { + for await (const oldEvent of eventStore.getEventsByTypes(['userCreated'])) + this.project(oldEvent); + } + + /** Pass data to corresponding event handler */ + project(event: IEvent): void { + this[event.type](event.aggregateId, event.payload); + } + + userCreated(userId, { username }) { + this.view.set(userId, { username }); + } +} + +/** + * Dumb event store that keeps all events in memory + * and re-distributes them to all subscribers + */ +class EventStore extends EventEmitter implements IObservable, IEventStorageReader, IEventStorageWriter { + + #events: IEvent[] = []; + + async commitEvents(events: Readonly) { + this.#events.push(...events); + + for (const e of events) + this.emit(e.type, e); + + return events; + } + + async* getEventsByTypes(eventTypes: string[]) { + yield* this.#events.filter(e => eventTypes.includes(e.type)); + } + + async* getAggregateEvents(aggregateId: Identifier) { + yield* this.#events.filter(e => e.aggregateId === aggregateId); + } + + async* getSagaEvents(sagaId: Identifier) { + yield* this.#events.filter(e => e.sagaId === sagaId); + } +} + +/** + * Sample command handler that routes commands to corresponding aggregates + */ +class CommandHandler implements ICommandHandler { + + #eventStore: IEventStorageReader & IEventStorageWriter; + + constructor(eventStore) { + this.#eventStore = eventStore; + } + + subscribe(commandBus: IObservable): void { + commandBus.on('createUser', cmd => this.passCommandToAggregate(cmd)); + commandBus.on('changePassword', cmd => this.passCommandToAggregate(cmd)); + } + + async passCommandToAggregate(cmd) { + const userAggregate = new UserAggregate(cmd.aggregateId); + + // restore aggregate state from past events + const oldEvents = this.#eventStore.getAggregateEvents(cmd.aggregateId); + for await (const event of oldEvents) + userAggregate.mutate(event); + + // store new events + const newEvents = userAggregate.handle(cmd); + this.#eventStore.commitEvents(newEvents); + } +} + +/** + * Run the test + */ +(async function main() { + + // create and wire all instances together + + const commandBus = new EventEmitter(); + const eventStore = new EventStore(); + + const commandHandler = new CommandHandler(eventStore); + commandHandler.subscribe(commandBus); + + const projection = new UserProjection(); + projection.subscribe(eventStore); + projection.restore(eventStore); + + // send test commands + + commandBus.emit('createUser', { + aggregateId: '1', + type: 'createUser', + payload: { username: 'John', password: 'magic' } + }); + + commandBus.emit('changeUserPassword', { + aggregateId: '1', + type: 'changeUserPassword', + payload: { oldPassword: 'magic', newPassword: 'no magic' } + }); + + // wait for the command bus to finish processing + await new Promise(setImmediate); + + const userRecord = projection.view.get('1'); + + // eslint-disable-next-line no-console + console.log(userRecord); // { username: 'John' } + +}()); diff --git a/examples/user-domain-own-implementation/package.json b/examples/user-domain-own-implementation/package.json new file mode 100644 index 0000000..f938c85 --- /dev/null +++ b/examples/user-domain-own-implementation/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "scripts": { + "start": "node index.ts", + "test": "node index.ts" + } +} \ No newline at end of file diff --git a/examples/user-domain-ts/UserAggregate.ts b/examples/user-domain-ts/UserAggregate.ts new file mode 100644 index 0000000..328b15e --- /dev/null +++ b/examples/user-domain-ts/UserAggregate.ts @@ -0,0 +1,36 @@ +import type { ChangePasswordCommandPayload, CreateUserCommandPayload, PasswordChangedEvent, UserCreatedEvent } from './messages'; +import { AbstractAggregate } from '../..'; +import { md5 } from './utils'; + +class UserAggregateState { + passwordHash!: string; + + userCreated(event: UserCreatedEvent) { + this.passwordHash = event.payload!.passwordHash; + } + + passwordChanged(event: PasswordChangedEvent) { + this.passwordHash = event.payload!.passwordHash; + } +} + +export class UserAggregate extends AbstractAggregate { + + protected readonly state = new UserAggregateState(); + + createUser(payload: CreateUserCommandPayload) { + this.emit('userCreated', { + username: payload.username, + passwordHash: md5(payload.password) + }); + } + + changePassword(payload: ChangePasswordCommandPayload) { + if (md5(payload.oldPassword) !== this.state.passwordHash) + throw new Error('Invalid password'); + + this.emit('passwordChanged', { + passwordHash: md5(payload.newPassword) + }); + } +} diff --git a/examples/user-domain-ts/UsersProjection.ts b/examples/user-domain-ts/UsersProjection.ts new file mode 100644 index 0000000..49aeb21 --- /dev/null +++ b/examples/user-domain-ts/UsersProjection.ts @@ -0,0 +1,18 @@ +import type { UserCreatedEvent } from './messages'; +import { AbstractProjection } from '../..'; + +export type UsersView = Map; + +export class UsersProjection extends AbstractProjection { + + constructor() { + super(); + this.view = new Map(); + } + + userCreated(event: UserCreatedEvent) { + this.view.set(event.aggregateId as string, { + username: event.payload!.username + }); + } +} diff --git a/examples/user-domain-ts/index.ts b/examples/user-domain-ts/index.ts new file mode 100644 index 0000000..9166bcb --- /dev/null +++ b/examples/user-domain-ts/index.ts @@ -0,0 +1,40 @@ +import { ContainerBuilder, IContainer, InMemoryEventStorage } from '../..'; +import type { ChangePasswordCommandPayload, CreateUserCommandPayload } from './messages'; +import { UserAggregate } from './UserAggregate'; +import { UsersProjection, UsersView } from './UsersProjection'; + +interface MyDiContainer extends IContainer { + users: UsersView; +} + +const builder = new ContainerBuilder(); +builder.register(InMemoryEventStorage) // In-memory implementations for local dev/tests + .as('eventStorageReader') + .as('eventStorageWriter'); +builder.registerAggregate(UserAggregate); +builder.registerProjection(UsersProjection, 'users'); + + +(async function main() { + const container = builder.container(); + const { users, commandBus } = container; + + const [userCreated] = await commandBus.send('createUser', undefined, { + payload: { + username: 'john', + password: 'magic' + } satisfies CreateUserCommandPayload + }); + + await commandBus.send('changePassword', userCreated.aggregateId as string, { + payload: { + oldPassword: 'magic', + newPassword: 'no magic' + } satisfies ChangePasswordCommandPayload + }); + + const user = users.get(userCreated.aggregateId as string); + + // eslint-disable-next-line no-console + console.log(user); // { username: 'john' } +}()); diff --git a/examples/user-domain-ts/messages.ts b/examples/user-domain-ts/messages.ts new file mode 100644 index 0000000..8084058 --- /dev/null +++ b/examples/user-domain-ts/messages.ts @@ -0,0 +1,10 @@ +import type { IEvent } from '../../types'; + +export type CreateUserCommandPayload = { username: string, password: string }; +export type UserCreatedEventPayload = { username: string, passwordHash: string }; +export type UserCreatedEvent = IEvent; + +export type ChangePasswordCommandPayload = { oldPassword: string, newPassword: string }; +export type PasswordChangedEventPayload = { passwordHash: string }; +export type PasswordChangedEvent = IEvent; + diff --git a/examples/user-domain-ts/utils.ts b/examples/user-domain-ts/utils.ts new file mode 100644 index 0000000..7f60531 --- /dev/null +++ b/examples/user-domain-ts/utils.ts @@ -0,0 +1,3 @@ +import * as crypto from 'crypto'; + +export const md5 = (v: string): string => crypto.createHash('md5').update(v).digest('hex'); diff --git a/examples/user-domain-tests/.eslintrc.json b/examples/user-domain/tests/.eslintrc.json similarity index 100% rename from examples/user-domain-tests/.eslintrc.json rename to examples/user-domain/tests/.eslintrc.json diff --git a/examples/user-domain-tests/index.test.js b/examples/user-domain/tests/index.test.js similarity index 97% rename from examples/user-domain-tests/index.test.js rename to examples/user-domain/tests/index.test.js index e5f4378..00f293e 100644 --- a/examples/user-domain-tests/index.test.js +++ b/examples/user-domain/tests/index.test.js @@ -1,7 +1,7 @@ 'use strict'; const { expect } = require('chai'); -const { createContainer, createBaseInstances } = require('../user-domain'); +const { createContainer, createBaseInstances } = require('..'); describe('user-domain example', () => { diff --git a/examples/worker-projection/CounterProjection.cjs b/examples/worker-projection/CounterProjection.cjs new file mode 100644 index 0000000..f2aadd0 --- /dev/null +++ b/examples/worker-projection/CounterProjection.cjs @@ -0,0 +1,30 @@ +const { AbstractWorkerProjection } = require('../../dist/workers'); + +class CounterView { + counter = 0; + + increment() { + this.counter += 1; + } + + getCounter() { + return this.counter; + } +} + +class CounterProjection extends AbstractWorkerProjection { + constructor() { + super({ + workerModulePath: __filename, + view: new CounterView() + }); + } + + somethingHappened() { + this.view.increment(); + } +} + +CounterProjection.createInstanceIfWorkerThread(); + +module.exports = CounterProjection; diff --git a/examples/worker-projection/index.cjs b/examples/worker-projection/index.cjs new file mode 100644 index 0000000..230b185 --- /dev/null +++ b/examples/worker-projection/index.cjs @@ -0,0 +1,18 @@ +const CounterProjection = require('./CounterProjection.cjs'); + +async function main() { + const projection = new CounterProjection(); + + await projection.project({ id: '1', type: 'somethingHappened' }); + await projection.project({ id: '2', type: 'somethingHappened' }); + + const counter = await projection.view.getCounter(); + console.log('counter =', counter); + + projection.dispose(); +} + +main().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/package-lock.json b/package-lock.json index 6a9bade..2242ae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,26 +15,30 @@ }, "devDependencies": { "@stylistic/eslint-plugin-ts": "^4.4.1", - "@types/amqplib": "^0.10.7", + "@types/amqplib": "^0.10.8", "@types/better-sqlite3": "^7.6.13", "@types/chai": "^4.3.20", "@types/jest": "^29.5.14", - "@types/md5": "^2.3.5", - "@types/node": "^20.19.21", + "@types/md5": "^2.3.6", + "@types/node": "^20.19.30", "@types/sinon": "^17.0.4", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", + "amqplib": "^0.10.9", + "better-sqlite3": "^11.10.0", "chai": "^4.5.0", + "comlink": "^4.4.2", "conventional-changelog": "^3.1.25", - "eslint": "^9.37.0", + "eslint": "^9.39.2", "eslint-plugin-jest": "^28.14.0", - "globals": "^16.4.0", + "globals": "^16.5.0", "jest": "^29.7.0", + "md5": "^2.3.0", "sinon": "^19.0.5", - "ts-jest": "^29.4.5", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.1" + "typescript-eslint": "^8.53.1" }, "engines": { "node": ">=18.0.0" @@ -42,17 +46,32 @@ "peerDependencies": { "amqplib": "^0.10.9", "better-sqlite3": "^11.10.0", + "comlink": "^4.4.2", "md5": "^2.3.0" + }, + "peerDependenciesMeta": { + "amqplib": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "comlink": { + "optional": true + }, + "md5": { + "optional": true + } } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -61,9 +80,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "dev": true, "license": "MIT", "engines": { @@ -71,21 +90,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -112,14 +132,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -129,13 +149,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -183,29 +203,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -215,9 +235,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -235,9 +255,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -255,27 +275,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -340,13 +360,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -382,13 +402,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -508,13 +528,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -524,33 +544,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", "debug": "^4.3.1" }, "engines": { @@ -558,14 +578,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -603,9 +623,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -635,9 +655,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -645,13 +665,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -684,22 +704,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -710,9 +730,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -722,7 +742,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -781,9 +801,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -794,9 +814,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -804,13 +824,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1338,44 +1358,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1450,9 +1432,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -1478,9 +1460,9 @@ "license": "MIT" }, "node_modules/@types/amqplib": { - "version": "0.10.7", - "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.7.tgz", - "integrity": "sha512-IVj3avf9AQd2nXCx0PGk/OYq7VmHiyNxWFSb5HhU9ATh+i+gHWvVcljFTcTWQ/dyHJCTrzCixde+r/asL2ErDA==", + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.8.tgz", + "integrity": "sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==", "dev": true, "license": "MIT", "dependencies": { @@ -1612,9 +1594,9 @@ "license": "MIT" }, "node_modules/@types/md5": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz", - "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.6.tgz", + "integrity": "sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA==", "dev": true, "license": "MIT" }, @@ -1626,11 +1608,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz", - "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==", + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1653,9 +1636,9 @@ } }, "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", - "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", "dev": true, "license": "MIT" }, @@ -1667,9 +1650,9 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -1684,21 +1667,21 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", - "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", + "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1708,23 +1691,24 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", + "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", + "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1739,15 +1723,15 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", + "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1761,14 +1745,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", + "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1779,9 +1763,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", + "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", "dev": true, "license": "MIT", "engines": { @@ -1796,17 +1780,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", - "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", + "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1821,9 +1805,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", + "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", "dev": true, "license": "MIT", "engines": { @@ -1835,22 +1819,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", + "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1864,16 +1847,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", - "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", + "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1888,13 +1871,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", + "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/types": "8.53.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1911,6 +1894,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1969,8 +1953,8 @@ "version": "0.10.9", "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz", "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-more-ints": "~1.0.0", "url-parse": "~1.5.10" @@ -2225,6 +2209,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -2239,13 +2224,12 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", - "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2256,9 +2240,9 @@ "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -2268,8 +2252,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -2278,8 +2262,8 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -2310,9 +2294,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -2329,12 +2313,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2370,6 +2355,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -2385,7 +2371,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -2402,8 +2387,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", @@ -2444,9 +2429,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001750", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", - "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { @@ -2514,8 +2499,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } @@ -2537,8 +2522,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "peer": true + "dev": true, + "license": "ISC" }, "node_modules/ci-info": { "version": "3.9.0", @@ -2587,9 +2572,9 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, @@ -2613,6 +2598,13 @@ "dev": true, "license": "MIT" }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -2942,8 +2934,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } @@ -3027,8 +3019,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -3040,9 +3032,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3071,8 +3063,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4.0.0" } @@ -3098,8 +3090,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8" } @@ -3154,9 +3146,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.235", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz", - "integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==", + "version": "1.5.278", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", + "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", "dev": true, "license": "ISC" }, @@ -3184,8 +3176,8 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "once": "^1.4.0" } @@ -3224,25 +3216,25 @@ } }, "node_modules/eslint": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -3407,9 +3399,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3489,8 +3481,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, "license": "(MIT OR WTFPL)", - "peer": true, "engines": { "node": ">=6" } @@ -3519,36 +3511,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3563,16 +3525,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3600,8 +3552,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", @@ -3658,8 +3610,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/fs.realpath": { "version": "1.0.0", @@ -3884,8 +3836,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/glob": { "version": "7.2.3", @@ -3947,9 +3899,9 @@ } }, "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -3966,13 +3918,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4062,6 +4007,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -4076,8 +4022,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "7.0.5", @@ -4162,12 +4107,14 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, "license": "ISC" }, "node_modules/is-arrayish": { @@ -4181,8 +4128,8 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/is-core-module": { "version": "2.16.1", @@ -4390,6 +4337,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5261,9 +5209,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -5361,8 +5309,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", @@ -5555,16 +5503,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5593,8 +5531,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -5632,6 +5570,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5656,8 +5595,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/modify-values": { "version": "1.0.1", @@ -5680,8 +5619,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5722,11 +5661,11 @@ } }, "node_modules/node-abi": { - "version": "3.78.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", - "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -5742,9 +5681,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", - "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -5791,6 +5730,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6098,8 +6038,8 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -6184,8 +6124,8 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -6234,28 +6174,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT", - "peer": true - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT" }, "node_modules/quick-lru": { @@ -6272,8 +6191,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -6288,8 +6207,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6437,6 +6356,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -6475,17 +6395,17 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -6542,45 +6462,11 @@ "node": ">=10" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -6601,6 +6487,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6643,6 +6530,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, "funding": [ { "type": "github", @@ -6657,13 +6545,13 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, "funding": [ { "type": "github", @@ -6679,7 +6567,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -6846,6 +6733,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -6969,8 +6857,8 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -6982,8 +6870,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -7061,6 +6949,55 @@ "readable-stream": "3" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7092,9 +7029,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -7105,9 +7042,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -7186,6 +7123,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7225,9 +7163,9 @@ } }, "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7238,8 +7176,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -7289,6 +7227,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7298,16 +7237,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz", - "integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==", + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.1.tgz", + "integrity": "sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.1", - "@typescript-eslint/parser": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1" + "@typescript-eslint/eslint-plugin": "8.53.1", + "@typescript-eslint/parser": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7343,9 +7282,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -7387,8 +7326,8 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -7398,6 +7337,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -7498,6 +7438,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 8e4a29d..15b407c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "types": "./types/index.d.ts", "typesVersions": { "*": { + "workers": [ + "types/workers/index.d.ts" + ], "rabbitmq": [ "types/rabbitmq/index.d.ts" ], @@ -28,6 +31,11 @@ "import": "./dist/index.js", "types": "./types/index.d.ts" }, + "./workers": { + "require": "./dist/workers/index.js", + "import": "./dist/workers/index.js", + "types": "./types/workers/index.d.ts" + }, "./rabbitmq": { "require": "./dist/rabbitmq/index.js", "import": "./dist/rabbitmq/index.js", @@ -50,7 +58,7 @@ "scripts": { "cleanup": "rm -rf ./dist ./types ./coverage && tsc --build --clean", "pretest": "npm run build", - "test": "jest tests/unit examples/user-domain-tests", + "test": "jest tests/unit examples/user-domain/tests", "test:coverage": "npm t -- --collect-coverage", "test:rabbitmq": "jest --verbose tests/integration/rabbitmq", "test:sqlite": "jest --verbose tests/integration/sqlite", @@ -71,30 +79,49 @@ }, "devDependencies": { "@stylistic/eslint-plugin-ts": "^4.4.1", - "@types/amqplib": "^0.10.7", + "@types/amqplib": "^0.10.8", "@types/better-sqlite3": "^7.6.13", "@types/chai": "^4.3.20", "@types/jest": "^29.5.14", - "@types/md5": "^2.3.5", - "@types/node": "^20.19.21", + "@types/md5": "^2.3.6", + "@types/node": "^20.19.30", "@types/sinon": "^17.0.4", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", + "amqplib": "^0.10.9", + "better-sqlite3": "^11.10.0", "chai": "^4.5.0", + "comlink": "^4.4.2", "conventional-changelog": "^3.1.25", - "eslint": "^9.37.0", + "eslint": "^9.39.2", "eslint-plugin-jest": "^28.14.0", - "globals": "^16.4.0", + "globals": "^16.5.0", "jest": "^29.7.0", + "md5": "^2.3.0", "sinon": "^19.0.5", - "ts-jest": "^29.4.5", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.1" + "typescript-eslint": "^8.53.1" }, "peerDependencies": { "amqplib": "^0.10.9", "better-sqlite3": "^11.10.0", + "comlink": "^4.4.2", "md5": "^2.3.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "amqplib": { + "optional": true + }, + "comlink": { + "optional": true + }, + "md5": { + "optional": true + } } } diff --git a/src/AbstractAggregate.ts b/src/AbstractAggregate.ts index 4243173..4c672d6 100644 --- a/src/AbstractAggregate.ts +++ b/src/AbstractAggregate.ts @@ -1,11 +1,11 @@ import { - IAggregate, - IMutableAggregateState, - ICommand, - Identifier, - IEvent, - IEventSet, - IAggregateConstructorParams, + type IAggregate, + type IMutableAggregateState, + type ICommand, + type Identifier, + type IEvent, + type IEventSet, + type IAggregateConstructorParams, SNAPSHOT_EVENT_TYPE } from './interfaces'; @@ -14,7 +14,8 @@ import { getClassName, validateHandlers, getHandler, getMessageHandlerNames } fr /** * Base class for Aggregate definition */ -export abstract class AbstractAggregate implements IAggregate { +export abstract class AbstractAggregate implements + IAggregate { /** * List of command names handled by the Aggregate. diff --git a/src/AbstractProjection.ts b/src/AbstractProjection.ts index 8b106c4..b48b780 100644 --- a/src/AbstractProjection.ts +++ b/src/AbstractProjection.ts @@ -1,13 +1,14 @@ import { describe } from './Event'; import { InMemoryView } from './in-memory/InMemoryView'; import { - IViewLocker, - IEventLocker, - IProjection, - ILogger, - IExtendableLogger, - IEventStore, - IEvent, + type IViewLocker, + type IEventLocker, + type IProjection, + type ILogger, + type IExtendableLogger, + type IEvent, + type IObservable, + type IEventStorageReader, isViewLocker, isEventLocker } from './interfaces'; @@ -56,8 +57,8 @@ export abstract class AbstractProjection implements IProjection implements IProjection implements IProjection { + /** + * Subscribe to event store + * and restore view state from not yet projected events + */ + async subscribe(eventStore: IObservable & IEventStorageReader): Promise { subscribe(eventStore, this, { masterHandler: this.project }); @@ -123,7 +133,7 @@ export abstract class AbstractProjection implements IProjection { - if (this._viewLocker && !this._viewLocker?.ready) { + if (this._viewLocker && !this._viewLocker.ready) { this._logger?.debug(`view is locked, awaiting until it is ready to process ${describe(event)}`); await this._viewLocker.once('ready'); this._logger?.debug(`view is ready, processing ${describe(event)}`); @@ -150,10 +160,13 @@ export abstract class AbstractProjection implements IProjection { - // lock the view to ensure same restoring procedure - // won't be performed by another projection instance + /** + * Restore view state from not-yet-projected events. + * + * Lock the view to ensure same restoring procedure + * won't be performed by another projection instance. + * */ + async restore(eventStore: IEventStorageReader): Promise { if (this._viewLocker) await this._viewLocker.lock(); @@ -163,8 +176,8 @@ export abstract class AbstractProjection implements IProjection { + /** Restore view state from not-yet-projected events */ + protected async _restore(eventStore: IEventStorageReader): Promise { if (!eventStore) throw new TypeError('eventStore argument required'); if (typeof eventStore.getEventsByTypes !== 'function') @@ -190,7 +203,7 @@ export abstract class AbstractProjection implements IProjection implements IProjection> = new Map(); protected uniqueEventHandlers: boolean; diff --git a/src/interfaces/IEventBus.ts b/src/interfaces/IEventBus.ts index 0e37e07..69406fb 100644 --- a/src/interfaces/IEventBus.ts +++ b/src/interfaces/IEventBus.ts @@ -1,5 +1,5 @@ -import { IEvent } from './IEvent'; -import { IObservable, isIObservable } from './IObservable'; +import type { IEvent } from './IEvent'; +import { type IObservable, isIObservable } from './IObservable'; export interface IEventBus extends IObservable { publish(event: IEvent, meta?: Record): Promise; diff --git a/src/interfaces/IEventLocker.ts b/src/interfaces/IEventLocker.ts index d3388b1..c2d0541 100644 --- a/src/interfaces/IEventLocker.ts +++ b/src/interfaces/IEventLocker.ts @@ -28,7 +28,14 @@ export interface IEventLocker { } export const isEventLocker = (view: unknown): view is IEventLocker => - isObject(view) - && 'getLastEvent' in view - && 'tryMarkAsProjecting' in view - && 'markAsProjected' in view; + ( + isObject(view) + && 'getLastEvent' in view + && 'tryMarkAsProjecting' in view + && 'markAsProjected' in view + ) || ( + typeof view === 'function' + && typeof (view as any).getLastEvent === 'function' + && typeof (view as any).tryMarkAsProjecting === 'function' + && typeof (view as any).markAsProjected === 'function' + ); diff --git a/src/interfaces/IEventStore.ts b/src/interfaces/IEventStore.ts index 0ded850..fae2816 100644 --- a/src/interfaces/IEventStore.ts +++ b/src/interfaces/IEventStore.ts @@ -1,11 +1,12 @@ -import { IEventDispatcher } from './IEventDispatcher'; -import { IEvent } from './IEvent'; -import { IEventStorageReader } from './IEventStorageReader'; -import { IIdentifierProvider } from './IIdentifierProvider'; -import { IMessageHandler, IObservable } from './IObservable'; +import type { IEventDispatcher } from './IEventDispatcher'; +import type { IEvent } from './IEvent'; +import type { IEventStorageReader } from './IEventStorageReader'; +import type { IIdentifierProvider } from './IIdentifierProvider'; +import type { IMessageHandler, IObservable } from './IObservable'; +import type { IObservableQueueProvider } from './IObservableQueueProvider'; export interface IEventStore - extends IObservable, IEventDispatcher, IEventStorageReader, IIdentifierProvider { + extends IObservable, IObservableQueueProvider, IEventDispatcher, IEventStorageReader, IIdentifierProvider { registerSagaStarters(startsWith: string[] | undefined): void; diff --git a/src/interfaces/IMessage.ts b/src/interfaces/IMessage.ts index 40c78f0..e5fda28 100644 --- a/src/interfaces/IMessage.ts +++ b/src/interfaces/IMessage.ts @@ -6,13 +6,28 @@ export interface IMessage { /** Event or command type */ type: string; + /** + * Target aggregate identifier for commands, + * originating aggregate identifier for events + */ aggregateId?: Identifier; + + /** Aggregate version at the time of the message (usually set on events, optional on commands) */ aggregateVersion?: number; + /** Saga identifier (used when a saga coordinates multiple steps/commands) */ sagaId?: Identifier; + + /** Saga version for ordering saga events */ sagaVersion?: number; + /** Business data */ payload?: TPayload; + + /** + * Optional metadata/context (e.g. auth info, request id); + * Commonly set on commands, then copied to emitted events + */ context?: any; } diff --git a/src/interfaces/IMessageBus.ts b/src/interfaces/IMessageBus.ts index 5b626a3..44a7ea2 100644 --- a/src/interfaces/IMessageBus.ts +++ b/src/interfaces/IMessageBus.ts @@ -1,6 +1,6 @@ -import { ICommand } from './ICommand'; -import { IEvent } from './IEvent'; -import { IObservable } from './IObservable'; +import type { ICommand } from './ICommand'; +import type { IEvent } from './IEvent'; +import type { IObservable } from './IObservable'; export interface IMessageBus extends IObservable { send(command: ICommand): Promise; diff --git a/src/interfaces/IObservable.ts b/src/interfaces/IObservable.ts index a04387a..f7bdab9 100644 --- a/src/interfaces/IObservable.ts +++ b/src/interfaces/IObservable.ts @@ -16,11 +16,6 @@ export interface IObservable { * Remove previously installed listener */ off(type: string, handler: IMessageHandler): void; - - /** - * Get or create a named queue, which delivers events to a single handler only - */ - queue?(name: string): IObservable; } export const isIObservable = (obj: unknown): obj is IObservable => diff --git a/src/interfaces/IObservableQueueProvider.ts b/src/interfaces/IObservableQueueProvider.ts new file mode 100644 index 0000000..2b32573 --- /dev/null +++ b/src/interfaces/IObservableQueueProvider.ts @@ -0,0 +1,15 @@ +import type { IObservable } from './IObservable'; +import { isObject } from './isObject'; + +export interface IObservableQueueProvider { + + /** + * Get or create a named queue, which delivers events to a single handler only + */ + queue(name: string): IObservable; +} + +export const isIObservableQueueProvider = (obj: unknown): obj is IObservableQueueProvider => + isObject(obj) + && 'queue' in obj + && typeof obj.queue === 'function'; diff --git a/src/interfaces/IProjection.ts b/src/interfaces/IProjection.ts index 4c260d7..a5d17bb 100644 --- a/src/interfaces/IProjection.ts +++ b/src/interfaces/IProjection.ts @@ -1,20 +1,22 @@ -import { IEvent } from './IEvent'; -import { IEventStore } from './IEventStore'; -import { IObserver } from './IObserver'; +import type { IObserver } from './IObserver'; +import type { IObservable } from './IObservable'; +import type { IEventStorageReader } from './IEventStorageReader'; +import type { IEvent } from './IEvent'; export interface IProjection extends IObserver { readonly view: TView; - subscribe(eventStore: IEventStore): Promise; + /** Subscribe to new events */ + subscribe(eventStore: IObservable): Promise | void; - project(event: IEvent): Promise; + /** Restore view state from not-yet-projected events */ + restore(eventStore: IEventStorageReader): Promise | void; + + /** Project new event */ + project(event: IEvent): Promise | void; } export interface IProjectionConstructor { new(c?: any): IProjection; readonly handles?: string[]; } - -export interface IViewFactory { - (options: { schemaVersion: string }): TView; -} diff --git a/src/interfaces/IViewLocker.ts b/src/interfaces/IViewLocker.ts index fefcd2d..1dd517c 100644 --- a/src/interfaces/IViewLocker.ts +++ b/src/interfaces/IViewLocker.ts @@ -39,8 +39,16 @@ export interface IViewLocker { * @returns `true` if the object implements `IViewLocker`, `false` otherwise. */ export const isViewLocker = (view: unknown): view is IViewLocker => - isObject(view) - && 'ready' in view - && 'lock' in view - && 'unlock' in view - && 'once' in view; + ( + isObject(view) + && 'ready' in view + && 'lock' in view + && 'unlock' in view + && 'once' in view + ) || ( + typeof view === 'function' + && typeof (view as any).lock === 'function' + && typeof (view as any).unlock === 'function' + && typeof (view as any).once === 'function' + && (view as any).ready !== undefined + ); diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 9b9539f..424070c 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -21,6 +21,7 @@ export * from './IMessage'; export * from './IMessageBus'; export * from './IObjectStorage'; export * from './IObservable'; +export * from './IObservableQueueProvider'; export * from './IObserver'; export * from './IProjection'; export * from './ISaga'; diff --git a/src/rabbitmq/RabbitMqEventBus.ts b/src/rabbitmq/RabbitMqEventBus.ts index 50e2e01..290febb 100644 --- a/src/rabbitmq/RabbitMqEventBus.ts +++ b/src/rabbitmq/RabbitMqEventBus.ts @@ -1,7 +1,10 @@ -import { IEvent, IEventBus, IMessageHandler, IObservable } from '../interfaces'; +import type { IEvent, IEventBus, IMessageHandler, IObservable, IObservableQueueProvider } from '../interfaces'; import { RabbitMqGateway } from './RabbitMqGateway'; -export class RabbitMqEventBus implements IEventBus { +/** + * RabbitMQ-backed `IEventBus` with named queues support + */ +export class RabbitMqEventBus implements IEventBus, IObservableQueueProvider { static get allEventsWildcard(): string { return RabbitMqGateway.ALL_EVENTS_WILDCARD; diff --git a/src/rabbitmq/RabbitMqGateway.ts b/src/rabbitmq/RabbitMqGateway.ts index 78b49ce..eb3dea5 100644 --- a/src/rabbitmq/RabbitMqGateway.ts +++ b/src/rabbitmq/RabbitMqGateway.ts @@ -1,5 +1,5 @@ -import { Channel, ChannelModel, ConfirmChannel, ConsumeMessage } from 'amqplib'; -import { IContainer, ILogger, IMessage, isMessage } from '../interfaces'; +import type { Channel, ChannelModel, ConfirmChannel, ConsumeMessage } from 'amqplib'; +import { type IContainer, type ILogger, type IMessage, isMessage } from '../interfaces'; import * as Event from '../Event'; import { extractErrorDetails, Lock } from '../utils'; import { registerExitCleanup } from './utils'; diff --git a/src/sqlite/AbstractSqliteAccessor.ts b/src/sqlite/AbstractSqliteAccessor.ts index 27d3c08..9a474cc 100644 --- a/src/sqlite/AbstractSqliteAccessor.ts +++ b/src/sqlite/AbstractSqliteAccessor.ts @@ -1,6 +1,6 @@ -import { IContainer } from '../interfaces'; +import type { IContainer } from '../interfaces'; import { Lock } from '../utils'; -import { Database } from 'better-sqlite3'; +import type { Database } from 'better-sqlite3'; /** * Abstract base class for accessing a SQLite database. diff --git a/src/sqlite/AbstractSqliteView.ts b/src/sqlite/AbstractSqliteView.ts index 52aa020..55927ba 100644 --- a/src/sqlite/AbstractSqliteView.ts +++ b/src/sqlite/AbstractSqliteView.ts @@ -3,6 +3,9 @@ import { SqliteViewLocker, SqliteViewLockerParams } from './SqliteViewLocker'; import { SqliteEventLocker, SqliteEventLockerParams } from './SqliteEventLocker'; import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; +/** + * Base class for SQLite-backed projection views with restore locking and last-processed-event tracking + */ export abstract class AbstractSqliteView extends AbstractSqliteAccessor implements IViewLocker, IEventLocker { protected readonly schemaVersion: string; diff --git a/src/sqlite/IContainer.ts b/src/sqlite/IContainer.ts index f24f6d6..a3d8145 100644 --- a/src/sqlite/IContainer.ts +++ b/src/sqlite/IContainer.ts @@ -1,4 +1,4 @@ -import { Database } from 'better-sqlite3'; +import type { Database } from 'better-sqlite3'; declare module '../interfaces/IContainer' { interface IContainer { diff --git a/src/sqlite/SqliteEventLocker.ts b/src/sqlite/SqliteEventLocker.ts index 316f028..a202060 100644 --- a/src/sqlite/SqliteEventLocker.ts +++ b/src/sqlite/SqliteEventLocker.ts @@ -1,9 +1,9 @@ -import { Database, Statement } from 'better-sqlite3'; -import { IContainer, IEvent, IEventLocker } from '../interfaces'; +import type { Database, Statement } from 'better-sqlite3'; +import type { IContainer, IEvent, IEventLocker } from '../interfaces'; import { getEventId } from './utils'; import { viewLockTableInit, eventLockTableInit } from './queries'; -import { SqliteViewLockerParams } from './SqliteViewLocker'; -import { SqliteProjectionDataParams } from './SqliteProjectionDataParams'; +import type { SqliteViewLockerParams } from './SqliteViewLocker'; +import type { SqliteProjectionDataParams } from './SqliteProjectionDataParams'; import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; export type SqliteEventLockerParams = diff --git a/src/sqlite/SqliteObjectStorage.ts b/src/sqlite/SqliteObjectStorage.ts index 8f16142..9ab3149 100644 --- a/src/sqlite/SqliteObjectStorage.ts +++ b/src/sqlite/SqliteObjectStorage.ts @@ -1,6 +1,6 @@ -import { Statement, Database } from 'better-sqlite3'; +import type { Statement, Database } from 'better-sqlite3'; import { guid } from './utils'; -import { IContainer, IObjectStorage } from '../interfaces'; +import type { IContainer, IObjectStorage } from '../interfaces'; import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; export class SqliteObjectStorage extends AbstractSqliteAccessor implements IObjectStorage { diff --git a/src/sqlite/SqliteObjectView.ts b/src/sqlite/SqliteObjectView.ts index ac99aed..a110701 100644 --- a/src/sqlite/SqliteObjectView.ts +++ b/src/sqlite/SqliteObjectView.ts @@ -1,8 +1,11 @@ import { AbstractSqliteView } from './AbstractSqliteView'; -import { IObjectStorage, IEventLocker } from '../interfaces'; +import type { IObjectStorage, IEventLocker } from '../interfaces'; import { SqliteObjectStorage } from './SqliteObjectStorage'; -import { Database } from 'better-sqlite3'; +import type { Database } from 'better-sqlite3'; +/** + * SQLite-backed object view with restore locking and last-processed-event tracking + */ export class SqliteObjectView extends AbstractSqliteView implements IObjectStorage, IEventLocker { #sqliteObjectStorage: SqliteObjectStorage; diff --git a/src/sqlite/SqliteViewLocker.ts b/src/sqlite/SqliteViewLocker.ts index b5f8e49..43fd5f1 100644 --- a/src/sqlite/SqliteViewLocker.ts +++ b/src/sqlite/SqliteViewLocker.ts @@ -1,9 +1,9 @@ -import { Database, Statement } from 'better-sqlite3'; -import { IContainer, ILogger, IViewLocker } from '../interfaces'; +import type { Database, Statement } from 'better-sqlite3'; +import type { IContainer, ILogger, IViewLocker } from '../interfaces'; import { Deferred } from '../utils'; import { promisify } from 'util'; import { viewLockTableInit } from './queries'; -import { SqliteProjectionDataParams } from './SqliteProjectionDataParams'; +import type { SqliteProjectionDataParams } from './SqliteProjectionDataParams'; import { AbstractSqliteAccessor } from './AbstractSqliteAccessor'; const delay = promisify(setTimeout); diff --git a/src/utils/index.ts b/src/utils/index.ts index b987ba6..8e8a8da 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,4 @@ export * from './Deferred'; -export * from './delay'; export * from './getClassName'; export * from './getHandler'; export * from './getMessageHandlerNames'; diff --git a/src/utils/subscribe.ts b/src/utils/subscribe.ts index 3f5b157..9c21664 100644 --- a/src/utils/subscribe.ts +++ b/src/utils/subscribe.ts @@ -1,4 +1,4 @@ -import { IMessageHandler, IObservable } from '../interfaces'; +import { type IMessageHandler, type IObservable, isIObservableQueueProvider } from '../interfaces'; import { getHandler } from './getHandler'; import { getMessageHandlerNames } from './getMessageHandlerNames'; @@ -40,8 +40,6 @@ export function subscribe( const { masterHandler, messageTypes, queueName } = options; if (masterHandler && typeof masterHandler !== 'function') throw new TypeError('masterHandler parameter, when provided, must be a Function'); - if (queueName && typeof observable.queue !== 'function') - throw new TypeError('observable.queue, when queueName is specified, must be a Function'); const subscribeTo = messageTypes || getHandledMessageTypes(observer); if (!Array.isArray(subscribeTo)) @@ -53,7 +51,7 @@ export function subscribe( throw new Error(`'${messageType}' handler is not defined or not a function`); if (queueName) { - if (!observable.queue) + if (!isIObservableQueueProvider(observable)) throw new TypeError('Observer does not support named queues'); observable.queue(queueName).on(messageType, (event, meta) => handler.call(observer, event, meta)); diff --git a/src/workers/AbstractWorkerProjection.ts b/src/workers/AbstractWorkerProjection.ts new file mode 100644 index 0000000..c7f66af --- /dev/null +++ b/src/workers/AbstractWorkerProjection.ts @@ -0,0 +1,246 @@ +import { isMainThread, Worker, MessageChannel, parentPort, workerData } from 'node:worker_threads'; +import { AbstractProjection, type AbstractProjectionParams } from '../AbstractProjection'; +import type { IEvent } from '../interfaces'; +import * as Comlink from 'comlink'; +import { nodeEndpoint, createWorker } from './utils'; +import { extractErrorDetails } from '../utils'; +import { isWorkerData, type IWorkerData, type WorkerInitMessage } from './protocol'; + +export type AbstractWorkerProjectionParams = AbstractProjectionParams & { + + /** + * Required in the main thread to spawn a worker (derived projection module path). + * Not used in the worker thread. + */ + workerModulePath?: string; + + /** + * When `false`, runs projection + view in the current thread (no Worker, no RPC). + * Intended for tests and environments where worker threads aren't desired. + */ + useWorkerThreads?: boolean; +}; + +interface IRemoteProjectionApi { + project(event: IEvent): Promise | void; + _project(event: IEvent): Promise | void; + ping(): true; +} + +interface IMainThreadProjection { + get remoteProjection(): Comlink.Remote; + get remoteView(): Comlink.Remote; +} + +/** + * Projection base class that can run projection handlers and the associated view in a worker thread + * to isolate CPU-heavy work and keep the main thread responsive + */ +export abstract class AbstractWorkerProjection extends AbstractProjection { + + #worker?: Worker; + readonly #workerInit?: Promise; + readonly #remoteProjection?: Comlink.Remote; + readonly #remoteView?: Comlink.Remote; + readonly #useWorkerThreads: boolean; + + /** + * Creates an instance of a class derived from AbstractWorkerProjection in a Worker thread + * + * @param factory - Optional factory function to create the projection instance + */ + static createWorkerInstance>( + this: new (...args: any[]) => T, + factory?: () => T + ): T { + if (!parentPort) + throw new Error('createWorkerInstance can only be called from a Worker thread'); + if (!isWorkerData(workerData)) + throw new Error('workerData does not contain projectionPort and viewPort'); + + const workerProjectionInstance = factory?.() ?? new this(); + const workerProjectionInstanceApi: IRemoteProjectionApi = { + project: event => workerProjectionInstance.project(event), + _project: event => workerProjectionInstance._project(event), + ping: () => workerProjectionInstance._pong() + }; + + Comlink.expose(workerProjectionInstanceApi, nodeEndpoint(workerData.projectionPort)); + Comlink.expose(workerProjectionInstance.view, nodeEndpoint(workerData.viewPort)); + + parentPort.postMessage({ type: 'ready' } satisfies WorkerInitMessage); + + return workerProjectionInstance; + } + + /** + * Convenience wrapper for module-level bootstrapping. + * + * In the main thread, does nothing. + * In a worker thread, creates and exposes the projection singleton (same as createWorkerInstance). + */ + static createInstanceIfWorkerThread>( + this: (new (...args: any[]) => T) & { createWorkerInstance: (factory?: () => T) => T }, + factory?: () => T + ): T | undefined { + if (isMainThread) + return undefined; + + return this.createWorkerInstance(factory); + } + + async project(event: IEvent): Promise { + if (this.#useWorkerThreads && isMainThread) { + if (!this.#worker) + await this.#workerInit; + + return this.remoteProjection.project(event); + } + + return super.project(event); + } + + /** + * Proxy to the projection instance in the worker thread + */ + get remoteProjection(): Comlink.Remote { + this.assertMainThread(); + return this.#remoteProjection!; + } + + /** + * Proxy to the projection instance in the worker thread (awaits worker init) + */ + get remoteProjectionInitializer(): Promise> { + this.assertMainThread(); + return this.ensureWorkerReady().then(() => this.remoteProjection); + } + + /** + * Proxy to the view instance in the worker thread + */ + get remoteView(): Comlink.Remote { + this.assertMainThread(); + return this.#remoteView!; + } + + get view(): TView { + if (this.#useWorkerThreads && isMainThread) + return this.remoteView as unknown as TView; + + return super.view; + } + + /** + * Proxy to the view instance in the worker thread (awaits worker init) + */ + get remoteViewInitializer(): Promise> { + this.assertMainThread(); + return this.ensureWorkerReady().then(() => this.remoteView); + } + + constructor({ + workerModulePath, + useWorkerThreads = true, + view, + viewLocker, + eventLocker, + logger + }: AbstractWorkerProjectionParams = {}) { + super({ + view, + viewLocker, + eventLocker, + logger + }); + + this.#useWorkerThreads = useWorkerThreads; + + if (this.#useWorkerThreads && isMainThread) { + if (!workerModulePath) + throw new TypeError('workerModulePath parameter is required in the main thread when useWorkerThreads=true'); + + const { port1: projectionPortMain, port2: projectionPort } = new MessageChannel(); + const { port1: viewPortMain, port2: viewPort } = new MessageChannel(); + + this.#workerInit = this._createWorker(workerModulePath, { + projectionPort, + viewPort + }).then(worker => { + this.#worker = worker; + worker.once('error', this._onWorkerError); + worker.once('exit', this._onWorkerExit); + return worker; + }); + + this.#workerInit.catch(() => { }); + + this.#remoteProjection = Comlink.wrap(nodeEndpoint(projectionPortMain)); + this.#remoteView = Comlink.wrap(nodeEndpoint(viewPortMain)); + } + } + + // eslint-disable-next-line class-methods-use-this + protected async _createWorker(workerModulePath: string, data: IWorkerData): Promise { + return createWorker(workerModulePath, data); + } + + protected _onWorkerError = (error: unknown) => { + this._logger?.error('worker error', { + error: extractErrorDetails(error) + }); + }; + + protected _onWorkerExit = (exitCode: number) => { + if (exitCode !== 0) + this._logger?.error(`worker exited with code ${exitCode}`); + }; + + protected _pong(): true { + this.assertWorkerThread(); + return true; + } + + protected assertMainThread(): asserts this is this & IMainThreadProjection { + if (!isMainThread) + throw new Error('This method can only be called from the main thread'); + if (!this.#useWorkerThreads) + throw new Error('Worker threads are disabled for this projection instance'); + if (!this.#workerInit) + throw new Error('Worker instance is not initialized'); + if (!this.#remoteProjection) + throw new Error('Remote projection instance is not initialized'); + if (!this.#remoteView) + throw new Error('Remote view instance is not initialized'); + } + + // eslint-disable-next-line class-methods-use-this + protected assertWorkerThread() { + if (!parentPort) + throw new Error('This method can only be called from a Worker thread'); + } + + async ensureWorkerReady(): Promise { + if (this.#useWorkerThreads && isMainThread) + await this.#workerInit; + } + + protected async _project(event: IEvent): Promise { + if (this.#useWorkerThreads && isMainThread) { + if (!this.#worker) + await this.#workerInit; + + return this.remoteProjection._project(event); + } + + return super._project(event); + } + + dispose() { + if (this.#useWorkerThreads && isMainThread) { + this.#remoteProjection?.[Comlink.releaseProxy](); + this.#remoteView?.[Comlink.releaseProxy](); + this.#worker?.terminate(); + } + } +} diff --git a/src/workers/index.ts b/src/workers/index.ts new file mode 100644 index 0000000..f5e88b1 --- /dev/null +++ b/src/workers/index.ts @@ -0,0 +1 @@ +export * from './AbstractWorkerProjection'; diff --git a/src/workers/protocol.ts b/src/workers/protocol.ts new file mode 100644 index 0000000..5c9cd14 --- /dev/null +++ b/src/workers/protocol.ts @@ -0,0 +1,23 @@ +import type { MessagePort } from 'node:worker_threads'; + +export interface IWorkerData { + projectionPort: MessagePort, + viewPort: MessagePort +} + +export const isWorkerData = (obj: unknown): obj is IWorkerData => + typeof obj === 'object' + && obj !== null + && 'projectionPort' in obj + && !!obj.projectionPort + && 'viewPort' in obj + && !!obj.viewPort; + +export type WorkerInitMessage = { type: 'ready' }; + +export const isWorkerInitMessage = (msg: unknown): msg is WorkerInitMessage => + typeof msg === 'object' + && msg !== null + && 'type' in msg + && msg.type === 'ready'; + diff --git a/src/workers/readme.md b/src/workers/readme.md new file mode 100644 index 0000000..efa0942 --- /dev/null +++ b/src/workers/readme.md @@ -0,0 +1,90 @@ +# Workers (Worker Projections) + +This module provides `AbstractWorkerProjection`, which lets you run projection handlers and view +computations inside a Node.js `worker_threads` Worker while keeping an `AbstractProjection`-like +API in the main thread. + +## Import + +CommonJS: + +```js +const { AbstractWorkerProjection } = require('node-cqrs/workers'); +``` + +ESM: + +```js +import { AbstractWorkerProjection } from 'node-cqrs/workers'; +``` + +## Defining a worker projection + +Key points: + +- The same projection module is used as the **worker entry point**. +- The module should bootstrap the worker-side singleton via + `YourProjection.createInstanceIfWorkerThread()`. +- In the main thread, `project()` automatically waits for worker startup (so `ensureWorkerReady()` is optional). + +Example (CommonJS): + +```js +const { AbstractWorkerProjection } = require('node-cqrs/workers'); + +class CounterView { + counter = 0; + increment() { this.counter += 1; } + getCounter() { return this.counter; } +} + +class CounterProjection extends AbstractWorkerProjection { + constructor() { + super({ + workerModulePath: __filename, + view: new CounterView() + }); + } + + somethingHappened() { + this.view.increment(); + } +} + +CounterProjection.createInstanceIfWorkerThread(); + +module.exports = CounterProjection; +``` + +## Using it (main thread) + +```js +const CounterProjection = require('./CounterProjection.cjs'); + +const projection = new CounterProjection(); +await projection.project({ id: '1', type: 'somethingHappened' }); + +// `projection.view` is a remote proxy (methods-only) +const counter = await projection.view.getCounter(); + +projection.dispose(); +``` + +## `workerModulePath` patterns + +- **CommonJS**: `__filename` (inside the projection module). +- **ESM**: `fileURLToPath(import.meta.url)` inside the projection module. + +Note: `workerModulePath` must point to the **JavaScript file that Node can execute** +(e.g. `dist/...` in TS projects), not a TypeScript source file. + +Tip: call `await projection.ensureWorkerReady()` if you want to fail fast on worker startup +before processing events. + +## Disabling workers (tests) + +To run everything in-thread (no Worker, no RPC): + +```js +const projection = new CounterProjection({ useWorkerThreads: false }); +``` diff --git a/src/workers/utils/createWorker.ts b/src/workers/utils/createWorker.ts new file mode 100644 index 0000000..a31c8d8 --- /dev/null +++ b/src/workers/utils/createWorker.ts @@ -0,0 +1,61 @@ +import { Worker } from 'node:worker_threads'; +import * as path from 'node:path'; +import { isWorkerInitMessage, type IWorkerData } from '../protocol'; + +/** + * Create a worker instance, await a handshake or a failure + * + * @param workerModulePath - Path to worker module + * @param ports - Container with MessagePorts for communication with worker projection and view instances + * @returns Worker instance + */ +export async function createWorker(workerModulePath: string, ports: IWorkerData) { + + const workerEntrypoint = path.isAbsolute(workerModulePath) ? + workerModulePath : + path.resolve(process.cwd(), workerModulePath); + + const worker = new Worker(workerEntrypoint, { + workerData: ports, + transferList: [ + ports.projectionPort, + ports.viewPort + ] + }); + + await new Promise((resolve, reject) => { + + const cleanup = () => { + // eslint-disable-next-line no-use-before-define + worker.off('error', onError); + // eslint-disable-next-line no-use-before-define + worker.off('message', onMessage); + // eslint-disable-next-line no-use-before-define + worker.off('exit', onExit); + }; + + const onMessage = (msg: unknown) => { + if (!isWorkerInitMessage(msg)) + return; + + cleanup(); + resolve(undefined); + }; + + const onError = (err: unknown) => { + cleanup(); + reject(err); + }; + + const onExit = (exitCode: number) => { + cleanup(); + reject(new Error(`Worker exited prematurely with exit code ${exitCode}`)); + }; + + worker.on('message', onMessage); + worker.once('error', onError); + worker.once('exit', onExit); + }); + + return worker; +} diff --git a/src/workers/utils/index.ts b/src/workers/utils/index.ts new file mode 100644 index 0000000..d336324 --- /dev/null +++ b/src/workers/utils/index.ts @@ -0,0 +1,2 @@ +export * from './createWorker'; +export * from './nodeEndpoint'; diff --git a/src/workers/utils/nodeEndpoint.ts b/src/workers/utils/nodeEndpoint.ts new file mode 100644 index 0000000..13869bd --- /dev/null +++ b/src/workers/utils/nodeEndpoint.ts @@ -0,0 +1,7 @@ +import * as Comlink from 'comlink'; + +// Jest (CJS) cannot import the ESM adapter; +// the UMD build is CJS/UMD but the default export shape varies by loader +const nodeEndpointModule = require('comlink/dist/umd/node-adapter'); +export const nodeEndpoint: (arg: any) => Comlink.Endpoint = + (nodeEndpointModule?.default ?? nodeEndpointModule) as any; diff --git a/tests/integration/rabbitmq/RabbitMqEventBus.test.ts b/tests/integration/rabbitmq/RabbitMqEventBus.test.ts index 10c31ad..0c262ba 100644 --- a/tests/integration/rabbitmq/RabbitMqEventBus.test.ts +++ b/tests/integration/rabbitmq/RabbitMqEventBus.test.ts @@ -2,11 +2,7 @@ import * as amqplib from 'amqplib'; import { RabbitMqGateway } from '../../../src/rabbitmq/RabbitMqGateway'; import { RabbitMqEventBus } from '../../../src/rabbitmq/RabbitMqEventBus'; import { IMessage, IEvent } from '../../../src/interfaces'; - -const delay = (ms: number) => new Promise(res => { - const t = setTimeout(res, ms); - t.unref(); -}); +import { delay } from './utils'; describe('RabbitMqEventBus', () => { diff --git a/tests/integration/rabbitmq/RabbitMqGateway.test.ts b/tests/integration/rabbitmq/RabbitMqGateway.test.ts index bc717b2..31fa52a 100644 --- a/tests/integration/rabbitmq/RabbitMqGateway.test.ts +++ b/tests/integration/rabbitmq/RabbitMqGateway.test.ts @@ -1,7 +1,7 @@ import { RabbitMqGateway } from '../../../src/rabbitmq/RabbitMqGateway'; import { IMessage } from '../../../src/interfaces'; import * as amqplib from 'amqplib'; -import { delay } from '../../../src/utils'; +import { delay } from './utils'; import { Deferred } from '../../../src/utils/Deferred'; import { EventEmitter } from 'stream'; diff --git a/src/utils/delay.ts b/tests/integration/rabbitmq/utils/delay.ts similarity index 100% rename from src/utils/delay.ts rename to tests/integration/rabbitmq/utils/delay.ts diff --git a/tests/integration/rabbitmq/utils/index.ts b/tests/integration/rabbitmq/utils/index.ts new file mode 100644 index 0000000..1e0db20 --- /dev/null +++ b/tests/integration/rabbitmq/utils/index.ts @@ -0,0 +1 @@ +export * from './delay'; diff --git a/tests/unit/workers/AbstractWorkerProjection.test.ts b/tests/unit/workers/AbstractWorkerProjection.test.ts new file mode 100644 index 0000000..3570c69 --- /dev/null +++ b/tests/unit/workers/AbstractWorkerProjection.test.ts @@ -0,0 +1,222 @@ +import { expect } from 'chai'; +import * as path from 'node:path'; +import type { IEvent } from '../../../src/interfaces'; + +type ProjectionFixtureCtor = typeof import('./fixtures/ProjectionFixture.cjs'); +// eslint-disable-next-line global-require +const ProjectionFixture = require('./fixtures/ProjectionFixture.cjs') as ProjectionFixtureCtor; + +function createEventStore(events: IEvent[]) { + return { + getEventsByTypes: (types: string[], options?: { afterEvent?: IEvent }) => (async function* () { + const afterId = options?.afterEvent?.id; + const startIndex = afterId ? Math.max(0, events.findIndex(e => e.id === afterId) + 1) : 0; + for (const event of events.slice(startIndex)) { + if (types.includes(event.type)) + yield event; + } + }()) + } as any; +} + +describe('AbstractWorkerProjection', () => { + + it('handles missing worker module error', async () => { + const workerModulePath = path.resolve(process.cwd(), 'tests/unit/workers/fixtures/DOES_NOT_EXIST.cjs'); + const projection = new ProjectionFixture({ workerModulePath }); + try { + let error: any; + try { + await projection.ensureWorkerReady(); + } + catch (err) { + error = err; + } + + expect(error).to.be.ok; + expect(error).to.have.property('message').that.includes('DOES_NOT_EXIST'); + } + finally { + projection.dispose(); + } + }); + + it('runs in-thread when workers are disabled', async () => { + const projection = new ProjectionFixture({ + useWorkerThreads: false, + workerModulePath: 'tests/unit/workers/fixtures/DOES_NOT_EXIST.cjs' + }); + try { + await projection.ensureWorkerReady(); + + await projection.project({ id: '1', type: 'somethingHappened' }); + expect(await projection.view.getCounter()).to.equal(1); + + let error: any; + try { + // accessing remote API should fail when workers are disabled + projection.remoteProjection; + } + catch (err) { + error = err; + } + + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Worker threads are disabled'); + } + finally { + projection.dispose(); + } + }); + + it('awaits view method calls while restoring', async () => { + const projection = new ProjectionFixture(); + try { + const eventStore = createEventStore([ + { id: '1', type: 'slowHappened' } + ]); + + const restorePromise = projection.restore(eventStore); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const counterPromise = projection.view.getCounter(); + + const resolvedEarly = await Promise.race([ + counterPromise.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 20)) + ]); + + expect(resolvedEarly).to.equal(false); + + await restorePromise; + + expect(await counterPromise).to.equal(1); + } + finally { + projection.dispose(); + } + }); + + it('spawns worker with an instance of projection', async () => { + const projection = new ProjectionFixture(); + try { + await projection.ensureWorkerReady(); + const pong = await projection.remoteProjection.ping(); + expect(pong).to.be.ok; + } + finally { + projection.dispose(); + } + }); + + it('exposes remote view', async () => { + const projection = new ProjectionFixture(); + try { + await projection.ensureWorkerReady(); + const counter = await projection.view.getCounter(); + expect(counter).to.eq(0); + } + finally { + projection.dispose(); + } + }); + + it('locks view during restore and unlocks on success', async () => { + const projection = new ProjectionFixture(); + try { + const eventStore = createEventStore([ + { id: '1', type: 'somethingHappened' }, + { id: '2', type: 'somethingHappened' } + ]); + + await projection.restore(eventStore); + + await projection.ensureWorkerReady(); + expect(await projection.view.getCalls()).to.deep.equal({ lock: 1, unlock: 1 }); + expect(await projection.view.isReady()).to.equal(true); + expect((await projection.view.getLastEvent())?.id).to.equal('2'); + expect(await projection.view.getCounter()).to.equal(2); + } + finally { + projection.dispose(); + } + }); + + it('restores only events after getLastEvent', async () => { + const projection = new ProjectionFixture(); + try { + await projection.restore(createEventStore([ + { id: '1', type: 'somethingHappened' }, + { id: '2', type: 'somethingHappened' } + ])); + + await projection.restore(createEventStore([ + { id: '1', type: 'somethingBadHappened' }, + { id: '2', type: 'somethingBadHappened' }, + { id: '3', type: 'somethingHappened' } + ])); + + await projection.ensureWorkerReady(); + expect(await projection.view.getCalls()).to.deep.equal({ lock: 2, unlock: 2 }); + expect((await projection.view.getLastEvent())?.id).to.equal('3'); + expect(await projection.view.getCounter()).to.equal(3); + } + finally { + projection.dispose(); + } + }); + + it('halts restore on handler error and keeps view locked', async () => { + const projection = new ProjectionFixture(); + try { + const eventStore = createEventStore([ + { id: '1', type: 'somethingHappened' }, + { id: '2', type: 'somethingBadHappened' }, + { id: '3', type: 'somethingHappened' } + ]); + + let error: any; + try { + await projection.restore(eventStore); + } + catch (err) { + error = err; + } + + expect(error).to.be.instanceOf(Error); + expect(error).to.have.property('message', 'boom'); + + await projection.ensureWorkerReady(); + expect(await projection.view.getCalls()).to.deep.equal({ lock: 1, unlock: 0 }); + expect(await projection.view.isReady()).to.equal(false); + expect((await projection.view.getLastEvent())?.id).to.equal('1'); + expect(await projection.view.getCounterNowait()).to.equal(1); + } + finally { + projection.dispose(); + } + }); + + it('does not project events when event lock is not obtained', async () => { + const projection = new ProjectionFixture(); + try { + await projection.ensureWorkerReady(); + await projection.view.setSkipIds(['1']); + + const eventStore = createEventStore([ + { id: '1', type: 'somethingHappened' }, + { id: '2', type: 'somethingHappened' } + ]); + + await projection.restore(eventStore); + + await projection.ensureWorkerReady(); + expect((await projection.view.getLastEvent())?.id).to.equal('2'); + expect(await projection.view.getCounter()).to.equal(1); + } + finally { + projection.dispose(); + } + }); +}); diff --git a/tests/unit/workers/fixtures/ProjectionFixture.cjs b/tests/unit/workers/fixtures/ProjectionFixture.cjs new file mode 100644 index 0000000..28e8e48 --- /dev/null +++ b/tests/unit/workers/fixtures/ProjectionFixture.cjs @@ -0,0 +1,121 @@ +/** @type {typeof import('../../../../src/workers')} */ +// @ts-ignore +const workers = require('../../../../dist/workers'); + +const { AbstractWorkerProjection } = workers; + +class ViewFixture { + counter = 0; + ready = true; + + #calls = { + lock: 0, + unlock: 0 + }; + #lastEvent = null; + #skipIds = new Set(); + #readyPromise = Promise.resolve(); + #resolveReady = null; + + increment() { + this.counter += 1; + } + + async getCounter() { + if (!this.ready) + await this.once('ready'); + return this.counter; + } + + getCounterNowait() { + return this.counter; + } + + setSkipIds(ids = []) { + this.#skipIds = new Set(ids); + } + + getCalls() { + return { ...this.#calls }; + } + + isReady() { + return this.ready; + } + + async lock() { + this.#calls.lock += 1; + this.ready = false; + this.#readyPromise = new Promise(resolve => { + this.#resolveReady = resolve; + }); + return true; + } + + async unlock() { + this.#calls.unlock += 1; + this.ready = true; + if (this.#resolveReady) + this.#resolveReady(); + this.#resolveReady = null; + } + + once(event) { + if (event !== 'ready') + throw new Error(`Unexpected event: ${event}`); + return this.#readyPromise; + } + + getLastEvent() { + return this.#lastEvent; + } + + tryMarkAsProjecting(event) { + if (event?.id && this.#skipIds.has(event.id)) + return false; + return true; + } + + markAsProjected(event) { + this.#lastEvent = event; + } +} + +/** + * @extends {AbstractWorkerProjection} + */ +class ProjectionFixture extends AbstractWorkerProjection { + + /** + * @param {any} container + */ + constructor({ + workerModulePath = __filename, + useWorkerThreads, + logger + } = {}) { + super({ + workerModulePath, + useWorkerThreads, + view: new ViewFixture(), + logger + }); + } + + async somethingHappened() { + this.view.increment(); + } + + async slowHappened() { + await new Promise(resolve => setTimeout(resolve, 50)); + this.view.increment(); + } + + async somethingBadHappened() { + throw new Error('boom'); + } +} + +ProjectionFixture.createInstanceIfWorkerThread(); + +module.exports = ProjectionFixture; diff --git a/tests/unit/workers/fixtures/ProjectionFixture.ts b/tests/unit/workers/fixtures/ProjectionFixture.ts new file mode 100644 index 0000000..1cebcfe --- /dev/null +++ b/tests/unit/workers/fixtures/ProjectionFixture.ts @@ -0,0 +1,37 @@ +import { AbstractWorkerProjection } from '../../../../src/workers'; + +class ViewFixture { + counter = 0; + + increment() { + this.counter += 1; + } + + getCounter() { + return this.counter; + } +} + +export class ProjectionFixture extends AbstractWorkerProjection { + + get view() { + return super.view; + } + + constructor({ workerModulePath = __filename } = {}) { + super({ + workerModulePath, + view: new ViewFixture() + }); + } + + async somethingHappened() { + this.view.increment(); + } + + async somethingBadHappened() { + throw new Error('boom'); + } +} + +ProjectionFixture.createInstanceIfWorkerThread();