@shirudo/ddd-kit 1.2.0 → 1.3.0

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/README.md CHANGED
@@ -17,9 +17,9 @@ Composable TypeScript toolkit for tactical Domain-Driven Design. Ships the canon
17
17
  - **Domain Events:** typed, deeply frozen, carry metadata for traceability and schema evolution.
18
18
  - **Repositories:** technology-agnostic persistence ports with an Identity-Map contract and OCC.
19
19
  - **CQRS:** zero-config in-memory `CommandBus` / `QueryBus`, plus `CommandHandler` / `QueryHandler` types for external brokers.
20
- - **Unit of Work:** opt-in `UnitOfWork` facade with tx-bound repositories, repository-side enrollment, a per-operation Identity Map, and aggregate-level dirty tracking (`changedKeys` / `hasChanges`) for partial writes honestly speaking: a transaction coordinator with registration and Identity Map; writes stay explicit by design (no auto-flush).
20
+ - **Unit of Work:** opt-in `UnitOfWork` facade with tx-bound repositories, repository-side enrollment, a per-operation Identity Map, and aggregate-level dirty tracking (`changedKeys` / `hasChanges`) for partial writes. Honestly speaking: a transaction coordinator with registration and Identity Map; writes stay explicit by design (no auto-flush).
21
21
  - **Outbox:** `withCommit` harvests pending events inside the transaction, stamps them with the aggregate's commit version, and publishes them atomically.
22
- - **Repository contract tests:** `@shirudo/ddd-kit/testing` ships the suite every adapter must pass OCC is a testable contract, not a documented pattern.
22
+ - **Repository contract tests:** `@shirudo/ddd-kit/testing` ships the suite every adapter must pass: OCC is a testable contract, not a documented pattern.
23
23
  - **Result-first boundary:** a typed error hierarchy on [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error) and `Result` from [`@shirudo/result`](https://www.npmjs.com/package/@shirudo/result); `voValidated` collects field violations and renders RFC 9457 via the opt-in `@shirudo/ddd-kit/http` entry.
24
24
 
25
25
  ## Installation
@@ -104,6 +104,60 @@ declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
104
104
  readonly eventType: string;
105
105
  constructor(eventType: string, cause?: unknown);
106
106
  }
