@shirudo/ddd-kit 1.2.0 → 2.0.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.
@@ -0,0 +1,54 @@
1
+ import { PublicIssue } from '@shirudo/base-error';
2
+ import { PublicErrorView } from '@shirudo/base-error/presentation';
3
+
4
+ /** Details shape carried by the view for a {@link toPublicErrorView} result. */
5
+ interface PublicErrorViewDetails {
6
+ /** Whitelisted field issues, present only for a `ValidationError`. */
7
+ readonly issues: readonly PublicIssue[];
8
+ }
9
+ /** Options for {@link toPublicErrorView}. */
10
+ interface PublicErrorViewOptions {
11
+ /**
12
+ * BCP 47 locale tag stamped on the view. The built-in messages are English;
13
+ * pass a locale only when you supply your own message resolution upstream.
14
+ * Default `"en"`.
15
+ */
16
+ locale?: string;
17
+ }
18
+ /**
19
+ * Maps a kit error (or any caught value) to a base-error
20
+ * {@link PublicErrorView}: a **transport-neutral**, client-safe representation
21
+ * (`code`, `message`, `locale`, optional `details`). It is deliberately *not* a
22
+ * transport adapter: it carries no HTTP status, header, or exit code, because
23
+ * those are the consumer's concern. Feed the view into base-error's
24
+ * `defineProblemDetailsAdapter` (HTTP / RFC 9457), a gRPC status mapper, or a
25
+ * CLI exit-code table, whichever boundary you are at.
26
+ *
27
+ * Total over `unknown`: an unmapped or non-kit value degrades to a generic
28
+ * `INTERNAL_ERROR` view rather than leaking the technical message or throwing.
29
+ * The kit's class-based errors match by their pinned `error.name`, so it
30
+ * survives minification and duplicate installs (no `instanceof`). A
31
+ * `ValidationError` is detected by its `publicIssues()` whitelist (base-error
32
+ * names it after its code), and those issues ride along in `details.issues`.
33
+ *
34
+ * For richer, multi-locale messages, register the kit's errors in a base-error
35
+ * `PublicErrorRegistry` and use `PublicErrorPresenter` instead; this helper is
36
+ * the lean, single-locale default.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { toPublicErrorView } from "@shirudo/ddd-kit/presentation";
41
+ * import { defineProblemDetailsAdapter } from "@shirudo/base-error/problem-details";
42
+ *
43
+ * const adapter = defineProblemDetailsAdapter({
44
+ * definitions: { AggregateNotFoundError: { type: "about:blank", status: 404 } },
45
+ * fallback: { type: "about:blank", status: 500 },
46
+ * });
47
+ *
48
+ * const { body, status } = adapter.map(toPublicErrorView(error));
49
+ * return Response.json(body, { status });
50
+ * ```
51
+ */
52
+ declare function toPublicErrorView(error: unknown, options?: PublicErrorViewOptions): PublicErrorView<PublicErrorViewDetails>;
53
+
54
+ export { type PublicErrorViewDetails, type PublicErrorViewOptions, toPublicErrorView };
@@ -0,0 +1,45 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/presentation/public-error-view.ts
5
+ var KIT_PUBLIC_MESSAGES = {
6
+ AggregateNotFoundError: "The requested resource could not be found.",
7
+ ConcurrencyConflictError: "The resource was modified by another request. Please reload and try again.",
8
+ DuplicateAggregateError: "The resource already exists."
9
+ };
10
+ var VALIDATION_MESSAGE = "The submitted data is invalid.";
11
+ var FALLBACK_CODE = "INTERNAL_ERROR";
12
+ var FALLBACK_MESSAGE = "An unexpected error occurred.";
13
+ var DEFAULT_LOCALE = "en";
14
+ function toPublicErrorView(error, options = {}) {
15
+ const locale = options.locale ?? DEFAULT_LOCALE;
16
+ const name = errorName(error);
17
+ if (hasPublicIssues(error)) {
18
+ return {
19
+ code: name ?? "VALIDATION_FAILED",
20
+ message: VALIDATION_MESSAGE,
21
+ locale,
22
+ details: { issues: error.publicIssues() }
23
+ };
24
+ }
25
+ const message = name ? KIT_PUBLIC_MESSAGES[name] : void 0;
26
+ if (message === void 0) {
27
+ return { code: FALLBACK_CODE, message: FALLBACK_MESSAGE, locale };
28
+ }
29
+ return { code: name, message, locale };
30
+ }
31
+ __name(toPublicErrorView, "toPublicErrorView");
32
+ function errorName(error) {
33
+ if (typeof error !== "object" || error === null) return void 0;
34
+ const { name } = error;
35
+ return typeof name === "string" ? name : void 0;
36
+ }
37
+ __name(errorName, "errorName");
38
+ function hasPublicIssues(error) {
39
+ return typeof error.publicIssues === "function";
40
+ }
41
+ __name(hasPublicIssues, "hasPublicIssues");
42
+
43
+ export { toPublicErrorView };
44
+ //# sourceMappingURL=presentation.js.map
45
+ //# sourceMappingURL=presentation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/presentation/public-error-view.ts"],"names":[],"mappings":";;;;AAaA,IAAM,mBAAA,GAAwD;AAAA,EAC7D,sBAAA,EAAwB,4CAAA;AAAA,EACxB,wBAAA,EACC,4EAAA;AAAA,EACD,uBAAA,EAAyB;AAC1B,CAAA;AAGA,IAAM,kBAAA,GAAqB,gCAAA;AAE3B,IAAM,aAAA,GAAgB,gBAAA;AACtB,IAAM,gBAAA,GAAmB,+BAAA;AAEzB,IAAM,cAAA,GAAiB,IAAA;AAoDhB,SAAS,iBAAA,CACf,KAAA,EACA,OAAA,GAAkC,EAAC,EACO;AAC1C,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,IAAU,cAAA;AACjC,EAAA,MAAM,IAAA,GAAO,UAAU,KAAK,CAAA;AAK5B,EAAA,IAAI,eAAA,CAAgB,KAAK,CAAA,EAAG;AAC3B,IAAA,OAAO;AAAA,MACN,MAAM,IAAA,IAAQ,mBAAA;AAAA,MACd,OAAA,EAAS,kBAAA;AAAA,MACT,MAAA;AAAA,MACA,OAAA,EAAS,EAAE,MAAA,EAAQ,KAAA,CAAM,cAAa;AAAE,KACzC;AAAA,EACD;AAEA,EAAA,MAAM,OAAA,GAAU,IAAA,GAAO,mBAAA,CAAoB,IAAI,CAAA,GAAI,MAAA;AACnD,EAAA,IAAI,YAAY,MAAA,EAAW;AAC1B,IAAA,OAAO,EAAE,IAAA,EAAM,aAAA,EAAe,OAAA,EAAS,kBAAkB,MAAA,EAAO;AAAA,EACjE;AAEA,EAAA,OAAO,EAAE,IAAA,EAAM,IAAA,EAAgB,OAAA,EAAS,MAAA,EAAO;AAChD;AAzBgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AA4BhB,SAAS,UAAU,KAAA,EAAoC;AACtD,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,KAAU,MAAM,OAAO,MAAA;AACxD,EAAA,MAAM,EAAE,MAAK,GAAI,KAAA;AACjB,EAAA,OAAO,OAAO,IAAA,KAAS,QAAA,GAAW,IAAA,GAAO,MAAA;AAC1C;AAJS,MAAA,CAAA,SAAA,EAAA,WAAA,CAAA;AAOT,SAAS,gBACR,KAAA,EAC6C;AAC7C,EAAA,OACC,OAAQ,MAAqC,YAAA,KAAiB,UAAA;AAEhE;AANS,MAAA,CAAA,eAAA,EAAA,iBAAA,CAAA","file":"presentation.js","sourcesContent":["import type { PublicIssue } from \"@shirudo/base-error\";\nimport type { PublicErrorView } from \"@shirudo/base-error/presentation\";\n\n/**\n * Safe, client-facing English messages for the kit's known errors. They carry\n * no occurrence data (no id, version, or technical detail), so they never leak\n * across the boundary. This is the transport-neutral *presentation* layer: the\n * technical error classes stay free of these strings (removed from the core in\n * 2.0); here is their opt-in home.\n *\n * Keyed by the kit's pinned `error.name` (stable across minification and\n * duplicate installs), so matching does not depend on `instanceof`.\n */\nconst KIT_PUBLIC_MESSAGES: Readonly<Record<string, string>> = {\n\tAggregateNotFoundError: \"The requested resource could not be found.\",\n\tConcurrencyConflictError:\n\t\t\"The resource was modified by another request. Please reload and try again.\",\n\tDuplicateAggregateError: \"The resource already exists.\",\n};\n\n/** Message for a `ValidationError`, which is detected by capability, not name. */\nconst VALIDATION_MESSAGE = \"The submitted data is invalid.\";\n/** Public code and message used for any unmapped or non-kit error. */\nconst FALLBACK_CODE = \"INTERNAL_ERROR\";\nconst FALLBACK_MESSAGE = \"An unexpected error occurred.\";\n/** BCP 47 locale the built-in messages are written in. */\nconst DEFAULT_LOCALE = \"en\";\n\n/** Details shape carried by the view for a {@link toPublicErrorView} result. */\nexport interface PublicErrorViewDetails {\n\t/** Whitelisted field issues, present only for a `ValidationError`. */\n\treadonly issues: readonly PublicIssue[];\n}\n\n/** Options for {@link toPublicErrorView}. */\nexport interface PublicErrorViewOptions {\n\t/**\n\t * BCP 47 locale tag stamped on the view. The built-in messages are English;\n\t * pass a locale only when you supply your own message resolution upstream.\n\t * Default `\"en\"`.\n\t */\n\tlocale?: string;\n}\n\n/**\n * Maps a kit error (or any caught value) to a base-error\n * {@link PublicErrorView}: a **transport-neutral**, client-safe representation\n * (`code`, `message`, `locale`, optional `details`). It is deliberately *not* a\n * transport adapter: it carries no HTTP status, header, or exit code, because\n * those are the consumer's concern. Feed the view into base-error's\n * `defineProblemDetailsAdapter` (HTTP / RFC 9457), a gRPC status mapper, or a\n * CLI exit-code table, whichever boundary you are at.\n *\n * Total over `unknown`: an unmapped or non-kit value degrades to a generic\n * `INTERNAL_ERROR` view rather than leaking the technical message or throwing.\n * The kit's class-based errors match by their pinned `error.name`, so it\n * survives minification and duplicate installs (no `instanceof`). A\n * `ValidationError` is detected by its `publicIssues()` whitelist (base-error\n * names it after its code), and those issues ride along in `details.issues`.\n *\n * For richer, multi-locale messages, register the kit's errors in a base-error\n * `PublicErrorRegistry` and use `PublicErrorPresenter` instead; this helper is\n * the lean, single-locale default.\n *\n * @example\n * ```ts\n * import { toPublicErrorView } from \"@shirudo/ddd-kit/presentation\";\n * import { defineProblemDetailsAdapter } from \"@shirudo/base-error/problem-details\";\n *\n * const adapter = defineProblemDetailsAdapter({\n * definitions: { AggregateNotFoundError: { type: \"about:blank\", status: 404 } },\n * fallback: { type: \"about:blank\", status: 500 },\n * });\n *\n * const { body, status } = adapter.map(toPublicErrorView(error));\n * return Response.json(body, { status });\n * ```\n */\nexport function toPublicErrorView(\n\terror: unknown,\n\toptions: PublicErrorViewOptions = {},\n): PublicErrorView<PublicErrorViewDetails> {\n\tconst locale = options.locale ?? DEFAULT_LOCALE;\n\tconst name = errorName(error);\n\n\t// ValidationError (and subclasses) are detected by the publicIssues()\n\t// whitelist, not by name: base-error names a ValidationError after its code\n\t// (\"VALIDATION_FAILED\"), so a name match would miss it.\n\tif (hasPublicIssues(error)) {\n\t\treturn {\n\t\t\tcode: name ?? \"VALIDATION_FAILED\",\n\t\t\tmessage: VALIDATION_MESSAGE,\n\t\t\tlocale,\n\t\t\tdetails: { issues: error.publicIssues() },\n\t\t};\n\t}\n\n\tconst message = name ? KIT_PUBLIC_MESSAGES[name] : undefined;\n\tif (message === undefined) {\n\t\treturn { code: FALLBACK_CODE, message: FALLBACK_MESSAGE, locale };\n\t}\n\n\treturn { code: name as string, message, locale };\n}\n\n/** Reads a string `name` off a caught value without assuming it is an Error. */\nfunction errorName(error: unknown): string | undefined {\n\tif (typeof error !== \"object\" || error === null) return undefined;\n\tconst { name } = error as { name?: unknown };\n\treturn typeof name === \"string\" ? name : undefined;\n}\n\n/** Duck-types base-error's `ValidationError.publicIssues()` accessor. */\nfunction hasPublicIssues(\n\terror: unknown,\n): error is { publicIssues(): PublicIssue[] } {\n\treturn (\n\t\ttypeof (error as { publicIssues?: unknown }).publicIssues === \"function\"\n\t);\n}\n"]}
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { I as IAggregateRoot, a as Id, A as AnyDomainEvent } from './aggregate-DclYgG_D.js';
1
+ import { I as IAggregateRoot, a as Id, A as AnyDomainEvent } from './aggregate-CePTINEt.js';
2
2
  import '@shirudo/result';
