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