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

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
@@ -399,6 +399,26 @@ function setEventIdFactory(factory) {
399
399
  currentEventIdFactory = factory;
400
400
  }
401
401
  __name(setEventIdFactory, "setEventIdFactory");
402
+ function assertNotThenable(result, helperName) {
403
+ if (result !== null && (typeof result === "object" || typeof result === "function") && typeof result.then === "function") {
404
+ throw new Error(
405
+ `${helperName}: fn returned a thenable. The factory is only installed for the synchronous portion of fn; awaited continuations would see the previous factory. For async-scoped factories use AsyncLocalStorage.`
406
+ );
407
+ }
408
+ }
409
+ __name(assertNotThenable, "assertNotThenable");
410
+ function withEventIdFactory(factory, fn) {
411
+ const previous = currentEventIdFactory;
412
+ currentEventIdFactory = factory;
413
+ try {
414
+ const result = fn();
415
+ assertNotThenable(result, "withEventIdFactory");
416
+ return result;
417
+ } finally {
418
+ currentEventIdFactory = previous;
419
+ }
420
+ }
421
+ __name(withEventIdFactory, "withEventIdFactory");
402
422
  function resetEventIdFactory() {
403
423
  currentEventIdFactory = defaultEventIdFactory;
404
424
  }
@@ -409,6 +429,18 @@ function setClockFactory(factory) {
409
429
  currentClockFactory = factory;
410
430
  }
411
431
  __name(setClockFactory, "setClockFactory");
432
+ function withClockFactory(factory, fn) {
433
+ const previous = currentClockFactory;
434
+ currentClockFactory = factory;
435
+ try {
436
+ const result = fn();
437
+ assertNotThenable(result, "withClockFactory");
438
+ return result;
439
+ } finally {
440
+ currentClockFactory = previous;
441
+ }
442
+ }
443
+ __name(withClockFactory, "withClockFactory");
412
444
  function resetClockFactory() {
413
445
  currentClockFactory = defaultClockFactory;
414
446
  }
@@ -587,18 +619,61 @@ var AggregateRoot = class extends Entity {
587
619
  this._pendingEvents = [];
588
620
  }
589
621
  /**
590
- * Post-save hook called by a `Repository.save()` implementation to push
591
- * the persisted version back into the in-memory aggregate and clear
592
- * pendingEvents (they are now safely on the write side / in the
593
- * outbox).
622
+ * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
623
+ * (or by your own orchestration code, after harvesting `pendingEvents`)
624
+ * to push the persisted version back into the in-memory aggregate and
625
+ * clear `pendingEvents`. TypeScript has no `final` keyword, but
626
+ * subclasses **should not** override this method directly.
627
+ *
628
+ * Overriding without calling `super.markPersisted(version)` silently
629
+ * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
630
+ * through the outbox, double-emitting events. This bug has been hit
631
+ * in production by consumers; the {@link onPersisted} hook below is
632
+ * the safer extension point.
633
+ *
634
+ * If you must override (legitimate cases are very rare), call
635
+ * `super.markPersisted(version)` FIRST so the framework's cleanup
636
+ * runs, then add your logic afterwards.
594
637
  *
595
- * Use this so `save()` can keep its `Promise<void>` return type: the
596
- * caller holds the aggregate reference, which is up to date after this
597
- * call.
638
+ * @param version - The version assigned by the persistence layer
639
+ * @see onPersisted the safe extension point for subclasses
598
640
  */
599
641
  markPersisted(version) {
600
642
  this.setVersion(version);
601
643
  this._pendingEvents = [];
644
+ this.onPersisted(version);
645
+ }
646
+ /**
647
+ * Subclass extension point — fires AFTER {@link markPersisted} has
648
+ * updated the version and cleared `pendingEvents`. Override this for
649
+ * post-persist logging, metrics, or cache-eviction without risk of
650
+ * breaking the framework's pendingEvents cleanup.
651
+ *
652
+ * The default implementation is a no-op. Subclasses do NOT need to
653
+ * call `super.onPersisted(version)` — there is nothing in the parent
654
+ * implementation to preserve.
655
+ *
656
+ * **`onPersisted` deliberately receives only the version, not the
657
+ * drained events.** Event-driven post-persist logic (aggregate-level
658
+ * audit logging, per-event-type side effects) belongs in `EventBus`
659
+ * subscribers or the outbox dispatcher — that is the proper
660
+ * Aggregate-Boundary separation. Building event-aware logic into
661
+ * `onPersisted` couples aggregate lifecycle to event processing and
662
+ * recreates the boundary problems Vernon's aggregate discipline is
663
+ * meant to prevent.
664
+ *
665
+ * **The hook must return synchronously.** `markPersisted` is `void`-
666
+ * typed and calls `onPersisted` without `await`. TypeScript's
667
+ * permissive `void` will accept an `async`-override returning
668
+ * `Promise<void>`, but the returned promise is fire-and-forget —
669
+ * any rejection becomes an unhandled rejection and `withCommit`
670
+ * proceeds without waiting. For asynchronous work, subscribe to the
671
+ * relevant domain event on the `EventBus` instead; that is the
672
+ * properly awaited extension point.
673
+ *
674
+ * @param version - The version that was just persisted
675
+ */
676
+ onPersisted(_version) {
602
677
  }
