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