@shirudo/ddd-kit 1.3.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) |
@@ -63,8 +63,10 @@ interface IdGenerator<Tag extends string> {
63
63
  * subclass: the kit can't know your invariants.
64
64
  *
65
65
  * Extends `BaseError<Name>`; see `@shirudo/base-error` for the inherited
66
- * surface (timestamps, cause chains, `toJSON()`, `getUserMessage()`,
67
- * `isRetryable`, …).
66
+ * surface (timestamps, cause chains, `toJSON()`, `isRetryable`, …). For
67
+ * client-safe / localized messages, project errors through the opt-in
68
+ * `@shirudo/base-error/presentation` subpath at the boundary; the technical
69
+ * core deliberately carries no user-facing message.
68
70
  */
69
71
  declare abstract class DomainError<Name extends string = string> extends BaseError<Name> {
70
72
  }
@@ -104,6 +106,29 @@ declare class MissingHandlerError extends BaseError<"MissingHandlerError"> {
104
106
  readonly eventType: string;
105
107
  constructor(eventType: string, cause?: unknown);
106
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
+ }
107
132
  /**
108
133
  * Thrown by `withCommit` when an event harvested from an aggregate cannot
109
134
  * be safely committed: it is missing `aggregateId` / `aggregateType`
@@ -129,6 +154,34 @@ declare class EventHarvestError extends BaseError<"EventHarvestError"> {
129
154
  /** The `type` of the offending event, for programmatic routing. */
130
155
  eventType?: string | undefined);
131
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
+ }
132
185
  /**
133
186
  * Thrown at the end of a `UnitOfWork.run` when an aggregate that was
134
187
  * loaded into the identity map during the operation carries unflushed
@@ -188,10 +241,16 @@ declare class AggregateDeletedError extends BaseError<"AggregateDeletedError"> {
188
241
  *
189
242
  * Not retryable: retrying won't make the row appear.
190
243
  */
244
+ interface AggregateNotFoundErrorOptions {
245
+ readonly aggregateType: string;
246
+ readonly id: string;
247
+ /** Optional lower-level error to preserve in the cause chain. */
248
+ readonly cause?: unknown;
249
+ }
191
250
  declare class AggregateNotFoundError extends InfrastructureError<"AggregateNotFoundError"> {
192
251
  readonly aggregateType: string;
193
252
  readonly id: string;
194
- constructor(aggregateType: string, id: string, cause?: unknown);
253
+ constructor(options: AggregateNotFoundErrorOptions);
195
254
  }
196
255
  /**
197
256
  * Thrown by a repository's `save()` INSERT path when a row with the
@@ -212,10 +271,46 @@ declare class AggregateNotFoundError extends InfrastructureError<"AggregateNotFo
212
271
  * idempotency-key flows load the existing aggregate and treat the
213
272
  * request as already-applied.
214
273
  */
274
+ interface DuplicateAggregateErrorOptions {
275
+ readonly aggregateType: string;
276
+ readonly aggregateId: string;
277
+ /** Optional driver-level error to preserve in the cause chain. */
278
+ readonly cause?: unknown;
279
+ }
215
280
  declare class DuplicateAggregateError extends InfrastructureError<"DuplicateAggregateError"> {
216
281
  readonly aggregateType: string;
217
282
  readonly aggregateId: string;
218
- constructor(aggregateType: string, aggregateId: string, cause?: unknown);
283
+ constructor(options: DuplicateAggregateErrorOptions);
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);
219
314
  }
220
315
  /**
221
316
  * Thrown by `IRepository.save()` when the aggregate's expected version
@@ -235,18 +330,26 @@ declare class DuplicateAggregateError extends InfrastructureError<"DuplicateAggr
235
330
  * rule) detects the race. Marks itself as `retryable: true` so the
236
331
  * `isRetryable` predicate from `@shirudo/base-error` picks it up.
237
332
  */
