@kairos-es/testing 0.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/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Neverbland
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @kairos-es/testing
2
+
3
+ Given/When/Then decision-model testing surface for kairos-es.
4
+
5
+ A `DeciderSpecification`-equivalent over the codec's `buildDecisionModel`, running
6
+ the real read → decode → decide → append cycle against the in-memory store rather
7
+ than a mock, on `@effect/vitest`. `@kairos-es/core`, `@kairos-es/codec`, `effect`
8
+ and `@effect/vitest` are peer dependencies.
9
+
10
+ The `Decider` a scenario exercises and the `runDecider` that runs it are
11
+ production symbols and belong to [`@kairos-es/codec`](../codec); this package
12
+ imports them and deliberately does not re-export them, so typing a command
13
+ handler never means naming a package that peers a test framework.
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ var _specification = require("./specification.js");
7
+ Object.keys(_specification).forEach(function (key) {
8
+ if (key === "default" || key === "__esModule") return;
9
+ if (key in exports && exports[key] === _specification[key]) return;
10
+ Object.defineProperty(exports, key, {
11
+ enumerable: true,
12
+ get: function () {
13
+ return _specification[key];
14
+ }
15
+ });
16
+ });
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["_specification","require","Object","keys","forEach","key","exports","defineProperty","enumerable","get"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":";;;;;AA0BA,IAAAA,cAAA,GAAAC,OAAA;AAAAC,MAAA,CAAAC,IAAA,CAAAH,cAAA,EAAAI,OAAA,WAAAC,GAAA;EAAA,IAAAA,GAAA,kBAAAA,GAAA;EAAA,IAAAA,GAAA,IAAAC,OAAA,IAAAA,OAAA,CAAAD,GAAA,MAAAL,cAAA,CAAAK,GAAA;EAAAH,MAAA,CAAAK,cAAA,CAAAD,OAAA,EAAAD,GAAA;IAAAG,UAAA;IAAAC,GAAA,WAAAA,CAAA;MAAA,OAAAT,cAAA,CAAAK,GAAA;IAAA;EAAA;AAAA","ignoreList":[]}
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.specificationFor = exports.scenario = void 0;
7
+ var _vitest = require("@effect/vitest");
8
+ var _codec = require("@kairos-es/codec");
9
+ var _core = require("@kairos-es/core");
10
+ var _effect = require("effect");
11
+ /**
12
+ * The Given/When/Then surface: a `DeciderSpecification`-equivalent over the real
13
+ * `@kairos-es/codec` `buildDecisionModel` fold and a `DcbEventStore` (the
14
+ * in-memory layer in practice). It is shaped so an Event Modeling
15
+ * `Specification` (given events, when a command, then events / thenError a tag)
16
+ * compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
17
+ * `DeciderSpecification`.
18
+ *
19
+ * specificationFor(decider)
20
+ * .given(...pastEventProducers)
21
+ * .when(command)
22
+ * .then(...expectedNewEventProducers) // success branch
23
+ *
24
+ * specificationFor(decider)
25
+ * .given(...pastEventProducers)
26
+ * .when(command)
27
+ * .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
28
+ *
29
+ * Events are supplied *effectfully*: a given/expected event is the producer an
30
+ * application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
31
+ * Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
32
+ * under the same layers the cycle runs against, and their `data` bytes match.
33
+ *
34
+ * Each terminal (`then` / `thenError`) returns an `Effect` requiring
35
+ * `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
36
+ * serialiser, and uuid layers (fresh store per case for isolation).
37
+ * `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
38
+ * against a fresh in-memory store plus `SerializerDefault` and `UuidTest`, so
39
+ * example specs read as one line.
40
+ *
41
+ * `then` compares the *appended* events (those written past the given seed) to
42
+ * the expected batch structurally — by `type`, `tags`, and `data` — ignoring
43
+ * the store-assigned `position` and the producer-supplied `uuid`/`occurredAt`,
44
+ * which are not part of the decision under test. JSON serialisation is
45
+ * deterministic, so equal payloads produce equal `data` bytes.
46
+ */
47
+
48
+ const shapeOf = event => ({
49
+ type: event.type,
50
+ tags: event.tags,
51
+ data: event.data
52
+ });
53
+ /** Collect a stream of sequenced events into a plain array. */
54
+ const collect = events => _effect.Stream.runCollect(events).pipe(_effect.Effect.map(_effect.Chunk.toReadonlyArray));
55
+ /**
56
+ * Seed the given past-event producers into the store: materialise each producer
57
+ * (encode -> serialise), then one append each (log order). Returns the position
58
+ * the LAST seeded append was written at, or `ORIGIN` when nothing was seeded —
59
+ * this is the boundary `then` reads strictly after.
60
+ *
61
+ * We track the boundary by position rather than by count because positions are
62
+ * gappy on a real store (Postgres `bigserial` skips values on rollbacks and
63
+ * across concurrent writers), so "everything past N rows" is wrong; "everything
64
+ * strictly after this position" is exact regardless of gaps.
65
+ */
66
+ const seed = producers => _effect.Effect.gen(function* () {
67
+ const store = yield* _core.DcbEventStore;
68
+ let last = _core.ORIGIN;
69
+ for (const producer of producers) {
70
+ const event = yield* producer;
71
+ // Unconditional appends never fail; a failure here would be a defect in
72
+ // the store, not a domain outcome, so turn it into one.
73
+ last = yield* store.append([event]).pipe(_effect.Effect.orDie);
74
+ }
75
+ return last;
76
+ });
77
+ /**
78
+ * The events appended past the seed boundary, so `then` can read back exactly
79
+ * the events the decider appended. We read all matching events strictly after
80
+ * `after` (the last seeded position), which is correct on a gappy store where
81
+ * dropping a fixed row count would not be — the in-memory store gives the
82
+ * identical result because its positions are gapless.
83
+ */
84
+ const appendedAfter = after => _effect.Effect.gen(function* () {
85
+ const store = yield* _core.DcbEventStore;
86
+ const {
87
+ events
88
+ } = yield* store.read(_core.query.all(), {
89
+ after
90
+ });
91
+ return yield* collect(events);
92
+ });
93
+ /**
94
+ * Begin a specification for `decider`. The returned `given(...)` starts the
95
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
96
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
97
+ */
98
+ const specificationFor = decider => ({
99
+ given: (...pastEvents) => ({
100
+ when: command => {
101
+ // Run the seed + decider cycle, exposing the outcome as an Exit so both
102
+ // branches (then / thenError) can inspect success or failure uniformly. The
103
+ // seed is inside the Exit too, so a decode failure while materialising a
104
+ // given producer is captured as a failure rather than escaping the channel.
105
+ // The success value pairs the seed boundary (the last seeded position) with
106
+ // the decider's outcome, so `then` reads strictly after that boundary
107
+ // rather than dropping a fixed row count (correct on a gappy store).
108
+ const runToExit = seed(pastEvents).pipe(_effect.Effect.zip((0, _codec.runDecider)(decider, command)), _effect.Effect.exit);
109
+ return {
110
+ // biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
111
+ then: (...expected) => _effect.Effect.gen(function* () {
112
+ const exit = yield* runToExit;
113
+ if (_effect.Exit.isFailure(exit)) {
114
+ // Surface the real failure to the caller's error channel; a
115
+ // `.then` on a failing cycle is a test authoring mistake, so make
116
+ // it loud rather than silently passing.
117
+ return yield* _effect.Effect.failCause(exit.cause);
118
+ }
119
+ const [seedBoundary] = exit.value;
120
+ const appended = yield* appendedAfter(seedBoundary);
121
+ const expectedEvents = yield* _effect.Effect.all(expected);
122
+ (0, _vitest.expect)(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
123
+ }),
124
+ thenError: tag => _effect.Effect.gen(function* () {
125
+ const exit = yield* runToExit;
126
+ (0, _vitest.expect)(_effect.Exit.isFailure(exit)).toBe(true);
127
+ if (_effect.Exit.isFailure(exit)) {
128
+ const failure = _effect.Cause.failureOption(exit.cause);
129
+ const error = failure._tag === 'Some' ? failure.value : undefined;
130
+ const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
131
+ (0, _vitest.expect)(actualTag).toBe(tag);
132
+ }
133
+ })
134
+ };
135
+ }
136
+ })
137
+ });
138
+ /**
139
+ * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
140
+ * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
141
+ * `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
142
+ * layer. The callback receives `specificationFor(decider)` so example suites
143
+ * read as a single Given/When/Then line per case.
144
+ *
145
+ * `scenario` provides exactly the store, serialiser, and uuid, so `build` must
146
+ * return an effect requiring only those three. For a plain decider (`R` and
147
+ * `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
148
+ * encryption requirement provides its cipher layer inside `build` before
149
+ * returning.
150
+ */
151
+ exports.specificationFor = specificationFor;
152
+ const scenario = (description, decider, build) => {
153
+ _vitest.it.effect(description, () => build(specificationFor(decider)).pipe(
154
+ // A fresh store + uuid layer per case: both are stateful, so isolation is
155
+ // required (mirrors the contract suite's per-case layer thunk).
156
+ _effect.Effect.provide(_effect.Layer.mergeAll(_effect.Layer.fresh(_core.DcbEventStoreInMemory), _codec.SerializerDefault, _effect.Layer.fresh(_codec.UuidTest)))));
157
+ };
158
+ exports.scenario = scenario;
159
+ //# sourceMappingURL=specification.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specification.js","names":["_vitest","require","_codec","_core","_effect","shapeOf","event","type","tags","data","collect","events","Stream","runCollect","pipe","Effect","map","Chunk","toReadonlyArray","seed","producers","gen","store","DcbEventStore","last","ORIGIN","producer","append","orDie","appendedAfter","after","read","Q","all","specificationFor","decider","given","pastEvents","when","command","runToExit","zip","runDecider","exit","then","expected","Exit","isFailure","failCause","cause","seedBoundary","value","appended","expectedEvents","expect","s","toEqual","thenError","tag","toBe","failure","Cause","failureOption","error","_tag","undefined","actualTag","exports","scenario","description","build","it","effect","provide","Layer","mergeAll","fresh","DcbEventStoreInMemory","SerializerDefault","UuidTest"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":";;;;;;AAoCA,IAAAA,OAAA,GAAAC,OAAA;AAOA,IAAAC,MAAA,GAAAD,OAAA;AAOA,IAAAE,KAAA,GAAAF,OAAA;AAUA,IAAAG,OAAA,GAAAH,OAAA;AA5DA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4EA,MAAMI,OAAO,GAAIC,KAAe,KAAkB;EAChDC,IAAI,EAAED,KAAK,CAACC,IAAI;EAChBC,IAAI,EAAEF,KAAK,CAACE,IAAI;EAChBC,IAAI,EAAEH,KAAK,CAACG;CACb,CAAC;AAEF;AACA,MAAMC,OAAO,GACXC,MAAqC,IAErCC,cAAM,CAACC,UAAU,CAACF,MAAM,CAAC,CAACG,IAAI,CAACC,cAAM,CAACC,GAAG,CAACC,aAAK,CAACC,eAAe,CAAC,CAAC;AAEnE;;;;;;;;;;;AAWA,MAAMC,IAAI,GACRC,SAA0C,IAE1CL,cAAM,CAACM,GAAG,CAAC,aAAS;EAClB,MAAMC,KAAK,GAAG,OAAOC,mBAAa;EAClC,IAAIC,IAAI,GAAaC,YAAM;EAC3B,KAAK,MAAMC,QAAQ,IAAIN,SAAS,EAAE;IAChC,MAAMd,KAAK,GAAG,OAAOoB,QAAQ;IAC7B;IACA;IACAF,IAAI,GAAG,OAAOF,KAAK,CAACK,MAAM,CAAC,CAACrB,KAAK,CAAC,CAAC,CAACQ,IAAI,CAACC,cAAM,CAACa,KAAK,CAAC;EACxD;EACA,OAAOJ,IAAI;AACb,CAAC,CAAC;AAEJ;;;;;;;AAOA,MAAMK,aAAa,GACjBC,KAAe,IAEff,cAAM,CAACM,GAAG,CAAC,aAAS;EAClB,MAAMC,KAAK,GAAG,OAAOC,mBAAa;EAClC,MAAM;IAAEZ;EAAM,CAAE,GAAG,OAAOW,KAAK,CAACS,IAAI,CAACC,WAAC,CAACC,GAAG,EAAE,EAAE;IAAEH;EAAK,CAAE,CAAC;EACxD,OAAO,OAAOpB,OAAO,CAACC,MAAM,CAAC;AAC/B,CAAC,CAAC;AAgEJ;;;;;AAKO,MAAMuB,gBAAgB,GAM3BC,OAAkC,KACkB;EACpDC,KAAK,EAAEA,CAAC,GAAGC,UAAU,MAAM;IACzBC,IAAI,EAAGC,OAAO,IAAI;MAChB;MACA;MACA;MACA;MACA;MACA;MACA;MACA,MAAMC,SAAS,GAAGrB,IAAI,CAACkB,UAAU,CAAC,CAACvB,IAAI,CACrCC,cAAM,CAAC0B,GAAG,CAAC,IAAAC,iBAAU,EAACP,OAAO,EAAEI,OAAO,CAAC,CAAC,EACxCxB,cAAM,CAAC4B,IAAI,CACZ;MAED,OAAO;QACL;QACAC,IAAI,EAAEA,CAAC,GAAGC,QAAQ,KAChB9B,cAAM,CAACM,GAAG,CAAC,aAAS;UAClB,MAAMsB,IAAI,GAAG,OAAOH,SAAS;UAC7B,IAAIM,YAAI,CAACC,SAAS,CAACJ,IAAI,CAAC,EAAE;YACxB;YACA;YACA;YACA,OAAO,OAAO5B,cAAM,CAACiC,SAAS,CAACL,IAAI,CAACM,KAAK,CAAC;UAC5C;UACA,MAAM,CAACC,YAAY,CAAC,GAAGP,IAAI,CAACQ,KAAK;UACjC,MAAMC,QAAQ,GAAG,OAAOvB,aAAa,CAACqB,YAAY,CAAC;UACnD,MAAMG,cAAc,GAAG,OAAOtC,cAAM,CAACkB,GAAG,CAACY,QAAQ,CAAC;UAClD,IAAAS,cAAM,EAACF,QAAQ,CAACpC,GAAG,CAAEuC,CAAC,IAAKlD,OAAO,CAACkD,CAAC,CAACjD,KAAK,CAAC,CAAC,CAAC,CAACkD,OAAO,CACnDH,cAAc,CAACrC,GAAG,CAACX,OAAO,CAAC,CAC5B;QACH,CAAC,CAAC;QACJoD,SAAS,EAAGC,GAAG,IACb3C,cAAM,CAACM,GAAG,CAAC,aAAS;UAClB,MAAMsB,IAAI,GAAG,OAAOH,SAAS;UAC7B,IAAAc,cAAM,EAACR,YAAI,CAACC,SAAS,CAACJ,IAAI,CAAC,CAAC,CAACgB,IAAI,CAAC,IAAI,CAAC;UACvC,IAAIb,YAAI,CAACC,SAAS,CAACJ,IAAI,CAAC,EAAE;YACxB,MAAMiB,OAAO,GAAGC,aAAK,CAACC,aAAa,CAACnB,IAAI,CAACM,KAAK,CAAC;YAC/C,MAAMc,KAAK,GAAGH,OAAO,CAACI,IAAI,KAAK,MAAM,GAAGJ,OAAO,CAACT,KAAK,GAAGc,SAAS;YACjE,MAAMC,SAAS,GACb,OAAOH,KAAK,KAAK,QAAQ,IAAIA,KAAK,KAAK,IAAI,IAAI,MAAM,IAAIA,KAAK,GACzDA,KAAoC,CAACC,IAAI,GAC1CC,SAAS;YACf,IAAAX,cAAM,EAACY,SAAS,CAAC,CAACP,IAAI,CAACD,GAAG,CAAC;UAC7B;QACF,CAAC;OACJ;IACH;GACD;CACF,CAAC;AAEF;;;;;;;;;;;;;AAAAS,OAAA,CAAAjC,gBAAA,GAAAA,gBAAA;AAaO,MAAMkC,QAAQ,GAAGA,CACtBC,WAAmB,EACnBlC,OAA+B,EAC/BmC,KAMC,KACO;EACRC,UAAE,CAACC,MAAM,CAACH,WAAW,EAAE,MACrBC,KAAK,CAACpC,gBAAgB,CAACC,OAAO,CAAC,CAAC,CAACrB,IAAI;EACnC;EACA;EACAC,cAAM,CAAC0D,OAAO,CACZC,aAAK,CAACC,QAAQ,CACZD,aAAK,CAACE,KAAK,CAACC,2BAAqB,CAAC,EAClCC,wBAAiB,EACjBJ,aAAK,CAACE,KAAK,CAACG,eAAQ,CAAC,CACtB,CACF,CACF,CACF;AACH,CAAC;AAAAZ,OAAA,CAAAC,QAAA,GAAAA,QAAA","ignoreList":[]}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @kairos-es/testing — the Given/When/Then decision-model testing surface.
3
+ *
4
+ * A `DeciderSpecification`-equivalent over the real `@kairos-es/codec`
5
+ * `buildDecisionModel` fold and a `DcbEventStore` (in practice the in-memory
6
+ * layer). Shaped so an Event Modeling `Specification` (given events / when a
7
+ * command / then events / thenError a tag) compiles onto it 1:1.
8
+ *
9
+ * scenario('subscribe a student', subscribeDecider, (spec) =>
10
+ * spec
11
+ * .given(courseDefined('c1', 10))
12
+ * .when({ studentId: 's1', courseId: 'c1' })
13
+ * .then(studentSubscribed('s1', 'c1')),
14
+ * )
15
+ *
16
+ * The unit it exercises — `Decider`, pairing the schema-backed projections a
17
+ * command reads with an effectful `decide` step producing events via `toEvent`,
18
+ * and `runDecider`, running the real read -> decode -> decide -> append cycle —
19
+ * belongs to `@kairos-es/codec` and is imported from there, so the surface
20
+ * exercises the genuine production cycle and not a test-only restatement of it.
21
+ * It is deliberately NOT re-exported here: this package peers `@effect/vitest`,
22
+ * and a re-export would let a production command handler reach a test framework
23
+ * through the type it names. Given/expected events are effectful producers
24
+ * (`toEvent`), materialised under the same serialiser + uuid layers `scenario`
25
+ * provides.
26
+ */
27
+ export * from './specification.js';
28
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,cAAc,iBAAiB,CAAA"}
@@ -0,0 +1,76 @@
1
+ import type { CodecError, Decider, RequirementOf, SliceProjection } from '@kairos-es/codec';
2
+ import { type Serializer, type Uuid } from '@kairos-es/codec';
3
+ import { type AppendConditionFailed, type DcbEvent, DcbEventStore } from '@kairos-es/core';
4
+ import { Effect } from 'effect';
5
+ /** An effectful producer of a wire event (the `toEvent` result an app authors). */
6
+ export type EventProducer<R> = Effect.Effect<DcbEvent, CodecError, Serializer | Uuid | R>;
7
+ /**
8
+ * The entry point of the Given/When/Then chain: what `specificationFor` returns
9
+ * and what `scenario` hands its `build` callback. It is NAMED rather than
10
+ * written inline at those two sites because it was the one step of this chain
11
+ * with no name — the two statements of it were free to drift — and it is the
12
+ * step a caller most needs to name, since factoring a `build` callback out of a
13
+ * `scenario` call means writing its parameter's type. The sibling fluent
14
+ * builder in `@kairos-es/codec` names its own entry point (`ProjectionBuilder`)
15
+ * for the same reason.
16
+ *
17
+ * It introduces no type parameter of its own: all four are `GivenSpec`'s below,
18
+ * passed straight through, because seeding past events changes nothing about
19
+ * the command, the domain error, or either requirement — it only fixes the
20
+ * state the decision is folded from.
21
+ */
22
+ export interface Specification<Command, E, R, PR> {
23
+ /**
24
+ * Seed the past events, in log order (one append each). They are effectful
25
+ * producers, materialised under the same layers the decision runs against.
26
+ */
27
+ readonly given: (...pastEvents: ReadonlyArray<EventProducer<R>>) => GivenSpec<Command, E, R, PR>;
28
+ }
29
+ /**
30
+ * A spec bound to a decider and a set of given events, awaiting `.when`. `PR` is
31
+ * the slices' decode requirement (`RequirementOf<P>`) that also flows through
32
+ * the terminals' requirement channel alongside the produce requirement `R`.
33
+ */
34
+ export interface GivenSpec<Command, E, R, PR> {
35
+ /** Supply the command to decide. */
36
+ readonly when: (command: Command) => WhenSpec<E, R, PR>;
37
+ }
38
+ /** A spec that has a command and awaits a `then`/`thenError` assertion. */
39
+ export interface WhenSpec<E, R, PR> {
40
+ /**
41
+ * Assert the success branch: the decider appended exactly these events
42
+ * (compared structurally by type/tags/data, in order). The expected events are
43
+ * effectful producers, materialised under the provided layers. The error
44
+ * channel keeps `E | CodecError | AppendConditionFailed` because a genuinely
45
+ * failing cycle re-raises its cause (a `.then` on a failing decision is a
46
+ * test-authoring mistake, made loud rather than silently green).
47
+ */
48
+ readonly then: (...expected: ReadonlyArray<EventProducer<R>>) => Effect.Effect<void, E | CodecError | AppendConditionFailed, DcbEventStore | Serializer | Uuid | R | PR>;
49
+ /**
50
+ * Assert the failure branch: the cycle failed with a tagged error whose
51
+ * `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
52
+ * decode, or `AppendConditionFailed` from a moved consistency boundary).
53
+ */
54
+ readonly thenError: (tag: string) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>;
55
+ }
56
+ /**
57
+ * Begin a specification for `decider`. The returned `given(...)` starts the
58
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
59
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
60
+ */
61
+ export declare const specificationFor: <P extends Record<string, SliceProjection>, Command, E, R>(decider: Decider<P, Command, E, R>) => Specification<Command, E, R, RequirementOf<P>>;
62
+ /**
63
+ * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
64
+ * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
65
+ * `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
66
+ * layer. The callback receives `specificationFor(decider)` so example suites
67
+ * read as a single Given/When/Then line per case.
68
+ *
69
+ * `scenario` provides exactly the store, serialiser, and uuid, so `build` must
70
+ * return an effect requiring only those three. For a plain decider (`R` and
71
+ * `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
72
+ * encryption requirement provides its cipher layer inside `build` before
73
+ * returning.
74
+ */
75
+ export declare const scenario: <P extends Record<string, SliceProjection>, Command, E>(description: string, decider: Decider<P, Command, E>, build: (spec: Specification<Command, E, never, RequirementOf<P>>) => Effect.Effect<void, E | CodecError | AppendConditionFailed, DcbEventStore | Serializer | Uuid>) => void;
76
+ //# sourceMappingURL=specification.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specification.d.ts","sourceRoot":"","sources":["../../src/specification.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EACV,UAAU,EACV,OAAO,EACP,aAAa,EACb,eAAe,EAChB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAEL,KAAK,UAAU,EAEf,KAAK,IAAI,EAEV,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,QAAQ,EACb,aAAa,EAMd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAgB,MAAM,EAAuB,MAAM,QAAQ,CAAA;AAElE,mFAAmF;AACnF,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI,MAAM,CAAC,MAAM,CAC1C,QAAQ,EACR,UAAU,EACV,UAAU,GAAG,IAAI,GAAG,CAAC,CACtB,CAAA;AA+DD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE;IAC9C;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,CACd,GAAG,UAAU,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,KAC3C,SAAS,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE;IAC1C,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;CACxD;AAED,2EAA2E;AAC3E,MAAM,WAAW,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;IAChC;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,CACb,GAAG,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,KACzC,MAAM,CAAC,MAAM,CAChB,IAAI,EACJ,CAAC,GAAG,UAAU,GAAG,qBAAqB,EACtC,aAAa,GAAG,UAAU,GAAG,IAAI,GAAG,CAAC,GAAG,EAAE,CAC3C,CAAA;IACD;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAClB,GAAG,EAAE,MAAM,KACR,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,aAAa,GAAG,UAAU,GAAG,IAAI,GAAG,CAAC,GAAG,EAAE,CAAC,CAAA;CAC5E;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,EACzC,OAAO,EACP,CAAC,EACD,CAAC,EAED,SAAS,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,KACjC,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,CAkD9C,CAAA;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,EAAE,OAAO,EAAE,CAAC,EAC5E,aAAa,MAAM,EACnB,SAAS,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,EAC/B,OAAO,CACL,IAAI,EAAE,aAAa,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,KACrD,MAAM,CAAC,MAAM,CAChB,IAAI,EACJ,CAAC,GAAG,UAAU,GAAG,qBAAqB,EACtC,aAAa,GAAG,UAAU,GAAG,IAAI,CAClC,KACA,IAcF,CAAA"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @kairos-es/testing — the Given/When/Then decision-model testing surface.
3
+ *
4
+ * A `DeciderSpecification`-equivalent over the real `@kairos-es/codec`
5
+ * `buildDecisionModel` fold and a `DcbEventStore` (in practice the in-memory
6
+ * layer). Shaped so an Event Modeling `Specification` (given events / when a
7
+ * command / then events / thenError a tag) compiles onto it 1:1.
8
+ *
9
+ * scenario('subscribe a student', subscribeDecider, (spec) =>
10
+ * spec
11
+ * .given(courseDefined('c1', 10))
12
+ * .when({ studentId: 's1', courseId: 'c1' })
13
+ * .then(studentSubscribed('s1', 'c1')),
14
+ * )
15
+ *
16
+ * The unit it exercises — `Decider`, pairing the schema-backed projections a
17
+ * command reads with an effectful `decide` step producing events via `toEvent`,
18
+ * and `runDecider`, running the real read -> decode -> decide -> append cycle —
19
+ * belongs to `@kairos-es/codec` and is imported from there, so the surface
20
+ * exercises the genuine production cycle and not a test-only restatement of it.
21
+ * It is deliberately NOT re-exported here: this package peers `@effect/vitest`,
22
+ * and a re-export would let a production command handler reach a test framework
23
+ * through the type it names. Given/expected events are effectful producers
24
+ * (`toEvent`), materialised under the same serialiser + uuid layers `scenario`
25
+ * provides.
26
+ */
27
+ export * from "./specification.js";
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,cAAc,oBAAiB","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": []
4
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The Given/When/Then surface: a `DeciderSpecification`-equivalent over the real
3
+ * `@kairos-es/codec` `buildDecisionModel` fold and a `DcbEventStore` (the
4
+ * in-memory layer in practice). It is shaped so an Event Modeling
5
+ * `Specification` (given events, when a command, then events / thenError a tag)
6
+ * compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
7
+ * `DeciderSpecification`.
8
+ *
9
+ * specificationFor(decider)
10
+ * .given(...pastEventProducers)
11
+ * .when(command)
12
+ * .then(...expectedNewEventProducers) // success branch
13
+ *
14
+ * specificationFor(decider)
15
+ * .given(...pastEventProducers)
16
+ * .when(command)
17
+ * .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
18
+ *
19
+ * Events are supplied *effectfully*: a given/expected event is the producer an
20
+ * application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
21
+ * Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
22
+ * under the same layers the cycle runs against, and their `data` bytes match.
23
+ *
24
+ * Each terminal (`then` / `thenError`) returns an `Effect` requiring
25
+ * `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
26
+ * serialiser, and uuid layers (fresh store per case for isolation).
27
+ * `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
28
+ * against a fresh in-memory store plus `SerializerDefault` and `UuidTest`, so
29
+ * example specs read as one line.
30
+ *
31
+ * `then` compares the *appended* events (those written past the given seed) to
32
+ * the expected batch structurally — by `type`, `tags`, and `data` — ignoring
33
+ * the store-assigned `position` and the producer-supplied `uuid`/`occurredAt`,
34
+ * which are not part of the decision under test. JSON serialisation is
35
+ * deterministic, so equal payloads produce equal `data` bytes.
36
+ */
37
+ import { expect, it } from '@effect/vitest';
38
+ import { runDecider, SerializerDefault, UuidTest } from '@kairos-es/codec';
39
+ import { DcbEventStore, DcbEventStoreInMemory, ORIGIN, query as Q } from '@kairos-es/core';
40
+ import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect';
41
+ const shapeOf = event => ({
42
+ type: event.type,
43
+ tags: event.tags,
44
+ data: event.data
45
+ });
46
+ /** Collect a stream of sequenced events into a plain array. */
47
+ const collect = events => Stream.runCollect(events).pipe(Effect.map(Chunk.toReadonlyArray));
48
+ /**
49
+ * Seed the given past-event producers into the store: materialise each producer
50
+ * (encode -> serialise), then one append each (log order). Returns the position
51
+ * the LAST seeded append was written at, or `ORIGIN` when nothing was seeded —
52
+ * this is the boundary `then` reads strictly after.
53
+ *
54
+ * We track the boundary by position rather than by count because positions are
55
+ * gappy on a real store (Postgres `bigserial` skips values on rollbacks and
56
+ * across concurrent writers), so "everything past N rows" is wrong; "everything
57
+ * strictly after this position" is exact regardless of gaps.
58
+ */
59
+ const seed = producers => Effect.gen(function* () {
60
+ const store = yield* DcbEventStore;
61
+ let last = ORIGIN;
62
+ for (const producer of producers) {
63
+ const event = yield* producer;
64
+ // Unconditional appends never fail; a failure here would be a defect in
65
+ // the store, not a domain outcome, so turn it into one.
66
+ last = yield* store.append([event]).pipe(Effect.orDie);
67
+ }
68
+ return last;
69
+ });
70
+ /**
71
+ * The events appended past the seed boundary, so `then` can read back exactly
72
+ * the events the decider appended. We read all matching events strictly after
73
+ * `after` (the last seeded position), which is correct on a gappy store where
74
+ * dropping a fixed row count would not be — the in-memory store gives the
75
+ * identical result because its positions are gapless.
76
+ */
77
+ const appendedAfter = after => Effect.gen(function* () {
78
+ const store = yield* DcbEventStore;
79
+ const {
80
+ events
81
+ } = yield* store.read(Q.all(), {
82
+ after
83
+ });
84
+ return yield* collect(events);
85
+ });
86
+ /**
87
+ * Begin a specification for `decider`. The returned `given(...)` starts the
88
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
89
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
90
+ */
91
+ export const specificationFor = decider => ({
92
+ given: (...pastEvents) => ({
93
+ when: command => {
94
+ // Run the seed + decider cycle, exposing the outcome as an Exit so both
95
+ // branches (then / thenError) can inspect success or failure uniformly. The
96
+ // seed is inside the Exit too, so a decode failure while materialising a
97
+ // given producer is captured as a failure rather than escaping the channel.
98
+ // The success value pairs the seed boundary (the last seeded position) with
99
+ // the decider's outcome, so `then` reads strictly after that boundary
100
+ // rather than dropping a fixed row count (correct on a gappy store).
101
+ const runToExit = seed(pastEvents).pipe(Effect.zip(runDecider(decider, command)), Effect.exit);
102
+ return {
103
+ // biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
104
+ then: (...expected) => Effect.gen(function* () {
105
+ const exit = yield* runToExit;
106
+ if (Exit.isFailure(exit)) {
107
+ // Surface the real failure to the caller's error channel; a
108
+ // `.then` on a failing cycle is a test authoring mistake, so make
109
+ // it loud rather than silently passing.
110
+ return yield* Effect.failCause(exit.cause);
111
+ }
112
+ const [seedBoundary] = exit.value;
113
+ const appended = yield* appendedAfter(seedBoundary);
114
+ const expectedEvents = yield* Effect.all(expected);
115
+ expect(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
116
+ }),
117
+ thenError: tag => Effect.gen(function* () {
118
+ const exit = yield* runToExit;
119
+ expect(Exit.isFailure(exit)).toBe(true);
120
+ if (Exit.isFailure(exit)) {
121
+ const failure = Cause.failureOption(exit.cause);
122
+ const error = failure._tag === 'Some' ? failure.value : undefined;
123
+ const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
124
+ expect(actualTag).toBe(tag);
125
+ }
126
+ })
127
+ };
128
+ }
129
+ })
130
+ });
131
+ /**
132
+ * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
133
+ * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
134
+ * `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
135
+ * layer. The callback receives `specificationFor(decider)` so example suites
136
+ * read as a single Given/When/Then line per case.
137
+ *
138
+ * `scenario` provides exactly the store, serialiser, and uuid, so `build` must
139
+ * return an effect requiring only those three. For a plain decider (`R` and
140
+ * `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
141
+ * encryption requirement provides its cipher layer inside `build` before
142
+ * returning.
143
+ */
144
+ export const scenario = (description, decider, build) => {
145
+ it.effect(description, () => build(specificationFor(decider)).pipe(
146
+ // A fresh store + uuid layer per case: both are stateful, so isolation is
147
+ // required (mirrors the contract suite's per-case layer thunk).
148
+ Effect.provide(Layer.mergeAll(Layer.fresh(DcbEventStoreInMemory), SerializerDefault, Layer.fresh(UuidTest)))));
149
+ };
150
+ //# sourceMappingURL=specification.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specification.js","names":["expect","it","runDecider","SerializerDefault","UuidTest","DcbEventStore","DcbEventStoreInMemory","ORIGIN","query","Q","Cause","Chunk","Effect","Exit","Layer","Stream","shapeOf","event","type","tags","data","collect","events","runCollect","pipe","map","toReadonlyArray","seed","producers","gen","store","last","producer","append","orDie","appendedAfter","after","read","all","specificationFor","decider","given","pastEvents","when","command","runToExit","zip","exit","then","expected","isFailure","failCause","cause","seedBoundary","value","appended","expectedEvents","s","toEqual","thenError","tag","toBe","failure","failureOption","error","_tag","undefined","actualTag","scenario","description","build","effect","provide","mergeAll","fresh"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@kairos-es/testing",
3
+ "version": "0.0.0",
4
+ "description": "Given/When/Then decision-model testing surface for kairos-es",
5
+ "license": "BSD-3-Clause",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/neverbland/kairos-es.git",
9
+ "directory": "packages/testing"
10
+ },
11
+ "sideEffects": [],
12
+ "homepage": "https://github.com/neverbland/kairos-es",
13
+ "peerDependencies": {
14
+ "@effect/vitest": "^0.30.0",
15
+ "effect": "^3.22.0",
16
+ "@kairos-es/codec": "^0.0.0",
17
+ "@kairos-es/core": "^0.0.0"
18
+ },
19
+ "main": "./dist/cjs/index.js",
20
+ "module": "./dist/esm/index.js",
21
+ "types": "./dist/dts/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/dts/index.d.ts",
25
+ "import": "./dist/esm/index.js",
26
+ "default": "./dist/cjs/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "typesVersions": {
31
+ "*": {}
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @kairos-es/testing — the Given/When/Then decision-model testing surface.
3
+ *
4
+ * A `DeciderSpecification`-equivalent over the real `@kairos-es/codec`
5
+ * `buildDecisionModel` fold and a `DcbEventStore` (in practice the in-memory
6
+ * layer). Shaped so an Event Modeling `Specification` (given events / when a
7
+ * command / then events / thenError a tag) compiles onto it 1:1.
8
+ *
9
+ * scenario('subscribe a student', subscribeDecider, (spec) =>
10
+ * spec
11
+ * .given(courseDefined('c1', 10))
12
+ * .when({ studentId: 's1', courseId: 'c1' })
13
+ * .then(studentSubscribed('s1', 'c1')),
14
+ * )
15
+ *
16
+ * The unit it exercises — `Decider`, pairing the schema-backed projections a
17
+ * command reads with an effectful `decide` step producing events via `toEvent`,
18
+ * and `runDecider`, running the real read -> decode -> decide -> append cycle —
19
+ * belongs to `@kairos-es/codec` and is imported from there, so the surface
20
+ * exercises the genuine production cycle and not a test-only restatement of it.
21
+ * It is deliberately NOT re-exported here: this package peers `@effect/vitest`,
22
+ * and a re-export would let a production command handler reach a test framework
23
+ * through the type it names. Given/expected events are effectful producers
24
+ * (`toEvent`), materialised under the same serialiser + uuid layers `scenario`
25
+ * provides.
26
+ */
27
+ export * from './specification'
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The Given/When/Then surface: a `DeciderSpecification`-equivalent over the real
3
+ * `@kairos-es/codec` `buildDecisionModel` fold and a `DcbEventStore` (the
4
+ * in-memory layer in practice). It is shaped so an Event Modeling
5
+ * `Specification` (given events, when a command, then events / thenError a tag)
6
+ * compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
7
+ * `DeciderSpecification`.
8
+ *
9
+ * specificationFor(decider)
10
+ * .given(...pastEventProducers)
11
+ * .when(command)
12
+ * .then(...expectedNewEventProducers) // success branch
13
+ *
14
+ * specificationFor(decider)
15
+ * .given(...pastEventProducers)
16
+ * .when(command)
17
+ * .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
18
+ *
19
+ * Events are supplied *effectfully*: a given/expected event is the producer an
20
+ * application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
21
+ * Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
22
+ * under the same layers the cycle runs against, and their `data` bytes match.
23
+ *
24
+ * Each terminal (`then` / `thenError`) returns an `Effect` requiring
25
+ * `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
26
+ * serialiser, and uuid layers (fresh store per case for isolation).
27
+ * `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
28
+ * against a fresh in-memory store plus `SerializerDefault` and `UuidTest`, so
29
+ * example specs read as one line.
30
+ *
31
+ * `then` compares the *appended* events (those written past the given seed) to
32
+ * the expected batch structurally — by `type`, `tags`, and `data` — ignoring
33
+ * the store-assigned `position` and the producer-supplied `uuid`/`occurredAt`,
34
+ * which are not part of the decision under test. JSON serialisation is
35
+ * deterministic, so equal payloads produce equal `data` bytes.
36
+ */
37
+ import { expect, it } from '@effect/vitest'
38
+ import type {
39
+ CodecError,
40
+ Decider,
41
+ RequirementOf,
42
+ SliceProjection,
43
+ } from '@kairos-es/codec'
44
+ import {
45
+ runDecider,
46
+ type Serializer,
47
+ SerializerDefault,
48
+ type Uuid,
49
+ UuidTest,
50
+ } from '@kairos-es/codec'
51
+ import {
52
+ type AppendConditionFailed,
53
+ type DcbEvent,
54
+ DcbEventStore,
55
+ DcbEventStoreInMemory,
56
+ ORIGIN,
57
+ type Position,
58
+ query as Q,
59
+ type SequencedEvent,
60
+ } from '@kairos-es/core'
61
+ import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect'
62
+
63
+ /** An effectful producer of a wire event (the `toEvent` result an app authors). */
64
+ export type EventProducer<R> = Effect.Effect<
65
+ DcbEvent,
66
+ CodecError,
67
+ Serializer | Uuid | R
68
+ >
69
+
70
+ /** The structural shape `then` compares on (position/uuid/occurredAt excluded). */
71
+ interface EventShape {
72
+ readonly type: string
73
+ readonly tags: ReadonlyArray<string>
74
+ readonly data: Uint8Array
75
+ }
76
+
77
+ const shapeOf = (event: DcbEvent): EventShape => ({
78
+ type: event.type,
79
+ tags: event.tags,
80
+ data: event.data,
81
+ })
82
+
83
+ /** Collect a stream of sequenced events into a plain array. */
84
+ const collect = (
85
+ events: Stream.Stream<SequencedEvent>,
86
+ ): Effect.Effect<ReadonlyArray<SequencedEvent>> =>
87
+ Stream.runCollect(events).pipe(Effect.map(Chunk.toReadonlyArray))
88
+
89
+ /**
90
+ * Seed the given past-event producers into the store: materialise each producer
91
+ * (encode -> serialise), then one append each (log order). Returns the position
92
+ * the LAST seeded append was written at, or `ORIGIN` when nothing was seeded —
93
+ * this is the boundary `then` reads strictly after.
94
+ *
95
+ * We track the boundary by position rather than by count because positions are
96
+ * gappy on a real store (Postgres `bigserial` skips values on rollbacks and
97
+ * across concurrent writers), so "everything past N rows" is wrong; "everything
98
+ * strictly after this position" is exact regardless of gaps.
99
+ */
100
+ const seed = <R>(
101
+ producers: ReadonlyArray<EventProducer<R>>,
102
+ ): Effect.Effect<Position, CodecError, DcbEventStore | Serializer | Uuid | R> =>
103
+ Effect.gen(function* () {
104
+ const store = yield* DcbEventStore
105
+ let last: Position = ORIGIN
106
+ for (const producer of producers) {
107
+ const event = yield* producer
108
+ // Unconditional appends never fail; a failure here would be a defect in
109
+ // the store, not a domain outcome, so turn it into one.
110
+ last = yield* store.append([event]).pipe(Effect.orDie)
111
+ }
112
+ return last
113
+ })
114
+
115
+ /**
116
+ * The events appended past the seed boundary, so `then` can read back exactly
117
+ * the events the decider appended. We read all matching events strictly after
118
+ * `after` (the last seeded position), which is correct on a gappy store where
119
+ * dropping a fixed row count would not be — the in-memory store gives the
120
+ * identical result because its positions are gapless.
121
+ */
122
+ const appendedAfter = (
123
+ after: Position,
124
+ ): Effect.Effect<ReadonlyArray<SequencedEvent>, never, DcbEventStore> =>
125
+ Effect.gen(function* () {
126
+ const store = yield* DcbEventStore
127
+ const { events } = yield* store.read(Q.all(), { after })
128
+ return yield* collect(events)
129
+ })
130
+
131
+ /**
132
+ * The entry point of the Given/When/Then chain: what `specificationFor` returns
133
+ * and what `scenario` hands its `build` callback. It is NAMED rather than
134
+ * written inline at those two sites because it was the one step of this chain
135
+ * with no name — the two statements of it were free to drift — and it is the
136
+ * step a caller most needs to name, since factoring a `build` callback out of a
137
+ * `scenario` call means writing its parameter's type. The sibling fluent
138
+ * builder in `@kairos-es/codec` names its own entry point (`ProjectionBuilder`)
139
+ * for the same reason.
140
+ *
141
+ * It introduces no type parameter of its own: all four are `GivenSpec`'s below,
142
+ * passed straight through, because seeding past events changes nothing about
143
+ * the command, the domain error, or either requirement — it only fixes the
144
+ * state the decision is folded from.
145
+ */
146
+ export interface Specification<Command, E, R, PR> {
147
+ /**
148
+ * Seed the past events, in log order (one append each). They are effectful
149
+ * producers, materialised under the same layers the decision runs against.
150
+ */
151
+ readonly given: (
152
+ ...pastEvents: ReadonlyArray<EventProducer<R>>
153
+ ) => GivenSpec<Command, E, R, PR>
154
+ }
155
+
156
+ /**
157
+ * A spec bound to a decider and a set of given events, awaiting `.when`. `PR` is
158
+ * the slices' decode requirement (`RequirementOf<P>`) that also flows through
159
+ * the terminals' requirement channel alongside the produce requirement `R`.
160
+ */
161
+ export interface GivenSpec<Command, E, R, PR> {
162
+ /** Supply the command to decide. */
163
+ readonly when: (command: Command) => WhenSpec<E, R, PR>
164
+ }
165
+
166
+ /** A spec that has a command and awaits a `then`/`thenError` assertion. */
167
+ export interface WhenSpec<E, R, PR> {
168
+ /**
169
+ * Assert the success branch: the decider appended exactly these events
170
+ * (compared structurally by type/tags/data, in order). The expected events are
171
+ * effectful producers, materialised under the provided layers. The error
172
+ * channel keeps `E | CodecError | AppendConditionFailed` because a genuinely
173
+ * failing cycle re-raises its cause (a `.then` on a failing decision is a
174
+ * test-authoring mistake, made loud rather than silently green).
175
+ */
176
+ readonly then: (
177
+ ...expected: ReadonlyArray<EventProducer<R>>
178
+ ) => Effect.Effect<
179
+ void,
180
+ E | CodecError | AppendConditionFailed,
181
+ DcbEventStore | Serializer | Uuid | R | PR
182
+ >
183
+ /**
184
+ * Assert the failure branch: the cycle failed with a tagged error whose
185
+ * `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
186
+ * decode, or `AppendConditionFailed` from a moved consistency boundary).
187
+ */
188
+ readonly thenError: (
189
+ tag: string,
190
+ ) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>
191
+ }
192
+
193
+ /**
194
+ * Begin a specification for `decider`. The returned `given(...)` starts the
195
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
196
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
197
+ */
198
+ export const specificationFor = <
199
+ P extends Record<string, SliceProjection>,
200
+ Command,
201
+ E,
202
+ R,
203
+ >(
204
+ decider: Decider<P, Command, E, R>,
205
+ ): Specification<Command, E, R, RequirementOf<P>> => ({
206
+ given: (...pastEvents) => ({
207
+ when: (command) => {
208
+ // Run the seed + decider cycle, exposing the outcome as an Exit so both
209
+ // branches (then / thenError) can inspect success or failure uniformly. The
210
+ // seed is inside the Exit too, so a decode failure while materialising a
211
+ // given producer is captured as a failure rather than escaping the channel.
212
+ // The success value pairs the seed boundary (the last seeded position) with
213
+ // the decider's outcome, so `then` reads strictly after that boundary
214
+ // rather than dropping a fixed row count (correct on a gappy store).
215
+ const runToExit = seed(pastEvents).pipe(
216
+ Effect.zip(runDecider(decider, command)),
217
+ Effect.exit,
218
+ )
219
+
220
+ return {
221
+ // biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
222
+ then: (...expected) =>
223
+ Effect.gen(function* () {
224
+ const exit = yield* runToExit
225
+ if (Exit.isFailure(exit)) {
226
+ // Surface the real failure to the caller's error channel; a
227
+ // `.then` on a failing cycle is a test authoring mistake, so make
228
+ // it loud rather than silently passing.
229
+ return yield* Effect.failCause(exit.cause)
230
+ }
231
+ const [seedBoundary] = exit.value
232
+ const appended = yield* appendedAfter(seedBoundary)
233
+ const expectedEvents = yield* Effect.all(expected)
234
+ expect(appended.map((s) => shapeOf(s.event))).toEqual(
235
+ expectedEvents.map(shapeOf),
236
+ )
237
+ }),
238
+ thenError: (tag) =>
239
+ Effect.gen(function* () {
240
+ const exit = yield* runToExit
241
+ expect(Exit.isFailure(exit)).toBe(true)
242
+ if (Exit.isFailure(exit)) {
243
+ const failure = Cause.failureOption(exit.cause)
244
+ const error = failure._tag === 'Some' ? failure.value : undefined
245
+ const actualTag =
246
+ typeof error === 'object' && error !== null && '_tag' in error
247
+ ? (error as { readonly _tag: unknown })._tag
248
+ : undefined
249
+ expect(actualTag).toBe(tag)
250
+ }
251
+ }),
252
+ }
253
+ },
254
+ }),
255
+ })
256
+
257
+ /**
258
+ * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
259
+ * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
260
+ * `DcbEventStore` plus the default serialiser and a fresh deterministic uuid
261
+ * layer. The callback receives `specificationFor(decider)` so example suites
262
+ * read as a single Given/When/Then line per case.
263
+ *
264
+ * `scenario` provides exactly the store, serialiser, and uuid, so `build` must
265
+ * return an effect requiring only those three. For a plain decider (`R` and
266
+ * `RequirementOf<P>` both `never`) that is automatic; a decider carrying an
267
+ * encryption requirement provides its cipher layer inside `build` before
268
+ * returning.
269
+ */
270
+ export const scenario = <P extends Record<string, SliceProjection>, Command, E>(
271
+ description: string,
272
+ decider: Decider<P, Command, E>,
273
+ build: (
274
+ spec: Specification<Command, E, never, RequirementOf<P>>,
275
+ ) => Effect.Effect<
276
+ void,
277
+ E | CodecError | AppendConditionFailed,
278
+ DcbEventStore | Serializer | Uuid
279
+ >,
280
+ ): void => {
281
+ it.effect(description, () =>
282
+ build(specificationFor(decider)).pipe(
283
+ // A fresh store + uuid layer per case: both are stateful, so isolation is
284
+ // required (mirrors the contract suite's per-case layer thunk).
285
+ Effect.provide(
286
+ Layer.mergeAll(
287
+ Layer.fresh(DcbEventStoreInMemory),
288
+ SerializerDefault,
289
+ Layer.fresh(UuidTest),
290
+ ),
291
+ ),
292
+ ),
293
+ )
294
+ }