@shirudo/ddd-kit 1.1.0 → 1.2.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 +21 -18
- package/dist/aggregate-DclYgG_D.d.ts +662 -0
- package/dist/http.d.ts +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +623 -655
- package/dist/index.js +554 -48
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +251 -0
- package/dist/testing.js +793 -0
- package/dist/testing.js.map +1 -0
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js.map +1 -1
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -474,7 +474,7 @@ __name(deepFreeze, "deepFreeze");
|
|
|
474
474
|
function cloneForVo(value, visited) {
|
|
475
475
|
if (typeof value === "function") {
|
|
476
476
|
throw new TypeError(
|
|
477
|
-
"vo() does not accept function values
|
|
477
|
+
"vo() does not accept function values: Value Objects are data, not behaviour"
|
|
478
478
|
);
|
|
479
479
|
}
|
|
480
480
|
if (value === null || typeof value !== "object") {
|
|
@@ -512,7 +512,7 @@ function cloneForVo(value, visited) {
|
|
|
512
512
|
}
|
|
513
513
|
if (tag === "[object Promise]" || tag === "[object WeakMap]" || tag === "[object WeakSet]") {
|
|
514
514
|
throw new TypeError(
|
|
515
|
-
`vo() cannot clone a ${tag.slice(8, -1)}
|
|
515
|
+
`vo() cannot clone a ${tag.slice(8, -1)}: Value Objects are plain data`
|
|
516
516
|
);
|
|
517
517
|
}
|
|
518
518
|
const builtInClone = structuredClone(obj);
|
|
@@ -575,7 +575,7 @@ var ValueObject = class {
|
|
|
575
575
|
/**
|
|
576
576
|
* Creates a new ValueObject.
|
|
577
577
|
* The properties are deep-cloned (prototype-preserving) and then deeply
|
|
578
|
-
* frozen
|
|
578
|
+
* frozen, so the caller's own object graph is never frozen or mutated,
|
|
579
579
|
* and later mutation of the input does not bleed into the value object.
|
|
580
580
|
*
|
|
581
581
|
* @param props - The properties of the value object
|
|
@@ -701,10 +701,11 @@ function createDomainEvent(type, payload, options) {
|
|
|
701
701
|
aggregateId: options?.aggregateId,
|
|
702
702
|
aggregateType: options?.aggregateType,
|
|
703
703
|
payload,
|
|
704
|
-
// Defensive copy
|
|
704
|
+
// Defensive copy: the event must not share the caller's live Date
|
|
705
705
|
// instance, or a later mutation of it would bleed into the event.
|
|
706
706
|
occurredAt: options?.occurredAt ? new Date(options.occurredAt.getTime()) : currentClockFactory(),
|
|
707
707
|
version: options?.version ?? 1,
|
|
708
|
+
aggregateVersion: options?.aggregateVersion,
|
|
708
709
|
metadata: options?.metadata
|
|
709
710
|
};
|
|
710
711
|
return deepFreeze(event);
|
|
@@ -758,7 +759,7 @@ var Entity = class {
|
|
|
758
759
|
/**
|
|
759
760
|
* Returns the current state of the entity.
|
|
760
761
|
*
|
|
761
|
-
* The state object is **shallowly frozen
|
|
762
|
+
* The state object is **shallowly frozen**: direct property writes
|
|
762
763
|
* (`entity.state.foo = …`) throw in strict mode, but writes to nested
|
|
763
764
|
* objects (`entity.state.address.zip = …`) bypass the freeze. For deep
|
|
764
765
|
* immutability either model nested data with `vo()` (which freezes
|
|
@@ -779,7 +780,7 @@ var Entity = class {
|
|
|
779
780
|
* **State ownership.** Plain-object and array states are shallow-copied
|
|
780
781
|
* before the freeze, so the caller's own object stays mutable. A CLASS
|
|
781
782
|
* INSTANCE passed as state is an ownership transfer: it is frozen
|
|
782
|
-
* in place (a copy would strip its prototype)
|
|
783
|
+
* in place (a copy would strip its prototype). Do not keep mutating
|
|
783
784
|
* the instance after handing it to the entity. The same contract
|
|
784
785
|
* applies to {@link setState}.
|
|
785
786
|
*/
|
|
@@ -799,7 +800,7 @@ var Entity = class {
|
|
|
799
800
|
* **⚠️ Must not read subclass instance fields via `this`.** The
|
|
800
801
|
* constructor calls `validateState(initialState)` BEFORE the subclass's
|
|
801
802
|
* field initializers run, so `this.someField` is `undefined` at that
|
|
802
|
-
* point
|
|
803
|
+
* point, a classic TypeScript/JavaScript constructor-ordering footgun.
|
|
803
804
|
* The `state` argument is the single source of truth; treat the method
|
|
804
805
|
* as pure with respect to `this`.
|
|
805
806
|
*
|
|
@@ -821,7 +822,7 @@ var Entity = class {
|
|
|
821
822
|
*
|
|
822
823
|
* Plain-object and array states are shallow-copied before the freeze
|
|
823
824
|
* (the caller's object stays mutable); a class-instance state is an
|
|
824
|
-
* ownership transfer and is frozen in place
|
|
825
|
+
* ownership transfer and is frozen in place; see the constructor.
|
|
825
826
|
*
|
|
826
827
|
* @param newState - The new state
|
|
827
828
|
*/
|
|
@@ -888,7 +889,7 @@ var BaseAggregate = class extends Entity {
|
|
|
888
889
|
*
|
|
889
890
|
* Distinct from {@link version}, which is the in-memory
|
|
890
891
|
* post-mutation value. Mutations bump `_version` but never touch
|
|
891
|
-
* `_persistedVersion
|
|
892
|
+
* `_persistedVersion`; that field only moves on {@link markRestored}
|
|
892
893
|
* (Post-Load) and {@link markPersisted} (Post-Save).
|
|
893
894
|
*/
|
|
894
895
|
_persistedVersion = void 0;
|
|
@@ -908,7 +909,7 @@ var BaseAggregate = class extends Entity {
|
|
|
908
909
|
}
|
|
909
910
|
/**
|
|
910
911
|
* Clears the pending-event list. Called by `markPersisted` after a
|
|
911
|
-
* successful write
|
|
912
|
+
* successful write: the events have been handed off to the outbox
|
|
912
913
|
* / event store and are no longer the aggregate's responsibility.
|
|
913
914
|
*/
|
|
914
915
|
clearPendingEvents() {
|
|
@@ -926,16 +927,26 @@ var BaseAggregate = class extends Entity {
|
|
|
926
927
|
this.setVersion(this._version + 1);
|
|
927
928
|
}
|
|
928
929
|
/**
|
|
929
|
-
* **Lifecycle marker
|
|
930
|
+
* **Lifecycle marker, Post-Load.** Syncs both `_version` and
|
|
930
931
|
* `_persistedVersion` to the DB-stored version. Used by
|
|
931
932
|
* `reconstitute(...)` factories to assemble an in-memory aggregate
|
|
932
933
|
* from a persisted row.
|
|
933
934
|
*
|
|
934
|
-
* Does NOT fire {@link onPersisted}
|
|
935
|
+
* Does NOT fire {@link onPersisted}; that hook has post-save
|
|
935
936
|
* semantics (metrics, audit, cache eviction), not post-load. The
|
|
936
937
|
* Factory-vs-Reconstitution distinction (Vernon §11) is honoured
|
|
937
938
|
* structurally: two separate markers, one for each transition.
|
|
938
939
|
*
|
|
940
|
+
* **If you override this, call `super.markRestored(version)` FIRST**,
|
|
941
|
+
* same discipline as {@link markPersisted}. The marker is load-bearing
|
|
942
|
+
* twice over: it syncs `version`/`persistedVersion`, and on
|
|
943
|
+
* `AggregateRoot` it also captures the dirty-tracking baseline for
|
|
944
|
+
* `changedKeys`/`hasChanges`. An override that skips `super` leaves
|
|
945
|
+
* that baseline uncaptured: `changedKeys` permanently reports ALL
|
|
946
|
+
* keys and `hasChanges` never returns `false`, so a partial-write
|
|
947
|
+
* repository silently degrades to full writes on every save — on top
|
|
948
|
+
* of the broken version sync.
|
|
949
|
+
*
|
|
939
950
|
* @param version - The version the row currently holds in the DB
|
|
940
951
|
*
|
|
941
952
|
* @example
|
|
@@ -952,14 +963,14 @@ var BaseAggregate = class extends Entity {
|
|
|
952
963
|
this._persistedVersion = version;
|
|
953
964
|
}
|
|
954
965
|
/**
|
|
955
|
-
* **Framework lifecycle method
|
|
966
|
+
* **Framework lifecycle method (`@sealed`).** Called by `withCommit`
|
|
956
967
|
* (or by your own orchestration code, after harvesting `pendingEvents`)
|
|
957
968
|
* to push the persisted version back into the in-memory aggregate and
|
|
958
969
|
* clear `pendingEvents`. TypeScript has no `final` keyword, but
|
|
959
970
|
* subclasses **should not** override this method directly.
|
|
960
971
|
*
|
|
961
972
|
* Overriding without calling `super.markPersisted(version)` silently
|
|
962
|
-
* leaks `pendingEvents
|
|
973
|
+
* leaks `pendingEvents`: the next `withCommit` will re-dispatch them
|
|
963
974
|
* through the outbox, double-emitting events. This bug has been hit
|
|
964
975
|
* in production by consumers; the {@link onPersisted} hook below is
|
|
965
976
|
* the safer extension point.
|
|
@@ -969,7 +980,7 @@ var BaseAggregate = class extends Entity {
|
|
|
969
980
|
* runs, then add your logic afterwards.
|
|
970
981
|
*
|
|
971
982
|
* @param version - The version assigned by the persistence layer
|
|
972
|
-
* @see onPersisted
|
|
983
|
+
* @see onPersisted, the safe extension point for subclasses
|
|
973
984
|
*/
|
|
974
985
|
markPersisted(version) {
|
|
975
986
|
this.markRestored(version);
|
|
@@ -977,13 +988,13 @@ var BaseAggregate = class extends Entity {
|
|
|
977
988
|
this.onPersisted(version);
|
|
978
989
|
}
|
|
979
990
|
/**
|
|
980
|
-
* Subclass extension point
|
|
991
|
+
* Subclass extension point: fires AFTER {@link markPersisted} has
|
|
981
992
|
* updated the version and cleared `pendingEvents`. Override this for
|
|
982
993
|
* post-persist logging, metrics, or cache-eviction without risk of
|
|
983
994
|
* breaking the framework's pendingEvents cleanup.
|
|
984
995
|
*
|
|
985
996
|
* The default implementation is a no-op. Subclasses do NOT need to
|
|
986
|
-
* call `super.onPersisted(version)
|
|
997
|
+
* call `super.onPersisted(version)`: there is nothing in the parent
|
|
987
998
|
* implementation to preserve.
|
|
988
999
|
*
|
|
989
1000
|
* **Observer contract: errors are swallowed.** `withCommit` invokes
|
|
@@ -995,7 +1006,7 @@ var BaseAggregate = class extends Entity {
|
|
|
995
1006
|
* **`onPersisted` deliberately receives only the version, not the
|
|
996
1007
|
* drained events.** Event-driven post-persist logic (aggregate-level
|
|
997
1008
|
* audit logging, per-event-type side effects) belongs in `EventBus`
|
|
998
|
-
* subscribers or the outbox dispatcher
|
|
1009
|
+
* subscribers or the outbox dispatcher; that is the proper
|
|
999
1010
|
* Aggregate-Boundary separation. Building event-aware logic into
|
|
1000
1011
|
* `onPersisted` couples aggregate lifecycle to event processing and
|
|
1001
1012
|
* recreates the boundary problems Vernon's aggregate discipline is
|
|
@@ -1004,7 +1015,7 @@ var BaseAggregate = class extends Entity {
|
|
|
1004
1015
|
* **The hook must return synchronously.** `markPersisted` is `void`-
|
|
1005
1016
|
* typed and calls `onPersisted` without `await`. TypeScript's
|
|
1006
1017
|
* permissive `void` will accept an `async`-override returning
|
|
1007
|
-
* `Promise<void>`, but the returned promise is fire-and-forget
|
|
1018
|
+
* `Promise<void>`, but the returned promise is fire-and-forget:
|
|
1008
1019
|
* any rejection becomes an unhandled rejection and `withCommit`
|
|
1009
1020
|
* proceeds without waiting. For asynchronous work, subscribe to the
|
|
1010
1021
|
* relevant domain event on the `EventBus` instead; that is the
|
|
@@ -1017,7 +1028,7 @@ var BaseAggregate = class extends Entity {
|
|
|
1017
1028
|
/**
|
|
1018
1029
|
* Appends a domain event to the pending list. Prefer the higher-level
|
|
1019
1030
|
* `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
|
|
1020
|
-
* (event-sourced) call sites
|
|
1031
|
+
* (event-sourced) call sites, both of which wrap `addDomainEvent` in the
|
|
1021
1032
|
* canonical record-AFTER-mutation order (Vernon §8). Calling
|
|
1022
1033
|
* `addDomainEvent` directly is appropriate only when state and event
|
|
1023
1034
|
* recording have already been decoupled deliberately (e.g. a
|
|
@@ -1027,7 +1038,7 @@ var BaseAggregate = class extends Entity {
|
|
|
1027
1038
|
this._pendingEvents.push(event);
|
|
1028
1039
|
}
|
|
1029
1040
|
/**
|
|
1030
|
-
* Creates a snapshot of the current aggregate state
|
|
1041
|
+
* Creates a snapshot of the current aggregate state: the state at
|
|
1031
1042
|
* this moment plus the version. Useful for ES snapshot policies and
|
|
1032
1043
|
* for state-stored backup / restore.
|
|
1033
1044
|
*
|
|
@@ -1045,7 +1056,7 @@ var BaseAggregate = class extends Entity {
|
|
|
1045
1056
|
* Converts live aggregate state into the plain-data shape stored in a
|
|
1046
1057
|
* snapshot. The default validates that the state graph is plain,
|
|
1047
1058
|
* serialisable data (no class instances, functions, Promise/WeakMap/
|
|
1048
|
-
* WeakSet) and then `structuredClone`s it
|
|
1059
|
+
* WeakSet) and then `structuredClone`s it: class instances would
|
|
1049
1060
|
* silently lose their prototype here AND on every snapshot-store
|
|
1050
1061
|
* round-trip, so the default fails fast with the offending path
|
|
1051
1062
|
* instead of producing a snapshot that breaks on first method call
|
|
@@ -1076,8 +1087,8 @@ var BaseAggregate = class extends Entity {
|
|
|
1076
1087
|
* into the event's metadata fields. This is the canonical path for
|
|
1077
1088
|
* recording events from inside aggregate domain methods.
|
|
1078
1089
|
*
|
|
1079
|
-
* Downstream consumers
|
|
1080
|
-
* audit logs
|
|
1090
|
+
* Downstream consumers (outbox dispatchers, projection handlers,
|
|
1091
|
+
* audit logs) route by these two fields. Calling
|
|
1081
1092
|
* `createDomainEvent(...)` directly inside an aggregate method
|
|
1082
1093
|
* leaves them unset and is caught at the `withCommit` harvest
|
|
1083
1094
|
* boundary, but `this.recordEvent(...)` makes the right thing
|
|
@@ -1101,8 +1112,8 @@ var BaseAggregate = class extends Entity {
|
|
|
1101
1112
|
* @param payload - payload for that event subtype
|
|
1102
1113
|
* @param options - any remaining `createDomainEvent` options
|
|
1103
1114
|
* (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
|
|
1104
|
-
* and `aggregateType` are deliberately omitted
|
|
1105
|
-
* them.
|
|
1115
|
+
* and `aggregateType` are deliberately omitted, because the helper
|
|
1116
|
+
* sets them.
|
|
1106
1117
|
*/
|
|
1107
1118
|
recordEvent(type, payload, options) {
|
|
1108
1119
|
return createDomainEvent(type, payload, {
|
|
@@ -1115,7 +1126,7 @@ var BaseAggregate = class extends Entity {
|
|
|
1115
1126
|
function assertSnapshotSafe(value, path, seen) {
|
|
1116
1127
|
if (typeof value === "function") {
|
|
1117
1128
|
throw new Error(
|
|
1118
|
-
`createSnapshot: state${path} is a function
|
|
1129
|
+
`createSnapshot: state${path} is a function: snapshot state must be plain, serialisable data. Override toSnapshotState()/fromSnapshotState() to map it.`
|
|
1119
1130
|
);
|
|
1120
1131
|
}
|
|
1121
1132
|
if (value === null || typeof value !== "object") return;
|
|
@@ -1149,12 +1160,12 @@ function assertSnapshotSafe(value, path, seen) {
|
|
|
1149
1160
|
}
|
|
1150
1161
|
if (tag === "[object Promise]" || tag === "[object WeakMap]" || tag === "[object WeakSet]") {
|
|
1151
1162
|
throw new Error(
|
|
1152
|
-
`createSnapshot: state${path} is a ${tag.slice(8, -1)}
|
|
1163
|
+
`createSnapshot: state${path} is a ${tag.slice(8, -1)}: it cannot be cloned or persisted. Override toSnapshotState()/fromSnapshotState() to map it.`
|
|
1153
1164
|
);
|
|
1154
1165
|
}
|
|
1155
1166
|
if (tag === "[object Error]") {
|
|
1156
1167
|
throw new Error(
|
|
1157
|
-
`createSnapshot: state${path} is an Error
|
|
1168
|
+
`createSnapshot: state${path} is an Error: structuredClone downgrades Error subclasses to plain Error and silently drops custom fields, so the restored value would not round-trip. Override toSnapshotState()/fromSnapshotState() to map it to plain data.`
|
|
1158
1169
|
);
|
|
1159
1170
|
}
|
|
1160
1171
|
return;
|
|
@@ -1166,7 +1177,7 @@ function assertSnapshotSafe(value, path, seen) {
|
|
|
1166
1177
|
if (!descriptor?.enumerable) continue;
|
|
1167
1178
|
if (typeof key === "symbol") {
|
|
1168
1179
|
throw new Error(
|
|
1169
|
-
`createSnapshot: state${path} has a symbol-keyed property (${String(key)})
|
|
1180
|
+
`createSnapshot: state${path} has a symbol-keyed property (${String(key)}): structuredClone silently drops symbol keys, so the snapshot would lose state. Override toSnapshotState()/fromSnapshotState() to map it.`
|
|
1170
1181
|
);
|
|
1171
1182
|
}
|
|
1172
1183
|
assertSnapshotSafe(
|
|
@@ -1179,7 +1190,7 @@ function assertSnapshotSafe(value, path, seen) {
|
|
|
1179
1190
|
}
|
|
1180
1191
|
const name = proto.constructor?.name || "anonymous class";
|
|
1181
1192
|
throw new Error(
|
|
1182
|
-
`createSnapshot: state${path} is a class instance (${name})
|
|
1193
|
+
`createSnapshot: state${path} is a class instance (${name}): structuredClone would strip its prototype and methods, producing a snapshot that breaks on the first method call after restore. Override toSnapshotState()/fromSnapshotState() to map child entities to plain data.`
|
|
1183
1194
|
);
|
|
1184
1195
|
}
|
|
1185
1196
|
__name(assertSnapshotSafe, "assertSnapshotSafe");
|
|
@@ -1190,10 +1201,125 @@ var AggregateRoot = class extends BaseAggregate {
|
|
|
1190
1201
|
__name(this, "AggregateRoot");
|
|
1191
1202
|
}
|
|
1192
1203
|
_autoVersionBump;
|
|
1204
|
+
/**
|
|
1205
|
+
* The state reference as of the last {@link markRestored} /
|
|
1206
|
+
* `markPersisted` (the persistence-lifecycle markers). Only
|
|
1207
|
+
* meaningful while {@link _hasBaseline} is `true`; tracked by a
|
|
1208
|
+
* separate flag rather than an `undefined` sentinel so a `TState`
|
|
1209
|
+
* that itself admits `undefined` cannot be confused with the
|
|
1210
|
+
* never-persisted insert path.
|
|
1211
|
+
*
|
|
1212
|
+
* Held by reference, never copied: `_state` is shallow-frozen and only
|
|
1213
|
+
* ever *replaced* (via `setState` / restore), so the captured reference
|
|
1214
|
+
* stays an exact image of the state at baseline time.
|
|
1215
|
+
*/
|
|
1216
|
+
_baselineState = void 0;
|
|
1217
|
+
/**
|
|
1218
|
+
* `false` until the aggregate has been persisted or restored at least
|
|
1219
|
+
* once: the insert path, where every key counts as changed.
|
|
1220
|
+
*/
|
|
1221
|
+
_hasBaseline = false;
|
|
1193
1222
|
constructor(id, initialState, config) {
|
|
1194
1223
|
super(id, initialState);
|
|
1195
1224
|
this._autoVersionBump = config?.autoVersionBump ?? false;
|
|
1196
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* **Lifecycle marker, Post-Load (see `BaseAggregate.markRestored`).**
|
|
1228
|
+
* Additionally captures the current state reference as the dirty-
|
|
1229
|
+
* tracking baseline for {@link changedKeys} / {@link hasChanges}.
|
|
1230
|
+
*
|
|
1231
|
+
* Covers all three baseline-capture paths through a single override:
|
|
1232
|
+
* `reconstitute(...)` factories, {@link restoreFromSnapshot} (which
|
|
1233
|
+
* assigns the restored state *before* calling this), and
|
|
1234
|
+
* `markPersisted` (which delegates here, so a successful save
|
|
1235
|
+
* re-baselines the diff).
|
|
1236
|
+
*
|
|
1237
|
+
* If you override this, call `super.markRestored(version)` FIRST:
|
|
1238
|
+
* skipping it leaves the baseline uncaptured, so `changedKeys`
|
|
1239
|
+
* permanently reports ALL keys and `hasChanges` never returns `false`
|
|
1240
|
+
* — partial-write repositories silently degrade to full writes — on
|
|
1241
|
+
* top of breaking version sync.
|
|
1242
|
+
*/
|
|
1243
|
+
markRestored(version) {
|
|
1244
|
+
super.markRestored(version);
|
|
1245
|
+
this._baselineState = this._state;
|
|
1246
|
+
this._hasBaseline = true;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Top-level state keys whose value (or presence) changed since the
|
|
1250
|
+
* last {@link markRestored} / `markPersisted`. Never-persisted
|
|
1251
|
+
* aggregates report ALL current keys (the insert path).
|
|
1252
|
+
*
|
|
1253
|
+
* This is the write-scoping signal for **partial writes in multi-table
|
|
1254
|
+
* repositories**: a `save()` for an aggregate whose state spans a root
|
|
1255
|
+
* row plus N child-collection tables can write only the collections
|
|
1256
|
+
* whose key is dirty, while the root-row OCC version write rides every
|
|
1257
|
+
* save. See `docs/guide/repository.md` → "Partial writes for
|
|
1258
|
+
* multi-table aggregates".
|
|
1259
|
+
*
|
|
1260
|
+
* **How it works.** `setState()` replaces state immutably and the
|
|
1261
|
+
* state object is shallow-frozen, so unchanged top-level sub-objects
|
|
1262
|
+
* keep reference identity across mutations. The diff is therefore a
|
|
1263
|
+
* shallow per-key `!==` against the baseline reference — O(top-level
|
|
1264
|
+
* keys), no proxies, no deep diff. A key also counts as dirty when its
|
|
1265
|
+
* *presence* differs (added or removed, even with an `undefined`
|
|
1266
|
+
* value). Computed fresh on every access (a new `Set` each time), so
|
|
1267
|
+
* callers cannot poison later reads.
|
|
1268
|
+
*
|
|
1269
|
+
* **Soundness contract (same one `freezeShallow` already makes):**
|
|
1270
|
+
* the per-key diff is exact only for plain-record `TState` mutated via
|
|
1271
|
+
* `setState` / `commit` (whole-state replacement). In-place mutation
|
|
1272
|
+
* of NESTED objects bypasses the shallow freeze AND this diff; a
|
|
1273
|
+
* class-instance `TState` mutated through its own methods defeats
|
|
1274
|
+
* tracking entirely (the reference never changes). A keyless `TState`
|
|
1275
|
+
* (primitive, bare `Date`) has no keys to report, so `changedKeys`
|
|
1276
|
+
* stays empty for it — use {@link hasChanges}, whose reference
|
|
1277
|
+
* fallback covers keyless states. A deep-equal but newly-referenced
|
|
1278
|
+
* value reports a false POSITIVE (harmless extra write); under the
|
|
1279
|
+
* contract above there are no false negatives.
|
|
1280
|
+
*
|
|
1281
|
+
* Granularity is per top-level key — table-granular, not row-granular:
|
|
1282
|
+
* a dirty collection key means "this child table changed", not which
|
|
1283
|
+
* rows. `EventSourcedAggregate` deliberately has no `changedKeys`;
|
|
1284
|
+
* its `pendingEvents` are the change record.
|
|
1285
|
+
*/
|
|
1286
|
+
get changedKeys() {
|
|
1287
|
+
if (!this._hasBaseline) {
|
|
1288
|
+
return new Set(ownKeys(this._state));
|
|
1289
|
+
}
|
|
1290
|
+
return computeChangedKeys(this._baselineState, this._state);
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Safe skip signal: `false` only when there is genuinely nothing to
|
|
1294
|
+
* persist or flush. `true` when the aggregate has never been
|
|
1295
|
+
* persisted, the version moved past `persistedVersion`, there are
|
|
1296
|
+
* unflushed {@link pendingEvents}, any state key is dirty, or — for
|
|
1297
|
+
* keyless states the per-key diff cannot see (primitive `TState`,
|
|
1298
|
+
* zero-own-key objects like a bare `Date`) — the state reference
|
|
1299
|
+
* changed since the baseline.
|
|
1300
|
+
*
|
|
1301
|
+
* The version clause is deliberate: `setState({...state}, true)` with
|
|
1302
|
+
* identical per-key values yields empty {@link changedKeys} but a
|
|
1303
|
+
* bumped version. If a repository skipped `save()` on a state-only
|
|
1304
|
+
* check, `withCommit` would still call `markPersisted(version)` after
|
|
1305
|
+
* commit, desyncing `persistedVersion` from the DB row — and the next
|
|
1306
|
+
* uncontended save would throw a false `ConcurrencyConflictError`.
|
|
1307
|
+
*
|
|
1308
|
+
* The pending-events clause covers the sanctioned decoupled
|
|
1309
|
+
* `addDomainEvent` path (an event recorded without a state change,
|
|
1310
|
+
* e.g. a deletion event before a hard delete): the aggregate still
|
|
1311
|
+
* needs its trip through `withCommit` so the event reaches the
|
|
1312
|
+
* outbox. With all clauses included, `hasChanges === false` genuinely
|
|
1313
|
+
* means "skipping save is safe".
|
|
1314
|
+
*/
|
|
1315
|
+
get hasChanges() {
|
|
1316
|
+
if (!this._hasBaseline) return true;
|
|
1317
|
+
if (this.version !== this.persistedVersion) return true;
|
|
1318
|
+
if (this.pendingEvents.length > 0) return true;
|
|
1319
|
+
if (this.changedKeys.size > 0) return true;
|
|
1320
|
+
const baseline = this._baselineState;
|
|
1321
|
+
return baseline !== this._state && ownKeys(baseline).length === 0 && ownKeys(this._state).length === 0;
|
|
1322
|
+
}
|
|
1197
1323
|
/**
|
|
1198
1324
|
* Mutates state and records the resulting domain events in the
|
|
1199
1325
|
* **canonical record-after-mutation order**. Use this instead of calling
|
|
@@ -1201,7 +1327,7 @@ var AggregateRoot = class extends BaseAggregate {
|
|
|
1201
1327
|
* "event for a fact that never happened" footgun.
|
|
1202
1328
|
*
|
|
1203
1329
|
* Order of operations:
|
|
1204
|
-
* 1. `setState(newState, true)
|
|
1330
|
+
* 1. `setState(newState, true)`: runs `validateState` first.
|
|
1205
1331
|
* If it throws, the method propagates and **no event is recorded
|
|
1206
1332
|
* and no version is bumped**.
|
|
1207
1333
|
* 2. Each event in `events` is appended via `addDomainEvent`.
|
|
@@ -1260,7 +1386,7 @@ var AggregateRoot = class extends BaseAggregate {
|
|
|
1260
1386
|
}
|
|
1261
1387
|
}
|
|
1262
1388
|
/**
|
|
1263
|
-
* Restores the aggregate from a snapshot
|
|
1389
|
+
* Restores the aggregate from a snapshot: loads state and aligns
|
|
1264
1390
|
* `version` + `persistedVersion` to the snapshot version. Validates
|
|
1265
1391
|
* the restored state.
|
|
1266
1392
|
*
|
|
@@ -1273,6 +1399,33 @@ var AggregateRoot = class extends BaseAggregate {
|
|
|
1273
1399
|
this.markRestored(snapshot.version);
|
|
1274
1400
|
}
|
|
1275
1401
|
};
|
|
1402
|
+
function ownKeys(value) {
|
|
1403
|
+
return value !== null && typeof value === "object" ? Object.keys(value) : [];
|
|
1404
|
+
}
|
|
1405
|
+
__name(ownKeys, "ownKeys");
|
|
1406
|
+
function computeChangedKeys(baseline, current) {
|
|
1407
|
+
const baselineKeys = new Set(ownKeys(baseline));
|
|
1408
|
+
const currentKeys = new Set(ownKeys(current));
|
|
1409
|
+
const dirty = /* @__PURE__ */ new Set();
|
|
1410
|
+
for (const key of currentKeys) {
|
|
1411
|
+
if (!baselineKeys.has(key)) {
|
|
1412
|
+
dirty.add(key);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const before = baseline[key];
|
|
1416
|
+
const after = current[key];
|
|
1417
|
+
if (before !== after) {
|
|
1418
|
+
dirty.add(key);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
for (const key of baselineKeys) {
|
|
1422
|
+
if (!currentKeys.has(key)) {
|
|
1423
|
+
dirty.add(key);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return dirty;
|
|
1427
|
+
}
|
|
1428
|
+
__name(computeChangedKeys, "computeChangedKeys");
|
|
1276
1429
|
var DomainError = class extends BaseError {
|
|
1277
1430
|
static {
|
|
1278
1431
|
__name(this, "DomainError");
|
|
@@ -1285,16 +1438,33 @@ var InfrastructureError = class extends BaseError {
|
|
|
1285
1438
|
};
|
|
1286
1439
|
var MissingHandlerError = class extends BaseError {
|
|
1287
1440
|
constructor(eventType, cause) {
|
|
1288
|
-
super(`Missing handler for event type: ${eventType}`, cause
|
|
1441
|
+
super(`Missing handler for event type: ${eventType}`, cause, {
|
|
1442
|
+
name: "MissingHandlerError"
|
|
1443
|
+
});
|
|
1289
1444
|
this.eventType = eventType;
|
|
1290
1445
|
}
|
|
1291
1446
|
static {
|
|
1292
1447
|
__name(this, "MissingHandlerError");
|
|
1293
1448
|
}
|
|
1294
1449
|
};
|
|
1450
|
+
var AggregateDeletedError = class extends BaseError {
|
|
1451
|
+
constructor(aggregateId) {
|
|
1452
|
+
super(
|
|
1453
|
+
`Aggregate ${aggregateId} was deleted in this unit of work and cannot be saved or registered again. Deletion is final within an operation; if the aggregate must live, do not delete it.`,
|
|
1454
|
+
void 0,
|
|
1455
|
+
{ name: "AggregateDeletedError" }
|
|
1456
|
+
);
|
|
1457
|
+
this.aggregateId = aggregateId;
|
|
1458
|
+
}
|
|
1459
|
+
static {
|
|
1460
|
+
__name(this, "AggregateDeletedError");
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1295
1463
|
var AggregateNotFoundError = class extends InfrastructureError {
|
|
1296
1464
|
constructor(aggregateType, id, cause) {
|
|
1297
|
-
super(`Aggregate not found: ${aggregateType}(${id})`, cause
|
|
1465
|
+
super(`Aggregate not found: ${aggregateType}(${id})`, cause, {
|
|
1466
|
+
name: "AggregateNotFoundError"
|
|
1467
|
+
});
|
|
1298
1468
|
this.aggregateType = aggregateType;
|
|
1299
1469
|
this.id = id;
|
|
1300
1470
|
this.withUserMessage(
|
|
@@ -1305,11 +1475,29 @@ var AggregateNotFoundError = class extends InfrastructureError {
|
|
|
1305
1475
|
__name(this, "AggregateNotFoundError");
|
|
1306
1476
|
}
|
|
1307
1477
|
};
|
|
1478
|
+
var DuplicateAggregateError = class extends InfrastructureError {
|
|
1479
|
+
constructor(aggregateType, aggregateId, cause) {
|
|
1480
|
+
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.`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
static {
|
|
1492
|
+
__name(this, "DuplicateAggregateError");
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1308
1495
|
var ConcurrencyConflictError = class extends InfrastructureError {
|
|
1309
1496
|
constructor(aggregateType, aggregateId, expectedVersion, actualVersion, cause) {
|
|
1310
1497
|
super(
|
|
1311
1498
|
`Concurrency conflict on ${aggregateType}(${aggregateId}): expected version ${expectedVersion}, actual ${actualVersion}`,
|
|
1312
|
-
cause
|
|
1499
|
+
cause,
|
|
1500
|
+
{ name: "ConcurrencyConflictError" }
|
|
1313
1501
|
);
|
|
1314
1502
|
this.aggregateType = aggregateType;
|
|
1315
1503
|
this.aggregateId = aggregateId;
|
|
@@ -1349,13 +1537,13 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
1349
1537
|
* Throws `DomainError` (or a subclass) on validation failure.
|
|
1350
1538
|
* Throws `MissingHandlerError` if no handler is registered for `event.type`.
|
|
1351
1539
|
*
|
|
1352
|
-
* State is not mutated if any step throws
|
|
1540
|
+
* State is not mutated if any step throws: the handler is invoked into
|
|
1353
1541
|
* a local and only assigned to `_state` once all checks pass.
|
|
1354
1542
|
*
|
|
1355
1543
|
* The method is generic in the event tag `K`, so concrete callers
|
|
1356
1544
|
* (`this.apply(orderCreated)`) narrow to the literal tag and the
|
|
1357
|
-
* dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }
|
|
1358
|
-
*
|
|
1545
|
+
* dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }>>`,
|
|
1546
|
+
* with no `as` cast required at the call site.
|
|
1359
1547
|
*
|
|
1360
1548
|
* @param event - The domain event to apply
|
|
1361
1549
|
* @param isNew - Whether the event is new (needs persisting) or replayed from history
|
|
@@ -1385,12 +1573,12 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
1385
1573
|
}
|
|
1386
1574
|
/**
|
|
1387
1575
|
* Reconstitutes the aggregate from an event history. Catches `DomainError`
|
|
1388
|
-
* thrown during replay and returns it as an `Err
|
|
1576
|
+
* thrown during replay and returns it as an `Err`: this is the
|
|
1389
1577
|
* infrastructure boundary, where event-stream corruption is an expected
|
|
1390
1578
|
* recoverable failure. Unexpected (non-DomainError) throws propagate.
|
|
1391
1579
|
*
|
|
1392
1580
|
* All-or-nothing: if any event mid-stream throws, the aggregate's state
|
|
1393
|
-
* is rolled back to its pre-call value
|
|
1581
|
+
* is rolled back to its pre-call value, the same contract as
|
|
1394
1582
|
* `restoreFromSnapshotWithEvents`. Partial replay is never observable.
|
|
1395
1583
|
* (Version needs no rollback: replay dispatches with `isNew = false`,
|
|
1396
1584
|
* which never bumps it; only the final `markRestored` advances it.)
|
|
@@ -1489,12 +1677,25 @@ var CommandBus = class {
|
|
|
1489
1677
|
|
|
1490
1678
|
// src/app/handler.ts
|
|
1491
1679
|
async function withCommit(deps, fn) {
|
|
1492
|
-
const { result, aggregates, events } = await deps.scope.transactional(
|
|
1680
|
+
const { result, aggregates, deleted, events } = await deps.scope.transactional(
|
|
1493
1681
|
async (ctx) => {
|
|
1494
1682
|
const fnResult = await fn(ctx);
|
|
1495
1683
|
const uniqueAggregates = Array.from(new Set(fnResult.aggregates));
|
|
1496
1684
|
const harvested = uniqueAggregates.flatMap(
|
|
1497
|
-
(agg) => agg.pendingEvents
|
|
1685
|
+
(agg) => agg.pendingEvents.map((event) => {
|
|
1686
|
+
if (event.aggregateVersion === void 0) {
|
|
1687
|
+
return Object.freeze({
|
|
1688
|
+
...event,
|
|
1689
|
+
aggregateVersion: agg.version
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
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.`
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
return event;
|
|
1698
|
+
})
|
|
1498
1699
|
);
|
|
1499
1700
|
for (const event of harvested) {
|
|
1500
1701
|
const missing = [];
|
|
@@ -1504,19 +1705,28 @@ async function withCommit(deps, fn) {
|
|
|
1504
1705
|
throw new Error(
|
|
1505
1706
|
`withCommit: event "${event.type}" is missing ${missing.join(
|
|
1506
1707
|
" and "
|
|
1507
|
-
)}. Use this.recordEvent(type, payload) inside aggregate methods instead of createDomainEvent(...)
|
|
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.`
|
|
1508
1709
|
);
|
|
1509
1710
|
}
|
|
1510
1711
|
}
|
|
1511
1712
|
if (harvested.length > 0) {
|
|
1512
1713
|
await deps.outbox.add(harvested);
|
|
1513
1714
|
}
|
|
1514
|
-
return {
|
|
1715
|
+
return {
|
|
1716
|
+
...fnResult,
|
|
1717
|
+
aggregates: uniqueAggregates,
|
|
1718
|
+
deleted: new Set(fnResult.deleted ?? []),
|
|
1719
|
+
events: harvested
|
|
1720
|
+
};
|
|
1515
1721
|
}
|
|
1516
1722
|
);
|
|
1517
1723
|
for (const agg of aggregates) {
|
|
1518
1724
|
try {
|
|
1519
|
-
|
|
1725
|
+
if (deleted.has(agg)) {
|
|
1726
|
+
agg.clearPendingEvents();
|
|
1727
|
+
} else {
|
|
1728
|
+
agg.markPersisted(agg.version);
|
|
1729
|
+
}
|
|
1520
1730
|
} catch {
|
|
1521
1731
|
}
|
|
1522
1732
|
}
|
|
@@ -1533,6 +1743,302 @@ async function withCommit(deps, fn) {
|
|
|
1533
1743
|
return result;
|
|
1534
1744
|
}
|
|
1535
1745
|
__name(withCommit, "withCommit");
|
|
1746
|
+
|
|
1747
|
+
// src/repo/identity-map.ts
|
|
1748
|
+
var IdentityMap = class {
|
|
1749
|
+
static {
|
|
1750
|
+
__name(this, "IdentityMap");
|
|
1751
|
+
}
|
|
1752
|
+
_stores = /* @__PURE__ */ new Map();
|
|
1753
|
+
_deleted = /* @__PURE__ */ new Map();
|
|
1754
|
+
/** The cached instance for type+id, or `undefined` (also after {@link delete}). */
|
|
1755
|
+
get(type, id) {
|
|
1756
|
+
return this._stores.get(type)?.get(id);
|
|
1757
|
+
}
|
|
1758
|
+
/** Whether an instance is registered for type+id (false after {@link delete}). */
|
|
1759
|
+
has(type, id) {
|
|
1760
|
+
return this._stores.get(type)?.has(id) ?? false;
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Whether type+id was {@link delete}d in this unit of work. The
|
|
1764
|
+
* read path checks this BEFORE hydrating and returns `null`, so
|
|
1765
|
+
* "deleted in this operation" reads uniformly as not-found —
|
|
1766
|
+
* regardless of whether the repository's physical delete already
|
|
1767
|
+
* removed the row or is deferred within the transaction. Without
|
|
1768
|
+
* the check, a read-only probe of a deleted aggregate would crash
|
|
1769
|
+
* in {@link set} for deferred-write repositories and return `null`
|
|
1770
|
+
* for immediate-write ones.
|
|
1771
|
+
*/
|
|
1772
|
+
isDeleted(type, id) {
|
|
1773
|
+
return this._deleted.get(type)?.has(id) ?? false;
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Registers the hydrated instance for type+id.
|
|
1777
|
+
*
|
|
1778
|
+
* - Re-registering the SAME instance is a no-op (idempotent).
|
|
1779
|
+
* - Registering a DIFFERENT instance for an occupied type+id throws:
|
|
1780
|
+
* that is precisely the identity-map violation this class exists
|
|
1781
|
+
* to prevent (the repository hydrated twice instead of checking
|
|
1782
|
+
* {@link get} first), and letting it pass would double-harvest
|
|
1783
|
+
* events downstream.
|
|
1784
|
+
* - Registering a type+id that was {@link delete}d in this unit of
|
|
1785
|
+
* work throws `AggregateDeletedError`: deletion is final within
|
|
1786
|
+
* the operation.
|
|
1787
|
+
*/
|
|
1788
|
+
set(type, id, aggregate) {
|
|
1789
|
+
if (this._deleted.get(type)?.has(id)) {
|
|
1790
|
+
throw new AggregateDeletedError(String(id));
|
|
1791
|
+
}
|
|
1792
|
+
let store = this._stores.get(type);
|
|
1793
|
+
if (store === void 0) {
|
|
1794
|
+
store = /* @__PURE__ */ new Map();
|
|
1795
|
+
this._stores.set(type, store);
|
|
1796
|
+
}
|
|
1797
|
+
const existing = store.get(id);
|
|
1798
|
+
if (existing !== void 0 && existing !== aggregate) {
|
|
1799
|
+
throw new Error(
|
|
1800
|
+
`IdentityMap: a different instance is already registered for ${type.name}(${String(id)}). Check get() before hydrating - two live instances of one aggregate break the one-instance-per-unit-of-work contract that exactly-once event harvest relies on.`
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
store.set(id, aggregate);
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Removes the entry for type+id and records a tombstone: subsequent
|
|
1807
|
+
* {@link get} / {@link has} report absence, and a subsequent
|
|
1808
|
+
* {@link set} of the same type+id throws `AggregateDeletedError`.
|
|
1809
|
+
* Called by a repository's `delete(aggregate)` alongside
|
|
1810
|
+
* `session.enrollDeleted(aggregate)`.
|
|
1811
|
+
*/
|
|
1812
|
+
delete(type, id) {
|
|
1813
|
+
this._stores.get(type)?.delete(id);
|
|
1814
|
+
let tombstones = this._deleted.get(type);
|
|
1815
|
+
if (tombstones === void 0) {
|
|
1816
|
+
tombstones = /* @__PURE__ */ new Set();
|
|
1817
|
+
this._deleted.set(type, tombstones);
|
|
1818
|
+
}
|
|
1819
|
+
tombstones.add(id);
|
|
1820
|
+
}
|
|
1821
|
+
/** Empties all stores and tombstones. Called by the unit of work on close. */
|
|
1822
|
+
clear() {
|
|
1823
|
+
this._stores.clear();
|
|
1824
|
+
this._deleted.clear();
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
// src/app/unit-of-work.ts
|
|
1829
|
+
var NestedUnitOfWorkError = class extends BaseError {
|
|
1830
|
+
static {
|
|
1831
|
+
__name(this, "NestedUnitOfWorkError");
|
|
1832
|
+
}
|
|
1833
|
+
constructor() {
|
|
1834
|
+
super(
|
|
1835
|
+
"UnitOfWork.run() was called while this instance is already running. A nested run() would open an independent transaction, not join the outer one - merge the work into a single run() callback. For concurrent operations, construct one UnitOfWork per operation.",
|
|
1836
|
+
void 0,
|
|
1837
|
+
{ name: "NestedUnitOfWorkError" }
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
var TransactionClosedError = class extends BaseError {
|
|
1842
|
+
constructor(operation) {
|
|
1843
|
+
super(
|
|
1844
|
+
`Unit of work is closed: ${operation} was called after the transaction committed or rolled back. Do not use the context or session outside the run() callback.`,
|
|
1845
|
+
void 0,
|
|
1846
|
+
{ name: "TransactionClosedError" }
|
|
1847
|
+
);
|
|
1848
|
+
this.operation = operation;
|
|
1849
|
+
}
|
|
1850
|
+
static {
|
|
1851
|
+
__name(this, "TransactionClosedError");
|
|
1852
|
+
}
|
|
1853
|
+
};
|
|
1854
|
+
var CommitError = class extends InfrastructureError {
|
|
1855
|
+
static {
|
|
1856
|
+
__name(this, "CommitError");
|
|
1857
|
+
}
|
|
1858
|
+
constructor(cause) {
|
|
1859
|
+
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.",
|
|
1861
|
+
cause,
|
|
1862
|
+
{ name: "CommitError" }
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
var RollbackError = class extends InfrastructureError {
|
|
1867
|
+
constructor(cause, rollbackCause) {
|
|
1868
|
+
super(
|
|
1869
|
+
"The work callback failed and the transaction scope rejected with a different error (possible rollback failure). The callback's error is the cause; the scope's error is in rollbackCause.",
|
|
1870
|
+
cause,
|
|
1871
|
+
{ name: "RollbackError" }
|
|
1872
|
+
);
|
|
1873
|
+
this.rollbackCause = rollbackCause;
|
|
1874
|
+
}
|
|
1875
|
+
static {
|
|
1876
|
+
__name(this, "RollbackError");
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
var UnitOfWork = class {
|
|
1880
|
+
constructor(deps) {
|
|
1881
|
+
this.deps = deps;
|
|
1882
|
+
}
|
|
1883
|
+
static {
|
|
1884
|
+
__name(this, "UnitOfWork");
|
|
1885
|
+
}
|
|
1886
|
+
_active = false;
|
|
1887
|
+
/**
|
|
1888
|
+
* Execute one unit of work: open the transaction, hand the callback
|
|
1889
|
+
* tx-bound repositories, commit on resolve, roll back on throw,
|
|
1890
|
+
* run the post-commit lifecycle (markPersisted, publish) for every
|
|
1891
|
+
* enrolled aggregate. Returns the callback's result.
|
|
1892
|
+
*/
|
|
1893
|
+
async run(work) {
|
|
1894
|
+
if (this._active) {
|
|
1895
|
+
throw new NestedUnitOfWorkError();
|
|
1896
|
+
}
|
|
1897
|
+
this._active = true;
|
|
1898
|
+
let session;
|
|
1899
|
+
let workCompleted = false;
|
|
1900
|
+
let workThrew = false;
|
|
1901
|
+
let workError;
|
|
1902
|
+
try {
|
|
1903
|
+
return await withCommit(
|
|
1904
|
+
{
|
|
1905
|
+
outbox: this.deps.outbox,
|
|
1906
|
+
bus: this.deps.bus,
|
|
1907
|
+
scope: this.deps.scope,
|
|
1908
|
+
onPublishError: this.deps.onPublishError
|
|
1909
|
+
},
|
|
1910
|
+
async (tx) => {
|
|
1911
|
+
session?.close();
|
|
1912
|
+
const s = new Session();
|
|
1913
|
+
session = s;
|
|
1914
|
+
workCompleted = false;
|
|
1915
|
+
workThrew = false;
|
|
1916
|
+
workError = void 0;
|
|
1917
|
+
const repositories = this.buildRepositories(tx, s);
|
|
1918
|
+
const context = makeContext(repositories, tx, s);
|
|
1919
|
+
try {
|
|
1920
|
+
const result = await work(context);
|
|
1921
|
+
workCompleted = true;
|
|
1922
|
+
const aggregates = s.enrolledAggregates;
|
|
1923
|
+
const deleted = s.deletedAggregates;
|
|
1924
|
+
s.close();
|
|
1925
|
+
return { result, aggregates, deleted };
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
workThrew = true;
|
|
1928
|
+
workError = error;
|
|
1929
|
+
throw error;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
);
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
if (workThrew) {
|
|
1935
|
+
if (error === workError || causeChainContains(error, workError)) {
|
|
1936
|
+
throw error;
|
|
1937
|
+
}
|
|
1938
|
+
throw new RollbackError(workError, error);
|
|
1939
|
+
}
|
|
1940
|
+
if (workCompleted) {
|
|
1941
|
+
throw new CommitError(error);
|
|
1942
|
+
}
|
|
1943
|
+
throw error;
|
|
1944
|
+
} finally {
|
|
1945
|
+
session?.close();
|
|
1946
|
+
this._active = false;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
buildRepositories(tx, session) {
|
|
1950
|
+
const repositories = {};
|
|
1951
|
+
for (const key of Object.keys(this.deps.repositories)) {
|
|
1952
|
+
repositories[key] = this.deps.repositories[key](tx, session);
|
|
1953
|
+
}
|
|
1954
|
+
return repositories;
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
var Session = class {
|
|
1958
|
+
static {
|
|
1959
|
+
__name(this, "Session");
|
|
1960
|
+
}
|
|
1961
|
+
// Insertion-ordered: harvest order = enrollment order (withCommit
|
|
1962
|
+
// then preserves per-aggregate emission order).
|
|
1963
|
+
_enrolled = /* @__PURE__ */ new Set();
|
|
1964
|
+
_deleted = /* @__PURE__ */ new Set();
|
|
1965
|
+
_identityMap = new IdentityMap();
|
|
1966
|
+
_closed = false;
|
|
1967
|
+
get identityMap() {
|
|
1968
|
+
this.assertOpen("session.identityMap");
|
|
1969
|
+
return this._identityMap;
|
|
1970
|
+
}
|
|
1971
|
+
enrollSaved(aggregate) {
|
|
1972
|
+
this.assertOpen("session.enrollSaved");
|
|
1973
|
+
if (this._deleted.has(aggregate) || this._identityMap.isDeleted(
|
|
1974
|
+
aggregate.constructor,
|
|
1975
|
+
aggregate.id
|
|
1976
|
+
)) {
|
|
1977
|
+
throw new AggregateDeletedError(String(aggregate.id));
|
|
1978
|
+
}
|
|
1979
|
+
this._enrolled.add(aggregate);
|
|
1980
|
+
}
|
|
1981
|
+
enrollDeleted(aggregate) {
|
|
1982
|
+
this.assertOpen("session.enrollDeleted");
|
|
1983
|
+
this._deleted.add(aggregate);
|
|
1984
|
+
this._identityMap.delete(
|
|
1985
|
+
aggregate.constructor,
|
|
1986
|
+
aggregate.id
|
|
1987
|
+
);
|
|
1988
|
+
this._enrolled.add(aggregate);
|
|
1989
|
+
}
|
|
1990
|
+
get enrolledAggregates() {
|
|
1991
|
+
return [...this._enrolled];
|
|
1992
|
+
}
|
|
1993
|
+
get deletedAggregates() {
|
|
1994
|
+
return [...this._deleted];
|
|
1995
|
+
}
|
|
1996
|
+
close() {
|
|
1997
|
+
this._closed = true;
|
|
1998
|
+
this._identityMap.clear();
|
|
1999
|
+
}
|
|
2000
|
+
assertOpen(operation) {
|
|
2001
|
+
if (this._closed) {
|
|
2002
|
+
throw new TransactionClosedError(operation);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
function makeContext(repositories, transaction, session) {
|
|
2007
|
+
return {
|
|
2008
|
+
get repositories() {
|
|
2009
|
+
session.assertOpen("context.repositories");
|
|
2010
|
+
return repositories;
|
|
2011
|
+
},
|
|
2012
|
+
get rawTransaction() {
|
|
2013
|
+
session.assertOpen("context.rawTransaction");
|
|
2014
|
+
return transaction;
|
|
2015
|
+
},
|
|
2016
|
+
session
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
__name(makeContext, "makeContext");
|
|
2020
|
+
function causeChainContains(error, target) {
|
|
2021
|
+
if (target === void 0 || target === null) {
|
|
2022
|
+
return false;
|
|
2023
|
+
}
|
|
2024
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2025
|
+
let current = error;
|
|
2026
|
+
while (current !== null && typeof current === "object" && !seen.has(current)) {
|
|
2027
|
+
seen.add(current);
|
|
2028
|
+
let next;
|
|
2029
|
+
try {
|
|
2030
|
+
next = current.cause;
|
|
2031
|
+
} catch {
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
if (next === target) {
|
|
2035
|
+
return true;
|
|
2036
|
+
}
|
|
2037
|
+
current = next;
|
|
2038
|
+
}
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
__name(causeChainContains, "causeChainContains");
|
|
1536
2042
|
var QueryBus = class {
|
|
1537
2043
|
static {
|
|
1538
2044
|
__name(this, "QueryBus");
|
|
@@ -1657,7 +2163,7 @@ var EventBusImpl = class {
|
|
|
1657
2163
|
if (result.status === "rejected") {
|
|
1658
2164
|
errors.push(
|
|
1659
2165
|
result.reason instanceof Error ? result.reason : (
|
|
1660
|
-
// Attach the raw reason as cause
|
|
2166
|
+
// Attach the raw reason as cause: a handler
|
|
1661
2167
|
// rejecting with a structured payload must stay
|
|
1662
2168
|
// diagnosable, not collapse to '[object Object]'.
|
|
1663
2169
|
new Error(String(result.reason), {
|
|
@@ -1708,6 +2214,6 @@ function voValidated(t, validate, message = "Validation failed") {
|
|
|
1708
2214
|
}
|
|
1709
2215
|
__name(voValidated, "voValidated");
|
|
1710
2216
|
|
|
1711
|
-
export { AggregateNotFoundError, AggregateRoot, CommandBus, ConcurrencyConflictError, DomainError, Entity, EventBusImpl, EventSourcedAggregate, InMemoryOutbox, InfrastructureError, MissingHandlerError, QueryBus, ValueObject, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepEqual, deepEqualExcept, deepFreeze, deepOmit, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withClockFactory, withCommit, withEventIdFactory };
|
|
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 };
|
|
1712
2218
|
//# sourceMappingURL=index.js.map
|
|
1713
2219
|
//# sourceMappingURL=index.js.map
|