238
- declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyConflictError"> {
333
+ interface ConcurrencyConflictErrorOptions {
239
334
  readonly aggregateType: string;
240
335
  readonly aggregateId: string;
241
336
  readonly expectedVersion: number;
242
337
  readonly actualVersion: number;
338
+ /** Optional driver-level error to preserve in the cause chain. */
339
+ readonly cause?: unknown;
340
+ }
341
+ declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyConflictError"> {
243
342
  /**
244
343
  * Marks this error as retryable so `isRetryable(err)` returns
245
344
  * true. The canonical OCC pattern is to reload the aggregate, re-apply
246
345
  * the use case, and retry on this exception.
247
346
  */
248
347
  readonly retryable: true;
249
- constructor(aggregateType: string, aggregateId: string, expectedVersion: number, actualVersion: number, cause?: unknown);
348
+ readonly aggregateType: string;
349
+ readonly aggregateId: string;
350
+ readonly expectedVersion: number;
351
+ readonly actualVersion: number;
352
+ constructor(options: ConcurrencyConflictErrorOptions);
250
353
  }
251
354
 
252
355
  /**
@@ -263,9 +366,8 @@ declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyC
263
366
  */
264
367
  type EventIdFactory = () => string;
265
368
  /**
266
- * Replaces the global event-id factory used by `createDomainEvent` and
267
- * `createDomainEventWithMetadata`. Call once during application bootstrap,
268
- * for example:
369
+ * Replaces the global event-id factory used by `createDomainEvent`. Call
370
+ * once during application bootstrap, for example:
269
371
  *
270
372
  * ```ts
271
373
  * import { ulid } from "ulid";
@@ -333,62 +435,6 @@ declare function withEventIdFactory<T>(factory: EventIdFactory, fn: () => T): T;
333
435
  * Intended for use in test `afterEach` hooks.
334
436
  */
335
437
  declare function resetEventIdFactory(): void;
336
- /**
337
- * Clock function producing a fresh `Date` for each call. The library
338
- * defaults to `() => new Date()`; override globally via `setClockFactory`
339
- * for deterministic event-sourcing tests, time-travel debugging, or any
340
- * scenario where `occurredAt` must be reproducible.
341
- */
342
- type ClockFactory = () => Date;
343
- /**
344
- * Replaces the global clock factory used by `createDomainEvent` and
345
- * `createDomainEventWithMetadata`. Call once during application bootstrap
346
- * (or per-test in deterministic test suites):
347
- *
348
- * ```ts
349
- * import { setClockFactory } from "@shirudo/ddd-kit";
350
- *
351
- * setClockFactory(() => new Date("2026-01-01T00:00:00Z"));
352
- * ```
353
- *
354
- * The per-call `options.occurredAt` override always wins over this
355
- * factory. Symmetric to `setEventIdFactory`.
356
- *
357
- * Module-scoped: see {@link setEventIdFactory} for the global-state
358
- * caveats. For test isolation prefer {@link withClockFactory}; for
359
- * multi-tenant request isolation prefer the per-call
360
- * `options.occurredAt`.
361
- */
362
- declare function setClockFactory(factory: ClockFactory): void;
363
- /**
364
- * Scoped variant of {@link setClockFactory}: installs `factory`, runs
365
- * `fn`, then restores the previous factory in a `finally` block.
366
- * Synchronous-only, with the same constraints (and same runtime thenable
367
- * guard) as {@link withEventIdFactory}.
368
- *
369
- * **When to prefer the per-call `options.occurredAt` instead.** Same
370
- * trade-off as {@link withEventIdFactory}: passing `{ occurredAt }`
371
- * to `createDomainEvent` is the strongest isolation for single-event
372
- * cases. The scoped helper is for events constructed deep inside
373
- * domain methods where threading an explicit timestamp is awkward.
374
- *
375
- * @example
376
- * ```ts
377
- * it("stamps events with a fixed clock", () => {
378
- * const fixed = new Date("2026-01-01T00:00:00Z");
379
- * withClockFactory(() => fixed, () => {
380
- * const e = createDomainEvent("X", { v: 1 });
381
- * expect(e.occurredAt).toEqual(fixed);
382
- * });
383
- * });
384
- * ```
385
- */
386
- declare function withClockFactory<T>(factory: ClockFactory, fn: () => T): T;
387
- /**
388
- * Restores the default clock factory (`() => new Date()`).
389
- * Intended for use in test `afterEach` hooks.
390
- */
391
- declare function resetClockFactory(): void;
392
438
  /**
393
439
  * Metadata associated with a domain event for traceability and correlation.
394
440
  * Used in event-driven architectures to track event flow across services.
@@ -492,11 +538,16 @@ interface DomainEvent<T extends string, P = void> {
492
538
  * `CreateDomainEventOptions.aggregateVersion`; a pre-set value is
493
539
  * never overwritten.
494
540
  *
495
- * Consumers use it for ordering ("apply projections up to aggregate
496
- * version N"), idempotency watermarks, debugging, and integration
497
- * logs. Optional at the type level: events created outside an
498
- * aggregate (system/integration events) and events from older kit
499
- * 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.
500
551
  */
