@shirudo/ddd-kit 1.0.0-rc.4 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -189,6 +189,13 @@ interface DomainEvent<T extends string, P = void> {
189
189
  */
190
190
  metadata?: EventMetadata;
191
191
  }
192
+ /**
193
+ * Upper-bound alias for "any `DomainEvent` shape". Use as a generic
194
+ * constraint when a type parameter should accept any concrete event
195
+ * union. The `unknown` payload is the upper bound — concrete unions
196
+ * still narrow via `Extract<Evt, { type: K }>` at the use-site.
197
+ */
198
+ type AnyDomainEvent = DomainEvent<string, unknown>;
192
199
  /**
193
200
  * Shared option bag for the `createDomainEvent*` factories.
194
201
  */
@@ -263,7 +270,7 @@ declare function createDomainEventWithMetadata<T extends string, P>(type: T, pay
263
270
  * );
264
271
  * ```
265
272
  */
266
- declare function copyMetadata(sourceEvent: DomainEvent<string, unknown>, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
273
+ declare function copyMetadata(sourceEvent: AnyDomainEvent, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
267
274
  /**
268
275
  * Merges multiple metadata objects into one.
269
276
  * Later metadata objects override earlier ones for the same keys.
@@ -502,7 +509,7 @@ declare function sameEntity<TId extends Id<string>>(a: Identifiable<TId>, b: Ide
502
509
  * // item is { id: itemId1, productId: "prod-1", quantity: 2 }
503
510
  * ```
504
511
  */
505
- declare function findEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId): T | undefined;
512
+ declare function findEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): T | undefined;
506
513
  /**
507
514
  * Checks if an entity with the given ID exists in the collection.
508
515
  *
@@ -520,7 +527,7 @@ declare function findEntityById<TId extends Id<string>, T extends Identifiable<T
520
527
  * hasEntityId(items, itemId2); // false
521
528
  * ```
522
529
  */
523
- declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId): boolean;
530
+ declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): boolean;
524
531
  /**
525
532
  * Removes an entity with the given ID from the collection.
526
533
  * Returns a new array without the entity.
@@ -540,7 +547,7 @@ declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>
540
547
  * // updated is [{ id: itemId2, productId: "prod-2", quantity: 1 }]
541
548
  * ```
542
549
  */
543
- declare function removeEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId): T[];
550
+ declare function removeEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): T[];
544
551
  /**
545
552
  * Updates an entity with the given ID in the collection.
546
553
  * Returns a new array with the updated entity.
@@ -564,7 +571,7 @@ declare function removeEntityById<TId extends Id<string>, T extends Identifiable
564
571
  * // updated is [{ id: itemId1, productId: "prod-1", quantity: 3 }]
565
572
  * ```
566
573
  */
567
- declare function updateEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId, updater: (entity: T) => T): T[];
574
+ declare function updateEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, updater: (entity: T) => T): T[];
568
575
  /**
569
576
  * Replaces an entity with the given ID in the collection.
570
577
  * Returns a new array with the replaced entity.
@@ -588,7 +595,7 @@ declare function updateEntityById<TId extends Id<string>, T extends Identifiable
588
595
  * });
589
596
  * ```
590
597
  */
591
- declare function replaceEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId, replacement: T): T[];
598
+ declare function replaceEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, replacement: T): T[];
592
599
  /**
593
600
  * Extracts all IDs from a collection of entities.
594
601
  *
@@ -606,7 +613,7 @@ declare function replaceEntityById<TId extends Id<string>, T extends Identifiabl
606
613
  * // ids is [itemId1, itemId2]
607
614
  * ```
608
615
  */
609
- declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[]): TId[];
616
+ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>): TId[];
610
617
 
611
618
  /**
612
619
  * Marker interface for Aggregate Roots.
@@ -635,7 +642,7 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
635
642
  * }
636
643
  * ```
637
644
  */