107
+ /**
108
+ * Thrown by `withCommit` when an event harvested from an aggregate cannot
109
+ * be safely committed: it is missing `aggregateId` / `aggregateType`
110
+ * (downstream routing would break), or it carries a pre-set
111
+ * `aggregateVersion` AHEAD of the aggregate's commit version (a leaked or
112
+ * copied fixture that would advance consumer idempotency watermarks past
113
+ * real history). Both are programming bugs in how the aggregate recorded
114
+ * the event, deterministic, and fail identically on every retry.
115
+ *
116
+ * Deliberately **not** an {@link InfrastructureError} (same reasoning as
117
+ * {@link MissingHandlerError}): the failure happens after the work
118
+ * callback completed, but it is NOT transient. A `catch (e instanceof
119
+ * InfrastructureError)` retry handler, or a retrying `TransactionScope`,
120
+ * must NOT mask it or loop on it forever; it should crash loud so the
121
+ * recordEvent / createDomainEvent misuse surfaces in development. This is
122
+ * why `withCommit` throws it directly and `UnitOfWork.run` passes it
123
+ * through unchanged instead of wrapping it in `CommitError`.
124
+ */
125
+ declare class EventHarvestError extends BaseError<"EventHarvestError"> {
126
+ /** The `type` of the offending event, for programmatic routing. */
127
+ readonly eventType?: string | undefined;
128
+ constructor(message: string,
129
+ /** The `type` of the offending event, for programmatic routing. */
130
+ eventType?: string | undefined);
131
+ }
132
+ /**
133
+ * Thrown at the end of a `UnitOfWork.run` when an aggregate that was
134
+ * loaded into the identity map during the operation carries unflushed
135
+ * `pendingEvents` but was never enrolled (no `session.enrollSaved`, and
136
+ * not deleted). The almost-certain cause is a repository `save()` that
137
+ * forgot to call `enrollSaved`, or a use case that recorded events on a
138
+ * loaded aggregate and never saved it. Without this guard those events
139
+ * would be silently dropped: never harvested into the outbox, never
140
+ * published.
141
+ *
142
+ * Deliberately **not** an `InfrastructureError` (same posture as
143
+ * {@link MissingHandlerError}): a programming bug that must crash loud,
144
+ * not be absorbed by a generic infrastructure-error handler. The throw
145
+ * happens inside the transaction, so the unit of work rolls back and
146
+ * leaves no partial state.
147
+ *
148
+ * **Scope of the guard.** A best-effort runtime safety net, not a proof.
149
+ * It only sees aggregates the identity map knows about (those loaded via
150
+ * `getById`), and detects new events by comparing the pending-event COUNT
151
+ * at load against commit, which assumes the kit's append-only event model
152
+ * (so it cannot see events that were recorded and then cleared within the
153
+ * same run). A freshly *created* aggregate that was never enrolled is
154
+ * invisible to the kit. The repository contract test suite remains the
155
+ * full mitigation. See the Unit of Work guide.
156
+ */
157
+ declare class UnenrolledChangesError extends BaseError<"UnenrolledChangesError"> {
158
+ readonly aggregateId: string;
159
+ constructor(aggregateId: string);
160
+ }
107
161
  /**
108
162
  * Thrown when an aggregate that was deleted within the current unit of
109
163
  * work is saved or re-registered again in the same operation: by
@@ -172,7 +226,7 @@ declare class DuplicateAggregateError extends InfrastructureError<"DuplicateAggr
172
226
  *
173
227
  * **Retry means a FRESH unit of work** (a new `UnitOfWork.run()` /
174
228
  * `withCommit` invocation): reload, re-apply, save. Do NOT catch this
175
- * inside the same `run()` callback and continue the failed aggregate
229
+ * inside the same `run()` callback and continue: the failed aggregate
176
230
  * is already enrolled (its events would be committed for a write that
177
231
  * never happened) and the identity map still serves the same stale
178
232
  * instance to any in-place "reload".
@@ -379,7 +433,7 @@ interface EventMetadata {
379
433
  * transport concerns the outbox needs (`aggregateId`, `aggregateType`,
380
434
  * `aggregateVersion`, `metadata`). That is the line: further transport
381
435
  * fields (partition keys, tenancy, schema URNs, …) belong in an outbox
382
- * envelope / `metadata`, not on the domain event the next first-class
436
+ * envelope / `metadata`, not on the domain event: the next first-class
383
437
  * transport field forces an `OutboxMessage` envelope port instead.
384
438
  *
385
439
  * @template T - The event type name (e.g., "OrderCreated")
@@ -422,7 +476,7 @@ interface DomainEvent<T extends string, P = void> {
422
476
  * Required for safe schema migration in event-sourced systems.
423
477
  * Use 1 for the initial schema version.
424
478
  *
425
- * **NOT the aggregate's version** that is
479
+ * **NOT the aggregate's version**: that is
426
480
  * {@link aggregateVersion}. The two are deliberately distinct
427
481
  * fields: this one says "which shape does the payload have"
428
482
  * (upcasting), the other says "which state revision of the
@@ -435,7 +489,7 @@ interface DomainEvent<T extends string, P = void> {
435
489
  * `withCommit` at the harvest boundary (all events of one aggregate
436
490
  * in one commit share it; their relative order within the commit is
437
491
  * the harvest order), or set manually via
438
- * `CreateDomainEventOptions.aggregateVersion` a pre-set value is
492
+ * `CreateDomainEventOptions.aggregateVersion`; a pre-set value is
439
493
  * never overwritten.
440
494
  *
441
495
  * Consumers use it for ordering ("apply projections up to aggregate
@@ -486,8 +540,8 @@ interface CreateDomainEventOptions {
486
540
  version?: number;
487
541
  /**
488
542
  * Pre-set the producing aggregate's version (see
489
- * `DomainEvent.aggregateVersion`). Normally left unset `withCommit`
490
- * stamps it at the harvest boundary with the commit version but
543
+ * `DomainEvent.aggregateVersion`). Normally left unset (`withCommit`
544
+ * stamps it at the harvest boundary with the commit version), but
491
545
  * useful for replay fixtures and events constructed outside an
492
546
  * aggregate. A pre-set value is never overwritten by the harvest.
493
547
  */
@@ -659,4 +713,4 @@ declare function sameVersion<TId extends Id<string>>(a: {
659
713
  version: Version;
660
714
  }): boolean;
661
715
 
