@shirudo/ddd-kit 1.1.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/dist/index.d.ts CHANGED
@@ -1,572 +1,10 @@
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';
1
3
  import { Result } from '@shirudo/result';
2
4
  import { BaseError, ValidationError } from '@shirudo/base-error';
3
5
  import { DeepEqualExceptOptions } from './utils.js';
4
6
  export { DeepOmitOptions, Key, PathSegment, deepEqual, deepEqualExcept, deepOmit } from './utils.js';
5
7
 
6
- /**
7
- * Branded string ID. `Tag` carries the aggregate / entity name so two ids
8
- * with different tags are not assignable to each other even though both
9
- * are strings at runtime.
10
- *
11
- * @example
12
- * ```ts
13
- * type UserId = Id<"UserId">;
14
- * type OrderId = Id<"OrderId">;
15
- *
16
- * const u = "user-1" as UserId;
17
- * const o: OrderId = u; // ❌ compile error
18
- * ```
19
- */
20
- type Id<Tag extends string> = string & {
21
- readonly __brand: Tag;
22
- };
23
- /**
24
- * Produces fresh ids of a single, fixed tag. The tag is bound at the
25
- * generator type — `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
26
- * with no caller-side generic to abuse.
27
- *
28
- * **Your factory must produce unique ids under concurrent calls.**
29
- * The kit makes no attempt to dedupe or detect collisions — a collision
30
- * silently overwrites earlier rows (under unique-key constraints) or
31
- * silently aliases two different entities (without them). Safe choices:
32
- * `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
33
- * KSUID — all collision-resistant by design. Unsafe choices: `Date.now()`
34
- * alone (duplicates within the same millisecond), a process-local
35
- * counter without persistence (resets to 1 on restart, collides with
36
- * prior runs), a sequential id derived from non-atomic state.
37
- *
38
- * @example
39
- * ```ts
40
- * import { ulid } from "ulid";
41
- *
42
- * const userIds: IdGenerator<"UserId"> = { next: () => ulid() as Id<"UserId"> };
43
- * const id = userIds.next(); // Id<"UserId">
44
- * ```
45
- *
46
- * The previous shape (`IdGenerator { next<T extends string>(): Id<T> }`)
47
- * let callers pick `T` themselves — `gen.next<"AnyTag">()` typechecked
48
- * even when the generator produced different-tag ids, silently defeating
49
- * the brand.
50
- */
51
- interface IdGenerator<Tag extends string> {
52
- next: () => Id<Tag>;
53
- }
54
-
55
- /**
56
- * Abstract base for **domain-invariant violations**. Domain methods
57
- * (aggregates, entity validation hooks, value-object constructors)
58
- * throw `DomainError`-derived exceptions when a business rule is
59
- * violated. Consumers derive their own concrete errors — e.g.
60
- * `class OrderAlreadyShippedError extends DomainError<"OrderAlreadyShippedError"> {}` —
61
- * for `instanceof`-style catching at the App-Service boundary, where
62
- * they typically map to HTTP 400 / business-rule responses.
63
- *
64
- * The library itself does **not** ship any concrete `DomainError`
65
- * subclass — the kit can't know your invariants.
66
- *
67
- * Extends `BaseError<Name>`; see `@shirudo/base-error` for the inherited
68
- * surface (timestamps, cause chains, `toJSON()`, `getUserMessage()`,
69
- * `isRetryable`, …).
70
- */
71
- declare abstract class DomainError<Name extends string = string> extends BaseError<Name> {
72
- }
73
- /**
74
- * Abstract base for **infrastructure / persistence failures** that the
75
- * App-Service can recover from — typically by retrying, by returning
76
- * HTTP 404 / 409, or by surfacing a "please try again" UX. These are
77
- * not domain-invariant violations (the business rules were not
78
- * broken); they describe race conditions and missing rows at the
79
- * storage boundary.
80
- *
81
- * Library-internal concrete subclasses: {@link AggregateNotFoundError},
82
- * {@link ConcurrencyConflictError}.
83
- */
84
- declare abstract class InfrastructureError<Name extends string = string> extends BaseError<Name> {
85
- }
86
- /**
87
- * Thrown by `EventSourcedAggregate.apply()` when no handler is
88
- * registered for the event's type. This means the aggregate's subclass
89
- * forgot to add an entry to its `handlers` map — a programming /
90
- * configuration bug, not a domain or infrastructure failure.
91
- *
92
- * Deliberately **not** on `DomainError` or `InfrastructureError` —
93
- * a generic `catch (e instanceof DomainError)` handler at the App
94
- * layer must not mask a forgotten handler; this should crash loud and
95
- * fail the calling Use Case so the bug surfaces in development. The
96
- * replay methods (`loadFromHistory`, `restoreFromSnapshotWithEvents`)
97
- * also let it propagate uncaught instead of wrapping it in `Result.Err`.
98
- *
99
- * Use `isBaseError(e)` from `@shirudo/base-error` to detect
100
- * "any structured error from the kit or any other BaseError-using
101
- * library" at the App boundary.
102
- */
103
- declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
104
- readonly eventType: string;
105
- constructor(eventType: string, cause?: unknown);
106
- }
107
- /**
108
- * Thrown by `IRepository.getByIdOrFail()` when an aggregate with the
109
- * given id does not exist. `InfrastructureError` because the storage
110
- * boundary, not a business rule, decided the row is absent. Use the
111
- * nullable variant `getById()` if "not found" is a valid outcome.
112
- *
113
- * Accepts an optional `cause` so a `Repository.save()` implementation
114
- * can wrap a lower-level "row not found" / driver-level error without
115
- * losing context. Cause-chain helpers (`getRootCause`,
116
- * `findInCauseChain`) from `@shirudo/base-error` traverse the chain.
117
- *
118
- * Not retryable — retrying won't make the row appear.
119
- */
120
- declare class AggregateNotFoundError extends InfrastructureError<"AggregateNotFoundError"> {
121
- readonly aggregateType: string;
122
- readonly id: string;
123
- constructor(aggregateType: string, id: string, cause?: unknown);
124
- }
125
- /**
126
- * Thrown by `IRepository.save()` when the aggregate's expected version
127
- * does not match the version currently persisted — i.e. another writer
128
- * updated the aggregate concurrently. The canonical optimistic-
129
- * concurrency signal; the App-Service typically reloads, re-applies
130
- * the use case, and retries, or surfaces HTTP 409 to the caller.
131
- *
132
- * `InfrastructureError` because the persistence layer (not a domain
133
- * rule) detects the race. Marks itself as `retryable: true` so the
134
- * `isRetryable` predicate from `@shirudo/base-error` picks it up.
135
- */
136
- declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyConflictError"> {
137
- readonly aggregateType: string;
138
- readonly aggregateId: string;
139
- readonly expectedVersion: number;
140
- readonly actualVersion: number;
141
- /**
142
- * Marks this error as retryable so `isRetryable(err)` returns
143
- * true. The canonical OCC pattern is to reload the aggregate, re-apply
144
- * the use case, and retry on this exception.
145
- */
146
- readonly retryable: true;
147
- constructor(aggregateType: string, aggregateId: string, expectedVersion: number, actualVersion: number, cause?: unknown);
148
- }
149
-
150
- /**
151
- * Factory function producing a fresh, unique event identifier for each call.
152
- *
153
- * The library ships a default that uses Web Crypto `crypto.randomUUID()`
154
- * (works on Node 19+, modern browsers in secure contexts, Deno, Bun,
155
- * Cloudflare Workers, Vercel Edge, and any runtime that implements Web
156
- * Crypto). Note that `crypto.randomUUID()` returns **UUID v4** (purely
157
- * random) — for production event stores prefer a **time-ordered** id
158
- * format (UUID v7 / ULID / KSUID) so B-tree indexes on the eventId
159
- * column stay clustered and `ORDER BY eventId` matches creation order.
160
- * Swap one in via `setEventIdFactory(() => uuidv7())` or `() => ulid()`.
161
- */
162
- type EventIdFactory = () => string;
163
- /**
164
- * Replaces the global event-id factory used by `createDomainEvent` and
165
- * `createDomainEventWithMetadata`. Call once during application bootstrap,
166
- * for example:
167
- *
168
- * ```ts
169
- * import { ulid } from "ulid";
170
- * import { setEventIdFactory } from "@shirudo/ddd-kit";
171
- *
172
- * setEventIdFactory(() => ulid());
173
- * ```
174
- *
175
- * The per-call `options.eventId` override always wins over this factory.
176
- *
177
- * **Module-scoped — last setter wins.** The factory lives as a single
178
- * module variable; importing two libraries that both call this races on
179
- * load order, and parallel test workers will see each other's factory.
180
- * For test isolation and short-lived contexts prefer
181
- * {@link withEventIdFactory}; for multi-tenant request isolation
182
- * (e.g. one factory per tenant in a single Worker invocation) **prefer
183
- * the per-call `options.eventId`** instead of mutating the global. Same
184
- * caveat applies to `setClockFactory`.
185
- */
186
- declare function setEventIdFactory(factory: EventIdFactory): void;
187
- /**
188
- * Scoped variant of {@link setEventIdFactory}: installs `factory`,
189
- * runs `fn`, then restores the previous factory in a `finally` block —
190
- * so the restoration happens even if `fn` throws. Safe for parallel
191
- * tests and for synchronous request handlers that need a tenant-
192
- * specific factory without polluting the global.
193
- *
194
- * **Synchronous-only — enforced at runtime.** If `fn` returns a
195
- * thenable (a `Promise` or any object with a `then` method), the
196
- * helper throws *before* returning the value to the caller. This
197
- * catches the async-misuse footgun where the factory would be
198
- * restored before the awaited body of `fn` runs, leaving the awaited
199
- * code reading the previous factory. For async scoping across `await`
200
- * boundaries, use `AsyncLocalStorage` — out of scope for this helper;
201
- * build it on top if you need it.
202
- *
203
- * Composes by nesting: an inner `withEventIdFactory` restores back to
204
- * the outer's factory; the outer restores to the original.
205
- *
206
- * **When to prefer the per-call `options.eventId` instead.** If you're
207
- * constructing a single event and want full control over its id,
208
- * passing `{ eventId: "..." }` to `createDomainEvent` is the strongest
209
- * isolation — it bypasses the factory mechanism entirely, no global
210
- * mutation, no scope to manage. Reach for `withEventIdFactory` when
211
- * the events are constructed deep inside domain methods you can't
212
- * thread an explicit id through (typical test scenario), or when many
213
- * events in a scope should share the same factory.
214
- *
215
- * @example
216
- * ```ts
217
- * // In a vitest test:
218
- * it("emits deterministic ids", () => {
219
- * withEventIdFactory(() => "evt-fixed", () => {
220
- * const e = createDomainEvent("X", { v: 1 });
221
- * expect(e.eventId).toBe("evt-fixed");
222
- * });
223
- * // Outside the callback the default crypto.randomUUID is restored,
224
- * // even if the body had thrown.
225
- * });
226
- * ```
227
- */
228
- declare function withEventIdFactory<T>(factory: EventIdFactory, fn: () => T): T;
229
- /**
230
- * Restores the default event-id factory (`crypto.randomUUID()`).
231
- * Intended for use in test `afterEach` hooks.
232
- */
233
- declare function resetEventIdFactory(): void;
234
- /**
235
- * Clock function producing a fresh `Date` for each call. The library
236
- * defaults to `() => new Date()`; override globally via `setClockFactory`
237
- * for deterministic event-sourcing tests, time-travel debugging, or any
238
- * scenario where `occurredAt` must be reproducible.
239
- */
240
- type ClockFactory = () => Date;
241
- /**
242
- * Replaces the global clock factory used by `createDomainEvent` and
243
- * `createDomainEventWithMetadata`. Call once during application bootstrap
244
- * (or per-test in deterministic test suites):
245
- *
246
- * ```ts
247
- * import { setClockFactory } from "@shirudo/ddd-kit";
248
- *
249
- * setClockFactory(() => new Date("2026-01-01T00:00:00Z"));
250
- * ```
251
- *
252
- * The per-call `options.occurredAt` override always wins over this
253
- * factory. Symmetric to `setEventIdFactory`.
254
- *
255
- * Module-scoped — see {@link setEventIdFactory} for the global-state
256
- * caveats. For test isolation prefer {@link withClockFactory}; for
257
- * multi-tenant request isolation prefer the per-call
258
- * `options.occurredAt`.
259
- */
260
- declare function setClockFactory(factory: ClockFactory): void;
261
- /**
262
- * Scoped variant of {@link setClockFactory}: installs `factory`, runs
263
- * `fn`, then restores the previous factory in a `finally` block.
264
- * Synchronous-only — same constraints (and same runtime thenable
265
- * guard) as {@link withEventIdFactory}.
266
- *
267
- * **When to prefer the per-call `options.occurredAt` instead.** Same
268
- * trade-off as {@link withEventIdFactory}: passing `{ occurredAt }`
269
- * to `createDomainEvent` is the strongest isolation for single-event
270
- * cases. The scoped helper is for events constructed deep inside
271
- * domain methods where threading an explicit timestamp is awkward.
272
- *
273
- * @example
274
- * ```ts
275
- * it("stamps events with a fixed clock", () => {
276
- * const fixed = new Date("2026-01-01T00:00:00Z");
277
- * withClockFactory(() => fixed, () => {
278
- * const e = createDomainEvent("X", { v: 1 });
279
- * expect(e.occurredAt).toEqual(fixed);
280
- * });
281
- * });
282
- * ```
283
- */
284
- declare function withClockFactory<T>(factory: ClockFactory, fn: () => T): T;
285
- /**
286
- * Restores the default clock factory (`() => new Date()`).
287
- * Intended for use in test `afterEach` hooks.
288
- */
289
- declare function resetClockFactory(): void;
290
- /**
291
- * Metadata associated with a domain event for traceability and correlation.
292
- * Used in event-driven architectures to track event flow across services.
293
- */
294
- interface EventMetadata {
295
- /**
296
- * Correlation ID for tracing events across multiple services/components.
297
- * Typically used to group related events in a distributed system.
298
- */
299
- correlationId?: string;
300
- /**
301
- * Causation ID referencing the event or command that caused this event.
302
- * Used to build event chains and understand causality.
303
- */
304
- causationId?: string;
305
- /**
306
- * User ID of the person or system that triggered the event.
307
- */
308
- userId?: string;
309
- /**
310
- * Source service or component that produced the event.
311
- */
312
- source?: string;
313
- /**
314
- * Additional custom metadata fields.
315
- * Allows extensibility for domain-specific metadata.
316
- */
317
- [key: string]: unknown;
318
- }
319
- /**
320
- * Domain Event represents something meaningful that happened in the domain.
321
- * Events are immutable and carry information about what occurred.
322
- *
323
- * @template T - The event type name (e.g., "OrderCreated")
324
- * @template P - The event payload type
325
- */
326
- interface DomainEvent<T extends string, P = void> {
327
- /**
328
- * Unique identifier for this specific event instance. Used by idempotent
329
- * consumers, outbox dispatch tracking, and as the target of
330
- * `metadata.causationId`. Defaults to `crypto.randomUUID()` if not
331
- * supplied.
332
- */
333
- eventId: string;
334
- /**
335
- * The type of the event, used for routing and handling.
336
- */
337
- type: T;
338
- /**
339
- * Identifier of the aggregate that produced the event. Optional at the
340
- * library level — set it whenever the producing aggregate is known so
341
- * downstream subscribers, outboxes, and projections can scope by entity.
342
- */
343
- aggregateId?: string;
344
- /**
345
- * Name of the aggregate type that produced the event (e.g. "Order").
346
- * Pairs with `aggregateId` to fully qualify the source aggregate.
347
- */
348
- aggregateType?: string;
349
- /**
350
- * The event payload containing the domain data. The field is always
351
- * present; its value is `undefined` when `P` is `void`.
352
- */
353
- payload: P;
354
- /**
355
- * Timestamp when the event occurred.
356
- */
357
- occurredAt: Date;
358
- /**
359
- * Event schema version for handling schema evolution.
360
- * Required for safe schema migration in event-sourced systems.
361
- * Use 1 for the initial schema version.
362
- */
363
- version: number;
364
- /**
365
- * Optional metadata for traceability, correlation, and auditing.
366
- * Includes correlationId, causationId, userId, source, and custom fields.
367
- */
368
- metadata?: EventMetadata;
369
- }
370
- /**
371
- * Upper-bound alias for "any `DomainEvent` shape". Use as a generic
372
- * constraint when a type parameter should accept any concrete event
373
- * union. The `unknown` payload is the upper bound — concrete unions
374
- * still narrow via `Extract<Evt, { type: K }>` at the use-site.
375
- */
376
- type AnyDomainEvent = DomainEvent<string, unknown>;
377
- /**
378
- * Shared option bag for the `createDomainEvent*` factories.
379
- */
380
- interface CreateDomainEventOptions {
381
- /**
382
- * Override for the auto-generated `eventId`. Pass an existing id (for
383
- * replay, tests, or deterministic event sourcing) instead of letting the
384
- * factory call `crypto.randomUUID()`.
385
- */
386
- eventId?: string;
387
- /**
388
- * Identifier of the aggregate that produced the event.
389
- */
390
- aggregateId?: string;
391
- /**
392
- * Name of the aggregate type that produced the event.
393
- */
394
- aggregateType?: string;
395
- /**
396
- * Override for the auto-generated `occurredAt` timestamp.
397
- */
398
- occurredAt?: Date;
399
- /**
400
- * Override for the default schema version (1).
401
- */
402
- version?: number;
403
- /**
404
- * Event metadata — correlation, causation, user, source, custom fields.
405
- */
406
- metadata?: EventMetadata;
407
- }
408
- /**
409
- * Creates a domain event with default values.
410
- * Sets occurredAt to current date and version to 1 if not provided.
411
- *
412
- * **For aggregate-internal events, prefer `this.recordEvent(...)` on
413
- * `AggregateRoot` / `EventSourcedAggregate`.** That helper auto-injects
414
- * `aggregateId` (from `this.id`) and `aggregateType` (from the
415
- * aggregate's declared `aggregateType` property), which downstream
416
- * consumers — outbox dispatchers, projection handlers, audit logs —
417
- * route by. The `withCommit` harvest boundary now validates both fields
418
- * are present and throws if they're missing, so a direct
419
- * `createDomainEvent(...)` call inside an aggregate that forgets the
420
- * options is caught at runtime.
421
- *
422
- * Use `createDomainEvent(...)` directly for events that don't belong to
423
- * an aggregate: system events, integration events, configuration events,
424
- * test fixtures. For those, set `aggregateId` / `aggregateType` in
425
- * `options` if downstream consumers expect routing metadata.
426
- *
427
- * @param type - The event type
428
- * @param payload - The event payload
429
- * @param options - Optional event configuration (including `aggregateId`
430
- * and `aggregateType` for routing)
431
- * @returns A domain event
432
- *
433
- * @example
434
- * ```typescript
435
- * const event = createDomainEvent("OrderCreated", { orderId: "123" });
436
- * ```
437
- */
438
- declare function createDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateDomainEventOptions): DomainEvent<T, void>;
439
- declare function createDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateDomainEventOptions): DomainEvent<T, P>;
440
- /**
441
- * Creates a domain event with metadata for traceability.
442
- * Convenience function for creating events with correlation and causation IDs.
443
- *
444
- * @example
445
- * ```typescript
446
- * const event = createDomainEventWithMetadata(
447
- * "OrderCreated",
448
- * { orderId: "123" },
449
- * { correlationId: "corr-123", causationId: "cmd-456", userId: "user-789" }
450
- * );
451
- * ```
452
- */
453
- declare function createDomainEventWithMetadata<T extends string, P>(type: T, payload: P, metadata: EventMetadata, options?: Omit<CreateDomainEventOptions, "metadata">): DomainEvent<T, P>;
454
- /**
455
- * Copies metadata from a source event to a new event.
456
- * Useful for maintaining correlation chains in event-driven architectures.
457
- *
458
- * @example
459
- * ```typescript
460
- * const newEvent = createDomainEvent(
461
- * "OrderShipped",
462
- * { orderId: "123" },
463
- * { metadata: copyMetadata(previousEvent, { causationId: previousEvent.type }) }
464
- * );
465
- * ```
466
- */
467
- declare function copyMetadata(sourceEvent: AnyDomainEvent, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
468
- /**
469
- * Merges multiple metadata objects into one.
470
- * Later metadata objects override earlier ones for the same keys.
471
- *
472
- * @example
473
- * ```typescript
474
- * const metadata = mergeMetadata(
475
- * { correlationId: "corr-123" },
476
- * { userId: "user-456" },
477
- * { source: "order-service" }
478
- * );
479
- * ```
480
- */
481
- declare function mergeMetadata(...metadataObjects: Array<EventMetadata | undefined>): EventMetadata;
482
-
483
- type Version = number & {
484
- readonly __v: true;
485
- };
486
- /**
487
- * Snapshot of an aggregate state at a specific point in time.
488
- * Used for optimizing event replay by starting from a snapshot
489
- * instead of replaying all events from the beginning.
490
- *
491
- * @template TState - The type of the aggregate state
492
- */
493
- interface AggregateSnapshot<TState> {
494
- /**
495
- * The state of the aggregate at the time of the snapshot.
496
- */
497
- state: TState;
498
- /**
499
- * The version of the aggregate when the snapshot was taken.
500
- */
501
- version: Version;
502
- /**
503
- * Timestamp when the snapshot was created.
504
- */
505
- snapshotAt: Date;
506
- }
507
- /**
508
- * Public contract every Aggregate Root satisfies. Implemented by
509
- * `BaseAggregate` and inherited by both `AggregateRoot` and
510
- * `EventSourcedAggregate`. Repository implementations type their
511
- * `save(aggregate)` parameter against this interface rather than the
512
- * concrete classes, so the repo layer does not take a compile-time
513
- * dependency on the aggregate hierarchy.
514
- *
515
- * Full per-member documentation lives on the concrete `BaseAggregate`
516
- * class; the interface is intentionally terse to avoid drift.
517
- *
518
- * @template TId - The aggregate root identifier (branded via `Id<Tag>`)
519
- * @template TEvent - The domain-event union, defaults to `never`
520
- */
521
- interface IAggregateRoot<TId extends Id<string>, TEvent = never> {
522
- readonly id: TId;
523
- readonly version: Version;
524
- readonly persistedVersion: Version | undefined;
525
- readonly pendingEvents: ReadonlyArray<TEvent>;
526
- clearPendingEvents(): void;
527
- markPersisted(version: Version): void;
528
- }
529
- /**
530
- * Public contract for Event-Sourced Aggregate Roots. Extends
531
- * `IAggregateRoot` with the replay-from-history boundary.
532
- *
533
- * @template TId - The aggregate root identifier
534
- * @template TEvent - The union type of all domain events
535
- */
536
- interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends IAggregateRoot<TId, TEvent> {
537
- /**
538
- * Reconstitutes the aggregate from an event history. Returns
539
- * `Result` because event-stream corruption is an expected
540
- * recoverable failure at the infrastructure boundary.
541
- */
542
- loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
543
- }
544
- /**
545
- * Checks if two aggregates are at the same version (same ID and version).
546
- * Useful for optimistic concurrency control checks.
547
- *
548
- * Note: Two aggregates with the same ID ARE the same aggregate (identity).
549
- * This function checks if they are at the same version — i.e., no concurrent modification.
550
- *
551
- * @example
552
- * ```typescript
553
- * const before = await repository.getById(id);
554
- * // ... some operations ...
555
- * const after = await repository.getById(id);
556
- *
557
- * if (!sameVersion(before, after)) {
558
- * throw new Error("Aggregate was modified by another process");
559
- * }
560
- * ```
561
- */
562
- declare function sameVersion<TId extends Id<string>>(a: {
563
- id: TId;
564
- version: Version;
565
- }, b: {
566
- id: TId;
567
- version: Version;
568
- }): boolean;
569
-
570
8
  /**
571
9
  * Entity utilities and interfaces for Domain-Driven Design.
572
10
  *
@@ -625,7 +63,7 @@ declare function sameVersion<TId extends Id<string>>(a: {
625
63
  */
626
64
 
627
65
  /**
628
- * Functional definition of an Entity via its capability an object is
66
+ * Functional definition of an Entity via its capability: an object is
629
67
  * identifiable if it has an `id`.
630
68
  *
631
69
  * `TId` is constrained to `Id<string>` so the brand discipline that
@@ -694,7 +132,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
694
132
  /**
695
133
  * Returns the current state of the entity.
696
134
  *
697
- * The state object is **shallowly frozen** direct property writes
135
+ * The state object is **shallowly frozen**: direct property writes
698
136
  * (`entity.state.foo = …`) throw in strict mode, but writes to nested
699
137
  * objects (`entity.state.address.zip = …`) bypass the freeze. For deep
700
138
  * immutability either model nested data with `vo()` (which freezes
@@ -713,7 +151,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
713
151
  * **State ownership.** Plain-object and array states are shallow-copied
714
152
  * before the freeze, so the caller's own object stays mutable. A CLASS
715
153
  * INSTANCE passed as state is an ownership transfer: it is frozen
716
- * in place (a copy would strip its prototype) do not keep mutating
154
+ * in place (a copy would strip its prototype). Do not keep mutating
717
155
  * the instance after handing it to the entity. The same contract
718
156
  * applies to {@link setState}.
719
157
  */
@@ -726,7 +164,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
726
164
  * **⚠️ Must not read subclass instance fields via `this`.** The
727
165
  * constructor calls `validateState(initialState)` BEFORE the subclass's
728
166
  * field initializers run, so `this.someField` is `undefined` at that
729
- * point a classic TypeScript/JavaScript constructor-ordering footgun.
167
+ * point, a classic TypeScript/JavaScript constructor-ordering footgun.
730
168
  * The `state` argument is the single source of truth; treat the method
731
169
  * as pure with respect to `this`.
732
170
  *
@@ -747,7 +185,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
747
185
  *
748
186
  * Plain-object and array states are shallow-copied before the freeze
749
187
  * (the caller's object stays mutable); a class-instance state is an
750
- * ownership transfer and is frozen in place see the constructor.
188
+ * ownership transfer and is frozen in place; see the constructor.
751
189
  *
752
190
  * @param newState - The new state
753
191
  */
@@ -917,7 +355,7 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
917
355
  * `recordEvent` helper that auto-injects `aggregateId` +
918
356
  * `aggregateType` on every event the aggregate emits.
919
357
  *
920
- * Consumers do NOT extend this class directly extend
358
+ * Consumers do NOT extend this class directly; extend
921
359
  * `AggregateRoot` for state-stored aggregates or
922
360
  * `EventSourcedAggregate` for event-sourced ones. The split between
923
361
  * those two reflects the canonical Vernon §8 (state-stored) /
@@ -949,7 +387,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
949
387
  *
950
388
  * The string is *the* identifier downstream consumers (outbox
951
389
  * dispatchers, projection handlers, audit logs) use to route by
952
- * aggregate kind. Use the same canonical name across your system
390
+ * aggregate kind. Use the same canonical name across your system;
953
391
  * matching the class name is the obvious choice, but the value
954
392
  * comes from this explicit declaration, not `constructor.name`
955
393
  * (which is fragile under minification, bundler transforms, and
@@ -965,7 +403,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
965
403
  *
966
404
  * Distinct from {@link version}, which is the in-memory
967
405
  * post-mutation value. Mutations bump `_version` but never touch
968
- * `_persistedVersion` that field only moves on {@link markRestored}
406
+ * `_persistedVersion`; that field only moves on {@link markRestored}
969
407
  * (Post-Load) and {@link markPersisted} (Post-Save).
970
408
  */
971
409
  private _persistedVersion;
@@ -979,7 +417,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
979
417
  get pendingEvents(): ReadonlyArray<TEvent>;
980
418
  /**
981
419
  * Clears the pending-event list. Called by `markPersisted` after a
982
- * successful write the events have been handed off to the outbox
420
+ * successful write: the events have been handed off to the outbox
983
421
  * / event store and are no longer the aggregate's responsibility.
984
422
  */
985
423
  clearPendingEvents(): void;
@@ -991,16 +429,26 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
991
429
  */
992
430
  protected bumpVersion(): void;
993
431
  /**
994
- * **Lifecycle marker Post-Load.** Syncs both `_version` and
432
+ * **Lifecycle marker, Post-Load.** Syncs both `_version` and
995
433
  * `_persistedVersion` to the DB-stored version. Used by
996
434
  * `reconstitute(...)` factories to assemble an in-memory aggregate
997
435
  * from a persisted row.
998
436
  *
999
- * Does NOT fire {@link onPersisted} that hook has post-save
437
+ * Does NOT fire {@link onPersisted}; that hook has post-save
1000
438
  * semantics (metrics, audit, cache eviction), not post-load. The
1001
439
  * Factory-vs-Reconstitution distinction (Vernon §11) is honoured
1002
440
  * structurally: two separate markers, one for each transition.
1003
441
  *
442
+ * **If you override this, call `super.markRestored(version)` FIRST**,
443
+ * same discipline as {@link markPersisted}. The marker is load-bearing
444
+ * twice over: it syncs `version`/`persistedVersion`, and on
445
+ * `AggregateRoot` it also captures the dirty-tracking baseline for
446
+ * `changedKeys`/`hasChanges`. An override that skips `super` leaves
447
+ * that baseline uncaptured: `changedKeys` permanently reports ALL
448
+ * keys and `hasChanges` never returns `false`, so a partial-write
449
+ * repository silently degrades to full writes on every save, on top
450
+ * of the broken version sync.
451
+ *
1004
452
  * @param version - The version the row currently holds in the DB
1005
453
  *
1006
454
  * @example
@@ -1014,14 +462,14 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1014
462
  */
1015
463
  protected markRestored(version: Version): void;
1016
464
  /**
1017
- * **Framework lifecycle method `@sealed`.** Called by `withCommit`
465
+ * **Framework lifecycle method (`@sealed`).** Called by `withCommit`
1018
466
  * (or by your own orchestration code, after harvesting `pendingEvents`)
1019
467
  * to push the persisted version back into the in-memory aggregate and
1020
468
  * clear `pendingEvents`. TypeScript has no `final` keyword, but
1021
469
  * subclasses **should not** override this method directly.
1022
470
  *
1023
471
  * Overriding without calling `super.markPersisted(version)` silently
1024
- * leaks `pendingEvents` the next `withCommit` will re-dispatch them
472
+ * leaks `pendingEvents`: the next `withCommit` will re-dispatch them
1025
473
  * through the outbox, double-emitting events. This bug has been hit
1026
474
  * in production by consumers; the {@link onPersisted} hook below is
1027
475
  * the safer extension point.
@@ -1031,17 +479,17 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1031
479
  * runs, then add your logic afterwards.
1032
480
  *
1033
481
  * @param version - The version assigned by the persistence layer
1034
- * @see onPersisted the safe extension point for subclasses
482
+ * @see onPersisted, the safe extension point for subclasses
1035
483
  */
1036
484
  markPersisted(version: Version): void;
1037
485
  /**
1038
- * Subclass extension point fires AFTER {@link markPersisted} has
486
+ * Subclass extension point: fires AFTER {@link markPersisted} has
1039
487
  * updated the version and cleared `pendingEvents`. Override this for
1040
488
  * post-persist logging, metrics, or cache-eviction without risk of
1041
489
  * breaking the framework's pendingEvents cleanup.
1042
490
  *
1043
491
  * The default implementation is a no-op. Subclasses do NOT need to
1044
- * call `super.onPersisted(version)` there is nothing in the parent
492
+ * call `super.onPersisted(version)`: there is nothing in the parent
1045
493
  * implementation to preserve.
1046
494
  *
1047
495
  * **Observer contract: errors are swallowed.** `withCommit` invokes
@@ -1053,7 +501,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1053
501
  * **`onPersisted` deliberately receives only the version, not the
1054
502
  * drained events.** Event-driven post-persist logic (aggregate-level
1055
503
  * audit logging, per-event-type side effects) belongs in `EventBus`
1056
- * subscribers or the outbox dispatcher that is the proper
504
+ * subscribers or the outbox dispatcher; that is the proper
1057
505
  * Aggregate-Boundary separation. Building event-aware logic into
1058
506
  * `onPersisted` couples aggregate lifecycle to event processing and
1059
507
  * recreates the boundary problems Vernon's aggregate discipline is
@@ -1062,7 +510,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1062
510
  * **The hook must return synchronously.** `markPersisted` is `void`-
1063
511
  * typed and calls `onPersisted` without `await`. TypeScript's
1064
512
  * permissive `void` will accept an `async`-override returning
1065
- * `Promise<void>`, but the returned promise is fire-and-forget
513
+ * `Promise<void>`, but the returned promise is fire-and-forget:
1066
514
  * any rejection becomes an unhandled rejection and `withCommit`
1067
515
  * proceeds without waiting. For asynchronous work, subscribe to the
1068
516
  * relevant domain event on the `EventBus` instead; that is the
@@ -1074,7 +522,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1074
522
  /**
1075
523
  * Appends a domain event to the pending list. Prefer the higher-level
1076
524
  * `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
1077
- * (event-sourced) call sites both wrap `addDomainEvent` in the
525
+ * (event-sourced) call sites, both of which wrap `addDomainEvent` in the
1078
526
  * canonical record-AFTER-mutation order (Vernon §8). Calling
1079
527
  * `addDomainEvent` directly is appropriate only when state and event
1080
528
  * recording have already been decoupled deliberately (e.g. a
@@ -1082,7 +530,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1082
530
  */
1083
531
  protected addDomainEvent(event: TEvent): void;
1084
532
  /**
1085
- * Creates a snapshot of the current aggregate state the state at
533
+ * Creates a snapshot of the current aggregate state: the state at
1086
534
  * this moment plus the version. Useful for ES snapshot policies and
1087
535
  * for state-stored backup / restore.
1088
536
  *
@@ -1094,7 +542,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1094
542
  * Converts live aggregate state into the plain-data shape stored in a
1095
543
  * snapshot. The default validates that the state graph is plain,
1096
544
  * serialisable data (no class instances, functions, Promise/WeakMap/
1097
- * WeakSet) and then `structuredClone`s it class instances would
545
+ * WeakSet) and then `structuredClone`s it: class instances would
1098
546
  * silently lose their prototype here AND on every snapshot-store
1099
547
  * round-trip, so the default fails fast with the offending path
1100
548
  * instead of producing a snapshot that breaks on first method call
@@ -1120,8 +568,8 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1120
568
  * into the event's metadata fields. This is the canonical path for
1121
569
  * recording events from inside aggregate domain methods.
1122
570
  *
1123
- * Downstream consumers outbox dispatchers, projection handlers,
1124
- * audit logs route by these two fields. Calling
571
+ * Downstream consumers (outbox dispatchers, projection handlers,
572
+ * audit logs) route by these two fields. Calling
1125
573
  * `createDomainEvent(...)` directly inside an aggregate method
1126
574
  * leaves them unset and is caught at the `withCommit` harvest
1127
575
  * boundary, but `this.recordEvent(...)` makes the right thing
@@ -1145,8 +593,8 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1145
593
  * @param payload - payload for that event subtype
1146
594
  * @param options - any remaining `createDomainEvent` options
1147
595
  * (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
1148
- * and `aggregateType` are deliberately omitted the helper sets
1149
- * them.
596
+ * and `aggregateType` are deliberately omitted, because the helper
597
+ * sets them.
1150
598
  */
1151
599
  protected recordEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateDomainEventOptions, "aggregateId" | "aggregateType">): E;
1152
600
  }
@@ -1159,7 +607,7 @@ interface AggregateConfig {
1159
607
  * Whether `setState()` should bump the version automatically when the
1160
608
  * caller omits the per-call `bumpVersion` argument.
1161
609
  *
1162
- * Defaults to **`false`** `setState()` already takes an explicit
610
+ * Defaults to **`false`**: `setState()` already takes an explicit
1163
611
  * `bumpVersion` argument per call, so the config is just the default
1164
612
  * the per-call argument falls back to. Set to `true` only if you have
1165
613
  * a subclass that never passes `bumpVersion` and you want every state
@@ -1170,8 +618,8 @@ interface AggregateConfig {
1170
618
  /**
1171
619
  * Base class for Aggregate Roots without Event Sourcing.
1172
620
  *
1173
- * In DDD (Evans), an Aggregate is a cluster of objects root entity, child entities,
1174
- * and value objects treated as a unit for consistency. The **Aggregate Root** is the
621
+ * In DDD (Evans), an Aggregate is a cluster of objects (root entity, child entities,
622
+ * and value objects) treated as a unit for consistency. The **Aggregate Root** is the
1175
623
  * root entity that represents the aggregate externally and is the only entry point
1176
624
  * for external code. This class serves as both: it IS the root entity and it contains
1177
625
  * the aggregate state (`TState`) which holds child entities and value objects.
@@ -1190,7 +638,7 @@ interface AggregateConfig {
1190
638
  *
1191
639
  * @template TState - The type of the aggregate state (contains child entities and value objects)
1192
640
  * @template TId - The type of the aggregate root identifier
1193
- * @template TEvent - The type of domain events recorded by this aggregate. Defaults to `never` aggregates without a declared event type cannot emit events (emitting any event becomes a compile error). Supply a concrete event union to opt in.
641
+ * @template TEvent - The type of domain events recorded by this aggregate. Defaults to `never`: aggregates without a declared event type cannot emit events (emitting any event becomes a compile error). Supply a concrete event union to opt in.
1194
642
  *
1195
643
  * @example
1196
644
  * ```typescript
@@ -1213,7 +661,106 @@ interface AggregateConfig {
1213
661
  */
1214
662
  declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never, TSnapshotState = TState> extends BaseAggregate<TState, TId, TEvent, TSnapshotState> {
1215
663
  private readonly _autoVersionBump;
664
+ /**
665
+ * The state reference as of the last {@link markRestored} /
666
+ * `markPersisted` (the persistence-lifecycle markers). Only
667
+ * meaningful while {@link _hasBaseline} is `true`; tracked by a
668
+ * separate flag rather than an `undefined` sentinel so a `TState`
669
+ * that itself admits `undefined` cannot be confused with the
670
+ * never-persisted insert path.
671
+ *
672
+ * Held by reference, never copied: `_state` is shallow-frozen and only
673
+ * ever *replaced* (via `setState` / restore), so the captured reference
674
+ * stays an exact image of the state at baseline time.
675
+ */
676
+ private _baselineState;
677
+ /**
678
+ * `false` until the aggregate has been persisted or restored at least
679
+ * once: the insert path, where every key counts as changed.
680
+ */
681
+ private _hasBaseline;
1216
682
  protected constructor(id: TId, initialState: TState, config?: AggregateConfig);
683
+ /**
684
+ * **Lifecycle marker, Post-Load (see `BaseAggregate.markRestored`).**
685
+ * Additionally captures the current state reference as the dirty-
686
+ * tracking baseline for {@link changedKeys} / {@link hasChanges}.
687
+ *
688
+ * Covers all three baseline-capture paths through a single override:
689
+ * `reconstitute(...)` factories, {@link restoreFromSnapshot} (which
690
+ * assigns the restored state *before* calling this), and
691
+ * `markPersisted` (which delegates here, so a successful save
692
+ * re-baselines the diff).
693
+ *
694
+ * If you override this, call `super.markRestored(version)` FIRST:
695
+ * skipping it leaves the baseline uncaptured, so `changedKeys`
696
+ * permanently reports ALL keys and `hasChanges` never returns `false`
697
+ * (partial-write repositories silently degrade to full writes), on
698
+ * top of breaking version sync.
699
+ */
700
+ protected markRestored(version: Version): void;
701
+ /**
702
+ * Top-level state keys whose value (or presence) changed since the
703
+ * last {@link markRestored} / `markPersisted`. Never-persisted
704
+ * aggregates report ALL current keys (the insert path).
705
+ *
706
+ * This is the write-scoping signal for **partial writes in multi-table
707
+ * repositories**: a `save()` for an aggregate whose state spans a root
708
+ * row plus N child-collection tables can write only the collections
709
+ * whose key is dirty, while the root-row OCC version write rides every
710
+ * save. See `docs/guide/repository.md` → "Partial writes for
711
+ * multi-table aggregates".
712
+ *
713
+ * **How it works.** `setState()` replaces state immutably and the
714
+ * state object is shallow-frozen, so unchanged top-level sub-objects
715
+ * keep reference identity across mutations. The diff is therefore a
716
+ * shallow per-key `!==` against the baseline reference: O(top-level
717
+ * keys), no proxies, no deep diff. A key also counts as dirty when its
718
+ * *presence* differs (added or removed, even with an `undefined`
719
+ * value). Computed fresh on every access (a new `Set` each time), so
720
+ * callers cannot poison later reads.
721
+ *
722
+ * **Soundness contract (same one `freezeShallow` already makes):**
723
+ * the per-key diff is exact only for plain-record `TState` mutated via
724
+ * `setState` / `commit` (whole-state replacement). In-place mutation
725
+ * of NESTED objects bypasses the shallow freeze AND this diff; a
726
+ * class-instance `TState` mutated through its own methods defeats
727
+ * tracking entirely (the reference never changes). A keyless `TState`
728
+ * (primitive, bare `Date`) has no keys to report, so `changedKeys`
729
+ * stays empty for it; use {@link hasChanges}, whose reference
730
+ * fallback covers keyless states. A deep-equal but newly-referenced
731
+ * value reports a false POSITIVE (harmless extra write); under the
732
+ * contract above there are no false negatives.
733
+ *
734
+ * Granularity is per top-level key, table-granular, not row-granular:
735
+ * a dirty collection key means "this child table changed", not which
736
+ * rows. `EventSourcedAggregate` deliberately has no `changedKeys`;
737
+ * its `pendingEvents` are the change record.
738
+ */
739
+ get changedKeys(): ReadonlySet<Extract<keyof TState, string>>;
740
+ /**
741
+ * Safe skip signal: `false` only when there is genuinely nothing to
742
+ * persist or flush. `true` when the aggregate has never been
743
+ * persisted, the version moved past `persistedVersion`, there are
744
+ * unflushed {@link pendingEvents}, any state key is dirty, or, for
745
+ * keyless states the per-key diff cannot see (primitive `TState`,
746
+ * zero-own-key objects like a bare `Date`), the state reference
747
+ * changed since the baseline.
748
+ *
749
+ * The version clause is deliberate: `setState({...state}, true)` with
750
+ * identical per-key values yields empty {@link changedKeys} but a
751
+ * bumped version. If a repository skipped `save()` on a state-only
752
+ * check, `withCommit` would still call `markPersisted(version)` after
753
+ * commit, desyncing `persistedVersion` from the DB row; and the next
754
+ * uncontended save would throw a false `ConcurrencyConflictError`.
755
+ *
756
+ * The pending-events clause covers the sanctioned decoupled
757
+ * `addDomainEvent` path (an event recorded without a state change,
758
+ * e.g. a deletion event before a hard delete): the aggregate still
759
+ * needs its trip through `withCommit` so the event reaches the
760
+ * outbox. With all clauses included, `hasChanges === false` genuinely
761
+ * means "skipping save is safe".
762
+ */
763
+ get hasChanges(): boolean;
1217
764
  /**
1218
765
  * Mutates state and records the resulting domain events in the
1219
766
  * **canonical record-after-mutation order**. Use this instead of calling
@@ -1221,7 +768,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
1221
768
  * "event for a fact that never happened" footgun.
1222
769
  *
1223
770
  * Order of operations:
1224
- * 1. `setState(newState, true)` runs `validateState` first.
771
+ * 1. `setState(newState, true)`: runs `validateState` first.
1225
772
  * If it throws, the method propagates and **no event is recorded
1226
773
  * and no version is bumped**.
1227
774
  * 2. Each event in `events` is appended via `addDomainEvent`.
@@ -1268,7 +815,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
1268
815
  */
1269
816
  protected setState(newState: TState, bumpVersion?: boolean): void;
1270
817
  /**
1271
- * Restores the aggregate from a snapshot loads state and aligns
818
+ * Restores the aggregate from a snapshot: loads state and aligns
1272
819
  * `version` + `persistedVersion` to the snapshot version. Validates
1273
820
  * the restored state.
1274
821
  *
@@ -1283,19 +830,19 @@ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEv
1283
830
  *
1284
831
  * Like `AggregateRoot`, this is both the root entity and the aggregate
1285
832
  * boundary. The difference is persistence: state is derived from events,
1286
- * not stored directly. Events are the single source of truth all state
833
+ * not stored directly. Events are the single source of truth: all state
1287
834
  * changes go through `apply()` → handler.
1288
835
  *
1289
836
  * Extends `BaseAggregate` (the shared lifecycle machinery) but does NOT
1290
837
  * expose `setState()` or `commit()` from `AggregateRoot`. This enforces
1291
- * the event sourcing pattern at the type level there is no way to
838
+ * the event sourcing pattern at the type level: there is no way to
1292
839
  * mutate state without going through an event handler.
1293
840
  *
1294
841
  * `apply()` and `validateEvent()` throw `DomainError`-derived exceptions
1295
842
  * on invariant violations. Subclasses override `validateEvent()` to
1296
843
  * throw their own concrete subclasses (e.g. `OrderAlreadyConfirmedError`).
1297
844
  * Only the infrastructure-boundary methods (`loadFromHistory`,
1298
- * `restoreFromSnapshotWithEvents`) return `Result` they catch
845
+ * `restoreFromSnapshotWithEvents`) return `Result`: they catch
1299
846
  * `DomainError` during replay so callers can react to corrupted event
1300
847
  * streams without try/catch.
1301
848
  *
@@ -1345,13 +892,13 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1345
892
  * Throws `DomainError` (or a subclass) on validation failure.
1346
893
  * Throws `MissingHandlerError` if no handler is registered for `event.type`.
1347
894
  *
1348
- * State is not mutated if any step throws the handler is invoked into
895
+ * State is not mutated if any step throws: the handler is invoked into
1349
896
  * a local and only assigned to `_state` once all checks pass.
1350
897
  *
1351
898
  * The method is generic in the event tag `K`, so concrete callers
1352
899
  * (`this.apply(orderCreated)`) narrow to the literal tag and the
1353
- * dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }>>`
1354
- * no `as` cast required at the call site.
900
+ * dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }>>`,
901
+ * with no `as` cast required at the call site.
1355
902
  *
1356
903
  * @param event - The domain event to apply
1357
904
  * @param isNew - Whether the event is new (needs persisting) or replayed from history
@@ -1369,12 +916,12 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1369
916
  private dispatchAndCommit;
1370
917
  /**
1371
918
  * Reconstitutes the aggregate from an event history. Catches `DomainError`
1372
- * thrown during replay and returns it as an `Err` this is the
919
+ * thrown during replay and returns it as an `Err`: this is the
1373
920
  * infrastructure boundary, where event-stream corruption is an expected
1374
921
  * recoverable failure. Unexpected (non-DomainError) throws propagate.
1375
922
  *
1376
923
  * All-or-nothing: if any event mid-stream throws, the aggregate's state
1377
- * is rolled back to its pre-call value same contract as
924
+ * is rolled back to its pre-call value, the same contract as
1378
925
  * `restoreFromSnapshotWithEvents`. Partial replay is never observable.
1379
926
  * (Version needs no rollback: replay dispatches with `isNew = false`,
1380
927
  * which never bumps it; only the final `markRestored` advances it.)
@@ -1553,7 +1100,7 @@ interface ICommandBus<TMap extends CommandTypeMap = CommandTypeMap> {
1553
1100
  *
1554
1101
  * When `TMap` is supplied, the `commandType` argument is restricted to
1555
1102
  * its keys and the handler signature is forced to match `TMap[K]` for the
1556
- * return value typos and wrong-typed handlers are compile errors.
1103
+ * return value: typos and wrong-typed handlers are compile errors.
1557
1104
  * Without `TMap` the registration is loose (any string key, any return
1558
1105
  * type) so the no-config path keeps working.
1559
1106
  *
@@ -1654,13 +1201,13 @@ interface EventBus<Evt extends AnyDomainEvent> {
1654
1201
  * awaits all of its handlers, then dispatches `b`, and so on. The
1655
1202
  * library never reorders or parallelises across events.
1656
1203
  * 2. **Handlers within a single event run in parallel.** All handlers
1657
- * subscribed to `event.type` are awaited via `Promise.allSettled` —
1204
+ * subscribed to `event.type` are awaited via `Promise.allSettled`:
1658
1205
  * none of them sees the others' errors and none is skipped if a
1659
1206
  * peer fails.
1660
1207
  * 3. **Errors are collected and thrown AFTER everything dispatches.**
1661
1208
  * If one handler throws, remaining handlers for that event still
1662
1209
  * run, and remaining events in the batch still publish. Once
1663
- * `publish` reaches the end of the batch it throws the single
1210
+ * `publish` reaches the end of the batch it throws: the single
1664
1211
  * error directly if there was one, or an `AggregateError`
1665
1212
  * ("Multiple event handlers failed") containing every captured
1666
1213
  * error otherwise. Callers that need fail-fast semantics should
@@ -1733,7 +1280,7 @@ interface OnceOptions {
1733
1280
  /**
1734
1281
  * One pending event in the outbox plus the opaque id the implementation
1735
1282
  * needs to ack it via `markDispatched`. The library does not prescribe
1736
- * what `dispatchId` looks like an implementation can reuse the event's
1283
+ * what `dispatchId` looks like: an implementation can reuse the event's
1737
1284
  * own `eventId`, generate its own UUID, use the row's auto-increment
1738
1285
  * primary key, or whatever the storage layer prefers.
1739
1286
  */
@@ -1742,7 +1289,7 @@ interface OutboxRecord<Evt extends AnyDomainEvent> {
1742
1289
  event: Evt;
1743
1290
  }
1744
1291
  /**
1745
- * Transactional outbox port the bridge between the write-side
1292
+ * Transactional outbox port: the bridge between the write-side
1746
1293
  * transaction and the (out-of-band) event dispatcher.
1747
1294
  *
1748
1295
  * Lifecycle:
@@ -1753,7 +1300,7 @@ interface OutboxRecord<Evt extends AnyDomainEvent> {
1753
1300
  * 3. After successful dispatch, the dispatcher calls `markDispatched()`
1754
1301
  * with the records' `dispatchId`s so they don't come back next poll.
1755
1302
  *
1756
- * `markDispatched` is required to be idempotent calling it with an id
1303
+ * `markDispatched` is required to be idempotent: calling it with an id
1757
1304
  * that's already marked is a no-op, not an error. This lets the
1758
1305
  * dispatcher safely retry on partial-failure.
1759
1306
  */
@@ -1792,20 +1339,24 @@ interface Outbox<Evt extends AnyDomainEvent> {
1792
1339
  * `$transaction`, etc.). The block commits when the callback resolves
1793
1340
  * and rolls back if it throws.
1794
1341
  *
1795
- * `TCtx` is the persistence layer's transaction handle Drizzle's `tx`,
1342
+ * `TCtx` is the persistence layer's transaction handle: Drizzle's `tx`,
1796
1343
  * Prisma's `tx`, Mongo's session, etc. The scope opens the transaction
1797
1344
  * and passes the handle to `fn`; the use case binds its repositories to
1798
1345
  * that handle (typically by constructing a tx-scoped repo from the ctx).
1799
1346
  *
1800
1347
  * No default for `TCtx`: every implementor names their context type
1801
1348
  * explicitly. For genuinely context-free scopes (in-memory tests, naive
1802
- * no-tx scopes) use `TransactionScope<undefined>` that's a conscious
1349
+ * no-tx scopes) use `TransactionScope<undefined>`: that's a conscious
1803
1350
  * "there is nothing meaningful here" statement, not an accidental
1804
1351
  * `unknown` fallback.
1805
1352
  *
1806
- * Intentionally **not** Fowler's full Unit of Work (no change tracking,
1807
- * no `registerDirty` / `registerNew` / `registerDeleted`, no commit-time
1808
- * flush). Change tracking is the ORM's job.
1353
+ * Intentionally minimal: the scope itself does no change tracking and
1354
+ * no commit-time flush. Those concerns live in the layers above - the
1355
+ * aggregate detects its own changes (`changedKeys` / `hasChanges`),
1356
+ * `withCommit` orchestrates the commit lifecycle, and the opt-in
1357
+ * `UnitOfWork` facade adds tx-bound repositories and enrollment. See
1358
+ * "TransactionScope stays minimal; the Unit of Work lives above it" in
1359
+ * docs/guide/design-decisions.md.
1809
1360
  *
1810
1361
  * @example Drizzle implementation
1811
1362
  * ```typescript
@@ -1817,7 +1368,7 @@ interface Outbox<Evt extends AnyDomainEvent> {
1817
1368
  * }
1818
1369
  * ```
1819
1370
  *
1820
- * @example Use site bind repos to the live transaction
1371
+ * @example Use site: bind repos to the live transaction
1821
1372
  * ```typescript
1822
1373
  * await scope.transactional(async (tx) => {
1823
1374
  * // Construct tx-bound repos from ctx (your factory / DI of choice)
@@ -1829,14 +1380,28 @@ interface Outbox<Evt extends AnyDomainEvent> {
1829
1380
  * });
1830
1381
  * ```
1831
1382
  *
1832
- * `IRepository`'s contract takes the id / aggregate only the tx handle
1383
+ * `IRepository`'s contract takes the id / aggregate only: the tx handle
1833
1384
  * is wired into a concrete repository at construction time, not threaded
1834
1385
  * through every call. Different ORMs have different idioms for that
1835
1386
  * (constructor injection, factory functions, `withTx` chains); pick one
1836
1387
  * and keep it consistent.
1837
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
+ }
1838
1403
  interface TransactionScope<TCtx> {
1839
- transactional<T>(fn: (ctx: TCtx) => Promise<T>): Promise<T>;
1404
+ transactional<T>(fn: (ctx: TCtx) => Promise<T>, options?: TransactionalOptions): Promise<T>;
1840
1405
  }
1841
1406
 
1842
1407
  /**
@@ -1849,14 +1414,17 @@ interface TransactionScope<TCtx> {
1849
1414
  * committed" is the orchestrator's call to make, not the repo's.
1850
1415
  *
1851
1416
  * Order of operations:
1852
- * 1. `fn(ctx)` runs inside `scope.transactional(...)` domain mutations
1417
+ * 1. `fn(ctx)` runs inside `scope.transactional(...)`; domain mutations
1853
1418
  * + repo writes happen here. `ctx` is whatever transaction handle the
1854
1419
  * `scope` exposes (Drizzle `tx`, Prisma `tx`, Mongo session, or
1855
1420
  * `undefined` for context-free scopes).
1856
1421
  * 2. **Still inside the transaction**, `withCommit` harvests every
1857
1422
  * aggregate's `pendingEvents` and writes them via `outbox.add` (so
1858
1423
  * events persist atomically with the state change). Skipped when no
1859
- * events were recorded.
1424
+ * events were recorded. Each harvested event is stamped with
1425
+ * `aggregateVersion` = the aggregate's commit version (onto a frozen
1426
+ * copy; a pre-set value wins) - consumers get the OCC version the
1427
+ * row was written with, for ordering and idempotency watermarks.
1860
1428
  *
1861
1429
  * **Harvest order.** Events are concatenated in the order
1862
1430
  * aggregates appear in the returned `aggregates` array, then in
@@ -1867,14 +1435,14 @@ interface TransactionScope<TCtx> {
1867
1435
  * that exact order.
1868
1436
  *
1869
1437
  * **Two ordering guarantees, not one.** Within a single aggregate
1870
- * the order is *causal* events are recorded in the order the
1438
+ * the order is *causal*: events are recorded in the order the
1871
1439
  * domain methods ran, and subscribers (handlers, projections,
1872
1440
  * replay) MUST process them in that order. Across aggregates the
1873
1441
  * order in this batch is deterministic but *not* a domain
1874
1442
  * guarantee. Greg Young / Vernon IDDD §10: aggregates are
1875
1443
  * independent consistency boundaries; events across them are
1876
1444
  * eventually consistent. Subscribers should NOT engineer
1877
- * dependencies on cross-aggregate ordering use
1445
+ * dependencies on cross-aggregate ordering; use
1878
1446
  * `EventMetadata.causationId` to express true causation, or a
1879
1447
  * process manager to coordinate. The in-process EventBus delivers
1880
1448
  * this batch in order, sequential outbox-dispatchers preserve it
@@ -1882,8 +1450,11 @@ interface TransactionScope<TCtx> {
1882
1450
  * across aggregates at delivery time.
1883
1451
  * 3. The transaction commits.
1884
1452
  * 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
1885
- * fires on each returned aggregate only now are pending events
1886
- * considered flushed.
1453
+ * fires on each returned aggregate; only now are pending events
1454
+ * considered flushed. Aggregates listed in the optional `deleted`
1455
+ * marker array are the exception: their pending events are cleared
1456
+ * directly WITHOUT `markPersisted`, so the post-save `onPersisted`
1457
+ * hook never fires for a row that was just deleted.
1887
1458
  * 5. `bus.publish(events)` fires for the in-process fast path (skipped
1888
1459
  * when no events or no `bus` is wired).
1889
1460
  *
@@ -1894,22 +1465,36 @@ interface TransactionScope<TCtx> {
1894
1465
  * them (eventual consistency).
1895
1466
  *
1896
1467
  * **A `bus.publish` failure never rejects `withCommit`.** Once the
1897
- * transaction has committed, the write succeeded surfacing a subscriber
1468
+ * transaction has committed, the write succeeded; surfacing a subscriber
1898
1469
  * failure as a rejection would hand the caller a use-case failure for a
1899
1470
  * committed write (a typical caller retries, double-executing it). The
1900
1471
  * in-process fast path is best-effort by design; the error is reported to
1901
1472
  * the optional `onPublishError(error, events)` hook (wire it to your
1902
- * logger/metrics) and otherwise dropped delivery is still guaranteed via
1473
+ * logger/metrics) and otherwise dropped; delivery is still guaranteed via
1903
1474
  * the outbox. The hook is an observer: if it throws, its error is
1904
1475
  * swallowed so the post-commit invariant holds.
1905
1476
  *
1906
- * If the transaction rolls back, `markPersisted` is **not** called the
1477
+ * If the transaction rolls back, `markPersisted` is **not** called: the
1907
1478
  * aggregate keeps its pending events, so the caller can retry or discard.
1908
1479
  *
1480
+ * **Do not mutate an aggregate after `repository.save(...)` inside `fn`.**
1481
+ * `withCommit` cannot see what `save` wrote; the post-commit
1482
+ * `markPersisted` syncs `persistedVersion` to the CURRENT in-memory
1483
+ * version and (on `AggregateRoot`) re-baselines dirty tracking against
1484
+ * the CURRENT state. A mutation between `save` and the callback's return
1485
+ * therefore desyncs OCC (next save throws a false
1486
+ * `ConcurrencyConflictError`); and under a partial-write repository
1487
+ * using `changedKeys`, an un-bumped mutation is silently marked clean
1488
+ * and never written. The `aggregateVersion` stamp widens the blast
1489
+ * radius further: harvested events would publicly claim a version the
1490
+ * committed row does not carry, poisoning every consumer's ordering
1491
+ * and idempotency watermarks: a cross-service inconsistency, not just
1492
+ * a local one. Mutate first, save last.
1493
+ *
1909
1494
  * **Duplicate aggregates are deduped by reference.** If the returned
1910
- * `aggregates` array contains the same instance twice e.g. a use
1495
+ * `aggregates` array contains the same instance twice (e.g. a use
1911
1496
  * case touches an order via two repository references that happen to
1912
- * resolve to the same identity-map entry `withCommit` dedupes by
1497
+ * resolve to the same identity-map entry), `withCommit` dedupes by
1913
1498
  * JavaScript object identity before harvesting. Each event lands in
1914
1499
  * the outbox exactly once and `markPersisted` fires exactly once. Two
1915
1500
  * *different* instances with the same logical id cannot be detected
@@ -1925,7 +1510,7 @@ interface TransactionScope<TCtx> {
1925
1510
  * const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
1926
1511
  * const order = await orderRepository.getByIdOrFail(orderId);
1927
1512
  * order.confirm();
1928
- * await orderRepository.save(order); // pure persistence does NOT call markPersisted
1513
+ * await orderRepository.save(order); // pure persistence; does NOT call markPersisted
1929
1514
  * return { result: order.id, aggregates: [order] };
1930
1515
  * });
1931
1516
  * ```
@@ -1937,12 +1522,32 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
1937
1522
  /**
1938
1523
  * Observer for post-commit `bus.publish` failures. Called with the
1939
1524
  * error and the events that were published. Must not be relied on
1940
- * for delivery the outbox dispatcher is the reliable path.
1525
+ * for delivery: the outbox dispatcher is the reliable path.
1941
1526
  */
1942
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;
1943
1537
  }, fn: (ctx: TCtx) => Promise<{
1944
1538
  result: R;
1945
1539
  aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
1540
+ /**
1541
+ * Optional marker: which of `aggregates` were DELETED in this unit
1542
+ * of work. Their pending events are harvested like any other
1543
+ * (deletion events must reach the outbox), but the post-commit
1544
+ * lifecycle differs: `markPersisted` is NOT called on them. It
1545
+ * would fire the user-overridable `onPersisted` hook, whose
1546
+ * post-save semantics (cache fill, read-model warm-up) are a lie
1547
+ * for a row that was just deleted. Their pending events are
1548
+ * cleared directly instead, so a later commit cannot re-emit them.
1549
+ */
1550
+ deleted?: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
1946
1551
  }>): Promise<R>;
