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

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
@@ -25,6 +25,16 @@ type Id<Tag extends string> = string & {
25
25
  * generator type — `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
26
26
  * with no caller-side generic to abuse.
27
27
  *
28
+ * **Your factory must produce unique ids under concurrent calls.**
29
+ * The kit makes no attempt to dedupe or detect collisions — a collision
30
+ * silently overwrites earlier rows (under unique-key constraints) or
31
+ * silently aliases two different entities (without them). Safe choices:
32
+ * `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
33
+ * KSUID — all collision-resistant by design. Unsafe choices: `Date.now()`
34
+ * alone (duplicates within the same millisecond), a process-local
35
+ * counter without persistence (resets to 1 on restart, collides with
36
+ * prior runs), a sequential id derived from non-atomic state.
37
+ *
28
38
  * @example
29
39
  * ```ts
30
40
  * import { ulid } from "ulid";
@@ -71,12 +81,56 @@ type EventIdFactory = () => string;
71
81
  *
72
82
  * **Module-scoped — last setter wins.** The factory lives as a single
73
83
  * module variable; importing two libraries that both call this races on
74
- * load order. For multi-tenant request isolation (e.g. one factory per
75
- * tenant in a single Worker invocation) **prefer the per-call
76
- * `options.eventId`** instead of mutating the global. Same caveat applies
77
- * to `setClockFactory`.
84
+ * load order, and parallel test workers will see each other's factory.
85
+ * For test isolation and short-lived contexts prefer
86
+ * {@link withEventIdFactory}; for multi-tenant request isolation
87
+ * (e.g. one factory per tenant in a single Worker invocation) **prefer
88
+ * the per-call `options.eventId`** instead of mutating the global. Same
89
+ * caveat applies to `setClockFactory`.
78
90
  */
79
91
  declare function setEventIdFactory(factory: EventIdFactory): void;
92
+ /**
93
+ * Scoped variant of {@link setEventIdFactory}: installs `factory`,
94
+ * runs `fn`, then restores the previous factory in a `finally` block —
95
+ * so the restoration happens even if `fn` throws. Safe for parallel
96
+ * tests and for synchronous request handlers that need a tenant-
97
+ * specific factory without polluting the global.
98
+ *
99
+ * **Synchronous-only — enforced at runtime.** If `fn` returns a
100
+ * thenable (a `Promise` or any object with a `then` method), the
101
+ * helper throws *before* returning the value to the caller. This
102
+ * catches the async-misuse footgun where the factory would be
103
+ * restored before the awaited body of `fn` runs, leaving the awaited
104
+ * code reading the previous factory. For async scoping across `await`
105
+ * boundaries, use `AsyncLocalStorage` — out of scope for this helper;
106
+ * build it on top if you need it.
107
+ *
108
+ * Composes by nesting: an inner `withEventIdFactory` restores back to
109
+ * the outer's factory; the outer restores to the original.
110
+ *
111
+ * **When to prefer the per-call `options.eventId` instead.** If you're
112
+ * constructing a single event and want full control over its id,
113
+ * passing `{ eventId: "..." }` to `createDomainEvent` is the strongest
114
+ * isolation — it bypasses the factory mechanism entirely, no global
115
+ * mutation, no scope to manage. Reach for `withEventIdFactory` when
116
+ * the events are constructed deep inside domain methods you can't
117
+ * thread an explicit id through (typical test scenario), or when many
118
+ * events in a scope should share the same factory.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * // In a vitest test:
123
+ * it("emits deterministic ids", () => {
124
+ * withEventIdFactory(() => "evt-fixed", () => {
125
+ * const e = createDomainEvent("X", { v: 1 });
126
+ * expect(e.eventId).toBe("evt-fixed");
127
+ * });
128
+ * // Outside the callback the default crypto.randomUUID is restored,
129
+ * // even if the body had thrown.
130
+ * });
131
+ * ```
132
+ */
133
+ declare function withEventIdFactory<T>(factory: EventIdFactory, fn: () => T): T;
80
134
  /**
81
135
  * Restores the default event-id factory (`crypto.randomUUID()`).
82
136
  * Intended for use in test `afterEach` hooks.
@@ -102,8 +156,37 @@ type ClockFactory = () => Date;
102
156
  *
103
157
  * The per-call `options.occurredAt` override always wins over this
104
158
  * factory. Symmetric to `setEventIdFactory`.
159
+ *
160
+ * Module-scoped — see {@link setEventIdFactory} for the global-state
161
+ * caveats. For test isolation prefer {@link withClockFactory}; for
162
+ * multi-tenant request isolation prefer the per-call
163
+ * `options.occurredAt`.
105
164
  */
106
165
  declare function setClockFactory(factory: ClockFactory): void;
166
+ /**
167
+ * Scoped variant of {@link setClockFactory}: installs `factory`, runs
168
+ * `fn`, then restores the previous factory in a `finally` block.
169
+ * Synchronous-only — same constraints (and same runtime thenable
170
+ * guard) as {@link withEventIdFactory}.
171
+ *
172
+ * **When to prefer the per-call `options.occurredAt` instead.** Same
173
+ * trade-off as {@link withEventIdFactory}: passing `{ occurredAt }`
174
+ * to `createDomainEvent` is the strongest isolation for single-event
175
+ * cases. The scoped helper is for events constructed deep inside
176
+ * domain methods where threading an explicit timestamp is awkward.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * it("stamps events with a fixed clock", () => {
181
+ * const fixed = new Date("2026-01-01T00:00:00Z");
182
+ * withClockFactory(() => fixed, () => {
183
+ * const e = createDomainEvent("X", { v: 1 });
184
+ * expect(e.occurredAt).toEqual(fixed);
185
+ * });
186
+ * });
187
+ * ```
188
+ */
189
+ declare function withClockFactory<T>(factory: ClockFactory, fn: () => T): T;
107
190
  /**
108
191
  * Restores the default clock factory (`() => new Date()`).
109
192
  * Intended for use in test `afterEach` hooks.
@@ -753,16 +836,57 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = ne
753
836
  */
754
837
  clearPendingEvents(): void;
755
838
  /**
756
- * Post-save hook called by a `Repository.save()` implementation to push
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).
839
+ * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
840
+ * (or by your own orchestration code, after harvesting `pendingEvents`)
841
+ * to push the persisted version back into the in-memory aggregate and
842
+ * clear `pendingEvents`. TypeScript has no `final` keyword, but
843
+ * subclasses **should not** override this method directly.
844
+ *
845
+ * Overriding without calling `super.markPersisted(version)` silently
846
+ * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
847
+ * through the outbox, double-emitting events. This bug has been hit
848
+ * in production by consumers; the {@link onPersisted} hook below is
849
+ * the safer extension point.
760
850
  *
761
- * Use this so `save()` can keep its `Promise<void>` return type: the
762
- * caller holds the aggregate reference, which is up to date after this
763
- * call.
851
+ * If you must override (legitimate cases are very rare), call
852
+ * `super.markPersisted(version)` FIRST so the framework's cleanup
853
+ * runs, then add your logic afterwards.
854
+ *
855
+ * @param version - The version assigned by the persistence layer
856
+ * @see onPersisted — the safe extension point for subclasses
764
857
  */
765
858
  markPersisted(version: Version): void;
859
+ /**
860
+ * Subclass extension point — fires AFTER {@link markPersisted} has
861
+ * updated the version and cleared `pendingEvents`. Override this for
862
+ * post-persist logging, metrics, or cache-eviction without risk of
863
+ * breaking the framework's pendingEvents cleanup.
864
+ *
865
+ * The default implementation is a no-op. Subclasses do NOT need to
866
+ * call `super.onPersisted(version)` — there is nothing in the parent
867
+ * implementation to preserve.
868
+ *
869
+ * **`onPersisted` deliberately receives only the version, not the
870
+ * drained events.** Event-driven post-persist logic (aggregate-level
871
+ * audit logging, per-event-type side effects) belongs in `EventBus`
872
+ * subscribers or the outbox dispatcher — that is the proper
873
+ * Aggregate-Boundary separation. Building event-aware logic into
874
+ * `onPersisted` couples aggregate lifecycle to event processing and
875
+ * recreates the boundary problems Vernon's aggregate discipline is
876
+ * meant to prevent.
877
+ *
878
+ * **The hook must return synchronously.** `markPersisted` is `void`-
879
+ * typed and calls `onPersisted` without `await`. TypeScript's
880
+ * permissive `void` will accept an `async`-override returning
881
+ * `Promise<void>`, but the returned promise is fire-and-forget —
882
+ * any rejection becomes an unhandled rejection and `withCommit`
883
+ * proceeds without waiting. For asynchronous work, subscribe to the
884
+ * relevant domain event on the `EventBus` instead; that is the
885
+ * properly awaited extension point.
886
+ *
887
+ * @param version - The version that was just persisted
888
+ */
889
+ protected onPersisted(_version: Version): void;
766
890
  /**
767
891
  * Mutates state and records the resulting domain events in the
768
892
  * **canonical record-after-mutation order**. Use this instead of calling
@@ -1058,12 +1182,57 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1058
1182
  get pendingEvents(): ReadonlyArray<TEvent>;
1059
1183
  clearPendingEvents(): void;
1060
1184
  /**
1061
- * Post-save hook called by a `Repository.save()` implementation to push
1062
- * the persisted version back into the in-memory aggregate and clear the
1063
- * pending events (they are now in the event store / outbox). Lets
1064
- * `save()` keep its `Promise<void>` return type.
1185
+ * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
1186
+ * (or by your own orchestration code, after harvesting `pendingEvents`)
1187
+ * to push the persisted version back into the in-memory aggregate and
1188
+ * clear `pendingEvents`. TypeScript has no `final` keyword, but
1189
+ * subclasses **should not** override this method directly.
1190
+ *
1191
+ * Overriding without calling `super.markPersisted(version)` silently
1192
+ * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
1193
+ * through the outbox, double-emitting events. This bug has been hit
1194
+ * in production by consumers; the {@link onPersisted} hook below is
1195
+ * the safer extension point.
1196
+ *
1197
+ * If you must override (legitimate cases are very rare), call
1198
+ * `super.markPersisted(version)` FIRST so the framework's cleanup
1199
+ * runs, then add your logic afterwards.
1200
+ *
1201
+ * @param version - The version assigned by the persistence layer
1202
+ * @see onPersisted — the safe extension point for subclasses
1065
1203
  */
1066
1204
  markPersisted(version: Version): void;
1205
+ /**
1206
+ * Subclass extension point — fires AFTER {@link markPersisted} has
1207
+ * updated the version and cleared `pendingEvents`. Override this for
1208
+ * post-persist logging, metrics, or cache-eviction without risk of
1209
+ * breaking the framework's pendingEvents cleanup.
1210
+ *
1211
+ * The default implementation is a no-op. Subclasses do NOT need to
1212
+ * call `super.onPersisted(version)` — there is nothing in the parent
1213
+ * implementation to preserve.
1214
+ *
1215
+ * **`onPersisted` deliberately receives only the version, not the
1216
+ * drained events.** Event-driven post-persist logic (aggregate-level
1217
+ * audit logging, per-event-type side effects) belongs in `EventBus`
1218
+ * subscribers or the outbox dispatcher — that is the proper
1219
+ * Aggregate-Boundary separation. Building event-aware logic into
1220
+ * `onPersisted` couples aggregate lifecycle to event processing and
1221
+ * recreates the boundary problems Vernon's aggregate discipline is
1222
+ * meant to prevent.
1223
+ *
1224
+ * **The hook must return synchronously.** `markPersisted` is `void`-
1225
+ * typed and calls `onPersisted` without `await`. TypeScript's
1226
+ * permissive `void` will accept an `async`-override returning
1227
+ * `Promise<void>`, but the returned promise is fire-and-forget —
1228
+ * any rejection becomes an unhandled rejection and `withCommit`
1229
+ * proceeds without waiting. For asynchronous work, subscribe to the
1230
+ * relevant domain event on the `EventBus` instead; that is the
1231
+ * properly awaited extension point.
1232
+ *
1233
+ * @param version - The version that was just persisted
1234
+ */
1235
+ protected onPersisted(_version: Version): void;
1067
1236
  protected constructor(id: TId, initialState: TState);
1068
1237
  /**
1069
1238
  * Validates an event before it is applied. Default is no-op.
@@ -1638,6 +1807,29 @@ interface TransactionScope<TCtx> {
1638
1807
  * aggregate's `pendingEvents` and writes them via `outbox.add` (so
1639
1808
  * events persist atomically with the state change). Skipped when no
1640
1809
  * events were recorded.
1810
+ *
1811
+ * **Harvest order.** Events are concatenated in the order
1812
+ * aggregates appear in the returned `aggregates` array, then in
1813
+ * each aggregate's `pendingEvents` order (insertion order via
1814
+ * `apply` / `commit` / `addDomainEvent`). So `aggregates: [a, b]`
1815
+ * with `a` emitting `[e1, e2]` and `b` emitting `[e3]` produces
1816
+ * `outbox.add([e1, e2, e3])` and `bus.publish([e1, e2, e3])` in
1817
+ * that exact order.
1818
+ *
1819
+ * **Two ordering guarantees, not one.** Within a single aggregate
1820
+ * the order is *causal* — events are recorded in the order the
1821
+ * domain methods ran, and subscribers (handlers, projections,
1822
+ * replay) MUST process them in that order. Across aggregates the
1823
+ * order in this batch is deterministic but *not* a domain
1824
+ * guarantee. Greg Young / Vernon IDDD §10: aggregates are
1825
+ * independent consistency boundaries; events across them are
1826
+ * eventually consistent. Subscribers should NOT engineer
1827
+ * dependencies on cross-aggregate ordering — use
1828
+ * `EventMetadata.causationId` to express true causation, or a
1829
+ * process manager to coordinate. The in-process EventBus delivers
1830
+ * this batch in order, sequential outbox-dispatchers preserve it
1831
+ * too, but parallel dispatchers or message brokers may reorder
1832
+ * across aggregates at delivery time.
1641
1833
  * 3. The transaction commits.
1642
1834
  * 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
1643
1835
  * fires on each returned aggregate — only now are pending events
@@ -1654,6 +1846,19 @@ interface TransactionScope<TCtx> {
1654
1846
  * If the transaction rolls back, `markPersisted` is **not** called — the
1655
1847
  * aggregate keeps its pending events, so the caller can retry or discard.
1656
1848
  *
1849
+ * **Duplicate aggregates are deduped by reference.** If the returned
1850
+ * `aggregates` array contains the same instance twice — e.g. a use
1851
+ * case touches an order via two repository references that happen to
1852
+ * resolve to the same identity-map entry — `withCommit` dedupes by
1853
+ * JavaScript object identity before harvesting. Each event lands in
1854
+ * the outbox exactly once and `markPersisted` fires exactly once. Two
1855
+ * *different* instances with the same logical id cannot be detected
1856
+ * at this layer; that is a Repository contract violation (failure to
1857
+ * maintain Fowler's Identity Map per Unit of Work). See
1858
+ * `docs/guide/repository.md` → "Identity Map: one instance per
1859
+ * aggregate per Unit of Work" for the requirement on `IRepository`
1860
+ * implementations that makes this dedupe sound.
1861
+ *
1657
1862
  * @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
1658
1863
  * ```typescript
1659
1864
  * const result = await withCommit({ outbox, bus, scope }, async (tx) => {
@@ -2008,6 +2213,27 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2008
2213
  * stored (optimistic concurrency).
2009
2214
  * 2. Write the aggregate to durable storage.
2010
2215
  *
2216
+ * **Insert vs update — library convention.** A fresh aggregate begins
2217
+ * at `version === 0` (the `Version` brand defaults to `0` in both
2218
+ * `AggregateRoot` and `EventSourcedAggregate`). After the first
2219
+ * versioned mutation (`setState(_, true)`, `apply()`, `commit()`) the
2220
+ * version is `> 0`. Implementations distinguish the two paths by the
2221
+ * incoming `aggregate.version`:
2222
+ *
2223
+ * - `aggregate.version === 0` → **INSERT** (no existing row to lock
2224
+ * against; the write succeeds unconditionally or fails the unique
2225
+ * constraint on `id`).
2226
+ * - `aggregate.version > 0` → **UPDATE** with the OCC predicate
2227
+ * `WHERE id = ? AND version = expected`. If the row count is `0`,
2228
+ * another writer raced you — throw `ConcurrencyConflictError`.
2229
+ *
2230
+ * The library does not formalise this in the type system because
2231
+ * version-bump semantics differ across the two aggregate flavours
2232
+ * (state-stored aggregates bump on the user's call to `setState(_,
2233
+ * true)`; event-sourced aggregates bump on every `apply()` by
2234
+ * definition). The `version === 0` invariant for "never persisted" is
2235
+ * the common contract.
2236
+ *
2011
2237
  * Do **not** call `aggregate.markPersisted(...)` here. The library's
2012
2238
  * `withCommit` orchestrator handles the post-save lifecycle (harvest
2013
2239
  * pending events into the outbox, then mark persisted after commit).
@@ -2021,7 +2247,45 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2021
2247
  */
2022
2248
  save(aggregate: TAgg): Promise<void>;
2023
2249
  /**
2024
- * Removes the aggregate by id.
2250
+ * Removes the aggregate's row by id. Pure persistence — does NOT
2251
+ * harvest pending events from the aggregate (the contract takes
2252
+ * only the id, so there is no aggregate to harvest from).
2253
+ *
2254
+ * Before reaching for `delete`, ask whether the user-facing "delete"
2255
+ * is the right domain verb. Most are actually state transitions
2256
+ * (*cancel*, *archive*, *close*, *deactivate*, *terminate*) with
2257
+ * proper domain names that should be modelled as state changes plus
2258
+ * a recorded event — not as row removal.
2259
+ *
2260
+ * `delete(id)` belongs in the toolkit for three distinct cases, in
2261
+ * decreasing order of common occurrence (see
2262
+ * `docs/guide/repository.md` → "Deletion and Domain Events" for
2263
+ * worked examples):
2264
+ *
2265
+ * 1. **State transition that records an event.** The user-facing
2266
+ * "delete" maps to a real domain operation (e.g. `order.cancel()`,
2267
+ * `order.archive()`). Call `save(aggregate)`; the row stays with
2268
+ * a status column. `delete(id)` is never called by the use case.
2269
+ *
2270
+ * 2. **Hard-delete with event harvest.** The row genuinely must
2271
+ * vanish (regulatory purge, retention-window expiry, true
2272
+ * termination) *and* the disappearance is a domain fact
2273
+ * subscribers care about. Inside `withCommit`'s transactional
2274
+ * callback, record the deletion event on the aggregate, then
2275
+ * call `delete(id)`. Return the aggregate in the `aggregates`
2276
+ * array so `withCommit` harvests its pending events into the
2277
+ * outbox before the row is gone.
2278
+ *
2279
+ * 3. **Hard-delete without event.** Deletion is invisible to the
2280
+ * domain (abandoned-cart cleanup, expired session rows). No
2281
+ * subscriber cares. If the entity has identity in the ubiquitous
2282
+ * language, you probably want path 1 or 2 instead.
2283
+ *
2284
+ * In pure event-sourced systems `delete` is rarely meaningful —
2285
+ * end-of-lifecycle there is a `Closed` / `Terminated` event in the
2286
+ * stream, and identity persists in the event log. `delete` applies
2287
+ * primarily to state-stored aggregates and snapshot / projection
2288
+ * tables.
2025
2289
  */
2026
2290
  delete(id: TId): Promise<void>;
2027
2291
  }
