@shirudo/ddd-kit 3.0.0-rc.3 → 3.0.0-rc.5

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.
@@ -1,64 +1,194 @@
1
- import { o as DomainError } from "./errors.js";
1
+ import { l as DomainError } from "./kit-errors.js";
2
2
  import { Result } from "@shirudo/result";
3
-
4
- //#region src/core/id.d.ts
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
5
16
  /**
6
- * Branded string ID. `Tag` carries the aggregate / entity name so two ids
7
- * with different tags are not assignable to each other even though both
8
- * are strings at runtime.
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.
9
23
  *
10
24
  * @example
11
- * ```ts
12
- * type UserId = Id<"UserId">;
13
- * type OrderId = Id<"OrderId">;
25
+ * ```typescript
26
+ * type CreateOrderCommand = Command & {
27
+ * type: "CreateOrder";
28
+ * customerId: string;
29
+ * items: OrderItem[];
30
+ * };
31
+ * ```
14
32
  *
15
- * const u = "user-1" as UserId;
16
- * const o: OrderId = u; // ❌ compile error
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
+ * });
17
88
  * ```
18
89
  */
19
- type Id<Tag extends string> = string & {
20
- readonly __brand: Tag;
21
- };
90
+ interface Command {
91
+ readonly type: string;
92
+ }
22
93
  /**
23
- * Produces fresh ids of a single, fixed tag. The tag is bound at the
24
- * generator type: `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
25
- * with no caller-side generic to abuse.
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.
26
110
  *
27
- * **Your factory must produce unique ids under concurrent calls.**
28
- * The kit makes no attempt to dedupe or detect collisions: a collision
29
- * silently overwrites earlier rows (under unique-key constraints) or
30
- * silently aliases two different entities (without them). Safe choices:
31
- * `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
32
- * KSUID: all collision-resistant by design. Unsafe choices: `Date.now()`
33
- * alone (duplicates within the same millisecond), a process-local
34
- * counter without persistence (resets to 1 on restart, collides with
35
- * prior runs), a sequential id derived from non-atomic state.
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.
36
118
  *
37
119
  * @example
38
- * ```ts
39
- * import { ulid } from "ulid";
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
+ * ```
40
127
  *
41
- * const userIds: IdGenerator<"UserId"> = { next: () => ulid() as Id<"UserId"> };
42
- * const id = userIds.next(); // Id<"UserId">
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
+ * });
43
159
  * ```
160
+ */
161
+ type CommandHandler<C extends Command, R, E = string> = (cmd: C) => Promise<Result<R, E>>;
162
+ //#endregion
163
+ //#region src/domain/aggregate/aggregate-address.d.ts
164
+ /**
165
+ * Stable value address of one aggregate instance.
44
166
  *
45
- * The previous shape (`IdGenerator { next<T extends string>(): Id<T> }`)
46
- * let callers pick `T` themselves: `gen.next<"AnyTag">()` typechecked
47
- * even when the generator produced different-tag ids, silently defeating
48
- * the brand.
167
+ * Aggregate ids are type-scoped, so the raw id alone is not globally unique:
168
+ * `SalesOrder 1` and `FulfillmentOrder 1` are different aggregates. Event
169
+ * streams, snapshots, committed-event sources, and projection checkpoints
170
+ * therefore carry both fields instead of defining boundary-specific variants.
171
+ *
172
+ * `aggregateType` is a stable technical stream category. Renaming it changes
173
+ * persistence keys and orphans checkpoints unless the stored addresses are
174
+ * migrated. When bounded contexts share infrastructure and reuse a domain
175
+ * name, qualify it at the source (`sales.order`, `fulfillment.order`). The kit
176
+ * deliberately adds no separate `boundedContext` field: qualification remains
177
+ * the consumer's naming decision.
49
178
  */