1947
1552
 
1948
1553
  /**
@@ -2020,6 +1625,398 @@ interface Query {
2020
1625
  */
2021
1626
  type QueryHandler<Q extends Query, R> = (query: Q) => Promise<R>;
2022
1627
 
1628
+ /**
1629
+ * A class reference used as the type key of the identity map. Keying
1630
+ * on the CLASS (not a name string) makes collisions impossible by
1631
+ * construction: `Restaurant` and `Booking` are different keys even if
1632
+ * someone names two aggregates identically across modules, and there
1633
+ * is no string-discipline to maintain.
1634
+ *
1635
+ * The `Function & { prototype: TAgg }` branch is load-bearing: the
1636
+ * kit's aggregate convention is a **protected constructor** plus
1637
+ * static factories, and TypeScript rejects assigning a class with a
1638
+ * protected constructor to a construct-signature type. The prototype
1639
+ * witness accepts those classes while still inferring `TAgg`.
1640
+ */
1641
+ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function & {
1642
+ prototype: TAgg;
1643
+ });
1644
+ /**
1645
+ * Per-unit-of-work Identity Map (Fowler, PoEAA): within one operation,
1646
+ * one aggregate type+id maps to exactly ONE in-memory instance.
1647
+ *
1648
+ * This is the shipped implementation of the contract the
1649
+ * [Repository guide](../../docs/guide/repository.md) places on
1650
+ * `IRepository` implementations: two `getById(id)` calls in the same
1651
+ * unit of work MUST return the same instance, because `withCommit`'s
1652
+ * aggregate dedupe (and therefore exactly-once event harvest and
1653
+ * `markPersisted`) is keyed on JavaScript object identity.
1654
+ *
1655
+ * Storage is two-level (per-type stores created lazily), so
1656
+ * `Restaurant:123` and `Booking:123` can never collide: the type key
1657
+ * is the aggregate CLASS, not the id alone and not a name string.
1658
+ *
1659
+ * Repository read-path contract:
1660
+ *
1661
+ * ```ts
1662
+ * async getById(id: OrderId): Promise<Order | null> {
1663
+ * const cached = this.session.identityMap.get(Order, id);
1664
+ * if (cached) return cached;
1665
+ * // Deleted in this unit of work = gone, even if the physical
1666
+ * // delete is deferred and the row is still visible in the tx.
1667
+ * if (this.session.identityMap.isDeleted(Order, id)) return null;
1668
+ *
1669
+ * const row = await this.loadRow(id);
1670
+ * if (!row) return null;
1671
+ * const order = Order.reconstitute(row.id, row.state, row.version);
1672
+ * this.session.identityMap.set(Order, id, order);
1673
+ * return order;
1674
+ * }
1675
+ * ```
1676
+ *
1677
+ * Deletion is final within an operation: {@link delete} removes the
1678
+ * entry AND records a tombstone, so a later {@link set} of the same
1679
+ * type+id throws `AggregateDeletedError`: a second instance of a
1680
+ * deleted aggregate can never sneak back into the unit of work, even
1681
+ * through a repository whose row delete is deferred.
1682
+ *
1683
+ * Lifetime is ONE unit of work: the `UnitOfWork` creates a fresh map
1684
+ * per `run()` and clears it on close. Never cache across operations;
1685
+ * that would silently bypass optimistic concurrency control.
1686
+ */
1687
+ declare class IdentityMap {
1688
+ private readonly _stores;
1689
+ private readonly _deleted;
1690
+ private readonly _pendingAtRegistration;
1691
+ /** The cached instance for type+id, or `undefined` (also after {@link delete}). */
1692
+ get<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): TAgg | undefined;
1693
+ /** Whether an instance is registered for type+id (false after {@link delete}). */
1694
+ has<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
1695
+ /**
1696
+ * Whether type+id was {@link delete}d in this unit of work. The
1697
+ * read path checks this BEFORE hydrating and returns `null`, so
1698
+ * "deleted in this operation" reads uniformly as not-found,
1699
+ * regardless of whether the repository's physical delete already
1700
+ * removed the row or is deferred within the transaction. Without
1701
+ * the check, a read-only probe of a deleted aggregate would crash
1702
+ * in {@link set} for deferred-write repositories and return `null`
1703
+ * for immediate-write ones.
1704
+ */
1705
+ isDeleted<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
1706
+ /**
1707
+ * Registers the hydrated instance for type+id.
1708
+ *
1709
+ * - Re-registering the SAME instance is a no-op (idempotent).
1710
+ * - Registering a DIFFERENT instance for an occupied type+id throws:
1711
+ * that is precisely the identity-map violation this class exists
1712
+ * to prevent (the repository hydrated twice instead of checking
1713
+ * {@link get} first), and letting it pass would double-harvest
1714
+ * events downstream.
1715
+ * - Registering a type+id that was {@link delete}d in this unit of
1716
+ * work throws `AggregateDeletedError`: deletion is final within
1717
+ * the operation.
1718
+ */
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[];
1729
+ /**
1730
+ * Removes the entry for type+id and records a tombstone: subsequent
1731
+ * {@link get} / {@link has} report absence, and a subsequent
1732
+ * {@link set} of the same type+id throws `AggregateDeletedError`.
1733
+ * Called by a repository's `delete(aggregate)` alongside
1734
+ * `session.enrollDeleted(aggregate)`.
1735
+ */
1736
+ delete<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): void;
1737
+ /** Empties all stores and tombstones. Called by the unit of work on close. */
1738
+ clear(): void;
1739
+ }
1740
+
1741
+ /**
1742
+ * Thrown when `UnitOfWork.run()` is called while the same instance is
1743
+ * already executing a unit of work: either a genuinely nested `run()`
1744
+ * inside the work callback, or two concurrent operations sharing one
1745
+ * instance.
1746
+ *
1747
+ * Both are contract violations, not recoverable infrastructure
1748
+ * failures, so this extends `BaseError` directly (same reasoning as
1749
+ * `MissingHandlerError`): a generic `catch (e instanceof
1750
+ * InfrastructureError)` handler must not mask it.
1751
+ *
1752
+ * A nested `run()` would NOT join the outer transaction; it would open
1753
+ * an independent one, silently breaking the all-or-nothing guarantee.
1754
+ * If two operations must commit together, they are ONE unit of work:
1755
+ * merge them into a single `run()` callback. For concurrent requests,
1756
+ * construct one `UnitOfWork` per operation (construction is trivially
1757
+ * cheap; the dependency object is the thing you share).
1758
+ */
1759
+ declare class NestedUnitOfWorkError extends BaseError<"NestedUnitOfWorkError"> {
1760
+ constructor();
1761
+ }
1762
+ /**
1763
+ * Thrown when the unit-of-work context is used after `run()` has
1764
+ * settled: reading `context.repositories` / `context.transaction`, or
1765
+ * calling `session.enrollSaved` / `session.enrollDeleted`, once the
1766
+ * transaction has committed or rolled back.
1767
+ *
1768
+ * Use-after-close is a programming bug (typically a leaked context
1769
+ * reference or a fire-and-forget promise outliving the callback), so
1770
+ * this extends `BaseError` directly and should crash loud.
1771
+ *
1772
+ * **Honest scope of this guard:** the kit can only invalidate what it
1773
+ * controls - the context getters and the session. A repository or raw
1774
+ * transaction handle captured into a variable BEFORE close keeps
1775
+ * working as far as the kit can see; whether the underlying tx handle
1776
+ * rejects is ORM-specific. Do not let references escape the callback.
1777
+ */
1778
+ declare class TransactionClosedError extends BaseError<"TransactionClosedError"> {
1779
+ readonly operation: string;
1780
+ constructor(operation: string);
1781
+ }
1782
+ /**
1783
+ * The unit of work failed AFTER the work callback completed
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`.
1788
+ *
1789
+ * `InfrastructureError`: the business logic ran to completion; the
1790
+ * persistence boundary failed. The transaction rolled back (or never
1791
+ * committed), no aggregate was marked persisted, and pending events
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.
1801
+ */
1802
+ declare class CommitError extends InfrastructureError<"CommitError"> {
1803
+ constructor(cause: unknown);
1804
+ }
1805
+ /**
1806
+ * The work callback threw AND the transaction scope rejected with a
1807
+ * DIFFERENT error that does not wrap the callback's error in its cause
1808
+ * chain - the strongest available signal that the rollback itself
1809
+ * failed. The callback's (primary) error is preserved as `cause`, so
1810
+ * cause-chain helpers (`someChainRetryable`, `findInCauseChain`) still
1811
+ * see a wrapped `ConcurrencyConflictError` & co.; the scope's error is
1812
+ * carried in {@link rollbackCause}.
1813
+ *
1814
+ * Scopes that rethrow the original error (Drizzle, Prisma do) never
1815
+ * produce this; scopes that WRAP the original are detected via the
1816
+ * cause chain and passed through unchanged instead.
1817
+ */
1818
+ declare class RollbackError extends InfrastructureError<"RollbackError"> {
1819
+ readonly rollbackCause: unknown;
1820
+ constructor(cause: unknown, rollbackCause: unknown);
1821
+ }
1822
+ /**
1823
+ * The enrollment handle a unit of work hands to its repositories.
1824
+ *
1825
+ * Repositories enroll every aggregate they write so the unit of work
1826
+ * can harvest pending events into the outbox (inside the transaction)
1827
+ * and call `markPersisted` after the commit - the same lifecycle
1828
+ * `withCommit` runs for its returned `aggregates` array, minus the
1829
+ * footgun: with enrollment, "forgot to list the aggregate" cannot
1830
+ * happen per call site; each repository implements it once and its
1831
+ * tests pin it once.
1832
+ *
1833
+ * Contract for repository implementations:
1834
+ * - `getById(id)` checks `identityMap.get` BEFORE hydrating, treats
1835
+ * `identityMap.isDeleted` as not-found (`null`), and registers the
1836
+ * hydrated instance after - two loads of the same aggregate in one
1837
+ * unit of work must return the same instance.
1838
+ * - `save(aggregate)` calls {@link enrollSaved} BEFORE the row write:
1839
+ * the deleted-gate then throws `AggregateDeletedError` before any SQL
1840
+ * runs (instead of the write surfacing as a confusing
1841
+ * `ConcurrencyConflictError` against the deleted row). Enrollment is
1842
+ * idempotent per instance, mirroring `withCommit`'s reference dedupe,
1843
+ * and a failed write rolls the whole unit of work back anyway.
1844
+ * - `delete(aggregate)` calls {@link enrollDeleted} - ONE call does all
1845
+ * the deletion bookkeeping: the identity-map entry is removed and
1846
+ * tombstoned automatically (keyed on the instance's concrete class),
1847
+ * the recorded deletion events are still harvested into the outbox,
1848
+ * and saving or re-registering the aggregate (same instance OR a
1849
+ * re-created one with the same type+id) later in this unit of work
1850
+ * throws `AggregateDeletedError`.
1851
+ *
1852
+ * The use case can also enroll manually via `context.session` for the
1853
+ * rare write that bypasses a repository.
1854
+ */
1855
+ interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
1856
+ /**
1857
+ * The per-operation Identity Map (Fowler): one aggregate type+id,
1858
+ * one in-memory instance. Created fresh per `run()`, cleared on
1859
+ * close; accessing it after close throws
1860
+ * {@link TransactionClosedError}.
1861
+ */
1862
+ readonly identityMap: IdentityMap;
1863
+ /** Enroll an aggregate that was (or will be) written in this unit of work. */
1864
+ enrollSaved(aggregate: IAggregateRoot<Id<string>, Evt>): void;
1865
+ /**
1866
+ * Enroll an aggregate whose row was (or will be) deleted in this
1867
+ * unit of work. Its pending events (e.g. a recorded deletion event)
1868
+ * are harvested like any other; re-saving the instance afterwards
1869
+ * throws `AggregateDeletedError`.
1870
+ */
1871
+ enrollDeleted(aggregate: IAggregateRoot<Id<string>, Evt>): void;
1872
+ }
1873
+ /**
1874
+ * What the work callback receives: repositories already bound to the
1875
+ * live transaction, the enrollment session, and, deliberately named to
1876
+ * look like the escape hatch it is, the raw transaction handle.
1877
+ *
1878
+ * All members throw {@link TransactionClosedError} once `run()` has
1879
+ * settled; do not let the context escape the callback.
1880
+ */
1881
+ interface UnitOfWorkContext<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> {
1882
+ readonly repositories: TRepos;
1883
+ /**
1884
+ * **Escape hatch: you are leaving the unit of work's guarantees.**
1885
+ * A write issued on the raw handle bypasses the repository contract,
1886
+ * enrollment (its aggregate's events are NOT harvested unless you
1887
+ * also call `session.enrollSaved`), and the identity map (a later
1888
+ * `getById` of the same aggregate hydrates a SECOND instance:
1889
+ * double harvest, double `markPersisted`). Use it only for writes no
1890
+ * repository covers, pair it with manual enrollment, and prefer
1891
+ * adding a repository method whenever one could exist.
1892
+ */
1893
+ readonly rawTransaction: TCtx;
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;
1914
+ }
1915
+ /**
1916
+ * Per-repository factory map: for each key of `TRepos`, a function
1917
+ * that constructs the repository bound to the live transaction handle
1918
+ * and the enrollment session. Called once per `run()`, so every
1919
+ * repository of one unit of work shares the same transaction.
1920
+ *
1921
+ * ```ts
1922
+ * const factories = {
1923
+ * orders: (tx, session) => new DrizzleOrderRepository(tx, session),
1924
+ * invoices: (tx, session) => new DrizzleInvoiceRepository(tx, session),
1925
+ * };
1926
+ * ```
1927
+ */
1928
+ type RepositoryFactories<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> = {
1929
+ [K in keyof TRepos]: (tx: TCtx, session: UnitOfWorkSession<Evt>) => TRepos[K];
1930
+ };
1931
+ /** Dependencies for {@link UnitOfWork}; the app-level singleton part. */
1932
+ interface UnitOfWorkDeps<Evt extends AnyDomainEvent, TCtx, TRepos> {
1933
+ scope: TransactionScope<TCtx>;
1934
+ outbox: Outbox<Evt>;
1935
+ bus?: EventBus<Evt>;
1936
+ /** See `withCommit`: observer for post-commit `bus.publish` failures. */
1937
+ onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
1938
+ repositories: RepositoryFactories<TCtx, TRepos, Evt>;
1939
+ }
1940
+ /**
1941
+ * Explicit-save Unit of Work: one `run()` call is one application-level
1942
+ * write operation. All repository writes inside the callback share one
1943
+ * transaction and either persist completely or not at all.
1944
+ *
1945
+ * Built ON TOP of `withCommit` - the commit orchestration (event
1946
+ * harvest into the outbox inside the transaction, `markPersisted`
1947
+ * after the commit, best-effort in-process publish last) is inherited,
1948
+ * not reimplemented. What this layer adds:
1949
+ *
1950
+ * - **Tx-bound repositories via a registry.** The callback receives
1951
+ * ready-made repositories instead of a raw transaction handle; the
1952
+ * factory map is wired once at construction.
1953
+ * - **Enrollment instead of a returned aggregates array.** Repositories
1954
+ * enroll what they write via {@link UnitOfWorkSession}; the use case
1955
+ * cannot forget to list an aggregate (the `withCommit` footgun).
1956
+ * - **Lifecycle errors.** {@link NestedUnitOfWorkError},
1957
+ * {@link TransactionClosedError}, {@link CommitError},
1958
+ * {@link RollbackError}, {@link AggregateDeletedError}.
1959
+ *
1960
+ * - **A per-operation Identity Map** on the session: repositories
1961
+ * check it before hydrating and register after, so one type+id maps
1962
+ * to one in-memory instance per unit of work (the contract
1963
+ * `withCommit`'s reference-dedupe relies on, now shipped instead of
1964
+ * merely documented).
1965
+ *
1966
+ * What it deliberately does NOT do (v1): no auto-flush (explicit
1967
+ * `save()` only - `hasChanges` makes a redundant save a cheap no-op),
1968
+ * no savepoints, no nested-transaction joining. `withCommit` with
1969
+ * hand-rolled repos remains fully supported; this facade is opt-in.
1970
+ *
1971
+ * **Instance discipline:** one instance owns one logical operation at
1972
+ * a time. `run()` while a run is active throws
1973
+ * {@link NestedUnitOfWorkError} - that covers genuine nesting AND two
1974
+ * concurrent requests sharing one instance, which is the same bug in
1975
+ * different clothes. Construct one `UnitOfWork` per operation
1976
+ * (construction stores one reference; the shareable singleton is the
1977
+ * deps object). Sequential reuse of an instance is fine.
1978
+ *
1979
+ * **Error pass-through:** an error thrown by the work callback (a
1980
+ * repository's `ConcurrencyConflictError`, a `DomainError`, anything)
1981
+ * is rethrown UNCHANGED - the unit of work never converts a concurrency
1982
+ * conflict into a generic error. Only the two failure modes the
1983
+ * callback cannot observe are wrapped: see {@link CommitError} and
1984
+ * {@link RollbackError}.
1985
+ *
1986
+ * @example
1987
+ * ```ts
1988
+ * const deps = {
1989
+ * scope: drizzleScope,
1990
+ * outbox: drizzleOutbox,
1991
+ * bus: eventBus,
1992
+ * repositories: {
1993
+ * restaurants: (tx, session) => new DrizzleRestaurantRepository(tx, session),
1994
+ * },
1995
+ * };
1996
+ *
1997
+ * const uow = new UnitOfWork(deps);
1998
+ * const result = await uow.run(async ({ repositories }) => {
1999
+ * const restaurant = await repositories.restaurants.getByIdOrFail(id);
2000
+ * restaurant.changeOpeningHours(openingHours);
2001
+ * await repositories.restaurants.save(restaurant); // save() enrolls
2002
+ * return restaurant.id;
2003
+ * });
2004
+ * ```
2005
+ */
2006
+ declare class UnitOfWork<Evt extends AnyDomainEvent, TCtx, TRepos extends Record<string, unknown>> {
2007
+ private readonly deps;
2008
+ private _active;
2009
+ constructor(deps: UnitOfWorkDeps<Evt, TCtx, TRepos>);
2010
+ /**
2011
+ * Execute one unit of work: open the transaction, hand the callback
2012
+ * tx-bound repositories, commit on resolve, roll back on throw,
2013
+ * run the post-commit lifecycle (markPersisted, publish) for every
2014
+ * enrolled aggregate. Returns the callback's result.
2015
+ */
2016
+ run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R>, options?: RunOptions): Promise<R>;
2017
+ private buildRepositories;
2018
+ }
2019
+
2023
2020
  /**
2024
2021
  * Type map for query types to their return types.
2025
2022
  * Used to improve type inference in QueryBus.
@@ -2089,7 +2086,7 @@ interface IQueryBus<TMap extends QueryTypeMap = QueryTypeMap> {
2089
2086
  *
2090
2087
  * When `TMap` is supplied, the `queryType` argument is restricted to its
2091
2088
  * keys and the handler signature is forced to match `TMap[K]` for the
2092
- * return value typos and wrong-typed handlers are compile errors.
2089
+ * return value: typos and wrong-typed handlers are compile errors.
2093
2090
  * Without `TMap` the registration is loose (any string key, any return
2094
2091
  * type) so the no-config path keeps working.
2095
2092
  *
@@ -2200,7 +2197,7 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
2200
2197
  * In-memory reference implementation of `Outbox<Evt>`.
2201
2198
  *
2202
2199
  * Intended for tests, single-process workers, and quick-start demos.
2203
- * Uses the event's own `eventId` as the dispatch id the common, clean
2200
+ * Uses the event's own `eventId` as the dispatch id: the common, clean
2204
2201
  * choice. Storage is a `Map<string, OutboxRecord<Evt>>` keyed by
2205
2202
  * `eventId`, so re-adding the same event is naturally idempotent (the
2206
2203
  * duplicate entry overwrites itself; `getPending` returns each event at
@@ -2209,7 +2206,13 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
2209
2206
  * For production, back the outbox with a transactional store so the
2210
2207
  * outbox row participates in the same transaction as the aggregate
2211
2208
  * write (see `TransactionScope` + `withCommit`). This class lives in
2212
- * memory only events are lost on process restart.
2209
+ * memory only: events are lost on process restart. Sharper still:
2210
+ * events `add()`ed inside a transaction that later rolls back are NOT
2211
+ * removed (the Map knows nothing about your scope's rollback). Tests
2212
+ * that assert rollback purity need an outbox that participates in the
2213
+ * test store's transactional semantics; see the reference adapter at
2214
+ * https://github.com/shi-rudo/ddd-kit-ts/blob/main/src/testing/repository-contract.test.ts
2215
+ * (repo-only, not shipped to npm).
2213
2216
  *
2214
2217
  * @example
2215
2218
  * ```ts
@@ -2234,13 +2237,33 @@ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt>
2234
2237
  markDispatched(dispatchIds: ReadonlyArray<string>): Promise<void>;
2235
2238
  }
2236
2239
 
2240
+ /**
2241
+ * The canonical shape of a unit-of-work-facing repository. Unlike
2242
+ * `IRepository` below (id-canonical CRUD for `withCommit`-style
2243
+ * setups), `delete` takes the AGGREGATE: the unit of work needs the
2244
+ * instance for deletion-event harvest, the identity-map tombstone, and
2245
+ * the deleted-cannot-be-resaved gate. Ids stay branded (`TId extends
2246
+ * Id<string>`) end-to-end.
2247
+ *
2248
+ * Implementing this interface is optional (the `UnitOfWork` registry
2249
+ * is structurally typed), but it is the single source of truth the
2250
+ * guide's examples and the repository contract test suite
2251
+ * (`@shirudo/ddd-kit/testing`, whose `ContractRepository` is the
2252
+ * minimal structural subset of this shape) are written against.
2253
+ */
2254
+ interface IUnitOfWorkRepository<TAgg extends IAggregateRoot<TId, Evt>, TId extends Id<string>, Evt extends AnyDomainEvent = AnyDomainEvent> {
2255
+ getById(id: TId): Promise<TAgg | null>;
2256
+ getByIdOrFail(id: TId): Promise<TAgg>;
2257
+ save(aggregate: TAgg): Promise<void>;
2258
+ delete(aggregate: TAgg): Promise<void>;
2259
+ }
2237
2260
  /**
2238
2261
  * Core repository contract for Aggregate Roots.
2239
2262
  *
2240
2263
  * In DDD a Repository is a "collection illusion" for aggregates: load by
2241
2264
  * identity, save the whole aggregate, delete by identity. Querying by
2242
2265
  * arbitrary criteria is a separate concern (CQRS read-side, ad-hoc bulk
2243
- * operations) and lives on the `IQueryableRepository` extension below so
2266
+ * operations) and lives on the `IQueryableRepository` extension below, so
2244
2267
  * write-side repositories don't have to implement query plumbing they
2245
2268
  * don't need.
2246
2269
  *
@@ -2271,7 +2294,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2271
2294
  exists(id: TId): Promise<boolean>;
2272
2295
  /**
2273
2296
  * Persists the aggregate (insert or update). Implementations are
2274
- * responsible for **persistence only** they must NOT touch the
2297
+ * responsible for **persistence only**; they must NOT touch the
2275
2298
  * aggregate's in-memory state:
2276
2299
  *
2277
2300
  * 1. Throw `ConcurrencyConflictError` from `@shirudo/ddd-kit` when the
@@ -2279,14 +2302,14 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2279
2302
  * stored (optimistic concurrency).
2280
2303
  * 2. Write the aggregate to durable storage.
2281
2304
  *
2282
- * **Insert vs update `persistedVersion` convention.** Every aggregate
2305
+ * **Insert vs update: the `persistedVersion` convention.** Every aggregate
2283
2306
  * exposes two version fields with distinct roles:
2284
2307
  *
2285
- * - `aggregate.version` in-memory post-mutation value, bumped by
2308
+ * - `aggregate.version`: in-memory post-mutation value, bumped by
2286
2309
  * `setState(_, true)` / `commit()` / `apply()`. NOT the right
2287
2310
  * routing key, because mutations can advance it past zero while
2288
2311
  * the DB row still does not exist.
2289
- * - `aggregate.persistedVersion` what the persistence layer holds.
2312
+ * - `aggregate.persistedVersion`: what the persistence layer holds.
2290
2313
  * `undefined` until the aggregate has been persisted or restored
2291
2314
  * at least once. This is the routing key.
2292
2315
  *
@@ -2296,14 +2319,14 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2296
2319
  * - otherwise → **UPDATE** with the OCC predicate
2297
2320
  * `WHERE id = ? AND version = aggregate.persistedVersion` (the
2298
2321
  * load-time / last-save baseline, not the post-mutation in-memory
2299
- * value). If the row count is `0`, another writer raced you
2322
+ * value). If the row count is `0`, another writer raced you:
2300
2323
  * throw `ConcurrencyConflictError`.
2301
2324
  *
2302
2325
  * Do **not** call `aggregate.markPersisted(...)` here. The library's
2303
2326
  * `withCommit` orchestrator handles the post-save lifecycle (harvest
2304
2327
  * pending events into the outbox, then mark persisted after commit).
2305
2328
  * Calling `markPersisted` inside `save` clears pending events too early
2306
- * and breaks the harvest path and is also why the Vernon/Axon/
2329
+ * and breaks the harvest path, and is also why the Vernon/Axon/
2307
2330
  * EventFlow pattern separates persistence from commit-events.
2308
2331
  *
2309
2332
  * If you are not using `withCommit` (custom orchestration), call
@@ -2312,7 +2335,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2312
2335
  */
