@shirudo/ddd-kit 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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-DclYgG_D.js';
2
+ export { p as AggregateDeletedError, q as AggregateNotFoundError, f as ClockFactory, u as ConcurrencyConflictError, k as DomainEvent, t as DuplicateAggregateError, E as EventIdFactory, j as EventMetadata, v as IdGenerator, M as MissingHandlerError, 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-DclYgG_D.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
@@ -709,6 +147,14 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
709
147
  * Subclasses can mutate this directly or use helper methods.
710
148
  */
711
149
  protected _state: TState;
150
+ /**
151
+ * **State ownership.** Plain-object and array states are shallow-copied
152
+ * before the freeze, so the caller's own object stays mutable. A CLASS
153
+ * INSTANCE passed as state is an ownership transfer: it is frozen
154
+ * in place (a copy would strip its prototype). Do not keep mutating
155
+ * the instance after handing it to the entity. The same contract
156
+ * applies to {@link setState}.
157
+ */
712
158
  protected constructor(id: TId, initialState: TState);
713
159
  /**
714
160
  * Optional validation hook to ensure state invariants. Called during
@@ -718,7 +164,7 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
718
164
  * **⚠️ Must not read subclass instance fields via `this`.** The
719
165
  * constructor calls `validateState(initialState)` BEFORE the subclass's
720
166
  * field initializers run, so `this.someField` is `undefined` at that
721
- * point a classic TypeScript/JavaScript constructor-ordering footgun.
167
+ * point, a classic TypeScript/JavaScript constructor-ordering footgun.
722
168
  * The `state` argument is the single source of truth; treat the method
723
169
  * as pure with respect to `this`.
724
170
  *
@@ -737,6 +183,10 @@ declare abstract class Entity<TState, TId extends Id<string>> implements IEntity
737
183
  * This is a convenience method for state mutations.
738
184
  * Automatically validates the newState using `validateState()`.
739
185
  *
186
+ * Plain-object and array states are shallow-copied before the freeze
187
+ * (the caller's object stays mutable); a class-instance state is an
188
+ * ownership transfer and is frozen in place; see the constructor.
189
+ *
740
190
  * @param newState - The new state
741
191
  */
742
192
  protected setState(newState: TState): void;
@@ -905,7 +355,7 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
905
355
  * `recordEvent` helper that auto-injects `aggregateId` +
906
356
  * `aggregateType` on every event the aggregate emits.
907
357
  *
908
- * Consumers do NOT extend this class directly extend
358
+ * Consumers do NOT extend this class directly; extend
909
359
  * `AggregateRoot` for state-stored aggregates or
910
360
  * `EventSourcedAggregate` for event-sourced ones. The split between
911
361
  * those two reflects the canonical Vernon §8 (state-stored) /
@@ -917,8 +367,12 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
917
367
  * @template TEvent - The domain-event union. Defaults to `never` so
918
368
  * aggregates without a declared event type cannot emit events
919
369
  * (emitting any event becomes a compile error).
370
+ * @template TSnapshotState - The plain-data shape stored in snapshots.
371
+ * Defaults to `TState` for plain-data states. Aggregates whose state
372
+ * carries class-based child entities declare a plain DTO shape here
373
+ * and override {@link toSnapshotState} / {@link fromSnapshotState}.
920
374
  */
921
- declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
375
+ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never, TSnapshotState = TState> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
922
376
  /**
923
377
  * The aggregate's domain type as a string, used to populate
924
378
  * `aggregateType` on events recorded via {@link recordEvent}.
@@ -933,7 +387,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
933
387
  *
934
388
  * The string is *the* identifier downstream consumers (outbox
935
389
  * dispatchers, projection handlers, audit logs) use to route by
936
- * aggregate kind. Use the same canonical name across your system
390
+ * aggregate kind. Use the same canonical name across your system;
937
391
  * matching the class name is the obvious choice, but the value
938
392
  * comes from this explicit declaration, not `constructor.name`
939
393
  * (which is fragile under minification, bundler transforms, and
@@ -949,7 +403,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
949
403
  *
950
404
  * Distinct from {@link version}, which is the in-memory
951
405
  * post-mutation value. Mutations bump `_version` but never touch
952
- * `_persistedVersion` that field only moves on {@link markRestored}
406
+ * `_persistedVersion`; that field only moves on {@link markRestored}
953
407
  * (Post-Load) and {@link markPersisted} (Post-Save).
954
408
  */
955
409
  private _persistedVersion;
@@ -963,7 +417,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
963
417
  get pendingEvents(): ReadonlyArray<TEvent>;
964
418
  /**
965
419
  * Clears the pending-event list. Called by `markPersisted` after a
966
- * successful write the events have been handed off to the outbox
420
+ * successful write: the events have been handed off to the outbox
967
421
  * / event store and are no longer the aggregate's responsibility.
968
422
  */
969
423
  clearPendingEvents(): void;
@@ -975,16 +429,26 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
975
429
  */
976
430
  protected bumpVersion(): void;
977
431
  /**
978
- * **Lifecycle marker Post-Load.** Syncs both `_version` and
432
+ * **Lifecycle marker, Post-Load.** Syncs both `_version` and
979
433
  * `_persistedVersion` to the DB-stored version. Used by
980
434
  * `reconstitute(...)` factories to assemble an in-memory aggregate
981
435
  * from a persisted row.
982
436
  *
983
- * Does NOT fire {@link onPersisted} that hook has post-save
437
+ * Does NOT fire {@link onPersisted}; that hook has post-save
984
438
  * semantics (metrics, audit, cache eviction), not post-load. The
985
439
  * Factory-vs-Reconstitution distinction (Vernon §11) is honoured
986
440
  * structurally: two separate markers, one for each transition.
987
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
+ *
988
452
  * @param version - The version the row currently holds in the DB
989
453
  *
990
454
  * @example
@@ -998,14 +462,14 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
998
462
  */
999
463
  protected markRestored(version: Version): void;
1000
464
  /**
1001
- * **Framework lifecycle method `@sealed`.** Called by `withCommit`
465
+ * **Framework lifecycle method (`@sealed`).** Called by `withCommit`
1002
466
  * (or by your own orchestration code, after harvesting `pendingEvents`)
1003
467
  * to push the persisted version back into the in-memory aggregate and
1004
468
  * clear `pendingEvents`. TypeScript has no `final` keyword, but
1005
469
  * subclasses **should not** override this method directly.
1006
470
  *
1007
471
  * Overriding without calling `super.markPersisted(version)` silently
1008
- * leaks `pendingEvents` the next `withCommit` will re-dispatch them
472
+ * leaks `pendingEvents`: the next `withCommit` will re-dispatch them
1009
473
  * through the outbox, double-emitting events. This bug has been hit
1010
474
  * in production by consumers; the {@link onPersisted} hook below is
1011
475
  * the safer extension point.
@@ -1015,23 +479,29 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1015
479
  * runs, then add your logic afterwards.
1016
480
  *
1017
481
  * @param version - The version assigned by the persistence layer
1018
- * @see onPersisted the safe extension point for subclasses
482
+ * @see onPersisted, the safe extension point for subclasses
1019
483
  */
1020
484
  markPersisted(version: Version): void;
1021
485
  /**
1022
- * Subclass extension point fires AFTER {@link markPersisted} has
486
+ * Subclass extension point: fires AFTER {@link markPersisted} has
1023
487
  * updated the version and cleared `pendingEvents`. Override this for
1024
488
  * post-persist logging, metrics, or cache-eviction without risk of
1025
489
  * breaking the framework's pendingEvents cleanup.
1026
490
  *
1027
491
  * The default implementation is a no-op. Subclasses do NOT need to
1028
- * call `super.onPersisted(version)` there is nothing in the parent
492
+ * call `super.onPersisted(version)`: there is nothing in the parent
1029
493
  * implementation to preserve.
1030
494
  *
495
+ * **Observer contract: errors are swallowed.** `withCommit` invokes
496
+ * `markPersisted` after the transaction has committed; a throwing hook
497
+ * must neither abort the loop for peer aggregates nor make the
498
+ * committed write look failed, so `withCommit` catches and discards
499
+ * hook errors. Handle failures inside the hook if you need them.
500
+ *
1031
501
  * **`onPersisted` deliberately receives only the version, not the
1032
502
  * drained events.** Event-driven post-persist logic (aggregate-level
1033
503
  * audit logging, per-event-type side effects) belongs in `EventBus`
1034
- * subscribers or the outbox dispatcher that is the proper
504
+ * subscribers or the outbox dispatcher; that is the proper
1035
505
  * Aggregate-Boundary separation. Building event-aware logic into
1036
506
  * `onPersisted` couples aggregate lifecycle to event processing and
1037
507
  * recreates the boundary problems Vernon's aggregate discipline is
@@ -1040,7 +510,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1040
510
  * **The hook must return synchronously.** `markPersisted` is `void`-
1041
511
  * typed and calls `onPersisted` without `await`. TypeScript's
1042
512
  * permissive `void` will accept an `async`-override returning
1043
- * `Promise<void>`, but the returned promise is fire-and-forget
513
+ * `Promise<void>`, but the returned promise is fire-and-forget:
1044
514
  * any rejection becomes an unhandled rejection and `withCommit`
1045
515
  * proceeds without waiting. For asynchronous work, subscribe to the
1046
516
  * relevant domain event on the `EventBus` instead; that is the
@@ -1052,7 +522,7 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1052
522
  /**
1053
523
  * Appends a domain event to the pending list. Prefer the higher-level
1054
524
  * `AggregateRoot.commit()` (state-stored) or `EventSourcedAggregate.apply()`
1055
- * (event-sourced) call sites both wrap `addDomainEvent` in the
525
+ * (event-sourced) call sites, both of which wrap `addDomainEvent` in the
1056
526
  * canonical record-AFTER-mutation order (Vernon §8). Calling
1057
527
  * `addDomainEvent` directly is appropriate only when state and event
1058
528
  * recording have already been decoupled deliberately (e.g. a
@@ -1060,19 +530,46 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1060
530
  */
1061
531
  protected addDomainEvent(event: TEvent): void;
1062
532
  /**
1063
- * Creates a snapshot of the current aggregate state the state at
533
+ * Creates a snapshot of the current aggregate state: the state at
1064
534
  * this moment plus the version. Useful for ES snapshot policies and
1065
535
  * for state-stored backup / restore.
536
+ *
537
+ * The state is converted via {@link toSnapshotState}; the default
538
+ * requires plain, serialisable data and fails fast otherwise.
539
+ */
540
+ createSnapshot(): AggregateSnapshot<TSnapshotState>;
541
+ /**
542
+ * Converts live aggregate state into the plain-data shape stored in a
543
+ * snapshot. The default validates that the state graph is plain,
544
+ * serialisable data (no class instances, functions, Promise/WeakMap/
545
+ * WeakSet) and then `structuredClone`s it: class instances would
546
+ * silently lose their prototype here AND on every snapshot-store
547
+ * round-trip, so the default fails fast with the offending path
548
+ * instead of producing a snapshot that breaks on first method call
549
+ * after restore.
550
+ *
551
+ * Override this together with {@link fromSnapshotState} (and the
552
+ * `TSnapshotState` generic) when the state carries class-based child
553
+ * entities. The override owns isolation: return fresh objects, not
554
+ * references into live state.
555
+ */
556
+ protected toSnapshotState(state: TState): TSnapshotState;
557
+ /**
558
+ * Converts the plain-data snapshot shape back into live aggregate
559
+ * state. The default `structuredClone`s the stored state so the
560
+ * restored aggregate never aliases the snapshot object. Override
561
+ * together with {@link toSnapshotState} to reconstruct class-based
562
+ * child entities.
1066
563
  */
1067
- createSnapshot(): AggregateSnapshot<TState>;
564
+ protected fromSnapshotState(stored: TSnapshotState): TState;
1068
565
  /**
1069
566
  * Sugar for `createDomainEvent` that auto-injects `aggregateId`
1070
567
  * (from `this.id`) and `aggregateType` (from {@link aggregateType})
1071
568
  * into the event's metadata fields. This is the canonical path for
1072
569
  * recording events from inside aggregate domain methods.
1073
570
  *
1074
- * Downstream consumers outbox dispatchers, projection handlers,
1075
- * audit logs route by these two fields. Calling
571
+ * Downstream consumers (outbox dispatchers, projection handlers,
572
+ * audit logs) route by these two fields. Calling
1076
573
  * `createDomainEvent(...)` directly inside an aggregate method
1077
574
  * leaves them unset and is caught at the `withCommit` harvest
1078
575
  * boundary, but `this.recordEvent(...)` makes the right thing
@@ -1096,8 +593,8 @@ declare abstract class BaseAggregate<TState, TId extends Id<string>, TEvent exte
1096
593
  * @param payload - payload for that event subtype
1097
594
  * @param options - any remaining `createDomainEvent` options
1098
595
  * (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
1099
- * and `aggregateType` are deliberately omitted the helper sets
1100
- * them.
596
+ * and `aggregateType` are deliberately omitted, because the helper
597
+ * sets them.
1101
598
  */
1102
599
  protected recordEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateDomainEventOptions, "aggregateId" | "aggregateType">): E;