501
552
  aggregateVersion?: number;
502
553
  /**
@@ -555,6 +606,14 @@ interface CreateDomainEventOptions {
555
606
  * Creates a domain event with default values.
556
607
  * Sets occurredAt to current date and version to 1 if not provided.
557
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
+ *
558
617
  * **For aggregate-internal events, prefer `this.recordEvent(...)` on
559
618
  * `AggregateRoot` / `EventSourcedAggregate`.** That helper auto-injects
560
619
  * `aggregateId` (from `this.id`) and `aggregateType` (from the
@@ -583,20 +642,6 @@ interface CreateDomainEventOptions {
583
642
  */
584
643
  declare function createDomainEvent<T extends string>(type: T, payload?: undefined, options?: CreateDomainEventOptions): DomainEvent<T, void>;
585
644
  declare function createDomainEvent<T extends string, P>(type: T, payload: P, options?: CreateDomainEventOptions): DomainEvent<T, P>;
586
- /**
587
- * Creates a domain event with metadata for traceability.
588
- * Convenience function for creating events with correlation and causation IDs.
589
- *
590
- * @example
591
- * ```typescript
592
- * const event = createDomainEventWithMetadata(
593
- * "OrderCreated",
594
- * { orderId: "123" },
595
- * { correlationId: "corr-123", causationId: "cmd-456", userId: "user-789" }
596
- * );
597
- * ```
598
- */
599
- declare function createDomainEventWithMetadata<T extends string, P>(type: T, payload: P, metadata: EventMetadata, options?: Omit<CreateDomainEventOptions, "metadata">): DomainEvent<T, P>;
600
645
  /**
601
646
  * Copies metadata from a source event to a new event.
602
647
  * Useful for maintaining correlation chains in event-driven architectures.
@@ -640,15 +685,25 @@ interface AggregateSnapshot<TState> {
640
685
  /**
641
686
  * The state of the aggregate at the time of the snapshot.
642
687
  */
643
- state: TState;
688
+ readonly state: TState;
644
689
  /**
645
690
  * The version of the aggregate when the snapshot was taken.
646
691
  */
647
- version: Version;
692
+ readonly version: Version;
648
693
  /**
649
694
  * Timestamp when the snapshot was created.
650
695
  */
651
- 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;
652
707
  }
