@shirudo/ddd-kit 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/{aggregate-DclYgG_D.d.ts → aggregate-BGdgvqKh.d.ts} +61 -7
- package/dist/index.d.ts +179 -36
- package/dist/index.js +240 -26
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +24 -23
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,9 +17,9 @@ Composable TypeScript toolkit for tactical Domain-Driven Design. Ships the canon
|
|
|
17
17
|
- **Domain Events:** typed, deeply frozen, carry metadata for traceability and schema evolution.
|
|
18
18
|
- **Repositories:** technology-agnostic persistence ports with an Identity-Map contract and OCC.
|
|
19
19
|
- **CQRS:** zero-config in-memory `CommandBus` / `QueryBus`, plus `CommandHandler` / `QueryHandler` types for external brokers.
|
|
20
|
-
- **Unit of Work:** opt-in `UnitOfWork` facade with tx-bound repositories, repository-side enrollment, a per-operation Identity Map, and aggregate-level dirty tracking (`changedKeys` / `hasChanges`) for partial writes
|
|
20
|
+
- **Unit of Work:** opt-in `UnitOfWork` facade with tx-bound repositories, repository-side enrollment, a per-operation Identity Map, and aggregate-level dirty tracking (`changedKeys` / `hasChanges`) for partial writes. Honestly speaking: a transaction coordinator with registration and Identity Map; writes stay explicit by design (no auto-flush).
|
|
21
21
|
- **Outbox:** `withCommit` harvests pending events inside the transaction, stamps them with the aggregate's commit version, and publishes them atomically.
|
|
22
|
-
- **Repository contract tests:** `@shirudo/ddd-kit/testing` ships the suite every adapter must pass
|
|
22
|
+
- **Repository contract tests:** `@shirudo/ddd-kit/testing` ships the suite every adapter must pass: OCC is a testable contract, not a documented pattern.
|
|
23
23
|
- **Result-first boundary:** a typed error hierarchy on [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error) and `Result` from [`@shirudo/result`](https://www.npmjs.com/package/@shirudo/result); `voValidated` collects field violations and renders RFC 9457 via the opt-in `@shirudo/ddd-kit/http` entry.
|
|
24
24
|
|
|
25
25
|
## Installation
|
|
@@ -104,6 +104,60 @@ declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
|
|
|
104
104
|
readonly eventType: string;
|
|
105
105
|
constructor(eventType: string, cause?: unknown);
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Thrown by `withCommit` when an event harvested from an aggregate cannot
|
|
109
|
+
* be safely committed: it is missing `aggregateId` / `aggregateType`
|
|
110
|
+
* (downstream routing would break), or it carries a pre-set
|
|
111
|
+
* `aggregateVersion` AHEAD of the aggregate's commit version (a leaked or
|
|
112
|
+
* copied fixture that would advance consumer idempotency watermarks past
|
|
113
|
+
* real history). Both are programming bugs in how the aggregate recorded
|
|
114
|
+
* the event, deterministic, and fail identically on every retry.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately **not** an {@link InfrastructureError} (same reasoning as
|
|
117
|
+
* {@link MissingHandlerError}): the failure happens after the work
|
|
118
|
+
* callback completed, but it is NOT transient. A `catch (e instanceof
|
|
119
|
+
* InfrastructureError)` retry handler, or a retrying `TransactionScope`,
|
|
120
|
+
* must NOT mask it or loop on it forever; it should crash loud so the
|
|
121
|
+
* recordEvent / createDomainEvent misuse surfaces in development. This is
|
|
122
|
+
* why `withCommit` throws it directly and `UnitOfWork.run` passes it
|
|
123
|
+
* through unchanged instead of wrapping it in `CommitError`.
|
|
124
|
+
*/
|
|
125
|
+
declare class EventHarvestError extends BaseError<"EventHarvestError"> {
|
|
126
|
+
/** The `type` of the offending event, for programmatic routing. */
|
|
127
|
+
readonly eventType?: string | undefined;
|
|
128
|
+
constructor(message: string,
|
|
129
|
+
/** The `type` of the offending event, for programmatic routing. */
|
|
130
|
+
eventType?: string | undefined);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Thrown at the end of a `UnitOfWork.run` when an aggregate that was
|
|
134
|
+
* loaded into the identity map during the operation carries unflushed
|
|
135
|
+
* `pendingEvents` but was never enrolled (no `session.enrollSaved`, and
|
|
136
|
+
* not deleted). The almost-certain cause is a repository `save()` that
|
|
137
|
+
* forgot to call `enrollSaved`, or a use case that recorded events on a
|
|
138
|
+
* loaded aggregate and never saved it. Without this guard those events
|
|
139
|
+
* would be silently dropped: never harvested into the outbox, never
|
|
140
|
+
* published.
|
|
141
|
+
*
|
|
142
|
+
* Deliberately **not** an `InfrastructureError` (same posture as
|
|
143
|
+
* {@link MissingHandlerError}): a programming bug that must crash loud,
|
|
144
|
+
* not be absorbed by a generic infrastructure-error handler. The throw
|
|
145
|
+
* happens inside the transaction, so the unit of work rolls back and
|
|
146
|
+
* leaves no partial state.
|
|
147
|
+
*
|
|
148
|
+
* **Scope of the guard.** A best-effort runtime safety net, not a proof.
|
|
149
|
+
* It only sees aggregates the identity map knows about (those loaded via
|
|
150
|
+
* `getById`), and detects new events by comparing the pending-event COUNT
|
|
151
|
+
* at load against commit, which assumes the kit's append-only event model
|
|
152
|
+
* (so it cannot see events that were recorded and then cleared within the
|
|
153
|
+
* same run). A freshly *created* aggregate that was never enrolled is
|
|
154
|
+
* invisible to the kit. The repository contract test suite remains the
|
|
155
|
+
* full mitigation. See the Unit of Work guide.
|
|
156
|
+
*/
|
|
157
|
+
declare class UnenrolledChangesError extends BaseError<"UnenrolledChangesError"> {
|
|
158
|
+
readonly aggregateId: string;
|
|
159
|
+
constructor(aggregateId: string);
|
|
160
|
+
}
|
|
107
161
|
/**
|
|
108
162
|
* Thrown when an aggregate that was deleted within the current unit of
|
|
109
163
|
* work is saved or re-registered again in the same operation: by
|
|
@@ -172,7 +226,7 @@ declare class DuplicateAggregateError extends InfrastructureError<"DuplicateAggr
|
|
|
172
226
|
*
|
|
173
227
|
* **Retry means a FRESH unit of work** (a new `UnitOfWork.run()` /
|
|
174
228
|
* `withCommit` invocation): reload, re-apply, save. Do NOT catch this
|
|
175
|
-
* inside the same `run()` callback and continue
|
|
229
|
+
* inside the same `run()` callback and continue: the failed aggregate
|
|
176
230
|
* is already enrolled (its events would be committed for a write that
|
|
177
231
|
* never happened) and the identity map still serves the same stale
|
|
178
232
|
* instance to any in-place "reload".
|
|
@@ -379,7 +433,7 @@ interface EventMetadata {
|
|
|
379
433
|
* transport concerns the outbox needs (`aggregateId`, `aggregateType`,
|
|
380
434
|
* `aggregateVersion`, `metadata`). That is the line: further transport
|
|
381
435
|
* fields (partition keys, tenancy, schema URNs, …) belong in an outbox
|
|
382
|
-
* envelope / `metadata`, not on the domain event
|
|
436
|
+
* envelope / `metadata`, not on the domain event: the next first-class
|
|
383
437
|
* transport field forces an `OutboxMessage` envelope port instead.
|
|
384
438
|
*
|
|
385
439
|
* @template T - The event type name (e.g., "OrderCreated")
|
|
@@ -422,7 +476,7 @@ interface DomainEvent<T extends string, P = void> {
|
|
|
422
476
|
* Required for safe schema migration in event-sourced systems.
|
|
423
477
|
* Use 1 for the initial schema version.
|
|
424
478
|
*
|
|
425
|
-
* **NOT the aggregate's version
|
|
479
|
+
* **NOT the aggregate's version**: that is
|
|
426
480
|
* {@link aggregateVersion}. The two are deliberately distinct
|
|
427
481
|
* fields: this one says "which shape does the payload have"
|
|
428
482
|
* (upcasting), the other says "which state revision of the
|
|
@@ -435,7 +489,7 @@ interface DomainEvent<T extends string, P = void> {
|
|
|
435
489
|
* `withCommit` at the harvest boundary (all events of one aggregate
|
|
436
490
|
* in one commit share it; their relative order within the commit is
|
|
437
491
|
* the harvest order), or set manually via
|
|
438
|
-
* `CreateDomainEventOptions.aggregateVersion
|
|
492
|
+
* `CreateDomainEventOptions.aggregateVersion`; a pre-set value is
|
|
439
493
|
* never overwritten.
|
|
440
494
|
*
|
|
441
495
|
* Consumers use it for ordering ("apply projections up to aggregate
|
|
@@ -486,8 +540,8 @@ interface CreateDomainEventOptions {
|
|
|
486
540
|
version?: number;
|
|
487
541
|
/**
|
|
488
542
|
* Pre-set the producing aggregate's version (see
|
|
489
|
-
* `DomainEvent.aggregateVersion`). Normally left unset
|
|
490
|
-
* stamps it at the harvest boundary with the commit version
|
|
543
|
+
* `DomainEvent.aggregateVersion`). Normally left unset (`withCommit`
|
|
544
|
+
* stamps it at the harvest boundary with the commit version), but
|
|
491
545
|
* useful for replay fixtures and events constructed outside an
|
|
492
546
|
* aggregate. A pre-set value is never overwritten by the harvest.
|
|
493
547
|
*/
|
|
@@ -659,4 +713,4 @@ declare function sameVersion<TId extends Id<string>>(a: {
|
|
|
659
713
|
version: Version;
|
|
660
714
|
}): boolean;
|
|
661
715
|
|
|
662
|
-
export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, setEventIdFactory as e, type ClockFactory as f, setClockFactory as g, withClockFactory as h, resetClockFactory as i, type EventMetadata as j, type DomainEvent as k, createDomainEvent as l, createDomainEventWithMetadata as m, copyMetadata as n, mergeMetadata as o,
|
|
716
|
+
export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, UnenrolledChangesError as U, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, setEventIdFactory as e, type ClockFactory as f, setClockFactory as g, withClockFactory as h, resetClockFactory as i, type EventMetadata as j, type DomainEvent as k, createDomainEvent as l, createDomainEventWithMetadata as m, copyMetadata as n, mergeMetadata as o, EventHarvestError as p, AggregateDeletedError as q, resetEventIdFactory as r, sameVersion as s, AggregateNotFoundError as t, DuplicateAggregateError as u, ConcurrencyConflictError as v, withEventIdFactory as w, type IdGenerator as x };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as Id, A as AnyDomainEvent, I as IAggregateRoot, V as Version, b as AggregateSnapshot, C as CreateDomainEventOptions, c as IEventSourcedAggregate, D as DomainError, d as InfrastructureError } from './aggregate-
|
|
2
|
-
export {
|
|
1
|
+
import { a as Id, A as AnyDomainEvent, I as IAggregateRoot, V as Version, b as AggregateSnapshot, C as CreateDomainEventOptions, c as IEventSourcedAggregate, D as DomainError, d as InfrastructureError } from './aggregate-BGdgvqKh.js';
|
|
2
|
+
export { q as AggregateDeletedError, t as AggregateNotFoundError, f as ClockFactory, v as ConcurrencyConflictError, k as DomainEvent, u as DuplicateAggregateError, p as EventHarvestError, E as EventIdFactory, j as EventMetadata, x as IdGenerator, M as MissingHandlerError, U as UnenrolledChangesError, n as copyMetadata, l as createDomainEvent, m as createDomainEventWithMetadata, o as mergeMetadata, i as resetClockFactory, r as resetEventIdFactory, s as sameVersion, g as setClockFactory, e as setEventIdFactory, h as withClockFactory, w as withEventIdFactory } from './aggregate-BGdgvqKh.js';
|
|
3
3
|
import { Result } from '@shirudo/result';
|
|
4
4
|
import { BaseError, ValidationError } from '@shirudo/base-error';
|
|
5
5
|
import { DeepEqualExceptOptions } from './utils.js';
|
|
@@ -446,7 +446,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
|
|
|
446
446
|
* `changedKeys`/`hasChanges`. An override that skips `super` leaves
|
|
447
447
|
* that baseline uncaptured: `changedKeys` permanently reports ALL
|
|
448
448
|
* keys and `hasChanges` never returns `false`, so a partial-write
|
|
449
|
-
* repository silently degrades to full writes on every save
|
|
449
|
+
* repository silently degrades to full writes on every save, on top
|
|
450
450
|
* of the broken version sync.
|
|
451
451
|
*
|
|
452
452
|
* @param version - The version the row currently holds in the DB
|
|
@@ -694,7 +694,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
|
|
|
694
694
|
* If you override this, call `super.markRestored(version)` FIRST:
|
|
695
695
|
* skipping it leaves the baseline uncaptured, so `changedKeys`
|
|
696
696
|
* permanently reports ALL keys and `hasChanges` never returns `false`
|
|
697
|
-
*
|
|
697
|
+
* (partial-write repositories silently degrade to full writes), on
|
|
698
698
|
* top of breaking version sync.
|
|
699
699
|
*/
|
|
700
700
|
protected markRestored(version: Version): void;
|
|
@@ -713,7 +713,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
|
|
|
713
713
|
* **How it works.** `setState()` replaces state immutably and the
|
|
714
714
|
* state object is shallow-frozen, so unchanged top-level sub-objects
|
|
715
715
|
* keep reference identity across mutations. The diff is therefore a
|
|
716
|
-
* shallow per-key `!==` against the baseline reference
|
|
716
|
+
* shallow per-key `!==` against the baseline reference: O(top-level
|
|
717
717
|
* keys), no proxies, no deep diff. A key also counts as dirty when its
|
|
718
718
|
* *presence* differs (added or removed, even with an `undefined`
|
|
719
719
|
* value). Computed fresh on every access (a new `Set` each time), so
|
|
@@ -726,12 +726,12 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
|
|
|
726
726
|
* class-instance `TState` mutated through its own methods defeats
|
|
727
727
|
* tracking entirely (the reference never changes). A keyless `TState`
|
|
728
728
|
* (primitive, bare `Date`) has no keys to report, so `changedKeys`
|
|
729
|
-
* stays empty for it
|
|
729
|
+
* stays empty for it; use {@link hasChanges}, whose reference
|
|
730
730
|
* fallback covers keyless states. A deep-equal but newly-referenced
|
|
731
731
|
* value reports a false POSITIVE (harmless extra write); under the
|
|
732
732
|
* contract above there are no false negatives.
|
|
733
733
|
*
|
|
734
|
-
* Granularity is per top-level key
|
|
734
|
+
* Granularity is per top-level key, table-granular, not row-granular:
|
|
735
735
|
* a dirty collection key means "this child table changed", not which
|
|
736
736
|
* rows. `EventSourcedAggregate` deliberately has no `changedKeys`;
|
|
737
737
|
* its `pendingEvents` are the change record.
|
|
@@ -741,16 +741,16 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
|
|
|
741
741
|
* Safe skip signal: `false` only when there is genuinely nothing to
|
|
742
742
|
* persist or flush. `true` when the aggregate has never been
|
|
743
743
|
* persisted, the version moved past `persistedVersion`, there are
|
|
744
|
-
* unflushed {@link pendingEvents}, any state key is dirty, or
|
|
744
|
+
* unflushed {@link pendingEvents}, any state key is dirty, or, for
|
|
745
745
|
* keyless states the per-key diff cannot see (primitive `TState`,
|
|
746
|
-
* zero-own-key objects like a bare `Date`)
|
|
746
|
+
* zero-own-key objects like a bare `Date`), the state reference
|
|
747
747
|
* changed since the baseline.
|
|
748
748
|
*
|
|
749
749
|
* The version clause is deliberate: `setState({...state}, true)` with
|
|
750
750
|
* identical per-key values yields empty {@link changedKeys} but a
|
|
751
751
|
* bumped version. If a repository skipped `save()` on a state-only
|
|
752
752
|
* check, `withCommit` would still call `markPersisted(version)` after
|
|
753
|
-
* commit, desyncing `persistedVersion` from the DB row
|
|
753
|
+
* commit, desyncing `persistedVersion` from the DB row; and the next
|
|
754
754
|
* uncontended save would throw a false `ConcurrencyConflictError`.
|
|
755
755
|
*
|
|
756
756
|
* The pending-events clause covers the sanctioned decoupled
|
|
@@ -1386,8 +1386,22 @@ interface Outbox<Evt extends AnyDomainEvent> {
|
|
|
1386
1386
|
* (constructor injection, factory functions, `withTx` chains); pick one
|
|
1387
1387
|
* and keep it consistent.
|
|
1388
1388
|
*/
|
|
1389
|
+
/** Options passed to {@link TransactionScope.transactional}. */
|
|
1390
|
+
interface TransactionalOptions {
|
|
1391
|
+
/**
|
|
1392
|
+
* Cooperative-cancellation signal forwarded from `withCommit` /
|
|
1393
|
+
* `UnitOfWork.run`. The kit does not interrupt an in-flight query
|
|
1394
|
+
* itself: it pre-checks `aborted` before opening the transaction and
|
|
1395
|
+
* exposes the signal for the work callback to poll. A scope whose
|
|
1396
|
+
* driver supports cancellation (passing the signal to the query, an
|
|
1397
|
+
* interactive-transaction timeout) SHOULD honor it to abort work
|
|
1398
|
+
* already in progress; scopes that ignore it stay correct, just not
|
|
1399
|
+
* eagerly cancellable.
|
|
1400
|
+
*/
|
|
1401
|
+
readonly signal?: AbortSignal;
|
|
1402
|
+
}
|
|
1389
1403
|
interface TransactionScope<TCtx> {
|
|
1390
|
-
transactional<T>(fn: (ctx: TCtx) => Promise<T
|
|
1404
|
+
transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
|
|
1391
1405
|
}
|
|
1392
1406
|
|
|
1393
1407
|
/**
|
|
@@ -1469,12 +1483,12 @@ interface TransactionScope<TCtx> {
|
|
|
1469
1483
|
* version and (on `AggregateRoot`) re-baselines dirty tracking against
|
|
1470
1484
|
* the CURRENT state. A mutation between `save` and the callback's return
|
|
1471
1485
|
* therefore desyncs OCC (next save throws a false
|
|
1472
|
-
* `ConcurrencyConflictError`)
|
|
1486
|
+
* `ConcurrencyConflictError`); and under a partial-write repository
|
|
1473
1487
|
* using `changedKeys`, an un-bumped mutation is silently marked clean
|
|
1474
1488
|
* and never written. The `aggregateVersion` stamp widens the blast
|
|
1475
1489
|
* radius further: harvested events would publicly claim a version the
|
|
1476
1490
|
* committed row does not carry, poisoning every consumer's ordering
|
|
1477
|
-
* and idempotency watermarks
|
|
1491
|
+
* and idempotency watermarks: a cross-service inconsistency, not just
|
|
1478
1492
|
* a local one. Mutate first, save last.
|
|
1479
1493
|
*
|
|
1480
1494
|
* **Duplicate aggregates are deduped by reference.** If the returned
|
|
@@ -1511,6 +1525,15 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
|
|
|
1511
1525
|
* for delivery: the outbox dispatcher is the reliable path.
|
|
1512
1526
|
*/
|
|
1513
1527
|
onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
|
|
1528
|
+
/**
|
|
1529
|
+
* Cooperative-cancellation signal. If already aborted, `withCommit`
|
|
1530
|
+
* rejects with the signal's `reason` BEFORE opening the transaction.
|
|
1531
|
+
* Otherwise the signal is forwarded to `scope.transactional`, where a
|
|
1532
|
+
* cancellation-aware scope can abort an in-flight query. The kit does
|
|
1533
|
+
* not race the work promise: aborting does not kill a running query
|
|
1534
|
+
* unless the scope honors the signal.
|
|
1535
|
+
*/
|
|
1536
|
+
signal?: AbortSignal;
|
|
1514
1537
|
}, fn: (ctx: TCtx) => Promise<{
|
|
1515
1538
|
result: R;
|
|
1516
1539
|
aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
|
|
@@ -1518,7 +1541,7 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
|
|
|
1518
1541
|
* Optional marker: which of `aggregates` were DELETED in this unit
|
|
1519
1542
|
* of work. Their pending events are harvested like any other
|
|
1520
1543
|
* (deletion events must reach the outbox), but the post-commit
|
|
1521
|
-
* lifecycle differs: `markPersisted` is NOT called on them
|
|
1544
|
+
* lifecycle differs: `markPersisted` is NOT called on them. It
|
|
1522
1545
|
* would fire the user-overridable `onPersisted` hook, whose
|
|
1523
1546
|
* post-save semantics (cache fill, read-model warm-up) are a lie
|
|
1524
1547
|
* for a row that was just deleted. Their pending events are
|
|
@@ -1630,7 +1653,7 @@ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function
|
|
|
1630
1653
|
* `markPersisted`) is keyed on JavaScript object identity.
|
|
1631
1654
|
*
|
|
1632
1655
|
* Storage is two-level (per-type stores created lazily), so
|
|
1633
|
-
* `Restaurant:123` and `Booking:123` can never collide
|
|
1656
|
+
* `Restaurant:123` and `Booking:123` can never collide: the type key
|
|
1634
1657
|
* is the aggregate CLASS, not the id alone and not a name string.
|
|
1635
1658
|
*
|
|
1636
1659
|
* Repository read-path contract:
|
|
@@ -1653,7 +1676,7 @@ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function
|
|
|
1653
1676
|
*
|
|
1654
1677
|
* Deletion is final within an operation: {@link delete} removes the
|
|
1655
1678
|
* entry AND records a tombstone, so a later {@link set} of the same
|
|
1656
|
-
* type+id throws `AggregateDeletedError
|
|
1679
|
+
* type+id throws `AggregateDeletedError`: a second instance of a
|
|
1657
1680
|
* deleted aggregate can never sneak back into the unit of work, even
|
|
1658
1681
|
* through a repository whose row delete is deferred.
|
|
1659
1682
|
*
|
|
@@ -1664,6 +1687,7 @@ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function
|
|
|
1664
1687
|
declare class IdentityMap {
|
|
1665
1688
|
private readonly _stores;
|
|
1666
1689
|
private readonly _deleted;
|
|
1690
|
+
private readonly _pendingAtRegistration;
|
|
1667
1691
|
/** The cached instance for type+id, or `undefined` (also after {@link delete}). */
|
|
1668
1692
|
get<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): TAgg | undefined;
|
|
1669
1693
|
/** Whether an instance is registered for type+id (false after {@link delete}). */
|
|
@@ -1671,7 +1695,7 @@ declare class IdentityMap {
|
|
|
1671
1695
|
/**
|
|
1672
1696
|
* Whether type+id was {@link delete}d in this unit of work. The
|
|
1673
1697
|
* read path checks this BEFORE hydrating and returns `null`, so
|
|
1674
|
-
* "deleted in this operation" reads uniformly as not-found
|
|
1698
|
+
* "deleted in this operation" reads uniformly as not-found,
|
|
1675
1699
|
* regardless of whether the repository's physical delete already
|
|
1676
1700
|
* removed the row or is deferred within the transaction. Without
|
|
1677
1701
|
* the check, a read-only probe of a deleted aggregate would crash
|
|
@@ -1693,6 +1717,15 @@ declare class IdentityMap {
|
|
|
1693
1717
|
* the operation.
|
|
1694
1718
|
*/
|
|
1695
1719
|
set<TAgg>(type: AggregateClass<TAgg>, id: Id<string>, aggregate: TAgg): void;
|
|
1720
|
+
/**
|
|
1721
|
+
* Registered instances that have recorded MORE pending events than they
|
|
1722
|
+
* carried when first registered (loaded). Used by the unit of work's
|
|
1723
|
+
* end-of-run guard: an aggregate that gained events after load but was
|
|
1724
|
+
* never enrolled would silently drop them. A read-only load, or a
|
|
1725
|
+
* reconstitution that already carried events, shows no increase and is
|
|
1726
|
+
* not reported.
|
|
1727
|
+
*/
|
|
1728
|
+
instancesWithNewPendingEvents(): unknown[];
|
|
1696
1729
|
/**
|
|
1697
1730
|
* Removes the entry for type+id and records a tombstone: subsequent
|
|
1698
1731
|
* {@link get} / {@link has} report absence, and a subsequent
|
|
@@ -1748,20 +1781,23 @@ declare class TransactionClosedError extends BaseError<"TransactionClosedError">
|
|
|
1748
1781
|
}
|
|
1749
1782
|
/**
|
|
1750
1783
|
* The unit of work failed AFTER the work callback completed
|
|
1751
|
-
* successfully
|
|
1752
|
-
* transaction commit itself. The kit cannot see inside
|
|
1753
|
-
* `TransactionScope.transactional`, so these
|
|
1754
|
-
*
|
|
1784
|
+
* successfully, at the persistence boundary: the outbox write or the
|
|
1785
|
+
* transaction commit itself rejected. The kit cannot see inside
|
|
1786
|
+
* `TransactionScope.transactional`, so these are deliberately one error
|
|
1787
|
+
* class; the underlying failure is attached as `cause`.
|
|
1755
1788
|
*
|
|
1756
1789
|
* `InfrastructureError`: the business logic ran to completion; the
|
|
1757
1790
|
* persistence boundary failed. The transaction rolled back (or never
|
|
1758
1791
|
* committed), no aggregate was marked persisted, and pending events
|
|
1759
|
-
* survive on the aggregates
|
|
1760
|
-
*
|
|
1761
|
-
* time serialization failure is
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
1792
|
+
* survive on the aggregates; the operation left no partial state behind.
|
|
1793
|
+
* A `CommitError` is the **potentially transient** post-completion
|
|
1794
|
+
* failure (a commit-time serialization failure is the classic case), so
|
|
1795
|
+
* it is the one a retrying caller should consider re-running. The
|
|
1796
|
+
* deterministic post-completion failure, a harvest-guard violation (an
|
|
1797
|
+
* event missing `aggregateId` / `aggregateType`, or an `aggregateVersion`
|
|
1798
|
+
* ahead of the commit version), is a programming bug and surfaces as
|
|
1799
|
+
* {@link EventHarvestError} instead, which does NOT extend
|
|
1800
|
+
* `InfrastructureError`, so it stays out of retry paths by construction.
|
|
1765
1801
|
*/
|
|
1766
1802
|
declare class CommitError extends InfrastructureError<"CommitError"> {
|
|
1767
1803
|
constructor(cause: unknown);
|
|
@@ -1836,8 +1872,8 @@ interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
|
1836
1872
|
}
|
|
1837
1873
|
/**
|
|
1838
1874
|
* What the work callback receives: repositories already bound to the
|
|
1839
|
-
* live transaction, the enrollment session, and
|
|
1840
|
-
* look like the escape hatch it is
|
|
1875
|
+
* live transaction, the enrollment session, and, deliberately named to
|
|
1876
|
+
* look like the escape hatch it is, the raw transaction handle.
|
|
1841
1877
|
*
|
|
1842
1878
|
* All members throw {@link TransactionClosedError} once `run()` has
|
|
1843
1879
|
* settled; do not let the context escape the callback.
|
|
@@ -1845,17 +1881,36 @@ interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
|
1845
1881
|
interface UnitOfWorkContext<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
1846
1882
|
readonly repositories: TRepos;
|
|
1847
1883
|
/**
|
|
1848
|
-
* **Escape hatch
|
|
1884
|
+
* **Escape hatch: you are leaving the unit of work's guarantees.**
|
|
1849
1885
|
* A write issued on the raw handle bypasses the repository contract,
|
|
1850
1886
|
* enrollment (its aggregate's events are NOT harvested unless you
|
|
1851
1887
|
* also call `session.enrollSaved`), and the identity map (a later
|
|
1852
|
-
* `getById` of the same aggregate hydrates a SECOND instance
|
|
1888
|
+
* `getById` of the same aggregate hydrates a SECOND instance:
|
|
1853
1889
|
* double harvest, double `markPersisted`). Use it only for writes no
|
|
1854
1890
|
* repository covers, pair it with manual enrollment, and prefer
|
|
1855
1891
|
* adding a repository method whenever one could exist.
|
|
1856
1892
|
*/
|
|
1857
1893
|
readonly rawTransaction: TCtx;
|
|
1858
1894
|
readonly session: UnitOfWorkSession<Evt>;
|
|
1895
|
+
/**
|
|
1896
|
+
* The cooperative-cancellation signal passed to {@link UnitOfWork.run},
|
|
1897
|
+
* or `undefined` if none was given. Poll `signal?.aborted` between
|
|
1898
|
+
* steps of a long operation and throw `signal.reason` to bail out; the
|
|
1899
|
+
* throw rolls the unit of work back like any other callback error. The
|
|
1900
|
+
* kit does not interrupt an in-flight query for you: actual query
|
|
1901
|
+
* cancellation depends on the `TransactionScope` honoring the signal.
|
|
1902
|
+
*/
|
|
1903
|
+
readonly signal?: AbortSignal;
|
|
1904
|
+
}
|
|
1905
|
+
/** Options for a single {@link UnitOfWork.run} call. */
|
|
1906
|
+
interface RunOptions {
|
|
1907
|
+
/**
|
|
1908
|
+
* Cooperative-cancellation signal. If already aborted, `run()` rejects
|
|
1909
|
+
* with the signal's `reason` before opening a transaction. Otherwise it
|
|
1910
|
+
* is exposed on the context (poll `context.signal`) and forwarded to the
|
|
1911
|
+
* `TransactionScope`. Use `AbortSignal.timeout(ms)` for a deadline.
|
|
1912
|
+
*/
|
|
1913
|
+
readonly signal?: AbortSignal;
|
|
1859
1914
|
}
|
|
1860
1915
|
/**
|
|
1861
1916
|
* Per-repository factory map: for each key of `TRepos`, a function
|
|
@@ -1958,7 +2013,7 @@ declare class UnitOfWork<Evt extends AnyDomainEvent, TCtx, TRepos extends Record
|
|
|
1958
2013
|
* run the post-commit lifecycle (markPersisted, publish) for every
|
|
1959
2014
|
* enrolled aggregate. Returns the callback's result.
|
|
1960
2015
|
*/
|
|
1961
|
-
run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R
|
|
2016
|
+
run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R>, options?: RunOptions): Promise<R>;
|
|
1962
2017
|
private buildRepositories;
|
|
1963
2018
|
}
|
|
1964
2019
|
|
|
@@ -2151,7 +2206,7 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
|
|
|
2151
2206
|
* For production, back the outbox with a transactional store so the
|
|
2152
2207
|
* outbox row participates in the same transaction as the aggregate
|
|
2153
2208
|
* write (see `TransactionScope` + `withCommit`). This class lives in
|
|
2154
|
-
* memory only: events are lost on process restart
|
|
2209
|
+
* memory only: events are lost on process restart. Sharper still:
|
|
2155
2210
|
* events `add()`ed inside a transaction that later rolls back are NOT
|
|
2156
2211
|
* removed (the Map knows nothing about your scope's rollback). Tests
|
|
2157
2212
|
* that assert rollback purity need an outbox that participates in the
|
|
@@ -2190,8 +2245,8 @@ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt>
|
|
|
2190
2245
|
* the deleted-cannot-be-resaved gate. Ids stay branded (`TId extends
|
|
2191
2246
|
* Id<string>`) end-to-end.
|
|
2192
2247
|
*
|
|
2193
|
-
* Implementing this interface is optional
|
|
2194
|
-
* is structurally typed
|
|
2248
|
+
* Implementing this interface is optional (the `UnitOfWork` registry
|
|
2249
|
+
* is structurally typed), but it is the single source of truth the
|
|
2195
2250
|
* guide's examples and the repository contract test suite
|
|
2196
2251
|
* (`@shirudo/ddd-kit/testing`, whose `ContractRepository` is the
|
|
2197
2252
|
* minimal structural subset of this shape) are written against.
|
|
@@ -2377,6 +2432,94 @@ interface IQueryableRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<
|
|
|
2377
2432
|
find(filter: TFilter): Promise<TAgg[]>;
|
|
2378
2433
|
}
|
|
2379
2434
|
|
|
2435
|
+
/**
|
|
2436
|
+
* Tuning for {@link RetryingTransactionScope}. All fields are optional;
|
|
2437
|
+
* the defaults suit optimistic-concurrency retries (a handful of writers
|
|
2438
|
+
* racing one aggregate), not high-fan-out hot-row contention.
|
|
2439
|
+
*/
|
|
2440
|
+
interface RetryPolicy {
|
|
2441
|
+
/** Total tries, including the first. Default `3` (1 initial + 2 retries). */
|
|
2442
|
+
maxAttempts?: number;
|
|
2443
|
+
/** First backoff delay; doubles each retry. Default `50`ms. */
|
|
2444
|
+
baseDelayMs?: number;
|
|
2445
|
+
/** Ceiling for the backoff delay. Default `1000`ms. */
|
|
2446
|
+
maxDelayMs?: number;
|
|
2447
|
+
/**
|
|
2448
|
+
* Classifier deciding whether an error is worth retrying. Default
|
|
2449
|
+
* {@link someChainRetryable} (walks the cause chain for the loose
|
|
2450
|
+
* `retryable === true` marker, so `ConcurrencyConflictError` matches
|
|
2451
|
+
* even when an adapter wraps it). Override to add driver-specific
|
|
2452
|
+
* serialization codes (Postgres 40001, MySQL 1213, SQLite SQLITE_BUSY)
|
|
2453
|
+
* that your adapter has not mapped to a retryable kit error.
|
|
2454
|
+
*/
|
|
2455
|
+
isRetryable?: (error: unknown) => boolean;
|
|
2456
|
+
/** Observer fired before each backoff wait (logging / metrics). */
|
|
2457
|
+
onRetry?: (info: {
|
|
2458
|
+
attempt: number;
|
|
2459
|
+
error: unknown;
|
|
2460
|
+
delayMs: number;
|
|
2461
|
+
}) => void;
|
|
2462
|
+
/** Backoff wait. Default an abortable `setTimeout`. Injectable for tests. */
|
|
2463
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
2464
|
+
/** Jitter source in `[0, 1)`. Default `Math.random`. Injectable for tests. */
|
|
2465
|
+
random?: () => number;
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Backoff delay for the attempt that just failed (1-based): exponential
|
|
2469
|
+
* (`baseDelayMs * 2^(attempt-1)`), capped at `maxDelayMs`, then a +/-20%
|
|
2470
|
+
* jitter band (`* random(0.8, 1.2)`) applied and re-clamped to the cap.
|
|
2471
|
+
* Pure and deterministic given `random`. Result is never negative.
|
|
2472
|
+
*
|
|
2473
|
+
* @internal Exported only so it can be unit-tested directly; not part of
|
|
2474
|
+
* the supported public API and may change without a major version.
|
|
2475
|
+
*/
|
|
2476
|
+
declare function computeBackoffDelay(attempt: number, opts: {
|
|
2477
|
+
baseDelayMs: number;
|
|
2478
|
+
maxDelayMs: number;
|
|
2479
|
+
random: () => number;
|
|
2480
|
+
}): number;
|
|
2481
|
+
/**
|
|
2482
|
+
* A {@link TransactionScope} that retries its inner scope on transient
|
|
2483
|
+
* failures with exponential backoff and jitter. Compose it transparently:
|
|
2484
|
+
*
|
|
2485
|
+
* ```ts
|
|
2486
|
+
* const scope = new RetryingTransactionScope(drizzleScope, { maxAttempts: 5 });
|
|
2487
|
+
* const uow = new UnitOfWork({ scope, outbox, repositories });
|
|
2488
|
+
* ```
|
|
2489
|
+
*
|
|
2490
|
+
* **Retries the transaction only.** Each attempt re-invokes the inner
|
|
2491
|
+
* `transactional` with a fresh transaction, so the work callback must be
|
|
2492
|
+
* reload-safe (load aggregates via `getById` inside it, never capture an
|
|
2493
|
+
* aggregate from a previous attempt) and free of non-transactional side
|
|
2494
|
+
* effects before commit. `withCommit` publishes AFTER the commit, so the
|
|
2495
|
+
* in-process publish is outside the retried region and never duplicated;
|
|
2496
|
+
* publish failures are handled by `onPublishError`, not retried here.
|
|
2497
|
+
*
|
|
2498
|
+
* **Classification is by error, not by guesswork.** Only errors the
|
|
2499
|
+
* `isRetryable` predicate accepts are retried; everything else (a
|
|
2500
|
+
* `DomainError`, `EventHarvestError`, `UnenrolledChangesError`,
|
|
2501
|
+
* `DuplicateAggregateError`, a non-Error throw) surfaces immediately.
|
|
2502
|
+
* After `maxAttempts` the last error is rethrown unchanged, so a caller
|
|
2503
|
+
* can still match `ConcurrencyConflictError` and map it to HTTP 409.
|
|
2504
|
+
*
|
|
2505
|
+
* **Cancellation.** The `AbortSignal` from `transactional` options is
|
|
2506
|
+
* checked before each attempt and aborts the backoff wait, so an
|
|
2507
|
+
* `AbortSignal.timeout(ms)` bounds total elapsed time (there is
|
|
2508
|
+
* deliberately no separate max-elapsed knob).
|
|
2509
|
+
*/
|
|
2510
|
+
declare class RetryingTransactionScope<TCtx> implements TransactionScope<TCtx> {
|
|
2511
|
+
private readonly inner;
|
|
2512
|
+
private readonly maxAttempts;
|
|
2513
|
+
private readonly baseDelayMs;
|
|
2514
|
+
private readonly maxDelayMs;
|
|
2515
|
+
private readonly isRetryable;
|
|
2516
|
+
private readonly sleep;
|
|
2517
|
+
private readonly random;
|
|
2518
|
+
private readonly onRetry?;
|
|
2519
|
+
constructor(inner: TransactionScope<TCtx>, policy?: RetryPolicy);
|
|
2520
|
+
transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2380
2523
|
type VO<T> = Readonly<T>;
|
|
2381
2524
|
/**
|
|
2382
2525
|
* Deep freezes an object and all its nested properties recursively, then
|
|
@@ -2649,4 +2792,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
|
|
|
2649
2792
|
*/
|
|
2650
2793
|
declare function voValidated<T>(t: T, validate: (issues: ValidationError, value: T) => void, message?: string): Result<VO<T>, ValidationError>;
|
|
2651
2794
|
|
|
2652
|
-
export { type AggregateClass, type AggregateConfig, AggregateRoot, AggregateSnapshot, AnyDomainEvent, type Command, CommandBus, type CommandHandler, CommitError, CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, Entity, type EventBus, EventBusImpl, type EventHandler, EventSourcedAggregate, IAggregateRoot, type ICommandBus, type IEntity, IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IUnitOfWorkRepository, type IValueObject, Id, type Identifiable, IdentityMap, InMemoryOutbox, InfrastructureError, NestedUnitOfWorkError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type RepositoryFactories, RollbackError, TransactionClosedError, type TransactionScope, UnitOfWork, type UnitOfWorkContext, type UnitOfWorkDeps, type UnitOfWorkSession, type VO, ValueObject, Version, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, removeEntityById, replaceEntityById, sameEntity, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit };
|
|
2795
|
+
export { type AggregateClass, type AggregateConfig, AggregateRoot, AggregateSnapshot, AnyDomainEvent, type Command, CommandBus, type CommandHandler, CommitError, CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, Entity, type EventBus, EventBusImpl, type EventHandler, EventSourcedAggregate, IAggregateRoot, type ICommandBus, type IEntity, IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IUnitOfWorkRepository, type IValueObject, Id, type Identifiable, IdentityMap, InMemoryOutbox, InfrastructureError, NestedUnitOfWorkError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type RepositoryFactories, type RetryPolicy, RetryingTransactionScope, RollbackError, type RunOptions, TransactionClosedError, type TransactionScope, type TransactionalOptions, UnitOfWork, type UnitOfWorkContext, type UnitOfWorkDeps, type UnitOfWorkSession, type VO, ValueObject, Version, computeBackoffDelay, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, removeEntityById, replaceEntityById, sameEntity, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit };
|