@shirudo/ddd-kit 1.0.0-rc.7 → 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
  }
@@ -675,6 +723,83 @@ var AggregateRoot = class extends Entity {
675
723
  */
676
724
  onPersisted(_version) {
677
725
  }
726
+ /**
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`).
734
+ */
735
+ addDomainEvent(event) {
736
+ this._pendingEvents.push(event);
737
+ }
738
+ /**
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.
742
+ */
743
+ createSnapshot() {
744
+ return {
745
+ state: structuredClone(this._state),
746
+ version: this.version,
747
+ snapshotAt: /* @__PURE__ */ new Date()
748
+ };
749
+ }
750
+ /**
751
+ * Sugar for `createDomainEvent` that auto-injects `aggregateId`
752
+ * (from `this.id`) and `aggregateType` (from {@link aggregateType})
753
+ * into the event's metadata fields. This is the canonical path for
754
+ * recording events from inside aggregate domain methods.
755
+ *
756
+ * Downstream consumers — outbox dispatchers, projection handlers,
757
+ * audit logs — route by these two fields. Calling
758
+ * `createDomainEvent(...)` directly inside an aggregate method
759
+ * leaves them unset and is caught at the `withCommit` harvest
760
+ * boundary, but `this.recordEvent(...)` makes the right thing
761
+ * impossible to forget.
762
+ *
763
+ * @example
764
+ * ```ts
765
+ * class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
766
+ * protected readonly aggregateType = "Order";
767
+ *
768
+ * confirm(): void {
769
+ * this.commit(
770
+ * { ...this.state, status: "confirmed" },
771
+ * this.recordEvent("OrderConfirmed", { orderId: this.id }),
772
+ * );
773
+ * }
774
+ * }
775
+ * ```
776
+ *
777
+ * @param type - event type discriminator (must be one of `TEvent`'s tags)
778
+ * @param payload - payload for that event subtype
779
+ * @param options - any remaining `createDomainEvent` options
780
+ * (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
781
+ * and `aggregateType` are deliberately omitted — the helper sets
782
+ * them.
783
+ */
784
+ recordEvent(type, payload, options) {
785
+ return createDomainEvent(type, payload, {
786
+ ...options,
787
+ aggregateId: this.id,
788
+ aggregateType: this.aggregateType
789
+ });
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
+ }
678
803
  /**
679
804
  * Mutates state and records the resulting domain events in the
680
805
  * **canonical record-after-mutation order**. Use this instead of calling
@@ -706,7 +831,7 @@ var AggregateRoot = class extends Entity {
706
831
  * }
707
832
  * this.commit(
708
833
  * { ...this.state, status: "confirmed" },
709
- * { type: "OrderConfirmed", orderId: this.id },
834
+ * this.recordEvent("OrderConfirmed", { orderId: this.id }),
710
835
  * );
711
836
  * }
712
837
  * ```
@@ -726,59 +851,9 @@ var AggregateRoot = class extends Entity {
726
851
  this.addDomainEvent(ev);
727
852
  }
728
853
  }
729
- constructor(id, initialState, config) {
730
- super(id, initialState);
731
- this._config = config ?? {};
732
- this._autoVersionBump = this._config.autoVersionBump ?? false;
733
- }
734
- /**
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
763
- */
764
- addDomainEvent(event) {
765
- this._pendingEvents.push(event);
766
- }
767
- /**
768
- * Manually bumps the aggregate version.
769
- * Call this after state changes for Optimistic Concurrency Control.
770
- *
771
- * If `autoVersionBump` is enabled, this is called automatically
772
- * when using `setState()`.
773
- */
774
- bumpVersion() {
775
- this.setVersion(this._version + 1);
776
- }
777
854
  /**
778
855
  * Sets the state and optionally bumps the version automatically.
779
- * This is a convenience method for state mutations.
780
- * Automatically validates the newState using `validateState()`.
781
- * Overrides Entity.setState to add version bumping.
856
+ * Validates `newState` via `validateState()`.
782
857
  *
783
858
  * @param newState - The new state
784
859
  * @param bumpVersion - Whether to bump the version (defaults to autoVersionBump config)
@@ -791,43 +866,17 @@ var AggregateRoot = class extends Entity {
791
866
  }
792
867
  }
793
868
  /**
794
- * Creates a snapshot of the current aggregate state.
795
- * Useful for performance optimization, backup/restore, and audit trails.
796
- *
797
- * @returns A snapshot containing the current state and version
798
- *
799
- * @example
800
- * ```typescript
801
- * const snapshot = aggregate.createSnapshot();
802
- * await snapshotRepository.save(aggregate.id, snapshot);
803
- * ```
804
- */
805
- createSnapshot() {
806
- return {
807
- state: structuredClone(this._state),
808
- version: this.version,
809
- snapshotAt: /* @__PURE__ */ new Date()
810
- };
811
- }
812
- /**
813
- * Restores the aggregate from a snapshot.
814
- * This is useful for loading aggregates from snapshots instead of
815
- * rebuilding them from scratch.
816
- * 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.
817
872
  *
818
873
  * @param snapshot - The snapshot to restore from
819
- *
820
- * @example
821
- * ```typescript
822
- * const snapshot = await snapshotRepository.getLatest(aggregateId);
823
- * aggregate.restoreFromSnapshot(snapshot);
824
- * ```
825
874
  */
826
875
  restoreFromSnapshot(snapshot) {
827
876
  const cloned = structuredClone(snapshot.state);
828
877
  this.validateState(cloned);
829
878
  this._state = freezeShallow(cloned);
830
- this.setVersion(snapshot.version);
879
+ this.markRestored(snapshot.version);
831
880
  }
832
881
  };