638
- interface IAggregateRoot<TId extends Id<string>> {
645
+ interface IAggregateRoot<TId extends Id<string>, TEvent = never> {
639
646
  /**
640
647
  * Unique identifier of the aggregate root entity.
641
648
  */
@@ -646,11 +653,25 @@ interface IAggregateRoot<TId extends Id<string>> {
646
653
  * This version applies to the entire aggregate, including all child entities.
647
654
  */
648
655
  readonly version: Version;
656
+ /**
657
+ * Read-only list of domain events recorded on this aggregate that have
658
+ * not yet been flushed to the outbox / persistence layer. Both state-
659
+ * stored (`AggregateRoot`) and event-sourced (`EventSourcedAggregate`)
660
+ * aggregates expose them under the same name, so Repository.save() can
661
+ * harvest them uniformly without branching on the aggregate flavour.
662
+ */
663
+ readonly pendingEvents: ReadonlyArray<TEvent>;
664
+ /**
665
+ * Clears the pending-event list. Called by `markPersisted` after a
666
+ * successful write — the events have been handed off to the outbox
667
+ * / event store and are no longer the aggregate's responsibility.
668
+ */
669
+ clearPendingEvents(): void;
649
670
  /**
650
671
  * Post-save hook: a `Repository.save()` implementation calls this with
651
672
  * the persisted version after a successful write to push the new
652
- * version back into the aggregate and clear any recorded domain events
653
- * (they are now safely on the write side / in the outbox).
673
+ * version back into the aggregate and clear pendingEvents (they are
674
+ * now safely on the write side / in the outbox).
654
675
  *
655
676
  * Required by the interface so a Repository implementation can call it
656
677
  * via the published `IAggregateRoot` contract without taking the
@@ -665,17 +686,14 @@ interface IAggregateRoot<TId extends Id<string>> {
665
686
  */
666
687
  interface AggregateConfig {
667
688
  /**
668
- * Whether `setState()` should bump the version automatically.
669
- *
670
- * Defaults to **`false`** for `AggregateRoot` — because `setState()`
671
- * already takes an explicit `bumpVersion` argument per call, so adding
672
- * an "always bump" config on top would be redundant. Keep it `false`
673
- * unless you have a subclass that never passes `bumpVersion` and you
674
- * want every state change to advance the version anyway.
689
+ * Whether `setState()` should bump the version automatically when the
690
+ * caller omits the per-call `bumpVersion` argument.
675
691
  *
676
- * (Contrast with `EventSourcedAggregate`, which defaults this to
677
- * `true` because every event-sourced state change is per definition a
678
- * versioned commit.)
692
+ * Defaults to **`false`** — `setState()` already takes an explicit
693
+ * `bumpVersion` argument per call, so the config is just the default
694
+ * the per-call argument falls back to. Set to `true` only if you have
695
+ * a subclass that never passes `bumpVersion` and you want every state
696
+ * change to advance the version anyway.
679
697
  */
680
698
  autoVersionBump?: boolean;
681
699
  }
@@ -701,7 +719,7 @@ interface AggregateConfig {
701
719
  *
702
720
  * @template TState - The type of the aggregate state (contains child entities and value objects)
703
721
  * @template TId - The type of the aggregate root identifier
704
- * @template TEvent - The type of domain events recorded by this aggregate (defaults to unknown)
722
+ * @template TEvent - The type of domain events recorded by this aggregate. Defaults to `never` — aggregates without a declared event type cannot emit events (emitting any event becomes a compile error). Supply a concrete event union to opt in.
705
723
  *
706
724
  * @example
707
725
  * ```typescript
@@ -717,28 +735,28 @@ interface AggregateConfig {
717
735
  * }
718
736
  * ```
719
737
  */
720
- declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId> {
738
+ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
721
739
  private _version;
722
740
  get version(): Version;
723
741
  protected setVersion(version: Version): void;
724
742
  private readonly _config;
725
743
  private readonly _autoVersionBump;
726
- private _domainEvents;
744
+ private _pendingEvents;
727
745
  /**
728
- * Returns a read-only list of domain events recorded by this aggregate.
729
- * These events are side-effects of state changes.
746
+ * Read-only list of domain events recorded on this aggregate that have
747
+ * not yet been flushed to the outbox / persistence layer.
730
748
  */
731
- get domainEvents(): ReadonlyArray<TEvent>;
749
+ get pendingEvents(): ReadonlyArray<TEvent>;
732
750
  /**
733
- * Clears the list of recorded domain events.
734
- * Call this after dispatching the events.
751
+ * Clears the pending-event list. Call this after the events have been
752
+ * dispatched (typically `markPersisted` handles it for you).
735
753
  */
736
- clearDomainEvents(): void;
754
+ clearPendingEvents(): void;
737
755
  /**
738
756
  * Post-save hook called by a `Repository.save()` implementation to push
739
- * the persisted version back into the in-memory aggregate and clear the
740
- * recorded domain events (they are now safely on the write side / in
741
- * the outbox).
757
+ * the persisted version back into the in-memory aggregate and clear
758
+ * pendingEvents (they are now safely on the write side / in the
759
+ * outbox).
742
760
  *
743
761
  * Use this so `save()` can keep its `Promise<void>` return type: the
744
762
  * caller holds the aggregate reference, which is up to date after this
@@ -880,14 +898,11 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = ne
880
898
  * they typically map to HTTP 400 / business-rule responses.
881
899
  *
882
900
  * The library itself does **not** ship any concrete `DomainError`
883
- * subclass — the kit can't know your invariants. {@link MissingHandlerError},
884
- * {@link AggregateNotFoundError}, and {@link ConcurrencyConflictError}
885
- * deliberately sit on other branches of the hierarchy (see below) because
886
- * they are not invariant violations.
901
+ * subclass — the kit can't know your invariants.
887
902
  *
888
- * Extends `BaseError<Name>` from `@shirudo/base-error`, so derived
889
- * classes get timestamps, `error.cause` traversal, `toJSON()`, i18n-
890
- * aware `getUserMessage()`, and the `isRetryable` predicate for free.
903
+ * Extends `BaseError<Name>`; see `@shirudo/base-error` for the inherited
904
+ * surface (timestamps, cause chains, `toJSON()`, `getUserMessage()`,
905
+ * `isRetryable`, …).
891
906
  */
892
907
  declare abstract class DomainError<Name extends string = string> extends BaseError<Name> {
893
908
  }
@@ -899,11 +914,8 @@ declare abstract class DomainError<Name extends string = string> extends BaseErr
899
914
  * broken); they describe race conditions and missing rows at the
900
915
  * storage boundary.
901
916
  *
902
- * Library-internal concrete subclasses:
903
- * - {@link AggregateNotFoundError}
904
- * - {@link ConcurrencyConflictError}
905
- *
906
- * Extends `BaseError<Name>` from `@shirudo/base-error`.
917
+ * Library-internal concrete subclasses: {@link AggregateNotFoundError},
918
+ * {@link ConcurrencyConflictError}.
907
919
  */
908
920
  declare abstract class InfrastructureError<Name extends string = string> extends BaseError<Name> {
909
921
  }
@@ -926,7 +938,7 @@ declare abstract class InfrastructureError<Name extends string = string> extends
926
938
  */
927
939
  declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
928
940
  readonly eventType: string;
929
- constructor(eventType: string);
941
+ constructor(eventType: string, cause?: unknown);
930
942
  }
931
943
  /**
932
944
  * Thrown by `IRepository.getByIdOrFail()` when an aggregate with the
@@ -934,13 +946,17 @@ declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
934
946
  * boundary, not a business rule, decided the row is absent. Use the
935
947
  * nullable variant `getById()` if "not found" is a valid outcome.
936
948
  *
937
- * Ships with a user-safe message via `withUserMessage`. Not retryable —
938
- * retrying won't make the row appear.
949
+ * Accepts an optional `cause` so a `Repository.save()` implementation
950
+ * can wrap a lower-level "row not found" / driver-level error without
951
+ * losing context. Cause-chain helpers (`getRootCause`,
952
+ * `findInCauseChain`) from `@shirudo/base-error` traverse the chain.
953
+ *
954
+ * Not retryable — retrying won't make the row appear.
939
955
  */
940
956
  declare class AggregateNotFoundError extends InfrastructureError<"AggregateNotFoundError"> {
941
957
  readonly aggregateType: string;
942
958
  readonly id: string;
943
- constructor(aggregateType: string, id: string);
959
+ constructor(aggregateType: string, id: string, cause?: unknown);
944
960
  }
945
961
  /**
946
962
  * Thrown by `IRepository.save()` when the aggregate's expected version
@@ -964,7 +980,7 @@ declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyC
964
980
  * the use case, and retry on this exception.
965
981
  */
966
982
  readonly retryable: true;
967
- constructor(aggregateType: string, aggregateId: string, expectedVersion: number, actualVersion: number);
983
+ constructor(aggregateType: string, aggregateId: string, expectedVersion: number, actualVersion: number, cause?: unknown);
968
984
  }
969
985
 
970
986
  /**
@@ -974,11 +990,7 @@ declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyC
974
990
  * @template TId - The type of the aggregate root identifier
975
991
  * @template TEvent - The union type of all domain events
976
992
  */
977
- interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends DomainEvent<string, unknown>> extends IAggregateRoot<TId> {
978
- /**
979
- * Returns a read-only list of new, not-yet-persisted events.
980
- */
981
- readonly pendingEvents: ReadonlyArray<TEvent>;
993
+ interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends IAggregateRoot<TId, TEvent> {
982
994
  /**
983
995
  * Reconstitutes the aggregate from an event history. Returns `Result`
984
996
  * because event-stream corruption is an expected recoverable failure
@@ -986,45 +998,9 @@ interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends DomainEv
986
998
  *
987
999
  * @param history - An ordered list of past events
988
1000
  */
989
- loadFromHistory(history: TEvent[]): Result<void, DomainError>;
990
- /**
991
- * Clears the list of pending events.
992
- */
993
- clearPendingEvents(): void;
994
- /**
995
- * Checks if the aggregate has any pending events.
996
- */
997
- hasPendingEvents(): boolean;
998
- /**
999
- * Returns the number of pending events.
1000
- */
1001
- getEventCount(): number;
1002
- /**
1003
- * Returns the latest pending event, if any.
1004
- */
1005
- getLatestEvent(): TEvent | undefined;
1006
- }
1007
- type Handler<TState, TEvent> = (state: TState, event: TEvent) => TState;
1008
- /**
1009
- * Configuration options for EventSourcedAggregate behavior.
1010
- */
1011
- interface EventSourcedAggregateConfig {
1012
- /**
1013
- * Whether `apply()` should bump the version per event.
1014
- *
1015
- * Defaults to **`true`** for `EventSourcedAggregate` — each applied
1016
- * event is by definition a versioned state change, so the canonical
1017
- * event-sourcing pattern is "one event = one version bump". Set to
1018
- * `false` only if your event store assigns version numbers itself
1019
- * and you want the aggregate to track them via `bumpVersion()` /
1020
- * `setVersion()` calls instead.
1021
- *
1022
- * (Contrast with `AggregateRoot`, which defaults this to `false`
1023
- * because its `setState()` already takes a per-call `bumpVersion`
1024
- * argument.)
1025
- */
1026
- autoVersionBump?: boolean;
1001
+ loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
1027
1002
  }
1003
+ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEvent) => TState;
1028
1004
  /**
1029
1005
  * Base class for Event-Sourced Aggregate Roots (Vernon, IDDD Chapter 8).
1030
1006
  *
@@ -1074,12 +1050,11 @@ interface EventSourcedAggregateConfig {
1074
1050
  * }
1075
1051
  * ```
1076
1052
  */
1077
- declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<string, unknown>, TId extends Id<string>> extends Entity<TState, TId> implements IEventSourcedAggregate<TId, TEvent> {
1053
+ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string>> extends Entity<TState, TId> implements IEventSourcedAggregate<TId, TEvent> {
1078
1054
  private _version;
1079
1055
  get version(): Version;
1080
1056
  private setVersion;
1081
1057
  private _pendingEvents;
1082
- private readonly _autoVersionBump;
1083
1058
  get pendingEvents(): ReadonlyArray<TEvent>;
1084
1059
  clearPendingEvents(): void;
1085
1060
  /**
@@ -1089,7 +1064,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1089
1064
  * `save()` keep its `Promise<void>` return type.
1090
1065
  */
1091
1066
  markPersisted(version: Version): void;
1092
- protected constructor(id: TId, initialState: TState, config?: EventSourcedAggregateConfig);
1067
+ protected constructor(id: TId, initialState: TState);
1093
1068
  /**
1094
1069
  * Validates an event before it is applied. Default is no-op.
1095
1070
  * Subclasses override to throw a concrete `DomainError` subclass when
@@ -1125,11 +1100,6 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1125
1100
  * resolved via the (statically-sound) `handlers` map.
1126
1101
  */
1127
1102
  private dispatchAndCommit;
1128
- /**
1129
- * Manually bumps the aggregate version.
1130
- * Only needed if `autoVersionBump` is disabled.
1131
- */
1132
- protected bumpVersion(): void;
1133
1103
  /**
1134
1104
  * Reconstitutes the aggregate from an event history. Catches `DomainError`
1135
1105
  * thrown during replay and returns it as an `Err` — this is the
@@ -1141,10 +1111,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1141
1111
  * an aggregate already at v=1 (e.g. after a creation event) loading
1142
1112
  * 2 events ends at v=3, not v=2.
1143
1113
  */
1144
- loadFromHistory(history: TEvent[]): Result<void, DomainError>;
1145
- hasPendingEvents(): boolean;
1146
- getEventCount(): number;
1147
- getLatestEvent(): TEvent | undefined;
1114
+ loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
1148
1115
  /**
1149
1116
  * Creates a snapshot of the current aggregate state.
1150
1117
  */
@@ -1159,7 +1126,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1159
1126
  * aggregate is rolled back to its pre-call state + version. Partial
1160
1127
  * restoration is never observable to the caller.
1161
1128
  */
1162
- restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TState>, eventsAfterSnapshot: TEvent[]): Result<void, DomainError>;
1129
+ restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TState>, eventsAfterSnapshot: ReadonlyArray<TEvent>): Result<void, DomainError>;
1163
1130
  /**
1164
1131
  * A map of event types to their corresponding handlers.
1165
1132
  * Subclasses MUST implement this property.
@@ -1458,9 +1425,7 @@ type EventHandler<Evt> = (event: Evt) => Promise<void> | void;
1458
1425
  * await bus.publish([orderCreatedEvent, orderShippedEvent]);
1459
1426
  * ```
1460
1427
  */
1461
- interface EventBus<Evt extends {
1462
- type: string;
1463
- }> {
1428
+ interface EventBus<Evt extends AnyDomainEvent> {
1464
1429
  /**
1465
1430
  * Publishes events to all subscribed handlers.
1466
1431
  *
@@ -1553,7 +1518,7 @@ interface OnceOptions {
1553
1518
  * own `eventId`, generate its own UUID, use the row's auto-increment
1554
1519
  * primary key, or whatever the storage layer prefers.
1555
1520
  */
1556
- interface OutboxRecord<Evt> {
1521
+ interface OutboxRecord<Evt extends AnyDomainEvent> {
1557
1522
  dispatchId: string;
1558
1523
  event: Evt;
1559
1524
  }
@@ -1573,7 +1538,7 @@ interface OutboxRecord<Evt> {
1573
1538
  * that's already marked is a no-op, not an error. This lets the
1574
1539
  * dispatcher safely retry on partial-failure.
1575
1540
  */
1576
- interface Outbox<Evt> {
1541
+ interface Outbox<Evt extends AnyDomainEvent> {
1577
1542
  /**
1578
1543
  * Persists events. Called from inside `withCommit`'s transactional
1579
1544
  * callback, atomically with the aggregate write.
@@ -1604,67 +1569,109 @@ interface Outbox<Evt> {
1604
1569
  * Transaction-scope abstraction.
1605
1570
  *
1606
1571
  * Wraps a block of work so it runs inside the persistence layer's native
1607
- * transaction (Postgres `BEGIN`/`COMMIT`, Mongo session, etc.). The block
1608
- * commits when the callback resolves and rolls back if it throws.
1609
- *
1610
- * This is **not** Fowler's full Unit of Work (no change tracking, no
1611
- * registerDirty/registerNew/registerDeleted, no commit-time flush). It is
1612
- * intentionally minimal change tracking is the ORM's job; the library
1613
- * stays out of it. The name `TransactionScope` is therefore more honest
1614
- * than `UnitOfWork`.
1572
+ * transaction (Postgres `BEGIN`/`COMMIT`, Mongo session, Drizzle / Prisma
1573
+ * `$transaction`, etc.). The block commits when the callback resolves
1574
+ * and rolls back if it throws.
1575
+ *
1576
+ * `TCtx` is the persistence layer's transaction handle — Drizzle's `tx`,
1577
+ * Prisma's `tx`, Mongo's session, etc. The scope opens the transaction
1578
+ * and passes the handle to `fn`; the use case binds its repositories to
1579
+ * that handle (typically by constructing a tx-scoped repo from the ctx).
1580
+ *
1581
+ * No default for `TCtx`: every implementor names their context type
1582
+ * explicitly. For genuinely context-free scopes (in-memory tests, naive
1583
+ * no-tx scopes) use `TransactionScope<undefined>` — that's a conscious
1584
+ * "there is nothing meaningful here" statement, not an accidental
1585
+ * `unknown` fallback.
1586
+ *
1587
+ * Intentionally **not** Fowler's full Unit of Work (no change tracking,
1588
+ * no `registerDirty` / `registerNew` / `registerDeleted`, no commit-time
1589
+ * flush). Change tracking is the ORM's job.
1590
+ *
1591
+ * @example Drizzle implementation
1592
+ * ```typescript
1593
+ * class DrizzleScope implements TransactionScope<DrizzleTx> {
1594
+ * constructor(private db: DrizzleDb) {}
1595
+ * async transactional<T>(fn: (tx: DrizzleTx) => Promise<T>): Promise<T> {
1596
+ * return this.db.transaction((tx) => fn(tx));
1597
+ * }
1598
+ * }
1599
+ * ```
1615
1600
  *
1616
- * @example
1601
+ * @example Use site — bind repos to the live transaction
1617
1602
  * ```typescript
1618
- * await scope.transactional(async () => {
1619
- * const order = await repo.getByIdOrFail(orderId);
1603
+ * await scope.transactional(async (tx) => {
1604
+ * // Construct tx-bound repos from ctx (your factory / DI of choice)
1605
+ * const orderRepository = makeOrderRepository(tx);
1606
+ *
1607
+ * const order = await orderRepository.getByIdOrFail(orderId);
1620
1608
  * order.confirm();
1621
- * await repo.save(order);
1609
+ * await orderRepository.save(order);
1622
1610
  * });
1623
1611
  * ```
1612
+ *
1613
+ * `IRepository`'s contract takes the id / aggregate only — the tx handle
1614
+ * is wired into a concrete repository at construction time, not threaded
1615
+ * through every call. Different ORMs have different idioms for that
1616
+ * (constructor injection, factory functions, `withTx` chains); pick one
1617
+ * and keep it consistent.
1624
1618
  */
1625
- interface TransactionScope {
1626
- transactional<T>(fn: () => Promise<T>): Promise<T>;
1619
+ interface TransactionScope<TCtx> {
1620
+ transactional<T>(fn: (ctx: TCtx) => Promise<T>): Promise<T>;
1627
1621
  }
1628
1622
 
1629
1623
  /**
1630
- * Helper for executing a write Use Case inside a Unit of Work.
1624
+ * Helper for executing a write Use Case inside a transaction scope.
1625
+ *
1626
+ * The use-case callback returns the aggregates it touched; `withCommit`
1627
+ * owns the post-save lifecycle (harvest, outbox, mark-persisted, publish).
1628
+ * This matches the Vernon / Axon / EventFlow unit-of-work pattern:
1629
+ * `Repository.save` is pure persistence; "this aggregate has been
1630
+ * committed" is the orchestrator's call to make, not the repo's.
1631
1631
  *
1632
1632
  * Order of operations:
1633
- * 1. `fn()` runs inside `uow.transactional(...)` — domain mutations + repo
1634
- * writes happen here.
1635
- * 2. `outbox.add(events)` is also inside the transaction, so events
1636
- * persist atomically with the state change (outbox pattern).
1633
+ * 1. `fn(ctx)` runs inside `scope.transactional(...)` — domain mutations
1634
+ * + repo writes happen here. `ctx` is whatever transaction handle the
1635
+ * `scope` exposes (Drizzle `tx`, Prisma `tx`, Mongo session, or
1636
+ * `undefined` for context-free scopes).
1637
+ * 2. **Still inside the transaction**, `withCommit` harvests every
1638
+ * aggregate's `pendingEvents` and writes them via `outbox.add` (so
1639
+ * events persist atomically with the state change). Skipped when no
1640
+ * events were recorded.
1637
1641
  * 3. The transaction commits.
1638
- * 4. **After** the commit, `bus.publish(events)` fires for the in-process
1639
- * fast path.
1642
+ * 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
1643
+ * fires on each returned aggregate — only now are pending events
1644
+ * considered flushed.
1645
+ * 5. `bus.publish(events)` fires for the in-process fast path (skipped
1646
+ * when no events or no `bus` is wired).
1640
1647
  *
1641
1648
  * Publishing AFTER commit prevents the classic "publish before commit"
1642
1649
  * footgun: in-process subscribers can never react to events from a
1643
- * transaction that later rolled back. If `bus.publish` itself fails, the
1644
- * outbox still holds the events and an outbox-dispatcher will deliver them
1645
- * (eventual consistency).
1650
+ * transaction that later rolled back. If `bus.publish` itself throws, the
1651
+ * outbox still holds the events and an outbox-dispatcher will deliver
1652
+ * them (eventual consistency).
1646
1653
  *
1647
- * @example
1654
+ * If the transaction rolls back, `markPersisted` is **not** called — the
1655
+ * aggregate keeps its pending events, so the caller can retry or discard.
1656
+ *
1657
+ * @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
1648
1658
  * ```typescript
1649
- * const result = await withCommit(
1650
- * { outbox, bus, uow },
1651
- * async () => {
1652
- * const order = Order.create(customerId, items);
1653
- * await repository.save(order);
1654
- * return { result: order.id, events: order.domainEvents };
1655
- * }
1656
- * );
1659
+ * const result = await withCommit({ outbox, bus, scope }, async (tx) => {
1660
+ * const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
1661
+ * const order = await orderRepository.getByIdOrFail(orderId);
1662
+ * order.confirm();
1663
+ * await orderRepository.save(order); // pure persistence — does NOT call markPersisted
1664
+ * return { result: order.id, aggregates: [order] };
1665
+ * });
1657
1666
  * ```
1658
1667
  */
1659
- declare function withCommit<Evt extends {
1660
- type: string;
1661
- }, R>(deps: {
1668
+ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
1662
1669
  outbox: Outbox<Evt>;
1663
1670
  bus?: EventBus<Evt>;
1664
- scope: TransactionScope;
1665
- }, fn: () => Promise<{
1671
+ scope: TransactionScope<TCtx>;
1672
+ }, fn: (ctx: TCtx) => Promise<{
1666
1673
  result: R;
1667
- events: ReadonlyArray<Evt>;
1674
+ aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
1668
1675
  }>): Promise<R>;
1669
1676
 
1670
1677
  /**
@@ -1899,7 +1906,7 @@ declare class QueryBus<TMap extends QueryTypeMap = QueryTypeMap> implements IQue
1899
1906
  * // Both handlers will be called
1900
1907
  * ```
1901
1908
  */
1902
- declare class EventBusImpl<Evt extends DomainEvent<string, unknown>> implements EventBus<Evt> {
1909
+ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt> {
1903
1910
  private readonly handlers;
1904
1911
  subscribe<K extends Evt["type"]>(eventType: K, handler: EventHandler<Extract<Evt, {
1905
1912
  type: K;
@@ -1918,6 +1925,44 @@ declare class EventBusImpl<Evt extends DomainEvent<string, unknown>> implements
1918
1925
  publish(events: ReadonlyArray<Evt>): Promise<void>;
1919
1926
  }
1920
1927
 
1928
+ /**
1929
+ * In-memory reference implementation of `Outbox<Evt>`.
1930
+ *
1931
+ * Intended for tests, single-process workers, and quick-start demos.
1932
+ * Uses the event's own `eventId` as the dispatch id — the common, clean
1933
+ * choice. Storage is a `Map<string, OutboxRecord<Evt>>` keyed by
1934
+ * `eventId`, so re-adding the same event is naturally idempotent (the
1935
+ * duplicate entry overwrites itself; `getPending` returns each event at
1936
+ * most once).
1937
+ *
1938
+ * For production, back the outbox with a transactional store so the
1939
+ * outbox row participates in the same transaction as the aggregate
1940
+ * write (see `TransactionScope` + `withCommit`). This class lives in
1941
+ * memory only — events are lost on process restart.
1942
+ *
1943
+ * @example
1944
+ * ```ts
1945
+ * import { InMemoryOutbox, EventBusImpl, withCommit } from "@shirudo/ddd-kit";
1946
+ *
1947
+ * const outbox = new InMemoryOutbox<OrderEvent>();
1948
+ * const bus = new EventBusImpl<OrderEvent>();
1949
+ *
1950
+ * await withCommit({ scope, outbox, bus }, async (tx) => {
1951
+ * const orderRepository = makeOrderRepository(tx);
1952
+ * const order = await orderRepository.getByIdOrFail(id);
1953
+ * order.confirm();
1954
+ * await orderRepository.save(order);
1955
+ * return { result: order.id, aggregates: [order] };
1956
+ * });
1957
+ * ```
1958
+ */
1959
+ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt> {
1960
+ private readonly pending;
1961
+ add(events: ReadonlyArray<Evt>): Promise<void>;
1962
+ getPending(limit?: number): Promise<ReadonlyArray<OutboxRecord<Evt>>>;
1963
+ markDispatched(dispatchIds: ReadonlyArray<string>): Promise<void>;
1964
+ }
1965
+
1921
1966
  /**
1922
1967
  * Core repository contract for Aggregate Roots.
1923
1968
  *
@@ -1954,17 +1999,25 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
1954
1999
  */
1955
2000
  exists(id: TId): Promise<boolean>;
1956
2001
  /**
1957
- * Persists the aggregate (insert or update). Implementations should:
2002
+ * Persists the aggregate (insert or update). Implementations are
2003
+ * responsible for **persistence only** — they must NOT touch the
2004
+ * aggregate's in-memory state:
1958
2005
  *
1959
2006
  * 1. Throw `ConcurrencyConflictError` from `@shirudo/ddd-kit` when the
1960
2007
  * aggregate's expected version does not match the version currently
1961
2008
  * stored (optimistic concurrency).
1962
- * 2. After a successful write, call `aggregate.markPersisted(newVersion)`
1963
- * so the in-memory aggregate reflects the new version and clears its
1964
- * pending/domain events.
2009
+ * 2. Write the aggregate to durable storage.
2010
+ *
2011
+ * Do **not** call `aggregate.markPersisted(...)` here. The library's
2012
+ * `withCommit` orchestrator handles the post-save lifecycle (harvest
2013
+ * pending events into the outbox, then mark persisted after commit).
2014
+ * Calling `markPersisted` inside `save` clears pending events too early
2015
+ * and breaks the harvest path — and is also why the Vernon/Axon/
2016
+ * EventFlow pattern separates persistence from commit-events.
1965
2017
  *
1966
- * Return type stays `void` the caller already holds the aggregate
1967
- * reference, which is now up to date.
2018
+ * If you are not using `withCommit` (custom orchestration), call
2019
+ * `aggregate.markPersisted(aggregate.version)` yourself **after** you
2020
+ * have harvested `aggregate.pendingEvents` for downstream dispatch.
1968
2021
  */
1969
2022
  save(aggregate: TAgg): Promise<void>;
1970
2023
  /**
@@ -2242,4 +2295,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2242
2295
  toJSON(): Readonly<T>;
2243
2296
  }
2244
2297
 
2245
- export { type AggregateConfig, AggregateNotFoundError, AggregateRoot, type AggregateSnapshot, type ClockFactory, type Command, CommandBus, type CommandHandler, ConcurrencyConflictError, type CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, type DomainEvent, Entity, type EventBus, EventBusImpl, type EventHandler, type EventIdFactory, type EventMetadata, EventSourcedAggregate, type EventSourcedAggregateConfig, type IAggregateRoot, type ICommandBus, type IEntity, type IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IValueObject, type Id, type IdGenerator, type Identifiable, InfrastructureError, MissingHandlerError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type TransactionScope, type VO, ValueObject, type Version, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voWithValidation, withCommit };
2298
+ export { type AggregateConfig, AggregateNotFoundError, AggregateRoot, type AggregateSnapshot, type AnyDomainEvent, type ClockFactory, type Command, CommandBus, type CommandHandler, ConcurrencyConflictError, type CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, type DomainEvent, Entity, type EventBus, EventBusImpl, type EventHandler, type EventIdFactory, type EventMetadata, EventSourcedAggregate, type IAggregateRoot, type ICommandBus, type IEntity, type IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IValueObject, type Id, type IdGenerator, type Identifiable, InMemoryOutbox, InfrastructureError, MissingHandlerError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type TransactionScope, type VO, ValueObject, type Version, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voWithValidation, withCommit };