@kairos-es/testing 1.0.0-alpha.0 → 1.0.0-alpha.2

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 CHANGED
@@ -17,10 +17,31 @@ a peer for you.
17
17
  Its other peers, `@kairos-es/core`, `@kairos-es/codec` and `effect`, are the ones
18
18
  an application using the library already has.
19
19
 
20
- A `DeciderSpecification`-equivalent over the codec's `buildDecisionModel`, running
21
- the real read → decode → decide → append cycle against the in-memory store rather
22
- than a mock, on `@effect/vitest`. `@kairos-es/core`, `@kairos-es/codec`, `effect`
23
- and `@effect/vitest` are peer dependencies.
20
+ A `DeciderSpecification`-equivalent running the real read → decode → decide →
21
+ append cycle against the in-memory store rather than a mock, on
22
+ `@effect/vitest`. Over the codec's `buildDecisionModel` for a single-hop
23
+ decision, and over `modelAtHead` and the read ledger for a layered one.
24
+ `@kairos-es/core`, `@kairos-es/codec`, `effect` and `@effect/vitest` are peer
25
+ dependencies.
26
+
27
+ `specificationFor(decider)` is the ordinary entry point.
28
+ `specificationForCycle(run)` is the general one it is a one-line specialisation
29
+ of: it takes any `(command) => Effect<Position, …>`, so a `transact` bracket slots
30
+ into the same chain a decider does. Its `run` states its errors as `E | CycleError`
31
+ — the chain adds that union itself, so a naive `Effect<Position, E, R>`
32
+ double-counts it and will not unify.
33
+
34
+ ⚠ `.thenError(tag)` is weaker on a bracket than on a decider, in three ways worth
35
+ knowing before you rely on it. Two different denials from one guard usually share
36
+ one tag, so a spec can pass on a refusal its author did not mean. An
37
+ `AppendConditionFailed` now also fires for an EARLIER hop, so the tag no longer says
38
+ which boundary moved. And `NoReadsRecorded` reads like a domain assertion while
39
+ being a statement about wiring. A layered or permission suite should assert more
40
+ than the tag.
41
+
42
+ `.thenUnchanged()` asserts the "already done" branch: the cycle succeeded with
43
+ the codec's `Unchanged` AND appended nothing. `.then()` and `.thenError(tag)` both
44
+ fail on an `Unchanged` cycle, so the three terminals cannot pass for one another.
24
45
 
25
46
  The `Decider` a scenario exercises and the `runDecider` that runs it are
26
47
  production symbols and belong to [`@kairos-es/codec`](../codec); this package