662
- export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, setEventIdFactory as e, type ClockFactory as f, setClockFactory as g, withClockFactory as h, resetClockFactory as i, type EventMetadata as j, type DomainEvent as k, createDomainEvent as l, createDomainEventWithMetadata as m, copyMetadata as n, mergeMetadata as o, AggregateDeletedError as p, AggregateNotFoundError as q, resetEventIdFactory as r, sameVersion as s, DuplicateAggregateError as t, ConcurrencyConflictError as u, type IdGenerator as v, withEventIdFactory as w };
716
+ export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, UnenrolledChangesError as U, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, setEventIdFactory as e, type ClockFactory as f, setClockFactory as g, withClockFactory as h, resetClockFactory as i, type EventMetadata as j, type DomainEvent as k, createDomainEvent as l, createDomainEventWithMetadata as m, copyMetadata as n, mergeMetadata as o, EventHarvestError as p, AggregateDeletedError as q, resetEventIdFactory as r, sameVersion as s, AggregateNotFoundError as t, DuplicateAggregateError as u, ConcurrencyConflictError as v, withEventIdFactory as w, type IdGenerator as x };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as Id, A as AnyDomainEvent, I as IAggregateRoot, V as Version, b as AggregateSnapshot, C as CreateDomainEventOptions, c as IEventSourcedAggregate, D as DomainError, d as InfrastructureError } from './aggregate-DclYgG_D.js';
2
- export { p as AggregateDeletedError, q as AggregateNotFoundError, f as ClockFactory, u as ConcurrencyConflictError, k as DomainEvent, t as DuplicateAggregateError, E as EventIdFactory, j as EventMetadata, v as IdGenerator, M as MissingHandlerError, n as copyMetadata, l as createDomainEvent, m as createDomainEventWithMetadata, o as mergeMetadata, i as resetClockFactory, r as resetEventIdFactory, s as sameVersion, g as setClockFactory, e as setEventIdFactory, h as withClockFactory, w as withEventIdFactory } from './aggregate-DclYgG_D.js';
1
+ import { a as Id, A as AnyDomainEvent, I as IAggregateRoot, V as Version, b as AggregateSnapshot, C as CreateDomainEventOptions, c as IEventSourcedAggregate, D as DomainError, d as InfrastructureError } from './aggregate-BGdgvqKh.js';
2
+ export { q as AggregateDeletedError, t as AggregateNotFoundError, f as ClockFactory, v as ConcurrencyConflictError, k as DomainEvent, u as DuplicateAggregateError, p as EventHarvestError, E as EventIdFactory, j as EventMetadata, x as IdGenerator, M as MissingHandlerError, U as UnenrolledChangesError, n as copyMetadata, l as createDomainEvent, m as createDomainEventWithMetadata, o as mergeMetadata, i as resetClockFactory, r as resetEventIdFactory, s as sameVersion, g as setClockFactory, e as setEventIdFactory, h as withClockFactory, w as withEventIdFactory } from './aggregate-BGdgvqKh.js';
3
3
  import { Result } from '@shirudo/result';
4
4
  import { BaseError, ValidationError } from '@shirudo/base-error';
5
5
  import { DeepEqualExceptOptions } from './utils.js';
@@ -446,7 +446,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
446
446
  * `changedKeys`/`hasChanges`. An override that skips `super` leaves
447
447
  * that baseline uncaptured: `changedKeys` permanently reports ALL
448
448
  * keys and `hasChanges` never returns `false`, so a partial-write
449
- * repository silently degrades to full writes on every save on top
449
+ * repository silently degrades to full writes on every save, on top
450
450
  * of the broken version sync.
451
451
  *
452
452
  * @param version - The version the row currently holds in the DB
@@ -694,7 +694,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
694
694
  * If you override this, call `super.markRestored(version)` FIRST:
695
695
  * skipping it leaves the baseline uncaptured, so `changedKeys`
696
696
  * permanently reports ALL keys and `hasChanges` never returns `false`
697
- * partial-write repositories silently degrade to full writes on
697
+ * (partial-write repositories silently degrade to full writes), on
698
698
  * top of breaking version sync.
699
699
  */
700
700
  protected markRestored(version: Version): void;
@@ -713,7 +713,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
713
713
  * **How it works.** `setState()` replaces state immutably and the
714
714
  * state object is shallow-frozen, so unchanged top-level sub-objects
715
715
  * keep reference identity across mutations. The diff is therefore a