3
3
  import '@shirudo/base-error';
4
4
 
@@ -6,9 +6,9 @@ import '@shirudo/base-error';
6
6
  * The repository surface the contract suite exercises: the minimal
7
7
  * structural subset of the canonical `IUnitOfWorkRepository` (exported
8
8
  * from the main entry) that the tests need. `getById` is typed over
9
- * the aggregate's own branded id (`TAgg["id"]`), so concrete adapters
10
- * including arrow-function-property style repositories, which are
11
- * checked contravariantly match without casts.
9
+ * the aggregate's own branded id (`TAgg["id"]`), so concrete adapters,
10
+ * including arrow-function-property style repositories, which are
11
+ * checked contravariantly, match without casts.
12
12
  */
13
13
  interface ContractRepository<TAgg extends IAggregateRoot<Id<string>, AnyDomainEvent>> {
14
14
  getById(id: TAgg["id"]): Promise<TAgg | null>;
@@ -27,7 +27,7 @@ interface RepositoryContractEnvironment<TAgg extends IAggregateRoot<Id<string>,
27
27
  * transaction, hand the suite a tx-bound repository, commit on
28
28
  * resolve, roll back on throw, and run the post-commit lifecycle
29
29
  * (event harvest into the outbox, `markPersisted`). Wire this
30
- * through your real `UnitOfWork` / `withCommit` setup the commit
30
+ * through your real `UnitOfWork` / `withCommit` setup: the commit
31
31
  * boundary IS part of what the suite proves.
32
32
  */
33
33
  run<R>(work: (ctx: {
@@ -35,7 +35,7 @@ interface RepositoryContractEnvironment<TAgg extends IAggregateRoot<Id<string>,
35
35
  }) => Promise<R>): Promise<R>;
36
36
  /**
37
37
  * All events currently persisted in the outbox (committed writes
38
- * only a rolled-back transaction's events must not appear here).
38
+ * only; a rolled-back transaction's events must not appear here).
39
39
  */
40
40
  committedOutboxEvents(): Promise<ReadonlyArray<Evt>>;
41
41
  /** Release connections, drop schemas, etc. Called in a finally. */
@@ -45,7 +45,7 @@ interface RepositoryContractEnvironment<TAgg extends IAggregateRoot<Id<string>,
45
45
  * What an adapter supplies to run the contract suite.
46
46
  *
47
47
  * The harness MUST provide isolation per environment (fresh
48
- * tables/keyspace or a truncate) tests assume they see only their
48
+ * tables/keyspace or a truncate); tests assume they see only their
49
49
  * own writes. **For SQL/ORM adapters this must run against a real