1103
600
  }
@@ -1110,7 +607,7 @@ interface AggregateConfig {
1110
607
  * Whether `setState()` should bump the version automatically when the
1111
608
  * caller omits the per-call `bumpVersion` argument.
1112
609
  *
1113
- * Defaults to **`false`** `setState()` already takes an explicit
610
+ * Defaults to **`false`**: `setState()` already takes an explicit
1114
611
  * `bumpVersion` argument per call, so the config is just the default
1115
612
  * the per-call argument falls back to. Set to `true` only if you have
1116
613
  * a subclass that never passes `bumpVersion` and you want every state
@@ -1121,8 +618,8 @@ interface AggregateConfig {
1121
618
  /**
1122
619
  * Base class for Aggregate Roots without Event Sourcing.
1123
620
  *
1124
- * In DDD (Evans), an Aggregate is a cluster of objects root entity, child entities,
1125
- * 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
1126
623
  * root entity that represents the aggregate externally and is the only entry point
1127
624
  * for external code. This class serves as both: it IS the root entity and it contains
1128
625
  * the aggregate state (`TState`) which holds child entities and value objects.
@@ -1141,7 +638,7 @@ interface AggregateConfig {
1141
638
  *
1142
639
  * @template TState - The type of the aggregate state (contains child entities and value objects)
1143
640
  * @template TId - The type of the aggregate root identifier
1144
- * @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.
1145
642
  *
1146
643
  * @example
1147
644
  * ```typescript
@@ -1162,9 +659,108 @@ interface AggregateConfig {
1162
659
  * }
1163
660
  * ```
1164
661
  */
1165
- declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends BaseAggregate<TState, TId, TEvent> {
662
+ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never, TSnapshotState = TState> extends BaseAggregate<TState, TId, TEvent, TSnapshotState> {
1166
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;
1167
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;
1168
764
  /**
1169
765
  * Mutates state and records the resulting domain events in the
1170
766
  * **canonical record-after-mutation order**. Use this instead of calling
@@ -1172,7 +768,7 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
1172
768
  * "event for a fact that never happened" footgun.
1173
769
  *
1174
770
  * Order of operations:
1175
- * 1. `setState(newState, true)` runs `validateState` first.
771
+ * 1. `setState(newState, true)`: runs `validateState` first.
1176
772
  * If it throws, the method propagates and **no event is recorded
1177
773
  * and no version is bumped**.
1178
774
  * 2. Each event in `events` is appended via `addDomainEvent`.
@@ -1219,13 +815,13 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent exte
1219
815
  */
1220
816
  protected setState(newState: TState, bumpVersion?: boolean): void;
1221
817
  /**
1222
- * Restores the aggregate from a snapshot loads state and aligns
818
+ * Restores the aggregate from a snapshot: loads state and aligns
1223
819
  * `version` + `persistedVersion` to the snapshot version. Validates
1224
820
  * the restored state.
1225
821
  *
1226
822
  * @param snapshot - The snapshot to restore from
1227
823
  */
1228
- restoreFromSnapshot(snapshot: AggregateSnapshot<TState>): void;
824
+ restoreFromSnapshot(snapshot: AggregateSnapshot<TSnapshotState>): void;
1229
825
  }
1230
826
 
1231
827
  type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEvent) => TState;
@@ -1234,19 +830,19 @@ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEv
1234
830
  *
1235
831
  * Like `AggregateRoot`, this is both the root entity and the aggregate
1236
832
  * boundary. The difference is persistence: state is derived from events,
1237
- * 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
1238
834
  * changes go through `apply()` → handler.
1239
835
  *
1240
836
  * Extends `BaseAggregate` (the shared lifecycle machinery) but does NOT
1241
837
  * expose `setState()` or `commit()` from `AggregateRoot`. This enforces
1242
- * 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
1243
839
  * mutate state without going through an event handler.
1244
840
  *
1245
841
  * `apply()` and `validateEvent()` throw `DomainError`-derived exceptions
1246
842
  * on invariant violations. Subclasses override `validateEvent()` to
1247
843
  * throw their own concrete subclasses (e.g. `OrderAlreadyConfirmedError`).
1248
844
  * Only the infrastructure-boundary methods (`loadFromHistory`,
1249
- * `restoreFromSnapshotWithEvents`) return `Result` they catch
845
+ * `restoreFromSnapshotWithEvents`) return `Result`: they catch
1250
846
  * `DomainError` during replay so callers can react to corrupted event
1251
847
  * streams without try/catch.
1252
848
  *
@@ -1282,7 +878,7 @@ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEv
1282
878
  * }