716
- * shallow per-key `!==` against the baseline reference O(top-level
716
+ * shallow per-key `!==` against the baseline reference: O(top-level
717
717
  * keys), no proxies, no deep diff. A key also counts as dirty when its
718
718
  * *presence* differs (added or removed, even with an `undefined`
719
719
  * value). Computed fresh on every access (a new `Set` each time), so
@@ -726,12 +726,12 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
726
726
  * class-instance `TState` mutated through its own methods defeats
727
727
  * tracking entirely (the reference never changes). A keyless `TState`
728
728
  * (primitive, bare `Date`) has no keys to report, so `changedKeys`
729
- * stays empty for it use {@link hasChanges}, whose reference
729
+ * stays empty for it; use {@link hasChanges}, whose reference
730
730
  * fallback covers keyless states. A deep-equal but newly-referenced
731
731
  * value reports a false POSITIVE (harmless extra write); under the
732
732
  * contract above there are no false negatives.
733
733
  *
734
- * Granularity is per top-level key table-granular, not row-granular:
734
+ * Granularity is per top-level key, table-granular, not row-granular:
735
735
  * a dirty collection key means "this child table changed", not which
736
736
  * rows. `EventSourcedAggregate` deliberately has no `changedKeys`;
737
737
  * its `pendingEvents` are the change record.
@@ -741,16 +741,16 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
741
741
  * Safe skip signal: `false` only when there is genuinely nothing to
742
742
  * persist or flush. `true` when the aggregate has never been
743
743
  * persisted, the version moved past `persistedVersion`, there are
744
- * unflushed {@link pendingEvents}, any state key is dirty, or for
744
+ * unflushed {@link pendingEvents}, any state key is dirty, or, for
745
745
  * keyless states the per-key diff cannot see (primitive `TState`,
746
- * zero-own-key objects like a bare `Date`) the state reference
746
+ * zero-own-key objects like a bare `Date`), the state reference
747
747
  * changed since the baseline.
748
748
  *
749
749
  * The version clause is deliberate: `setState({...state}, true)` with
750
750
  * identical per-key values yields empty {@link changedKeys} but a
751
751
  * bumped version. If a repository skipped `save()` on a state-only
752
752
  * check, `withCommit` would still call `markPersisted(version)` after
753
- * commit, desyncing `persistedVersion` from the DB row and the next
753
+ * commit, desyncing `persistedVersion` from the DB row; and the next
754
754
  * uncontended save would throw a false `ConcurrencyConflictError`.
755
755
  *
756
756
  * The pending-events clause covers the sanctioned decoupled
@@ -1386,8 +1386,22 @@ interface Outbox<Evt extends AnyDomainEvent> {
1386
1386
  * (constructor injection, factory functions, `withTx` chains); pick one
1387
1387
  * and keep it consistent.
1388
1388
  */
1389
+ /** Options passed to {@link TransactionScope.transactional}. */
1390
+ interface TransactionalOptions {
1391
+ /**
1392
+ * Cooperative-cancellation signal forwarded from `withCommit` /
1393
+ * `UnitOfWork.run`. The kit does not interrupt an in-flight query
1394
+ * itself: it pre-checks `aborted` before opening the transaction and
1395
+ * exposes the signal for the work callback to poll. A scope whose
1396
+ * driver supports cancellation (passing the signal to the query, an
1397
+ * interactive-transaction timeout) SHOULD honor it to abort work
1398
+ * already in progress; scopes that ignore it stay correct, just not
1399
+ * eagerly cancellable.
1400
+ */
1401
+ readonly signal?: AbortSignal;
1402
+ }
1389
1403
  interface TransactionScope<TCtx> {
1390
- transactional<T>(fn: (ctx: TCtx) => Promise<T>): Promise<T>;
1404
+ transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
1391
1405
  }
1392
1406
 
1393
1407
  /**
@@ -1469,12 +1483,12 @@ interface TransactionScope<TCtx> {
1469
1483
  * version and (on `AggregateRoot`) re-baselines dirty tracking against
1470
1484
  * the CURRENT state. A mutation between `save` and the callback's return
1471
1485
  * therefore desyncs OCC (next save throws a false
1472
- * `ConcurrencyConflictError`) and under a partial-write repository
1486
+ * `ConcurrencyConflictError`); and under a partial-write repository
1473
1487
  * using `changedKeys`, an un-bumped mutation is silently marked clean
1474
1488
  * and never written. The `aggregateVersion` stamp widens the blast
1475
1489
  * radius further: harvested events would publicly claim a version the
1476
1490
  * committed row does not carry, poisoning every consumer's ordering
1477
- * and idempotency watermarks a cross-service inconsistency, not just
1491
+ * and idempotency watermarks: a cross-service inconsistency, not just
1478
1492
  * a local one. Mutate first, save last.
1479
1493
  *
1480
1494
  * **Duplicate aggregates are deduped by reference.** If the returned
@@ -1511,6 +1525,15 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
1511
1525
  * for delivery: the outbox dispatcher is the reliable path.
1512
1526
  */
1513
1527
  onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
1528
+ /**
1529
+ * Cooperative-cancellation signal. If already aborted, `withCommit`
1530
+ * rejects with the signal's `reason` BEFORE opening the transaction.
1531
+ * Otherwise the signal is forwarded to `scope.transactional`, where a
1532
+ * cancellation-aware scope can abort an in-flight query. The kit does
1533
+ * not race the work promise: aborting does not kill a running query
1534
+ * unless the scope honors the signal.
1535
+ */
1536
+ signal?: AbortSignal;
1514
1537
  }, fn: (ctx: TCtx) => Promise<{
1515
1538
  result: R;
1516
1539
  aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
@@ -1518,7 +1541,7 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
1518
1541
  * Optional marker: which of `aggregates` were DELETED in this unit
1519
1542
  * of work. Their pending events are harvested like any other
1520
1543
  * (deletion events must reach the outbox), but the post-commit
1521
- * lifecycle differs: `markPersisted` is NOT called on them — it
1544
+ * lifecycle differs: `markPersisted` is NOT called on them. It
1522
1545
  * would fire the user-overridable `onPersisted` hook, whose
1523
1546
  * post-save semantics (cache fill, read-model warm-up) are a lie
1524
1547
  * for a row that was just deleted. Their pending events are
@@ -1630,7 +1653,7 @@ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function
1630
1653
  * `markPersisted`) is keyed on JavaScript object identity.
