@shirudo/ddd-kit 3.0.0-rc.10 → 3.0.0-rc.12
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} +713 -225
- package/dist/chunks/read-stream-pages.js.map +1 -0
- package/dist/chunks/snapshot-store.d.ts +597 -115
- package/dist/index.d.ts +608 -437
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { f as DomainError, ot as ReplayRejectedError } from "./kit-errors.js";
|
|
2
2
|
import { Result } from "@shirudo/result";
|
|
3
3
|
//#region src/internal/json-value.d.ts
|
|
4
4
|
/** A primitive value represented without loss by JSON. */
|
|
@@ -160,9 +160,9 @@ interface PublishedCommand<TType extends string = string, TPayload extends JsonV
|
|
|
160
160
|
*/
|
|
161
161
|
type CommandHandler<C extends Command, R, E = string> = (cmd: C) => Promise<Result<R, E>>;
|
|
162
162
|
//#endregion
|
|
163
|
-
//#region src/domain/aggregate/aggregate-
|
|
163
|
+
//#region src/domain/aggregate/aggregate-identity.d.ts
|
|
164
164
|
/**
|
|
165
|
-
*
|
|
165
|
+
* The identity of one aggregate instance, as a value.
|
|
166
166
|
*
|
|
167
167
|
* Aggregate ids are type-scoped, so the raw id alone is not globally unique:
|
|
168
168
|
* `SalesOrder 1` and `FulfillmentOrder 1` are different aggregates. Event
|
|
@@ -170,13 +170,14 @@ type CommandHandler<C extends Command, R, E = string> = (cmd: C) => Promise<Resu
|
|
|
170
170
|
* therefore carry both fields instead of defining boundary-specific variants.
|
|
171
171
|
*
|
|
172
172
|
* `aggregateType` is a stable technical stream category. Renaming it changes
|
|
173
|
-
* persistence keys and orphans checkpoints unless the stored
|
|
174
|
-
* migrated. When bounded contexts share infrastructure and
|
|
175
|
-
* name, qualify it at the source (`sales.order`,
|
|
173
|
+
* persistence keys and orphans checkpoints unless the stored aggregate
|
|
174
|
+
* identities are migrated. When bounded contexts share infrastructure and
|
|
175
|
+
* reuse a domain name, qualify it at the source (`sales.order`,
|
|
176
|
+
* `fulfillment.order`). The kit
|
|
176
177
|
* deliberately adds no separate `boundedContext` field: qualification remains
|
|
177
178
|
* the consumer's naming decision.
|
|
178
179
|
*/
|
|
179
|
-
interface
|
|
180
|
+
interface AggregateIdentity<TAggregateId extends string = string> {
|
|
180
181
|
readonly aggregateType: string;
|
|
181
182
|
readonly aggregateId: TAggregateId;
|
|
182
183
|
}
|
|
@@ -323,8 +324,9 @@ type AnyDomainEvent = DomainEvent<string, unknown>;
|
|
|
323
324
|
* A domain event accepted by an aggregate but not yet given its recording
|
|
324
325
|
* identity, recording time, or delivery metadata.
|
|
325
326
|
*
|
|
326
|
-
* The aggregate owns the event type, payload, source
|
|
327
|
-
* schema version because those values describe the business
|
|
327
|
+
* The aggregate owns the event type, payload, source aggregate identity,
|
|
328
|
+
* and payload schema version because those values describe the business
|
|
329
|
+
* fact it produced.
|
|
328
330
|
* The application shell later turns this value into a {@link DomainEvent}.
|
|
329
331
|
*/
|
|
330
332
|
interface UncommittedDomainEvent<T extends string, P = void> {
|
|
@@ -434,6 +436,27 @@ interface DomainEventFactory {
|
|
|
434
436
|
*/
|
|
435
437
|
readonly now: () => Date;
|
|
436
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Creates an immutable, instance-bound domain-event factory.
|
|
441
|
+
*
|
|
442
|
+
* The supplied functions are read once and captured by value. The returned
|
|
443
|
+
* object is frozen, so another request, test, or library cannot replace its
|
|
444
|
+
* policy. Its {@link DomainEventFactory.createStamp} method is the
|
|
445
|
+
* application-shell bridge that records an accepted aggregate decision.
|
|
446
|
+
* Passing the factory through `AggregateConfig`
|
|
447
|
+
* enables the explicitly named convenience methods, whose defaults read time
|
|
448
|
+
* and randomness.
|
|
449
|
+
*
|
|
450
|
+
* @example
|
|
451
|
+
* ```ts
|
|
452
|
+
* const domainEvents = createDomainEventFactory({
|
|
453
|
+
* eventIdFactory: () => uuidv7(),
|
|
454
|
+
* clock: () => new Date(),
|
|
455
|
+
* });
|
|
456
|
+
* order.confirm();
|
|
457
|
+
* recordPendingEvents(order, domainEvents);
|
|
458
|
+
* ```
|
|
459
|
+
*/
|
|
437
460
|
declare function createDomainEventFactory(options?: DomainEventFactoryOptions): DomainEventFactory;
|
|
438
461
|
/**
|
|
439
462
|
* Immutable UUID-v4/platform-clock factory used by the top-level
|
|
@@ -441,16 +464,71 @@ declare function createDomainEventFactory(options?: DomainEventFactoryOptions):
|
|
|
441
464
|
* with {@link createDomainEventFactory} for custom policy.
|
|
442
465
|
*/
|
|
443
466
|
declare const defaultDomainEventFactory: DomainEventFactory;
|
|
467
|
+
/**
|
|
468
|
+
* Creates an uncommitted domain event: the event that an aggregate method
|
|
469
|
+
* returns before the application shell records it. The payload and the
|
|
470
|
+
* metadata follow the input ownership rule of {@link createDomainEvent}.
|
|
471
|
+
*/
|
|
444
472
|
declare function createUncommittedDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateUncommittedDomainEventOptions): UncommittedDomainEvent<T, void>;
|
|
445
473
|
declare function createUncommittedDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateUncommittedDomainEventOptions): UncommittedDomainEvent<T, P>;
|
|
446
474
|
/**
|
|
447
475
|
* Attaches shell-owned recording data to an accepted aggregate decision.
|
|
448
476
|
*
|
|
449
|
-
* The decision supplies the domain type, payload, source
|
|
450
|
-
* schema version. The stamp supplies only event
|
|
451
|
-
* trace metadata.
|
|
477
|
+
* The decision supplies the domain type, payload, source aggregate
|
|
478
|
+
* identity, and payload schema version. The stamp supplies only event
|
|
479
|
+
* identity, recording time, and trace metadata.
|
|
452
480
|
*/
|
|
453
481
|
declare function recordDomainEvent<T extends string, P>(event: UncommittedDomainEvent<T, P>, stamp: DomainEventStamp): DomainEvent<T, P>;
|
|
482
|
+
/**
|
|
483
|
+
* Creates a domain event with default values.
|
|
484
|
+
* Sets occurredAt to current date and schemaVersion to 1 if not provided.
|
|
485
|
+
*
|
|
486
|
+
* **Input ownership.** The event is deeply frozen, and `payload` and
|
|
487
|
+
* `metadata` are deep-cloned first, so the caller's own objects are never
|
|
488
|
+
* frozen in place and later mutation of them does not bleed into the event
|
|
489
|
+
* (same contract as `vo()`). The event data rule checks the data before the
|
|
490
|
+
* copy and the copy after it. So the copy loses no data without an error,
|
|
491
|
+
* and no getter of the data runs. The in-memory event store applies the
|
|
492
|
+
* same rule on append. The checks catch accidents, not a hostile `Proxy`: a
|
|
493
|
+
* trap that changes the data during the check can hide a loss. An error
|
|
494
|
+
* that a `Proxy` trap throws passes unchanged, so a coded error of the
|
|
495
|
+
* caller keeps its code. These values throw a `TypeError`: a function, a
|
|
496
|
+
* Promise, a WeakMap or a WeakSet, an `Error`, a host object such as a
|
|
497
|
+
* `File`, a binary buffer, a `RegExp`, and shared memory. The same holds
|
|
498
|
+
* for what the copy would lose or change: a class instance, an accessor, an
|
|
499
|
+
* enumerable symbol-keyed property, a non-enumerable data property, and an
|
|
500
|
+
* expando on a built-in. A non-enumerable symbol key is metadata by
|
|
501
|
+
* convention, and the event does not get it. Map a value object to plain
|
|
502
|
+
* data before it goes into an event. `toJSON()` of a `ValueObject` is
|
|
503
|
+
* shallow, so map a nested value object too.
|
|
504
|
+
*
|
|
505
|
+
* **For aggregate-internal events, prefer `this.createEvent(...)` on
|
|
506
|
+
* `StateStoredAggregate` / `EventSourcedAggregate`.** That helper auto-injects
|
|
507
|
+
* `aggregateId` (from `this.id`) and `aggregateType` (from the
|
|
508
|
+
* aggregate's declared `aggregateType` property), which downstream
|
|
509
|
+
* consumers (outbox dispatchers, projection handlers, audit logs)
|
|
510
|
+
* route by. The commit boundary validates that both fields are present
|
|
511
|
+
* and throws if they are missing, so a direct `createDomainEvent(...)`
|
|
512
|
+
* call inside an aggregate that forgets the options is caught at
|
|
513
|
+
* runtime. Record pending decisions in the application
|
|
514
|
+
* shell before repository persistence or outbox harvest.
|
|
515
|
+
*
|
|
516
|
+
* Use `createDomainEvent(...)` directly for events that don't belong to
|
|
517
|
+
* an aggregate: system events, integration events, configuration events,
|
|
518
|
+
* test fixtures. For those, set `aggregateId` / `aggregateType` in
|
|
519
|
+
* `options` if downstream consumers expect routing metadata.
|
|
520
|
+
*
|
|
521
|
+
* @param type - The event type
|
|
522
|
+
* @param payload - The event payload
|
|
523
|
+
* @param options - Optional event configuration (including `aggregateId`
|
|
524
|
+
* and `aggregateType` for routing)
|
|
525
|
+
* @returns A domain event
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* ```typescript
|
|
529
|
+
* const event = createDomainEvent("OrderCreated", { orderId: "123" });
|
|
530
|
+
* ```
|
|
531
|
+
*/
|
|
454
532
|
declare function createDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateDomainEventOptions): DomainEvent<T, void>;
|
|
455
533
|
declare function createDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateDomainEventOptions): DomainEvent<T, P>;
|
|
456
534
|
/**
|
|
@@ -531,7 +609,7 @@ type EventCommitCandidatePosition = Omit<CommitPosition, "previousEventfulAggreg
|
|
|
531
609
|
*/
|
|
532
610
|
interface EventCommitCandidate<Evt extends AnyDomainEvent> {
|
|
533
611
|
readonly event: Evt;
|
|
534
|
-
readonly source:
|
|
612
|
+
readonly source: AggregateIdentity;
|
|
535
613
|
readonly position: EventCommitCandidatePosition;
|
|
536
614
|
}
|
|
537
615
|
/**
|
|
@@ -541,7 +619,7 @@ interface EventCommitCandidate<Evt extends AnyDomainEvent> {
|
|
|
541
619
|
*/
|
|
542
620
|
interface CommittedDomainEvent<Evt extends AnyDomainEvent> {
|
|
543
621
|
readonly event: Evt;
|
|
544
|
-
readonly source:
|
|
622
|
+
readonly source: AggregateIdentity;
|
|
545
623
|
readonly position: CommitPosition;
|
|
546
624
|
}
|
|
547
625
|
//#endregion
|
|
@@ -625,6 +703,22 @@ interface OutboxWriter<Evt extends AnyDomainEvent> {
|
|
|
625
703
|
* should reject it rather than replace or silently reinterpret it as a retry.
|
|
626
704
|
*/
|
|
627
705
|
add: (events: ReadonlyArray<EventCommitCandidate<Evt>>) => Promise<void>;
|
|
706
|
+
/**
|
|
707
|
+
* Ends the event sources of removed aggregates. Called from inside
|
|
708
|
+
* `withCommit`'s transactional callback, after `add()`, atomically with
|
|
709
|
+
* the removal.
|
|
710
|
+
*
|
|
711
|
+
* For every source that has a head, the adapter marks the head as ended
|
|
712
|
+
* in the SAME transaction. The mark must outlive any retention of heads:
|
|
713
|
+
* a pruned ended head opens the source again. A source without a head
|
|
714
|
+
* has no events, so it has no continuity to protect: it stays open, and
|
|
715
|
+
* its first event starts a new head. After the mark, `add()` must reject
|
|
716
|
+
* a new event of that source: its aggregate was created again under a
|
|
717
|
+
* removed identity, which the kit does not support. An exact retry of a
|
|
718
|
+
* stored event stays idempotent, and ending a source twice is not an
|
|
719
|
+
* error.
|
|
720
|
+
*/
|
|
721
|
+
endEventSources: (sources: ReadonlyArray<AggregateIdentity>) => Promise<void>;
|
|
628
722
|
}
|
|
629
723
|
/**
|
|
630
724
|
* Transactional outbox port: the bridge between the write-side
|
|
@@ -773,7 +867,7 @@ interface DurableCommandMessage<C extends PublishedCommand> extends CommandMessa
|
|
|
773
867
|
*/
|
|
774
868
|
interface CommandCommitOriginCandidate {
|
|
775
869
|
readonly eventId: string;
|
|
776
|
-
readonly source:
|
|
870
|
+
readonly source: AggregateIdentity;
|
|
777
871
|
readonly position: EventCommitCandidatePosition;
|
|
778
872
|
}
|
|
779
873
|
/**
|
|
@@ -801,6 +895,14 @@ interface CommandOutboxCommitCandidate<C extends PublishedCommand> {
|
|
|
801
895
|
*/
|
|
802
896
|
interface CommandOutboxWriter<C extends PublishedCommand> {
|
|
803
897
|
add(commits: ReadonlyArray<CommandOutboxCommitCandidate<C>>): Promise<void>;
|
|
898
|
+
/**
|
|
899
|
+
* Ends the source cursors of removed aggregates, in the same transaction
|
|
900
|
+
* as the removal. After the mark, `add()` must reject a new commit whose
|
|
901
|
+
* origin comes from an ended source; an exact retry by `origin.eventId`
|
|
902
|
+
* stays idempotent. A source without a cursor stays open, and ending a
|
|
903
|
+
* source twice is not an error. See `OutboxWriter.endEventSources`.
|
|
904
|
+
*/
|
|
905
|
+
endEventSources(sources: ReadonlyArray<AggregateIdentity>): Promise<void>;
|
|
804
906
|
}
|
|
805
907
|
/** Maps one private accepted event to zero or more addressed commands. */
|
|
806
908
|
type CommandOutboxMapper<Evt extends AnyDomainEvent, C extends PublishedCommand> = (event: Evt) => ReadonlyArray<CommandMessageContent<C>>;
|
|
@@ -937,6 +1039,8 @@ interface AggregateSnapshot<TState> {
|
|
|
937
1039
|
*/
|
|
938
1040
|
interface Aggregate<TId extends Id<string>, TEvent extends AnyDomainEvent = AnyDomainEvent> {
|
|
939
1041
|
readonly id: TId;
|
|
1042
|
+
/** The aggregate type and the id: the full identity of the aggregate. */
|
|
1043
|
+
readonly aggregateIdentity: AggregateIdentity<TId>;
|
|
940
1044
|
readonly version: Version;
|
|
941
1045
|
readonly pendingEvents: ReadonlyArray<PendingDomainEvent<TEvent>>;
|
|
942
1046
|
}
|
|
@@ -953,7 +1057,8 @@ interface ReplayableAggregate<TId extends Id<string>, TEvent extends AnyDomainEv
|
|
|
953
1057
|
* `Result` because event-stream corruption is an expected
|
|
954
1058
|
* recoverable failure at the infrastructure boundary: a `DomainError`
|
|
955
1059
|
* thrown by a fold arrives as `Err`. Every other failure propagates
|
|
956
|
-
* after the all-or-nothing rollback.
|
|
1060
|
+
* after the all-or-nothing rollback. The pending-decisions guard runs
|
|
1061
|
+
* for an empty history as well; an empty history is otherwise a no-op.
|
|
957
1062
|
*
|
|
958
1063
|
* @throws ForeignEventError when a history event names another aggregate
|
|
959
1064
|
* @throws UnreplayableAggregateError when the target carries pending
|
|
@@ -1238,6 +1343,15 @@ interface TransactionalOptions {
|
|
|
1238
1343
|
* eagerly cancellable.
|
|
1239
1344
|
*/
|
|
1240
1345
|
readonly signal?: AbortSignal;
|
|
1346
|
+
/**
|
|
1347
|
+
* Tells the caller that a new attempt starts. A scope that can open more
|
|
1348
|
+
* than one transaction for one call (a retrying scope) calls it before
|
|
1349
|
+
* each attempt opens its transaction. The caller can then tell a failure
|
|
1350
|
+
* to open the next attempt apart from a failure of the previous one. A
|
|
1351
|
+
* scope that opens one transaction per call can ignore it. A scope that
|
|
1352
|
+
* wraps another scope passes the options on unchanged.
|
|
1353
|
+
*/
|
|
1354
|
+
readonly onAttemptStart?: () => void;
|
|
1241
1355
|
}
|
|
1242
1356
|
/**
|
|
1243
1357
|
* Transaction-scope abstraction.
|
|
@@ -1759,15 +1873,15 @@ type IdempotencyClaim = {
|
|
|
1759
1873
|
* production pattern and the only family that proves atomic command effect +
|
|
1760
1874
|
* idempotency completion without reconciliation.
|
|
1761
1875
|
*
|
|
1762
|
-
* A NON-transactional store (the in-memory reference, a separate
|
|
1763
|
-
* store) cannot see commits or rollbacks. Every fresh claim
|
|
1764
|
-
* a store-minted token and bounded lease. The wrapper
|
|
1765
|
-
* transaction runs; `complete`, `renew`, `confirm`,
|
|
1766
|
-
* compare the token so a stale owner cannot
|
|
1767
|
-
* expired PENDING claim may be replaced. An
|
|
1768
|
-
* replayed or released automatically:
|
|
1769
|
-
* `reconciliation-required`, and the application must
|
|
1770
|
-
* truth. `unknown` keeps it blocked.
|
|
1876
|
+
* A NON-transactional store (the in-memory reference on its own, a separate
|
|
1877
|
+
* durable store) cannot see commits or rollbacks. Every fresh claim
|
|
1878
|
+
* therefore returns a store-minted token and bounded lease. The wrapper
|
|
1879
|
+
* renews it while the transaction runs; `complete`, `renew`, `confirm`,
|
|
1880
|
+
* `abandon`, and `reconcile` compare the token so a stale owner cannot
|
|
1881
|
+
* mutate a successor claim. An expired PENDING claim may be replaced. An
|
|
1882
|
+
* expired STAGED outcome is never replayed or released automatically:
|
|
1883
|
+
* `claim` returns `reconciliation-required`, and the application must
|
|
1884
|
+
* consult the source of truth. `unknown` keeps it blocked.
|
|
1771
1885
|
*
|
|
1772
1886
|
* A lease is coordination, not a security or exactly-once boundary. To return
|
|
1773
1887
|
* `not-committed` safely, the source transaction must persist an idempotency
|
|
@@ -2026,23 +2140,23 @@ interface ProjectionCheckpointStore<TCtx = unknown> {
|
|
|
2026
2140
|
* Implementations may serialize more than the requested keys, but never
|
|
2027
2141
|
* less. The callback is non-reentrant for an overlapping key set.
|
|
2028
2142
|
*/
|
|
2029
|
-
withCheckpointLocks<R>(ctx: TCtx, projection: string,
|
|
2143
|
+
withCheckpointLocks<R>(ctx: TCtx, projection: string, identities: ReadonlyArray<AggregateIdentity>, work: () => Promise<R>): Promise<R>;
|
|
2030
2144
|
/**
|
|
2031
|
-
* The stored watermark receipt for `(projection,
|
|
2145
|
+
* The stored watermark receipt for `(projection, identity)`, or `undefined`
|
|
2032
2146
|
* when this projection has never applied an event of that
|
|
2033
2147
|
* aggregate. Called inside the projector's transaction.
|
|
2034
2148
|
*/
|
|
2035
|
-
load(ctx: TCtx, projection: string,
|
|
2149
|
+
load(ctx: TCtx, projection: string, identity: AggregateIdentity): Promise<ProjectionCheckpoint | undefined>;
|
|
2036
2150
|
/**
|
|
2037
2151
|
* Persists the watermark receipt, overwriting a previous one (last write
|
|
2038
2152
|
* wins; the projector only calls this with advancing checkpoints).
|
|
2039
2153
|
* Called inside the projector's transaction, after the read-model
|
|
2040
2154
|
* update it accounts for.
|
|
2041
2155
|
*/
|
|
2042
|
-
save(ctx: TCtx, projection: string,
|
|
2156
|
+
save(ctx: TCtx, projection: string, identity: AggregateIdentity, checkpoint: ProjectionCheckpoint): Promise<void>;
|
|
2043
2157
|
/**
|
|
2044
2158
|
* The wait-for-version building block: `true` when the stored
|
|
2045
|
-
* watermark for `(projection,
|
|
2159
|
+
* watermark for `(projection, identity)` is at or past
|
|
2046
2160
|
* `position`. Runs OUTSIDE any transaction (a query-side poll).
|
|
2047
2161
|
*
|
|
2048
2162
|
* Pass the position of the LAST event your commit emitted: all
|
|
@@ -2050,7 +2164,7 @@ interface ProjectionCheckpointStore<TCtx = unknown> {
|
|
|
2050
2164
|
* on the version alone would report "reached" while later events
|
|
2051
2165
|
* of the same commit are still unapplied.
|
|
2052
2166
|
*/
|
|
2053
|
-
hasReached(projection: string,
|
|
2167
|
+
hasReached(projection: string, identity: AggregateIdentity, position: ProjectionPosition): Promise<boolean>;
|
|
2054
2168
|
/**
|
|
2055
2169
|
* Deletes every checkpoint of `projection` (other projections'
|
|
2056
2170
|
* checkpoints are untouched): the rebuild entry point. Called
|
|
@@ -2067,7 +2181,7 @@ interface ProjectionCheckpointStore<TCtx = unknown> {
|
|
|
2067
2181
|
* read-model writes.
|
|
2068
2182
|
*
|
|
2069
2183
|
* The projector feed MUST contain every committed envelope for each aggregate
|
|
2070
|
-
*
|
|
2184
|
+
* identity it carries, including event types this read model does not use.
|
|
2071
2185
|
* Handle those events as explicit no-ops in `apply`: the projector still
|
|
2072
2186
|
* advances their cursor. Filtering a broker subscription by event type drops
|
|
2073
2187
|
* positions from the source chain and turns the next commit into a real gap.
|
|
@@ -2106,6 +2220,298 @@ interface Projection<Evt extends AnyDomainEvent, TCtx = unknown> {
|
|
|
2106
2220
|
truncate?(ctx: TCtx): Promise<void>;
|
|
2107
2221
|
}
|
|
2108
2222
|
//#endregion
|
|
2223
|
+
//#region src/persistence/repository/identity-map.d.ts
|
|
2224
|
+
/**
|
|
2225
|
+
* A class reference used as the type key of the identity map. Keying
|
|
2226
|
+
* on the CLASS (not a name string) makes collisions impossible by
|
|
2227
|
+
* construction: `Restaurant` and `Booking` are different keys even if
|
|
2228
|
+
* someone names two aggregates identically across modules, and there
|
|
2229
|
+
* is no string-discipline to maintain.
|
|
2230
|
+
*
|
|
2231
|
+
* `Function & { prototype: TAgg }` carries this alone. The kit's aggregate
|
|
2232
|
+
* convention is a **protected constructor** plus static factories, and
|
|
2233
|
+
* TypeScript rejects assigning such a class to a construct-signature type, so
|
|
2234
|
+
* the prototype witness is what accepts them. It infers `TAgg` as well.
|
|
2235
|
+
* Measured: a construct signature beside it changes neither what the type
|
|
2236
|
+
* accepts nor what it infers, and it was the last `any` in the published
|
|
2237
|
+
* types.
|
|
2238
|
+
*/
|
|
2239
|
+
type AggregateClass<TAgg> = Function & {
|
|
2240
|
+
prototype: TAgg;
|
|
2241
|
+
};
|
|
2242
|
+
/**
|
|
2243
|
+
* Per-unit-of-work Identity Map (Fowler, PoEAA): within one operation,
|
|
2244
|
+
* one aggregate type+id maps to exactly ONE in-memory instance.
|
|
2245
|
+
*
|
|
2246
|
+
* This is the shipped implementation of the contract the
|
|
2247
|
+
* Repository guide (`docs/guide/repository.md`) places on
|
|
2248
|
+
* `AggregatePersistence` implementations: two `findById(id)` calls in the same
|
|
2249
|
+
* unit of work MUST return the same instance, because commit-token
|
|
2250
|
+
* write registration dedupes by JavaScript object identity. Two instances for
|
|
2251
|
+
* one logical aggregate can otherwise produce two tokens, two harvests,
|
|
2252
|
+
* and two post-commit lifecycle calls.
|
|
2253
|
+
*
|
|
2254
|
+
* Storage is two-level (per-type stores created lazily), so
|
|
2255
|
+
* `Restaurant:123` and `Booking:123` can never collide: the type key
|
|
2256
|
+
* is the aggregate CLASS, not the id alone and not a name string.
|
|
2257
|
+
*
|
|
2258
|
+
* Repository read-path contract:
|
|
2259
|
+
*
|
|
2260
|
+
* ```ts
|
|
2261
|
+
* async findById(id: OrderId): Promise<Order | undefined> {
|
|
2262
|
+
* const cached = this.tracking.identityMap.get(Order, id);
|
|
2263
|
+
* if (cached) return cached;
|
|
2264
|
+
* // Deleted in this unit of work = gone, even if the physical
|
|
2265
|
+
* // delete is deferred and the row is still visible in the tx.
|
|
2266
|
+
* if (this.tracking.identityMap.isDeleted(Order, id)) return undefined;
|
|
2267
|
+
*
|
|
2268
|
+
* const row = await this.loadRow(id);
|
|
2269
|
+
* if (!row) return undefined;
|
|
2270
|
+
* const order = Order.reconstitute(row.id, row.state, row.version);
|
|
2271
|
+
* return this.tracking.trackLoaded(order);
|
|
2272
|
+
* }
|
|
2273
|
+
* ```
|
|
2274
|
+
*
|
|
2275
|
+
* Deletion is final within an operation: {@link delete} removes the
|
|
2276
|
+
* entry AND records a tombstone, so a later {@link set} of the same
|
|
2277
|
+
* type+id throws `AggregateDeletedError`: a second instance of a
|
|
2278
|
+
* deleted aggregate can never sneak back into the unit of work, even
|
|
2279
|
+
* through a repository whose row delete is deferred.
|
|
2280
|
+
*
|
|
2281
|
+
* Lifetime is ONE unit of work: the `UnitOfWork` creates a fresh map
|
|
2282
|
+
* per `run()` and clears it on close. Never cache across operations;
|
|
2283
|
+
* that would silently bypass optimistic concurrency control.
|
|
2284
|
+
*/
|
|
2285
|
+
declare class IdentityMap {
|
|
2286
|
+
private readonly _stores;
|
|
2287
|
+
private readonly _deleted;
|
|
2288
|
+
private _pendingAtRegistration;
|
|
2289
|
+
/** The cached instance for type+id, or `undefined` (also after {@link delete}). */
|
|
2290
|
+
get<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): TAgg | undefined;
|
|
2291
|
+
/** Whether an instance is registered for type+id (false after {@link delete}). */
|
|
2292
|
+
has<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
|
|
2293
|
+
/**
|
|
2294
|
+
* Whether type+id was {@link delete}d in this unit of work. The
|
|
2295
|
+
* read path checks this BEFORE hydrating and returns `null`, so
|
|
2296
|
+
* "deleted in this operation" reads uniformly as not-found,
|
|
2297
|
+
* regardless of whether the repository's physical delete already
|
|
2298
|
+
* removed the row or is deferred within the transaction. Without
|
|
2299
|
+
* the check, a read-only probe of a deleted aggregate would crash
|
|
2300
|
+
* in {@link set} for deferred-write repositories and return `null`
|
|
2301
|
+
* for immediate-write ones.
|
|
2302
|
+
*/
|
|
2303
|
+
isDeleted<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
|
|
2304
|
+
/**
|
|
2305
|
+
* Registers the hydrated instance under its class and the id of its
|
|
2306
|
+
* `aggregateIdentity`.
|
|
2307
|
+
*
|
|
2308
|
+
* - Re-registering the SAME instance is a no-op (idempotent).
|
|
2309
|
+
* - Registering a DIFFERENT instance for an occupied type+id throws:
|
|
2310
|
+
* that is precisely the identity-map violation this class exists
|
|
2311
|
+
* to prevent (the repository hydrated twice instead of checking
|
|
2312
|
+
* {@link get} first), and letting it pass would double-harvest
|
|
2313
|
+
* events downstream.
|
|
2314
|
+
* - Registering a type+id that was {@link delete}d in this unit of
|
|
2315
|
+
* work throws `AggregateDeletedError`: deletion is final within
|
|
2316
|
+
* the operation.
|
|
2317
|
+
*/
|
|
2318
|
+
set<TAgg extends {
|
|
2319
|
+
readonly aggregateIdentity: AggregateIdentity;
|
|
2320
|
+
}>(type: AggregateClass<TAgg>, aggregate: TAgg): void;
|
|
2321
|
+
/**
|
|
2322
|
+
* Registered instances that have recorded MORE pending events than they
|
|
2323
|
+
* carried when first registered (loaded). Used by the unit of work's
|
|
2324
|
+
* end-of-run guard: an aggregate that gained events after load but was
|
|
2325
|
+
* never enrolled would silently drop them. A read-only load, or a
|
|
2326
|
+
* reconstitution that already carried events, shows no increase and is
|
|
2327
|
+
* not reported.
|
|
2328
|
+
*/
|
|
2329
|
+
instancesWithNewPendingEvents(): unknown[];
|
|
2330
|
+
/**
|
|
2331
|
+
* Takes back a registration WITHOUT a tombstone: the entry is removed
|
|
2332
|
+
* only when the stored instance IS the given one, and a later
|
|
2333
|
+
* {@link set} of the same type+id stays legal. The Unit of Work calls
|
|
2334
|
+
* this when a registration step fails after {@link set} already ran, so
|
|
2335
|
+
* the failed instance cannot be served by `findById` as a phantom.
|
|
2336
|
+
* This is rollback, not deletion; deletion finality belongs to
|
|
2337
|
+
* {@link delete}.
|
|
2338
|
+
*/
|
|
2339
|
+
discard<TAgg>(type: AggregateClass<TAgg>, id: Id<string>, aggregate: TAgg): void;
|
|
2340
|
+
/**
|
|
2341
|
+
* Removes the entry for type+id and records a tombstone: subsequent
|
|
2342
|
+
* {@link get} / {@link has} report absence, and a subsequent
|
|
2343
|
+
* {@link set} of the same type+id throws `AggregateDeletedError`.
|
|
2344
|
+
* The Unit of Work calls this as part of `repository.remove(aggregate)`;
|
|
2345
|
+
* repository adapters receive only the read-only
|
|
2346
|
+
* identity-map view.
|
|
2347
|
+
*/
|
|
2348
|
+
delete<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): void;
|
|
2349
|
+
/** Empties all stores and tombstones. Called by the unit of work on close. */
|
|
2350
|
+
clear(): void;
|
|
2351
|
+
}
|
|
2352
|
+
//#endregion
|
|
2353
|
+
//#region src/persistence/repository/persistence-model.d.ts
|
|
2354
|
+
/** Whether a baseline represents an existing row or a pending insert. */
|
|
2355
|
+
type PersistenceLifecycle = "loaded" | "new";
|
|
2356
|
+
/**
|
|
2357
|
+
* Adapter-owned projection and change derivation for one aggregate type.
|
|
2358
|
+
*
|
|
2359
|
+
* The domain model does not implement this contract. A repository adapter
|
|
2360
|
+
* chooses what it persists, how that projection is captured at load, and
|
|
2361
|
+
* whether a change set is a partial diff or a full replacement.
|
|
2362
|
+
*/
|
|
2363
|
+
interface PersistenceModel<TAggregate, TBaseline, TChangeSet> {
|
|
2364
|
+
/**
|
|
2365
|
+
* Captures the adapter's persistence projection at the current moment.
|
|
2366
|
+
* Return a detached value or an immutable value object: the Unit of Work
|
|
2367
|
+
* retains it as a baseline and cannot make an arbitrary adapter type safe.
|
|
2368
|
+
*
|
|
2369
|
+
* Capture must be deterministic for an unchanged aggregate: the Unit of
|
|
2370
|
+
* Work compares successive captures to detect mutation after write
|
|
2371
|
+
* registration. A capture that embeds ambient values (clock reads,
|
|
2372
|
+
* random ids) would make every commit look mutated. The default
|
|
2373
|
+
* comparison is the package's structural deep equality, which matches
|
|
2374
|
+
* `Set` members and `Map` keys by reference (JS `SameValueZero`
|
|
2375
|
+
* semantics): a capture that re-materializes object Set members or Map
|
|
2376
|
+
* keys on every call must supply {@link captureEquals}.
|
|
2377
|
+
*
|
|
2378
|
+
* A column that the store owns stays out of the capture, for example an
|
|
2379
|
+
* `updatedAt` that the adapter stamps; `flush` writes it. A captured store
|
|
2380
|
+
* column pushes a stale value back on the next write, or it marks every
|
|
2381
|
+
* row as changed.
|
|
2382
|
+
*/
|
|
2383
|
+
capture(aggregate: TAggregate): TBaseline;
|
|
2384
|
+
/**
|
|
2385
|
+
* Adapter-owned equality for two captures of the persistence projection.
|
|
2386
|
+
* Optional: the default is the package's structural deep equality (Set
|
|
2387
|
+
* members and Map keys by reference). Supply it when the capture shape
|
|
2388
|
+
* needs domain-specific comparison, for example rebuilt value-object Set
|
|
2389
|
+
* members compared by value.
|
|
2390
|
+
*/
|
|
2391
|
+
readonly captureEquals?: (a: TBaseline, b: TBaseline) => boolean;
|
|
2392
|
+
/**
|
|
2393
|
+
* Derives the adapter's write payload from its own baseline.
|
|
2394
|
+
*
|
|
2395
|
+
* `baseline` is absent for a new aggregate. `lifecycle` disambiguates that
|
|
2396
|
+
* case from an adapter whose loaded baseline type itself admits `undefined`.
|
|
2397
|
+
* The returned payload must not share mutable references with the aggregate;
|
|
2398
|
+
* it is the exact value later handed to `flush`.
|
|
2399
|
+
*/
|
|
2400
|
+
changes(baseline: TBaseline | undefined, aggregate: TAggregate, lifecycle: PersistenceLifecycle): TChangeSet;
|
|
2401
|
+
/** Tells orchestration whether the derived state write is empty. */
|
|
2402
|
+
isEmpty(changes: TChangeSet): boolean;
|
|
2403
|
+
}
|
|
2404
|
+
declare const persistenceBaselineBrand: unique symbol;
|
|
2405
|
+
/**
|
|
2406
|
+
* Opaque, typed receipt for an adapter-owned persistence baseline.
|
|
2407
|
+
*
|
|
2408
|
+
* It intentionally exposes no data. The Unit of Work may retain the token and
|
|
2409
|
+
* ask the owning adapter capability to derive changes, but cannot branch on or
|
|
2410
|
+
* couple itself to the baseline's shape.
|
|
2411
|
+
*/
|
|
2412
|
+
interface PersistenceBaseline<TAggregate, TChangeSet> {
|
|
2413
|
+
readonly [persistenceBaselineBrand]: (aggregate: TAggregate) => TChangeSet;
|
|
2414
|
+
}
|
|
2415
|
+
/** A derived adapter change set plus its adapter-defined emptiness result. */
|
|
2416
|
+
interface PersistenceChanges<TChangeSet> {
|
|
2417
|
+
readonly value: TChangeSet;
|
|
2418
|
+
readonly empty: boolean;
|
|
2419
|
+
}
|
|
2420
|
+
/** Captures a baseline for an aggregate restored by a repository adapter. */
|
|
2421
|
+
declare function capturePersistenceBaseline<TAggregate, TBaseline, TChangeSet>(model: PersistenceModel<TAggregate, TBaseline, TChangeSet>, aggregate: TAggregate): PersistenceBaseline<TAggregate, TChangeSet>;
|
|
2422
|
+
/** Creates the explicit no-row baseline for a newly added aggregate. */
|
|
2423
|
+
declare function insertPersistenceBaseline<TAggregate, TBaseline, TChangeSet>(model: PersistenceModel<TAggregate, TBaseline, TChangeSet>): PersistenceBaseline<TAggregate, TChangeSet>;
|
|
2424
|
+
/**
|
|
2425
|
+
* Captures the aggregate's current adapter projection using the capability
|
|
2426
|
+
* carried by an existing baseline. Used to seal persistence-last registration.
|
|
2427
|
+
*/
|
|
2428
|
+
declare function recapturePersistenceBaseline<TAggregate, TChangeSet>(baseline: PersistenceBaseline<TAggregate, TChangeSet>, aggregate: TAggregate): PersistenceBaseline<TAggregate, TChangeSet>;
|
|
2429
|
+
/**
|
|
2430
|
+
* Recaptures the adapter projection and reports whether it drifted from the
|
|
2431
|
+
* baseline's stored capture, using the model's `captureEquals` when supplied
|
|
2432
|
+
* and structural deep equality otherwise.
|
|
2433
|
+
*
|
|
2434
|
+
* This, not `changes()`/`isEmpty()`, is the mutation detector: the
|
|
2435
|
+
* `PersistenceModel` contract explicitly permits a full-replacement change
|
|
2436
|
+
* set whose `isEmpty` is never true, so a non-empty change set proves
|
|
2437
|
+
* nothing about mutation. Comparing capture to capture asks the honest
|
|
2438
|
+
* question independent of the model's diffing strategy. A `"new"` lifecycle
|
|
2439
|
+
* baseline has no stored capture and never reports drift.
|
|
2440
|
+
*/
|
|
2441
|
+
declare function persistenceProjectionDrifted<TAggregate, TChangeSet>(baseline: PersistenceBaseline<TAggregate, TChangeSet>, aggregate: TAggregate): boolean;
|
|
2442
|
+
/** Derives a typed adapter change set without exposing the stored baseline. */
|
|
2443
|
+
declare function derivePersistenceChanges<TAggregate, TChangeSet>(baseline: PersistenceBaseline<TAggregate, TChangeSet>, aggregate: TAggregate): PersistenceChanges<TChangeSet>;
|
|
2444
|
+
//#endregion
|
|
2445
|
+
//#region src/application/unit-of-work/persistence-contract.d.ts
|
|
2446
|
+
/** The explicit persistence intent registered for one tracked aggregate. */
|
|
2447
|
+
type AggregateWriteIntent = "add" | "update" | "remove";
|
|
2448
|
+
/** Read-only Identity Map operations available to repository adapters. */
|
|
2449
|
+
type UnitOfWorkIdentityMap = Pick<IdentityMap, "get" | "has" | "isDeleted">;
|
|
2450
|
+
/**
|
|
2451
|
+
* Repository-specific tracking capability handed only to its adapter.
|
|
2452
|
+
*
|
|
2453
|
+
* Read paths call {@link RepositoryTracking.trackLoaded} before returning a
|
|
2454
|
+
* restored aggregate.
|
|
2455
|
+
* Write methods exposed to application code are replaced by Unit-of-Work-owned
|
|
2456
|
+
* `add`, `update`, and `remove` registrations, so an adapter implementation of
|
|
2457
|
+
* those methods cannot perform durable I/O early or skip event harvesting.
|
|
2458
|
+
*
|
|
2459
|
+
* Contract for repository implementations:
|
|
2460
|
+
* - `findById(id)` checks `identityMap.get` BEFORE hydrating, treats
|
|
2461
|
+
* `identityMap.isDeleted` as not-found (`undefined`), and returns
|
|
2462
|
+
* `tracking.trackLoaded(aggregate)` after hydration. This captures the
|
|
2463
|
+
* expected version before application code can mutate the instance.
|
|
2464
|
+
* - Adapter objects have no lifecycle methods; the facade installs the
|
|
2465
|
+
* Unit-of-Work-owned `add`, `update` unless the definition is append-only,
|
|
2466
|
+
* and `remove` with `physicalRemoval`. An adapter that defines `add`,
|
|
2467
|
+
* `update`, or `remove` fails with `InvalidRepositoryAdapterError`.
|
|
2468
|
+
* - Other repository methods are reads. A custom method that performs a write
|
|
2469
|
+
* would bypass the Unit of Work and violates the adapter contract.
|
|
2470
|
+
*/
|
|
2471
|
+
interface RepositoryTracking<TAggregate extends Aggregate<Id<string>, AnyDomainEvent>> {
|
|
2472
|
+
/**
|
|
2473
|
+
* Registers an aggregate restored by a repository before returning it to
|
|
2474
|
+
* application code. The Unit of Work identity-maps the instance and captures
|
|
2475
|
+
* its current version as the optimistic-concurrency expectation.
|
|
2476
|
+
*
|
|
2477
|
+
* Return the result, not the argument. If the Unit of Work already tracks
|
|
2478
|
+
* an instance with the same identity, the result is that instance: when
|
|
2479
|
+
* two loads of one id overlap, the first tracked instance wins.
|
|
2480
|
+
*/
|
|
2481
|
+
trackLoaded(aggregate: TAggregate): TAggregate;
|
|
2482
|
+
/**
|
|
2483
|
+
* Read-only view of the per-operation Identity Map (Fowler): one aggregate type+id,
|
|
2484
|
+
* one in-memory instance. Created fresh per `run()`, cleared on
|
|
2485
|
+
* close; accessing it after close throws
|
|
2486
|
+
* {@link TransactionClosedError}.
|
|
2487
|
+
*/
|
|
2488
|
+
readonly identityMap: UnitOfWorkIdentityMap;
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Immutable adapter input for one registered aggregate write.
|
|
2492
|
+
*
|
|
2493
|
+
* `expectedVersion` is captured when a loaded aggregate joins the Unit of
|
|
2494
|
+
* Work and is absent for `add`. `version`, `changes`, and `events` describe
|
|
2495
|
+
* the exact moment at which the application registered its write intent.
|
|
2496
|
+
* Adapters must use the expected/current version pair for their OCC predicate
|
|
2497
|
+
* and must not read mutable write state back from an aggregate reference.
|
|
2498
|
+
*
|
|
2499
|
+
* The predicate itself is adapter code. The compare-and-set must run in the
|
|
2500
|
+
* store's own write statement. The kit does not know the store, so it cannot
|
|
2501
|
+
* write that statement. The kit owns the version pair and
|
|
2502
|
+
* {@link ConcurrencyConflictError}; the repository contract suite proves the
|
|
2503
|
+
* predicate.
|
|
2504
|
+
*/
|
|
2505
|
+
interface AggregatePersistenceWrite<TAggregate extends Aggregate<Id<string>, AnyDomainEvent>, TChangeSet> {
|
|
2506
|
+
readonly intent: AggregateWriteIntent;
|
|
2507
|
+
/** The aggregate type and the id of the written aggregate. */
|
|
2508
|
+
readonly aggregateIdentity: AggregateIdentity<TAggregate["id"]>;
|
|
2509
|
+
readonly expectedVersion: Version | undefined;
|
|
2510
|
+
readonly version: Version;
|
|
2511
|
+
readonly changes: PersistenceChanges<TChangeSet>;
|
|
2512
|
+
readonly events: TAggregate["pendingEvents"];
|
|
2513
|
+
}
|
|
2514
|
+
//#endregion
|
|
2109
2515
|
//#region src/persistence/event-store/event-store.d.ts
|
|
2110
2516
|
/** Options for {@link EventStore.append}. */
|
|
2111
2517
|
interface EventStoreAppendOptions {
|
|
@@ -2123,18 +2529,15 @@ interface ReadStreamOptions {
|
|
|
2123
2529
|
/**
|
|
2124
2530
|
* Maximum number of events returned by this page. Required so callers
|
|
2125
2531
|
* cannot accidentally materialize an unbounded stream. Must be a positive
|
|
2126
|
-
* safe integer. An adapter may return fewer events,
|
|
2127
|
-
*
|
|
2532
|
+
* safe integer. An adapter may return fewer events, for example when it
|
|
2533
|
+
* clamps `limit` to a maximum of its own. It must return at least one
|
|
2534
|
+
* event while unread events remain inside the requested window.
|
|
2128
2535
|
*/
|
|
2129
2536
|
readonly limit: number;
|
|
2130
2537
|
/**
|
|
2131
2538
|
* Return only events AFTER this stream position (1-based event count),
|
|
2132
|
-
* the snapshot
|
|
2133
|
-
*
|
|
2134
|
-
* `aggregate.replayHistory`; the caller checks that the aggregate
|
|
2135
|
-
* ends at the pinned head ({@link ReplayHeadMismatchError}). Defaults
|
|
2136
|
-
* to `0` (the first stream page).
|
|
2137
|
-
* Must be a non-negative safe integer when present.
|
|
2539
|
+
* for example the version of a snapshot. Defaults to `0` (the first
|
|
2540
|
+
* stream page). Must be a non-negative safe integer when present.
|
|
2138
2541
|
*/
|
|
2139
2542
|
readonly fromVersion?: number;
|
|
2140
2543
|
/**
|
|
@@ -2143,10 +2546,16 @@ interface ReadStreamOptions {
|
|
|
2143
2546
|
* `(fromVersion, toVersion]`. Defaults to the actual stream head.
|
|
2144
2547
|
* `0` therefore returns an empty window; a value beyond the head clamps
|
|
2145
2548
|
* to the head; and `fromVersion >= toVersion` is an empty interval, not
|
|
2146
|
-
* an error.
|
|
2147
|
-
* Must be a non-negative safe integer when present.
|
|
2549
|
+
* an error. Must be a non-negative safe integer when present.
|
|
2148
2550
|
*/
|
|
2149
2551
|
readonly toVersion?: number;
|
|
2552
|
+
/**
|
|
2553
|
+
* Cooperative-cancellation signal of the surrounding operation. An
|
|
2554
|
+
* adapter either rejects with the `reason` of an aborted signal, or
|
|
2555
|
+
* bounds each page read with the timeout of its driver. So a page read
|
|
2556
|
+
* that hangs cannot hang its caller.
|
|
2557
|
+
*/
|
|
2558
|
+
readonly signal?: AbortSignal;
|
|
2150
2559
|
}
|
|
2151
2560
|
/**
|
|
2152
2561
|
* State returned by {@link EventStore.readStream}.
|
|
@@ -2155,7 +2564,7 @@ interface ReadStreamOptions {
|
|
|
2155
2564
|
* of the requested read window and page limit. `exists: true` implies
|
|
2156
2565
|
* `lastVersion >= 1`: an
|
|
2157
2566
|
* existing stream has at least one event, while metadata or tombstones without
|
|
2158
|
-
* events must be reported as `exists: false`.
|
|
2567
|
+
* events must be reported as `exists: false`. An absent stream is therefore
|
|
2159
2568
|
* distinguishable from an existing stream whose requested window is empty.
|
|
2160
2569
|
* Snapshot-backed repositories use that distinction to reject a snapshot whose
|
|
2161
2570
|
* version lies beyond the current authoritative stream head.
|
|
@@ -2169,9 +2578,50 @@ type StreamReadResult<Evt extends AnyDomainEvent> = {
|
|
|
2169
2578
|
readonly lastVersion: number;
|
|
2170
2579
|
readonly events: ReadonlyArray<Evt>;
|
|
2171
2580
|
};
|
|
2581
|
+
/**
|
|
2582
|
+
* The read half of the store: one bounded page of a stream at a time. A
|
|
2583
|
+
* store implements both halves; a read-only implementation offers this
|
|
2584
|
+
* role alone.
|
|
2585
|
+
*/
|
|
2586
|
+
interface EventStreamReader<Evt extends AnyDomainEvent> {
|
|
2587
|
+
/**
|
|
2588
|
+
* Reads one bounded page of the qualified stream in append order. An absent stream returns
|
|
2589
|
+
* `{ exists: false, lastVersion: 0, events: [] }`; an existing stream keeps
|
|
2590
|
+
* `exists: true` even when its requested window is empty. An existing stream
|
|
2591
|
+
* has at least one event, so `exists: true` implies `lastVersion >= 1`;
|
|
2592
|
+
* metadata or tombstones without events must be reported as absent.
|
|
2593
|
+
* `lastVersion` is always the actual stream head. `options.limit` is
|
|
2594
|
+
* mandatory and caps the returned array. An adapter may return fewer than
|
|
2595
|
+
* the requested limit, but if the requested window still contains unread
|
|
2596
|
+
* events it must return a non-empty contiguous prefix so callers can make
|
|
2597
|
+
* progress. Each returned event occupies one stream position, so the
|
|
2598
|
+
* events of a page fill the positions after `fromVersion` without a gap.
|
|
2599
|
+
* `options.fromVersion`
|
|
2600
|
+
* excludes positions at or below its 1-based event count;
|
|
2601
|
+
* `options.toVersion` includes positions through its count, so both bounds
|
|
2602
|
+
* describe `(fromVersion, toVersion]`. `toVersion: 0` and inverted ranges
|
|
2603
|
+
* return an empty existing window, while a bound beyond the head clamps to
|
|
2604
|
+
* the head. This distinction is load-bearing for snapshot catch-up and
|
|
2605
|
+
* point-in-time reconstruction: a repository can verify the requested
|
|
2606
|
+
* historical window against the authoritative head. `limit` must be a
|
|
2607
|
+
* positive safe integer; present bounds must be non-negative safe integers.
|
|
2608
|
+
* Invalid options reject with `RangeError` before querying storage.
|
|
2609
|
+
*
|
|
2610
|
+
* Each page's `exists`, `lastVersion`, and `events` must describe one
|
|
2611
|
+
* consistent view of the stream. Multiple page reads are not one database
|
|
2612
|
+
* snapshot. A caller that reads a stream in several pages pins a target
|
|
2613
|
+
* version on the first page, passes it as `toVersion` on every
|
|
2614
|
+
* continuation, and advances `fromVersion` by the number of events each
|
|
2615
|
+
* page returned. Because streams are append-only, that yields a stable
|
|
2616
|
+
* prefix even if new events arrive while replay is in progress. The returned
|
|
2617
|
+
* event array is owned by the caller; implementations must not hand out
|
|
2618
|
+
* mutable live internal state.
|
|
2619
|
+
*/
|
|
2620
|
+
readStream(stream: AggregateIdentity, options: ReadStreamOptions): Promise<StreamReadResult<Evt>>;
|
|
2621
|
+
}
|
|
2172
2622
|
/**
|
|
2173
2623
|
* Driven port for event-sourced aggregate persistence: an append-only
|
|
2174
|
-
* store with one stream per aggregate. Each stream is
|
|
2624
|
+
* store with one stream per aggregate. Each stream is identified by the
|
|
2175
2625
|
* qualified tuple `(aggregateType, aggregateId)`, because aggregate ids
|
|
2176
2626
|
* are type-scoped rather than globally unique.
|
|
2177
2627
|
*
|
|
@@ -2187,58 +2637,38 @@ type StreamReadResult<Evt extends AnyDomainEvent> = {
|
|
|
2187
2637
|
* Repository usage (see the event-sourcing guide):
|
|
2188
2638
|
*
|
|
2189
2639
|
* ```ts
|
|
2190
|
-
* private stream(id: OrderId):
|
|
2640
|
+
* private stream(id: OrderId): AggregateIdentity<OrderId> {
|
|
2191
2641
|
* return { aggregateType: "Order", aggregateId: id };
|
|
2192
2642
|
* }
|
|
2193
2643
|
*
|
|
2194
2644
|
* async findById(id: OrderId): Promise<Order | undefined> {
|
|
2195
2645
|
* const cached = this.tracking.identityMap.get(Order, id);
|
|
2196
2646
|
* if (cached) return cached;
|
|
2197
|
-
* const
|
|
2198
|
-
*
|
|
2199
|
-
*
|
|
2200
|
-
*
|
|
2201
|
-
* const
|
|
2647
|
+
* const read = await readStreamPages(this.eventStore, this.stream(id), {
|
|
2648
|
+
* limit: 256,
|
|
2649
|
+
* });
|
|
2650
|
+
* if (!read.reachable) return undefined;
|
|
2651
|
+
* const loaded = await reconstituteAggregateFromStreamPages(
|
|
2202
2652
|
* () => Order.reconstitute(id), // bare instance, no events
|
|
2203
|
-
*
|
|
2653
|
+
* read,
|
|
2204
2654
|
* );
|
|
2205
|
-
* if (
|
|
2206
|
-
*
|
|
2207
|
-
* let fromVersion = first.events.length;
|
|
2208
|
-
* while (fromVersion < targetVersion) {
|
|
2209
|
-
* const page = await this.eventStore.readStream(address, {
|
|
2210
|
-
* fromVersion,
|
|
2211
|
-
* toVersion: targetVersion,
|
|
2212
|
-
* limit: 256,
|
|
2213
|
-
* });
|
|
2214
|
-
* if (!page.exists || page.events.length === 0) {
|
|
2215
|
-
* throw new NonProgressingEventStreamPageError({
|
|
2216
|
-
* ...address,
|
|
2217
|
-
* fromVersion,
|
|
2218
|
-
* targetVersion,
|
|
2219
|
-
* });
|
|
2220
|
-
* }
|
|
2221
|
-
* const catchUp = order.replayHistory(page.events);
|
|
2222
|
-
* if (catchUp.isErr()) throw catchUp.error; // corrupt stream
|
|
2223
|
-
* fromVersion += page.events.length;
|
|
2224
|
-
* }
|
|
2225
|
-
* if (order.version !== targetVersion) {
|
|
2226
|
-
* throw new ReplayHeadMismatchError({
|
|
2227
|
-
* ...address,
|
|
2228
|
-
* targetVersion,
|
|
2229
|
-
* actualVersion: order.version,
|
|
2230
|
-
* });
|
|
2231
|
-
* }
|
|
2232
|
-
* return this.tracking.trackLoaded(order);
|
|
2655
|
+
* if (loaded.isErr()) throw loaded.error; // corrupt stream
|
|
2656
|
+
* return this.tracking.trackLoaded(loaded.value);
|
|
2233
2657
|
* }
|
|
2234
2658
|
*
|
|
2235
2659
|
* flush(write: AggregatePersistenceWrite<Order, number | undefined>) {
|
|
2236
|
-
* return this.eventStore.append(
|
|
2660
|
+
* return this.eventStore.append(write.aggregateIdentity, write.events, {
|
|
2237
2661
|
* expectedVersion: write.expectedVersion ?? 0,
|
|
2238
2662
|
* });
|
|
2239
2663
|
* }
|
|
2240
2664
|
* ```
|
|
2241
2665
|
*
|
|
2666
|
+
* `readStreamPages` pins the head on the first page as the target version
|
|
2667
|
+
* and reads the rest in bounded pages up to it.
|
|
2668
|
+
* `reconstituteAggregateFromStreamPages` replays the pages and checks that
|
|
2669
|
+
* the replay ends at the target version. The long form, for an adapter that
|
|
2670
|
+
* pages on its own, is in the event-sourcing guide.
|
|
2671
|
+
*
|
|
2242
2672
|
* `flush` appends the exact event batch registered by `add` or `update`;
|
|
2243
2673
|
* `withCommit`
|
|
2244
2674
|
* separately composes them into outbox envelopes. The event store's own
|
|
@@ -2247,7 +2677,7 @@ type StreamReadResult<Evt extends AnyDomainEvent> = {
|
|
|
2247
2677
|
* The exact appended event batch is acknowledged only after the surrounding
|
|
2248
2678
|
* transaction commits. Rollback leaves it pending.
|
|
2249
2679
|
*/
|
|
2250
|
-
interface EventStore<Evt extends AnyDomainEvent> {
|
|
2680
|
+
interface EventStore<Evt extends AnyDomainEvent> extends EventStreamReader<Evt> {
|
|
2251
2681
|
/**
|
|
2252
2682
|
* Atomically appends `events` to the stream, guarded by optimistic
|
|
2253
2683
|
* concurrency: the append succeeds only when the stream currently
|
|
@@ -2292,44 +2722,96 @@ interface EventStore<Evt extends AnyDomainEvent> {
|
|
|
2292
2722
|
* touching the store (an ES repository skips `append` for aggregates
|
|
2293
2723
|
* without pending events anyway).
|
|
2294
2724
|
*
|
|
2725
|
+
* A load that read its pages through a replica still appends here with
|
|
2726
|
+
* the pinned target as `expectedVersion`. The primary rejects a stale
|
|
2727
|
+
* head, so a lagging replica cannot lose an update.
|
|
2728
|
+
*
|
|
2295
2729
|
* Treat `aggregateType` as a stable technical stream category. If two
|
|
2296
2730
|
* bounded contexts share one physical store and reuse a domain name,
|
|
2297
2731
|
* qualify it at the source (`sales.order`, `fulfillment.order`). Renaming
|
|
2298
2732
|
* it changes the stream key and therefore requires a data migration.
|
|
2299
2733
|
*/
|
|
2300
|
-
append(stream:
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
*
|
|
2320
|
-
*
|
|
2734
|
+
append(stream: AggregateIdentity, events: ReadonlyArray<Evt>, options: EventStoreAppendOptions): Promise<void>;
|
|
2735
|
+
}
|
|
2736
|
+
//#endregion
|
|
2737
|
+
//#region src/persistence/event-store/reconstitute-from-stream-pages.d.ts
|
|
2738
|
+
/**
|
|
2739
|
+
* The shape the replay reads: the stream, the window, and the pages.
|
|
2740
|
+
* `readStreamPages` returns it as the reachable branch of a kit read. An
|
|
2741
|
+
* adapter that pages on its own builds it directly, after it pinned the
|
|
2742
|
+
* target version with `pinTargetVersion`.
|
|
2743
|
+
*/
|
|
2744
|
+
interface ReplayableStreamPages<Evt extends AnyDomainEvent> {
|
|
2745
|
+
/** The qualified stream the pages come from. */
|
|
2746
|
+
readonly stream: AggregateIdentity;
|
|
2747
|
+
/**
|
|
2748
|
+
* The cursor the read started at: `fromVersion`, or `0`. The pages hold
|
|
2749
|
+
* the events after it, so the replay target must stand at this version.
|
|
2750
|
+
*/
|
|
2751
|
+
readonly fromVersion: number;
|
|
2752
|
+
/**
|
|
2753
|
+
* The version the replay must end at: the head at read time, or a
|
|
2754
|
+
* `toVersion` at or below it. Never a version taken from a snapshot, and
|
|
2755
|
+
* never below `fromVersion`. Later appends do not move it.
|
|
2756
|
+
*/
|
|
2757
|
+
readonly targetVersion: number;
|
|
2758
|
+
/**
|
|
2759
|
+
* The events after the cursor through the target version, in append
|
|
2760
|
+
* order, one bounded page per iteration. Every iteration starts again
|
|
2761
|
+
* from the first page, so a second replay of one read sees the same
|
|
2762
|
+
* prefix. A page holds at least one event, and the pages end at the
|
|
2763
|
+
* target version. The replay rejects an empty page, and a page that
|
|
2764
|
+
* would run past the target version, with
|
|
2765
|
+
* {@link InvalidEventStreamPageError} before any row of it reaches the
|
|
2766
|
+
* aggregate. `createReplayableStreamPagesContractTests` proves these
|
|
2767
|
+
* rules for an adapter.
|
|
2321
2768
|
*
|
|
2322
|
-
*
|
|
2323
|
-
*
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
* returned. Because streams are append-only, that yields a stable prefix
|
|
2327
|
-
* even if new events arrive while replay is in progress. The returned
|
|
2328
|
-
* event array is owned by the caller; implementations must not hand out
|
|
2329
|
-
* mutable live internal state.
|
|
2769
|
+
* The events of a page are stored facts: no consumer, upcaster, or fold
|
|
2770
|
+
* changes one in place. `readStreamPages` reads fresh events on every
|
|
2771
|
+
* later iteration, and `createReplayableStreamPages` hands out frozen
|
|
2772
|
+
* ones, so a consumer that breaks this rule fails in its own tests.
|
|
2330
2773
|
*/
|
|
2331
|
-
|
|
2774
|
+
readonly pages: AsyncIterable<ReadonlyArray<Evt>>;
|
|
2332
2775
|
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Reconstitutes an event-sourced aggregate from the pages of a stream read
|
|
2778
|
+
* and yields it only when the replay ends at the target version.
|
|
2779
|
+
*
|
|
2780
|
+
* This is the paged form of `reconstituteAggregateFromHistory`.
|
|
2781
|
+
* `createReplayTarget` builds the replay target: a fresh instance for a
|
|
2782
|
+
* full replay, or an instance reconstituted from a snapshot for a read that
|
|
2783
|
+
* starts at `snapshot.version`. `read` is the reachable branch of a kit
|
|
2784
|
+
* read, or the value that an adapter that pages on its own built.
|
|
2785
|
+
*
|
|
2786
|
+
* The call checks the window first. `targetVersion` must be a positive
|
|
2787
|
+
* safe integer, and `fromVersion` a non-negative one at or below it. A bad
|
|
2788
|
+
* window rejects with `RangeError` before the replay target is built.
|
|
2789
|
+
*
|
|
2790
|
+
* The call then primes the replay target with an empty history, so the
|
|
2791
|
+
* aggregate runs its own guard even when the read holds no page: a replay
|
|
2792
|
+
* target with pending decisions throws `UnreplayableAggregateError`. The
|
|
2793
|
+
* replay target must stand at `read.fromVersion`. A replay target at
|
|
2794
|
+
* another version throws {@link ReplayTargetMismatchError} before the
|
|
2795
|
+
* first page, even when it also rejected the empty history: a wiring
|
|
2796
|
+
* defect wins over a rejection that the data cannot explain.
|
|
2797
|
+
*
|
|
2798
|
+
* Each page goes through `replayHistory` on the replay target, so
|
|
2799
|
+
* allocation stays bounded by the size of a page; `readStreamPages` rejects
|
|
2800
|
+
* a page over its `limit`. An empty page, and a page that would run past
|
|
2801
|
+
* the target version, throw {@link InvalidEventStreamPageError} before any
|
|
2802
|
+
* row of them reaches the aggregate. Events carry no stream position, so
|
|
2803
|
+
* only the call can find pages that end short of the target version. After
|
|
2804
|
+
* the last page it compares the final version with `read.targetVersion` and
|
|
2805
|
+
* throws {@link ReplayTargetMismatchError} on a difference.
|
|
2806
|
+
*
|
|
2807
|
+
* When the aggregate rejects a stored event with a `DomainError`, the call
|
|
2808
|
+
* stops reading and returns {@link ReplayRejectedError} as `Err`. The
|
|
2809
|
+
* error names the stream and the window of the rejected page, and it holds
|
|
2810
|
+
* the `DomainError` as `cause`. The replay target never escapes on that
|
|
2811
|
+
* path. Wiring errors and a foreign row throw, as in `replayHistory`. What
|
|
2812
|
+
* `createReplayTarget` throws propagates.
|
|
2813
|
+
*/
|
|
2814
|
+
declare function reconstituteAggregateFromStreamPages<TAggregate extends ReplayableAggregate<Id<string>, AnyDomainEvent>>(createReplayTarget: () => TAggregate, read: ReplayableStreamPages<Parameters<TAggregate["replayHistory"]>[0][number]>): Promise<Result<TAggregate, ReplayRejectedError>>;
|
|
2333
2815
|
//#endregion
|
|
2334
2816
|
//#region src/persistence/snapshot-store/snapshot-store.d.ts
|
|
2335
2817
|
/**
|
|
@@ -2371,14 +2853,14 @@ interface SnapshotStore<TState = unknown> {
|
|
|
2371
2853
|
* The latest snapshot for the aggregate, or `undefined` when none
|
|
2372
2854
|
* exists. The repository falls back to a full replay then.
|
|
2373
2855
|
*/
|
|
2374
|
-
load(
|
|
2856
|
+
load(identity: AggregateIdentity): Promise<AggregateSnapshot<TState> | undefined>;
|
|
2375
2857
|
/**
|
|
2376
2858
|
* Persists `snapshot` as the new latest for the aggregate,
|
|
2377
2859
|
* replacing any previous one. Called AFTER the write transaction
|
|
2378
2860
|
* committed (see the port docs); a single-row upsert is the
|
|
2379
2861
|
* standard implementation.
|
|
2380
2862
|
*/
|
|
2381
|
-
save(
|
|
2863
|
+
save(identity: AggregateIdentity, snapshot: AggregateSnapshot<TState>): Promise<void>;
|
|
2382
2864
|
/**
|
|
2383
2865
|
* Removes the aggregate's snapshot; a no-op when none exists. The
|
|
2384
2866
|
* two callers: the schema-migration fallback (a
|
|
@@ -2390,8 +2872,8 @@ interface SnapshotStore<TState = unknown> {
|
|
|
2390
2872
|
* which a stale snapshot resurrects the erased aggregate on the
|
|
2391
2873
|
* snapshot load path; snapshot-first degrades to a full replay.
|
|
2392
2874
|
*/
|
|
2393
|
-
delete(
|
|
2875
|
+
delete(identity: AggregateIdentity): Promise<void>;
|
|
2394
2876
|
}
|
|
2395
2877
|
//#endregion
|
|
2396
|
-
export {
|
|
2878
|
+
export { EventHandler as $, Command as $t, IdempotencyClaimHandle as A, CreateDomainEventFromFactsOptions as At, withIdempotentCommit as B, PendingDomainEvent as Bt, IdentityMap as C, ExecutionContext as Ct, ProjectionPosition as D, EventCommitCandidatePosition as Dt, ProjectionCheckpointStore as E, EventCommitCandidate as Et, IdempotencyStore as F, DomainEventFactory as Ft, CommitEnrollment as G, createDomainEventFactory as Gt, DeadlineStore as H, UncommittedDomainEventOf as Ht, IdempotentCommitRequest as I, DomainEventFactoryOptions as It, WithCommitWorkResult as J, defaultDomainEventFactory as Jt, CommitEnrollmentOptions as K, createDomainEventFromFacts as Kt, IdempotentCommitResult as L, DomainEventStamp as Lt, IdempotencyOperationErrorContext as M, CreateDomainEventStampOptions as Mt, IdempotencyReconciliation as N, CreateUncommittedDomainEventOptions as Nt, isPositionAfter as O, AnyDomainEvent as Ot, IdempotencyReconciliationDecision as P, DomainEvent as Pt, EventBus as Q, AggregateIdentity as Qt, IdempotentExecution as R, EventIdFactory as Rt, AggregateClass as S, OutboxWriter as St, ProjectionCheckpoint as T, CommittedDomainEvent as Tt, DueDeadline as U, copyMetadata as Ut, DeadLetterDeadline as V, UncommittedDomainEvent as Vt, AggregateCommitToken as W, createDomainEvent as Wt, TransactionScope as X, recordDomainEvent as Xt, withCommit as Y, mergeMetadata as Yt, TransactionalOptions as Z, ClockFactory as Zt, capturePersistenceBaseline as _, routeEventsToCommandOutbox as _t, EventStoreAppendOptions as a, Version as at, persistenceProjectionDrifted as b, Outbox as bt, StreamReadResult as c, Id as ct, RepositoryTracking as d, CommandMessageContent as dt, CommandHandler as en, OnceOptions as et, UnitOfWorkIdentityMap as f, CommandMessageRelationships as ft, PersistenceModel as g, DurableCommandMessage as gt, PersistenceLifecycle as h, CommandOutboxWriter as ht, EventStore as i, JsonValue as in, ReplayableAggregate as it, IdempotencyLease as j, CreateDomainEventOptions as jt, IdempotencyClaim as k, AnyUncommittedDomainEvent as kt, AggregatePersistenceWrite as l, IdGenerator as lt, PersistenceChanges as m, CommandOutboxMapper as mt, ReplayableStreamPages as n, JsonObject as nn, Aggregate as nt, EventStreamReader as o, sameVersion as ot, PersistenceBaseline as p, CommandOutboxCommitCandidate as pt, WithCommitDeps as q, createUncommittedDomainEvent as qt, reconstituteAggregateFromStreamPages as r, JsonPrimitive as rn, AggregateSnapshot as rt, ReadStreamOptions as s, toVersion as st, SnapshotStore as t, PublishedCommand as tn, PublishOptions as tt, AggregateWriteIntent as u, CommandCommitOriginCandidate as ut, derivePersistenceChanges as v, DeadLetterRecord as vt, Projection as w, CommitPosition as wt, recapturePersistenceBaseline as x, OutboxRecord as xt, insertPersistenceBaseline as y, DispatchTrackingOutbox as yt, WithIdempotentCommitDeps as z, EventMetadata as zt };
|
|
2397
2879
|
//# sourceMappingURL=snapshot-store.d.ts.map
|