2313
2336
  save(aggregate: TAgg): Promise<void>;
2314
2337
  /**
2315
- * Removes the aggregate's row by id. Pure persistence does NOT
2338
+ * Removes the aggregate's row by id. Pure persistence: does NOT
2316
2339
  * harvest pending events from the aggregate (the contract takes
2317
2340
  * only the id, so there is no aggregate to harvest from).
2318
2341
  *
@@ -2320,7 +2343,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2320
2343
  * is the right domain verb. Most are actually state transitions
2321
2344
  * (*cancel*, *archive*, *close*, *deactivate*, *terminate*) with
2322
2345
  * proper domain names that should be modelled as state changes plus
2323
- * a recorded event not as row removal.
2346
+ * a recorded event, not as row removal.
2324
2347
  *
2325
2348
  * `delete(id)` belongs in the toolkit for three distinct cases, in
2326
2349
  * decreasing order of common occurrence (see
@@ -2346,7 +2369,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2346
2369
  * subscriber cares. If the entity has identity in the ubiquitous
2347
2370
  * language, you probably want path 1 or 2 instead.
2348
2371
  *
2349
- * In pure event-sourced systems `delete` is rarely meaningful
2372
+ * In pure event-sourced systems `delete` is rarely meaningful:
2350
2373
  * end-of-lifecycle there is a `Closed` / `Terminated` event in the
2351
2374
  * stream, and identity persists in the event log. `delete` applies
2352
2375
  * primarily to state-stored aggregates and snapshot / projection
@@ -2360,7 +2383,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2360
2383
  * Prisma `WhereInput`, a MongoDB filter document, a plain
2361
2384
  * `(t: TAgg) => boolean` predicate for in-memory repos, or anything else.
2362
2385
  *
2363
- * The library does not prescribe a Specification or query DSL the
2386
+ * The library does not prescribe a Specification or query DSL: the
2364
2387
  * Repository implementation owns its query language. This avoids the
2365
2388
  * phantom-interface trap of a library-level `ISpecification<T>` with no
2366
2389
  * methods and lets each Repository expose the strongest possible types for
@@ -2396,19 +2419,107 @@ interface IQueryableRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<
2396
2419
  */
