@shirudo/ddd-kit 1.0.1 → 1.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/README.md +21 -18
- package/dist/aggregate-DclYgG_D.d.ts +662 -0
- package/dist/http.d.ts +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +715 -652
- package/dist/index.js +1059 -112
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +251 -0
- package/dist/testing.js +793 -0
- package/dist/testing.js.map +1 -0
- package/dist/utils.d.ts +16 -4
- package/dist/utils.js +158 -53
- package/dist/utils.js.map +1 -1
- package/package.json +6 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,572 +1,10 @@
|
|
|
1
|
+
import { a as Id, A as AnyDomainEvent, I as IAggregateRoot, V as Version, b as AggregateSnapshot, C as CreateDomainEventOptions, c as IEventSourcedAggregate, D as DomainError, d as InfrastructureError } from './aggregate-DclYgG_D.js';
|
|
2
|
+
export { p as AggregateDeletedError, q as AggregateNotFoundError, f as ClockFactory, u as ConcurrencyConflictError, k as DomainEvent, t as DuplicateAggregateError, E as EventIdFactory, j as EventMetadata, v as IdGenerator, M as MissingHandlerError, n as copyMetadata, l as createDomainEvent, m as createDomainEventWithMetadata, o as mergeMetadata, i as resetClockFactory, r as resetEventIdFactory, s as sameVersion, g as setClockFactory, e as setEventIdFactory, h as withClockFactory, w as withEventIdFactory } from './aggregate-DclYgG_D.js';
|
|
1
3
|
import { Result } from '@shirudo/result';
|
|
2
4
|
import { BaseError, ValidationError } from '@shirudo/base-error';
|
|
3
5
|
import { DeepEqualExceptOptions } from './utils.js';
|
|
4
6
|
export { DeepOmitOptions, Key, PathSegment, deepEqual, deepEqualExcept, deepOmit } from './utils.js';
|
|
5
7
|
|
|
6
|
-
/**
|
|
7
|
-
* Branded string ID. `Tag` carries the aggregate / entity name so two ids
|
|
8
|
-
* with different tags are not assignable to each other even though both
|
|
9
|
-
* are strings at runtime.
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* ```ts
|
|
13
|
-
* type UserId = Id<"UserId">;
|
|
14
|
-
* type OrderId = Id<"OrderId">;
|
|
15
|
-
*
|
|
16
|
-
* const u = "user-1" as UserId;
|
|
17
|
-
* const o: OrderId = u; // ❌ compile error
|
|
18
|
-
* ```
|
|
19
|
-
*/
|
|
20
|
-
type Id<Tag extends string> = string & {
|
|
21
|
-
readonly __brand: Tag;
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
|
-
* Produces fresh ids of a single, fixed tag. The tag is bound at the
|
|
25
|
-
* generator type — `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
|
|
26
|
-
* with no caller-side generic to abuse.
|
|
27
|
-
*
|
|
28
|
-
* **Your factory must produce unique ids under concurrent calls.**
|
|
29
|
-
* The kit makes no attempt to dedupe or detect collisions — a collision
|
|
30
|
-
* silently overwrites earlier rows (under unique-key constraints) or
|
|
31
|
-
* silently aliases two different entities (without them). Safe choices:
|
|
32
|
-
* `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
|
|
33
|
-
* KSUID — all collision-resistant by design. Unsafe choices: `Date.now()`
|
|
34
|
-
* alone (duplicates within the same millisecond), a process-local
|
|
35
|
-
* counter without persistence (resets to 1 on restart, collides with
|
|
36
|
-
* prior runs), a sequential id derived from non-atomic state.
|
|
37
|
-
*
|
|
38
|
-
* @example
|
|
39
|
-
* ```ts
|
|
40
|
-
* import { ulid } from "ulid";
|
|
41
|
-
*
|
|
42
|
-
* const userIds: IdGenerator<"UserId"> = { next: () => ulid() as Id<"UserId"> };
|
|
43
|
-
* const id = userIds.next(); // Id<"UserId">
|
|
44
|
-
* ```
|
|
45
|
-
*
|
|
46
|
-
* The previous shape (`IdGenerator { next<T extends string>(): Id<T> }`)
|
|
47
|
-
* let callers pick `T` themselves — `gen.next<"AnyTag">()` typechecked
|
|
48
|
-
* even when the generator produced different-tag ids, silently defeating
|
|
49
|
-
* the brand.
|
|
50
|
-
*/
|
|
51
|
-
interface IdGenerator<Tag extends string> {
|
|
52
|
-
next: () => Id<Tag>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Abstract base for **domain-invariant violations**. Domain methods
|
|
57
|
-
* (aggregates, entity validation hooks, value-object constructors)
|
|
58
|
-
* throw `DomainError`-derived exceptions when a business rule is
|
|
59
|
-
* violated. Consumers derive their own concrete errors — e.g.
|
|
60
|
-
* `class OrderAlreadyShippedError extends DomainError<"OrderAlreadyShippedError"> {}` —
|
|
61
|
-
* for `instanceof`-style catching at the App-Service boundary, where
|
|
62
|
-
* they typically map to HTTP 400 / business-rule responses.
|
|
63
|
-
*
|
|
64
|
-
* The library itself does **not** ship any concrete `DomainError`
|
|
65
|
-
* subclass — the kit can't know your invariants.
|
|
66
|
-
*
|
|
67
|
-
* Extends `BaseError<Name>`; see `@shirudo/base-error` for the inherited
|
|
68
|
-
* surface (timestamps, cause chains, `toJSON()`, `getUserMessage()`,
|
|
69
|
-
* `isRetryable`, …).
|
|
70
|
-
*/
|
|
71
|
-
declare abstract class DomainError<Name extends string = string> extends BaseError<Name> {
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Abstract base for **infrastructure / persistence failures** that the
|
|
75
|
-
* App-Service can recover from — typically by retrying, by returning
|
|
76
|
-
* HTTP 404 / 409, or by surfacing a "please try again" UX. These are
|
|
77
|
-
* not domain-invariant violations (the business rules were not
|
|
78
|
-
* broken); they describe race conditions and missing rows at the
|
|
79
|
-
* storage boundary.
|
|
80
|
-
*
|
|
81
|
-
* Library-internal concrete subclasses: {@link AggregateNotFoundError},
|
|
82
|
-
* {@link ConcurrencyConflictError}.
|
|
83
|
-
*/
|
|
84
|
-
declare abstract class InfrastructureError<Name extends string = string> extends BaseError<Name> {
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Thrown by `EventSourcedAggregate.apply()` when no handler is
|
|
88
|
-
* registered for the event's type. This means the aggregate's subclass
|
|
89
|
-
* forgot to add an entry to its `handlers` map — a programming /
|
|
90
|
-
* configuration bug, not a domain or infrastructure failure.
|
|
91
|
-
*
|
|
92
|
-
* Deliberately **not** on `DomainError` or `InfrastructureError` —
|
|
93
|
-
* a generic `catch (e instanceof DomainError)` handler at the App
|
|
94
|
-
* layer must not mask a forgotten handler; this should crash loud and
|
|
95
|
-
* fail the calling Use Case so the bug surfaces in development. The
|
|
96
|
-
* replay methods (`loadFromHistory`, `restoreFromSnapshotWithEvents`)
|
|
97
|
-
* also let it propagate uncaught instead of wrapping it in `Result.Err`.
|
|
98
|
-
*
|
|
99
|
-
* Use `isBaseError(e)` from `@shirudo/base-error` to detect
|
|
100
|
-
* "any structured error from the kit or any other BaseError-using
|
|
101
|
-
* library" at the App boundary.
|
|
102
|
-
*/
|
|
103
|
-
declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
|
|
104
|
-
readonly eventType: string;
|
|
105
|
-
constructor(eventType: string, cause?: unknown);
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Thrown by `IRepository.getByIdOrFail()` when an aggregate with the
|
|
109
|
-
* given id does not exist. `InfrastructureError` because the storage
|
|
110
|
-
* boundary, not a business rule, decided the row is absent. Use the
|
|
111
|
-
* nullable variant `getById()` if "not found" is a valid outcome.
|
|
112
|
-
*
|
|
113
|
-
* Accepts an optional `cause` so a `Repository.save()` implementation
|
|
114
|
-
* can wrap a lower-level "row not found" / driver-level error without
|
|
115
|
-
* losing context. Cause-chain helpers (`getRootCause`,
|
|
116
|
-
* `findInCauseChain`) from `@shirudo/base-error` traverse the chain.
|
|
117
|
-
*
|
|
118
|
-
* Not retryable — retrying won't make the row appear.
|
|
119
|
-
*/
|
|
120
|
-
declare class AggregateNotFoundError extends InfrastructureError<"AggregateNotFoundError"> {
|
|
121
|
-
readonly aggregateType: string;
|
|
122
|
-
readonly id: string;
|
|
123
|
-
constructor(aggregateType: string, id: string, cause?: unknown);
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Thrown by `IRepository.save()` when the aggregate's expected version
|
|
127
|
-
* does not match the version currently persisted — i.e. another writer
|
|
128
|
-
* updated the aggregate concurrently. The canonical optimistic-
|
|
129
|
-
* concurrency signal; the App-Service typically reloads, re-applies
|
|
130
|
-
* the use case, and retries, or surfaces HTTP 409 to the caller.
|
|
131
|
-
*
|
|
132
|
-
* `InfrastructureError` because the persistence layer (not a domain
|
|
133
|
-
* rule) detects the race. Marks itself as `retryable: true` so the
|
|
134
|
-
* `isRetryable` predicate from `@shirudo/base-error` picks it up.
|
|
135
|
-
*/
|
|
136
|
-
declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyConflictError"> {
|
|
137
|
-
readonly aggregateType: string;
|
|
138
|
-
readonly aggregateId: string;
|
|
139
|
-
readonly expectedVersion: number;
|
|
140
|
-
readonly actualVersion: number;
|
|
141
|
-
/**
|
|
142
|
-
* Marks this error as retryable so `isRetryable(err)` returns
|
|
143
|
-
* true. The canonical OCC pattern is to reload the aggregate, re-apply
|
|
144
|
-
* the use case, and retry on this exception.
|
|
145
|
-
*/
|
|
146
|
-
readonly retryable: true;
|
|
147
|
-
constructor(aggregateType: string, aggregateId: string, expectedVersion: number, actualVersion: number, cause?: unknown);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Factory function producing a fresh, unique event identifier for each call.
|
|
152
|
-
*
|
|
153
|
-
* The library ships a default that uses Web Crypto `crypto.randomUUID()`
|
|
154
|
-
* (works on Node 19+, modern browsers in secure contexts, Deno, Bun,
|
|
155
|
-
* Cloudflare Workers, Vercel Edge, and any runtime that implements Web
|
|
156
|
-
* Crypto). Note that `crypto.randomUUID()` returns **UUID v4** (purely
|
|
157
|
-
* random) — for production event stores prefer a **time-ordered** id
|
|
158
|
-
* format (UUID v7 / ULID / KSUID) so B-tree indexes on the eventId
|
|
159
|
-
* column stay clustered and `ORDER BY eventId` matches creation order.
|
|
160
|
-
* Swap one in via `setEventIdFactory(() => uuidv7())` or `() => ulid()`.
|
|
161
|
-
*/
|
|
162
|
-
type EventIdFactory = () => string;
|
|
163
|
-
/**
|
|
164
|
-
* Replaces the global event-id factory used by `createDomainEvent` and
|
|
165
|
-
* `createDomainEventWithMetadata`. Call once during application bootstrap,
|
|
166
|
-
* for example:
|
|
167
|
-
*
|
|
168
|
-
* ```ts
|
|
169
|
-
* import { ulid } from "ulid";
|
|
170
|
-
* import { setEventIdFactory } from "@shirudo/ddd-kit";
|
|
171
|
-
*
|
|
172
|
-
* setEventIdFactory(() => ulid());
|
|
173
|
-
* ```
|
|
174
|
-
*
|
|
175
|
-
* The per-call `options.eventId` override always wins over this factory.
|
|
176
|
-
*
|
|
177
|
-
* **Module-scoped — last setter wins.** The factory lives as a single
|
|
178
|
-
* module variable; importing two libraries that both call this races on
|
|
179
|
-
* load order, and parallel test workers will see each other's factory.
|
|
180
|
-
* For test isolation and short-lived contexts prefer
|
|
181
|
-
* {@link withEventIdFactory}; for multi-tenant request isolation
|
|
182
|
-
* (e.g. one factory per tenant in a single Worker invocation) **prefer
|
|
183
|
-
* the per-call `options.eventId`** instead of mutating the global. Same
|
|
184
|
-
* caveat applies to `setClockFactory`.
|
|
185
|
-
*/
|
|
186
|
-
declare function setEventIdFactory(factory: EventIdFactory): void;
|
|
187
|
-
/**
|
|
188
|
-
* Scoped variant of {@link setEventIdFactory}: installs `factory`,
|
|
189
|
-
* runs `fn`, then restores the previous factory in a `finally` block —
|
|
190
|
-
* so the restoration happens even if `fn` throws. Safe for parallel
|
|
191
|
-
* tests and for synchronous request handlers that need a tenant-
|
|
192
|
-
* specific factory without polluting the global.
|
|
193
|
-
*
|
|
194
|
-
* **Synchronous-only — enforced at runtime.** If `fn` returns a
|
|
195
|
-
* thenable (a `Promise` or any object with a `then` method), the
|
|
196
|
-
* helper throws *before* returning the value to the caller. This
|
|
197
|
-
* catches the async-misuse footgun where the factory would be
|
|
198
|
-
* restored before the awaited body of `fn` runs, leaving the awaited
|
|
199
|
-
* code reading the previous factory. For async scoping across `await`
|
|
200
|
-
* boundaries, use `AsyncLocalStorage` — out of scope for this helper;
|
|
201
|
-
* build it on top if you need it.
|
|
202
|
-
*
|
|
203
|
-
* Composes by nesting: an inner `withEventIdFactory` restores back to
|
|
204
|
-
* the outer's factory; the outer restores to the original.
|
|
205
|
-
*
|
|
206
|
-
* **When to prefer the per-call `options.eventId` instead.** If you're
|
|
207
|
-
* constructing a single event and want full control over its id,
|
|
208
|
-
* passing `{ eventId: "..." }` to `createDomainEvent` is the strongest
|
|
209
|
-
* isolation — it bypasses the factory mechanism entirely, no global
|
|
210
|
-
* mutation, no scope to manage. Reach for `withEventIdFactory` when
|
|
211
|
-
* the events are constructed deep inside domain methods you can't
|
|
212
|
-
* thread an explicit id through (typical test scenario), or when many
|
|
213
|
-
* events in a scope should share the same factory.
|
|
214
|
-
*
|
|
215
|
-
* @example
|
|
216
|
-
* ```ts
|
|
217
|
-
* // In a vitest test:
|
|
218
|
-
* it("emits deterministic ids", () => {
|
|
219
|
-
* withEventIdFactory(() => "evt-fixed", () => {
|
|
220
|
-
* const e = createDomainEvent("X", { v: 1 });
|
|
221
|
-
* expect(e.eventId).toBe("evt-fixed");
|
|
222
|
-
* });
|
|
223
|
-
* // Outside the callback the default crypto.randomUUID is restored,
|
|
224
|
-
* // even if the body had thrown.
|
|
225
|
-
* });
|
|
226
|
-
* ```
|
|
227
|
-
*/
|
|
228
|
-
declare function withEventIdFactory<T>(factory: EventIdFactory, fn: () => T): T;
|
|
229
|
-
/**
|
|
230
|
-
* Restores the default event-id factory (`crypto.randomUUID()`).
|
|
231
|
-
* Intended for use in test `afterEach` hooks.
|
|
232
|
-
*/
|
|
233
|
-
declare function resetEventIdFactory(): void;
|
|
234
|
-
/**
|
|
235
|
-
* Clock function producing a fresh `Date` for each call. The library
|
|
236
|
-
* defaults to `() => new Date()`; override globally via `setClockFactory`
|
|
237
|
-
* for deterministic event-sourcing tests, time-travel debugging, or any
|
|
238
|
-
* scenario where `occurredAt` must be reproducible.
|
|
239
|
-
*/
|
|
240
|
-
type ClockFactory = () => Date;
|
|
241
|
-
/**
|
|
242
|
-
* Replaces the global clock factory used by `createDomainEvent` and
|
|
243
|
-
* `createDomainEventWithMetadata`. Call once during application bootstrap
|
|
244
|
-
* (or per-test in deterministic test suites):
|
|
245
|
-
*
|
|
246
|
-
* ```ts
|
|
247
|
-
* import { setClockFactory } from "@shirudo/ddd-kit";
|
|
248
|
-
*
|
|
249
|
-
* setClockFactory(() => new Date("2026-01-01T00:00:00Z"));
|
|
250
|
-
* ```
|
|
251
|
-
*
|
|
252
|
-
* The per-call `options.occurredAt` override always wins over this
|
|
253
|
-
* factory. Symmetric to `setEventIdFactory`.
|
|
254
|
-
*
|
|
255
|
-
* Module-scoped — see {@link setEventIdFactory} for the global-state
|
|
256
|
-
* caveats. For test isolation prefer {@link withClockFactory}; for
|
|
257
|
-
* multi-tenant request isolation prefer the per-call
|
|
258
|
-
* `options.occurredAt`.
|
|
259
|
-
*/
|
|
260
|
-
declare function setClockFactory(factory: ClockFactory): void;
|
|
261
|
-
/**
|
|
262
|
-
* Scoped variant of {@link setClockFactory}: installs `factory`, runs
|
|
263
|
-
* `fn`, then restores the previous factory in a `finally` block.
|
|
264
|
-
* Synchronous-only — same constraints (and same runtime thenable
|
|
265
|
-
* guard) as {@link withEventIdFactory}.
|
|
266
|
-
*
|
|
267
|
-
* **When to prefer the per-call `options.occurredAt` instead.** Same
|
|
268
|
-
* trade-off as {@link withEventIdFactory}: passing `{ occurredAt }`
|
|
269
|
-
* to `createDomainEvent` is the strongest isolation for single-event
|
|
270
|
-
* cases. The scoped helper is for events constructed deep inside
|
|
271
|
-
* domain methods where threading an explicit timestamp is awkward.
|
|
272
|
-
*
|
|
273
|
-
* @example
|
|
274
|
-
* ```ts
|
|
275
|
-
* it("stamps events with a fixed clock", () => {
|
|
276
|
-
* const fixed = new Date("2026-01-01T00:00:00Z");
|
|
277
|
-
* withClockFactory(() => fixed, () => {
|
|
278
|
-
* const e = createDomainEvent("X", { v: 1 });
|
|
279
|
-
* expect(e.occurredAt).toEqual(fixed);
|
|
280
|
-
* });
|
|
281
|
-
* });
|
|
282
|
-
* ```
|
|
283
|
-
*/
|
|
284
|
-
declare function withClockFactory<T>(factory: ClockFactory, fn: () => T): T;
|
|
285
|
-
/**
|
|
286
|
-
* Restores the default clock factory (`() => new Date()`).
|
|
287
|
-
* Intended for use in test `afterEach` hooks.
|
|
288
|
-
*/
|
|
289
|
-
declare function resetClockFactory(): void;
|
|
290
|
-
/**
|
|
291
|
-
* Metadata associated with a domain event for traceability and correlation.
|
|
292
|
-
* Used in event-driven architectures to track event flow across services.
|
|
293
|
-
*/
|
|
294
|
-
interface EventMetadata {
|
|
295
|
-
/**
|
|
296
|
-
* Correlation ID for tracing events across multiple services/components.
|
|
297
|
-
* Typically used to group related events in a distributed system.
|
|
298
|
-
*/
|
|
299
|
-
correlationId?: string;
|
|
300
|
-
/**
|
|
301
|
-
* Causation ID referencing the event or command that caused this event.
|
|
302
|
-
* Used to build event chains and understand causality.
|
|
303
|
-
*/
|
|
304
|
-
causationId?: string;
|
|
305
|
-
/**
|
|
306
|
-
* User ID of the person or system that triggered the event.
|
|
307
|
-
*/
|
|
308
|
-
userId?: string;
|
|
309
|
-
/**
|
|
310
|
-
* Source service or component that produced the event.
|
|
311
|
-
*/
|
|
312
|
-
source?: string;
|
|
313
|
-
/**
|
|
314
|
-
* Additional custom metadata fields.
|
|
315
|
-
* Allows extensibility for domain-specific metadata.
|
|
316
|
-
*/
|
|
317
|
-
[key: string]: unknown;
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Domain Event represents something meaningful that happened in the domain.
|
|
321
|
-
* Events are immutable and carry information about what occurred.
|
|
322
|
-
*
|
|
323
|
-
* @template T - The event type name (e.g., "OrderCreated")
|
|
324
|
-
* @template P - The event payload type
|
|
325
|
-
*/
|
|
326
|
-
interface DomainEvent<T extends string, P = void> {
|
|
327
|
-
/**
|
|
328
|
-
* Unique identifier for this specific event instance. Used by idempotent
|
|
329
|
-
* consumers, outbox dispatch tracking, and as the target of
|
|
330
|
-
* `metadata.causationId`. Defaults to `crypto.randomUUID()` if not
|
|
331
|
-
* supplied.
|
|
332
|
-
*/
|
|
333
|
-
eventId: string;
|
|
334
|
-
/**
|
|
335
|
-
* The type of the event, used for routing and handling.
|
|
336
|
-
*/
|
|
337
|
-
type: T;
|
|
338
|
-
/**
|
|
339
|
-
* Identifier of the aggregate that produced the event. Optional at the
|
|
340
|
-
* library level — set it whenever the producing aggregate is known so
|
|
341
|
-
* downstream subscribers, outboxes, and projections can scope by entity.
|
|
342
|
-
*/
|
|
343
|
-
aggregateId?: string;
|
|
344
|
-
/**
|
|
345
|
-
* Name of the aggregate type that produced the event (e.g. "Order").
|
|
346
|
-
* Pairs with `aggregateId` to fully qualify the source aggregate.
|
|
347
|
-
*/
|
|
348
|
-
aggregateType?: string;
|
|
349
|
-
/**
|
|
350
|
-
* The event payload containing the domain data. The field is always
|
|
351
|
-
* present; its value is `undefined` when `P` is `void`.
|
|
352
|
-
*/
|
|
353
|
-
payload: P;
|
|
354
|
-
/**
|
|
355
|
-
* Timestamp when the event occurred.
|
|
356
|
-
*/
|
|
357
|
-
occurredAt: Date;
|
|
358
|
-
/**
|
|
359
|
-
* Event schema version for handling schema evolution.
|
|
360
|
-
* Required for safe schema migration in event-sourced systems.
|
|
361
|
-
* Use 1 for the initial schema version.
|
|
362
|
-
*/
|
|
363
|
-
version: number;
|
|
364
|
-
/**
|
|
365
|
-
* Optional metadata for traceability, correlation, and auditing.
|
|
366
|
-
* Includes correlationId, causationId, userId, source, and custom fields.
|
|
367
|
-
*/
|
|
368
|
-
metadata?: EventMetadata;
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Upper-bound alias for "any `DomainEvent` shape". Use as a generic
|
|
372
|
-
* constraint when a type parameter should accept any concrete event
|
|
373
|
-
* union. The `unknown` payload is the upper bound — concrete unions
|
|
374
|
-
* still narrow via `Extract<Evt, { type: K }>` at the use-site.
|
|
375
|
-
*/
|
|
376
|
-
type AnyDomainEvent = DomainEvent<string, unknown>;
|
|
377
|
-
/**
|
|
378
|
-
* Shared option bag for the `createDomainEvent*` factories.
|
|
379
|
-
*/
|
|
380
|
-
interface CreateDomainEventOptions {
|
|
381
|
-
/**
|
|
382
|
-
* Override for the auto-generated `eventId`. Pass an existing id (for
|
|
383
|
-
* replay, tests, or deterministic event sourcing) instead of letting the
|
|
384
|
-
* factory call `crypto.randomUUID()`.
|
|
385
|
-
*/
|
|
386
|
-
eventId?: string;
|
|
387
|
-
/**
|
|
388
|
-
* Identifier of the aggregate that produced the event.
|
|
389
|
-
*/
|
|
390
|
-
aggregateId?: string;
|
|
391
|
-
/**
|
|
392
|
-
* Name of the aggregate type that produced the event.
|
|
393
|
-
*/
|
|
394
|
-
aggregateType?: string;
|
|
395
|
-
/**
|
|
396
|
-
* Override for the auto-generated `occurredAt` timestamp.
|
|
397
|
-
*/
|
|
398
|
-
occurredAt?: Date;
|
|
399
|
-
/**
|
|
400
|
-
* Override for the default schema version (1).
|
|
401
|
-
*/
|
|
402
|
-
version?: number;
|
|
403
|
-
/**
|
|
404
|
-
* Event metadata — correlation, causation, user, source, custom fields.
|
|
405
|
-
*/
|
|
406
|
-
metadata?: EventMetadata;
|
|
407
|
-
}
|
|
408
|
-
/**
|
|
409
|
-
* Creates a domain event with default values.
|
|
410
|
-
* Sets occurredAt to current date and version to 1 if not provided.
|
|
411
|
-
*
|
|
412
|
-
* **For aggregate-internal events, prefer `this.recordEvent(...)` on
|
|
413
|
-
* `AggregateRoot` / `EventSourcedAggregate`.** That helper auto-injects
|
|
414
|
-
* `aggregateId` (from `this.id`) and `aggregateType` (from the
|
|
415
|
-
* aggregate's declared `aggregateType` property), which downstream
|
|
416
|
-
* consumers — outbox dispatchers, projection handlers, audit logs —
|
|
417
|
-
* route by. The `withCommit` harvest boundary now validates both fields
|
|
418
|
-
* are present and throws if they're missing, so a direct
|
|
419
|
-
* `createDomainEvent(...)` call inside an aggregate that forgets the
|
|
420
|
-
* options is caught at runtime.
|
|
421
|
-
*
|
|
422
|
-
* Use `createDomainEvent(...)` directly for events that don't belong to
|
|
423
|
-
* an aggregate: system events, integration events, configuration events,
|
|
424
|
-
* test fixtures. For those, set `aggregateId` / `aggregateType` in
|
|
425
|
-
* `options` if downstream consumers expect routing metadata.
|
|
426
|
-
*
|
|
427
|
-
* @param type - The event type
|
|
428
|
-
* @param payload - The event payload
|
|
429
|
-
* @param options - Optional event configuration (including `aggregateId`
|
|
430
|
-
* and `aggregateType` for routing)
|
|
431
|
-
* @returns A domain event
|
|
432
|
-
*
|
|
433
|
-
* @example
|
|
434
|
-
* ```typescript
|
|
435
|
-
* const event = createDomainEvent("OrderCreated", { orderId: "123" });
|
|
436
|
-
* ```
|
|
437
|
-
*/
|
|
438
|
-
declare function createDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateDomainEventOptions): DomainEvent<T, void>;
|
|
439
|
-
declare function createDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateDomainEventOptions): DomainEvent<T, P>;
|
|
440
|
-
/**
|
|
441
|
-
* Creates a domain event with metadata for traceability.
|
|
442
|
-
* Convenience function for creating events with correlation and causation IDs.
|
|
443
|
-
*
|
|
444
|
-
* @example
|
|
445
|
-
* ```typescript
|
|
446
|
-
* const event = createDomainEventWithMetadata(
|
|
447
|
-
* "OrderCreated",
|
|
448
|
-
* { orderId: "123" },
|
|
449
|
-
* { correlationId: "corr-123", causationId: "cmd-456", userId: "user-789" }
|
|
450
|
-
* );
|
|
451
|
-
* ```
|
|
452
|
-
*/
|
|
453
|
-
declare function createDomainEventWithMetadata<T extends string, P>(type: T, payload: P, metadata: EventMetadata, options?: Omit<CreateDomainEventOptions, "metadata">): DomainEvent<T, P>;
|
|
454
|
-
/**
|
|
455
|
-
* Copies metadata from a source event to a new event.
|
|
456
|
-
* Useful for maintaining correlation chains in event-driven architectures.
|
|
457
|
-
*
|
|
458
|
-
* @example
|
|
459
|
-
* ```typescript
|
|
460
|
-
* const newEvent = createDomainEvent(
|
|
461
|
-
* "OrderShipped",
|
|
462
|
-
* { orderId: "123" },
|
|
463
|
-
* { metadata: copyMetadata(previousEvent, { causationId: previousEvent.type }) }
|
|
464
|
-
* );
|
|
465
|
-
* ```
|
|
466
|
-
*/
|
|
467
|
-
declare function copyMetadata(sourceEvent: AnyDomainEvent, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
|
|
468
|
-
/**
|
|
469
|
-
* Merges multiple metadata objects into one.
|
|
470
|
-
* Later metadata objects override earlier ones for the same keys.
|
|
471
|
-
*
|
|
472
|
-
* @example
|
|
473
|
-
* ```typescript
|
|
474
|
-
* const metadata = mergeMetadata(
|
|
475
|
-
* { correlationId: "corr-123" },
|
|
476
|
-
* { userId: "user-456" },
|
|
477
|
-
* { source: "order-service" }
|
|
478
|
-
* );
|
|
479
|
-
* ```
|
|
480
|
-
*/
|
|
481
|
-
declare function mergeMetadata(...metadataObjects: Array<EventMetadata | undefined>): EventMetadata;
|
|
482
|
-
|
|
483
|
-
type Version = number & {
|
|
484
|
-
readonly __v: true;
|
|
485
|
-
};
|
|
486
|
-
/**
|
|
487
|
-
* Snapshot of an aggregate state at a specific point in time.
|
|
488
|
-
* Used for optimizing event replay by starting from a snapshot
|
|
489
|
-
* instead of replaying all events from the beginning.
|
|
490
|
-
*
|
|
491
|
-
* @template TState - The type of the aggregate state
|
|
492
|
-
*/
|
|
493
|
-
interface AggregateSnapshot<TState> {
|
|
494
|
-
/**
|
|
495
|
-
* The state of the aggregate at the time of the snapshot.
|
|
496
|
-
*/
|
|
497
|
-
state: TState;
|
|
498
|
-
/**
|
|
499
|
-
* The version of the aggregate when the snapshot was taken.
|
|
500
|
-
*/
|
|
501
|
-
version: Version;
|
|
502
|
-
/**
|
|
503
|
-
* Timestamp when the snapshot was created.
|
|
504
|
-
*/
|
|
505
|
-
snapshotAt: Date;
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Public contract every Aggregate Root satisfies. Implemented by
|
|
509
|
-
* `BaseAggregate` and inherited by both `AggregateRoot` and
|
|
510
|
-
* `EventSourcedAggregate`. Repository implementations type their
|
|
511
|
-
* `save(aggregate)` parameter against this interface rather than the
|
|
512
|
-
* concrete classes, so the repo layer does not take a compile-time
|
|
513
|
-
* dependency on the aggregate hierarchy.
|
|
514
|
-
*
|
|
515
|
-
* Full per-member documentation lives on the concrete `BaseAggregate`
|
|
516
|
-
* class; the interface is intentionally terse to avoid drift.
|
|
517
|
-
*
|
|
518
|
-
* @template TId - The aggregate root identifier (branded via `Id<Tag>`)
|
|
519
|
-
* @template TEvent - The domain-event union, defaults to `never`
|
|
520
|
-
*/
|
|
521
|
-
interface IAggregateRoot<TId extends Id<string>, TEvent = never> {
|
|
522
|
-
readonly id: TId;
|
|
523
|
-
readonly version: Version;
|
|
524
|
-
readonly persistedVersion: Version | undefined;
|
|
525
|
-
readonly pendingEvents: ReadonlyArray<TEvent>;
|
|
526
|
-
clearPendingEvents(): void;
|
|
527
|
-
markPersisted(version: Version): void;
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* Public contract for Event-Sourced Aggregate Roots. Extends
|
|
531
|
-
* `IAggregateRoot` with the replay-from-history boundary.
|
|
532
|
-
*
|
|
533
|
-
* @template TId - The aggregate root identifier
|
|
534
|
-
* @template TEvent - The union type of all domain events
|
|
535
|
-
*/
|
|
536
|
-
interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends IAggregateRoot<TId, TEvent> {
|
|
537
|
-
/**
|
|
538
|
-
* Reconstitutes the aggregate from an event history. Returns
|
|
539
|
-
* `Result` because event-stream corruption is an expected
|
|
540
|
-
* recoverable failure at the infrastructure boundary.
|
|
541
|
-
*/
|
|
542
|
-
loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
|
|
543
|
-
}
|
|
544
|
-
/**
|
|
545
|
-
* Checks if two aggregates are at the same version (same ID and version).
|
|
546
|
-
* Useful for optimistic concurrency control checks.
|
|
547
|
-
*
|
|
548
|
-
* Note: Two aggregates with the same ID ARE the same aggregate (identity).
|
|
549
|
-
* This function checks if they are at the same version — i.e., no concurrent modification.
|
|
550
|
-
*
|
|
551
|
-
* @example
|
|
552
|
-
* ```typescript
|
|
553
|
-
* const before = await repository.getById(id);
|
|
554
|
-
* // ... some operations ...
|
|
555
|
-
* const after = await repository.getById(id);
|
|
556
|
-
*
|
|
557
|
-
* if (!sameVersion(before, after)) {
|
|
558
|
-
* throw new Error("Aggregate was modified by another process");
|
|
559
|
-
* }
|
|
560
|
-
* ```
|
|
561
|
-
*/
|
|
562
|
-
declare function sameVersion<TId extends Id<string>>(a: {
|
|
563
|
-
id: TId;
|
|
564
|
-
version: Version;
|
|
565
|
-
}, b: {
|
|
566
|
-
id: TId;
|
|
567
|
-
version: Version;
|
|
568
|
-
}): boolean;
|
|
569
|
-
|
|
570
8
|
/**
|
|
571
9
|
* Entity utilities and interfaces for Domain-Driven Design.
|
|
572
10
|
*
|
|
@@ -625,7 +63,7 @@ declare function sameVersion<TId extends Id<string>>(a: {
|
|
|
625
63
|
*/
|
|
626
64
|
|
|
627
65
|
/**
|
|
628
|
-
* Functional definition of an Entity via its capability
|
|
66
|
+
* Functional definition of an Entity via its capability: an object is
|
|
629
67
|
* identifiable if it has an `id`.
|
|
630
68
|
*
|
|
631
69
|
* `TId` is constrained to `Id<string>` so the brand discipline that
|
|
@@ -694,7 +132,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
|
|
|
694
132
|
/**
|
|
695
133
|
* Returns the current state of the entity.
|
|
696
134
|
*
|
|
697
|
-
* The state object is **shallowly frozen
|
|
135
|
+
* The state object is **shallowly frozen**: direct property writes
|
|
698
136
|
* (`entity.state.foo = …`) throw in strict mode, but writes to nested
|
|
699
137
|
* objects (`entity.state.address.zip = …`) bypass the freeze. For deep
|
|
700
138
|
* immutability either model nested data with `vo()` (which freezes
|
|
@@ -709,6 +147,14 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
|
|
|
709
147
|
* Subclasses can mutate this directly or use helper methods.
|
|
710
148
|
*/
|
|
711
149
|
protected _state: TState;
|
|
150
|
+
/**
|
|
151
|
+
* **State ownership.** Plain-object and array states are shallow-copied
|
|
152
|
+
* before the freeze, so the caller's own object stays mutable. A CLASS
|
|
153
|
+
* INSTANCE passed as state is an ownership transfer: it is frozen
|
|
154
|
+
* in place (a copy would strip its prototype). Do not keep mutating
|
|
155
|
+
* the instance after handing it to the entity. The same contract
|
|
156
|
+
* applies to {@link setState}.
|
|
157
|
+
*/
|
|
712
158
|
protected constructor(id: TId, initialState: TState);
|
|
713
159
|
/**
|
|
714
160
|
* Optional validation hook to ensure state invariants. Called during
|
|
@@ -718,7 +164,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
|
|
|
718
164
|
* **⚠️ Must not read subclass instance fields via `this`.** The
|
|
719
165
|
* constructor calls `validateState(initialState)` BEFORE the subclass's
|
|
720
166
|
* field initializers run, so `this.someField` is `undefined` at that
|
|
721
|
-
* point
|
|
167
|
+
* point, a classic TypeScript/JavaScript constructor-ordering footgun.
|
|
722
168
|
* The `state` argument is the single source of truth; treat the method
|
|
723
169
|
* as pure with respect to `this`.
|
|
724
170
|
*
|
|
@@ -737,6 +183,10 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
|
|
|
737
183
|
* This is a convenience method for state mutations.
|
|
738
184
|
* Automatically validates the newState using `validateState()`.
|
|
739
185
|
*
|
|
186
|
+
* Plain-object and array states are shallow-copied before the freeze
|
|
187
|
+
* (the caller's object stays mutable); a class-instance state is an
|
|
188
|
+
* ownership transfer and is frozen in place; see the constructor.
|
|
189
|
+
*
|
|
740
190
|
* @param newState - The new state
|
|
741
191
|
*/
|
|
742
192
|
protected setState(newState: TState): void;
|
|
@@ -905,7 +355,7 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
|
|
|
905
355
|
* `recordEvent` helper that auto-injects `aggregateId` +
|
|
906
356
|
* `aggregateType` on every event the aggregate emits.
|
|
907
357
|
*
|
|
908
|
-
* Consumers do NOT extend this class directly
|
|
358
|
+
* Consumers do NOT extend this class directly; extend
|
|
909
359
|
* `AggregateRoot` for state-stored aggregates or
|
|
910
360
|
* `EventSourcedAggregate` for event-sourced ones. The split between
|
|
911
361
|
* those two reflects the canonical Vernon §8 (state-stored) /
|
|
@@ -917,8 +367,12 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
|
|
|
917
367
|
* @template TEvent - The domain-event union. Defaults to `never` so
|
|
918
368
|
* aggregates without a declared event type cannot emit events
|
|
919
369
|
* (emitting any event becomes a compile error).
|
|
370
|
+
* @template TSnapshotState - The plain-data shape stored in snapshots.
|
|
371
|
+
* Defaults to `TState` for plain-data states. Aggregates whose state
|
|
372
|
+
* carries class-based child entities declare a plain DTO shape here
|
|
373
|
+
* and override {@link toSnapshotState} / {@link fromSnapshotState}.
|
|
920
374
|
*/
|
|
921
|
-
declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
|
|
375
|
+
declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never, TSnapshotState = TState> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
|
|
922
376
|
/**
|
|
923
377
|
* The aggregate's domain type as a string, used to populate
|
|
924
378
|
* `aggregateType` on events recorded via {@link recordEvent}.
|
|
@@ -933,7 +387,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
933
387
|
*
|
|
934
388
|
* The string is *the* identifier downstream consumers (outbox
|
|
935
389
|
* dispatchers, projection handlers, audit logs) use to route by
|
|
936
|
-
* aggregate kind. Use the same canonical name across your system
|
|
390
|
+
* aggregate kind. Use the same canonical name across your system;
|
|
937
391
|
* matching the class name is the obvious choice, but the value
|
|
938
392
|
* comes from this explicit declaration, not `constructor.name`
|
|
939
393
|
* (which is fragile under minification, bundler transforms, and
|
|
@@ -949,7 +403,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
949
403
|
*
|
|
950
404
|
* Distinct from {@link version}, which is the in-memory
|
|
951
405
|
* post-mutation value. Mutations bump `_version` but never touch
|
|
952
|
-
* `_persistedVersion
|
|
406
|
+
* `_persistedVersion`; that field only moves on {@link markRestored}
|
|
953
407
|
* (Post-Load) and {@link markPersisted} (Post-Save).
|
|
954
408
|
*/
|
|
955
409
|
private _persistedVersion;
|
|
@@ -963,7 +417,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
963
417
|
get pendingEvents(): ReadonlyArray<TEvent>;
|
|
964
418
|
/**
|
|
965
419
|
* Clears the pending-event list. Called by `markPersisted` after a
|
|
966
|
-
* successful write
|
|
420
|
+
* successful write: the events have been handed off to the outbox
|
|
967
421
|
* / event store and are no longer the aggregate's responsibility.
|
|
968
422
|
*/
|
|
969
423
|
clearPendingEvents(): void;
|
|
@@ -975,16 +429,26 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
975
429
|
*/
|
|
976
430
|
protected bumpVersion(): void;
|
|
977
431
|
/**
|
|
978
|
-
* **Lifecycle marker
|
|
432
|
+
* **Lifecycle marker, Post-Load.** Syncs both `_version` and
|
|
979
433
|
* `_persistedVersion` to the DB-stored version. Used by
|
|
980
434
|
* `reconstitute(...)` factories to assemble an in-memory aggregate
|
|
981
435
|
* from a persisted row.
|
|
982
436
|
*
|
|
983
|
-
* Does NOT fire {@link onPersisted}
|
|
437
|
+
* Does NOT fire {@link onPersisted}; that hook has post-save
|
|
984
438
|
* semantics (metrics, audit, cache eviction), not post-load. The
|
|
985
439
|
* Factory-vs-Reconstitution distinction (Vernon §11) is honoured
|
|
986
440
|
* structurally: two separate markers, one for each transition.
|
|
987
441
|
*
|
|
442
|
+
* **If you override this, call `super.markRestored(version)` FIRST**,
|
|
443
|
+
* same discipline as {@link markPersisted}. The marker is load-bearing
|
|
444
|
+
* twice over: it syncs `version`/`persistedVersion`, and on
|
|
445
|
+
* `AggregateRoot` it also captures the dirty-tracking baseline for
|
|
446
|
+
* `changedKeys`/`hasChanges`. An override that skips `super` leaves
|
|
447
|
+
* that baseline uncaptured: `changedKeys` permanently reports ALL
|
|
448
|
+
* keys and `hasChanges` never returns `false`, so a partial-write
|
|
449
|
+
* repository silently degrades to full writes on every save — on top
|
|
450
|
+
* of the broken version sync.
|
|
451
|
+
*
|
|
988
452
|
* @param version - The version the row currently holds in the DB
|
|
989
453
|
*
|
|
990
454
|
* @example
|
|
@@ -998,14 +462,14 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
998
462
|
*/
|
|
999
463
|
protected markRestored(version: Version): void;
|
|
1000
464
|
/**
|
|
1001
|
-
* **Framework lifecycle method
|
|
465
|
+
* **Framework lifecycle method (`@sealed`).** Called by `withCommit`
|
|
1002
466
|
* (or by your own orchestration code, after harvesting `pendingEvents`)
|
|
1003
467
|
* to push the persisted version back into the in-memory aggregate and
|
|
1004
468
|
* clear `pendingEvents`. TypeScript has no `final` keyword, but
|
|
1005
469
|
* subclasses **should not** override this method directly.
|
|
1006
470
|
*
|
|
1007
471
|
* Overriding without calling `super.markPersisted(version)` silently
|
|
1008
|
-
* leaks `pendingEvents
|
|
472
|
+
* leaks `pendingEvents`: the next `withCommit` will re-dispatch them
|
|
1009
473
|
* through the outbox, double-emitting events. This bug has been hit
|
|
1010
474
|
* in production by consumers; the {@link onPersisted} hook below is
|
|
1011
475
|
* the safer extension point.
|
|
@@ -1015,23 +479,29 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
1015
479
|
* runs, then add your logic afterwards.
|
|
1016
480
|
*
|
|
1017
481
|
* @param version - The version assigned by the persistence layer
|
|
1018
|
-
* @see onPersisted
|
|
482
|
+
* @see onPersisted, the safe extension point for subclasses
|
|
1019
483
|
*/
|
|
1020
484
|
markPersisted(version: Version): void;
|
|
1021
485
|
/**
|
|
1022
|
-
* Subclass extension point
|
|
486
|
+
* Subclass extension point: fires AFTER {@link markPersisted} has
|
|
1023
487
|
* updated the version and cleared `pendingEvents`. Override this for
|
|
1024
488
|
* post-persist logging, metrics, or cache-eviction without risk of
|
|
1025
489
|
* breaking the framework's pendingEvents cleanup.
|
|
1026
490
|
*
|
|
1027
491
|
* The default implementation is a no-op. Subclasses do NOT need to
|
|
1028
|
-
* call `super.onPersisted(version)
|
|
492
|
+
* call `super.onPersisted(version)`: there is nothing in the parent
|
|
1029
493
|
* implementation to preserve.
|
|
1030
494
|
*
|
|
495
|
+
* **Observer contract: errors are swallowed.** `withCommit` invokes
|
|
496
|
+
* `markPersisted` after the transaction has committed; a throwing hook
|
|
497
|
+
* must neither abort the loop for peer aggregates nor make the
|
|
498
|
+
* committed write look failed, so `withCommit` catches and discards
|
|
499
|
+
* hook errors. Handle failures inside the hook if you need them.
|
|
500
|
+
*
|
|
1031
501
|
* **`onPersisted` deliberately receives only the version, not the
|
|
1032
502
|
* drained events.** Event-driven post-persist logic (aggregate-level
|
|
1033
503
|
* audit logging, per-event-type side effects) belongs in `EventBus`
|
|
1034
|
-
* subscribers or the outbox dispatcher
|
|
504
|
+
* subscribers or the outbox dispatcher; that is the proper
|
|
1035
505
|
* Aggregate-Boundary separation. Building event-aware logic into
|
|
1036
506
|
* `onPersisted` couples aggregate lifecycle to event processing and
|
|
1037
507
|
* recreates the boundary problems Vernon's aggregate discipline is
|
|
@@ -1040,7 +510,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
1040
510
|
* **The hook must return synchronously.** `markPersisted` is `void`-
|
|
1041
511
|
* typed and calls `onPersisted` without `await`. TypeScript's
|
|
1042
512
|
* permissive `void` will accept an `async`-override returning
|
|
1043
|
-
* `Promise<void>`, but the returned promise is fire-and-forget
|
|
513
|
+
* `Promise<void>`, but the returned promise is fire-and-forget:
|
|
1044
514
|
* any rejection becomes an unhandled rejection and `withCommit`
|
|
1045
515
|
* proceeds without waiting. For asynchronous work, subscribe to the
|
|
1046
516
|
* relevant domain event on the `EventBus` instead; that is the
|
|
@@ -1052,7 +522,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
1052
522
|
/**
|
|
1053
523
|
* Appends a domain event to the pending list. Prefer the higher-level
|
|
1054
524
|
* `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
|
|
1055
|
-
* (event-sourced) call sites
|
|
525
|
+
* (event-sourced) call sites, both of which wrap `addDomainEvent` in the
|
|
1056
526
|
* canonical record-AFTER-mutation order (Vernon §8). Calling
|
|
1057
527
|
* `addDomainEvent` directly is appropriate only when state and event
|
|
1058
528
|
* recording have already been decoupled deliberately (e.g. a
|
|
@@ -1060,19 +530,46 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
1060
530
|
*/
|
|
1061
531
|
protected addDomainEvent(event: TEvent): void;
|
|
1062
532
|
/**
|
|
1063
|
-
* Creates a snapshot of the current aggregate state
|
|
533
|
+
* Creates a snapshot of the current aggregate state: the state at
|
|
1064
534
|
* this moment plus the version. Useful for ES snapshot policies and
|
|
1065
535
|
* for state-stored backup / restore.
|
|
536
|
+
*
|
|
537
|
+
* The state is converted via {@link toSnapshotState}; the default
|
|
538
|
+
* requires plain, serialisable data and fails fast otherwise.
|
|
539
|
+
*/
|
|
540
|
+
createSnapshot(): AggregateSnapshot<TSnapshotState>;
|
|
541
|
+
/**
|
|
542
|
+
* Converts live aggregate state into the plain-data shape stored in a
|
|
543
|
+
* snapshot. The default validates that the state graph is plain,
|
|
544
|
+
* serialisable data (no class instances, functions, Promise/WeakMap/
|
|
545
|
+
* WeakSet) and then `structuredClone`s it: class instances would
|
|
546
|
+
* silently lose their prototype here AND on every snapshot-store
|
|
547
|
+
* round-trip, so the default fails fast with the offending path
|
|
548
|
+
* instead of producing a snapshot that breaks on first method call
|
|
549
|
+
* after restore.
|
|
550
|
+
*
|
|
551
|
+
* Override this together with {@link fromSnapshotState} (and the
|
|
552
|
+
* `TSnapshotState` generic) when the state carries class-based child
|
|
553
|
+
* entities. The override owns isolation: return fresh objects, not
|
|
554
|
+
* references into live state.
|
|
555
|
+
*/
|
|
556
|
+
protected toSnapshotState(state: TState): TSnapshotState;
|
|
557
|
+
/**
|
|
558
|
+
* Converts the plain-data snapshot shape back into live aggregate
|
|
559
|
+
* state. The default `structuredClone`s the stored state so the
|
|
560
|
+
* restored aggregate never aliases the snapshot object. Override
|
|
561
|
+
* together with {@link toSnapshotState} to reconstruct class-based
|
|
562
|
+
* child entities.
|
|
1066
563
|
*/
|
|
1067
|
-
|
|
564
|
+
protected fromSnapshotState(stored: TSnapshotState): TState;
|
|
1068
565
|
/**
|
|
1069
566
|
* Sugar for `createDomainEvent` that auto-injects `aggregateId`
|
|
1070
567
|
* (from `this.id`) and `aggregateType` (from {@link aggregateType})
|
|
1071
568
|
* into the event's metadata fields. This is the canonical path for
|
|
1072
569
|
* recording events from inside aggregate domain methods.
|
|
1073
570
|
*
|
|
1074
|
-
* Downstream consumers
|
|
1075
|
-
* audit logs
|
|
571
|
+
* Downstream consumers (outbox dispatchers, projection handlers,
|
|
572
|
+
* audit logs) route by these two fields. Calling
|
|
1076
573
|
* `createDomainEvent(...)` directly inside an aggregate method
|
|
1077
574
|
* leaves them unset and is caught at the `withCommit` harvest
|
|
1078
575
|
* boundary, but `this.recordEvent(...)` makes the right thing
|
|
@@ -1096,8 +593,8 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
1096
593
|
* @param payload - payload for that event subtype
|
|
1097
594
|
* @param options - any remaining `createDomainEvent` options
|
|
1098
595
|
* (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
|
|
1099
|
-
* and `aggregateType` are deliberately omitted
|
|
1100
|
-
* them.
|
|
596
|
+
* and `aggregateType` are deliberately omitted, because the helper
|
|
597
|
+
* sets them.
|
|
1101
598
|
*/
|
|
1102
599
|
protected recordEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateDomainEventOptions, "aggregateId" | "aggregateType">): E;
|
|
1103
600
|
}
|
|
@@ -1110,7 +607,7 @@ interface AggregateConfig {
|
|
|
1110
607
|
* Whether `setState()` should bump the version automatically when the
|
|
1111
608
|
* caller omits the per-call `bumpVersion` argument.
|
|
1112
609
|
*
|
|
1113
|
-
* Defaults to **`false
|
|
610
|
+
* Defaults to **`false`**: `setState()` already takes an explicit
|
|
1114
611
|
* `bumpVersion` argument per call, so the config is just the default
|
|
1115
612
|
* the per-call argument falls back to. Set to `true` only if you have
|
|
1116
613
|
* a subclass that never passes `bumpVersion` and you want every state
|
|
@@ -1121,8 +618,8 @@ interface AggregateConfig {
|
|
|
1121
618
|
/**
|
|
1122
619
|
* Base class for Aggregate Roots without Event Sourcing.
|
|
1123
620
|
*
|
|
1124
|
-
* In DDD (Evans), an Aggregate is a cluster of objects
|
|
1125
|
-
* and value objects
|
|
621
|
+
* In DDD (Evans), an Aggregate is a cluster of objects (root entity, child entities,
|
|
622
|
+
* and value objects) treated as a unit for consistency. The **Aggregate Root** is the
|
|
1126
623
|
* root entity that represents the aggregate externally and is the only entry point
|
|
1127
624
|
* for external code. This class serves as both: it IS the root entity and it contains
|
|
1128
625
|
* the aggregate state (`TState`) which holds child entities and value objects.
|
|
@@ -1141,7 +638,7 @@ interface AggregateConfig {
|
|
|
1141
638
|
*
|
|
1142
639
|
* @template TState - The type of the aggregate state (contains child entities and value objects)
|
|
1143
640
|
* @template TId - The type of the aggregate root identifier
|
|
1144
|
-
* @template TEvent - The type of domain events recorded by this aggregate. Defaults to `never
|
|
641
|
+
* @template TEvent - The type of domain events recorded by this aggregate. Defaults to `never`: aggregates without a declared event type cannot emit events (emitting any event becomes a compile error). Supply a concrete event union to opt in.
|
|
1145
642
|
*
|
|
1146
643
|
* @example
|
|
1147
644
|
* ```typescript
|
|
@@ -1162,9 +659,108 @@ interface AggregateConfig {
|
|
|
1162
659
|
* }
|
|
1163
660
|
* ```
|
|
1164
661
|
*/
|
|
1165
|
-
declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends BaseAggregate<TState, TId, TEvent> {
|
|
662
|
+
declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never, TSnapshotState = TState> extends BaseAggregate<TState, TId, TEvent, TSnapshotState> {
|
|
1166
663
|
private readonly _autoVersionBump;
|
|
664
|
+
/**
|
|
665
|
+
* The state reference as of the last {@link markRestored} /
|
|
666
|
+
* `markPersisted` (the persistence-lifecycle markers). Only
|
|
667
|
+
* meaningful while {@link _hasBaseline} is `true`; tracked by a
|
|
668
|
+
* separate flag rather than an `undefined` sentinel so a `TState`
|
|
669
|
+
* that itself admits `undefined` cannot be confused with the
|
|
670
|
+
* never-persisted insert path.
|
|
671
|
+
*
|
|
672
|
+
* Held by reference, never copied: `_state` is shallow-frozen and only
|
|
673
|
+
* ever *replaced* (via `setState` / restore), so the captured reference
|
|
674
|
+
* stays an exact image of the state at baseline time.
|
|
675
|
+
*/
|
|
676
|
+
private _baselineState;
|
|
677
|
+
/**
|
|
678
|
+
* `false` until the aggregate has been persisted or restored at least
|
|
679
|
+
* once: the insert path, where every key counts as changed.
|
|
680
|
+
*/
|
|
681
|
+
private _hasBaseline;
|
|
1167
682
|
protected constructor(id: TId, initialState: TState, config?: AggregateConfig);
|
|
683
|
+
/**
|
|
684
|
+
* **Lifecycle marker, Post-Load (see `BaseAggregate.markRestored`).**
|
|
685
|
+
* Additionally captures the current state reference as the dirty-
|
|
686
|
+
* tracking baseline for {@link changedKeys} / {@link hasChanges}.
|
|
687
|
+
*
|
|
688
|
+
* Covers all three baseline-capture paths through a single override:
|
|
689
|
+
* `reconstitute(...)` factories, {@link restoreFromSnapshot} (which
|
|
690
|
+
* assigns the restored state *before* calling this), and
|
|
691
|
+
* `markPersisted` (which delegates here, so a successful save
|
|
692
|
+
* re-baselines the diff).
|
|
693
|
+
*
|
|
694
|
+
* If you override this, call `super.markRestored(version)` FIRST:
|
|
695
|
+
* skipping it leaves the baseline uncaptured, so `changedKeys`
|
|
696
|
+
* permanently reports ALL keys and `hasChanges` never returns `false`
|
|
697
|
+
* — partial-write repositories silently degrade to full writes — on
|
|
698
|
+
* top of breaking version sync.
|
|
699
|
+
*/
|
|
700
|
+
protected markRestored(version: Version): void;
|
|
701
|
+
/**
|
|
702
|
+
* Top-level state keys whose value (or presence) changed since the
|
|
703
|
+
* last {@link markRestored} / `markPersisted`. Never-persisted
|
|
704
|
+
* aggregates report ALL current keys (the insert path).
|
|
705
|
+
*
|
|
706
|
+
* This is the write-scoping signal for **partial writes in multi-table
|
|
707
|
+
* repositories**: a `save()` for an aggregate whose state spans a root
|
|
708
|
+
* row plus N child-collection tables can write only the collections
|
|
709
|
+
* whose key is dirty, while the root-row OCC version write rides every
|
|
710
|
+
* save. See `docs/guide/repository.md` → "Partial writes for
|
|
711
|
+
* multi-table aggregates".
|
|
712
|
+
*
|
|
713
|
+
* **How it works.** `setState()` replaces state immutably and the
|
|
714
|
+
* state object is shallow-frozen, so unchanged top-level sub-objects
|
|
715
|
+
* keep reference identity across mutations. The diff is therefore a
|
|
716
|
+
* shallow per-key `!==` against the baseline reference — O(top-level
|
|
717
|
+
* keys), no proxies, no deep diff. A key also counts as dirty when its
|
|
718
|
+
* *presence* differs (added or removed, even with an `undefined`
|
|
719
|
+
* value). Computed fresh on every access (a new `Set` each time), so
|
|
720
|
+
* callers cannot poison later reads.
|
|
721
|
+
*
|
|
722
|
+
* **Soundness contract (same one `freezeShallow` already makes):**
|
|
723
|
+
* the per-key diff is exact only for plain-record `TState` mutated via
|
|
724
|
+
* `setState` / `commit` (whole-state replacement). In-place mutation
|
|
725
|
+
* of NESTED objects bypasses the shallow freeze AND this diff; a
|
|
726
|
+
* class-instance `TState` mutated through its own methods defeats
|
|
727
|
+
* tracking entirely (the reference never changes). A keyless `TState`
|
|
728
|
+
* (primitive, bare `Date`) has no keys to report, so `changedKeys`
|
|
729
|
+
* stays empty for it — use {@link hasChanges}, whose reference
|
|
730
|
+
* fallback covers keyless states. A deep-equal but newly-referenced
|
|
731
|
+
* value reports a false POSITIVE (harmless extra write); under the
|
|
732
|
+
* contract above there are no false negatives.
|
|
733
|
+
*
|
|
734
|
+
* Granularity is per top-level key — table-granular, not row-granular:
|
|
735
|
+
* a dirty collection key means "this child table changed", not which
|
|
736
|
+
* rows. `EventSourcedAggregate` deliberately has no `changedKeys`;
|
|
737
|
+
* its `pendingEvents` are the change record.
|
|
738
|
+
*/
|
|
739
|
+
get changedKeys(): ReadonlySet<Extract<keyof TState, string>>;
|
|
740
|
+
/**
|
|
741
|
+
* Safe skip signal: `false` only when there is genuinely nothing to
|
|
742
|
+
* persist or flush. `true` when the aggregate has never been
|
|
743
|
+
* persisted, the version moved past `persistedVersion`, there are
|
|
744
|
+
* unflushed {@link pendingEvents}, any state key is dirty, or — for
|
|
745
|
+
* keyless states the per-key diff cannot see (primitive `TState`,
|
|
746
|
+
* zero-own-key objects like a bare `Date`) — the state reference
|
|
747
|
+
* changed since the baseline.
|
|
748
|
+
*
|
|
749
|
+
* The version clause is deliberate: `setState({...state}, true)` with
|
|
750
|
+
* identical per-key values yields empty {@link changedKeys} but a
|
|
751
|
+
* bumped version. If a repository skipped `save()` on a state-only
|
|
752
|
+
* check, `withCommit` would still call `markPersisted(version)` after
|
|
753
|
+
* commit, desyncing `persistedVersion` from the DB row — and the next
|
|
754
|
+
* uncontended save would throw a false `ConcurrencyConflictError`.
|
|
755
|
+
*
|
|
756
|
+
* The pending-events clause covers the sanctioned decoupled
|
|
757
|
+
* `addDomainEvent` path (an event recorded without a state change,
|
|
758
|
+
* e.g. a deletion event before a hard delete): the aggregate still
|
|
759
|
+
* needs its trip through `withCommit` so the event reaches the
|
|
760
|
+
* outbox. With all clauses included, `hasChanges === false` genuinely
|
|
761
|
+
* means "skipping save is safe".
|
|
762
|
+
*/
|
|
763
|
+
get hasChanges(): boolean;
|
|
1168
764
|
/**
|
|
1169
765
|
* Mutates state and records the resulting domain events in the
|
|
1170
766
|
* **canonical record-after-mutation order**. Use this instead of calling
|
|
@@ -1172,7 +768,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
|
|
|
1172
768
|
* "event for a fact that never happened" footgun.
|
|
1173
769
|
*
|
|
1174
770
|
* Order of operations:
|
|
1175
|
-
* 1. `setState(newState, true)
|
|
771
|
+
* 1. `setState(newState, true)`: runs `validateState` first.
|
|
1176
772
|
* If it throws, the method propagates and **no event is recorded
|
|
1177
773
|
* and no version is bumped**.
|
|
1178
774
|
* 2. Each event in `events` is appended via `addDomainEvent`.
|
|
@@ -1219,13 +815,13 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
|
|
|
1219
815
|
*/
|
|
1220
816
|
protected setState(newState: TState, bumpVersion?: boolean): void;
|
|
1221
817
|
/**
|
|
1222
|
-
* Restores the aggregate from a snapshot
|
|
818
|
+
* Restores the aggregate from a snapshot: loads state and aligns
|
|
1223
819
|
* `version` + `persistedVersion` to the snapshot version. Validates
|
|
1224
820
|
* the restored state.
|
|
1225
821
|
*
|
|
1226
822
|
* @param snapshot - The snapshot to restore from
|
|
1227
823
|
*/
|
|
1228
|
-
restoreFromSnapshot(snapshot: AggregateSnapshot<
|
|
824
|
+
restoreFromSnapshot(snapshot: AggregateSnapshot<TSnapshotState>): void;
|
|
1229
825
|
}
|
|
1230
826
|
|
|
1231
827
|
type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEvent) => TState;
|
|
@@ -1234,19 +830,19 @@ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEv
|
|
|
1234
830
|
*
|
|
1235
831
|
* Like `AggregateRoot`, this is both the root entity and the aggregate
|
|
1236
832
|
* boundary. The difference is persistence: state is derived from events,
|
|
1237
|
-
* not stored directly. Events are the single source of truth
|
|
833
|
+
* not stored directly. Events are the single source of truth: all state
|
|
1238
834
|
* changes go through `apply()` → handler.
|
|
1239
835
|
*
|
|
1240
836
|
* Extends `BaseAggregate` (the shared lifecycle machinery) but does NOT
|
|
1241
837
|
* expose `setState()` or `commit()` from `AggregateRoot`. This enforces
|
|
1242
|
-
* the event sourcing pattern at the type level
|
|
838
|
+
* the event sourcing pattern at the type level: there is no way to
|
|
1243
839
|
* mutate state without going through an event handler.
|
|
1244
840
|
*
|
|
1245
841
|
* `apply()` and `validateEvent()` throw `DomainError`-derived exceptions
|
|
1246
842
|
* on invariant violations. Subclasses override `validateEvent()` to
|
|
1247
843
|
* throw their own concrete subclasses (e.g. `OrderAlreadyConfirmedError`).
|
|
1248
844
|
* Only the infrastructure-boundary methods (`loadFromHistory`,
|
|
1249
|
-
* `restoreFromSnapshotWithEvents`) return `Result
|
|
845
|
+
* `restoreFromSnapshotWithEvents`) return `Result`: they catch
|
|
1250
846
|
* `DomainError` during replay so callers can react to corrupted event
|
|
1251
847
|
* streams without try/catch.
|
|
1252
848
|
*
|
|
@@ -1282,7 +878,7 @@ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEv
|
|
|
1282
878
|
* }
|
|
1283
879
|
* ```
|
|
1284
880
|
*/
|
|
1285
|
-
declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string
|
|
881
|
+
declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string>, TSnapshotState = TState> extends BaseAggregate<TState, TId, TEvent, TSnapshotState> implements IEventSourcedAggregate<TId, TEvent> {
|
|
1286
882
|
/**
|
|
1287
883
|
* Validates an event before it is applied. Default is no-op.
|
|
1288
884
|
* Subclasses override to throw a concrete `DomainError` subclass when
|
|
@@ -1296,13 +892,13 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
|
|
|
1296
892
|
* Throws `DomainError` (or a subclass) on validation failure.
|
|
1297
893
|
* Throws `MissingHandlerError` if no handler is registered for `event.type`.
|
|
1298
894
|
*
|
|
1299
|
-
* State is not mutated if any step throws
|
|
895
|
+
* State is not mutated if any step throws: the handler is invoked into
|
|
1300
896
|
* a local and only assigned to `_state` once all checks pass.
|
|
1301
897
|
*
|
|
1302
898
|
* The method is generic in the event tag `K`, so concrete callers
|
|
1303
899
|
* (`this.apply(orderCreated)`) narrow to the literal tag and the
|
|
1304
|
-
* dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }
|
|
1305
|
-
*
|
|
900
|
+
* dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }>>`,
|
|
901
|
+
* with no `as` cast required at the call site.
|
|
1306
902
|
*
|
|
1307
903
|
* @param event - The domain event to apply
|
|
1308
904
|
* @param isNew - Whether the event is new (needs persisting) or replayed from history
|
|
@@ -1320,10 +916,16 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
|
|
|
1320
916
|
private dispatchAndCommit;
|
|
1321
917
|
/**
|
|
1322
918
|
* Reconstitutes the aggregate from an event history. Catches `DomainError`
|
|
1323
|
-
* thrown during replay and returns it as an `Err
|
|
919
|
+
* thrown during replay and returns it as an `Err`: this is the
|
|
1324
920
|
* infrastructure boundary, where event-stream corruption is an expected
|
|
1325
921
|
* recoverable failure. Unexpected (non-DomainError) throws propagate.
|
|
1326
922
|
*
|
|
923
|
+
* All-or-nothing: if any event mid-stream throws, the aggregate's state
|
|
924
|
+
* is rolled back to its pre-call value, the same contract as
|
|
925
|
+
* `restoreFromSnapshotWithEvents`. Partial replay is never observable.
|
|
926
|
+
* (Version needs no rollback: replay dispatches with `isNew = false`,
|
|
927
|
+
* which never bumps it; only the final `markRestored` advances it.)
|
|
928
|
+
*
|
|
1327
929
|
* Version advances additively: the aggregate's pre-existing version plus
|
|
1328
930
|
* `history.length`. A fresh aggregate (v=0) loading 3 events ends at v=3;
|
|
1329
931
|
* an aggregate already at v=1 (e.g. after a creation event) loading
|
|
@@ -1340,7 +942,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
|
|
|
1340
942
|
* aggregate is rolled back to its pre-call state + version. Partial
|
|
1341
943
|
* restoration is never observable to the caller.
|
|
1342
944
|
*/
|
|
1343
|
-
restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<
|
|
945
|
+
restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TSnapshotState>, eventsAfterSnapshot: ReadonlyArray<TEvent>): Result<void, DomainError>;
|
|
1344
946
|
/**
|
|
1345
947
|
* A map of event types to their corresponding handlers.
|
|
1346
948
|
* Subclasses MUST implement this property.
|
|
@@ -1498,7 +1100,7 @@ interface ICommandBus<TMap extends CommandTypeMap = CommandTypeMap> {
|
|
|
1498
1100
|
*
|
|
1499
1101
|
* When `TMap` is supplied, the `commandType` argument is restricted to
|
|
1500
1102
|
* its keys and the handler signature is forced to match `TMap[K]` for the
|
|
1501
|
-
* return value
|
|
1103
|
+
* return value: typos and wrong-typed handlers are compile errors.
|
|
1502
1104
|
* Without `TMap` the registration is loose (any string key, any return
|
|
1503
1105
|
* type) so the no-config path keeps working.
|
|
1504
1106
|
*
|
|
@@ -1599,13 +1201,13 @@ interface EventBus<Evt extends AnyDomainEvent> {
|
|
|
1599
1201
|
* awaits all of its handlers, then dispatches `b`, and so on. The
|
|
1600
1202
|
* library never reorders or parallelises across events.
|
|
1601
1203
|
* 2. **Handlers within a single event run in parallel.** All handlers
|
|
1602
|
-
* subscribed to `event.type` are awaited via `Promise.allSettled
|
|
1204
|
+
* subscribed to `event.type` are awaited via `Promise.allSettled`:
|
|
1603
1205
|
* none of them sees the others' errors and none is skipped if a
|
|
1604
1206
|
* peer fails.
|
|
1605
1207
|
* 3. **Errors are collected and thrown AFTER everything dispatches.**
|
|
1606
1208
|
* If one handler throws, remaining handlers for that event still
|
|
1607
1209
|
* run, and remaining events in the batch still publish. Once
|
|
1608
|
-
* `publish` reaches the end of the batch it throws
|
|
1210
|
+
* `publish` reaches the end of the batch it throws: the single
|
|
1609
1211
|
* error directly if there was one, or an `AggregateError`
|
|
1610
1212
|
* ("Multiple event handlers failed") containing every captured
|
|
1611
1213
|
* error otherwise. Callers that need fail-fast semantics should
|
|
@@ -1678,7 +1280,7 @@ interface OnceOptions {
|
|
|
1678
1280
|
/**
|
|
1679
1281
|
* One pending event in the outbox plus the opaque id the implementation
|
|
1680
1282
|
* needs to ack it via `markDispatched`. The library does not prescribe
|
|
1681
|
-
* what `dispatchId` looks like
|
|
1283
|
+
* what `dispatchId` looks like: an implementation can reuse the event's
|
|
1682
1284
|
* own `eventId`, generate its own UUID, use the row's auto-increment
|
|
1683
1285
|
* primary key, or whatever the storage layer prefers.
|
|
1684
1286
|
*/
|
|
@@ -1687,7 +1289,7 @@ interface OutboxRecord<Evt extends AnyDomainEvent> {
|
|
|
1687
1289
|
event: Evt;
|
|
1688
1290
|
}
|
|
1689
1291
|
/**
|
|
1690
|
-
* Transactional outbox port
|
|
1292
|
+
* Transactional outbox port: the bridge between the write-side
|
|
1691
1293
|
* transaction and the (out-of-band) event dispatcher.
|
|
1692
1294
|
*
|
|
1693
1295
|
* Lifecycle:
|
|
@@ -1698,7 +1300,7 @@ interface OutboxRecord<Evt extends AnyDomainEvent> {
|
|
|
1698
1300
|
* 3. After successful dispatch, the dispatcher calls `markDispatched()`
|
|
1699
1301
|
* with the records' `dispatchId`s so they don't come back next poll.
|
|
1700
1302
|
*
|
|
1701
|
-
* `markDispatched` is required to be idempotent
|
|
1303
|
+
* `markDispatched` is required to be idempotent: calling it with an id
|
|
1702
1304
|
* that's already marked is a no-op, not an error. This lets the
|
|
1703
1305
|
* dispatcher safely retry on partial-failure.
|
|
1704
1306
|
*/
|
|
@@ -1737,20 +1339,24 @@ interface Outbox<Evt extends AnyDomainEvent> {
|
|
|
1737
1339
|
* `$transaction`, etc.). The block commits when the callback resolves
|
|
1738
1340
|
* and rolls back if it throws.
|
|
1739
1341
|
*
|
|
1740
|
-
* `TCtx` is the persistence layer's transaction handle
|
|
1342
|
+
* `TCtx` is the persistence layer's transaction handle: Drizzle's `tx`,
|
|
1741
1343
|
* Prisma's `tx`, Mongo's session, etc. The scope opens the transaction
|
|
1742
1344
|
* and passes the handle to `fn`; the use case binds its repositories to
|
|
1743
1345
|
* that handle (typically by constructing a tx-scoped repo from the ctx).
|
|
1744
1346
|
*
|
|
1745
1347
|
* No default for `TCtx`: every implementor names their context type
|
|
1746
1348
|
* explicitly. For genuinely context-free scopes (in-memory tests, naive
|
|
1747
|
-
* no-tx scopes) use `TransactionScope<undefined
|
|
1349
|
+
* no-tx scopes) use `TransactionScope<undefined>`: that's a conscious
|
|
1748
1350
|
* "there is nothing meaningful here" statement, not an accidental
|
|
1749
1351
|
* `unknown` fallback.
|
|
1750
1352
|
*
|
|
1751
|
-
* Intentionally
|
|
1752
|
-
* no
|
|
1753
|
-
*
|
|
1353
|
+
* Intentionally minimal: the scope itself does no change tracking and
|
|
1354
|
+
* no commit-time flush. Those concerns live in the layers above - the
|
|
1355
|
+
* aggregate detects its own changes (`changedKeys` / `hasChanges`),
|
|
1356
|
+
* `withCommit` orchestrates the commit lifecycle, and the opt-in
|
|
1357
|
+
* `UnitOfWork` facade adds tx-bound repositories and enrollment. See
|
|
1358
|
+
* "TransactionScope stays minimal; the Unit of Work lives above it" in
|
|
1359
|
+
* docs/guide/design-decisions.md.
|
|
1754
1360
|
*
|
|
1755
1361
|
* @example Drizzle implementation
|
|
1756
1362
|
* ```typescript
|
|
@@ -1762,7 +1368,7 @@ interface Outbox<Evt extends AnyDomainEvent> {
|
|
|
1762
1368
|
* }
|
|
1763
1369
|
* ```
|
|
1764
1370
|
*
|
|
1765
|
-
* @example Use site
|
|
1371
|
+
* @example Use site: bind repos to the live transaction
|
|
1766
1372
|
* ```typescript
|
|
1767
1373
|
* await scope.transactional(async (tx) => {
|
|
1768
1374
|
* // Construct tx-bound repos from ctx (your factory / DI of choice)
|
|
@@ -1774,7 +1380,7 @@ interface Outbox<Evt extends AnyDomainEvent> {
|
|
|
1774
1380
|
* });
|
|
1775
1381
|
* ```
|
|
1776
1382
|
*
|
|
1777
|
-
* `IRepository`'s contract takes the id / aggregate only
|
|
1383
|
+
* `IRepository`'s contract takes the id / aggregate only: the tx handle
|
|
1778
1384
|
* is wired into a concrete repository at construction time, not threaded
|
|
1779
1385
|
* through every call. Different ORMs have different idioms for that
|
|
1780
1386
|
* (constructor injection, factory functions, `withTx` chains); pick one
|
|
@@ -1794,14 +1400,17 @@ interface TransactionScope<TCtx> {
|
|
|
1794
1400
|
* committed" is the orchestrator's call to make, not the repo's.
|
|
1795
1401
|
*
|
|
1796
1402
|
* Order of operations:
|
|
1797
|
-
* 1. `fn(ctx)` runs inside `scope.transactional(...)
|
|
1403
|
+
* 1. `fn(ctx)` runs inside `scope.transactional(...)`; domain mutations
|
|
1798
1404
|
* + repo writes happen here. `ctx` is whatever transaction handle the
|
|
1799
1405
|
* `scope` exposes (Drizzle `tx`, Prisma `tx`, Mongo session, or
|
|
1800
1406
|
* `undefined` for context-free scopes).
|
|
1801
1407
|
* 2. **Still inside the transaction**, `withCommit` harvests every
|
|
1802
1408
|
* aggregate's `pendingEvents` and writes them via `outbox.add` (so
|
|
1803
1409
|
* events persist atomically with the state change). Skipped when no
|
|
1804
|
-
* events were recorded.
|
|
1410
|
+
* events were recorded. Each harvested event is stamped with
|
|
1411
|
+
* `aggregateVersion` = the aggregate's commit version (onto a frozen
|
|
1412
|
+
* copy; a pre-set value wins) - consumers get the OCC version the
|
|
1413
|
+
* row was written with, for ordering and idempotency watermarks.
|
|
1805
1414
|
*
|
|
1806
1415
|
* **Harvest order.** Events are concatenated in the order
|
|
1807
1416
|
* aggregates appear in the returned `aggregates` array, then in
|
|
@@ -1812,14 +1421,14 @@ interface TransactionScope<TCtx> {
|
|
|
1812
1421
|
* that exact order.
|
|
1813
1422
|
*
|
|
1814
1423
|
* **Two ordering guarantees, not one.** Within a single aggregate
|
|
1815
|
-
* the order is *causal
|
|
1424
|
+
* the order is *causal*: events are recorded in the order the
|
|
1816
1425
|
* domain methods ran, and subscribers (handlers, projections,
|
|
1817
1426
|
* replay) MUST process them in that order. Across aggregates the
|
|
1818
1427
|
* order in this batch is deterministic but *not* a domain
|
|
1819
1428
|
* guarantee. Greg Young / Vernon IDDD §10: aggregates are
|
|
1820
1429
|
* independent consistency boundaries; events across them are
|
|
1821
1430
|
* eventually consistent. Subscribers should NOT engineer
|
|
1822
|
-
* dependencies on cross-aggregate ordering
|
|
1431
|
+
* dependencies on cross-aggregate ordering; use
|
|
1823
1432
|
* `EventMetadata.causationId` to express true causation, or a
|
|
1824
1433
|
* process manager to coordinate. The in-process EventBus delivers
|
|
1825
1434
|
* this batch in order, sequential outbox-dispatchers preserve it
|
|
@@ -1827,8 +1436,11 @@ interface TransactionScope<TCtx> {
|
|
|
1827
1436
|
* across aggregates at delivery time.
|
|
1828
1437
|
* 3. The transaction commits.
|
|
1829
1438
|
* 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
|
|
1830
|
-
* fires on each returned aggregate
|
|
1831
|
-
* considered flushed.
|
|
1439
|
+
* fires on each returned aggregate; only now are pending events
|
|
1440
|
+
* considered flushed. Aggregates listed in the optional `deleted`
|
|
1441
|
+
* marker array are the exception: their pending events are cleared
|
|
1442
|
+
* directly WITHOUT `markPersisted`, so the post-save `onPersisted`
|
|
1443
|
+
* hook never fires for a row that was just deleted.
|
|
1832
1444
|
* 5. `bus.publish(events)` fires for the in-process fast path (skipped
|
|
1833
1445
|
* when no events or no `bus` is wired).
|
|
1834
1446
|
*
|
|
@@ -1838,13 +1450,37 @@ interface TransactionScope<TCtx> {
|
|
|
1838
1450
|
* outbox still holds the events and an outbox-dispatcher will deliver
|
|
1839
1451
|
* them (eventual consistency).
|
|
1840
1452
|
*
|
|
1841
|
-
*
|
|
1453
|
+
* **A `bus.publish` failure never rejects `withCommit`.** Once the
|
|
1454
|
+
* transaction has committed, the write succeeded; surfacing a subscriber
|
|
1455
|
+
* failure as a rejection would hand the caller a use-case failure for a
|
|
1456
|
+
* committed write (a typical caller retries, double-executing it). The
|
|
1457
|
+
* in-process fast path is best-effort by design; the error is reported to
|
|
1458
|
+
* the optional `onPublishError(error, events)` hook (wire it to your
|
|
1459
|
+
* logger/metrics) and otherwise dropped; delivery is still guaranteed via
|
|
1460
|
+
* the outbox. The hook is an observer: if it throws, its error is
|
|
1461
|
+
* swallowed so the post-commit invariant holds.
|
|
1462
|
+
*
|
|
1463
|
+
* If the transaction rolls back, `markPersisted` is **not** called: the
|
|
1842
1464
|
* aggregate keeps its pending events, so the caller can retry or discard.
|
|
1843
1465
|
*
|
|
1466
|
+
* **Do not mutate an aggregate after `repository.save(...)` inside `fn`.**
|
|
1467
|
+
* `withCommit` cannot see what `save` wrote; the post-commit
|
|
1468
|
+
* `markPersisted` syncs `persistedVersion` to the CURRENT in-memory
|
|
1469
|
+
* version and (on `AggregateRoot`) re-baselines dirty tracking against
|
|
1470
|
+
* the CURRENT state. A mutation between `save` and the callback's return
|
|
1471
|
+
* therefore desyncs OCC (next save throws a false
|
|
1472
|
+
* `ConcurrencyConflictError`) — and under a partial-write repository
|
|
1473
|
+
* using `changedKeys`, an un-bumped mutation is silently marked clean
|
|
1474
|
+
* and never written. The `aggregateVersion` stamp widens the blast
|
|
1475
|
+
* radius further: harvested events would publicly claim a version the
|
|
1476
|
+
* committed row does not carry, poisoning every consumer's ordering
|
|
1477
|
+
* and idempotency watermarks — a cross-service inconsistency, not just
|
|
1478
|
+
* a local one. Mutate first, save last.
|
|
1479
|
+
*
|
|
1844
1480
|
* **Duplicate aggregates are deduped by reference.** If the returned
|
|
1845
|
-
* `aggregates` array contains the same instance twice
|
|
1481
|
+
* `aggregates` array contains the same instance twice (e.g. a use
|
|
1846
1482
|
* case touches an order via two repository references that happen to
|
|
1847
|
-
* resolve to the same identity-map entry
|
|
1483
|
+
* resolve to the same identity-map entry), `withCommit` dedupes by
|
|
1848
1484
|
* JavaScript object identity before harvesting. Each event lands in
|
|
1849
1485
|
* the outbox exactly once and `markPersisted` fires exactly once. Two
|
|
1850
1486
|
* *different* instances with the same logical id cannot be detected
|
|
@@ -1860,7 +1496,7 @@ interface TransactionScope<TCtx> {
|
|
|
1860
1496
|
* const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
|
|
1861
1497
|
* const order = await orderRepository.getByIdOrFail(orderId);
|
|
1862
1498
|
* order.confirm();
|
|
1863
|
-
* await orderRepository.save(order); // pure persistence
|
|
1499
|
+
* await orderRepository.save(order); // pure persistence; does NOT call markPersisted
|
|
1864
1500
|
* return { result: order.id, aggregates: [order] };
|
|
1865
1501
|
* });
|
|
1866
1502
|
* ```
|
|
@@ -1869,9 +1505,26 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
|
|
|
1869
1505
|
outbox: Outbox<Evt>;
|
|
1870
1506
|
bus?: EventBus<Evt>;
|
|
1871
1507
|
scope: TransactionScope<TCtx>;
|
|
1508
|
+
/**
|
|
1509
|
+
* Observer for post-commit `bus.publish` failures. Called with the
|
|
1510
|
+
* error and the events that were published. Must not be relied on
|
|
1511
|
+
* for delivery: the outbox dispatcher is the reliable path.
|
|
1512
|
+
*/
|
|
1513
|
+
onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
|
|
1872
1514
|
}, fn: (ctx: TCtx) => Promise<{
|
|
1873
1515
|
result: R;
|
|
1874
1516
|
aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
|
|
1517
|
+
/**
|
|
1518
|
+
* Optional marker: which of `aggregates` were DELETED in this unit
|
|
1519
|
+
* of work. Their pending events are harvested like any other
|
|
1520
|
+
* (deletion events must reach the outbox), but the post-commit
|
|
1521
|
+
* lifecycle differs: `markPersisted` is NOT called on them — it
|
|
1522
|
+
* would fire the user-overridable `onPersisted` hook, whose
|
|
1523
|
+
* post-save semantics (cache fill, read-model warm-up) are a lie
|
|
1524
|
+
* for a row that was just deleted. Their pending events are
|
|
1525
|
+
* cleared directly instead, so a later commit cannot re-emit them.
|
|
1526
|
+
*/
|
|
1527
|
+
deleted?: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
|
|
1875
1528
|
}>): Promise<R>;
|
|
1876
1529
|
|
|
1877
1530
|
/**
|
|
@@ -1949,6 +1602,366 @@ interface Query {
|
|
|
1949
1602
|
*/
|
|
1950
1603
|
type QueryHandler<Q extends Query, R> = (query: Q) => Promise<R>;
|
|
1951
1604
|
|
|
1605
|
+
/**
|
|
1606
|
+
* A class reference used as the type key of the identity map. Keying
|
|
1607
|
+
* on the CLASS (not a name string) makes collisions impossible by
|
|
1608
|
+
* construction: `Restaurant` and `Booking` are different keys even if
|
|
1609
|
+
* someone names two aggregates identically across modules, and there
|
|
1610
|
+
* is no string-discipline to maintain.
|
|
1611
|
+
*
|
|
1612
|
+
* The `Function & { prototype: TAgg }` branch is load-bearing: the
|
|
1613
|
+
* kit's aggregate convention is a **protected constructor** plus
|
|
1614
|
+
* static factories, and TypeScript rejects assigning a class with a
|
|
1615
|
+
* protected constructor to a construct-signature type. The prototype
|
|
1616
|
+
* witness accepts those classes while still inferring `TAgg`.
|
|
1617
|
+
*/
|
|
1618
|
+
type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function & {
|
|
1619
|
+
prototype: TAgg;
|
|
1620
|
+
});
|
|
1621
|
+
/**
|
|
1622
|
+
* Per-unit-of-work Identity Map (Fowler, PoEAA): within one operation,
|
|
1623
|
+
* one aggregate type+id maps to exactly ONE in-memory instance.
|
|
1624
|
+
*
|
|
1625
|
+
* This is the shipped implementation of the contract the
|
|
1626
|
+
* [Repository guide](../../docs/guide/repository.md) places on
|
|
1627
|
+
* `IRepository` implementations: two `getById(id)` calls in the same
|
|
1628
|
+
* unit of work MUST return the same instance, because `withCommit`'s
|
|
1629
|
+
* aggregate dedupe (and therefore exactly-once event harvest and
|
|
1630
|
+
* `markPersisted`) is keyed on JavaScript object identity.
|
|
1631
|
+
*
|
|
1632
|
+
* Storage is two-level (per-type stores created lazily), so
|
|
1633
|
+
* `Restaurant:123` and `Booking:123` can never collide — the type key
|
|
1634
|
+
* is the aggregate CLASS, not the id alone and not a name string.
|
|
1635
|
+
*
|
|
1636
|
+
* Repository read-path contract:
|
|
1637
|
+
*
|
|
1638
|
+
* ```ts
|
|
1639
|
+
* async getById(id: OrderId): Promise<Order | null> {
|
|
1640
|
+
* const cached = this.session.identityMap.get(Order, id);
|
|
1641
|
+
* if (cached) return cached;
|
|
1642
|
+
* // Deleted in this unit of work = gone, even if the physical
|
|
1643
|
+
* // delete is deferred and the row is still visible in the tx.
|
|
1644
|
+
* if (this.session.identityMap.isDeleted(Order, id)) return null;
|
|
1645
|
+
*
|
|
1646
|
+
* const row = await this.loadRow(id);
|
|
1647
|
+
* if (!row) return null;
|
|
1648
|
+
* const order = Order.reconstitute(row.id, row.state, row.version);
|
|
1649
|
+
* this.session.identityMap.set(Order, id, order);
|
|
1650
|
+
* return order;
|
|
1651
|
+
* }
|
|
1652
|
+
* ```
|
|
1653
|
+
*
|
|
1654
|
+
* Deletion is final within an operation: {@link delete} removes the
|
|
1655
|
+
* entry AND records a tombstone, so a later {@link set} of the same
|
|
1656
|
+
* type+id throws `AggregateDeletedError` — a second instance of a
|
|
1657
|
+
* deleted aggregate can never sneak back into the unit of work, even
|
|
1658
|
+
* through a repository whose row delete is deferred.
|
|
1659
|
+
*
|
|
1660
|
+
* Lifetime is ONE unit of work: the `UnitOfWork` creates a fresh map
|
|
1661
|
+
* per `run()` and clears it on close. Never cache across operations;
|
|
1662
|
+
* that would silently bypass optimistic concurrency control.
|
|
1663
|
+
*/
|
|
1664
|
+
declare class IdentityMap {
|
|
1665
|
+
private readonly _stores;
|
|
1666
|
+
private readonly _deleted;
|
|
1667
|
+
/** The cached instance for type+id, or `undefined` (also after {@link delete}). */
|
|
1668
|
+
get<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): TAgg | undefined;
|
|
1669
|
+
/** Whether an instance is registered for type+id (false after {@link delete}). */
|
|
1670
|
+
has<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
|
|
1671
|
+
/**
|
|
1672
|
+
* Whether type+id was {@link delete}d in this unit of work. The
|
|
1673
|
+
* read path checks this BEFORE hydrating and returns `null`, so
|
|
1674
|
+
* "deleted in this operation" reads uniformly as not-found —
|
|
1675
|
+
* regardless of whether the repository's physical delete already
|
|
1676
|
+
* removed the row or is deferred within the transaction. Without
|
|
1677
|
+
* the check, a read-only probe of a deleted aggregate would crash
|
|
1678
|
+
* in {@link set} for deferred-write repositories and return `null`
|
|
1679
|
+
* for immediate-write ones.
|
|
1680
|
+
*/
|
|
1681
|
+
isDeleted<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
|
|
1682
|
+
/**
|
|
1683
|
+
* Registers the hydrated instance for type+id.
|
|
1684
|
+
*
|
|
1685
|
+
* - Re-registering the SAME instance is a no-op (idempotent).
|
|
1686
|
+
* - Registering a DIFFERENT instance for an occupied type+id throws:
|
|
1687
|
+
* that is precisely the identity-map violation this class exists
|
|
1688
|
+
* to prevent (the repository hydrated twice instead of checking
|
|
1689
|
+
* {@link get} first), and letting it pass would double-harvest
|
|
1690
|
+
* events downstream.
|
|
1691
|
+
* - Registering a type+id that was {@link delete}d in this unit of
|
|
1692
|
+
* work throws `AggregateDeletedError`: deletion is final within
|
|
1693
|
+
* the operation.
|
|
1694
|
+
*/
|
|
1695
|
+
set<TAgg>(type: AggregateClass<TAgg>, id: Id<string>, aggregate: TAgg): void;
|
|
1696
|
+
/**
|
|
1697
|
+
* Removes the entry for type+id and records a tombstone: subsequent
|
|
1698
|
+
* {@link get} / {@link has} report absence, and a subsequent
|
|
1699
|
+
* {@link set} of the same type+id throws `AggregateDeletedError`.
|
|
1700
|
+
* Called by a repository's `delete(aggregate)` alongside
|
|
1701
|
+
* `session.enrollDeleted(aggregate)`.
|
|
1702
|
+
*/
|
|
1703
|
+
delete<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): void;
|
|
1704
|
+
/** Empties all stores and tombstones. Called by the unit of work on close. */
|
|
1705
|
+
clear(): void;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Thrown when `UnitOfWork.run()` is called while the same instance is
|
|
1710
|
+
* already executing a unit of work: either a genuinely nested `run()`
|
|
1711
|
+
* inside the work callback, or two concurrent operations sharing one
|
|
1712
|
+
* instance.
|
|
1713
|
+
*
|
|
1714
|
+
* Both are contract violations, not recoverable infrastructure
|
|
1715
|
+
* failures, so this extends `BaseError` directly (same reasoning as
|
|
1716
|
+
* `MissingHandlerError`): a generic `catch (e instanceof
|
|
1717
|
+
* InfrastructureError)` handler must not mask it.
|
|
1718
|
+
*
|
|
1719
|
+
* A nested `run()` would NOT join the outer transaction; it would open
|
|
1720
|
+
* an independent one, silently breaking the all-or-nothing guarantee.
|
|
1721
|
+
* If two operations must commit together, they are ONE unit of work:
|
|
1722
|
+
* merge them into a single `run()` callback. For concurrent requests,
|
|
1723
|
+
* construct one `UnitOfWork` per operation (construction is trivially
|
|
1724
|
+
* cheap; the dependency object is the thing you share).
|
|
1725
|
+
*/
|
|
1726
|
+
declare class NestedUnitOfWorkError extends BaseError<"NestedUnitOfWorkError"> {
|
|
1727
|
+
constructor();
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Thrown when the unit-of-work context is used after `run()` has
|
|
1731
|
+
* settled: reading `context.repositories` / `context.transaction`, or
|
|
1732
|
+
* calling `session.enrollSaved` / `session.enrollDeleted`, once the
|
|
1733
|
+
* transaction has committed or rolled back.
|
|
1734
|
+
*
|
|
1735
|
+
* Use-after-close is a programming bug (typically a leaked context
|
|
1736
|
+
* reference or a fire-and-forget promise outliving the callback), so
|
|
1737
|
+
* this extends `BaseError` directly and should crash loud.
|
|
1738
|
+
*
|
|
1739
|
+
* **Honest scope of this guard:** the kit can only invalidate what it
|
|
1740
|
+
* controls - the context getters and the session. A repository or raw
|
|
1741
|
+
* transaction handle captured into a variable BEFORE close keeps
|
|
1742
|
+
* working as far as the kit can see; whether the underlying tx handle
|
|
1743
|
+
* rejects is ORM-specific. Do not let references escape the callback.
|
|
1744
|
+
*/
|
|
1745
|
+
declare class TransactionClosedError extends BaseError<"TransactionClosedError"> {
|
|
1746
|
+
readonly operation: string;
|
|
1747
|
+
constructor(operation: string);
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* The unit of work failed AFTER the work callback completed
|
|
1751
|
+
* successfully: during the event harvest, the outbox write, or the
|
|
1752
|
+
* transaction commit itself. The kit cannot see inside
|
|
1753
|
+
* `TransactionScope.transactional`, so these three are deliberately
|
|
1754
|
+
* one error class - the underlying failure is attached as `cause`.
|
|
1755
|
+
*
|
|
1756
|
+
* `InfrastructureError`: the business logic ran to completion; the
|
|
1757
|
+
* persistence boundary failed. The transaction rolled back (or never
|
|
1758
|
+
* committed), no aggregate was marked persisted, and pending events
|
|
1759
|
+
* survive on the aggregates — the operation left no partial state
|
|
1760
|
+
* behind. **Whether a retry helps depends on the cause**: a commit-
|
|
1761
|
+
* time serialization failure is transient, but `withCommit`'s harvest
|
|
1762
|
+
* guard (an event missing `aggregateId` / `aggregateType` — a
|
|
1763
|
+
* programming bug) also lands here and will fail deterministically on
|
|
1764
|
+
* every retry. Inspect the `cause` before routing into retry logic.
|
|
1765
|
+
*/
|
|
1766
|
+
declare class CommitError extends InfrastructureError<"CommitError"> {
|
|
1767
|
+
constructor(cause: unknown);
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* The work callback threw AND the transaction scope rejected with a
|
|
1771
|
+
* DIFFERENT error that does not wrap the callback's error in its cause
|
|
1772
|
+
* chain - the strongest available signal that the rollback itself
|
|
1773
|
+
* failed. The callback's (primary) error is preserved as `cause`, so
|
|
1774
|
+
* cause-chain helpers (`someChainRetryable`, `findInCauseChain`) still
|
|
1775
|
+
* see a wrapped `ConcurrencyConflictError` & co.; the scope's error is
|
|
1776
|
+
* carried in {@link rollbackCause}.
|
|
1777
|
+
*
|
|
1778
|
+
* Scopes that rethrow the original error (Drizzle, Prisma do) never
|
|
1779
|
+
* produce this; scopes that WRAP the original are detected via the
|
|
1780
|
+
* cause chain and passed through unchanged instead.
|
|
1781
|
+
*/
|
|
1782
|
+
declare class RollbackError extends InfrastructureError<"RollbackError"> {
|
|
1783
|
+
readonly rollbackCause: unknown;
|
|
1784
|
+
constructor(cause: unknown, rollbackCause: unknown);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* The enrollment handle a unit of work hands to its repositories.
|
|
1788
|
+
*
|
|
1789
|
+
* Repositories enroll every aggregate they write so the unit of work
|
|
1790
|
+
* can harvest pending events into the outbox (inside the transaction)
|
|
1791
|
+
* and call `markPersisted` after the commit - the same lifecycle
|
|
1792
|
+
* `withCommit` runs for its returned `aggregates` array, minus the
|
|
1793
|
+
* footgun: with enrollment, "forgot to list the aggregate" cannot
|
|
1794
|
+
* happen per call site; each repository implements it once and its
|
|
1795
|
+
* tests pin it once.
|
|
1796
|
+
*
|
|
1797
|
+
* Contract for repository implementations:
|
|
1798
|
+
* - `getById(id)` checks `identityMap.get` BEFORE hydrating, treats
|
|
1799
|
+
* `identityMap.isDeleted` as not-found (`null`), and registers the
|
|
1800
|
+
* hydrated instance after - two loads of the same aggregate in one
|
|
1801
|
+
* unit of work must return the same instance.
|
|
1802
|
+
* - `save(aggregate)` calls {@link enrollSaved} BEFORE the row write:
|
|
1803
|
+
* the deleted-gate then throws `AggregateDeletedError` before any SQL
|
|
1804
|
+
* runs (instead of the write surfacing as a confusing
|
|
1805
|
+
* `ConcurrencyConflictError` against the deleted row). Enrollment is
|
|
1806
|
+
* idempotent per instance, mirroring `withCommit`'s reference dedupe,
|
|
1807
|
+
* and a failed write rolls the whole unit of work back anyway.
|
|
1808
|
+
* - `delete(aggregate)` calls {@link enrollDeleted} - ONE call does all
|
|
1809
|
+
* the deletion bookkeeping: the identity-map entry is removed and
|
|
1810
|
+
* tombstoned automatically (keyed on the instance's concrete class),
|
|
1811
|
+
* the recorded deletion events are still harvested into the outbox,
|
|
1812
|
+
* and saving or re-registering the aggregate (same instance OR a
|
|
1813
|
+
* re-created one with the same type+id) later in this unit of work
|
|
1814
|
+
* throws `AggregateDeletedError`.
|
|
1815
|
+
*
|
|
1816
|
+
* The use case can also enroll manually via `context.session` for the
|
|
1817
|
+
* rare write that bypasses a repository.
|
|
1818
|
+
*/
|
|
1819
|
+
interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
1820
|
+
/**
|
|
1821
|
+
* The per-operation Identity Map (Fowler): one aggregate type+id,
|
|
1822
|
+
* one in-memory instance. Created fresh per `run()`, cleared on
|
|
1823
|
+
* close; accessing it after close throws
|
|
1824
|
+
* {@link TransactionClosedError}.
|
|
1825
|
+
*/
|
|
1826
|
+
readonly identityMap: IdentityMap;
|
|
1827
|
+
/** Enroll an aggregate that was (or will be) written in this unit of work. */
|
|
1828
|
+
enrollSaved(aggregate: IAggregateRoot<Id<string>, Evt>): void;
|
|
1829
|
+
/**
|
|
1830
|
+
* Enroll an aggregate whose row was (or will be) deleted in this
|
|
1831
|
+
* unit of work. Its pending events (e.g. a recorded deletion event)
|
|
1832
|
+
* are harvested like any other; re-saving the instance afterwards
|
|
1833
|
+
* throws `AggregateDeletedError`.
|
|
1834
|
+
*/
|
|
1835
|
+
enrollDeleted(aggregate: IAggregateRoot<Id<string>, Evt>): void;
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* What the work callback receives: repositories already bound to the
|
|
1839
|
+
* live transaction, the enrollment session, and — deliberately named to
|
|
1840
|
+
* look like the escape hatch it is — the raw transaction handle.
|
|
1841
|
+
*
|
|
1842
|
+
* All members throw {@link TransactionClosedError} once `run()` has
|
|
1843
|
+
* settled; do not let the context escape the callback.
|
|
1844
|
+
*/
|
|
1845
|
+
interface UnitOfWorkContext<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
1846
|
+
readonly repositories: TRepos;
|
|
1847
|
+
/**
|
|
1848
|
+
* **Escape hatch — you are leaving the unit of work's guarantees.**
|
|
1849
|
+
* A write issued on the raw handle bypasses the repository contract,
|
|
1850
|
+
* enrollment (its aggregate's events are NOT harvested unless you
|
|
1851
|
+
* also call `session.enrollSaved`), and the identity map (a later
|
|
1852
|
+
* `getById` of the same aggregate hydrates a SECOND instance —
|
|
1853
|
+
* double harvest, double `markPersisted`). Use it only for writes no
|
|
1854
|
+
* repository covers, pair it with manual enrollment, and prefer
|
|
1855
|
+
* adding a repository method whenever one could exist.
|
|
1856
|
+
*/
|
|
1857
|
+
readonly rawTransaction: TCtx;
|
|
1858
|
+
readonly session: UnitOfWorkSession<Evt>;
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Per-repository factory map: for each key of `TRepos`, a function
|
|
1862
|
+
* that constructs the repository bound to the live transaction handle
|
|
1863
|
+
* and the enrollment session. Called once per `run()`, so every
|
|
1864
|
+
* repository of one unit of work shares the same transaction.
|
|
1865
|
+
*
|
|
1866
|
+
* ```ts
|
|
1867
|
+
* const factories = {
|
|
1868
|
+
* orders: (tx, session) => new DrizzleOrderRepository(tx, session),
|
|
1869
|
+
* invoices: (tx, session) => new DrizzleInvoiceRepository(tx, session),
|
|
1870
|
+
* };
|
|
1871
|
+
* ```
|
|
1872
|
+
*/
|
|
1873
|
+
type RepositoryFactories<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> = {
|
|
1874
|
+
[K in keyof TRepos]: (tx: TCtx, session: UnitOfWorkSession<Evt>) => TRepos[K];
|
|
1875
|
+
};
|
|
1876
|
+
/** Dependencies for {@link UnitOfWork}; the app-level singleton part. */
|
|
1877
|
+
interface UnitOfWorkDeps<Evt extends AnyDomainEvent, TCtx, TRepos> {
|
|
1878
|
+
scope: TransactionScope<TCtx>;
|
|
1879
|
+
outbox: Outbox<Evt>;
|
|
1880
|
+
bus?: EventBus<Evt>;
|
|
1881
|
+
/** See `withCommit`: observer for post-commit `bus.publish` failures. */
|
|
1882
|
+
onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
|
|
1883
|
+
repositories: RepositoryFactories<TCtx, TRepos, Evt>;
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Explicit-save Unit of Work: one `run()` call is one application-level
|
|
1887
|
+
* write operation. All repository writes inside the callback share one
|
|
1888
|
+
* transaction and either persist completely or not at all.
|
|
1889
|
+
*
|
|
1890
|
+
* Built ON TOP of `withCommit` - the commit orchestration (event
|
|
1891
|
+
* harvest into the outbox inside the transaction, `markPersisted`
|
|
1892
|
+
* after the commit, best-effort in-process publish last) is inherited,
|
|
1893
|
+
* not reimplemented. What this layer adds:
|
|
1894
|
+
*
|
|
1895
|
+
* - **Tx-bound repositories via a registry.** The callback receives
|
|
1896
|
+
* ready-made repositories instead of a raw transaction handle; the
|
|
1897
|
+
* factory map is wired once at construction.
|
|
1898
|
+
* - **Enrollment instead of a returned aggregates array.** Repositories
|
|
1899
|
+
* enroll what they write via {@link UnitOfWorkSession}; the use case
|
|
1900
|
+
* cannot forget to list an aggregate (the `withCommit` footgun).
|
|
1901
|
+
* - **Lifecycle errors.** {@link NestedUnitOfWorkError},
|
|
1902
|
+
* {@link TransactionClosedError}, {@link CommitError},
|
|
1903
|
+
* {@link RollbackError}, {@link AggregateDeletedError}.
|
|
1904
|
+
*
|
|
1905
|
+
* - **A per-operation Identity Map** on the session: repositories
|
|
1906
|
+
* check it before hydrating and register after, so one type+id maps
|
|
1907
|
+
* to one in-memory instance per unit of work (the contract
|
|
1908
|
+
* `withCommit`'s reference-dedupe relies on, now shipped instead of
|
|
1909
|
+
* merely documented).
|
|
1910
|
+
*
|
|
1911
|
+
* What it deliberately does NOT do (v1): no auto-flush (explicit
|
|
1912
|
+
* `save()` only - `hasChanges` makes a redundant save a cheap no-op),
|
|
1913
|
+
* no savepoints, no nested-transaction joining. `withCommit` with
|
|
1914
|
+
* hand-rolled repos remains fully supported; this facade is opt-in.
|
|
1915
|
+
*
|
|
1916
|
+
* **Instance discipline:** one instance owns one logical operation at
|
|
1917
|
+
* a time. `run()` while a run is active throws
|
|
1918
|
+
* {@link NestedUnitOfWorkError} - that covers genuine nesting AND two
|
|
1919
|
+
* concurrent requests sharing one instance, which is the same bug in
|
|
1920
|
+
* different clothes. Construct one `UnitOfWork` per operation
|
|
1921
|
+
* (construction stores one reference; the shareable singleton is the
|
|
1922
|
+
* deps object). Sequential reuse of an instance is fine.
|
|
1923
|
+
*
|
|
1924
|
+
* **Error pass-through:** an error thrown by the work callback (a
|
|
1925
|
+
* repository's `ConcurrencyConflictError`, a `DomainError`, anything)
|
|
1926
|
+
* is rethrown UNCHANGED - the unit of work never converts a concurrency
|
|
1927
|
+
* conflict into a generic error. Only the two failure modes the
|
|
1928
|
+
* callback cannot observe are wrapped: see {@link CommitError} and
|
|
1929
|
+
* {@link RollbackError}.
|
|
1930
|
+
*
|
|
1931
|
+
* @example
|
|
1932
|
+
* ```ts
|
|
1933
|
+
* const deps = {
|
|
1934
|
+
* scope: drizzleScope,
|
|
1935
|
+
* outbox: drizzleOutbox,
|
|
1936
|
+
* bus: eventBus,
|
|
1937
|
+
* repositories: {
|
|
1938
|
+
* restaurants: (tx, session) => new DrizzleRestaurantRepository(tx, session),
|
|
1939
|
+
* },
|
|
1940
|
+
* };
|
|
1941
|
+
*
|
|
1942
|
+
* const uow = new UnitOfWork(deps);
|
|
1943
|
+
* const result = await uow.run(async ({ repositories }) => {
|
|
1944
|
+
* const restaurant = await repositories.restaurants.getByIdOrFail(id);
|
|
1945
|
+
* restaurant.changeOpeningHours(openingHours);
|
|
1946
|
+
* await repositories.restaurants.save(restaurant); // save() enrolls
|
|
1947
|
+
* return restaurant.id;
|
|
1948
|
+
* });
|
|
1949
|
+
* ```
|
|
1950
|
+
*/
|
|
1951
|
+
declare class UnitOfWork<Evt extends AnyDomainEvent, TCtx, TRepos extends Record<string, unknown>> {
|
|
1952
|
+
private readonly deps;
|
|
1953
|
+
private _active;
|
|
1954
|
+
constructor(deps: UnitOfWorkDeps<Evt, TCtx, TRepos>);
|
|
1955
|
+
/**
|
|
1956
|
+
* Execute one unit of work: open the transaction, hand the callback
|
|
1957
|
+
* tx-bound repositories, commit on resolve, roll back on throw,
|
|
1958
|
+
* run the post-commit lifecycle (markPersisted, publish) for every
|
|
1959
|
+
* enrolled aggregate. Returns the callback's result.
|
|
1960
|
+
*/
|
|
1961
|
+
run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R>): Promise<R>;
|
|
1962
|
+
private buildRepositories;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1952
1965
|
/**
|
|
1953
1966
|
* Type map for query types to their return types.
|
|
1954
1967
|
* Used to improve type inference in QueryBus.
|
|
@@ -2018,7 +2031,7 @@ interface IQueryBus<TMap extends QueryTypeMap = QueryTypeMap> {
|
|
|
2018
2031
|
*
|
|
2019
2032
|
* When `TMap` is supplied, the `queryType` argument is restricted to its
|
|
2020
2033
|
* keys and the handler signature is forced to match `TMap[K]` for the
|
|
2021
|
-
* return value
|
|
2034
|
+
* return value: typos and wrong-typed handlers are compile errors.
|
|
2022
2035
|
* Without `TMap` the registration is loose (any string key, any return
|
|
2023
2036
|
* type) so the no-config path keeps working.
|
|
2024
2037
|
*
|
|
@@ -2129,7 +2142,7 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
|
|
|
2129
2142
|
* In-memory reference implementation of `Outbox<Evt>`.
|
|
2130
2143
|
*
|
|
2131
2144
|
* Intended for tests, single-process workers, and quick-start demos.
|
|
2132
|
-
* Uses the event's own `eventId` as the dispatch id
|
|
2145
|
+
* Uses the event's own `eventId` as the dispatch id: the common, clean
|
|
2133
2146
|
* choice. Storage is a `Map<string, OutboxRecord<Evt>>` keyed by
|
|
2134
2147
|
* `eventId`, so re-adding the same event is naturally idempotent (the
|
|
2135
2148
|
* duplicate entry overwrites itself; `getPending` returns each event at
|
|
@@ -2138,7 +2151,13 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
|
|
|
2138
2151
|
* For production, back the outbox with a transactional store so the
|
|
2139
2152
|
* outbox row participates in the same transaction as the aggregate
|
|
2140
2153
|
* write (see `TransactionScope` + `withCommit`). This class lives in
|
|
2141
|
-
* memory only
|
|
2154
|
+
* memory only: events are lost on process restart — and, sharper:
|
|
2155
|
+
* events `add()`ed inside a transaction that later rolls back are NOT
|
|
2156
|
+
* removed (the Map knows nothing about your scope's rollback). Tests
|
|
2157
|
+
* that assert rollback purity need an outbox that participates in the
|
|
2158
|
+
* test store's transactional semantics; see the reference adapter at
|
|
2159
|
+
* https://github.com/shi-rudo/ddd-kit-ts/blob/main/src/testing/repository-contract.test.ts
|
|
2160
|
+
* (repo-only, not shipped to npm).
|
|
2142
2161
|
*
|
|
2143
2162
|
* @example
|
|
2144
2163
|
* ```ts
|
|
@@ -2163,13 +2182,33 @@ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt>
|
|
|
2163
2182
|
markDispatched(dispatchIds: ReadonlyArray<string>): Promise<void>;
|
|
2164
2183
|
}
|
|
2165
2184
|
|
|
2185
|
+
/**
|
|
2186
|
+
* The canonical shape of a unit-of-work-facing repository. Unlike
|
|
2187
|
+
* `IRepository` below (id-canonical CRUD for `withCommit`-style
|
|
2188
|
+
* setups), `delete` takes the AGGREGATE: the unit of work needs the
|
|
2189
|
+
* instance for deletion-event harvest, the identity-map tombstone, and
|
|
2190
|
+
* the deleted-cannot-be-resaved gate. Ids stay branded (`TId extends
|
|
2191
|
+
* Id<string>`) end-to-end.
|
|
2192
|
+
*
|
|
2193
|
+
* Implementing this interface is optional — the `UnitOfWork` registry
|
|
2194
|
+
* is structurally typed — but it is the single source of truth the
|
|
2195
|
+
* guide's examples and the repository contract test suite
|
|
2196
|
+
* (`@shirudo/ddd-kit/testing`, whose `ContractRepository` is the
|
|
2197
|
+
* minimal structural subset of this shape) are written against.
|
|
2198
|
+
*/
|
|
2199
|
+
interface IUnitOfWorkRepository<TAgg extends IAggregateRoot<TId, Evt>, TId extends Id<string>, Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
2200
|
+
getById(id: TId): Promise<TAgg | null>;
|
|
2201
|
+
getByIdOrFail(id: TId): Promise<TAgg>;
|
|
2202
|
+
save(aggregate: TAgg): Promise<void>;
|
|
2203
|
+
delete(aggregate: TAgg): Promise<void>;
|
|
2204
|
+
}
|
|
2166
2205
|
/**
|
|
2167
2206
|
* Core repository contract for Aggregate Roots.
|
|
2168
2207
|
*
|
|
2169
2208
|
* In DDD a Repository is a "collection illusion" for aggregates: load by
|
|
2170
2209
|
* identity, save the whole aggregate, delete by identity. Querying by
|
|
2171
2210
|
* arbitrary criteria is a separate concern (CQRS read-side, ad-hoc bulk
|
|
2172
|
-
* operations) and lives on the `IQueryableRepository` extension below
|
|
2211
|
+
* operations) and lives on the `IQueryableRepository` extension below, so
|
|
2173
2212
|
* write-side repositories don't have to implement query plumbing they
|
|
2174
2213
|
* don't need.
|
|
2175
2214
|
*
|
|
@@ -2200,7 +2239,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2200
2239
|
exists(id: TId): Promise<boolean>;
|
|
2201
2240
|
/**
|
|
2202
2241
|
* Persists the aggregate (insert or update). Implementations are
|
|
2203
|
-
* responsible for **persistence only
|
|
2242
|
+
* responsible for **persistence only**; they must NOT touch the
|
|
2204
2243
|
* aggregate's in-memory state:
|
|
2205
2244
|
*
|
|
2206
2245
|
* 1. Throw `ConcurrencyConflictError` from `@shirudo/ddd-kit` when the
|
|
@@ -2208,14 +2247,14 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2208
2247
|
* stored (optimistic concurrency).
|
|
2209
2248
|
* 2. Write the aggregate to durable storage.
|
|
2210
2249
|
*
|
|
2211
|
-
* **Insert vs update
|
|
2250
|
+
* **Insert vs update: the `persistedVersion` convention.** Every aggregate
|
|
2212
2251
|
* exposes two version fields with distinct roles:
|
|
2213
2252
|
*
|
|
2214
|
-
* - `aggregate.version
|
|
2253
|
+
* - `aggregate.version`: in-memory post-mutation value, bumped by
|
|
2215
2254
|
* `setState(_, true)` / `commit()` / `apply()`. NOT the right
|
|
2216
2255
|
* routing key, because mutations can advance it past zero while
|
|
2217
2256
|
* the DB row still does not exist.
|
|
2218
|
-
* - `aggregate.persistedVersion
|
|
2257
|
+
* - `aggregate.persistedVersion`: what the persistence layer holds.
|
|
2219
2258
|
* `undefined` until the aggregate has been persisted or restored
|
|
2220
2259
|
* at least once. This is the routing key.
|
|
2221
2260
|
*
|
|
@@ -2225,14 +2264,14 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2225
2264
|
* - otherwise → **UPDATE** with the OCC predicate
|
|
2226
2265
|
* `WHERE id = ? AND version = aggregate.persistedVersion` (the
|
|
2227
2266
|
* load-time / last-save baseline, not the post-mutation in-memory
|
|
2228
|
-
* value). If the row count is `0`, another writer raced you
|
|
2267
|
+
* value). If the row count is `0`, another writer raced you:
|
|
2229
2268
|
* throw `ConcurrencyConflictError`.
|
|
2230
2269
|
*
|
|
2231
2270
|
* Do **not** call `aggregate.markPersisted(...)` here. The library's
|
|
2232
2271
|
* `withCommit` orchestrator handles the post-save lifecycle (harvest
|
|
2233
2272
|
* pending events into the outbox, then mark persisted after commit).
|
|
2234
2273
|
* Calling `markPersisted` inside `save` clears pending events too early
|
|
2235
|
-
* and breaks the harvest path
|
|
2274
|
+
* and breaks the harvest path, and is also why the Vernon/Axon/
|
|
2236
2275
|
* EventFlow pattern separates persistence from commit-events.
|
|
2237
2276
|
*
|
|
2238
2277
|
* If you are not using `withCommit` (custom orchestration), call
|
|
@@ -2241,7 +2280,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2241
2280
|
*/
|
|
2242
2281
|
save(aggregate: TAgg): Promise<void>;
|
|
2243
2282
|
/**
|
|
2244
|
-
* Removes the aggregate's row by id. Pure persistence
|
|
2283
|
+
* Removes the aggregate's row by id. Pure persistence: does NOT
|
|
2245
2284
|
* harvest pending events from the aggregate (the contract takes
|
|
2246
2285
|
* only the id, so there is no aggregate to harvest from).
|
|
2247
2286
|
*
|
|
@@ -2249,7 +2288,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2249
2288
|
* is the right domain verb. Most are actually state transitions
|
|
2250
2289
|
* (*cancel*, *archive*, *close*, *deactivate*, *terminate*) with
|
|
2251
2290
|
* proper domain names that should be modelled as state changes plus
|
|
2252
|
-
* a recorded event
|
|
2291
|
+
* a recorded event, not as row removal.
|
|
2253
2292
|
*
|
|
2254
2293
|
* `delete(id)` belongs in the toolkit for three distinct cases, in
|
|
2255
2294
|
* decreasing order of common occurrence (see
|
|
@@ -2275,7 +2314,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2275
2314
|
* subscriber cares. If the entity has identity in the ubiquitous
|
|
2276
2315
|
* language, you probably want path 1 or 2 instead.
|
|
2277
2316
|
*
|
|
2278
|
-
* In pure event-sourced systems `delete` is rarely meaningful
|
|
2317
|
+
* In pure event-sourced systems `delete` is rarely meaningful:
|
|
2279
2318
|
* end-of-lifecycle there is a `Closed` / `Terminated` event in the
|
|
2280
2319
|
* stream, and identity persists in the event log. `delete` applies
|
|
2281
2320
|
* primarily to state-stored aggregates and snapshot / projection
|
|
@@ -2289,7 +2328,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
|
|
|
2289
2328
|
* Prisma `WhereInput`, a MongoDB filter document, a plain
|
|
2290
2329
|
* `(t: TAgg) => boolean` predicate for in-memory repos, or anything else.
|
|
2291
2330
|
*
|
|
2292
|
-
* The library does not prescribe a Specification or query DSL
|
|
2331
|
+
* The library does not prescribe a Specification or query DSL: the
|
|
2293
2332
|
* Repository implementation owns its query language. This avoids the
|
|
2294
2333
|
* phantom-interface trap of a library-level `ISpecification<T>` with no
|
|
2295
2334
|
* methods and lets each Repository expose the strongest possible types for
|
|
@@ -2325,13 +2364,13 @@ interface IQueryableRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<
|
|
|
2325
2364
|
*/
|
|
2326
2365
|
findOne(filter: TFilter): Promise<TAgg | null>;
|
|
2327
2366
|
/**
|
|
2328
|
-
* Returns **every** aggregate matching the filter
|
|
2367
|
+
* Returns **every** aggregate matching the filter: no pagination,
|
|
2329
2368
|
* no cursor. For unbounded result sets, prefer a read-side projection
|
|
2330
2369
|
* (CQRS read model) over loading aggregates in bulk; aggregates are
|
|
2331
2370
|
* write-side objects and rehydrating thousands of them by id is rarely
|
|
2332
2371
|
* what you want. If you need pagination on the write side, declare a
|
|
2333
2372
|
* domain-specific paged method on your concrete repository (e.g.
|
|
2334
|
-
* `findPage(filter, cursor)`)
|
|
2373
|
+
* `findPage(filter, cursor)`): the library does not prescribe a
|
|
2335
2374
|
* pagination contract because cursor/offset/keyset semantics vary too
|
|
2336
2375
|
* much across storage backends.
|
|
2337
2376
|
*/
|
|
@@ -2345,18 +2384,35 @@ type VO<T> = Readonly<T>;
|
|
|
2345
2384
|
* so the freeze symmetry matches `deepEqual` (which also considers symbol
|
|
2346
2385
|
* keys). Handles circular references by tracking visited objects.
|
|
2347
2386
|
*
|
|
2348
|
-
* Note: `deepFreeze` mutates its argument in place
|
|
2387
|
+
* Note: `deepFreeze` mutates its argument in place; it sets `[[Frozen]]`
|
|
2349
2388
|
* on the object you pass in. Callers that need to avoid touching the
|
|
2350
2389
|
* input (e.g. `vo()`) should deep-clone first.
|
|
2390
|
+
*
|
|
2391
|
+
* Date/Map/Set keep internal-slot mutability under `Object.freeze`
|
|
2392
|
+
* (`setTime`, `set`, `add`, … still work on frozen instances), so their
|
|
2393
|
+
* mutator methods are shadowed with throwing own properties and Map/Set
|
|
2394
|
+
* contents are frozen recursively. The shadows are non-enumerable:
|
|
2395
|
+
* invisible to `Object.keys`, spread, `deepEqual`, and `structuredClone`.
|
|
2396
|
+
*
|
|
2397
|
+
* The shadowing is deny-by-enumeration: only the mutators known at
|
|
2398
|
+
* release time are blocked. If the runtime grows a NEW mutator (e.g. the
|
|
2399
|
+
* stage-3 `Map.prototype.getOrInsert` upsert proposal), it is not blocked
|
|
2400
|
+
* until the list is updated. Treat the mutator blocking as a guard rail,
|
|
2401
|
+
* not a security boundary.
|
|
2402
|
+
*
|
|
2403
|
+
* Limitation: ArrayBuffer views (TypedArrays, DataView) are passed through
|
|
2404
|
+
* unfrozen, because the spec forbids freezing a view with elements, and
|
|
2405
|
+
* freezing cannot protect the underlying buffer. Their contents remain mutable.
|
|
2351
2406
|
*/
|
|
2352
2407
|
declare function deepFreeze<T>(obj: T, visited?: WeakSet<object>): Readonly<T>;
|
|
2353
2408
|
/**
|
|
2354
2409
|
* Creates a deeply immutable value object from the given data.
|
|
2355
2410
|
*
|
|
2356
|
-
* The input is first deep-cloned
|
|
2357
|
-
*
|
|
2358
|
-
*
|
|
2359
|
-
*
|
|
2411
|
+
* The input is first deep-cloned, then the clone is frozen, so calling
|
|
2412
|
+
* `vo(input)` never freezes the caller's own object graph as a
|
|
2413
|
+
* side-effect. Mutating the input afterwards does not bleed into the VO.
|
|
2414
|
+
* Symbol-keyed properties are preserved (matching `voEquals`); function
|
|
2415
|
+
* values are rejected (Value Objects are data, not behaviour).
|
|
2360
2416
|
*
|
|
2361
2417
|
* @example
|
|
2362
2418
|
* ```typescript
|
|
@@ -2441,6 +2497,11 @@ declare function voEqualsExcept<T>(a: VO<T>, b: VO<T>, options: DeepEqualExceptO
|
|
|
2441
2497
|
* Creates a value object with optional validation.
|
|
2442
2498
|
* Returns a Result type instead of throwing an error.
|
|
2443
2499
|
*
|
|
2500
|
+
* Note: the Result covers VALIDATION failures only. Non-data values in
|
|
2501
|
+
* the input (functions, Promise/WeakMap/WeakSet) still throw a
|
|
2502
|
+
* `TypeError` from `vo()`; they cannot occur in parsed JSON and signal
|
|
2503
|
+
* a programming error, not a validation failure.
|
|
2504
|
+
*
|
|
2444
2505
|
* @param t - The data to convert into a value object
|
|
2445
2506
|
* @param validate - Validation function that returns true if valid
|
|
2446
2507
|
* @param errorMessage - Optional custom error message if validation fails
|
|
@@ -2505,7 +2566,9 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
|
|
|
2505
2566
|
readonly props: Readonly<T>;
|
|
2506
2567
|
/**
|
|
2507
2568
|
* Creates a new ValueObject.
|
|
2508
|
-
* The properties are
|
|
2569
|
+
* The properties are deep-cloned (prototype-preserving) and then deeply
|
|
2570
|
+
* frozen, so the caller's own object graph is never frozen or mutated,
|
|
2571
|
+
* and later mutation of the input does not bleed into the value object.
|
|
2509
2572
|
*
|
|
2510
2573
|
* @param props - The properties of the value object
|
|
2511
2574
|
* @example
|
|
@@ -2564,7 +2627,7 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
|
|
|
2564
2627
|
* was recorded the input is frozen into a `VO<T>` and returned as `Ok`;
|
|
2565
2628
|
* otherwise the populated `ValidationError` is returned as `Err`.
|
|
2566
2629
|
*
|
|
2567
|
-
* `ValidationError` comes from `@shirudo/base-error
|
|
2630
|
+
* `ValidationError` comes from `@shirudo/base-error`; import it from there to
|
|
2568
2631
|
* narrow the `Err` branch, exactly as `Result` is imported from
|
|
2569
2632
|
* `@shirudo/result`. It serializes to RFC 9457 Problem Details; use
|
|
2570
2633
|
* {@link validationProblemDetails} at the HTTP boundary to surface the issues.
|
|
@@ -2586,4 +2649,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
|
|
|
2586
2649
|
*/
|
|
2587
2650
|
declare function voValidated<T>(t: T, validate: (issues: ValidationError, value: T) => void, message?: string): Result<VO<T>, ValidationError>;
|
|
2588
2651
|
|
|
2589
|
-
export { type
|
|
2652
|
+
export { type AggregateClass, type AggregateConfig, AggregateRoot, AggregateSnapshot, AnyDomainEvent, type Command, CommandBus, type CommandHandler, CommitError, CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, Entity, type EventBus, EventBusImpl, type EventHandler, EventSourcedAggregate, IAggregateRoot, type ICommandBus, type IEntity, IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IUnitOfWorkRepository, type IValueObject, Id, type Identifiable, IdentityMap, InMemoryOutbox, InfrastructureError, NestedUnitOfWorkError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type RepositoryFactories, RollbackError, TransactionClosedError, type TransactionScope, UnitOfWork, type UnitOfWorkContext, type UnitOfWorkDeps, type UnitOfWorkSession, type VO, ValueObject, Version, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, removeEntityById, replaceEntityById, sameEntity, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit };
|