@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/README.md +2 -2
- package/dist/{aggregate-DclYgG_D.d.ts → aggregate-CePTINEt.d.ts} +93 -33
- package/dist/http.d.ts +36 -13
- package/dist/http.js +15 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +249 -52
- package/dist/index.js +295 -71
- package/dist/index.js.map +1 -1
- package/dist/presentation.d.ts +54 -0
- package/dist/presentation.js +45 -0
- package/dist/presentation.js.map +1 -0
- package/dist/testing.d.ts +24 -23
- package/dist/testing.js.map +1 -1
- package/package.json +18 -11
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`)
|
|
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
|
|
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
|
-
|
|
1479
|
-
constructor(
|
|
1482
|
+
aggregateType;
|
|
1483
|
+
id;
|
|
1484
|
+
constructor(options) {
|
|
1480
1485
|
super(
|
|
1481
|
-
`
|
|
1482
|
-
cause,
|
|
1483
|
-
{ name: "
|
|
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
|
-
|
|
1496
|
-
constructor(
|
|
1498
|
+
aggregateType;
|
|
1499
|
+
aggregateId;
|
|
1500
|
+
constructor(options) {
|
|
1497
1501
|
super(
|
|
1498
|
-
`
|
|
1499
|
-
cause,
|
|
1500
|
-
{ name: "
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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
|