@shirudo/ddd-kit 1.1.0 → 1.3.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,252 @@
1
+ import { I as IAggregateRoot, a as Id, A as AnyDomainEvent } from './aggregate-BGdgvqKh.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, and 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, namely `session.identityMap`, the deleted-gate, and
231
+ * `withCommit`'s harvest, and fail when your repository bypasses or
232
+ * mis-wires it).
233
+ * A deletion-finality failure usually means a missing
234
+ * `identityMap.isDeleted` check or an `enrollSaved` placed after the
235
+ * row write, not a broken DELETE statement.
236
+ *
237
+ * **Known limitation: no truly concurrent runs.** The mandatory
238
+ * two-writer test is deliberately sequential-deterministic: writer B
239
+ * loads, writer A loads/mutates/commits, then B commits its stale
240
+ * instance. The stale `persistedVersion` baseline travels with B's
241
+ * instance, so the version predicate is exercised exactly as in a true
242
+ * race, without depending on lock timing, pool sizes, or
243
+ * engine-specific blocking. The flip side: lock interaction is NOT
244
+ * covered. A `SELECT … FOR UPDATE`-style repository that blocks
245
+ * instead of conflicting, or a SERIALIZABLE engine surfacing raw
246
+ * serialization failures (Postgres 40001) your adapter must map to
247
+ * `ConcurrencyConflictError`, needs adapter-specific tests on top of
248
+ * this suite.
249
+ */
250
+ declare function createRepositoryContractTests<TAgg extends IAggregateRoot<Id<string>, Evt>, Evt extends AnyDomainEvent = AnyDomainEvent>(harness: RepositoryContractHarness<TAgg, Evt>): RepositoryContractTest[];
251
+
252
+ export { type ContractRepository, type RepositoryContractEnvironment, type RepositoryContractHarness, type RepositoryContractTest, createRepositoryContractTests };