2397
2420
  findOne(filter: TFilter): Promise<TAgg | null>;
2398
2421
  /**
2399
- * Returns **every** aggregate matching the filter no pagination,
2422
+ * Returns **every** aggregate matching the filter: no pagination,
2400
2423
  * no cursor. For unbounded result sets, prefer a read-side projection
2401
2424
  * (CQRS read model) over loading aggregates in bulk; aggregates are
2402
2425
  * write-side objects and rehydrating thousands of them by id is rarely
2403
2426
  * what you want. If you need pagination on the write side, declare a
2404
2427
  * domain-specific paged method on your concrete repository (e.g.
2405
- * `findPage(filter, cursor)`) the library does not prescribe a
2428
+ * `findPage(filter, cursor)`): the library does not prescribe a
2406
2429
  * pagination contract because cursor/offset/keyset semantics vary too
2407
2430
  * much across storage backends.
2408
2431
  */
2409
2432
  find(filter: TFilter): Promise<TAgg[]>;
2410
2433
  }
2411
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
+
2412
2523
  type VO<T> = Readonly<T>;
2413
2524
  /**
2414
2525
  * Deep freezes an object and all its nested properties recursively, then
@@ -2416,31 +2527,31 @@ type VO<T> = Readonly<T>;
2416
2527
  * so the freeze symmetry matches `deepEqual` (which also considers symbol
2417
2528
  * keys). Handles circular references by tracking visited objects.
2418
2529
  *
2419
- * Note: `deepFreeze` mutates its argument in place it sets `[[Frozen]]`
2530
+ * Note: `deepFreeze` mutates its argument in place; it sets `[[Frozen]]`
2420
2531
  * on the object you pass in. Callers that need to avoid touching the
2421
2532
  * input (e.g. `vo()`) should deep-clone first.
2422
2533
  *
2423
2534
  * Date/Map/Set keep internal-slot mutability under `Object.freeze`
2424
2535
  * (`setTime`, `set`, `add`, … still work on frozen instances), so their
2425
2536
  * mutator methods are shadowed with throwing own properties and Map/Set
2426
- * contents are frozen recursively. The shadows are non-enumerable
2537
+ * contents are frozen recursively. The shadows are non-enumerable:
2427
2538
  * invisible to `Object.keys`, spread, `deepEqual`, and `structuredClone`.
2428
2539
  *
2429
2540
  * The shadowing is deny-by-enumeration: only the mutators known at
2430
2541
  * release time are blocked. If the runtime grows a NEW mutator (e.g. the
2431
2542
  * stage-3 `Map.prototype.getOrInsert` upsert proposal), it is not blocked
2432
- * until the list is updated treat the mutator blocking as a guard rail,
2543
+ * until the list is updated. Treat the mutator blocking as a guard rail,
2433
2544
  * not a security boundary.
2434
2545
  *
2435
2546
  * Limitation: ArrayBuffer views (TypedArrays, DataView) are passed through
2436
- * unfrozen the spec forbids freezing a view with elements, and freezing
2437
- * cannot protect the underlying buffer. Their contents remain mutable.
2547
+ * unfrozen, because the spec forbids freezing a view with elements, and
2548
+ * freezing cannot protect the underlying buffer. Their contents remain mutable.
2438
2549
  */
