@shirudo/ddd-kit 1.0.0-rc.8 → 1.0.0-rc.9

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.js CHANGED
@@ -589,35 +589,83 @@ function entityIds(entities) {
589
589
  }
590
590
  __name(entityIds, "entityIds");
591
591
 
592
- // src/aggregate/aggregate-root.ts
593
- var AggregateRoot = class extends Entity {
592
+ // src/aggregate/base-aggregate.ts
593
+ var BaseAggregate = class extends Entity {
594
594
  static {
595
- __name(this, "AggregateRoot");
595
+ __name(this, "BaseAggregate");
596
596
  }
597
597
  _version = 0;
598
+ /**
599
+ * DB-baseline version. `undefined` until the aggregate has been
600
+ * persisted or restored at least once. Repository implementations
601
+ * route INSERT vs UPDATE on this field and use it as the OCC
602
+ * baseline. See `IRepository.save` JSDoc.
603
+ *
604
+ * Distinct from {@link version}, which is the in-memory
605
+ * post-mutation value. Mutations bump `_version` but never touch
606
+ * `_persistedVersion` — that field only moves on {@link markRestored}
607
+ * (Post-Load) and {@link markPersisted} (Post-Save).
608
+ */
609
+ _persistedVersion = void 0;
610
+ _pendingEvents = [];
598
611
  get version() {
599
612
  return this._version;
600
613
  }
601
- setVersion(version) {
602
- this._version = version;
614
+ get persistedVersion() {
615
+ return this._persistedVersion;
603
616
  }
604
- _config;
605
- _autoVersionBump;
606
- _pendingEvents = [];
607
617
  /**
608
- * Read-only list of domain events recorded on this aggregate that have
609
- * not yet been flushed to the outbox / persistence layer.
618
+ * Read-only list of domain events recorded on this aggregate that
619
+ * have not yet been flushed to the outbox / persistence layer.
610
620
  */
611
621
  get pendingEvents() {
612
622
  return Object.freeze(this._pendingEvents.slice());
613
623
  }
614
624
  /**
615
- * Clears the pending-event list. Call this after the events have been
616
- * dispatched (typically `markPersisted` handles it for you).
625
+ * Clears the pending-event list. Called by `markPersisted` after a
626
+ * successful write the events have been handed off to the outbox
627
+ * / event store and are no longer the aggregate's responsibility.
617
628
  */
618
629
  clearPendingEvents() {
619
630
  this._pendingEvents = [];
620
631
  }
632
+ setVersion(version) {
633
+ this._version = version;
634
+ }
635
+ /**
636
+ * Manually bumps the aggregate version. Used by state-stored
637
+ * aggregates' `setState(_, true)` / `commit()` paths and by the
638
+ * event-sourced replay path after each applied event.
639
+ */
640
+ bumpVersion() {
641
+ this.setVersion(this._version + 1);
642
+ }
643
+ /**
644
+ * **Lifecycle marker — Post-Load.** Syncs both `_version` and
645
+ * `_persistedVersion` to the DB-stored version. Used by
646
+ * `reconstitute(...)` factories to assemble an in-memory aggregate
647
+ * from a persisted row.
648
+ *
649
+ * Does NOT fire {@link onPersisted} — that hook has post-save
650
+ * semantics (metrics, audit, cache eviction), not post-load. The
651
+ * Factory-vs-Reconstitution distinction (Vernon §11) is honoured
652
+ * structurally: two separate markers, one for each transition.
653
+ *
654
+ * @param version - The version the row currently holds in the DB
655
+ *
656
+ * @example
657
+ * ```ts
658
+ * static reconstitute(id: OrderId, state: OrderState, version: Version): Order {
659
+ * const order = new Order(id, state);
660
+ * order.markRestored(version);
661
+ * return order;
662
+ * }
663
+ * ```
664
+ */
665
+ markRestored(version) {
666
+ this.setVersion(version);
667
+ this._persistedVersion = version;
668
+ }
621
669
  /**
622
670
  * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
623
671
  * (or by your own orchestration code, after harvesting `pendingEvents`)
@@ -639,7 +687,7 @@ var AggregateRoot = class extends Entity {
639
687
  * @see onPersisted — the safe extension point for subclasses
640
688
  */
641
689
  markPersisted(version) {
642
- this.setVersion(version);
690
+ this.markRestored(version);
643
691
  this._pendingEvents = [];
644
692
  this.onPersisted(version);
645
693
  }
@@ -676,93 +724,28 @@ var AggregateRoot = class extends Entity {
676
724
  onPersisted(_version) {
677
725
  }
678
726
  /**
679
- * Mutates state and records the resulting domain events in the
680
- * **canonical record-after-mutation order**. Use this instead of calling
681
- * `setState` + `addDomainEvent` separately and you cannot trip the
682
- * "event for a fact that never happened" footgun.
683
- *
684
- * Order of operations:
685
- * 1. `setState(newState, true)` runs `validateState` first.
686
- * If it throws, the method propagates and **no event is recorded
687
- * and no version is bumped**.
688
- * 2. Each event in `events` is appended via `addDomainEvent`.
689
- *
690
- * `commit()` **always bumps the version**, regardless of the aggregate's
691
- * `autoVersionBump` config. Recording a domain event implies "something
692
- * happened that the outside world cares about", and optimistic-
693
- * concurrency callers must see a fresh version every time. The config
694
- * still governs the un-coupled `setState` path. If you need to mutate
695
- * state without bumping (e.g. cosmetic caches), call `setState(newState,
696
- * false)` and skip `commit` entirely.
697
- *
698
- * `events` accepts a single event or an array. Omit it (or pass `[]`)
699
- * for state-only mutations.
700
- *
701
- * @example
702
- * ```ts
703
- * confirm(): void {
704
- * if (this.state.status === "confirmed") {
705
- * throw new OrderAlreadyConfirmedError(this.id);
706
- * }
707
- * this.commit(
708
- * { ...this.state, status: "confirmed" },
709
- * { type: "OrderConfirmed", orderId: this.id },
710
- * );
711
- * }
712
- * ```
713
- *
714
- * `EventSourcedAggregate.apply()` enforces the same ordering
715
- * structurally; `commit()` is the opt-in equivalent on `AggregateRoot`,
716
- * where `setState` and `addDomainEvent` are otherwise decoupled and the
717
- * ordering is convention-only.
718
- *
719
- * @param newState - The new state (validated by `validateState`)
720
- * @param events - One event, an array of events, or none (default)
727
+ * Appends a domain event to the pending list. Prefer the higher-level
728
+ * `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
729
+ * (event-sourced) call sites both wrap `addDomainEvent` in the
730
+ * canonical record-AFTER-mutation order (Vernon §8). Calling
731
+ * `addDomainEvent` directly is appropriate only when state and event
732
+ * recording have already been decoupled deliberately (e.g. a
733
+ * deletion event before a hard-delete; see `docs/guide/repository.md`).
721
734
  */
722
- commit(newState, events = []) {
723
- this.setState(newState, true);
724
- const list = Array.isArray(events) ? events : [events];
725
- for (const ev of list) {
726
- this.addDomainEvent(ev);
727
- }
728
- }
729
- constructor(id, initialState, config) {
730
- super(id, initialState);
731
- this._config = config ?? {};
732
- this._autoVersionBump = this._config.autoVersionBump ?? false;
735
+ addDomainEvent(event) {
736
+ this._pendingEvents.push(event);
733
737
  }
734
738
  /**
735
- * Records a domain event for later publication.
736
- *
737
- * **Ordering: record AFTER state mutation.** Vernon (IDDD §8) is
738
- * explicit: a domain event describes something that has just happened
739
- * to the aggregate — its existence implies the state change already
740
- * occurred. Concretely:
741
- *
742
- * ```ts
743
- * confirm(): void {
744
- * if (this.state.status === "confirmed") {
745
- * throw new OrderAlreadyConfirmedError(this.id);
746
- * }
747
- * this.setState({ ...this.state, status: "confirmed" }, true);
748
- * this.addDomainEvent({ type: "OrderConfirmed", orderId: this.id });
749
- * // ↑ post-mutation. The event represents the committed fact.
750
- * }
751
- * ```
752
- *
753
- * Recording before mutation is a footgun: if a subsequent invariant
754
- * check throws, the event has already been queued but the state never
755
- * actually changed — consumers see an event for a fact that did not
756
- * happen.
757
- *
758
- * `EventSourcedAggregate.apply()` enforces this ordering structurally;
759
- * `AggregateRoot` leaves it as a convention because the state-mutation
760
- * path (`setState`) is decoupled from event recording.
761
- *
762
- * @param event - The domain event to record
739
+ * Creates a snapshot of the current aggregate state — the state at
740
+ * this moment plus the version. Useful for ES snapshot policies and
741
+ * for state-stored backup / restore.
763
742
  */
764
- addDomainEvent(event) {
765
- this._pendingEvents.push(event);
743
+ createSnapshot() {
744
+ return {
745
+ state: structuredClone(this._state),
746
+ version: this.version,
747
+ snapshotAt: /* @__PURE__ */ new Date()
748
+ };
766
749
  }
767
750
  /**
768
751
  * Sugar for `createDomainEvent` that auto-injects `aggregateId`
@@ -805,21 +788,72 @@ var AggregateRoot = class extends Entity {
805
788
  aggregateType: this.aggregateType
806
789
  });
807
790
  }
791
+ };
792
+
793
+ // src/aggregate/aggregate-root.ts
794
+ var AggregateRoot = class extends BaseAggregate {
795
+ static {
796
+ __name(this, "AggregateRoot");
797
+ }
798
+ _autoVersionBump;
799
+ constructor(id, initialState, config) {
800
+ super(id, initialState);
801
+ this._autoVersionBump = config?.autoVersionBump ?? false;
802
+ }
808
803
  /**
809
- * Manually bumps the aggregate version.
810
- * Call this after state changes for Optimistic Concurrency Control.
804
+ * Mutates state and records the resulting domain events in the
805
+ * **canonical record-after-mutation order**. Use this instead of calling
806
+ * `setState` + `addDomainEvent` separately and you cannot trip the
807
+ * "event for a fact that never happened" footgun.
808
+ *
809
+ * Order of operations:
810
+ * 1. `setState(newState, true)` — runs `validateState` first.
811
+ * If it throws, the method propagates and **no event is recorded
812
+ * and no version is bumped**.
813
+ * 2. Each event in `events` is appended via `addDomainEvent`.
814
+ *
815
+ * `commit()` **always bumps the version**, regardless of the aggregate's
816
+ * `autoVersionBump` config. Recording a domain event implies "something
817
+ * happened that the outside world cares about", and optimistic-
818
+ * concurrency callers must see a fresh version every time. The config
819
+ * still governs the un-coupled `setState` path. If you need to mutate
820
+ * state without bumping (e.g. cosmetic caches), call `setState(newState,
821
+ * false)` and skip `commit` entirely.
822
+ *
823
+ * `events` accepts a single event or an array. Omit it (or pass `[]`)
824
+ * for state-only mutations.
825
+ *
826
+ * @example
827
+ * ```ts
828
+ * confirm(): void {
829
+ * if (this.state.status === "confirmed") {
830
+ * throw new OrderAlreadyConfirmedError(this.id);
831
+ * }
832
+ * this.commit(
833
+ * { ...this.state, status: "confirmed" },
834
+ * this.recordEvent("OrderConfirmed", { orderId: this.id }),
835
+ * );
836
+ * }
837
+ * ```
811
838
  *
812
- * If `autoVersionBump` is enabled, this is called automatically
813
- * when using `setState()`.
839
+ * `EventSourcedAggregate.apply()` enforces the same ordering
840
+ * structurally; `commit()` is the opt-in equivalent on `AggregateRoot`,
841
+ * where `setState` and `addDomainEvent` are otherwise decoupled and the
842
+ * ordering is convention-only.
843
+ *
844
+ * @param newState - The new state (validated by `validateState`)
845
+ * @param events - One event, an array of events, or none (default)
814
846
  */
815
- bumpVersion() {
816
- this.setVersion(this._version + 1);
847
+ commit(newState, events = []) {
848
+ this.setState(newState, true);
849
+ const list = Array.isArray(events) ? events : [events];
850
+ for (const ev of list) {
851
+ this.addDomainEvent(ev);
852
+ }
817
853
  }
818
854
  /**
819
855
  * Sets the state and optionally bumps the version automatically.
820
- * This is a convenience method for state mutations.
821
- * Automatically validates the newState using `validateState()`.
822
- * Overrides Entity.setState to add version bumping.
856
+ * Validates `newState` via `validateState()`.
823
857
  *
824
858
  * @param newState - The new state
825
859
  * @param bumpVersion - Whether to bump the version (defaults to autoVersionBump config)
@@ -832,43 +866,17 @@ var AggregateRoot = class extends Entity {
832
866
  }
833
867
  }
834
868
  /**
835
- * Creates a snapshot of the current aggregate state.
836
- * Useful for performance optimization, backup/restore, and audit trails.
837
- *
838
- * @returns A snapshot containing the current state and version
839
- *
840
- * @example
841
- * ```typescript
842
- * const snapshot = aggregate.createSnapshot();
843
- * await snapshotRepository.save(aggregate.id, snapshot);
844
- * ```
845
- */
846
- createSnapshot() {
847
- return {
848
- state: structuredClone(this._state),
849
- version: this.version,
850
- snapshotAt: /* @__PURE__ */ new Date()
851
- };
852
- }
853
- /**
854
- * Restores the aggregate from a snapshot.
855
- * This is useful for loading aggregates from snapshots instead of
856
- * rebuilding them from scratch.
857
- * Validates the restored state.
869
+ * Restores the aggregate from a snapshot loads state and aligns
870
+ * `version` + `persistedVersion` to the snapshot version. Validates
871
+ * the restored state.
858
872
  *
859
873
  * @param snapshot - The snapshot to restore from
860
- *
861
- * @example
862
- * ```typescript
863
- * const snapshot = await snapshotRepository.getLatest(aggregateId);
864
- * aggregate.restoreFromSnapshot(snapshot);
865
- * ```
866
874
  */
867
875
  restoreFromSnapshot(snapshot) {
868
876
  const cloned = structuredClone(snapshot.state);
869
877
  this.validateState(cloned);
870
878
  this._state = freezeShallow(cloned);
871
- this.setVersion(snapshot.version);
879
+ this.markRestored(snapshot.version);
872
880
  }
873
881
  };
874
882
  var DomainError = class extends BaseError {
@@ -929,118 +937,10 @@ var ConcurrencyConflictError = class extends InfrastructureError {
929
937
  };
930
938
 
931
939
  // src/aggregate/event-sourced-aggregate.ts
932
- var EventSourcedAggregate = class extends Entity {
940
+ var EventSourcedAggregate = class extends BaseAggregate {
933
941
  static {
934
942
  __name(this, "EventSourcedAggregate");
935
943
  }
936
- // --- Version management (own, not inherited from AggregateRoot) ---
937
- _version = 0;
938
- get version() {
939
- return this._version;
940
- }
941
- setVersion(version) {
942
- this._version = version;
943
- }
944
- // --- Event tracking ---
945
- _pendingEvents = [];
946
- get pendingEvents() {
947
- return Object.freeze(this._pendingEvents.slice());
948
- }
949
- clearPendingEvents() {
950
- this._pendingEvents = [];
951
- }
952
- /**
953
- * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
954
- * (or by your own orchestration code, after harvesting `pendingEvents`)
955
- * to push the persisted version back into the in-memory aggregate and
956
- * clear `pendingEvents`. TypeScript has no `final` keyword, but
957
- * subclasses **should not** override this method directly.
958
- *
959
- * Overriding without calling `super.markPersisted(version)` silently
960
- * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
961
- * through the outbox, double-emitting events. This bug has been hit
962
- * in production by consumers; the {@link onPersisted} hook below is
963
- * the safer extension point.
964
- *
965
- * If you must override (legitimate cases are very rare), call
966
- * `super.markPersisted(version)` FIRST so the framework's cleanup
967
- * runs, then add your logic afterwards.
968
- *
969
- * @param version - The version assigned by the persistence layer
970
- * @see onPersisted — the safe extension point for subclasses
971
- */
972
- markPersisted(version) {
973
- this.setVersion(version);
974
- this._pendingEvents = [];
975
- this.onPersisted(version);
976
- }
977
- /**
978
- * Subclass extension point — fires AFTER {@link markPersisted} has
979
- * updated the version and cleared `pendingEvents`. Override this for
980
- * post-persist logging, metrics, or cache-eviction without risk of
981
- * breaking the framework's pendingEvents cleanup.
982
- *
983
- * The default implementation is a no-op. Subclasses do NOT need to
984
- * call `super.onPersisted(version)` — there is nothing in the parent
985
- * implementation to preserve.
986
- *
987
- * **`onPersisted` deliberately receives only the version, not the
988
- * drained events.** Event-driven post-persist logic (aggregate-level
989
- * audit logging, per-event-type side effects) belongs in `EventBus`
990
- * subscribers or the outbox dispatcher — that is the proper
991
- * Aggregate-Boundary separation. Building event-aware logic into
992
- * `onPersisted` couples aggregate lifecycle to event processing and
993
- * recreates the boundary problems Vernon's aggregate discipline is
994
- * meant to prevent.
995
- *
996
- * **The hook must return synchronously.** `markPersisted` is `void`-
997
- * typed and calls `onPersisted` without `await`. TypeScript's
998
- * permissive `void` will accept an `async`-override returning
999
- * `Promise<void>`, but the returned promise is fire-and-forget —
1000
- * any rejection becomes an unhandled rejection and `withCommit`
1001
- * proceeds without waiting. For asynchronous work, subscribe to the
1002
- * relevant domain event on the `EventBus` instead; that is the
1003
- * properly awaited extension point.
1004
- *
1005
- * @param version - The version that was just persisted
1006
- */
1007
- onPersisted(_version) {
1008
- }
1009
- constructor(id, initialState) {
1010
- super(id, initialState);
1011
- }
1012
- /**
1013
- * Sugar for `createDomainEvent` that auto-injects `aggregateId`
1014
- * (from `this.id`) and `aggregateType` (from {@link aggregateType})
1015
- * into the event's metadata fields. The canonical path for
1016
- * constructing events to feed into `apply()` from inside aggregate
1017
- * domain methods.
1018
- *
1019
- * @example
1020
- * ```ts
1021
- * class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
1022
- * protected readonly aggregateType = "Order";
1023
- *
1024
- * confirm(): void {
1025
- * this.apply(this.recordEvent("OrderConfirmed", { orderId: this.id }));
1026
- * }
1027
- * }
1028
- * ```
1029
- *
1030
- * Calling `createDomainEvent(...)` directly inside an aggregate
1031
- * method leaves `aggregateId` and `aggregateType` unset; the
1032
- * `withCommit` harvest boundary catches it at runtime, but
1033
- * `this.recordEvent(...)` makes the right thing impossible to
1034
- * forget.
1035
- */
1036
- recordEvent(type, payload, options) {
1037
- return createDomainEvent(type, payload, {
1038
- ...options,
1039
- aggregateId: this.id,
1040
- aggregateType: this.aggregateType
1041
- });
1042
- }
1043
- // --- Event application ---
1044
944
  /**
1045
945
  * Validates an event before it is applied. Default is no-op.
1046
946
  * Subclasses override to throw a concrete `DomainError` subclass when
@@ -1085,11 +985,10 @@ var EventSourcedAggregate = class extends Entity {
1085
985
  const nextState = handler(this._state, event);
1086
986
  this._state = freezeShallow(nextState);
1087
987
  if (isNew) {
1088
- this._pendingEvents.push(event);
1089
- this.setVersion(this._version + 1);
988
+ this.addDomainEvent(event);
989
+ this.bumpVersion();
1090
990
  }
1091
991
  }
1092
- // --- History & Snapshots ---
1093
992
  /**
1094
993
  * Reconstitutes the aggregate from an event history. Catches `DomainError`
1095
994
  * thrown during replay and returns it as an `Err` — this is the
@@ -1102,7 +1001,7 @@ var EventSourcedAggregate = class extends Entity {
1102
1001
  * 2 events ends at v=3, not v=2.
1103
1002
  */
1104
1003
  loadFromHistory(history) {
1105
- const startVersion = this._version;
1004
+ const startVersion = this.version;
1106
1005
  for (const event of history) {
1107
1006
  try {
1108
1007
  this.dispatchAndCommit(event, false);
@@ -1111,19 +1010,9 @@ var EventSourcedAggregate = class extends Entity {
1111
1010
  throw e;
1112
1011
  }
1113
1012
  }
1114
- this.setVersion(startVersion + history.length);
1013
+ this.markRestored(startVersion + history.length);
1115
1014
  return ok();
1116
1015
  }
1117
- /**
1118
- * Creates a snapshot of the current aggregate state.
1119
- */
1120
- createSnapshot() {
1121
- return {
1122
- state: structuredClone(this._state),
1123
- version: this._version,
1124
- snapshotAt: /* @__PURE__ */ new Date()
1125
- };
1126
- }
1127
1016
  /**
1128
1017
  * Restores the aggregate from a snapshot and applies events that occurred
1129
1018
  * after. Same infrastructure-boundary semantics as `loadFromHistory`:
@@ -1136,7 +1025,7 @@ var EventSourcedAggregate = class extends Entity {
1136
1025
  */
1137
1026
  restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot) {
1138
1027
  const previousState = this._state;
1139
- const previousVersion = this._version;
1028
+ const previousVersion = this.version;
1140
1029
  this._state = freezeShallow(structuredClone(snapshot.state));
1141
1030
  this.setVersion(snapshot.version);
1142
1031
  for (const event of eventsAfterSnapshot) {
@@ -1149,7 +1038,9 @@ var EventSourcedAggregate = class extends Entity {
1149
1038
  throw e;
1150
1039
  }
1151
1040
  }
1152
- this.setVersion(snapshot.version + eventsAfterSnapshot.length);
1041
+ this.markRestored(
1042
+ snapshot.version + eventsAfterSnapshot.length
1043
+ );
1153
1044
  return ok();
1154
1045
  }
1155
1046
  };