@shirudo/ddd-kit 3.0.0-rc.10 → 3.0.0-rc.11
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/chunks/kit-errors.d.ts +287 -108
- package/dist/chunks/kit-errors.js +250 -111
- package/dist/chunks/kit-errors.js.map +1 -1
- package/dist/chunks/{ports.js → read-stream-pages.js} +531 -92
- package/dist/chunks/read-stream-pages.js.map +1 -0
- package/dist/chunks/snapshot-store.d.ts +542 -115
- package/dist/index.d.ts +598 -434
- package/dist/index.js +1508 -637
- package/dist/index.js.map +1 -1
- package/dist/money.d.ts +1 -1
- package/dist/testing.d.ts +106 -15
- package/dist/testing.js +546 -52
- package/dist/testing.js.map +1 -1
- package/package.json +3 -2
- package/dist/chunks/ports.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import { A as voWithValidation, C as
|
|
1
|
+
import { $ as isDomainErrorLike, A as MissingHandlerError, B as SnapshotCorruptedError, C as InvalidEventStreamPageError, D as MisattributedEventError, E as KitWiringError, F as ProjectionOrderViolationError, G as UnmintedEventError, H as SnapshotVersionNotRestoredError, I as ProjectionReceiptViolationError, J as UnreplayableAggregateError, K as UnprojectableEventError, L as ReentrantEventRecordingError, M as PendingEventLimitExceededError, N as ProjectionGapError, O as MissingEntityIdError, P as ProjectionIdentityViolationError, Q as detachAggregateIdentity, R as ReplayRejectedError, S as InvalidCommandMessageError, T as InvalidVersionError, U as UnenrolledChangesError, V as SnapshotSchemaMismatchError, W as UnmanagedInstanceError, X as attributeConflictIntent, Y as assertNoHostileOwnProtoKey, Z as describeAggregateIdentity, _ as IdempotencyInFlightError, a as DirectStateMutationError, b as InMemoryCapacityExceededError, c as DuplicateEventIdError, d as EventHarvestError, et as isInfrastructureErrorLike, f as FoldReturnedNoStateError, g as IdempotencyCompletionWithoutClaimError, h as IdempotencyClaimLostError, i as ConcurrencyConflictError, j as PendingEventBatchMismatchError, k as MissingFoldError, l as DuplicateHandlerRegistrationError, m as HostileStateKeyError, n as AggregateNotFoundError, nt as rewriteErrorMessage, o as DomainError, p as ForeignEventError, q as UnregisteredHandlerError, r as CapabilityRegistryConflictError, s as DuplicateAggregateError, t as AggregateDeletedError, tt as isWiringErrorLike, u as ErrorMapperFailedError, v as IdempotencyKeyReuseError, w as InvalidIntegrationMessageError, x as InfrastructureError, y as IdempotencyReconciliationRequiredError, z as ReplayTargetMismatchError } from "./chunks/kit-errors.js";
|
|
2
|
+
import { A as SnapshotTimeValidationError, B as voWithValidation, C as defaultDomainEventFactory, D as mintUncommittedDomainEvent, E as mergeMetadata, F as ValueObject, G as isBuiltInObject, H as deepOmit, I as deepFreeze, K as isIntrinsicConstructorPrototype, L as vo, M as detachState, N as hasCooperativeBrand, O as recordDomainEvent, P as stampCooperativeBrand, R as voEquals, S as createUncommittedDomainEvent, T as isUncommittedDomainEvent, U as deepEqual, V as deepEqualExcept, W as findPropertyDescriptor, _ as adoptUncommittedDomainEvent, a as isDispatchTrackingOutbox, b as createDomainEventFactory, c as runBoundedExecution, d as assertPositiveInteger, f as assertPositiveSafeInteger, g as adoptRecordedDomainEvent, h as waitRejectingOnAbort, i as assertPageWithinWindow, j as detachFreezableState, k as DomainEventValidationError, l as MAX_TIMER_DELAY_MS, m as abortReason, n as readStreamPages, o as DEFAULT_EXECUTION_TIMEOUT_MS, p as assertTimerDelay, q as isWeakMap, r as assertPageNotEmpty, s as ownerSignalOf, t as pinTargetVersion, u as assertNonNegativeSafeInteger, v as copyMetadata, w as isRecordedDomainEvent, x as createDomainEventFromFacts, y as createDomainEvent, z as voEqualsExcept } from "./chunks/read-stream-pages.js";
|
|
3
3
|
import { err, ok } from "@shirudo/result";
|
|
4
4
|
import { ValidationError, someChainRetryable } from "@shirudo/base-error";
|
|
5
5
|
|
|
@@ -200,10 +200,13 @@ function assertJsonValue(value, path, invalid, active = /* @__PURE__ */ new Weak
|
|
|
200
200
|
* destinations, while event-stream replay only rebuilds process state.
|
|
201
201
|
*/
|
|
202
202
|
function routeEventsToCommandOutbox(outbox, mapper) {
|
|
203
|
-
return {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
return {
|
|
204
|
+
add: async (events) => {
|
|
205
|
+
const commits = events.map((candidate) => toCommandCommit(candidate, mapper));
|
|
206
|
+
await outbox.add(commits);
|
|
207
|
+
},
|
|
208
|
+
endEventSources: (sources) => outbox.endEventSources(sources)
|
|
209
|
+
};
|
|
207
210
|
}
|
|
208
211
|
function toCommandCommit(candidate, mapper) {
|
|
209
212
|
const mapped = mapper(candidate.event);
|
|
@@ -283,7 +286,7 @@ function invalid$1(path, reason) {
|
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
//#endregion
|
|
286
|
-
//#region src/
|
|
289
|
+
//#region src/internal/global-capability-registry.ts
|
|
287
290
|
/**
|
|
288
291
|
* The sentence a capability lookup appends to its rejection when the
|
|
289
292
|
* registry is private to this copy, so the reader learns why an instance
|
|
@@ -343,7 +346,7 @@ function withRequire(registry, shared) {
|
|
|
343
346
|
|
|
344
347
|
//#endregion
|
|
345
348
|
//#region src/domain/aggregate/pending-event-lifecycle.ts
|
|
346
|
-
const { registry: capabilities$2, require: require$1 } = createGlobalCapabilityRegistry(Symbol.for("@shirudo/ddd-kit/pending-event-lifecycle-registry/
|
|
349
|
+
const { registry: capabilities$2, require: require$1 } = createGlobalCapabilityRegistry(Symbol.for("@shirudo/ddd-kit/pending-event-lifecycle-registry/v7"));
|
|
347
350
|
/** Resolves the lifecycle authority or throws `UnmanagedInstanceError`. */
|
|
348
351
|
function requirePendingEventLifecycleCapability(aggregate, operation) {
|
|
349
352
|
return require$1(aggregate, operation, "aggregate");
|
|
@@ -424,9 +427,9 @@ function createCommitTokenScope() {
|
|
|
424
427
|
if (existing) {
|
|
425
428
|
const record = recordsByToken.get(existing);
|
|
426
429
|
if (!record) throw new EventHarvestError("withCommit: internal commit-token registry is inconsistent.");
|
|
427
|
-
if (record.disposition === "deleted" && disposition === "saved") throw new EventHarvestError(`withCommit: aggregate ${
|
|
428
|
-
if (options?.expectedVersion !== void 0 && options.expectedVersion !== record.expectedVersion) throw new EventHarvestError(`withCommit: aggregate ${
|
|
429
|
-
if (enrollmentDiverged(record)) throw new EventHarvestError(`withCommit: aggregate ${
|
|
430
|
+
if (record.disposition === "deleted" && disposition === "saved") throw new EventHarvestError(`withCommit: aggregate ${describeAggregateIdentity(aggregate.aggregateIdentity)} was enrolled as saved after it was enrolled as deleted in the same transaction.`);
|
|
431
|
+
if (options?.expectedVersion !== void 0 && options.expectedVersion !== record.expectedVersion) throw new EventHarvestError(`withCommit: aggregate ${describeAggregateIdentity(aggregate.aggregateIdentity)} was re-enrolled with expectedVersion ${String(options.expectedVersion)}, but its enrollment recorded ${String(record.expectedVersion)}. Duplicate enrollment must assert the same OCC baseline or none.`);
|
|
432
|
+
if (enrollmentDiverged(record)) throw new EventHarvestError(`withCommit: aggregate ${describeAggregateIdentity(aggregate.aggregateIdentity)} changed after its commit batch was enrolled. Register persistence intent last.`);
|
|
430
433
|
if (disposition === "deleted") record.disposition = "deleted";
|
|
431
434
|
return existing;
|
|
432
435
|
}
|
|
@@ -467,7 +470,7 @@ function createCommitTokenScope() {
|
|
|
467
470
|
if (!record) throw new EventHarvestError("withCommit: a commit token was not minted by this callback's enrollment capability. Forged and stale tokens are rejected.");
|
|
468
471
|
if (seen.has(tokenObject)) continue;
|
|
469
472
|
seen.add(tokenObject);
|
|
470
|
-
if (enrollmentDiverged(record)) throw new EventHarvestError(`withCommit: aggregate ${
|
|
473
|
+
if (enrollmentDiverged(record)) throw new EventHarvestError(`withCommit: aggregate ${describeAggregateIdentity(record.aggregate.aggregateIdentity)} changed after its commit batch was enrolled; events recorded after enrollment are not part of the attested write and would be silently dropped. Make domain decisions first, write, and enroll last.`);
|
|
471
474
|
records.push(record);
|
|
472
475
|
}
|
|
473
476
|
if (seen.size !== mintedTokenCount) throw new EventHarvestError("withCommit: every token minted by the current enrollment capability must be returned in `commits`. If an enrolled write must not commit, throw so the transaction rolls back.");
|
|
@@ -595,8 +598,21 @@ function createCommitTokenScope() {
|
|
|
595
598
|
* ```
|
|
596
599
|
*/
|
|
597
600
|
async function withCommit(deps, fn) {
|
|
601
|
+
return withCheckedCommit(deps, async (ctx, enrollment) => {
|
|
602
|
+
const { result, commits } = await fn(ctx, enrollment);
|
|
603
|
+
return {
|
|
604
|
+
result,
|
|
605
|
+
commits
|
|
606
|
+
};
|
|
607
|
+
}, void 0);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* {@link withCommit} with a check per attempt before the commit. The scope
|
|
611
|
+
* receives `onAttemptStart` in its transactional options.
|
|
612
|
+
*/
|
|
613
|
+
async function withCheckedCommit(deps, fn, onAttemptStart) {
|
|
598
614
|
const postCommitTimeoutMs = deps.postCommitTimeoutMs ?? 3e4;
|
|
599
|
-
|
|
615
|
+
assertTimerDelay("withCommit", "postCommitTimeoutMs", postCommitTimeoutMs);
|
|
600
616
|
if (deps.signal?.aborted) throw abortReason(deps.signal, "withCommit aborted before opening a transaction");
|
|
601
617
|
const { result, commitRecords, events } = await deps.scope.transactional(async (ctx) => {
|
|
602
618
|
const tokenScope = createCommitTokenScope();
|
|
@@ -606,44 +622,64 @@ async function withCommit(deps, fn) {
|
|
|
606
622
|
} finally {
|
|
607
623
|
tokenScope.close();
|
|
608
624
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
aggregateType
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
625
|
+
try {
|
|
626
|
+
const commitRecords = tokenScope.resolve(fnResult.commits);
|
|
627
|
+
const candidates = commitRecords.flatMap((record) => {
|
|
628
|
+
const enrolled = record.aggregate.aggregateIdentity;
|
|
629
|
+
if (record.events.length > 0 && record.persistedVersion !== void 0 && record.version <= record.persistedVersion) throw new EventHarvestError(`withCommit: aggregate ${describeAggregateIdentity(enrolled)} recorded events but did not advance its version beyond the persisted version (${String(record.persistedVersion)}). An eventful commit needs a unique cursor; use StateStoredAggregate.setState(currentState, event) instead of addDomainEvent(event) alone.`);
|
|
630
|
+
const enrolledId = enrolled.aggregateId;
|
|
631
|
+
const enrolledType = enrolled.aggregateType;
|
|
632
|
+
return record.events.map((event, index) => {
|
|
633
|
+
if (!isRecordedDomainEvent(event)) throw new EventHarvestError(`withCommit: event "${event.type}" has not been recorded. Call recordPendingEvents(aggregate, createStamp) in the application shell before persistence or outbox harvest.`, event.type);
|
|
634
|
+
const recordedEvent = event;
|
|
635
|
+
const commitSize = record.events.length;
|
|
636
|
+
const aggregateId = recordedEvent.aggregateId;
|
|
637
|
+
const aggregateType = recordedEvent.aggregateType;
|
|
638
|
+
const missing = [];
|
|
639
|
+
if (!aggregateId) missing.push("aggregateId");
|
|
640
|
+
if (!aggregateType) missing.push("aggregateType");
|
|
641
|
+
if (!aggregateId || !aggregateType) throw new EventHarvestError(`withCommit: event "${recordedEvent.type}" is missing ${missing.join(" and ")}. Use this.createEvent(type, payload) inside aggregate methods instead of createDomainEvent(...); createEvent auto-injects aggregateId and aggregateType. Outbox dispatchers and projection handlers rely on the envelope source.`, recordedEvent.type);
|
|
642
|
+
if (aggregateId !== enrolledId || aggregateType !== enrolledType) throw new EventHarvestError(`withCommit: event "${recordedEvent.type}" belongs to ${describeAggregateIdentity({
|
|
643
|
+
aggregateType,
|
|
644
|
+
aggregateId
|
|
645
|
+
})} but was enrolled under ${describeAggregateIdentity({
|
|
646
|
+
aggregateType: enrolledType,
|
|
647
|
+
aggregateId: enrolledId
|
|
648
|
+
})}. The aggregate base classes stamp the aggregate identity on every recording path; an instance from another package copy must stamp it the same way.`, recordedEvent.type);
|
|
649
|
+
return Object.freeze({
|
|
650
|
+
event: recordedEvent,
|
|
651
|
+
source: Object.freeze({
|
|
652
|
+
aggregateId,
|
|
653
|
+
aggregateType
|
|
654
|
+
}),
|
|
655
|
+
position: Object.freeze({
|
|
656
|
+
aggregateVersion: record.version,
|
|
657
|
+
commitSequence: index,
|
|
658
|
+
commitSize
|
|
659
|
+
})
|
|
660
|
+
});
|
|
637
661
|
});
|
|
638
662
|
});
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
663
|
+
const endedSources = commitRecords.filter((record) => record.disposition === "deleted").map((record) => detachAggregateIdentity(record.aggregate.aggregateIdentity));
|
|
664
|
+
if (candidates.length > 0) await deps.outbox.add(Object.freeze(candidates));
|
|
665
|
+
if (endedSources.length > 0) await deps.outbox.endEventSources(Object.freeze(endedSources));
|
|
666
|
+
fnResult.checkBeforeCommit?.();
|
|
667
|
+
if (candidates.length > 0 || endedSources.length > 0) {
|
|
668
|
+
for (const record of commitRecords) if (enrollmentDiverged(record)) throw new EventHarvestError(`withCommit: aggregate ${describeAggregateIdentity(record.aggregate.aggregateIdentity)} changed while its outbox write ran. Await every change inside the work callback.`);
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
result: fnResult.result,
|
|
672
|
+
commitRecords,
|
|
673
|
+
events: candidates.map(({ event }) => event)
|
|
674
|
+
};
|
|
675
|
+
} catch (error) {
|
|
676
|
+
fnResult.onFailureBeforeCommit?.(error);
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
}, {
|
|
680
|
+
signal: deps.signal,
|
|
681
|
+
onAttemptStart
|
|
682
|
+
});
|
|
647
683
|
const persistedObservations = [];
|
|
648
684
|
for (const { aggregate, eventLifecycle, disposition, version, events: committedEvents } of commitRecords) try {
|
|
649
685
|
if (disposition === "deleted") eventLifecycle.discardPendingEvents(committedEvents);
|
|
@@ -753,11 +789,21 @@ var QueryBus = class {
|
|
|
753
789
|
* limit rejects a new address before mutation; delivery state is never
|
|
754
790
|
* silently evicted.
|
|
755
791
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
792
|
+
* On its own, the store knows nothing about your `TransactionScope`
|
|
793
|
+
* rollbacks: a rolled-back `schedule` or `cancel` stays applied. Register
|
|
794
|
+
* the store with an `InMemoryTransactionScope` for tests that roll back or
|
|
795
|
+
* retry.
|
|
796
|
+
*
|
|
797
|
+
* A processor reads only committed deadlines. While a transaction is open,
|
|
798
|
+
* `due` skips a deadline that the transaction scheduled. For an address that
|
|
799
|
+
* the transaction rescheduled or cancelled, it returns the committed
|
|
800
|
+
* deadline as it was at the first write of the transaction. A failure report
|
|
801
|
+
* against that deadline counts its attempts. At the ceiling, `due` stops
|
|
802
|
+
* returning it until the transaction ends. A rollback also undoes an acknowledgement or a failure report
|
|
803
|
+
* that a processor made while the transaction was open. The processor then
|
|
804
|
+
* delivers that deadline again. For production, prove the transactional half
|
|
805
|
+
* of the contract with `createDeadlineStoreContractTests` and its rollback
|
|
806
|
+
* capability.
|
|
761
807
|
*
|
|
762
808
|
* Payloads are deep-copied on schedule and on delivery
|
|
763
809
|
* (`structuredClone`), so neither side can mutate the other's copy.
|
|
@@ -769,6 +815,11 @@ var InMemoryDeadlineStore = class {
|
|
|
769
815
|
maxDeliveryAttempts;
|
|
770
816
|
maxRecords;
|
|
771
817
|
nextSequence = 0;
|
|
818
|
+
/**
|
|
819
|
+
* The committed deadline of each address that the open transaction wrote,
|
|
820
|
+
* captured at its first write. `undefined` marks an address without one.
|
|
821
|
+
*/
|
|
822
|
+
committedVersions;
|
|
772
823
|
constructor(options = {}) {
|
|
773
824
|
const max = options.maxDeliveryAttempts ?? 5;
|
|
774
825
|
assertPositiveInteger("InMemoryDeadlineStore", "maxDeliveryAttempts", max);
|
|
@@ -776,8 +827,38 @@ var InMemoryDeadlineStore = class {
|
|
|
776
827
|
if (options.maxRecords !== void 0) assertPositiveSafeInteger("InMemoryDeadlineStore", "maxRecords", options.maxRecords);
|
|
777
828
|
this.maxRecords = options.maxRecords;
|
|
778
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* Records the state that a rollback of an `InMemoryTransactionScope`
|
|
832
|
+
* returns to: the pending and the dead-letter records. The rollback
|
|
833
|
+
* keeps the sequence counter, as a database sequence does, so a
|
|
834
|
+
* delivery id stays unique. Until the transaction ends, `due` returns the
|
|
835
|
+
* committed deadline of each address that the transaction writes.
|
|
836
|
+
*/
|
|
837
|
+
beginTransaction() {
|
|
838
|
+
const pending = [...this.pending].map(([key, deadline]) => [key, { ...deadline }]);
|
|
839
|
+
const dead = [...this.dead].map(([deliveryId, deadline]) => [deliveryId, { ...deadline }]);
|
|
840
|
+
const versions = /* @__PURE__ */ new Map();
|
|
841
|
+
this.committedVersions = versions;
|
|
842
|
+
const end = () => {
|
|
843
|
+
if (this.committedVersions === versions) this.committedVersions = void 0;
|
|
844
|
+
};
|
|
845
|
+
return {
|
|
846
|
+
commit: end,
|
|
847
|
+
rollback: () => {
|
|
848
|
+
this.pending.clear();
|
|
849
|
+
for (const [key, deadline] of pending) this.pending.set(key, { ...deadline });
|
|
850
|
+
this.dead.clear();
|
|
851
|
+
for (const [deliveryId, deadline] of dead) this.dead.set(deliveryId, { ...deadline });
|
|
852
|
+
end();
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
}
|
|
779
856
|
async schedule(deadline) {
|
|
780
|
-
const
|
|
857
|
+
const scope = deadline.scope;
|
|
858
|
+
const key = deadline.key;
|
|
859
|
+
const dueAt = new Date(deadline.dueAt);
|
|
860
|
+
const payload = structuredClone(deadline.payload);
|
|
861
|
+
const deadlineAddress = address(scope, key);
|
|
781
862
|
if (!this.pending.has(deadlineAddress) && this.maxRecords !== void 0 && this.pending.size + this.dead.size >= this.maxRecords) throw new InMemoryCapacityExceededError({
|
|
782
863
|
store: "InMemoryDeadlineStore",
|
|
783
864
|
resource: "records",
|
|
@@ -786,26 +867,54 @@ var InMemoryDeadlineStore = class {
|
|
|
786
867
|
attempted: 1
|
|
787
868
|
});
|
|
788
869
|
const sequence = this.nextSequence++;
|
|
789
|
-
|
|
870
|
+
const incarnation = {
|
|
790
871
|
deliveryId: `deadline-${sequence}`,
|
|
791
|
-
scope
|
|
792
|
-
key
|
|
793
|
-
dueAt
|
|
794
|
-
payload
|
|
872
|
+
scope,
|
|
873
|
+
key,
|
|
874
|
+
dueAt,
|
|
875
|
+
payload,
|
|
795
876
|
attempts: 0,
|
|
796
877
|
sequence
|
|
797
|
-
}
|
|
878
|
+
};
|
|
879
|
+
this.recordCommittedVersion(deadlineAddress);
|
|
880
|
+
this.pending.set(deadlineAddress, incarnation);
|
|
798
881
|
}
|
|
799
882
|
async cancel(scope, key) {
|
|
800
|
-
|
|
883
|
+
const deadlineAddress = address(scope, key);
|
|
884
|
+
this.recordCommittedVersion(deadlineAddress);
|
|
885
|
+
this.pending.delete(deadlineAddress);
|
|
801
886
|
}
|
|
802
887
|
async due(now, limit) {
|
|
803
888
|
if (!Number.isInteger(limit) || limit < 0) throw new Error(`InMemoryDeadlineStore: limit must be an integer >= 0, got ${limit}`);
|
|
804
889
|
if (limit === 0) return [];
|
|
805
|
-
return
|
|
890
|
+
return this.committedPending().filter((deadline) => deadline.dueAt.getTime() <= now.getTime()).sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || a.sequence - b.sequence).slice(0, limit).map((deadline) => toRecord(deadline));
|
|
891
|
+
}
|
|
892
|
+
recordCommittedVersion(deadlineAddress) {
|
|
893
|
+
const versions = this.committedVersions;
|
|
894
|
+
if (versions === void 0 || versions.has(deadlineAddress)) return;
|
|
895
|
+
const committed = this.pending.get(deadlineAddress);
|
|
896
|
+
versions.set(deadlineAddress, committed === void 0 ? void 0 : { ...committed });
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* The pending deadlines that a processor may see. For an address that the
|
|
900
|
+
* open transaction wrote, it is the committed deadline, if any, and not
|
|
901
|
+
* the live record.
|
|
902
|
+
*/
|
|
903
|
+
committedPending() {
|
|
904
|
+
const versions = this.committedVersions;
|
|
905
|
+
if (versions === void 0) return [...this.pending.values()];
|
|
906
|
+
const deadlines = [...this.pending].filter(([deadlineAddress]) => !versions.has(deadlineAddress)).map(([, deadline]) => deadline);
|
|
907
|
+
for (const committed of versions.values()) if (committed !== void 0) deadlines.push(committed);
|
|
908
|
+
return deadlines;
|
|
909
|
+
}
|
|
910
|
+
/** The address of the committed deadline with this delivery id, if any. */
|
|
911
|
+
committedVersionAddress(deliveryId) {
|
|
912
|
+
for (const [deadlineAddress, committed] of this.committedVersions ?? []) if (committed?.deliveryId === deliveryId) return deadlineAddress;
|
|
806
913
|
}
|
|
807
914
|
async markDelivered(deliveryIds) {
|
|
808
915
|
for (const deliveryId of deliveryIds) {
|
|
916
|
+
const committedAddress = this.committedVersionAddress(deliveryId);
|
|
917
|
+
if (committedAddress !== void 0) this.committedVersions?.set(committedAddress, void 0);
|
|
809
918
|
this.dead.delete(deliveryId);
|
|
810
919
|
for (const [key, deadline] of this.pending) if (deadline.deliveryId === deliveryId) {
|
|
811
920
|
this.pending.delete(key);
|
|
@@ -825,6 +934,14 @@ var InMemoryDeadlineStore = class {
|
|
|
825
934
|
}
|
|
826
935
|
return;
|
|
827
936
|
}
|
|
937
|
+
const committedAddress = this.committedVersionAddress(deliveryId);
|
|
938
|
+
const committed = committedAddress === void 0 ? void 0 : this.committedVersions?.get(committedAddress);
|
|
939
|
+
if (committedAddress !== void 0 && committed !== void 0) {
|
|
940
|
+
committed.attempts += 1;
|
|
941
|
+
if (error !== void 0) committed.lastError = String(error);
|
|
942
|
+
if (committed.attempts >= this.maxDeliveryAttempts) this.committedVersions?.set(committedAddress, void 0);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
828
945
|
}
|
|
829
946
|
async deadLetters() {
|
|
830
947
|
return [...this.dead.values()].sort((a, b) => a.sequence - b.sequence).map(toDeadLetter);
|
|
@@ -995,9 +1112,9 @@ var PollLoop = class {
|
|
|
995
1112
|
this.pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
996
1113
|
this.baseDelayMs = options.baseDelayMs ?? 50;
|
|
997
1114
|
this.maxDelayMs = options.maxDelayMs ?? 5e3;
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1115
|
+
assertTimerDelay(context, "pollIntervalMs", this.pollIntervalMs);
|
|
1116
|
+
assertTimerDelay(context, "baseDelayMs", this.baseDelayMs);
|
|
1117
|
+
assertTimerDelay(context, "maxDelayMs", this.maxDelayMs);
|
|
1001
1118
|
this.jitter = neutralJitterSource(options.random ?? Math.random);
|
|
1002
1119
|
}
|
|
1003
1120
|
/**
|
|
@@ -1158,8 +1275,8 @@ var DeadlineProcessor = class extends PollLoop {
|
|
|
1158
1275
|
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
1159
1276
|
this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 3e4;
|
|
1160
1277
|
this.storageTimeoutMs = options.storageTimeoutMs ?? 3e4;
|
|
1161
|
-
|
|
1162
|
-
|
|
1278
|
+
assertTimerDelay("DeadlineProcessor", "deliveryTimeoutMs", this.deliveryTimeoutMs);
|
|
1279
|
+
assertTimerDelay("DeadlineProcessor", "storageTimeoutMs", this.storageTimeoutMs);
|
|
1163
1280
|
}
|
|
1164
1281
|
/**
|
|
1165
1282
|
* One full delivery pass (the `run`/`drainOnce` shell lives on
|
|
@@ -1287,14 +1404,26 @@ function positiveSafeInteger(value) {
|
|
|
1287
1404
|
* process must configure the limit or use a durable adapter; exhaustion
|
|
1288
1405
|
* rejects new keys before mutation and never forgets an idempotency decision.
|
|
1289
1406
|
*
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1407
|
+
* On its own, the store is the reference for the leased family: it cannot
|
|
1408
|
+
* see commits or rollbacks. Claims and staged outcomes carry bounded leases,
|
|
1409
|
+
* while every mutation compares the store-minted token. An expired pending
|
|
1410
|
+
* claim may be replaced; an expired staged outcome cannot be guessed away and
|
|
1411
|
+
* instead returns `reconciliation-required`. Only an authoritative
|
|
1412
|
+
* `committed` / `not-committed` decision can settle it.
|
|
1413
|
+
*
|
|
1414
|
+
* Register the store with an `InMemoryTransactionScope` for tests that roll
|
|
1415
|
+
* back or retry. A rollback then undoes the writes of `claim` and `complete`,
|
|
1416
|
+
* which the port runs inside the transaction. It returns each key that they
|
|
1417
|
+
* wrote to its earlier entry. The lease operations `renew`, `confirm`,
|
|
1418
|
+
* `abandon`, and `reconcile` stay out of band, as the port defines them. A
|
|
1419
|
+
* rollback keeps their writes on every other key. A confirmation that
|
|
1420
|
+
* arrives after the next transaction began therefore survives the rollback
|
|
1421
|
+
* of that transaction.
|
|
1295
1422
|
*/
|
|
1296
1423
|
var InMemoryIdempotencyStore = class {
|
|
1297
1424
|
entries = /* @__PURE__ */ new Map();
|
|
1425
|
+
/** The entries that `claim` and `complete` replaced in the open transaction. */
|
|
1426
|
+
replacedInTransaction;
|
|
1298
1427
|
clock;
|
|
1299
1428
|
claimTokenFactory;
|
|
1300
1429
|
leaseDurationMs;
|
|
@@ -1308,73 +1437,76 @@ var InMemoryIdempotencyStore = class {
|
|
|
1308
1437
|
this.renewAfterMs = options.renewAfterMs ?? Math.floor(this.leaseDurationMs / 2);
|
|
1309
1438
|
if (options.maxEntries !== void 0) assertPositiveSafeInteger("InMemoryIdempotencyStore", "maxEntries", options.maxEntries);
|
|
1310
1439
|
this.maxEntries = options.maxEntries;
|
|
1311
|
-
if (!positiveSafeInteger(this.leaseDurationMs) || this.leaseDurationMs > 2147483647) throw new RangeError(
|
|
1312
|
-
if (!positiveSafeInteger(this.renewAfterMs) || this.renewAfterMs >= this.leaseDurationMs || this.renewAfterMs > 2147483647) throw new RangeError(
|
|
1440
|
+
if (!positiveSafeInteger(this.leaseDurationMs) || this.leaseDurationMs > 2147483647) throw new RangeError(`leaseDurationMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
1441
|
+
if (!positiveSafeInteger(this.renewAfterMs) || this.renewAfterMs >= this.leaseDurationMs || this.renewAfterMs > 2147483647) throw new RangeError(`renewAfterMs must be a positive safe integer below leaseDurationMs and no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
1313
1442
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
});
|
|
1325
|
-
return this.createPending(key, fingerprint, now);
|
|
1326
|
-
}
|
|
1327
|
-
if (existing.fingerprint !== fingerprint) throw new IdempotencyKeyReuseError({
|
|
1328
|
-
key,
|
|
1329
|
-
storedFingerprint: existing.fingerprint,
|
|
1330
|
-
receivedFingerprint: fingerprint
|
|
1331
|
-
});
|
|
1332
|
-
if (existing.status === "confirmed") return {
|
|
1333
|
-
status: "completed",
|
|
1334
|
-
outcome: structuredClone(existing.outcome)
|
|
1443
|
+
/**
|
|
1444
|
+
* Starts to record the entries that `claim` and `complete` replace. The
|
|
1445
|
+
* rollback restores them in reverse order. It keeps the token generation,
|
|
1446
|
+
* so a token from a rolled-back claim never names a later claim.
|
|
1447
|
+
*/
|
|
1448
|
+
beginTransaction() {
|
|
1449
|
+
const replaced = [];
|
|
1450
|
+
this.replacedInTransaction = replaced;
|
|
1451
|
+
const end = () => {
|
|
1452
|
+
if (this.replacedInTransaction === replaced) this.replacedInTransaction = void 0;
|
|
1335
1453
|
};
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
key,
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
expiredAt: new Date(existing.expiresAtMs).toISOString()
|
|
1344
|
-
})
|
|
1454
|
+
return {
|
|
1455
|
+
commit: end,
|
|
1456
|
+
rollback: () => {
|
|
1457
|
+
for (const [key, entry] of [...replaced].reverse()) if (entry === void 0) this.entries.delete(key);
|
|
1458
|
+
else this.entries.set(key, entry);
|
|
1459
|
+
end();
|
|
1460
|
+
}
|
|
1345
1461
|
};
|
|
1346
|
-
|
|
1462
|
+
}
|
|
1463
|
+
async claim(_ctx, key, fingerprint) {
|
|
1464
|
+
const now = this.nowMs();
|
|
1465
|
+
const decided = this.decideFromStoredEntry(key, fingerprint, now);
|
|
1466
|
+
if (decided !== void 0) return decided;
|
|
1467
|
+
const tokenPart = this.newTokenPart();
|
|
1468
|
+
return this.decideFromStoredEntry(key, fingerprint, now) ?? this.createPending(key, fingerprint, now, tokenPart);
|
|
1347
1469
|
}
|
|
1348
1470
|
async complete(_ctx, claim, outcome) {
|
|
1471
|
+
const { key, token } = claim;
|
|
1472
|
+
const owned = structuredClone(outcome);
|
|
1349
1473
|
const now = this.nowMs();
|
|
1350
|
-
const existing = this.entries.get(
|
|
1351
|
-
if (existing === void 0) throw new IdempotencyCompletionWithoutClaimError(
|
|
1352
|
-
if (existing.status !== "pending" || existing.token !==
|
|
1474
|
+
const existing = this.entries.get(key);
|
|
1475
|
+
if (existing === void 0) throw new IdempotencyCompletionWithoutClaimError(key);
|
|
1476
|
+
if (existing.status !== "pending" || existing.token !== token || now >= existing.expiresAtMs) throw this.claimLost({
|
|
1477
|
+
key,
|
|
1478
|
+
token
|
|
1479
|
+
});
|
|
1353
1480
|
const expiresAtMs = now + this.leaseDurationMs;
|
|
1354
1481
|
this.lease(expiresAtMs);
|
|
1355
|
-
this.
|
|
1482
|
+
this.writeInTransaction(key, {
|
|
1356
1483
|
fingerprint: existing.fingerprint,
|
|
1357
1484
|
status: "staged",
|
|
1358
1485
|
token: existing.token,
|
|
1359
1486
|
expiresAtMs,
|
|
1360
|
-
outcome:
|
|
1487
|
+
outcome: owned
|
|
1361
1488
|
});
|
|
1362
1489
|
}
|
|
1363
1490
|
async renew(claim) {
|
|
1491
|
+
const { key, token } = claim;
|
|
1364
1492
|
const now = this.nowMs();
|
|
1365
|
-
const existing = this.entries.get(
|
|
1366
|
-
if (existing === void 0 || existing.status === "confirmed" || existing.token !==
|
|
1493
|
+
const existing = this.entries.get(key);
|
|
1494
|
+
if (existing === void 0 || existing.status === "confirmed" || existing.token !== token || now >= existing.expiresAtMs) throw this.claimLost({
|
|
1495
|
+
key,
|
|
1496
|
+
token
|
|
1497
|
+
});
|
|
1367
1498
|
const expiresAtMs = now + this.leaseDurationMs;
|
|
1368
1499
|
const lease = this.lease(expiresAtMs);
|
|
1369
|
-
this.entries.set(
|
|
1500
|
+
this.entries.set(key, {
|
|
1370
1501
|
...existing,
|
|
1371
1502
|
expiresAtMs
|
|
1372
1503
|
});
|
|
1373
1504
|
return lease;
|
|
1374
1505
|
}
|
|
1375
1506
|
async confirm(claim) {
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1507
|
+
const { key, token } = claim;
|
|
1508
|
+
const existing = this.entries.get(key);
|
|
1509
|
+
if (existing?.status === "staged" && existing.token === token) this.entries.set(key, {
|
|
1378
1510
|
fingerprint: existing.fingerprint,
|
|
1379
1511
|
status: "confirmed",
|
|
1380
1512
|
token: existing.token,
|
|
@@ -1382,18 +1514,21 @@ var InMemoryIdempotencyStore = class {
|
|
|
1382
1514
|
});
|
|
1383
1515
|
}
|
|
1384
1516
|
async abandon(claim) {
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1517
|
+
const { key, token } = claim;
|
|
1518
|
+
const existing = this.entries.get(key);
|
|
1519
|
+
if (existing !== void 0 && existing.status !== "confirmed" && existing.token === token) this.entries.delete(key);
|
|
1387
1520
|
}
|
|
1388
1521
|
async reconcile(reconciliation, decision) {
|
|
1522
|
+
const { key, token, fingerprint, expiredAt } = reconciliation;
|
|
1389
1523
|
if (decision !== "committed" && decision !== "not-committed") throw new TypeError("reconcile decision must be committed or not-committed; uncertainty must leave the record untouched");
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1524
|
+
const now = this.nowMs();
|
|
1525
|
+
const existing = this.entries.get(key);
|
|
1526
|
+
if (existing === void 0 || existing.status !== "staged" || existing.token !== token || existing.fingerprint !== fingerprint || new Date(existing.expiresAtMs).toISOString() !== expiredAt || now < existing.expiresAtMs) throw new IdempotencyClaimLostError({
|
|
1527
|
+
key,
|
|
1528
|
+
token
|
|
1394
1529
|
});
|
|
1395
1530
|
if (decision === "committed") {
|
|
1396
|
-
this.entries.set(
|
|
1531
|
+
this.entries.set(key, {
|
|
1397
1532
|
fingerprint: existing.fingerprint,
|
|
1398
1533
|
status: "confirmed",
|
|
1399
1534
|
token: existing.token,
|
|
@@ -1401,7 +1536,7 @@ var InMemoryIdempotencyStore = class {
|
|
|
1401
1536
|
});
|
|
1402
1537
|
return;
|
|
1403
1538
|
}
|
|
1404
|
-
this.entries.delete(
|
|
1539
|
+
this.entries.delete(key);
|
|
1405
1540
|
}
|
|
1406
1541
|
/** Test hook: number of stored records in any state. */
|
|
1407
1542
|
get size() {
|
|
@@ -1411,15 +1546,55 @@ var InMemoryIdempotencyStore = class {
|
|
|
1411
1546
|
clear() {
|
|
1412
1547
|
this.entries.clear();
|
|
1413
1548
|
}
|
|
1414
|
-
|
|
1549
|
+
/**
|
|
1550
|
+
* Returns the claim that the stored entry of the key decides, or
|
|
1551
|
+
* `undefined` when the store can create a pending claim. Throws for a
|
|
1552
|
+
* reused key, a claim in flight, and a full store.
|
|
1553
|
+
*/
|
|
1554
|
+
decideFromStoredEntry(key, fingerprint, now) {
|
|
1555
|
+
const existing = this.entries.get(key);
|
|
1556
|
+
if (existing === void 0) {
|
|
1557
|
+
if (this.maxEntries !== void 0 && this.entries.size >= this.maxEntries) throw new InMemoryCapacityExceededError({
|
|
1558
|
+
store: "InMemoryIdempotencyStore",
|
|
1559
|
+
resource: "entries",
|
|
1560
|
+
limit: this.maxEntries,
|
|
1561
|
+
current: this.entries.size,
|
|
1562
|
+
attempted: 1
|
|
1563
|
+
});
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (existing.fingerprint !== fingerprint) throw new IdempotencyKeyReuseError({
|
|
1567
|
+
key,
|
|
1568
|
+
storedFingerprint: existing.fingerprint,
|
|
1569
|
+
receivedFingerprint: fingerprint
|
|
1570
|
+
});
|
|
1571
|
+
if (existing.status === "confirmed") return {
|
|
1572
|
+
status: "completed",
|
|
1573
|
+
outcome: structuredClone(existing.outcome)
|
|
1574
|
+
};
|
|
1575
|
+
if (now < existing.expiresAtMs) throw new IdempotencyInFlightError({ key });
|
|
1576
|
+
if (existing.status === "staged") return {
|
|
1577
|
+
status: "reconciliation-required",
|
|
1578
|
+
reconciliation: Object.freeze({
|
|
1579
|
+
key,
|
|
1580
|
+
fingerprint,
|
|
1581
|
+
token: existing.token,
|
|
1582
|
+
expiredAt: new Date(existing.expiresAtMs).toISOString()
|
|
1583
|
+
})
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
newTokenPart() {
|
|
1415
1587
|
const tokenPart = this.claimTokenFactory();
|
|
1416
1588
|
if (typeof tokenPart !== "string" || tokenPart.length === 0) throw new TypeError("claimTokenFactory must return a non-empty string");
|
|
1589
|
+
return tokenPart;
|
|
1590
|
+
}
|
|
1591
|
+
createPending(key, fingerprint, now, tokenPart) {
|
|
1417
1592
|
this.tokenGeneration += 1;
|
|
1418
1593
|
if (!Number.isSafeInteger(this.tokenGeneration)) throw new RangeError("idempotency claim-token generation exhausted");
|
|
1419
1594
|
const token = `${this.tokenGeneration}:${tokenPart}`;
|
|
1420
1595
|
const expiresAtMs = now + this.leaseDurationMs;
|
|
1421
1596
|
const lease = this.lease(expiresAtMs);
|
|
1422
|
-
this.
|
|
1597
|
+
this.writeInTransaction(key, {
|
|
1423
1598
|
fingerprint,
|
|
1424
1599
|
status: "pending",
|
|
1425
1600
|
token,
|
|
@@ -1434,6 +1609,10 @@ var InMemoryIdempotencyStore = class {
|
|
|
1434
1609
|
})
|
|
1435
1610
|
};
|
|
1436
1611
|
}
|
|
1612
|
+
writeInTransaction(key, entry) {
|
|
1613
|
+
this.replacedInTransaction?.push([key, this.entries.get(key)]);
|
|
1614
|
+
this.entries.set(key, entry);
|
|
1615
|
+
}
|
|
1437
1616
|
lease(expiresAtMs) {
|
|
1438
1617
|
return Object.freeze({
|
|
1439
1618
|
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
@@ -1467,7 +1646,7 @@ function startLeaseHeartbeat(store, claim) {
|
|
|
1467
1646
|
let heartbeatFailure;
|
|
1468
1647
|
const schedule = (delayMs) => {
|
|
1469
1648
|
if (!validRenewAfterMs(delayMs)) {
|
|
1470
|
-
heartbeatFailure = /* @__PURE__ */ new TypeError(
|
|
1649
|
+
heartbeatFailure = /* @__PURE__ */ new TypeError(`IdempotencyStore returned an invalid lease renewAfterMs; expected a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
1471
1650
|
return;
|
|
1472
1651
|
}
|
|
1473
1652
|
timer = setTimeout(() => {
|
|
@@ -1677,13 +1856,13 @@ async function withIdempotentCommit(deps, request, fn) {
|
|
|
1677
1856
|
}
|
|
1678
1857
|
|
|
1679
1858
|
//#endregion
|
|
1680
|
-
//#region src/domain/aggregate/aggregate-
|
|
1859
|
+
//#region src/domain/aggregate/aggregate-identity.ts
|
|
1681
1860
|
/**
|
|
1682
1861
|
* Collision-safe map-key encoding shared by the in-memory adapters.
|
|
1683
1862
|
* Internal: durable adapters key on the two storage columns themselves.
|
|
1684
1863
|
*/
|
|
1685
|
-
function
|
|
1686
|
-
return JSON.stringify([
|
|
1864
|
+
function encodeAggregateIdentity(identity) {
|
|
1865
|
+
return JSON.stringify([identity.aggregateType, identity.aggregateId]);
|
|
1687
1866
|
}
|
|
1688
1867
|
|
|
1689
1868
|
//#endregion
|
|
@@ -1707,16 +1886,19 @@ function isPositionAfter(candidate, reference) {
|
|
|
1707
1886
|
* serves tests and in-memory read models.
|
|
1708
1887
|
*
|
|
1709
1888
|
* Its checkpoint-key locks serialize competing projectors only inside one
|
|
1710
|
-
* process and only when they share this store instance.
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1889
|
+
* process and only when they share this store instance. The store ignores
|
|
1890
|
+
* the `ctx` parameter. On its own, it knows nothing about your
|
|
1891
|
+
* `TransactionScope` rollbacks: a rolled-back projector batch keeps its
|
|
1892
|
+
* checkpoints. Register the store with an `InMemoryTransactionScope` for
|
|
1893
|
+
* tests that roll back or retry. While a transaction is open, `hasReached`
|
|
1894
|
+
* answers from the checkpoints at the start of the transaction. Use it for
|
|
1895
|
+
* tests and disposable in-memory read models; production atomicity is the
|
|
1896
|
+
* durable adapter's contract, proved with
|
|
1897
|
+
* `createProjectionCheckpointStoreContractTests` and its rollback capability.
|
|
1716
1898
|
*
|
|
1717
1899
|
* Without `maxCheckpoints`, checkpoint retention is unbounded and supported
|
|
1718
1900
|
* only for finite-lifetime tests and demos. A configured limit rejects a new
|
|
1719
|
-
*
|
|
1901
|
+
* identity before mutation; existing watermarks remain updatable and are never
|
|
1720
1902
|
* evicted because forgetting one would change projection correctness.
|
|
1721
1903
|
*
|
|
1722
1904
|
* Do not nest `withCheckpointLocks` calls whose key sets overlap. This
|
|
@@ -1731,15 +1913,40 @@ var InMemoryProjectionCheckpointStore = class {
|
|
|
1731
1913
|
lockTails = /* @__PURE__ */ new Map();
|
|
1732
1914
|
maxCheckpoints;
|
|
1733
1915
|
checkpointCount = 0;
|
|
1916
|
+
/** The checkpoints at the start of the open transaction. */
|
|
1917
|
+
committedCheckpoints;
|
|
1734
1918
|
constructor(options = {}) {
|
|
1735
1919
|
if (options.maxCheckpoints !== void 0) assertPositiveSafeInteger("InMemoryProjectionCheckpointStore", "maxCheckpoints", options.maxCheckpoints);
|
|
1736
1920
|
this.maxCheckpoints = options.maxCheckpoints;
|
|
1737
1921
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1922
|
+
/**
|
|
1923
|
+
* Records the state that a rollback of an `InMemoryTransactionScope`
|
|
1924
|
+
* returns to: the checkpoints and their count. Until the transaction
|
|
1925
|
+
* ends, `hasReached` reads these recorded checkpoints. The rollback
|
|
1926
|
+
* keeps the checkpoint locks, because a caller still holds them.
|
|
1927
|
+
*/
|
|
1928
|
+
beginTransaction() {
|
|
1929
|
+
const committed = new Map([...this.checkpoints].map(([projection, perAggregate]) => [projection, new Map(perAggregate)]));
|
|
1930
|
+
const checkpointCount = this.checkpointCount;
|
|
1931
|
+
this.committedCheckpoints = committed;
|
|
1932
|
+
const end = () => {
|
|
1933
|
+
if (this.committedCheckpoints === committed) this.committedCheckpoints = void 0;
|
|
1934
|
+
};
|
|
1935
|
+
return {
|
|
1936
|
+
commit: end,
|
|
1937
|
+
rollback: () => {
|
|
1938
|
+
this.checkpoints.clear();
|
|
1939
|
+
for (const [projection, perAggregate] of committed) this.checkpoints.set(projection, new Map(perAggregate));
|
|
1940
|
+
this.checkpointCount = checkpointCount;
|
|
1941
|
+
end();
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
async withCheckpointLocks(_ctx, projection, identities, work) {
|
|
1946
|
+
const keys = [...new Set(identities.map((identity) => JSON.stringify([
|
|
1740
1947
|
projection,
|
|
1741
|
-
|
|
1742
|
-
|
|
1948
|
+
identity.aggregateType,
|
|
1949
|
+
identity.aggregateId
|
|
1743
1950
|
])))].sort();
|
|
1744
1951
|
const releases = [];
|
|
1745
1952
|
for (const key of keys) {
|
|
@@ -1762,17 +1969,22 @@ var InMemoryProjectionCheckpointStore = class {
|
|
|
1762
1969
|
for (let index = releases.length - 1; index >= 0; index -= 1) releases[index]?.();
|
|
1763
1970
|
}
|
|
1764
1971
|
}
|
|
1765
|
-
async load(_ctx, projection,
|
|
1766
|
-
const stored = this.checkpoints.get(projection)?.get(
|
|
1972
|
+
async load(_ctx, projection, identity) {
|
|
1973
|
+
const stored = this.checkpoints.get(projection)?.get(encodeAggregateIdentity(identity));
|
|
1767
1974
|
return stored === void 0 ? void 0 : {
|
|
1768
1975
|
...stored,
|
|
1769
1976
|
position: { ...stored.position }
|
|
1770
1977
|
};
|
|
1771
1978
|
}
|
|
1772
|
-
async save(_ctx, projection,
|
|
1773
|
-
const
|
|
1979
|
+
async save(_ctx, projection, identity, checkpoint) {
|
|
1980
|
+
const identityKey = encodeAggregateIdentity(identity);
|
|
1981
|
+
const { position, ...rest } = checkpoint;
|
|
1982
|
+
const owned = {
|
|
1983
|
+
...rest,
|
|
1984
|
+
position: { ...position }
|
|
1985
|
+
};
|
|
1774
1986
|
let perAggregate = this.checkpoints.get(projection);
|
|
1775
|
-
const isNewCheckpoint = perAggregate?.has(
|
|
1987
|
+
const isNewCheckpoint = perAggregate?.has(identityKey) !== true;
|
|
1776
1988
|
if (isNewCheckpoint && this.maxCheckpoints !== void 0 && this.checkpointCount >= this.maxCheckpoints) throw new InMemoryCapacityExceededError({
|
|
1777
1989
|
store: "InMemoryProjectionCheckpointStore",
|
|
1778
1990
|
resource: "checkpoints",
|
|
@@ -1784,14 +1996,11 @@ var InMemoryProjectionCheckpointStore = class {
|
|
|
1784
1996
|
perAggregate = /* @__PURE__ */ new Map();
|
|
1785
1997
|
this.checkpoints.set(projection, perAggregate);
|
|
1786
1998
|
}
|
|
1787
|
-
perAggregate.set(
|
|
1788
|
-
...checkpoint,
|
|
1789
|
-
position: { ...checkpoint.position }
|
|
1790
|
-
});
|
|
1999
|
+
perAggregate.set(identityKey, owned);
|
|
1791
2000
|
if (isNewCheckpoint) this.checkpointCount += 1;
|
|
1792
2001
|
}
|
|
1793
|
-
async hasReached(projection,
|
|
1794
|
-
const stored = this.checkpoints.get(projection)?.get(
|
|
2002
|
+
async hasReached(projection, identity, position) {
|
|
2003
|
+
const stored = (this.committedCheckpoints ?? this.checkpoints).get(projection)?.get(encodeAggregateIdentity(identity));
|
|
1795
2004
|
if (stored === void 0) return false;
|
|
1796
2005
|
return !isPositionAfter(position, stored.position);
|
|
1797
2006
|
}
|
|
@@ -1870,8 +2079,8 @@ function projectionFromHandlers(options) {
|
|
|
1870
2079
|
* Because checkpoints advance only across a verified chain, a position
|
|
1871
2080
|
* at or behind the watermark is already traversed and can be skipped under
|
|
1872
2081
|
* the source's one-logical-event-per-position contract.
|
|
1873
|
-
* - **Feeds are complete per aggregate
|
|
1874
|
-
*
|
|
2082
|
+
* - **Feeds are complete per aggregate identity.** Once a feed supplies one
|
|
2083
|
+
* identity, it must supply every committed envelope in that aggregate's cursor
|
|
1875
2084
|
* chain. Do not event-type-filter a projector subscription. Irrelevant event
|
|
1876
2085
|
* types are explicit no-ops in `Projection.apply`; invoking the handler and
|
|
1877
2086
|
* checkpointing their positions preserves continuity.
|
|
@@ -1890,13 +2099,13 @@ function projectionFromHandlers(options) {
|
|
|
1890
2099
|
* redeliveries.
|
|
1891
2100
|
* - **Malformed envelopes reject loudly.** A missing/empty `eventId`, a
|
|
1892
2101
|
* missing/invalid `position`, missing `source.aggregateId` /
|
|
1893
|
-
* `source.aggregateType`, or an optional event
|
|
2102
|
+
* `source.aggregateType`, or an optional event identity contradicting its
|
|
1894
2103
|
* authoritative envelope source fails the batch BEFORE anything is applied.
|
|
1895
2104
|
* The domain event remains persistence-agnostic.
|
|
1896
2105
|
* - **Competing instances serialize by checkpoint key.** The required
|
|
1897
2106
|
* {@link ProjectionCheckpointStore.withCheckpointLocks} callback covers the
|
|
1898
|
-
* complete load / apply / save critical section for every
|
|
1899
|
-
*
|
|
2107
|
+
* complete load / apply / save critical section for every aggregate of
|
|
2108
|
+
* the batch. The adapter must lock a key even when its checkpoint row does
|
|
1900
2109
|
* not exist yet; a plain row lock is insufficient at genesis. Without that
|
|
1901
2110
|
* adapter guarantee, only a hard single-projector deployment is safe.
|
|
1902
2111
|
*
|
|
@@ -1924,7 +2133,7 @@ var Projector = class {
|
|
|
1924
2133
|
* per advanced aggregate. Rejects
|
|
1925
2134
|
* (after rollback) when a handler throws or an envelope carries no
|
|
1926
2135
|
* valid cursor; the caller's at-least-once redelivery retries the batch.
|
|
1927
|
-
* The input must be a complete, ordered feed per aggregate
|
|
2136
|
+
* The input must be a complete, ordered feed per aggregate identity; an
|
|
1928
2137
|
* event-type-filtered subscription cannot satisfy the cursor contract.
|
|
1929
2138
|
* An already-aborted signal rejects before validation or transaction setup.
|
|
1930
2139
|
* In-flight cancellation is forwarded to the transaction scope rather than
|
|
@@ -1955,36 +2164,36 @@ var Projector = class {
|
|
|
1955
2164
|
return {
|
|
1956
2165
|
event,
|
|
1957
2166
|
position,
|
|
1958
|
-
|
|
2167
|
+
identity: {
|
|
1959
2168
|
aggregateType,
|
|
1960
2169
|
aggregateId
|
|
1961
2170
|
}
|
|
1962
2171
|
};
|
|
1963
2172
|
});
|
|
1964
|
-
const
|
|
2173
|
+
const lockIdentities = [...new Map(cursored.map(({ identity }) => [encodeAggregateIdentity(identity), identity])).entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, identity]) => identity);
|
|
1965
2174
|
const projectWithLocks = async (ctx) => {
|
|
1966
2175
|
let applied = 0;
|
|
1967
2176
|
let skipped = 0;
|
|
1968
2177
|
const checkpointsAtBatchStart = /* @__PURE__ */ new Map();
|
|
1969
2178
|
const watermarks = /* @__PURE__ */ new Map();
|
|
1970
|
-
for (const { event,
|
|
1971
|
-
const key =
|
|
2179
|
+
for (const { event, identity } of cursored) {
|
|
2180
|
+
const key = encodeAggregateIdentity(identity);
|
|
1972
2181
|
if (watermarks.has(key)) continue;
|
|
1973
|
-
const stored = await this.checkpoints.load(ctx, this.projection.name,
|
|
2182
|
+
const stored = await this.checkpoints.load(ctx, this.projection.name, identity);
|
|
1974
2183
|
if (stored !== void 0 && !isValidCheckpoint(stored)) throw new UnprojectableEventError(this.projection.name, event.eventId, "found a stored checkpoint with an invalid or legacy cursor. Migrate the commitSize/previousEventfulAggregateVersion/lastAppliedEventId columns or reset and rebuild this projection.");
|
|
1975
2184
|
checkpointsAtBatchStart.set(key, stored);
|
|
1976
2185
|
watermarks.set(key, stored?.position);
|
|
1977
2186
|
}
|
|
1978
|
-
for (const { event, position,
|
|
1979
|
-
const stored = checkpointsAtBatchStart.get(
|
|
2187
|
+
for (const { event, position, identity } of cursored) {
|
|
2188
|
+
const stored = checkpointsAtBatchStart.get(encodeAggregateIdentity(identity));
|
|
1980
2189
|
if (stored !== void 0 && isSameOrderedPosition(position, stored.position)) {
|
|
1981
2190
|
if (event.eventId !== stored.lastAppliedEventId) throw new ProjectionIdentityViolationError(this.projection.name, event.eventId, stored.lastAppliedEventId, formatPosition(position));
|
|
1982
2191
|
if (!isSamePositionReceipt(position, stored.position)) throw new ProjectionReceiptViolationError(this.projection.name, event.eventId, formatReceipt(stored.position), formatReceipt(position));
|
|
1983
2192
|
}
|
|
1984
2193
|
}
|
|
1985
2194
|
const batchReceiptsByPosition = /* @__PURE__ */ new Map();
|
|
1986
|
-
for (const { event, position,
|
|
1987
|
-
const key =
|
|
2195
|
+
for (const { event, position, identity } of cursored) {
|
|
2196
|
+
const key = aggregatePositionKey(identity, position);
|
|
1988
2197
|
const recorded = batchReceiptsByPosition.get(key);
|
|
1989
2198
|
if (recorded !== void 0 && recorded.eventId !== event.eventId) throw new ProjectionIdentityViolationError(this.projection.name, event.eventId, recorded.eventId, formatPosition(position));
|
|
1990
2199
|
if (recorded !== void 0 && !isSamePositionReceipt(position, recorded.position)) throw new ProjectionReceiptViolationError(this.projection.name, event.eventId, formatReceipt(recorded.position), formatReceipt(position));
|
|
@@ -1995,11 +2204,11 @@ var Projector = class {
|
|
|
1995
2204
|
}
|
|
1996
2205
|
const newestUnprocessed = /* @__PURE__ */ new Map();
|
|
1997
2206
|
const positionsSeenInBatch = /* @__PURE__ */ new Set();
|
|
1998
|
-
for (const { event, position,
|
|
1999
|
-
const key =
|
|
2207
|
+
for (const { event, position, identity } of cursored) {
|
|
2208
|
+
const key = encodeAggregateIdentity(identity);
|
|
2000
2209
|
const stored = watermarks.get(key);
|
|
2001
2210
|
if (stored !== void 0 && !isPositionAfter(position, stored)) continue;
|
|
2002
|
-
const positionKey =
|
|
2211
|
+
const positionKey = aggregatePositionKey(identity, position);
|
|
2003
2212
|
if (positionsSeenInBatch.has(positionKey)) continue;
|
|
2004
2213
|
positionsSeenInBatch.add(positionKey);
|
|
2005
2214
|
const newest = newestUnprocessed.get(key);
|
|
@@ -2008,8 +2217,8 @@ var Projector = class {
|
|
|
2008
2217
|
}
|
|
2009
2218
|
const advanced = /* @__PURE__ */ new Map();
|
|
2010
2219
|
const toApply = [];
|
|
2011
|
-
for (const { event, position,
|
|
2012
|
-
const key =
|
|
2220
|
+
for (const { event, position, identity } of cursored) {
|
|
2221
|
+
const key = encodeAggregateIdentity(identity);
|
|
2013
2222
|
const watermark = watermarks.get(key);
|
|
2014
2223
|
if (watermark !== void 0 && !isPositionAfter(position, watermark)) {
|
|
2015
2224
|
skipped += 1;
|
|
@@ -2018,7 +2227,7 @@ var Projector = class {
|
|
|
2018
2227
|
if (!isContiguousPosition(position, watermark)) throw new ProjectionGapError(this.projection.name, event.eventId, formatPosition(watermark), formatPosition(position));
|
|
2019
2228
|
watermarks.set(key, position);
|
|
2020
2229
|
advanced.set(key, {
|
|
2021
|
-
|
|
2230
|
+
identity,
|
|
2022
2231
|
checkpoint: {
|
|
2023
2232
|
position,
|
|
2024
2233
|
lastAppliedEventId: event.eventId
|
|
@@ -2028,23 +2237,23 @@ var Projector = class {
|
|
|
2028
2237
|
applied += 1;
|
|
2029
2238
|
}
|
|
2030
2239
|
for (const { event } of toApply) await this.projection.apply(ctx, event);
|
|
2031
|
-
for (const {
|
|
2240
|
+
for (const { identity, checkpoint } of advanced.values()) await this.checkpoints.save(ctx, this.projection.name, identity, checkpoint);
|
|
2032
2241
|
return {
|
|
2033
2242
|
applied,
|
|
2034
2243
|
skipped
|
|
2035
2244
|
};
|
|
2036
2245
|
};
|
|
2037
|
-
return this.scope.transactional((ctx) => this.checkpoints.withCheckpointLocks(ctx, this.projection.name,
|
|
2246
|
+
return this.scope.transactional((ctx) => this.checkpoints.withCheckpointLocks(ctx, this.projection.name, lockIdentities, () => projectWithLocks(ctx)), { signal: options.signal });
|
|
2038
2247
|
}
|
|
2039
2248
|
/**
|
|
2040
2249
|
* The wait-for-version query: `true` when this projection has
|
|
2041
|
-
* processed the
|
|
2250
|
+
* processed the given aggregate at least up to `position`. Pass the
|
|
2042
2251
|
* position of the last event the awaited commit emitted (see
|
|
2043
2252
|
* {@link ProjectionCheckpointStore.hasReached} for why the full
|
|
2044
2253
|
* cursor, not just the version).
|
|
2045
2254
|
*/
|
|
2046
|
-
hasProcessed(
|
|
2047
|
-
return this.checkpoints.hasReached(this.projection.name,
|
|
2255
|
+
hasProcessed(identity, position) {
|
|
2256
|
+
return this.checkpoints.hasReached(this.projection.name, identity, position);
|
|
2048
2257
|
}
|
|
2049
2258
|
/**
|
|
2050
2259
|
* Rebuild entry point: one transaction that clears this
|
|
@@ -2052,7 +2261,7 @@ var Projector = class {
|
|
|
2052
2261
|
* `truncate`, the read model with them. Replay the source through
|
|
2053
2262
|
* {@link Projector.project} afterwards. Stop all live consumers for this
|
|
2054
2263
|
* projection before reset and keep them stopped through catch-up replay;
|
|
2055
|
-
* rebuild is not coordinated by the per-
|
|
2264
|
+
* rebuild is not coordinated by the per-aggregate delivery locks.
|
|
2056
2265
|
*/
|
|
2057
2266
|
async reset() {
|
|
2058
2267
|
await this.scope.transactional(async (ctx) => {
|
|
@@ -2098,10 +2307,10 @@ function isSameOrderedPosition(left, right) {
|
|
|
2098
2307
|
function isSamePositionReceipt(left, right) {
|
|
2099
2308
|
return isSameOrderedPosition(left, right) && left.commitSize === right.commitSize && left.previousEventfulAggregateVersion === right.previousEventfulAggregateVersion;
|
|
2100
2309
|
}
|
|
2101
|
-
function
|
|
2310
|
+
function aggregatePositionKey(identity, position) {
|
|
2102
2311
|
return JSON.stringify([
|
|
2103
|
-
|
|
2104
|
-
|
|
2312
|
+
identity.aggregateType,
|
|
2313
|
+
identity.aggregateId,
|
|
2105
2314
|
position.aggregateVersion,
|
|
2106
2315
|
position.commitSequence
|
|
2107
2316
|
]);
|
|
@@ -2121,6 +2330,33 @@ function formatReceipt(position) {
|
|
|
2121
2330
|
return `(${position.aggregateVersion}, ${position.commitSequence}; commitSize=${position.commitSize}, previousEventfulAggregateVersion=${String(position.previousEventfulAggregateVersion)})`;
|
|
2122
2331
|
}
|
|
2123
2332
|
|
|
2333
|
+
//#endregion
|
|
2334
|
+
//#region src/internal/cause-chain.ts
|
|
2335
|
+
/**
|
|
2336
|
+
* Cycle-safe, getter-throw-safe walk over `error`'s standard `cause`
|
|
2337
|
+
* chain. `visit` runs for every object link (the top error included) and
|
|
2338
|
+
* receives the link plus its lazily read `cause`; a non-undefined return
|
|
2339
|
+
* stops the walk. A throwing `cause` getter (lazy deserialization, revoked
|
|
2340
|
+
* Proxy) ends the walk as no-match instead of replacing the real failure
|
|
2341
|
+
* with the getter's exception.
|
|
2342
|
+
*/
|
|
2343
|
+
function findInCauseChain(error, visit) {
|
|
2344
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2345
|
+
let current = error;
|
|
2346
|
+
while (current !== null && typeof current === "object" && !seen.has(current)) {
|
|
2347
|
+
seen.add(current);
|
|
2348
|
+
let cause;
|
|
2349
|
+
try {
|
|
2350
|
+
cause = current.cause;
|
|
2351
|
+
} catch {
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const found = visit(current, cause);
|
|
2355
|
+
if (found !== void 0) return found;
|
|
2356
|
+
current = cause;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2124
2360
|
//#endregion
|
|
2125
2361
|
//#region src/application/unit-of-work/errors.ts
|
|
2126
2362
|
/**
|
|
@@ -2147,10 +2383,13 @@ var NestedUnitOfWorkError = class extends KitWiringError {
|
|
|
2147
2383
|
}
|
|
2148
2384
|
};
|
|
2149
2385
|
/**
|
|
2150
|
-
* Thrown when the unit-of-work context is used after
|
|
2151
|
-
*
|
|
2152
|
-
* `tracking.trackLoaded`, or using a repository facade after the
|
|
2153
|
-
*
|
|
2386
|
+
* Thrown when the unit-of-work context is used after the unit of work closed
|
|
2387
|
+
* it: reading `context.repositories`, calling an adapter-held
|
|
2388
|
+
* `tracking.trackLoaded`, or using a repository facade after the flush of its
|
|
2389
|
+
* attempt, while the transaction commits, or after it committed or rolled
|
|
2390
|
+
* back. An `add`, `update`, or `remove` between the start of the flush and the
|
|
2391
|
+
* last check before the commit fails the attempt with `AggregateTrackingError`
|
|
2392
|
+
* instead.
|
|
2154
2393
|
*
|
|
2155
2394
|
* Use-after-close is a programming bug (typically a leaked context
|
|
2156
2395
|
* reference or a fire-and-forget promise outliving the callback), so
|
|
@@ -2166,18 +2405,22 @@ var NestedUnitOfWorkError = class extends KitWiringError {
|
|
|
2166
2405
|
var TransactionClosedError = class extends KitWiringError {
|
|
2167
2406
|
operation;
|
|
2168
2407
|
constructor(operation) {
|
|
2169
|
-
super("TRANSACTION_CLOSED", `Unit of work is closed: ${operation} was called after
|
|
2408
|
+
super("TRANSACTION_CLOSED", `Unit of work is closed: ${operation} was called after its attempt ended. Do not use the context, the repository facade, or the tracking capability outside the run() callback, and await all work inside it.`);
|
|
2170
2409
|
this.operation = operation;
|
|
2171
2410
|
}
|
|
2172
2411
|
};
|
|
2173
2412
|
/** A repository factory returned a value that cannot be wrapped as a facade. */
|
|
2174
2413
|
var InvalidRepositoryAdapterError = class extends KitWiringError {
|
|
2175
2414
|
repository;
|
|
2415
|
+
reason;
|
|
2176
2416
|
receivedType;
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
this.
|
|
2417
|
+
operation;
|
|
2418
|
+
constructor(options) {
|
|
2419
|
+
super("INVALID_REPOSITORY_ADAPTER", options.reason === "not_an_object" ? `Repository factory "${options.repository}" returned ${options.receivedType}; it must return an adapter object.` : `Repository factory "${options.repository}" returned an adapter that defines ${options.operation}. The unit of work installs add, update, and remove on the facade, so an adapter must not define them. Remove ${options.operation} from the adapter.`);
|
|
2420
|
+
this.repository = options.repository;
|
|
2421
|
+
this.reason = options.reason;
|
|
2422
|
+
this.receivedType = options.reason === "not_an_object" ? options.receivedType : void 0;
|
|
2423
|
+
this.operation = options.reason === "defines_lifecycle_operation" ? options.operation : void 0;
|
|
2181
2424
|
}
|
|
2182
2425
|
};
|
|
2183
2426
|
/** A Unit of Work received repository wiring that bypassed {@link defineRepository}. */
|
|
@@ -2190,12 +2433,12 @@ var InvalidRepositoryDefinitionError = class extends KitWiringError {
|
|
|
2190
2433
|
};
|
|
2191
2434
|
/** A repository's persistence-error policy threw or returned a non-kit error. */
|
|
2192
2435
|
var RepositoryErrorMappingFailedError = class extends KitWiringError {
|
|
2193
|
-
|
|
2436
|
+
identity;
|
|
2194
2437
|
intent;
|
|
2195
2438
|
mapperCause;
|
|
2196
2439
|
constructor(options) {
|
|
2197
|
-
super("REPOSITORY_ERROR_MAPPING_FAILED", `The repository error mapper failed for ${options.intent} of aggregate ${options.
|
|
2198
|
-
this.
|
|
2440
|
+
super("REPOSITORY_ERROR_MAPPING_FAILED", `The repository error mapper failed for ${options.intent} of aggregate ${describeAggregateIdentity(options.identity)}. The original persistence failure is preserved as cause; the mapper failure is available as mapperCause.`, options.persistenceError);
|
|
2441
|
+
this.identity = detachAggregateIdentity(options.identity);
|
|
2199
2442
|
this.intent = options.intent;
|
|
2200
2443
|
this.mapperCause = options.mapperError;
|
|
2201
2444
|
}
|
|
@@ -2205,36 +2448,51 @@ var RepositoryErrorMappingFailedError = class extends KitWiringError {
|
|
|
2205
2448
|
*
|
|
2206
2449
|
* This is a wiring error, not a persistence failure: the statements cannot
|
|
2207
2450
|
* run the write the Unit of Work registered, and a retry repeats the same
|
|
2208
|
-
* outcome.
|
|
2209
|
-
* repository's `mapError
|
|
2210
|
-
*
|
|
2211
|
-
* error, and the reason stays in the cause chain.
|
|
2451
|
+
* outcome. The commit phase hands it to the caller unchanged, never to the
|
|
2452
|
+
* repository's `mapError`, because a mapper returns an `InfrastructureError`
|
|
2453
|
+
* and would relabel the defect as a store failure.
|
|
2212
2454
|
*/
|
|
2213
2455
|
var InvalidFlushStatementError = class extends KitWiringError {
|
|
2214
|
-
|
|
2215
|
-
|
|
2456
|
+
identity;
|
|
2457
|
+
/** The write, or `null` when the code that found the defect does not know it. */
|
|
2216
2458
|
intent;
|
|
2217
2459
|
reason;
|
|
2218
|
-
/**
|
|
2460
|
+
/** The value that the flush received in place of a valid one. */
|
|
2219
2461
|
received;
|
|
2220
2462
|
/** The failure of `isDuplicate`, when the classifier itself threw. */
|
|
2221
2463
|
classifierCause;
|
|
2222
2464
|
constructor(options) {
|
|
2223
2465
|
super("INVALID_FLUSH_STATEMENT", flushStatementReasonMessage(options), options.cause);
|
|
2224
|
-
this.
|
|
2225
|
-
this.
|
|
2226
|
-
this.intent = options.intent;
|
|
2466
|
+
this.identity = detachAggregateIdentity(options.identity);
|
|
2467
|
+
this.intent = options.intent ?? null;
|
|
2227
2468
|
this.reason = options.reason;
|
|
2228
2469
|
this.received = options.received;
|
|
2229
2470
|
this.classifierCause = options.classifierCause;
|
|
2230
2471
|
}
|
|
2231
2472
|
};
|
|
2473
|
+
/**
|
|
2474
|
+
* Sets the write intent on a statement defect that its raising code could not
|
|
2475
|
+
* name, and renders the message again.
|
|
2476
|
+
*/
|
|
2477
|
+
function attributeFlushStatementIntent(defect, intent) {
|
|
2478
|
+
if (defect.intent === intent) return;
|
|
2479
|
+
Reflect.set(defect, "intent", intent);
|
|
2480
|
+
rewriteErrorMessage(defect, flushStatementReasonMessage({
|
|
2481
|
+
identity: defect.identity,
|
|
2482
|
+
intent,
|
|
2483
|
+
reason: defect.reason,
|
|
2484
|
+
received: defect.received
|
|
2485
|
+
}));
|
|
2486
|
+
}
|
|
2232
2487
|
function flushStatementReasonMessage(options) {
|
|
2233
|
-
const
|
|
2488
|
+
const aggregate = describeAggregateIdentity(options.identity);
|
|
2489
|
+
const site = options.intent == null ? aggregate : `${options.intent} of ${aggregate}`;
|
|
2490
|
+
const statement = options.intent ?? "write";
|
|
2234
2491
|
switch (options.reason) {
|
|
2235
|
-
case "statement_absent": return `The Unit of Work registered the ${site}, but the statements carry no ${
|
|
2236
|
-
case "no_row_count": return `The ${
|
|
2237
|
-
case "no_expected_version": return `The ${site} carries no expectedVersion. The Unit of Work captures it when the aggregate is loaded, so this write did not come from a loaded aggregate
|
|
2492
|
+
case "statement_absent": return `The Unit of Work registered the ${site}, but the statements carry no ${statement} statement. ` + (options.intent === "remove" ? "A definition with physicalRemoval: true needs a remove statement." : "A definition without appendOnly: true needs an update statement.");
|
|
2493
|
+
case "no_row_count": return `The ${statement} statement of the ${site} returned ${options.received ?? "no value"}. It must return the count of rows its predicate matched, so the flush can tell a conflict from a write.`;
|
|
2494
|
+
case "no_expected_version": return `The ${site} carries no valid expectedVersion` + (options.received === void 0 ? "" : ` (${options.received})`) + ". The Unit of Work captures it when the aggregate is loaded, so this write did not come from a loaded aggregate.";
|
|
2495
|
+
case "no_version": return `The currentVersion statement of ${site} returned ${options.received ?? "no value"}. It must return the stored version, a safe integer of at least 0, or undefined when the aggregate does not exist. Convert a bigint or a numeric string with Number().`;
|
|
2238
2496
|
case "duplicate_check_failed": return `The isDuplicate check of the ${site} threw. It must classify the error of insert, never fail. The insert failure is the cause; the failure of the check is in classifierCause.`;
|
|
2239
2497
|
}
|
|
2240
2498
|
}
|
|
@@ -2246,25 +2504,29 @@ function flushStatementReasonMessage(options) {
|
|
|
2246
2504
|
* cannot execute truthfully. Retrying the same callback cannot repair it.
|
|
2247
2505
|
*/
|
|
2248
2506
|
var AggregateTrackingError = class extends KitWiringError {
|
|
2249
|
-
|
|
2507
|
+
identity;
|
|
2250
2508
|
operation;
|
|
2251
2509
|
reason;
|
|
2252
2510
|
registeredIntent;
|
|
2253
|
-
constructor(
|
|
2254
|
-
super("AGGREGATE_TRACKING", trackingReasonMessage(
|
|
2255
|
-
this.
|
|
2256
|
-
this.operation = operation;
|
|
2257
|
-
this.reason = reason;
|
|
2258
|
-
this.registeredIntent = registeredIntent;
|
|
2511
|
+
constructor(options) {
|
|
2512
|
+
super("AGGREGATE_TRACKING", trackingReasonMessage(options));
|
|
2513
|
+
this.identity = detachAggregateIdentity(options.identity);
|
|
2514
|
+
this.operation = options.operation;
|
|
2515
|
+
this.reason = options.reason;
|
|
2516
|
+
this.registeredIntent = options.registeredIntent;
|
|
2259
2517
|
}
|
|
2260
2518
|
};
|
|
2261
|
-
function trackingReasonMessage(
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
case "
|
|
2266
|
-
case "
|
|
2267
|
-
case "
|
|
2519
|
+
function trackingReasonMessage(options) {
|
|
2520
|
+
const { operation, registeredIntent } = options;
|
|
2521
|
+
const aggregate = describeAggregateIdentity(options.identity);
|
|
2522
|
+
switch (options.reason) {
|
|
2523
|
+
case "not_loaded": return `Aggregate ${aggregate} cannot be registered for ${operation}: it was not loaded into this unit of work. Load it through the repository before updating or removing it.`;
|
|
2524
|
+
case "loaded_as_new": return `Aggregate ${aggregate} cannot be added as new because it was loaded by this unit of work. ` + (options.appendOnly ? "Its repository is append-only, so a loaded aggregate is already persisted and must not change." : "Use update for a loaded aggregate.");
|
|
2525
|
+
case "different_repository": return `Aggregate ${aggregate} cannot be registered for ${operation} through a different repository in the same unit of work. One aggregate instance must remain owned by the repository definition that first tracked it.`;
|
|
2526
|
+
case "conflicting_intent": return `Aggregate ${aggregate} is already registered for ${registeredIntent ?? "another write"}; ${operation} would create conflicting persistence intent in one unit of work. Decide the final lifecycle outcome before registering it.`;
|
|
2527
|
+
case "mutated_after_registration": return `Aggregate ${aggregate} changed after ${registeredIntent ?? "write"} was registered. Make domain decisions first and call add, update, or remove last so persisted state and recorded events cannot diverge.`;
|
|
2528
|
+
case "registered_during_flush": return `Aggregate ${aggregate} cannot be registered for ${operation}: the unit of work already flushes its writes, so this write cannot join the transaction. Await every repository call inside the run() callback.`;
|
|
2529
|
+
case "identity_already_tracked": return `Aggregate ${aggregate} cannot be registered for ${operation}: this unit of work already tracks another instance with the same identity. Change the tracked instance, or give the new aggregate a new id.`;
|
|
2268
2530
|
}
|
|
2269
2531
|
}
|
|
2270
2532
|
/**
|
|
@@ -2297,29 +2559,44 @@ var CommitError = class extends InfrastructureError {
|
|
|
2297
2559
|
}
|
|
2298
2560
|
};
|
|
2299
2561
|
/**
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2304
|
-
*
|
|
2305
|
-
*
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2308
|
-
*
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2562
|
+
* A step inside the transaction threw, and the transaction scope rejected
|
|
2563
|
+
* with a different error that does not wrap that failure in its cause chain.
|
|
2564
|
+
* This is the strongest available signal that the rollback itself failed.
|
|
2565
|
+
* The step is the work callback, or a step after it and before the commit,
|
|
2566
|
+
* for example the outbox write. The failure inside the transaction is the
|
|
2567
|
+
* `cause`, so cause-chain helpers (`someChainRetryable`, `findInCauseChain`)
|
|
2568
|
+
* still see a wrapped `ConcurrencyConflictError` & co. The scope's error is
|
|
2569
|
+
* in {@link rollbackCause}. A wiring error as that failure is the cause too,
|
|
2570
|
+
* so both facts stay visible.
|
|
2571
|
+
*
|
|
2572
|
+
* A scope that rethrows the failure (Drizzle and Prisma do) never produces
|
|
2573
|
+
* this. A scope that wraps the failure is detected through the cause chain.
|
|
2574
|
+
* A wrapped failure of the work passes through unchanged. A wrapped failure
|
|
2575
|
+
* after the work becomes a `CommitError`, or passes through when it is a
|
|
2576
|
+
* wiring error.
|
|
2311
2577
|
*/
|
|
2312
2578
|
var RollbackError = class extends InfrastructureError {
|
|
2313
2579
|
rollbackCause;
|
|
2314
2580
|
constructor(cause, rollbackCause) {
|
|
2315
2581
|
super({
|
|
2316
2582
|
code: "ROLLBACK_FAILED",
|
|
2317
|
-
message: "
|
|
2583
|
+
message: "A step inside the transaction failed and the transaction scope rejected with a different error (possible rollback failure). The failure inside the transaction is the cause; the scope's error is in rollbackCause.",
|
|
2318
2584
|
cause
|
|
2319
2585
|
});
|
|
2320
2586
|
this.rollbackCause = rollbackCause;
|
|
2321
2587
|
}
|
|
2322
2588
|
};
|
|
2589
|
+
/**
|
|
2590
|
+
* The first kit wiring error in the cause chain of `error`, also from another
|
|
2591
|
+
* copy of the kit. A wiring error states a defect of the wiring, so the unit
|
|
2592
|
+
* of work passes it to the caller and never lets a mapper or a retry
|
|
2593
|
+
* relabel it, even when an adapter or a scope wrapped it. If the rollback
|
|
2594
|
+
* failed as well, the wiring error becomes the cause of a `RollbackError`,
|
|
2595
|
+
* so the rollback failure stays visible.
|
|
2596
|
+
*/
|
|
2597
|
+
function wiringErrorInCauseChain(error) {
|
|
2598
|
+
return findInCauseChain(error, (link) => isWiringErrorLike(link) ? link : void 0);
|
|
2599
|
+
}
|
|
2323
2600
|
|
|
2324
2601
|
//#endregion
|
|
2325
2602
|
//#region src/domain/aggregate/pending-event-recording.ts
|
|
@@ -2347,17 +2624,30 @@ function recordPendingEvents(aggregate, source, stampOptions) {
|
|
|
2347
2624
|
//#endregion
|
|
2348
2625
|
//#region src/application/unit-of-work/repository-facade.ts
|
|
2349
2626
|
/**
|
|
2350
|
-
* Builds the application-facing repository facade.
|
|
2351
|
-
*
|
|
2352
|
-
*
|
|
2353
|
-
* fields keep their normal receiver.
|
|
2627
|
+
* Builds the application-facing repository facade. The Unit of Work supplies
|
|
2628
|
+
* the lifecycle writes, and an adapter that defines its own add, update, or
|
|
2629
|
+
* remove is rejected. Other methods are bound to the adapter so classes with private
|
|
2630
|
+
* fields keep their normal receiver. A member that returns the adapter itself
|
|
2631
|
+
* (a fluent `this`) returns the facade instead. A promise passes unchanged: a
|
|
2632
|
+
* raw adapter that it resolves to can only read, because an adapter defines
|
|
2633
|
+
* no add, update, or remove.
|
|
2354
2634
|
*/
|
|
2355
2635
|
function bindRepositoryWrites(adapter, session, definition, repository) {
|
|
2356
|
-
if (adapter === null || typeof adapter !== "object") throw new InvalidRepositoryAdapterError(
|
|
2636
|
+
if (adapter === null || typeof adapter !== "object") throw new InvalidRepositoryAdapterError({
|
|
2637
|
+
repository,
|
|
2638
|
+
reason: "not_an_object",
|
|
2639
|
+
receivedType: adapter === null ? "null" : typeof adapter
|
|
2640
|
+
});
|
|
2641
|
+
for (const operation of REPOSITORY_LIFECYCLE_OPERATIONS) if (hasMemberBelowObjectPrototype(adapter, operation)) throw new InvalidRepositoryAdapterError({
|
|
2642
|
+
repository,
|
|
2643
|
+
reason: "defines_lifecycle_operation",
|
|
2644
|
+
operation
|
|
2645
|
+
});
|
|
2357
2646
|
const state = createRepositoryFacadeState(adapter, session, definition);
|
|
2358
2647
|
installRepositoryLifecycleOperations(state);
|
|
2359
2648
|
forwardAdapterOwnProperties(state);
|
|
2360
|
-
|
|
2649
|
+
state.facade = new Proxy(state.target, createRepositoryFacadeHandler(state));
|
|
2650
|
+
return state.facade;
|
|
2361
2651
|
}
|
|
2362
2652
|
const REPOSITORY_LIFECYCLE_OPERATIONS = [
|
|
2363
2653
|
"add",
|
|
@@ -2368,6 +2658,7 @@ function createRepositoryFacadeState(source, session, definition) {
|
|
|
2368
2658
|
return {
|
|
2369
2659
|
source,
|
|
2370
2660
|
target: Object.create(Reflect.getPrototypeOf(source)),
|
|
2661
|
+
facade: void 0,
|
|
2371
2662
|
session,
|
|
2372
2663
|
definition,
|
|
2373
2664
|
methodCache: /* @__PURE__ */ new Map(),
|
|
@@ -2382,10 +2673,20 @@ function isRepositoryLifecycleOperation(property) {
|
|
|
2382
2673
|
return REPOSITORY_LIFECYCLE_OPERATIONS.includes(property);
|
|
2383
2674
|
}
|
|
2384
2675
|
/**
|
|
2385
|
-
*
|
|
2386
|
-
*
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2676
|
+
* Whether a property read or `in` probe is language plumbing, not a
|
|
2677
|
+
* repository member: promise resolution probes `then`, JSON.stringify probes
|
|
2678
|
+
* `toJSON`, string interpolation reads `toString`, and inspection utilities
|
|
2679
|
+
* read well-known symbols. Only a property present BELOW Object.prototype is
|
|
2680
|
+
* repository surface, except `constructor`, which every class prototype
|
|
2681
|
+
* carries. Plumbing answers without the session-open assertion, so logging a
|
|
2682
|
+
* leaked facade after close cannot mask the original failure.
|
|
2683
|
+
*/
|
|
2684
|
+
function isLanguagePlumbing(state, property) {
|
|
2685
|
+
return property === "constructor" || !hasMemberBelowObjectPrototype(state.target, property) && !hasMemberBelowObjectPrototype(state.source, property);
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Own-or-inherited presence that stops BEFORE `Object.prototype`, where the
|
|
2689
|
+
* members that every object inherits live.
|
|
2389
2690
|
*/
|
|
2390
2691
|
function hasMemberBelowObjectPrototype(object, property) {
|
|
2391
2692
|
let current = object;
|
|
@@ -2398,20 +2699,29 @@ function hasMemberBelowObjectPrototype(object, property) {
|
|
|
2398
2699
|
function readRepositorySource(state, property) {
|
|
2399
2700
|
state.session.assertOpen(repositoryOperationName(property));
|
|
2400
2701
|
const value = Reflect.get(state.source, property, state.source);
|
|
2401
|
-
if (typeof value !== "function") return value;
|
|
2702
|
+
if (typeof value !== "function") return facadeInPlaceOfSource(state, value);
|
|
2402
2703
|
const cached = state.methodCache.get(property);
|
|
2403
2704
|
if (cached && cached.sourceMethod === value) return cached.guarded;
|
|
2404
2705
|
const sourceMethod = value;
|
|
2405
|
-
const guarded = (
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2706
|
+
const guarded = new Proxy(sourceMethod, {
|
|
2707
|
+
apply: (method, _receiver, args) => {
|
|
2708
|
+
state.session.assertOpen(repositoryOperationName(property));
|
|
2709
|
+
return facadeInPlaceOfSource(state, Reflect.apply(method, state.source, args));
|
|
2710
|
+
},
|
|
2711
|
+
construct: (method, args, newTarget) => {
|
|
2712
|
+
state.session.assertOpen(repositoryOperationName(property));
|
|
2713
|
+
return Reflect.construct(method, args, newTarget);
|
|
2714
|
+
}
|
|
2715
|
+
});
|
|
2409
2716
|
state.methodCache.set(property, {
|
|
2410
2717
|
sourceMethod,
|
|
2411
2718
|
guarded
|
|
2412
2719
|
});
|
|
2413
2720
|
return guarded;
|
|
2414
2721
|
}
|
|
2722
|
+
function facadeInPlaceOfSource(state, value) {
|
|
2723
|
+
return value === state.source ? state.facade : value;
|
|
2724
|
+
}
|
|
2415
2725
|
function defineForwardedRepositoryProperty(state, property, descriptor) {
|
|
2416
2726
|
Object.defineProperty(state.target, property, {
|
|
2417
2727
|
configurable: true,
|
|
@@ -2426,8 +2736,8 @@ function defineForwardedRepositoryProperty(state, property, descriptor) {
|
|
|
2426
2736
|
}
|
|
2427
2737
|
function installedLifecycleOperations(definition) {
|
|
2428
2738
|
const operations = ["add"];
|
|
2429
|
-
if (
|
|
2430
|
-
if (definition.physicalRemoval) operations.push("remove");
|
|
2739
|
+
if (definition.appendOnly !== true) operations.push("update");
|
|
2740
|
+
if (definition.physicalRemoval === true) operations.push("remove");
|
|
2431
2741
|
return operations;
|
|
2432
2742
|
}
|
|
2433
2743
|
function installRepositoryLifecycleOperations(state) {
|
|
@@ -2438,7 +2748,6 @@ function installRepositoryLifecycleOperations(state) {
|
|
|
2438
2748
|
enumerable: false,
|
|
2439
2749
|
writable: false,
|
|
2440
2750
|
value: (aggregate) => {
|
|
2441
|
-
state.session.assertOpen(repositoryOperationName(operation));
|
|
2442
2751
|
state.session[operation](aggregate, state.definition);
|
|
2443
2752
|
}
|
|
2444
2753
|
});
|
|
@@ -2454,7 +2763,11 @@ function forwardAdapterOwnProperties(state) {
|
|
|
2454
2763
|
function createRepositoryFacadeHandler(state) {
|
|
2455
2764
|
return {
|
|
2456
2765
|
get: (target, property, receiver) => {
|
|
2457
|
-
if (
|
|
2766
|
+
if (isLanguagePlumbing(state, property)) return Reflect.get(target, property, receiver);
|
|
2767
|
+
if (state.writes.has(property)) {
|
|
2768
|
+
state.session.assertOpenForRegistration(repositoryOperationName(property));
|
|
2769
|
+
return Reflect.get(target, property, receiver);
|
|
2770
|
+
}
|
|
2458
2771
|
state.session.assertOpen(repositoryOperationName(property));
|
|
2459
2772
|
if (Reflect.getOwnPropertyDescriptor(target, property)) return Reflect.get(target, property, receiver);
|
|
2460
2773
|
if (isRepositoryLifecycleOperation(property)) return void 0;
|
|
@@ -2462,6 +2775,11 @@ function createRepositoryFacadeHandler(state) {
|
|
|
2462
2775
|
},
|
|
2463
2776
|
set: (target, property, value, receiver) => setRepositoryFacadeProperty(state, target, property, value, receiver),
|
|
2464
2777
|
has: (target, property) => {
|
|
2778
|
+
if (isLanguagePlumbing(state, property)) return Reflect.has(target, property);
|
|
2779
|
+
if (state.writes.has(property)) {
|
|
2780
|
+
state.session.assertOpenForRegistration(repositoryOperationName(property));
|
|
2781
|
+
return true;
|
|
2782
|
+
}
|
|
2465
2783
|
state.session.assertOpen(repositoryOperationName(property));
|
|
2466
2784
|
return state.writes.has(property) || !isRepositoryLifecycleOperation(property) && (Reflect.has(target, property) || Reflect.has(state.source, property));
|
|
2467
2785
|
},
|
|
@@ -2578,7 +2896,8 @@ var IdentityMap = class {
|
|
|
2578
2896
|
return this._deleted.get(type)?.has(id) ?? false;
|
|
2579
2897
|
}
|
|
2580
2898
|
/**
|
|
2581
|
-
* Registers the hydrated instance
|
|
2899
|
+
* Registers the hydrated instance under its class and the id of its
|
|
2900
|
+
* `aggregateIdentity`.
|
|
2582
2901
|
*
|
|
2583
2902
|
* - Re-registering the SAME instance is a no-op (idempotent).
|
|
2584
2903
|
* - Registering a DIFFERENT instance for an occupied type+id throws:
|
|
@@ -2590,15 +2909,16 @@ var IdentityMap = class {
|
|
|
2590
2909
|
* work throws `AggregateDeletedError`: deletion is final within
|
|
2591
2910
|
* the operation.
|
|
2592
2911
|
*/
|
|
2593
|
-
set(type,
|
|
2594
|
-
|
|
2912
|
+
set(type, aggregate) {
|
|
2913
|
+
const id = aggregate.aggregateIdentity.aggregateId;
|
|
2914
|
+
if (this._deleted.get(type)?.has(id)) throw new AggregateDeletedError({ identity: aggregate.aggregateIdentity });
|
|
2595
2915
|
let store = this._stores.get(type);
|
|
2596
2916
|
if (store === void 0) {
|
|
2597
2917
|
store = /* @__PURE__ */ new Map();
|
|
2598
2918
|
this._stores.set(type, store);
|
|
2599
2919
|
}
|
|
2600
2920
|
const existing = store.get(id);
|
|
2601
|
-
if (existing !== void 0 && existing !== aggregate) throw new Error(`IdentityMap: a different instance is already registered for ${
|
|
2921
|
+
if (existing !== void 0 && existing !== aggregate) throw new Error(`IdentityMap: a different instance is already registered for ${describeAggregateIdentity(aggregate.aggregateIdentity)}. 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.`);
|
|
2602
2922
|
store.set(id, aggregate);
|
|
2603
2923
|
if (aggregate !== null && typeof aggregate === "object" && !this._pendingAtRegistration.has(aggregate)) {
|
|
2604
2924
|
const pending = pendingEventCountOf(aggregate);
|
|
@@ -2761,13 +3081,24 @@ var Session = class {
|
|
|
2761
3081
|
_commitTokens = /* @__PURE__ */ new Set();
|
|
2762
3082
|
_identityMap = new IdentityMap();
|
|
2763
3083
|
_identityMapView = Object.freeze({
|
|
2764
|
-
get: this._identityMap.get
|
|
2765
|
-
has: this._identityMap.has
|
|
2766
|
-
isDeleted: this._identityMap.isDeleted
|
|
3084
|
+
get: (type, id) => this._phase === "open" ? this._identityMap.get(type, id) : void 0,
|
|
3085
|
+
has: (type, id) => this._phase === "open" && this._identityMap.has(type, id),
|
|
3086
|
+
isDeleted: (type, id) => this._phase === "open" && this._identityMap.isDeleted(type, id)
|
|
2767
3087
|
});
|
|
2768
3088
|
_trackingByAggregate = /* @__PURE__ */ new WeakMap();
|
|
2769
3089
|
_trackedAggregates = /* @__PURE__ */ new Set();
|
|
2770
|
-
|
|
3090
|
+
/** Whether the context, facades, and tracking may still be used. */
|
|
3091
|
+
_phase = "open";
|
|
3092
|
+
/**
|
|
3093
|
+
* True from the start of the flush until the last check before the
|
|
3094
|
+
* commit ran or the run ended. A write registered in that window would
|
|
3095
|
+
* be committed and harvested without its flush, so it is rejected, and
|
|
3096
|
+
* the first rejection also fails the attempt, even when the caller
|
|
3097
|
+
* swallows it. After the window, a registration reports a closed unit of
|
|
3098
|
+
* work: nothing can fail the attempt any more.
|
|
3099
|
+
*/
|
|
3100
|
+
_lateRegistrationsFailAttempt = false;
|
|
3101
|
+
_registrationDuringFlush;
|
|
2771
3102
|
constructor(commitEnrollment) {
|
|
2772
3103
|
this.commitEnrollment = commitEnrollment;
|
|
2773
3104
|
}
|
|
@@ -2794,31 +3125,55 @@ var Session = class {
|
|
|
2794
3125
|
}
|
|
2795
3126
|
trackLoaded(aggregate, definition) {
|
|
2796
3127
|
this.assertOpen("tracking.trackLoaded");
|
|
3128
|
+
requirePendingEventLifecycleReadView(aggregate, "tracking.trackLoaded");
|
|
3129
|
+
const tracked = this._identityMap.get(definition.aggregate, aggregate.id);
|
|
3130
|
+
if (tracked !== void 0 && tracked !== aggregate) return this.trackLoaded(tracked, definition);
|
|
2797
3131
|
const existing = this._trackingByAggregate.get(aggregate);
|
|
2798
|
-
if (existing && existing.definition !== definition) throw new AggregateTrackingError(
|
|
2799
|
-
|
|
2800
|
-
|
|
3132
|
+
if (existing && existing.definition !== definition) throw new AggregateTrackingError({
|
|
3133
|
+
identity: aggregate.aggregateIdentity,
|
|
3134
|
+
operation: "load",
|
|
3135
|
+
reason: "different_repository",
|
|
3136
|
+
registeredIntent: existing.registration?.intent
|
|
3137
|
+
});
|
|
3138
|
+
if (existing) {
|
|
3139
|
+
this._identityMap.set(definition.aggregate, aggregate);
|
|
3140
|
+
return aggregate;
|
|
3141
|
+
}
|
|
3142
|
+
const baseline = capturePersistenceBaseline(definition.persistence, aggregate);
|
|
3143
|
+
this._identityMap.set(definition.aggregate, aggregate);
|
|
2801
3144
|
const entry = {
|
|
2802
3145
|
aggregate,
|
|
2803
3146
|
lifecycle: "loaded",
|
|
2804
3147
|
expectedVersion: aggregate.version,
|
|
2805
3148
|
definition,
|
|
2806
|
-
baseline
|
|
3149
|
+
baseline
|
|
2807
3150
|
};
|
|
2808
3151
|
this._trackingByAggregate.set(aggregate, entry);
|
|
2809
3152
|
this._trackedAggregates.add(entry);
|
|
2810
3153
|
return aggregate;
|
|
2811
3154
|
}
|
|
2812
3155
|
add(aggregate, definition) {
|
|
2813
|
-
this.
|
|
3156
|
+
if (this.isRepeatAdmitted(aggregate, definition, "add")) return;
|
|
2814
3157
|
this.assertNotRemoved(aggregate, definition);
|
|
2815
3158
|
const existing = this._trackingByAggregate.get(aggregate);
|
|
2816
|
-
if (existing && existing.definition !== definition) throw new AggregateTrackingError(
|
|
2817
|
-
|
|
3159
|
+
if (existing && existing.definition !== definition) throw new AggregateTrackingError({
|
|
3160
|
+
identity: aggregate.aggregateIdentity,
|
|
3161
|
+
operation: "add",
|
|
3162
|
+
reason: "different_repository",
|
|
3163
|
+
registeredIntent: existing.registration?.intent
|
|
3164
|
+
});
|
|
3165
|
+
if (existing?.lifecycle === "loaded") throw new AggregateTrackingError({
|
|
3166
|
+
identity: aggregate.aggregateIdentity,
|
|
3167
|
+
operation: "add",
|
|
3168
|
+
reason: "loaded_as_new",
|
|
3169
|
+
registeredIntent: existing.registration?.intent,
|
|
3170
|
+
appendOnly: definition.appendOnly === true
|
|
3171
|
+
});
|
|
2818
3172
|
let entry = existing;
|
|
2819
3173
|
const newlyTracked = !entry;
|
|
2820
3174
|
if (!entry) {
|
|
2821
|
-
this.
|
|
3175
|
+
this.assertIdentityNotTaken(aggregate, definition);
|
|
3176
|
+
this._identityMap.set(definition.aggregate, aggregate);
|
|
2822
3177
|
entry = {
|
|
2823
3178
|
aggregate,
|
|
2824
3179
|
lifecycle: "new",
|
|
@@ -2840,15 +3195,29 @@ var Session = class {
|
|
|
2840
3195
|
throw error;
|
|
2841
3196
|
}
|
|
2842
3197
|
}
|
|
3198
|
+
/** Two live instances of one identity would each flush and harvest. */
|
|
3199
|
+
assertIdentityNotTaken(aggregate, definition) {
|
|
3200
|
+
const tracked = this._identityMap.get(definition.aggregate, aggregate.id);
|
|
3201
|
+
if (tracked === void 0 || tracked === aggregate) return;
|
|
3202
|
+
throw new AggregateTrackingError({
|
|
3203
|
+
identity: aggregate.aggregateIdentity,
|
|
3204
|
+
operation: "add",
|
|
3205
|
+
reason: "identity_already_tracked",
|
|
3206
|
+
registeredIntent: this.registrationOf(tracked)?.intent
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
2843
3209
|
update(aggregate, definition) {
|
|
2844
|
-
this.
|
|
3210
|
+
if (this.isRepeatAdmitted(aggregate, definition, "update")) return;
|
|
2845
3211
|
const entry = this.loadedEntryFor(aggregate, "update", definition);
|
|
2846
3212
|
this.registerWrite(entry, "update", definition);
|
|
2847
3213
|
}
|
|
2848
3214
|
remove(aggregate, definition) {
|
|
2849
|
-
this.
|
|
3215
|
+
if (this.isRepeatAdmitted(aggregate, definition, "remove")) return;
|
|
2850
3216
|
const entry = this._trackingByAggregate.get(aggregate);
|
|
2851
|
-
if (this.isRemovedInstance(aggregate) && entry?.definition === definition)
|
|
3217
|
+
if (this.isRemovedInstance(aggregate) && entry?.definition === definition) {
|
|
3218
|
+
this.assertUnchangedAfterRegistration(entry, "remove");
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
2852
3221
|
const loaded = this.loadedEntryFor(aggregate, "remove", definition);
|
|
2853
3222
|
this.registerWrite(loaded, "remove", definition);
|
|
2854
3223
|
}
|
|
@@ -2866,18 +3235,38 @@ var Session = class {
|
|
|
2866
3235
|
loadedEntryFor(aggregate, operation, definition) {
|
|
2867
3236
|
this.assertNotRemoved(aggregate, definition);
|
|
2868
3237
|
const entry = this._trackingByAggregate.get(aggregate);
|
|
2869
|
-
if (entry && entry.definition !== definition) throw new AggregateTrackingError(
|
|
2870
|
-
|
|
2871
|
-
|
|
3238
|
+
if (entry && entry.definition !== definition) throw new AggregateTrackingError({
|
|
3239
|
+
identity: aggregate.aggregateIdentity,
|
|
3240
|
+
operation,
|
|
3241
|
+
reason: "different_repository",
|
|
3242
|
+
registeredIntent: entry.registration?.intent
|
|
3243
|
+
});
|
|
3244
|
+
if (entry && entry.lifecycle === "new" && entry.definition === definition) throw new AggregateTrackingError({
|
|
3245
|
+
identity: aggregate.aggregateIdentity,
|
|
3246
|
+
operation,
|
|
3247
|
+
reason: "conflicting_intent",
|
|
3248
|
+
registeredIntent: entry.registration?.intent
|
|
3249
|
+
});
|
|
3250
|
+
if (entry?.lifecycle !== "loaded" || entry.definition !== definition) throw new AggregateTrackingError({
|
|
3251
|
+
identity: aggregate.aggregateIdentity,
|
|
3252
|
+
operation,
|
|
3253
|
+
reason: "not_loaded",
|
|
3254
|
+
registeredIntent: entry?.registration?.intent
|
|
3255
|
+
});
|
|
2872
3256
|
return entry;
|
|
2873
3257
|
}
|
|
2874
3258
|
assertNotRemoved(aggregate, definition) {
|
|
2875
|
-
if (this._identityMap.isDeleted(definition.aggregate, aggregate.id)) throw new AggregateDeletedError(
|
|
3259
|
+
if (this._identityMap.isDeleted(definition.aggregate, aggregate.id)) throw new AggregateDeletedError({ identity: aggregate.aggregateIdentity });
|
|
2876
3260
|
}
|
|
2877
3261
|
registerIntent(entry, intent) {
|
|
2878
3262
|
if (entry.registration !== void 0) {
|
|
2879
|
-
if (entry.registration.intent !== intent) throw new AggregateTrackingError(
|
|
2880
|
-
|
|
3263
|
+
if (entry.registration.intent !== intent) throw new AggregateTrackingError({
|
|
3264
|
+
identity: entry.aggregate.aggregateIdentity,
|
|
3265
|
+
operation: intent,
|
|
3266
|
+
reason: "conflicting_intent",
|
|
3267
|
+
registeredIntent: entry.registration.intent
|
|
3268
|
+
});
|
|
3269
|
+
this.assertUnchangedAfterRegistration(entry, intent);
|
|
2881
3270
|
return false;
|
|
2882
3271
|
}
|
|
2883
3272
|
entry.registration = Object.freeze({
|
|
@@ -2896,17 +3285,22 @@ var Session = class {
|
|
|
2896
3285
|
if (index >= 0) this._registeredWrites.splice(index, 1);
|
|
2897
3286
|
delete entry.registration;
|
|
2898
3287
|
}
|
|
2899
|
-
assertUnchangedAfterRegistration(entry) {
|
|
3288
|
+
assertUnchangedAfterRegistration(entry, operation) {
|
|
2900
3289
|
const registration = entry.registration;
|
|
2901
3290
|
if (registration === void 0) return;
|
|
2902
3291
|
const currentEvents = entry.aggregate.pendingEvents;
|
|
2903
3292
|
const persistenceChanged = persistenceProjectionDrifted(registration.baseline, entry.aggregate);
|
|
2904
3293
|
const sameEvents = currentEvents.length === registration.events.length && currentEvents.every((event, index) => event === registration.events[index]);
|
|
2905
|
-
if (registration.version !== entry.aggregate.version || !sameEvents || persistenceChanged) throw new AggregateTrackingError(
|
|
3294
|
+
if (registration.version !== entry.aggregate.version || !sameEvents || persistenceChanged) throw new AggregateTrackingError({
|
|
3295
|
+
identity: entry.aggregate.aggregateIdentity,
|
|
3296
|
+
operation,
|
|
3297
|
+
reason: "mutated_after_registration",
|
|
3298
|
+
registeredIntent: registration.intent
|
|
3299
|
+
});
|
|
2906
3300
|
}
|
|
2907
3301
|
registerSavedCommit(aggregate, definition, expectedVersion) {
|
|
2908
3302
|
this.assertOpen("repository.add/update");
|
|
2909
|
-
if (this.isRemovedInstance(aggregate) || this._identityMap.isDeleted(definition.aggregate, aggregate.id)) throw new AggregateDeletedError(
|
|
3303
|
+
if (this.isRemovedInstance(aggregate) || this._identityMap.isDeleted(definition.aggregate, aggregate.id)) throw new AggregateDeletedError({ identity: aggregate.aggregateIdentity });
|
|
2910
3304
|
const token = this.commitEnrollment.enrollSaved(aggregate, { expectedVersion });
|
|
2911
3305
|
this._commitTokens.add(token);
|
|
2912
3306
|
return token;
|
|
@@ -2926,28 +3320,40 @@ var Session = class {
|
|
|
2926
3320
|
* transaction.
|
|
2927
3321
|
*/
|
|
2928
3322
|
assertReadyToCommit() {
|
|
3323
|
+
if (this._registrationDuringFlush !== void 0) throw this._registrationDuringFlush;
|
|
2929
3324
|
for (const entry of this._trackedAggregates) {
|
|
2930
3325
|
if (entry.registration !== void 0) {
|
|
2931
|
-
this.assertUnchangedAfterRegistration(entry);
|
|
3326
|
+
this.assertUnchangedAfterRegistration(entry, "commit");
|
|
2932
3327
|
continue;
|
|
2933
3328
|
}
|
|
2934
|
-
if (entry.lifecycle === "loaded" && (entry.aggregate.version !== entry.expectedVersion || persistenceProjectionDrifted(entry.baseline, entry.aggregate))) throw new UnenrolledChangesError(
|
|
3329
|
+
if (entry.lifecycle === "loaded" && (entry.aggregate.version !== entry.expectedVersion || persistenceProjectionDrifted(entry.baseline, entry.aggregate))) throw new UnenrolledChangesError({
|
|
3330
|
+
identity: entry.aggregate.aggregateIdentity,
|
|
3331
|
+
appendOnly: entry.definition.appendOnly === true
|
|
3332
|
+
});
|
|
2935
3333
|
}
|
|
2936
3334
|
for (const instance of this._identityMap.instancesWithNewPendingEvents()) {
|
|
2937
3335
|
if (instance !== null && typeof instance === "object" && this.registrationOf(instance) !== void 0) continue;
|
|
2938
|
-
const
|
|
2939
|
-
throw new UnenrolledChangesError(
|
|
3336
|
+
const aggregate = instance;
|
|
3337
|
+
throw new UnenrolledChangesError({
|
|
3338
|
+
identity: aggregate.aggregateIdentity,
|
|
3339
|
+
appendOnly: this._trackingByAggregate.get(aggregate)?.definition.appendOnly === true
|
|
3340
|
+
});
|
|
2940
3341
|
}
|
|
2941
3342
|
}
|
|
2942
3343
|
/** Flushes every registered receipt in deterministic registration order. */
|
|
2943
3344
|
async flush(transaction) {
|
|
2944
3345
|
this.assertOpen("unitOfWork.flush");
|
|
3346
|
+
this._lateRegistrationsFailAttempt = true;
|
|
2945
3347
|
for (const entry of this._registeredWrites) {
|
|
2946
3348
|
const registration = entry.registration;
|
|
2947
|
-
if (registration === void 0) throw new AggregateTrackingError(
|
|
3349
|
+
if (registration === void 0) throw new AggregateTrackingError({
|
|
3350
|
+
identity: entry.aggregate.aggregateIdentity,
|
|
3351
|
+
operation: "commit",
|
|
3352
|
+
reason: "mutated_after_registration"
|
|
3353
|
+
});
|
|
2948
3354
|
const write = Object.freeze({
|
|
2949
3355
|
intent: registration.intent,
|
|
2950
|
-
|
|
3356
|
+
aggregateIdentity: entry.aggregate.aggregateIdentity,
|
|
2951
3357
|
expectedVersion: entry.expectedVersion,
|
|
2952
3358
|
version: registration.version,
|
|
2953
3359
|
changes: registration.changes,
|
|
@@ -2963,42 +3369,119 @@ var Session = class {
|
|
|
2963
3369
|
get commitTokens() {
|
|
2964
3370
|
return [...this._commitTokens];
|
|
2965
3371
|
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Closes the session for use. The tracked state stays, and late
|
|
3374
|
+
* registrations still fail the attempt until its last check before the
|
|
3375
|
+
* commit: a scope can abandon an attempt and still commit it, and that
|
|
3376
|
+
* attempt's check must see its own state. Leaked views answer empty once
|
|
3377
|
+
* the session is closed, so the state cannot serve a later operation.
|
|
3378
|
+
*/
|
|
2966
3379
|
close() {
|
|
2967
|
-
this.
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
this.
|
|
3380
|
+
this._phase = "closed";
|
|
3381
|
+
}
|
|
3382
|
+
/** Runs {@link assertReadyToCommit} for the last time before the commit. */
|
|
3383
|
+
assertReadyToCommitLastTime() {
|
|
3384
|
+
this.assertReadyToCommit();
|
|
3385
|
+
this._lateRegistrationsFailAttempt = false;
|
|
3386
|
+
}
|
|
3387
|
+
/** Closes the session for good, when its attempt or the run ended. */
|
|
3388
|
+
end() {
|
|
3389
|
+
this._phase = "closed";
|
|
3390
|
+
this._lateRegistrationsFailAttempt = false;
|
|
2972
3391
|
}
|
|
2973
3392
|
assertOpen(operation) {
|
|
2974
|
-
if (this.
|
|
3393
|
+
if (this._phase !== "open") throw new TransactionClosedError(operation);
|
|
3394
|
+
}
|
|
3395
|
+
/** Like {@link assertOpen}, but open while a late registration still counts. */
|
|
3396
|
+
assertOpenForRegistration(operation) {
|
|
3397
|
+
if (!this._lateRegistrationsFailAttempt) this.assertOpen(operation);
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* Admits an add, update, or remove. Before the flush, the call proceeds.
|
|
3401
|
+
* In the late-registration window, a repeat of the same registered write
|
|
3402
|
+
* stays a no-op (returns true), and any other registration is rejected
|
|
3403
|
+
* and fails the attempt. After the window, the call reports a closed unit
|
|
3404
|
+
* of work before it looks at its argument.
|
|
3405
|
+
*/
|
|
3406
|
+
isRepeatAdmitted(aggregate, definition, operation) {
|
|
3407
|
+
const name = repositoryOperation(operation);
|
|
3408
|
+
if (!this._lateRegistrationsFailAttempt) {
|
|
3409
|
+
this.assertOpen(name);
|
|
3410
|
+
requirePendingEventLifecycleReadView(aggregate, name);
|
|
3411
|
+
return false;
|
|
3412
|
+
}
|
|
3413
|
+
requirePendingEventLifecycleReadView(aggregate, name);
|
|
3414
|
+
const entry = this._trackingByAggregate.get(aggregate);
|
|
3415
|
+
if (entry?.definition === definition && entry.registration?.intent === operation) {
|
|
3416
|
+
this.assertUnchangedAfterRegistration(entry, operation);
|
|
3417
|
+
return true;
|
|
3418
|
+
}
|
|
3419
|
+
const rejection = new AggregateTrackingError({
|
|
3420
|
+
identity: aggregate.aggregateIdentity,
|
|
3421
|
+
operation,
|
|
3422
|
+
reason: "registered_during_flush"
|
|
3423
|
+
});
|
|
3424
|
+
this._registrationDuringFlush ??= rejection;
|
|
3425
|
+
throw rejection;
|
|
2975
3426
|
}
|
|
2976
3427
|
};
|
|
3428
|
+
/**
|
|
3429
|
+
* A flush and an event store report what they observed; the unit of work
|
|
3430
|
+
* knows which write it asked for. So the intent of a conflict and of a
|
|
3431
|
+
* statement defect comes from here. It is set on the error object itself,
|
|
3432
|
+
* found by its code anywhere in the cause chain, so a wrapped error and an
|
|
3433
|
+
* error from another copy of the kit get it too.
|
|
3434
|
+
*/
|
|
3435
|
+
function attributeWriteIntent(error, intent) {
|
|
3436
|
+
const conflict = firstInCauseChainWithCode(error, "CONCURRENCY_CONFLICT");
|
|
3437
|
+
if (conflict instanceof ConcurrencyConflictError) attributeConflictIntent(conflict, intent);
|
|
3438
|
+
else if (conflict !== void 0) Reflect.set(conflict, "intent", intent);
|
|
3439
|
+
const defect = firstInCauseChainWithCode(error, "INVALID_FLUSH_STATEMENT");
|
|
3440
|
+
if (defect instanceof InvalidFlushStatementError) attributeFlushStatementIntent(defect, intent);
|
|
3441
|
+
else if (defect !== void 0) Reflect.set(defect, "intent", intent);
|
|
3442
|
+
}
|
|
3443
|
+
function firstInCauseChainWithCode(error, code) {
|
|
3444
|
+
return findInCauseChain(error, (link) => {
|
|
3445
|
+
try {
|
|
3446
|
+
return link.code === code ? link : void 0;
|
|
3447
|
+
} catch {
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
});
|
|
3451
|
+
}
|
|
2977
3452
|
function mapRepositoryPersistenceError(definition, error, write) {
|
|
2978
|
-
|
|
3453
|
+
attributeWriteIntent(error, write.intent);
|
|
3454
|
+
const wiringError = wiringErrorInCauseChain(error);
|
|
3455
|
+
if (wiringError !== void 0) throw wiringError;
|
|
2979
3456
|
let mapped;
|
|
2980
3457
|
try {
|
|
2981
3458
|
mapped = definition.mapError(error, write);
|
|
2982
3459
|
} catch (mapperError) {
|
|
2983
3460
|
throw new RepositoryErrorMappingFailedError({
|
|
2984
|
-
|
|
3461
|
+
identity: write.aggregateIdentity,
|
|
2985
3462
|
intent: write.intent,
|
|
2986
3463
|
persistenceError: error,
|
|
2987
3464
|
mapperError
|
|
2988
3465
|
});
|
|
2989
3466
|
}
|
|
2990
|
-
if (isInfrastructureErrorLike(mapped))
|
|
3467
|
+
if (isInfrastructureErrorLike(mapped)) {
|
|
3468
|
+
attributeWriteIntent(mapped, write.intent);
|
|
3469
|
+
return mapped;
|
|
3470
|
+
}
|
|
2991
3471
|
throw new RepositoryErrorMappingFailedError({
|
|
2992
|
-
|
|
3472
|
+
identity: write.aggregateIdentity,
|
|
2993
3473
|
intent: write.intent,
|
|
2994
3474
|
persistenceError: error,
|
|
2995
3475
|
mapperError: /* @__PURE__ */ new TypeError("Repository mapError must return an InfrastructureError instance")
|
|
2996
3476
|
});
|
|
2997
3477
|
}
|
|
3478
|
+
function repositoryOperation(operation) {
|
|
3479
|
+
return `repository.${operation}`;
|
|
3480
|
+
}
|
|
2998
3481
|
|
|
2999
3482
|
//#endregion
|
|
3000
3483
|
//#region src/application/unit-of-work/unit-of-work.ts
|
|
3001
|
-
const repositoryDefinitionBrand = Symbol.for("@shirudo/ddd-kit/repository-definition/
|
|
3484
|
+
const repositoryDefinitionBrand = Symbol.for("@shirudo/ddd-kit/repository-definition/v3");
|
|
3002
3485
|
function assertRepositoryDefinitionMembers(definition) {
|
|
3003
3486
|
for (const key of [
|
|
3004
3487
|
"create",
|
|
@@ -3007,6 +3490,19 @@ function assertRepositoryDefinitionMembers(definition) {
|
|
|
3007
3490
|
]) if (typeof definition[key] !== "function") throw new TypeError(`defineRepository: "${key}" is missing or not a function on the definition. The builder copies own enumerable properties only; prototype methods and non-enumerable members are not carried. Pass a plain object literal.`);
|
|
3008
3491
|
if (typeof definition.aggregate !== "function") throw new TypeError("defineRepository: \"aggregate\" is missing or not a class reference on the definition. Pass a plain object literal with own enumerable properties.");
|
|
3009
3492
|
if (definition.persistence === null || typeof definition.persistence !== "object") throw new TypeError("defineRepository: \"persistence\" is missing or not a PersistenceModel on the definition. Pass a plain object literal with own enumerable properties.");
|
|
3493
|
+
assertPersistenceModelMembers(definition.persistence);
|
|
3494
|
+
for (const key of ["appendOnly", "physicalRemoval"]) {
|
|
3495
|
+
const flag = definition[key];
|
|
3496
|
+
if (flag !== void 0 && typeof flag !== "boolean") throw new TypeError(`defineRepository: "${key}" must be true or false, got ${String(flag)}.`);
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
function assertPersistenceModelMembers(persistence) {
|
|
3500
|
+
for (const key of [
|
|
3501
|
+
"capture",
|
|
3502
|
+
"changes",
|
|
3503
|
+
"isEmpty"
|
|
3504
|
+
]) if (typeof persistence[key] !== "function") throw new TypeError(`defineRepository: "persistence.${key}" is not a function. A PersistenceModel needs capture, changes, and isEmpty.`);
|
|
3505
|
+
if (persistence.captureEquals !== void 0 && typeof persistence.captureEquals !== "function") throw new TypeError("defineRepository: \"persistence.captureEquals\" is not a function. Omit it, or pass a function that compares two captures.");
|
|
3010
3506
|
}
|
|
3011
3507
|
/**
|
|
3012
3508
|
* Defines repository wiring for an application-owned driven port.
|
|
@@ -3015,7 +3511,8 @@ function assertRepositoryDefinitionMembers(definition) {
|
|
|
3015
3511
|
* aggregate, persistence, event, and lifecycle types from the adapter wiring.
|
|
3016
3512
|
* The port must declare `add` for the aggregate. It declares `update` unless
|
|
3017
3513
|
* the definition sets `appendOnly: true`. If it declares `remove`, the
|
|
3018
|
-
* definition must set `physicalRemoval: true`.
|
|
3514
|
+
* definition must set `physicalRemoval: true`. The adapter that `create`
|
|
3515
|
+
* returns does not define `add`, `update`, or `remove`. A violated constraint fails
|
|
3019
3516
|
* the call with a compiler error that names the constraint. The adapter
|
|
3020
3517
|
* created by the definition implements only the remaining methods because
|
|
3021
3518
|
* lifecycle writes are installed by the Unit of Work.
|
|
@@ -3113,11 +3610,10 @@ var UnitOfWork = class {
|
|
|
3113
3610
|
if (this._active) throw new NestedUnitOfWorkError();
|
|
3114
3611
|
this._active = true;
|
|
3115
3612
|
let session;
|
|
3116
|
-
|
|
3117
|
-
let
|
|
3118
|
-
let workError;
|
|
3613
|
+
const sessions = [];
|
|
3614
|
+
let attempt = startAttempt();
|
|
3119
3615
|
try {
|
|
3120
|
-
return await
|
|
3616
|
+
return await withCheckedCommit({
|
|
3121
3617
|
outbox: this.deps.outbox,
|
|
3122
3618
|
bus: this.deps.bus,
|
|
3123
3619
|
scope: this.deps.scope,
|
|
@@ -3130,37 +3626,40 @@ var UnitOfWork = class {
|
|
|
3130
3626
|
session?.close();
|
|
3131
3627
|
const s = new Session(enrollment);
|
|
3132
3628
|
session = s;
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
const context = makeContext(this.buildRepositories(tx, s), s, options?.signal);
|
|
3629
|
+
sessions.push(s);
|
|
3630
|
+
const current = startAttempt();
|
|
3631
|
+
attempt = current;
|
|
3137
3632
|
try {
|
|
3138
|
-
const result = await work(
|
|
3633
|
+
const result = await work(makeContext(this.buildRepositories(tx, s), s, options?.signal));
|
|
3139
3634
|
s.assertReadyToCommit();
|
|
3140
3635
|
await s.flush(tx);
|
|
3141
3636
|
s.assertReadyToCommit();
|
|
3142
|
-
workCompleted = true;
|
|
3637
|
+
current.workCompleted = true;
|
|
3143
3638
|
const commits = s.commitTokens;
|
|
3144
3639
|
s.close();
|
|
3145
3640
|
return {
|
|
3146
3641
|
result,
|
|
3147
|
-
commits
|
|
3642
|
+
commits,
|
|
3643
|
+
checkBeforeCommit: () => s.assertReadyToCommitLastTime(),
|
|
3644
|
+
onFailureBeforeCommit: (error) => {
|
|
3645
|
+
current.failedBeforeCommit = true;
|
|
3646
|
+
current.failureBeforeCommit = error;
|
|
3647
|
+
s.end();
|
|
3648
|
+
}
|
|
3148
3649
|
};
|
|
3149
3650
|
} catch (error) {
|
|
3150
|
-
workThrew = true;
|
|
3151
|
-
workError = error;
|
|
3651
|
+
current.workThrew = true;
|
|
3652
|
+
current.workError = error;
|
|
3653
|
+
s.end();
|
|
3152
3654
|
throw error;
|
|
3153
3655
|
}
|
|
3656
|
+
}, () => {
|
|
3657
|
+
attempt = startAttempt();
|
|
3154
3658
|
});
|
|
3155
3659
|
} catch (error) {
|
|
3156
|
-
throw classifyRunError(error,
|
|
3157
|
-
workThrew,
|
|
3158
|
-
workCompleted,
|
|
3159
|
-
workError,
|
|
3160
|
-
signal: options?.signal
|
|
3161
|
-
});
|
|
3660
|
+
throw classifyRunError(error, attempt, options?.signal);
|
|
3162
3661
|
} finally {
|
|
3163
|
-
|
|
3662
|
+
for (const ended of sessions) ended.end();
|
|
3164
3663
|
this._active = false;
|
|
3165
3664
|
}
|
|
3166
3665
|
}
|
|
@@ -3188,13 +3687,23 @@ function makeContext(repositories, session, signal) {
|
|
|
3188
3687
|
signal
|
|
3189
3688
|
};
|
|
3190
3689
|
}
|
|
3690
|
+
function startAttempt() {
|
|
3691
|
+
return {
|
|
3692
|
+
workCompleted: false,
|
|
3693
|
+
workThrew: false,
|
|
3694
|
+
workError: void 0,
|
|
3695
|
+
failedBeforeCommit: false,
|
|
3696
|
+
failureBeforeCommit: void 0
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3191
3699
|
/**
|
|
3192
3700
|
* Classifies a `withCommit` rejection into the error `run()` should throw,
|
|
3193
|
-
* using the
|
|
3701
|
+
* using the record of the attempt that the scope ran last. Pure and total: it
|
|
3194
3702
|
* returns the error to throw rather than throwing itself, so `run()` reads
|
|
3195
3703
|
* as orchestration and this decision is unit-testable in isolation.
|
|
3196
3704
|
*
|
|
3197
|
-
* - `workThrew`: the
|
|
3705
|
+
* - `workThrew`: the attempt threw before it completed: the repository
|
|
3706
|
+
* factories, the work callback, or `assertReadyToCommit`.
|
|
3198
3707
|
* The scope normally rethrows that error unchanged (rolled back, pass
|
|
3199
3708
|
* through so a `ConcurrencyConflictError` & co. stay catchable as-is); a
|
|
3200
3709
|
* scope that WRAPS the original is detected via the cause chain and also
|
|
@@ -3202,66 +3711,37 @@ function makeContext(repositories, session, signal) {
|
|
|
3202
3711
|
* callback's error indicates the rollback itself failed, which becomes a
|
|
3203
3712
|
* {@link RollbackError}.
|
|
3204
3713
|
* - `workCompleted`: the callback finished; the failure is post-completion.
|
|
3205
|
-
*
|
|
3206
|
-
* or an
|
|
3207
|
-
*
|
|
3208
|
-
* NOT extend `InfrastructureError`, so a retry-on-Infrastructure
|
|
3209
|
-
* skips it
|
|
3210
|
-
* scope can nest it: walk the chain rather than a bare
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
3714
|
+
* The checks run in this order. First, a wiring error in the rejection (a
|
|
3715
|
+
* harvest-guard violation, or an aggregate that changed after its
|
|
3716
|
+
* registration) is a deterministic programming bug and passes through; it
|
|
3717
|
+
* does NOT extend `InfrastructureError`, so a retry-on-Infrastructure
|
|
3718
|
+
* handler skips it. It is thrown inside `scope.transactional()`, so a
|
|
3719
|
+
* wrapping scope can nest it: walk the chain rather than a bare
|
|
3720
|
+
* `instanceof`. Second, a step that failed inside the transaction after
|
|
3721
|
+
* the callback (`failedBeforeCommit`) and a rejection that neither is nor
|
|
3722
|
+
* wraps that failure indicate a possible rollback failure: a
|
|
3723
|
+
* {@link RollbackError} with that failure as its cause, also when it is a
|
|
3724
|
+
* wiring error, as in the work phase. Every other post-completion failure
|
|
3725
|
+
* (a failed step whose rollback succeeded, the commit itself) becomes a
|
|
3726
|
+
* {@link CommitError}.
|
|
3213
3727
|
* - Neither flag set: `withCommit` rejected before the callback ran (the
|
|
3214
3728
|
* scope failed to even open a transaction); pass the error through.
|
|
3215
3729
|
*/
|
|
3216
|
-
function classifyRunError(error, state) {
|
|
3217
|
-
if (
|
|
3730
|
+
function classifyRunError(error, state, signal) {
|
|
3731
|
+
if (signal?.aborted && signal.reason !== void 0 && (error === signal.reason || causeChainContains(error, signal.reason))) return error;
|
|
3218
3732
|
if (state.workThrew) {
|
|
3219
3733
|
if (error === state.workError || causeChainContains(error, state.workError)) return error;
|
|
3220
3734
|
return new RollbackError(state.workError, error);
|
|
3221
3735
|
}
|
|
3222
3736
|
if (state.workCompleted) {
|
|
3223
|
-
const
|
|
3224
|
-
if (
|
|
3737
|
+
const wiringError = wiringErrorInCauseChain(error);
|
|
3738
|
+
if (wiringError) return wiringError;
|
|
3739
|
+
if (state.failedBeforeCommit && error !== state.failureBeforeCommit && !causeChainContains(error, state.failureBeforeCommit)) return new RollbackError(state.failureBeforeCommit, error);
|
|
3225
3740
|
return new CommitError(error);
|
|
3226
3741
|
}
|
|
3227
3742
|
return error;
|
|
3228
3743
|
}
|
|
3229
3744
|
/**
|
|
3230
|
-
* Cycle-safe, getter-throw-safe walk over `error`'s standard `cause`
|
|
3231
|
-
* chain. `visit` runs for every object link (the top error included) and
|
|
3232
|
-
* receives the link plus its lazily read `cause`; a non-undefined return
|
|
3233
|
-
* stops the walk. A throwing `cause` getter (lazy deserialization, revoked
|
|
3234
|
-
* Proxy) ends the walk as no-match instead of replacing the real failure
|
|
3235
|
-
* with the getter's exception.
|
|
3236
|
-
*/
|
|
3237
|
-
function findInCauseChain(error, visit) {
|
|
3238
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3239
|
-
let current = error;
|
|
3240
|
-
while (current !== null && typeof current === "object" && !seen.has(current)) {
|
|
3241
|
-
seen.add(current);
|
|
3242
|
-
let cause;
|
|
3243
|
-
try {
|
|
3244
|
-
cause = current.cause;
|
|
3245
|
-
} catch {
|
|
3246
|
-
return;
|
|
3247
|
-
}
|
|
3248
|
-
const found = visit(current, cause);
|
|
3249
|
-
if (found !== void 0) return found;
|
|
3250
|
-
current = cause;
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3253
|
-
/**
|
|
3254
|
-
* Walks `error`'s `cause` chain and returns the first `EventHarvestError`,
|
|
3255
|
-
* or `undefined`. `withCommit` throws the harvest-guard error INSIDE
|
|
3256
|
-
* `scope.transactional`, so a wrapping scope can nest it; matching
|
|
3257
|
-
* only the top-level error would let the wrapper mask the non-retryable
|
|
3258
|
-
* type. `withCommit` and `run()` share this module, so the local
|
|
3259
|
-
* `instanceof` is reliable for the un-wrapped link.
|
|
3260
|
-
*/
|
|
3261
|
-
function findHarvestErrorInChain(error) {
|
|
3262
|
-
return findInCauseChain(error, (link) => link instanceof EventHarvestError ? link : void 0);
|
|
3263
|
-
}
|
|
3264
|
-
/**
|
|
3265
3745
|
* Whether `error`'s `cause` chain contains `target` by reference. A
|
|
3266
3746
|
* `target` of `undefined`/`null` never matches: every error without a
|
|
3267
3747
|
* `cause` property would otherwise "contain" a thrown `undefined`.
|
|
@@ -3290,9 +3770,8 @@ function causeChainContains(error, target) {
|
|
|
3290
3770
|
*/
|
|
3291
3771
|
function versionedFlush(statements) {
|
|
3292
3772
|
const versionedWrites = versionedWritesOf(statements);
|
|
3293
|
-
const aggregateType = statements.aggregateType;
|
|
3294
3773
|
const runInsert = insertWriter(statements);
|
|
3295
|
-
const { update: runUpdate, remove: runRemove } = versionedWriters(
|
|
3774
|
+
const { update: runUpdate, remove: runRemove } = versionedWriters(versionedWrites);
|
|
3296
3775
|
return async (transaction, write) => {
|
|
3297
3776
|
switch (write.intent) {
|
|
3298
3777
|
case "add": return runInsert(transaction, write);
|
|
@@ -3307,7 +3786,7 @@ function versionedFlush(statements) {
|
|
|
3307
3786
|
}
|
|
3308
3787
|
/** Reads its statements here, like its versioned peers. */
|
|
3309
3788
|
function insertWriter(statements) {
|
|
3310
|
-
const {
|
|
3789
|
+
const { insert, isDuplicate } = statements;
|
|
3311
3790
|
return async (transaction, write) => {
|
|
3312
3791
|
try {
|
|
3313
3792
|
await insert(transaction, write);
|
|
@@ -3317,8 +3796,7 @@ function insertWriter(statements) {
|
|
|
3317
3796
|
duplicate = isDuplicate(error);
|
|
3318
3797
|
} catch (classifierCause) {
|
|
3319
3798
|
throw new InvalidFlushStatementError({
|
|
3320
|
-
|
|
3321
|
-
aggregateId: write.aggregateId,
|
|
3799
|
+
identity: write.aggregateIdentity,
|
|
3322
3800
|
intent: "add",
|
|
3323
3801
|
reason: "duplicate_check_failed",
|
|
3324
3802
|
cause: error,
|
|
@@ -3327,8 +3805,7 @@ function insertWriter(statements) {
|
|
|
3327
3805
|
}
|
|
3328
3806
|
if (!duplicate) throw error;
|
|
3329
3807
|
throw new DuplicateAggregateError({
|
|
3330
|
-
|
|
3331
|
-
aggregateId: write.aggregateId,
|
|
3808
|
+
identity: write.aggregateIdentity,
|
|
3332
3809
|
cause: error
|
|
3333
3810
|
});
|
|
3334
3811
|
}
|
|
@@ -3343,42 +3820,72 @@ function versionedWritesOf(statements) {
|
|
|
3343
3820
|
if ((statements.update ?? statements.remove) === void 0) return void 0;
|
|
3344
3821
|
throw new TypeError("versionedFlush: the statements carry update or remove but no currentVersion. A failed version check reports the stored version through currentVersion.");
|
|
3345
3822
|
}
|
|
3346
|
-
function versionedWriters(
|
|
3823
|
+
function versionedWriters(versionedWrites) {
|
|
3347
3824
|
return {
|
|
3348
|
-
update: versionedWriter(
|
|
3349
|
-
remove: versionedWriter(
|
|
3825
|
+
update: versionedWriter(versionedWrites, "update"),
|
|
3826
|
+
remove: versionedWriter(versionedWrites, "remove")
|
|
3350
3827
|
};
|
|
3351
3828
|
}
|
|
3352
3829
|
/**
|
|
3353
3830
|
* Resolves the statement of one intent here, so a statements object that
|
|
3354
3831
|
* changes after the flush is built has no effect.
|
|
3355
3832
|
*/
|
|
3356
|
-
function versionedWriter(
|
|
3833
|
+
function versionedWriter(versionedWrites, intent) {
|
|
3357
3834
|
const statement = versionedWrites?.[intent];
|
|
3358
3835
|
const statementDefect = (write, reason, received) => new InvalidFlushStatementError({
|
|
3359
|
-
|
|
3360
|
-
aggregateId: write.aggregateId,
|
|
3836
|
+
identity: write.aggregateIdentity,
|
|
3361
3837
|
intent,
|
|
3362
3838
|
reason,
|
|
3363
3839
|
received
|
|
3364
3840
|
});
|
|
3365
3841
|
return async (transaction, write) => {
|
|
3366
3842
|
if (versionedWrites === void 0 || statement === void 0) throw statementDefect(write, "statement_absent");
|
|
3367
|
-
if (write.expectedVersion
|
|
3843
|
+
if (!isStoredVersion(write.expectedVersion)) throw statementDefect(write, "no_expected_version", describeReceived(write.expectedVersion));
|
|
3368
3844
|
const matchedRows = await statement(transaction, write);
|
|
3369
|
-
if (!Number.isInteger(matchedRows) || matchedRows < 0) throw statementDefect(write, "no_row_count",
|
|
3845
|
+
if (!Number.isInteger(matchedRows) || matchedRows < 0) throw statementDefect(write, "no_row_count", describeReceived(matchedRows));
|
|
3370
3846
|
if (matchedRows > 0) return;
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
aggregateType,
|
|
3374
|
-
aggregateId: write.aggregateId,
|
|
3847
|
+
throw await classifyConcurrencyConflict({
|
|
3848
|
+
identity: write.aggregateIdentity,
|
|
3375
3849
|
expectedVersion: write.expectedVersion,
|
|
3376
|
-
|
|
3377
|
-
|
|
3850
|
+
transaction,
|
|
3851
|
+
currentVersion: versionedWrites.currentVersion
|
|
3378
3852
|
});
|
|
3379
3853
|
};
|
|
3380
3854
|
}
|
|
3381
3855
|
/**
|
|
3856
|
+
* Builds the conflict of a compare-and-set that matched no row. It reads the
|
|
3857
|
+
* stored version and names the reason of the conflict. The zero row count
|
|
3858
|
+
* already proves the conflict, so the read is diagnostic: a read that fails
|
|
3859
|
+
* becomes `version_unknown`, with the failure as the cause.
|
|
3860
|
+
*
|
|
3861
|
+
* A version read that returns neither a stored version nor `undefined`, and
|
|
3862
|
+
* an expected version that is no version, are defects of the statements. They
|
|
3863
|
+
* throw {@link InvalidFlushStatementError}.
|
|
3864
|
+
*/
|
|
3865
|
+
async function classifyConcurrencyConflict(options) {
|
|
3866
|
+
const { identity, expectedVersion } = options;
|
|
3867
|
+
if (!isStoredVersion(expectedVersion)) throw new InvalidFlushStatementError({
|
|
3868
|
+
identity,
|
|
3869
|
+
reason: "no_expected_version",
|
|
3870
|
+
received: describeReceived(expectedVersion)
|
|
3871
|
+
});
|
|
3872
|
+
const stored = await readStoredVersion(() => options.currentVersion(options.transaction, identity.aggregateId));
|
|
3873
|
+
if (stored.read && stored.currentVersion !== void 0 && !isStoredVersion(stored.currentVersion)) throw new InvalidFlushStatementError({
|
|
3874
|
+
identity,
|
|
3875
|
+
reason: "no_version",
|
|
3876
|
+
received: describeReceived(stored.currentVersion)
|
|
3877
|
+
});
|
|
3878
|
+
return new ConcurrencyConflictError({
|
|
3879
|
+
identity,
|
|
3880
|
+
expectedVersion,
|
|
3881
|
+
cause: stored.read ? void 0 : stored.readFailure,
|
|
3882
|
+
...storedVersionOf(stored, expectedVersion)
|
|
3883
|
+
});
|
|
3884
|
+
}
|
|
3885
|
+
function isStoredVersion(value) {
|
|
3886
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3382
3889
|
* Names which version the conflict reports, and why. A read that failed leaves
|
|
3383
3890
|
* the stored version unknown; a store that lost the aggregate has none to
|
|
3384
3891
|
* name; a version that still equals the expected one means the statement
|
|
@@ -3395,21 +3902,23 @@ function storedVersionOf(stored, expectedVersion) {
|
|
|
3395
3902
|
actualVersion: stored.currentVersion
|
|
3396
3903
|
};
|
|
3397
3904
|
}
|
|
3398
|
-
/** Names
|
|
3399
|
-
function
|
|
3905
|
+
/** Names a value that the flush received in place of a valid one. */
|
|
3906
|
+
function describeReceived(value) {
|
|
3400
3907
|
if (value === void 0) return void 0;
|
|
3401
|
-
|
|
3908
|
+
if (typeof value === "number") return String(value);
|
|
3909
|
+
let text;
|
|
3910
|
+
try {
|
|
3911
|
+
text = String(value);
|
|
3912
|
+
} catch {
|
|
3913
|
+
text = Object.prototype.toString.call(value);
|
|
3914
|
+
}
|
|
3915
|
+
return `${text} (${typeof value})`;
|
|
3402
3916
|
}
|
|
3403
|
-
|
|
3404
|
-
* Reads the stored version for the `actualVersion` of a conflict. The zero
|
|
3405
|
-
* row count already proves the conflict, so a failed read must not replace
|
|
3406
|
-
* it. Such a read reports no version and travels as the conflict's cause.
|
|
3407
|
-
*/
|
|
3408
|
-
async function readCurrentVersion(versionedWrites, transaction, aggregateId) {
|
|
3917
|
+
async function readStoredVersion(currentVersion) {
|
|
3409
3918
|
try {
|
|
3410
3919
|
return {
|
|
3411
3920
|
read: true,
|
|
3412
|
-
currentVersion: await
|
|
3921
|
+
currentVersion: await currentVersion()
|
|
3413
3922
|
};
|
|
3414
3923
|
} catch (readFailure) {
|
|
3415
3924
|
return {
|
|
@@ -3956,6 +4465,21 @@ function entityIds(entities) {
|
|
|
3956
4465
|
* (emitting any event becomes a compile error).
|
|
3957
4466
|
*/
|
|
3958
4467
|
var BaseAggregate = class extends Entity {
|
|
4468
|
+
#aggregateIdentity;
|
|
4469
|
+
/**
|
|
4470
|
+
* The full identity of the aggregate: its declared `aggregateType` and
|
|
4471
|
+
* its `id`, as one frozen value. The id alone is unique only within the
|
|
4472
|
+
* type, so this value names the aggregate unambiguously.
|
|
4473
|
+
*/
|
|
4474
|
+
get aggregateIdentity() {
|
|
4475
|
+
if (this.#aggregateIdentity !== void 0) return this.#aggregateIdentity;
|
|
4476
|
+
const identity = Object.freeze({
|
|
4477
|
+
aggregateType: this.aggregateType,
|
|
4478
|
+
aggregateId: this.id
|
|
4479
|
+
});
|
|
4480
|
+
if (identity.aggregateType !== void 0) this.#aggregateIdentity = identity;
|
|
4481
|
+
return identity;
|
|
4482
|
+
}
|
|
3959
4483
|
_version = 0;
|
|
3960
4484
|
/**
|
|
3961
4485
|
* Version the persistence layer last confirmed for this instance:
|
|
@@ -3980,8 +4504,7 @@ var BaseAggregate = class extends Entity {
|
|
|
3980
4504
|
this.discardPendingEventsAfterDeletion(events);
|
|
3981
4505
|
},
|
|
3982
4506
|
persistedVersion: () => this._persistedVersion,
|
|
3983
|
-
pendingEventCount: () => this._pendingEvents.length
|
|
3984
|
-
aggregateType: () => this.aggregateType
|
|
4507
|
+
pendingEventCount: () => this._pendingEvents.length
|
|
3985
4508
|
});
|
|
3986
4509
|
registerPendingEventRecordingCapability(this, { record: (createStamp) => this.recordPendingDecisions(createStamp) });
|
|
3987
4510
|
}
|
|
@@ -4005,11 +4528,14 @@ var BaseAggregate = class extends Entity {
|
|
|
4005
4528
|
if (!isUncommittedDomainEvent(candidate)) throw new UnmintedEventError(event.type);
|
|
4006
4529
|
return recordDomainEvent(candidate, createStamp(candidate, index));
|
|
4007
4530
|
});
|
|
4008
|
-
if (this._pendingEvents !== stamped || this._pendingEvents.length !== stampedCount) throw new ReentrantEventRecordingError(
|
|
4531
|
+
if (this._pendingEvents !== stamped || this._pendingEvents.length !== stampedCount) throw new ReentrantEventRecordingError({ identity: this.aggregateIdentity });
|
|
4009
4532
|
const seenEventIds = /* @__PURE__ */ new Set();
|
|
4010
4533
|
for (const event of recorded) {
|
|
4011
4534
|
const eventId = event.eventId;
|
|
4012
|
-
if (seenEventIds.has(eventId)) throw new DuplicateEventIdError(
|
|
4535
|
+
if (seenEventIds.has(eventId)) throw new DuplicateEventIdError({
|
|
4536
|
+
identity: this.aggregateIdentity,
|
|
4537
|
+
eventId
|
|
4538
|
+
});
|
|
4013
4539
|
seenEventIds.add(eventId);
|
|
4014
4540
|
}
|
|
4015
4541
|
this._pendingEvents = recorded;
|
|
@@ -4030,7 +4556,11 @@ var BaseAggregate = class extends Entity {
|
|
|
4030
4556
|
this.stripAcknowledgedPrefix(events);
|
|
4031
4557
|
}
|
|
4032
4558
|
stripAcknowledgedPrefix(events) {
|
|
4033
|
-
if (events.length > this._pendingEvents.length || events.some((event, index) => event !== this._pendingEvents[index])) throw new PendingEventBatchMismatchError(
|
|
4559
|
+
if (events.length > this._pendingEvents.length || events.some((event, index) => event !== this._pendingEvents[index])) throw new PendingEventBatchMismatchError({
|
|
4560
|
+
identity: this.aggregateIdentity,
|
|
4561
|
+
batchLength: events.length,
|
|
4562
|
+
pendingLength: this._pendingEvents.length
|
|
4563
|
+
});
|
|
4034
4564
|
this._pendingEvents = this._pendingEvents.slice(events.length);
|
|
4035
4565
|
}
|
|
4036
4566
|
get version() {
|
|
@@ -4099,7 +4629,7 @@ var BaseAggregate = class extends Entity {
|
|
|
4099
4629
|
* ```
|
|
4100
4630
|
*/
|
|
4101
4631
|
markReconstituted(version) {
|
|
4102
|
-
assertReplayTargetHasNoPendingEvents(this.
|
|
4632
|
+
assertReplayTargetHasNoPendingEvents(this.aggregateIdentity, this._pendingEvents.length);
|
|
4103
4633
|
const restored = toVersion(version);
|
|
4104
4634
|
if (restored < this._version) throw new InvalidVersionError(version, `is below the current version ${this._version}`);
|
|
4105
4635
|
this.setVersion(restored);
|
|
@@ -4108,8 +4638,8 @@ var BaseAggregate = class extends Entity {
|
|
|
4108
4638
|
/**
|
|
4109
4639
|
* Appends a domain event to the pending list. The event must be minted
|
|
4110
4640
|
* by a kit constructor; a missing `aggregateId` or `aggregateType` is
|
|
4111
|
-
* stamped from this aggregate, and an
|
|
4112
|
-
* aggregate throws {@link
|
|
4641
|
+
* stamped from this aggregate, and an aggregate identity that names another
|
|
4642
|
+
* aggregate throws {@link MisattributedEventError} before anything is
|
|
4113
4643
|
* recorded. Each append is one fact. A decision appended twice becomes
|
|
4114
4644
|
* two facts with distinct ids. A recorded event that is already pending
|
|
4115
4645
|
* throws {@link DuplicateEventIdError}, and a list at `maxPendingEvents`
|
|
@@ -4124,7 +4654,7 @@ var BaseAggregate = class extends Entity {
|
|
|
4124
4654
|
* application shell rejects it; use `setState(currentState, event)`.
|
|
4125
4655
|
*/
|
|
4126
4656
|
addDomainEvent(event) {
|
|
4127
|
-
const stamped = this.
|
|
4657
|
+
const stamped = this.stampNewEventIdentity(event);
|
|
4128
4658
|
this.assertEventIdsNotPending([stamped]);
|
|
4129
4659
|
this.assertPendingEventLimit(1);
|
|
4130
4660
|
this.appendStampedEvent(stamped);
|
|
@@ -4141,7 +4671,10 @@ var BaseAggregate = class extends Entity {
|
|
|
4141
4671
|
for (const pending of this._pendingEvents) if (isRecordedDomainEvent(pending)) pendingIds.add(pending.eventId);
|
|
4142
4672
|
for (const event of batch) {
|
|
4143
4673
|
if (!isRecordedDomainEvent(event)) continue;
|
|
4144
|
-
if (pendingIds.has(event.eventId)) throw new DuplicateEventIdError(
|
|
4674
|
+
if (pendingIds.has(event.eventId)) throw new DuplicateEventIdError({
|
|
4675
|
+
identity: this.aggregateIdentity,
|
|
4676
|
+
eventId: event.eventId
|
|
4677
|
+
});
|
|
4145
4678
|
pendingIds.add(event.eventId);
|
|
4146
4679
|
}
|
|
4147
4680
|
}
|
|
@@ -4157,8 +4690,7 @@ var BaseAggregate = class extends Entity {
|
|
|
4157
4690
|
const pending = this._pendingEvents.length;
|
|
4158
4691
|
if (pending + added <= limit) return;
|
|
4159
4692
|
throw new PendingEventLimitExceededError({
|
|
4160
|
-
|
|
4161
|
-
aggregateId: String(this.id),
|
|
4693
|
+
identity: this.aggregateIdentity,
|
|
4162
4694
|
limit,
|
|
4163
4695
|
pending,
|
|
4164
4696
|
added
|
|
@@ -4166,7 +4698,7 @@ var BaseAggregate = class extends Entity {
|
|
|
4166
4698
|
}
|
|
4167
4699
|
/**
|
|
4168
4700
|
* Appends an event that the caller already passed through
|
|
4169
|
-
* {@link
|
|
4701
|
+
* {@link stampNewEventIdentity}, {@link assertEventIdsNotPending} and
|
|
4170
4702
|
* {@link assertPendingEventLimit}. `setState()` and `apply()` run the
|
|
4171
4703
|
* three before the state moves and append afterwards, so each gate
|
|
4172
4704
|
* runs once per event.
|
|
@@ -4183,22 +4715,23 @@ var BaseAggregate = class extends Entity {
|
|
|
4183
4715
|
this._pendingEvents = [];
|
|
4184
4716
|
}
|
|
4185
4717
|
/**
|
|
4186
|
-
*
|
|
4718
|
+
* Aggregate identity discipline for NEW facts, shared by both flavours: a
|
|
4187
4719
|
* present-but-foreign `aggregateId` / `aggregateType` is a wiring bug and
|
|
4188
|
-
* throws {@link
|
|
4189
|
-
* from the aggregate, so a recorded event
|
|
4190
|
-
* can never fail the harvest or the replay guard
|
|
4720
|
+
* throws {@link MisattributedEventError}; missing fields are filled in
|
|
4721
|
+
* from the aggregate, so a recorded event always carries its full
|
|
4722
|
+
* aggregate identity and can never fail the harvest or the replay guard
|
|
4723
|
+
* later. The mint gate
|
|
4191
4724
|
* runs first, so an unminted event fails before anything else. The
|
|
4192
4725
|
* stamped copy is frozen like the original (payload and metadata are
|
|
4193
|
-
* shared, already deep-frozen by the constructors); a fully
|
|
4726
|
+
* shared, already deep-frozen by the constructors); a fully stamped
|
|
4194
4727
|
* event is returned as is.
|
|
4195
4728
|
*/
|
|
4196
|
-
|
|
4729
|
+
stampNewEventIdentity(event) {
|
|
4197
4730
|
this.assertMintedEvent(event);
|
|
4198
4731
|
const { aggregateId, aggregateType } = event;
|
|
4199
4732
|
const idForeign = aggregateId !== void 0 && aggregateId !== this.id;
|
|
4200
4733
|
const typeForeign = aggregateType !== void 0 && aggregateType !== this.aggregateType;
|
|
4201
|
-
if (idForeign || typeForeign) throw new
|
|
4734
|
+
if (idForeign || typeForeign) throw new MisattributedEventError({
|
|
4202
4735
|
expected: {
|
|
4203
4736
|
aggregateType: this.aggregateType,
|
|
4204
4737
|
aggregateId: this.id
|
|
@@ -4240,11 +4773,11 @@ var BaseAggregate = class extends Entity {
|
|
|
4240
4773
|
* concrete event producer, rather than in shell-owned recording data.
|
|
4241
4774
|
*/
|
|
4242
4775
|
createEvent(type, payload, options) {
|
|
4243
|
-
return
|
|
4776
|
+
return mintUncommittedDomainEvent(type, payload, {
|
|
4244
4777
|
...options,
|
|
4245
4778
|
aggregateId: this.id,
|
|
4246
4779
|
aggregateType: this.aggregateType
|
|
4247
|
-
});
|
|
4780
|
+
}, "createEvent");
|
|
4248
4781
|
}
|
|
4249
4782
|
};
|
|
4250
4783
|
/**
|
|
@@ -4267,8 +4800,11 @@ var BaseAggregate = class extends Entity {
|
|
|
4267
4800
|
* @internal Shared by the aggregate flavours in this package; not part of
|
|
4268
4801
|
* the public API.
|
|
4269
4802
|
*/
|
|
4270
|
-
function assertReplayTargetHasNoPendingEvents(
|
|
4271
|
-
if (pending > 0) throw new UnreplayableAggregateError(
|
|
4803
|
+
function assertReplayTargetHasNoPendingEvents(identity, pending) {
|
|
4804
|
+
if (pending > 0) throw new UnreplayableAggregateError({
|
|
4805
|
+
identity,
|
|
4806
|
+
reason: `it carries ${pending} unflushed pending event(s) that are not part of the persisted stream; discard this dirty instance and reconstitute a fresh aggregate before restoring persisted history`
|
|
4807
|
+
});
|
|
4272
4808
|
}
|
|
4273
4809
|
|
|
4274
4810
|
//#endregion
|
|
@@ -4358,7 +4894,7 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
4358
4894
|
* recorded and the version advances with it.
|
|
4359
4895
|
*/
|
|
4360
4896
|
setState(_newState) {
|
|
4361
|
-
throw new DirectStateMutationError(
|
|
4897
|
+
throw new DirectStateMutationError({ identity: this.aggregateIdentity });
|
|
4362
4898
|
}
|
|
4363
4899
|
/**
|
|
4364
4900
|
* Applies an event: validates the decision, locates the fold, computes
|
|
@@ -4371,9 +4907,9 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
4371
4907
|
* Throws `MissingFoldError` if no fold is declared for `event.type`.
|
|
4372
4908
|
* Throws `FoldReturnedNoStateError` (wiring) when the fold returns
|
|
4373
4909
|
* `undefined`, the signature of a fold without a `return`.
|
|
4374
|
-
* Throws `
|
|
4910
|
+
* Throws `MisattributedEventError` (wiring) when the event carries an
|
|
4375
4911
|
* `aggregateId` or `aggregateType` naming a different aggregate;
|
|
4376
|
-
* missing
|
|
4912
|
+
* missing aggregate identity fields are stamped from the aggregate instead.
|
|
4377
4913
|
*
|
|
4378
4914
|
* State is not mutated if any step throws: the fold is invoked into
|
|
4379
4915
|
* a local and only stored once all checks pass.
|
|
@@ -4390,7 +4926,7 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
4390
4926
|
* @param event - The domain event to apply
|
|
4391
4927
|
*/
|
|
4392
4928
|
apply(event) {
|
|
4393
|
-
const stamped = this.
|
|
4929
|
+
const stamped = this.stampNewEventIdentity(event);
|
|
4394
4930
|
this.assertEventIdsNotPending([stamped]);
|
|
4395
4931
|
this.assertPendingEventLimit(1);
|
|
4396
4932
|
this.validateEvent(stamped);
|
|
@@ -4414,16 +4950,16 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
4414
4950
|
* and the discriminator is resolved via the (statically-sound)
|
|
4415
4951
|
* `folds` map.
|
|
4416
4952
|
*
|
|
4417
|
-
* Replay
|
|
4953
|
+
* Replay aggregate identity check: a history event that names a DIFFERENT
|
|
4418
4954
|
* aggregate id or type is a persisted row that belongs to someone
|
|
4419
4955
|
* else (a miswired stream read, colliding ids across types, a
|
|
4420
4956
|
* corrupted store). Throws `ForeignEventError`, an
|
|
4421
4957
|
* `InfrastructureError`, which PROPAGATES through the replay
|
|
4422
4958
|
* methods (their `Result` channel is reserved for `DomainError`
|
|
4423
4959
|
* stream corruption) after the all-or-nothing rollback. History
|
|
4424
|
-
* events without the optional
|
|
4960
|
+
* events without the optional aggregate identity fields pass unchecked (the
|
|
4425
4961
|
* fields are optional on the event shape); NEW events are covered
|
|
4426
|
-
* by the stricter `
|
|
4962
|
+
* by the stricter `stampNewEventIdentity` on the apply path.
|
|
4427
4963
|
*/
|
|
4428
4964
|
assertReplayedEventBelongsHere(event) {
|
|
4429
4965
|
const idMismatch = event.aggregateId !== void 0 && event.aggregateId !== this.id;
|
|
@@ -4467,15 +5003,15 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
4467
5003
|
* a reconstituted aggregate at v=P catching up on M newer events ends at
|
|
4468
5004
|
* v=P+M. Events carry no stream position, so an overlap with the current
|
|
4469
5005
|
* version is invisible here: the caller passes only the events after
|
|
4470
|
-
* that version and
|
|
4471
|
-
*
|
|
5006
|
+
* that version, and it compares the final version with the version it
|
|
5007
|
+
* expects.
|
|
4472
5008
|
*
|
|
4473
5009
|
* The replay target must not carry pending decisions. Factory-vs-load
|
|
4474
5010
|
* lifecycle is owned by the Unit of Work rather than inferred from an
|
|
4475
5011
|
* aggregate persistence flag.
|
|
4476
5012
|
*/
|
|
4477
5013
|
replayHistory(history) {
|
|
4478
|
-
assertReplayTargetHasNoPendingEvents(this.
|
|
5014
|
+
assertReplayTargetHasNoPendingEvents(this.aggregateIdentity, requirePendingEventLifecycleReadView(this, "replayHistory").pendingEventCount());
|
|
4479
5015
|
if (history.length === 0) return ok();
|
|
4480
5016
|
const previousState = this.state;
|
|
4481
5017
|
const startVersion = this.version;
|
|
@@ -4499,10 +5035,10 @@ var EventSourcedAggregate = class extends BaseAggregate {
|
|
|
4499
5035
|
/**
|
|
4500
5036
|
* Reconstitutes an event-sourced aggregate from one page of history and
|
|
4501
5037
|
* yields it only on success. `createReplayTarget` builds the instance: a
|
|
4502
|
-
* fresh one, or one
|
|
5038
|
+
* fresh one, or one reconstituted from a snapshot. The instance exists only
|
|
4503
5039
|
* inside this call. A rejected replay therefore leaves the caller with
|
|
4504
|
-
* nothing to return by mistake.
|
|
4505
|
-
* `
|
|
5040
|
+
* nothing to return by mistake. A stream read in pages goes through
|
|
5041
|
+
* `reconstituteAggregateFromStreamPages` instead. A `DomainError` from a fold rides the
|
|
4506
5042
|
* `Result`; wiring errors and a foreign row throw, as in `replayHistory`.
|
|
4507
5043
|
* The creator runs outside the `Result`: what it throws propagates.
|
|
4508
5044
|
*/
|
|
@@ -4528,13 +5064,13 @@ var StateStoredAggregate = class extends BaseAggregate {
|
|
|
4528
5064
|
/**
|
|
4529
5065
|
* Replaces the state, advances the OCC version, and records the events
|
|
4530
5066
|
* of the change, in that order. State validation, the event mint gate,
|
|
4531
|
-
* the
|
|
4532
|
-
* event limit check run before the change becomes
|
|
4533
|
-
* rejected decision records nothing and moves nothing.
|
|
4534
|
-
* change.
|
|
5067
|
+
* the aggregate identity check of the event, the pending event-id check,
|
|
5068
|
+
* and the pending event limit check run before the change becomes
|
|
5069
|
+
* observable, so a rejected decision records nothing and moves nothing.
|
|
5070
|
+
* Without events the call is a plain versioned state change.
|
|
4535
5071
|
*/
|
|
4536
5072
|
setState(newState, events = []) {
|
|
4537
|
-
const stamped = (Array.isArray(events) ? events : [events]).map((event) => this.
|
|
5073
|
+
const stamped = (Array.isArray(events) ? events : [events]).map((event) => this.stampNewEventIdentity(event));
|
|
4538
5074
|
this.assertEventIdsNotPending(stamped);
|
|
4539
5075
|
this.assertPendingEventLimit(stamped.length);
|
|
4540
5076
|
const next = this.nextVersion();
|
|
@@ -5399,117 +5935,6 @@ function voValidated(t, validate, message = "Validation failed") {
|
|
|
5399
5935
|
return issues.hasIssues() ? err(issues) : ok(vo(t));
|
|
5400
5936
|
}
|
|
5401
5937
|
|
|
5402
|
-
//#endregion
|
|
5403
|
-
//#region src/internal/structural/detach-state.ts
|
|
5404
|
-
/**
|
|
5405
|
-
* Returns a copy of `state` that shares no object with the original.
|
|
5406
|
-
* Throws a `TypeError` that names the path when the graph carries a value
|
|
5407
|
-
* a structured clone would lose or silently degrade. A class instance, a
|
|
5408
|
-
* subclass of a built-in included, loses the methods on its prototype. A
|
|
5409
|
-
* symbol-keyed, non-enumerable, or accessor property and an expando on a
|
|
5410
|
-
* built-in are dropped. A function or a symbol value throws a raw
|
|
5411
|
-
* `DataCloneError`. An Error, a Promise, a WeakMap, or a WeakSet cannot be
|
|
5412
|
-
* detached at all. A SharedArrayBuffer and a view over one keep sharing
|
|
5413
|
-
* their memory. A Proxy is invisible to the walk and fails inside the
|
|
5414
|
-
* clone; that failure is rethrown as a `TypeError` with the cause.
|
|
5415
|
-
*
|
|
5416
|
-
* Plain objects (from any realm), arrays, Dates, Maps, Sets, bigints, and
|
|
5417
|
-
* typed arrays pass. A RegExp passes: pattern and flags survive the clone,
|
|
5418
|
-
* and `lastIndex` restores as 0. The scan state of a global or sticky
|
|
5419
|
-
* pattern is not domain data. A non-enumerable property on a built-in
|
|
5420
|
-
* passes: it is the built-in's own machinery (`lastIndex`), not data. A
|
|
5421
|
-
* non-enumerable symbol key passes anywhere: it is metadata by convention.
|
|
5422
|
-
*
|
|
5423
|
-
* The concrete entity uses it for a detached read DTO of a plain-data
|
|
5424
|
-
* state. The snapshot model uses it for the captured DTO and the restored
|
|
5425
|
-
* state. A state that carries a class-based child is mapped to plain data
|
|
5426
|
-
* first, in the entity or in the model.
|
|
5427
|
-
*/
|
|
5428
|
-
function detachState(state) {
|
|
5429
|
-
assertDetachable(state, "", /* @__PURE__ */ new WeakSet());
|
|
5430
|
-
try {
|
|
5431
|
-
return structuredClone(state);
|
|
5432
|
-
} catch (cause) {
|
|
5433
|
-
throw new TypeError("detachState: state holds a Proxy or a host object that cannot be cloned; map it to plain data", { cause });
|
|
5434
|
-
}
|
|
5435
|
-
}
|
|
5436
|
-
const INDEX_KEY = /^(0|[1-9]\d*)$/;
|
|
5437
|
-
function assertDetachable(value, path, seen) {
|
|
5438
|
-
if (typeof value === "function") throw new TypeError(`detachState: state${path} is a function; map it to plain data`);
|
|
5439
|
-
if (typeof value === "symbol") throw new TypeError(`detachState: state${path} is a symbol; map it to plain data`);
|
|
5440
|
-
if (value === null || typeof value !== "object") return;
|
|
5441
|
-
const object = value;
|
|
5442
|
-
if (seen.has(object)) return;
|
|
5443
|
-
seen.add(object);
|
|
5444
|
-
if (Array.isArray(object)) {
|
|
5445
|
-
if (!hasIntrinsicPrototypeChain(object, "Array")) throwClassInstance(object, path);
|
|
5446
|
-
assertOwnPropertiesDetachable(object, path, seen, "array");
|
|
5447
|
-
return;
|
|
5448
|
-
}
|
|
5449
|
-
const tag = builtInTagWithoutInvokingAccessors(object);
|
|
5450
|
-
if (tag !== void 0) {
|
|
5451
|
-
if (!hasIntrinsicPrototypeChain(object)) throwClassInstance(object, path);
|
|
5452
|
-
if (tag === "[object Map]") {
|
|
5453
|
-
let index = 0;
|
|
5454
|
-
for (const [key, entry] of object) {
|
|
5455
|
-
assertDetachable(key, `${path}<map key #${index}>`, seen);
|
|
5456
|
-
assertDetachable(entry, `${path}<map value #${index}>`, seen);
|
|
5457
|
-
index++;
|
|
5458
|
-
}
|
|
5459
|
-
} else if (tag === "[object Set]") {
|
|
5460
|
-
let index = 0;
|
|
5461
|
-
for (const member of object) {
|
|
5462
|
-
assertDetachable(member, `${path}<set member #${index}>`, seen);
|
|
5463
|
-
index++;
|
|
5464
|
-
}
|
|
5465
|
-
} else if (tag === "[object Promise]" || tag === "[object WeakMap]" || tag === "[object WeakSet]") throw new TypeError(`detachState: state${path} is a ${tag.slice(8, -1)} and cannot be detached`);
|
|
5466
|
-
else if (tag === "[object Error]") throw new TypeError(`detachState: state${path} is an Error; map it to plain data`);
|
|
5467
|
-
else if (sharesMemory(object, tag)) throw new TypeError(`detachState: state${path} is backed by a SharedArrayBuffer and the copy would share its memory; map it to plain data`);
|
|
5468
|
-
assertOwnPropertiesDetachable(object, path, seen, "built-in");
|
|
5469
|
-
return;
|
|
5470
|
-
}
|
|
5471
|
-
const prototype = Object.getPrototypeOf(object);
|
|
5472
|
-
if (!(prototype === null || isIntrinsicConstructorPrototype(prototype, "Object") && Object.getPrototypeOf(prototype) === null)) throwClassInstance(object, path);
|
|
5473
|
-
assertOwnPropertiesDetachable(object, path, seen, "record");
|
|
5474
|
-
}
|
|
5475
|
-
/**
|
|
5476
|
-
* Audits the own properties the clone would copy or drop. The clone keeps
|
|
5477
|
-
* the enumerable own keys of a record and of an array, expandos included,
|
|
5478
|
-
* and drops every own key of another built-in. `length` on an array and
|
|
5479
|
-
* the non-enumerable keys of a built-in (`lastIndex`) are its own
|
|
5480
|
-
* machinery, not data. Index keys of a typed array or a boxed String are
|
|
5481
|
-
* its content and pass as such.
|
|
5482
|
-
*/
|
|
5483
|
-
function assertOwnPropertiesDetachable(object, path, seen, kind) {
|
|
5484
|
-
for (const key of Reflect.ownKeys(object)) {
|
|
5485
|
-
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
5486
|
-
if (descriptor === void 0) continue;
|
|
5487
|
-
if (typeof key === "symbol") {
|
|
5488
|
-
if (!descriptor.enumerable) continue;
|
|
5489
|
-
throw new TypeError(`detachState: state${path} has a symbol-keyed property; map it to plain data`);
|
|
5490
|
-
}
|
|
5491
|
-
if (kind === "array" && key === "length") continue;
|
|
5492
|
-
const isIndex = INDEX_KEY.test(key);
|
|
5493
|
-
if (!descriptor.enumerable) {
|
|
5494
|
-
if (kind === "built-in") continue;
|
|
5495
|
-
throw new TypeError(`detachState: state${path}.${key} is not enumerable and the clone would drop it; map it to plain data`);
|
|
5496
|
-
}
|
|
5497
|
-
if (kind === "built-in" && isIndex) continue;
|
|
5498
|
-
const memberPath = isIndex ? `${path}[${key}]` : `${path}.${key}`;
|
|
5499
|
-
if (!("value" in descriptor)) throw new TypeError(`detachState: state${memberPath} is an accessor property; map it to plain data`);
|
|
5500
|
-
if (kind === "built-in") throw new TypeError(`detachState: state${memberPath} is an expando on a ${object.constructor?.name ?? "built-in"} and the clone would drop it; map it to plain data`);
|
|
5501
|
-
assertDetachable(descriptor.value, memberPath, seen);
|
|
5502
|
-
}
|
|
5503
|
-
}
|
|
5504
|
-
function sharesMemory(object, tag) {
|
|
5505
|
-
if (tag === "[object SharedArrayBuffer]") return true;
|
|
5506
|
-
return ArrayBuffer.isView(object) && Object.prototype.toString.call(object.buffer) === "[object SharedArrayBuffer]";
|
|
5507
|
-
}
|
|
5508
|
-
function throwClassInstance(object, path) {
|
|
5509
|
-
const name = Object.getPrototypeOf(object)?.constructor?.name || "anonymous class";
|
|
5510
|
-
throw new TypeError(`detachState: state${path} is a class instance (${name}); map it to plain data`);
|
|
5511
|
-
}
|
|
5512
|
-
|
|
5513
5938
|
//#endregion
|
|
5514
5939
|
//#region src/messaging/event-bus/errors.ts
|
|
5515
5940
|
/** Keeps a deep chain readable in the message without losing the recent path. */
|
|
@@ -5912,6 +6337,8 @@ var EventBusImpl = class {
|
|
|
5912
6337
|
}
|
|
5913
6338
|
once(eventType, options) {
|
|
5914
6339
|
return new Promise((resolve, reject) => {
|
|
6340
|
+
const timeoutMs = options?.timeoutMs;
|
|
6341
|
+
if (timeoutMs !== void 0) assertTimerDelay("EventBus.once", "timeoutMs", timeoutMs);
|
|
5915
6342
|
if (this.closed) {
|
|
5916
6343
|
reject(new EventBusClosedError("once"));
|
|
5917
6344
|
return;
|
|
@@ -5953,10 +6380,10 @@ var EventBusImpl = class {
|
|
|
5953
6380
|
};
|
|
5954
6381
|
signal.addEventListener("abort", abortListener);
|
|
5955
6382
|
}
|
|
5956
|
-
if (
|
|
6383
|
+
if (timeoutMs !== void 0) timer = setTimeout(() => {
|
|
5957
6384
|
cleanup();
|
|
5958
|
-
reject(/* @__PURE__ */ new Error(`EventBus.once timed out after ${
|
|
5959
|
-
},
|
|
6385
|
+
reject(/* @__PURE__ */ new Error(`EventBus.once timed out after ${timeoutMs}ms waiting for "${eventType}"`));
|
|
6386
|
+
}, timeoutMs);
|
|
5960
6387
|
});
|
|
5961
6388
|
}
|
|
5962
6389
|
/**
|
|
@@ -6218,7 +6645,10 @@ function invalid(path, reason) {
|
|
|
6218
6645
|
* name.
|
|
6219
6646
|
*/
|
|
6220
6647
|
function outboxWriterAcceptingEventLoss() {
|
|
6221
|
-
return {
|
|
6648
|
+
return {
|
|
6649
|
+
add: async () => {},
|
|
6650
|
+
endEventSources: async () => {}
|
|
6651
|
+
};
|
|
6222
6652
|
}
|
|
6223
6653
|
/**
|
|
6224
6654
|
* In-memory reference implementation of `DispatchTrackingOutbox<Evt>`
|
|
@@ -6234,12 +6664,16 @@ function outboxWriterAcceptingEventLoss() {
|
|
|
6234
6664
|
* recent-dispatch receipt cache keeps retries idempotent after acknowledgement.
|
|
6235
6665
|
* Re-adding a pending event refreshes the stored commit envelope while the
|
|
6236
6666
|
* delivery attempt count survives. Its commit sequence and size remain
|
|
6237
|
-
* immutable
|
|
6238
|
-
*
|
|
6239
|
-
* add. Dead-lettered and acknowledged
|
|
6240
|
-
* candidate receipt. Reusing an `eventId` for another source or commit position
|
|
6667
|
+
* immutable. Only this in-memory adapter may move a still-pending event to
|
|
6668
|
+
* another aggregate version. Without an `InMemoryTransactionScope`, it keeps
|
|
6669
|
+
* the first add of a rolled-back attempt. Dead-lettered and acknowledged
|
|
6670
|
+
* retries must match the complete original candidate receipt. Reusing an `eventId` for another source or commit position
|
|
6241
6671
|
* throws {@link EventHarvestError} while the pending, dead-letter, or bounded
|
|
6242
|
-
* dispatched receipt still proves the collision.
|
|
6672
|
+
* dispatched receipt still proves the collision. `add` also throws
|
|
6673
|
+
* {@link EventHarvestError} for a malformed candidate: a part that is not an
|
|
6674
|
+
* object, an `eventId` or `type` that is not an own string data property, a
|
|
6675
|
+
* source field that is not a string, or a position that is not a valid
|
|
6676
|
+
* commit position. Insertion order is preserved:
|
|
6243
6677
|
* `getPending` returns records in commit order, as the port contract requires.
|
|
6244
6678
|
*
|
|
6245
6679
|
* Dispatch tracking: `markFailed` increments the record's attempt count
|
|
@@ -6249,7 +6683,9 @@ function outboxWriterAcceptingEventLoss() {
|
|
|
6249
6683
|
* inverse of `deadLetters()`); `markDispatched` acks pending AND
|
|
6250
6684
|
* dead-lettered records (manual redelivery then ack).
|
|
6251
6685
|
* To link future eventful commits, the implementation also retains one
|
|
6252
|
-
* source cursor per qualified aggregate after dispatch.
|
|
6686
|
+
* source cursor per qualified aggregate after dispatch. `endEventSources`
|
|
6687
|
+
* marks the cursor of a removed aggregate as ended, and a new event of that
|
|
6688
|
+
* source then throws {@link EventHarvestError}. Consequently a
|
|
6253
6689
|
* long-lived instance is bounded only when `maxRecords` and `maxSources` are
|
|
6254
6690
|
* configured, plus `maxRetainedDispatchedEventIds`; use a durable adapter with
|
|
6255
6691
|
* an explicit source-head lifecycle and an event-id unique key for unbounded
|
|
@@ -6263,12 +6699,21 @@ function outboxWriterAcceptingEventLoss() {
|
|
|
6263
6699
|
* that are never `markDispatched` accumulate until `maxRecords` rejects a new
|
|
6264
6700
|
* add, or without that option grow unbounded. For a deliberate no-delivery
|
|
6265
6701
|
* setup use {@link outboxWriterAcceptingEventLoss} instead. Sharper still:
|
|
6266
|
-
*
|
|
6267
|
-
*
|
|
6268
|
-
* that
|
|
6269
|
-
*
|
|
6270
|
-
*
|
|
6271
|
-
*
|
|
6702
|
+
* on its own, the outbox knows nothing about your scope's rollback. Events
|
|
6703
|
+
* `add()`ed inside a transaction that later rolls back stay, and a source
|
|
6704
|
+
* that `endEventSources` ended inside such a transaction stays ended: the
|
|
6705
|
+
* outbox then rejects every later event of that aggregate, also on the retry
|
|
6706
|
+
* of a `RetryingTransactionScope`. For tests that roll back or retry,
|
|
6707
|
+
* register the outbox with an `InMemoryTransactionScope`. A rollback then
|
|
6708
|
+
* returns the outbox to its state at the start of the attempt.
|
|
6709
|
+
*
|
|
6710
|
+
* A relay reads only committed records. While a transaction is open,
|
|
6711
|
+
* `getPending` returns a record that the transaction wrote as it was
|
|
6712
|
+
* committed at the first write, with its current attempts. It skips a record
|
|
6713
|
+
* that was not pending at that write, such as a new or a requeued record. A
|
|
6714
|
+
* rollback also undoes an acknowledgement or a failure report that a
|
|
6715
|
+
* dispatcher made while the transaction was open. The dispatcher then
|
|
6716
|
+
* delivers that record again.
|
|
6272
6717
|
*
|
|
6273
6718
|
* @example
|
|
6274
6719
|
* ```ts
|
|
@@ -6291,8 +6736,15 @@ var InMemoryOutbox = class {
|
|
|
6291
6736
|
dead = /* @__PURE__ */ new Map();
|
|
6292
6737
|
/** Latest eventful commit and its predecessor per qualified source. */
|
|
6293
6738
|
sourceCursors = /* @__PURE__ */ new Map();
|
|
6739
|
+
/** Keys of source cursors whose aggregate was removed. */
|
|
6740
|
+
endedSourceKeys = /* @__PURE__ */ new Set();
|
|
6294
6741
|
/** Bounded insertion-ordered receipts for exact retries after acknowledgement. */
|
|
6295
6742
|
dispatchedEventIds = /* @__PURE__ */ new Map();
|
|
6743
|
+
/**
|
|
6744
|
+
* The committed pending record of each event id that the open transaction
|
|
6745
|
+
* wrote, captured at its first write. `undefined` marks an id without one.
|
|
6746
|
+
*/
|
|
6747
|
+
committedVersions;
|
|
6296
6748
|
maxDeliveryAttempts;
|
|
6297
6749
|
maxRetainedDispatchedEventIds;
|
|
6298
6750
|
maxRecords;
|
|
@@ -6310,11 +6762,14 @@ var InMemoryOutbox = class {
|
|
|
6310
6762
|
this.maxSources = options?.maxSources;
|
|
6311
6763
|
}
|
|
6312
6764
|
async add(events) {
|
|
6313
|
-
|
|
6314
|
-
this.
|
|
6315
|
-
this.
|
|
6316
|
-
|
|
6765
|
+
const batch = Array.from(events, ownCandidate);
|
|
6766
|
+
const headRetries = this.retriesAtSourceHead(batch);
|
|
6767
|
+
this.assertBatchEventReceiptIntegrity(batch);
|
|
6768
|
+
this.assertBatchPositionIntegrity(batch, headRetries);
|
|
6769
|
+
this.assertCapacity(batch, headRetries);
|
|
6770
|
+
for (const message of batch) {
|
|
6317
6771
|
const { event, source, position } = message;
|
|
6772
|
+
if (headRetries.has(event.eventId)) continue;
|
|
6318
6773
|
const dispatchedReceipt = this.dispatchedEventIds.get(event.eventId);
|
|
6319
6774
|
if (dispatchedReceipt !== void 0) {
|
|
6320
6775
|
assertSameEventSource(event, source, dispatchedReceipt.source);
|
|
@@ -6332,6 +6787,7 @@ var InMemoryOutbox = class {
|
|
|
6332
6787
|
assertSameEventSource(event, source, deadLetter.source);
|
|
6333
6788
|
assertSameCandidateReceipt(event, position, deadLetter.position);
|
|
6334
6789
|
this.dead.delete(event.eventId);
|
|
6790
|
+
this.recordCommittedVersion(event.eventId);
|
|
6335
6791
|
this.pending.set(event.eventId, {
|
|
6336
6792
|
dispatchId: deadLetter.dispatchId,
|
|
6337
6793
|
event: deadLetter.event,
|
|
@@ -6341,17 +6797,16 @@ var InMemoryOutbox = class {
|
|
|
6341
6797
|
});
|
|
6342
6798
|
continue;
|
|
6343
6799
|
}
|
|
6344
|
-
const
|
|
6345
|
-
const sourceKey = encodeAggregateAddress(source);
|
|
6800
|
+
const sourceKey = encodeAggregateIdentity(source);
|
|
6346
6801
|
const sourceCursor = this.sourceCursors.get(sourceKey);
|
|
6347
6802
|
let staleHeadVersion;
|
|
6348
6803
|
if (existing !== void 0 && position.aggregateVersion < existing.position.aggregateVersion) staleHeadVersion = existing.position.aggregateVersion;
|
|
6349
6804
|
else if (existing === void 0 && sourceCursor !== void 0 && position.aggregateVersion < sourceCursor.aggregateVersion) staleHeadVersion = sourceCursor.aggregateVersion;
|
|
6350
6805
|
if (staleHeadVersion !== void 0) throw staleHeadError(event, source, position, staleHeadVersion);
|
|
6351
6806
|
if (sourceCursor?.aggregateVersion === position.aggregateVersion) {
|
|
6352
|
-
if (sourceCursor.commitSize !== position.commitSize) throw
|
|
6807
|
+
if (sourceCursor.commitSize !== position.commitSize) throw commitSizeMismatchError(event, source, position, sourceCursor.commitSize);
|
|
6353
6808
|
const positionOwner = sourceCursor.eventIdsBySequence.get(position.commitSequence);
|
|
6354
|
-
if (positionOwner !== void 0 && positionOwner !== event.eventId) throw
|
|
6809
|
+
if (positionOwner !== void 0 && positionOwner !== event.eventId) throw positionOwnedError(event, source, position, positionOwner);
|
|
6355
6810
|
}
|
|
6356
6811
|
let previousEventfulAggregateVersion;
|
|
6357
6812
|
if (existing !== void 0 && existing.position.aggregateVersion !== position.aggregateVersion) {
|
|
@@ -6381,27 +6836,30 @@ var InMemoryOutbox = class {
|
|
|
6381
6836
|
previousEventfulAggregateVersion
|
|
6382
6837
|
});
|
|
6383
6838
|
if (existing) {
|
|
6839
|
+
this.recordCommittedVersion(event.eventId);
|
|
6384
6840
|
existing.event = event;
|
|
6385
|
-
existing.source =
|
|
6841
|
+
existing.source = source;
|
|
6386
6842
|
existing.position = ownedPosition;
|
|
6387
6843
|
continue;
|
|
6388
6844
|
}
|
|
6845
|
+
this.recordCommittedVersion(event.eventId);
|
|
6389
6846
|
this.pending.set(event.eventId, {
|
|
6390
6847
|
dispatchId: event.eventId,
|
|
6391
6848
|
event,
|
|
6392
|
-
source
|
|
6849
|
+
source,
|
|
6393
6850
|
position: ownedPosition,
|
|
6394
6851
|
attempts: 0
|
|
6395
6852
|
});
|
|
6396
6853
|
}
|
|
6854
|
+
for (const { event, source, position } of batch) if (headRetries.has(event.eventId)) this.rememberDispatched(event.eventId, source, position);
|
|
6397
6855
|
}
|
|
6398
|
-
assertCapacity(events) {
|
|
6856
|
+
assertCapacity(events, headRetries) {
|
|
6399
6857
|
const newRecordIds = /* @__PURE__ */ new Set();
|
|
6400
6858
|
const newSourceKeys = /* @__PURE__ */ new Set();
|
|
6401
6859
|
for (const { event, source } of events) {
|
|
6402
|
-
if (this.pending.has(event.eventId) || this.dead.has(event.eventId) || this.dispatchedEventIds.has(event.eventId)) continue;
|
|
6860
|
+
if (this.pending.has(event.eventId) || this.dead.has(event.eventId) || this.dispatchedEventIds.has(event.eventId) || headRetries.has(event.eventId)) continue;
|
|
6403
6861
|
newRecordIds.add(event.eventId);
|
|
6404
|
-
const sourceKey =
|
|
6862
|
+
const sourceKey = encodeAggregateIdentity(source);
|
|
6405
6863
|
if (!this.sourceCursors.has(sourceKey)) newSourceKeys.add(sourceKey);
|
|
6406
6864
|
}
|
|
6407
6865
|
const currentRecords = this.pending.size + this.dead.size;
|
|
@@ -6449,10 +6907,12 @@ var InMemoryOutbox = class {
|
|
|
6449
6907
|
}
|
|
6450
6908
|
}
|
|
6451
6909
|
}
|
|
6452
|
-
assertBatchPositionIntegrity(events) {
|
|
6910
|
+
assertBatchPositionIntegrity(events, headRetries) {
|
|
6453
6911
|
const simulatedCursors = /* @__PURE__ */ new Map();
|
|
6454
6912
|
for (const { event, source, position } of events) {
|
|
6455
|
-
|
|
6913
|
+
if (headRetries.has(event.eventId)) continue;
|
|
6914
|
+
const sourceKey = encodeAggregateIdentity(source);
|
|
6915
|
+
this.assertSourceNotEnded(event, source, position, sourceKey);
|
|
6456
6916
|
const cursor = simulatedCursors.get(sourceKey) ?? this.sourceCursors.get(sourceKey);
|
|
6457
6917
|
if (cursor === void 0 || position.aggregateVersion > cursor.aggregateVersion) {
|
|
6458
6918
|
simulatedCursors.set(sourceKey, {
|
|
@@ -6469,23 +6929,26 @@ var InMemoryOutbox = class {
|
|
|
6469
6929
|
if (pendingRecord !== void 0 && position.aggregateVersion < pendingRecord.position.aggregateVersion || pendingRecord === void 0 && !dedupes) throw staleHeadError(event, source, position, pendingRecord?.position.aggregateVersion ?? cursor.aggregateVersion);
|
|
6470
6930
|
continue;
|
|
6471
6931
|
}
|
|
6472
|
-
if (cursor.commitSize !== position.commitSize) throw
|
|
6932
|
+
if (cursor.commitSize !== position.commitSize) throw commitSizeMismatchError(event, source, position, cursor.commitSize);
|
|
6473
6933
|
const positionOwner = cursor.eventIdsBySequence.get(position.commitSequence);
|
|
6474
|
-
if (positionOwner !== void 0 && positionOwner !== event.eventId) throw
|
|
6934
|
+
if (positionOwner !== void 0 && positionOwner !== event.eventId) throw positionOwnedError(event, source, position, positionOwner);
|
|
6475
6935
|
if (positionOwner === void 0) simulatedCursors.set(sourceKey, cursorWithEvent(cursor, position.commitSequence, event.eventId));
|
|
6476
6936
|
}
|
|
6477
6937
|
}
|
|
6478
6938
|
async getPending(limit) {
|
|
6479
6939
|
const max = typeof limit === "number" ? Math.max(0, Number.isNaN(limit) ? 0 : limit) : Number.POSITIVE_INFINITY;
|
|
6480
6940
|
const batch = [];
|
|
6481
|
-
|
|
6941
|
+
const versions = this.committedVersions;
|
|
6942
|
+
for (const [eventId, live] of this.pending) {
|
|
6482
6943
|
if (batch.length >= max) break;
|
|
6944
|
+
const committed = versions?.has(eventId) ? versions.get(eventId) : live;
|
|
6945
|
+
if (committed === void 0) continue;
|
|
6483
6946
|
batch.push({
|
|
6484
|
-
dispatchId:
|
|
6485
|
-
event:
|
|
6486
|
-
source:
|
|
6487
|
-
position:
|
|
6488
|
-
attempts:
|
|
6947
|
+
dispatchId: committed.dispatchId,
|
|
6948
|
+
event: committed.event,
|
|
6949
|
+
source: committed.source,
|
|
6950
|
+
position: committed.position,
|
|
6951
|
+
attempts: live.attempts
|
|
6489
6952
|
});
|
|
6490
6953
|
}
|
|
6491
6954
|
return batch;
|
|
@@ -6495,9 +6958,88 @@ var InMemoryOutbox = class {
|
|
|
6495
6958
|
const record = this.pending.get(id) ?? this.dead.get(id);
|
|
6496
6959
|
if (record !== void 0) this.rememberDispatched(id, record.source, record.position);
|
|
6497
6960
|
this.pending.delete(id);
|
|
6961
|
+
this.forgetCommittedVersion(id);
|
|
6498
6962
|
this.dead.delete(id);
|
|
6499
6963
|
}
|
|
6500
6964
|
}
|
|
6965
|
+
/**
|
|
6966
|
+
* Records the state that a rollback of an `InMemoryTransactionScope`
|
|
6967
|
+
* returns to: pending and dead-letter records, receipts, source cursors,
|
|
6968
|
+
* and ended sources, in their order. Until the transaction ends,
|
|
6969
|
+
* `getPending` returns the committed record of each event id that the
|
|
6970
|
+
* transaction writes.
|
|
6971
|
+
*/
|
|
6972
|
+
beginTransaction() {
|
|
6973
|
+
const pending = [...this.pending].map(([id, record]) => [id, { ...record }]);
|
|
6974
|
+
const dead = [...this.dead];
|
|
6975
|
+
const receipts = [...this.dispatchedEventIds];
|
|
6976
|
+
const cursors = [...this.sourceCursors];
|
|
6977
|
+
const ended = [...this.endedSourceKeys];
|
|
6978
|
+
const versions = /* @__PURE__ */ new Map();
|
|
6979
|
+
this.committedVersions = versions;
|
|
6980
|
+
const end = () => {
|
|
6981
|
+
if (this.committedVersions === versions) this.committedVersions = void 0;
|
|
6982
|
+
};
|
|
6983
|
+
return {
|
|
6984
|
+
commit: end,
|
|
6985
|
+
rollback: () => {
|
|
6986
|
+
refill(this.pending, pending);
|
|
6987
|
+
refill(this.dead, dead);
|
|
6988
|
+
refill(this.dispatchedEventIds, receipts);
|
|
6989
|
+
refill(this.sourceCursors, cursors);
|
|
6990
|
+
this.endedSourceKeys.clear();
|
|
6991
|
+
for (const key of ended) this.endedSourceKeys.add(key);
|
|
6992
|
+
end();
|
|
6993
|
+
}
|
|
6994
|
+
};
|
|
6995
|
+
}
|
|
6996
|
+
/** A record that a dispatcher removed is no longer pending in the committed state. */
|
|
6997
|
+
forgetCommittedVersion(eventId) {
|
|
6998
|
+
if (this.committedVersions?.has(eventId)) this.committedVersions.set(eventId, void 0);
|
|
6999
|
+
}
|
|
7000
|
+
recordCommittedVersion(eventId) {
|
|
7001
|
+
const versions = this.committedVersions;
|
|
7002
|
+
if (versions === void 0 || versions.has(eventId)) return;
|
|
7003
|
+
const committed = this.pending.get(eventId);
|
|
7004
|
+
versions.set(eventId, committed === void 0 ? void 0 : { ...committed });
|
|
7005
|
+
}
|
|
7006
|
+
async endEventSources(sources) {
|
|
7007
|
+
for (const source of sources) {
|
|
7008
|
+
const sourceKey = encodeAggregateIdentity(source);
|
|
7009
|
+
if (this.sourceCursors.has(sourceKey)) this.endedSourceKeys.add(sourceKey);
|
|
7010
|
+
}
|
|
7011
|
+
}
|
|
7012
|
+
/**
|
|
7013
|
+
* Rejects a new event of an ended source above its head. An exact retry of
|
|
7014
|
+
* a stored event of an ended source stays a retry; the caller skips
|
|
7015
|
+
* retries at the source head before this check. The stale-head, commit
|
|
7016
|
+
* size, and position checks reject a candidate below the head or at a
|
|
7017
|
+
* position that another event owns, and their messages name the
|
|
7018
|
+
* re-created identity as a cause.
|
|
7019
|
+
*/
|
|
7020
|
+
assertSourceNotEnded(event, source, position, sourceKey) {
|
|
7021
|
+
if (!this.endedSourceKeys.has(sourceKey)) return;
|
|
7022
|
+
if (this.pending.has(event.eventId) || this.dead.has(event.eventId) || this.dispatchedEventIds.has(event.eventId)) return;
|
|
7023
|
+
const cursor = this.sourceCursors.get(sourceKey);
|
|
7024
|
+
if (cursor !== void 0 && (position.aggregateVersion < cursor.aggregateVersion || position.aggregateVersion === cursor.aggregateVersion && cursor.eventIdsBySequence.has(position.commitSequence))) return;
|
|
7025
|
+
throw new EventHarvestError(`InMemoryOutbox rejected event "${event.eventId}" for ${describeAggregateIdentity(source)}: the aggregate was removed, so its event source ended. The kit does not support an aggregate that is created again under a removed identity. Give the new aggregate a new id.`, event.type);
|
|
7026
|
+
}
|
|
7027
|
+
/**
|
|
7028
|
+
* Event ids of the batch that the source head names at their position
|
|
7029
|
+
* although no receipt, pending record, or dead letter remains: events
|
|
7030
|
+
* that were dispatched and whose receipt was evicted. They are decided
|
|
7031
|
+
* against the heads before the batch, so their place in the batch does
|
|
7032
|
+
* not matter. Their receipts are restored after the batch.
|
|
7033
|
+
*/
|
|
7034
|
+
retriesAtSourceHead(events) {
|
|
7035
|
+
const retries = /* @__PURE__ */ new Set();
|
|
7036
|
+
for (const { event, source, position } of events) if (!this.pending.has(event.eventId) && !this.dead.has(event.eventId) && !this.dispatchedEventIds.has(event.eventId) && this.recordedAtSourceHead(event, source, position)) retries.add(event.eventId);
|
|
7037
|
+
return retries;
|
|
7038
|
+
}
|
|
7039
|
+
recordedAtSourceHead(event, source, position) {
|
|
7040
|
+
const cursor = this.sourceCursors.get(encodeAggregateIdentity(source));
|
|
7041
|
+
return cursor?.aggregateVersion === position.aggregateVersion && cursor.commitSize === position.commitSize && cursor.eventIdsBySequence.get(position.commitSequence) === event.eventId;
|
|
7042
|
+
}
|
|
6501
7043
|
rememberDispatched(eventId, source, position) {
|
|
6502
7044
|
this.dispatchedEventIds.delete(eventId);
|
|
6503
7045
|
this.dispatchedEventIds.set(eventId, {
|
|
@@ -6521,6 +7063,7 @@ var InMemoryOutbox = class {
|
|
|
6521
7063
|
record.lastError = error instanceof Error ? error.message : String(error ?? "unknown");
|
|
6522
7064
|
if (record.attempts >= this.maxDeliveryAttempts) {
|
|
6523
7065
|
this.pending.delete(dispatchId);
|
|
7066
|
+
this.forgetCommittedVersion(dispatchId);
|
|
6524
7067
|
const deadLetter = {
|
|
6525
7068
|
dispatchId: record.dispatchId,
|
|
6526
7069
|
event: record.event,
|
|
@@ -6547,9 +7090,10 @@ function assertSameCandidateReceipt(event, received, recorded) {
|
|
|
6547
7090
|
assertReceiptShape(event, received, recorded, false);
|
|
6548
7091
|
}
|
|
6549
7092
|
/**
|
|
6550
|
-
* The lenient variant for PENDING records only:
|
|
6551
|
-
* cannot observe rollback, so a re-harvested
|
|
6552
|
-
* at a new aggregateVersion. Index and commit
|
|
7093
|
+
* The lenient variant for PENDING records only: an outbox without an
|
|
7094
|
+
* `InMemoryTransactionScope` cannot observe a rollback, so a re-harvested
|
|
7095
|
+
* event may legitimately arrive at a new aggregateVersion. Index and commit
|
|
7096
|
+
* cardinality stay immutable.
|
|
6553
7097
|
*/
|
|
6554
7098
|
function assertSameCandidateReceiptAllowingVersionRefresh(event, received, recorded) {
|
|
6555
7099
|
assertReceiptShape(event, received, recorded, true);
|
|
@@ -6558,12 +7102,77 @@ function assertReceiptShape(event, received, recorded, allowAggregateVersionRefr
|
|
|
6558
7102
|
if ((allowAggregateVersionRefresh || received.aggregateVersion === recorded.aggregateVersion) && received.commitSequence === recorded.commitSequence && received.commitSize === recorded.commitSize) return;
|
|
6559
7103
|
throw new EventHarvestError(`InMemoryOutbox rejected event "${event.eventId}": its commit candidate changed from (${recorded.aggregateVersion}, ${recorded.commitSequence}; commitSize=${recorded.commitSize}) to (${received.aggregateVersion}, ${received.commitSequence}; commitSize=${received.commitSize}). An exact redelivery must keep its source position immutable.`, event.type);
|
|
6560
7104
|
}
|
|
7105
|
+
/**
|
|
7106
|
+
* The fields of a candidate that the outbox reads, read once and checked.
|
|
7107
|
+
* The function copies the source and the position. The event stays the
|
|
7108
|
+
* caller's object, because a dispatcher gets the event that the commit
|
|
7109
|
+
* recorded. The outbox reads only `eventId` and `type` from it, so these
|
|
7110
|
+
* must be own data properties: a read of them then runs no getter.
|
|
7111
|
+
*/
|
|
7112
|
+
function ownCandidate(candidate, index) {
|
|
7113
|
+
let subject = `the candidate at index ${index}`;
|
|
7114
|
+
let eventType;
|
|
7115
|
+
const rejection = (field, expected, observed) => new EventHarvestError(`InMemoryOutbox rejected ${subject}: ${field} must be ${expected}, but it is ${observed}. Pass the candidates that withCommit builds.`, eventType);
|
|
7116
|
+
if (typeof candidate !== "object" || candidate === null) throw rejection("the candidate", "an object", describeRejected(candidate));
|
|
7117
|
+
const { event, source, position } = candidate;
|
|
7118
|
+
if (typeof event !== "object" || event === null) throw rejection("event", "an object", describeRejected(event));
|
|
7119
|
+
const eventId = Object.getOwnPropertyDescriptor(event, "eventId");
|
|
7120
|
+
const type = Object.getOwnPropertyDescriptor(event, "type");
|
|
7121
|
+
if (typeof type?.value === "string") eventType = type.value;
|
|
7122
|
+
if (typeof eventId?.value === "string") subject = `event "${eventId.value}" (the candidate at index ${index})`;
|
|
7123
|
+
for (const [field, descriptor] of [["event.eventId", eventId], ["event.type", type]]) {
|
|
7124
|
+
if (descriptor === void 0) throw rejection(field, "an own data property", "missing");
|
|
7125
|
+
if (!("value" in descriptor)) throw rejection(field, "an own data property", "an accessor");
|
|
7126
|
+
if (typeof descriptor.value !== "string") throw rejection(field, "a string", describeRejected(descriptor.value));
|
|
7127
|
+
}
|
|
7128
|
+
if (typeof source !== "object" || source === null) throw rejection("source", "an object", describeRejected(source));
|
|
7129
|
+
const { aggregateId, aggregateType } = source;
|
|
7130
|
+
if (typeof aggregateType !== "string") throw rejection("source.aggregateType", "a string", describeRejected(aggregateType));
|
|
7131
|
+
if (typeof aggregateId !== "string") throw rejection("source.aggregateId", "a string", describeRejected(aggregateId));
|
|
7132
|
+
if (typeof position !== "object" || position === null) throw rejection("position", "an object", describeRejected(position));
|
|
7133
|
+
const { aggregateVersion, commitSequence, commitSize } = position;
|
|
7134
|
+
if (!Number.isSafeInteger(aggregateVersion) || aggregateVersion < 0) throw rejection("position.aggregateVersion", "an integer >= 0", describeRejected(aggregateVersion));
|
|
7135
|
+
if (!Number.isSafeInteger(commitSize) || commitSize < 1) throw rejection("position.commitSize", "an integer >= 1", describeRejected(commitSize));
|
|
7136
|
+
if (!Number.isSafeInteger(commitSequence) || commitSequence < 0 || commitSequence >= commitSize) throw rejection("position.commitSequence", `an integer from 0 to ${commitSize - 1}`, describeRejected(commitSequence));
|
|
7137
|
+
return {
|
|
7138
|
+
event,
|
|
7139
|
+
source: Object.freeze({
|
|
7140
|
+
aggregateId,
|
|
7141
|
+
aggregateType
|
|
7142
|
+
}),
|
|
7143
|
+
position: Object.freeze({
|
|
7144
|
+
aggregateVersion,
|
|
7145
|
+
commitSequence,
|
|
7146
|
+
commitSize
|
|
7147
|
+
})
|
|
7148
|
+
};
|
|
7149
|
+
}
|
|
7150
|
+
/** Describes a rejected value. It does not call `toString` of an object. */
|
|
7151
|
+
function describeRejected(value) {
|
|
7152
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
7153
|
+
if (value === null || value === void 0) return String(value);
|
|
7154
|
+
if (typeof value === "object") return "an object";
|
|
7155
|
+
if (typeof value === "function") return "a function";
|
|
7156
|
+
if (typeof value === "symbol") return "a symbol";
|
|
7157
|
+
return `${typeof value} ${String(value)}`;
|
|
7158
|
+
}
|
|
7159
|
+
/** Replaces the entries of a map with recorded ones, in their order. */
|
|
7160
|
+
function refill(map, entries) {
|
|
7161
|
+
map.clear();
|
|
7162
|
+
for (const [key, value] of entries) map.set(key, value);
|
|
7163
|
+
}
|
|
7164
|
+
function commitSizeMismatchError(event, source, position, recordedCommitSize) {
|
|
7165
|
+
return new EventHarvestError(`InMemoryOutbox rejected event "${event.eventId}" for ${describeAggregateIdentity(source)}: aggregate version ${position.aggregateVersion} was already recorded with commitSize ${recordedCommitSize}, not ${position.commitSize}. An aggregate that was removed and created again under the same identity causes this too; the kit does not support that, so give the new aggregate a new id.`, event.type);
|
|
7166
|
+
}
|
|
7167
|
+
function positionOwnedError(event, source, position, positionOwner) {
|
|
7168
|
+
return new EventHarvestError(`InMemoryOutbox rejected event "${event.eventId}" for ${describeAggregateIdentity(source)}: source position (${position.aggregateVersion}, ${position.commitSequence}) is already owned by event "${positionOwner}". One qualified source position must identify exactly one immutable event. An aggregate that was removed and created again under the same identity causes this too; the kit does not support that, so give the new aggregate a new id.`, event.type);
|
|
7169
|
+
}
|
|
6561
7170
|
function staleHeadError(event, source, position, staleHeadVersion) {
|
|
6562
|
-
return new EventHarvestError(`InMemoryOutbox rejected stale event "${event.eventId}" for ${source
|
|
7171
|
+
return new EventHarvestError(`InMemoryOutbox rejected stale event "${event.eventId}" for ${describeAggregateIdentity(source)} at aggregate version ${position.aggregateVersion}: the event-source head is already ${staleHeadVersion}. There are two possible causes. First, the aggregate was removed and created again under the same identity. The kit does not support this, because the versions of the new aggregate restart below the head. Give the new aggregate a new id. Second, this is a retry of an event whose dispatched-id receipt expired. Use a durable outbox with a transactional eventId unique key for unbounded idempotency.`, event.type);
|
|
6563
7172
|
}
|
|
6564
7173
|
function assertSameEventSource(event, received, recorded) {
|
|
6565
7174
|
if (received.aggregateType === recorded.aggregateType && received.aggregateId === recorded.aggregateId) return;
|
|
6566
|
-
throw new EventHarvestError(`InMemoryOutbox rejected eventId collision for "${event.eventId}": it already belongs to ${recorded
|
|
7175
|
+
throw new EventHarvestError(`InMemoryOutbox rejected eventId collision for "${event.eventId}": it already belongs to ${describeAggregateIdentity(recorded)}, but was received for ${describeAggregateIdentity(received)}. An eventId must identify one immutable event across all aggregate sources.`, event.type);
|
|
6567
7176
|
}
|
|
6568
7177
|
|
|
6569
7178
|
//#endregion
|
|
@@ -6718,8 +7327,8 @@ var OutboxDispatcher = class extends PollLoop {
|
|
|
6718
7327
|
this.classifyFailure = options.classifyFailure;
|
|
6719
7328
|
this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? 3e4;
|
|
6720
7329
|
this.storageTimeoutMs = options.storageTimeoutMs ?? 3e4;
|
|
6721
|
-
|
|
6722
|
-
|
|
7330
|
+
assertTimerDelay("OutboxDispatcher", "deliveryTimeoutMs", this.deliveryTimeoutMs);
|
|
7331
|
+
assertTimerDelay("OutboxDispatcher", "storageTimeoutMs", this.storageTimeoutMs);
|
|
6723
7332
|
}
|
|
6724
7333
|
/**
|
|
6725
7334
|
* One full dispatch pass (the `run`/`drainOnce` shell lives on
|
|
@@ -6813,6 +7422,19 @@ var OutboxDispatcher = class extends PollLoop {
|
|
|
6813
7422
|
|
|
6814
7423
|
//#endregion
|
|
6815
7424
|
//#region src/persistence/event-store/adapters/in-memory-event-store.ts
|
|
7425
|
+
/**
|
|
7426
|
+
* A copy of `event` that shares nothing with the caller. The walk rejects a
|
|
7427
|
+
* value that a copy would lose or change, for example a function, a class
|
|
7428
|
+
* instance, or an accessor, and what a freeze cannot make immutable (binary
|
|
7429
|
+
* data and a RegExp), as the event constructors do.
|
|
7430
|
+
* It runs no accessor unless a Proxy trap adds one. Its rejection names the
|
|
7431
|
+
* event, and any other error passes unchanged.
|
|
7432
|
+
*/
|
|
7433
|
+
function copyForStorage(event, index, stream) {
|
|
7434
|
+
const subject = `InMemoryEventStore.append (stream ${describeAggregateIdentity(stream)}): events[${index}]`;
|
|
7435
|
+
if (event === null || typeof event !== "object") throw new TypeError(`${subject} is not an event object`);
|
|
7436
|
+
return detachFreezableState(event, subject);
|
|
7437
|
+
}
|
|
6816
7438
|
function assertStreamPosition(name, value) {
|
|
6817
7439
|
if (value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) throw new RangeError(`InMemoryEventStore: ${name} must be a non-negative safe integer, got ${String(value)}`);
|
|
6818
7440
|
}
|
|
@@ -6826,20 +7448,19 @@ function assertStreamPosition(name, value) {
|
|
|
6826
7448
|
* `InMemoryCapacityExceededError`; histories are never silently evicted.
|
|
6827
7449
|
* Implements the full port contract: expectedVersion-guarded appends
|
|
6828
7450
|
* (throwing `ConcurrencyConflictError` on mismatch), atomic rejected
|
|
6829
|
-
* appends, explicit
|
|
7451
|
+
* appends, explicit absent/existing stream state with the actual head,
|
|
6830
7452
|
* append-order reads, mandatory page bounds, and `(fromVersion, toVersion]`
|
|
6831
|
-
* slicing. Invalid limits or positions reject with `RangeError`.
|
|
7453
|
+
* slicing. Invalid limits or positions reject with `RangeError`. A read
|
|
7454
|
+
* with an aborted `signal` rejects with its `reason`.
|
|
6832
7455
|
*
|
|
6833
7456
|
* For production, back the port with a durable store whose append and
|
|
6834
7457
|
* the aggregate transaction share atomicity (a table with a
|
|
6835
7458
|
* `(aggregate_type, aggregate_id, position)` unique key inside the same
|
|
6836
7459
|
* transaction, or a dedicated event store). Same caveat as
|
|
6837
|
-
* `InMemoryOutbox`: this class
|
|
6838
|
-
*
|
|
6839
|
-
*
|
|
6840
|
-
*
|
|
6841
|
-
* reference environment shows the snapshot/restore pattern for
|
|
6842
|
-
* rollback-pure in-memory testing.
|
|
7460
|
+
* `InMemoryOutbox`: this class lives in memory only. On its own, it knows
|
|
7461
|
+
* nothing about your `TransactionScope` rollbacks, so events appended inside
|
|
7462
|
+
* a transaction that later rolls back stay. Register the store with an
|
|
7463
|
+
* `InMemoryTransactionScope` for tests that roll back or retry.
|
|
6843
7464
|
*/
|
|
6844
7465
|
var InMemoryEventStore = class {
|
|
6845
7466
|
streams = /* @__PURE__ */ new Map();
|
|
@@ -6852,14 +7473,48 @@ var InMemoryEventStore = class {
|
|
|
6852
7473
|
this.maxStreams = options.maxStreams;
|
|
6853
7474
|
this.maxEvents = options.maxEvents;
|
|
6854
7475
|
}
|
|
7476
|
+
/**
|
|
7477
|
+
* Records the state that a rollback of an `InMemoryTransactionScope`
|
|
7478
|
+
* returns to: every stream and the event count.
|
|
7479
|
+
*/
|
|
7480
|
+
beginTransaction() {
|
|
7481
|
+
const streams = [...this.streams].map(([key, events]) => [key, [...events]]);
|
|
7482
|
+
const totalEvents = this.totalEvents;
|
|
7483
|
+
return {
|
|
7484
|
+
commit: () => {},
|
|
7485
|
+
rollback: () => {
|
|
7486
|
+
this.streams.clear();
|
|
7487
|
+
for (const [key, events] of streams) this.streams.set(key, [...events]);
|
|
7488
|
+
this.totalEvents = totalEvents;
|
|
7489
|
+
}
|
|
7490
|
+
};
|
|
7491
|
+
}
|
|
6855
7492
|
async append(stream, events, options) {
|
|
6856
|
-
|
|
6857
|
-
|
|
7493
|
+
const batch = Array.from(events);
|
|
7494
|
+
if (batch.length === 0) return;
|
|
7495
|
+
const identity = detachAggregateIdentity(stream);
|
|
7496
|
+
const expectedVersion = options.expectedVersion;
|
|
7497
|
+
const key = encodeAggregateIdentity(identity);
|
|
7498
|
+
this.existingStreamForAppend(identity, key, expectedVersion, batch.length);
|
|
7499
|
+
const owned = batch.map((event, index) => copyForStorage(event, index, identity));
|
|
7500
|
+
let storedEvents = this.existingStreamForAppend(identity, key, expectedVersion, owned.length);
|
|
7501
|
+
if (storedEvents === void 0) {
|
|
7502
|
+
storedEvents = [];
|
|
7503
|
+
this.streams.set(key, storedEvents);
|
|
7504
|
+
}
|
|
7505
|
+
for (const event of owned) storedEvents.push(event);
|
|
7506
|
+
this.totalEvents += owned.length;
|
|
7507
|
+
}
|
|
7508
|
+
/**
|
|
7509
|
+
* The stream that an append of `count` events at `expectedVersion`
|
|
7510
|
+
* writes to, or `undefined` for a new stream. Throws when the version
|
|
7511
|
+
* is stale or a capacity would overflow.
|
|
7512
|
+
*/
|
|
7513
|
+
existingStreamForAppend(stream, key, expectedVersion, count) {
|
|
6858
7514
|
const existing = this.streams.get(key);
|
|
6859
|
-
if ((existing?.length ?? 0) !==
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
expectedVersion: options.expectedVersion,
|
|
7515
|
+
if ((existing?.length ?? 0) !== expectedVersion) throw new ConcurrencyConflictError({
|
|
7516
|
+
identity: stream,
|
|
7517
|
+
expectedVersion,
|
|
6863
7518
|
reason: "stale_version",
|
|
6864
7519
|
actualVersion: existing?.length ?? 0
|
|
6865
7520
|
});
|
|
@@ -6870,26 +7525,21 @@ var InMemoryEventStore = class {
|
|
|
6870
7525
|
current: this.streams.size,
|
|
6871
7526
|
attempted: 1
|
|
6872
7527
|
});
|
|
6873
|
-
if (this.maxEvents !== void 0 && this.totalEvents +
|
|
7528
|
+
if (this.maxEvents !== void 0 && this.totalEvents + count > this.maxEvents) throw new InMemoryCapacityExceededError({
|
|
6874
7529
|
store: "InMemoryEventStore",
|
|
6875
7530
|
resource: "events",
|
|
6876
7531
|
limit: this.maxEvents,
|
|
6877
7532
|
current: this.totalEvents,
|
|
6878
|
-
attempted:
|
|
7533
|
+
attempted: count
|
|
6879
7534
|
});
|
|
6880
|
-
|
|
6881
|
-
if (storedEvents === void 0) {
|
|
6882
|
-
storedEvents = [];
|
|
6883
|
-
this.streams.set(key, storedEvents);
|
|
6884
|
-
}
|
|
6885
|
-
for (const event of events) storedEvents.push(structuredClone(event));
|
|
6886
|
-
this.totalEvents += events.length;
|
|
7535
|
+
return existing;
|
|
6887
7536
|
}
|
|
6888
7537
|
async readStream(stream, options) {
|
|
6889
7538
|
if (!Number.isSafeInteger(options?.limit) || options.limit < 1) throw new RangeError(`InMemoryEventStore: limit must be a positive safe integer, got ${String(options?.limit)}`);
|
|
6890
7539
|
assertStreamPosition("fromVersion", options.fromVersion);
|
|
6891
7540
|
assertStreamPosition("toVersion", options.toVersion);
|
|
6892
|
-
|
|
7541
|
+
if (options.signal?.aborted) throw abortReason(options.signal, "InMemoryEventStore.readStream aborted");
|
|
7542
|
+
const events = this.streams.get(encodeAggregateIdentity(stream));
|
|
6893
7543
|
if (events === void 0) return {
|
|
6894
7544
|
exists: false,
|
|
6895
7545
|
lastVersion: 0,
|
|
@@ -6906,6 +7556,191 @@ var InMemoryEventStore = class {
|
|
|
6906
7556
|
}
|
|
6907
7557
|
};
|
|
6908
7558
|
|
|
7559
|
+
//#endregion
|
|
7560
|
+
//#region src/persistence/event-store/reconstitute-from-stream-pages.ts
|
|
7561
|
+
/**
|
|
7562
|
+
* Reconstitutes an event-sourced aggregate from the pages of a stream read
|
|
7563
|
+
* and yields it only when the replay ends at the target version.
|
|
7564
|
+
*
|
|
7565
|
+
* This is the paged form of `reconstituteAggregateFromHistory`.
|
|
7566
|
+
* `createReplayTarget` builds the replay target: a fresh instance for a
|
|
7567
|
+
* full replay, or an instance reconstituted from a snapshot for a read that
|
|
7568
|
+
* starts at `snapshot.version`. `read` is the reachable branch of a kit
|
|
7569
|
+
* read, or the value that an adapter that pages on its own built.
|
|
7570
|
+
*
|
|
7571
|
+
* The call checks the window first. `targetVersion` must be a positive
|
|
7572
|
+
* safe integer, and `fromVersion` a non-negative one at or below it. A bad
|
|
7573
|
+
* window rejects with `RangeError` before the replay target is built.
|
|
7574
|
+
*
|
|
7575
|
+
* The call then primes the replay target with an empty history, so the
|
|
7576
|
+
* aggregate runs its own guard even when the read holds no page: a replay
|
|
7577
|
+
* target with pending decisions throws `UnreplayableAggregateError`. The
|
|
7578
|
+
* replay target must stand at `read.fromVersion`. A replay target at
|
|
7579
|
+
* another version throws {@link ReplayTargetMismatchError} before the
|
|
7580
|
+
* first page, even when it also rejected the empty history: a wiring
|
|
7581
|
+
* defect wins over a rejection that the data cannot explain.
|
|
7582
|
+
*
|
|
7583
|
+
* Each page goes through `replayHistory` on the replay target, so
|
|
7584
|
+
* allocation stays bounded by the size of a page; `readStreamPages` rejects
|
|
7585
|
+
* a page over its `limit`. An empty page, and a page that would run past
|
|
7586
|
+
* the target version, throw {@link InvalidEventStreamPageError} before any
|
|
7587
|
+
* row of them reaches the aggregate. Events carry no stream position, so
|
|
7588
|
+
* only the call can find pages that end short of the target version. After
|
|
7589
|
+
* the last page it compares the final version with `read.targetVersion` and
|
|
7590
|
+
* throws {@link ReplayTargetMismatchError} on a difference.
|
|
7591
|
+
*
|
|
7592
|
+
* When the aggregate rejects a stored event with a `DomainError`, the call
|
|
7593
|
+
* stops reading and returns {@link ReplayRejectedError} as `Err`. The
|
|
7594
|
+
* error names the stream and the window of the rejected page, and it holds
|
|
7595
|
+
* the `DomainError` as `cause`. The replay target never escapes on that
|
|
7596
|
+
* path. Wiring errors and a foreign row throw, as in `replayHistory`. What
|
|
7597
|
+
* `createReplayTarget` throws propagates.
|
|
7598
|
+
*/
|
|
7599
|
+
async function reconstituteAggregateFromStreamPages(createReplayTarget, read) {
|
|
7600
|
+
assertNonNegativeSafeInteger("reconstituteAggregateFromStreamPages", "fromVersion", read.fromVersion);
|
|
7601
|
+
assertPositiveSafeInteger("reconstituteAggregateFromStreamPages", "targetVersion", read.targetVersion);
|
|
7602
|
+
if (read.fromVersion > read.targetVersion) throw new RangeError(`reconstituteAggregateFromStreamPages: fromVersion must not exceed targetVersion, got ${read.fromVersion} > ${read.targetVersion}`);
|
|
7603
|
+
const aggregate = createReplayTarget();
|
|
7604
|
+
const primed = aggregate.replayHistory([]);
|
|
7605
|
+
if (aggregate.version !== read.fromVersion) throw new ReplayTargetMismatchError({
|
|
7606
|
+
identity: read.stream,
|
|
7607
|
+
reason: "target_not_at_cursor",
|
|
7608
|
+
fromVersion: read.fromVersion,
|
|
7609
|
+
targetVersion: read.targetVersion,
|
|
7610
|
+
actualVersion: aggregate.version
|
|
7611
|
+
});
|
|
7612
|
+
if (primed.isErr()) return err(new ReplayRejectedError({
|
|
7613
|
+
identity: read.stream,
|
|
7614
|
+
fromVersion: aggregate.version,
|
|
7615
|
+
toVersion: aggregate.version,
|
|
7616
|
+
cause: primed.error
|
|
7617
|
+
}));
|
|
7618
|
+
for await (const page of read.pages) {
|
|
7619
|
+
assertPageNotEmpty(read.stream, page.length, aggregate.version, read.targetVersion);
|
|
7620
|
+
assertPageWithinWindow(read.stream, page.length, aggregate.version, read.targetVersion);
|
|
7621
|
+
const versionBeforePage = aggregate.version;
|
|
7622
|
+
const replayed = aggregate.replayHistory(page);
|
|
7623
|
+
if (replayed.isErr()) return err(new ReplayRejectedError({
|
|
7624
|
+
identity: read.stream,
|
|
7625
|
+
fromVersion: versionBeforePage,
|
|
7626
|
+
toVersion: versionBeforePage + page.length,
|
|
7627
|
+
cause: replayed.error
|
|
7628
|
+
}));
|
|
7629
|
+
}
|
|
7630
|
+
if (aggregate.version !== read.targetVersion) throw new ReplayTargetMismatchError({
|
|
7631
|
+
identity: read.stream,
|
|
7632
|
+
reason: "pages_short_of_target",
|
|
7633
|
+
fromVersion: read.fromVersion,
|
|
7634
|
+
targetVersion: read.targetVersion,
|
|
7635
|
+
actualVersion: aggregate.version
|
|
7636
|
+
});
|
|
7637
|
+
return ok(aggregate);
|
|
7638
|
+
}
|
|
7639
|
+
|
|
7640
|
+
//#endregion
|
|
7641
|
+
//#region src/persistence/repository/adapters/in-memory-transaction-scope.ts
|
|
7642
|
+
const ABORT_MESSAGE$1 = "InMemoryTransactionScope aborted";
|
|
7643
|
+
const { registry: registeredParticipants } = createGlobalCapabilityRegistry(Symbol.for("@shirudo/ddd-kit/in-memory-transaction-participants/v1"));
|
|
7644
|
+
/**
|
|
7645
|
+
* A {@link TransactionScope} for tests and demos that gives in-memory stores
|
|
7646
|
+
* a rollback. Register every store that the work writes inside the
|
|
7647
|
+
* transaction. When the work fails, each registered store undoes the writes
|
|
7648
|
+
* of the attempt, so the retry of a `RetryingTransactionScope` does not see
|
|
7649
|
+
* them. Each store decides what its rollback undoes, and its documentation
|
|
7650
|
+
* says so.
|
|
7651
|
+
*
|
|
7652
|
+
* **One transaction at a time.** A store cannot tell which of two open
|
|
7653
|
+
* transactions a write belongs to. The scope therefore runs its transactions
|
|
7654
|
+
* one after the other, in call order. A queued transaction stops its wait
|
|
7655
|
+
* when its abort signal fires. Work that waits inside a transaction for
|
|
7656
|
+
* another transaction of the same scope never finishes, because the inner
|
|
7657
|
+
* transaction waits for the outer one. Only the abort signal of the inner
|
|
7658
|
+
* transaction ends that wait. A database with only one connection behaves
|
|
7659
|
+
* the same way. Use a database with real isolation to test concurrent
|
|
7660
|
+
* transactions.
|
|
7661
|
+
*
|
|
7662
|
+
* **Code outside a transaction.** A dispatcher, a deadline processor, and a
|
|
7663
|
+
* `hasReached` check run outside the transactions. Outbox `getPending`,
|
|
7664
|
+
* deadline `due`, and checkpoint `hasReached` read only committed writes.
|
|
7665
|
+
* Other reads also see the writes of an open transaction. A store that
|
|
7666
|
+
* restores its whole state on rollback also undoes the writes that code
|
|
7667
|
+
* outside the transaction made meanwhile. An example is an acknowledgement
|
|
7668
|
+
* of an outbox dispatcher. The dispatcher then delivers the record again,
|
|
7669
|
+
* which the at-least-once contract allows.
|
|
7670
|
+
*
|
|
7671
|
+
* **A store belongs to one scope.** A scope works like one in-memory
|
|
7672
|
+
* database, so register all stores of a test with one scope. A second scope
|
|
7673
|
+
* with a registered store throws at construction, because the rollback of
|
|
7674
|
+
* one scope would undo a commit of the other.
|
|
7675
|
+
*
|
|
7676
|
+
* A store that is not registered keeps its own behavior: it does not roll
|
|
7677
|
+
* back.
|
|
7678
|
+
*/
|
|
7679
|
+
var InMemoryTransactionScope = class {
|
|
7680
|
+
participants;
|
|
7681
|
+
lastTransaction = Promise.resolve();
|
|
7682
|
+
constructor(participants) {
|
|
7683
|
+
const listed = /* @__PURE__ */ new Map();
|
|
7684
|
+
for (const [index, participant] of participants.entries()) {
|
|
7685
|
+
if (typeof participant?.beginTransaction !== "function") throw new TypeError(`InMemoryTransactionScope: participant ${index} has no beginTransaction method.`);
|
|
7686
|
+
const firstIndex = listed.get(participant);
|
|
7687
|
+
if (firstIndex !== void 0) throw new TypeError(`InMemoryTransactionScope: participant ${index} repeats participant ${firstIndex}.`);
|
|
7688
|
+
if (registeredParticipants.has(participant)) throw new TypeError(`InMemoryTransactionScope: participant ${index} already belongs to an InMemoryTransactionScope. Register all in-memory stores of a test with one scope.`);
|
|
7689
|
+
listed.set(participant, index);
|
|
7690
|
+
}
|
|
7691
|
+
for (const participant of listed.keys()) registeredParticipants.set(participant, this);
|
|
7692
|
+
this.participants = [...listed.keys()];
|
|
7693
|
+
}
|
|
7694
|
+
async transactional(fn, options) {
|
|
7695
|
+
const previous = this.lastTransaction;
|
|
7696
|
+
let finish;
|
|
7697
|
+
this.lastTransaction = new Promise((resolve) => {
|
|
7698
|
+
finish = resolve;
|
|
7699
|
+
});
|
|
7700
|
+
const signal = options?.signal;
|
|
7701
|
+
try {
|
|
7702
|
+
await waitRejectingOnAbort(previous, signal, ABORT_MESSAGE$1);
|
|
7703
|
+
if (signal?.aborted) throw abortReason(signal, ABORT_MESSAGE$1);
|
|
7704
|
+
const transactions = [];
|
|
7705
|
+
let result;
|
|
7706
|
+
try {
|
|
7707
|
+
for (const participant of this.participants) transactions.push(participant.beginTransaction());
|
|
7708
|
+
result = await fn(void 0);
|
|
7709
|
+
} catch (error) {
|
|
7710
|
+
const rollback = endAll([...transactions].reverse(), (transaction) => transaction.rollback());
|
|
7711
|
+
throw rollback.failed ? rollback.failure : error;
|
|
7712
|
+
}
|
|
7713
|
+
const commit = endAll(transactions, (transaction) => transaction.commit());
|
|
7714
|
+
if (commit.failed) throw commit.failure;
|
|
7715
|
+
return result;
|
|
7716
|
+
} finally {
|
|
7717
|
+
previous.then(finish);
|
|
7718
|
+
}
|
|
7719
|
+
}
|
|
7720
|
+
};
|
|
7721
|
+
/**
|
|
7722
|
+
* Ends every transaction, also after a failed end, and returns the first
|
|
7723
|
+
* failure. For a rollback, a scope that rejects with a different error than
|
|
7724
|
+
* the failure inside the transaction tells `UnitOfWork.run` that the
|
|
7725
|
+
* rollback failed.
|
|
7726
|
+
*/
|
|
7727
|
+
function endAll(transactions, end) {
|
|
7728
|
+
let failure;
|
|
7729
|
+
let failed = false;
|
|
7730
|
+
for (const transaction of transactions) try {
|
|
7731
|
+
end(transaction);
|
|
7732
|
+
} catch (error) {
|
|
7733
|
+
if (!failed) {
|
|
7734
|
+
failure = error;
|
|
7735
|
+
failed = true;
|
|
7736
|
+
}
|
|
7737
|
+
}
|
|
7738
|
+
return {
|
|
7739
|
+
failed,
|
|
7740
|
+
failure
|
|
7741
|
+
};
|
|
7742
|
+
}
|
|
7743
|
+
|
|
6909
7744
|
//#endregion
|
|
6910
7745
|
//#region src/persistence/repository/retrying-scope.ts
|
|
6911
7746
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
@@ -6940,6 +7775,10 @@ function defaultSleep(ms, signal) {
|
|
|
6940
7775
|
* After `maxAttempts` the last error is rethrown unchanged, so a caller
|
|
6941
7776
|
* can still match `ConcurrencyConflictError` and map it to HTTP 409.
|
|
6942
7777
|
*
|
|
7778
|
+
* **Attempts.** Before each attempt, the scope calls `onAttemptStart` from
|
|
7779
|
+
* the `transactional` options, so the caller can attribute a failure to the
|
|
7780
|
+
* attempt that raised it.
|
|
7781
|
+
*
|
|
6943
7782
|
* **Cancellation.** The `AbortSignal` from `transactional` options is
|
|
6944
7783
|
* checked before each attempt and aborts the backoff wait, so an
|
|
6945
7784
|
* `AbortSignal.timeout(ms)` bounds total elapsed time (there is
|
|
@@ -6960,8 +7799,8 @@ var RetryingTransactionScope = class {
|
|
|
6960
7799
|
this.baseDelayMs = policy.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
6961
7800
|
this.maxDelayMs = policy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
6962
7801
|
assertPositiveInteger("RetryingTransactionScope", "maxAttempts", this.maxAttempts);
|
|
6963
|
-
|
|
6964
|
-
|
|
7802
|
+
assertTimerDelay("RetryingTransactionScope", "baseDelayMs", this.baseDelayMs);
|
|
7803
|
+
assertTimerDelay("RetryingTransactionScope", "maxDelayMs", this.maxDelayMs);
|
|
6965
7804
|
this.isRetryable = policy.isRetryable ?? someChainRetryable;
|
|
6966
7805
|
this.sleep = policy.sleep ?? defaultSleep;
|
|
6967
7806
|
this.random = neutralJitterSource(policy.random ?? Math.random);
|
|
@@ -6979,6 +7818,7 @@ var RetryingTransactionScope = class {
|
|
|
6979
7818
|
};
|
|
6980
7819
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
6981
7820
|
if (signal?.aborted) throw abortReason(signal, ABORT_MESSAGE);
|
|
7821
|
+
options?.onAttemptStart?.();
|
|
6982
7822
|
try {
|
|
6983
7823
|
return await this.inner.transactional(fn, options);
|
|
6984
7824
|
} catch (error) {
|
|
@@ -7014,6 +7854,13 @@ var RetryingTransactionScope = class {
|
|
|
7014
7854
|
* rebuildable derived data, so `maxEntries` may evict the least recently used
|
|
7015
7855
|
* entry and `ttlMs` may expire it safely. A load updates LRU recency but does
|
|
7016
7856
|
* not extend TTL; only another save does.
|
|
7857
|
+
*
|
|
7858
|
+
* The port keeps snapshot writes out of the write transaction, so the store
|
|
7859
|
+
* does not roll back on its own. If you register it with an
|
|
7860
|
+
* `InMemoryTransactionScope`, a rollback restores the snapshots, their LRU
|
|
7861
|
+
* order, and their expiry from the start of the transaction. It also undoes
|
|
7862
|
+
* a save or a delete that code made outside the transaction meanwhile. This
|
|
7863
|
+
* is safe, because a snapshot is derived data.
|
|
7017
7864
|
*/
|
|
7018
7865
|
var InMemorySnapshotStore = class {
|
|
7019
7866
|
snapshots = /* @__PURE__ */ new Map();
|
|
@@ -7027,8 +7874,22 @@ var InMemorySnapshotStore = class {
|
|
|
7027
7874
|
this.ttlMs = options.ttlMs;
|
|
7028
7875
|
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
7029
7876
|
}
|
|
7030
|
-
|
|
7031
|
-
|
|
7877
|
+
/**
|
|
7878
|
+
* Records the state that a rollback of an `InMemoryTransactionScope`
|
|
7879
|
+
* returns to: every snapshot in LRU order.
|
|
7880
|
+
*/
|
|
7881
|
+
beginTransaction() {
|
|
7882
|
+
const snapshots = [...this.snapshots];
|
|
7883
|
+
return {
|
|
7884
|
+
commit: () => {},
|
|
7885
|
+
rollback: () => {
|
|
7886
|
+
this.snapshots.clear();
|
|
7887
|
+
for (const [key, stored] of snapshots) this.snapshots.set(key, stored);
|
|
7888
|
+
}
|
|
7889
|
+
};
|
|
7890
|
+
}
|
|
7891
|
+
async load(identity) {
|
|
7892
|
+
const key = encodeAggregateIdentity(identity);
|
|
7032
7893
|
const stored = this.snapshots.get(key);
|
|
7033
7894
|
if (stored === void 0) return void 0;
|
|
7034
7895
|
if (stored.expiresAtMs !== void 0 && this.readClock() >= stored.expiresAtMs) {
|
|
@@ -7039,9 +7900,9 @@ var InMemorySnapshotStore = class {
|
|
|
7039
7900
|
this.snapshots.set(key, stored);
|
|
7040
7901
|
return structuredClone(stored.snapshot);
|
|
7041
7902
|
}
|
|
7042
|
-
async save(
|
|
7903
|
+
async save(identity, snapshot) {
|
|
7043
7904
|
const ownedSnapshot = structuredClone(snapshot);
|
|
7044
|
-
const key =
|
|
7905
|
+
const key = encodeAggregateIdentity(identity);
|
|
7045
7906
|
let expiresAtMs;
|
|
7046
7907
|
if (this.ttlMs !== void 0) {
|
|
7047
7908
|
const nowMs = this.readClock();
|
|
@@ -7058,8 +7919,8 @@ var InMemorySnapshotStore = class {
|
|
|
7058
7919
|
expiresAtMs
|
|
7059
7920
|
});
|
|
7060
7921
|
}
|
|
7061
|
-
async delete(
|
|
7062
|
-
this.snapshots.delete(
|
|
7922
|
+
async delete(identity) {
|
|
7923
|
+
this.snapshots.delete(encodeAggregateIdentity(identity));
|
|
7063
7924
|
}
|
|
7064
7925
|
readClock() {
|
|
7065
7926
|
const now = this.clock();
|
|
@@ -7122,7 +7983,10 @@ function reconstituteAggregateFromSnapshot(model, id, snapshot) {
|
|
|
7122
7983
|
try {
|
|
7123
7984
|
version = toVersion(snapshot.version);
|
|
7124
7985
|
} catch (error) {
|
|
7125
|
-
throw new SnapshotCorruptedError(`Snapshot of ${
|
|
7986
|
+
throw new SnapshotCorruptedError(`Snapshot of ${describeAggregateIdentity({
|
|
7987
|
+
aggregateType: model.aggregateType,
|
|
7988
|
+
aggregateId: String(id)
|
|
7989
|
+
})} carries the invalid version ${String(snapshot.version)}. Discard the derived snapshot and refold from the stream.`, error);
|
|
7126
7990
|
}
|
|
7127
7991
|
let aggregate;
|
|
7128
7992
|
try {
|
|
@@ -7130,19 +7994,26 @@ function reconstituteAggregateFromSnapshot(model, id, snapshot) {
|
|
|
7130
7994
|
if (storedSchemaVersion === model.schemaVersion) state = detachState(snapshot.state);
|
|
7131
7995
|
else if (model.migrate) state = detachState(model.migrate(detachState(snapshot.state), storedSchemaVersion));
|
|
7132
7996
|
else throw new SnapshotSchemaMismatchError({
|
|
7133
|
-
|
|
7134
|
-
|
|
7997
|
+
identity: {
|
|
7998
|
+
aggregateType: model.aggregateType,
|
|
7999
|
+
aggregateId: String(id)
|
|
8000
|
+
},
|
|
7135
8001
|
expectedSchemaVersion: model.schemaVersion,
|
|
7136
8002
|
actualSchemaVersion: storedSchemaVersion
|
|
7137
8003
|
});
|
|
7138
8004
|
aggregate = model.reconstitute(id, state, version);
|
|
7139
8005
|
} catch (error) {
|
|
7140
|
-
if (isDomainErrorLike(error)) throw new SnapshotCorruptedError(`Snapshot of ${
|
|
8006
|
+
if (isDomainErrorLike(error)) throw new SnapshotCorruptedError(`Snapshot of ${describeAggregateIdentity({
|
|
8007
|
+
aggregateType: model.aggregateType,
|
|
8008
|
+
aggregateId: String(id)
|
|
8009
|
+
})} (schema ${storedSchemaVersion}, version ${String(snapshot.version)}) was rejected during reconstitution. Discard the derived snapshot and refold from the stream.`, error);
|
|
7141
8010
|
throw error;
|
|
7142
8011
|
}
|
|
7143
8012
|
if (aggregate.version !== snapshot.version) throw new SnapshotVersionNotRestoredError({
|
|
7144
|
-
|
|
7145
|
-
|
|
8013
|
+
identity: {
|
|
8014
|
+
aggregateType: model.aggregateType,
|
|
8015
|
+
aggregateId: String(id)
|
|
8016
|
+
},
|
|
7146
8017
|
snapshotVersion: snapshot.version,
|
|
7147
8018
|
restoredVersion: aggregate.version
|
|
7148
8019
|
});
|
|
@@ -7160,5 +8031,5 @@ function copySnapshotAt(snapshotAt) {
|
|
|
7160
8031
|
}
|
|
7161
8032
|
|
|
7162
8033
|
//#endregion
|
|
7163
|
-
export { AggregateDeletedError, AggregateNotFoundError, AggregateTrackingError, CapabilityRegistryConflictError, CommandBus, CommitError, ConcurrencyConflictError, DeadlineProcessor, DirectStateMutationError, DomainError, DomainEventValidationError, DomainStateMachine, DomainTransitionGuardRejectedError, DuplicateAggregateError, DuplicateEventIdError, DuplicateHandlerRegistrationError, Entity, ErrorMapperFailedError, EventBusClosedError, EventBusImpl, EventHarvestError, EventSourcedAggregate, FoldReturnedNoStateError, ForeignEventError, HostileStateKeyError, IdempotencyClaimLostError, IdempotencyCompletionWithoutClaimError, IdempotencyInFlightError, IdempotencyKeyReuseError, IdempotencyReconciliationRequiredError, IdentityMap, InMemoryCapacityExceededError, InMemoryDeadlineStore, InMemoryEventStore, InMemoryIdempotencyStore, InMemoryOutbox, InMemoryProjectionCheckpointStore, InMemorySnapshotStore, InfrastructureError, InvalidCommandMessageError, InvalidDomainMachineContextError, InvalidDomainMachineDefinitionError, InvalidDomainMachineInputError, InvalidDomainMachineSnapshotError, InvalidDomainTransitionError, InvalidDomainTransitionGuardResultError, InvalidDomainTransitionResultError, InvalidFlushStatementError, InvalidIntegrationMessageError, InvalidRepositoryAdapterError, InvalidRepositoryDefinitionError, InvalidVersionError,
|
|
8034
|
+
export { AggregateDeletedError, AggregateNotFoundError, AggregateTrackingError, CapabilityRegistryConflictError, CommandBus, CommitError, ConcurrencyConflictError, DeadlineProcessor, DirectStateMutationError, DomainError, DomainEventValidationError, DomainStateMachine, DomainTransitionGuardRejectedError, DuplicateAggregateError, DuplicateEventIdError, DuplicateHandlerRegistrationError, Entity, ErrorMapperFailedError, EventBusClosedError, EventBusImpl, EventHarvestError, EventSourcedAggregate, FoldReturnedNoStateError, ForeignEventError, HostileStateKeyError, IdempotencyClaimLostError, IdempotencyCompletionWithoutClaimError, IdempotencyInFlightError, IdempotencyKeyReuseError, IdempotencyReconciliationRequiredError, IdentityMap, InMemoryCapacityExceededError, InMemoryDeadlineStore, InMemoryEventStore, InMemoryIdempotencyStore, InMemoryOutbox, InMemoryProjectionCheckpointStore, InMemorySnapshotStore, InMemoryTransactionScope, InfrastructureError, InvalidCommandMessageError, InvalidDomainMachineContextError, InvalidDomainMachineDefinitionError, InvalidDomainMachineInputError, InvalidDomainMachineSnapshotError, InvalidDomainTransitionError, InvalidDomainTransitionGuardResultError, InvalidDomainTransitionResultError, InvalidEventStreamPageError, InvalidFlushStatementError, InvalidIntegrationMessageError, InvalidRepositoryAdapterError, InvalidRepositoryDefinitionError, InvalidVersionError, MisattributedEventError, MissingEntityIdError, MissingFoldError, MissingHandlerError, NestedUnitOfWorkError, OutboxDispatcher, PendingEventBatchMismatchError, PendingEventLimitExceededError, ProjectionGapError, ProjectionIdentityViolationError, ProjectionOrderViolationError, ProjectionReceiptViolationError, Projector, PublishDepthExceededError, QueryBus, ReentrantDomainStateMachineEvaluationError, ReentrantEventRecordingError, ReplayRejectedError, ReplayTargetMismatchError, RepositoryErrorMappingFailedError, RetryingTransactionScope, RollbackError, SnapshotCorruptedError, SnapshotSchemaMismatchError, SnapshotTimeValidationError, SnapshotVersionNotRestoredError, Specification, StateStoredAggregate, TransactionClosedError, UnenrolledChangesError, UnitOfWork, UnmanagedInstanceError, UnmintedEventError, UnprojectableEventError, UnregisteredHandlerError, UnreplayableAggregateError, ValueObject, analyzeDomainMachineDefinition, canTransitionDomainState, captureAggregateSnapshot, capturePersistenceBaseline, classifyConcurrencyConflict, copyMetadata, createDomainEvent, createDomainEventFactory, createDomainEventFromFacts, createInitialDomainMachineSnapshot, createIntegrationMessage, createUncommittedDomainEvent, decodeIntegrationMessage, deepEqual, deepEqualExcept, deepFreeze, deepOmit, defaultDomainEventFactory, defineRepository, defineSnapshotModel, derivePersistenceChanges, detachState, domainErrorToResult, encodeIntegrationMessage, entityIds, eventBusSink, findEntityById, freezeShallow, hasEntityId, ignoreProjectionEvent, insertPersistenceBaseline, integrationMessageToCommittedEvent, isDomainErrorLike, isInfrastructureErrorLike, isPositionAfter, isWiringErrorLike, mergeMetadata, outboxWriterAcceptingEventLoss, persistenceProjectionDrifted, pinTargetVersion, prepareDomainMachineDefinition, projectionFromHandlers, readStreamPages, recapturePersistenceBaseline, reconstituteAggregateFromHistory, reconstituteAggregateFromSnapshot, reconstituteAggregateFromStreamPages, recordDomainEvent, recordPendingEvents, removeEntityById, replaceEntityById, routeEventsToCommandOutbox, sameEntity, sameVersion, specification, toVersion, transitionDomainState, updateEntityById, versionedFlush, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit, withIdempotentCommit };
|
|
7164
8035
|
//# sourceMappingURL=index.js.map
|