1283
879
  * ```
1284
880
  */
1285
- declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string>> extends BaseAggregate<TState, TId, TEvent> implements IEventSourcedAggregate<TId, TEvent> {
881
+ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string>, TSnapshotState = TState> extends BaseAggregate<TState, TId, TEvent, TSnapshotState> implements IEventSourcedAggregate<TId, TEvent> {
1286
882
  /**
1287
883
  * Validates an event before it is applied. Default is no-op.
1288
884
  * Subclasses override to throw a concrete `DomainError` subclass when
@@ -1296,13 +892,13 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1296
892
  * Throws `DomainError` (or a subclass) on validation failure.
1297
893
  * Throws `MissingHandlerError` if no handler is registered for `event.type`.
1298
894
  *
1299
- * 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
1300
896
  * a local and only assigned to `_state` once all checks pass.
1301
897
  *
1302
898
  * The method is generic in the event tag `K`, so concrete callers
1303
899
  * (`this.apply(orderCreated)`) narrow to the literal tag and the
1304
- * dispatched handler is typed as `Handler<TState, Extract<TEvent, { type: K }>>`
1305
- * 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.
1306
902
  *
1307
903
  * @param event - The domain event to apply
1308
904
  * @param isNew - Whether the event is new (needs persisting) or replayed from history
@@ -1320,10 +916,16 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1320
916
  private dispatchAndCommit;
1321
917
  /**
1322
918
  * Reconstitutes the aggregate from an event history. Catches `DomainError`
1323
- * 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
1324
920
  * infrastructure boundary, where event-stream corruption is an expected
1325
921
  * recoverable failure. Unexpected (non-DomainError) throws propagate.
1326
922
  *
923
+ * All-or-nothing: if any event mid-stream throws, the aggregate's state
924
+ * is rolled back to its pre-call value, the same contract as
925
+ * `restoreFromSnapshotWithEvents`. Partial replay is never observable.
926
+ * (Version needs no rollback: replay dispatches with `isNew = false`,
927
+ * which never bumps it; only the final `markRestored` advances it.)
928
+ *
1327
929
  * Version advances additively: the aggregate's pre-existing version plus
1328
930
  * `history.length`. A fresh aggregate (v=0) loading 3 events ends at v=3;
1329
931
  * an aggregate already at v=1 (e.g. after a creation event) loading
@@ -1340,7 +942,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1340
942
  * aggregate is rolled back to its pre-call state + version. Partial
1341
943
  * restoration is never observable to the caller.
1342
944
  */
1343
- restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TState>, eventsAfterSnapshot: ReadonlyArray<TEvent>): Result<void, DomainError>;
945
+ restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TSnapshotState>, eventsAfterSnapshot: ReadonlyArray<TEvent>): Result<void, DomainError>;
1344
946
  /**
1345
947
  * A map of event types to their corresponding handlers.
1346
948
  * Subclasses MUST implement this property.
@@ -1498,7 +1100,7 @@ interface ICommandBus<TMap extends CommandTypeMap = CommandTypeMap> {
1498
1100
  *
1499
1101
  * When `TMap` is supplied, the `commandType` argument is restricted to
1500
1102
  * its keys and the handler signature is forced to match `TMap[K]` for the
1501
- * return value typos and wrong-typed handlers are compile errors.
1103
+ * return value: typos and wrong-typed handlers are compile errors.
1502
1104
  * Without `TMap` the registration is loose (any string key, any return
1503
1105
  * type) so the no-config path keeps working.
1504
1106
  *
@@ -1599,13 +1201,13 @@ interface EventBus<Evt extends AnyDomainEvent> {
1599
1201
  * awaits all of its handlers, then dispatches `b`, and so on. The
1600
1202
  * library never reorders or parallelises across events.
1601
1203
  * 2. **Handlers within a single event run in parallel.** All handlers
1602
- * subscribed to `event.type` are awaited via `Promise.allSettled` —
1204
+ * subscribed to `event.type` are awaited via `Promise.allSettled`:
1603
1205
  * none of them sees the others' errors and none is skipped if a
1604
1206
  * peer fails.
1605
1207
  * 3. **Errors are collected and thrown AFTER everything dispatches.**
1606
1208
  * If one handler throws, remaining handlers for that event still
1607
1209
  * run, and remaining events in the batch still publish. Once
1608
- * `publish` reaches the end of the batch it throws the single
1210
+ * `publish` reaches the end of the batch it throws: the single
1609
1211
  * error directly if there was one, or an `AggregateError`
1610
1212
  * ("Multiple event handlers failed") containing every captured
1611
1213
  * error otherwise. Callers that need fail-fast semantics should
@@ -1678,7 +1280,7 @@ interface OnceOptions {
1678
1280
  /**
1679
1281
  * One pending event in the outbox plus the opaque id the implementation
1680
1282
  * needs to ack it via `markDispatched`. The library does not prescribe
1681
- * what `dispatchId` looks like an implementation can reuse the event's
1283
+ * what `dispatchId` looks like: an implementation can reuse the event's
1682
1284
  * own `eventId`, generate its own UUID, use the row's auto-increment
1683
1285
  * primary key, or whatever the storage layer prefers.
1684
1286
  */
@@ -1687,7 +1289,7 @@ interface OutboxRecord<Evt extends AnyDomainEvent> {
1687
1289
  event: Evt;
1688
1290
  }
1689
1291
  /**
1690
- * Transactional outbox port the bridge between the write-side
1292
+ * Transactional outbox port: the bridge between the write-side
1691
1293
  * transaction and the (out-of-band) event dispatcher.
1692
1294
  *
1693
1295
  * Lifecycle:
@@ -1698,7 +1300,7 @@ interface OutboxRecord<Evt extends AnyDomainEvent> {
1698
1300
  * 3. After successful dispatch, the dispatcher calls `markDispatched()`
1699
1301
  * with the records' `dispatchId`s so they don't come back next poll.
1700
1302
  *
1701
- * `markDispatched` is required to be idempotent calling it with an id
1303
+ * `markDispatched` is required to be idempotent: calling it with an id
1702
1304
  * that's already marked is a no-op, not an error. This lets the
1703
1305
  * dispatcher safely retry on partial-failure.
1704
1306
  */
@@ -1737,20 +1339,24 @@ interface Outbox<Evt extends AnyDomainEvent> {
1737
1339
  * `$transaction`, etc.). The block commits when the callback resolves
1738
1340
  * and rolls back if it throws.
1739
1341
  *
1740
- * `TCtx` is the persistence layer's transaction handle Drizzle's `tx`,
1342
+ * `TCtx` is the persistence layer's transaction handle: Drizzle's `tx`,
1741
1343
  * Prisma's `tx`, Mongo's session, etc. The scope opens the transaction
1742
1344
  * and passes the handle to `fn`; the use case binds its repositories to
1743
1345
  * that handle (typically by constructing a tx-scoped repo from the ctx).
1744
1346
  *
1745
1347
  * No default for `TCtx`: every implementor names their context type
1746
1348
  * explicitly. For genuinely context-free scopes (in-memory tests, naive
1747
- * no-tx scopes) use `TransactionScope<undefined>` that's a conscious
1349
+ * no-tx scopes) use `TransactionScope<undefined>`: that's a conscious
1748
1350
  * "there is nothing meaningful here" statement, not an accidental
1749
1351
  * `unknown` fallback.
1750
1352
  *
1751
- * Intentionally **not** Fowler's full Unit of Work (no change tracking,
1752
- * no `registerDirty` / `registerNew` / `registerDeleted`, no commit-time
1753
- * 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.
1754
1360
  *
1755
1361
  * @example Drizzle implementation
1756
1362
  * ```typescript
@@ -1762,7 +1368,7 @@ interface Outbox<Evt extends AnyDomainEvent> {
1762
1368
  * }
1763
1369
  * ```
1764
1370
  *
1765
- * @example Use site bind repos to the live transaction
1371
+ * @example Use site: bind repos to the live transaction
1766
1372
  * ```typescript
