@cqrs-ddd/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMMERCIAL_LICENSE.txt +34 -0
- package/LICENSE +661 -0
- package/README.md +707 -0
- package/dist/application/base.command.d.ts +44 -0
- package/dist/application/base.command.d.ts.map +1 -0
- package/dist/application/base.command.js +58 -0
- package/dist/application/base.command.js.map +1 -0
- package/dist/application/base.query.d.ts +42 -0
- package/dist/application/base.query.d.ts.map +1 -0
- package/dist/application/base.query.js +52 -0
- package/dist/application/base.query.js.map +1 -0
- package/dist/application/command-base.handler.d.ts +106 -0
- package/dist/application/command-base.handler.d.ts.map +1 -0
- package/dist/application/command-base.handler.js +119 -0
- package/dist/application/command-base.handler.js.map +1 -0
- package/dist/application/index.d.ts +16 -0
- package/dist/application/index.d.ts.map +1 -0
- package/dist/application/index.js +33 -0
- package/dist/application/index.js.map +1 -0
- package/dist/application/ports/cache.port.d.ts +104 -0
- package/dist/application/ports/cache.port.d.ts.map +1 -0
- package/dist/application/ports/cache.port.js +17 -0
- package/dist/application/ports/cache.port.js.map +1 -0
- package/dist/application/ports/command-repository.port.d.ts +28 -0
- package/dist/application/ports/command-repository.port.d.ts.map +1 -0
- package/dist/application/ports/command-repository.port.js +4 -0
- package/dist/application/ports/command-repository.port.js.map +1 -0
- package/dist/application/ports/domain-event-publisher.port.d.ts +18 -0
- package/dist/application/ports/domain-event-publisher.port.d.ts.map +1 -0
- package/dist/application/ports/domain-event-publisher.port.js +4 -0
- package/dist/application/ports/domain-event-publisher.port.js.map +1 -0
- package/dist/application/ports/query-repository.port.d.ts +26 -0
- package/dist/application/ports/query-repository.port.d.ts.map +1 -0
- package/dist/application/ports/query-repository.port.js +4 -0
- package/dist/application/ports/query-repository.port.js.map +1 -0
- package/dist/application/ports/write-side-aggregate-repository.port.d.ts +33 -0
- package/dist/application/ports/write-side-aggregate-repository.port.d.ts.map +1 -0
- package/dist/application/ports/write-side-aggregate-repository.port.js +4 -0
- package/dist/application/ports/write-side-aggregate-repository.port.js.map +1 -0
- package/dist/application/query.options.d.ts +22 -0
- package/dist/application/query.options.d.ts.map +1 -0
- package/dist/application/query.options.js +4 -0
- package/dist/application/query.options.js.map +1 -0
- package/dist/application/request-fields.helper.d.ts +5 -0
- package/dist/application/request-fields.helper.d.ts.map +1 -0
- package/dist/application/request-fields.helper.js +20 -0
- package/dist/application/request-fields.helper.js.map +1 -0
- package/dist/application/tenant-resolver.d.ts +56 -0
- package/dist/application/tenant-resolver.d.ts.map +1 -0
- package/dist/application/tenant-resolver.js +60 -0
- package/dist/application/tenant-resolver.js.map +1 -0
- package/dist/domain/decorators/apply-mutation.decorator.d.ts +64 -0
- package/dist/domain/decorators/apply-mutation.decorator.d.ts.map +1 -0
- package/dist/domain/decorators/apply-mutation.decorator.js +86 -0
- package/dist/domain/decorators/apply-mutation.decorator.js.map +1 -0
- package/dist/domain/decorators/mutable.decorator.d.ts +54 -0
- package/dist/domain/decorators/mutable.decorator.d.ts.map +1 -0
- package/dist/domain/decorators/mutable.decorator.js +76 -0
- package/dist/domain/decorators/mutable.decorator.js.map +1 -0
- package/dist/domain/events/domain.event.d.ts +13 -0
- package/dist/domain/events/domain.event.d.ts.map +1 -0
- package/dist/domain/events/domain.event.js +20 -0
- package/dist/domain/events/domain.event.js.map +1 -0
- package/dist/domain/events/event.interface.d.ts +9 -0
- package/dist/domain/events/event.interface.d.ts.map +1 -0
- package/dist/domain/events/event.interface.js +4 -0
- package/dist/domain/events/event.interface.js.map +1 -0
- package/dist/domain/events/root-domain.event.d.ts +50 -0
- package/dist/domain/events/root-domain.event.d.ts.map +1 -0
- package/dist/domain/events/root-domain.event.js +156 -0
- package/dist/domain/events/root-domain.event.js.map +1 -0
- package/dist/domain/exceptions/concurrency-conflict.error.d.ts +20 -0
- package/dist/domain/exceptions/concurrency-conflict.error.d.ts.map +1 -0
- package/dist/domain/exceptions/concurrency-conflict.error.js +32 -0
- package/dist/domain/exceptions/concurrency-conflict.error.js.map +1 -0
- package/dist/domain/exceptions/domain.exception.d.ts +30 -0
- package/dist/domain/exceptions/domain.exception.d.ts.map +1 -0
- package/dist/domain/exceptions/domain.exception.js +39 -0
- package/dist/domain/exceptions/domain.exception.js.map +1 -0
- package/dist/domain/exceptions/entity-not-found.exception.d.ts +14 -0
- package/dist/domain/exceptions/entity-not-found.exception.d.ts.map +1 -0
- package/dist/domain/exceptions/entity-not-found.exception.js +23 -0
- package/dist/domain/exceptions/entity-not-found.exception.js.map +1 -0
- package/dist/domain/exceptions/invalid-value.exception.d.ts +35 -0
- package/dist/domain/exceptions/invalid-value.exception.d.ts.map +1 -0
- package/dist/domain/exceptions/invalid-value.exception.js +67 -0
- package/dist/domain/exceptions/invalid-value.exception.js.map +1 -0
- package/dist/domain/exceptions/missing-tenant-context.exception.d.ts +24 -0
- package/dist/domain/exceptions/missing-tenant-context.exception.d.ts.map +1 -0
- package/dist/domain/exceptions/missing-tenant-context.exception.js +33 -0
- package/dist/domain/exceptions/missing-tenant-context.exception.js.map +1 -0
- package/dist/domain/exceptions/transient-operation.error.d.ts +17 -0
- package/dist/domain/exceptions/transient-operation.error.d.ts.map +1 -0
- package/dist/domain/exceptions/transient-operation.error.js +27 -0
- package/dist/domain/exceptions/transient-operation.error.js.map +1 -0
- package/dist/domain/exceptions/unknown-mutable-field.error.d.ts +14 -0
- package/dist/domain/exceptions/unknown-mutable-field.error.d.ts.map +1 -0
- package/dist/domain/exceptions/unknown-mutable-field.error.js +24 -0
- package/dist/domain/exceptions/unknown-mutable-field.error.js.map +1 -0
- package/dist/domain/index.d.ts +26 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +43 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/domain/interfaces/root-entity-snapshot.interface.d.ts +7 -0
- package/dist/domain/interfaces/root-entity-snapshot.interface.d.ts.map +1 -0
- package/dist/domain/interfaces/root-entity-snapshot.interface.js +4 -0
- package/dist/domain/interfaces/root-entity-snapshot.interface.js.map +1 -0
- package/dist/domain/models/aggregate-root.d.ts +87 -0
- package/dist/domain/models/aggregate-root.d.ts.map +1 -0
- package/dist/domain/models/aggregate-root.js +122 -0
- package/dist/domain/models/aggregate-root.js.map +1 -0
- package/dist/domain/models/root.entity.d.ts +208 -0
- package/dist/domain/models/root.entity.d.ts.map +1 -0
- package/dist/domain/models/root.entity.js +350 -0
- package/dist/domain/models/root.entity.js.map +1 -0
- package/dist/domain/rules/number.rule.d.ts +81 -0
- package/dist/domain/rules/number.rule.d.ts.map +1 -0
- package/dist/domain/rules/number.rule.js +81 -0
- package/dist/domain/rules/number.rule.js.map +1 -0
- package/dist/domain/rules/rule-support.d.ts +6 -0
- package/dist/domain/rules/rule-support.d.ts.map +1 -0
- package/dist/domain/rules/rule-support.js +19 -0
- package/dist/domain/rules/rule-support.js.map +1 -0
- package/dist/domain/rules/text.rule.d.ts +98 -0
- package/dist/domain/rules/text.rule.d.ts.map +1 -0
- package/dist/domain/rules/text.rule.js +103 -0
- package/dist/domain/rules/text.rule.js.map +1 -0
- package/dist/domain/rules/value-violation.d.ts +44 -0
- package/dist/domain/rules/value-violation.d.ts.map +1 -0
- package/dist/domain/rules/value-violation.js +4 -0
- package/dist/domain/rules/value-violation.js.map +1 -0
- package/dist/http/domain-error-http-status.d.ts +43 -0
- package/dist/http/domain-error-http-status.d.ts.map +1 -0
- package/dist/http/domain-error-http-status.js +27 -0
- package/dist/http/domain-error-http-status.js.map +1 -0
- package/dist/http/index.d.ts +9 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +26 -0
- package/dist/http/index.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence/cache/cache-logger.d.ts +29 -0
- package/dist/persistence/cache/cache-logger.d.ts.map +1 -0
- package/dist/persistence/cache/cache-logger.js +23 -0
- package/dist/persistence/cache/cache-logger.js.map +1 -0
- package/dist/persistence/cache/memory.cache.d.ts +72 -0
- package/dist/persistence/cache/memory.cache.d.ts.map +1 -0
- package/dist/persistence/cache/memory.cache.js +183 -0
- package/dist/persistence/cache/memory.cache.js.map +1 -0
- package/dist/persistence/command-repository.abstract.d.ts +39 -0
- package/dist/persistence/command-repository.abstract.d.ts.map +1 -0
- package/dist/persistence/command-repository.abstract.js +43 -0
- package/dist/persistence/command-repository.abstract.js.map +1 -0
- package/dist/persistence/decorators/acknowledge-persisted.decorator.d.ts +82 -0
- package/dist/persistence/decorators/acknowledge-persisted.decorator.d.ts.map +1 -0
- package/dist/persistence/decorators/acknowledge-persisted.decorator.js +82 -0
- package/dist/persistence/decorators/acknowledge-persisted.decorator.js.map +1 -0
- package/dist/persistence/decorators/cache.decorator.d.ts +93 -0
- package/dist/persistence/decorators/cache.decorator.d.ts.map +1 -0
- package/dist/persistence/decorators/cache.decorator.js +200 -0
- package/dist/persistence/decorators/cache.decorator.js.map +1 -0
- package/dist/persistence/decorators/from-cache.decorator.d.ts +65 -0
- package/dist/persistence/decorators/from-cache.decorator.d.ts.map +1 -0
- package/dist/persistence/decorators/from-cache.decorator.js +165 -0
- package/dist/persistence/decorators/from-cache.decorator.js.map +1 -0
- package/dist/persistence/decorators/map-persistence-errors.decorator.d.ts +88 -0
- package/dist/persistence/decorators/map-persistence-errors.decorator.d.ts.map +1 -0
- package/dist/persistence/decorators/map-persistence-errors.decorator.js +89 -0
- package/dist/persistence/decorators/map-persistence-errors.decorator.js.map +1 -0
- package/dist/persistence/decorators/persisted-write.decorator.d.ts +61 -0
- package/dist/persistence/decorators/persisted-write.decorator.d.ts.map +1 -0
- package/dist/persistence/decorators/persisted-write.decorator.js +57 -0
- package/dist/persistence/decorators/persisted-write.decorator.js.map +1 -0
- package/dist/persistence/helpers/cache-barrier.helper.d.ts +70 -0
- package/dist/persistence/helpers/cache-barrier.helper.d.ts.map +1 -0
- package/dist/persistence/helpers/cache-barrier.helper.js +57 -0
- package/dist/persistence/helpers/cache-barrier.helper.js.map +1 -0
- package/dist/persistence/helpers/cache-key.helper.d.ts +73 -0
- package/dist/persistence/helpers/cache-key.helper.d.ts.map +1 -0
- package/dist/persistence/helpers/cache-key.helper.js +132 -0
- package/dist/persistence/helpers/cache-key.helper.js.map +1 -0
- package/dist/persistence/helpers/cache-owner.helper.d.ts +9 -0
- package/dist/persistence/helpers/cache-owner.helper.d.ts.map +1 -0
- package/dist/persistence/helpers/cache-owner.helper.js +22 -0
- package/dist/persistence/helpers/cache-owner.helper.js.map +1 -0
- package/dist/persistence/helpers/cache-snapshot.helper.d.ts +28 -0
- package/dist/persistence/helpers/cache-snapshot.helper.d.ts.map +1 -0
- package/dist/persistence/helpers/cache-snapshot.helper.js +42 -0
- package/dist/persistence/helpers/cache-snapshot.helper.js.map +1 -0
- package/dist/persistence/helpers/cache-version.helper.d.ts +29 -0
- package/dist/persistence/helpers/cache-version.helper.d.ts.map +1 -0
- package/dist/persistence/helpers/cache-version.helper.js +71 -0
- package/dist/persistence/helpers/cache-version.helper.js.map +1 -0
- package/dist/persistence/index.d.ts +23 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +40 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/persistence-dialect.d.ts +35 -0
- package/dist/persistence/persistence-dialect.d.ts.map +1 -0
- package/dist/persistence/persistence-dialect.js +31 -0
- package/dist/persistence/persistence-dialect.js.map +1 -0
- package/dist/persistence/query-repository.abstract.d.ts +76 -0
- package/dist/persistence/query-repository.abstract.d.ts.map +1 -0
- package/dist/persistence/query-repository.abstract.js +67 -0
- package/dist/persistence/query-repository.abstract.js.map +1 -0
- package/dist/types/Method.type.d.ts +2 -0
- package/dist/types/Method.type.d.ts.map +1 -0
- package/dist/types/Method.type.js +4 -0
- package/dist/types/Method.type.js.map +1 -0
- package/package.json +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
# @cqrs-ddd/core
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@cqrs-ddd/core)
|
|
4
|
+
[](https://www.npmjs.com/package/@cqrs-ddd/core)
|
|
5
|
+
|
|
6
|
+
Framework-neutral building blocks for domain-driven design in TypeScript: aggregates with
|
|
7
|
+
versioned mutations, detached domain events, a command handler that publishes those
|
|
8
|
+
events, repository contracts, persistence lifecycle decorators, a revision-fenced
|
|
9
|
+
repository cache, tenant-scoped cache keys and HTTP status mapping for its errors.
|
|
10
|
+
|
|
11
|
+
It depends on no framework and no ORM. MikroORM adapters are in
|
|
12
|
+
[`@cqrs-ddd/mikro-orm`](https://www.npmjs.com/package/@cqrs-ddd/mikro-orm). It works in a plain Node service, and in a NestJS
|
|
13
|
+
application through structural compatibility: see [Using it from NestJS](#using-it-from-nestjs).
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
- [Installation](#installation)
|
|
18
|
+
- [Entry points](#entry-points)
|
|
19
|
+
- [Aggregates](#aggregates)
|
|
20
|
+
- [Value rules](#value-rules)
|
|
21
|
+
- [Domain events](#domain-events)
|
|
22
|
+
- [Commands and event publication](#commands-and-event-publication)
|
|
23
|
+
- [Write-side repositories](#write-side-repositories)
|
|
24
|
+
- [Read-side repositories](#read-side-repositories)
|
|
25
|
+
- [The repository cache](#the-repository-cache)
|
|
26
|
+
- [Tenant-scoped cache keys](#tenant-scoped-cache-keys)
|
|
27
|
+
- [HTTP status mapping](#http-status-mapping)
|
|
28
|
+
- [Using it from NestJS](#using-it-from-nestjs)
|
|
29
|
+
- [Moving from ddd/core](#moving-from-dddcore)
|
|
30
|
+
- [Known limits](#known-limits)
|
|
31
|
+
- [License](#license)
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm add @cqrs-ddd/core
|
|
37
|
+
# or
|
|
38
|
+
npm install @cqrs-ddd/core
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Node.js 22 or later. It installs `@cqrs-ddd/uuidv7` and
|
|
42
|
+
`@cqrs-ddd/safe-stringify`, which have no dependencies. To persist with MikroORM, add
|
|
43
|
+
`@cqrs-ddd/mikro-orm` and `@mikro-orm/core` 7.
|
|
44
|
+
|
|
45
|
+
## Entry points
|
|
46
|
+
|
|
47
|
+
Import from the entry point for the layer you are writing. Each one loads only what it
|
|
48
|
+
needs.
|
|
49
|
+
|
|
50
|
+
| Entry | Holds |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `@cqrs-ddd/core/domain` | `AggregateRoot`, `RootEntity`, `RootEntitySnapshot`, `@Mutable`, `getMutableFields`, `@ApplyMutation`, `IEvent`, `DomainEvent`, `RootDomainEvent`, `deepCloneAndFreeze`, `textRule`, `numberRule`, `ValueViolation`, and the errors `DomainException`, `InvalidValueException`, `EntityNotFoundException`, `ConcurrencyConflictError`, `TransientOperationError` (with `isTransientOperationError`), `MissingTenantContextError`, `UnknownMutableFieldError` |
|
|
53
|
+
| `@cqrs-ddd/core/application` | `BaseCommand`, `BaseQuery`, `IQueryOptions`, `CommandBaseHandler`, the ports (`IDomainEventPublisher`, `ICommandRepository`, `IQueryRepository`, `IWriteSideAggregateRepository`, `ICache`, `IVersionedCache`, `isVersionedCache`), `requireTenantId`, `setTenantResolver` |
|
|
54
|
+
| `@cqrs-ddd/core/persistence` | the lifecycle decorators (`@PersistedWrite`, `@Cache`, `@AcknowledgePersisted`, `@MapPersistenceErrors`, `@FromCache`), `QueryRepository`, `CommandRepository`, `MemoryCache` and its injection token `CACHE_TOKEN`, `cacheKey`, `cacheKeyTemplate`, `isCacheNewer`, `toCacheSnapshot`, the mutation-barrier helpers, `consoleCacheLogger`, `safeWarn`, and the persistence dialect contract (`IPersistenceDialect`, `setPersistenceDialect`, `persistenceDialect`) |
|
|
55
|
+
| `@cqrs-ddd/core/http` | `domainErrorHttpStatus` |
|
|
56
|
+
| `@cqrs-ddd/core` | all of the above, plus the `Method` type |
|
|
57
|
+
|
|
58
|
+
No entry point loads an ORM or NestJS.
|
|
59
|
+
|
|
60
|
+
## Aggregates
|
|
61
|
+
|
|
62
|
+
An aggregate extends `RootEntity<TSnapshot>`. It gets a UUIDv7 `id`, `createdAt` and
|
|
63
|
+
`updatedAt`, an optimistic-concurrency `version`, and an event buffer. State changes go
|
|
64
|
+
through domain methods decorated with `@ApplyMutation`, which write only the fields
|
|
65
|
+
declared `@Mutable`:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import {
|
|
69
|
+
ApplyMutation,
|
|
70
|
+
DomainException,
|
|
71
|
+
InvalidValueException,
|
|
72
|
+
Mutable,
|
|
73
|
+
RootEntity,
|
|
74
|
+
type RootEntitySnapshot,
|
|
75
|
+
textRule,
|
|
76
|
+
} from '@cqrs-ddd/core/domain';
|
|
77
|
+
|
|
78
|
+
export class InvalidUsernameException extends InvalidValueException {}
|
|
79
|
+
export class InvalidDepartmentException extends InvalidValueException {}
|
|
80
|
+
export class EmptyUserUpdateException extends DomainException {
|
|
81
|
+
constructor() {
|
|
82
|
+
super('A user update needs at least one field.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface UserSnapshot extends Partial<RootEntitySnapshot> {
|
|
87
|
+
readonly username: string;
|
|
88
|
+
readonly email: string;
|
|
89
|
+
readonly department?: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class User extends RootEntity<UserSnapshot> {
|
|
93
|
+
static readonly aggregateName = 'user';
|
|
94
|
+
static readonly rules = {
|
|
95
|
+
username: textRule({
|
|
96
|
+
field: 'username',
|
|
97
|
+
minLength: 3,
|
|
98
|
+
maxLength: 255,
|
|
99
|
+
error: (violation) => new InvalidUsernameException(violation),
|
|
100
|
+
}),
|
|
101
|
+
department: textRule({
|
|
102
|
+
field: 'department',
|
|
103
|
+
required: false,
|
|
104
|
+
maxLength: 255,
|
|
105
|
+
error: (violation) => new InvalidDepartmentException(violation),
|
|
106
|
+
}),
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
@Mutable<string>({ normalize: (value) => User.rules.username.parse(value) })
|
|
110
|
+
private _username: string;
|
|
111
|
+
|
|
112
|
+
@Mutable<string | null>({ normalize: (value) => User.rules.department.parse(value) })
|
|
113
|
+
private _department: string | null;
|
|
114
|
+
|
|
115
|
+
readonly email: string;
|
|
116
|
+
|
|
117
|
+
private constructor(snapshot: UserSnapshot) {
|
|
118
|
+
super(snapshot);
|
|
119
|
+
this._username = User.rules.username.parse(snapshot.username);
|
|
120
|
+
this._department = User.rules.department.parse(snapshot.department);
|
|
121
|
+
this.email = snapshot.email;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static create(username: string, email: string, department?: string | null): User {
|
|
125
|
+
const user = new User({ username, email, department });
|
|
126
|
+
user.apply(new UserCreatedEvent(user));
|
|
127
|
+
return user;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static fromJSON(snapshot: UserSnapshot): User {
|
|
131
|
+
return new User(snapshot);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get username(): string {
|
|
135
|
+
return this._username;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private set username(value: string) {
|
|
139
|
+
this._username = User.rules.username.parse(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get department(): string | null {
|
|
143
|
+
return this._department;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private set department(value: string | null) {
|
|
147
|
+
this._department = User.rules.department.parse(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@ApplyMutation<User>({ event: (user) => new UserUpdatedEvent(user) })
|
|
151
|
+
update(fields: { username?: string; department?: string | null }): this {
|
|
152
|
+
if (fields.username === undefined && fields.department === undefined) {
|
|
153
|
+
throw new EmptyUserUpdateException();
|
|
154
|
+
}
|
|
155
|
+
this.applyPatch({ username: fields.username, department: fields.department });
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@ApplyMutation<User>({ event: (user) => new UserDeletedEvent(user) })
|
|
160
|
+
delete(): this {
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
toJSON(): UserSnapshot & RootEntitySnapshot {
|
|
165
|
+
return this.freezeState({
|
|
166
|
+
id: this.id,
|
|
167
|
+
username: this._username,
|
|
168
|
+
department: this._department,
|
|
169
|
+
email: this.email,
|
|
170
|
+
createdAt: this.createdAt,
|
|
171
|
+
updatedAt: this.updatedAt,
|
|
172
|
+
version: this.version,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The events are declared in [Domain events](#domain-events). Using the aggregate:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const user = User.create('alice', 'alice@example.test');
|
|
182
|
+
user.version; // 1
|
|
183
|
+
user.getUncommittedEvents(); // [UserCreatedEvent]
|
|
184
|
+
|
|
185
|
+
user.update({ department: 'Research' });
|
|
186
|
+
user.version; // 2
|
|
187
|
+
user.getExpectedVersion(); // 1: the version the next write must find in storage
|
|
188
|
+
|
|
189
|
+
user.update({}); // throws EmptyUserUpdateException; nothing is recorded
|
|
190
|
+
user.update({ username: 'a' }); // throws InvalidUsernameException before any field is written
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `@Mutable` registers a field for `applyPatch` under its property name without a
|
|
194
|
+
leading underscore (`_username` is patched as `username`); `as: 'name'` sets another
|
|
195
|
+
key, and `normalize` validates and converts each value.
|
|
196
|
+
|
|
197
|
+
- `applyPatch(patch)` validates and normalizes every supplied field before writing any
|
|
198
|
+
of them, and ignores `undefined` values. An unknown key throws `UnknownMutableFieldError`.
|
|
199
|
+
- After a successful method, `@ApplyMutation` advances `version` and `updatedAt` and
|
|
200
|
+
records the event. A method that throws records nothing.
|
|
201
|
+
- Validate before patching: a failure after the fields are written (later method code,
|
|
202
|
+
a lifecycle hook, event creation) does not roll them back. Discard or reload the
|
|
203
|
+
instance.
|
|
204
|
+
- `RootEntity.from(value)` rehydrates an instance, a snapshot or a nullish database
|
|
205
|
+
result, and throws a `TypeError` for an incompatible aggregate.
|
|
206
|
+
- `id`, `createdAt` and `updatedAt` have public getters and private setters. The ORM
|
|
207
|
+
hydrates through the setters (see below); application code cannot assign them and
|
|
208
|
+
changes state through domain methods and factories. Give a subclass's own persisted
|
|
209
|
+
fields private setters too, as `username` and `department` above.
|
|
210
|
+
- `version` is the in-memory version; `getExpectedVersion()` is the version last read
|
|
211
|
+
from or written to storage, which a version-conditioned write compares against.
|
|
212
|
+
`acknowledgePersisted()` moves it forward after a successful write; `@PersistedWrite`
|
|
213
|
+
calls it for you.
|
|
214
|
+
|
|
215
|
+
## Value rules
|
|
216
|
+
|
|
217
|
+
`textRule(options)` and `numberRule(options)` define the rule for one field once. The
|
|
218
|
+
aggregate uses the rule's `parse(value)` to normalize the value, and other layers read
|
|
219
|
+
the rule's limits, so a request schema cannot drift from the domain:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
z.string().trim().min(User.rules.username.minLength).max(User.rules.username.maxLength);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`parse(value)` returns the normalized value or throws the rule's error. It is a
|
|
226
|
+
standalone function, and the rule is frozen.
|
|
227
|
+
|
|
228
|
+
| `textRule` option | Effect |
|
|
229
|
+
| --- | --- |
|
|
230
|
+
| `field` | Name reported in the violation and in the default message |
|
|
231
|
+
| `required` | `false` normalizes `null`, `undefined` and blank text to `null`; by default they are rejected |
|
|
232
|
+
| `minLength`, `maxLength` | Length after normalization, in UTF-16 code units like Zod's `.min()` and `.max()` |
|
|
233
|
+
| `pattern` | Regular expression the text must match; a `g` or `y` flag is rejected |
|
|
234
|
+
| `multiline` | `true` accepts tab, line feed and carriage return inside the text |
|
|
235
|
+
| `error` | Builds the error for a violation; defaults to `InvalidValueException` |
|
|
236
|
+
|
|
237
|
+
Text is checked for type and unpaired surrogates, then put in Unicode NFC form and
|
|
238
|
+
trimmed. Control characters are always rejected, and so are tabs and line breaks unless
|
|
239
|
+
`multiline` is set.
|
|
240
|
+
|
|
241
|
+
| `numberRule` option | Effect |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| `field`, `required`, `error` | As for `textRule` (`required: false` accepts `null` and `undefined`) |
|
|
244
|
+
| `integer` | Accepts safe integers only |
|
|
245
|
+
| `min`, `max` | Inclusive bounds |
|
|
246
|
+
|
|
247
|
+
A number must be of type `number` and finite; a numeric string is rejected, not
|
|
248
|
+
converted. An invalid option, such as `minLength` above `maxLength`, throws a
|
|
249
|
+
`TypeError` when the rule is defined.
|
|
250
|
+
|
|
251
|
+
A broken rule throws `error(violation)`. `violation` is a frozen `ValueViolation`:
|
|
252
|
+
`{ field, rule }`, plus `limit` for a length or range rule and `expected` for a type
|
|
253
|
+
rule. `InvalidValueException` carries it, builds the message (`username must be at
|
|
254
|
+
least 3 characters.`), and does not keep the rejected value. Extend it for your own
|
|
255
|
+
exceptions, as in the example above, so every one shares that shape.
|
|
256
|
+
|
|
257
|
+
## Domain events
|
|
258
|
+
|
|
259
|
+
A domain event extends `RootDomainEvent<TEntity, TPayload>`. It carries a UUIDv7 `id`,
|
|
260
|
+
the event-time `aggregateId` and `aggregateVersion`, and `payload`: a deep clone of the
|
|
261
|
+
aggregate's `toJSON()`, recursively frozen.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { RootDomainEvent } from '@cqrs-ddd/core/domain';
|
|
265
|
+
|
|
266
|
+
export class UserCreatedEvent extends RootDomainEvent<User, UserSnapshot> {
|
|
267
|
+
constructor(user: User) {
|
|
268
|
+
super(user);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`UserUpdatedEvent` and `UserDeletedEvent` are declared the same way.
|
|
274
|
+
|
|
275
|
+
- **Detached:** later changes to the aggregate never reach `payload`.
|
|
276
|
+
- **Read-only for ordinary access:** own properties are frozen, and the mutating methods
|
|
277
|
+
of cloned `Date`, `Map` and `Set` values throw.
|
|
278
|
+
- **Not a security boundary:** `Object.freeze` does not cover built-in internal state,
|
|
279
|
+
so `Date.prototype.setTime.call(payload.at, 0)` still changes it, and every consumer
|
|
280
|
+
of one event shares the same `payload`. `event.clonePayload()` returns a fresh frozen
|
|
281
|
+
copy per call when a consumer needs its own.
|
|
282
|
+
- **Not a transport format:** send an explicit, serializable message built from the
|
|
283
|
+
fields you need.
|
|
284
|
+
|
|
285
|
+
`deepCloneAndFreeze(value)` is the function behind `payload`, also usable on its own. It
|
|
286
|
+
handles objects, arrays, `Date`, `Map`, `Set`, `RegExp` and circular references.
|
|
287
|
+
|
|
288
|
+
## Commands and event publication
|
|
289
|
+
|
|
290
|
+
`BaseCommand` and `BaseQuery` are plain base classes for command and query objects.
|
|
291
|
+
`BaseCommand` keeps an optional `sessionPrincipal` (or `sessionUser`) out of enumeration and `toJSON()`, so it
|
|
292
|
+
never reaches a fingerprint or a log. `getUpdateFields(fields)` returns the listed
|
|
293
|
+
fields the command carries (`null` counts, `undefined` does not), for field-level
|
|
294
|
+
authorization.
|
|
295
|
+
|
|
296
|
+
`CommandBaseHandler` runs a command and publishes the events of the aggregate it
|
|
297
|
+
returns:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
import {
|
|
301
|
+
BaseCommand,
|
|
302
|
+
CommandBaseHandler,
|
|
303
|
+
type IDomainEventPublisher,
|
|
304
|
+
type IWriteSideAggregateRepository,
|
|
305
|
+
} from '@cqrs-ddd/core/application';
|
|
306
|
+
import { EntityNotFoundException } from '@cqrs-ddd/core/domain';
|
|
307
|
+
|
|
308
|
+
export class UpdateUserCommand extends BaseCommand {
|
|
309
|
+
constructor(
|
|
310
|
+
readonly id: string,
|
|
311
|
+
readonly username?: string,
|
|
312
|
+
readonly department?: string | null,
|
|
313
|
+
) {
|
|
314
|
+
super();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export class UpdateUserHandler extends CommandBaseHandler<UpdateUserCommand, User> {
|
|
319
|
+
constructor(
|
|
320
|
+
private readonly users: IWriteSideAggregateRepository<User>,
|
|
321
|
+
eventBus: IDomainEventPublisher,
|
|
322
|
+
) {
|
|
323
|
+
super(eventBus);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async handle(command: UpdateUserCommand): Promise<User> {
|
|
327
|
+
const user = await this.users.findById(command.id);
|
|
328
|
+
if (!user) throw new EntityNotFoundException('User', command.id);
|
|
329
|
+
user.update({ username: command.username, department: command.department });
|
|
330
|
+
await this.users.save(user);
|
|
331
|
+
return user;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const handler = new UpdateUserHandler(users, {
|
|
336
|
+
publishAll: async (events) => events.forEach((event) => emitter.emit('event', event)),
|
|
337
|
+
});
|
|
338
|
+
await handler.execute(new UpdateUserCommand(id, 'bob'));
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- `handle()` returns the aggregate, or a result carrying it as `aggregate`.
|
|
342
|
+
`execute()` calls it, publishes the buffered events once with
|
|
343
|
+
`eventBus.publishAll(events)`, clears the buffer and returns the result unchanged.
|
|
344
|
+
- A rejected `handle()` publishes nothing. If `publishAll()` throws, the error
|
|
345
|
+
propagates and the events stay buffered.
|
|
346
|
+
- Any object with `publishAll(events)` is an `IDomainEventPublisher`.
|
|
347
|
+
|
|
348
|
+
**`AggregateRoot.commit()` publishes nothing by default.** It hands the buffer to the
|
|
349
|
+
aggregate's own `publish`/`publishAll` hooks, which do nothing until a publisher is
|
|
350
|
+
connected by overriding them, and then clears the buffer. Calling `commit()` without a
|
|
351
|
+
connected publisher therefore drops the events; so does `autoCommit`. Let
|
|
352
|
+
`CommandBaseHandler` publish, or read `getUncommittedEvents()` and call `uncommit()`
|
|
353
|
+
yourself.
|
|
354
|
+
|
|
355
|
+
## Write-side repositories
|
|
356
|
+
|
|
357
|
+
A command handler loads the authoritative aggregate through
|
|
358
|
+
`IWriteSideAggregateRepository<TEntity>.findById(id)`: it must read primary storage, never
|
|
359
|
+
a cache. `@cqrs-ddd/mikro-orm`'s `AggregateRepository` implements it for MikroORM.
|
|
360
|
+
|
|
361
|
+
`save()` declares its lifecycle with `@PersistedWrite`. With `@cqrs-ddd/mikro-orm`, where
|
|
362
|
+
`AggregateRepository` provides `findById()` and `this.store`:
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import type { ICache } from '@cqrs-ddd/core/application';
|
|
366
|
+
import { DomainException } from '@cqrs-ddd/core/domain';
|
|
367
|
+
import { cacheKey, PersistedWrite } from '@cqrs-ddd/core/persistence';
|
|
368
|
+
import {
|
|
369
|
+
AggregateRepository,
|
|
370
|
+
type IEntityManagerSource,
|
|
371
|
+
mapPersistenceError,
|
|
372
|
+
optimisticUpdate,
|
|
373
|
+
} from '@cqrs-ddd/mikro-orm';
|
|
374
|
+
|
|
375
|
+
export class UniqueEmailException extends DomainException {
|
|
376
|
+
constructor(readonly user: User) {
|
|
377
|
+
super('This email is already registered.');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export class UpdateUserRepository extends AggregateRepository<UserSnapshot, User, UserSnapshot> {
|
|
382
|
+
constructor(cache: ICache<UserSnapshot>, store: IEntityManagerSource) {
|
|
383
|
+
super(cache, store, User, User.aggregateName, User.fromJSON);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@PersistedWrite<User>({
|
|
387
|
+
cache: {
|
|
388
|
+
setKey: (user) => cacheKey(User.aggregateName, { id: user.id }),
|
|
389
|
+
invalidateKeys: (user) => [cacheKey(User.aggregateName, { email: user.email })],
|
|
390
|
+
},
|
|
391
|
+
unique: { email: (user) => new UniqueEmailException(user) },
|
|
392
|
+
otherwise: (error, user) => mapPersistenceError(error, `updating User ${user.id}`),
|
|
393
|
+
})
|
|
394
|
+
async save(user: User): Promise<UserSnapshot> {
|
|
395
|
+
const snapshot = user.toJSON();
|
|
396
|
+
await optimisticUpdate(
|
|
397
|
+
this.store.em,
|
|
398
|
+
User,
|
|
399
|
+
user,
|
|
400
|
+
{
|
|
401
|
+
username: snapshot.username,
|
|
402
|
+
department: snapshot.department ?? null,
|
|
403
|
+
updatedAt: snapshot.updatedAt,
|
|
404
|
+
},
|
|
405
|
+
'User',
|
|
406
|
+
);
|
|
407
|
+
return snapshot;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`optimisticUpdate` adds `version` to the written fields; every other changed column,
|
|
413
|
+
`updatedAt` included, is listed explicitly.
|
|
414
|
+
|
|
415
|
+
`@PersistedWrite` applies three decorators. Stacked by hand, the order is fixed,
|
|
416
|
+
outermost first:
|
|
417
|
+
|
|
418
|
+
1. `@Cache(options)`: after a successful write, writes the returned snapshot through
|
|
419
|
+
with a compare-and-set (`isCacheNewer` unless `isNewer` says otherwise), and
|
|
420
|
+
installs barriers for `invalidateKeys`.
|
|
421
|
+
When `save()` resolves `null` or `undefined`, it installs barriers for `deleteKeys`.
|
|
422
|
+
Cache errors are logged and never turn a committed write into a failure.
|
|
423
|
+
2. `@AcknowledgePersisted({ entity: ([aggregate]) => aggregate })`: advances the
|
|
424
|
+
selected aggregate's persisted version only after the write succeeded.
|
|
425
|
+
3. `@MapPersistenceErrors({ entity, unique, dialect, otherwise })`: translates a unique
|
|
426
|
+
violation into your domain error. `unique` is keyed by the entity property the
|
|
427
|
+
constraint covers (`{ email: ... }`), or by the declared name of a multi-column
|
|
428
|
+
constraint (`MapPersistenceErrors<Args, Entity, typeof NAME>`), so a repository never
|
|
429
|
+
names a database constraint or column. Which constraint an error violated is read by
|
|
430
|
+
the persistence dialect: `options.dialect`, else the one registered with
|
|
431
|
+
`setPersistenceDialect()` at the composition root. Core knows no database; an adapter
|
|
432
|
+
such as `MikroOrmDialect` of `@cqrs-ddd/mikro-orm` implements `IPersistenceDialect`.
|
|
433
|
+
A method that declares `unique` with no dialect available throws a `TypeError` before
|
|
434
|
+
it runs. `otherwise` translates the rest, for example with `mapPersistenceError` of
|
|
435
|
+
`@cqrs-ddd/mikro-orm`, which turns retryable driver and network failures into
|
|
436
|
+
`TransientOperationError` and keeps every other error unchanged.
|
|
437
|
+
|
|
438
|
+
Deletes do not acknowledge, so they stack `@Cache` and `@MapPersistenceErrors` alone,
|
|
439
|
+
and resolve `null` so `@Cache` installs barriers for `deleteKeys`:
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
import { Cache, cacheKey, MapPersistenceErrors } from '@cqrs-ddd/core/persistence';
|
|
443
|
+
import { mapPersistenceError, optimisticDelete } from '@cqrs-ddd/mikro-orm';
|
|
444
|
+
|
|
445
|
+
export class DeleteUserRepository extends AggregateRepository<UserSnapshot, User, null> {
|
|
446
|
+
constructor(cache: ICache<UserSnapshot>, store: IEntityManagerSource) {
|
|
447
|
+
super(cache, store, User, User.aggregateName, User.fromJSON);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@Cache<User, null>({
|
|
451
|
+
deleteKeys: (user) => [
|
|
452
|
+
cacheKey(User.aggregateName, { id: user.id }),
|
|
453
|
+
cacheKey(User.aggregateName, { email: user.email }),
|
|
454
|
+
],
|
|
455
|
+
})
|
|
456
|
+
@MapPersistenceErrors<[User], User>({
|
|
457
|
+
entity: ([user]) => user,
|
|
458
|
+
otherwise: (error, user) => mapPersistenceError(error, `deleting User ${user.id}`),
|
|
459
|
+
})
|
|
460
|
+
async save(user: User): Promise<null> {
|
|
461
|
+
await optimisticDelete(this.store.em, User, user, 'User');
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
The write itself must be version-conditioned (`WHERE id = ? AND version = expected`) and
|
|
468
|
+
must not run inside an outer transaction, whose success would not mean durable
|
|
469
|
+
persistence. `@cqrs-ddd/mikro-orm` provides `optimisticUpdate` and `optimisticDelete`
|
|
470
|
+
for this.
|
|
471
|
+
|
|
472
|
+
## Read-side repositories
|
|
473
|
+
|
|
474
|
+
A query repository extends `QueryRepository` and decorates `find()` with `@FromCache`. The
|
|
475
|
+
constructor takes the cache (any `ICache`; see [The repository cache](#the-repository-cache)
|
|
476
|
+
for why `@FromCache` needs an `IVersionedCache`) and the repository's default hydration:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
import { BaseQuery, type ICache, type IQueryOptions } from '@cqrs-ddd/core/application';
|
|
480
|
+
import { cacheKey, FromCache, QueryRepository } from '@cqrs-ddd/core/persistence';
|
|
481
|
+
import type { IEntityManagerSource } from '@cqrs-ddd/mikro-orm';
|
|
482
|
+
|
|
483
|
+
export class GetUserQuery extends BaseQuery {
|
|
484
|
+
constructor(
|
|
485
|
+
readonly id: string,
|
|
486
|
+
options?: IQueryOptions,
|
|
487
|
+
) {
|
|
488
|
+
super(options);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export class GetUserRepository extends QueryRepository<GetUserQuery, User | null> {
|
|
493
|
+
constructor(
|
|
494
|
+
cache: ICache<UserSnapshot>,
|
|
495
|
+
private readonly store: IEntityManagerSource,
|
|
496
|
+
) {
|
|
497
|
+
super(cache, { hydrateFn: (cached) => User.fromJSON(cached as UserSnapshot) });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
@FromCache<GetUserQuery, User | null>({
|
|
501
|
+
keyFn: (query) => cacheKey(User.aggregateName, { id: query.id }),
|
|
502
|
+
})
|
|
503
|
+
async find(query: GetUserQuery): Promise<User | null> {
|
|
504
|
+
return this.store.em.findOne(User, { id: query.id }, query.refresh ? { refresh: true } : undefined);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await users.find(new GetUserQuery(id)); // may be served from the cache
|
|
509
|
+
await users.find(new GetUserQuery(id, { refresh: true })); // always reads the database
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`@FromCache` options: `keyFn` (return `null` to skip the cache for that call),
|
|
513
|
+
`hydrateFn`, `serializeFn`, `ttl` in milliseconds, `isNewer` and `logger`.
|
|
514
|
+
|
|
515
|
+
- Every hit is rehydrated whenever a hydrator applies, so a hit and a miss return the
|
|
516
|
+
same type. A method's own `hydrateFn` (or `hydrateFn: null`) overrides the
|
|
517
|
+
repository's; `serializeFn` follows the same rule.
|
|
518
|
+
- Without a hydrator, only plain data is cached; a class instance is returned uncached.
|
|
519
|
+
- Only non-nullish results are stored. A barrier or a stored `null` counts as a miss.
|
|
520
|
+
- A query with `refresh: true` bypasses the cache.
|
|
521
|
+
- Cache errors propagate from reads: a query fails rather than guessing.
|
|
522
|
+
|
|
523
|
+
## The repository cache
|
|
524
|
+
|
|
525
|
+
`@Cache` works with any `ICache` (`get`, `set`, `delete`). `@FromCache` requires an
|
|
526
|
+
`IVersionedCache`, which adds a revision fence:
|
|
527
|
+
|
|
528
|
+
| Method | Contract |
|
|
529
|
+
| --- | --- |
|
|
530
|
+
| `readState(key)` | the entry's status (`hit`, `miss`, `expired`), value and opaque revision |
|
|
531
|
+
| `invalidate(key)` | evicts the value and advances the revision |
|
|
532
|
+
| `tryFill(key, observedRevision, value, options?)` | writes only if the revision is still `observedRevision`; `false` otherwise |
|
|
533
|
+
|
|
534
|
+
`@FromCache` observes the revision before the database read and fills with `tryFill`, so
|
|
535
|
+
a read that raced an invalidation or a delete cannot write a stale snapshot back. A
|
|
536
|
+
rejected fill re-reads the key, returns a strictly newer snapshot if one is there, and
|
|
537
|
+
otherwise retries a bounded number of times before returning the database result
|
|
538
|
+
uncached. **An adapter that implements only `ICache` is bypassed by `@FromCache` for
|
|
539
|
+
both reads and fills**, with a warning logged once: it cannot fence a fill.
|
|
540
|
+
|
|
541
|
+
Two adapters implement `IVersionedCache`:
|
|
542
|
+
|
|
543
|
+
- `MemoryCache({ defaultTtlMs = 60_000, maxEntries = 10_000 })`: in process, entries
|
|
544
|
+
JSON-cloned on write and read, for tests and a single process.
|
|
545
|
+
- `MikroOrmCache`, in `@cqrs-ddd/mikro-orm`: one database row per key, every write a
|
|
546
|
+
compare-and-set in its own transaction.
|
|
547
|
+
|
|
548
|
+
`CacheSetOptions.isNewer(cached, incoming)` returns `true` when the cached value is
|
|
549
|
+
newer and must not be overwritten; `isCacheNewer` compares `version`, then `__gen`, then
|
|
550
|
+
`updatedAt`. `toCacheSnapshot` turns a result into a detached snapshot: the cache never
|
|
551
|
+
stores a live aggregate.
|
|
552
|
+
|
|
553
|
+
Mutation barriers (`CacheMutationBarrier`) mark a deleted or invalidated key for
|
|
554
|
+
`barrierTtl`, 60 seconds by default. Size it above your longest in-flight read.
|
|
555
|
+
|
|
556
|
+
**The `logger` option.** `@Cache` and `@FromCache` accept `logger: { warn(message) }`
|
|
557
|
+
for their operational warnings, such as a failed cache write, a bypassed adapter or a
|
|
558
|
+
miswired repository. A NestJS `Logger` or `console` fits. Without one, warnings go to
|
|
559
|
+
`console.warn`. A logger that throws never changes a result. An adapter reports its own
|
|
560
|
+
warnings the same way through `consoleCacheLogger(context)` and `safeWarn(logger, message)`.
|
|
561
|
+
|
|
562
|
+
## Tenant-scoped cache keys
|
|
563
|
+
|
|
564
|
+
`cacheKey(resource, conditions, tenant?)` and `cacheKeyTemplate(template, tenant?)`
|
|
565
|
+
namespace every key by tenant. The tenant comes from, in order:
|
|
566
|
+
|
|
567
|
+
1. the explicit `tenant` argument: a tenant id string, or an object carrying `tenantId`,
|
|
568
|
+
such as a request context;
|
|
569
|
+
2. for `cacheKeyTemplate`, a context source's `tenantId`;
|
|
570
|
+
3. the resolver the application registers once at startup with `setTenantResolver(fn)`,
|
|
571
|
+
a function that returns the tenant of the running request or job;
|
|
572
|
+
4. otherwise, `MissingTenantContextError`.
|
|
573
|
+
|
|
574
|
+
```typescript
|
|
575
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
576
|
+
import { setTenantResolver } from '@cqrs-ddd/core/application';
|
|
577
|
+
|
|
578
|
+
const requestTenant = new AsyncLocalStorage<string>();
|
|
579
|
+
setTenantResolver(() => requestTenant.getStore());
|
|
580
|
+
|
|
581
|
+
// Per request or job:
|
|
582
|
+
requestTenant.run(tenantId, () => handle(request));
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
There is no shared namespace: a missing or empty tenant, or a resolver that returns
|
|
586
|
+
nothing, throws. A single-tenant deployment passes a fixed id or registers
|
|
587
|
+
`setTenantResolver(() => 'single')`. The same resolution is public as
|
|
588
|
+
`requireTenantId(source, purpose)`, for any other key that must be partitioned by
|
|
589
|
+
tenant, such as an idempotency or rate-limit key.
|
|
590
|
+
|
|
591
|
+
`cacheKey` keys have the form `${tenant}:${resource}:v1:${sha256}`, hashed over a
|
|
592
|
+
key-sorted serialization of `[tenant, resource, conditions]`. The format is frozen, so
|
|
593
|
+
stored entries stay addressable across releases.
|
|
594
|
+
|
|
595
|
+
## HTTP status mapping
|
|
596
|
+
|
|
597
|
+
`domainErrorHttpStatus(error)` from `/http` maps this package's errors to plain
|
|
598
|
+
`{ statusCode, error, message }` values, for any HTTP framework:
|
|
599
|
+
|
|
600
|
+
| Error | Status |
|
|
601
|
+
| --- | --- |
|
|
602
|
+
| `ConcurrencyConflictError` | 409 |
|
|
603
|
+
| `EntityNotFoundException` | 404 |
|
|
604
|
+
| `MissingTenantContextError` | 500, with a generic message: a request without a tenant is a server fault |
|
|
605
|
+
| any other `DomainException`, `InvalidValueException` included | 400 |
|
|
606
|
+
|
|
607
|
+
It returns `undefined` for anything else. Map your own exceptions first, then pass the
|
|
608
|
+
rest to it.
|
|
609
|
+
|
|
610
|
+
## Using it from NestJS
|
|
611
|
+
|
|
612
|
+
Nothing in this package imports NestJS; the fit is structural.
|
|
613
|
+
|
|
614
|
+
- The NestJS CQRS `EventBus` has `publishAll(events)`, so it is an
|
|
615
|
+
`IDomainEventPublisher`: a handler passes its injected bus to `super(eventBus)`.
|
|
616
|
+
- `CommandBaseHandler.execute(command)` is the method the NestJS command bus calls, so a
|
|
617
|
+
class decorated with `@CommandHandler` extends it directly.
|
|
618
|
+
- `BaseCommand`, `BaseQuery` and the domain events are plain classes that NestJS
|
|
619
|
+
dispatches like any other.
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
@CommandHandler(UpdateUserCommand)
|
|
623
|
+
export class UpdateUserHandler extends CommandBaseHandler<UpdateUserCommand, User> {
|
|
624
|
+
constructor(
|
|
625
|
+
@Inject(USER_WRITE_REPOSITORY) private readonly users: IWriteSideAggregateRepository<User>,
|
|
626
|
+
eventBus: EventBus,
|
|
627
|
+
) {
|
|
628
|
+
super(eventBus);
|
|
629
|
+
}
|
|
630
|
+
// handle() as above
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
The application writes the rest of the glue:
|
|
635
|
+
|
|
636
|
+
- providers for the repositories and the cache, for example a `useFactory` provider
|
|
637
|
+
that builds a `MikroOrmCache` from `@cqrs-ddd/mikro-orm` under `CACHE_TOKEN`:
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
{ provide: CACHE_TOKEN, useValue: new MemoryCache({ defaultTtlMs: 30_000 }) }
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
- the tenant resolver, registered once before any lifecycle hook can start work that
|
|
644
|
+
reads it, for example in a module constructor;
|
|
645
|
+
- an exception filter that maps its own errors, then calls `domainErrorHttpStatus`;
|
|
646
|
+
- a `logger` for the cache decorators, such as `new Logger('UserCache')`.
|
|
647
|
+
|
|
648
|
+
## Moving from ddd/core
|
|
649
|
+
|
|
650
|
+
The unpublished workspace package `@nestjs-pipeline/ddd-core` (directory `ddd/core`) maps
|
|
651
|
+
onto this package as follows. It depended on NestJS and MikroORM; this package depends on
|
|
652
|
+
neither, and the MikroORM parts are in `@cqrs-ddd/mikro-orm`.
|
|
653
|
+
|
|
654
|
+
| `ddd/core` | `@cqrs-ddd/core` |
|
|
655
|
+
| --- | --- |
|
|
656
|
+
| `import { … } from '@nestjs-pipeline/ddd-core'` | the layer entry points: `@cqrs-ddd/core/domain`, `/application`, `/persistence`, `/http` |
|
|
657
|
+
| `@Mutate()` on a mutation method, which called `onUpdate()` | `@ApplyMutation({ event })`, which also records the event, plus `@Mutable` fields written through `applyPatch()` |
|
|
658
|
+
| `DomainOutcome`, `RootDomainOutcome` returned by `handle()` | `handle()` returns the aggregate, or a result with an `aggregate` property; its buffered events are published |
|
|
659
|
+
| `CommandBaseHandler(eventBus: EventBus)` from `@nestjs/cqrs` | `CommandBaseHandler(eventBus: IDomainEventPublisher)`; a NestJS `EventBus` still fits |
|
|
660
|
+
| `CacheableEntity`, `ICacheKey`, `entity.cacheKey` (`prefix + id`) | `RootEntity` with a static `aggregateName`, keys built with `cacheKey(User.aggregateName, { id })`, always tenant-scoped |
|
|
661
|
+
| `CacheableEntity.fromStringify(data, fromJSON)` | `RootEntity.from(value)` or the aggregate's own `fromJSON` |
|
|
662
|
+
| `ICommandRepository.save(domainOutcome)` | `ICommandRepository.save(entity)`, and `IWriteSideAggregateRepository.findById(id)` for loading |
|
|
663
|
+
| `@Cache(setKeyFn, deleteKeysFn)` | `@Cache({ setKey, deleteKeys, invalidateKeys, ttl, isNewer, barrierTtl, logger })`, usually through `@PersistedWrite` |
|
|
664
|
+
| `@FromCache(keyFn, hydrateFn)`, hydrating when `query.hydrate` is set | `@FromCache({ keyFn, hydrateFn, … })` with an `IVersionedCache`; hits are always rehydrated when a hydrator applies |
|
|
665
|
+
| `QueryRepository(cache)` | `QueryRepository(cache, { hydrateFn, serializeFn? })` |
|
|
666
|
+
| `ICache` in `persistence/cache.interface` | `ICache` and `IVersionedCache` in `@cqrs-ddd/core/application` |
|
|
667
|
+
| `UnixTimestampType` | `UnixTimestampType` in `@cqrs-ddd/mikro-orm` |
|
|
668
|
+
| `RootEntity` with an abstract `afterUpdate()` | `afterUpdate()` is an optional hook; `RootEntity` extends `AggregateRoot` and adds `version`, `getExpectedVersion()`, the event buffer and private hydration setters |
|
|
669
|
+
|
|
670
|
+
The old imports and the new ones side by side:
|
|
671
|
+
|
|
672
|
+
```typescript
|
|
673
|
+
// ddd/core
|
|
674
|
+
import { CacheableEntity, CommandBaseHandler, Mutate, RootDomainOutcome } from '@nestjs-pipeline/ddd-core';
|
|
675
|
+
|
|
676
|
+
// @cqrs-ddd/core
|
|
677
|
+
import { ApplyMutation, Mutable, RootEntity } from '@cqrs-ddd/core/domain';
|
|
678
|
+
import { CommandBaseHandler } from '@cqrs-ddd/core/application';
|
|
679
|
+
import { cacheKey, PersistedWrite } from '@cqrs-ddd/core/persistence';
|
|
680
|
+
import { AggregateRepository, optimisticUpdate, UnixTimestampType } from '@cqrs-ddd/mikro-orm';
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
## Known limits
|
|
684
|
+
|
|
685
|
+
- Persistence and event publication are not atomic. A crash between the write and
|
|
686
|
+
`publishAll()` loses the events; durable delivery needs an outbox.
|
|
687
|
+
- Cache maintenance after a commit is best-effort. If an invalidation fails, the
|
|
688
|
+
previous entry stays until it expires: the revision fence coordinates the cache with
|
|
689
|
+
itself, not with the database. Read with `{ refresh: true }` where a decision must
|
|
690
|
+
not rest on a cached value.
|
|
691
|
+
- A mutation barrier protects only for `barrierTtl`. It is a bounded race window, not a
|
|
692
|
+
tombstone.
|
|
693
|
+
- The persistence helpers reject outer transactions; decorators cannot make several
|
|
694
|
+
writes atomic. A row that must commit with the aggregate, such as an audit record or
|
|
695
|
+
an outbox message, therefore cannot share its transaction yet: that needs commit
|
|
696
|
+
hooks that run acknowledgment and cache work after the commit.
|
|
697
|
+
- `@MapPersistenceErrors` maps a unique violation only as well as the registered
|
|
698
|
+
persistence dialect reads it; `MikroOrmDialect` reads PostgreSQL and SQLite errors.
|
|
699
|
+
- The tenant resolver is process-wide: one per process, for each installed copy of this
|
|
700
|
+
package.
|
|
701
|
+
- Event payloads are frozen for ordinary access only; see [Domain events](#domain-events).
|
|
702
|
+
|
|
703
|
+
## License
|
|
704
|
+
|
|
705
|
+
Dual-licensed under **AGPL-3.0-or-later** or a **Commercial License**. See
|
|
706
|
+
[`LICENSE`](https://github.com/aristoteliss/nestjs-pipeline/blob/master/LICENSE) and [`COMMERCIAL_LICENSE.txt`](https://github.com/aristoteliss/nestjs-pipeline/blob/master/COMMERCIAL_LICENSE.txt)
|
|
707
|
+
at the repository root.
|