@@ -1 +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":[]}
1
+ {"version":3,"file":"index.js","names":["_specification","require","Object","keys","forEach","key","exports","defineProperty","enumerable","get"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":";;;;;AA6BA,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":[]}
@@ -3,15 +3,20 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.specificationFor = exports.scenario = void 0;
6
+ exports.specificationForCycle = exports.specificationFor = exports.scenario = void 0;
7
7
  var _vitest = require("@effect/vitest");
8
8
  var _codec = require("@kairos-es/codec");
9
9
  var _core = require("@kairos-es/core");
10
10
  var _effect = require("effect");
11
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
12
+ * The Given/When/Then surface: a `DeciderSpecification`-equivalent over a real
13
+ * fold and a `DcbEventStore` (the in-memory layer in practice) —
14
+ * `@kairos-es/codec`'s `buildDecisionModel` for a single-hop decision, and
15
+ * `modelAtHead` plus the read ledger for a layered one. `specificationForCycle`
16
+ * is the general entry point over either, named for the command CYCLE it takes
17
+ * rather than for the layered bracket that was its first caller — the shape it
18
+ * accepts is `(command) => Effect<Position, …>` and a decider is one instance of
19
+ * it. It is shaped so an Event Modeling
15
20
  * `Specification` (given events, when a command, then events / thenError a tag)
16
21
  * compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
17
22
  * `DeciderSpecification`.
@@ -26,12 +31,17 @@ var _effect = require("effect");
26
31
  * .when(command)
27
32
  * .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
28
33
  *
34
+ * specificationFor(decider)
35
+ * .given(...pastEventProducers)
36
+ * .when(command)
37
+ * .thenUnchanged() // "already done" branch: nothing appended
38
+ *
29
39
  * Events are supplied *effectfully*: a given/expected event is the producer an
30
40
  * application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
31
41
  * Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
32
42
  * under the same layers the cycle runs against, and their `data` bytes match.
33
43
  *
34
- * Each terminal (`then` / `thenError`) returns an `Effect` requiring
44
+ * Each terminal (`then` / `thenError` / `thenUnchanged`) returns an `Effect` requiring
35
45
  * `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
36
46
  * serialiser, and uuid layers (fresh store per case for isolation).
37
47
  * `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
@@ -91,21 +101,32 @@ const appendedAfter = after => _effect.Effect.gen(function* () {
91
101
  return yield* collect(events);
92
102
  });
93
103
  /**
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>`.
104
+ * Begin a specification for ANY command cycle that returns the appended
105
+ * `Position` — a bracket for one command as readily as a decider — or
106
+ * `Unchanged`, for a cycle that can answer "already done".
107
+ *
108
+ * This is the general form and `specificationFor` below is a one-line
109
+ * specialisation of it, which is the result worth stating rather than the
110
+ * mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
111
+ * and `runDecider` is the function that says so. Nothing downstream of `when`
112
+ * moves — the same seed boundary, the same read-back, the same shape comparison
113
+ * — because both cycles already agree on what they return.
114
+ *
115
+ * ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
116
+ * that union itself, so a naive `Effect<Position, E, R>` double-counts it and
117
+ * fails to unify. `CycleError` exists to be written here.
97
118
  */
98
- const specificationFor = decider => ({
119
+ const specificationForCycle = run => ({
99
120
  given: (...pastEvents) => ({
100
121
  when: command => {
101
- // Run the seed + decider cycle, exposing the outcome as an Exit so both
122
+ // Run the seed + cycle, exposing the outcome as an Exit so both
102
123
  // branches (then / thenError) can inspect success or failure uniformly. The
103
124
  // seed is inside the Exit too, so a decode failure while materialising a
104
125
  // given producer is captured as a failure rather than escaping the channel.
105
126
  // The success value pairs the seed boundary (the last seeded position) with
106
- // the decider's outcome, so `then` reads strictly after that boundary
127
+ // the cycle's outcome, so `then` reads strictly after that boundary
107
128
  // 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);
129
+ const runToExit = seed(pastEvents).pipe(_effect.Effect.zip(run(command)), _effect.Effect.exit);
109
130
  return {
110
131
  // biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
111
132
  then: (...expected) => _effect.Effect.gen(function* () {
@@ -116,7 +137,8 @@ const specificationFor = decider => ({
116
137
  // it loud rather than silently passing.
117
138
  return yield* _effect.Effect.failCause(exit.cause);
118
139
  }
119
- const [seedBoundary] = exit.value;
140
+ const [seedBoundary, result] = exit.value;
141
+ (0, _vitest.expect)((0, _codec.isUnchanged)(result)).toBe(false);
120
142
  const appended = yield* appendedAfter(seedBoundary);
121
143
  const expectedEvents = yield* _effect.Effect.all(expected);
122
144
  (0, _vitest.expect)(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
@@ -130,11 +152,32 @@ const specificationFor = decider => ({
130
152
  const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
131
153
  (0, _vitest.expect)(actualTag).toBe(tag);
132
154
  }
155
+ }),
156
+ thenUnchanged: () => _effect.Effect.gen(function* () {
157
+ const exit = yield* runToExit;
158
+ if (_effect.Exit.isFailure(exit)) {
159
+ return yield* _effect.Effect.failCause(exit.cause);
160
+ }
161
+ const [seedBoundary, result] = exit.value;
162
+ (0, _vitest.expect)((0, _codec.isUnchanged)(result)).toBe(true);
163
+ const appended = yield* appendedAfter(seedBoundary);
164
+ (0, _vitest.expect)(appended.map(s => shapeOf(s.event))).toEqual([]);
133
165
  })
134
166
  };
135
167
  }
136
168
  })
137
169
  });
170
+ /**
171
+ * Begin a specification for `decider`. The returned `given(...)` starts the
172
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
173
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
174
+ *
175
+ * One line over the layered sibling above, and deliberately not a twin of it:
176
+ * a duplicated body would be two sets of tests and two things free to drift on
177
+ * the seed boundary, the read-back or the shape comparison.
178
+ */
179
+ exports.specificationForCycle = specificationForCycle;
180
+ const specificationFor = decider => specificationForCycle(command => (0, _codec.runDecider)(decider, command));
138
181
  /**
139
182
  * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
140
183
  * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
@@ -1 +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":[]}
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","specificationForCycle","run","given","pastEvents","when","command","runToExit","zip","exit","then","expected","Exit","isFailure","failCause","cause","seedBoundary","result","value","expect","isUnchanged","toBe","appended","expectedEvents","s","toEqual","thenError","tag","failure","Cause","failureOption","error","_tag","undefined","actualTag","thenUnchanged","exports","specificationFor","decider","runDecider","scenario","description","build","it","effect","provide","Layer","mergeAll","fresh","DcbEventStoreInMemory","SerializerDefault","UuidTest"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * @kairos-es/testing — the Given/When/Then decision-model testing surface.
3
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.
4
+ * A `DeciderSpecification`-equivalent over a real fold and a `DcbEventStore` (in
5
+ * practice the in-memory layer): `@kairos-es/codec`'s `buildDecisionModel` for
6
+ * a single-hop decision, and `modelAtHead` plus the read ledger for a layered
7
+ * one. Shaped so an Event Modeling `Specification` (given events / when a
8
+ * command / then events / thenError a tag) compiles onto it 1:1, plus
9
+ * `thenUnchanged` for a decision that answers "already done" and appends
10
+ * nothing.
8
11
  *
9
12
  * scenario('subscribe a student', subscribeDecider, (spec) =>
10
13
  * spec
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,cAAc,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,cAAc,iBAAiB,CAAA"}
@@ -1,9 +1,26 @@
1
- import type { CodecError, Decider, RequirementOf, SliceProjection } from '@kairos-es/codec';
1
+ import type { CodecError, Decider, DecisionOutput, NoReadsRecorded, RequirementOf, SliceProjection, TaglessSliceInLedger, Unchanged } from '@kairos-es/codec';
2
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';
3
+ import { type AppendConditionFailed, type DcbEvent, DcbEventStore, type Position } from '@kairos-es/core';
4
+ import { type Array as Arr, Effect } from 'effect';
5
5
  /** An effectful producer of a wire event (the `toEvent` result an app authors). */
6
6
  export type EventProducer<R> = Effect.Effect<DcbEvent, CodecError, Serializer | Uuid | R>;
7
+ /**
8
+ * The errors a command CYCLE contributes, beyond whatever `E` the decision
9
+ * itself fails with.
10
+ *
11
+ * Named once and exported because it is what makes the layered entry point below
12
+ * UNIFY. `WhenSpec.then` adds this union itself, so a `run` written as a naive
13
+ * `Effect<Position, E, R>` double-counts every member of it and fails to
14
+ * typecheck against a chain that has already added them — a trap with an
15
+ * unhelpful error message, met once and closed here by giving the union a name a
16
+ * caller can write.
17
+ *
18
+ * `NoReadsRecorded` is reachable only on the layered path and `TaglessSliceInLedger`
19
+ * only where a bracket is open, but both are members here rather than in a second
20
+ * union, because the two paths share every step downstream of `when` and a second
21
+ * union would be a second thing to keep in step for no caller's benefit.
22
+ */
23
+ export type CycleError = CodecError | AppendConditionFailed | TaglessSliceInLedger | NoReadsRecorded;
7
24
  /**
8
25
  * The entry point of the Given/When/Then chain: what `specificationFor` returns
9
26
  * and what `scenario` hands its `build` callback. It is NAMED rather than
@@ -35,30 +52,78 @@ export interface GivenSpec<Command, E, R, PR> {
35
52
  /** Supply the command to decide. */
36
53
  readonly when: (command: Command) => WhenSpec<E, R, PR>;
37
54
  }
38
- /** A spec that has a command and awaits a `then`/`thenError` assertion. */
55
+ /** A spec that has a command and awaits a `then`/`thenError`/`thenUnchanged` assertion. */
39
56
  export interface WhenSpec<E, R, PR> {
40
57
  /**
41
58
  * Assert the success branch: the decider appended exactly these events
42
59
  * (compared structurally by type/tags/data, in order). The expected events are
43
60
  * 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).
61
+ * channel keeps `E | CycleError` because a genuinely failing cycle re-raises
62
+ * its cause (a `.then` on a failing decision is a test-authoring mistake, made
63
+ * loud rather than silently green).
64
+ *
65
+ * A cycle that answered `Unchanged` FAILS this assertion, even with no
66
+ * expected events. `.then()` with an empty list and `.thenUnchanged()` both
67
+ * see nothing appended, but they claim different things — *decided an empty
68
+ * change* is not a decision this library can make, while *already done* is —
69
+ * so the one that does not match the cycle's answer must not pass.
47
70
  */
48
- readonly then: (...expected: ReadonlyArray<EventProducer<R>>) => Effect.Effect<void, E | CodecError | AppendConditionFailed, DcbEventStore | Serializer | Uuid | R | PR>;
71
+ readonly then: (...expected: ReadonlyArray<EventProducer<R>>) => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid | R | PR>;
49
72
  /**
50
73
  * Assert the failure branch: the cycle failed with a tagged error whose
51
74
  * `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
52
75
  * decode, or `AppendConditionFailed` from a moved consistency boundary).
76
+ *
77
+ * ⚠ THREE WAYS THIS PASSES FOR THE WRONG REASON, and a layered or permission
78
+ * suite must assert more than the tag. An opaque refusal is correct on the wire
79
+ * and is exactly what blinds this assertion — two different denials share one
80
+ * tag, so a spec can pass on a refusal its author did not mean. An
81
+ * `AppendConditionFailed` now also fires for an EARLIER hop, so the tag no
82
+ * longer says which boundary moved. And `NoReadsRecorded` is a reachable spec
83
+ * whose shape does not mark it as structural: it reads like a domain assertion
84
+ * and is a statement about wiring.
53
85
  */
54
86
  readonly thenError: (tag: string) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>;
87
+ /**
88
+ * Assert the "already done" branch: the cycle SUCCEEDED with `Unchanged`
89
+ * (recognised by `isUnchanged`, never by its tag) and appended nothing past
90
+ * the seed. A cycle that appended — even an empty-looking one — or that
91
+ * returned a `Position` fails it, and a failing cycle re-raises its cause, for
92
+ * the reason `then` gives.
93
+ *
94
+ * Both halves are asserted, not just the result, because they are different
95
+ * claims: a runner that returned `Unchanged` AND appended would be exactly the
96
+ * defect this step exists to catch, and the result alone would not see it.
97
+ */
98
+ readonly thenUnchanged: () => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid | R | PR>;
55
99
  }
100
+ /**
101
+ * Begin a specification for ANY command cycle that returns the appended
102
+ * `Position` — a bracket for one command as readily as a decider — or
103
+ * `Unchanged`, for a cycle that can answer "already done".
104
+ *
105
+ * This is the general form and `specificationFor` below is a one-line
106
+ * specialisation of it, which is the result worth stating rather than the
107
+ * mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
108
+ * and `runDecider` is the function that says so. Nothing downstream of `when`
109
+ * moves — the same seed boundary, the same read-back, the same shape comparison
110
+ * — because both cycles already agree on what they return.
111
+ *
112
+ * ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
113
+ * that union itself, so a naive `Effect<Position, E, R>` double-counts it and
114
+ * fails to unify. `CycleError` exists to be written here.
115
+ */
116
+ export declare const specificationForCycle: <Command, E, R, PR = never>(run: (command: Command) => Effect.Effect<Position | Unchanged, E | CycleError, DcbEventStore | Serializer | Uuid | R | PR>) => Specification<Command, E, R, PR>;
56
117
  /**
57
118
  * Begin a specification for `decider`. The returned `given(...)` starts the
58
119
  * Given/When/Then chain; the terminal step yields an `Effect` requiring
59
120
  * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
121
+ *
122
+ * One line over the layered sibling above, and deliberately not a twin of it:
123
+ * a duplicated body would be two sets of tests and two things free to drift on
124
+ * the seed boundary, the read-back or the shape comparison.
60
125
  */
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>>;
126
+ export declare const specificationFor: <P extends Record<string, SliceProjection>, Command, E, R, O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>>(decider: Decider<P, Command, E, R, O>) => Specification<Command, E, R, RequirementOf<P>>;
62
127
  /**
63
128
  * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
64
129
  * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
@@ -72,5 +137,5 @@ export declare const specificationFor: <P extends Record<string, SliceProjection
72
137
  * encryption requirement provides its cipher layer inside `build` before
73
138
  * returning.
74
139
  */
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;
140
+ export declare const scenario: <P extends Record<string, SliceProjection>, Command, E, O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>>(description: string, decider: Decider<P, Command, E, never, O>, build: (spec: Specification<Command, E, never, RequirementOf<P>>) => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid>) => void;
76
141
  //# sourceMappingURL=specification.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"specification.d.ts","sourceRoot":"","sources":["../../src/specification.ts"],"names":[],"mappings":"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"}
package/dist/esm/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * @kairos-es/testing — the Given/When/Then decision-model testing surface.
3
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.
4
+ * A `DeciderSpecification`-equivalent over a real fold and a `DcbEventStore` (in
5
+ * practice the in-memory layer): `@kairos-es/codec`'s `buildDecisionModel` for
6
+ * a single-hop decision, and `modelAtHead` plus the read ledger for a layered
7
+ * one. Shaped so an Event Modeling `Specification` (given events / when a
8
+ * command / then events / thenError a tag) compiles onto it 1:1, plus
9
+ * `thenUnchanged` for a decision that answers "already done" and appends
10
+ * nothing.
8
11
  *
9
12
  * scenario('subscribe a student', subscribeDecider, (spec) =>
10
13
  * spec
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,cAAc,oBAAiB","ignoreList":[]}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,cAAc,oBAAiB","ignoreList":[]}
@@ -1,7 +1,12 @@
1
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
2
+ * The Given/When/Then surface: a `DeciderSpecification`-equivalent over a real
3
+ * fold and a `DcbEventStore` (the in-memory layer in practice) —
4
+ * `@kairos-es/codec`'s `buildDecisionModel` for a single-hop decision, and
5
+ * `modelAtHead` plus the read ledger for a layered one. `specificationForCycle`
6
+ * is the general entry point over either, named for the command CYCLE it takes
7
+ * rather than for the layered bracket that was its first caller — the shape it
8
+ * accepts is `(command) => Effect<Position, …>` and a decider is one instance of
9
+ * it. It is shaped so an Event Modeling
5
10
  * `Specification` (given events, when a command, then events / thenError a tag)
6
11
  * compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
7
12
  * `DeciderSpecification`.
@@ -16,12 +21,17 @@
16
21
  * .when(command)
17
22
  * .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
18
23
  *
24
+ * specificationFor(decider)
25
+ * .given(...pastEventProducers)
26
+ * .when(command)
27
+ * .thenUnchanged() // "already done" branch: nothing appended
28
+ *
19
29
  * Events are supplied *effectfully*: a given/expected event is the producer an
20
30
  * application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
21
31
  * Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
22
32
  * under the same layers the cycle runs against, and their `data` bytes match.
23
33
  *
24
- * Each terminal (`then` / `thenError`) returns an `Effect` requiring
34
+ * Each terminal (`then` / `thenError` / `thenUnchanged`) returns an `Effect` requiring
25
35
  * `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
26
36
  * serialiser, and uuid layers (fresh store per case for isolation).
27
37
  * `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
@@ -35,7 +45,7 @@
35
45
  * deterministic, so equal payloads produce equal `data` bytes.
36
46
  */
37
47
  import { expect, it } from '@effect/vitest';
38
- import { runDecider, SerializerDefault, UuidTest } from '@kairos-es/codec';
48
+ import { isUnchanged, runDecider, SerializerDefault, UuidTest } from '@kairos-es/codec';
39
49
  import { DcbEventStore, DcbEventStoreInMemory, ORIGIN, query as Q } from '@kairos-es/core';
40
50
  import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect';
41
51
  const shapeOf = event => ({
@@ -84,21 +94,32 @@ const appendedAfter = after => Effect.gen(function* () {
84
94
  return yield* collect(events);
85
95
  });
86
96
  /**
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>`.
97
+ * Begin a specification for ANY command cycle that returns the appended
98
+ * `Position` — a bracket for one command as readily as a decider — or
99
+ * `Unchanged`, for a cycle that can answer "already done".
100
+ *
101
+ * This is the general form and `specificationFor` below is a one-line
102
+ * specialisation of it, which is the result worth stating rather than the
103
+ * mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
104
+ * and `runDecider` is the function that says so. Nothing downstream of `when`
105
+ * moves — the same seed boundary, the same read-back, the same shape comparison
106
+ * — because both cycles already agree on what they return.
107
+ *
108
+ * ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
109
+ * that union itself, so a naive `Effect<Position, E, R>` double-counts it and
110
+ * fails to unify. `CycleError` exists to be written here.
90
111
  */
91
- export const specificationFor = decider => ({
112
+ export const specificationForCycle = run => ({
92
113
  given: (...pastEvents) => ({
93
114
  when: command => {
94
- // Run the seed + decider cycle, exposing the outcome as an Exit so both
115
+ // Run the seed + cycle, exposing the outcome as an Exit so both
95
116
  // branches (then / thenError) can inspect success or failure uniformly. The
96
117
  // seed is inside the Exit too, so a decode failure while materialising a
97
118
  // given producer is captured as a failure rather than escaping the channel.
98
119
  // The success value pairs the seed boundary (the last seeded position) with
99
- // the decider's outcome, so `then` reads strictly after that boundary
120
+ // the cycle's outcome, so `then` reads strictly after that boundary
100
121
  // 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);
122
+ const runToExit = seed(pastEvents).pipe(Effect.zip(run(command)), Effect.exit);
102
123
  return {
103
124
  // biome-ignore lint/suspicious/noThenProperty: deliberate Given/When/Then terminal name (see WhenSpec.then); never awaited as a thenable.
104
125
  then: (...expected) => Effect.gen(function* () {
@@ -109,7 +130,8 @@ export const specificationFor = decider => ({
109
130
  // it loud rather than silently passing.
110
131
  return yield* Effect.failCause(exit.cause);
111
132
  }
112
- const [seedBoundary] = exit.value;
133
+ const [seedBoundary, result] = exit.value;
134
+ expect(isUnchanged(result)).toBe(false);
113
135
  const appended = yield* appendedAfter(seedBoundary);
114
136
  const expectedEvents = yield* Effect.all(expected);
115
137
  expect(appended.map(s => shapeOf(s.event))).toEqual(expectedEvents.map(shapeOf));
@@ -123,11 +145,31 @@ export const specificationFor = decider => ({
123
145
  const actualTag = typeof error === 'object' && error !== null && '_tag' in error ? error._tag : undefined;
124
146
  expect(actualTag).toBe(tag);
125
147
  }
148
+ }),
149
+ thenUnchanged: () => Effect.gen(function* () {
150
+ const exit = yield* runToExit;
151
+ if (Exit.isFailure(exit)) {
152
+ return yield* Effect.failCause(exit.cause);
153
+ }
154
+ const [seedBoundary, result] = exit.value;
155
+ expect(isUnchanged(result)).toBe(true);
156
+ const appended = yield* appendedAfter(seedBoundary);
157
+ expect(appended.map(s => shapeOf(s.event))).toEqual([]);
126
158
  })
127
159
  };
128
160
  }
129
161
  })
130
162
  });
163
+ /**
164
+ * Begin a specification for `decider`. The returned `given(...)` starts the
165
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
166
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
167
+ *
168
+ * One line over the layered sibling above, and deliberately not a twin of it:
169
+ * a duplicated body would be two sets of tests and two things free to drift on
170
+ * the seed boundary, the read-back or the shape comparison.
171
+ */
172
+ export const specificationFor = decider => specificationForCycle(command => runDecider(decider, command));
131
173
  /**
132
174
  * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
133
175
  * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
@@ -1 +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":[]}
1
+ {"version":3,"file":"specification.js","names":["expect","it","isUnchanged","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","specificationForCycle","run","given","pastEvents","when","command","runToExit","zip","exit","then","expected","isFailure","failCause","cause","seedBoundary","result","value","toBe","appended","expectedEvents","s","toEqual","thenError","tag","failure","failureOption","error","_tag","undefined","actualTag","thenUnchanged","specificationFor","decider","scenario","description","build","effect","provide","mergeAll","fresh"],"sources":["../../src/specification.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairos-es/testing",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.2",
4
4
  "description": "Given/When/Then decision-model testing surface for kairos-es",
5
5
  "license": "BSD-3-Clause",
6
6
  "repository": {
@@ -13,8 +13,8 @@
13
13
  "peerDependencies": {
14
14
  "@effect/vitest": "^0.30.0",
15
15
  "effect": "^3.22.0",
16
- "@kairos-es/codec": "^1.0.0-alpha.0",
17
- "@kairos-es/core": "^1.0.0-alpha.0"
16
+ "@kairos-es/codec": "^1.0.0-alpha.2",
17
+ "@kairos-es/core": "^1.0.0-alpha.2"
18
18
  },
19
19
  "main": "./dist/cjs/index.js",
20
20
  "module": "./dist/esm/index.js",
package/src/index.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * @kairos-es/testing — the Given/When/Then decision-model testing surface.
3
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.
4
+ * A `DeciderSpecification`-equivalent over a real fold and a `DcbEventStore` (in
5
+ * practice the in-memory layer): `@kairos-es/codec`'s `buildDecisionModel` for
6
+ * a single-hop decision, and `modelAtHead` plus the read ledger for a layered
7
+ * one. Shaped so an Event Modeling `Specification` (given events / when a
8
+ * command / then events / thenError a tag) compiles onto it 1:1, plus
9
+ * `thenUnchanged` for a decision that answers "already done" and appends
10
+ * nothing.
8
11
  *
9
12
  * scenario('subscribe a student', subscribeDecider, (spec) =>
10
13
  * spec
@@ -1,7 +1,12 @@
1
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
2
+ * The Given/When/Then surface: a `DeciderSpecification`-equivalent over a real
3
+ * fold and a `DcbEventStore` (the in-memory layer in practice) —
4
+ * `@kairos-es/codec`'s `buildDecisionModel` for a single-hop decision, and
5
+ * `modelAtHead` plus the read ledger for a layered one. `specificationForCycle`
6
+ * is the general entry point over either, named for the command CYCLE it takes
7
+ * rather than for the layered bracket that was its first caller — the shape it
8
+ * accepts is `(command) => Effect<Position, …>` and a decider is one instance of
9
+ * it. It is shaped so an Event Modeling
5
10
  * `Specification` (given events, when a command, then events / thenError a tag)
6
11
  * compiles onto it 1:1 — prior art: dcb-events `TestRunner` and Emmett's
7
12
  * `DeciderSpecification`.
@@ -16,12 +21,17 @@
16
21
  * .when(command)
17
22
  * .thenError('AppendConditionFailed') // failure branch (domain error or conflict)
18
23
  *
24
+ * specificationFor(decider)
25
+ * .given(...pastEventProducers)
26
+ * .when(command)
27
+ * .thenUnchanged() // "already done" branch: nothing appended
28
+ *
19
29
  * Events are supplied *effectfully*: a given/expected event is the producer an
20
30
  * application authors via `toEvent` — an `Effect<DcbEvent, CodecError,
21
31
  * Serializer | Uuid | R>` — so both sides materialise (encode -> serialise)
22
32
  * under the same layers the cycle runs against, and their `data` bytes match.
23
33
  *
24
- * Each terminal (`then` / `thenError`) returns an `Effect` requiring
34
+ * Each terminal (`then` / `thenError` / `thenUnchanged`) returns an `Effect` requiring
25
35
  * `DcbEventStore | Serializer | Uuid | R`, so a test provides the store,
26
36
  * serialiser, and uuid layers (fresh store per case for isolation).
27
37
  * `scenario(...)` wraps the whole thing in `@effect/vitest`'s `it.effect`
@@ -38,10 +48,15 @@ import { expect, it } from '@effect/vitest'
38
48
  import type {
39
49
  CodecError,
40
50
  Decider,
51
+ DecisionOutput,
52
+ NoReadsRecorded,
41
53
  RequirementOf,
42
54
  SliceProjection,
55
+ TaglessSliceInLedger,
56
+ Unchanged,
43
57
  } from '@kairos-es/codec'
44
58
  import {
59
+ isUnchanged,
45
60
  runDecider,
46
61
  type Serializer,
47
62
  SerializerDefault,
@@ -58,7 +73,15 @@ import {
58
73
  query as Q,
59
74
  type SequencedEvent,
60
75
  } from '@kairos-es/core'
61
- import { Cause, Chunk, Effect, Exit, Layer, Stream } from 'effect'
76
+ import {
77
+ type Array as Arr,
78
+ Cause,
79
+ Chunk,
80
+ Effect,
81
+ Exit,
82
+ Layer,
83
+ Stream,
84
+ } from 'effect'
62
85
 
63
86
  /** An effectful producer of a wire event (the `toEvent` result an app authors). */
64
87
  export type EventProducer<R> = Effect.Effect<
@@ -128,6 +151,28 @@ const appendedAfter = (
128
151
  return yield* collect(events)
129
152
  })
130
153
 
154
+ /**
155
+ * The errors a command CYCLE contributes, beyond whatever `E` the decision
156
+ * itself fails with.
157
+ *
158
+ * Named once and exported because it is what makes the layered entry point below
159
+ * UNIFY. `WhenSpec.then` adds this union itself, so a `run` written as a naive
160
+ * `Effect<Position, E, R>` double-counts every member of it and fails to
161
+ * typecheck against a chain that has already added them — a trap with an
162
+ * unhelpful error message, met once and closed here by giving the union a name a
163
+ * caller can write.
164
+ *
165
+ * `NoReadsRecorded` is reachable only on the layered path and `TaglessSliceInLedger`
166
+ * only where a bracket is open, but both are members here rather than in a second
167
+ * union, because the two paths share every step downstream of `when` and a second
168
+ * union would be a second thing to keep in step for no caller's benefit.
169
+ */
170
+ export type CycleError =
171
+ | CodecError
172
+ | AppendConditionFailed
173
+ | TaglessSliceInLedger
174
+ | NoReadsRecorded
175
+
131
176
  /**
132
177
  * The entry point of the Given/When/Then chain: what `specificationFor` returns
133
178
  * and what `scenario` hands its `build` callback. It is NAMED rather than
@@ -163,57 +208,100 @@ export interface GivenSpec<Command, E, R, PR> {
163
208
  readonly when: (command: Command) => WhenSpec<E, R, PR>
164
209
  }
165
210
 
166
- /** A spec that has a command and awaits a `then`/`thenError` assertion. */
211
+ /** A spec that has a command and awaits a `then`/`thenError`/`thenUnchanged` assertion. */
167
212
  export interface WhenSpec<E, R, PR> {
168
213
  /**
169
214
  * Assert the success branch: the decider appended exactly these events
170
215
  * (compared structurally by type/tags/data, in order). The expected events are
171
216
  * 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).
217
+ * channel keeps `E | CycleError` because a genuinely failing cycle re-raises
218
+ * its cause (a `.then` on a failing decision is a test-authoring mistake, made
219
+ * loud rather than silently green).
220
+ *
221
+ * A cycle that answered `Unchanged` FAILS this assertion, even with no
222
+ * expected events. `.then()` with an empty list and `.thenUnchanged()` both
223
+ * see nothing appended, but they claim different things — *decided an empty
224
+ * change* is not a decision this library can make, while *already done* is —
225
+ * so the one that does not match the cycle's answer must not pass.
175
226
  */
176
227
  readonly then: (
177
228
  ...expected: ReadonlyArray<EventProducer<R>>
178
229
  ) => Effect.Effect<
179
230
  void,
180
- E | CodecError | AppendConditionFailed,
231
+ E | CycleError,
181
232
  DcbEventStore | Serializer | Uuid | R | PR
182
233
  >
183
234
  /**
184
235
  * Assert the failure branch: the cycle failed with a tagged error whose
185
236
  * `_tag` equals `tag` (a domain error from `decide`, a `CodecError` from
186
237
  * decode, or `AppendConditionFailed` from a moved consistency boundary).
238
+ *
239
+ * ⚠ THREE WAYS THIS PASSES FOR THE WRONG REASON, and a layered or permission
240
+ * suite must assert more than the tag. An opaque refusal is correct on the wire
241
+ * and is exactly what blinds this assertion — two different denials share one
242
+ * tag, so a spec can pass on a refusal its author did not mean. An
243
+ * `AppendConditionFailed` now also fires for an EARLIER hop, so the tag no
244
+ * longer says which boundary moved. And `NoReadsRecorded` is a reachable spec
245
+ * whose shape does not mark it as structural: it reads like a domain assertion
246
+ * and is a statement about wiring.
187
247
  */
188
248
  readonly thenError: (
189
249
  tag: string,
190
250
  ) => Effect.Effect<void, never, DcbEventStore | Serializer | Uuid | R | PR>
251
+ /**
252
+ * Assert the "already done" branch: the cycle SUCCEEDED with `Unchanged`
253
+ * (recognised by `isUnchanged`, never by its tag) and appended nothing past
254
+ * the seed. A cycle that appended — even an empty-looking one — or that
255
+ * returned a `Position` fails it, and a failing cycle re-raises its cause, for
256
+ * the reason `then` gives.
257
+ *
258
+ * Both halves are asserted, not just the result, because they are different
259
+ * claims: a runner that returned `Unchanged` AND appended would be exactly the
260
+ * defect this step exists to catch, and the result alone would not see it.
261
+ */
262
+ readonly thenUnchanged: () => Effect.Effect<
263
+ void,
264
+ E | CycleError,
265
+ DcbEventStore | Serializer | Uuid | R | PR
266
+ >
191
267
  }
192
268
 
193
269
  /**
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>`.
270
+ * Begin a specification for ANY command cycle that returns the appended
271
+ * `Position` — a bracket for one command as readily as a decider — or
272
+ * `Unchanged`, for a cycle that can answer "already done".
273
+ *
274
+ * This is the general form and `specificationFor` below is a one-line
275
+ * specialisation of it, which is the result worth stating rather than the
276
+ * mechanism: a `Decider` is one particular `(command) => Effect<Position, …>`,
277
+ * and `runDecider` is the function that says so. Nothing downstream of `when`
278
+ * moves — the same seed boundary, the same read-back, the same shape comparison
279
+ * — because both cycles already agree on what they return.
280
+ *
281
+ * ⚠ `run`'s error channel is written `E | CycleError` and must be: the chain adds
282
+ * that union itself, so a naive `Effect<Position, E, R>` double-counts it and
283
+ * fails to unify. `CycleError` exists to be written here.
197
284
  */
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>> => ({
285
+ export const specificationForCycle = <Command, E, R, PR = never>(
286
+ run: (
287
+ command: Command,
288
+ ) => Effect.Effect<
289
+ Position | Unchanged,
290
+ E | CycleError,
291
+ DcbEventStore | Serializer | Uuid | R | PR
292
+ >,
293
+ ): Specification<Command, E, R, PR> => ({
206
294
  given: (...pastEvents) => ({
207
295
  when: (command) => {
208
- // Run the seed + decider cycle, exposing the outcome as an Exit so both
296
+ // Run the seed + cycle, exposing the outcome as an Exit so both
209
297
  // branches (then / thenError) can inspect success or failure uniformly. The
210
298
  // seed is inside the Exit too, so a decode failure while materialising a
211
299
  // given producer is captured as a failure rather than escaping the channel.
212
300
  // The success value pairs the seed boundary (the last seeded position) with
213
- // the decider's outcome, so `then` reads strictly after that boundary
301
+ // the cycle's outcome, so `then` reads strictly after that boundary
214
302
  // rather than dropping a fixed row count (correct on a gappy store).
215
303
  const runToExit = seed(pastEvents).pipe(
216
- Effect.zip(runDecider(decider, command)),
304
+ Effect.zip(run(command)),
217
305
  Effect.exit,
218
306
  )
219
307
 
@@ -228,7 +316,8 @@ export const specificationFor = <
228
316
  // it loud rather than silently passing.
229
317
  return yield* Effect.failCause(exit.cause)
230
318
  }
231
- const [seedBoundary] = exit.value
319
+ const [seedBoundary, result] = exit.value
320
+ expect(isUnchanged(result)).toBe(false)
232
321
  const appended = yield* appendedAfter(seedBoundary)
233
322
  const expectedEvents = yield* Effect.all(expected)
234
323
  expect(appended.map((s) => shapeOf(s.event))).toEqual(
@@ -249,11 +338,44 @@ export const specificationFor = <
249
338
  expect(actualTag).toBe(tag)
250
339
  }
251
340
  }),
341
+ thenUnchanged: () =>
342
+ Effect.gen(function* () {
343
+ const exit = yield* runToExit
344
+ if (Exit.isFailure(exit)) {
345
+ return yield* Effect.failCause(exit.cause)
346
+ }
347
+ const [seedBoundary, result] = exit.value
348
+ expect(isUnchanged(result)).toBe(true)
349
+ const appended = yield* appendedAfter(seedBoundary)
350
+ expect(appended.map((s) => shapeOf(s.event))).toEqual([])
351
+ }),
252
352
  }
253
353
  },
254
354
  }),
255
355
  })
256
356
 
357
+ /**
358
+ * Begin a specification for `decider`. The returned `given(...)` starts the
359
+ * Given/When/Then chain; the terminal step yields an `Effect` requiring
360
+ * `DcbEventStore | Serializer | Uuid | R | RequirementOf<P>`.
361
+ *
362
+ * One line over the layered sibling above, and deliberately not a twin of it:
363
+ * a duplicated body would be two sets of tests and two things free to drift on
364
+ * the seed boundary, the read-back or the shape comparison.
365
+ */
366
+ export const specificationFor = <
367
+ P extends Record<string, SliceProjection>,
368
+ Command,
369
+ E,
370
+ R,
371
+ O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
372
+ >(
373
+ decider: Decider<P, Command, E, R, O>,
374
+ ): Specification<Command, E, R, RequirementOf<P>> =>
375
+ specificationForCycle<Command, E, R, RequirementOf<P>>((command) =>
376
+ runDecider(decider, command),
377
+ )
378
+
257
379
  /**
258
380
  * `scenario(description, spec => spec.given(...).when(...).then(...))` — register
259
381
  * an `@effect/vitest` `it.effect` case running `build` against a fresh in-memory
@@ -267,16 +389,17 @@ export const specificationFor = <
267
389
  * encryption requirement provides its cipher layer inside `build` before
268
390
  * returning.
269
391
  */
270
- export const scenario = <P extends Record<string, SliceProjection>, Command, E>(
392
+ export const scenario = <
393
+ P extends Record<string, SliceProjection>,
394
+ Command,
395
+ E,
396
+ O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
397
+ >(
271
398
  description: string,
272
- decider: Decider<P, Command, E>,
399
+ decider: Decider<P, Command, E, never, O>,
273
400
  build: (
274
401
  spec: Specification<Command, E, never, RequirementOf<P>>,
275
- ) => Effect.Effect<
276
- void,
277
- E | CodecError | AppendConditionFailed,
278
- DcbEventStore | Serializer | Uuid
279
- >,
402
+ ) => Effect.Effect<void, E | CycleError, DcbEventStore | Serializer | Uuid>,
280
403
  ): void => {
281
404
  it.effect(description, () =>
282
405
  build(specificationFor(decider)).pipe(