2439
2550
  declare function deepFreeze<T>(obj: T, visited?: WeakSet<object>): Readonly<T>;
2440
2551
  /**
2441
2552
  * Creates a deeply immutable value object from the given data.
2442
2553
  *
2443
- * The input is first deep-cloned, then the clone is frozen so calling
2554
+ * The input is first deep-cloned, then the clone is frozen, so calling
2444
2555
  * `vo(input)` never freezes the caller's own object graph as a
2445
2556
  * side-effect. Mutating the input afterwards does not bleed into the VO.
2446
2557
  * Symbol-keyed properties are preserved (matching `voEquals`); function
@@ -2531,7 +2642,7 @@ declare function voEqualsExcept<T>(a: VO<T>, b: VO<T>, options: DeepEqualExceptO
2531
2642
  *
2532
2643
  * Note: the Result covers VALIDATION failures only. Non-data values in
2533
2644
  * the input (functions, Promise/WeakMap/WeakSet) still throw a
2534
- * `TypeError` from `vo()` they cannot occur in parsed JSON and signal
2645
+ * `TypeError` from `vo()`; they cannot occur in parsed JSON and signal
2535
2646
  * a programming error, not a validation failure.
2536
2647
  *
2537
2648
  * @param t - The data to convert into a value object
@@ -2599,7 +2710,7 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2599
2710
  /**
2600
2711
  * Creates a new ValueObject.
2601
2712
  * The properties are deep-cloned (prototype-preserving) and then deeply
2602
- * frozen the caller's own object graph is never frozen or mutated,
2713
+ * frozen, so the caller's own object graph is never frozen or mutated,
2603
2714
  * and later mutation of the input does not bleed into the value object.
2604
2715
  *
2605
2716
  * @param props - The properties of the value object
@@ -2659,7 +2770,7 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2659
2770
  * was recorded the input is frozen into a `VO<T>` and returned as `Ok`;
2660
2771
  * otherwise the populated `ValidationError` is returned as `Err`.
2661
2772
  *
2662
- * `ValidationError` comes from `@shirudo/base-error` import it from there to
2773
+ * `ValidationError` comes from `@shirudo/base-error`; import it from there to
2663
2774
  * narrow the `Err` branch, exactly as `Result` is imported from
2664
2775
  * `@shirudo/result`. It serializes to RFC 9457 Problem Details; use
2665
2776
  * {@link validationProblemDetails} at the HTTP boundary to surface the issues.
@@ -2681,4 +2792,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2681
2792
  */
2682
2793
  declare function voValidated<T>(t: T, validate: (issues: ValidationError, value: T) => void, message?: string): Result<VO<T>, ValidationError>;
2683
2794
 
2684
- export { type AggregateConfig, AggregateNotFoundError, AggregateRoot, type AggregateSnapshot, type AnyDomainEvent, type ClockFactory, type Command, CommandBus, type CommandHandler, ConcurrencyConflictError, type CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, type DomainEvent, Entity, type EventBus, EventBusImpl, type EventHandler, type EventIdFactory, type EventMetadata, EventSourcedAggregate, type IAggregateRoot, type ICommandBus, type IEntity, type IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IValueObject, type Id, type IdGenerator, type Identifiable, InMemoryOutbox, InfrastructureError, MissingHandlerError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type TransactionScope, type VO, ValueObject, type Version, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withClockFactory, withCommit, withEventIdFactory };
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 };