1631
1654
  *
1632
1655
  * Storage is two-level (per-type stores created lazily), so
1633
- * `Restaurant:123` and `Booking:123` can never collide the type key
1656
+ * `Restaurant:123` and `Booking:123` can never collide: the type key
1634
1657
  * is the aggregate CLASS, not the id alone and not a name string.
1635
1658
  *
1636
1659
  * Repository read-path contract:
@@ -1653,7 +1676,7 @@ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function
1653
1676
  *
1654
1677
  * Deletion is final within an operation: {@link delete} removes the
1655
1678
  * entry AND records a tombstone, so a later {@link set} of the same
1656
- * type+id throws `AggregateDeletedError` a second instance of a
1679
+ * type+id throws `AggregateDeletedError`: a second instance of a
1657
1680
  * deleted aggregate can never sneak back into the unit of work, even
1658
1681
  * through a repository whose row delete is deferred.
1659
1682
  *
@@ -1664,6 +1687,7 @@ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function
1664
1687
  declare class IdentityMap {
1665
1688
  private readonly _stores;
1666
1689
  private readonly _deleted;
1690
+ private readonly _pendingAtRegistration;
1667
1691
  /** The cached instance for type+id, or `undefined` (also after {@link delete}). */
1668
1692
  get<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): TAgg | undefined;
1669
1693
  /** Whether an instance is registered for type+id (false after {@link delete}). */