1767
1373
  * await scope.transactional(async (tx) => {
1768
1374
  * // Construct tx-bound repos from ctx (your factory / DI of choice)
@@ -1774,7 +1380,7 @@ interface Outbox<Evt extends AnyDomainEvent> {
1774
1380
  * });
1775
1381
  * ```
1776
1382
  *
1777
- * `IRepository`'s contract takes the id / aggregate only the tx handle
1383
+ * `IRepository`'s contract takes the id / aggregate only: the tx handle
1778
1384
  * is wired into a concrete repository at construction time, not threaded
1779
1385
  * through every call. Different ORMs have different idioms for that
1780
1386
  * (constructor injection, factory functions, `withTx` chains); pick one
@@ -1794,14 +1400,17 @@ interface TransactionScope<TCtx> {
1794
1400
  * committed" is the orchestrator's call to make, not the repo's.
1795
1401
  *
1796
1402
  * Order of operations:
1797
- * 1. `fn(ctx)` runs inside `scope.transactional(...)` domain mutations
1403
+ * 1. `fn(ctx)` runs inside `scope.transactional(...)`; domain mutations
1798
1404
  * + repo writes happen here. `ctx` is whatever transaction handle the
1799
1405
  * `scope` exposes (Drizzle `tx`, Prisma `tx`, Mongo session, or
1800
1406
  * `undefined` for context-free scopes).
1801
1407
  * 2. **Still inside the transaction**, `withCommit` harvests every
1802
1408
  * aggregate's `pendingEvents` and writes them via `outbox.add` (so
1803
1409
  * events persist atomically with the state change). Skipped when no
1804
- * events were recorded.
1410
+ * events were recorded. Each harvested event is stamped with
1411
+ * `aggregateVersion` = the aggregate's commit version (onto a frozen
1412
+ * copy; a pre-set value wins) - consumers get the OCC version the
1413
+ * row was written with, for ordering and idempotency watermarks.
1805
1414
  *
1806
1415
  * **Harvest order.** Events are concatenated in the order
1807
1416
  * aggregates appear in the returned `aggregates` array, then in
@@ -1812,14 +1421,14 @@ interface TransactionScope<TCtx> {
1812
1421
  * that exact order.
1813
1422
  *
1814
1423
  * **Two ordering guarantees, not one.** Within a single aggregate
1815
- * the order is *causal* events are recorded in the order the
1424
+ * the order is *causal*: events are recorded in the order the
1816
1425
  * domain methods ran, and subscribers (handlers, projections,
1817
1426
  * replay) MUST process them in that order. Across aggregates the
1818
1427
  * order in this batch is deterministic but *not* a domain
1819
1428
  * guarantee. Greg Young / Vernon IDDD §10: aggregates are
1820
1429
  * independent consistency boundaries; events across them are
1821
1430
  * eventually consistent. Subscribers should NOT engineer
1822
- * dependencies on cross-aggregate ordering use
1431
+ * dependencies on cross-aggregate ordering; use
1823
1432
  * `EventMetadata.causationId` to express true causation, or a
1824
1433
  * process manager to coordinate. The in-process EventBus delivers
1825
1434
  * this batch in order, sequential outbox-dispatchers preserve it
@@ -1827,8 +1436,11 @@ interface TransactionScope<TCtx> {
1827
1436
  * across aggregates at delivery time.
1828
1437
  * 3. The transaction commits.
1829
1438
  * 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
1830
- * fires on each returned aggregate only now are pending events
1831
- * considered flushed.
1439
+ * fires on each returned aggregate; only now are pending events
1440
+ * considered flushed. Aggregates listed in the optional `deleted`
1441
+ * marker array are the exception: their pending events are cleared
1442
+ * directly WITHOUT `markPersisted`, so the post-save `onPersisted`
1443
+ * hook never fires for a row that was just deleted.
1832
1444
  * 5. `bus.publish(events)` fires for the in-process fast path (skipped
1833
1445
  * when no events or no `bus` is wired).
1834
1446
  *
@@ -1838,13 +1450,37 @@ interface TransactionScope<TCtx> {
1838
1450
  * outbox still holds the events and an outbox-dispatcher will deliver
1839
1451
  * them (eventual consistency).
1840
1452
  *
1841
- * If the transaction rolls back, `markPersisted` is **not** called — the
1453
+ * **A `bus.publish` failure never rejects `withCommit`.** Once the
1454
+ * transaction has committed, the write succeeded; surfacing a subscriber
1455
+ * failure as a rejection would hand the caller a use-case failure for a
1456
+ * committed write (a typical caller retries, double-executing it). The
1457
+ * in-process fast path is best-effort by design; the error is reported to
1458
+ * the optional `onPublishError(error, events)` hook (wire it to your
1459
+ * logger/metrics) and otherwise dropped; delivery is still guaranteed via
1460
+ * the outbox. The hook is an observer: if it throws, its error is
1461
+ * swallowed so the post-commit invariant holds.
1462
+ *
1463
+ * If the transaction rolls back, `markPersisted` is **not** called: the
1842
1464
  * aggregate keeps its pending events, so the caller can retry or discard.
1843
1465
  *
1466
+ * **Do not mutate an aggregate after `repository.save(...)` inside `fn`.**
1467
+ * `withCommit` cannot see what `save` wrote; the post-commit
1468
+ * `markPersisted` syncs `persistedVersion` to the CURRENT in-memory
1469
+ * version and (on `AggregateRoot`) re-baselines dirty tracking against
1470
+ * the CURRENT state. A mutation between `save` and the callback's return
1471
+ * therefore desyncs OCC (next save throws a false
1472
+ * `ConcurrencyConflictError`) — and under a partial-write repository
1473
+ * using `changedKeys`, an un-bumped mutation is silently marked clean
1474
+ * and never written. The `aggregateVersion` stamp widens the blast
1475
+ * radius further: harvested events would publicly claim a version the
1476
+ * committed row does not carry, poisoning every consumer's ordering
1477
+ * and idempotency watermarks — a cross-service inconsistency, not just
1478
+ * a local one. Mutate first, save last.
1479
+ *
1844
1480
  * **Duplicate aggregates are deduped by reference.** If the returned
1845
- * `aggregates` array contains the same instance twice e.g. a use
1481
+ * `aggregates` array contains the same instance twice (e.g. a use
1846
1482
  * case touches an order via two repository references that happen to
1847
- * resolve to the same identity-map entry `withCommit` dedupes by
1483
+ * resolve to the same identity-map entry), `withCommit` dedupes by
1848
1484
  * JavaScript object identity before harvesting. Each event lands in
1849
1485
  * the outbox exactly once and `markPersisted` fires exactly once. Two
1850
1486
  * *different* instances with the same logical id cannot be detected
@@ -1860,7 +1496,7 @@ interface TransactionScope<TCtx> {
1860
1496
  * const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
1861
1497
  * const order = await orderRepository.getByIdOrFail(orderId);
1862
1498
  * order.confirm();
1863
- * await orderRepository.save(order); // pure persistence does NOT call markPersisted
1499
+ * await orderRepository.save(order); // pure persistence; does NOT call markPersisted
1864
1500
  * return { result: order.id, aggregates: [order] };
1865
1501
  * });
1866
1502
  * ```
@@ -1869,9 +1505,26 @@ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
1869
1505
  outbox: Outbox<Evt>;
1870
1506
  bus?: EventBus<Evt>;
1871
1507
  scope: TransactionScope<TCtx>;
1508
+ /**
1509
+ * Observer for post-commit `bus.publish` failures. Called with the
1510
+ * error and the events that were published. Must not be relied on
1511
+ * for delivery: the outbox dispatcher is the reliable path.
1512
+ */
1513
+ onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
1872
1514
  }, fn: (ctx: TCtx) => Promise<{
1873
1515
  result: R;
1874
1516
  aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
1517
+ /**
1518
+ * Optional marker: which of `aggregates` were DELETED in this unit
1519
+ * of work. Their pending events are harvested like any other
1520
+ * (deletion events must reach the outbox), but the post-commit
1521
+ * lifecycle differs: `markPersisted` is NOT called on them — it
1522
+ * would fire the user-overridable `onPersisted` hook, whose
1523
+ * post-save semantics (cache fill, read-model warm-up) are a lie
1524
+ * for a row that was just deleted. Their pending events are
1525
+ * cleared directly instead, so a later commit cannot re-emit them.
1526
+ */
1527
+ deleted?: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
1875
1528
  }>): Promise<R>;
1876
1529
 
1877
1530
  /**
@@ -1949,6 +1602,366 @@ interface Query {
1949
1602
  */
1950
1603
  type QueryHandler<Q extends Query, R> = (query: Q) => Promise<R>;
1951
1604
 
