@shirudo/ddd-kit 2.2.0 → 3.0.0-rc.3
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 +204 -55
- package/dist/chunks/deep-equal-except.js +639 -0
- package/dist/chunks/deep-equal-except.js.map +1 -0
- package/dist/chunks/errors.d.ts +785 -0
- package/dist/chunks/errors.js +822 -0
- package/dist/chunks/errors.js.map +1 -0
- package/dist/chunks/ports.js +891 -0
- package/dist/chunks/ports.js.map +1 -0
- package/dist/chunks/snapshot-store.d.ts +2808 -0
- package/dist/chunks/utils.d.ts +110 -0
- package/dist/http.d.ts +64 -51
- package/dist/http.js +54 -20
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2341 -2640
- package/dist/index.js +6073 -3915
- package/dist/index.js.map +1 -1
- package/dist/money.d.ts +376 -0
- package/dist/money.js +578 -0
- package/dist/money.js.map +1 -0
- package/dist/presentation.d.ts +86 -37
- package/dist/presentation.js +208 -39
- package/dist/presentation.js.map +1 -1
- package/dist/testing.d.ts +517 -335
- package/dist/testing.js +2396 -1184
- package/dist/testing.js.map +1 -1
- package/dist/utils.d.ts +2 -106
- package/dist/utils.js +2 -530
- package/package.json +35 -18
- package/dist/aggregate-DFi6HlEh.d.ts +0 -771
- package/dist/utils.js.map +0 -1
|
@@ -0,0 +1,2808 @@
|
|
|
1
|
+
import { o as DomainError } from "./errors.js";
|
|
2
|
+
import { Result } from "@shirudo/result";
|
|
3
|
+
|
|
4
|
+
//#region src/core/id.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Branded string ID. `Tag` carries the aggregate / entity name so two ids
|
|
7
|
+
* with different tags are not assignable to each other even though both
|
|
8
|
+
* are strings at runtime.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* type UserId = Id<"UserId">;
|
|
13
|
+
* type OrderId = Id<"OrderId">;
|
|
14
|
+
*
|
|
15
|
+
* const u = "user-1" as UserId;
|
|
16
|
+
* const o: OrderId = u; // ❌ compile error
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
type Id<Tag extends string> = string & {
|
|
20
|
+
readonly __brand: Tag;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Produces fresh ids of a single, fixed tag. The tag is bound at the
|
|
24
|
+
* generator type: `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
|
|
25
|
+
* with no caller-side generic to abuse.
|
|
26
|
+
*
|
|
27
|
+
* **Your factory must produce unique ids under concurrent calls.**
|
|
28
|
+
* The kit makes no attempt to dedupe or detect collisions: a collision
|
|
29
|
+
* silently overwrites earlier rows (under unique-key constraints) or
|
|
30
|
+
* silently aliases two different entities (without them). Safe choices:
|
|
31
|
+
* `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
|
|
32
|
+
* KSUID: all collision-resistant by design. Unsafe choices: `Date.now()`
|
|
33
|
+
* alone (duplicates within the same millisecond), a process-local
|
|
34
|
+
* counter without persistence (resets to 1 on restart, collides with
|
|
35
|
+
* prior runs), a sequential id derived from non-atomic state.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { ulid } from "ulid";
|
|
40
|
+
*
|
|
41
|
+
* const userIds: IdGenerator<"UserId"> = { next: () => ulid() as Id<"UserId"> };
|
|
42
|
+
* const id = userIds.next(); // Id<"UserId">
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* The previous shape (`IdGenerator { next<T extends string>(): Id<T> }`)
|
|
46
|
+
* let callers pick `T` themselves: `gen.next<"AnyTag">()` typechecked
|
|
47
|
+
* even when the generator produced different-tag ids, silently defeating
|
|
48
|
+
* the brand.
|
|
49
|
+
*/
|
|
50
|
+
interface IdGenerator<Tag extends string> {
|
|
51
|
+
next: () => Id<Tag>;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/aggregate/clock.d.ts
|
|
55
|
+
/**
|
|
56
|
+
* Clock function producing a valid `Date` for the current instant.
|
|
57
|
+
* Event-clock reads throw `TypeError` when the result is invalid.
|
|
58
|
+
*/
|
|
59
|
+
type ClockFactory = () => Date;
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/aggregate/domain-event.d.ts
|
|
62
|
+
/**
|
|
63
|
+
* Factory function producing a fresh, unique event identifier for each call.
|
|
64
|
+
*
|
|
65
|
+
* The library ships a default that uses Web Crypto `crypto.randomUUID()`
|
|
66
|
+
* (works on Node 19+, modern browsers in secure contexts, Deno, Bun,
|
|
67
|
+
* Cloudflare Workers, Vercel Edge, and any runtime that implements Web
|
|
68
|
+
* Crypto). Note that `crypto.randomUUID()` returns **UUID v4** (purely
|
|
69
|
+
* random); for production event stores prefer a **time-ordered** id
|
|
70
|
+
* format (UUID v7 / ULID / KSUID) so B-tree indexes on the eventId
|
|
71
|
+
* column stay clustered and `ORDER BY eventId` matches creation order.
|
|
72
|
+
* Supply one to {@link createDomainEventFactory} to use UUID v7, ULID,
|
|
73
|
+
* KSUID, or another collision-safe format without mutating module state.
|
|
74
|
+
*/
|
|
75
|
+
type EventIdFactory = () => string;
|
|
76
|
+
/**
|
|
77
|
+
* Metadata associated with a domain event for traceability and correlation.
|
|
78
|
+
* Used in event-driven architectures to track event flow across services.
|
|
79
|
+
*/
|
|
80
|
+
interface EventMetadata {
|
|
81
|
+
/**
|
|
82
|
+
* Correlation ID for tracing events across multiple services/components.
|
|
83
|
+
* Typically used to group related events in a distributed system.
|
|
84
|
+
*/
|
|
85
|
+
readonly correlationId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Conversation ID shared by every message in one long-running business
|
|
88
|
+
* interaction, even when that interaction spans several correlations.
|
|
89
|
+
*/
|
|
90
|
+
readonly conversationId?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Causation ID referencing the event or command that caused this event.
|
|
93
|
+
* Used to build event chains and understand causality.
|
|
94
|
+
*/
|
|
95
|
+
readonly causationId?: string;
|
|
96
|
+
/**
|
|
97
|
+
* W3C Trace Context parent for technical tracing across process boundaries.
|
|
98
|
+
* This is distinct from business correlation and conversation identifiers.
|
|
99
|
+
*/
|
|
100
|
+
readonly traceparent?: string;
|
|
101
|
+
/** Optional W3C vendor trace state associated with `traceparent`. */
|
|
102
|
+
readonly tracestate?: string;
|
|
103
|
+
/**
|
|
104
|
+
* User ID of the person or system that triggered the event.
|
|
105
|
+
*/
|
|
106
|
+
readonly userId?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Source service or component that produced the event.
|
|
109
|
+
*/
|
|
110
|
+
readonly source?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Additional custom metadata fields.
|
|
113
|
+
* Allows extensibility for domain-specific metadata.
|
|
114
|
+
*/
|
|
115
|
+
readonly [key: string]: unknown;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Domain Event represents something meaningful that happened in the domain.
|
|
119
|
+
* Events are immutable and carry information about what occurred.
|
|
120
|
+
*
|
|
121
|
+
* **Events are PLAIN DATA objects**, constructed via `createDomainEvent`
|
|
122
|
+
* (or the aggregate's `createEvent` plus application-shell recording path)
|
|
123
|
+
* and deeply frozen. Class-based
|
|
124
|
+
* event objects that satisfy this shape structurally via prototype
|
|
125
|
+
* members are unsupported.
|
|
126
|
+
*
|
|
127
|
+
* **Field-accretion boundary.** Persistence positions, commit boundaries,
|
|
128
|
+
* broker offsets, and other delivery concerns belong in an event envelope,
|
|
129
|
+
* not on the domain event itself.
|
|
130
|
+
*
|
|
131
|
+
* @template T - The event type name (e.g., "OrderCreated")
|
|
132
|
+
* @template P - The event payload type
|
|
133
|
+
*/
|
|
134
|
+
interface DomainEvent<T extends string, P = void> {
|
|
135
|
+
/**
|
|
136
|
+
* Unique identifier for this specific event instance. Used by idempotent
|
|
137
|
+
* consumers, outbox dispatch tracking, and as the target of
|
|
138
|
+
* `metadata.causationId`. Convenience constructors default to
|
|
139
|
+
* `crypto.randomUUID()`; strict construction requires the caller to supply it.
|
|
140
|
+
*/
|
|
141
|
+
readonly eventId: string;
|
|
142
|
+
/**
|
|
143
|
+
* The type of the event, used for routing and handling.
|
|
144
|
+
*/
|
|
145
|
+
readonly type: T;
|
|
146
|
+
/**
|
|
147
|
+
* Identifier of the aggregate that produced the event. Optional at the
|
|
148
|
+
* library level; set it whenever the producing aggregate is known so
|
|
149
|
+
* downstream subscribers, outboxes, and projections can scope by entity.
|
|
150
|
+
*/
|
|
151
|
+
readonly aggregateId?: string;
|
|
152
|
+
/**
|
|
153
|
+
* Name of the aggregate type that produced the event (e.g. "Order").
|
|
154
|
+
* Pairs with `aggregateId` to fully qualify the source aggregate.
|
|
155
|
+
*/
|
|
156
|
+
readonly aggregateType?: string;
|
|
157
|
+
/**
|
|
158
|
+
* The event payload containing the domain data. The field is always
|
|
159
|
+
* present; its value is `undefined` when `P` is `void`.
|
|
160
|
+
*/
|
|
161
|
+
readonly payload: P;
|
|
162
|
+
/**
|
|
163
|
+
* Timestamp when the accepted fact was recorded by the application shell.
|
|
164
|
+
* Put business-relevant time in the payload under a domain name.
|
|
165
|
+
*/
|
|
166
|
+
readonly occurredAt: Date;
|
|
167
|
+
/**
|
|
168
|
+
* Event schema version for handling schema evolution.
|
|
169
|
+
* Required for safe schema migration in event-sourced systems.
|
|
170
|
+
* Use 1 for the initial schema version.
|
|
171
|
+
*
|
|
172
|
+
* This is the event PAYLOAD schema version, not a persisted aggregate
|
|
173
|
+
* position. Commit positions live on `CommittedDomainEvent`.
|
|
174
|
+
*/
|
|
175
|
+
readonly version: number;
|
|
176
|
+
/**
|
|
177
|
+
* Optional metadata for traceability, correlation, and auditing.
|
|
178
|
+
* Includes correlationId, conversationId, causationId, userId, source, and
|
|
179
|
+
* custom fields.
|
|
180
|
+
*/
|
|
181
|
+
readonly metadata?: EventMetadata;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Upper-bound alias for "any `DomainEvent` shape". Use as a generic
|
|
185
|
+
* constraint when a type parameter should accept any concrete event
|
|
186
|
+
* union. The `unknown` payload is the upper bound; concrete unions
|
|
187
|
+
* still narrow via `Extract<Evt, { type: K }>` at the use-site.
|
|
188
|
+
*/
|
|
189
|
+
type AnyDomainEvent = DomainEvent<string, unknown>;
|
|
190
|
+
/**
|
|
191
|
+
* A domain event accepted by an aggregate but not yet given its recording
|
|
192
|
+
* identity, recording time, or delivery metadata.
|
|
193
|
+
*
|
|
194
|
+
* The aggregate owns the event type, payload, source address, and payload
|
|
195
|
+
* schema version because those values describe the business fact it produced.
|
|
196
|
+
* The application shell later turns this value into a {@link DomainEvent}.
|
|
197
|
+
*/
|
|
198
|
+
interface UncommittedDomainEvent<T extends string, P = void> {
|
|
199
|
+
readonly type: T;
|
|
200
|
+
readonly aggregateId?: string;
|
|
201
|
+
readonly aggregateType?: string;
|
|
202
|
+
readonly payload: P;
|
|
203
|
+
readonly version: number;
|
|
204
|
+
}
|
|
205
|
+
/** Upper-bound alias for any uncommitted domain-event shape. */
|
|
206
|
+
type AnyUncommittedDomainEvent = UncommittedDomainEvent<string, unknown>;
|
|
207
|
+
/** Derives the uncommitted shape represented by a concrete event or event union. */
|
|
208
|
+
type UncommittedDomainEventOf<TEvent extends AnyDomainEvent> = TEvent extends DomainEvent<infer TType, infer TPayload> ? UncommittedDomainEvent<TType, TPayload> : never;
|
|
209
|
+
/** An aggregate may hold unstamped decisions and already recorded events together. */
|
|
210
|
+
type PendingDomainEvent<TEvent extends AnyDomainEvent> = TEvent | UncommittedDomainEventOf<TEvent>;
|
|
211
|
+
/** Producer-owned options for an uncommitted event. */
|
|
212
|
+
interface CreateUncommittedDomainEventOptions {
|
|
213
|
+
readonly aggregateId?: string;
|
|
214
|
+
readonly aggregateType?: string;
|
|
215
|
+
readonly version?: number;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Shared option bag for the `createDomainEvent*` factories.
|
|
219
|
+
*/
|
|
220
|
+
interface CreateDomainEventOptions {
|
|
221
|
+
/**
|
|
222
|
+
* Override for the auto-generated `eventId`. Pass an existing id (for
|
|
223
|
+
* replay, tests, or deterministic event sourcing) instead of letting the
|
|
224
|
+
* factory call `crypto.randomUUID()`.
|
|
225
|
+
*/
|
|
226
|
+
eventId?: string;
|
|
227
|
+
/**
|
|
228
|
+
* Identifier of the aggregate that produced the event.
|
|
229
|
+
*/
|
|
230
|
+
aggregateId?: string;
|
|
231
|
+
/**
|
|
232
|
+
* Name of the aggregate type that produced the event.
|
|
233
|
+
*/
|
|
234
|
+
aggregateType?: string;
|
|
235
|
+
/**
|
|
236
|
+
* Override for the auto-generated `occurredAt` timestamp.
|
|
237
|
+
*/
|
|
238
|
+
occurredAt?: Date;
|
|
239
|
+
/**
|
|
240
|
+
* Override for the default schema version (1).
|
|
241
|
+
*/
|
|
242
|
+
version?: number;
|
|
243
|
+
/**
|
|
244
|
+
* Event metadata: correlation, causation, user, source, custom fields.
|
|
245
|
+
*/
|
|
246
|
+
metadata?: EventMetadata;
|
|
247
|
+
}
|
|
248
|
+
/** Technical recording data attached by the application shell. */
|
|
249
|
+
interface DomainEventStamp {
|
|
250
|
+
/** Stable identity for this event instance. */
|
|
251
|
+
readonly eventId: string;
|
|
252
|
+
/** Time at which the accepted domain fact was recorded. */
|
|
253
|
+
readonly occurredAt: Date;
|
|
254
|
+
/** Optional correlation, causation, actor, and source metadata. */
|
|
255
|
+
readonly metadata?: EventMetadata;
|
|
256
|
+
}
|
|
257
|
+
/** Full strict-construction options, including producer-owned event fields. */
|
|
258
|
+
interface CreateDomainEventFromFactsOptions extends DomainEventStamp {
|
|
259
|
+
readonly aggregateId?: string;
|
|
260
|
+
readonly aggregateType?: string;
|
|
261
|
+
readonly version?: number;
|
|
262
|
+
}
|
|
263
|
+
/** Overrides accepted when an application-shell factory creates a stamp. */
|
|
264
|
+
interface CreateDomainEventStampOptions {
|
|
265
|
+
readonly eventId?: string;
|
|
266
|
+
readonly occurredAt?: Date;
|
|
267
|
+
readonly metadata?: EventMetadata;
|
|
268
|
+
}
|
|
269
|
+
/** Dependencies captured by one immutable domain-event factory instance. */
|
|
270
|
+
interface DomainEventFactoryOptions {
|
|
271
|
+
/** Event-id generator. Defaults to Web Crypto `crypto.randomUUID()`. */
|
|
272
|
+
readonly eventIdFactory?: EventIdFactory;
|
|
273
|
+
/** Event-recording clock. Defaults to `() => new Date()`. */
|
|
274
|
+
readonly clock?: ClockFactory;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Instance-bound event constructor. Each factory permanently captures its
|
|
278
|
+
* own event-id and clock dependencies, so request and test instances cannot
|
|
279
|
+
* overwrite one another through module state.
|
|
280
|
+
*/
|
|
281
|
+
interface DomainEventFactory {
|
|
282
|
+
/**
|
|
283
|
+
* Creates immutable technical recording data in the application shell.
|
|
284
|
+
*/
|
|
285
|
+
readonly createStamp: (options?: CreateDomainEventStampOptions) => DomainEventStamp;
|
|
286
|
+
readonly create: {
|
|
287
|
+
<T extends string>(type: T, payload?: undefined, options?: CreateDomainEventOptions): DomainEvent<T, void>;
|
|
288
|
+
<T extends string, P>(type: T, payload: P, options?: CreateDomainEventOptions): DomainEvent<T, P>;
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Reads the captured clock and returns a defensive `Date` copy.
|
|
292
|
+
* Throws `TypeError` when the clock does not return a valid date.
|
|
293
|
+
*/
|
|
294
|
+
readonly now: () => Date;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Creates an immutable, instance-bound domain-event factory.
|
|
298
|
+
*
|
|
299
|
+
* The supplied functions are read once and captured by value. The returned
|
|
300
|
+
* object is frozen, so another request, test, or library cannot replace its
|
|
301
|
+
* policy. Its {@link DomainEventFactory.createStamp} method is the
|
|
302
|
+
* application-shell bridge that records an accepted aggregate decision.
|
|
303
|
+
* Passing the factory through `AggregateConfig`
|
|
304
|
+
* enables the explicitly named convenience methods, whose defaults read time
|
|
305
|
+
* and randomness.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* const domainEvents = createDomainEventFactory({
|
|
310
|
+
* eventIdFactory: () => uuidv7(),
|
|
311
|
+
* clock: () => new Date(),
|
|
312
|
+
* });
|
|
313
|
+
* order.confirm();
|
|
314
|
+
* recordPendingEvents(order, domainEvents);
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
declare function createDomainEventFactory(options?: DomainEventFactoryOptions): DomainEventFactory;
|
|
318
|
+
/**
|
|
319
|
+
* Immutable UUID-v4/platform-clock factory used by the top-level
|
|
320
|
+
* {@link createDomainEvent}. It cannot be reconfigured; construct an instance
|
|
321
|
+
* with {@link createDomainEventFactory} for custom policy.
|
|
322
|
+
*/
|
|
323
|
+
declare const defaultDomainEventFactory: DomainEventFactory;
|
|
324
|
+
declare function createUncommittedDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateUncommittedDomainEventOptions): UncommittedDomainEvent<T, void>;
|
|
325
|
+
declare function createUncommittedDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateUncommittedDomainEventOptions): UncommittedDomainEvent<T, P>;
|
|
326
|
+
/**
|
|
327
|
+
* Attaches shell-owned recording data to an accepted aggregate decision.
|
|
328
|
+
*
|
|
329
|
+
* The decision supplies the domain type, payload, source address, and payload
|
|
330
|
+
* schema version. The stamp supplies only event identity, recording time, and
|
|
331
|
+
* trace metadata.
|
|
332
|
+
*/
|
|
333
|
+
declare function recordDomainEvent<T extends string, P>(event: UncommittedDomainEvent<T, P>, stamp: DomainEventStamp): DomainEvent<T, P>;
|
|
334
|
+
declare function createDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateDomainEventOptions): DomainEvent<T, void>;
|
|
335
|
+
declare function createDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateDomainEventOptions): DomainEvent<T, P>;
|
|
336
|
+
/**
|
|
337
|
+
* Creates an already minted domain event exclusively from explicit envelope
|
|
338
|
+
* facts. Unlike {@link createDomainEvent}, it has no clock or event-id fallback
|
|
339
|
+
* and is useful when replay, migration, or a caller-owned boundary already has
|
|
340
|
+
* the final identity and occurrence time.
|
|
341
|
+
*
|
|
342
|
+
* Aggregate behavior normally creates an {@link UncommittedDomainEvent} through
|
|
343
|
+
* its protected `createEvent` helper. The application shell later records that
|
|
344
|
+
* pending fact with caller-owned time and identity.
|
|
345
|
+
*/
|
|
346
|
+
declare function createDomainEventFromFacts<T extends string>(type: T, payload: undefined, options: CreateDomainEventFromFactsOptions): DomainEvent<T, void>;
|
|
347
|
+
declare function createDomainEventFromFacts<T extends string, P>(type: T, payload: P, options: CreateDomainEventFromFactsOptions): DomainEvent<T, P>;
|
|
348
|
+
/**
|
|
349
|
+
* Copies metadata from a source event to a new event.
|
|
350
|
+
* Useful for maintaining correlation chains in event-driven architectures.
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* ```typescript
|
|
354
|
+
* const newEvent = createDomainEvent(
|
|
355
|
+
* "OrderShipped",
|
|
356
|
+
* { orderId: "123" },
|
|
357
|
+
* { metadata: copyMetadata(previousEvent, { causationId: previousEvent.type }) }
|
|
358
|
+
* );
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
declare function copyMetadata(sourceEvent: AnyDomainEvent, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
|
|
362
|
+
/**
|
|
363
|
+
* Merges multiple metadata objects into one.
|
|
364
|
+
* Later metadata objects override earlier ones for the same keys.
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```typescript
|
|
368
|
+
* const metadata = mergeMetadata(
|
|
369
|
+
* { correlationId: "corr-123" },
|
|
370
|
+
* { userId: "user-456" },
|
|
371
|
+
* { source: "order-service" }
|
|
372
|
+
* );
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
declare function mergeMetadata(...metadataObjects: Array<EventMetadata | undefined>): EventMetadata;
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/aggregate/aggregate.d.ts
|
|
378
|
+
type Version = number & {
|
|
379
|
+
readonly __v: true;
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* Snapshot of an aggregate state at a specific point in time.
|
|
383
|
+
* Used for optimizing event replay by starting from a snapshot
|
|
384
|
+
* instead of replaying all events from the beginning.
|
|
385
|
+
*
|
|
386
|
+
* @template TState - The type of the aggregate state
|
|
387
|
+
*/
|
|
388
|
+
interface AggregateSnapshot<TState> {
|
|
389
|
+
/**
|
|
390
|
+
* The state of the aggregate at the time of the snapshot.
|
|
391
|
+
*/
|
|
392
|
+
readonly state: TState;
|
|
393
|
+
/**
|
|
394
|
+
* The version of the aggregate when the snapshot was taken.
|
|
395
|
+
*/
|
|
396
|
+
readonly version: Version;
|
|
397
|
+
/**
|
|
398
|
+
* Timestamp when the snapshot was created.
|
|
399
|
+
*/
|
|
400
|
+
readonly snapshotAt: Date;
|
|
401
|
+
/**
|
|
402
|
+
* Schema version of the stored `state` shape, declared by its adapter-owned
|
|
403
|
+
* `SnapshotModel` and stamped by `captureAggregateSnapshot`. Distinct from
|
|
404
|
+
* {@link version}, which counts mutations: this field says "which
|
|
405
|
+
* shape does the stored state have", so a restore can detect a
|
|
406
|
+
* snapshot written against an older DTO shape and migrate or
|
|
407
|
+
* discard it instead of crashing later. Optional: absent on snapshots
|
|
408
|
+
* written by older kit versions, which restore treats as schema `1`.
|
|
409
|
+
*/
|
|
410
|
+
readonly schemaVersion?: number;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Public contract every Aggregate Root satisfies. Implemented by
|
|
414
|
+
* `BaseAggregate` and inherited by both `AggregateRoot` and
|
|
415
|
+
* `EventSourcedAggregate`. Repository ports use this interface as their
|
|
416
|
+
* aggregate type rather than depending on concrete base classes, so persistence
|
|
417
|
+
* orchestration does not take a compile-time
|
|
418
|
+
* dependency on the aggregate hierarchy.
|
|
419
|
+
*
|
|
420
|
+
* Full per-member documentation lives on the concrete `BaseAggregate`
|
|
421
|
+
* class; the interface is intentionally terse to avoid drift. Persistence
|
|
422
|
+
* facts are readable, but acknowledgement and pending-event disposal are not
|
|
423
|
+
* part of this surface. `withCommit` and `UnitOfWork` hold that authority.
|
|
424
|
+
*
|
|
425
|
+
* @template TId - The aggregate root identifier (branded via `Id<Tag>`)
|
|
426
|
+
* @template TEvent - The domain-event union, defaults to `never`
|
|
427
|
+
*/
|
|
428
|
+
interface IAggregateRoot<TId extends Id<string>, TEvent extends AnyDomainEvent = never> {
|
|
429
|
+
readonly id: TId;
|
|
430
|
+
readonly version: Version;
|
|
431
|
+
readonly pendingEvents: ReadonlyArray<PendingDomainEvent<TEvent>>;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Public contract for Event-Sourced Aggregate Roots. Extends
|
|
435
|
+
* `IAggregateRoot` with the replay-from-history boundary.
|
|
436
|
+
*
|
|
437
|
+
* @template TId - The aggregate root identifier
|
|
438
|
+
* @template TEvent - The union type of all domain events
|
|
439
|
+
*/
|
|
440
|
+
interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends IAggregateRoot<TId, TEvent> {
|
|
441
|
+
/**
|
|
442
|
+
* Reconstitutes the aggregate from an event history. Returns
|
|
443
|
+
* `Result` because event-stream corruption is an expected
|
|
444
|
+
* recoverable failure at the infrastructure boundary.
|
|
445
|
+
*/
|
|
446
|
+
loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Checks if two aggregates are at the same version (same ID and version).
|
|
450
|
+
* Useful for optimistic concurrency control checks.
|
|
451
|
+
*
|
|
452
|
+
* Note: Two aggregates with the same ID ARE the same aggregate (identity).
|
|
453
|
+
* This function checks if they are at the same version: i.e., no concurrent modification.
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* ```typescript
|
|
457
|
+
* const before = await repository.findById(id);
|
|
458
|
+
* // ... some operations ...
|
|
459
|
+
* const after = await repository.findById(id);
|
|
460
|
+
*
|
|
461
|
+
* if (!sameVersion(before, after)) {
|
|
462
|
+
* throw new Error("Aggregate was modified by another process");
|
|
463
|
+
* }
|
|
464
|
+
* ```
|
|
465
|
+
*/
|
|
466
|
+
declare function sameVersion<TId extends Id<string>>(a: {
|
|
467
|
+
id: TId;
|
|
468
|
+
version: Version;
|
|
469
|
+
}, b: {
|
|
470
|
+
id: TId;
|
|
471
|
+
version: Version;
|
|
472
|
+
}): boolean;
|
|
473
|
+
//#endregion
|
|
474
|
+
//#region src/aggregate/aggregate-address.d.ts
|
|
475
|
+
/**
|
|
476
|
+
* Stable value address of one aggregate instance.
|
|
477
|
+
*
|
|
478
|
+
* Aggregate ids are type-scoped, so the raw id alone is not globally unique:
|
|
479
|
+
* `SalesOrder 1` and `FulfillmentOrder 1` are different aggregates. Event
|
|
480
|
+
* streams, snapshots, committed-event sources, and projection checkpoints
|
|
481
|
+
* therefore carry both fields instead of defining boundary-specific variants.
|
|
482
|
+
*
|
|
483
|
+
* `aggregateType` is a stable technical stream category. Renaming it changes
|
|
484
|
+
* persistence keys and orphans checkpoints unless the stored addresses are
|
|
485
|
+
* migrated. When bounded contexts share infrastructure and reuse a domain
|
|
486
|
+
* name, qualify it at the source (`sales.order`, `fulfillment.order`). The kit
|
|
487
|
+
* deliberately adds no separate `boundedContext` field: qualification remains
|
|
488
|
+
* the consumer's naming decision.
|
|
489
|
+
*/
|
|
490
|
+
interface AggregateAddress<TAggregateId extends string = string> {
|
|
491
|
+
readonly aggregateType: string;
|
|
492
|
+
readonly aggregateId: TAggregateId;
|
|
493
|
+
}
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/entity/entity.d.ts
|
|
496
|
+
/** A pure invariant check that throws when a candidate state is invalid. */
|
|
497
|
+
type StateValidator<TState> = (state: TState) => void;
|
|
498
|
+
/**
|
|
499
|
+
* Construction options shared by `Entity` and (via `AggregateConfig`) the
|
|
500
|
+
* aggregate base classes.
|
|
501
|
+
*/
|
|
502
|
+
interface EntityConfig<TState = unknown> {
|
|
503
|
+
/**
|
|
504
|
+
* Pure state-invariant validator captured by the entity instance. It runs
|
|
505
|
+
* against the exact frozen state stored during construction and every
|
|
506
|
+
* {@link Entity.setState} call. Throw to reject the candidate state.
|
|
507
|
+
*
|
|
508
|
+
* Passing validation as data avoids virtual dispatch from the base
|
|
509
|
+
* constructor: the function cannot observe partly initialised subclass
|
|
510
|
+
* fields through `this`. Close over immutable policy supplied to the
|
|
511
|
+
* concrete constructor when validation needs instance-specific inputs.
|
|
512
|
+
*/
|
|
513
|
+
readonly validateState?: StateValidator<TState>;
|
|
514
|
+
/**
|
|
515
|
+
* Opt-in: freeze the WHOLE state graph (via `deepFreeze`) instead of
|
|
516
|
+
* the default shallow freeze. This protects against nested aliases
|
|
517
|
+
* retained by constructor callers and against accidental in-place
|
|
518
|
+
* writes inside the entity; live state itself is never public.
|
|
519
|
+
*
|
|
520
|
+
* Defaults to `false` (the documented shallow contract): deep freezing
|
|
521
|
+
* costs a full state-graph walk on every state write, which is why it
|
|
522
|
+
* is not the default on hot paths.
|
|
523
|
+
*
|
|
524
|
+
* **Only for plain-data states.** The deep freeze walks the entire
|
|
525
|
+
* graph: a class-based child entity inside the state would be frozen
|
|
526
|
+
* too, and its own mutation methods would start throwing. States
|
|
527
|
+
* carrying class-based children must keep the default shallow freeze.
|
|
528
|
+
* Note that the ownership transfer widens accordingly: nested objects
|
|
529
|
+
* passed into the constructor or `setState` are frozen IN PLACE (the
|
|
530
|
+
* shallow copy protects only the top-level input object).
|
|
531
|
+
*/
|
|
532
|
+
deepFreezeState?: boolean;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Functional definition of an Entity via its capability: an object is
|
|
536
|
+
* identifiable if it has an `id`.
|
|
537
|
+
*
|
|
538
|
+
* `TId` is constrained to `Id<string>` so the brand discipline that
|
|
539
|
+
* `Id<Tag>` enforces is preserved end-to-end: an `Identifiable<UserId>`
|
|
540
|
+
* cannot accidentally be paired with an `Identifiable<OrderId>` or with
|
|
541
|
+
* a plain `string`.
|
|
542
|
+
*/
|
|
543
|
+
type Identifiable<TId extends Id<string>> = {
|
|
544
|
+
readonly id: TId;
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* Interface for Entities with state.
|
|
548
|
+
*
|
|
549
|
+
* In Domain-Driven Design, Entities have:
|
|
550
|
+
* - Identity (id): Distinguishes one entity from another
|
|
551
|
+
* - State: The attributes/properties of the entity
|
|
552
|
+
*
|
|
553
|
+
* Unlike Value Objects (which are immutable and compared by value),
|
|
554
|
+
* Entities are compared by identity and can have mutable state.
|
|
555
|
+
*
|
|
556
|
+
* @template TId - The type of the entity identifier
|
|
557
|
+
*/
|
|
558
|
+
interface IEntity<TId extends Id<string>> extends Identifiable<TId> {
|
|
559
|
+
/**
|
|
560
|
+
* Unique identifier of the entity.
|
|
561
|
+
*/
|
|
562
|
+
readonly id: TId;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Abstract base class for Entities with state.
|
|
566
|
+
*
|
|
567
|
+
* Provides:
|
|
568
|
+
* - Identity management (id)
|
|
569
|
+
* - State management
|
|
570
|
+
* - Instance-bound pure state validation
|
|
571
|
+
* - Protected state access for domain behavior
|
|
572
|
+
*
|
|
573
|
+
* This is the foundation for all Entities in DDD:
|
|
574
|
+
* - Child Entities within aggregates can extend this
|
|
575
|
+
* - Aggregate Roots extend this and add version + events
|
|
576
|
+
*
|
|
577
|
+
* @template TState - The type of the entity state
|
|
578
|
+
* @template TId - The type of the entity identifier
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* ```typescript
|
|
582
|
+
* // Child Entity within an aggregate
|
|
583
|
+
* const validateOrderItemState = (state: OrderItemState): void => {
|
|
584
|
+
* if (state.quantity < 1) throw new Error("quantity must be positive");
|
|
585
|
+
* };
|
|
586
|
+
*
|
|
587
|
+
* class OrderItem extends Entity<OrderItemState, ItemId> {
|
|
588
|
+
* constructor(id: ItemId, initialState: OrderItemState) {
|
|
589
|
+
* super(id, initialState, { validateState: validateOrderItemState });
|
|
590
|
+
* }
|
|
591
|
+
*
|
|
592
|
+
* updateQuantity(quantity: number): void {
|
|
593
|
+
* // setState runs validateState and re-freezes; a direct
|
|
594
|
+
* // `this._state = ...` assignment would skip both.
|
|
595
|
+
* this.setState({ ...this.state, quantity });
|
|
596
|
+
* }
|
|
597
|
+
* }
|
|
598
|
+
* ```
|
|
599
|
+
*/
|
|
600
|
+
declare abstract class Entity<TState, TId extends Id<string>> implements IEntity<TId> {
|
|
601
|
+
readonly id: TId;
|
|
602
|
+
/**
|
|
603
|
+
* Returns the live state to subclass domain behavior.
|
|
604
|
+
*
|
|
605
|
+
* This accessor is deliberately protected: returning the generic
|
|
606
|
+
* `TState` publicly would expose the aggregate's live object graph and
|
|
607
|
+
* let nested mutation bypass behavior, validation, versioning, and
|
|
608
|
+
* dirty tracking. Concrete entities should expose business-meaningful queries or
|
|
609
|
+
* detached immutable DTOs. Snapshot projection belongs to an adapter-owned
|
|
610
|
+
* `SnapshotModel`; persistence code captures an aggregate with
|
|
611
|
+
* `captureAggregateSnapshot(model, aggregate, snapshotAt)` rather than
|
|
612
|
+
* asking the entity to create its own persistence memento.
|
|
613
|
+
*/
|
|
614
|
+
protected get state(): TState;
|
|
615
|
+
/**
|
|
616
|
+
* The state is `protected` so that only the subclass can modify it.
|
|
617
|
+
* Ordinary entity behavior must use {@link setState}; direct assignment
|
|
618
|
+
* skips instance-bound validation. Kit event-sourcing internals use direct
|
|
619
|
+
* assignment deliberately because historical evolution must not run
|
|
620
|
+
* today's decision validator.
|
|
621
|
+
*/
|
|
622
|
+
protected _state: TState;
|
|
623
|
+
private readonly _deepFreezeState;
|
|
624
|
+
private readonly validateState;
|
|
625
|
+
/**
|
|
626
|
+
* **State ownership.** Plain-object and array states are shallow-copied
|
|
627
|
+
* before the freeze, so the caller's own object stays mutable. A CLASS
|
|
628
|
+
* INSTANCE passed as state is an ownership transfer: it is frozen
|
|
629
|
+
* in place (a copy would strip its prototype). Do not keep mutating
|
|
630
|
+
* the instance after handing it to the entity. The same contract
|
|
631
|
+
* applies to {@link setState}. With
|
|
632
|
+
* {@link EntityConfig.deepFreezeState} enabled, the ownership transfer
|
|
633
|
+
* widens to the whole graph: NESTED objects are frozen in place too.
|
|
634
|
+
*
|
|
635
|
+
* @throws HostileStateKeyError when a plain-object, null-prototype,
|
|
636
|
+
* or array state carries an own `"__proto__"` data key; validate and
|
|
637
|
+
* strip untrusted input at the boundary.
|
|
638
|
+
*/
|
|
639
|
+
protected constructor(id: TId, initialState: TState, config?: EntityConfig<TState>);
|
|
640
|
+
/**
|
|
641
|
+
* Freezes a state value according to this entity's configured freeze
|
|
642
|
+
* mode: the default shallow freeze, or `deepFreeze` when
|
|
643
|
+
* {@link EntityConfig.deepFreezeState} was enabled at construction.
|
|
644
|
+
* Infrastructure-style subclass code that deliberately assigns
|
|
645
|
+
* `this._state` directly must freeze through this method, not
|
|
646
|
+
* `freezeShallow`, or the opt-in silently degrades to shallow for that
|
|
647
|
+
* path. Ordinary domain behavior should use {@link setState} instead.
|
|
648
|
+
*/
|
|
649
|
+
protected freezeState(value: TState): TState;
|
|
650
|
+
/**
|
|
651
|
+
* Sets the state of the entity.
|
|
652
|
+
* This is a convenience method for state mutations.
|
|
653
|
+
* Automatically validates `newState` with the instance-bound
|
|
654
|
+
* {@link EntityConfig.validateState} function.
|
|
655
|
+
*
|
|
656
|
+
* Plain-object and array states are shallow-copied before the freeze
|
|
657
|
+
* (the caller's object stays mutable); a class-instance state is an
|
|
658
|
+
* ownership transfer and is frozen in place; see the constructor.
|
|
659
|
+
*
|
|
660
|
+
* @param newState - The new state
|
|
661
|
+
* @throws HostileStateKeyError when the state carries an own
|
|
662
|
+
* `"__proto__"` data key; the previous state is kept.
|
|
663
|
+
*/
|
|
664
|
+
protected setState(newState: TState): void;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Shallow-freezes `value` when it's a non-null object or array, so that
|
|
668
|
+
* direct property writes throw in strict mode. Returns the value as-is for
|
|
669
|
+
* primitives. Used internally by `Entity` (via `freezeState`, which picks
|
|
670
|
+
* shallow or deep per the `deepFreezeState` config) to prevent outside
|
|
671
|
+
* mutation of state read through the `state` getter without paying the
|
|
672
|
+
* cost of a deep clone on every read.
|
|
673
|
+
*
|
|
674
|
+
* Subclass code that assigns `this._state` directly should freeze through
|
|
675
|
+
* the protected `freezeState(value)` method rather than calling this
|
|
676
|
+
* helper, so the configured freeze mode is honored. The export remains
|
|
677
|
+
* for consumers using it as a standalone utility.
|
|
678
|
+
*/
|
|
679
|
+
declare function freezeShallow<T>(value: T): T;
|
|
680
|
+
/**
|
|
681
|
+
* Checks if two entities have the same ID.
|
|
682
|
+
* Works with any object that has an 'id' property.
|
|
683
|
+
*
|
|
684
|
+
* @param a - First entity
|
|
685
|
+
* @param b - Second entity
|
|
686
|
+
* @returns true if both entities have the same ID, false otherwise
|
|
687
|
+
*
|
|
688
|
+
* @example
|
|
689
|
+
* ```typescript
|
|
690
|
+
* const item1: OrderItem = { id: itemId1, productId: "prod-1", quantity: 2 };
|
|
691
|
+
* const item2: OrderItem = { id: itemId2, productId: "prod-2", quantity: 1 };
|
|
692
|
+
*
|
|
693
|
+
* sameEntity(item1, item2); // false
|
|
694
|
+
* sameEntity(item1, item1); // true
|
|
695
|
+
* ```
|
|
696
|
+
*/
|
|
697
|
+
declare function sameEntity<TId extends Id<string>>(a: Identifiable<TId>, b: Identifiable<TId>): boolean;
|
|
698
|
+
/**
|
|
699
|
+
* Finds an entity by ID in a collection.
|
|
700
|
+
* Returns undefined if not found.
|
|
701
|
+
*
|
|
702
|
+
* @param entities - Array of entities to search
|
|
703
|
+
* @param id - The ID to search for
|
|
704
|
+
* @returns The entity if found, undefined otherwise
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
* ```typescript
|
|
708
|
+
* const items: OrderItem[] = [
|
|
709
|
+
* { id: itemId1, productId: "prod-1", quantity: 2 },
|
|
710
|
+
* { id: itemId2, productId: "prod-2", quantity: 1 }
|
|
711
|
+
* ];
|
|
712
|
+
*
|
|
713
|
+
* const item = findEntityById(items, itemId1);
|
|
714
|
+
* // item is { id: itemId1, productId: "prod-1", quantity: 2 }
|
|
715
|
+
* ```
|
|
716
|
+
*/
|
|
717
|
+
declare function findEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): T | undefined;
|
|
718
|
+
/**
|
|
719
|
+
* Checks if an entity with the given ID exists in the collection.
|
|
720
|
+
*
|
|
721
|
+
* @param entities - Array of entities to search
|
|
722
|
+
* @param id - The ID to check for
|
|
723
|
+
* @returns true if an entity with the ID exists, false otherwise
|
|
724
|
+
*
|
|
725
|
+
* @example
|
|
726
|
+
* ```typescript
|
|
727
|
+
* const items: OrderItem[] = [
|
|
728
|
+
* { id: itemId1, productId: "prod-1", quantity: 2 }
|
|
729
|
+
* ];
|
|
730
|
+
*
|
|
731
|
+
* hasEntityId(items, itemId1); // true
|
|
732
|
+
* hasEntityId(items, itemId2); // false
|
|
733
|
+
* ```
|
|
734
|
+
*/
|
|
735
|
+
declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): boolean;
|
|
736
|
+
/**
|
|
737
|
+
* Removes an entity with the given ID from the collection. Returns the
|
|
738
|
+
* ORIGINAL array when the id is absent (structural sharing for the
|
|
739
|
+
* reference-based dirty tracking; see `updateEntityById`), otherwise a
|
|
740
|
+
* new array without the entity.
|
|
741
|
+
*
|
|
742
|
+
* @param entities - Array of entities
|
|
743
|
+
* @param id - The ID of the entity to remove
|
|
744
|
+
* @returns A new array without the entity with the given ID
|
|
745
|
+
*
|
|
746
|
+
* @example
|
|
747
|
+
* ```typescript
|
|
748
|
+
* const items: OrderItem[] = [
|
|
749
|
+
* { id: itemId1, productId: "prod-1", quantity: 2 },
|
|
750
|
+
* { id: itemId2, productId: "prod-2", quantity: 1 }
|
|
751
|
+
* ];
|
|
752
|
+
*
|
|
753
|
+
* const updated = removeEntityById(items, itemId1);
|
|
754
|
+
* // updated is [{ id: itemId2, productId: "prod-2", quantity: 1 }]
|
|
755
|
+
* ```
|
|
756
|
+
*/
|
|
757
|
+
declare function removeEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): ReadonlyArray<T>;
|
|
758
|
+
/**
|
|
759
|
+
* Updates an entity with the given ID in the collection.
|
|
760
|
+
* Returns a new array with the updated entity.
|
|
761
|
+
* Structural sharing for adapter-owned persistence projections: returns
|
|
762
|
+
* the ORIGINAL array when nothing changed (no match, or the element kept
|
|
763
|
+
* its reference), so a partial-write adapter can skip the untouched
|
|
764
|
+
* collection; a new array only when an
|
|
765
|
+
* element reference actually changed. The result is `ReadonlyArray<T>`:
|
|
766
|
+
* it may BE the (possibly frozen) input; spread it if you need a mutable
|
|
767
|
+
* copy.
|
|
768
|
+
*
|
|
769
|
+
* @param entities - Array of entities
|
|
770
|
+
* @param id - The ID of the entity to update
|
|
771
|
+
* @param updater - Function that takes the entity and returns the updated entity
|
|
772
|
+
* @returns A new array with the updated entity
|
|
773
|
+
*
|
|
774
|
+
* @example
|
|
775
|
+
* ```typescript
|
|
776
|
+
* const items: OrderItem[] = [
|
|
777
|
+
* { id: itemId1, productId: "prod-1", quantity: 2 }
|
|
778
|
+
* ];
|
|
779
|
+
*
|
|
780
|
+
* const updated = updateEntityById(items, itemId1, (item) => ({
|
|
781
|
+
* ...item,
|
|
782
|
+
* quantity: item.quantity + 1
|
|
783
|
+
* }));
|
|
784
|
+
* // updated is [{ id: itemId1, productId: "prod-1", quantity: 3 }]
|
|
785
|
+
* ```
|
|
786
|
+
*/
|
|
787
|
+
declare function updateEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, updater: (entity: T) => T): ReadonlyArray<T>;
|
|
788
|
+
/**
|
|
789
|
+
* Replaces an entity with the given ID in the collection.
|
|
790
|
+
* Returns a new array with the replaced entity.
|
|
791
|
+
* Structural sharing for adapter-owned persistence projections: returns
|
|
792
|
+
* the ORIGINAL array when nothing changed (no match, or the element kept
|
|
793
|
+
* its reference), so a partial-write adapter can skip the untouched
|
|
794
|
+
* collection; a new array only when an
|
|
795
|
+
* element reference actually changed. The result is `ReadonlyArray<T>`:
|
|
796
|
+
* it may BE the (possibly frozen) input; spread it if you need a mutable
|
|
797
|
+
* copy.
|
|
798
|
+
*
|
|
799
|
+
* @param entities - Array of entities
|
|
800
|
+
* @param id - The ID of the entity to replace
|
|
801
|
+
* @param replacement - The replacement entity
|
|
802
|
+
* @returns A new array with the replaced entity
|
|
803
|
+
*
|
|
804
|
+
* @example
|
|
805
|
+
* ```typescript
|
|
806
|
+
* const items: OrderItem[] = [
|
|
807
|
+
* { id: itemId1, productId: "prod-1", quantity: 2 }
|
|
808
|
+
* ];
|
|
809
|
+
*
|
|
810
|
+
* const updated = replaceEntityById(items, itemId1, {
|
|
811
|
+
* id: itemId1,
|
|
812
|
+
* productId: "prod-1",
|
|
813
|
+
* quantity: 5
|
|
814
|
+
* });
|
|
815
|
+
* ```
|
|
816
|
+
*/
|
|
817
|
+
declare function replaceEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, replacement: T): ReadonlyArray<T>;
|
|
818
|
+
/**
|
|
819
|
+
* Extracts all IDs from a collection of entities.
|
|
820
|
+
*
|
|
821
|
+
* @param entities - Array of entities
|
|
822
|
+
* @returns Array of entity IDs
|
|
823
|
+
*
|
|
824
|
+
* @example
|
|
825
|
+
* ```typescript
|
|
826
|
+
* const items: OrderItem[] = [
|
|
827
|
+
* { id: itemId1, productId: "prod-1", quantity: 2 },
|
|
828
|
+
* { id: itemId2, productId: "prod-2", quantity: 1 }
|
|
829
|
+
* ];
|
|
830
|
+
*
|
|
831
|
+
* const ids = entityIds(items);
|
|
832
|
+
* // ids is [itemId1, itemId2]
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>): TId[];
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/aggregate/base-aggregate.d.ts
|
|
838
|
+
/** Construction options shared by state-stored and event-sourced aggregates. */
|
|
839
|
+
type AggregateConfig<TState = unknown> = EntityConfig<TState>;
|
|
840
|
+
/**
|
|
841
|
+
* Shared base for both `AggregateRoot` (state-stored) and
|
|
842
|
+
* `EventSourcedAggregate`. Carries the lifecycle machinery that's
|
|
843
|
+
* identical across the two flavours: current version, pending-event
|
|
844
|
+
* tracking, the kit-internal post-commit acknowledgement capability,
|
|
845
|
+
* the `markRestored` post-load marker, and the `createEvent` helper
|
|
846
|
+
* that auto-injects `aggregateId` + `aggregateType` on every event the
|
|
847
|
+
* aggregate emits. The application shell records the pending decisions
|
|
848
|
+
* with `recordPendingEvents` before persistence.
|
|
849
|
+
*
|
|
850
|
+
* Consumers do NOT extend this class directly; extend
|
|
851
|
+
* `AggregateRoot` for state-stored aggregates or
|
|
852
|
+
* `EventSourcedAggregate` for event-sourced ones. The split between
|
|
853
|
+
* those two reflects the canonical Vernon §8 (state-stored) /
|
|
854
|
+
* Vernon §11 + Greg Young (event-sourced) distinction in how state
|
|
855
|
+
* is represented; the lifecycle machinery is the same for both.
|
|
856
|
+
*
|
|
857
|
+
* @template TState - The type of the aggregate state
|
|
858
|
+
* @template TId - The aggregate root identifier
|
|
859
|
+
* @template TEvent - The domain-event union. Defaults to `never` so
|
|
860
|
+
* aggregates without a declared event type cannot emit events
|
|
861
|
+
* (emitting any event becomes a compile error).
|
|
862
|
+
*/
|
|
863
|
+
declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
|
|
864
|
+
/**
|
|
865
|
+
* The aggregate's domain type as a string, used to populate
|
|
866
|
+
* `aggregateType` on events created via {@link createEvent}.
|
|
867
|
+
*
|
|
868
|
+
* Subclasses MUST declare this as a string literal:
|
|
869
|
+
*
|
|
870
|
+
* ```ts
|
|
871
|
+
* class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
|
|
872
|
+
* protected readonly aggregateType = "Order";
|
|
873
|
+
* }
|
|
874
|
+
* ```
|
|
875
|
+
*
|
|
876
|
+
* The string is *the* identifier downstream consumers (outbox
|
|
877
|
+
* dispatchers, projection handlers, audit logs) use to route by
|
|
878
|
+
* aggregate kind. Use the same canonical name across your system;
|
|
879
|
+
* matching the class name is the obvious choice, but the value
|
|
880
|
+
* comes from this explicit declaration, not `constructor.name`
|
|
881
|
+
* (which is fragile under minification, bundler transforms, and
|
|
882
|
+
* subclass renaming).
|
|
883
|
+
*/
|
|
884
|
+
protected abstract readonly aggregateType: string;
|
|
885
|
+
private _version;
|
|
886
|
+
/**
|
|
887
|
+
* Version the persistence layer last confirmed for this instance:
|
|
888
|
+
* `undefined` until the aggregate is reconstituted (`markRestored`) or a
|
|
889
|
+
* commit is acknowledged. Kit-internal via the lifecycle capability; it
|
|
890
|
+
* grounds the `withCommit` unique-cursor guard so an eventful commit that
|
|
891
|
+
* did not advance beyond the persisted row is rejected deterministically.
|
|
892
|
+
*/
|
|
893
|
+
private _persistedVersion;
|
|
894
|
+
private _pendingEvents;
|
|
895
|
+
protected constructor(id: TId, initialState: TState, config?: AggregateConfig<TState>);
|
|
896
|
+
private acknowledgePendingEvents;
|
|
897
|
+
/**
|
|
898
|
+
* Post-commit cleanup for the deleted disposition. The row is gone, so
|
|
899
|
+
* there is no persisted version to advance: stamping the marker from the
|
|
900
|
+
* live instance would make a later legitimate re-enrollment of this
|
|
901
|
+
* instance trip the unique-cursor guard for a row that does not exist.
|
|
902
|
+
*/
|
|
903
|
+
private discardPendingEventsAfterDeletion;
|
|
904
|
+
private stripAcknowledgedPrefix;
|
|
905
|
+
get version(): Version;
|
|
906
|
+
/**
|
|
907
|
+
* Read-only list of domain events recorded on this aggregate that
|
|
908
|
+
* have not yet been flushed to the outbox / persistence layer.
|
|
909
|
+
*/
|
|
910
|
+
get pendingEvents(): ReadonlyArray<PendingDomainEvent<TEvent>>;
|
|
911
|
+
/**
|
|
912
|
+
* Count-only accessor for internal aggregate paths: the public
|
|
913
|
+
* {@link pendingEvents} getter allocates and freezes
|
|
914
|
+
* a defensive copy per read, which a length check does not need.
|
|
915
|
+
*/
|
|
916
|
+
protected get pendingEventCount(): number;
|
|
917
|
+
protected setVersion(version: Version): void;
|
|
918
|
+
/**
|
|
919
|
+
* Manually bumps the aggregate version. Used by state-stored
|
|
920
|
+
* aggregates' `setState()` / `commit()` paths and by the
|
|
921
|
+
* event-sourced replay path after each applied event.
|
|
922
|
+
*/
|
|
923
|
+
protected bumpVersion(): void;
|
|
924
|
+
/**
|
|
925
|
+
* **Lifecycle marker, Post-Load.** Syncs both `_version` and
|
|
926
|
+
* the current version to the stored version. Used by
|
|
927
|
+
* `reconstitute(...)` factories to assemble an in-memory aggregate
|
|
928
|
+
* from a persisted row.
|
|
929
|
+
*
|
|
930
|
+
* The Factory-vs-Reconstitution distinction (Vernon §11) is honoured
|
|
931
|
+
* structurally: reconstitution stays inside the aggregate factory while
|
|
932
|
+
* post-commit acknowledgement belongs to application commit orchestration.
|
|
933
|
+
*
|
|
934
|
+
* If you override this, call `super.markRestored(version)` so the current
|
|
935
|
+
* domain version remains aligned with the reconstituted facts.
|
|
936
|
+
*
|
|
937
|
+
* @param version - The version the row currently holds in the DB
|
|
938
|
+
*
|
|
939
|
+
* @example
|
|
940
|
+
* ```ts
|
|
941
|
+
* static reconstitute(id: OrderId, state: OrderState, version: Version): Order {
|
|
942
|
+
* const order = new Order(id, state);
|
|
943
|
+
* order.markRestored(version);
|
|
944
|
+
* return order;
|
|
945
|
+
* }
|
|
946
|
+
* ```
|
|
947
|
+
*/
|
|
948
|
+
protected markRestored(version: Version): void;
|
|
949
|
+
/**
|
|
950
|
+
* Appends a domain event to the pending list. Prefer the higher-level
|
|
951
|
+
* `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
|
|
952
|
+
* (event-sourced) call sites, both of which wrap `addDomainEvent` in the
|
|
953
|
+
* canonical record-AFTER-mutation order (Vernon §8). Calling
|
|
954
|
+
* `addDomainEvent` directly is appropriate only after a version-advancing
|
|
955
|
+
* state mutation, or while constructing a never-persisted aggregate.
|
|
956
|
+
* An event-only commit on an already-persisted aggregate has no unique
|
|
957
|
+
* cursor and `withCommit` rejects it; use `commit(currentState, event)`.
|
|
958
|
+
*/
|
|
959
|
+
protected addDomainEvent(event: PendingDomainEvent<TEvent>): void;
|
|
960
|
+
/**
|
|
961
|
+
* Immutability gate for every recording path: only events minted by
|
|
962
|
+
* the kit's constructors (`createDomainEvent`,
|
|
963
|
+
* `createDomainEventFromFacts`, `createEvent`) pass,
|
|
964
|
+
* checked against the constructor's internal, unforgeable mint
|
|
965
|
+
* marker. Minted implies deeply frozen with defensively copied
|
|
966
|
+
* payload and metadata, a guarantee no frozen-ness probe can
|
|
967
|
+
* establish (a shallow-frozen literal with mutable nested data
|
|
968
|
+
* would fool it). O(1): one WeakSet lookup.
|
|
969
|
+
*/
|
|
970
|
+
protected assertMintedEvent(event: PendingDomainEvent<TEvent>): void;
|
|
971
|
+
/**
|
|
972
|
+
* Creates the immutable business fact accepted by this aggregate without
|
|
973
|
+
* reading a clock, generating an id, or attaching tracing metadata.
|
|
974
|
+
*
|
|
975
|
+
* The application shell records pending events after the domain operation
|
|
976
|
+
* and before persistence. Payload schema version stays here, next to the
|
|
977
|
+
* concrete event producer, rather than in shell-owned recording data.
|
|
978
|
+
*/
|
|
979
|
+
protected createEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateUncommittedDomainEventOptions, "aggregateId" | "aggregateType">): UncommittedDomainEventOf<E>;
|
|
980
|
+
}
|
|
981
|
+
//#endregion
|
|
982
|
+
//#region src/aggregate/aggregate-root.d.ts
|
|
983
|
+
/**
|
|
984
|
+
* OO-first Aggregate Root for state-stored domain models.
|
|
985
|
+
*
|
|
986
|
+
* The aggregate owns identity, valid domain state, behavior, its current
|
|
987
|
+
* domain version, and pending domain events. It deliberately does not own a
|
|
988
|
+
* database baseline or dirty-key bookkeeping. A repository adapter defines
|
|
989
|
+
* its persistence projection through `PersistenceModel`; the Unit of Work
|
|
990
|
+
* retains that opaque baseline and derives the adapter's change set at flush.
|
|
991
|
+
*/
|
|
992
|
+
declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends BaseAggregate<TState, TId, TEvent> {
|
|
993
|
+
/**
|
|
994
|
+
* Changes state and records the resulting facts in record-after-mutation
|
|
995
|
+
* order. Validation and event mint checks run before the transition becomes
|
|
996
|
+
* observable, so a rejected decision records nothing.
|
|
997
|
+
*/
|
|
998
|
+
protected commit(newState: TState, events?: PendingDomainEvent<TEvent> | readonly PendingDomainEvent<TEvent>[]): void;
|
|
999
|
+
/** Every normal domain-state transition advances the OCC version. */
|
|
1000
|
+
protected setState(newState: TState): void;
|
|
1001
|
+
/**
|
|
1002
|
+
* Replaces loss-tolerant derived state without advancing the domain version.
|
|
1003
|
+
*
|
|
1004
|
+
* This is intentionally loud: concurrent writers may overwrite such a
|
|
1005
|
+
* change. Keep business facts on the normal `setState`/`commit` path.
|
|
1006
|
+
*/
|
|
1007
|
+
protected setStateWithoutVersionBump(newState: TState): void;
|
|
1008
|
+
}
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/events/json-value.d.ts
|
|
1011
|
+
/** A primitive value represented without loss by JSON. */
|
|
1012
|
+
type JsonPrimitive = boolean | null | number | string;
|
|
1013
|
+
/** A recursively JSON-safe value. Runtime validation rejects lossy shapes. */
|
|
1014
|
+
type JsonValue = JsonPrimitive | ReadonlyArray<JsonValue> | {
|
|
1015
|
+
readonly [key: string]: JsonValue;
|
|
1016
|
+
};
|
|
1017
|
+
/** A JSON-safe object. */
|
|
1018
|
+
type JsonObject = {
|
|
1019
|
+
readonly [key: string]: JsonValue;
|
|
1020
|
+
};
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region src/app/command.d.ts
|
|
1023
|
+
/**
|
|
1024
|
+
* Marker interface for Commands.
|
|
1025
|
+
* Commands represent write operations that change system state.
|
|
1026
|
+
* They should be immutable and contain all data needed to perform the operation.
|
|
1027
|
+
*
|
|
1028
|
+
* This interface can be used as a type marker even when using external frameworks
|
|
1029
|
+
* (e.g., RabbitMQ, AWS SQS) to ensure type safety across different bus implementations.
|
|
1030
|
+
*
|
|
1031
|
+
* @example
|
|
1032
|
+
* ```typescript
|
|
1033
|
+
* type CreateOrderCommand = Command & {
|
|
1034
|
+
* type: "CreateOrder";
|
|
1035
|
+
* customerId: string;
|
|
1036
|
+
* items: OrderItem[];
|
|
1037
|
+
* };
|
|
1038
|
+
* ```
|
|
1039
|
+
*
|
|
1040
|
+
* @example Using with external frameworks (RabbitMQ, etc.)
|
|
1041
|
+
* ```typescript
|
|
1042
|
+
* // Define command using Command marker
|
|
1043
|
+
* type CreateOrderCommand = Command & {
|
|
1044
|
+
* type: "CreateOrder";
|
|
1045
|
+
* customerId: string;
|
|
1046
|
+
* };
|
|
1047
|
+
*
|
|
1048
|
+
* // Handler can be typed with CommandHandler even for external frameworks
|
|
1049
|
+
* const handler: CommandHandler<CreateOrderCommand, OrderId> = async (cmd) => {
|
|
1050
|
+
* // ... handler logic
|
|
1051
|
+
* return ok(orderId);
|
|
1052
|
+
* };
|
|
1053
|
+
*
|
|
1054
|
+
* // The consumer owns this runtime decoder. It checks byte and collection
|
|
1055
|
+
* // ceilings, parses to unknown, allow-lists fields, and constructs domain types.
|
|
1056
|
+
* declare function decodeCreateOrderCommand(
|
|
1057
|
+
* body: Uint8Array,
|
|
1058
|
+
* principal: AuthenticatedPrincipal,
|
|
1059
|
+
* ): Result<CreateOrderCommand, InvalidCommand>;
|
|
1060
|
+
* declare function decodeMessageId(
|
|
1061
|
+
* value: unknown,
|
|
1062
|
+
* ): Result<string, InvalidTransportMetadata>;
|
|
1063
|
+
* declare function createOrderDeliveryKey(messageId: string): string;
|
|
1064
|
+
*
|
|
1065
|
+
* // This application service invokes the handler through withIdempotentCommit.
|
|
1066
|
+
* // createOrderDeliveryKey scopes the message id by consumer. The service
|
|
1067
|
+
* // fingerprints the complete CreateOrder intention and commits that claim,
|
|
1068
|
+
* // the aggregate write, outbox entries, and outcome together.
|
|
1069
|
+
* declare function executeIdempotentCreateOrder(
|
|
1070
|
+
* deliveryKey: string,
|
|
1071
|
+
* command: CreateOrderCommand,
|
|
1072
|
+
* ): Promise<Result<OrderId, string>>;
|
|
1073
|
+
*
|
|
1074
|
+
* // Register with RabbitMQ or another external bus.
|
|
1075
|
+
* rabbitMQChannel.consume("order.commands", async (message) => {
|
|
1076
|
+
* const messageId = decodeMessageId(message.properties.messageId);
|
|
1077
|
+
* if (messageId.isErr()) {
|
|
1078
|
+
* rabbitMQChannel.reject(message, false); // missing identity: dead-letter
|
|
1079
|
+
* return;
|
|
1080
|
+
* }
|
|
1081
|
+
* const deliveryKey = createOrderDeliveryKey(messageId.value);
|
|
1082
|
+
* const principal = authenticateProducer(message.properties.headers);
|
|
1083
|
+
* const decoded = decodeCreateOrderCommand(message.content, principal);
|
|
1084
|
+
* if (decoded.isErr()) {
|
|
1085
|
+
* rabbitMQChannel.reject(message, false); // invalid input: dead-letter, do not retry
|
|
1086
|
+
* return;
|
|
1087
|
+
* }
|
|
1088
|
+
* const outcome = await executeIdempotentCreateOrder(
|
|
1089
|
+
* deliveryKey,
|
|
1090
|
+
* decoded.value,
|
|
1091
|
+
* );
|
|
1092
|
+
* await recordCommandOutcome(deliveryKey, outcome);
|
|
1093
|
+
* rabbitMQChannel.ack(message);
|
|
1094
|
+
* });
|
|
1095
|
+
* ```
|
|
1096
|
+
*/
|
|
1097
|
+
interface Command {
|
|
1098
|
+
readonly type: string;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Versioned Published Language for a command that crosses a process or
|
|
1102
|
+
* Bounded-Context boundary. Unlike a local {@link Command}, its payload is
|
|
1103
|
+
* JSON-safe data rather than a domain object graph. Map value objects to their
|
|
1104
|
+
* wire DTOs at this boundary; for example, use `MoneyDto` instead of `Money`.
|
|
1105
|
+
*/
|
|
1106
|
+
interface PublishedCommand<TType extends string = string, TPayload extends JsonValue = JsonValue> extends Command {
|
|
1107
|
+
readonly type: TType;
|
|
1108
|
+
readonly version: number;
|
|
1109
|
+
readonly payload: TPayload;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Handler for executing commands.
|
|
1113
|
+
* Commands return Result for explicit error handling.
|
|
1114
|
+
* Commands may modify system state. When a caller can retry or a broker can
|
|
1115
|
+
* redeliver, the application service must enforce idempotency; this handler
|
|
1116
|
+
* type alone does not provide it.
|
|
1117
|
+
*
|
|
1118
|
+
* This type can be used to mark handlers even when using external frameworks
|
|
1119
|
+
* (e.g., RabbitMQ, AWS SQS, Kafka) to ensure type safety and consistency.
|
|
1120
|
+
*
|
|
1121
|
+
* @template C - The command type (must extend Command)
|
|
1122
|
+
* @template R - The result type
|
|
1123
|
+
* @template E - The error channel type. Defaults to `string`; widen it (e.g.
|
|
1124
|
+
* to a `DomainError` union) to carry typed failures through the bus.
|
|
1125
|
+
*
|
|
1126
|
+
* @example
|
|
1127
|
+
* ```typescript
|
|
1128
|
+
* const handler: CommandHandler<CreateOrderCommand, OrderId> = async (cmd) => {
|
|
1129
|
+
* const order = Order.create(cmd.customerId, cmd.items);
|
|
1130
|
+
* repository.add(order);
|
|
1131
|
+
* return ok(order.id);
|
|
1132
|
+
* };
|
|
1133
|
+
* ```
|
|
1134
|
+
*
|
|
1135
|
+
* @example Using with external frameworks
|
|
1136
|
+
* ```typescript
|
|
1137
|
+
* // Handler typed with CommandHandler for type safety
|
|
1138
|
+
* const createOrderHandler: CommandHandler<CreateOrderCommand, OrderId> = async (cmd) => {
|
|
1139
|
+
* // ... handler logic
|
|
1140
|
+
* return ok(orderId);
|
|
1141
|
+
* };
|
|
1142
|
+
*
|
|
1143
|
+
* // The broker adapter validates before calling the application handler.
|
|
1144
|
+
* rabbitMQChannel.consume("commands", async (msg) => {
|
|
1145
|
+
* const messageId = decodeMessageId(msg.properties.messageId);
|
|
1146
|
+
* if (messageId.isErr()) {
|
|
1147
|
+
* rabbitMQChannel.reject(msg, false);
|
|
1148
|
+
* return;
|
|
1149
|
+
* }
|
|
1150
|
+
* const deliveryKey = createOrderDeliveryKey(messageId.value);
|
|
1151
|
+
* const principal = authenticateProducer(msg.properties.headers);
|
|
1152
|
+
* const decoded = decodeCreateOrderCommand(msg.content, principal);
|
|
1153
|
+
* if (decoded.isErr()) {
|
|
1154
|
+
* rabbitMQChannel.reject(msg, false); // malformed or over limit
|
|
1155
|
+
* return;
|
|
1156
|
+
* }
|
|
1157
|
+
* // executeIdempotentCreateOrder invokes createOrderHandler through the same
|
|
1158
|
+
* // atomic withIdempotentCommit boundary described in the first example.
|
|
1159
|
+
* const outcome = await executeIdempotentCreateOrder(
|
|
1160
|
+
* deliveryKey,
|
|
1161
|
+
* decoded.value,
|
|
1162
|
+
* );
|
|
1163
|
+
* await recordCommandOutcome(deliveryKey, outcome);
|
|
1164
|
+
* rabbitMQChannel.ack(msg);
|
|
1165
|
+
* });
|
|
1166
|
+
* ```
|
|
1167
|
+
*/
|
|
1168
|
+
type CommandHandler<C extends Command, R, E = string> = (cmd: C) => Promise<Result<R, E>>;
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/utils/execution.d.ts
|
|
1171
|
+
/** Cancellation and deadline controls for one bounded shell operation. */
|
|
1172
|
+
interface ExecutionContext {
|
|
1173
|
+
/** Cooperative cancellation for the in-flight operation. */
|
|
1174
|
+
readonly signal: AbortSignal;
|
|
1175
|
+
/** Absolute Unix epoch millisecond at which the shell stops waiting. */
|
|
1176
|
+
readonly deadlineAt: number;
|
|
1177
|
+
}
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/events/ports.d.ts
|
|
1180
|
+
/**
|
|
1181
|
+
* Event handler function type for subscribing to domain events. The execution
|
|
1182
|
+
* context carries the publication's cooperative cancellation and deadline;
|
|
1183
|
+
* those runtime controls belong to the imperative shell, never to the domain
|
|
1184
|
+
* event itself.
|
|
1185
|
+
*
|
|
1186
|
+
* @template Evt - The type of domain event
|
|
1187
|
+
*/
|
|
1188
|
+
type EventHandler<Evt> = (event: Evt, context: ExecutionContext) => Promise<void> | void;
|
|
1189
|
+
/** Controls one bounded in-process event publication. */
|
|
1190
|
+
interface PublishOptions {
|
|
1191
|
+
/** Owner/request cancellation propagated to every event handler. */
|
|
1192
|
+
readonly signal?: AbortSignal;
|
|
1193
|
+
/** Maximum time to await the complete publication. Default `30000`ms. */
|
|
1194
|
+
readonly timeoutMs?: number;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Event Bus interface for publishing and subscribing to domain events.
|
|
1198
|
+
* Supports multiple subscribers per event type (pub/sub pattern).
|
|
1199
|
+
*
|
|
1200
|
+
* @template Evt - The type of domain events
|
|
1201
|
+
*
|
|
1202
|
+
* @example
|
|
1203
|
+
* ```typescript
|
|
1204
|
+
* const bus = new EventBus<OrderEvent>();
|
|
1205
|
+
*
|
|
1206
|
+
* // Subscribe to specific event types
|
|
1207
|
+
* bus.subscribe("OrderCreated", async (event) => {
|
|
1208
|
+
* await sendEmail(event.payload.customerId);
|
|
1209
|
+
* });
|
|
1210
|
+
*
|
|
1211
|
+
* bus.subscribe("OrderShipped", async (event) => {
|
|
1212
|
+
* await updateInventory(event.payload.orderId);
|
|
1213
|
+
* });
|
|
1214
|
+
*
|
|
1215
|
+
* // Publish events
|
|
1216
|
+
* await bus.publish([orderCreatedEvent, orderShippedEvent]);
|
|
1217
|
+
* ```
|
|
1218
|
+
*/
|
|
1219
|
+
interface EventBus<Evt extends AnyDomainEvent> {
|
|
1220
|
+
/**
|
|
1221
|
+
* Publishes events to all subscribed handlers.
|
|
1222
|
+
*
|
|
1223
|
+
* **Ordering & parallelism contract:**
|
|
1224
|
+
*
|
|
1225
|
+
* 1. **Events run in input order.** `publish([a, b, c])` dispatches `a`,
|
|
1226
|
+
* awaits all of its handlers, then dispatches `b`, and so on. The
|
|
1227
|
+
* library never reorders or parallelises across events.
|
|
1228
|
+
* 2. **Handlers within a single event run in parallel.** All handlers
|
|
1229
|
+
* subscribed to `event.type` are awaited via `Promise.allSettled`:
|
|
1230
|
+
* none of them sees the others' errors and none is skipped if a
|
|
1231
|
+
* peer fails.
|
|
1232
|
+
* 3. **Errors are collected and thrown AFTER everything dispatches.**
|
|
1233
|
+
* If one handler throws, remaining handlers for that event still
|
|
1234
|
+
* run, and remaining events in the batch still publish. Once
|
|
1235
|
+
* `publish` reaches the end of the batch it throws: the single
|
|
1236
|
+
* error directly if there was one, or an `AggregateError`
|
|
1237
|
+
* ("Multiple event handlers failed") containing every captured
|
|
1238
|
+
* error otherwise. Callers that need fail-fast semantics should
|
|
1239
|
+
* publish events one at a time and not rely on batch atomicity.
|
|
1240
|
+
*
|
|
1241
|
+
* The contract is intentionally simple and in-process. For
|
|
1242
|
+
* cross-process delivery (RabbitMQ, Kafka, etc.), use the `Outbox`
|
|
1243
|
+
* port and a dedicated dispatcher.
|
|
1244
|
+
*
|
|
1245
|
+
* @param events - Array of events to publish
|
|
1246
|
+
* @param options - Owner cancellation and publication timeout
|
|
1247
|
+
*/
|
|
1248
|
+
publish: (events: ReadonlyArray<Evt>, options?: PublishOptions) => Promise<void>;
|
|
1249
|
+
/**
|
|
1250
|
+
* Subscribes a handler to a specific event type.
|
|
1251
|
+
* Multiple handlers can subscribe to the same event type.
|
|
1252
|
+
*
|
|
1253
|
+
* @param eventType - The event type to subscribe to
|
|
1254
|
+
* @param handler - The handler function to call when events of this type are published
|
|
1255
|
+
* @returns A function to unsubscribe the handler
|
|
1256
|
+
*
|
|
1257
|
+
* @example
|
|
1258
|
+
* ```typescript
|
|
1259
|
+
* const unsubscribe = bus.subscribe("OrderCreated", async (event) => {
|
|
1260
|
+
* console.log("Order created:", event.payload.orderId);
|
|
1261
|
+
* });
|
|
1262
|
+
*
|
|
1263
|
+
* // Later: unsubscribe
|
|
1264
|
+
* unsubscribe();
|
|
1265
|
+
* ```
|
|
1266
|
+
*/
|
|
1267
|
+
subscribe: <K extends Evt["type"]>(eventType: K, handler: EventHandler<Extract<Evt, {
|
|
1268
|
+
type: K;
|
|
1269
|
+
}>>) => () => void;
|
|
1270
|
+
/**
|
|
1271
|
+
* Subscribes a handler to EVERY event type: the subscription for
|
|
1272
|
+
* cross-cutting consumers (audit log, metrics, dev logging,
|
|
1273
|
+
* forward-all) that would otherwise have to enumerate the union's
|
|
1274
|
+
* event types and silently miss every type added later.
|
|
1275
|
+
*
|
|
1276
|
+
* Catch-all handlers run in the SAME `Promise.allSettled` batch as
|
|
1277
|
+
* the event's typed handlers, so the publish contract is unchanged:
|
|
1278
|
+
* awaited delivery, no handler skipped when a peer fails, errors
|
|
1279
|
+
* collected and thrown after the batch, events in input order.
|
|
1280
|
+
*
|
|
1281
|
+
* Deliberately minimal: no predicate subscriptions (filter in your
|
|
1282
|
+
* handler; it is one line) and no glob/topic patterns (topic routing
|
|
1283
|
+
* belongs to broker sinks: Kafka topics, JetStream subjects).
|
|
1284
|
+
*
|
|
1285
|
+
* @param handler - Called with every published event, typed as the
|
|
1286
|
+
* full event union; narrow via `event.type` in the handler
|
|
1287
|
+
* @returns A function to unsubscribe the handler
|
|
1288
|
+
*
|
|
1289
|
+
* @example
|
|
1290
|
+
* ```typescript
|
|
1291
|
+
* const unsubscribe = bus.subscribeAll(async (event) => {
|
|
1292
|
+
* await auditLog.append(event.type, event.eventId, event.payload);
|
|
1293
|
+
* });
|
|
1294
|
+
* ```
|
|
1295
|
+
*/
|
|
1296
|
+
subscribeAll: (handler: EventHandler<Evt>) => () => void;
|
|
1297
|
+
/**
|
|
1298
|
+
* Subscribes to the next occurrence of an event type.
|
|
1299
|
+
* Returns a Promise that resolves with the event data.
|
|
1300
|
+
* Automatically unsubscribes after the first event.
|
|
1301
|
+
*
|
|
1302
|
+
* @param eventType - The event type to wait for
|
|
1303
|
+
* @returns A Promise that resolves with the event
|
|
1304
|
+
*
|
|
1305
|
+
* @example
|
|
1306
|
+
* ```typescript
|
|
1307
|
+
* const event = await bus.once("OrderCreated");
|
|
1308
|
+
* console.log("Order created:", event.payload.orderId);
|
|
1309
|
+
* ```
|
|
1310
|
+
*/
|
|
1311
|
+
once: <K extends Evt["type"]>(eventType: K, options?: OnceOptions) => Promise<Extract<Evt, {
|
|
1312
|
+
type: K;
|
|
1313
|
+
}>>;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Options for `EventBus.once()`. Both fields are optional; without them
|
|
1317
|
+
* `once()` waits forever (the historical behaviour).
|
|
1318
|
+
*/
|
|
1319
|
+
interface OnceOptions {
|
|
1320
|
+
/**
|
|
1321
|
+
* Aborts the wait. When `signal` fires, `once()` rejects with
|
|
1322
|
+
* `signal.reason` (or a generic abort error if none was supplied) and
|
|
1323
|
+
* the internal subscription is removed.
|
|
1324
|
+
*/
|
|
1325
|
+
signal?: AbortSignal;
|
|
1326
|
+
/**
|
|
1327
|
+
* Rejects with a timeout error after this many milliseconds if no event
|
|
1328
|
+
* has arrived. The internal subscription and timer are cleaned up
|
|
1329
|
+
* regardless of which path settles the promise.
|
|
1330
|
+
*/
|
|
1331
|
+
timeoutMs?: number;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Gap-proof position finalized by the event source at the persistence
|
|
1335
|
+
* boundary. It is deliberately separate from `DomainEvent`: these values
|
|
1336
|
+
* describe a stored commit, not the business fact itself.
|
|
1337
|
+
*/
|
|
1338
|
+
interface CommitPosition {
|
|
1339
|
+
/** Aggregate OCC version reached by this eventful commit. */
|
|
1340
|
+
readonly aggregateVersion: number;
|
|
1341
|
+
/** Zero-based event index inside this aggregate commit. */
|
|
1342
|
+
readonly commitSequence: number;
|
|
1343
|
+
/** Total number of events emitted by this aggregate commit. */
|
|
1344
|
+
readonly commitSize: number;
|
|
1345
|
+
/**
|
|
1346
|
+
* Aggregate version of the immediately preceding EVENTFUL commit for this
|
|
1347
|
+
* qualified aggregate source, or `null` when this is its first eventful
|
|
1348
|
+
* commit. State-only persistence is intentionally absent from this chain.
|
|
1349
|
+
*
|
|
1350
|
+
* The outbox/event-store adapter owns this value. It must read and advance
|
|
1351
|
+
* the source head atomically with inserting the committed event envelope;
|
|
1352
|
+
* application orchestration cannot derive it from the Unit of Work's OCC
|
|
1353
|
+
* receipt because state-only commits are intentionally absent here.
|
|
1354
|
+
*/
|
|
1355
|
+
readonly previousEventfulAggregateVersion: number | null;
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Commit information known by the application transaction before the outbox
|
|
1359
|
+
* source has linked this eventful commit to its predecessor.
|
|
1360
|
+
*/
|
|
1361
|
+
type EventCommitCandidatePosition = Omit<CommitPosition, "previousEventfulAggregateVersion">;
|
|
1362
|
+
/**
|
|
1363
|
+
* A bare domain event prepared for the transactional outbox. The outbox source
|
|
1364
|
+
* owns the predecessor link and turns this candidate into a
|
|
1365
|
+
* {@link CommittedDomainEvent} when it persists the record.
|
|
1366
|
+
*/
|
|
1367
|
+
interface EventCommitCandidate<Evt extends AnyDomainEvent> {
|
|
1368
|
+
readonly event: Evt;
|
|
1369
|
+
readonly source: AggregateAddress;
|
|
1370
|
+
readonly position: EventCommitCandidatePosition;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* A domain event enriched after persistence has established its source and
|
|
1374
|
+
* commit position. Outboxes and projectors consume this envelope; in-process
|
|
1375
|
+
* domain handlers continue to consume the bare {@link DomainEvent} value.
|
|
1376
|
+
*/
|
|
1377
|
+
interface CommittedDomainEvent<Evt extends AnyDomainEvent> {
|
|
1378
|
+
readonly event: Evt;
|
|
1379
|
+
readonly source: AggregateAddress;
|
|
1380
|
+
readonly position: CommitPosition;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* One pending event in the outbox plus the opaque id the implementation
|
|
1384
|
+
* needs to ack it via `markDispatched`. The library does not prescribe
|
|
1385
|
+
* what `dispatchId` looks like: an implementation can reuse the event's
|
|
1386
|
+
* own `eventId`, generate its own UUID, use the row's auto-increment
|
|
1387
|
+
* primary key, or whatever the storage layer prefers.
|
|
1388
|
+
*/
|
|
1389
|
+
interface OutboxRecord<Evt extends AnyDomainEvent> extends CommittedDomainEvent<Evt> {
|
|
1390
|
+
dispatchId: string;
|
|
1391
|
+
/**
|
|
1392
|
+
* Failed delivery attempts so far. Populated by implementations that
|
|
1393
|
+
* track dispatch failures (see {@link DispatchTrackingOutbox});
|
|
1394
|
+
* plain `Outbox` implementations may omit it.
|
|
1395
|
+
*/
|
|
1396
|
+
attempts?: number;
|
|
1397
|
+
}
|
|
1398
|
+
/** A record that exhausted its delivery attempts; see {@link DispatchTrackingOutbox.deadLetters}. */
|
|
1399
|
+
interface DeadLetterRecord<Evt extends AnyDomainEvent> extends CommittedDomainEvent<Evt> {
|
|
1400
|
+
dispatchId: string;
|
|
1401
|
+
/** Failed delivery attempts when the record was dead-lettered. */
|
|
1402
|
+
attempts: number;
|
|
1403
|
+
/** Human-readable rendering of the last delivery error, if recorded. */
|
|
1404
|
+
lastError?: string;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Write half of the transactional outbox: the only outbox capability the
|
|
1408
|
+
* write side (`withCommit`, `UnitOfWork`) depends on. Persisting the
|
|
1409
|
+
* events atomically with the aggregate state is the kit's guarantee;
|
|
1410
|
+
* DELIVERY is a separate, replaceable concern.
|
|
1411
|
+
*
|
|
1412
|
+
* Implement ONLY this interface to plug in an external delivery
|
|
1413
|
+
* solution: `add()` writes into that solution's outbox storage inside
|
|
1414
|
+
* the ambient transaction, and its own listener (polling or
|
|
1415
|
+
* WAL/CDC-based, such as a Debezium-style connector, a delivery
|
|
1416
|
+
* library, or a broker-native outbox) owns delivery entirely. The
|
|
1417
|
+
* kit-side poll surface ({@link Outbox}) is then never involved. See
|
|
1418
|
+
* the outbox guide, "External dispatchers".
|
|
1419
|
+
*/
|
|
1420
|
+
interface OutboxWriter<Evt extends AnyDomainEvent> {
|
|
1421
|
+
/**
|
|
1422
|
+
* Finalizes and persists event commit candidates. Called from inside
|
|
1423
|
+
* `withCommit`'s transactional callback, atomically with the aggregate
|
|
1424
|
+
* write.
|
|
1425
|
+
*
|
|
1426
|
+
* For every qualified aggregate source, the adapter must serialize source
|
|
1427
|
+
* advancement, read its last eventful aggregate version, write that value as
|
|
1428
|
+
* `previousEventfulAggregateVersion` on every event in the candidate's
|
|
1429
|
+
* commit, and advance the source head to `aggregateVersion` in the SAME
|
|
1430
|
+
* transaction. A state-only aggregate commit does not call `add()` and must
|
|
1431
|
+
* therefore not advance this event-source head.
|
|
1432
|
+
*
|
|
1433
|
+
* A qualified source position `(aggregateType, aggregateId,
|
|
1434
|
+
* aggregateVersion, commitSequence)` MUST identify one immutable event. All
|
|
1435
|
+
* candidates for the same aggregate commit MUST also agree on `commitSize`.
|
|
1436
|
+
* Enforce both constraints before advancing the source head; a conflicting
|
|
1437
|
+
* retry must reject without replacing the stored event or changing the head.
|
|
1438
|
+
*
|
|
1439
|
+
* **Idempotency:** implementations should dedupe on
|
|
1440
|
+
* `candidate.event.eventId`. `withCommit` itself does not retry, but the
|
|
1441
|
+
* surrounding use case (a queue consumer, an HTTP retry, a transactional
|
|
1442
|
+
* outbox-dispatcher loop) may legitimately invoke the same write more than
|
|
1443
|
+
* once. A unique-key constraint on `(eventId)` in the outbox table is the
|
|
1444
|
+
* standard implementation; the source-head update and dedupe decision must
|
|
1445
|
+
* share the transaction. Idempotency applies only to an exact candidate
|
|
1446
|
+
* retry: the same event ID, qualified source, aggregate version, commit
|
|
1447
|
+
* sequence, and commit size. Reusing an `eventId` for another source or
|
|
1448
|
+
* position is a caller bug: adapters that retain the conflicting record
|
|
1449
|
+
* should reject it rather than replace or silently reinterpret it as a retry.
|
|
1450
|
+
*/
|
|
1451
|
+
add: (events: ReadonlyArray<EventCommitCandidate<Evt>>) => Promise<void>;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Transactional outbox port: the bridge between the write-side
|
|
1455
|
+
* transaction and the (out-of-band) event dispatcher.
|
|
1456
|
+
*
|
|
1457
|
+
* Lifecycle:
|
|
1458
|
+
* 1. `add()` inside the write transaction (`withCommit` calls this) so
|
|
1459
|
+
* events persist atomically with the aggregate state
|
|
1460
|
+
* ({@link OutboxWriter}, the only part the write side needs).
|
|
1461
|
+
* 2. An outbox dispatcher (the kit's `OutboxDispatcher` or your own)
|
|
1462
|
+
* polls `getPending()` and forwards the events to subscribers /
|
|
1463
|
+
* external brokers.
|
|
1464
|
+
* 3. After successful dispatch, the dispatcher calls `markDispatched()`
|
|
1465
|
+
* with the records' `dispatchId`s so they don't come back next poll.
|
|
1466
|
+
*
|
|
1467
|
+
* `markDispatched` is required to be idempotent: calling it with an id
|
|
1468
|
+
* that's already marked is a no-op, not an error. This lets the
|
|
1469
|
+
* dispatcher safely retry on partial-failure.
|
|
1470
|
+
*
|
|
1471
|
+
* **Competing dispatcher instances** are an adapter contract, not a
|
|
1472
|
+
* dispatcher feature: a transactional implementation that should
|
|
1473
|
+
* support several concurrent pollers must make `getPending` claim the
|
|
1474
|
+
* returned records (`FOR UPDATE SKIP LOCKED` or equivalent). Without
|
|
1475
|
+
* claiming, run one logical dispatcher per outbox.
|
|
1476
|
+
*
|
|
1477
|
+
* The bundled dispatcher supplies an {@link ExecutionContext} to every poll-side
|
|
1478
|
+
* operation. Production adapters MUST pass its signal to native I/O or enforce
|
|
1479
|
+
* a native timeout no later than `deadlineAt`; the shell can bound its wait but
|
|
1480
|
+
* cannot terminate a promise that ignores cancellation. A timed-out write has
|
|
1481
|
+
* an unknown outcome. Acknowledgements must remain idempotent when they complete
|
|
1482
|
+
* late; a late failure update may count its original delivery attempt and must
|
|
1483
|
+
* still no-op after the record was dispatched.
|
|
1484
|
+
*/
|
|
1485
|
+
interface Outbox<Evt extends AnyDomainEvent> extends OutboxWriter<Evt> {
|
|
1486
|
+
/**
|
|
1487
|
+
* Returns up to `limit` outbox records that have not yet been
|
|
1488
|
+
* dispatched, **in the order `add()` persisted them** (commit order).
|
|
1489
|
+
* The ordering is part of the port contract: `withCommit` promises
|
|
1490
|
+
* subscribers per-aggregate causal order, and a sequential dispatcher
|
|
1491
|
+
* can only honor that promise when this read is ordered. SQL-backed
|
|
1492
|
+
* implementations need a monotonic position column (an auto-increment
|
|
1493
|
+
* primary key works) and an `ORDER BY` on it; a bare `SELECT` returns
|
|
1494
|
+
* rows in storage order, not insertion order. The dispatcher polls
|
|
1495
|
+
* this on a schedule. When `limit` is omitted, the implementation
|
|
1496
|
+
* decides on a default page size. The bundled dispatcher always supplies
|
|
1497
|
+
* `context`; it is optional only so existing adapters remain assignable.
|
|
1498
|
+
*/
|
|
1499
|
+
getPending: (limit?: number, context?: ExecutionContext) => Promise<ReadonlyArray<OutboxRecord<Evt>>>;
|
|
1500
|
+
/**
|
|
1501
|
+
* Marks the given dispatch records as delivered so subsequent
|
|
1502
|
+
* `getPending` calls don't return them. Must be idempotent on
|
|
1503
|
+
* already-marked ids, including a late completion after the caller's
|
|
1504
|
+
* storage deadline. The bundled dispatcher always supplies `context`.
|
|
1505
|
+
*/
|
|
1506
|
+
markDispatched: (dispatchIds: ReadonlyArray<string>, context?: ExecutionContext) => Promise<void>;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Optional extension of {@link Outbox} for dispatchers that track
|
|
1510
|
+
* delivery failures. Without failure tracking, a poison message (an
|
|
1511
|
+
* event whose delivery always throws) is redelivered forever: it comes
|
|
1512
|
+
* back from every `getPending` poll, blocks per-aggregate ordering
|
|
1513
|
+
* behind it, and burns the dispatcher's cycles. This extension gives
|
|
1514
|
+
* the dispatcher a bounded-retry story: report each failed delivery via
|
|
1515
|
+
* {@link markFailed}; the implementation moves records past its
|
|
1516
|
+
* attempt ceiling to a dead-letter set that `getPending` no longer
|
|
1517
|
+
* returns, and {@link deadLetters} exposes them for alerting, manual
|
|
1518
|
+
* inspection, and redelivery (deliver by hand, then ack via
|
|
1519
|
+
* `markDispatched`, which also clears dead-lettered records).
|
|
1520
|
+
*
|
|
1521
|
+
* See the outbox guide's dispatcher recipe for the retry-then-dead-letter
|
|
1522
|
+
* loop this port shape supports.
|
|
1523
|
+
*/
|
|
1524
|
+
interface DispatchTrackingOutbox<Evt extends AnyDomainEvent> extends Outbox<Evt> {
|
|
1525
|
+
/**
|
|
1526
|
+
* Records one failed delivery attempt for the given record:
|
|
1527
|
+
* increments its attempt count (surfaced as
|
|
1528
|
+
* {@link OutboxRecord.attempts}) and, once the implementation's
|
|
1529
|
+
* ceiling is reached, moves the record to the dead-letter set.
|
|
1530
|
+
* A no-op for unknown or already-dispatched ids (a late failure
|
|
1531
|
+
* report after a successful retry must not resurrect the record).
|
|
1532
|
+
* Returns the exact dead-letter record only on the call that performs
|
|
1533
|
+
* that transition; retries below the ceiling and no-ops return
|
|
1534
|
+
* `undefined`. This lets productive pollers emit an immediate signal
|
|
1535
|
+
* without scanning the durable dead-letter set after every failure. A late
|
|
1536
|
+
* completion may count that original delivery attempt; it must still no-op if
|
|
1537
|
+
* the record was dispatched in the meantime. The bundled dispatcher never
|
|
1538
|
+
* reissues the same store call and always supplies `context`.
|
|
1539
|
+
*/
|
|
1540
|
+
markFailed: (dispatchId: string, error?: unknown, context?: ExecutionContext) => Promise<DeadLetterRecord<Evt> | undefined>;
|
|
1541
|
+
/**
|
|
1542
|
+
* Records that exhausted their delivery attempts. They no longer
|
|
1543
|
+
* come back from `getPending`; wire this to durable alerting and
|
|
1544
|
+
* reconciliation so poison messages surface even if the poller stops
|
|
1545
|
+
* between this store transition and its immediate observer callback.
|
|
1546
|
+
*/
|
|
1547
|
+
deadLetters: () => Promise<ReadonlyArray<DeadLetterRecord<Evt>>>;
|
|
1548
|
+
}
|
|
1549
|
+
//#endregion
|
|
1550
|
+
//#region src/app/command-outbox.d.ts
|
|
1551
|
+
/**
|
|
1552
|
+
* Business relationships and technical trace context selected explicitly for
|
|
1553
|
+
* an outgoing command. Correlation/conversation explain the business flow;
|
|
1554
|
+
* W3C Trace Context connects technical spans.
|
|
1555
|
+
*/
|
|
1556
|
+
interface CommandMessageRelationships {
|
|
1557
|
+
/** Groups messages that belong to one operation or trace. */
|
|
1558
|
+
readonly correlationId?: string;
|
|
1559
|
+
/** Groups every message in one long-running business interaction. */
|
|
1560
|
+
readonly conversationId?: string;
|
|
1561
|
+
/** W3C Trace Context parent for technical distributed tracing. */
|
|
1562
|
+
readonly traceparent?: string;
|
|
1563
|
+
/** Optional vendor trace state associated with `traceparent`. */
|
|
1564
|
+
readonly tracestate?: string;
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Application-owned Published Language produced from one private domain or
|
|
1568
|
+
* process event. `destination` names one receiver contract; it is deliberately
|
|
1569
|
+
* required because a command is an instruction, not a broadcast fact.
|
|
1570
|
+
*
|
|
1571
|
+
* The command carries a stable schema `version` and JSON-safe `payload`.
|
|
1572
|
+
* Domain value objects are translated to wire DTOs by the mapper before this
|
|
1573
|
+
* boundary.
|
|
1574
|
+
*/
|
|
1575
|
+
interface CommandMessageContent<C extends PublishedCommand> extends CommandMessageRelationships {
|
|
1576
|
+
readonly destination: string;
|
|
1577
|
+
readonly command: C;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Immutable, JSON-safe command envelope stored for later at-least-once
|
|
1581
|
+
* delivery.
|
|
1582
|
+
*
|
|
1583
|
+
* `causationId` always identifies the private event whose accepted decision
|
|
1584
|
+
* requested this command. The mapper cannot replace it with a weaker
|
|
1585
|
+
* correlation. Consumer-produced events should in turn use `messageId` as
|
|
1586
|
+
* their causation id.
|
|
1587
|
+
*/
|
|
1588
|
+
interface DurableCommandMessage<C extends PublishedCommand> extends CommandMessageContent<C> {
|
|
1589
|
+
readonly messageId: string;
|
|
1590
|
+
readonly recordedAt: string;
|
|
1591
|
+
readonly causationId: string;
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Receipt for the private event that requested one command batch. It retains
|
|
1595
|
+
* commit identity and ordering without putting the private event or its
|
|
1596
|
+
* payload into the command outbox.
|
|
1597
|
+
*/
|
|
1598
|
+
interface CommandCommitOriginCandidate {
|
|
1599
|
+
readonly eventId: string;
|
|
1600
|
+
readonly source: AggregateAddress;
|
|
1601
|
+
readonly position: EventCommitCandidatePosition;
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* One private process-event commit and the exact commands it requested.
|
|
1605
|
+
* `messages` may be empty: the receipt still advances the originating source
|
|
1606
|
+
* and makes an exact retry distinguishable from a missing commit.
|
|
1607
|
+
*/
|
|
1608
|
+
interface CommandOutboxCommitCandidate<C extends PublishedCommand> {
|
|
1609
|
+
readonly origin: CommandCommitOriginCandidate;
|
|
1610
|
+
readonly messages: ReadonlyArray<DurableCommandMessage<C>>;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Write port for a dedicated transactional command outbox.
|
|
1614
|
+
*
|
|
1615
|
+
* The adapter is bound to the same ambient transaction as the aggregate or
|
|
1616
|
+
* event-stream repository. It must persist the complete input atomically,
|
|
1617
|
+
* retain input order, deduplicate exact retries by `origin.eventId`, and reject
|
|
1618
|
+
* a reused origin id whose source, position, or messages differ. It also owns
|
|
1619
|
+
* the durable source cursor represented by `origin.position`; an empty command
|
|
1620
|
+
* batch still advances that cursor.
|
|
1621
|
+
*
|
|
1622
|
+
* Delivery is out of band and at least once. A consumer therefore uses
|
|
1623
|
+
* `message.messageId` as its idempotency key and acknowledges only after the
|
|
1624
|
+
* command result has been stored.
|
|
1625
|
+
*/
|
|
1626
|
+
interface CommandOutboxWriter<C extends PublishedCommand> {
|
|
1627
|
+
add(commits: ReadonlyArray<CommandOutboxCommitCandidate<C>>): Promise<void>;
|
|
1628
|
+
}
|
|
1629
|
+
/** Maps one private accepted event to zero or more addressed commands. */
|
|
1630
|
+
type CommandOutboxMapper<Evt extends AnyDomainEvent, C extends PublishedCommand> = (event: Evt) => ReadonlyArray<CommandMessageContent<C>>;
|
|
1631
|
+
/**
|
|
1632
|
+
* Adapts a dedicated command outbox to the event-candidate write port consumed
|
|
1633
|
+
* by `withCommit`.
|
|
1634
|
+
*
|
|
1635
|
+
* Mapping happens inside the transaction, before the command outbox write.
|
|
1636
|
+
* The private event is used only at this boundary and is reduced to an origin
|
|
1637
|
+
* receipt. The helper never publishes it or copies its payload implicitly; the
|
|
1638
|
+
* application mapper selects and translates the data that belongs in the
|
|
1639
|
+
* versioned Published Language. The route rejects values JSON would lose or
|
|
1640
|
+
* change before it calls the adapter.
|
|
1641
|
+
* Every command gets a stable id derived from the event id and its zero-based
|
|
1642
|
+
* order, so an exact transaction retry produces the same rows.
|
|
1643
|
+
*
|
|
1644
|
+
* Omit `withCommit`'s in-process `bus` for private process events. Participants
|
|
1645
|
+
* consume the durable command messages from their explicitly named
|
|
1646
|
+
* destinations, while event-stream replay only rebuilds process state.
|
|
1647
|
+
*/
|
|
1648
|
+
declare function routeEventsToCommandOutbox<C extends PublishedCommand, Evt extends AnyDomainEvent = AnyDomainEvent>(outbox: CommandOutboxWriter<C>, mapper: CommandOutboxMapper<Evt, C>): OutboxWriter<Evt>;
|
|
1649
|
+
//#endregion
|
|
1650
|
+
//#region src/repo/scope.d.ts
|
|
1651
|
+
/** Options passed to {@link TransactionScope.transactional}. */
|
|
1652
|
+
interface TransactionalOptions {
|
|
1653
|
+
/**
|
|
1654
|
+
* Cooperative-cancellation signal forwarded from `withCommit` /
|
|
1655
|
+
* `UnitOfWork.run`. The kit does not interrupt an in-flight query
|
|
1656
|
+
* itself: it pre-checks `aborted` before opening the transaction and
|
|
1657
|
+
* exposes the signal for the work callback to poll. A scope whose
|
|
1658
|
+
* driver supports cancellation (passing the signal to the query, an
|
|
1659
|
+
* interactive-transaction timeout) SHOULD honor it to abort work
|
|
1660
|
+
* already in progress; scopes that ignore it stay correct, just not
|
|
1661
|
+
* eagerly cancellable.
|
|
1662
|
+
*/
|
|
1663
|
+
readonly signal?: AbortSignal;
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Transaction-scope abstraction.
|
|
1667
|
+
*
|
|
1668
|
+
* Wraps a block of work so it runs inside the persistence layer's native
|
|
1669
|
+
* transaction (Postgres `BEGIN`/`COMMIT`, Mongo session, Drizzle / Prisma
|
|
1670
|
+
* `$transaction`, etc.). The block commits when the callback resolves
|
|
1671
|
+
* and rolls back if it throws.
|
|
1672
|
+
*
|
|
1673
|
+
* `TCtx` is the persistence layer's transaction handle: Drizzle's `tx`,
|
|
1674
|
+
* Prisma's `tx`, Mongo's session, etc. The scope opens the transaction
|
|
1675
|
+
* and passes the handle to `fn`; the use case binds its repositories to
|
|
1676
|
+
* that handle (typically by constructing a tx-scoped repo from the ctx).
|
|
1677
|
+
*
|
|
1678
|
+
* No default for `TCtx`: every implementor names their context type
|
|
1679
|
+
* explicitly. For genuinely context-free scopes (in-memory tests, naive
|
|
1680
|
+
* no-tx scopes) use `TransactionScope<undefined>`: that's a conscious
|
|
1681
|
+
* "there is nothing meaningful here" statement, not an accidental
|
|
1682
|
+
* `unknown` fallback.
|
|
1683
|
+
*
|
|
1684
|
+
* Intentionally minimal: the scope itself does no change tracking and
|
|
1685
|
+
* no commit-time flush. Those concerns live in the layers above: a
|
|
1686
|
+
* repository adapter derives its change set, `withCommit` orchestrates the
|
|
1687
|
+
* event lifecycle, and `UnitOfWork` owns tracking and atomic flush. See
|
|
1688
|
+
* "TransactionScope stays minimal; the Unit of Work lives above it" in
|
|
1689
|
+
* docs/guide/design-decisions.md.
|
|
1690
|
+
*
|
|
1691
|
+
* @example Drizzle implementation
|
|
1692
|
+
* ```typescript
|
|
1693
|
+
* class DrizzleScope implements TransactionScope<DrizzleTx> {
|
|
1694
|
+
* constructor(private db: DrizzleDb) {}
|
|
1695
|
+
* async transactional<T>(fn: (tx: DrizzleTx) => Promise<T>): Promise<T> {
|
|
1696
|
+
* return this.db.transaction((tx) => fn(tx));
|
|
1697
|
+
* }
|
|
1698
|
+
* }
|
|
1699
|
+
* ```
|
|
1700
|
+
*
|
|
1701
|
+
* @example Use site: bind repos to the live transaction
|
|
1702
|
+
* ```typescript
|
|
1703
|
+
* await scope.transactional(async (tx) => {
|
|
1704
|
+
* // Construct tx-bound repos from ctx (your factory / DI of choice)
|
|
1705
|
+
* const orderRepository = makeOrderRepository(tx);
|
|
1706
|
+
*
|
|
1707
|
+
* const order = await orderRepository.getById(orderId);
|
|
1708
|
+
* order.confirm();
|
|
1709
|
+
* orderRepository.update(order);
|
|
1710
|
+
* });
|
|
1711
|
+
* ```
|
|
1712
|
+
*
|
|
1713
|
+
* Repository contracts take the id or aggregate only: the tx handle
|
|
1714
|
+
* is wired into a concrete repository at construction time, not threaded
|
|
1715
|
+
* through every call. Different ORMs have different idioms for that
|
|
1716
|
+
* (constructor injection, factory functions, `withTx` chains); pick one
|
|
1717
|
+
* and keep it consistent.
|
|
1718
|
+
*/
|
|
1719
|
+
interface TransactionScope<TCtx> {
|
|
1720
|
+
transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
|
|
1721
|
+
}
|
|
1722
|
+
//#endregion
|
|
1723
|
+
//#region src/app/handler.d.ts
|
|
1724
|
+
/** Dependencies for {@link withCommit}. */
|
|
1725
|
+
interface WithCommitDeps<Evt extends AnyDomainEvent, TCtx> {
|
|
1726
|
+
/**
|
|
1727
|
+
* The write half of the outbox: `withCommit` only ever calls `add()`.
|
|
1728
|
+
* Pass a full `Outbox` for the kit's poll-based dispatch, or a bare
|
|
1729
|
+
* `OutboxWriter` backed by an external delivery solution.
|
|
1730
|
+
*
|
|
1731
|
+
* Required on purpose, while `bus` is optional: the bus is the
|
|
1732
|
+
* best-effort in-process fast path, the outbox is the delivery
|
|
1733
|
+
* guarantee. Running without delivery reliability is a decision, not
|
|
1734
|
+
* a default; make it explicit with
|
|
1735
|
+
* `outboxWriterAcceptingEventLoss()`.
|
|
1736
|
+
*/
|
|
1737
|
+
outbox: OutboxWriter<Evt>;
|
|
1738
|
+
bus?: EventBus<Evt>;
|
|
1739
|
+
scope: TransactionScope<TCtx>;
|
|
1740
|
+
/**
|
|
1741
|
+
* Observer for post-commit `bus.publish` failures. Called with the
|
|
1742
|
+
* error and the events that were published. Must not be relied on
|
|
1743
|
+
* for delivery: the outbox dispatcher is the reliable path.
|
|
1744
|
+
*/
|
|
1745
|
+
onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
|
|
1746
|
+
/**
|
|
1747
|
+
* Application-shell observer invoked for each successfully acknowledged
|
|
1748
|
+
* saved aggregate, after every commit record has completed its internal
|
|
1749
|
+
* acknowledgement attempt. Deleted aggregates do not trigger it. `version`
|
|
1750
|
+
* is the commit-time value captured before any observer runs. Observer
|
|
1751
|
+
* failures are reported through `onPersistError` and never turn an already
|
|
1752
|
+
* committed write into an apparent failure. The execution context carries
|
|
1753
|
+
* owner cancellation and the configured post-commit deadline.
|
|
1754
|
+
*/
|
|
1755
|
+
onPersisted?: (aggregate: IAggregateRoot<Id<string>, Evt>, version: Version, context: ExecutionContext) => void | Promise<void>;
|
|
1756
|
+
/**
|
|
1757
|
+
* Observer for post-commit persistence failures: either the internal
|
|
1758
|
+
* acknowledgement/disposal step or the application-shell `onPersisted`
|
|
1759
|
+
* observer. Called once per failure with the error and affected aggregate.
|
|
1760
|
+
* Symmetric with {@link onPublishError}: the
|
|
1761
|
+
* transaction has already committed, so the failure must NOT reject the
|
|
1762
|
+
* write; without this observer it would otherwise vanish silently. The
|
|
1763
|
+
* hook is an observer only: if it throws, its error is swallowed so the
|
|
1764
|
+
* post-commit invariant holds, and the loop continues the remaining
|
|
1765
|
+
* post-commit work.
|
|
1766
|
+
*/
|
|
1767
|
+
onPersistError?: (error: unknown, aggregate: IAggregateRoot<Id<string>, Evt>) => void;
|
|
1768
|
+
/**
|
|
1769
|
+
* Total time allotted to the complete post-commit application phase:
|
|
1770
|
+
* every application observer followed by in-process bus publication shares
|
|
1771
|
+
* one absolute deadline. Callbacks that have not started when the deadline is
|
|
1772
|
+
* reached are skipped and reported as timeouts. Defaults to `30000`ms.
|
|
1773
|
+
* Timing out or aborting these best-effort operations is reported through the
|
|
1774
|
+
* matching error observer and never rejects an already committed write.
|
|
1775
|
+
*/
|
|
1776
|
+
postCommitTimeoutMs?: number;
|
|
1777
|
+
/**
|
|
1778
|
+
* Cooperative-cancellation signal. If already aborted, `withCommit`
|
|
1779
|
+
* rejects with the signal's `reason` BEFORE opening the transaction.
|
|
1780
|
+
* Otherwise the signal is forwarded to `scope.transactional`, where a
|
|
1781
|
+
* cancellation-aware scope can abort an in-flight query. The kit does
|
|
1782
|
+
* not race the work promise: aborting does not kill a running query
|
|
1783
|
+
* unless the scope honors the signal.
|
|
1784
|
+
*/
|
|
1785
|
+
signal?: AbortSignal;
|
|
1786
|
+
}
|
|
1787
|
+
declare const aggregateCommitTokenBrand: unique symbol;
|
|
1788
|
+
/**
|
|
1789
|
+
* Opaque receipt that one aggregate was explicitly enrolled in the current
|
|
1790
|
+
* {@link withCommit} invocation. Tokens are minted only by the invocation's
|
|
1791
|
+
* {@link CommitEnrollment} capability and are bound to that invocation at
|
|
1792
|
+
* runtime; a forged token or one retained from an earlier call is rejected
|
|
1793
|
+
* inside the transaction.
|
|
1794
|
+
*/
|
|
1795
|
+
interface AggregateCommitToken<Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
1796
|
+
readonly [aggregateCommitTokenBrand]: Evt;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Invocation-scoped enrollment capability handed to a {@link withCommit}
|
|
1800
|
+
* callback. Call `enrollSaved` only for an aggregate participating in the
|
|
1801
|
+
* repository write, and return every resulting token in `commits`. Omitting
|
|
1802
|
+
* any token rejects the transaction: an enrolled write may not commit without
|
|
1803
|
+
* its event harvest and post-commit acknowledgement. Enrollable instances
|
|
1804
|
+
* must extend `AggregateRoot` or `EventSourcedAggregate`; structural
|
|
1805
|
+
* `IAggregateRoot` lookalikes have no internal lifecycle capability and fail
|
|
1806
|
+
* before commit.
|
|
1807
|
+
*/
|
|
1808
|
+
interface CommitEnrollment<Evt extends AnyDomainEvent> {
|
|
1809
|
+
enrollSaved(aggregate: IAggregateRoot<Id<string>, Evt>, options?: CommitEnrollmentOptions): AggregateCommitToken<Evt>;
|
|
1810
|
+
/**
|
|
1811
|
+
* Enroll an aggregate whose row is deleted by the current transaction.
|
|
1812
|
+
* Its events are harvested and discarded after commit, but the saved-only
|
|
1813
|
+
* application `onPersisted` observer is not called.
|
|
1814
|
+
*/
|
|
1815
|
+
enrollDeleted(aggregate: IAggregateRoot<Id<string>, Evt>, options?: CommitEnrollmentOptions): AggregateCommitToken<Evt>;
|
|
1816
|
+
}
|
|
1817
|
+
/** OCC baseline associated with one exact commit enrollment. */
|
|
1818
|
+
interface CommitEnrollmentOptions {
|
|
1819
|
+
/** Absent for a new aggregate; captured at load for update or removal. */
|
|
1820
|
+
readonly expectedVersion?: Version;
|
|
1821
|
+
}
|
|
1822
|
+
/** The resolved value of a {@link withCommit} work callback. */
|
|
1823
|
+
interface WithCommitWorkResult<Evt extends AnyDomainEvent, R> {
|
|
1824
|
+
result: R;
|
|
1825
|
+
/**
|
|
1826
|
+
* Commit tokens returned by the invocation's enrollment capability.
|
|
1827
|
+
* Every token minted during the callback must appear at least once.
|
|
1828
|
+
* Naked aggregates are intentionally not accepted: touching an aggregate
|
|
1829
|
+
* does not prove that its repository write participated in the transaction.
|
|
1830
|
+
*/
|
|
1831
|
+
commits: ReadonlyArray<AggregateCommitToken<Evt>>;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Helper for executing a write Use Case inside a transaction scope.
|
|
1835
|
+
*
|
|
1836
|
+
* The use-case callback receives an invocation-scoped enrollment capability
|
|
1837
|
+
* and returns opaque commit tokens for the repository writes that completed
|
|
1838
|
+
* in the transaction. `withCommit` owns the post-commit lifecycle (harvest,
|
|
1839
|
+
* outbox, mark-persisted, publish). A naked aggregate is not commit evidence:
|
|
1840
|
+
* merely touching or constructing one must never make it look persisted.
|
|
1841
|
+
*
|
|
1842
|
+
* **Trust boundary.** A token proves invocation-local enrollment, not that the
|
|
1843
|
+
* kit inspected a database write; a generic transaction helper cannot observe
|
|
1844
|
+
* adapter internals. Repository code must enroll only writes participating in
|
|
1845
|
+
* this transaction. `UnitOfWork` centralizes that rule in repository methods.
|
|
1846
|
+
* The opaque, scoped token prevents accidental aggregate smuggling and stale
|
|
1847
|
+
* reuse; it is not a security boundary against code that deliberately lies to
|
|
1848
|
+
* its own persistence capability.
|
|
1849
|
+
*
|
|
1850
|
+
* Order of operations:
|
|
1851
|
+
* 1. `fn(ctx, enrollment)` runs inside `scope.transactional(...)`; domain
|
|
1852
|
+
* mutations + repo writes happen here. After a repository write has
|
|
1853
|
+
* enrolled an aggregate, the callback includes that opaque token in its
|
|
1854
|
+
* `commits` result. Tokens are invocation-bound: forged or stale tokens
|
|
1855
|
+
* fail before harvest. `ctx` is whatever transaction handle the `scope`
|
|
1856
|
+
* exposes (Drizzle `tx`, Prisma `tx`, Mongo session, or `undefined` for
|
|
1857
|
+
* context-free scopes).
|
|
1858
|
+
* 2. **Still inside the transaction**, `withCommit` harvests every
|
|
1859
|
+
* aggregate's `pendingEvents` and writes them via `outbox.add` (so
|
|
1860
|
+
* events persist atomically with the state change). Skipped when no
|
|
1861
|
+
* events were recorded. Each bare domain event is composed into an
|
|
1862
|
+
* `EventCommitCandidate` carrying its aggregate source and the commit
|
|
1863
|
+
* facts known by the application. The outbox source atomically links
|
|
1864
|
+
* that candidate to the preceding eventful commit and persists the
|
|
1865
|
+
* resulting `CommittedDomainEvent`. The domain event itself is never
|
|
1866
|
+
* stamped or copied.
|
|
1867
|
+
*
|
|
1868
|
+
* **Harvest order.** Events are concatenated in the order
|
|
1869
|
+
* tokens appear in the returned `commits` array, then in
|
|
1870
|
+
* each aggregate's `pendingEvents` order (insertion order via
|
|
1871
|
+
* `apply` / `commit` / `addDomainEvent`). So tokens for `[a, b]`
|
|
1872
|
+
* with `a` emitting `[e1, e2]` and `b` emitting `[e3]` produces
|
|
1873
|
+
* `outbox.add([envelope(e1), envelope(e2), envelope(e3)])` and
|
|
1874
|
+
* `bus.publish([e1, e2, e3])` in that exact order.
|
|
1875
|
+
*
|
|
1876
|
+
* **Two ordering guarantees, not one.** Within a single aggregate
|
|
1877
|
+
* the order is *causal*: events are recorded in the order the
|
|
1878
|
+
* domain methods ran, and subscribers (handlers, projections,
|
|
1879
|
+
* replay) MUST process them in that order. Across aggregates the
|
|
1880
|
+
* order in this batch is deterministic but *not* a domain
|
|
1881
|
+
* guarantee. Greg Young / Vernon IDDD §10: aggregates are
|
|
1882
|
+
* independent consistency boundaries; events across them are
|
|
1883
|
+
* eventually consistent. Subscribers should NOT engineer
|
|
1884
|
+
* dependencies on cross-aggregate ordering; use
|
|
1885
|
+
* `EventMetadata.causationId` to express true causation, or a
|
|
1886
|
+
* process manager to coordinate. The in-process EventBus delivers
|
|
1887
|
+
* this batch in order, sequential outbox-dispatchers preserve it
|
|
1888
|
+
* too, but parallel dispatchers or message brokers may reorder
|
|
1889
|
+
* across aggregates at delivery time.
|
|
1890
|
+
* 3. The transaction commits.
|
|
1891
|
+
* 4. **After** the commit, a non-exported capability acknowledges every
|
|
1892
|
+
* saved enrollment and discards pending events for deleted enrollments.
|
|
1893
|
+
* Only after the complete commit set is clean does the optional
|
|
1894
|
+
* application-shell `onPersisted(aggregate, version, context)` observer run for
|
|
1895
|
+
* saved aggregates. Deleted rows never trigger that observer.
|
|
1896
|
+
* 5. `bus.publish(events)` fires for the in-process fast path (skipped
|
|
1897
|
+
* when no events or no `bus` is wired).
|
|
1898
|
+
*
|
|
1899
|
+
* Publishing AFTER commit prevents the classic "publish before commit"
|
|
1900
|
+
* footgun: in-process subscribers can never react to events from a
|
|
1901
|
+
* transaction that later rolled back. If `bus.publish` itself throws, the
|
|
1902
|
+
* outbox still holds the events and an outbox-dispatcher will deliver
|
|
1903
|
+
* them (eventual consistency).
|
|
1904
|
+
*
|
|
1905
|
+
* **A `bus.publish` failure never rejects `withCommit`.** Once the
|
|
1906
|
+
* transaction has committed, the write succeeded; surfacing a subscriber
|
|
1907
|
+
* failure as a rejection would hand the caller a use-case failure for a
|
|
1908
|
+
* committed write (a typical caller retries, double-executing it). The
|
|
1909
|
+
* in-process fast path is best-effort by design; the error is reported to
|
|
1910
|
+
* the optional `onPublishError(error, events)` hook (wire it to your
|
|
1911
|
+
* logger/metrics) and otherwise dropped; delivery is still guaranteed via
|
|
1912
|
+
* the outbox. The hook is an observer: if it throws, its error is
|
|
1913
|
+
* swallowed so the post-commit invariant holds.
|
|
1914
|
+
* The complete application-observer and bus-publication phase shares one
|
|
1915
|
+
* absolute `postCommitTimeoutMs` budget (30 seconds by default); later callbacks
|
|
1916
|
+
* are not started once it expires. A timeout or owner abort is reported
|
|
1917
|
+
* through the same observer paths and never changes the committed result.
|
|
1918
|
+
*
|
|
1919
|
+
* If the transaction rolls back, no acknowledgement occurs: the aggregate
|
|
1920
|
+
* keeps its pending events, so the caller can retry or discard the instance.
|
|
1921
|
+
*
|
|
1922
|
+
* Enrollment captures an exact version and event batch. Re-enrolling the same
|
|
1923
|
+
* aggregate after it changes rejects. `UnitOfWork` additionally seals the
|
|
1924
|
+
* adapter persistence projection and rejects later mutation before flush. For
|
|
1925
|
+
* direct `withCommit` use, make domain decisions first, write, and enroll last.
|
|
1926
|
+
*
|
|
1927
|
+
* **Duplicate enrollment is idempotent by reference.** Enrolling the same
|
|
1928
|
+
* instance repeatedly returns the same token, and a repeated token in
|
|
1929
|
+
* `commits` is harvested once. A repeat call that omits `expectedVersion`
|
|
1930
|
+
* makes no OCC assertion; only a supplied value that contradicts the
|
|
1931
|
+
* enrollment-time baseline rejects. Each event lands in the outbox exactly once
|
|
1932
|
+
* and post-commit acknowledgement runs exactly once. Two
|
|
1933
|
+
* *different* instances with the same logical id cannot be detected
|
|
1934
|
+
* at this layer; that is a Repository contract violation (failure to
|
|
1935
|
+
* maintain Fowler's Identity Map per Unit of Work). See
|
|
1936
|
+
* `docs/guide/repository.md` → "Identity Map: one instance per
|
|
1937
|
+
* aggregate per Unit of Work" for the requirement on repository
|
|
1938
|
+
* implementations that makes this dedupe sound.
|
|
1939
|
+
*
|
|
1940
|
+
* @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
|
|
1941
|
+
* ```typescript
|
|
1942
|
+
* const result = await withCommit({ outbox, bus, scope }, async (tx, enrollment) => {
|
|
1943
|
+
* const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
|
|
1944
|
+
* const order = await orderRepository.getById(orderId);
|
|
1945
|
+
* order.confirm();
|
|
1946
|
+
* await persistOrder(tx, order); // low-level adapter write
|
|
1947
|
+
* const commit = enrollment.enrollSaved(order); // attest the repository write
|
|
1948
|
+
* return { result: order.id, commits: [commit] };
|
|
1949
|
+
* });
|
|
1950
|
+
* ```
|
|
1951
|
+
*/
|
|
1952
|
+
declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: WithCommitDeps<Evt, TCtx>, fn: (ctx: TCtx, enrollment: CommitEnrollment<Evt>) => Promise<WithCommitWorkResult<Evt, R>>): Promise<R>;
|
|
1953
|
+
//#endregion
|
|
1954
|
+
//#region src/app/idempotency.d.ts
|
|
1955
|
+
/**
|
|
1956
|
+
* Result of `IdempotencyStore.claim()`: this execution owns the key and must
|
|
1957
|
+
* run the command (`claimed`), a previous execution completed and its outcome
|
|
1958
|
+
* is replayed (`completed`), or an expired staged outcome needs evidence from
|
|
1959
|
+
* the authoritative write model (`reconciliation-required`).
|
|
1960
|
+
*
|
|
1961
|
+
* The two FAILURE answers are thrown, not returned, following the kit's
|
|
1962
|
+
* error posture: a concurrent unfinished execution throws
|
|
1963
|
+
* `IdempotencyInFlightError` (retryable), and the same key arriving
|
|
1964
|
+
* with a different fingerprint throws `IdempotencyKeyReuseError`
|
|
1965
|
+
* (not retryable).
|
|
1966
|
+
*/
|
|
1967
|
+
interface IdempotencyLease {
|
|
1968
|
+
/** Adapter-clock expiry as a canonical ISO-8601 timestamp. */
|
|
1969
|
+
readonly expiresAt: string;
|
|
1970
|
+
/** Delay after which the wrapper should renew this lease. */
|
|
1971
|
+
readonly renewAfterMs: number;
|
|
1972
|
+
}
|
|
1973
|
+
/** Store-minted ownership receipt for one successful claim. */
|
|
1974
|
+
interface IdempotencyClaimHandle {
|
|
1975
|
+
readonly key: string;
|
|
1976
|
+
/** Unique across ownership generations for this key; treat as opaque. */
|
|
1977
|
+
readonly token: string;
|
|
1978
|
+
/** Absent for a transactional store; required for a leased store. */
|
|
1979
|
+
readonly lease?: IdempotencyLease;
|
|
1980
|
+
}
|
|
1981
|
+
/** Receipt for an expired staged outcome that needs authoritative evidence. */
|
|
1982
|
+
interface IdempotencyReconciliation {
|
|
1983
|
+
readonly key: string;
|
|
1984
|
+
readonly fingerprint: string;
|
|
1985
|
+
readonly token: string;
|
|
1986
|
+
readonly expiredAt: string;
|
|
1987
|
+
}
|
|
1988
|
+
type IdempotencyReconciliationDecision = "committed" | "not-committed" | "unknown";
|
|
1989
|
+
type IdempotencyClaim = {
|
|
1990
|
+
readonly status: "claimed";
|
|
1991
|
+
readonly claim: IdempotencyClaimHandle;
|
|
1992
|
+
} | {
|
|
1993
|
+
readonly status: "completed";
|
|
1994
|
+
readonly outcome: unknown;
|
|
1995
|
+
} | {
|
|
1996
|
+
readonly status: "reconciliation-required";
|
|
1997
|
+
readonly reconciliation: IdempotencyReconciliation;
|
|
1998
|
+
};
|
|
1999
|
+
/**
|
|
2000
|
+
* Driven port for command idempotency and message-inbox deduplication.
|
|
2001
|
+
*
|
|
2002
|
+
* The store keeps one record per idempotency key: the key, a
|
|
2003
|
+
* fingerprint of the command that first claimed it, and, once the
|
|
2004
|
+
* execution completed, the stored outcome. The intended integration is
|
|
2005
|
+
* the SINGLE-TRANSACTION pattern via {@link withIdempotentCommit}: the
|
|
2006
|
+
* record is written in the same transaction as the aggregate and the
|
|
2007
|
+
* outbox, so a rollback releases the claim and there is no crash window
|
|
2008
|
+
* between claim and commit.
|
|
2009
|
+
*
|
|
2010
|
+
* Adapter contract (mirror of the repository/event-store delegation
|
|
2011
|
+
* model): the adapter maps its store's native signals onto the kit's
|
|
2012
|
+
* errors instead of leaking driver errors:
|
|
2013
|
+
*
|
|
2014
|
+
* - unique-constraint conflict from a CONCURRENT uncommitted claim ->
|
|
2015
|
+
* `IdempotencyInFlightError` (retryable; a retry replays the outcome
|
|
2016
|
+
* or claims fresh),
|
|
2017
|
+
* - existing COMPLETED record with the same fingerprint -> return
|
|
2018
|
+
* `{ status: "completed", outcome }`,
|
|
2019
|
+
* - existing record with a DIFFERENT fingerprint ->
|
|
2020
|
+
* `IdempotencyKeyReuseError`.
|
|
2021
|
+
*
|
|
2022
|
+
* **Transactional vs leased non-transactional stores.** A transactional
|
|
2023
|
+
* adapter (the record lives in the same database as the aggregate)
|
|
2024
|
+
* gets the commit boundary for free: `complete` is atomic with the
|
|
2025
|
+
* command's commit, a rollback releases everything, and `confirm` /
|
|
2026
|
+
* `abandon` / `renew` / `reconcile` are no-ops. This remains the recommended
|
|
2027
|
+
* production pattern and the only family that proves atomic command effect +
|
|
2028
|
+
* idempotency completion without reconciliation.
|
|
2029
|
+
*
|
|
2030
|
+
* A NON-transactional store (the in-memory reference, a separate durable
|
|
2031
|
+
* store) cannot see commits or rollbacks. Every fresh claim therefore returns
|
|
2032
|
+
* a store-minted token and bounded lease. The wrapper renews it while the
|
|
2033
|
+
* transaction runs; `complete`, `renew`, `confirm`, `abandon`, and `reconcile`
|
|
2034
|
+
* compare the token so a stale owner cannot mutate a successor claim. An
|
|
2035
|
+
* expired PENDING claim may be replaced. An expired STAGED outcome is never
|
|
2036
|
+
* replayed or released automatically: `claim` returns
|
|
2037
|
+
* `reconciliation-required`, and the application must consult the source of
|
|
2038
|
+
* truth. `unknown` keeps it blocked.
|
|
2039
|
+
*
|
|
2040
|
+
* A lease is coordination, not a security or exactly-once boundary. To return
|
|
2041
|
+
* `not-committed` safely, the source transaction must persist an idempotency
|
|
2042
|
+
* key or claim token (available as the callback's `execution` argument), or
|
|
2043
|
+
* offer equivalent durable fencing proving the old transaction cannot still
|
|
2044
|
+
* commit. Without that evidence, return `unknown`. A database row merely being
|
|
2045
|
+
* absent while an old transaction may still be in flight is not proof.
|
|
2046
|
+
* A takeover can overlap briefly with the stale worker, so `fn` must keep
|
|
2047
|
+
* irreversible external side effects out of the transaction. Persist an
|
|
2048
|
+
* outbox record and deliver after commit; token fencing can stop the stale
|
|
2049
|
+
* database commit, but it cannot undo an HTTP call already sent.
|
|
2050
|
+
*
|
|
2051
|
+
* The same store doubles as a message INBOX: use the message id as the
|
|
2052
|
+
* key and a constant fingerprint; a duplicate delivery replays the
|
|
2053
|
+
* stored (possibly `undefined`) outcome instead of re-running the
|
|
2054
|
+
* handler.
|
|
2055
|
+
*
|
|
2056
|
+
* The stored outcome must be PLAIN, serialisable data (the same
|
|
2057
|
+
* discipline as snapshots and event payloads): the record round-trips
|
|
2058
|
+
* through the adapter's storage, so class instances would silently lose
|
|
2059
|
+
* their prototype.
|
|
2060
|
+
*
|
|
2061
|
+
* @template TCtx - The transaction context the surrounding scope
|
|
2062
|
+
* exposes (Drizzle `tx`, Prisma `tx`, `undefined` for context-free
|
|
2063
|
+
* scopes). `claim` and `complete` run inside that transaction.
|
|
2064
|
+
*/
|
|
2065
|
+
interface IdempotencyStore<TCtx = unknown> {
|
|
2066
|
+
/**
|
|
2067
|
+
* Claims the key for this execution, atomically with respect to
|
|
2068
|
+
* concurrent claimers (`INSERT ... ON CONFLICT` or equivalent).
|
|
2069
|
+
* Returns `claimed` when this execution owns the key, or
|
|
2070
|
+
* `completed` with the stored outcome when a previous execution
|
|
2071
|
+
* already finished under the same key and fingerprint. Throws
|
|
2072
|
+
* `IdempotencyInFlightError` / `IdempotencyKeyReuseError` for the
|
|
2073
|
+
* failure answers (see the port docs). A live staged outcome is in-flight;
|
|
2074
|
+
* after its lease expires it returns `reconciliation-required`, never a
|
|
2075
|
+
* replay or fresh claim.
|
|
2076
|
+
*/
|
|
2077
|
+
claim(ctx: TCtx, key: string, fingerprint: string): Promise<IdempotencyClaim>;
|
|
2078
|
+
/**
|
|
2079
|
+
* Stores the outcome for a key this execution claimed, in the same
|
|
2080
|
+
* transaction as the command's writes. On a transactional store the
|
|
2081
|
+
* commit makes it durable and replayable; on a non-transactional
|
|
2082
|
+
* store the outcome is only STAGED until {@link confirm} runs.
|
|
2083
|
+
* Throws `IdempotencyCompletionWithoutClaimError` when no claim exists, and
|
|
2084
|
+
* `IdempotencyClaimLostError` when the receipt is stale, already settled, or
|
|
2085
|
+
* expired. A stale completion must fail before the source transaction can
|
|
2086
|
+
* commit.
|
|
2087
|
+
*/
|
|
2088
|
+
complete(ctx: TCtx, claim: IdempotencyClaimHandle, outcome: unknown): Promise<void>;
|
|
2089
|
+
/**
|
|
2090
|
+
* Extends a non-transactional claim's lease and returns its new timing.
|
|
2091
|
+
* The update is compare-and-set on key + token. A transactional adapter
|
|
2092
|
+
* implements this as a no-op returning `undefined`; the wrapper never calls
|
|
2093
|
+
* it for a claim without a lease.
|
|
2094
|
+
*/
|
|
2095
|
+
renew(claim: IdempotencyClaimHandle): Promise<IdempotencyLease | undefined>;
|
|
2096
|
+
/**
|
|
2097
|
+
* Finalizes a staged outcome AFTER the surrounding transaction
|
|
2098
|
+
* committed. Called by {@link withIdempotentCommit} post-commit on
|
|
2099
|
+
* every fresh execution. A transactional adapter implements this as
|
|
2100
|
+
* a no-op (the commit already finalized the record). Idempotent:
|
|
2101
|
+
* confirming an already-confirmed receipt is a no-op. A missing or stale
|
|
2102
|
+
* receipt is also a no-op and must never confirm its successor.
|
|
2103
|
+
*/
|
|
2104
|
+
confirm(claim: IdempotencyClaimHandle): Promise<void>;
|
|
2105
|
+
/**
|
|
2106
|
+
* Releases a claim whose attempt did not commit: a pending claim or
|
|
2107
|
+
* a staged, unconfirmed outcome. Called by
|
|
2108
|
+
* {@link withIdempotentCommit} once per failed attempt, best-effort.
|
|
2109
|
+
* A transactional adapter implements this as a no-op: the rollback
|
|
2110
|
+
* already removed the row, and the method must be SAFE to call when
|
|
2111
|
+
* the commit outcome is unknown; it never releases a confirmed
|
|
2112
|
+
* record. A stale receipt is a no-op and must never release its successor.
|
|
2113
|
+
*/
|
|
2114
|
+
abandon(claim: IdempotencyClaimHandle): Promise<void>;
|
|
2115
|
+
/**
|
|
2116
|
+
* Resolves an EXPIRED staged outcome after the application consulted its
|
|
2117
|
+
* authoritative write model. `committed` makes the staged result replayable;
|
|
2118
|
+
* `not-committed` releases it for a fresh execution. `unknown` is
|
|
2119
|
+
* intentionally not accepted here: uncertainty must preserve the record.
|
|
2120
|
+
* The receipt is compare-and-set so a stale reconciler cannot settle a newer
|
|
2121
|
+
* owner. Transactional adapters implement this as a no-op because they never
|
|
2122
|
+
* return `reconciliation-required`.
|
|
2123
|
+
*/
|
|
2124
|
+
reconcile(reconciliation: IdempotencyReconciliation, decision: Exclude<IdempotencyReconciliationDecision, "unknown">): Promise<void>;
|
|
2125
|
+
}
|
|
2126
|
+
/** Identifies one logical command execution for {@link withIdempotentCommit}. */
|
|
2127
|
+
interface IdempotentCommitRequest {
|
|
2128
|
+
/**
|
|
2129
|
+
* The idempotency key: client-supplied header, message id, or a key
|
|
2130
|
+
* derived from actor + intention. One key names one logical command.
|
|
2131
|
+
*/
|
|
2132
|
+
readonly key: string;
|
|
2133
|
+
/**
|
|
2134
|
+
* Fingerprint of the command's content (a hash or canonical string
|
|
2135
|
+
* of the request payload). Detects the same key being reused for a
|
|
2136
|
+
* DIFFERENT command, which is rejected instead of replayed.
|
|
2137
|
+
*/
|
|
2138
|
+
readonly fingerprint: string;
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Outcome of {@link withIdempotentCommit}: `replayed: false` carries the
|
|
2142
|
+
* fresh result of this execution; `replayed: true` carries the stored
|
|
2143
|
+
* outcome of the previous execution with the same key and fingerprint.
|
|
2144
|
+
* The replayed value is typed `R` on the strength of the fingerprint
|
|
2145
|
+
* match: the same command was executed, so the stored outcome has the
|
|
2146
|
+
* shape this command produces, provided the adapter round-trips plain
|
|
2147
|
+
* data faithfully.
|
|
2148
|
+
*/
|
|
2149
|
+
interface IdempotentCommitResult<R> {
|
|
2150
|
+
readonly replayed: boolean;
|
|
2151
|
+
readonly result: R;
|
|
2152
|
+
}
|
|
2153
|
+
/** Claim identity visible to work that persists a source-of-truth marker. */
|
|
2154
|
+
interface IdempotentExecution extends IdempotentCommitRequest {
|
|
2155
|
+
readonly claimToken: string;
|
|
2156
|
+
}
|
|
2157
|
+
interface IdempotencyOperationErrorContext {
|
|
2158
|
+
readonly operation: "abandon" | "confirm" | "renew";
|
|
2159
|
+
readonly key: string;
|
|
2160
|
+
readonly token: string;
|
|
2161
|
+
}
|
|
2162
|
+
interface WithIdempotentCommitDeps<Evt extends AnyDomainEvent, TCtx> extends WithCommitDeps<Evt, TCtx> {
|
|
2163
|
+
idempotency: IdempotencyStore<TCtx>;
|
|
2164
|
+
/**
|
|
2165
|
+
* Source-of-truth decision for an expired staged outcome. The callback must
|
|
2166
|
+
* return `committed` only when the command effect is durably visible, and
|
|
2167
|
+
* `not-committed` only when a durable marker proves the attempt cannot still
|
|
2168
|
+
* commit. `unknown` keeps the key blocked.
|
|
2169
|
+
*/
|
|
2170
|
+
reconcileIdempotency?: (reconciliation: IdempotencyReconciliation, ctx: TCtx) => Promise<IdempotencyReconciliationDecision>;
|
|
2171
|
+
/**
|
|
2172
|
+
* Observer for best-effort post-commit confirm, rollback abandon, and a
|
|
2173
|
+
* secondary heartbeat failure masked by the primary work error.
|
|
2174
|
+
*/
|
|
2175
|
+
onIdempotencyError?: (error: unknown, context: IdempotencyOperationErrorContext) => void;
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* {@link withCommit} with command idempotency: the duplicate-safe write
|
|
2179
|
+
* path for retryable deliveries (client retries, at-least-once
|
|
2180
|
+
* messages, scheduler re-runs).
|
|
2181
|
+
*
|
|
2182
|
+
* Order of operations:
|
|
2183
|
+
* 1. Inside the transaction, `store.claim(ctx, key, fingerprint)` runs
|
|
2184
|
+
* FIRST. A completed execution short-circuits without touching the domain.
|
|
2185
|
+
* An expired staged outcome invokes `reconcileIdempotency`; `committed`
|
|
2186
|
+
* replays it, `not-committed` releases and claims fresh, and `unknown` (or
|
|
2187
|
+
* no callback) throws `IdempotencyReconciliationRequiredError` without
|
|
2188
|
+
* changing the store.
|
|
2189
|
+
* 2. A fresh claim carries an opaque ownership token. For a leased store the
|
|
2190
|
+
* wrapper renews it at `renewAfterMs` until the transaction callback is
|
|
2191
|
+
* ready to commit. A renewal failure rejects before commit and releases
|
|
2192
|
+
* the claim. `fn(ctx, enrollment, execution)` receives the same token so a
|
|
2193
|
+
* source-side marker can make later reconciliation conclusive.
|
|
2194
|
+
* 3. `store.complete(ctx, claim, fn's result)` stages or completes the outcome
|
|
2195
|
+
* in the same transaction as aggregate writes and outbox. The enrollment
|
|
2196
|
+
* capability is sealed and its token array copied before `complete` can
|
|
2197
|
+
* yield, so leaked callback state cannot change the harvest receipt.
|
|
2198
|
+
* 4. After commit, `store.confirm(claim)` finalizes a leased store's staged
|
|
2199
|
+
* outcome; it is a no-op for transactional stores. A failure cannot reject
|
|
2200
|
+
* an already committed write, so it is sent to `onIdempotencyError` and the
|
|
2201
|
+
* record later enters reconciliation after lease expiry.
|
|
2202
|
+
* 5. Any pre-commit failure releases that exact token through
|
|
2203
|
+
* `store.abandon(claim)` before leaving the transactional region. A stale
|
|
2204
|
+
* abandon cannot release a successor. Secondary abandon/renew failures are
|
|
2205
|
+
* observable but never mask the primary error.
|
|
2206
|
+
*
|
|
2207
|
+
* Composes with `RetryingTransactionScope`: a retryable failure inside
|
|
2208
|
+
* one attempt releases that attempt's claim, and the retry either
|
|
2209
|
+
* executes fresh or, when a concurrent execution completed meanwhile,
|
|
2210
|
+
* replays its confirmed outcome. A concurrent duplicate while the first
|
|
2211
|
+
* execution is still running surfaces as `IdempotencyInFlightError`
|
|
2212
|
+
* (retryable); unwrapped, map it to a conflict/retry-later application
|
|
2213
|
+
* outcome.
|
|
2214
|
+
*
|
|
2215
|
+
* The stored outcome is `fn`'s `result` value; it must be plain, serialisable
|
|
2216
|
+
* data (see {@link IdempotencyStore}). Transactional storage remains the
|
|
2217
|
+
* production default. Leases make the non-transactional family recoverable;
|
|
2218
|
+
* they do not manufacture an atomic exactly-once boundary across two stores.
|
|
2219
|
+
*/
|
|
2220
|
+
declare function withIdempotentCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: WithIdempotentCommitDeps<Evt, TCtx>, request: IdempotentCommitRequest, fn: (ctx: TCtx, enrollment: CommitEnrollment<Evt>, execution: IdempotentExecution) => Promise<WithCommitWorkResult<Evt, R>>): Promise<IdempotentCommitResult<R>>;
|
|
2221
|
+
//#endregion
|
|
2222
|
+
//#region src/deadlines/deadline-store.d.ts
|
|
2223
|
+
/**
|
|
2224
|
+
* One deadline due for delivery, as returned by
|
|
2225
|
+
* {@link DeadlineStore.due}. `deliveryId` identifies this scheduled
|
|
2226
|
+
* INCARNATION of the deadline, not the `(scope, key)` address: a
|
|
2227
|
+
* reschedule replaces the incarnation, and acknowledging a stale
|
|
2228
|
+
* incarnation must never consume the new one (see
|
|
2229
|
+
* {@link DeadlineStore.markDelivered}).
|
|
2230
|
+
*/
|
|
2231
|
+
interface DueDeadline<TPayload = unknown> {
|
|
2232
|
+
/** Opaque per-incarnation id used for `markDelivered`/`markFailed`. */
|
|
2233
|
+
readonly deliveryId: string;
|
|
2234
|
+
/** The namespace half of the address, e.g. a process or policy name. */
|
|
2235
|
+
readonly scope: string;
|
|
2236
|
+
/** The instance half of the address, e.g. a saga or reservation id. */
|
|
2237
|
+
readonly key: string;
|
|
2238
|
+
/** When the deadline was due. */
|
|
2239
|
+
readonly dueAt: Date;
|
|
2240
|
+
/** The payload handed back as the input; plain data only. */
|
|
2241
|
+
readonly payload: TPayload;
|
|
2242
|
+
/** Failed delivery attempts so far (see `markFailed`). */
|
|
2243
|
+
readonly attempts: number;
|
|
2244
|
+
}
|
|
2245
|
+
/** A deadline that exhausted its delivery attempts; see {@link DeadlineStore.deadLetters}. */
|
|
2246
|
+
interface DeadLetterDeadline<TPayload = unknown> extends DueDeadline<TPayload> {
|
|
2247
|
+
/** Human-readable rendering of the last delivery error, if recorded. */
|
|
2248
|
+
readonly lastError?: string;
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Driven port for durable deadlines: timeout-as-input. A process that
|
|
2252
|
+
* waits ("if PaymentReceived has not arrived in 30 minutes,
|
|
2253
|
+
* compensate"; "release the reservation hold after 15 minutes";
|
|
2254
|
+
* "expire the offer at month's end") schedules a deadline, and a poll
|
|
2255
|
+
* loop later DELIVERS it as an input to whatever owns the decision, a
|
|
2256
|
+
* saga aggregate, a use case, a policy. The store never executes
|
|
2257
|
+
* consumer code; firing a deadline means handing back a record.
|
|
2258
|
+
*
|
|
2259
|
+
* Deliberately general-purpose and deliberately small. This is not a
|
|
2260
|
+
* scheduler framework and not a cron abstraction: there is no
|
|
2261
|
+
* recurrence, no execution engine, and the poll loop belongs to the
|
|
2262
|
+
* consumer (the outbox guide's `drainOnce` pattern fits; the deadlines
|
|
2263
|
+
* guide shows the wiring).
|
|
2264
|
+
*
|
|
2265
|
+
* Addressing is the `(scope, key)` pair, so one table serves every
|
|
2266
|
+
* waiting process in an application: `scope` names the policy
|
|
2267
|
+
* ("checkout-saga", "reservation-hold"), `key` the instance. There is
|
|
2268
|
+
* at most ONE pending deadline per address; `schedule` on an existing
|
|
2269
|
+
* address replaces it (that IS the reschedule operation), and each
|
|
2270
|
+
* scheduling gets a fresh `deliveryId`, so acknowledgements of a
|
|
2271
|
+
* replaced incarnation cannot consume its successor.
|
|
2272
|
+
*
|
|
2273
|
+
* Two sides, two transactional postures, the same split as the outbox:
|
|
2274
|
+
*
|
|
2275
|
+
* - **`schedule` and `cancel` are write-side calls** and must join the
|
|
2276
|
+
* ambient write transaction (use a tx-bound store instance inside
|
|
2277
|
+
* `withCommit`'s callback, exactly like an outbox adapter). This is
|
|
2278
|
+
* a correctness rule, not a preference: state that says "waiting for
|
|
2279
|
+
* payment" committed without its deadline is a process that never
|
|
2280
|
+
* wakes up, and a deadline scheduled for a rolled-back state change
|
|
2281
|
+
* is a ghost input.
|
|
2282
|
+
* - **`due`, `markDelivered`, `markFailed`, and `deadLetters` are the
|
|
2283
|
+
* poll surface** and run out of band, in the consumer's loop.
|
|
2284
|
+
*
|
|
2285
|
+
* Delivery is at-least-once: a crash between processing and
|
|
2286
|
+
* `markDelivered` redelivers, so consumers make deadline handling
|
|
2287
|
+
* idempotent (the idempotency store with the `deliveryId` as key is
|
|
2288
|
+
* the ready-made answer). Deadlines have no cross-key ordering
|
|
2289
|
+
* obligations, so unlike the outbox a poison deadline blocks only
|
|
2290
|
+
* itself; bounded retries still matter, which is why failure tracking
|
|
2291
|
+
* is part of the port rather than an extension: report failed
|
|
2292
|
+
* deliveries via `markFailed`, and the store dead-letters a deadline
|
|
2293
|
+
* past its attempt ceiling.
|
|
2294
|
+
*
|
|
2295
|
+
* Run one logical poller per store unless your adapter's `due` claims
|
|
2296
|
+
* records for competing pollers; the same rule as the outbox
|
|
2297
|
+
* dispatcher.
|
|
2298
|
+
*
|
|
2299
|
+
* The bundled processor supplies an `ExecutionContext` to every poll-side
|
|
2300
|
+
* operation. Production adapters MUST pass its signal to native I/O or enforce
|
|
2301
|
+
* a native timeout no later than `deadlineAt`; the shell can bound its wait but
|
|
2302
|
+
* cannot terminate a promise that ignores cancellation. A timed-out write has
|
|
2303
|
+
* an unknown outcome. Acknowledgements must remain idempotent when they complete
|
|
2304
|
+
* late; a late failure update may count its original delivery attempt and must
|
|
2305
|
+
* still no-op after the incarnation was delivered or replaced.
|
|
2306
|
+
*
|
|
2307
|
+
* Verify an adapter with `createDeadlineStoreContractTests` from
|
|
2308
|
+
* `@shirudo/ddd-kit/testing`; `InMemoryDeadlineStore` is the
|
|
2309
|
+
* reference.
|
|
2310
|
+
*
|
|
2311
|
+
* @template TPayload - The payload shape carried from `schedule` to
|
|
2312
|
+
* delivery; plain, serializable data (the same discipline as event
|
|
2313
|
+
* payloads and snapshots)
|
|
2314
|
+
*/
|
|
2315
|
+
interface DeadlineStore<TPayload = unknown> {
|
|
2316
|
+
/**
|
|
2317
|
+
* Schedules (or reschedules) the deadline at `(scope, key)`: at most
|
|
2318
|
+
* one pending deadline exists per address, and scheduling an
|
|
2319
|
+
* occupied address replaces its due time, payload, attempt count,
|
|
2320
|
+
* and incarnation. Called inside the write transaction.
|
|
2321
|
+
*/
|
|
2322
|
+
schedule(deadline: {
|
|
2323
|
+
scope: string;
|
|
2324
|
+
key: string;
|
|
2325
|
+
dueAt: Date;
|
|
2326
|
+
payload: TPayload;
|
|
2327
|
+
}): Promise<void>;
|
|
2328
|
+
/**
|
|
2329
|
+
* Removes the pending deadline at `(scope, key)`; a no-op when none
|
|
2330
|
+
* exists (the awaited input arrived in time and the wait is over).
|
|
2331
|
+
* Called inside the write transaction.
|
|
2332
|
+
*/
|
|
2333
|
+
cancel(scope: string, key: string): Promise<void>;
|
|
2334
|
+
/**
|
|
2335
|
+
* Up to `limit` deadlines with `dueAt <= now` that are neither
|
|
2336
|
+
* delivered nor dead-lettered, ordered by `dueAt` (earliest first;
|
|
2337
|
+
* ties in scheduling order). A `limit` of `0` is legal and yields an
|
|
2338
|
+
* empty page (poll loops computing a remaining capacity may pass
|
|
2339
|
+
* it). `now` is a parameter on purpose: the poll loop owns the
|
|
2340
|
+
* clock, which keeps adapters deterministic and tests free of real
|
|
2341
|
+
* time. The bundled processor always supplies `context`; it is optional only
|
|
2342
|
+
* so existing adapters remain assignable.
|
|
2343
|
+
*/
|
|
2344
|
+
due(now: Date, limit: number, context?: ExecutionContext): Promise<ReadonlyArray<DueDeadline<TPayload>>>;
|
|
2345
|
+
/**
|
|
2346
|
+
* Acknowledges delivered incarnations so they stop coming back.
|
|
2347
|
+
* Idempotent on already-acknowledged and unknown ids, and a no-op
|
|
2348
|
+
* for ids of REPLACED incarnations (a late ack after a reschedule
|
|
2349
|
+
* must not consume the successor). Also clears a dead-lettered
|
|
2350
|
+
* incarnation (manual redelivery, then ack). It remains idempotent if the
|
|
2351
|
+
* operation completes after the caller timed out. The bundled processor
|
|
2352
|
+
* always supplies `context`.
|
|
2353
|
+
*/
|
|
2354
|
+
markDelivered(deliveryIds: ReadonlyArray<string>, context?: ExecutionContext): Promise<void>;
|
|
2355
|
+
/**
|
|
2356
|
+
* Records one failed delivery attempt for the incarnation:
|
|
2357
|
+
* increments its `attempts` and, once the store's ceiling is
|
|
2358
|
+
* reached, moves it to the dead-letter set that `due` no longer
|
|
2359
|
+
* returns. A no-op for unknown, delivered, or replaced ids.
|
|
2360
|
+
* Returns the exact dead-letter record only on the call that performs
|
|
2361
|
+
* that transition; retries below the ceiling and no-ops return
|
|
2362
|
+
* `undefined`. A late completion may count that original delivery attempt; it
|
|
2363
|
+
* must still no-op if the incarnation was delivered or replaced in the
|
|
2364
|
+
* meantime. The bundled processor never reissues the same store call and
|
|
2365
|
+
* always supplies `context`.
|
|
2366
|
+
*/
|
|
2367
|
+
markFailed(deliveryId: string, error?: unknown, context?: ExecutionContext): Promise<DeadLetterDeadline<TPayload> | undefined>;
|
|
2368
|
+
/**
|
|
2369
|
+
* Deadlines that exhausted their delivery attempts. Wire this to durable
|
|
2370
|
+
* alerting and reconciliation: a growing set means processes that stopped
|
|
2371
|
+
* waking up, and the poller can stop between the store transition and its
|
|
2372
|
+
* immediate observer callback.
|
|
2373
|
+
*/
|
|
2374
|
+
deadLetters(): Promise<ReadonlyArray<DeadLetterDeadline<TPayload>>>;
|
|
2375
|
+
}
|
|
2376
|
+
//#endregion
|
|
2377
|
+
//#region src/projections/ports.d.ts
|
|
2378
|
+
/**
|
|
2379
|
+
* A projection's gap-proof cursor into one aggregate's commit chain.
|
|
2380
|
+
* `aggregateVersion` plus `commitSequence` orders events; `commitSize`
|
|
2381
|
+
* proves the current commit is complete; `previousEventfulAggregateVersion`
|
|
2382
|
+
* links the next commit to the eventful predecessor. `withCommit` supplies
|
|
2383
|
+
* the current commit facts; the event source finalizes the predecessor on the
|
|
2384
|
+
* surrounding `CommittedDomainEvent`.
|
|
2385
|
+
*
|
|
2386
|
+
* A source MUST map exactly one immutable receipt to each qualified position:
|
|
2387
|
+
* one `eventId`, one `commitSize`, and one eventful predecessor. Custom
|
|
2388
|
+
* envelopes may translate another store's cursor into these fields, but
|
|
2389
|
+
* changing any part of an already observed receipt destroys the proof and is
|
|
2390
|
+
* a source-adapter bug.
|
|
2391
|
+
*/
|
|
2392
|
+
type ProjectionPosition = CommitPosition;
|
|
2393
|
+
/**
|
|
2394
|
+
* Durable receipt for the last event one projection applied from an aggregate
|
|
2395
|
+
* stream. The position answers "how far?"; `lastAppliedEventId` identifies the
|
|
2396
|
+
* event at exactly that watermark. Together they let the projector distinguish
|
|
2397
|
+
* a true watermark redelivery from a source changing the event identity,
|
|
2398
|
+
* commit cardinality, or predecessor at the same position. Older positions
|
|
2399
|
+
* still rely on the source's immutable-receipt-per-position contract because a
|
|
2400
|
+
* checkpoint deliberately retains no full history.
|
|
2401
|
+
*/
|
|
2402
|
+
interface ProjectionCheckpoint {
|
|
2403
|
+
readonly position: ProjectionPosition;
|
|
2404
|
+
readonly lastAppliedEventId: string;
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* `true` when `candidate` comes strictly after `reference` in the
|
|
2408
|
+
* per-aggregate tuple order (higher version, or same version and higher
|
|
2409
|
+
* commit sequence). This comparison alone does not prove continuity;
|
|
2410
|
+
* the projector checks the boundary fields before advancing.
|
|
2411
|
+
*/
|
|
2412
|
+
declare function isPositionAfter(candidate: ProjectionPosition, reference: ProjectionPosition): boolean;
|
|
2413
|
+
/**
|
|
2414
|
+
* Driven port for projection checkpoints: the per-`(projection,
|
|
2415
|
+
* aggregateType, aggregateId)` watermark receipt that makes a projection
|
|
2416
|
+
* idempotent and rebuild-safe. The {@link
|
|
2417
|
+
* ProjectionCheckpointStore.withCheckpointLocks} callback and every
|
|
2418
|
+
* {@link ProjectionCheckpointStore.load} / {@link
|
|
2419
|
+
* ProjectionCheckpointStore.save} it contains run inside the SAME transaction
|
|
2420
|
+
* as the read-model update (the `Projector` guarantees the pairing); the store
|
|
2421
|
+
* itself is a dumb last-write-wins record, monotonicity is the projector's job.
|
|
2422
|
+
*
|
|
2423
|
+
* Production adapters put the checkpoint table in the same database
|
|
2424
|
+
* as the read model, so update and checkpoint commit atomically: a
|
|
2425
|
+
* checkpoint without its update loses events, an update without its
|
|
2426
|
+
* checkpoint replays work. Verify an adapter with
|
|
2427
|
+
* `createProjectionCheckpointStoreContractTests` from
|
|
2428
|
+
* `@shirudo/ddd-kit/testing`.
|
|
2429
|
+
*
|
|
2430
|
+
* @template TCtx - The transaction context of the ambient
|
|
2431
|
+
* `TransactionScope` (a knex trx, a drizzle tx, a pg client)
|
|
2432
|
+
*/
|
|
2433
|
+
interface ProjectionCheckpointStore<TCtx = unknown> {
|
|
2434
|
+
/**
|
|
2435
|
+
* Runs the complete checkpoint read / read-model update / checkpoint save
|
|
2436
|
+
* critical section with exclusive access to every supplied
|
|
2437
|
+
* `(projection, aggregateType, aggregateId)` key.
|
|
2438
|
+
*
|
|
2439
|
+
* Exclusivity MUST cover keys for which no checkpoint row exists yet. A
|
|
2440
|
+
* plain `SELECT ... FOR UPDATE` against the checkpoint table is therefore
|
|
2441
|
+
* insufficient at genesis: use transaction-scoped advisory/key locks, or
|
|
2442
|
+
* first materialize durable lock rows and lock those. Acquire multiple keys
|
|
2443
|
+
* in a deterministic order to avoid deadlocks, and keep database locks until
|
|
2444
|
+
* the surrounding transaction commits or rolls back. On entry, `work` must
|
|
2445
|
+
* observe checkpoint commits made by the preceding lock holder; choose the
|
|
2446
|
+
* transaction isolation level accordingly, or surface and retry a
|
|
2447
|
+
* serialization conflict instead of applying against a stale snapshot.
|
|
2448
|
+
*
|
|
2449
|
+
* Implementations may serialize more than the requested keys, but never
|
|
2450
|
+
* less. The callback is non-reentrant for an overlapping key set.
|
|
2451
|
+
*/
|
|
2452
|
+
withCheckpointLocks<R>(ctx: TCtx, projection: string, addresses: ReadonlyArray<AggregateAddress>, work: () => Promise<R>): Promise<R>;
|
|
2453
|
+
/**
|
|
2454
|
+
* The stored watermark receipt for `(projection, address)`, or `undefined`
|
|
2455
|
+
* when this projection has never applied an event of that
|
|
2456
|
+
* aggregate. Called inside the projector's transaction.
|
|
2457
|
+
*/
|
|
2458
|
+
load(ctx: TCtx, projection: string, address: AggregateAddress): Promise<ProjectionCheckpoint | undefined>;
|
|
2459
|
+
/**
|
|
2460
|
+
* Persists the watermark receipt, overwriting a previous one (last write
|
|
2461
|
+
* wins; the projector only calls this with advancing checkpoints).
|
|
2462
|
+
* Called inside the projector's transaction, after the read-model
|
|
2463
|
+
* update it accounts for.
|
|
2464
|
+
*/
|
|
2465
|
+
save(ctx: TCtx, projection: string, address: AggregateAddress, checkpoint: ProjectionCheckpoint): Promise<void>;
|
|
2466
|
+
/**
|
|
2467
|
+
* The wait-for-version building block: `true` when the stored
|
|
2468
|
+
* watermark for `(projection, address)` is at or past
|
|
2469
|
+
* `position`. Runs OUTSIDE any transaction (a query-side poll).
|
|
2470
|
+
*
|
|
2471
|
+
* Pass the position of the LAST event your commit emitted: all
|
|
2472
|
+
* events of one commit share the `aggregateVersion`, so comparing
|
|
2473
|
+
* on the version alone would report "reached" while later events
|
|
2474
|
+
* of the same commit are still unapplied.
|
|
2475
|
+
*/
|
|
2476
|
+
hasReached(projection: string, address: AggregateAddress, position: ProjectionPosition): Promise<boolean>;
|
|
2477
|
+
/**
|
|
2478
|
+
* Deletes every checkpoint of `projection` (other projections'
|
|
2479
|
+
* checkpoints are untouched): the rebuild entry point. Called
|
|
2480
|
+
* inside the rebuild transaction, together with the projection's
|
|
2481
|
+
* `truncate`, so a rebuild starts from a consistent zero.
|
|
2482
|
+
*/
|
|
2483
|
+
reset(ctx: TCtx, projection: string): Promise<void>;
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* One projection: the consumer-owned mapping from events to ONE read
|
|
2487
|
+
* model (one table/view per projection; run several `Projector`s for
|
|
2488
|
+
* several read shapes). The kit owns the mechanics around it
|
|
2489
|
+
* (cursor skip, atomic checkpointing, rebuild); the handler owns the
|
|
2490
|
+
* read-model writes.
|
|
2491
|
+
*
|
|
2492
|
+
* The projector feed MUST contain every committed envelope for each aggregate
|
|
2493
|
+
* address it carries, including event types this read model does not use.
|
|
2494
|
+
* Handle those events as explicit no-ops in `apply`: the projector still
|
|
2495
|
+
* advances their cursor. Filtering a broker subscription by event type drops
|
|
2496
|
+
* positions from the source chain and turns the next commit into a real gap.
|
|
2497
|
+
* For correctness-critical read models, use `projectionFromHandlers` to make
|
|
2498
|
+
* every event in the declared union a compile-time handler-or-ignore decision;
|
|
2499
|
+
* implement this interface directly when intentionally partial routing is the
|
|
2500
|
+
* better fit.
|
|
2501
|
+
*/
|
|
2502
|
+
interface Projection<Evt extends AnyDomainEvent, TCtx = unknown> {
|
|
2503
|
+
/**
|
|
2504
|
+
* Stable unique name; keys the checkpoints. Renaming it orphans the
|
|
2505
|
+
* old checkpoints and replays everything under the new name.
|
|
2506
|
+
*/
|
|
2507
|
+
name: string;
|
|
2508
|
+
/**
|
|
2509
|
+
* Applies ONE event's read-model change inside the ambient
|
|
2510
|
+
* transaction. The projector's cursor already filtered duplicates
|
|
2511
|
+
* and stale events, so plain writes are safe; route on
|
|
2512
|
+
* `event.type` and handle creates, updates, deletes, corrections,
|
|
2513
|
+
* and tombstones explicitly (an upsert-only handler silently
|
|
2514
|
+
* retains stale rows). For a known event type this projection does not use,
|
|
2515
|
+
* return without writing; that explicit no-op still consumes and checkpoints
|
|
2516
|
+
* the envelope's source position.
|
|
2517
|
+
*
|
|
2518
|
+
* MUST be side-effect-free beyond the read model: no mails, no
|
|
2519
|
+
* external calls, no commands. A rebuild replays every event; side
|
|
2520
|
+
* effects would fire again.
|
|
2521
|
+
*/
|
|
2522
|
+
apply(ctx: TCtx, event: Evt): Promise<void>;
|
|
2523
|
+
/**
|
|
2524
|
+
* Optional: clears the read model, called by `Projector.reset()`
|
|
2525
|
+
* in the same transaction as the checkpoint reset, so a rebuild
|
|
2526
|
+
* never observes a half-cleared state. Without it, truncating the
|
|
2527
|
+
* read model before a rebuild is the caller's responsibility.
|
|
2528
|
+
*/
|
|
2529
|
+
truncate?(ctx: TCtx): Promise<void>;
|
|
2530
|
+
}
|
|
2531
|
+
//#endregion
|
|
2532
|
+
//#region src/repo/event-store.d.ts
|
|
2533
|
+
/** Options for {@link EventStore.append}. */
|
|
2534
|
+
interface EventStoreAppendOptions {
|
|
2535
|
+
/**
|
|
2536
|
+
* The stream version the writer loaded (its optimistic-concurrency
|
|
2537
|
+
* baseline): the number of events the stream held when the aggregate
|
|
2538
|
+
* was reconstituted. `0` for a brand-new stream. `UnitOfWork` captures
|
|
2539
|
+
* this value when the adapter returns a loaded aggregate; it is not stored
|
|
2540
|
+
* on the aggregate itself.
|
|
2541
|
+
*/
|
|
2542
|
+
readonly expectedVersion: number;
|
|
2543
|
+
}
|
|
2544
|
+
/** Options for {@link EventStore.readStream}. */
|
|
2545
|
+
interface ReadStreamOptions {
|
|
2546
|
+
/**
|
|
2547
|
+
* Maximum number of events returned by this page. Required so callers
|
|
2548
|
+
* cannot accidentally materialize an unbounded stream. Must be a positive
|
|
2549
|
+
* safe integer. An adapter may return fewer events, but must return at least
|
|
2550
|
+
* one while unread events remain inside the requested window.
|
|
2551
|
+
*/
|
|
2552
|
+
readonly limit: number;
|
|
2553
|
+
/**
|
|
2554
|
+
* Return only events AFTER this stream position (1-based event count),
|
|
2555
|
+
* the snapshot catch-up read: `readStream(stream, { fromVersion:
|
|
2556
|
+
* snapshot.version, limit: 256 })` yields the next page passed to
|
|
2557
|
+
* `aggregate.loadFromHistory`. Defaults to `0` (the first
|
|
2558
|
+
* stream page).
|
|
2559
|
+
* Must be a non-negative safe integer when present.
|
|
2560
|
+
*/
|
|
2561
|
+
readonly fromVersion?: number;
|
|
2562
|
+
/**
|
|
2563
|
+
* Return events only THROUGH this stream position (inclusive, 1-based
|
|
2564
|
+
* event count). Together with `fromVersion`, this describes the interval
|
|
2565
|
+
* `(fromVersion, toVersion]`. Defaults to the actual stream head.
|
|
2566
|
+
* `0` therefore returns an empty window; a value beyond the head clamps
|
|
2567
|
+
* to the head; and `fromVersion >= toVersion` is an empty interval, not
|
|
2568
|
+
* an error.
|
|
2569
|
+
* Must be a non-negative safe integer when present.
|
|
2570
|
+
*/
|
|
2571
|
+
readonly toVersion?: number;
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* State returned by {@link EventStore.readStream}.
|
|
2575
|
+
*
|
|
2576
|
+
* `lastVersion` is always the actual stream head (the event count), independent
|
|
2577
|
+
* of the requested read window and page limit. `exists: true` implies
|
|
2578
|
+
* `lastVersion >= 1`: an
|
|
2579
|
+
* existing stream has at least one event, while metadata or tombstones without
|
|
2580
|
+
* events must be reported as `exists: false`. A missing stream is therefore
|
|
2581
|
+
* distinguishable from an existing stream whose requested window is empty.
|
|
2582
|
+
* Snapshot-backed repositories use that distinction to reject a snapshot whose
|
|
2583
|
+
* version lies beyond the current authoritative stream head.
|
|
2584
|
+
*/
|
|
2585
|
+
type StreamReadResult<Evt extends AnyDomainEvent> = {
|
|
2586
|
+
readonly exists: false;
|
|
2587
|
+
readonly lastVersion: 0;
|
|
2588
|
+
readonly events: readonly [];
|
|
2589
|
+
} | {
|
|
2590
|
+
readonly exists: true;
|
|
2591
|
+
readonly lastVersion: number;
|
|
2592
|
+
readonly events: ReadonlyArray<Evt>;
|
|
2593
|
+
};
|
|
2594
|
+
/**
|
|
2595
|
+
* Driven port for event-sourced aggregate persistence: an append-only
|
|
2596
|
+
* store with one stream per aggregate. Each stream is addressed by the
|
|
2597
|
+
* qualified tuple `(aggregateType, aggregateId)`, because aggregate ids
|
|
2598
|
+
* are type-scoped rather than globally unique.
|
|
2599
|
+
*
|
|
2600
|
+
* The kit ships the port, the OCC error contract, `InMemoryEventStore`
|
|
2601
|
+
* as the reference implementation, and the event-sourced repository
|
|
2602
|
+
* contract suites (`createEventStoreContractTests` and
|
|
2603
|
+
* `createEsRepositoryContractTests` from `@shirudo/ddd-kit/testing`).
|
|
2604
|
+
* Your adapter implements this port against a real store and must pass
|
|
2605
|
+
* those suites. Like the state-stored repository contract, its optimistic
|
|
2606
|
+
* concurrency and key isolation are testable adapter contracts, not kit
|
|
2607
|
+
* guarantees.
|
|
2608
|
+
*
|
|
2609
|
+
* Repository usage (see the event-sourcing guide):
|
|
2610
|
+
*
|
|
2611
|
+
* ```ts
|
|
2612
|
+
* private stream(id: OrderId): AggregateAddress<OrderId> {
|
|
2613
|
+
* return { aggregateType: "Order", aggregateId: id };
|
|
2614
|
+
* }
|
|
2615
|
+
*
|
|
2616
|
+
* async findById(id: OrderId): Promise<Order | undefined> {
|
|
2617
|
+
* const cached = this.tracking.identityMap.get(Order, id);
|
|
2618
|
+
* if (cached) return cached;
|
|
2619
|
+
* const address = this.stream(id);
|
|
2620
|
+
* const order = Order.reconstitute(id); // bare instance, no events
|
|
2621
|
+
* let fromVersion = 0;
|
|
2622
|
+
* let targetVersion: number | undefined;
|
|
2623
|
+
* for (;;) {
|
|
2624
|
+
* const page = await this.eventStore.readStream(address, {
|
|
2625
|
+
* fromVersion,
|
|
2626
|
+
* toVersion: targetVersion,
|
|
2627
|
+
* limit: 256,
|
|
2628
|
+
* });
|
|
2629
|
+
* if (!page.exists) return undefined;
|
|
2630
|
+
* targetVersion ??= page.lastVersion; // pin the first observed head
|
|
2631
|
+
* if (fromVersion === targetVersion) break;
|
|
2632
|
+
* if (page.events.length === 0) {
|
|
2633
|
+
* throw new NonProgressingEventStreamPageError({
|
|
2634
|
+
* ...address,
|
|
2635
|
+
* fromVersion,
|
|
2636
|
+
* targetVersion,
|
|
2637
|
+
* });
|
|
2638
|
+
* }
|
|
2639
|
+
* const result = order.loadFromHistory(page.events);
|
|
2640
|
+
* if (result.isErr()) throw result.error; // corrupt stream
|
|
2641
|
+
* fromVersion += page.events.length;
|
|
2642
|
+
* }
|
|
2643
|
+
* return this.tracking.trackLoaded(order);
|
|
2644
|
+
* }
|
|
2645
|
+
*
|
|
2646
|
+
* flush(write: AggregatePersistenceWrite<Order, number | undefined>) {
|
|
2647
|
+
* return this.eventStore.append(this.stream(write.aggregateId), write.events, {
|
|
2648
|
+
* expectedVersion: write.expectedVersion ?? 0,
|
|
2649
|
+
* });
|
|
2650
|
+
* }
|
|
2651
|
+
* ```
|
|
2652
|
+
*
|
|
2653
|
+
* `flush` appends the exact event batch registered by `add` or `update`;
|
|
2654
|
+
* `withCommit`
|
|
2655
|
+
* separately composes them into outbox envelopes. The event store's own
|
|
2656
|
+
* stream position remains the ordering authority for replay.
|
|
2657
|
+
*
|
|
2658
|
+
* The exact appended event batch is acknowledged only after the surrounding
|
|
2659
|
+
* transaction commits. Rollback leaves it pending.
|
|
2660
|
+
*/
|
|
2661
|
+
interface EventStore<Evt extends AnyDomainEvent> {
|
|
2662
|
+
/**
|
|
2663
|
+
* Atomically appends `events` to the stream, guarded by optimistic
|
|
2664
|
+
* concurrency: the append succeeds only when the stream currently
|
|
2665
|
+
* holds exactly `options.expectedVersion` events.
|
|
2666
|
+
*
|
|
2667
|
+
* Contract for implementations:
|
|
2668
|
+
*
|
|
2669
|
+
* 1. **OCC:** on a version mismatch (stale writer, duplicate create
|
|
2670
|
+
* racing on `expectedVersion: 0`, or an expectedVersion ahead of
|
|
2671
|
+
* the stream), throw `ConcurrencyConflictError` from
|
|
2672
|
+
* `@shirudo/ddd-kit` carrying the expected and actual stream
|
|
2673
|
+
* versions; map your store's native conflict signal to it instead
|
|
2674
|
+
* of letting a raw driver error escape. One sanctioned exception:
|
|
2675
|
+
* an adapter that can DISTINGUISH the duplicate-create race
|
|
2676
|
+
* (`expectedVersion: 0` against a stream that already exists,
|
|
2677
|
+
* typically a unique violation on the first position) may throw
|
|
2678
|
+
* `DuplicateAggregateError` for that case instead, matching the
|
|
2679
|
+
* state-stored insert path. It is deliberately NOT retryable:
|
|
2680
|
+
* replaying the same append cannot succeed; the use case resolves
|
|
2681
|
+
* the create race (load the existing aggregate, or surface HTTP
|
|
2682
|
+
* 409). The contract suite accepts both errors for this race.
|
|
2683
|
+
* 2. **Atomicity:** all events land or none do; a rejected append
|
|
2684
|
+
* leaves the stream untouched.
|
|
2685
|
+
* 3. **Qualified identity:** `(aggregateType, aggregateId)` is the
|
|
2686
|
+
* storage key. Equal raw ids under different aggregate types are
|
|
2687
|
+
* independent streams. Use both columns in every primary/unique
|
|
2688
|
+
* key, OCC predicate, and read predicate.
|
|
2689
|
+
* 4. **Order:** events are stored in the given array order, appended
|
|
2690
|
+
* after the existing stream tail.
|
|
2691
|
+
* 5. **Append-only:** stored events are never edited or deleted;
|
|
2692
|
+
* corrections are new (compensating) events.
|
|
2693
|
+
* 6. **Replay integrity:** reads order by the persisted stream position
|
|
2694
|
+
* and reject duplicate or non-contiguous positions where the backing
|
|
2695
|
+
* store exposes them. The portable contract suite proves observable
|
|
2696
|
+
* append order and slicing; because the port cannot inject malformed
|
|
2697
|
+
* physical rows, adapters add a store-specific corruption fixture that
|
|
2698
|
+
* proves the duplicate/gap rejection. The repository then calls
|
|
2699
|
+
* `loadFromHistory`, whose replay guard rejects any event carrying an
|
|
2700
|
+
* aggregate type or id that contradicts this stream key.
|
|
2701
|
+
*
|
|
2702
|
+
* An empty `events` array is a no-op; implementations resolve without
|
|
2703
|
+
* touching the store (an ES repository skips `append` for aggregates
|
|
2704
|
+
* without pending events anyway).
|
|
2705
|
+
*
|
|
2706
|
+
* Treat `aggregateType` as a stable technical stream category. If two
|
|
2707
|
+
* bounded contexts share one physical store and reuse a domain name,
|
|
2708
|
+
* qualify it at the source (`sales.order`, `fulfillment.order`). Renaming
|
|
2709
|
+
* it changes the stream key and therefore requires a data migration.
|
|
2710
|
+
*/
|
|
2711
|
+
append(stream: AggregateAddress, events: ReadonlyArray<Evt>, options: EventStoreAppendOptions): Promise<void>;
|
|
2712
|
+
/**
|
|
2713
|
+
* Reads one bounded page of the qualified stream in append order. An unknown stream returns
|
|
2714
|
+
* `{ exists: false, lastVersion: 0, events: [] }`; an existing stream keeps
|
|
2715
|
+
* `exists: true` even when its requested window is empty. An existing stream
|
|
2716
|
+
* has at least one event, so `exists: true` implies `lastVersion >= 1`;
|
|
2717
|
+
* metadata or tombstones without events must be reported as absent.
|
|
2718
|
+
* `lastVersion` is always the actual stream head. `options.limit` is
|
|
2719
|
+
* mandatory and caps the returned array. An adapter may return fewer than
|
|
2720
|
+
* the requested limit, but if the requested window still contains unread
|
|
2721
|
+
* events it must return a non-empty contiguous prefix so callers can make
|
|
2722
|
+
* progress. `options.fromVersion`
|
|
2723
|
+
* excludes positions at or below its 1-based event count;
|
|
2724
|
+
* `options.toVersion` includes positions through its count, so both bounds
|
|
2725
|
+
* describe `(fromVersion, toVersion]`. `toVersion: 0` and inverted ranges
|
|
2726
|
+
* return an empty existing window, while a bound beyond the head clamps to
|
|
2727
|
+
* the head. This distinction is load-bearing for snapshot catch-up and
|
|
2728
|
+
* point-in-time reconstruction: a repository can verify the requested
|
|
2729
|
+
* historical window against the authoritative head. `limit` must be a
|
|
2730
|
+
* positive safe integer; present bounds must be non-negative safe integers.
|
|
2731
|
+
* Invalid options reject with `RangeError` before querying storage.
|
|
2732
|
+
*
|
|
2733
|
+
* Each page's `exists`, `lastVersion`, and `events` must describe one
|
|
2734
|
+
* consistent view of the stream. Multiple page reads are not one database
|
|
2735
|
+
* snapshot: pin the first page's `lastVersion` as `toVersion` on every
|
|
2736
|
+
* continuation, then advance `fromVersion` by the number of events actually
|
|
2737
|
+
* returned. Because streams are append-only, that yields a stable prefix
|
|
2738
|
+
* even if new events arrive while replay is in progress. The returned
|
|
2739
|
+
* event array is owned by the caller; implementations must not hand out
|
|
2740
|
+
* mutable live internal state.
|
|
2741
|
+
*/
|
|
2742
|
+
readStream(stream: AggregateAddress, options: ReadStreamOptions): Promise<StreamReadResult<Evt>>;
|
|
2743
|
+
}
|
|
2744
|
+
//#endregion
|
|
2745
|
+
//#region src/repo/snapshot-store.d.ts
|
|
2746
|
+
/**
|
|
2747
|
+
* Driven port for aggregate snapshot persistence: the storage half of
|
|
2748
|
+
* the snapshot-plus-recent-events load path for event-sourced aggregates.
|
|
2749
|
+
* `SnapshotModel` owns projection, migration, and reconstitution;
|
|
2750
|
+
* `EventStore.readStream` supplies the catch-up tail to `loadFromHistory`.
|
|
2751
|
+
*
|
|
2752
|
+
* **A snapshot is derived data, never authority.** The stream remains
|
|
2753
|
+
* the source of truth; a snapshot only shortens replay. That shapes
|
|
2754
|
+
* the port:
|
|
2755
|
+
*
|
|
2756
|
+
* - **Transaction-free by design.** Unlike the outbox or the
|
|
2757
|
+
* idempotency store, saving a snapshot does NOT belong in the write
|
|
2758
|
+
* transaction: write it after the commit, out of band, on whatever
|
|
2759
|
+
* cadence your policy picks. A lost save costs replay time, not
|
|
2760
|
+
* correctness; a stale snapshot is caught up by the event tail.
|
|
2761
|
+
* - **Latest only.** One snapshot per `(aggregateType, aggregateId)`;
|
|
2762
|
+
* `save` replaces the previous one. Snapshot history has no reader
|
|
2763
|
+
* in this load path.
|
|
2764
|
+
* - **WHEN to snapshot is policy and stays with the consumer** (every
|
|
2765
|
+
* N events after commit is the usual shape); the kit ships the port,
|
|
2766
|
+
* not the policy.
|
|
2767
|
+
*
|
|
2768
|
+
* Contract for implementations (verified by
|
|
2769
|
+
* `createSnapshotStoreContractTests` from `@shirudo/ddd-kit/testing`):
|
|
2770
|
+
* the snapshot round-trips verbatim (`state` as plain data,
|
|
2771
|
+
* `version`, `snapshotAt` with millisecond fidelity, `schemaVersion`
|
|
2772
|
+
* including its absence), loads return detached copies (never live
|
|
2773
|
+
* internal state), and keys are isolated per aggregate type AND id
|
|
2774
|
+
* (one table may serve every aggregate type).
|
|
2775
|
+
*
|
|
2776
|
+
* @template TState - The adapter-owned snapshot DTO shape; a store shared
|
|
2777
|
+
* across aggregate types is a
|
|
2778
|
+
* `SnapshotStore<unknown>` with typed views per repository
|
|
2779
|
+
*/
|
|
2780
|
+
interface SnapshotStore<TState = unknown> {
|
|
2781
|
+
/**
|
|
2782
|
+
* The latest snapshot for the aggregate, or `undefined` when none
|
|
2783
|
+
* exists. The repository falls back to a full replay then.
|
|
2784
|
+
*/
|
|
2785
|
+
load(address: AggregateAddress): Promise<AggregateSnapshot<TState> | undefined>;
|
|
2786
|
+
/**
|
|
2787
|
+
* Persists `snapshot` as the new latest for the aggregate,
|
|
2788
|
+
* replacing any previous one. Called AFTER the write transaction
|
|
2789
|
+
* committed (see the port docs); a single-row upsert is the
|
|
2790
|
+
* standard implementation.
|
|
2791
|
+
*/
|
|
2792
|
+
save(address: AggregateAddress, snapshot: AggregateSnapshot<TState>): Promise<void>;
|
|
2793
|
+
/**
|
|
2794
|
+
* Removes the aggregate's snapshot; a no-op when none exists. The
|
|
2795
|
+
* two callers: the schema-migration fallback (a
|
|
2796
|
+
* `SnapshotSchemaMismatchError` or corrupt snapshot during adapter
|
|
2797
|
+
* reconstitution discards the snapshot and refolds from the full stream)
|
|
2798
|
+
* and erasure (a snapshot duplicates aggregate state and follows
|
|
2799
|
+
* the same retention rules). When erasing, delete the snapshot
|
|
2800
|
+
* BEFORE the event stream: the reverse order has a crash window in
|
|
2801
|
+
* which a stale snapshot resurrects the erased aggregate on the
|
|
2802
|
+
* snapshot load path; snapshot-first degrades to a full replay.
|
|
2803
|
+
*/
|
|
2804
|
+
delete(address: AggregateAddress): Promise<void>;
|
|
2805
|
+
}
|
|
2806
|
+
//#endregion
|
|
2807
|
+
export { OutboxWriter as $, ClockFactory as $t, WithCommitWorkResult as A, AnyDomainEvent as At, DurableCommandMessage as B, EventIdFactory as Bt, IdempotentExecution as C, updateEntityById as Ct, CommitEnrollment as D, IEventSourcedAggregate as Dt, AggregateCommitToken as E, IAggregateRoot as Et, CommandMessageContent as F, CreateUncommittedDomainEventOptions as Ft, DispatchTrackingOutbox as G, copyMetadata as Gt, CommitPosition as H, PendingDomainEvent as Ht, CommandMessageRelationships as I, DomainEvent as It, EventCommitCandidatePosition as J, createDomainEventFromFacts as Jt, EventBus as K, createDomainEvent as Kt, CommandOutboxCommitCandidate as L, DomainEventFactory as Lt, TransactionScope as M, CreateDomainEventFromFactsOptions as Mt, TransactionalOptions as N, CreateDomainEventOptions as Nt, CommitEnrollmentOptions as O, Version as Ot, CommandCommitOriginCandidate as P, CreateDomainEventStampOptions as Pt, OutboxRecord as Q, recordDomainEvent as Qt, CommandOutboxMapper as R, DomainEventFactoryOptions as Rt, IdempotentCommitResult as S, sameEntity as St, withIdempotentCommit as T, AggregateSnapshot as Tt, CommittedDomainEvent as U, UncommittedDomainEvent as Ut, routeEventsToCommandOutbox as V, EventMetadata as Vt, DeadLetterRecord as W, UncommittedDomainEventOf as Wt, OnceOptions as X, defaultDomainEventFactory as Xt, EventHandler as Y, createUncommittedDomainEvent as Yt, Outbox as Z, mergeMetadata as Zt, IdempotencyOperationErrorContext as _, findEntityById as _t, StreamReadResult as a, JsonObject as at, IdempotencyStore as b, removeEntityById as bt, ProjectionCheckpointStore as c, AggregateRoot as ct, DeadLetterDeadline as d, Entity as dt, Id as en, PublishOptions as et, DeadlineStore as f, EntityConfig as ft, IdempotencyLease as g, entityIds as gt, IdempotencyClaimHandle as h, StateValidator as ht, ReadStreamOptions as i, PublishedCommand as it, withCommit as j, AnyUncommittedDomainEvent as jt, WithCommitDeps as k, sameVersion as kt, ProjectionPosition as l, AggregateConfig as lt, IdempotencyClaim as m, Identifiable as mt, EventStore as n, Command as nt, Projection as o, JsonPrimitive as ot, DueDeadline as p, IEntity as pt, EventCommitCandidate as q, createDomainEventFactory as qt, EventStoreAppendOptions as r, CommandHandler as rt, ProjectionCheckpoint as s, JsonValue as st, SnapshotStore as t, IdGenerator as tn, ExecutionContext as tt, isPositionAfter as u, BaseAggregate as ut, IdempotencyReconciliation as v, freezeShallow as vt, WithIdempotentCommitDeps as w, AggregateAddress as wt, IdempotentCommitRequest as x, replaceEntityById as xt, IdempotencyReconciliationDecision as y, hasEntityId as yt, CommandOutboxWriter as z, DomainEventStamp as zt };
|
|
2808
|
+
//# sourceMappingURL=snapshot-store.d.ts.map
|