@@ -1671,7 +1695,7 @@ declare class IdentityMap {
1671
1695
  /**
1672
1696
  * Whether type+id was {@link delete}d in this unit of work. The
1673
1697
  * read path checks this BEFORE hydrating and returns `null`, so
1674
- * "deleted in this operation" reads uniformly as not-found
1698
+ * "deleted in this operation" reads uniformly as not-found,
1675
1699
  * regardless of whether the repository's physical delete already
1676
1700
  * removed the row or is deferred within the transaction. Without
1677
1701
  * the check, a read-only probe of a deleted aggregate would crash
@@ -1693,6 +1717,15 @@ declare class IdentityMap {
1693
1717
  * the operation.
1694
1718
  */
1695
1719
  set<TAgg>(type: AggregateClass<TAgg>, id: Id<string>, aggregate: TAgg): void;
1720
+ /**
1721
+ * Registered instances that have recorded MORE pending events than they
1722
+ * carried when first registered (loaded). Used by the unit of work's
1723
+ * end-of-run guard: an aggregate that gained events after load but was
1724
+ * never enrolled would silently drop them. A read-only load, or a
1725
+ * reconstitution that already carried events, shows no increase and is
1726
+ * not reported.
1727
+ */
1728
+ instancesWithNewPendingEvents(): unknown[];
1696
1729
  /**
1697
1730
  * Removes the entry for type+id and records a tombstone: subsequent
1698
1731
  * {@link get} / {@link has} report absence, and a subsequent
@@ -1748,20 +1781,23 @@ declare class TransactionClosedError extends BaseError<"TransactionClosedError">
1748
1781
  }
1749
1782
  /**
1750
1783
  * The unit of work failed AFTER the work callback completed
1751
- * successfully: during the event harvest, the outbox write, or the
1752
- * transaction commit itself. The kit cannot see inside
1753
- * `TransactionScope.transactional`, so these three are deliberately
1754
- * one error class - the underlying failure is attached as `cause`.
1784
+ * successfully, at the persistence boundary: the outbox write or the
1785
+ * transaction commit itself rejected. The kit cannot see inside
1786
+ * `TransactionScope.transactional`, so these are deliberately one error
1787
+ * class; the underlying failure is attached as `cause`.
1755
1788
  *
1756
1789
  * `InfrastructureError`: the business logic ran to completion; the
1757
1790
  * persistence boundary failed. The transaction rolled back (or never
1758
1791
  * committed), no aggregate was marked persisted, and pending events
1759
- * survive on the aggregates the operation left no partial state
1760
- * behind. **Whether a retry helps depends on the cause**: a commit-
1761
- * time serialization failure is transient, but `withCommit`'s harvest
1762
- * guard (an event missing `aggregateId` / `aggregateType` a
1763
- * programming bug) also lands here and will fail deterministically on
1764
- * every retry. Inspect the `cause` before routing into retry logic.
1792
+ * survive on the aggregates; the operation left no partial state behind.
1793
+ * A `CommitError` is the **potentially transient** post-completion
1794
+ * failure (a commit-time serialization failure is the classic case), so
1795
+ * it is the one a retrying caller should consider re-running. The
1796
+ * deterministic post-completion failure, a harvest-guard violation (an
1797
+ * event missing `aggregateId` / `aggregateType`, or an `aggregateVersion`
1798
+ * ahead of the commit version), is a programming bug and surfaces as
1799
+ * {@link EventHarvestError} instead, which does NOT extend
1800
+ * `InfrastructureError`, so it stays out of retry paths by construction.
1765
1801
  */
1766
1802
  declare class CommitError extends InfrastructureError<"CommitError"> {
1767
1803
  constructor(cause: unknown);
@@ -1836,8 +1872,8 @@ interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
1836
1872
  }
1837
1873
  /**
1838
1874
  * What the work callback receives: repositories already bound to the
1839
- * live transaction, the enrollment session, and deliberately named to
1840
- * look like the escape hatch it is the raw transaction handle.
1875
+ * live transaction, the enrollment session, and, deliberately named to
1876
+ * look like the escape hatch it is, the raw transaction handle.
1841
1877
  *
1842
1878
  * All members throw {@link TransactionClosedError} once `run()` has
1843
1879
  * settled; do not let the context escape the callback.
@@ -1845,17 +1881,36 @@ interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
1845
1881
  interface UnitOfWorkContext<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> {
1846
1882
  readonly repositories: TRepos;
1847
1883
  /**
1848
- * **Escape hatch you are leaving the unit of work's guarantees.**
1884
+ * **Escape hatch: you are leaving the unit of work's guarantees.**
1849
1885
  * A write issued on the raw handle bypasses the repository contract,
1850
1886
  * enrollment (its aggregate's events are NOT harvested unless you
1851
1887
  * also call `session.enrollSaved`), and the identity map (a later
1852
- * `getById` of the same aggregate hydrates a SECOND instance
1888
+ * `getById` of the same aggregate hydrates a SECOND instance:
1853
1889
  * double harvest, double `markPersisted`). Use it only for writes no
1854
1890
  * repository covers, pair it with manual enrollment, and prefer
1855
1891
  * adding a repository method whenever one could exist.
1856
1892
  */
1857
1893
  readonly rawTransaction: TCtx;
1858
1894
  readonly session: UnitOfWorkSession<Evt>;
1895
+ /**
1896
+ * The cooperative-cancellation signal passed to {@link UnitOfWork.run},
1897
+ * or `undefined` if none was given. Poll `signal?.aborted` between
1898
+ * steps of a long operation and throw `signal.reason` to bail out; the
1899
+ * throw rolls the unit of work back like any other callback error. The
1900
+ * kit does not interrupt an in-flight query for you: actual query
1901
+ * cancellation depends on the `TransactionScope` honoring the signal.
1902
+ */
1903
+ readonly signal?: AbortSignal;
1904
+ }
1905
+ /** Options for a single {@link UnitOfWork.run} call. */
1906
+ interface RunOptions {
1907
+ /**
1908
+ * Cooperative-cancellation signal. If already aborted, `run()` rejects
1909
+ * with the signal's `reason` before opening a transaction. Otherwise it
1910
+ * is exposed on the context (poll `context.signal`) and forwarded to the
1911
+ * `TransactionScope`. Use `AbortSignal.timeout(ms)` for a deadline.
1912
+ */
1913
+ readonly signal?: AbortSignal;
1859
1914
  }