1605
+ /**
1606
+ * A class reference used as the type key of the identity map. Keying
1607
+ * on the CLASS (not a name string) makes collisions impossible by
1608
+ * construction: `Restaurant` and `Booking` are different keys even if
1609
+ * someone names two aggregates identically across modules, and there
1610
+ * is no string-discipline to maintain.
1611
+ *
1612
+ * The `Function & { prototype: TAgg }` branch is load-bearing: the
1613
+ * kit's aggregate convention is a **protected constructor** plus
1614
+ * static factories, and TypeScript rejects assigning a class with a
1615
+ * protected constructor to a construct-signature type. The prototype
1616
+ * witness accepts those classes while still inferring `TAgg`.
1617
+ */
1618
+ type AggregateClass<TAgg> = (abstract new (...args: any[]) => TAgg) | (Function & {
1619
+ prototype: TAgg;
1620
+ });
1621
+ /**
1622
+ * Per-unit-of-work Identity Map (Fowler, PoEAA): within one operation,
1623
+ * one aggregate type+id maps to exactly ONE in-memory instance.
1624
+ *
1625
+ * This is the shipped implementation of the contract the
1626
+ * [Repository guide](../../docs/guide/repository.md) places on
1627
+ * `IRepository` implementations: two `getById(id)` calls in the same
1628
+ * unit of work MUST return the same instance, because `withCommit`'s
1629
+ * aggregate dedupe (and therefore exactly-once event harvest and
1630
+ * `markPersisted`) is keyed on JavaScript object identity.
1631
+ *
1632
+ * Storage is two-level (per-type stores created lazily), so
1633
+ * `Restaurant:123` and `Booking:123` can never collide — the type key
1634
+ * is the aggregate CLASS, not the id alone and not a name string.
1635
+ *
1636
+ * Repository read-path contract:
1637
+ *
1638
+ * ```ts
1639
+ * async getById(id: OrderId): Promise<Order | null> {
1640
+ * const cached = this.session.identityMap.get(Order, id);
1641
+ * if (cached) return cached;
1642
+ * // Deleted in this unit of work = gone, even if the physical
1643
+ * // delete is deferred and the row is still visible in the tx.
1644
+ * if (this.session.identityMap.isDeleted(Order, id)) return null;
1645
+ *
1646
+ * const row = await this.loadRow(id);
1647
+ * if (!row) return null;
1648
+ * const order = Order.reconstitute(row.id, row.state, row.version);
1649
+ * this.session.identityMap.set(Order, id, order);
1650
+ * return order;
1651
+ * }
1652
+ * ```
1653
+ *
1654
+ * Deletion is final within an operation: {@link delete} removes the
1655
+ * entry AND records a tombstone, so a later {@link set} of the same
1656
+ * type+id throws `AggregateDeletedError` — a second instance of a
1657
+ * deleted aggregate can never sneak back into the unit of work, even
1658
+ * through a repository whose row delete is deferred.
1659
+ *
1660
+ * Lifetime is ONE unit of work: the `UnitOfWork` creates a fresh map
1661
+ * per `run()` and clears it on close. Never cache across operations;
1662
+ * that would silently bypass optimistic concurrency control.
1663
+ */
1664
+ declare class IdentityMap {
1665
+ private readonly _stores;
1666
+ private readonly _deleted;
1667
+ /** The cached instance for type+id, or `undefined` (also after {@link delete}). */
1668
+ get<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): TAgg | undefined;
1669
+ /** Whether an instance is registered for type+id (false after {@link delete}). */
1670
+ has<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
1671
+ /**
1672
+ * Whether type+id was {@link delete}d in this unit of work. The
1673
+ * read path checks this BEFORE hydrating and returns `null`, so
1674
+ * "deleted in this operation" reads uniformly as not-found —
1675
+ * regardless of whether the repository's physical delete already
1676
+ * removed the row or is deferred within the transaction. Without
1677
+ * the check, a read-only probe of a deleted aggregate would crash
1678
+ * in {@link set} for deferred-write repositories and return `null`
1679
+ * for immediate-write ones.
1680
+ */
1681
+ isDeleted<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): boolean;
1682
+ /**
1683
+ * Registers the hydrated instance for type+id.
1684
+ *
1685
+ * - Re-registering the SAME instance is a no-op (idempotent).
1686
+ * - Registering a DIFFERENT instance for an occupied type+id throws:
1687
+ * that is precisely the identity-map violation this class exists
1688
+ * to prevent (the repository hydrated twice instead of checking
1689
+ * {@link get} first), and letting it pass would double-harvest
1690
+ * events downstream.
1691
+ * - Registering a type+id that was {@link delete}d in this unit of
1692
+ * work throws `AggregateDeletedError`: deletion is final within
1693
+ * the operation.
1694
+ */
1695
+ set<TAgg>(type: AggregateClass<TAgg>, id: Id<string>, aggregate: TAgg): void;
1696
+ /**
1697
+ * Removes the entry for type+id and records a tombstone: subsequent
1698
+ * {@link get} / {@link has} report absence, and a subsequent
1699
+ * {@link set} of the same type+id throws `AggregateDeletedError`.
1700
+ * Called by a repository's `delete(aggregate)` alongside
1701
+ * `session.enrollDeleted(aggregate)`.
1702
+ */
1703
+ delete<TAgg>(type: AggregateClass<TAgg>, id: Id<string>): void;
1704
+ /** Empties all stores and tombstones. Called by the unit of work on close. */
1705
+ clear(): void;
1706
+ }
1707
+
1708
+ /**
1709
+ * Thrown when `UnitOfWork.run()` is called while the same instance is
1710
+ * already executing a unit of work: either a genuinely nested `run()`
1711
+ * inside the work callback, or two concurrent operations sharing one
1712
+ * instance.
1713
+ *
1714
+ * Both are contract violations, not recoverable infrastructure
1715
+ * failures, so this extends `BaseError` directly (same reasoning as
1716
+ * `MissingHandlerError`): a generic `catch (e instanceof
1717
+ * InfrastructureError)` handler must not mask it.
1718
+ *
1719
+ * A nested `run()` would NOT join the outer transaction; it would open
1720
+ * an independent one, silently breaking the all-or-nothing guarantee.
1721
+ * If two operations must commit together, they are ONE unit of work:
1722
+ * merge them into a single `run()` callback. For concurrent requests,
1723
+ * construct one `UnitOfWork` per operation (construction is trivially
1724
+ * cheap; the dependency object is the thing you share).
1725
+ */
1726
+ declare class NestedUnitOfWorkError extends BaseError<"NestedUnitOfWorkError"> {
1727
+ constructor();
1728
+ }
1729
+ /**
1730
+ * Thrown when the unit-of-work context is used after `run()` has
1731
+ * settled: reading `context.repositories` / `context.transaction`, or
1732
+ * calling `session.enrollSaved` / `session.enrollDeleted`, once the
1733
+ * transaction has committed or rolled back.
1734
+ *
1735
+ * Use-after-close is a programming bug (typically a leaked context
1736
+ * reference or a fire-and-forget promise outliving the callback), so
1737
+ * this extends `BaseError` directly and should crash loud.
1738
+ *
1739
+ * **Honest scope of this guard:** the kit can only invalidate what it
1740
+ * controls - the context getters and the session. A repository or raw
1741
+ * transaction handle captured into a variable BEFORE close keeps
1742
+ * working as far as the kit can see; whether the underlying tx handle
1743
+ * rejects is ORM-specific. Do not let references escape the callback.
1744
+ */
1745
+ declare class TransactionClosedError extends BaseError<"TransactionClosedError"> {
1746
+ readonly operation: string;
1747
+ constructor(operation: string);
1748
+ }
1749
+ /**
1750
+ * The unit of work failed AFTER the work callback completed
1751
+ * successfully: during the event harvest, the outbox write, or the
1752
+ * transaction commit itself. The kit cannot see inside
1753
+ * `TransactionScope.transactional`, so these three are deliberately
1754
+ * one error class - the underlying failure is attached as `cause`.
1755
+ *
1756
+ * `InfrastructureError`: the business logic ran to completion; the
1757
+ * persistence boundary failed. The transaction rolled back (or never
1758
+ * committed), no aggregate was marked persisted, and pending events
1759
+ * survive on the aggregates — the operation left no partial state
1760
+ * behind. **Whether a retry helps depends on the cause**: a commit-
1761
+ * time serialization failure is transient, but `withCommit`'s harvest
1762
+ * guard (an event missing `aggregateId` / `aggregateType` — a
1763
+ * programming bug) also lands here and will fail deterministically on
1764
+ * every retry. Inspect the `cause` before routing into retry logic.
1765
+ */
1766
+ declare class CommitError extends InfrastructureError<"CommitError"> {
1767
+ constructor(cause: unknown);
1768
+ }
1769
+ /**
1770
+ * The work callback threw AND the transaction scope rejected with a
1771
+ * DIFFERENT error that does not wrap the callback's error in its cause
1772
+ * chain - the strongest available signal that the rollback itself
1773
+ * failed. The callback's (primary) error is preserved as `cause`, so
1774
+ * cause-chain helpers (`someChainRetryable`, `findInCauseChain`) still
1775
+ * see a wrapped `ConcurrencyConflictError` & co.; the scope's error is
1776
+ * carried in {@link rollbackCause}.
1777
+ *
1778
+ * Scopes that rethrow the original error (Drizzle, Prisma do) never
1779
+ * produce this; scopes that WRAP the original are detected via the
1780
+ * cause chain and passed through unchanged instead.
1781
+ */
1782
+ declare class RollbackError extends InfrastructureError<"RollbackError"> {
1783
+ readonly rollbackCause: unknown;
1784
+ constructor(cause: unknown, rollbackCause: unknown);
1785
+ }
1786
+ /**
1787
+ * The enrollment handle a unit of work hands to its repositories.
1788
+ *
1789
+ * Repositories enroll every aggregate they write so the unit of work
1790
+ * can harvest pending events into the outbox (inside the transaction)
1791
+ * and call `markPersisted` after the commit - the same lifecycle
1792
+ * `withCommit` runs for its returned `aggregates` array, minus the
1793
+ * footgun: with enrollment, "forgot to list the aggregate" cannot
1794
+ * happen per call site; each repository implements it once and its
1795
+ * tests pin it once.
1796
+ *
1797
+ * Contract for repository implementations:
1798
+ * - `getById(id)` checks `identityMap.get` BEFORE hydrating, treats
1799
+ * `identityMap.isDeleted` as not-found (`null`), and registers the
1800
+ * hydrated instance after - two loads of the same aggregate in one
1801
+ * unit of work must return the same instance.
1802
+ * - `save(aggregate)` calls {@link enrollSaved} BEFORE the row write:
1803
+ * the deleted-gate then throws `AggregateDeletedError` before any SQL
1804
+ * runs (instead of the write surfacing as a confusing
1805
+ * `ConcurrencyConflictError` against the deleted row). Enrollment is
1806
+ * idempotent per instance, mirroring `withCommit`'s reference dedupe,
1807
+ * and a failed write rolls the whole unit of work back anyway.
1808
+ * - `delete(aggregate)` calls {@link enrollDeleted} - ONE call does all
1809
+ * the deletion bookkeeping: the identity-map entry is removed and
1810
+ * tombstoned automatically (keyed on the instance's concrete class),
1811
+ * the recorded deletion events are still harvested into the outbox,
1812
+ * and saving or re-registering the aggregate (same instance OR a
1813
+ * re-created one with the same type+id) later in this unit of work
1814
+ * throws `AggregateDeletedError`.
1815
+ *
1816
+ * The use case can also enroll manually via `context.session` for the
1817
+ * rare write that bypasses a repository.
1818
+ */
1819
+ interface UnitOfWorkSession<Evt extends AnyDomainEvent = AnyDomainEvent> {
1820
+ /**
1821
+ * The per-operation Identity Map (Fowler): one aggregate type+id,
1822
+ * one in-memory instance. Created fresh per `run()`, cleared on
1823
+ * close; accessing it after close throws
1824
+ * {@link TransactionClosedError}.
1825
+ */
1826
+ readonly identityMap: IdentityMap;
1827
+ /** Enroll an aggregate that was (or will be) written in this unit of work. */
1828
+ enrollSaved(aggregate: IAggregateRoot<Id<string>, Evt>): void;
1829
+ /**
1830
+ * Enroll an aggregate whose row was (or will be) deleted in this
1831
+ * unit of work. Its pending events (e.g. a recorded deletion event)
1832
+ * are harvested like any other; re-saving the instance afterwards
1833
+ * throws `AggregateDeletedError`.
1834
+ */
1835
+ enrollDeleted(aggregate: IAggregateRoot<Id<string>, Evt>): void;
1836
+ }
1837
+ /**
1838
+ * What the work callback receives: repositories already bound to the
1839
+ * live transaction, the enrollment session, and — deliberately named to
1840
+ * look like the escape hatch it is — the raw transaction handle.
1841
+ *
1842
+ * All members throw {@link TransactionClosedError} once `run()` has
1843
+ * settled; do not let the context escape the callback.
1844
+ */
1845
+ interface UnitOfWorkContext<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> {
1846
+ readonly repositories: TRepos;
1847
+ /**
1848
+ * **Escape hatch — you are leaving the unit of work's guarantees.**
1849
+ * A write issued on the raw handle bypasses the repository contract,
1850
+ * enrollment (its aggregate's events are NOT harvested unless you
1851
+ * also call `session.enrollSaved`), and the identity map (a later
1852
+ * `getById` of the same aggregate hydrates a SECOND instance —
1853
+ * double harvest, double `markPersisted`). Use it only for writes no
1854
+ * repository covers, pair it with manual enrollment, and prefer
1855
+ * adding a repository method whenever one could exist.
1856
+ */
1857
+ readonly rawTransaction: TCtx;
1858
+ readonly session: UnitOfWorkSession<Evt>;
1859
+ }
1860
+ /**
1861
+ * Per-repository factory map: for each key of `TRepos`, a function
1862
+ * that constructs the repository bound to the live transaction handle
1863
+ * and the enrollment session. Called once per `run()`, so every
1864
+ * repository of one unit of work shares the same transaction.
1865
+ *
1866
+ * ```ts
1867
+ * const factories = {
1868
+ * orders: (tx, session) => new DrizzleOrderRepository(tx, session),
1869
+ * invoices: (tx, session) => new DrizzleInvoiceRepository(tx, session),
1870
+ * };
1871
+ * ```
1872
+ */
1873
+ type RepositoryFactories<TCtx, TRepos, Evt extends AnyDomainEvent = AnyDomainEvent> = {
1874
+ [K in keyof TRepos]: (tx: TCtx, session: UnitOfWorkSession<Evt>) => TRepos[K];
1875
+ };
1876
+ /** Dependencies for {@link UnitOfWork}; the app-level singleton part. */
1877
+ interface UnitOfWorkDeps<Evt extends AnyDomainEvent, TCtx, TRepos> {
1878
+ scope: TransactionScope<TCtx>;
1879
+ outbox: Outbox<Evt>;
1880
+ bus?: EventBus<Evt>;
1881
+ /** See `withCommit`: observer for post-commit `bus.publish` failures. */
1882
+ onPublishError?: (error: unknown, events: ReadonlyArray<Evt>) => void;
1883
+ repositories: RepositoryFactories<TCtx, TRepos, Evt>;
1884
+ }
1885
+ /**
1886
+ * Explicit-save Unit of Work: one `run()` call is one application-level
1887
+ * write operation. All repository writes inside the callback share one
1888
+ * transaction and either persist completely or not at all.
1889
+ *
1890
+ * Built ON TOP of `withCommit` - the commit orchestration (event
1891
+ * harvest into the outbox inside the transaction, `markPersisted`
1892
+ * after the commit, best-effort in-process publish last) is inherited,
1893
+ * not reimplemented. What this layer adds:
1894
+ *
1895
+ * - **Tx-bound repositories via a registry.** The callback receives
1896
+ * ready-made repositories instead of a raw transaction handle; the
1897
+ * factory map is wired once at construction.
1898
+ * - **Enrollment instead of a returned aggregates array.** Repositories
1899
+ * enroll what they write via {@link UnitOfWorkSession}; the use case
1900
+ * cannot forget to list an aggregate (the `withCommit` footgun).
1901
+ * - **Lifecycle errors.** {@link NestedUnitOfWorkError},
1902
+ * {@link TransactionClosedError}, {@link CommitError},
1903
+ * {@link RollbackError}, {@link AggregateDeletedError}.
1904
+ *
1905
+ * - **A per-operation Identity Map** on the session: repositories
1906
+ * check it before hydrating and register after, so one type+id maps
1907
+ * to one in-memory instance per unit of work (the contract
1908
+ * `withCommit`'s reference-dedupe relies on, now shipped instead of
1909
+ * merely documented).
1910
+ *
1911
+ * What it deliberately does NOT do (v1): no auto-flush (explicit
1912
+ * `save()` only - `hasChanges` makes a redundant save a cheap no-op),
1913
+ * no savepoints, no nested-transaction joining. `withCommit` with
1914
+ * hand-rolled repos remains fully supported; this facade is opt-in.
1915
+ *
1916
+ * **Instance discipline:** one instance owns one logical operation at
1917
+ * a time. `run()` while a run is active throws
1918
+ * {@link NestedUnitOfWorkError} - that covers genuine nesting AND two
1919
+ * concurrent requests sharing one instance, which is the same bug in
1920
+ * different clothes. Construct one `UnitOfWork` per operation
1921
+ * (construction stores one reference; the shareable singleton is the
1922
+ * deps object). Sequential reuse of an instance is fine.
1923
+ *
1924
+ * **Error pass-through:** an error thrown by the work callback (a
1925
+ * repository's `ConcurrencyConflictError`, a `DomainError`, anything)
1926
+ * is rethrown UNCHANGED - the unit of work never converts a concurrency
1927
+ * conflict into a generic error. Only the two failure modes the
1928
+ * callback cannot observe are wrapped: see {@link CommitError} and
1929
+ * {@link RollbackError}.
1930
+ *
1931
+ * @example
1932
+ * ```ts
1933
+ * const deps = {
1934
+ * scope: drizzleScope,
1935
+ * outbox: drizzleOutbox,
1936
+ * bus: eventBus,
1937
+ * repositories: {
1938
+ * restaurants: (tx, session) => new DrizzleRestaurantRepository(tx, session),
1939
+ * },
1940
+ * };
1941
+ *
1942
+ * const uow = new UnitOfWork(deps);
1943
+ * const result = await uow.run(async ({ repositories }) => {
1944
+ * const restaurant = await repositories.restaurants.getByIdOrFail(id);
1945
+ * restaurant.changeOpeningHours(openingHours);
1946
+ * await repositories.restaurants.save(restaurant); // save() enrolls
1947
+ * return restaurant.id;
1948
+ * });
1949
+ * ```
1950
+ */
1951
+ declare class UnitOfWork<Evt extends AnyDomainEvent, TCtx, TRepos extends Record<string, unknown>> {
1952
+ private readonly deps;
1953
+ private _active;
1954
+ constructor(deps: UnitOfWorkDeps<Evt, TCtx, TRepos>);
1955
+ /**
1956
+ * Execute one unit of work: open the transaction, hand the callback
1957
+ * tx-bound repositories, commit on resolve, roll back on throw,
1958
+ * run the post-commit lifecycle (markPersisted, publish) for every
1959
+ * enrolled aggregate. Returns the callback's result.
1960
+ */
1961
+ run<R>(work: (context: UnitOfWorkContext<TCtx, TRepos, Evt>) => Promise<R>): Promise<R>;
1962
+ private buildRepositories;
1963
+ }
1964
+
1952
1965
  /**
1953
1966
  * Type map for query types to their return types.
1954
1967
  * Used to improve type inference in QueryBus.
@@ -2018,7 +2031,7 @@ interface IQueryBus<TMap extends QueryTypeMap = QueryTypeMap> {
2018
2031
  *
2019
2032
  * When `TMap` is supplied, the `queryType` argument is restricted to its
2020
2033
  * keys and the handler signature is forced to match `TMap[K]` for the
2021
- * return value typos and wrong-typed handlers are compile errors.
2034
+ * return value: typos and wrong-typed handlers are compile errors.
2022
2035
  * Without `TMap` the registration is loose (any string key, any return
2023
2036
  * type) so the no-config path keeps working.
2024
2037
  *
@@ -2129,7 +2142,7 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
2129
2142
  * In-memory reference implementation of `Outbox<Evt>`.
2130
2143
  *
2131
2144
  * Intended for tests, single-process workers, and quick-start demos.
2132
- * Uses the event's own `eventId` as the dispatch id the common, clean
2145
+ * Uses the event's own `eventId` as the dispatch id: the common, clean
2133
2146
  * choice. Storage is a `Map<string, OutboxRecord<Evt>>` keyed by
2134
2147
  * `eventId`, so re-adding the same event is naturally idempotent (the
2135
2148
  * duplicate entry overwrites itself; `getPending` returns each event at
@@ -2138,7 +2151,13 @@ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt>
2138
2151
  * For production, back the outbox with a transactional store so the
2139
2152
  * outbox row participates in the same transaction as the aggregate
2140
2153
  * write (see `TransactionScope` + `withCommit`). This class lives in
2141
- * memory only events are lost on process restart.
2154
+ * memory only: events are lost on process restart — and, sharper:
2155
+ * events `add()`ed inside a transaction that later rolls back are NOT
2156
+ * removed (the Map knows nothing about your scope's rollback). Tests
2157
+ * that assert rollback purity need an outbox that participates in the
2158
+ * test store's transactional semantics; see the reference adapter at
2159
+ * https://github.com/shi-rudo/ddd-kit-ts/blob/main/src/testing/repository-contract.test.ts
2160
+ * (repo-only, not shipped to npm).
2142
2161
  *
2143
2162
  * @example
2144
2163
  * ```ts
@@ -2163,13 +2182,33 @@ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt>
2163
2182
  markDispatched(dispatchIds: ReadonlyArray<string>): Promise<void>;
2164
2183
  }
2165
2184
 
2185
+ /**
2186
+ * The canonical shape of a unit-of-work-facing repository. Unlike
2187
+ * `IRepository` below (id-canonical CRUD for `withCommit`-style
2188
+ * setups), `delete` takes the AGGREGATE: the unit of work needs the
2189
+ * instance for deletion-event harvest, the identity-map tombstone, and
2190
+ * the deleted-cannot-be-resaved gate. Ids stay branded (`TId extends
2191
+ * Id<string>`) end-to-end.
2192
+ *
2193
+ * Implementing this interface is optional — the `UnitOfWork` registry
2194
+ * is structurally typed — but it is the single source of truth the
2195
+ * guide's examples and the repository contract test suite
2196
+ * (`@shirudo/ddd-kit/testing`, whose `ContractRepository` is the
2197
+ * minimal structural subset of this shape) are written against.
2198
+ */
2199
+ interface IUnitOfWorkRepository<TAgg extends IAggregateRoot<TId, Evt>, TId extends Id<string>, Evt extends AnyDomainEvent = AnyDomainEvent> {
2200
+ getById(id: TId): Promise<TAgg | null>;
2201
+ getByIdOrFail(id: TId): Promise<TAgg>;
2202
+ save(aggregate: TAgg): Promise<void>;
2203
+ delete(aggregate: TAgg): Promise<void>;
2204
+ }
2166
2205
  /**
2167
2206
  * Core repository contract for Aggregate Roots.
2168
2207
  *
2169
2208
  * In DDD a Repository is a "collection illusion" for aggregates: load by
2170
2209
  * identity, save the whole aggregate, delete by identity. Querying by
2171
2210
  * arbitrary criteria is a separate concern (CQRS read-side, ad-hoc bulk
2172
- * operations) and lives on the `IQueryableRepository` extension below so
2211
+ * operations) and lives on the `IQueryableRepository` extension below, so
2173
2212
  * write-side repositories don't have to implement query plumbing they
2174
2213
  * don't need.
2175
2214
  *
@@ -2200,7 +2239,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2200
2239
  exists(id: TId): Promise<boolean>;
2201
2240
  /**
2202
2241
  * Persists the aggregate (insert or update). Implementations are
2203
- * responsible for **persistence only** they must NOT touch the
2242
+ * responsible for **persistence only**; they must NOT touch the
2204
2243
  * aggregate's in-memory state:
2205
2244
  *
2206
2245
  * 1. Throw `ConcurrencyConflictError` from `@shirudo/ddd-kit` when the
@@ -2208,14 +2247,14 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2208
2247
  * stored (optimistic concurrency).
2209
2248
  * 2. Write the aggregate to durable storage.
2210
2249
  *
2211
- * **Insert vs update `persistedVersion` convention.** Every aggregate
2250
+ * **Insert vs update: the `persistedVersion` convention.** Every aggregate
2212
2251
  * exposes two version fields with distinct roles:
2213
2252
  *
2214
- * - `aggregate.version` in-memory post-mutation value, bumped by
2253
+ * - `aggregate.version`: in-memory post-mutation value, bumped by
2215
2254
  * `setState(_, true)` / `commit()` / `apply()`. NOT the right
2216
2255
  * routing key, because mutations can advance it past zero while
2217
2256
  * the DB row still does not exist.
2218
- * - `aggregate.persistedVersion` what the persistence layer holds.
2257
+ * - `aggregate.persistedVersion`: what the persistence layer holds.
2219
2258
  * `undefined` until the aggregate has been persisted or restored
2220
2259
  * at least once. This is the routing key.
2221
2260
  *
@@ -2225,14 +2264,14 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2225
2264
  * - otherwise → **UPDATE** with the OCC predicate
2226
2265
  * `WHERE id = ? AND version = aggregate.persistedVersion` (the
2227
2266
  * load-time / last-save baseline, not the post-mutation in-memory
2228
- * value). If the row count is `0`, another writer raced you
2267
+ * value). If the row count is `0`, another writer raced you:
2229
2268
  * throw `ConcurrencyConflictError`.
2230
2269
  *
2231
2270
  * Do **not** call `aggregate.markPersisted(...)` here. The library's
2232
2271
  * `withCommit` orchestrator handles the post-save lifecycle (harvest
2233
2272
  * pending events into the outbox, then mark persisted after commit).
2234
2273
  * Calling `markPersisted` inside `save` clears pending events too early
2235
- * and breaks the harvest path and is also why the Vernon/Axon/
2274
+ * and breaks the harvest path, and is also why the Vernon/Axon/
2236
2275
  * EventFlow pattern separates persistence from commit-events.
2237
2276
  *
2238
2277
  * If you are not using `withCommit` (custom orchestration), call
@@ -2241,7 +2280,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2241
2280
  */
2242
2281
  save(aggregate: TAgg): Promise<void>;
2243
2282
  /**
2244
- * Removes the aggregate's row by id. Pure persistence does NOT
2283
+ * Removes the aggregate's row by id. Pure persistence: does NOT
2245
2284
  * harvest pending events from the aggregate (the contract takes
2246
2285
  * only the id, so there is no aggregate to harvest from).
2247
2286
  *
@@ -2249,7 +2288,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2249
2288
  * is the right domain verb. Most are actually state transitions
2250
2289
  * (*cancel*, *archive*, *close*, *deactivate*, *terminate*) with
2251
2290
  * proper domain names that should be modelled as state changes plus
2252
- * a recorded event not as row removal.
2291
+ * a recorded event, not as row removal.
2253
2292
  *
2254
2293
  * `delete(id)` belongs in the toolkit for three distinct cases, in
2255
2294
  * decreasing order of common occurrence (see
@@ -2275,7 +2314,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2275
2314
  * subscriber cares. If the entity has identity in the ubiquitous
2276
2315
  * language, you probably want path 1 or 2 instead.
2277
2316
  *
2278
- * In pure event-sourced systems `delete` is rarely meaningful
2317
+ * In pure event-sourced systems `delete` is rarely meaningful:
2279
2318
  * end-of-lifecycle there is a `Closed` / `Terminated` event in the
2280
2319
  * stream, and identity persists in the event log. `delete` applies
2281
2320
  * primarily to state-stored aggregates and snapshot / projection
@@ -2289,7 +2328,7 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2289
2328
  * Prisma `WhereInput`, a MongoDB filter document, a plain
2290
2329
  * `(t: TAgg) => boolean` predicate for in-memory repos, or anything else.
2291
2330
  *
2292
- * The library does not prescribe a Specification or query DSL the
2331
+ * The library does not prescribe a Specification or query DSL: the
2293
2332
  * Repository implementation owns its query language. This avoids the
2294
2333
  * phantom-interface trap of a library-level `ISpecification<T>` with no
2295
2334
  * methods and lets each Repository expose the strongest possible types for
@@ -2325,13 +2364,13 @@ interface IQueryableRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<
2325
2364
  */
2326
2365
  findOne(filter: TFilter): Promise<TAgg | null>;
2327
2366
  /**
2328
- * Returns **every** aggregate matching the filter no pagination,
2367
+ * Returns **every** aggregate matching the filter: no pagination,
2329
2368
  * no cursor. For unbounded result sets, prefer a read-side projection
2330
2369
  * (CQRS read model) over loading aggregates in bulk; aggregates are
2331
2370
  * write-side objects and rehydrating thousands of them by id is rarely
2332
2371
  * what you want. If you need pagination on the write side, declare a
2333
2372
  * domain-specific paged method on your concrete repository (e.g.
2334
- * `findPage(filter, cursor)`) the library does not prescribe a
2373
+ * `findPage(filter, cursor)`): the library does not prescribe a
2335
2374
  * pagination contract because cursor/offset/keyset semantics vary too
2336
2375
  * much across storage backends.
2337
2376
  */
@@ -2345,18 +2384,35 @@ type VO<T> = Readonly<T>;
2345
2384
  * so the freeze symmetry matches `deepEqual` (which also considers symbol
2346
2385
  * keys). Handles circular references by tracking visited objects.
2347
2386
  *
2348
- * Note: `deepFreeze` mutates its argument in place it sets `[[Frozen]]`
2387
+ * Note: `deepFreeze` mutates its argument in place; it sets `[[Frozen]]`
2349
2388
  * on the object you pass in. Callers that need to avoid touching the