833
882
  var DomainError = class extends BaseError {
@@ -888,87 +937,10 @@ var ConcurrencyConflictError = class extends InfrastructureError {
888
937
  };
889
938
 
890
939
  // src/aggregate/event-sourced-aggregate.ts
891
- var EventSourcedAggregate = class extends Entity {
940
+ var EventSourcedAggregate = class extends BaseAggregate {
892
941
  static {
893
942
  __name(this, "EventSourcedAggregate");
894
943
  }
895
- // --- Version management (own, not inherited from AggregateRoot) ---
896
- _version = 0;
897
- get version() {
898
- return this._version;
899
- }
900
- setVersion(version) {
901
- this._version = version;
902
- }
903
- // --- Event tracking ---
904
- _pendingEvents = [];
905
- get pendingEvents() {
906
- return Object.freeze(this._pendingEvents.slice());
907
- }
908
- clearPendingEvents() {
909
- this._pendingEvents = [];
910
- }
911
- /**
912
- * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
913
- * (or by your own orchestration code, after harvesting `pendingEvents`)
914
- * to push the persisted version back into the in-memory aggregate and
915
- * clear `pendingEvents`. TypeScript has no `final` keyword, but
916
- * subclasses **should not** override this method directly.
917
- *
918
- * Overriding without calling `super.markPersisted(version)` silently
919
- * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
920
- * through the outbox, double-emitting events. This bug has been hit
921
- * in production by consumers; the {@link onPersisted} hook below is
922
- * the safer extension point.
923
- *
924
- * If you must override (legitimate cases are very rare), call
925
- * `super.markPersisted(version)` FIRST so the framework's cleanup
926
- * runs, then add your logic afterwards.
927
- *
928
- * @param version - The version assigned by the persistence layer
929
- * @see onPersisted — the safe extension point for subclasses
930
- */
931
- markPersisted(version) {
932
- this.setVersion(version);
933
- this._pendingEvents = [];
934
- this.onPersisted(version);
935
- }
936
- /**
937
- * Subclass extension point — fires AFTER {@link markPersisted} has
938
- * updated the version and cleared `pendingEvents`. Override this for
939
- * post-persist logging, metrics, or cache-eviction without risk of
940
- * breaking the framework's pendingEvents cleanup.
941
- *
942
- * The default implementation is a no-op. Subclasses do NOT need to
943
- * call `super.onPersisted(version)` — there is nothing in the parent
944
- * implementation to preserve.
945
- *
946
- * **`onPersisted` deliberately receives only the version, not the
947
- * drained events.** Event-driven post-persist logic (aggregate-level
948
- * audit logging, per-event-type side effects) belongs in `EventBus`
949
- * subscribers or the outbox dispatcher — that is the proper
950
- * Aggregate-Boundary separation. Building event-aware logic into
951
- * `onPersisted` couples aggregate lifecycle to event processing and
952
- * recreates the boundary problems Vernon's aggregate discipline is
953
- * meant to prevent.
954
- *
955
- * **The hook must return synchronously.** `markPersisted` is `void`-
956
- * typed and calls `onPersisted` without `await`. TypeScript's
957
- * permissive `void` will accept an `async`-override returning
958
- * `Promise<void>`, but the returned promise is fire-and-forget —
959
- * any rejection becomes an unhandled rejection and `withCommit`
960
- * proceeds without waiting. For asynchronous work, subscribe to the
961
- * relevant domain event on the `EventBus` instead; that is the
962
- * properly awaited extension point.
963
- *
964
- * @param version - The version that was just persisted
965
- */
966
- onPersisted(_version) {
967
- }
968
- constructor(id, initialState) {
969
- super(id, initialState);
970
- }
971
- // --- Event application ---
972
944
  /**
973
945
  * Validates an event before it is applied. Default is no-op.
974
946
  * Subclasses override to throw a concrete `DomainError` subclass when
@@ -1013,11 +985,10 @@ var EventSourcedAggregate = class extends Entity {
1013
985
  const nextState = handler(this._state, event);
1014
986
  this._state = freezeShallow(nextState);
1015
987
  if (isNew) {
1016
- this._pendingEvents.push(event);
1017
- this.setVersion(this._version + 1);
988
+ this.addDomainEvent(event);
989
+ this.bumpVersion();
1018
990
  }
1019
991
  }
1020
- // --- History & Snapshots ---
1021
992
  /**
1022
993
  * Reconstitutes the aggregate from an event history. Catches `DomainError`
1023
994
  * thrown during replay and returns it as an `Err` — this is the
@@ -1030,7 +1001,7 @@ var EventSourcedAggregate = class extends Entity {
1030
1001
  * 2 events ends at v=3, not v=2.
1031
1002
  */
1032
1003
  loadFromHistory(history) {
1033
- const startVersion = this._version;
1004
+ const startVersion = this.version;
1034
1005
  for (const event of history) {
1035
1006
  try {
1036
1007
  this.dispatchAndCommit(event, false);
@@ -1039,19 +1010,9 @@ var EventSourcedAggregate = class extends Entity {
1039
1010
  throw e;
1040
1011
  }
1041
1012
  }
1042
- this.setVersion(startVersion + history.length);
1013
+ this.markRestored(startVersion + history.length);
1043
1014
  return ok();
1044
1015
  }
1045
- /**
1046
- * Creates a snapshot of the current aggregate state.
1047
- */
1048
- createSnapshot() {
1049
- return {
1050
- state: structuredClone(this._state),
1051
- version: this._version,
1052
- snapshotAt: /* @__PURE__ */ new Date()
1053
- };
1054
- }
1055
1016
  /**
1056
1017
  * Restores the aggregate from a snapshot and applies events that occurred
1057
1018
  * after. Same infrastructure-boundary semantics as `loadFromHistory`:
@@ -1064,7 +1025,7 @@ var EventSourcedAggregate = class extends Entity {
1064
1025
  */
1065
1026
  restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot) {
1066
1027
  const previousState = this._state;
1067
- const previousVersion = this._version;
1028
+ const previousVersion = this.version;
1068
1029
  this._state = freezeShallow(structuredClone(snapshot.state));
1069
1030
  this.setVersion(snapshot.version);
1070
1031
  for (const event of eventsAfterSnapshot) {
@@ -1077,7 +1038,9 @@ var EventSourcedAggregate = class extends Entity {
1077
1038
  throw e;
1078
1039
  }
1079
1040
  }
1080
- this.setVersion(snapshot.version + eventsAfterSnapshot.length);
1041
+ this.markRestored(
1042
+ snapshot.version + eventsAfterSnapshot.length
1043
+ );
1081
1044
  return ok();
1082
1045
  }
1083
1046
  };
@@ -1113,6 +1076,18 @@ async function withCommit(deps, fn) {
1113
1076
  const harvested = uniqueAggregates.flatMap(
1114
1077
  (agg) => agg.pendingEvents
1115
1078
  );
1079
+ for (const event of harvested) {
1080
+ const missing = [];
1081
+ if (!event.aggregateId) missing.push("aggregateId");
1082
+ if (!event.aggregateType) missing.push("aggregateType");
1083
+ if (missing.length > 0) {
1084
+ throw new Error(
1085
+ `withCommit: event "${event.type}" is missing ${missing.join(
1086
+ " and "
1087
+ )}. Use this.recordEvent(type, payload) inside aggregate methods instead of createDomainEvent(...) \u2014 recordEvent auto-injects aggregateId and aggregateType. Outbox dispatchers and projection handlers rely on these fields for routing.`
1088
+ );
1089
+ }
1090
+ }
1116
1091
  if (harvested.length > 0) {
1117
1092
  await deps.outbox.add(harvested);
1118
1093
  }