@shirudo/ddd-kit 1.2.0 → 2.0.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ok, err } from '@shirudo/result';
2
- import { BaseError, ValidationError } from '@shirudo/base-error';
2
+ import { BaseError, someChainRetryable, ValidationError } from '@shirudo/base-error';
3
3
 
4
4
  var __defProp = Object.defineProperty;
5
5
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
@@ -711,13 +711,6 @@ function createDomainEvent(type, payload, options) {
711
711
  return deepFreeze(event);
712
712
  }
713
713
  __name(createDomainEvent, "createDomainEvent");
714
- function createDomainEventWithMetadata(type, payload, metadata, options) {
715
- return createDomainEvent(type, payload, {
716
- ...options,
717
- metadata
718
- });
719
- }
720
- __name(createDomainEventWithMetadata, "createDomainEventWithMetadata");
721
714
  function copyMetadata(sourceEvent, additionalMetadata) {
722
715
  return {
723
716
  ...sourceEvent.metadata ?? {},
@@ -944,7 +937,7 @@ var BaseAggregate = class extends Entity {
944
937
  * `changedKeys`/`hasChanges`. An override that skips `super` leaves
945
938
  * that baseline uncaptured: `changedKeys` permanently reports ALL
946
939
  * keys and `hasChanges` never returns `false`, so a partial-write
947
- * repository silently degrades to full writes on every save on top
940
+ * repository silently degrades to full writes on every save, on top
948
941
  * of the broken version sync.
949
942
  *
950
943
  * @param version - The version the row currently holds in the DB
@@ -1237,7 +1230,7 @@ var AggregateRoot = class extends BaseAggregate {
1237
1230
  * If you override this, call `super.markRestored(version)` FIRST:
1238
1231
  * skipping it leaves the baseline uncaptured, so `changedKeys`
1239
1232
  * permanently reports ALL keys and `hasChanges` never returns `false`
1240
- * partial-write repositories silently degrade to full writes on
1233
+ * (partial-write repositories silently degrade to full writes), on
1241
1234
  * top of breaking version sync.
1242
1235
  */
1243
1236
  markRestored(version) {
@@ -1260,7 +1253,7 @@ var AggregateRoot = class extends BaseAggregate {
1260
1253
  * **How it works.** `setState()` replaces state immutably and the
1261
1254
  * state object is shallow-frozen, so unchanged top-level sub-objects
1262
1255
  * keep reference identity across mutations. The diff is therefore a
1263
- * shallow per-key `!==` against the baseline reference O(top-level
1256
+ * shallow per-key `!==` against the baseline reference: O(top-level
1264
1257
  * keys), no proxies, no deep diff. A key also counts as dirty when its
1265
1258
  * *presence* differs (added or removed, even with an `undefined`
1266
1259
  * value). Computed fresh on every access (a new `Set` each time), so
@@ -1273,12 +1266,12 @@ var AggregateRoot = class extends BaseAggregate {
1273
1266
  * class-instance `TState` mutated through its own methods defeats
1274
1267
  * tracking entirely (the reference never changes). A keyless `TState`
1275
1268
  * (primitive, bare `Date`) has no keys to report, so `changedKeys`
1276
- * stays empty for it use {@link hasChanges}, whose reference
1269
+ * stays empty for it; use {@link hasChanges}, whose reference
1277
1270
  * fallback covers keyless states. A deep-equal but newly-referenced
1278
1271
  * value reports a false POSITIVE (harmless extra write); under the
1279
1272
  * contract above there are no false negatives.
1280
1273
  *
1281
- * Granularity is per top-level key table-granular, not row-granular:
1274
+ * Granularity is per top-level key, table-granular, not row-granular:
1282
1275
  * a dirty collection key means "this child table changed", not which
1283
1276
  * rows. `EventSourcedAggregate` deliberately has no `changedKeys`;
1284
1277
  * its `pendingEvents` are the change record.
@@ -1293,16 +1286,16 @@ var AggregateRoot = class extends BaseAggregate {
1293
1286
  * Safe skip signal: `false` only when there is genuinely nothing to
1294
1287
  * persist or flush. `true` when the aggregate has never been
1295
1288
  * persisted, the version moved past `persistedVersion`, there are
1296
- * unflushed {@link pendingEvents}, any state key is dirty, or for
1289
+ * unflushed {@link pendingEvents}, any state key is dirty, or, for
1297
1290
  * keyless states the per-key diff cannot see (primitive `TState`,
1298
- * zero-own-key objects like a bare `Date`) the state reference
1291
+ * zero-own-key objects like a bare `Date`), the state reference
1299
1292
  * changed since the baseline.
1300
1293
  *
1301
1294
  * The version clause is deliberate: `setState({...state}, true)` with
1302
1295
  * identical per-key values yields empty {@link changedKeys} but a
1303
1296
  * bumped version. If a repository skipped `save()` on a state-only
1304
1297
  * check, `withCommit` would still call `markPersisted(version)` after
1305
- * commit, desyncing `persistedVersion` from the DB row and the next
1298
+ * commit, desyncing `persistedVersion` from the DB row; and the next
1306
1299
  * uncontended save would throw a false `ConcurrencyConflictError`.
1307
1300
  *
1308
1301
  * The pending-events clause covers the sanctioned decoupled
@@ -1447,6 +1440,28 @@ var MissingHandlerError = class extends BaseError {
1447
1440
  __name(this, "MissingHandlerError");
1448
1441
  }
1449
1442
  };
1443
+ var EventHarvestError = class extends BaseError {
1444
+ constructor(message, eventType) {
1445
+ super(message, void 0, { name: "EventHarvestError" });
1446
+ this.eventType = eventType;
1447
+ }
1448
+ static {
1449
+ __name(this, "EventHarvestError");
1450
+ }
1451
+ };
1452
+ var UnenrolledChangesError = class extends BaseError {
1453
+ constructor(aggregateId) {
1454
+ super(
1455
+ `Aggregate ${aggregateId} was loaded in this unit of work and has pending events, but was never enrolled (no save), so its events would be silently dropped. Call repository.save(aggregate), and ensure save() calls session.enrollSaved before the row write.`,
1456
+ void 0,
1457
+ { name: "UnenrolledChangesError" }
1458
+ );
1459
+ this.aggregateId = aggregateId;
1460
+ }
1461
+ static {
1462
+ __name(this, "UnenrolledChangesError");
1463
+ }
1464
+ };
1450
1465
  var AggregateDeletedError = class extends BaseError {
1451
1466
  constructor(aggregateId) {
1452
1467
  super(
@@ -1461,52 +1476,38 @@ var AggregateDeletedError = class extends BaseError {
1461
1476
  }
1462
1477
  };
1463
1478
  var AggregateNotFoundError = class extends InfrastructureError {
1464
- constructor(aggregateType, id, cause) {
1465
- super(`Aggregate not found: ${aggregateType}(${id})`, cause, {
1466
- name: "AggregateNotFoundError"
1467
- });
1468
- this.aggregateType = aggregateType;
1469
- this.id = id;
1470
- this.withUserMessage(
1471
- `The requested ${aggregateType} could not be found.`
1472
- );
1473
- }
1474
1479
  static {
1475
1480
  __name(this, "AggregateNotFoundError");
1476
1481
  }
1477
- };
1478
- var DuplicateAggregateError = class extends InfrastructureError {
1479
- constructor(aggregateType, aggregateId, cause) {
1482
+ aggregateType;
1483
+ id;
1484
+ constructor(options) {
1480
1485
  super(
1481
- `Duplicate aggregate: ${aggregateType}(${aggregateId}) already exists`,
1482
- cause,
1483
- { name: "DuplicateAggregateError" }
1484
- );
1485
- this.aggregateType = aggregateType;
1486
- this.aggregateId = aggregateId;
1487
- this.withUserMessage(
1488
- `This ${aggregateType} already exists. It may have been created by a concurrent request.`
1486
+ `Aggregate not found: ${options.aggregateType}(${options.id})`,
1487
+ options.cause,
1488
+ { name: "AggregateNotFoundError" }
1489
1489
  );
1490
+ this.aggregateType = options.aggregateType;
1491
+ this.id = options.id;
1490
1492
  }
1493
+ };
1494
+ var DuplicateAggregateError = class extends InfrastructureError {
1491
1495
  static {
1492
1496
  __name(this, "DuplicateAggregateError");
1493
1497
  }
1494
- };
1495
- var ConcurrencyConflictError = class extends InfrastructureError {
1496
- constructor(aggregateType, aggregateId, expectedVersion, actualVersion, cause) {
1498
+ aggregateType;
1499
+ aggregateId;
1500
+ constructor(options) {
1497
1501
  super(
1498
- `Concurrency conflict on ${aggregateType}(${aggregateId}): expected version ${expectedVersion}, actual ${actualVersion}`,
1499
- cause,
1500
- { name: "ConcurrencyConflictError" }
1501
- );
1502
- this.aggregateType = aggregateType;
1503
- this.aggregateId = aggregateId;
1504
- this.expectedVersion = expectedVersion;
1505
- this.actualVersion = actualVersion;
1506
- this.withUserMessage(
1507
- "This resource was updated by another request. Please reload and try again."
1502
+ `Duplicate aggregate: ${options.aggregateType}(${options.aggregateId}) already exists`,
1503
+ options.cause,
1504
+ { name: "DuplicateAggregateError" }
1508
1505
  );
1506
+ this.aggregateType = options.aggregateType;
1507
+ this.aggregateId = options.aggregateId;
1509
1508
  }
1509
+ };
1510
+ var ConcurrencyConflictError = class extends InfrastructureError {
1510
1511
  static {
1511
1512
  __name(this, "ConcurrencyConflictError");
1512
1513
  }
@@ -1516,6 +1517,21 @@ var ConcurrencyConflictError = class extends InfrastructureError {
1516
1517
  * the use case, and retry on this exception.
1517
1518
  */
1518
1519
  retryable = true;
1520
+ aggregateType;
1521
+ aggregateId;
1522
+ expectedVersion;
1523
+ actualVersion;
1524
+ constructor(options) {
1525
+ super(
1526
+ `Concurrency conflict on ${options.aggregateType}(${options.aggregateId}): expected version ${options.expectedVersion}, actual ${options.actualVersion}`,
1527
+ options.cause,
1528
+ { name: "ConcurrencyConflictError" }
1529
+ );
1530
+ this.aggregateType = options.aggregateType;
1531
+ this.aggregateId = options.aggregateId;
1532
+ this.expectedVersion = options.expectedVersion;
1533
+ this.actualVersion = options.actualVersion;
1534
+ }
1519
1535
  };
1520
1536
 
1521
1537
  // src/aggregate/event-sourced-aggregate.ts
@@ -1654,6 +1670,10 @@ var CommandBus = class {
1654
1670
  __name(this, "CommandBus");
1655
1671
  }
1656
1672
  handlers = /* @__PURE__ */ new Map();
1673
+ errorMapper;
1674
+ constructor(...args) {
1675
+ this.errorMapper = args[0]?.errorMapper ?? describeThrown;
1676
+ }
1657
1677
  register(commandType, handler) {
1658
1678
  if (this.handlers.has(commandType)) {
1659
1679
  throw new Error(
@@ -1665,18 +1685,31 @@ var CommandBus = class {
1665
1685
  async execute(command) {
1666
1686
  const handler = this.handlers.get(command.type);
1667
1687
  if (!handler) {
1668
- return err(`No handler registered for command type: ${command.type}`);
1688
+ return err(
1689
+ this.errorMapper(
1690
+ new Error(`No handler registered for command type: ${command.type}`)
1691
+ )
1692
+ );
1669
1693
  }
1670
1694
  try {
1671
1695
  return await handler(command);
1672
1696
  } catch (error) {
1673
- return err(describeThrown(error));
1697
+ return err(this.errorMapper(error));
1674
1698
  }
1675
1699
  }
1676
1700
  };
1677
1701
 
1702
+ // src/utils/abort.ts
1703
+ function abortReason(signal, fallbackMessage) {
1704
+ return signal.reason ?? new Error(fallbackMessage);
1705
+ }
1706
+ __name(abortReason, "abortReason");
1707
+
1678
1708
  // src/app/handler.ts
1679
1709
  async function withCommit(deps, fn) {
1710
+ if (deps.signal?.aborted) {
1711
+ throw abortReason(deps.signal, "withCommit aborted before opening a transaction");
1712
+ }
1680
1713
  const { result, aggregates, deleted, events } = await deps.scope.transactional(
1681
1714
  async (ctx) => {
1682
1715
  const fnResult = await fn(ctx);
@@ -1690,8 +1723,9 @@ async function withCommit(deps, fn) {
1690
1723
  });
1691
1724
  }
1692
1725
  if (event.aggregateVersion > agg.version) {
1693
- throw new Error(
1694
- `withCommit: event "${event.type}" carries a pre-set aggregateVersion (${event.aggregateVersion}) AHEAD of its aggregate's commit version (${agg.version}). A stale-or-copied pre-set would advance consumer idempotency watermarks past real history; remove the manual aggregateVersion or correct it.`
1726
+ throw new EventHarvestError(
1727
+ `withCommit: event "${event.type}" carries a pre-set aggregateVersion (${event.aggregateVersion}) AHEAD of its aggregate's commit version (${agg.version}). A stale-or-copied pre-set would advance consumer idempotency watermarks past real history; remove the manual aggregateVersion or correct it.`,
1728
+ event.type
1695
1729
  );
1696
1730
  }
1697
1731
  return event;
@@ -1702,10 +1736,11 @@ async function withCommit(deps, fn) {
1702
1736
  if (!event.aggregateId) missing.push("aggregateId");
1703
1737
  if (!event.aggregateType) missing.push("aggregateType");
1704
1738
  if (missing.length > 0) {
1705
- throw new Error(
1739
+ throw new EventHarvestError(
1706
1740
  `withCommit: event "${event.type}" is missing ${missing.join(
1707
1741
  " and "
1708
- )}. Use this.recordEvent(type, payload) inside aggregate methods instead of createDomainEvent(...); recordEvent auto-injects aggregateId and aggregateType. Outbox dispatchers and projection handlers rely on these fields for routing.`
1742
+ )}. Use this.recordEvent(type, payload) inside aggregate methods instead of createDomainEvent(...); recordEvent auto-injects aggregateId and aggregateType. Outbox dispatchers and projection handlers rely on these fields for routing.`,
1743
+ event.type
1709
1744
  );
1710
1745
  }
1711
1746
  }
@@ -1718,7 +1753,8 @@ async function withCommit(deps, fn) {
1718
1753
  deleted: new Set(fnResult.deleted ?? []),
1719
1754
  events: harvested
1720
1755
  };
1721
- }
1756
+ },
1757
+ { signal: deps.signal }
1722
1758
  );
1723
1759
  for (const agg of aggregates) {
1724
1760
  try {
@@ -1751,6 +1787,10 @@ var IdentityMap = class {
1751
1787
  }
1752
1788
  _stores = /* @__PURE__ */ new Map();
1753
1789
  _deleted = /* @__PURE__ */ new Map();
1790
+ // pendingEvents length captured when an instance was first registered
1791
+ // (load time), so the unit of work can tell events RECORDED AFTER load
1792
+ // apart from a "dirty" reconstitution that already carried events.
1793
+ _pendingAtRegistration = /* @__PURE__ */ new WeakMap();
1754
1794
  /** The cached instance for type+id, or `undefined` (also after {@link delete}). */
1755
1795
  get(type, id) {
1756
1796
  return this._stores.get(type)?.get(id);
@@ -1762,7 +1802,7 @@ var IdentityMap = class {
1762
1802
  /**
1763
1803
  * Whether type+id was {@link delete}d in this unit of work. The
1764
1804
  * read path checks this BEFORE hydrating and returns `null`, so
1765
- * "deleted in this operation" reads uniformly as not-found
1805
+ * "deleted in this operation" reads uniformly as not-found,
1766
1806
  * regardless of whether the repository's physical delete already
1767
1807
  * removed the row or is deferred within the transaction. Without
1768
1808
  * the check, a read-only probe of a deleted aggregate would crash
@@ -1801,6 +1841,34 @@ var IdentityMap = class {
1801
1841
  );
1802
1842
  }
1803
1843
  store.set(id, aggregate);
1844
+ if (aggregate !== null && typeof aggregate === "object" && !this._pendingAtRegistration.has(aggregate)) {
1845
+ const pending = pendingEventsOf(aggregate);
1846
+ if (pending) {
1847
+ this._pendingAtRegistration.set(aggregate, pending.length);
1848
+ }
1849
+ }
1850
+ }
1851
+ /**
1852
+ * Registered instances that have recorded MORE pending events than they
1853
+ * carried when first registered (loaded). Used by the unit of work's
1854
+ * end-of-run guard: an aggregate that gained events after load but was
1855
+ * never enrolled would silently drop them. A read-only load, or a
1856
+ * reconstitution that already carried events, shows no increase and is
1857
+ * not reported.
1858
+ */
1859
+ instancesWithNewPendingEvents() {
1860
+ const result = [];
1861
+ for (const store of this._stores.values()) {
1862
+ for (const instance of store.values()) {
1863
+ const pending = pendingEventsOf(instance);
1864
+ if (!pending) continue;
1865
+ const atRegistration = this._pendingAtRegistration.get(instance) ?? 0;
1866
+ if (pending.length > atRegistration) {
1867
+ result.push(instance);
1868
+ }
1869
+ }
1870
+ }
1871
+ return result;
1804
1872
  }
1805
1873
  /**
1806
1874
  * Removes the entry for type+id and records a tombstone: subsequent
@@ -1824,6 +1892,12 @@ var IdentityMap = class {
1824
1892
  this._deleted.clear();
1825
1893
  }
1826
1894
  };
1895
+ function pendingEventsOf(value) {
1896
+ if (value === null || typeof value !== "object") return void 0;
1897
+ const pending = value.pendingEvents;
1898
+ return Array.isArray(pending) ? pending : void 0;
1899
+ }
1900
+ __name(pendingEventsOf, "pendingEventsOf");
1827
1901
 
1828
1902
  // src/app/unit-of-work.ts
1829
1903
  var NestedUnitOfWorkError = class extends BaseError {
@@ -1857,7 +1931,7 @@ var CommitError = class extends InfrastructureError {
1857
1931
  }
1858
1932
  constructor(cause) {
1859
1933
  super(
1860
- "Unit of work failed after the work callback completed: the event harvest, outbox write, or transaction commit rejected. The transaction did not commit; see cause.",
1934
+ "Unit of work failed after the work callback completed: the outbox write or the transaction commit rejected. The transaction did not commit; this failure may be transient, inspect the cause (e.g. someChainRetryable) before retrying.",
1861
1935
  cause,
1862
1936
  { name: "CommitError" }
1863
1937
  );
@@ -1890,7 +1964,10 @@ var UnitOfWork = class {
1890
1964
  * run the post-commit lifecycle (markPersisted, publish) for every
1891
1965
  * enrolled aggregate. Returns the callback's result.
1892
1966
  */
1893
- async run(work) {
1967
+ async run(work, options) {
1968
+ if (options?.signal?.aborted) {
1969
+ throw abortReason(options.signal, "UnitOfWork.run aborted before opening a transaction");
1970
+ }
1894
1971
  if (this._active) {
1895
1972
  throw new NestedUnitOfWorkError();
1896
1973
  }
@@ -1905,7 +1982,8 @@ var UnitOfWork = class {
1905
1982
  outbox: this.deps.outbox,
1906
1983
  bus: this.deps.bus,
1907
1984
  scope: this.deps.scope,
1908
- onPublishError: this.deps.onPublishError
1985
+ onPublishError: this.deps.onPublishError,
1986
+ signal: options?.signal
1909
1987
  },
1910
1988
  async (tx) => {
1911
1989
  session?.close();
@@ -1915,9 +1993,10 @@ var UnitOfWork = class {
1915
1993
  workThrew = false;
1916
1994
  workError = void 0;
1917
1995
  const repositories = this.buildRepositories(tx, s);
1918
- const context = makeContext(repositories, tx, s);
1996
+ const context = makeContext(repositories, tx, s, options?.signal);
1919
1997
  try {
1920
1998
  const result = await work(context);
1999
+ s.assertAllChangesEnrolled();
1921
2000
  workCompleted = true;
1922
2001
  const aggregates = s.enrolledAggregates;
1923
2002
  const deleted = s.deletedAggregates;
@@ -1938,6 +2017,10 @@ var UnitOfWork = class {
1938
2017
  throw new RollbackError(workError, error);
1939
2018
  }
1940
2019
  if (workCompleted) {
2020
+ const harvestError = findHarvestErrorInChain(error);
2021
+ if (harvestError) {
2022
+ throw harvestError;
2023
+ }
1941
2024
  throw new CommitError(error);
1942
2025
  }
1943
2026
  throw error;
@@ -1987,6 +2070,24 @@ var Session = class {
1987
2070
  );
1988
2071
  this._enrolled.add(aggregate);
1989
2072
  }
2073
+ /**
2074
+ * End-of-run safety net: a loaded aggregate (registered in the identity
2075
+ * map via `getById`) that carries pending events but was never enrolled
2076
+ * is almost certainly a forgotten `save()` / `enrollSaved`, whose events
2077
+ * would otherwise be silently dropped. Convert that silent loss into a
2078
+ * loud, rolling-back {@link UnenrolledChangesError}. Only sees loaded
2079
+ * aggregates; a freshly created one that was never enrolled is invisible
2080
+ * to the kit (the contract test suite remains the full mitigation).
2081
+ */
2082
+ assertAllChangesEnrolled() {
2083
+ for (const instance of this._identityMap.instancesWithNewPendingEvents()) {
2084
+ if (this._enrolled.has(instance) || this._deleted.has(instance)) {
2085
+ continue;
2086
+ }
2087
+ const id = instance.id;
2088
+ throw new UnenrolledChangesError(String(id));
2089
+ }
2090
+ }
1990
2091
  get enrolledAggregates() {
1991
2092
  return [...this._enrolled];
1992
2093
  }
@@ -2003,7 +2104,7 @@ var Session = class {
2003
2104
  }
2004
2105
  }
2005
2106
  };
2006
- function makeContext(repositories, transaction, session) {
2107
+ function makeContext(repositories, transaction, session, signal) {
2007
2108
  return {
2008
2109
  get repositories() {
2009
2110
  session.assertOpen("context.repositories");
@@ -2013,10 +2114,32 @@ function makeContext(repositories, transaction, session) {
2013
2114
  session.assertOpen("context.rawTransaction");
2014
2115
  return transaction;
2015
2116
  },
2016
- session
2117
+ session,
2118
+ // The caller's own signal: exposed directly, not gated by
2119
+ // assertOpen, so polling `aborted` after close stays harmless.
2120
+ signal
2017
2121
  };
2018
2122
  }
2019
2123
  __name(makeContext, "makeContext");
2124
+ function findHarvestErrorInChain(error) {
2125
+ const seen = /* @__PURE__ */ new Set();
2126
+ let current = error;
2127
+ while (current !== null && typeof current === "object" && !seen.has(current)) {
2128
+ seen.add(current);
2129
+ if (current instanceof EventHarvestError) {
2130
+ return current;
2131
+ }
2132
+ let next;
2133
+ try {
2134
+ next = current.cause;
2135
+ } catch {
2136
+ return void 0;
2137
+ }
2138
+ current = next;
2139
+ }
2140
+ return void 0;
2141
+ }
2142
+ __name(findHarvestErrorInChain, "findHarvestErrorInChain");
2020
2143
  function causeChainContains(error, target) {
2021
2144
  if (target === void 0 || target === null) {
2022
2145
  return false;
@@ -2044,6 +2167,10 @@ var QueryBus = class {
2044
2167
  __name(this, "QueryBus");
2045
2168
  }
2046
2169
  handlers = /* @__PURE__ */ new Map();
2170
+ errorMapper;
2171
+ constructor(...args) {
2172
+ this.errorMapper = args[0]?.errorMapper ?? describeThrown;
2173
+ }
2047
2174
  register(queryType, handler) {
2048
2175
  if (this.handlers.has(queryType)) {
2049
2176
  throw new Error(
@@ -2055,13 +2182,17 @@ var QueryBus = class {
2055
2182
  async execute(query) {
2056
2183
  const handler = this.handlers.get(query.type);
2057
2184
  if (!handler) {
2058
- return err(`No handler registered for query type: ${query.type}`);
2185
+ return err(
2186
+ this.errorMapper(
2187
+ new Error(`No handler registered for query type: ${query.type}`)
2188
+ )
2189
+ );
2059
2190
  }
2060
2191
  try {
2061
2192
  const result = await handler(query);
2062
2193
  return ok(result);
2063
2194
  } catch (error) {
2064
- return err(describeThrown(error));
2195
+ return err(this.errorMapper(error));
2065
2196
  }
2066
2197
  }
2067
2198
  async executeUnsafe(query) {
@@ -2103,7 +2234,7 @@ var EventBusImpl = class {
2103
2234
  once(eventType, options) {
2104
2235
  return new Promise((resolve, reject) => {
2105
2236
  if (options?.signal?.aborted) {
2106
- reject(options.signal.reason ?? new Error("EventBus.once aborted"));
2237
+ reject(abortReason(options.signal, "EventBus.once aborted"));
2107
2238
  return;
2108
2239
  }
2109
2240
  let timer;
@@ -2125,9 +2256,7 @@ var EventBusImpl = class {
2125
2256
  if (options?.signal) {
2126
2257
  abortListener = /* @__PURE__ */ __name(() => {
2127
2258
  cleanup();
2128
- reject(
2129
- options.signal.reason ?? new Error("EventBus.once aborted")
2130
- );
2259
+ reject(abortReason(options.signal, "EventBus.once aborted"));
2131
2260
  }, "abortListener");
2132
2261
  options.signal.addEventListener("abort", abortListener);
2133
2262
  }
@@ -2207,6 +2336,101 @@ var InMemoryOutbox = class {
2207
2336
  for (const id of dispatchIds) this.pending.delete(id);
2208
2337
  }
2209
2338
  };
2339
+ var DEFAULT_MAX_ATTEMPTS = 3;
2340
+ var DEFAULT_BASE_DELAY_MS = 50;
2341
+ var DEFAULT_MAX_DELAY_MS = 1e3;
2342
+ function computeBackoffDelay(attempt, opts) {
2343
+ const exponential = opts.baseDelayMs * 2 ** (attempt - 1);
2344
+ const capped = Math.min(opts.maxDelayMs, exponential);
2345
+ const jitter = 0.8 + opts.random() * 0.4;
2346
+ return Math.max(0, Math.min(opts.maxDelayMs, Math.round(capped * jitter)));
2347
+ }
2348
+ __name(computeBackoffDelay, "computeBackoffDelay");
2349
+ function assertNonNegativeFinite(field, value) {
2350
+ if (!Number.isFinite(value) || value < 0) {
2351
+ throw new Error(
2352
+ `RetryingTransactionScope: ${field} must be a non-negative finite number, got ${value}`
2353
+ );
2354
+ }
2355
+ }
2356
+ __name(assertNonNegativeFinite, "assertNonNegativeFinite");
2357
+ var ABORT_MESSAGE = "RetryingTransactionScope aborted";
2358
+ function defaultSleep(ms, signal) {
2359
+ return new Promise((resolve, reject) => {
2360
+ if (signal?.aborted) {
2361
+ reject(abortReason(signal, ABORT_MESSAGE));
2362
+ return;
2363
+ }
2364
+ let onAbort;
2365
+ const timer = setTimeout(() => {
2366
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
2367
+ resolve();
2368
+ }, ms);
2369
+ if (signal) {
2370
+ onAbort = /* @__PURE__ */ __name(() => {
2371
+ clearTimeout(timer);
2372
+ reject(abortReason(signal, ABORT_MESSAGE));
2373
+ }, "onAbort");
2374
+ signal.addEventListener("abort", onAbort, { once: true });
2375
+ }
2376
+ });
2377
+ }
2378
+ __name(defaultSleep, "defaultSleep");
2379
+ var RetryingTransactionScope = class {
2380
+ constructor(inner, policy = {}) {
2381
+ this.inner = inner;
2382
+ this.maxAttempts = policy.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
2383
+ this.baseDelayMs = policy.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
2384
+ this.maxDelayMs = policy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
2385
+ if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) {
2386
+ throw new Error(
2387
+ `RetryingTransactionScope: maxAttempts must be an integer >= 1, got ${this.maxAttempts}`
2388
+ );
2389
+ }
2390
+ assertNonNegativeFinite("baseDelayMs", this.baseDelayMs);
2391
+ assertNonNegativeFinite("maxDelayMs", this.maxDelayMs);
2392
+ this.isRetryable = policy.isRetryable ?? someChainRetryable;
2393
+ this.sleep = policy.sleep ?? defaultSleep;
2394
+ this.random = policy.random ?? Math.random;
2395
+ this.onRetry = policy.onRetry;
2396
+ }
2397
+ static {
2398
+ __name(this, "RetryingTransactionScope");
2399
+ }
2400
+ // Policy resolved and validated once at construction (a misconfigured
2401
+ // policy is a wiring bug and fails fast, never at run time).
2402
+ maxAttempts;
2403
+ baseDelayMs;
2404
+ maxDelayMs;
2405
+ isRetryable;
2406
+ sleep;
2407
+ random;
2408
+ onRetry;
2409
+ async transactional(fn, options) {
2410
+ const { maxAttempts, isRetryable, sleep } = this;
2411
+ const signal = options?.signal;
2412
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2413
+ if (signal?.aborted) {
2414
+ throw abortReason(signal, ABORT_MESSAGE);
2415
+ }
2416
+ try {
2417
+ return await this.inner.transactional(fn, options);
2418
+ } catch (error) {
2419
+ if (attempt === maxAttempts || !isRetryable(error)) {
2420
+ throw error;
2421
+ }
2422
+ const delayMs = computeBackoffDelay(attempt, {
2423
+ baseDelayMs: this.baseDelayMs,
2424
+ maxDelayMs: this.maxDelayMs,
2425
+ random: this.random
2426
+ });
2427
+ this.onRetry?.({ attempt, error, delayMs });
2428
+ await sleep(delayMs, signal);
2429
+ }
2430
+ }
2431
+ throw new Error("RetryingTransactionScope: exhausted without result");
2432
+ }
2433
+ };
2210
2434
  function voValidated(t, validate, message = "Validation failed") {
2211
2435
  const issues = new ValidationError(message);
2212
2436
  validate(issues, t);
@@ -2214,6 +2438,6 @@ function voValidated(t, validate, message = "Validation failed") {
2214
2438
  }
2215
2439
  __name(voValidated, "voValidated");
2216
2440
 
2217
- export { AggregateDeletedError, AggregateNotFoundError, AggregateRoot, CommandBus, CommitError, ConcurrencyConflictError, DomainError, DuplicateAggregateError, Entity, EventBusImpl, EventSourcedAggregate, IdentityMap, InMemoryOutbox, InfrastructureError, MissingHandlerError, NestedUnitOfWorkError, QueryBus, RollbackError, TransactionClosedError, UnitOfWork, 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, voValidated, voWithValidation, withClockFactory, withCommit, withEventIdFactory };
2441
+ export { AggregateDeletedError, AggregateNotFoundError, AggregateRoot, CommandBus, CommitError, ConcurrencyConflictError, DomainError, DuplicateAggregateError, Entity, EventBusImpl, EventHarvestError, EventSourcedAggregate, IdentityMap, InMemoryOutbox, InfrastructureError, MissingHandlerError, NestedUnitOfWorkError, QueryBus, RetryingTransactionScope, RollbackError, TransactionClosedError, UnenrolledChangesError, UnitOfWork, ValueObject, computeBackoffDelay, copyMetadata, createDomainEvent, deepEqual, deepEqualExcept, deepFreeze, deepOmit, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withClockFactory, withCommit, withEventIdFactory };
2218
2442
  //# sourceMappingURL=index.js.map
2219
2443
  //# sourceMappingURL=index.js.map