@shirudo/ddd-kit 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -18
- package/dist/aggregate-DclYgG_D.d.ts +662 -0
- package/dist/http.d.ts +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +623 -655
- package/dist/index.js +554 -48
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +251 -0
- package/dist/testing.js +793 -0
- package/dist/testing.js.map +1 -0
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js.map +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { I as IAggregateRoot, a as Id, A as AnyDomainEvent } from './aggregate-DclYgG_D.js';
|
|
2
|
+
import '@shirudo/result';
|
|
3
|
+
import '@shirudo/base-error';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The repository surface the contract suite exercises: the minimal
|
|
7
|
+
* structural subset of the canonical `IUnitOfWorkRepository` (exported
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
interface ContractRepository<TAgg extends IAggregateRoot<Id<string>, AnyDomainEvent>> {
|
|
14
|
+
getById(id: TAgg["id"]): Promise<TAgg | null>;
|
|
15
|
+
save(aggregate: TAgg): Promise<void>;
|
|
16
|
+
delete(aggregate: TAgg): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One isolated test environment: fresh storage, fresh outbox. The
|
|
20
|
+
* suite creates one per test via {@link RepositoryContractHarness} and
|
|
21
|
+
* tears it down afterwards (a teardown failure never masks an
|
|
22
|
+
* in-flight contract violation).
|
|
23
|
+
*/
|
|
24
|
+
interface RepositoryContractEnvironment<TAgg extends IAggregateRoot<Id<string>, Evt>, Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
25
|
+
/**
|
|
26
|
+
* Execute one unit of work against the adapter under test: open the
|
|
27
|
+
* transaction, hand the suite a tx-bound repository, commit on
|
|
28
|
+
* resolve, roll back on throw, and run the post-commit lifecycle
|
|
29
|
+
* (event harvest into the outbox, `markPersisted`). Wire this
|
|
30
|
+
* through your real `UnitOfWork` / `withCommit` setup — the commit
|
|
31
|
+
* boundary IS part of what the suite proves.
|
|
32
|
+
*/
|
|
33
|
+
run<R>(work: (ctx: {
|
|
34
|
+
repository: ContractRepository<TAgg>;
|
|
35
|
+
}) => Promise<R>): Promise<R>;
|
|
36
|
+
/**
|
|
37
|
+
* All events currently persisted in the outbox (committed writes
|
|
38
|
+
* only — a rolled-back transaction's events must not appear here).
|
|
39
|
+
*/
|
|
40
|
+
committedOutboxEvents(): Promise<ReadonlyArray<Evt>>;
|
|
41
|
+
/** Release connections, drop schemas, etc. Called in a finally. */
|
|
42
|
+
teardown?(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What an adapter supplies to run the contract suite.
|
|
46
|
+
*
|
|
47
|
+
* The harness MUST provide isolation per environment (fresh
|
|
48
|
+
* tables/keyspace or a truncate) — tests assume they see only their
|
|
49
|
+
* own writes. **For SQL/ORM adapters this must run against a real
|
|
50
|
+
* database** (testcontainers or equivalent), not an in-memory fake:
|
|
51
|
+
* the mandatory two-writer test proves YOUR `WHERE version = ?`
|
|
52
|
+
* predicate, and an in-memory stand-in proves only itself.
|
|
53
|
+
*
|
|
54
|
+
* Optional capabilities widen the suite: tests for an absent
|
|
55
|
+
* capability come back **marked `skipped`** with a `run()` that
|
|
56
|
+
* rejects loudly — bind them with `it.skip` so the gap stays visible
|
|
57
|
+
* in every report (see {@link RepositoryContractTest}); a naive
|
|
58
|
+
* binding fails instead of green-no-op'ing. Capabilities are captured
|
|
59
|
+
* once at suite creation. Provide every capability your adapter can
|
|
60
|
+
* support — each one closes a real OCC hole.
|
|
61
|
+
*/
|
|
62
|
+
interface RepositoryContractHarness<TAgg extends IAggregateRoot<Id<string>, Evt>, Evt extends AnyDomainEvent = AnyDomainEvent> {
|
|
63
|
+
createEnvironment(): Promise<RepositoryContractEnvironment<TAgg, Evt>>;
|
|
64
|
+
/**
|
|
65
|
+
* A brand-new aggregate (never persisted, unique id,
|
|
66
|
+
* `persistedVersion === undefined`).
|
|
67
|
+
*/
|
|
68
|
+
createAggregate(): TAgg;
|
|
69
|
+
/**
|
|
70
|
+
* Apply exactly ONE version-bumping domain mutation that records at
|
|
71
|
+
* least one domain event (a `commit()`-style state change). The
|
|
72
|
+
* suite relies on the +1-per-call arithmetic and on the event for
|
|
73
|
+
* its outbox assertions.
|
|
74
|
+
*/
|
|
75
|
+
mutate(aggregate: TAgg): void;
|
|
76
|
+
/**
|
|
77
|
+
* Optional: a version-bumping mutation whose state is deep-equal to
|
|
78
|
+
* the previous state (`setState({...state}, true)`). Enables the
|
|
79
|
+
* version-only-change-still-persists test — the skip-save/OCC-desync
|
|
80
|
+
* trap.
|
|
81
|
+
*/
|
|
82
|
+
mutateVersionOnly?(aggregate: TAgg): void;
|
|
83
|
+
/**
|
|
84
|
+
* Optional: a mutation that changes ONLY a child collection (a
|
|
85
|
+
* non-root-row `changedKeys` entry). Enables the
|
|
86
|
+
* child-change-bumps-root-version test for partial-write
|
|
87
|
+
* repositories.
|
|
88
|
+
*/
|
|
89
|
+
mutateChildCollection?(aggregate: TAgg): void;
|
|
90
|
+
/**
|
|
91
|
+
* Optional: construct a NEW (never-persisted) aggregate instance
|
|
92
|
+
* carrying a SPECIFIC id. Enables TWO tests: deletion-is-final-
|
|
93
|
+
* across-instances (resurrection via a factory after delete) and
|
|
94
|
+
* the duplicate-insert test (see
|
|
95
|
+
* {@link insertsAreDuplicateChecked} to opt out of the latter
|
|
96
|
+
* independently).
|
|
97
|
+
*/
|
|
98
|
+
createAggregateWithId?(id: TAgg["id"]): TAgg;
|
|
99
|
+
/**
|
|
100
|
+
* Semantic opt-OUT (default `true`): whether `save()`'s INSERT path
|
|
101
|
+
* rejects an existing id with `DuplicateAggregateError` (mapping the
|
|
102
|
+
* driver's unique-violation — Postgres `23505`, MySQL `1062`, SQLite
|
|
103
|
+
* `SQLITE_CONSTRAINT_UNIQUE`). This is the near-mandatory contract:
|
|
104
|
+
* `save()` is insert-or-update, never upsert — create-idempotency
|
|
105
|
+
* belongs in the USE CASE (load, then decide), not in the save path.
|
|
106
|
+
* Set `false` ONLY for a deliberately upserting adapter
|
|
107
|
+
* (idempotent-create design); the duplicate-insert test is then
|
|
108
|
+
* reported as skipped under this capability name, without costing
|
|
109
|
+
* the deletion-finality coverage that `createAggregateWithId` also
|
|
110
|
+
* gates.
|
|
111
|
+
*/
|
|
112
|
+
insertsAreDuplicateChecked?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Optional: a plain-data projection of the aggregate's persisted
|
|
115
|
+
* state, compared with deep equality. Enables the mandatory test's
|
|
116
|
+
* state assertion (without it, only the version and the outbox are
|
|
117
|
+
* compared — an adapter whose predicate guards the version write
|
|
118
|
+
* but not the state write would slip through).
|
|
119
|
+
*
|
|
120
|
+
* **The projection must be roundtrip-stable**: it compares a
|
|
121
|
+
* DB-reloaded aggregate against an in-memory one, so normalize
|
|
122
|
+
* anything your store changes in transit — dates to ISO strings at
|
|
123
|
+
* your store's precision (MySQL DATETIME truncates millis), no
|
|
124
|
+
* `undefined`-valued keys (JSON columns drop them), decimals/bigints
|
|
125
|
+
* to one consistent representation. A mismatch here fails the
|
|
126
|
+
* mandatory test; the message names the projection as a suspect.
|
|
127
|
+
*/
|
|
128
|
+
snapshotState?(aggregate: TAgg): unknown;
|
|
129
|
+
/**
|
|
130
|
+
* Optional flag: declare it when your `delete(aggregate)` runs an
|
|
131
|
+
* OCC predicate (`DELETE … WHERE id = ? AND version = ?`). Enables
|
|
132
|
+
* the stale-delete conflict test. Unpredicated deletes are
|
|
133
|
+
* last-write-wins by construction — acceptable for GC-style
|
|
134
|
+
* cleanup, rarely for user-initiated deletion of contended
|
|
135
|
+
* aggregates (see the repository guide).
|
|
136
|
+
*/
|
|
137
|
+
deletesAreVersionChecked?: boolean;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* One named contract test; `run` rejects with a descriptive Error on
|
|
141
|
+
* violation. When the harness lacks the capability a test needs, the
|
|
142
|
+
* entry is still returned with {@link skipped} set and a `run` that
|
|
143
|
+
* REJECTS with an explanatory error: bind it with your runner's skip
|
|
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
|
|
146
|
+
* like green coverage, and a naive binding that ignores `skipped`
|
|
147
|
+
* fails loud instead of passing silently.
|
|
148
|
+
*/
|
|
149
|
+
interface RepositoryContractTest {
|
|
150
|
+
name: string;
|
|
151
|
+
run: () => Promise<void>;
|
|
152
|
+
/** Present when the harness lacks the capability this test needs. */
|
|
153
|
+
skipped?: {
|
|
154
|
+
capability: string;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The repository contract test suite: the proof that an adapter
|
|
159
|
+
* actually delivers the guarantees the kit's Unit of Work documents.
|
|
160
|
+
*
|
|
161
|
+
* The kit is ORM-agnostic — the OCC version predicate lives in YOUR
|
|
162
|
+
* repository's SQL. That makes optimistic concurrency a **repository
|
|
163
|
+
* contract, not a kit guarantee**: the kit ships the boundary, the
|
|
164
|
+
* `persistedVersion` baseline, `ConcurrencyConflictError`, and this
|
|
165
|
+
* suite; your adapter must pass it. An adapter that has not passed the
|
|
166
|
+
* suite (against a real database, for SQL adapters) has not
|
|
167
|
+
* demonstrated OCC.
|
|
168
|
+
*
|
|
169
|
+
* Framework-agnostic: assertions throw plain `Error`s, so the suite
|
|
170
|
+
* binds to vitest, jest, or `node:test` the same way:
|
|
171
|
+
*
|
|
172
|
+
* ```ts
|
|
173
|
+
* import { describe, it } from "vitest";
|
|
174
|
+
* import { createRepositoryContractTests } from "@shirudo/ddd-kit/testing";
|
|
175
|
+
*
|
|
176
|
+
* const harness: RepositoryContractHarness<Order, OrderEvent> = {
|
|
177
|
+
* createEnvironment: async () => {
|
|
178
|
+
* const schema = await provisionTestSchema(); // testcontainers etc.
|
|
179
|
+
* const uowDeps = {
|
|
180
|
+
* scope: schema.scope,
|
|
181
|
+
* outbox: schema.outbox,
|
|
182
|
+
* repositories: {
|
|
183
|
+
* orders: (tx, session) => new DrizzleOrderRepository(tx, session),
|
|
184
|
+
* },
|
|
185
|
+
* };
|
|
186
|
+
* return {
|
|
187
|
+
* run: (work) =>
|
|
188
|
+
* new UnitOfWork(uowDeps).run(({ repositories }) =>
|
|
189
|
+
* work({ repository: repositories.orders })),
|
|
190
|
+
* committedOutboxEvents: () => schema.readOutboxEvents(),
|
|
191
|
+
* teardown: () => schema.drop(),
|
|
192
|
+
* };
|
|
193
|
+
* },
|
|
194
|
+
* createAggregate: () => Order.draft(orderIds.next()),
|
|
195
|
+
* mutate: (order) => order.changeNote(`note-${counter++}`), // ONE bump + event
|
|
196
|
+
* // provide every optional capability your adapter supports:
|
|
197
|
+
* createAggregateWithId: (id) => Order.draft(id),
|
|
198
|
+
* snapshotState: (order) => normalizeForRoundtrip(order.state),
|
|
199
|
+
* deletesAreVersionChecked: true,
|
|
200
|
+
* };
|
|
201
|
+
*
|
|
202
|
+
* describe("DrizzleOrderRepository: repository contract", () => {
|
|
203
|
+
* for (const test of createRepositoryContractTests(harness)) {
|
|
204
|
+
* (test.skipped ? it.skip : it)(test.name, test.run);
|
|
205
|
+
* }
|
|
206
|
+
* });
|
|
207
|
+
* ```
|
|
208
|
+
*
|
|
209
|
+
* **`env.run` must provide unit-of-work semantics.** Three core tests
|
|
210
|
+
* (identity-map sameness, getById-null-after-delete, deletion
|
|
211
|
+
* finality) exercise the session machinery — `session.identityMap`,
|
|
212
|
+
* the `isDeleted` probe, the deleted-gate. Wiring `run` through the
|
|
213
|
+
* kit's `UnitOfWork` gives you all of it; a hand-rolled `withCommit`
|
|
214
|
+
* wiring must provide equivalents or those tests will fail. A
|
|
215
|
+
* `withCommit`-only setup that deliberately makes no identity-map /
|
|
216
|
+
* deletion-finality claims is outside this suite's scope — the suite
|
|
217
|
+
* is the compliance bar for unit-of-work repositories.
|
|
218
|
+
*
|
|
219
|
+
* **Error matching is by NAME along the `cause` chain, not by
|
|
220
|
+
* `instanceof`.** The suite ships in its own bundle entry; comparing
|
|
221
|
+
* class identity would spuriously fail whenever the adapter's errors
|
|
222
|
+
* come from a different copy of the kit (the main entry's bundle, or a
|
|
223
|
+
* second installed version). `error.name === "ConcurrencyConflictError"`
|
|
224
|
+
* anywhere in the chain is the contract.
|
|
225
|
+
*
|
|
226
|
+
* **What each test proves.** The OCC, routing, rollback, and outbox
|
|
227
|
+
* tests prove YOUR adapter's SQL and transaction wiring. The
|
|
228
|
+
* identity-map, deletion-finality, and event-lifecycle tests prove
|
|
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).
|
|
232
|
+
* A deletion-finality failure usually means a missing
|
|
233
|
+
* `identityMap.isDeleted` check or an `enrollSaved` placed after the
|
|
234
|
+
* row write, not a broken DELETE statement.
|
|
235
|
+
*
|
|
236
|
+
* **Known limitation: no truly concurrent runs.** The mandatory
|
|
237
|
+
* two-writer test is deliberately sequential-deterministic — writer B
|
|
238
|
+
* loads, writer A loads/mutates/commits, then B commits its stale
|
|
239
|
+
* instance. The stale `persistedVersion` baseline travels with B's
|
|
240
|
+
* instance, so the version predicate is exercised exactly as in a true
|
|
241
|
+
* race, without depending on lock timing, pool sizes, or
|
|
242
|
+
* engine-specific blocking. The flip side: lock interaction is NOT
|
|
243
|
+
* covered — a `SELECT … FOR UPDATE`-style repository that blocks
|
|
244
|
+
* instead of conflicting, or a SERIALIZABLE engine surfacing raw
|
|
245
|
+
* serialization failures (Postgres 40001) your adapter must map to
|
|
246
|
+
* `ConcurrencyConflictError`, needs adapter-specific tests on top of
|
|
247
|
+
* this suite.
|
|
248
|
+
*/
|
|
249
|
+
declare function createRepositoryContractTests<TAgg extends IAggregateRoot<Id<string>, Evt>, Evt extends AnyDomainEvent = AnyDomainEvent>(harness: RepositoryContractHarness<TAgg, Evt>): RepositoryContractTest[];
|
|
250
|
+
|
|
251
|
+
export { type ContractRepository, type RepositoryContractEnvironment, type RepositoryContractHarness, type RepositoryContractTest, createRepositoryContractTests };
|