1860
1915
  /**
1861
1916
  * Per-repository factory map: for each key of `TRepos`, a function
@@ -1958,7 +2013,7 @@ declare class UnitOfWork<Evt extends AnyDomainEvent, TCtx, TRepos extends Record
1958
2013
  * run the post-commit lifecycle (markPersisted, publish) for every
1959
2014
  * enrolled aggregate. Returns the callback's result.
1960
2015
  */
1961
- run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R>): Promise<R>;
2016
+ run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R>, options?: RunOptions): Promise<R>;
1962
2017
  private buildRepositories;
1963
2018
  }
1964
2019
 
@@ -2151,7 +2206,7 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
2151
2206
  * For production, back the outbox with a transactional store so the
2152
2207
  * outbox row participates in the same transaction as the aggregate
2153
2208
  * write (see `TransactionScope` + `withCommit`). This class lives in
2154
- * memory only: events are lost on process restart and, sharper:
2209
+ * memory only: events are lost on process restart. Sharper still:
2155
2210
  * events `add()`ed inside a transaction that later rolls back are NOT
2156
2211
  * removed (the Map knows nothing about your scope's rollback). Tests
2157
2212
  * that assert rollback purity need an outbox that participates in the
@@ -2190,8 +2245,8 @@ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt>
2190
2245
  * the deleted-cannot-be-resaved gate. Ids stay branded (`TId extends
2191
2246
  * Id<string>`) end-to-end.
2192
2247
  *
2193
- * Implementing this interface is optional the `UnitOfWork` registry
2194
- * is structurally typed but it is the single source of truth the
2248
+ * Implementing this interface is optional (the `UnitOfWork` registry
2249
+ * is structurally typed), but it is the single source of truth the
2195
2250
  * guide's examples and the repository contract test suite
2196
2251
  * (`@shirudo/ddd-kit/testing`, whose `ContractRepository` is the
2197
2252
  * minimal structural subset of this shape) are written against.
@@ -2377,6 +2432,94 @@ interface IQueryableRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<
2377
2432
  find(filter: TFilter): Promise<TAgg[]>;
2378
2433
  }
2379
2434
 