50
- interface IdGenerator<Tag extends string> {
51
- next: () => Id<Tag>;
179
+ interface AggregateAddress<TAggregateId extends string = string> {
180
+ readonly aggregateType: string;
181
+ readonly aggregateId: TAggregateId;
52
182
  }
53
183
  //#endregion
54
- //#region src/aggregate/clock.d.ts
184
+ //#region src/domain/event/clock.d.ts
55
185
  /**
56
186
  * Clock function producing a valid `Date` for the current instant.
57
187
  * Event-clock reads throw `TypeError` when the result is invalid.
58
188
  */
59
189
  type ClockFactory = () => Date;
60
190
  //#endregion
61
- //#region src/aggregate/domain-event.d.ts
191
+ //#region src/domain/event/domain-event.d.ts
62
192
  /**
63
193
  * Factory function producing a fresh, unique event identifier for each call.
64
194
  *
@@ -170,9 +300,11 @@ interface DomainEvent<T extends string, P = void> {
170
300
  * Use 1 for the initial schema version.
171
301
  *
172
302
  * This is the event PAYLOAD schema version, not a persisted aggregate
173
- * position. Commit positions live on `CommittedDomainEvent`.
303
+ * position. Commit positions live on `CommittedDomainEvent`. It is
304
+ * also not `AggregateSnapshot.schemaVersion`: that field versions the
305
+ * stored snapshot state shape. The two evolve independently.
174
306
  */
175
- readonly version: number;
307
+ readonly schemaVersion: number;
176
308
  /**
177
309
  * Optional metadata for traceability, correlation, and auditing.
178
310
  * Includes correlationId, conversationId, causationId, userId, source, and
@@ -200,7 +332,7 @@ interface UncommittedDomainEvent<T extends string, P = void> {
200
332
  readonly aggregateId?: string;
201
333
  readonly aggregateType?: string;
202
334
  readonly payload: P;
203
- readonly version: number;
335
+ readonly schemaVersion: number;
204
336
  }
205
337
  /** Upper-bound alias for any uncommitted domain-event shape. */
206
338
  type AnyUncommittedDomainEvent = UncommittedDomainEvent<string, unknown>;
@@ -212,7 +344,7 @@ type PendingDomainEvent<TEvent extends AnyDomainEvent> = TEvent | UncommittedDom
212
344
  interface CreateUncommittedDomainEventOptions {
213
345
  readonly aggregateId?: string;
214
346
  readonly aggregateType?: string;
215
- readonly version?: number;
347
+ readonly schemaVersion?: number;
216
348
  }
217
349
  /**
218
350
  * Shared option bag for the `createDomainEvent*` factories.
@@ -239,7 +371,7 @@ interface CreateDomainEventOptions {
239
371
  /**
240
372
  * Override for the default schema version (1).
241
373
  */
242
- version?: number;
374
+ schemaVersion?: number;
243
375
  /**
244
376
  * Event metadata: correlation, causation, user, source, custom fields.
245
377
  */
@@ -258,7 +390,7 @@ interface DomainEventStamp {
258
390
  interface CreateDomainEventFromFactsOptions extends DomainEventStamp {
259
391
  readonly aggregateId?: string;
260
392
  readonly aggregateType?: string;
261
- readonly version?: number;
393
+ readonly schemaVersion?: number;
262
394
  }
263
395
  /** Overrides accepted when an application-shell factory creates a stamp. */
264
396
  interface CreateDomainEventStampOptions {
@@ -272,6 +404,15 @@ interface DomainEventFactoryOptions {
272
404
  readonly eventIdFactory?: EventIdFactory;
273
405
  /** Event-recording clock. Defaults to `() => new Date()`. */
274
406
  readonly clock?: ClockFactory;
407
+ /**
408
+ * Origin stamped on every event this factory mints, unless the call site
409
+ * names one itself.
410
+ *
411
+ * A plain value, not a factory like the two above. Those produce a new
412
+ * value for each event. An origin identifies the system that mints them
413
+ * and does not change between two of them.
414
+ */
415
+ readonly source?: string;
275
416
  }
276
417
  /**
277
418
  * Instance-bound event constructor. Each factory permanently captures its
@@ -293,27 +434,6 @@ interface DomainEventFactory {
293
434
  */
294
435
  readonly now: () => Date;
295
436
  }
296
- /**
297
- * Creates an immutable, instance-bound domain-event factory.
298
- *
299
- * The supplied functions are read once and captured by value. The returned
300
- * object is frozen, so another request, test, or library cannot replace its
301
- * policy. Its {@link DomainEventFactory.createStamp} method is the
302
- * application-shell bridge that records an accepted aggregate decision.
303
- * Passing the factory through `AggregateConfig`
304
- * enables the explicitly named convenience methods, whose defaults read time
305
- * and randomness.
306
- *
307
- * @example
308
- * ```ts
309
- * const domainEvents = createDomainEventFactory({
310
- * eventIdFactory: () => uuidv7(),
311
- * clock: () => new Date(),
312
- * });
313
- * order.confirm();
314
- * recordPendingEvents(order, domainEvents);
315
- * ```
316
- */
317
437
  declare function createDomainEventFactory(options?: DomainEventFactoryOptions): DomainEventFactory;
318
438
  /**
319
439
  * Immutable UUID-v4/platform-clock factory used by the top-level
@@ -354,7 +474,7 @@ declare function createDomainEventFromFacts<T extends string, P>(type: T, payloa
354
474
  * const newEvent = createDomainEvent(
355
475
  * "OrderShipped",
356
476
  * { orderId: "123" },
357
- * { metadata: copyMetadata(previousEvent, { causationId: previousEvent.type }) }
477
+ * { metadata: copyMetadata(previousEvent, { causationId: previousEvent.eventId }) }
358
478
  * );
359
479
  * ```
360
480
  */
@@ -374,809 +494,502 @@ declare function copyMetadata(sourceEvent: AnyDomainEvent, additionalMetadata?:
374
494
  */
375
495
  declare function mergeMetadata(...metadataObjects: Array<EventMetadata | undefined>): EventMetadata;
376
496
  //#endregion
377
- //#region src/aggregate/aggregate.d.ts
378
- type Version = number & {
379
- readonly __v: true;
380
- };
497
+ //#region src/messaging/committed-event.d.ts
381
498
  /**
382
- * Snapshot of an aggregate state at a specific point in time.
383
- * Used for optimizing event replay by starting from a snapshot
384
- * instead of replaying all events from the beginning.
385
- *
386
- * @template TState - The type of the aggregate state
499
+ * Gap-proof position finalized by the event source at the persistence
500
+ * boundary. It is deliberately separate from `DomainEvent`: these values
501
+ * describe a stored commit, not the business fact itself.
387
502
  */
388
- interface AggregateSnapshot<TState> {
389
- /**
390
- * The state of the aggregate at the time of the snapshot.
391
- */
392
- readonly state: TState;
393
- /**
394
- * The version of the aggregate when the snapshot was taken.
395
- */
396
- readonly version: Version;
397
- /**
398
- * Timestamp when the snapshot was created.
399
- */
400
- readonly snapshotAt: Date;
503
+ interface CommitPosition {
504
+ /** Aggregate OCC version reached by this eventful commit. */
505
+ readonly aggregateVersion: number;
506
+ /** Zero-based event index inside this aggregate commit. */
507
+ readonly commitSequence: number;
508
+ /** Total number of events emitted by this aggregate commit. */
509
+ readonly commitSize: number;
401
510
  /**
402
- * Schema version of the stored `state` shape, declared by its adapter-owned
403
- * `SnapshotModel` and stamped by `captureAggregateSnapshot`. Distinct from
404
- * {@link version}, which counts mutations: this field says "which
405
- * shape does the stored state have", so a restore can detect a
406
- * snapshot written against an older DTO shape and migrate or
407
- * discard it instead of crashing later. Optional: absent on snapshots
408
- * written by older kit versions, which restore treats as schema `1`.
511
+ * Aggregate version of the immediately preceding EVENTFUL commit for this
512
+ * qualified aggregate source, or `null` when this is its first eventful
513
+ * commit. State-only persistence is intentionally absent from this chain.
514
+ *
515
+ * The outbox/event-store adapter owns this value. It must read and advance
516
+ * the source head atomically with inserting the committed event envelope;
517
+ * application orchestration cannot derive it from the Unit of Work's OCC
518
+ * receipt because state-only commits are intentionally absent here.
409
519
  */
410
- readonly schemaVersion?: number;
520
+ readonly previousEventfulAggregateVersion: number | null;
411
521
  }
412
522
  /**
413
- * Public contract every Aggregate Root satisfies. Implemented by
414
- * `BaseAggregate` and inherited by both `AggregateRoot` and
415
- * `EventSourcedAggregate`. Repository ports use this interface as their
416
- * aggregate type rather than depending on concrete base classes, so persistence
417
- * orchestration does not take a compile-time
418
- * dependency on the aggregate hierarchy.
419
- *
420
- * Full per-member documentation lives on the concrete `BaseAggregate`
421
- * class; the interface is intentionally terse to avoid drift. Persistence
422
- * facts are readable, but acknowledgement and pending-event disposal are not
423
- * part of this surface. `withCommit` and `UnitOfWork` hold that authority.
424
- *
425
- * @template TId - The aggregate root identifier (branded via `Id<Tag>`)
426
- * @template TEvent - The domain-event union, defaults to `never`
523
+ * Commit information known by the application transaction before the outbox
524
+ * source has linked this eventful commit to its predecessor.
427
525
  */
428
- interface IAggregateRoot<TId extends Id<string>, TEvent extends AnyDomainEvent = never> {
429
- readonly id: TId;
430
- readonly version: Version;
431
- readonly pendingEvents: ReadonlyArray<PendingDomainEvent<TEvent>>;
432
- }
526
+ type EventCommitCandidatePosition = Omit<CommitPosition, "previousEventfulAggregateVersion">;
433
527
  /**
434
- * Public contract for Event-Sourced Aggregate Roots. Extends
435
- * `IAggregateRoot` with the replay-from-history boundary.
436
- *
437
- * @template TId - The aggregate root identifier
438
- * @template TEvent - The union type of all domain events
528
+ * A bare domain event prepared for the transactional outbox. The outbox source
529
+ * owns the predecessor link and turns this candidate into a
530
+ * {@link CommittedDomainEvent} when it persists the record.
439
531
  */
440
- interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends IAggregateRoot<TId, TEvent> {
441
- /**
442
- * Reconstitutes the aggregate from an event history. Returns
443
- * `Result` because event-stream corruption is an expected
444
- * recoverable failure at the infrastructure boundary.
445
- */
446
- loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
532
+ interface EventCommitCandidate<Evt extends AnyDomainEvent> {
533
+ readonly event: Evt;
534
+ readonly source: AggregateAddress;
535
+ readonly position: EventCommitCandidatePosition;
447
536
  }
448
537
  /**
449
- * Checks if two aggregates are at the same version (same ID and version).
450
- * Useful for optimistic concurrency control checks.
451
- *
452
- * Note: Two aggregates with the same ID ARE the same aggregate (identity).
453
- * This function checks if they are at the same version: i.e., no concurrent modification.
454
- *
455
- * @example
456
- * ```typescript
457
- * const before = await repository.findById(id);
458
- * // ... some operations ...
459
- * const after = await repository.findById(id);
460
- *
461
- * if (!sameVersion(before, after)) {
462
- * throw new Error("Aggregate was modified by another process");
463
- * }
464
- * ```
538
+ * A domain event enriched after persistence has established its source and
539
+ * commit position. Outboxes and projectors consume this envelope; in-process
540
+ * domain handlers continue to consume the bare {@link DomainEvent} value.
465
541
  */
466
- declare function sameVersion<TId extends Id<string>>(a: {
467
- id: TId;
468
- version: Version;
469
- }, b: {
470
- id: TId;
471
- version: Version;
472
- }): boolean;
542
+ interface CommittedDomainEvent<Evt extends AnyDomainEvent> {
543
+ readonly event: Evt;
544
+ readonly source: AggregateAddress;
545
+ readonly position: CommitPosition;
546
+ }
473
547
  //#endregion
474
- //#region src/aggregate/aggregate-address.d.ts
475
- /**
476
- * Stable value address of one aggregate instance.
477
- *
478
- * Aggregate ids are type-scoped, so the raw id alone is not globally unique:
479
- * `SalesOrder 1` and `FulfillmentOrder 1` are different aggregates. Event
480
- * streams, snapshots, committed-event sources, and projection checkpoints
481
- * therefore carry both fields instead of defining boundary-specific variants.
482
- *
483
- * `aggregateType` is a stable technical stream category. Renaming it changes
484
- * persistence keys and orphans checkpoints unless the stored addresses are
485
- * migrated. When bounded contexts share infrastructure and reuse a domain
486
- * name, qualify it at the source (`sales.order`, `fulfillment.order`). The kit
487
- * deliberately adds no separate `boundedContext` field: qualification remains
488
- * the consumer's naming decision.
489
- */
490
- interface AggregateAddress<TAggregateId extends string = string> {
491
- readonly aggregateType: string;
492
- readonly aggregateId: TAggregateId;
548
+ //#region src/internal/async/execution.d.ts
549
+ /** Cancellation and deadline controls for one bounded shell operation. */
550
+ interface ExecutionContext {
551
+ /** Cooperative cancellation for the in-flight operation. */
552
+ readonly signal: AbortSignal;
553
+ /** Absolute Unix epoch millisecond at which the shell stops waiting. */
554
+ readonly deadlineAt: number;
493
555
  }
494
556
  //#endregion
495
- //#region src/entity/entity.d.ts
496
- /** A pure invariant check that throws when a candidate state is invalid. */
497
- type StateValidator<TState> = (state: TState) => void;
557
+ //#region src/messaging/outbox/ports.d.ts
498
558
  /**
499
- * Construction options shared by `Entity` and (via `AggregateConfig`) the
500
- * aggregate base classes.
559
+ * One pending event in the outbox plus the opaque id the implementation
560
+ * needs to ack it via `markDispatched`. The library does not prescribe
561
+ * what `dispatchId` looks like: an implementation can reuse the event's
562
+ * own `eventId`, generate its own UUID, use the row's auto-increment
563
+ * primary key, or whatever the storage layer prefers.
501
564
  */
502
- interface EntityConfig<TState = unknown> {
565
+ interface OutboxRecord<Evt extends AnyDomainEvent> extends CommittedDomainEvent<Evt> {
566
+ dispatchId: string;
503
567
  /**
504
- * Pure state-invariant validator captured by the entity instance. It runs
505
- * against the exact frozen state stored during construction and every
506
- * {@link Entity.setState} call. Throw to reject the candidate state.
507
- *
508
- * Passing validation as data avoids virtual dispatch from the base
509
- * constructor: the function cannot observe partly initialised subclass
510
- * fields through `this`. Close over immutable policy supplied to the
511
- * concrete constructor when validation needs instance-specific inputs.
568
+ * Failed delivery attempts so far. Populated by implementations that
569
+ * track dispatch failures (see {@link DispatchTrackingOutbox});
570
+ * plain `Outbox` implementations may omit it.
512
571
  */
513
- readonly validateState?: StateValidator<TState>;
572
+ attempts?: number;
573
+ }
574
+ /** A record that exhausted its delivery attempts; see {@link DispatchTrackingOutbox.deadLetters}. */
575
+ interface DeadLetterRecord<Evt extends AnyDomainEvent> extends CommittedDomainEvent<Evt> {
576
+ dispatchId: string;
577
+ /** Failed delivery attempts when the record was dead-lettered. */
578
+ attempts: number;
579
+ /** Human-readable rendering of the last delivery error, if recorded. */
580
+ lastError?: string;
581
+ }
582
+ /**
583
+ * Write half of the transactional outbox: the only outbox capability the
584
+ * write side (`withCommit`, `UnitOfWork`) depends on. Persisting the
585
+ * events atomically with the aggregate state is the kit's guarantee;
586
+ * DELIVERY is a separate, replaceable concern.
587
+ *
588
+ * Implement ONLY this interface to plug in an external delivery
589
+ * solution: `add()` writes into that solution's outbox storage inside
590
+ * the ambient transaction, and its own listener (polling or
591
+ * WAL/CDC-based, such as a Debezium-style connector, a delivery
592
+ * library, or a broker-native outbox) owns delivery entirely. The
593
+ * kit-side poll surface ({@link Outbox}) is then never involved. See
594
+ * the outbox guide, "External dispatchers".
595
+ */
596
+ interface OutboxWriter<Evt extends AnyDomainEvent> {
514
597
  /**
515
- * Opt-in: freeze the WHOLE state graph (via `deepFreeze`) instead of
516
- * the default shallow freeze. This protects against nested aliases
517
- * retained by constructor callers and against accidental in-place
518
- * writes inside the entity; live state itself is never public.
598
+ * Finalizes and persists event commit candidates. Called from inside
599
+ * `withCommit`'s transactional callback, atomically with the aggregate
600
+ * write.
601
+ *
602
+ * For every qualified aggregate source, the adapter must serialize source
603
+ * advancement, read its last eventful aggregate version, write that value as
604
+ * `previousEventfulAggregateVersion` on every event in the candidate's
605
+ * commit, and advance the source head to `aggregateVersion` in the SAME
606
+ * transaction. A state-only aggregate commit does not call `add()` and must
607
+ * therefore not advance this event-source head.
519
608
  *
520
- * Defaults to `false` (the documented shallow contract): deep freezing
521
- * costs a full state-graph walk on every state write, which is why it
522
- * is not the default on hot paths.
609
+ * A qualified source position `(aggregateType, aggregateId,
610
+ * aggregateVersion, commitSequence)` MUST identify one immutable event. All
611
+ * candidates for the same aggregate commit MUST also agree on `commitSize`.
612
+ * Enforce both constraints before advancing the source head; a conflicting
613
+ * retry must reject without replacing the stored event or changing the head.
523
614
  *
524
- * **Only for plain-data states.** The deep freeze walks the entire
525
- * graph: a class-based child entity inside the state would be frozen
526
- * too, and its own mutation methods would start throwing. States
527
- * carrying class-based children must keep the default shallow freeze.
528
- * Note that the ownership transfer widens accordingly: nested objects
529
- * passed into the constructor or `setState` are frozen IN PLACE (the
530
- * shallow copy protects only the top-level input object).
531
- */
532
- deepFreezeState?: boolean;
615
+ * **Idempotency:** implementations should dedupe on
616
+ * `candidate.event.eventId`. `withCommit` itself does not retry, but the
617
+ * surrounding use case (a queue consumer, an HTTP retry, a transactional
618
+ * outbox-dispatcher loop) may legitimately invoke the same write more than
619
+ * once. A unique-key constraint on `(eventId)` in the outbox table is the
620
+ * standard implementation; the source-head update and dedupe decision must
621
+ * share the transaction. Idempotency applies only to an exact candidate
622
+ * retry: the same event ID, qualified source, aggregate version, commit
623
+ * sequence, and commit size. Reusing an `eventId` for another source or
624
+ * position is a caller bug: adapters that retain the conflicting record
625
+ * should reject it rather than replace or silently reinterpret it as a retry.
626
+ */
627
+ add: (events: ReadonlyArray<EventCommitCandidate<Evt>>) => Promise<void>;
533
628
  }
534
629
  /**
535
- * Functional definition of an Entity via its capability: an object is
536
- * identifiable if it has an `id`.
630
+ * Transactional outbox port: the bridge between the write-side
631
+ * transaction and the (out-of-band) event dispatcher.
537
632
  *
538
- * `TId` is constrained to `Id<string>` so the brand discipline that
539
- * `Id<Tag>` enforces is preserved end-to-end: an `Identifiable<UserId>`
540
- * cannot accidentally be paired with an `Identifiable<OrderId>` or with
541
- * a plain `string`.
542
- */
543
- type Identifiable<TId extends Id<string>> = {
544
- readonly id: TId;
545
- };
546
- /**
547
- * Interface for Entities with state.
633
+ * Lifecycle:
634
+ * 1. `add()` inside the write transaction (`withCommit` calls this) so
635
+ * events persist atomically with the aggregate state
636
+ * ({@link OutboxWriter}, the only part the write side needs).
637
+ * 2. An outbox dispatcher (the kit's `OutboxDispatcher` or your own)
638
+ * polls `getPending()` and forwards the events to subscribers /
639
+ * external brokers.
640
+ * 3. After successful dispatch, the dispatcher calls `markDispatched()`
641
+ * with the records' `dispatchId`s so they don't come back next poll.
548
642
  *
549
- * In Domain-Driven Design, Entities have:
550
- * - Identity (id): Distinguishes one entity from another
551
- * - State: The attributes/properties of the entity
643
+ * `markDispatched` is required to be idempotent: calling it with an id
644
+ * that's already marked is a no-op, not an error. This lets the
645
+ * dispatcher safely retry on partial-failure.
552
646
  *
553
- * Unlike Value Objects (which are immutable and compared by value),
554
- * Entities are compared by identity and can have mutable state.
647
+ * **Competing dispatcher instances** are an adapter contract, not a
648
+ * dispatcher feature: a transactional implementation that should
649
+ * support several concurrent pollers must make `getPending` claim the
650
+ * returned records (`FOR UPDATE SKIP LOCKED` or equivalent). Without
651
+ * claiming, run one logical dispatcher per outbox.
555
652
  *
556
- * @template TId - The type of the entity identifier
653
+ * The bundled dispatcher supplies an {@link ExecutionContext} to every poll-side
654
+ * operation. Production adapters MUST pass its signal to native I/O or enforce
655
+ * a native timeout no later than `deadlineAt`; the shell can bound its wait but
656
+ * cannot terminate a promise that ignores cancellation. A timed-out write has
657
+ * an unknown outcome. Acknowledgements must remain idempotent when they complete
658
+ * late; a late failure update may count its original delivery attempt and must
659
+ * still no-op after the record was dispatched.
557
660
  */
558
- interface IEntity<TId extends Id<string>> extends Identifiable<TId> {
661
+ interface Outbox<Evt extends AnyDomainEvent> extends OutboxWriter<Evt> {
559
662
  /**
560
- * Unique identifier of the entity.
663
+ * Returns up to `limit` outbox records that have not yet been
664
+ * dispatched, **in the order `add()` persisted them** (commit order).
665
+ * The ordering is part of the port contract: `withCommit` promises
666
+ * subscribers per-aggregate causal order, and a sequential dispatcher
667
+ * can only honor that promise when this read is ordered. SQL-backed
668
+ * implementations need a monotonic position column (an auto-increment
669
+ * primary key works) and an `ORDER BY` on it; a bare `SELECT` returns
670
+ * rows in storage order, not insertion order. The dispatcher polls
671
+ * this on a schedule. When `limit` is omitted, the implementation
672
+ * decides on a default page size. The bundled dispatcher always supplies
673
+ * `context`; it is optional only so existing adapters remain assignable.
561
674
  */
562
- readonly id: TId;
675
+ getPending: (limit?: number, context?: ExecutionContext) => Promise<ReadonlyArray<OutboxRecord<Evt>>>;
676
+ /**
677
+ * Marks the given dispatch records as delivered so subsequent
678
+ * `getPending` calls don't return them. Must be idempotent on
679
+ * already-marked ids, including a late completion after the caller's
680
+ * storage deadline. The bundled dispatcher always supplies `context`.
681
+ */
682
+ markDispatched: (dispatchIds: ReadonlyArray<string>, context?: ExecutionContext) => Promise<void>;
563
683
  }
564
684
  /**
565
- * Abstract base class for Entities with state.
566
- *
567
- * Provides:
568
- * - Identity management (id)
569
- * - State management
570
- * - Instance-bound pure state validation
571
- * - Protected state access for domain behavior
572
- *
573
- * This is the foundation for all Entities in DDD:
574
- * - Child Entities within aggregates can extend this
575
- * - Aggregate Roots extend this and add version + events
576
- *
577
- * @template TState - The type of the entity state
578
- * @template TId - The type of the entity identifier
579
- *
580
- * @example
581
- * ```typescript
582
- * // Child Entity within an aggregate
583
- * const validateOrderItemState = (state: OrderItemState): void => {
584
- * if (state.quantity < 1) throw new Error("quantity must be positive");
585
- * };
586
- *
587
- * class OrderItem extends Entity<OrderItemState, ItemId> {
588
- * constructor(id: ItemId, initialState: OrderItemState) {
589
- * super(id, initialState, { validateState: validateOrderItemState });
590
- * }
685
+ * Optional extension of {@link Outbox} for dispatchers that track
686
+ * delivery failures. Without failure tracking, a poison message (an
687
+ * event whose delivery always throws) is redelivered forever: it comes
688
+ * back from every `getPending` poll, blocks per-aggregate ordering
689
+ * behind it, and burns the dispatcher's cycles. This extension gives
690
+ * the dispatcher a bounded-retry story: report each failed delivery via
691
+ * {@link markFailed}; the implementation moves records past its
692
+ * attempt ceiling to a dead-letter set that `getPending` no longer
693
+ * returns, and {@link deadLetters} exposes them for alerting, manual
694
+ * inspection, and redelivery (deliver by hand, then ack via
695
+ * `markDispatched`, which also clears dead-lettered records).
591
696
  *
592
- * updateQuantity(quantity: number): void {
593
- * // setState runs validateState and re-freezes; a direct
594
- * // `this._state = ...` assignment would skip both.
595
- * this.setState({ ...this.state, quantity });
596
- * }
597
- * }
598
- * ```
697
+ * See the outbox guide's dispatcher recipe for the retry-then-dead-letter
698
+ * loop this port shape supports.
599
699
  */
600
- declare abstract class Entity<TState, TId extends Id<string>> implements IEntity<TId> {
601
- readonly id: TId;
700
+ interface DispatchTrackingOutbox<Evt extends AnyDomainEvent> extends Outbox<Evt> {
602
701
  /**
603
- * Returns the live state to subclass domain behavior.
604
- *
605
- * This accessor is deliberately protected: returning the generic
606
- * `TState` publicly would expose the aggregate's live object graph and
607
- * let nested mutation bypass behavior, validation, versioning, and
608
- * dirty tracking. Concrete entities should expose business-meaningful queries or
609
- * detached immutable DTOs. Snapshot projection belongs to an adapter-owned
610
- * `SnapshotModel`; persistence code captures an aggregate with
611
- * `captureAggregateSnapshot(model, aggregate, snapshotAt)` rather than
612
- * asking the entity to create its own persistence memento.
613
- */
614
- protected get state(): TState;
615
- /**
616
- * The state is `protected` so that only the subclass can modify it.
617
- * Ordinary entity behavior must use {@link setState}; direct assignment
618
- * skips instance-bound validation. Kit event-sourcing internals use direct
619
- * assignment deliberately because historical evolution must not run
620
- * today's decision validator.
621
- */
622
- protected _state: TState;
623
- private readonly _deepFreezeState;
624
- private readonly validateState;
625
- /**
626
- * **State ownership.** Plain-object and array states are shallow-copied
627
- * before the freeze, so the caller's own object stays mutable. A CLASS
628
- * INSTANCE passed as state is an ownership transfer: it is frozen
629
- * in place (a copy would strip its prototype). Do not keep mutating
630
- * the instance after handing it to the entity. The same contract
631
- * applies to {@link setState}. With
632
- * {@link EntityConfig.deepFreezeState} enabled, the ownership transfer
633
- * widens to the whole graph: NESTED objects are frozen in place too.
634
- *
635
- * @throws HostileStateKeyError when a plain-object, null-prototype,
636
- * or array state carries an own `"__proto__"` data key; validate and
637
- * strip untrusted input at the boundary.
638
- */
639
- protected constructor(id: TId, initialState: TState, config?: EntityConfig<TState>);
640
- /**
641
- * Freezes a state value according to this entity's configured freeze
642
- * mode: the default shallow freeze, or `deepFreeze` when
643
- * {@link EntityConfig.deepFreezeState} was enabled at construction.
644
- * Infrastructure-style subclass code that deliberately assigns
645
- * `this._state` directly must freeze through this method, not
646
- * `freezeShallow`, or the opt-in silently degrades to shallow for that
647
- * path. Ordinary domain behavior should use {@link setState} instead.
648
- */
649
- protected freezeState(value: TState): TState;
650
- /**
651
- * Sets the state of the entity.
652
- * This is a convenience method for state mutations.
653
- * Automatically validates `newState` with the instance-bound
654
- * {@link EntityConfig.validateState} function.
655
- *
656
- * Plain-object and array states are shallow-copied before the freeze
657
- * (the caller's object stays mutable); a class-instance state is an
658
- * ownership transfer and is frozen in place; see the constructor.
659
- *
660
- * @param newState - The new state
661
- * @throws HostileStateKeyError when the state carries an own
662
- * `"__proto__"` data key; the previous state is kept.
702
+ * Records one failed delivery attempt for the given record:
703
+ * increments its attempt count (surfaced as
704
+ * {@link OutboxRecord.attempts}) and, once the implementation's
705
+ * ceiling is reached, moves the record to the dead-letter set.
706
+ * A no-op for unknown or already-dispatched ids (a late failure
707
+ * report after a successful retry must not resurrect the record).
708
+ * Returns the exact dead-letter record only on the call that performs
709
+ * that transition; retries below the ceiling and no-ops return
710
+ * `undefined`. This lets productive pollers emit an immediate signal
711
+ * without scanning the durable dead-letter set after every failure. A late
712
+ * completion may count that original delivery attempt; it must still no-op if
713
+ * the record was dispatched in the meantime. The bundled dispatcher never
714
+ * reissues the same store call and always supplies `context`.
715
+ */
716
+ markFailed: (dispatchId: string, error?: unknown, context?: ExecutionContext) => Promise<DeadLetterRecord<Evt> | undefined>;
717
+ /**
718
+ * Records that exhausted their delivery attempts. They no longer
719
+ * come back from `getPending`; wire this to durable alerting and
720
+ * reconciliation so poison messages surface even if the poller stops
721
+ * between this store transition and its immediate observer callback.
663
722
  */
664
- protected setState(newState: TState): void;
723
+ deadLetters: () => Promise<ReadonlyArray<DeadLetterRecord<Evt>>>;
665
724
  }
725
+ //#endregion
726
+ //#region src/application/cqrs/command/command-outbox.d.ts
666
727
  /**
667
- * Shallow-freezes `value` when it's a non-null object or array, so that
668
- * direct property writes throw in strict mode. Returns the value as-is for
669
- * primitives. Used internally by `Entity` (via `freezeState`, which picks
670
- * shallow or deep per the `deepFreezeState` config) to prevent outside
671
- * mutation of state read through the `state` getter without paying the
672
- * cost of a deep clone on every read.
673
- *
674
- * Subclass code that assigns `this._state` directly should freeze through
675
- * the protected `freezeState(value)` method rather than calling this
676
- * helper, so the configured freeze mode is honored. The export remains
677
- * for consumers using it as a standalone utility.
728
+ * Business relationships and technical trace context selected explicitly for
729
+ * an outgoing command. Correlation/conversation explain the business flow;
730
+ * W3C Trace Context connects technical spans.
678
731
  */
679
- declare function freezeShallow<T>(value: T): T;
732
+ interface CommandMessageRelationships {
733
+ /** Groups messages that belong to one operation or trace. */
734
+ readonly correlationId?: string;
735
+ /** Groups every message in one long-running business interaction. */
736
+ readonly conversationId?: string;
737
+ /** W3C Trace Context parent for technical distributed tracing. */
738
+ readonly traceparent?: string;
739
+ /** Optional vendor trace state associated with `traceparent`. */
740
+ readonly tracestate?: string;
741
+ }
680
742
  /**
681
- * Checks if two entities have the same ID.
682
- * Works with any object that has an 'id' property.
683
- *
684
- * @param a - First entity
685
- * @param b - Second entity
686
- * @returns true if both entities have the same ID, false otherwise
687
- *
688
- * @example
689
- * ```typescript
690
- * const item1: OrderItem = { id: itemId1, productId: "prod-1", quantity: 2 };
691
- * const item2: OrderItem = { id: itemId2, productId: "prod-2", quantity: 1 };
743
+ * Application-owned Published Language produced from one private domain or
744
+ * process event. `destination` names one receiver contract; it is deliberately
745
+ * required because a command is an instruction, not a broadcast fact.
692
746
  *
693
- * sameEntity(item1, item2); // false
694
- * sameEntity(item1, item1); // true
695
- * ```
747
+ * The command carries a stable schema `version` and JSON-safe `payload`.
748
+ * Domain value objects are translated to wire DTOs by the mapper before this
749
+ * boundary.
696
750
  */
697
- declare function sameEntity<TId extends Id<string>>(a: Identifiable<TId>, b: Identifiable<TId>): boolean;
751
+ interface CommandMessageContent<C extends PublishedCommand> extends CommandMessageRelationships {
752
+ readonly destination: string;
753
+ readonly command: C;
754
+ }
698
755
  /**
699
- * Finds an entity by ID in a collection.
700
- * Returns undefined if not found.
701
- *
702
- * @param entities - Array of entities to search
703
- * @param id - The ID to search for
704
- * @returns The entity if found, undefined otherwise
705
- *
706
- * @example
707
- * ```typescript
708
- * const items: OrderItem[] = [
709
- * { id: itemId1, productId: "prod-1", quantity: 2 },
710
- * { id: itemId2, productId: "prod-2", quantity: 1 }
711
- * ];
756
+ * Immutable, JSON-safe command envelope stored for later at-least-once
757
+ * delivery.
712
758
  *
713
- * const item = findEntityById(items, itemId1);
714
- * // item is { id: itemId1, productId: "prod-1", quantity: 2 }
715
- * ```
759
+ * `causationId` always identifies the private event whose accepted decision
760
+ * requested this command. The mapper cannot replace it with a weaker
761
+ * correlation. Consumer-produced events should in turn use `messageId` as
762
+ * their causation id.
716
763
  */
717
- declare function findEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): T | undefined;
764
+ interface DurableCommandMessage<C extends PublishedCommand> extends CommandMessageContent<C> {
765
+ readonly messageId: string;
766
+ readonly recordedAt: string;
767
+ readonly causationId: string;
768
+ }
718
769
  /**
719
- * Checks if an entity with the given ID exists in the collection.
720
- *
721
- * @param entities - Array of entities to search
722
- * @param id - The ID to check for
723
- * @returns true if an entity with the ID exists, false otherwise
724
- *
725
- * @example
726
- * ```typescript
727
- * const items: OrderItem[] = [
728
- * { id: itemId1, productId: "prod-1", quantity: 2 }
729
- * ];
730
- *
731
- * hasEntityId(items, itemId1); // true
732
- * hasEntityId(items, itemId2); // false
733
- * ```
770
+ * Receipt for the private event that requested one command batch. It retains
771
+ * commit identity and ordering without putting the private event or its
772
+ * payload into the command outbox.
734
773
  */
735
- declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): boolean;
774
+ interface CommandCommitOriginCandidate {
775
+ readonly eventId: string;
776
+ readonly source: AggregateAddress;
777
+ readonly position: EventCommitCandidatePosition;
778
+ }
736
779
  /**
737
- * Removes an entity with the given ID from the collection. Returns the
738
- * ORIGINAL array when the id is absent (structural sharing for the
739
- * reference-based dirty tracking; see `updateEntityById`), otherwise a
740
- * new array without the entity.
741
- *
742
- * @param entities - Array of entities
743
- * @param id - The ID of the entity to remove
744
- * @returns A new array without the entity with the given ID
780
+ * One private process-event commit and the exact commands it requested.
781
+ * `messages` may be empty: the receipt still advances the originating source
782
+ * and makes an exact retry distinguishable from a missing commit.
783
+ */
784
+ interface CommandOutboxCommitCandidate<C extends PublishedCommand> {
785
+ readonly origin: CommandCommitOriginCandidate;
786
+ readonly messages: ReadonlyArray<DurableCommandMessage<C>>;
787
+ }
788
+ /**
789
+ * Write port for a dedicated transactional command outbox.
745
790
  *
746
- * @example
747
- * ```typescript
748
- * const items: OrderItem[] = [
749
- * { id: itemId1, productId: "prod-1", quantity: 2 },
750
- * { id: itemId2, productId: "prod-2", quantity: 1 }
751
- * ];
791
+ * The adapter is bound to the same ambient transaction as the aggregate or
792
+ * event-stream repository. It must persist the complete input atomically,
793
+ * retain input order, deduplicate exact retries by `origin.eventId`, and reject
794
+ * a reused origin id whose source, position, or messages differ. It also owns
795
+ * the durable source cursor represented by `origin.position`; an empty command
796
+ * batch still advances that cursor.
752
797
  *
753
- * const updated = removeEntityById(items, itemId1);
754
- * // updated is [{ id: itemId2, productId: "prod-2", quantity: 1 }]
755
- * ```
798
+ * Delivery is out of band and at least once. A consumer therefore uses
799
+ * `message.messageId` as its idempotency key and acknowledges only after the
800
+ * command result has been stored.
756
801
  */
757
- declare function removeEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): ReadonlyArray<T>;
802
+ interface CommandOutboxWriter<C extends PublishedCommand> {
803
+ add(commits: ReadonlyArray<CommandOutboxCommitCandidate<C>>): Promise<void>;
804
+ }
805
+ /** Maps one private accepted event to zero or more addressed commands. */
806
+ type CommandOutboxMapper<Evt extends AnyDomainEvent, C extends PublishedCommand> = (event: Evt) => ReadonlyArray<CommandMessageContent<C>>;
758
807
  /**
759
- * Updates an entity with the given ID in the collection.
760
- * Returns a new array with the updated entity.
761
- * Structural sharing for adapter-owned persistence projections: returns
762
- * the ORIGINAL array when nothing changed (no match, or the element kept
763
- * its reference), so a partial-write adapter can skip the untouched
764
- * collection; a new array only when an
765
- * element reference actually changed. The result is `ReadonlyArray<T>`:
766
- * it may BE the (possibly frozen) input; spread it if you need a mutable
767
- * copy.
768
- *
769
- * @param entities - Array of entities
770
- * @param id - The ID of the entity to update
771
- * @param updater - Function that takes the entity and returns the updated entity
772
- * @returns A new array with the updated entity
808
+ * Adapts a dedicated command outbox to the event-candidate write port consumed
809
+ * by `withCommit`.
810
+ *
811
+ * Mapping happens inside the transaction, before the command outbox write.
812
+ * The private event is used only at this boundary and is reduced to an origin
813
+ * receipt. The helper never publishes it or copies its payload implicitly; the
814
+ * application mapper selects and translates the data that belongs in the
815
+ * versioned Published Language. The route rejects values JSON would lose or
816
+ * change before it calls the adapter.
817
+ * Every command gets a stable id derived from the event id and its zero-based
818
+ * order, so an exact transaction retry produces the same rows.
773
819
  *
774
- * @example
775
- * ```typescript
776
- * const items: OrderItem[] = [
777
- * { id: itemId1, productId: "prod-1", quantity: 2 }
778
- * ];
779
- *
780
- * const updated = updateEntityById(items, itemId1, (item) => ({
781
- * ...item,
782
- * quantity: item.quantity + 1
783
- * }));
784
- * // updated is [{ id: itemId1, productId: "prod-1", quantity: 3 }]
785
- * ```
820
+ * Omit `withCommit`'s in-process `bus` for private process events. Participants
821
+ * consume the durable command messages from their explicitly named
822
+ * destinations, while event-stream replay only rebuilds process state.
786
823
  */
787
- declare function updateEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, updater: (entity: T) => T): ReadonlyArray<T>;
824
+ declare function routeEventsToCommandOutbox<C extends PublishedCommand, Evt extends AnyDomainEvent = AnyDomainEvent>(outbox: CommandOutboxWriter<C>, mapper: CommandOutboxMapper<Evt, C>): OutboxWriter<Evt>;
825
+ //#endregion
826
+ //#region src/domain/identity/id.d.ts
788
827
  /**
789
- * Replaces an entity with the given ID in the collection.
790
- * Returns a new array with the replaced entity.
791
- * Structural sharing for adapter-owned persistence projections: returns
792
- * the ORIGINAL array when nothing changed (no match, or the element kept
793
- * its reference), so a partial-write adapter can skip the untouched
794
- * collection; a new array only when an
795
- * element reference actually changed. The result is `ReadonlyArray<T>`:
796
- * it may BE the (possibly frozen) input; spread it if you need a mutable
797
- * copy.
798
- *
799
- * @param entities - Array of entities
800
- * @param id - The ID of the entity to replace
801
- * @param replacement - The replacement entity
802
- * @returns A new array with the replaced entity
828
+ * Branded string ID. `Tag` carries the aggregate / entity name so two ids
829
+ * with different tags are not assignable to each other even though both
830
+ * are strings at runtime.
803
831
  *
804
832
  * @example
805
- * ```typescript
806
- * const items: OrderItem[] = [
807
- * { id: itemId1, productId: "prod-1", quantity: 2 }
808
- * ];
809
- *
810
- * const updated = replaceEntityById(items, itemId1, {
811
- * id: itemId1,
812
- * productId: "prod-1",
813
- * quantity: 5
814
- * });
833
+ * ```ts
834
+ * type UserId = Id<"UserId">;
835
+ * type OrderId = Id<"OrderId">;
836
+ *
837
+ * const u = "user-1" as UserId;
838
+ * const o: OrderId = u; // ❌ compile error
815
839
  * ```
816
840
  */
817
- declare function replaceEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, replacement: T): ReadonlyArray<T>;
841
+ type Id<Tag extends string> = string & {
842
+ readonly __brand: Tag;
843
+ };
818
844
  /**
819
- * Extracts all IDs from a collection of entities.
845
+ * Produces fresh ids of a single, fixed tag. The tag is bound at the
846
+ * generator type: `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
847
+ * with no caller-side generic to abuse.
820
848
  *
821
- * @param entities - Array of entities
822
- * @returns Array of entity IDs
849
+ * **Your factory must produce unique ids under concurrent calls.**
850
+ * The kit makes no attempt to dedupe or detect collisions: a collision
851
+ * silently overwrites earlier rows (under unique-key constraints) or
852
+ * silently aliases two different entities (without them). Safe choices:
853
+ * `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
854
+ * KSUID: all collision-resistant by design. Unsafe choices: `Date.now()`
855
+ * alone (duplicates within the same millisecond), a process-local
856
+ * counter without persistence (resets to 1 on restart, collides with
857
+ * prior runs), a sequential id derived from non-atomic state.
823
858
  *
824
859
  * @example
825
- * ```typescript
826
- * const items: OrderItem[] = [
827
- * { id: itemId1, productId: "prod-1", quantity: 2 },
828
- * { id: itemId2, productId: "prod-2", quantity: 1 }
829
- * ];
860
+ * ```ts
861
+ * import { ulid } from "ulid";
830
862
  *
831
- * const ids = entityIds(items);
832
- * // ids is [itemId1, itemId2]
863
+ * const userIds: IdGenerator<"UserId"> = { next: () => ulid() as Id<"UserId"> };
864
+ * const id = userIds.next(); // Id<"UserId">
833
865
  * ```
866
+ *
867
+ * The previous shape (`IdGenerator { next<T extends string>(): Id<T> }`)
868
+ * let callers pick `T` themselves: `gen.next<"AnyTag">()` typechecked
869
+ * even when the generator produced different-tag ids, silently defeating
870
+ * the brand.
834
871
  */
835
- declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>): TId[];
872
+ interface IdGenerator<Tag extends string> {
873
+ next: () => Id<Tag>;
874
+ }
836
875
  //#endregion
837
- //#region src/aggregate/base-aggregate.d.ts
838
- /** Construction options shared by state-stored and event-sourced aggregates. */
839
- type AggregateConfig<TState = unknown> = EntityConfig<TState>;
876
+ //#region src/domain/aggregate/aggregate.d.ts
877
+ type Version = number & {
878
+ readonly __v: true;
879
+ };
880
+ /**
881
+ * Brands a stored number as an aggregate {@link Version}. A version is a
882
+ * safe integer of at least zero. Use it in repository adapters instead of
883
+ * a cast, so a corrupt row value fails here with {@link InvalidVersionError}
884
+ * and never reaches the optimistic-concurrency cursor.
885
+ */
886
+ declare function toVersion(value: number): Version;
840
887
  /**
841
- * Shared base for both `AggregateRoot` (state-stored) and
842
- * `EventSourcedAggregate`. Carries the lifecycle machinery that's
843
- * identical across the two flavours: current version, pending-event
844
- * tracking, the kit-internal post-commit acknowledgement capability,
845
- * the `markRestored` post-load marker, and the `createEvent` helper
846
- * that auto-injects `aggregateId` + `aggregateType` on every event the
847
- * aggregate emits. The application shell records the pending decisions
848
- * with `recordPendingEvents` before persistence.
849
- *
850
- * Consumers do NOT extend this class directly; extend
851
- * `AggregateRoot` for state-stored aggregates or
852
- * `EventSourcedAggregate` for event-sourced ones. The split between
853
- * those two reflects the canonical Vernon §8 (state-stored) /
854
- * Vernon §11 + Greg Young (event-sourced) distinction in how state
855
- * is represented; the lifecycle machinery is the same for both.
888
+ * Snapshot of an aggregate state at a specific point in time.
889
+ * Used for optimizing event replay by starting from a snapshot
890
+ * instead of replaying all events from the beginning.
856
891
  *
857
892
  * @template TState - The type of the aggregate state
858
- * @template TId - The aggregate root identifier
859
- * @template TEvent - The domain-event union. Defaults to `never` so
860
- * aggregates without a declared event type cannot emit events
861
- * (emitting any event becomes a compile error).
862
893
  */
863
- declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
864
- /**
865
- * The aggregate's domain type as a string, used to populate
866
- * `aggregateType` on events created via {@link createEvent}.
867
- *
868
- * Subclasses MUST declare this as a string literal:
869
- *
870
- * ```ts
871
- * class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
872
- * protected readonly aggregateType = "Order";
873
- * }
874
- * ```
875
- *
876
- * The string is *the* identifier downstream consumers (outbox
877
- * dispatchers, projection handlers, audit logs) use to route by
878
- * aggregate kind. Use the same canonical name across your system;
879
- * matching the class name is the obvious choice, but the value
880
- * comes from this explicit declaration, not `constructor.name`
881
- * (which is fragile under minification, bundler transforms, and
882
- * subclass renaming).
883
- */
884
- protected abstract readonly aggregateType: string;
885
- private _version;
886
- /**
887
- * Version the persistence layer last confirmed for this instance:
888
- * `undefined` until the aggregate is reconstituted (`markRestored`) or a
889
- * commit is acknowledged. Kit-internal via the lifecycle capability; it
890
- * grounds the `withCommit` unique-cursor guard so an eventful commit that
891
- * did not advance beyond the persisted row is rejected deterministically.
892
- */
893
- private _persistedVersion;
894
- private _pendingEvents;
895
- protected constructor(id: TId, initialState: TState, config?: AggregateConfig<TState>);
896
- private acknowledgePendingEvents;
897
- /**
898
- * Post-commit cleanup for the deleted disposition. The row is gone, so
899
- * there is no persisted version to advance: stamping the marker from the
900
- * live instance would make a later legitimate re-enrollment of this
901
- * instance trip the unique-cursor guard for a row that does not exist.
902
- */
903
- private discardPendingEventsAfterDeletion;
904
- private stripAcknowledgedPrefix;
905
- get version(): Version;
906
- /**
907
- * Read-only list of domain events recorded on this aggregate that
908
- * have not yet been flushed to the outbox / persistence layer.
909
- */
910
- get pendingEvents(): ReadonlyArray<PendingDomainEvent<TEvent>>;
911
- /**
912
- * Count-only accessor for internal aggregate paths: the public
913
- * {@link pendingEvents} getter allocates and freezes
914
- * a defensive copy per read, which a length check does not need.
915
- */
916
- protected get pendingEventCount(): number;
917
- protected setVersion(version: Version): void;
918
- /**
919
- * Manually bumps the aggregate version. Used by state-stored
920
- * aggregates' `setState()` / `commit()` paths and by the
921
- * event-sourced replay path after each applied event.
922
- */
923
- protected bumpVersion(): void;
924
- /**
925
- * **Lifecycle marker, Post-Load.** Syncs both `_version` and
926
- * the current version to the stored version. Used by
927
- * `reconstitute(...)` factories to assemble an in-memory aggregate
928
- * from a persisted row.
929
- *
930
- * The Factory-vs-Reconstitution distinction (Vernon §11) is honoured
931
- * structurally: reconstitution stays inside the aggregate factory while
932
- * post-commit acknowledgement belongs to application commit orchestration.
933
- *
934
- * If you override this, call `super.markRestored(version)` so the current
935
- * domain version remains aligned with the reconstituted facts.
936
- *
937
- * @param version - The version the row currently holds in the DB
938
- *
939
- * @example
940
- * ```ts
941
- * static reconstitute(id: OrderId, state: OrderState, version: Version): Order {
942
- * const order = new Order(id, state);
943
- * order.markRestored(version);
944
- * return order;
945
- * }
946
- * ```
947
- */
948
- protected markRestored(version: Version): void;
949
- /**
950
- * Appends a domain event to the pending list. Prefer the higher-level
951
- * `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
952
- * (event-sourced) call sites, both of which wrap `addDomainEvent` in the
953
- * canonical record-AFTER-mutation order (Vernon §8). Calling
954
- * `addDomainEvent` directly is appropriate only after a version-advancing
955
- * state mutation, or while constructing a never-persisted aggregate.
956
- * An event-only commit on an already-persisted aggregate has no unique
957
- * cursor and `withCommit` rejects it; use `commit(currentState, event)`.
958
- */
959
- protected addDomainEvent(event: PendingDomainEvent<TEvent>): void;
894
+ interface AggregateSnapshot<TState> {
960
895
  /**
961
- * Immutability gate for every recording path: only events minted by
962
- * the kit's constructors (`createDomainEvent`,
963
- * `createDomainEventFromFacts`, `createEvent`) pass,
964
- * checked against the constructor's internal, unforgeable mint
965
- * marker. Minted implies deeply frozen with defensively copied
966
- * payload and metadata, a guarantee no frozen-ness probe can
967
- * establish (a shallow-frozen literal with mutable nested data
968
- * would fool it). O(1): one WeakSet lookup.
896
+ * The state of the aggregate at the time of the snapshot.
969
897
  */
970
- protected assertMintedEvent(event: PendingDomainEvent<TEvent>): void;
898
+ readonly state: TState;
971
899
  /**
972
- * Creates the immutable business fact accepted by this aggregate without
973
- * reading a clock, generating an id, or attaching tracing metadata.
974
- *
975
- * The application shell records pending events after the domain operation
976
- * and before persistence. Payload schema version stays here, next to the
977
- * concrete event producer, rather than in shell-owned recording data.
900
+ * The version of the aggregate when the snapshot was taken.
978
901
  */
979
- protected createEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateUncommittedDomainEventOptions, "aggregateId" | "aggregateType">): UncommittedDomainEventOf<E>;
980
- }
981
- //#endregion
982
- //#region src/aggregate/aggregate-root.d.ts
983
- /**
984
- * OO-first Aggregate Root for state-stored domain models.
985
- *
986
- * The aggregate owns identity, valid domain state, behavior, its current
987
- * domain version, and pending domain events. It deliberately does not own a
988
- * database baseline or dirty-key bookkeeping. A repository adapter defines
989
- * its persistence projection through `PersistenceModel`; the Unit of Work
990
- * retains that opaque baseline and derives the adapter's change set at flush.
991
- */
992
- declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends BaseAggregate<TState, TId, TEvent> {
902
+ readonly version: Version;
993
903
  /**
994
- * Changes state and records the resulting facts in record-after-mutation
995
- * order. Validation and event mint checks run before the transition becomes
996
- * observable, so a rejected decision records nothing.
904
+ * Timestamp when the snapshot was created.
997
905
  */
998
- protected commit(newState: TState, events?: PendingDomainEvent<TEvent> | readonly PendingDomainEvent<TEvent>[]): void;
999
- /** Every normal domain-state transition advances the OCC version. */
1000
- protected setState(newState: TState): void;
906
+ readonly snapshotAt: Date;
1001
907
  /**
1002
- * Replaces loss-tolerant derived state without advancing the domain version.
1003
- *
1004
- * This is intentionally loud: concurrent writers may overwrite such a
1005
- * change. Keep business facts on the normal `setState`/`commit` path.
908
+ * Schema version of the stored `state` shape, declared and stamped by
909
+ * the persistence adapter that captures the snapshot. Distinct from
910
+ * {@link version}, which counts mutations: this field says "which
911
+ * shape does the stored state have", so a restore can detect a
912
+ * snapshot written against an older DTO shape and migrate or
913
+ * discard it instead of crashing later. Optional: a snapshot without
914
+ * this field restores as schema `1`. Distinct also from
915
+ * `DomainEvent.schemaVersion`, which versions one event payload shape.
916
+ * A payload change and a snapshot state change bump their own field.
1006
917
  */
1007
- protected setStateWithoutVersionBump(newState: TState): void;
918
+ readonly schemaVersion?: number;
1008
919
  }
1009
- //#endregion
1010
- //#region src/events/json-value.d.ts
1011
- /** A primitive value represented without loss by JSON. */
1012
- type JsonPrimitive = boolean | null | number | string;
1013
- /** A recursively JSON-safe value. Runtime validation rejects lossy shapes. */
1014
- type JsonValue = JsonPrimitive | ReadonlyArray<JsonValue> | {
1015
- readonly [key: string]: JsonValue;
1016
- };
1017
- /** A JSON-safe object. */
1018
- type JsonObject = {
1019
- readonly [key: string]: JsonValue;
1020
- };
1021
- //#endregion
1022
- //#region src/app/command.d.ts
1023
920
  /**
1024
- * Marker interface for Commands.
1025
- * Commands represent write operations that change system state.
1026
- * They should be immutable and contain all data needed to perform the operation.
1027
- *
1028
- * This interface can be used as a type marker even when using external frameworks
1029
- * (e.g., RabbitMQ, AWS SQS) to ensure type safety across different bus implementations.
1030
- *
1031
- * @example
1032
- * ```typescript
1033
- * type CreateOrderCommand = Command & {
1034
- * type: "CreateOrder";
1035
- * customerId: string;
1036
- * items: OrderItem[];
1037
- * };
1038
- * ```
1039
- *
1040
- * @example Using with external frameworks (RabbitMQ, etc.)
1041
- * ```typescript
1042
- * // Define command using Command marker
1043
- * type CreateOrderCommand = Command & {
1044
- * type: "CreateOrder";
1045
- * customerId: string;
1046
- * };
1047
- *
1048
- * // Handler can be typed with CommandHandler even for external frameworks
1049
- * const handler: CommandHandler<CreateOrderCommand, OrderId> = async (cmd) => {
1050
- * // ... handler logic
1051
- * return ok(orderId);
1052
- * };
1053
- *
1054
- * // The consumer owns this runtime decoder. It checks byte and collection
1055
- * // ceilings, parses to unknown, allow-lists fields, and constructs domain types.
1056
- * declare function decodeCreateOrderCommand(
1057
- * body: Uint8Array,
1058
- * principal: AuthenticatedPrincipal,
1059
- * ): Result<CreateOrderCommand, InvalidCommand>;
1060
- * declare function decodeMessageId(
1061
- * value: unknown,
1062
- * ): Result<string, InvalidTransportMetadata>;
1063
- * declare function createOrderDeliveryKey(messageId: string): string;
921
+ * Public contract every Aggregate Root satisfies. Implemented by
922
+ * `BaseAggregate` and inherited by both `StateStoredAggregate` and
923
+ * `EventSourcedAggregate`. Repository ports use this interface as their
924
+ * aggregate type rather than depending on concrete base classes, so persistence
925
+ * orchestration does not take a compile-time
926
+ * dependency on the aggregate hierarchy.
1064
927
  *
1065
- * // This application service invokes the handler through withIdempotentCommit.
1066
- * // createOrderDeliveryKey scopes the message id by consumer. The service
1067
- * // fingerprints the complete CreateOrder intention and commits that claim,
1068
- * // the aggregate write, outbox entries, and outcome together.
1069
- * declare function executeIdempotentCreateOrder(
1070
- * deliveryKey: string,
1071
- * command: CreateOrderCommand,
1072
- * ): Promise<Result<OrderId, string>>;
928
+ * Full per-member documentation lives on the concrete `BaseAggregate`
929
+ * class; the interface is intentionally terse to avoid drift. Persistence
930
+ * facts are readable, but acknowledgement and pending-event disposal are not
931
+ * part of this surface. The application shell holds that authority.
1073
932
  *
1074
- * // Register with RabbitMQ or another external bus.
1075
- * rabbitMQChannel.consume("order.commands", async (message) => {
1076
- * const messageId = decodeMessageId(message.properties.messageId);
1077
- * if (messageId.isErr()) {
1078
- * rabbitMQChannel.reject(message, false); // missing identity: dead-letter
1079
- * return;
1080
- * }
1081
- * const deliveryKey = createOrderDeliveryKey(messageId.value);
1082
- * const principal = authenticateProducer(message.properties.headers);
1083
- * const decoded = decodeCreateOrderCommand(message.content, principal);
1084
- * if (decoded.isErr()) {
1085
- * rabbitMQChannel.reject(message, false); // invalid input: dead-letter, do not retry
1086
- * return;
1087
- * }
1088
- * const outcome = await executeIdempotentCreateOrder(
1089
- * deliveryKey,
1090
- * decoded.value,
1091
- * );
1092
- * await recordCommandOutcome(deliveryKey, outcome);
1093
- * rabbitMQChannel.ack(message);
1094
- * });
1095
- * ```
1096
- */
1097
- interface Command {
1098
- readonly type: string;
1099
- }
1100
- /**
1101
- * Versioned Published Language for a command that crosses a process or
1102
- * Bounded-Context boundary. Unlike a local {@link Command}, its payload is
1103
- * JSON-safe data rather than a domain object graph. Map value objects to their
1104
- * wire DTOs at this boundary; for example, use `MoneyDto` instead of `Money`.
933
+ * @template TId - The aggregate root identifier (branded via `Id<Tag>`)
934
+ * @template TEvent - The domain-event union, defaults to `never`
1105
935
  */
1106
- interface PublishedCommand<TType extends string = string, TPayload extends JsonValue = JsonValue> extends Command {
1107
- readonly type: TType;
1108
- readonly version: number;
1109
- readonly payload: TPayload;
936
+ interface Aggregate<TId extends Id<string>, TEvent extends AnyDomainEvent = never> {
937
+ readonly id: TId;
938
+ readonly version: Version;
939
+ readonly pendingEvents: ReadonlyArray<PendingDomainEvent<TEvent>>;
1110
940
  }
1111
941
  /**
1112
- * Handler for executing commands.
1113
- * Commands return Result for explicit error handling.
1114
- * Commands may modify system state. When a caller can retry or a broker can
1115
- * redeliver, the application service must enforce idempotency; this handler
1116
- * type alone does not provide it.
1117
- *
1118
- * This type can be used to mark handlers even when using external frameworks
1119
- * (e.g., RabbitMQ, AWS SQS, Kafka) to ensure type safety and consistency.
1120
- *
1121
- * @template C - The command type (must extend Command)
1122
- * @template R - The result type
1123
- * @template E - The error channel type. Defaults to `string`; widen it (e.g.
1124
- * to a `DomainError` union) to carry typed failures through the bus.
1125
- *
1126
- * @example
1127
- * ```typescript
1128
- * const handler: CommandHandler<CreateOrderCommand, OrderId> = async (cmd) => {
1129
- * const order = Order.create(cmd.customerId, cmd.items);
1130
- * repository.add(order);
1131
- * return ok(order.id);
1132
- * };
1133
- * ```
1134
- *
1135
- * @example Using with external frameworks
1136
- * ```typescript
1137
- * // Handler typed with CommandHandler for type safety
1138
- * const createOrderHandler: CommandHandler<CreateOrderCommand, OrderId> = async (cmd) => {
1139
- * // ... handler logic
1140
- * return ok(orderId);
1141
- * };
942
+ * Public contract for Event-Sourced Aggregate Roots. Extends
943
+ * `Aggregate` with the replay-from-history boundary.
1142
944
  *
1143
- * // The broker adapter validates before calling the application handler.
1144
- * rabbitMQChannel.consume("commands", async (msg) => {
1145
- * const messageId = decodeMessageId(msg.properties.messageId);
1146
- * if (messageId.isErr()) {
1147
- * rabbitMQChannel.reject(msg, false);
1148
- * return;
1149
- * }
1150
- * const deliveryKey = createOrderDeliveryKey(messageId.value);
1151
- * const principal = authenticateProducer(msg.properties.headers);
1152
- * const decoded = decodeCreateOrderCommand(msg.content, principal);
1153
- * if (decoded.isErr()) {
1154
- * rabbitMQChannel.reject(msg, false); // malformed or over limit
1155
- * return;
1156
- * }
1157
- * // executeIdempotentCreateOrder invokes createOrderHandler through the same
1158
- * // atomic withIdempotentCommit boundary described in the first example.
1159
- * const outcome = await executeIdempotentCreateOrder(
1160
- * deliveryKey,
1161
- * decoded.value,
1162
- * );
1163
- * await recordCommandOutcome(deliveryKey, outcome);
1164
- * rabbitMQChannel.ack(msg);
1165
- * });
945
+ * @template TId - The aggregate root identifier
946
+ * @template TEvent - The union type of all domain events
947
+ */
948
+ interface ReplayableAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends Aggregate<TId, TEvent> {
949
+ /**
950
+ * Reconstitutes the aggregate from an event history. Returns
951
+ * `Result` because event-stream corruption is an expected
952
+ * recoverable failure at the infrastructure boundary: a `DomainError`
953
+ * thrown by a fold arrives as `Err`. Every other failure propagates
954
+ * after the all-or-nothing rollback.
955
+ *
956
+ * @throws ForeignEventError when a history event names another aggregate
957
+ * @throws UnreplayableAggregateError when the target carries pending
958
+ * decisions, or a fold records one
959
+ * @throws MissingFoldError when no fold is declared for an event type
960
+ * @throws FoldReturnedNoStateError when a fold returns `undefined`
961
+ * @throws HostileStateKeyError when the folded state carries an own
962
+ * `__proto__` key
963
+ */
964
+ replayHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
965
+ }
966
+ /**
967
+ * Checks if two aggregates are at the same version (same ID and version).
968
+ * Useful for optimistic concurrency control checks.
969
+ *
970
+ * Note: Two aggregates with the same ID ARE the same aggregate (identity).
971
+ * This function checks if they are at the same version: i.e., no concurrent modification.
972
+ *
973
+ * @example
974
+ * ```typescript
975
+ * const before = await repository.findById(id);
976
+ * // ... some operations ...
977
+ * const after = await repository.findById(id);
978
+ *
979
+ * if (!sameVersion(before, after)) {
980
+ * throw new Error("Aggregate was modified by another process");
981
+ * }
1166
982
  * ```
1167
983
  */
1168
- type CommandHandler<C extends Command, R, E = string> = (cmd: C) => Promise<Result<R, E>>;
1169
- //#endregion
1170
- //#region src/utils/execution.d.ts
1171
- /** Cancellation and deadline controls for one bounded shell operation. */
1172
- interface ExecutionContext {
1173
- /** Cooperative cancellation for the in-flight operation. */
1174
- readonly signal: AbortSignal;
1175
- /** Absolute Unix epoch millisecond at which the shell stops waiting. */
1176
- readonly deadlineAt: number;
1177
- }
984
+ declare function sameVersion<TId extends Id<string>>(a: {
985
+ id: TId;
986
+ version: Version;
987
+ }, b: {
988
+ id: TId;
989
+ version: Version;
990
+ }): boolean;
1178
991
  //#endregion
1179
- //#region src/events/ports.d.ts
992
+ //#region src/messaging/event-bus/ports.d.ts
1180
993
  /**
1181
994
  * Event handler function type for subscribing to domain events. The execution
1182
995
  * context carries the publication's cooperative cancellation and deadline;
@@ -1190,7 +1003,14 @@ type EventHandler<Evt> = (event: Evt, context: ExecutionContext) => Promise<void
1190
1003
  interface PublishOptions {
1191
1004
  /** Owner/request cancellation propagated to every event handler. */
1192
1005
  readonly signal?: AbortSignal;
1193
- /** Maximum time to await the complete publication. Default `30000`ms. */
1006
+ /**
1007
+ * Maximum time to await the complete publication. Default `30000`ms.
1008
+ *
1009
+ * This bounds the WAIT, not the handler. JavaScript cannot terminate a
1010
+ * running promise, so a handler that ignores `context.signal` keeps
1011
+ * running after `publish` rejects, and its side effects still land.
1012
+ * Pass `context.signal` into every I/O call a handler makes.
1013
+ */
1194
1014
  readonly timeoutMs?: number;
1195
1015
  }
1196
1016
  /**
@@ -1222,25 +1042,36 @@ interface EventBus<Evt extends AnyDomainEvent> {
1222
1042
  *
1223
1043
  * **Ordering & parallelism contract:**
1224
1044
  *
1225
- * 1. **Events run in input order.** `publish([a, b, c])` dispatches `a`,
1226
- * awaits all of its handlers, then dispatches `b`, and so on. The
1227
- * library never reorders or parallelises across events.
1228
- * 2. **Handlers within a single event run in parallel.** All handlers
1229
- * subscribed to `event.type` are awaited via `Promise.allSettled`:
1230
- * none of them sees the others' errors and none is skipped if a
1231
- * peer fails.
1232
- * 3. **Errors are collected and thrown AFTER everything dispatches.**
1233
- * If one handler throws, remaining handlers for that event still
1234
- * run, and remaining events in the batch still publish. Once
1235
- * `publish` reaches the end of the batch it throws: the single
1236
- * error directly if there was one, or an `AggregateError`
1237
- * ("Multiple event handlers failed") containing every captured
1238
- * error otherwise. Callers that need fail-fast semantics should
1239
- * publish events one at a time and not rely on batch atomicity.
1045
+ * 1. **Events run in input order.** `publish([a, b, c])` dispatches `a`
1046
+ * and awaits every handler of `a`. Then it dispatches `b`, and so
1047
+ * on. The bus never changes that order. It never dispatches two
1048
+ * events at the same time.
1049
+ * 2. **The handlers of one event run in parallel.** The bus awaits
1050
+ * every handler of `event.type` through `Promise.allSettled`. One
1051
+ * handler never sees the error of another handler. The bus skips no
1052
+ * handler when a peer fails. The bus applies no limit here: twenty
1053
+ * handlers that each open a connection open twenty connections.
1054
+ * Backpressure belongs to the client that the handler calls.
1055
+ * 3. **The bus collects the errors and throws them after the batch.**
1056
+ * If one handler throws, the other handlers of that event still
1057
+ * run, and the remaining events still publish. At the end of the
1058
+ * batch `publish` throws. One failure throws that error directly.
1059
+ * Two or more failures throw an `AggregateError` with the message
1060
+ * "Multiple event handlers failed", which carries every collected
1061
+ * error. For fail-fast behavior, publish one event for each call.
1062
+ * A batch is not atomic.
1063
+ *
1064
+ * The contract is intentionally simple and in-process. For delivery
1065
+ * across processes, for example through RabbitMQ or Kafka, use the
1066
+ * `Outbox` port and a dedicated dispatcher.
1067
+ *
1068
+ * **Delivery guarantee.** The port does not promise persistence, retry, or
1069
+ * a dead-letter path. Each implementation states its own guarantee. Work
1070
+ * that must survive a crash belongs behind the `Outbox` port.
1240
1071
  *
1241
- * The contract is intentionally simple and in-process. For
1242
- * cross-process delivery (RabbitMQ, Kafka, etc.), use the `Outbox`
1243
- * port and a dedicated dispatcher.
1072
+ * **Handlers must tolerate a second run.** The port never redelivers. A
1073
+ * caller that retries does redeliver, and the handlers of the first
1074
+ * attempt can still run. Make a handler idempotent, or do not retry.
1244
1075
  *
1245
1076
  * @param events - Array of events to publish
1246
1077
  * @param options - Owner cancellation and publication timeout
@@ -1267,6 +1098,38 @@ interface EventBus<Evt extends AnyDomainEvent> {
1267
1098
  subscribe: <K extends Evt["type"]>(eventType: K, handler: EventHandler<Extract<Evt, {
1268
1099
  type: K;
1269
1100
  }>>) => () => void;
1101
+ /**
1102
+ * Subscribes one handler to a set of event types.
1103
+ *
1104
+ * The returned function releases every subscription it made, so a
1105
+ * consumer that reacts to several types keeps one release instead of
1106
+ * one for each type. Losing one of several releases is how a partial
1107
+ * leak starts.
1108
+ *
1109
+ * A type that appears twice subscribes once: the argument is a set of
1110
+ * types, and delivering the same event twice to one handler would be a
1111
+ * surprise, not a feature. An empty set subscribes nothing and returns
1112
+ * a release that does nothing.
1113
+ *
1114
+ * @param eventTypes - The event types to subscribe to
1115
+ * @param handler - Called with every event of those types, narrowed to
1116
+ * their union
1117
+ * @returns A function that releases all of them, and does nothing when
1118
+ * called again
1119
+ *
1120
+ * @example
1121
+ * ```typescript
1122
+ * const release = bus.subscribeMany(
1123
+ * ["OrderCreated", "OrderShipped"],
1124
+ * async (event) => {
1125
+ * await touchReadModel(event.payload.orderId);
1126
+ * },
1127
+ * );
1128
+ * ```
1129
+ */
1130
+ subscribeMany: <K extends Evt["type"]>(eventTypes: readonly K[], handler: EventHandler<Extract<Evt, {
1131
+ type: K;
1132
+ }>>) => () => void;
1270
1133
  /**
1271
1134
  * Subscribes a handler to EVERY event type: the subscription for
1272
1135
  * cross-cutting consumers (audit log, metrics, dev logging,
@@ -1295,359 +1158,71 @@ interface EventBus<Evt extends AnyDomainEvent> {
1295
1158
  */
1296
1159
  subscribeAll: (handler: EventHandler<Evt>) => () => void;
1297
1160
  /**
1298
- * Subscribes to the next occurrence of an event type.
1299
- * Returns a Promise that resolves with the event data.
1300
- * Automatically unsubscribes after the first event.
1301
- *
1302
- * @param eventType - The event type to wait for
1303
- * @returns A Promise that resolves with the event
1161
+ * Releases every subscription and settles every waiter.
1304
1162
  *
1305
- * @example
1306
- * ```typescript
1307
- * const event = await bus.once("OrderCreated");
1308
- * console.log("Order created:", event.payload.orderId);
1309
- * ```
1310
- */
1311
- once: <K extends Evt["type"]>(eventType: K, options?: OnceOptions) => Promise<Extract<Evt, {
1312
- type: K;
1313
- }>>;
1314
- }
1315
- /**
1316
- * Options for `EventBus.once()`. Both fields are optional; without them
1317
- * `once()` waits forever (the historical behaviour).
1318
- */
1319
- interface OnceOptions {
1320
- /**
1321
- * Aborts the wait. When `signal` fires, `once()` rejects with
1322
- * `signal.reason` (or a generic abort error if none was supplied) and
1323
- * the internal subscription is removed.
1324
- */
1325
- signal?: AbortSignal;
1326
- /**
1327
- * Rejects with a timeout error after this many milliseconds if no event
1328
- * has arrived. The internal subscription and timer are cleaned up
1329
- * regardless of which path settles the promise.
1330
- */
1331
- timeoutMs?: number;
1332
- }
1333
- /**
1334
- * Gap-proof position finalized by the event source at the persistence
1335
- * boundary. It is deliberately separate from `DomainEvent`: these values
1336
- * describe a stored commit, not the business fact itself.
1337
- */
1338
- interface CommitPosition {
1339
- /** Aggregate OCC version reached by this eventful commit. */
1340
- readonly aggregateVersion: number;
1341
- /** Zero-based event index inside this aggregate commit. */
1342
- readonly commitSequence: number;
1343
- /** Total number of events emitted by this aggregate commit. */
1344
- readonly commitSize: number;
1345
- /**
1346
- * Aggregate version of the immediately preceding EVENTFUL commit for this
1347
- * qualified aggregate source, or `null` when this is its first eventful
1348
- * commit. State-only persistence is intentionally absent from this chain.
1163
+ * A bus that outlives its scope keeps its handlers alive with it. A
1164
+ * worker that shuts down, a test that tears down, and a request scope
1165
+ * that ends all need one call that leaves the bus holding nothing.
1349
1166
  *
1350
- * The outbox/event-store adapter owns this value. It must read and advance
1351
- * the source head atomically with inserting the committed event envelope;
1352
- * application orchestration cannot derive it from the Unit of Work's OCC
1353
- * receipt because state-only commits are intentionally absent here.
1354
- */
1355
- readonly previousEventfulAggregateVersion: number | null;
1356
- }
1357
- /**
1358
- * Commit information known by the application transaction before the outbox
1359
- * source has linked this eventful commit to its predecessor.
1360
- */
1361
- type EventCommitCandidatePosition = Omit<CommitPosition, "previousEventfulAggregateVersion">;
1362
- /**
1363
- * A bare domain event prepared for the transactional outbox. The outbox source
1364
- * owns the predecessor link and turns this candidate into a
1365
- * {@link CommittedDomainEvent} when it persists the record.
1366
- */
1367
- interface EventCommitCandidate<Evt extends AnyDomainEvent> {
1368
- readonly event: Evt;
1369
- readonly source: AggregateAddress;
1370
- readonly position: EventCommitCandidatePosition;
1371
- }
1372
- /**
1373
- * A domain event enriched after persistence has established its source and
1374
- * commit position. Outboxes and projectors consume this envelope; in-process
1375
- * domain handlers continue to consume the bare {@link DomainEvent} value.
1376
- */
1377
- interface CommittedDomainEvent<Evt extends AnyDomainEvent> {
1378
- readonly event: Evt;
1379
- readonly source: AggregateAddress;
1380
- readonly position: CommitPosition;
1381
- }
1382
- /**
1383
- * One pending event in the outbox plus the opaque id the implementation
1384
- * needs to ack it via `markDispatched`. The library does not prescribe
1385
- * what `dispatchId` looks like: an implementation can reuse the event's
1386
- * own `eventId`, generate its own UUID, use the row's auto-increment
1387
- * primary key, or whatever the storage layer prefers.
1388
- */
1389
- interface OutboxRecord<Evt extends AnyDomainEvent> extends CommittedDomainEvent<Evt> {
1390
- dispatchId: string;
1391
- /**
1392
- * Failed delivery attempts so far. Populated by implementations that
1393
- * track dispatch failures (see {@link DispatchTrackingOutbox});
1394
- * plain `Outbox` implementations may omit it.
1395
- */
1396
- attempts?: number;
1397
- }
1398
- /** A record that exhausted its delivery attempts; see {@link DispatchTrackingOutbox.deadLetters}. */
1399
- interface DeadLetterRecord<Evt extends AnyDomainEvent> extends CommittedDomainEvent<Evt> {
1400
- dispatchId: string;
1401
- /** Failed delivery attempts when the record was dead-lettered. */
1402
- attempts: number;
1403
- /** Human-readable rendering of the last delivery error, if recorded. */
1404
- lastError?: string;
1405
- }
1406
- /**
1407
- * Write half of the transactional outbox: the only outbox capability the
1408
- * write side (`withCommit`, `UnitOfWork`) depends on. Persisting the
1409
- * events atomically with the aggregate state is the kit's guarantee;
1410
- * DELIVERY is a separate, replaceable concern.
1411
- *
1412
- * Implement ONLY this interface to plug in an external delivery
1413
- * solution: `add()` writes into that solution's outbox storage inside
1414
- * the ambient transaction, and its own listener (polling or
1415
- * WAL/CDC-based, such as a Debezium-style connector, a delivery
1416
- * library, or a broker-native outbox) owns delivery entirely. The
1417
- * kit-side poll surface ({@link Outbox}) is then never involved. See
1418
- * the outbox guide, "External dispatchers".
1419
- */
1420
- interface OutboxWriter<Evt extends AnyDomainEvent> {
1421
- /**
1422
- * Finalizes and persists event commit candidates. Called from inside
1423
- * `withCommit`'s transactional callback, atomically with the aggregate
1424
- * write.
1167
+ * After this call, `publish`, `subscribe`, `subscribeAll` and `once`
1168
+ * throw. Use after close is a programming bug, and a silent no-op would
1169
+ * look like a delivery that did not happen. A pending `once()` rejects
1170
+ * rather than waiting forever, which is the only waiter the port can
1171
+ * settle: a handler is a callback and learns that no event follows by
1172
+ * not being called again.
1425
1173
  *
1426
- * For every qualified aggregate source, the adapter must serialize source
1427
- * advancement, read its last eventful aggregate version, write that value as
1428
- * `previousEventfulAggregateVersion` on every event in the candidate's
1429
- * commit, and advance the source head to `aggregateVersion` in the SAME
1430
- * transaction. A state-only aggregate commit does not call `add()` and must
1431
- * therefore not advance this event-source head.
1174
+ * Calling it again does nothing.
1432
1175
  *
1433
- * A qualified source position `(aggregateType, aggregateId,
1434
- * aggregateVersion, commitSequence)` MUST identify one immutable event. All
1435
- * candidates for the same aggregate commit MUST also agree on `commitSize`.
1436
- * Enforce both constraints before advancing the source head; a conflicting
1437
- * retry must reject without replacing the stored event or changing the head.
1176
+ * A plain method, not `Symbol.dispose`. A port that declared the symbol
1177
+ * would need `esnext.disposable` in the `lib` of every consumer, only to
1178
+ * typecheck the types of this kit, and that requirement cannot be
1179
+ * declined. Group your own releases instead, as the common mistakes
1180
+ * guide shows.
1438
1181
  *
1439
- * **Idempotency:** implementations should dedupe on
1440
- * `candidate.event.eventId`. `withCommit` itself does not retry, but the
1441
- * surrounding use case (a queue consumer, an HTTP retry, a transactional
1442
- * outbox-dispatcher loop) may legitimately invoke the same write more than
1443
- * once. A unique-key constraint on `(eventId)` in the outbox table is the
1444
- * standard implementation; the source-head update and dedupe decision must
1445
- * share the transaction. Idempotency applies only to an exact candidate
1446
- * retry: the same event ID, qualified source, aggregate version, commit
1447
- * sequence, and commit size. Reusing an `eventId` for another source or
1448
- * position is a caller bug: adapters that retain the conflicting record
1449
- * should reject it rather than replace or silently reinterpret it as a retry.
1450
- */
1451
- add: (events: ReadonlyArray<EventCommitCandidate<Evt>>) => Promise<void>;
1452
- }
1453
- /**
1454
- * Transactional outbox port: the bridge between the write-side
1455
- * transaction and the (out-of-band) event dispatcher.
1456
- *
1457
- * Lifecycle:
1458
- * 1. `add()` inside the write transaction (`withCommit` calls this) so
1459
- * events persist atomically with the aggregate state
1460
- * ({@link OutboxWriter}, the only part the write side needs).
1461
- * 2. An outbox dispatcher (the kit's `OutboxDispatcher` or your own)
1462
- * polls `getPending()` and forwards the events to subscribers /
1463
- * external brokers.
1464
- * 3. After successful dispatch, the dispatcher calls `markDispatched()`
1465
- * with the records' `dispatchId`s so they don't come back next poll.
1466
- *
1467
- * `markDispatched` is required to be idempotent: calling it with an id
1468
- * that's already marked is a no-op, not an error. This lets the
1469
- * dispatcher safely retry on partial-failure.
1470
- *
1471
- * **Competing dispatcher instances** are an adapter contract, not a
1472
- * dispatcher feature: a transactional implementation that should
1473
- * support several concurrent pollers must make `getPending` claim the
1474
- * returned records (`FOR UPDATE SKIP LOCKED` or equivalent). Without
1475
- * claiming, run one logical dispatcher per outbox.
1476
- *
1477
- * The bundled dispatcher supplies an {@link ExecutionContext} to every poll-side
1478
- * operation. Production adapters MUST pass its signal to native I/O or enforce
1479
- * a native timeout no later than `deadlineAt`; the shell can bound its wait but
1480
- * cannot terminate a promise that ignores cancellation. A timed-out write has
1481
- * an unknown outcome. Acknowledgements must remain idempotent when they complete
1482
- * late; a late failure update may count its original delivery attempt and must
1483
- * still no-op after the record was dispatched.
1484
- */
1485
- interface Outbox<Evt extends AnyDomainEvent> extends OutboxWriter<Evt> {
1486
- /**
1487
- * Returns up to `limit` outbox records that have not yet been
1488
- * dispatched, **in the order `add()` persisted them** (commit order).
1489
- * The ordering is part of the port contract: `withCommit` promises
1490
- * subscribers per-aggregate causal order, and a sequential dispatcher
1491
- * can only honor that promise when this read is ordered. SQL-backed
1492
- * implementations need a monotonic position column (an auto-increment
1493
- * primary key works) and an `ORDER BY` on it; a bare `SELECT` returns
1494
- * rows in storage order, not insertion order. The dispatcher polls
1495
- * this on a schedule. When `limit` is omitted, the implementation
1496
- * decides on a default page size. The bundled dispatcher always supplies
1497
- * `context`; it is optional only so existing adapters remain assignable.
1182
+ * This releases the subscriptions. It does not stop a handler that is
1183
+ * already running, because JavaScript cannot terminate a running
1184
+ * promise. Pass `context.signal` into every call a handler makes, and a
1185
+ * publication in flight ends with the handler that honours it.
1498
1186
  */
1499
- getPending: (limit?: number, context?: ExecutionContext) => Promise<ReadonlyArray<OutboxRecord<Evt>>>;
1187
+ close: () => void;
1500
1188
  /**
1501
- * Marks the given dispatch records as delivered so subsequent
1502
- * `getPending` calls don't return them. Must be idempotent on
1503
- * already-marked ids, including a late completion after the caller's
1504
- * storage deadline. The bundled dispatcher always supplies `context`.
1189
+ * Subscribes to the next occurrence of an event type.
1190
+ * Returns a Promise that resolves with the event data.
1191
+ * Automatically unsubscribes after the first event.
1192
+ *
1193
+ * @param eventType - The event type to wait for
1194
+ * @returns A Promise that resolves with the event
1195
+ *
1196
+ * @example
1197
+ * ```typescript
1198
+ * const event = await bus.once("OrderCreated");
1199
+ * console.log("Order created:", event.payload.orderId);
1200
+ * ```
1505
1201
  */
1506
- markDispatched: (dispatchIds: ReadonlyArray<string>, context?: ExecutionContext) => Promise<void>;
1202
+ once: <K extends Evt["type"]>(eventType: K, options?: OnceOptions) => Promise<Extract<Evt, {
1203
+ type: K;
1204
+ }>>;
1507
1205
  }
1508
1206
  /**
1509
- * Optional extension of {@link Outbox} for dispatchers that track
1510
- * delivery failures. Without failure tracking, a poison message (an
1511
- * event whose delivery always throws) is redelivered forever: it comes
1512
- * back from every `getPending` poll, blocks per-aggregate ordering
1513
- * behind it, and burns the dispatcher's cycles. This extension gives
1514
- * the dispatcher a bounded-retry story: report each failed delivery via
1515
- * {@link markFailed}; the implementation moves records past its
1516
- * attempt ceiling to a dead-letter set that `getPending` no longer
1517
- * returns, and {@link deadLetters} exposes them for alerting, manual
1518
- * inspection, and redelivery (deliver by hand, then ack via
1519
- * `markDispatched`, which also clears dead-lettered records).
1520
- *
1521
- * See the outbox guide's dispatcher recipe for the retry-then-dead-letter
1522
- * loop this port shape supports.
1207
+ * Options for `EventBus.once()`. Both fields are optional; without them
1208
+ * `once()` waits forever.
1523
1209
  */
1524
- interface DispatchTrackingOutbox<Evt extends AnyDomainEvent> extends Outbox<Evt> {
1210
+ interface OnceOptions {
1525
1211
  /**
1526
- * Records one failed delivery attempt for the given record:
1527
- * increments its attempt count (surfaced as
1528
- * {@link OutboxRecord.attempts}) and, once the implementation's
1529
- * ceiling is reached, moves the record to the dead-letter set.
1530
- * A no-op for unknown or already-dispatched ids (a late failure
1531
- * report after a successful retry must not resurrect the record).
1532
- * Returns the exact dead-letter record only on the call that performs
1533
- * that transition; retries below the ceiling and no-ops return
1534
- * `undefined`. This lets productive pollers emit an immediate signal
1535
- * without scanning the durable dead-letter set after every failure. A late
1536
- * completion may count that original delivery attempt; it must still no-op if
1537
- * the record was dispatched in the meantime. The bundled dispatcher never
1538
- * reissues the same store call and always supplies `context`.
1212
+ * Aborts the wait. When `signal` fires, `once()` rejects with
1213
+ * `signal.reason` (or a generic abort error if none was supplied) and
1214
+ * the internal subscription is removed.
1539
1215
  */
1540
- markFailed: (dispatchId: string, error?: unknown, context?: ExecutionContext) => Promise<DeadLetterRecord<Evt> | undefined>;
1216
+ signal?: AbortSignal;
1541
1217
  /**
1542
- * Records that exhausted their delivery attempts. They no longer
1543
- * come back from `getPending`; wire this to durable alerting and
1544
- * reconciliation so poison messages surface even if the poller stops
1545
- * between this store transition and its immediate observer callback.
1218
+ * Rejects with a timeout error after this many milliseconds if no event
1219
+ * has arrived. The internal subscription and timer are cleaned up
1220
+ * regardless of which path settles the promise.
1546
1221
  */
1547
- deadLetters: () => Promise<ReadonlyArray<DeadLetterRecord<Evt>>>;
1548
- }
1549
- //#endregion
1550
- //#region src/app/command-outbox.d.ts
1551
- /**
1552
- * Business relationships and technical trace context selected explicitly for
1553
- * an outgoing command. Correlation/conversation explain the business flow;
1554
- * W3C Trace Context connects technical spans.
1555
- */
1556
- interface CommandMessageRelationships {
1557
- /** Groups messages that belong to one operation or trace. */
1558
- readonly correlationId?: string;
1559
- /** Groups every message in one long-running business interaction. */
1560
- readonly conversationId?: string;
1561
- /** W3C Trace Context parent for technical distributed tracing. */
1562
- readonly traceparent?: string;
1563
- /** Optional vendor trace state associated with `traceparent`. */
1564
- readonly tracestate?: string;
1565
- }
1566
- /**
1567
- * Application-owned Published Language produced from one private domain or
1568
- * process event. `destination` names one receiver contract; it is deliberately
1569
- * required because a command is an instruction, not a broadcast fact.
1570
- *
1571
- * The command carries a stable schema `version` and JSON-safe `payload`.
1572
- * Domain value objects are translated to wire DTOs by the mapper before this
1573
- * boundary.
1574
- */
1575
- interface CommandMessageContent<C extends PublishedCommand> extends CommandMessageRelationships {
1576
- readonly destination: string;
1577
- readonly command: C;
1578
- }
1579
- /**
1580
- * Immutable, JSON-safe command envelope stored for later at-least-once
1581
- * delivery.
1582
- *
1583
- * `causationId` always identifies the private event whose accepted decision
1584
- * requested this command. The mapper cannot replace it with a weaker
1585
- * correlation. Consumer-produced events should in turn use `messageId` as
1586
- * their causation id.
1587
- */
1588
- interface DurableCommandMessage<C extends PublishedCommand> extends CommandMessageContent<C> {
1589
- readonly messageId: string;
1590
- readonly recordedAt: string;
1591
- readonly causationId: string;
1592
- }
1593
- /**
1594
- * Receipt for the private event that requested one command batch. It retains
1595
- * commit identity and ordering without putting the private event or its
1596
- * payload into the command outbox.
1597
- */
1598
- interface CommandCommitOriginCandidate {
1599
- readonly eventId: string;
1600
- readonly source: AggregateAddress;
1601
- readonly position: EventCommitCandidatePosition;
1602
- }
1603
- /**
1604
- * One private process-event commit and the exact commands it requested.
1605
- * `messages` may be empty: the receipt still advances the originating source
1606
- * and makes an exact retry distinguishable from a missing commit.
1607
- */
1608
- interface CommandOutboxCommitCandidate<C extends PublishedCommand> {
1609
- readonly origin: CommandCommitOriginCandidate;
1610
- readonly messages: ReadonlyArray<DurableCommandMessage<C>>;
1611
- }
1612
- /**
1613
- * Write port for a dedicated transactional command outbox.
1614
- *
1615
- * The adapter is bound to the same ambient transaction as the aggregate or
1616
- * event-stream repository. It must persist the complete input atomically,
1617
- * retain input order, deduplicate exact retries by `origin.eventId`, and reject
1618
- * a reused origin id whose source, position, or messages differ. It also owns
1619
- * the durable source cursor represented by `origin.position`; an empty command
1620
- * batch still advances that cursor.
1621
- *
1622
- * Delivery is out of band and at least once. A consumer therefore uses
1623
- * `message.messageId` as its idempotency key and acknowledges only after the
1624
- * command result has been stored.
1625
- */
1626
- interface CommandOutboxWriter<C extends PublishedCommand> {
1627
- add(commits: ReadonlyArray<CommandOutboxCommitCandidate<C>>): Promise<void>;
1222
+ timeoutMs?: number;
1628
1223
  }
1629
- /** Maps one private accepted event to zero or more addressed commands. */
1630
- type CommandOutboxMapper<Evt extends AnyDomainEvent, C extends PublishedCommand> = (event: Evt) => ReadonlyArray<CommandMessageContent<C>>;
1631
- /**
1632
- * Adapts a dedicated command outbox to the event-candidate write port consumed
1633
- * by `withCommit`.
1634
- *
1635
- * Mapping happens inside the transaction, before the command outbox write.
1636
- * The private event is used only at this boundary and is reduced to an origin
1637
- * receipt. The helper never publishes it or copies its payload implicitly; the
1638
- * application mapper selects and translates the data that belongs in the
1639
- * versioned Published Language. The route rejects values JSON would lose or
1640
- * change before it calls the adapter.
1641
- * Every command gets a stable id derived from the event id and its zero-based
1642
- * order, so an exact transaction retry produces the same rows.
1643
- *
1644
- * Omit `withCommit`'s in-process `bus` for private process events. Participants
1645
- * consume the durable command messages from their explicitly named
1646
- * destinations, while event-stream replay only rebuilds process state.
1647
- */
1648
- declare function routeEventsToCommandOutbox<C extends PublishedCommand, Evt extends AnyDomainEvent = AnyDomainEvent>(outbox: CommandOutboxWriter<C>, mapper: CommandOutboxMapper<Evt, C>): OutboxWriter<Evt>;
1649
1224
  //#endregion
1650
- //#region src/repo/scope.d.ts
1225
+ //#region src/persistence/repository/scope.d.ts
1651
1226
  /** Options passed to {@link TransactionScope.transactional}. */
1652
1227
  interface TransactionalOptions {
1653
1228
  /**
@@ -1720,7 +1295,7 @@ interface TransactionScope<TCtx> {
1720
1295
  transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
1721
1296
  }
1722
1297
  //#endregion
1723
- //#region src/app/handler.d.ts
1298
+ //#region src/application/cqrs/handler.d.ts
1724
1299
  /** Dependencies for {@link withCommit}. */
1725
1300
  interface WithCommitDeps<Evt extends AnyDomainEvent, TCtx> {
1726
1301
  /**
@@ -1752,7 +1327,7 @@ interface WithCommitDeps<Evt extends AnyDomainEvent, TCtx> {
1752
1327
  * committed write into an apparent failure. The execution context carries
1753
1328
  * owner cancellation and the configured post-commit deadline.
1754
1329
  */
1755
- onPersisted?: (aggregate: IAggregateRoot<Id<string>, Evt>, version: Version, context: ExecutionContext) => void | Promise<void>;
1330
+ onPersisted?: (aggregate: Aggregate<Id<string>, Evt>, version: Version, context: ExecutionContext) => void | Promise<void>;
1756
1331
  /**
1757
1332
  * Observer for post-commit persistence failures: either the internal
1758
1333
  * acknowledgement/disposal step or the application-shell `onPersisted`
@@ -1764,7 +1339,7 @@ interface WithCommitDeps<Evt extends AnyDomainEvent, TCtx> {
1764
1339
  * post-commit invariant holds, and the loop continues the remaining
1765
1340
  * post-commit work.
1766
1341
  */
1767
- onPersistError?: (error: unknown, aggregate: IAggregateRoot<Id<string>, Evt>) => void;
1342
+ onPersistError?: (error: unknown, aggregate: Aggregate<Id<string>, Evt>) => void;
1768
1343
  /**
1769
1344
  * Total time allotted to the complete post-commit application phase:
1770
1345
  * every application observer followed by in-process bus publication shares
@@ -1801,18 +1376,18 @@ interface AggregateCommitToken<Evt extends AnyDomainEvent = AnyDomainEvent> {
1801
1376
  * repository write, and return every resulting token in `commits`. Omitting
1802
1377
  * any token rejects the transaction: an enrolled write may not commit without
1803
1378
  * its event harvest and post-commit acknowledgement. Enrollable instances
1804
- * must extend `AggregateRoot` or `EventSourcedAggregate`; structural
1805
- * `IAggregateRoot` lookalikes have no internal lifecycle capability and fail
1379
+ * must extend `StateStoredAggregate` or `EventSourcedAggregate`; structural
1380
+ * `Aggregate` lookalikes have no internal lifecycle capability and fail
1806
1381
  * before commit.
1807
1382
  */
1808
1383
  interface CommitEnrollment<Evt extends AnyDomainEvent> {
1809
- enrollSaved(aggregate: IAggregateRoot<Id<string>, Evt>, options?: CommitEnrollmentOptions): AggregateCommitToken<Evt>;
1384
+ enrollSaved(aggregate: Aggregate<Id<string>, Evt>, options?: CommitEnrollmentOptions): AggregateCommitToken<Evt>;
1810
1385
  /**
1811
1386
  * Enroll an aggregate whose row is deleted by the current transaction.
1812
1387
  * Its events are harvested and discarded after commit, but the saved-only
1813
1388
  * application `onPersisted` observer is not called.
1814
1389
  */
1815
- enrollDeleted(aggregate: IAggregateRoot<Id<string>, Evt>, options?: CommitEnrollmentOptions): AggregateCommitToken<Evt>;
1390
+ enrollDeleted(aggregate: Aggregate<Id<string>, Evt>, options?: CommitEnrollmentOptions): AggregateCommitToken<Evt>;
1816
1391
  }
1817
1392
  /** OCC baseline associated with one exact commit enrollment. */
1818
1393
  interface CommitEnrollmentOptions {
@@ -1924,34 +1499,189 @@ interface WithCommitWorkResult<Evt extends AnyDomainEvent, R> {
1924
1499
  * adapter persistence projection and rejects later mutation before flush. For
1925
1500
  * direct `withCommit` use, make domain decisions first, write, and enroll last.
1926
1501
  *
1927
- * **Duplicate enrollment is idempotent by reference.** Enrolling the same
1928
- * instance repeatedly returns the same token, and a repeated token in
1929
- * `commits` is harvested once. A repeat call that omits `expectedVersion`
1930
- * makes no OCC assertion; only a supplied value that contradicts the
1931
- * enrollment-time baseline rejects. Each event lands in the outbox exactly once
1932
- * and post-commit acknowledgement runs exactly once. Two
1933
- * *different* instances with the same logical id cannot be detected
1934
- * at this layer; that is a Repository contract violation (failure to
1935
- * maintain Fowler's Identity Map per Unit of Work). See
1936
- * `docs/guide/repository.md` → "Identity Map: one instance per
1937
- * aggregate per Unit of Work" for the requirement on repository
1938
- * implementations that makes this dedupe sound.
1502
+ * **Duplicate enrollment is idempotent by reference.** Enrolling the same
1503
+ * instance repeatedly returns the same token, and a repeated token in
1504
+ * `commits` is harvested once. A repeat call that omits `expectedVersion`
1505
+ * makes no OCC assertion; only a supplied value that contradicts the
1506
+ * enrollment-time baseline rejects. Each event lands in the outbox exactly once
1507
+ * and post-commit acknowledgement runs exactly once. Two
1508
+ * *different* instances with the same logical id cannot be detected
1509
+ * at this layer; that is a Repository contract violation (failure to
1510
+ * maintain Fowler's Identity Map per Unit of Work). See
1511
+ * `docs/guide/repository.md` → "Identity Map: one instance per
1512
+ * aggregate per Unit of Work" for the requirement on repository
1513
+ * implementations that makes this dedupe sound.
1514
+ *
1515
+ * @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
1516
+ * ```typescript
1517
+ * const result = await withCommit({ outbox, bus, scope }, async (tx, enrollment) => {
1518
+ * const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
1519
+ * const order = await orderRepository.getById(orderId);
1520
+ * order.confirm();
1521
+ * await persistOrder(tx, order); // low-level adapter write
1522
+ * const commit = enrollment.enrollSaved(order); // attest the repository write
1523
+ * return { result: order.id, commits: [commit] };
1524
+ * });
1525
+ * ```
1526
+ */
1527
+ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: WithCommitDeps<Evt, TCtx>, fn: (ctx: TCtx, enrollment: CommitEnrollment<Evt>) => Promise<WithCommitWorkResult<Evt, R>>): Promise<R>;
1528
+ //#endregion
1529
+ //#region src/application/deadlines/deadline-store.d.ts
1530
+ /**
1531
+ * One deadline due for delivery, as returned by
1532
+ * {@link DeadlineStore.due}. `deliveryId` identifies this scheduled
1533
+ * INCARNATION of the deadline, not the `(scope, key)` address: a
1534
+ * reschedule replaces the incarnation, and acknowledging a stale
1535
+ * incarnation must never consume the new one (see
1536
+ * {@link DeadlineStore.markDelivered}).
1537
+ */
1538
+ interface DueDeadline<TPayload = unknown> {
1539
+ /** Opaque per-incarnation id used for `markDelivered`/`markFailed`. */
1540
+ readonly deliveryId: string;
1541
+ /** The namespace half of the address, e.g. a process or policy name. */
1542
+ readonly scope: string;
1543
+ /** The instance half of the address, e.g. a saga or reservation id. */
1544
+ readonly key: string;
1545
+ /** When the deadline was due. */
1546
+ readonly dueAt: Date;
1547
+ /** The payload handed back as the input; plain data only. */
1548
+ readonly payload: TPayload;
1549
+ /** Failed delivery attempts so far (see `markFailed`). */
1550
+ readonly attempts: number;
1551
+ }
1552
+ /** A deadline that exhausted its delivery attempts; see {@link DeadlineStore.deadLetters}. */
1553
+ interface DeadLetterDeadline<TPayload = unknown> extends DueDeadline<TPayload> {
1554
+ /** Human-readable rendering of the last delivery error, if recorded. */
1555
+ readonly lastError?: string;
1556
+ }
1557
+ /**
1558
+ * Driven port for durable deadlines: timeout-as-input. A process that
1559
+ * waits ("if PaymentReceived has not arrived in 30 minutes,
1560
+ * compensate"; "release the reservation hold after 15 minutes";
1561
+ * "expire the offer at month's end") schedules a deadline, and a poll
1562
+ * loop later DELIVERS it as an input to whatever owns the decision, a
1563
+ * saga aggregate, a use case, a policy. The store never executes
1564
+ * consumer code; firing a deadline means handing back a record.
1565
+ *
1566
+ * Deliberately general-purpose and deliberately small. This is not a
1567
+ * scheduler framework and not a cron abstraction: there is no
1568
+ * recurrence, no execution engine, and the poll loop belongs to the
1569
+ * consumer (the outbox guide's `drainOnce` pattern fits; the deadlines
1570
+ * guide shows the wiring).
1571
+ *
1572
+ * Addressing is the `(scope, key)` pair, so one table serves every
1573
+ * waiting process in an application: `scope` names the policy
1574
+ * ("checkout-saga", "reservation-hold"), `key` the instance. There is
1575
+ * at most ONE pending deadline per address; `schedule` on an existing
1576
+ * address replaces it (that IS the reschedule operation), and each
1577
+ * scheduling gets a fresh `deliveryId`, so acknowledgements of a
1578
+ * replaced incarnation cannot consume its successor.
1579
+ *
1580
+ * Two sides, two transactional postures, the same split as the outbox:
1581
+ *
1582
+ * - **`schedule` and `cancel` are write-side calls** and must join the
1583
+ * ambient write transaction (use a tx-bound store instance inside
1584
+ * `withCommit`'s callback, exactly like an outbox adapter). This is
1585
+ * a correctness rule, not a preference: state that says "waiting for
1586
+ * payment" committed without its deadline is a process that never
1587
+ * wakes up, and a deadline scheduled for a rolled-back state change
1588
+ * is a ghost input.
1589
+ * - **`due`, `markDelivered`, `markFailed`, and `deadLetters` are the
1590
+ * poll surface** and run out of band, in the consumer's loop.
1591
+ *
1592
+ * Delivery is at-least-once: a crash between processing and
1593
+ * `markDelivered` redelivers, so consumers make deadline handling
1594
+ * idempotent (the idempotency store with the `deliveryId` as key is
1595
+ * the ready-made answer). Deadlines have no cross-key ordering
1596
+ * obligations, so unlike the outbox a poison deadline blocks only
1597
+ * itself; bounded retries still matter, which is why failure tracking
1598
+ * is part of the port rather than an extension: report failed
1599
+ * deliveries via `markFailed`, and the store dead-letters a deadline
1600
+ * past its attempt ceiling.
1601
+ *
1602
+ * Run one logical poller per store unless your adapter's `due` claims
1603
+ * records for competing pollers; the same rule as the outbox
1604
+ * dispatcher.
1605
+ *
1606
+ * The bundled processor supplies an `ExecutionContext` to every poll-side
1607
+ * operation. Production adapters MUST pass its signal to native I/O or enforce
1608
+ * a native timeout no later than `deadlineAt`; the shell can bound its wait but
1609
+ * cannot terminate a promise that ignores cancellation. A timed-out write has
1610
+ * an unknown outcome. Acknowledgements must remain idempotent when they complete
1611
+ * late; a late failure update may count its original delivery attempt and must
1612
+ * still no-op after the incarnation was delivered or replaced.
1939
1613
  *
1940
- * @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
1941
- * ```typescript
1942
- * const result = await withCommit({ outbox, bus, scope }, async (tx, enrollment) => {
1943
- * const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
1944
- * const order = await orderRepository.getById(orderId);
1945
- * order.confirm();
1946
- * await persistOrder(tx, order); // low-level adapter write
1947
- * const commit = enrollment.enrollSaved(order); // attest the repository write
1948
- * return { result: order.id, commits: [commit] };
1949
- * });
1950
- * ```
1614
+ * Verify an adapter with `createDeadlineStoreContractTests` from
1615
+ * `@shirudo/ddd-kit/testing`; `InMemoryDeadlineStore` is the
1616
+ * reference.
1617
+ *
1618
+ * @template TPayload - The payload shape carried from `schedule` to
1619
+ * delivery; plain, serializable data (the same discipline as event
1620
+ * payloads and snapshots)
1951
1621
  */
1952
- declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: WithCommitDeps<Evt, TCtx>, fn: (ctx: TCtx, enrollment: CommitEnrollment<Evt>) => Promise<WithCommitWorkResult<Evt, R>>): Promise<R>;
1622
+ interface DeadlineStore<TPayload = unknown> {
1623
+ /**
1624
+ * Schedules (or reschedules) the deadline at `(scope, key)`: at most
1625
+ * one pending deadline exists per address, and scheduling an
1626
+ * occupied address replaces its due time, payload, attempt count,
1627
+ * and incarnation. Called inside the write transaction.
1628
+ */
1629
+ schedule(deadline: {
1630
+ scope: string;
1631
+ key: string;
1632
+ dueAt: Date;
1633
+ payload: TPayload;
1634
+ }): Promise<void>;
1635
+ /**
1636
+ * Removes the pending deadline at `(scope, key)`; a no-op when none
1637
+ * exists (the awaited input arrived in time and the wait is over).
1638
+ * Called inside the write transaction.
1639
+ */
1640
+ cancel(scope: string, key: string): Promise<void>;
1641
+ /**
1642
+ * Up to `limit` deadlines with `dueAt <= now` that are neither
1643
+ * delivered nor dead-lettered, ordered by `dueAt` (earliest first;
1644
+ * ties in scheduling order). A `limit` of `0` is legal and yields an
1645
+ * empty page (poll loops computing a remaining capacity may pass
1646
+ * it). `now` is a parameter on purpose: the poll loop owns the
1647
+ * clock, which keeps adapters deterministic and tests free of real
1648
+ * time. The bundled processor always supplies `context`; it is optional only
1649
+ * so existing adapters remain assignable.
1650
+ */
1651
+ due(now: Date, limit: number, context?: ExecutionContext): Promise<ReadonlyArray<DueDeadline<TPayload>>>;
1652
+ /**
1653
+ * Acknowledges delivered incarnations so they stop coming back.
1654
+ * Idempotent on already-acknowledged and unknown ids, and a no-op
1655
+ * for ids of REPLACED incarnations (a late ack after a reschedule
1656
+ * must not consume the successor). Also clears a dead-lettered
1657
+ * incarnation (manual redelivery, then ack). It remains idempotent if the
1658
+ * operation completes after the caller timed out. The bundled processor
1659
+ * always supplies `context`.
1660
+ */
1661
+ markDelivered(deliveryIds: ReadonlyArray<string>, context?: ExecutionContext): Promise<void>;
1662
+ /**
1663
+ * Records one failed delivery attempt for the incarnation:
1664
+ * increments its `attempts` and, once the store's ceiling is
1665
+ * reached, moves it to the dead-letter set that `due` no longer
1666
+ * returns. A no-op for unknown, delivered, or replaced ids.
1667
+ * Returns the exact dead-letter record only on the call that performs
1668
+ * that transition; retries below the ceiling and no-ops return
1669
+ * `undefined`. A late completion may count that original delivery attempt; it
1670
+ * must still no-op if the incarnation was delivered or replaced in the
1671
+ * meantime. The bundled processor never reissues the same store call and
1672
+ * always supplies `context`.
1673
+ */
1674
+ markFailed(deliveryId: string, error?: unknown, context?: ExecutionContext): Promise<DeadLetterDeadline<TPayload> | undefined>;
1675
+ /**
1676
+ * Deadlines that exhausted their delivery attempts. Wire this to durable
1677
+ * alerting and reconciliation: a growing set means processes that stopped
1678
+ * waking up, and the poller can stop between the store transition and its
1679
+ * immediate observer callback.
1680
+ */
1681
+ deadLetters(): Promise<ReadonlyArray<DeadLetterDeadline<TPayload>>>;
1682
+ }
1953
1683
  //#endregion
1954
- //#region src/app/idempotency.d.ts
1684
+ //#region src/application/idempotency/idempotency.d.ts
1955
1685
  /**
1956
1686
  * Result of `IdempotencyStore.claim()`: this execution owns the key and must
1957
1687
  * run the command (`claimed`), a previous execution completed and its outcome
@@ -2219,162 +1949,7 @@ interface WithIdempotentCommitDeps<Evt extends AnyDomainEvent, TCtx> extends Wit
2219
1949
  */
2220
1950
  declare function withIdempotentCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: WithIdempotentCommitDeps<Evt, TCtx>, request: IdempotentCommitRequest, fn: (ctx: TCtx, enrollment: CommitEnrollment<Evt>, execution: IdempotentExecution) => Promise<WithCommitWorkResult<Evt, R>>): Promise<IdempotentCommitResult<R>>;
2221
1951
  //#endregion
2222
- //#region src/deadlines/deadline-store.d.ts
2223
- /**
2224
- * One deadline due for delivery, as returned by
2225
- * {@link DeadlineStore.due}. `deliveryId` identifies this scheduled
2226
- * INCARNATION of the deadline, not the `(scope, key)` address: a
2227
- * reschedule replaces the incarnation, and acknowledging a stale
2228
- * incarnation must never consume the new one (see
2229
- * {@link DeadlineStore.markDelivered}).
2230
- */
2231
- interface DueDeadline<TPayload = unknown> {
2232
- /** Opaque per-incarnation id used for `markDelivered`/`markFailed`. */
2233
- readonly deliveryId: string;
2234
- /** The namespace half of the address, e.g. a process or policy name. */
2235
- readonly scope: string;
2236
- /** The instance half of the address, e.g. a saga or reservation id. */
2237
- readonly key: string;
2238
- /** When the deadline was due. */
2239
- readonly dueAt: Date;
2240
- /** The payload handed back as the input; plain data only. */
2241
- readonly payload: TPayload;
2242
- /** Failed delivery attempts so far (see `markFailed`). */
2243
- readonly attempts: number;
2244
- }
2245
- /** A deadline that exhausted its delivery attempts; see {@link DeadlineStore.deadLetters}. */
2246
- interface DeadLetterDeadline<TPayload = unknown> extends DueDeadline<TPayload> {
2247
- /** Human-readable rendering of the last delivery error, if recorded. */
2248
- readonly lastError?: string;
2249
- }
2250
- /**
2251
- * Driven port for durable deadlines: timeout-as-input. A process that
2252
- * waits ("if PaymentReceived has not arrived in 30 minutes,
2253
- * compensate"; "release the reservation hold after 15 minutes";
2254
- * "expire the offer at month's end") schedules a deadline, and a poll
2255
- * loop later DELIVERS it as an input to whatever owns the decision, a
2256
- * saga aggregate, a use case, a policy. The store never executes
2257
- * consumer code; firing a deadline means handing back a record.
2258
- *
2259
- * Deliberately general-purpose and deliberately small. This is not a
2260
- * scheduler framework and not a cron abstraction: there is no
2261
- * recurrence, no execution engine, and the poll loop belongs to the
2262
- * consumer (the outbox guide's `drainOnce` pattern fits; the deadlines
2263
- * guide shows the wiring).
2264
- *
2265
- * Addressing is the `(scope, key)` pair, so one table serves every
2266
- * waiting process in an application: `scope` names the policy
2267
- * ("checkout-saga", "reservation-hold"), `key` the instance. There is
2268
- * at most ONE pending deadline per address; `schedule` on an existing
2269
- * address replaces it (that IS the reschedule operation), and each
2270
- * scheduling gets a fresh `deliveryId`, so acknowledgements of a
2271
- * replaced incarnation cannot consume its successor.
2272
- *
2273
- * Two sides, two transactional postures, the same split as the outbox:
2274
- *
2275
- * - **`schedule` and `cancel` are write-side calls** and must join the
2276
- * ambient write transaction (use a tx-bound store instance inside
2277
- * `withCommit`'s callback, exactly like an outbox adapter). This is
2278
- * a correctness rule, not a preference: state that says "waiting for
2279
- * payment" committed without its deadline is a process that never
2280
- * wakes up, and a deadline scheduled for a rolled-back state change
2281
- * is a ghost input.
2282
- * - **`due`, `markDelivered`, `markFailed`, and `deadLetters` are the
2283
- * poll surface** and run out of band, in the consumer's loop.
2284
- *
2285
- * Delivery is at-least-once: a crash between processing and
2286
- * `markDelivered` redelivers, so consumers make deadline handling
2287
- * idempotent (the idempotency store with the `deliveryId` as key is
2288
- * the ready-made answer). Deadlines have no cross-key ordering
2289
- * obligations, so unlike the outbox a poison deadline blocks only
2290
- * itself; bounded retries still matter, which is why failure tracking
2291
- * is part of the port rather than an extension: report failed
2292
- * deliveries via `markFailed`, and the store dead-letters a deadline
2293
- * past its attempt ceiling.
2294
- *
2295
- * Run one logical poller per store unless your adapter's `due` claims
2296
- * records for competing pollers; the same rule as the outbox
2297
- * dispatcher.
2298
- *
2299
- * The bundled processor supplies an `ExecutionContext` to every poll-side
2300
- * operation. Production adapters MUST pass its signal to native I/O or enforce
2301
- * a native timeout no later than `deadlineAt`; the shell can bound its wait but
2302
- * cannot terminate a promise that ignores cancellation. A timed-out write has
2303
- * an unknown outcome. Acknowledgements must remain idempotent when they complete
2304
- * late; a late failure update may count its original delivery attempt and must
2305
- * still no-op after the incarnation was delivered or replaced.
2306
- *
2307
- * Verify an adapter with `createDeadlineStoreContractTests` from
2308
- * `@shirudo/ddd-kit/testing`; `InMemoryDeadlineStore` is the
2309
- * reference.
2310
- *
2311
- * @template TPayload - The payload shape carried from `schedule` to
2312
- * delivery; plain, serializable data (the same discipline as event
2313
- * payloads and snapshots)
2314
- */
2315
- interface DeadlineStore<TPayload = unknown> {
2316
- /**
2317
- * Schedules (or reschedules) the deadline at `(scope, key)`: at most
2318
- * one pending deadline exists per address, and scheduling an
2319
- * occupied address replaces its due time, payload, attempt count,
2320
- * and incarnation. Called inside the write transaction.
2321
- */
2322
- schedule(deadline: {
2323
- scope: string;
2324
- key: string;
2325
- dueAt: Date;
2326
- payload: TPayload;
2327
- }): Promise<void>;
2328
- /**
2329
- * Removes the pending deadline at `(scope, key)`; a no-op when none
2330
- * exists (the awaited input arrived in time and the wait is over).
2331
- * Called inside the write transaction.
2332
- */
2333
- cancel(scope: string, key: string): Promise<void>;
2334
- /**
2335
- * Up to `limit` deadlines with `dueAt <= now` that are neither
2336
- * delivered nor dead-lettered, ordered by `dueAt` (earliest first;
2337
- * ties in scheduling order). A `limit` of `0` is legal and yields an
2338
- * empty page (poll loops computing a remaining capacity may pass
2339
- * it). `now` is a parameter on purpose: the poll loop owns the
2340
- * clock, which keeps adapters deterministic and tests free of real
2341
- * time. The bundled processor always supplies `context`; it is optional only
2342
- * so existing adapters remain assignable.
2343
- */
2344
- due(now: Date, limit: number, context?: ExecutionContext): Promise<ReadonlyArray<DueDeadline<TPayload>>>;
2345
- /**
2346
- * Acknowledges delivered incarnations so they stop coming back.
2347
- * Idempotent on already-acknowledged and unknown ids, and a no-op
2348
- * for ids of REPLACED incarnations (a late ack after a reschedule
2349
- * must not consume the successor). Also clears a dead-lettered
2350
- * incarnation (manual redelivery, then ack). It remains idempotent if the
2351
- * operation completes after the caller timed out. The bundled processor
2352
- * always supplies `context`.
2353
- */
2354
- markDelivered(deliveryIds: ReadonlyArray<string>, context?: ExecutionContext): Promise<void>;
2355
- /**
2356
- * Records one failed delivery attempt for the incarnation:
2357
- * increments its `attempts` and, once the store's ceiling is
2358
- * reached, moves it to the dead-letter set that `due` no longer
2359
- * returns. A no-op for unknown, delivered, or replaced ids.
2360
- * Returns the exact dead-letter record only on the call that performs
2361
- * that transition; retries below the ceiling and no-ops return
2362
- * `undefined`. A late completion may count that original delivery attempt; it
2363
- * must still no-op if the incarnation was delivered or replaced in the
2364
- * meantime. The bundled processor never reissues the same store call and
2365
- * always supplies `context`.
2366
- */
2367
- markFailed(deliveryId: string, error?: unknown, context?: ExecutionContext): Promise<DeadLetterDeadline<TPayload> | undefined>;
2368
- /**
2369
- * Deadlines that exhausted their delivery attempts. Wire this to durable
2370
- * alerting and reconciliation: a growing set means processes that stopped
2371
- * waking up, and the poller can stop between the store transition and its
2372
- * immediate observer callback.
2373
- */
2374
- deadLetters(): Promise<ReadonlyArray<DeadLetterDeadline<TPayload>>>;
2375
- }
2376
- //#endregion
2377
- //#region src/projections/ports.d.ts
1952
+ //#region src/application/projections/ports.d.ts
2378
1953
  /**
2379
1954
  * A projection's gap-proof cursor into one aggregate's commit chain.
2380
1955
  * `aggregateVersion` plus `commitSequence` orders events; `commitSize`
@@ -2529,7 +2104,7 @@ interface Projection<Evt extends AnyDomainEvent, TCtx = unknown> {
2529
2104
  truncate?(ctx: TCtx): Promise<void>;
2530
2105
  }
2531
2106
  //#endregion
2532
- //#region src/repo/event-store.d.ts
2107
+ //#region src/persistence/event-store/event-store.d.ts
2533
2108
  /** Options for {@link EventStore.append}. */
2534
2109
  interface EventStoreAppendOptions {
2535
2110
  /**
@@ -2554,8 +2129,9 @@ interface ReadStreamOptions {
2554
2129
  * Return only events AFTER this stream position (1-based event count),
2555
2130
  * the snapshot catch-up read: `readStream(stream, { fromVersion:
2556
2131
  * snapshot.version, limit: 256 })` yields the next page passed to
2557
- * `aggregate.loadFromHistory`. Defaults to `0` (the first
2558
- * stream page).
2132
+ * `aggregate.replayHistory`; the caller checks that the aggregate
2133
+ * ends at the pinned head ({@link ReplayHeadMismatchError}). Defaults
2134
+ * to `0` (the first stream page).
2559
2135
  * Must be a non-negative safe integer when present.
2560
2136
  */
2561
2137
  readonly fromVersion?: number;
@@ -2617,29 +2193,40 @@ type StreamReadResult<Evt extends AnyDomainEvent> = {
2617
2193
  * const cached = this.tracking.identityMap.get(Order, id);
2618
2194
  * if (cached) return cached;
2619
2195
  * const address = this.stream(id);
2620
- * const order = Order.reconstitute(id); // bare instance, no events
2621
- * let fromVersion = 0;
2622
- * let targetVersion: number | undefined;
2623
- * for (;;) {
2196
+ * const first = await this.eventStore.readStream(address, { limit: 256 });
2197
+ * if (!first.exists) return undefined;
2198
+ * const targetVersion = first.lastVersion; // pin the first observed head
2199
+ * const reconstituted = reconstituteAggregateFromHistory(
2200
+ * () => Order.reconstitute(id), // bare instance, no events
2201
+ * first.events,
2202
+ * );
2203
+ * if (reconstituted.isErr()) throw reconstituted.error; // corrupt stream
2204
+ * const order = reconstituted.value;
2205
+ * let fromVersion = first.events.length;
2206
+ * while (fromVersion < targetVersion) {
2624
2207
  * const page = await this.eventStore.readStream(address, {
2625
2208
  * fromVersion,
2626
2209
  * toVersion: targetVersion,
2627
2210
  * limit: 256,
2628
2211
  * });
2629
- * if (!page.exists) return undefined;
2630
- * targetVersion ??= page.lastVersion; // pin the first observed head
2631
- * if (fromVersion === targetVersion) break;
2632
- * if (page.events.length === 0) {
2212
+ * if (!page.exists || page.events.length === 0) {
2633
2213
  * throw new NonProgressingEventStreamPageError({
2634
2214
  * ...address,
2635
2215
  * fromVersion,
2636
2216
  * targetVersion,
2637
2217
  * });
2638
2218
  * }
2639
- * const result = order.loadFromHistory(page.events);
2640
- * if (result.isErr()) throw result.error; // corrupt stream
2219
+ * const catchUp = order.replayHistory(page.events);
2220
+ * if (catchUp.isErr()) throw catchUp.error; // corrupt stream
2641
2221
  * fromVersion += page.events.length;
2642
2222
  * }
2223
+ * if (order.version !== targetVersion) {
2224
+ * throw new ReplayHeadMismatchError({
2225
+ * ...address,
2226
+ * targetVersion,
2227
+ * actualVersion: order.version,
2228
+ * });
2229
+ * }
2643
2230
  * return this.tracking.trackLoaded(order);
2644
2231
  * }
2645
2232
  *
@@ -2696,7 +2283,7 @@ interface EventStore<Evt extends AnyDomainEvent> {
2696
2283
  * append order and slicing; because the port cannot inject malformed
2697
2284
  * physical rows, adapters add a store-specific corruption fixture that
2698
2285
  * proves the duplicate/gap rejection. The repository then calls
2699
- * `loadFromHistory`, whose replay guard rejects any event carrying an
2286
+ * `replayHistory`, whose replay guard rejects any event carrying an
2700
2287
  * aggregate type or id that contradicts this stream key.
2701
2288
  *
2702
2289
  * An empty `events` array is a no-op; implementations resolve without
@@ -2742,12 +2329,12 @@ interface EventStore<Evt extends AnyDomainEvent> {
2742
2329
  readStream(stream: AggregateAddress, options: ReadStreamOptions): Promise<StreamReadResult<Evt>>;
2743
2330
  }
2744
2331
  //#endregion
2745
- //#region src/repo/snapshot-store.d.ts
2332
+ //#region src/persistence/snapshot-store/snapshot-store.d.ts
2746
2333
  /**
2747
2334
  * Driven port for aggregate snapshot persistence: the storage half of
2748
2335
  * the snapshot-plus-recent-events load path for event-sourced aggregates.
2749
2336
  * `SnapshotModel` owns projection, migration, and reconstitution;
2750
- * `EventStore.readStream` supplies the catch-up tail to `loadFromHistory`.
2337
+ * `EventStore.readStream` supplies the catch-up tail to `replayHistory`.
2751
2338
  *
2752
2339
  * **A snapshot is derived data, never authority.** The stream remains
2753
2340
  * the source of truth; a snapshot only shortens replay. That shapes
@@ -2804,5 +2391,5 @@ interface SnapshotStore<TState = unknown> {
2804
2391
  delete(address: AggregateAddress): Promise<void>;
2805
2392
  }
2806
2393
  //#endregion
2807
- export { OutboxWriter as $, ClockFactory as $t, WithCommitWorkResult as A, AnyDomainEvent as At, DurableCommandMessage as B, EventIdFactory as Bt, IdempotentExecution as C, updateEntityById as Ct, CommitEnrollment as D, IEventSourcedAggregate as Dt, AggregateCommitToken as E, IAggregateRoot as Et, CommandMessageContent as F, CreateUncommittedDomainEventOptions as Ft, DispatchTrackingOutbox as G, copyMetadata as Gt, CommitPosition as H, PendingDomainEvent as Ht, CommandMessageRelationships as I, DomainEvent as It, EventCommitCandidatePosition as J, createDomainEventFromFacts as Jt, EventBus as K, createDomainEvent as Kt, CommandOutboxCommitCandidate as L, DomainEventFactory as Lt, TransactionScope as M, CreateDomainEventFromFactsOptions as Mt, TransactionalOptions as N, CreateDomainEventOptions as Nt, CommitEnrollmentOptions as O, Version as Ot, CommandCommitOriginCandidate as P, CreateDomainEventStampOptions as Pt, OutboxRecord as Q, recordDomainEvent as Qt, CommandOutboxMapper as R, DomainEventFactoryOptions as Rt, IdempotentCommitResult as S, sameEntity as St, withIdempotentCommit as T, AggregateSnapshot as Tt, CommittedDomainEvent as U, UncommittedDomainEvent as Ut, routeEventsToCommandOutbox as V, EventMetadata as Vt, DeadLetterRecord as W, UncommittedDomainEventOf as Wt, OnceOptions as X, defaultDomainEventFactory as Xt, EventHandler as Y, createUncommittedDomainEvent as Yt, Outbox as Z, mergeMetadata as Zt, IdempotencyOperationErrorContext as _, findEntityById as _t, StreamReadResult as a, JsonObject as at, IdempotencyStore as b, removeEntityById as bt, ProjectionCheckpointStore as c, AggregateRoot as ct, DeadLetterDeadline as d, Entity as dt, Id as en, PublishOptions as et, DeadlineStore as f, EntityConfig as ft, IdempotencyLease as g, entityIds as gt, IdempotencyClaimHandle as h, StateValidator as ht, ReadStreamOptions as i, PublishedCommand as it, withCommit as j, AnyUncommittedDomainEvent as jt, WithCommitDeps as k, sameVersion as kt, ProjectionPosition as l, AggregateConfig as lt, IdempotencyClaim as m, Identifiable as mt, EventStore as n, Command as nt, Projection as o, JsonPrimitive as ot, DueDeadline as p, IEntity as pt, EventCommitCandidate as q, createDomainEventFactory as qt, EventStoreAppendOptions as r, CommandHandler as rt, ProjectionCheckpoint as s, JsonValue as st, SnapshotStore as t, IdGenerator as tn, ExecutionContext as tt, isPositionAfter as u, BaseAggregate as ut, IdempotencyReconciliation as v, freezeShallow as vt, WithIdempotentCommitDeps as w, AggregateAddress as wt, IdempotentCommitRequest as x, replaceEntityById as xt, IdempotencyReconciliationDecision as y, hasEntityId as yt, CommandOutboxWriter as z, DomainEventStamp as zt };
2394
+ export { routeEventsToCommandOutbox as $, WithCommitWorkResult as A, defaultDomainEventFactory as At, ReplayableAggregate as B, JsonValue as Bt, DeadLetterDeadline as C, UncommittedDomainEvent as Ct, CommitEnrollment as D, createDomainEventFactory as Dt, AggregateCommitToken as E, createDomainEvent as Et, EventHandler as F, Command as Ft, IdGenerator as G, sameVersion as H, OnceOptions as I, CommandHandler as It, CommandMessageRelationships as J, CommandCommitOriginCandidate as K, PublishOptions as L, PublishedCommand as Lt, TransactionScope as M, recordDomainEvent as Mt, TransactionalOptions as N, ClockFactory as Nt, CommitEnrollmentOptions as O, createDomainEventFromFacts as Ot, EventBus as P, AggregateAddress as Pt, DurableCommandMessage as Q, Aggregate as R, JsonObject as Rt, withIdempotentCommit as S, PendingDomainEvent as St, DueDeadline as T, copyMetadata as Tt, toVersion as U, Version as V, Id as W, CommandOutboxMapper as X, CommandOutboxCommitCandidate as Y, CommandOutboxWriter as Z, IdempotencyStore as _, DomainEventFactory as _t, StreamReadResult as a, ExecutionContext as at, IdempotentExecution as b, EventIdFactory as bt, ProjectionCheckpointStore as c, EventCommitCandidate as ct, IdempotencyClaim as d, AnyUncommittedDomainEvent as dt, DeadLetterRecord as et, IdempotencyClaimHandle as f, CreateDomainEventFromFactsOptions as ft, IdempotencyReconciliationDecision as g, DomainEvent as gt, IdempotencyReconciliation as h, CreateUncommittedDomainEventOptions as ht, ReadStreamOptions as i, OutboxWriter as it, withCommit as j, mergeMetadata as jt, WithCommitDeps as k, createUncommittedDomainEvent as kt, ProjectionPosition as l, EventCommitCandidatePosition as lt, IdempotencyOperationErrorContext as m, CreateDomainEventStampOptions as mt, EventStore as n, Outbox as nt, Projection as o, CommitPosition as ot, IdempotencyLease as p, CreateDomainEventOptions as pt, CommandMessageContent as q, EventStoreAppendOptions as r, OutboxRecord as rt, ProjectionCheckpoint as s, CommittedDomainEvent as st, SnapshotStore as t, DispatchTrackingOutbox as tt, isPositionAfter as u, AnyDomainEvent as ut, IdempotentCommitRequest as v, DomainEventFactoryOptions as vt, DeadlineStore as w, UncommittedDomainEventOf as wt, WithIdempotentCommitDeps as x, EventMetadata as xt, IdempotentCommitResult as y, DomainEventStamp as yt, AggregateSnapshot as z, JsonPrimitive as zt };
2808
2395
  //# sourceMappingURL=snapshot-store.d.ts.map