50
50
  * database** (testcontainers or equivalent), not an in-memory fake:
51
51
  * the mandatory two-writer test proves YOUR `WHERE version = ?`
@@ -53,11 +53,11 @@ interface RepositoryContractEnvironment<TAgg extends IAggregateRoot<Id<string>,
53
53
  *
54
54
  * Optional capabilities widen the suite: tests for an absent
55
55
  * capability come back **marked `skipped`** with a `run()` that
56
- * rejects loudly bind them with `it.skip` so the gap stays visible
56
+ * rejects loudly: bind them with `it.skip` so the gap stays visible
57
57
  * in every report (see {@link RepositoryContractTest}); a naive
58
58
  * binding fails instead of green-no-op'ing. Capabilities are captured
59
59
  * once at suite creation. Provide every capability your adapter can
60
- * support each one closes a real OCC hole.
60
+ * support; each one closes a real OCC hole.
61
61
  */
62
62
  interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>, Evt extends AnyDomainEvent = AnyDomainEvent> {
63
63
  createEnvironment(): Promise<RepositoryContractEnvironment<TAgg, Evt>>;
@@ -76,7 +76,7 @@ interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>
76
76
  /**
77
77
  * Optional: a version-bumping mutation whose state is deep-equal to
78
78
  * the previous state (`setState({...state}, true)`). Enables the
79
- * version-only-change-still-persists test the skip-save/OCC-desync
79
+ * version-only-change-still-persists test: the skip-save/OCC-desync
80
80
  * trap.
81
81
  */
82
82
  mutateVersionOnly?(aggregate: TAgg): void;
@@ -99,9 +99,9 @@ interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>
99
99
  /**
100
100
  * Semantic opt-OUT (default `true`): whether `save()`'s INSERT path
101
101
  * rejects an existing id with `DuplicateAggregateError` (mapping the
102
- * driver's unique-violation Postgres `23505`, MySQL `1062`, SQLite
102
+ * driver's unique-violation: Postgres `23505`, MySQL `1062`, SQLite
103
103
  * `SQLITE_CONSTRAINT_UNIQUE`). This is the near-mandatory contract:
104
- * `save()` is insert-or-update, never upsert — create-idempotency
104
+ * `save()` is insert-or-update, never upsert. Create-idempotency
105
105
  * belongs in the USE CASE (load, then decide), not in the save path.
106
106
  * Set `false` ONLY for a deliberately upserting adapter
107
107
  * (idempotent-create design); the duplicate-insert test is then
@@ -114,12 +114,12 @@ interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>
114
114
  * Optional: a plain-data projection of the aggregate's persisted
115
115
  * state, compared with deep equality. Enables the mandatory test's
116
116
  * state assertion (without it, only the version and the outbox are
117
- * compared an adapter whose predicate guards the version write
117
+ * compared, and an adapter whose predicate guards the version write
118
118
  * but not the state write would slip through).
119
119
  *
120
120
  * **The projection must be roundtrip-stable**: it compares a
121
121
  * DB-reloaded aggregate against an in-memory one, so normalize
122
- * anything your store changes in transit dates to ISO strings at
122
+ * anything your store changes in transit: dates to ISO strings at
123
123
  * your store's precision (MySQL DATETIME truncates millis), no
124
124
  * `undefined`-valued keys (JSON columns drop them), decimals/bigints
125
125
  * to one consistent representation. A mismatch here fails the
@@ -130,7 +130,7 @@ interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>
130
130
  * Optional flag: declare it when your `delete(aggregate)` runs an
131
131
  * OCC predicate (`DELETE … WHERE id = ? AND version = ?`). Enables
132
132
  * the stale-delete conflict test. Unpredicated deletes are
133
- * last-write-wins by construction acceptable for GC-style
133
+ * last-write-wins by construction: acceptable for GC-style
134
134
  * cleanup, rarely for user-initiated deletion of contended
135
135
  * aggregates (see the repository guide).
136
136
  */
@@ -142,7 +142,7 @@ interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>
142
142
  * entry is still returned with {@link skipped} set and a `run` that
143
143
  * REJECTS with an explanatory error: bind it with your runner's skip
144
144
  * (`(test.skipped ? it.skip : it)(test.name, test.run)`) so the gap is
145
- * visible in every test report a missing capability must never look
145
+ * visible in every test report: a missing capability must never look
146
146
  * like green coverage, and a naive binding that ignores `skipped`
147
147
  * fails loud instead of passing silently.
148
148
  */
@@ -158,7 +158,7 @@ interface RepositoryContractTest {
158
158
  * The repository contract test suite: the proof that an adapter
159
159
  * actually delivers the guarantees the kit's Unit of Work documents.
160
160
  *
161
- * The kit is ORM-agnostic the OCC version predicate lives in YOUR
161
+ * The kit is ORM-agnostic: the OCC version predicate lives in YOUR
162
162
  * repository's SQL. That makes optimistic concurrency a **repository
163
163
  * contract, not a kit guarantee**: the kit ships the boundary, the
164
164
  * `persistedVersion` baseline, `ConcurrencyConflictError`, and this
@@ -208,12 +208,12 @@ interface RepositoryContractTest {
208
208
  *
209
209
  * **`env.run` must provide unit-of-work semantics.** Three core tests
210
210
  * (identity-map sameness, getById-null-after-delete, deletion
211
- * finality) exercise the session machinery `session.identityMap`,
211
+ * finality) exercise the session machinery: `session.identityMap`,
212
212
  * the `isDeleted` probe, the deleted-gate. Wiring `run` through the
213
213
  * kit's `UnitOfWork` gives you all of it; a hand-rolled `withCommit`
214
214
  * wiring must provide equivalents or those tests will fail. A
215
215
  * `withCommit`-only setup that deliberately makes no identity-map /
216
- * deletion-finality claims is outside this suite's scope the suite
216
+ * deletion-finality claims is outside this suite's scope; the suite
217
217
  * is the compliance bar for unit-of-work repositories.
218
218
  *
219
219
  * **Error matching is by NAME along the `cause` chain, not by
@@ -227,20 +227,21 @@ interface RepositoryContractTest {
227
227
  * tests prove YOUR adapter's SQL and transaction wiring. The
228
228
  * identity-map, deletion-finality, and event-lifecycle tests prove
229
229
  * your READ-PATH and unit-of-work WIRING (they exercise kit-provided
230
- * machinery `session.identityMap`, the deleted-gate, `withCommit`'s
231
- * harvest and fail when your repository bypasses or mis-wires it).
230
+ * machinery, namely `session.identityMap`, the deleted-gate, and
231
+ * `withCommit`'s harvest, and fail when your repository bypasses or
232
+ * mis-wires it).
232
233
  * A deletion-finality failure usually means a missing
233
234
  * `identityMap.isDeleted` check or an `enrollSaved` placed after the
234
235
  * row write, not a broken DELETE statement.
235
236
  *
236
237
  * **Known limitation: no truly concurrent runs.** The mandatory
237
- * two-writer test is deliberately sequential-deterministic writer B
238
+ * two-writer test is deliberately sequential-deterministic: writer B
238
239
  * loads, writer A loads/mutates/commits, then B commits its stale
239
240
  * instance. The stale `persistedVersion` baseline travels with B's
240
241
  * instance, so the version predicate is exercised exactly as in a true
241
242
  * race, without depending on lock timing, pool sizes, or
242
243
  * engine-specific blocking. The flip side: lock interaction is NOT
243
- * covered a `SELECT … FOR UPDATE`-style repository that blocks
244
+ * covered. A `SELECT … FOR UPDATE`-style repository that blocks
244
245
  * instead of conflicting, or a SERIALIZABLE engine surfacing raw
245
246
  * serialization failures (Postgres 40001) your adapter must map to
246
247
  * `ConcurrencyConflictError`, needs adapter-specific tests on top of