@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.
- package/README.md +2 -2
- package/dist/{aggregate-DclYgG_D.d.ts → aggregate-CePTINEt.d.ts} +93 -33
- package/dist/http.d.ts +36 -13
- package/dist/http.js +15 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +249 -52
- package/dist/index.js +295 -71
- package/dist/index.js.map +1 -1
- package/dist/presentation.d.ts +54 -0
- package/dist/presentation.js +45 -0
- package/dist/presentation.js.map +1 -0
- package/dist/testing.d.ts +24 -23
- package/dist/testing.js.map +1 -1
- package/package.json +18 -11
|
@@ -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-
|
|
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
|
-
*
|
|
11
|
-
* checked contravariantly
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
231
|
-
* harvest
|
|
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
|
|
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
|
|
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
|