2435
+ /**
2436
+ * Tuning for {@link RetryingTransactionScope}. All fields are optional;
2437
+ * the defaults suit optimistic-concurrency retries (a handful of writers
2438
+ * racing one aggregate), not high-fan-out hot-row contention.
2439
+ */
2440
+ interface RetryPolicy {
2441
+ /** Total tries, including the first. Default `3` (1 initial + 2 retries). */
2442
+ maxAttempts?: number;
2443
+ /** First backoff delay; doubles each retry. Default `50`ms. */
2444
+ baseDelayMs?: number;
2445
+ /** Ceiling for the backoff delay. Default `1000`ms. */
2446
+ maxDelayMs?: number;
2447
+ /**
2448
+ * Classifier deciding whether an error is worth retrying. Default
2449
+ * {@link someChainRetryable} (walks the cause chain for the loose
2450
+ * `retryable === true` marker, so `ConcurrencyConflictError` matches
2451
+ * even when an adapter wraps it). Override to add driver-specific
2452
+ * serialization codes (Postgres 40001, MySQL 1213, SQLite SQLITE_BUSY)
2453
+ * that your adapter has not mapped to a retryable kit error.
2454
+ */
2455
+ isRetryable?: (error: unknown) => boolean;
2456
+ /** Observer fired before each backoff wait (logging / metrics). */
2457
+ onRetry?: (info: {
2458
+ attempt: number;
2459
+ error: unknown;
2460
+ delayMs: number;
2461
+ }) => void;
2462
+ /** Backoff wait. Default an abortable `setTimeout`. Injectable for tests. */
2463
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
2464
+ /** Jitter source in `[0, 1)`. Default `Math.random`. Injectable for tests. */
2465
+ random?: () => number;
2466
+ }
2467
+ /**
2468
+ * Backoff delay for the attempt that just failed (1-based): exponential
2469
+ * (`baseDelayMs * 2^(attempt-1)`), capped at `maxDelayMs`, then a +/-20%
2470
+ * jitter band (`* random(0.8, 1.2)`) applied and re-clamped to the cap.
2471
+ * Pure and deterministic given `random`. Result is never negative.
2472
+ *
2473
+ * @internal Exported only so it can be unit-tested directly; not part of
2474
+ * the supported public API and may change without a major version.
2475
+ */
2476
+ declare function computeBackoffDelay(attempt: number, opts: {
2477
+ baseDelayMs: number;
2478
+ maxDelayMs: number;
2479
+ random: () => number;
2480
+ }): number;
2481
+ /**
2482
+ * A {@link TransactionScope} that retries its inner scope on transient
2483
+ * failures with exponential backoff and jitter. Compose it transparently:
2484
+ *
2485
+ * ```ts
2486
+ * const scope = new RetryingTransactionScope(drizzleScope, { maxAttempts: 5 });
2487
+ * const uow = new UnitOfWork({ scope, outbox, repositories });
2488
+ * ```
2489
+ *
2490
+ * **Retries the transaction only.** Each attempt re-invokes the inner
2491
+ * `transactional` with a fresh transaction, so the work callback must be
2492
+ * reload-safe (load aggregates via `getById` inside it, never capture an
2493
+ * aggregate from a previous attempt) and free of non-transactional side
2494
+ * effects before commit. `withCommit` publishes AFTER the commit, so the
2495
+ * in-process publish is outside the retried region and never duplicated;
2496
+ * publish failures are handled by `onPublishError`, not retried here.
2497
+ *
2498
+ * **Classification is by error, not by guesswork.** Only errors the
2499
+ * `isRetryable` predicate accepts are retried; everything else (a
2500
+ * `DomainError`, `EventHarvestError`, `UnenrolledChangesError`,
2501
+ * `DuplicateAggregateError`, a non-Error throw) surfaces immediately.
2502
+ * After `maxAttempts` the last error is rethrown unchanged, so a caller
2503
+ * can still match `ConcurrencyConflictError` and map it to HTTP 409.
2504
+ *
2505
+ * **Cancellation.** The `AbortSignal` from `transactional` options is
2506
+ * checked before each attempt and aborts the backoff wait, so an
2507
+ * `AbortSignal.timeout(ms)` bounds total elapsed time (there is
2508
+ * deliberately no separate max-elapsed knob).
2509
+ */
2510
+ declare class RetryingTransactionScope<TCtx> implements TransactionScope<TCtx> {
2511
+ private readonly inner;
2512
+ private readonly maxAttempts;
2513
+ private readonly baseDelayMs;
2514
+ private readonly maxDelayMs;
2515
+ private readonly isRetryable;
2516
+ private readonly sleep;
2517
+ private readonly random;
2518
+ private readonly onRetry?;
2519
+ constructor(inner: TransactionScope<TCtx>, policy?: RetryPolicy);
2520
+ transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
2521
+ }
2522
+
2380
2523
  type VO<T> = Readonly<T>;
2381
2524
  /**
2382
2525
  * Deep freezes an object and all its nested properties recursively, then
@@ -2649,4 +2792,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2649
2792
  */
2650
2793
  declare function voValidated<T>(t: T, validate: (issues: ValidationError, value: T) => void, message?: string): Result<VO<T>, ValidationError>;
2651
2794
 
2652
- export { type AggregateClass, type AggregateConfig, AggregateRoot, AggregateSnapshot, AnyDomainEvent, type Command, CommandBus, type CommandHandler, CommitError, CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, Entity, type EventBus, EventBusImpl, type EventHandler, EventSourcedAggregate, IAggregateRoot, type ICommandBus, type IEntity, IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IUnitOfWorkRepository, type IValueObject, Id, type Identifiable, IdentityMap, InMemoryOutbox, InfrastructureError, NestedUnitOfWorkError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type RepositoryFactories, RollbackError, TransactionClosedError, type TransactionScope, UnitOfWork, type UnitOfWorkContext, type UnitOfWorkDeps, type UnitOfWorkSession, type VO, ValueObject, Version, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, removeEntityById, replaceEntityById, sameEntity, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit };
2795
+ export { type AggregateClass, type AggregateConfig, AggregateRoot, AggregateSnapshot, AnyDomainEvent, type Command, CommandBus, type CommandHandler, CommitError, CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, Entity, type EventBus, EventBusImpl, type EventHandler, EventSourcedAggregate, IAggregateRoot, type ICommandBus, type IEntity, IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IUnitOfWorkRepository, type IValueObject, Id, type Identifiable, IdentityMap, InMemoryOutbox, InfrastructureError, NestedUnitOfWorkError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type RepositoryFactories, type RetryPolicy, RetryingTransactionScope, RollbackError, type RunOptions, TransactionClosedError, type TransactionScope, type TransactionalOptions, UnitOfWork, type UnitOfWorkContext, type UnitOfWorkDeps, type UnitOfWorkSession, type VO, ValueObject, Version, computeBackoffDelay, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, removeEntityById, replaceEntityById, sameEntity, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit };