@shirudo/ddd-kit 2.0.0 → 2.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @shirudo/ddd-kit
2
2
 
3
- Composable TypeScript toolkit for tactical Domain-Driven Design. Ships the canonical building blocks (Value Objects, Entities, Aggregate Roots, Domain Events, Repositories, and CQRS handlers) without a framework or runtime lock-in. ESM-only; runs on Node 18+, Cloudflare Workers, Vercel Edge, Deno, and Bun.
3
+ Composable TypeScript toolkit for tactical Domain-Driven Design. Ships the canonical building blocks (Value Objects, Entities, Aggregate Roots, Domain Events, Repositories, and CQRS handlers) without a framework or runtime lock-in. ESM-only; runs on Node 20+, Cloudflare Workers, Vercel Edge, Deno, and Bun.
4
4
 
5
- > **Stable: 1.0**
5
+ > **Stable: 2.1**
6
6
  >
7
7
  > The public API is stable and follows [Semantic Versioning](https://semver.org/). Breaking changes bump the major and ship with a migration path in the [CHANGELOG](https://github.com/shi-rudo/ddd-kit-ts/blob/main/CHANGELOG.md).
8
8
 
@@ -15,11 +15,13 @@ Composable TypeScript toolkit for tactical Domain-Driven Design. Ships the canon
15
15
  - **Entities:** identity + lifecycle, with collection helpers branded by `Id<Tag>`.
16
16
  - **Aggregate Roots:** state-stored (`AggregateRoot`) and event-sourced (`EventSourcedAggregate`), with optimistic-concurrency versioning.
17
17
  - **Domain Events:** typed, deeply frozen, carry metadata for traceability and schema evolution.
18
+ - **Domain State Machine:** finite, named domain states with typed context, guards, reducers, terminal states, and value outputs for aggregate lifecycles and process managers.
18
19
  - **Repositories:** technology-agnostic persistence ports with an Identity-Map contract and OCC.
19
20
  - **CQRS:** zero-config in-memory `CommandBus` / `QueryBus`, plus `CommandHandler` / `QueryHandler` types for external brokers.
20
21
  - **Unit of Work:** opt-in `UnitOfWork` facade with tx-bound repositories, repository-side enrollment, a per-operation Identity Map, and aggregate-level dirty tracking (`changedKeys` / `hasChanges`) for partial writes. Honestly speaking: a transaction coordinator with registration and Identity Map; writes stay explicit by design (no auto-flush).
21
22
  - **Outbox:** `withCommit` harvests pending events inside the transaction, stamps them with the aggregate's commit version, and publishes them atomically.
22
- - **Repository contract tests:** `@shirudo/ddd-kit/testing` ships the suite every adapter must pass: OCC is a testable contract, not a documented pattern.
23
+ - **Event Store:** `EventStore` port with expectedVersion-guarded appends and snapshot catch-up reads, plus `InMemoryEventStore` as the reference implementation.
24
+ - **Repository contract tests:** `@shirudo/ddd-kit/testing` ships the suites (state-stored and event-sourced) every adapter must pass: OCC is a testable contract, not a documented pattern.
23
25
  - **Result-first boundary:** a typed error hierarchy on [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error) and `Result` from [`@shirudo/result`](https://www.npmjs.com/package/@shirudo/result); `voValidated` collects field violations and renders RFC 9457 via the opt-in `@shirudo/ddd-kit/http` entry.
24
26
 
25
27
  ## Installation
@@ -58,6 +60,7 @@ Each building block has a dedicated guide. Start with [Design Decisions](https:/
58
60
  | Aggregate Roots, factories, reconstitution | [Aggregate Roots](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/aggregates.md) |
59
61
  | Event sourcing (`apply`, replay, snapshots) | [Event Sourcing](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/event-sourcing.md) |
60
62
  | Domain Events (`createDomainEvent`, metadata) | [Domain Events](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/domain-events.md) |
63
+ | Domain State Machine (`DomainStateMachine`, `transitionDomainState`) | [Domain State Machine](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/domain-state-machine.md) |
61
64
  | Errors: throw vs Result, `ValidationError`, RFC 9457 | [Result vs Throw](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/result-vs-throw.md) |
62
65
  | Commands, queries, buses | [CQRS & Buses](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/cqrs-and-buses.md) |
63
66
  | Repositories, Identity Map, OCC | [Repository](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/repository.md) |
@@ -106,6 +106,29 @@ declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
106
106
  readonly eventType: string;
107
107
  constructor(eventType: string, cause?: unknown);
108
108
  }
109
+ /**
110
+ * Thrown by `EventSourcedAggregate.loadFromHistory` and
111
+ * `restoreFromSnapshotWithEvents` when the replay target is not fresh:
112
+ * the aggregate carries unflushed `pendingEvents`, or (for
113
+ * `loadFromHistory`) an in-memory version that was never persisted.
114
+ * Replaying onto such an instance would `markRestored` a
115
+ * `persistedVersion` that counts unpersisted history: repository routing
116
+ * flips from INSERT to UPDATE (or appends with a wrong expected version)
117
+ * and harvested events would claim a version baseline the stream does
118
+ * not carry.
119
+ *
120
+ * Deliberately **not** a `DomainError` or `InfrastructureError` (same
121
+ * posture as {@link MissingHandlerError}): a deterministic programming
122
+ * bug in how the aggregate was constructed before replay. It propagates
123
+ * as a throw instead of riding the replay methods' `Result` channel, so
124
+ * a generic corrupted-stream handler cannot absorb it. Reconstitution
125
+ * belongs on a bare instance: construct the aggregate without
126
+ * factory-recorded events or prior mutations, then replay.
127
+ */
128
+ declare class UnreplayableAggregateError extends BaseError<"UnreplayableAggregateError"> {
129
+ readonly aggregateId: string;
130
+ constructor(aggregateId: string, reason: string);
131
+ }
109
132
  /**
110
133
  * Thrown by `withCommit` when an event harvested from an aggregate cannot
111
134
  * be safely committed: it is missing `aggregateId` / `aggregateType`
@@ -131,6 +154,34 @@ declare class EventHarvestError extends BaseError<"EventHarvestError"> {
131
154
  /** The `type` of the offending event, for programmatic routing. */
132
155
  eventType?: string | undefined);
133
156
  }
157
+ /** Constructor options for {@link UnregisteredHandlerError}. */
158
+ interface UnregisteredHandlerErrorOptions {
159
+ /** Which bus rejected the dispatch. */
160
+ readonly busKind: "command" | "query";
161
+ /** The message type no handler was registered for. */
162
+ readonly messageType: string;
163
+ }
164
+ /**
165
+ * Produced by the in-memory `CommandBus` / `QueryBus` when a message is
166
+ * dispatched for a type no handler was registered under: a wiring bug
167
+ * (typo in the type string, missing `register` call at bootstrap), not
168
+ * a domain or infrastructure failure.
169
+ *
170
+ * Extends `BaseError` directly (same crash-loud family as
171
+ * {@link MissingHandlerError}): a generic `catch (e instanceof
172
+ * DomainError)` handler must not absorb it. For 2.x compatibility the
173
+ * buses still deliver it through their error CHANNEL (`err(...)` via the
174
+ * `errorMapper`, so the default string channel keeps its exact message)
175
+ * rather than throwing; the named type exists so a typed error channel
176
+ * can route it explicitly instead of pattern-matching message strings.
177
+ * `QueryBus.executeUnsafe` throws it directly. v3 makes `execute` throw
178
+ * it too.
179
+ */
180
+ declare class UnregisteredHandlerError extends BaseError<"UnregisteredHandlerError"> {
181
+ readonly busKind: "command" | "query";
182
+ readonly messageType: string;
183
+ constructor(options: UnregisteredHandlerErrorOptions);
184
+ }
134
185
  /**
135
186
  * Thrown at the end of a `UnitOfWork.run` when an aggregate that was
136
187
  * loaded into the identity map during the operation carries unflushed
@@ -231,6 +282,36 @@ declare class DuplicateAggregateError extends InfrastructureError<"DuplicateAggr
231
282
  readonly aggregateId: string;
232
283
  constructor(options: DuplicateAggregateErrorOptions);
233
284
  }
285
+ /**
286
+ * Thrown on snapshot restore (`restoreFromSnapshot`,
287
+ * `restoreFromSnapshotWithEvents`) when the stored snapshot carries a
288
+ * different schema version than the aggregate's declared
289
+ * `snapshotSchemaVersion` and no `migrateSnapshotState` override handles
290
+ * the upgrade. Without the check, a snapshot written against an older
291
+ * `TSnapshotState` shape would surface as an undefined-field crash on
292
+ * the first method call after a much later restore.
293
+ *
294
+ * `InfrastructureError` because the storage boundary served outdated
295
+ * data; the schema evolving past stored snapshots is an expected
296
+ * lifecycle event, not a programming bug. NOT retryable: the recovery
297
+ * is a code path, not a repeat. Either override `migrateSnapshotState`
298
+ * on the aggregate (upgrade old shapes in place), or catch this error
299
+ * in the repository, discard the snapshot, and refold from the full
300
+ * event stream / reload from the source of truth.
301
+ */
302
+ interface SnapshotSchemaMismatchErrorOptions {
303
+ readonly aggregateType: string;
304
+ readonly aggregateId: string;
305
+ readonly expectedSchemaVersion: number;
306
+ readonly actualSchemaVersion: number;
307
+ }
308
+ declare class SnapshotSchemaMismatchError extends InfrastructureError<"SnapshotSchemaMismatchError"> {
309
+ readonly aggregateType: string;
310
+ readonly aggregateId: string;
311
+ readonly expectedSchemaVersion: number;
312
+ readonly actualSchemaVersion: number;
313
+ constructor(options: SnapshotSchemaMismatchErrorOptions);
314
+ }
234
315
  /**
235
316
  * Thrown by `IRepository.save()` when the aggregate's expected version
236
317
  * does not match the version currently persisted: i.e. another writer
@@ -354,61 +435,6 @@ declare function withEventIdFactory<T>(factory: EventIdFactory, fn: () => T): T;
354
435
  * Intended for use in test `afterEach` hooks.
355
436
  */