@@ -2295,4 +2559,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2295
2559
  toJSON(): Readonly<T>;
2296
2560
  }
2297
2561
 
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 };
2562
+ 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, withClockFactory, withCommit, withEventIdFactory };
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
@@ -834,14 +909,61 @@ var EventSourcedAggregate = class extends Entity {
834
909
  this._pendingEvents = [];
835
910
  }
836
911
  /**
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.
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
841
930
  */
842
931
  markPersisted(version) {
843
932
  this.setVersion(version);
844
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) {
845
967
  }
846
968
  constructor(id, initialState) {
847
969
  super(id, initialState);
@@ -987,13 +1109,14 @@ async function withCommit(deps, fn) {
987
1109
  const { result, aggregates, events } = await deps.scope.transactional(
988
1110
  async (ctx) => {
989
1111
  const fnResult = await fn(ctx);
990
- const harvested = fnResult.aggregates.flatMap(
1112
+ const uniqueAggregates = Array.from(new Set(fnResult.aggregates));
1113
+ const harvested = uniqueAggregates.flatMap(
991
1114
  (agg) => agg.pendingEvents
992
1115
  );
993
1116
  if (harvested.length > 0) {
994
1117
  await deps.outbox.add(harvested);
995
1118
  }
996
- return { ...fnResult, events: harvested };
1119
+ return { ...fnResult, aggregates: uniqueAggregates, events: harvested };
997
1120
  }
998
1121
  );
999
1122
  for (const agg of aggregates) {
@@ -1163,6 +1286,6 @@ var InMemoryOutbox = class {
1163
1286
  }
1164
1287
  };
1165
1288
 
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 };
1289
+ 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
1290
  //# sourceMappingURL=index.js.map
1168
1291
  //# sourceMappingURL=index.js.map