2350
2389
  * input (e.g. `vo()`) should deep-clone first.
2390
+ *
2391
+ * Date/Map/Set keep internal-slot mutability under `Object.freeze`
2392
+ * (`setTime`, `set`, `add`, … still work on frozen instances), so their
2393
+ * mutator methods are shadowed with throwing own properties and Map/Set
2394
+ * contents are frozen recursively. The shadows are non-enumerable:
2395
+ * invisible to `Object.keys`, spread, `deepEqual`, and `structuredClone`.
2396
+ *
2397
+ * The shadowing is deny-by-enumeration: only the mutators known at
2398
+ * release time are blocked. If the runtime grows a NEW mutator (e.g. the
2399
+ * stage-3 `Map.prototype.getOrInsert` upsert proposal), it is not blocked
2400
+ * until the list is updated. Treat the mutator blocking as a guard rail,
2401
+ * not a security boundary.
2402
+ *
2403
+ * Limitation: ArrayBuffer views (TypedArrays, DataView) are passed through
2404
+ * unfrozen, because the spec forbids freezing a view with elements, and
2405
+ * freezing cannot protect the underlying buffer. Their contents remain mutable.
2351
2406
  */
2352
2407
  declare function deepFreeze<T>(obj: T, visited?: WeakSet<object>): Readonly<T>;
2353
2408
  /**
2354
2409
  * Creates a deeply immutable value object from the given data.
2355
2410
  *
2356
- * The input is first deep-cloned with `structuredClone`, then the clone
2357
- * is frozen — so calling `vo(input)` never freezes the caller's own
2358
- * object graph as a side-effect. Mutating the input afterwards does not
2359
- * bleed into the VO.
2411
+ * The input is first deep-cloned, then the clone is frozen, so calling
2412
+ * `vo(input)` never freezes the caller's own object graph as a
2413
+ * side-effect. Mutating the input afterwards does not bleed into the VO.
2414
+ * Symbol-keyed properties are preserved (matching `voEquals`); function
2415
+ * values are rejected (Value Objects are data, not behaviour).
2360
2416
  *
2361
2417
  * @example
2362
2418
  * ```typescript
@@ -2441,6 +2497,11 @@ declare function voEqualsExcept<T>(a: VO<T>, b: VO<T>, options: DeepEqualExceptO
2441
2497
  * Creates a value object with optional validation.
2442
2498
  * Returns a Result type instead of throwing an error.
2443
2499
  *
2500
+ * Note: the Result covers VALIDATION failures only. Non-data values in
2501
+ * the input (functions, Promise/WeakMap/WeakSet) still throw a
2502
+ * `TypeError` from `vo()`; they cannot occur in parsed JSON and signal
2503
+ * a programming error, not a validation failure.
2504
+ *
2444
2505
  * @param t - The data to convert into a value object
2445
2506
  * @param validate - Validation function that returns true if valid
2446
2507
  * @param errorMessage - Optional custom error message if validation fails
@@ -2505,7 +2566,9 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2505
2566
  readonly props: Readonly<T>;
2506
2567
  /**
2507
2568
  * Creates a new ValueObject.
2508
- * The properties are deeply frozen to ensure immutability.
2569
+ * The properties are deep-cloned (prototype-preserving) and then deeply
2570
+ * frozen, so the caller's own object graph is never frozen or mutated,
2571
+ * and later mutation of the input does not bleed into the value object.
2509
2572
  *
2510
2573
  * @param props - The properties of the value object
2511
2574
  * @example
@@ -2564,7 +2627,7 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2564
2627
  * was recorded the input is frozen into a `VO<T>` and returned as `Ok`;
2565
2628
  * otherwise the populated `ValidationError` is returned as `Err`.
2566
2629
  *
2567
- * `ValidationError` comes from `@shirudo/base-error` import it from there to
2630
+ * `ValidationError` comes from `@shirudo/base-error`; import it from there to
2568
2631
  * narrow the `Err` branch, exactly as `Result` is imported from
2569
2632
  * `@shirudo/result`. It serializes to RFC 9457 Problem Details; use
2570
2633
  * {@link validationProblemDetails} at the HTTP boundary to surface the issues.
@@ -2586,4 +2649,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2586
2649
  */
2587
2650
  declare function voValidated<T>(t: T, validate: (issues: ValidationError, value: T) => void, message?: string): Result<VO<T>, ValidationError>;
2588
2651
 
2589
- 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 };
2652
+ export { type AggregateClass, type AggregateConfig, AggregateRoot, AggregateSnapshot, AnyDomainEvent, type Command, CommandBus, type CommandHandler, CommitError, CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, Entity, type EventBus, EventBusImpl, type EventHandler, EventSourcedAggregate, IAggregateRoot, type ICommandBus, type IEntity, IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IUnitOfWorkRepository, type IValueObject, Id, type Identifiable, IdentityMap, InMemoryOutbox, InfrastructureError, NestedUnitOfWorkError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type RepositoryFactories, RollbackError, TransactionClosedError, type TransactionScope, UnitOfWork, type UnitOfWorkContext, type UnitOfWorkDeps, type UnitOfWorkSession, type VO, ValueObject, Version, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, removeEntityById, replaceEntityById, sameEntity, updateEntityById, vo, voEquals, voEqualsExcept, voValidated, voWithValidation, withCommit };