603
678
  /**
604
679
  * Mutates state and records the resulting domain events in the
@@ -689,6 +764,47 @@ var AggregateRoot = class extends Entity {
689
764
  addDomainEvent(event) {
690
765
  this._pendingEvents.push(event);
691
766
  }
767
+ /**
768
+ * Sugar for `createDomainEvent` that auto-injects `aggregateId`
769
+ * (from `this.id`) and `aggregateType` (from {@link aggregateType})
770
+ * into the event's metadata fields. This is the canonical path for
771
+ * recording events from inside aggregate domain methods.
772
+ *
773
+ * Downstream consumers — outbox dispatchers, projection handlers,
774
+ * audit logs — route by these two fields. Calling
775
+ * `createDomainEvent(...)` directly inside an aggregate method
776
+ * leaves them unset and is caught at the `withCommit` harvest
777
+ * boundary, but `this.recordEvent(...)` makes the right thing
778
+ * impossible to forget.
779
+ *
780
+ * @example
781
+ * ```ts
782
+ * class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
783
+ * protected readonly aggregateType = "Order";
784
+ *
785
+ * confirm(): void {
786
+ * this.commit(
787
+ * { ...this.state, status: "confirmed" },
788
+ * this.recordEvent("OrderConfirmed", { orderId: this.id }),
789
+ * );
790
+ * }
791
+ * }
792
+ * ```
793
+ *
794
+ * @param type - event type discriminator (must be one of `TEvent`'s tags)
795
+ * @param payload - payload for that event subtype
796
+ * @param options - any remaining `createDomainEvent` options
797
+ * (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
798
+ * and `aggregateType` are deliberately omitted — the helper sets
799
+ * them.
800
+ */
801
+ recordEvent(type, payload, options) {
802
+ return createDomainEvent(type, payload, {
803
+ ...options,
804
+ aggregateId: this.id,
805
+ aggregateType: this.aggregateType
806
+ });
807
+ }
692
808
  /**
693
809
  * Manually bumps the aggregate version.
694
810
  * Call this after state changes for Optimistic Concurrency Control.
@@ -834,18 +950,96 @@ var EventSourcedAggregate = class extends Entity {
834
950
  this._pendingEvents = [];
835
951
  }
836
952
  /**
837
- * Post-save hook called by a `Repository.save()` implementation to push
838
- * the persisted version back into the in-memory aggregate and clear the
839
- * pending events (they are now in the event store / outbox). Lets
840
- * `save()` keep its `Promise<void>` return type.
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
841
971
  */
842
972
  markPersisted(version) {
843
973
  this.setVersion(version);
844
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) {
845
1008
  }
846
1009
  constructor(id, initialState) {
847
1010
  super(id, initialState);
848
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
+ }
849
1043
  // --- Event application ---
850
1044
  /**
851
1045
  * Validates an event before it is applied. Default is no-op.
@@ -987,13 +1181,26 @@ async function withCommit(deps, fn) {
987
1181
  const { result, aggregates, events } = await deps.scope.transactional(
988
1182
  async (ctx) => {
989
1183
  const fnResult = await fn(ctx);
990
- const harvested = fnResult.aggregates.flatMap(
1184
+ const uniqueAggregates = Array.from(new Set(fnResult.aggregates));
1185
+ const harvested = uniqueAggregates.flatMap(
991
1186
  (agg) => agg.pendingEvents
992
1187
  );
1188
+ for (const event of harvested) {
1189
+ const missing = [];
1190
+ if (!event.aggregateId) missing.push("aggregateId");
1191
+ if (!event.aggregateType) missing.push("aggregateType");
1192
+ if (missing.length > 0) {
1193
+ throw new Error(
1194
+ `withCommit: event "${event.type}" is missing ${missing.join(
1195
+ " and "
1196
+ )}. 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.`
1197
+ );
1198
+ }
1199
+ }
993
1200
  if (harvested.length > 0) {
994
1201
  await deps.outbox.add(harvested);
995
1202
  }
996
- return { ...fnResult, events: harvested };
1203
+ return { ...fnResult, aggregates: uniqueAggregates, events: harvested };
997
1204
  }
998
1205
  );
999
1206
  for (const agg of aggregates) {
@@ -1163,6 +1370,6 @@ var InMemoryOutbox = class {
1163
1370
  }
1164
1371
  };
1165
1372
 
1166
- export { AggregateNotFoundError, AggregateRoot, CommandBus, ConcurrencyConflictError, DomainError, Entity, EventBusImpl, EventSourcedAggregate, InMemoryOutbox, InfrastructureError, MissingHandlerError, QueryBus, ValueObject, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepEqual, deepEqualExcept, deepFreeze, deepOmit, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voWithValidation, withCommit };
1373
+ export { AggregateNotFoundError, AggregateRoot, CommandBus, ConcurrencyConflictError, DomainError, Entity, EventBusImpl, EventSourcedAggregate, InMemoryOutbox, InfrastructureError, MissingHandlerError, QueryBus, ValueObject, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepEqual, deepEqualExcept, deepFreeze, deepOmit, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voWithValidation, withClockFactory, withCommit, withEventIdFactory };
1167
1374
  //# sourceMappingURL=index.js.map
1168
1375
  //# sourceMappingURL=index.js.map