653
708
  /**
654
709
  * Public contract every Aggregate Root satisfies. Implemented by
@@ -713,4 +768,4 @@ declare function sameVersion<TId extends Id<string>>(a: {
713
768
  version: Version;
714
769
  }): boolean;
715
770
 
716
- export { type AnyDomainEvent as A, type CreateDomainEventOptions as C, DomainError as D, type EventIdFactory as E, type IAggregateRoot as I, MissingHandlerError as M, UnenrolledChangesError as U, type Version as V, type Id as a, type AggregateSnapshot as b, type IEventSourcedAggregate as c, InfrastructureError as d, setEventIdFactory as e, type ClockFactory as f, setClockFactory as g, withClockFactory as h, resetClockFactory as i, type EventMetadata as j, type DomainEvent as k, createDomainEvent as l, createDomainEventWithMetadata as m, copyMetadata as n, mergeMetadata as o, EventHarvestError as p, AggregateDeletedError as q, resetEventIdFactory as r, sameVersion as s, AggregateNotFoundError as t, DuplicateAggregateError as u, ConcurrencyConflictError as v, withEventIdFactory as w, type IdGenerator as x };
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 };
package/dist/http.d.ts CHANGED
@@ -1,31 +1,54 @@
1
- import { ValidationError, ProblemDetailsOptions, ProblemDetails } from '@shirudo/base-error';
1
+ import { ValidationError } from '@shirudo/base-error';
2
+ import { ProblemDetailsExtensions, ProblemDetails } from '@shirudo/base-error/problem-details';
2
3
 
3
4
  /** Extension member that carries the collected field issues. */
4
5
  type ValidationProblemMember = "errors" | "invalid-params";
5
6
  /**
6
- * Options for {@link toProblemDetails}. Mirrors base-error's
7
- * `ProblemDetailsOptions` but takes over the `extensions` member to attach the
8
- * collected field issues, and adds {@link member} to choose the wire key.
7
+ * Options for {@link toProblemDetails}: the standard RFC 9457 members the
8
+ * boundary may set, plus {@link member} to choose the wire key for the issues
9
+ * and {@link extensions} for extra public members merged alongside them.
9
10
  */