356
437
  declare function resetEventIdFactory(): void;
357
- /**
358
- * Clock function producing a fresh `Date` for each call. The library
359
- * defaults to `() => new Date()`; override globally via `setClockFactory`
360
- * for deterministic event-sourcing tests, time-travel debugging, or any
361
- * scenario where `occurredAt` must be reproducible.
362
- */
363
- type ClockFactory = () => Date;
364
- /**
365
- * Replaces the global clock factory used by `createDomainEvent`. Call once
366
- * during application bootstrap (or per-test in deterministic test suites):
367
- *
368
- * ```ts
369
- * import { setClockFactory } from "@shirudo/ddd-kit";
370
- *
371
- * setClockFactory(() => new Date("2026-01-01T00:00:00Z"));
372
- * ```
373
- *
374
- * The per-call `options.occurredAt` override always wins over this
375
- * factory. Symmetric to `setEventIdFactory`.
376
- *
377
- * Module-scoped: see {@link setEventIdFactory} for the global-state
378
- * caveats. For test isolation prefer {@link withClockFactory}; for
379
- * multi-tenant request isolation prefer the per-call
380
- * `options.occurredAt`.
381
- */
382
- declare function setClockFactory(factory: ClockFactory): void;
383
- /**
384
- * Scoped variant of {@link setClockFactory}: installs `factory`, runs
385
- * `fn`, then restores the previous factory in a `finally` block.
386
- * Synchronous-only, with the same constraints (and same runtime thenable
387
- * guard) as {@link withEventIdFactory}.
388
- *
389
- * **When to prefer the per-call `options.occurredAt` instead.** Same
390
- * trade-off as {@link withEventIdFactory}: passing `{ occurredAt }`
391
- * to `createDomainEvent` is the strongest isolation for single-event
392
- * cases. The scoped helper is for events constructed deep inside
393
- * domain methods where threading an explicit timestamp is awkward.
394
- *
395
- * @example
396
- * ```ts
397
- * it("stamps events with a fixed clock", () => {
398
- * const fixed = new Date("2026-01-01T00:00:00Z");
399
- * withClockFactory(() => fixed, () => {
400
- * const e = createDomainEvent("X", { v: 1 });
401
- * expect(e.occurredAt).toEqual(fixed);
402
- * });
403
- * });
404
- * ```
405
- */
406
- declare function withClockFactory<T>(factory: ClockFactory, fn: () => T): T;
407
- /**
408
- * Restores the default clock factory (`() => new Date()`).
409
- * Intended for use in test `afterEach` hooks.
410
- */
411
- declare function resetClockFactory(): void;
412
438
  /**
413
439
  * Metadata associated with a domain event for traceability and correlation.
414
440
  * Used in event-driven architectures to track event flow across services.
@@ -512,11 +538,16 @@ interface DomainEvent<T extends string, P = void> {
512
538
  * `CreateDomainEventOptions.aggregateVersion`; a pre-set value is
513
539
  * never overwritten.
514
540
  *
515
- * Consumers use it for ordering ("apply projections up to aggregate
516
- * version N"), idempotency watermarks, debugging, and integration
517
- * logs. Optional at the type level: events created outside an
518
- * aggregate (system/integration events) and events from older kit
519
- * versions don't carry it.
541
+ * Consumers use it for cross-commit ordering and debugging. It is NOT
542
+ * a per-event idempotency key: all events of one commit share the
543
+ * stamp, so a position cursor keyed on it alone would silently skip
544
+ * every event after the first within a commit (e.g. after a crash
545
+ * between two same-version events). Dedup keys belong on `eventId`;
546
+ * as a watermark this value is per-commit only (advance it after
547
+ * processing ALL events of that version; see the outbox guide).
548
+ * Optional at the type level: events created outside an aggregate
549
+ * (system/integration events) and events from older kit versions
550
+ * don't carry it.
520
551
  */
521
552
  aggregateVersion?: number;
522
553
  /**
@@ -575,6 +606,14 @@ interface CreateDomainEventOptions {
575
606
  * Creates a domain event with default values.
576
607
  * Sets occurredAt to current date and version to 1 if not provided.
577
608
  *
609
+ * **Input ownership.** The event is deeply frozen, and `payload` and
610
+ * `metadata` are deep-cloned first, so the caller's own objects are never
611
+ * frozen in place and later mutation of them does not bleed into the
612
+ * event (same contract as `vo()`). The clone follows the plain-data event
613
+ * contract via `structuredClone`: functions, Promise, and WeakMap/WeakSet
614
+ * values throw a `TypeError`; symbol-keyed properties are not carried
615
+ * over.
616
+ *
578
617
  * **For aggregate-internal events, prefer `this.recordEvent(...)` on
579
618
  * `AggregateRoot` / `EventSourcedAggregate`.** That helper auto-injects
580
619
  * `aggregateId` (from `this.id`) and `aggregateType` (from the
@@ -646,15 +685,25 @@ interface AggregateSnapshot<TState> {
646
685
  /**
647
686
  * The state of the aggregate at the time of the snapshot.
648
687
  */
649
- state: TState;
688
+ readonly state: TState;
650
689
  /**
651
690
  * The version of the aggregate when the snapshot was taken.
652
691
  */
653
- version: Version;
692
+ readonly version: Version;
654
693
  /**
655
694
  * Timestamp when the snapshot was created.
656
695
  */
657
- snapshotAt: Date;
696
+ readonly snapshotAt: Date;
697
+ /**
698
+ * Schema version of the SHAPE of `state` (the aggregate's declared
699
+ * `snapshotSchemaVersion`), stamped by `createSnapshot`. Distinct from
700
+ * {@link version}, which counts mutations: this field says "which
701
+ * shape does the stored state have", so a restore can detect a
702
+ * snapshot written against an older `TSnapshotState` and migrate or
703
+ * discard it instead of crashing later. Optional: absent on snapshots
704
+ * written by older kit versions, which restore treats as schema `1`.
705
+ */
706
+ readonly schemaVersion?: number;
658
707
  }
659
708
  /**
660
709
  * Public contract every Aggregate Root satisfies. Implemented by
@@ -719,4 +768,4 @@ declare function sameVersion<TId extends Id<string>>(a: {
719
768
  version: Version;
720
769
  }): boolean;
721
770
 
722
- export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, UnenrolledChangesError as U, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, setEventIdFactory as e, type ClockFactory as f, setClockFactory as g, withClockFactory as h, resetClockFactory as i, type EventMetadata as j, type DomainEvent as k, createDomainEvent as l, copyMetadata as m, mergeMetadata as n, EventHarvestError as o, AggregateDeletedError as p, type AggregateNotFoundErrorOptions as q, resetEventIdFactory as r, sameVersion as s, AggregateNotFoundError as t, type DuplicateAggregateErrorOptions as u, DuplicateAggregateError as v, withEventIdFactory as w, type ConcurrencyConflictErrorOptions as x, ConcurrencyConflictError as y, type IdGenerator as z };
771
+ export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, SnapshotSchemaMismatchError as S, UnenrolledChangesError as U, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, copyMetadata as e, createDomainEvent as f, type DomainEvent as g, type EventMetadata as h, setEventIdFactory as i, AggregateDeletedError as j, AggregateNotFoundError as k, type AggregateNotFoundErrorOptions as l, mergeMetadata as m, ConcurrencyConflictError as n, type ConcurrencyConflictErrorOptions as o, DuplicateAggregateError as p, type DuplicateAggregateErrorOptions as q, resetEventIdFactory as r, sameVersion as s, EventHarvestError as t, type SnapshotSchemaMismatchErrorOptions as u, UnregisteredHandlerError as v, withEventIdFactory as w, type UnregisteredHandlerErrorOptions as x, UnreplayableAggregateError as y, type IdGenerator as z };