10
- interface ValidationProblemOptions extends Omit<ProblemDetailsOptions, "extensions"> {
11
+ interface ValidationProblemOptions {
12
+ /** URI reference identifying the problem type. Defaults to `"about:blank"`. */
13
+ type?: string;
14
+ /** Short, human-readable summary. Default `"Validation Failed"`. */
15
+ title?: string;
16
+ /** HTTP status code. Default `422`. */
17
+ status?: number;
18
+ /** Human-readable explanation specific to this occurrence. */
19
+ detail?: string;
20
+ /** URI reference identifying this specific occurrence. */
21
+ instance?: string;
11
22
  /**
12
23
  * Extension member that carries the field issues. Default `"errors"`
13
24
  * (`{ message, path, code?, pointer? }` entries). RFC 9457 does not
14
25
  * standardize a multi-error member; `errors` is the common convention.
15
26
  */
16
27
  member?: ValidationProblemMember;
17
- /** Extra public extension members merged alongside the issues. */
18
- extensions?: Record<string, unknown>;
28
+ /**
29
+ * Extra public extension members merged alongside the issues. JSON-safe
30
+ * by contract (RFC 9457 bodies must serialize); a trace id, for example,
31
+ * is passed here, not as a recognized top-level field.
32
+ */
33
+ extensions?: ProblemDetailsExtensions;
19
34
  }
20
35
  /**
21
36
  * Projects a base-error {@link ValidationError} to an RFC 9457 Problem Details
22
37
  * object with the collected field issues attached under an extension member.
38
+ * The return type is base-error's own
39
+ * {@link ProblemDetails} (from `@shirudo/base-error/problem-details`), so the
40
+ * RFC 9457 shape stays a single source of truth across the ecosystem.
23
41
  *
24
- * base-error is **safe by default**: `ValidationError.toProblemDetails()` does
25
- * not expose the issues on its own: they only cross to a client through the
26
- * `publicIssues()` whitelist. This helper performs that explicit projection and
42
+ * base-error is **safe by default**: the issues only cross to a client through
43
+ * the `publicIssues()` whitelist (`{ message, path, code?, pointer? }`, never
44
+ * raw validator extras). This helper performs that explicit projection and
27
45
  * applies sensible validation defaults (`422`, `"Validation Failed"`), so the
28
- * common boundary case is a one-liner instead of a footgun.
46
+ * common boundary case is a one-liner instead of a footgun. The full-fidelity
47
+ * issues remain available for observability via `error.toLogObject()`.
48
+ *
49
+ * For the general error-to-Problem-Details mapping (a public-code catalog with
50
+ * per-code `type` / `status`), use base-error's `defineProblemDetailsAdapter`
51
+ * over a `PublicErrorView`. This helper is the narrow validation shortcut.
29
52
  *
30
53
  * This is a presentation/transport concern and ships from the opt-in
31
54
  * `@shirudo/ddd-kit/http` entry point: the core kit stays transport-free.
@@ -37,10 +60,10 @@ interface ValidationProblemOptions extends Omit<ProblemDetailsOptions, "extensio
37
60
  * if (result.isErr()) {
38
61
  * return Response.json(toProblemDetails(result.error), { status: 422 });
39
62
  * }
40
- * // → { type, title: "Validation Failed", status: 422,
63
+ * // → { type: "about:blank", title: "Validation Failed", status: 422,
41
64
  * // errors: [{ message: "must be a valid email", path: ["email"], pointer: "email" }] }
42
65
  * ```
43
66
  */
44
- declare function toProblemDetails(error: ValidationError, options?: ValidationProblemOptions): ProblemDetails;
67
+ declare function toProblemDetails(error: ValidationError, options?: ValidationProblemOptions): ProblemDetails<never, ProblemDetailsExtensions>;
45
68
 
46
69
  export { type ValidationProblemMember, type ValidationProblemOptions, toProblemDetails };
package/dist/http.js CHANGED
@@ -3,13 +3,21 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
3
3
 
4
4
  // src/http/problem-details.ts
5
5
  function toProblemDetails(error, options = {}) {
6
- const { member = "errors", extensions, ...rest } = options;
7
- return error.toProblemDetails({
8
- title: "Validation Failed",
9
- status: 422,
10
- ...rest,
11
- extensions: { ...extensions, [member]: error.publicIssues() }
12
- });
6
+ const {
7
+ member = "errors",
8
+ extensions,
9
+ type = "about:blank",
10
+ title = "Validation Failed",
11
+ status = 422,
12
+ detail,
13
+ instance
14
+ } = options;
15
+ const problem = { type, title, status };
16
+ if (detail !== void 0) problem.detail = detail;
17
+ if (instance !== void 0) problem.instance = instance;
18
+ if (extensions) Object.assign(problem, extensions);
19
+ problem[member] = error.publicIssues();
20
+ return problem;
13
21
  }
14
22
  __name(toProblemDetails, "toProblemDetails");
15
23
 
package/dist/http.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/http/problem-details.ts"],"names":[],"mappings":";;;;AAkDO,SAAS,gBAAA,CACf,KAAA,EACA,OAAA,GAAoC,EAAC,EACpB;AACjB,EAAA,MAAM,EAAE,MAAA,GAAS,QAAA,EAAU,UAAA,EAAY,GAAG,MAAK,GAAI,OAAA;AACnD,EAAA,OAAO,MAAM,gBAAA,CAAiB;AAAA,IAC7B,KAAA,EAAO,mBAAA;AAAA,IACP,MAAA,EAAQ,GAAA;AAAA,IACR,GAAG,IAAA;AAAA,IACH,UAAA,EAAY,EAAE,GAAG,UAAA,EAAY,CAAC,MAAM,GAAG,KAAA,CAAM,YAAA,EAAa;AAAE,GAC5D,CAAA;AACF;AAXgB,MAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA","file":"http.js","sourcesContent":["import type {\n\tProblemDetails,\n\tProblemDetailsOptions,\n\tValidationError,\n} from \"@shirudo/base-error\";\n\n/** Extension member that carries the collected field issues. */\nexport type ValidationProblemMember = \"errors\" | \"invalid-params\";\n\n/**\n * Options for {@link toProblemDetails}. Mirrors base-error's\n * `ProblemDetailsOptions` but takes over the `extensions` member to attach the\n * collected field issues, and adds {@link member} to choose the wire key.\n */\nexport interface ValidationProblemOptions\n\textends Omit<ProblemDetailsOptions, \"extensions\"> {\n\t/**\n\t * Extension member that carries the field issues. Default `\"errors\"`\n\t * (`{ message, path, code?, pointer? }` entries). RFC 9457 does not\n\t * standardize a multi-error member; `errors` is the common convention.\n\t */\n\tmember?: ValidationProblemMember;\n\t/** Extra public extension members merged alongside the issues. */\n\textensions?: Record<string, unknown>;\n}\n\n/**\n * Projects a base-error {@link ValidationError} to an RFC 9457 Problem Details\n * object with the collected field issues attached under an extension member.\n *\n * base-error is **safe by default**: `ValidationError.toProblemDetails()` does\n * not expose the issues on its own: they only cross to a client through the\n * `publicIssues()` whitelist. This helper performs that explicit projection and\n * applies sensible validation defaults (`422`, `\"Validation Failed\"`), so the\n * common boundary case is a one-liner instead of a footgun.\n *\n * This is a presentation/transport concern and ships from the opt-in\n * `@shirudo/ddd-kit/http` entry point: the core kit stays transport-free.\n *\n * @example\n * ```ts\n * import { toProblemDetails } from \"@shirudo/ddd-kit/http\";\n *\n * if (result.isErr()) {\n * return Response.json(toProblemDetails(result.error), { status: 422 });\n * }\n * // → { type, title: \"Validation Failed\", status: 422,\n * // errors: [{ message: \"must be a valid email\", path: [\"email\"], pointer: \"email\" }] }\n * ```\n */\nexport function toProblemDetails(\n\terror: ValidationError,\n\toptions: ValidationProblemOptions = {},\n): ProblemDetails {\n\tconst { member = \"errors\", extensions, ...rest } = options;\n\treturn error.toProblemDetails({\n\t\ttitle: \"Validation Failed\",\n\t\tstatus: 422,\n\t\t...rest,\n\t\textensions: { ...extensions, [member]: error.publicIssues() },\n\t});\n}\n"]}
1
+ {"version":3,"sources":["../src/http/problem-details.ts"],"names":[],"mappings":";;;;AAuEO,SAAS,gBAAA,CACf,KAAA,EACA,OAAA,GAAoC,EAAC,EACa;AAClD,EAAA,MAAM;AAAA,IACL,MAAA,GAAS,QAAA;AAAA,IACT,UAAA;AAAA,IACA,IAAA,GAAO,aAAA;AAAA,IACP,KAAA,GAAQ,mBAAA;AAAA,IACR,MAAA,GAAS,GAAA;AAAA,IACT,MAAA;AAAA,IACA;AAAA,GACD,GAAI,OAAA;AAEJ,EAAA,MAAM,OAAA,GAAmC,EAAE,IAAA,EAAM,KAAA,EAAO,MAAA,EAAO;AAC/D,EAAA,IAAI,MAAA,KAAW,MAAA,EAAW,OAAA,CAAQ,MAAA,GAAS,MAAA;AAC3C,EAAA,IAAI,QAAA,KAAa,MAAA,EAAW,OAAA,CAAQ,QAAA,GAAW,QAAA;AAC/C,EAAA,IAAI,UAAA,EAAY,MAAA,CAAO,MAAA,CAAO,OAAA,EAAS,UAAU,CAAA;AAIjD,EAAA,OAAA,CAAQ,MAAM,CAAA,GAAI,KAAA,CAAM,YAAA,EAAa;AAErC,EAAA,OAAO,OAAA;AACR;AAxBgB,MAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA","file":"http.js","sourcesContent":["import type { ValidationError } from \"@shirudo/base-error\";\nimport type {\n\tProblemDetails,\n\tProblemDetailsExtensions,\n} from \"@shirudo/base-error/problem-details\";\n\n/** Extension member that carries the collected field issues. */\nexport type ValidationProblemMember = \"errors\" | \"invalid-params\";\n\n/**\n * Options for {@link toProblemDetails}: the standard RFC 9457 members the\n * boundary may set, plus {@link member} to choose the wire key for the issues\n * and {@link extensions} for extra public members merged alongside them.\n */\nexport interface ValidationProblemOptions {\n\t/** URI reference identifying the problem type. Defaults to `\"about:blank\"`. */\n\ttype?: string;\n\t/** Short, human-readable summary. Default `\"Validation Failed\"`. */\n\ttitle?: string;\n\t/** HTTP status code. Default `422`. */\n\tstatus?: number;\n\t/** Human-readable explanation specific to this occurrence. */\n\tdetail?: string;\n\t/** URI reference identifying this specific occurrence. */\n\tinstance?: string;\n\t/**\n\t * Extension member that carries the field issues. Default `\"errors\"`\n\t * (`{ message, path, code?, pointer? }` entries). RFC 9457 does not\n\t * standardize a multi-error member; `errors` is the common convention.\n\t */\n\tmember?: ValidationProblemMember;\n\t/**\n\t * Extra public extension members merged alongside the issues. JSON-safe\n\t * by contract (RFC 9457 bodies must serialize); a trace id, for example,\n\t * is passed here, not as a recognized top-level field.\n\t */\n\textensions?: ProblemDetailsExtensions;\n}\n\n/**\n * Projects a base-error {@link ValidationError} to an RFC 9457 Problem Details\n * object with the collected field issues attached under an extension member.\n * The return type is base-error's own\n * {@link ProblemDetails} (from `@shirudo/base-error/problem-details`), so the\n * RFC 9457 shape stays a single source of truth across the ecosystem.\n *\n * base-error is **safe by default**: the issues only cross to a client through\n * the `publicIssues()` whitelist (`{ message, path, code?, pointer? }`, never\n * raw validator extras). This helper performs that explicit projection and\n * applies sensible validation defaults (`422`, `\"Validation Failed\"`), so the\n * common boundary case is a one-liner instead of a footgun. The full-fidelity\n * issues remain available for observability via `error.toLogObject()`.\n *\n * For the general error-to-Problem-Details mapping (a public-code catalog with\n * per-code `type` / `status`), use base-error's `defineProblemDetailsAdapter`\n * over a `PublicErrorView`. This helper is the narrow validation shortcut.\n *\n * This is a presentation/transport concern and ships from the opt-in\n * `@shirudo/ddd-kit/http` entry point: the core kit stays transport-free.\n *\n * @example\n * ```ts\n * import { toProblemDetails } from \"@shirudo/ddd-kit/http\";\n *\n * if (result.isErr()) {\n * return Response.json(toProblemDetails(result.error), { status: 422 });\n * }\n * // → { type: \"about:blank\", title: \"Validation Failed\", status: 422,\n * // errors: [{ message: \"must be a valid email\", path: [\"email\"], pointer: \"email\" }] }\n * ```\n */\nexport function toProblemDetails(\n\terror: ValidationError,\n\toptions: ValidationProblemOptions = {},\n): ProblemDetails<never, ProblemDetailsExtensions> {\n\tconst {\n\t\tmember = \"errors\",\n\t\textensions,\n\t\ttype = \"about:blank\",\n\t\ttitle = \"Validation Failed\",\n\t\tstatus = 422,\n\t\tdetail,\n\t\tinstance,\n\t} = options;\n\n\tconst problem: Record<string, unknown> = { type, title, status };\n\tif (detail !== undefined) problem.detail = detail;\n\tif (instance !== undefined) problem.instance = instance;\n\tif (extensions) Object.assign(problem, extensions);\n\t// `PublicIssue.path` is `ReadonlyArray<PropertyKey>`, so the issue array is\n\t// not statically a `ProblemDetailsJsonValue`; the wire form only ever\n\t// carries string/number path segments, so this is JSON-safe in practice.\n\tproblem[member] = error.publicIssues();\n\n\treturn problem as ProblemDetails<never, ProblemDetailsExtensions>;\n}\n"]}