@kairos-es/codec 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.
Files changed (88) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +46 -0
  3. package/dist/cjs/Serializer.js +34 -0
  4. package/dist/cjs/Serializer.js.map +1 -0
  5. package/dist/cjs/Uuid.js +41 -0
  6. package/dist/cjs/Uuid.js.map +1 -0
  7. package/dist/cjs/decider.js +152 -0
  8. package/dist/cjs/decider.js.map +1 -0
  9. package/dist/cjs/decisionModel.js +155 -0
  10. package/dist/cjs/decisionModel.js.map +1 -0
  11. package/dist/cjs/decode.js +170 -0
  12. package/dist/cjs/decode.js.map +1 -0
  13. package/dist/cjs/deserialiseThenDecode.js +20 -0
  14. package/dist/cjs/deserialiseThenDecode.js.map +1 -0
  15. package/dist/cjs/errors.js +94 -0
  16. package/dist/cjs/errors.js.map +1 -0
  17. package/dist/cjs/event.js +26 -0
  18. package/dist/cjs/event.js.map +1 -0
  19. package/dist/cjs/index.js +112 -0
  20. package/dist/cjs/index.js.map +1 -0
  21. package/dist/cjs/projection.js +118 -0
  22. package/dist/cjs/projection.js.map +1 -0
  23. package/dist/cjs/registry.js +41 -0
  24. package/dist/cjs/registry.js.map +1 -0
  25. package/dist/cjs/toEvent.js +51 -0
  26. package/dist/cjs/toEvent.js.map +1 -0
  27. package/dist/dts/Serializer.d.ts +27 -0
  28. package/dist/dts/Serializer.d.ts.map +1 -0
  29. package/dist/dts/Uuid.d.ts +19 -0
  30. package/dist/dts/Uuid.d.ts.map +1 -0
  31. package/dist/dts/decider.d.ts +157 -0
  32. package/dist/dts/decider.d.ts.map +1 -0
  33. package/dist/dts/decisionModel.d.ts +129 -0
  34. package/dist/dts/decisionModel.d.ts.map +1 -0
  35. package/dist/dts/decode.d.ts +92 -0
  36. package/dist/dts/decode.d.ts.map +1 -0
  37. package/dist/dts/deserialiseThenDecode.d.ts +62 -0
  38. package/dist/dts/deserialiseThenDecode.d.ts.map +1 -0
  39. package/dist/dts/errors.d.ts +79 -0
  40. package/dist/dts/errors.d.ts.map +1 -0
  41. package/dist/dts/event.d.ts +71 -0
  42. package/dist/dts/event.d.ts.map +1 -0
  43. package/dist/dts/index.d.ts +63 -0
  44. package/dist/dts/index.d.ts.map +1 -0
  45. package/dist/dts/projection.d.ts +56 -0
  46. package/dist/dts/projection.d.ts.map +1 -0
  47. package/dist/dts/registry.d.ts +46 -0
  48. package/dist/dts/registry.d.ts.map +1 -0
  49. package/dist/dts/toEvent.d.ts +34 -0
  50. package/dist/dts/toEvent.d.ts.map +1 -0
  51. package/dist/esm/Serializer.js +26 -0
  52. package/dist/esm/Serializer.js.map +1 -0
  53. package/dist/esm/Uuid.js +33 -0
  54. package/dist/esm/Uuid.js.map +1 -0
  55. package/dist/esm/decider.js +143 -0
  56. package/dist/esm/decider.js.map +1 -0
  57. package/dist/esm/decisionModel.js +146 -0
  58. package/dist/esm/decisionModel.js.map +1 -0
  59. package/dist/esm/decode.js +163 -0
  60. package/dist/esm/decode.js.map +1 -0
  61. package/dist/esm/deserialiseThenDecode.js +13 -0
  62. package/dist/esm/deserialiseThenDecode.js.map +1 -0
  63. package/dist/esm/errors.js +85 -0
  64. package/dist/esm/errors.js.map +1 -0
  65. package/dist/esm/event.js +19 -0
  66. package/dist/esm/event.js.map +1 -0
  67. package/dist/esm/index.js +63 -0
  68. package/dist/esm/index.js.map +1 -0
  69. package/dist/esm/package.json +4 -0
  70. package/dist/esm/projection.js +110 -0
  71. package/dist/esm/projection.js.map +1 -0
  72. package/dist/esm/registry.js +34 -0
  73. package/dist/esm/registry.js.map +1 -0
  74. package/dist/esm/toEvent.js +44 -0
  75. package/dist/esm/toEvent.js.map +1 -0
  76. package/package.json +31 -0
  77. package/src/Serializer.ts +37 -0
  78. package/src/Uuid.ts +36 -0
  79. package/src/decider.ts +219 -0
  80. package/src/decisionModel.ts +182 -0
  81. package/src/decode.ts +256 -0
  82. package/src/deserialiseThenDecode.ts +76 -0
  83. package/src/errors.ts +91 -0
  84. package/src/event.ts +90 -0
  85. package/src/index.ts +71 -0
  86. package/src/projection.ts +163 -0
  87. package/src/registry.ts +87 -0
  88. package/src/toEvent.ts +63 -0
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The pluggable serialiser: the `Uint8Array` <-> value boundary applied
3
+ * *outside* `Schema`. Encode is `Schema.encode` then `serialise`; decode is
4
+ * `deserialise` then `Schema.decodeUnknown`. Both codec paths read the same
5
+ * `Serializer` from context, so encode and decode cannot mismatch; swapping
6
+ * JSON for msgpack is a different layer with no schema or call-site changes.
7
+ *
8
+ * `deserialise` may throw synchronously (corrupt bytes): wrapping it as a
9
+ * `CodecError` is the *caller's* concern — every decode site wraps it in
10
+ * `Effect.try`. Keeping the service interface plain (no Effect) lets a layer be
11
+ * a trivial `Layer.succeed`.
12
+ */
13
+ import { Context, Layer } from 'effect'
14
+
15
+ export class Serializer extends Context.Tag('@kairos-es/codec/Serializer')<
16
+ Serializer,
17
+ {
18
+ readonly serialise: (v: unknown) => Uint8Array
19
+ readonly deserialise: (b: Uint8Array) => unknown
20
+ }
21
+ >() {}
22
+
23
+ const encoder = new TextEncoder()
24
+ const decoder = new TextDecoder()
25
+
26
+ /**
27
+ * The shipped default: JSON over UTF-8. `serialise` = `JSON.stringify` then
28
+ * `TextEncoder`; `deserialise` = `TextDecoder` then `JSON.parse` (which throws
29
+ * synchronously on corrupt bytes, wrapped to `CodecError` at the call site).
30
+ */
31
+ export const SerializerDefault: Layer.Layer<Serializer> = Layer.succeed(
32
+ Serializer,
33
+ {
34
+ serialise: (v) => encoder.encode(JSON.stringify(v)),
35
+ deserialise: (b) => JSON.parse(decoder.decode(b)),
36
+ },
37
+ )
package/src/Uuid.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The uuid service: `toEvent` stamps each wire event's `uuid` from here, keeping
3
+ * `core` RNG-free (and letting suites make events comparable). Overridable per
4
+ * `toEvent` call via `options.uuid`.
5
+ *
6
+ * `UuidLive` uses WebCrypto (`globalThis.crypto.randomUUID`) so the codec stays
7
+ * platform-neutral with no `node:crypto`. `UuidTest` is a deterministic layer
8
+ * whose per-layer counter yields stable RFC-4122 v4-shaped uuids
9
+ * (`00000000-0000-4000-8000-<12 hex>`), so a suite that builds one `UuidTest`
10
+ * layer gets a repeatable sequence.
11
+ */
12
+ import { makeSequentialUuid } from '@kairos-es/core/testing'
13
+ import { Context, Effect, Layer } from 'effect'
14
+
15
+ export class Uuid extends Context.Tag('@kairos-es/codec/Uuid')<
16
+ Uuid,
17
+ { readonly generate: Effect.Effect<string> }
18
+ >() {}
19
+
20
+ /** Live uuid generation via WebCrypto (no `node:crypto`). */
21
+ export const UuidLive: Layer.Layer<Uuid> = Layer.succeed(Uuid, {
22
+ generate: Effect.sync(() => globalThis.crypto.randomUUID()),
23
+ })
24
+
25
+ /**
26
+ * A deterministic uuid layer for suites, wrapping core's shared
27
+ * `makeSequentialUuid` (the single source of truth for the counter scheme).
28
+ * Each layer instance builds a fresh generator, so it owns its own counter and
29
+ * yields a stable v4-shaped sequence `00000000-0000-4000-8000-<12 hex>` starting
30
+ * at `...000000000000`. Generation is wrapped in `Effect.sync` because the
31
+ * generator advances its counter (a side effect on each call).
32
+ */
33
+ export const UuidTest: Layer.Layer<Uuid> = Layer.sync(Uuid, () => {
34
+ const next = makeSequentialUuid()
35
+ return { generate: Effect.sync(next) }
36
+ })
package/src/decider.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * A `Decider`: the write side's unit of decision. It pairs the projections a
3
+ * command handler reads (its decision model) with an effectful `decide` step
4
+ * that turns the folded state plus a command into the events to append.
5
+ *
6
+ * This is PRODUCTION code and it lives beside the `buildDecisionModel` it
7
+ * drives. It began in `@kairos-es/testing` because the Given/When/Then surface
8
+ * was its first consumer, but nothing in it is test apparatus: every import
9
+ * resolves to this package or to `@kairos-es/core`, and a test framework is
10
+ * never one of them. That home was a real hazard rather than a tidiness
11
+ * complaint — `@kairos-es/testing` peers `@effect/vitest`, so a command handler
12
+ * typed against `Decider` (as this repo's own worked example is, in `src/` and
13
+ * not `test/`) named a package whose sibling module imports `expect` and `it` at
14
+ * top level, and a handler that actually RAN a command would have
15
+ * runtime-imported `runDecider` from it, loading a test framework into a
16
+ * production module graph. `@kairos-es/read/testing` documents that same hazard
17
+ * as the reason a subpath may add no peer; the difference here is that this is
18
+ * not a testing surface at all, so the fix is a home rather than a rule.
19
+ *
20
+ * `@kairos-es/testing` now imports these two symbols like any other consumer,
21
+ * and NO compatibility re-export is left behind there: nothing is published yet,
22
+ * so a re-export would be permanent clutter bought for no consumer, and it would
23
+ * preserve in that package's public surface exactly the coupling the move
24
+ * exists to remove. That is the argument ADR-0008 made when `retryOnConflict`
25
+ * moved to the package that owns the error it matches, and it holds here for the
26
+ * same reason — a write-side consumer depending on the wrong package is
27
+ * backwards, whichever direction the dependency points.
28
+ *
29
+ * `projections` is a *function of the command* because a DCB decision model is
30
+ * parametrised by the command's own identifiers (e.g. narrow to
31
+ * `course:{command.courseId}`), which keeps the consistency boundary — and the
32
+ * `failIfEventsMatch` guard the read produces — as tight as the decision
33
+ * actually needs. This mirrors dcb-events, where the command handler calls
34
+ * `buildDecisionModel(store, { ...projections built from command.data })`.
35
+ *
36
+ * `runDecider` runs the DCB command cycle against whatever `DcbEventStore` is in
37
+ * context: build the decision model for the command (a real `read` + decode +
38
+ * fold via `buildDecisionModel`), decide, then `append` the new events under the
39
+ * append condition the read produced. That IS the optimistic-concurrency
40
+ * contract, in production and in a scenario alike — which is also why the
41
+ * Given/When/Then surface built on it exercises the real fold and the real store
42
+ * rather than a mock. It is a free function rather than a `Context.Tag` service,
43
+ * so it is still not the "production command-runner service method" a retrying
44
+ * `Layer` would need (ADR-0008): the correct retry remains
45
+ * `retryOnConflict(runDecider(...))`, wrapping the whole cycle so a fresh read
46
+ * re-derives the condition.
47
+ *
48
+ * The `decide` step produces its events effectfully (they are encoded via
49
+ * `toEvent`, so it requires `Serializer | Uuid | R`) and may fail on its own
50
+ * error channel (a domain rule violation, e.g. "course already exists"); the
51
+ * append may fail with `AppendConditionFailed` if a concurrent writer moved the
52
+ * boundary, and decode may fail with `CodecError`. All surface on the error
53
+ * channel, so a caller handles all three the same way — and
54
+ * `@kairos-es/testing`'s `.thenError(tag)` can assert any of them.
55
+ */
56
+
57
+ import type {
58
+ AppendConditionFailed,
59
+ CompositeState,
60
+ DcbEvent,
61
+ Position,
62
+ } from '@kairos-es/core'
63
+ import { DcbEventStore } from '@kairos-es/core'
64
+ import type { Array as Arr } from 'effect'
65
+ import { Effect } from 'effect'
66
+ import { buildDecisionModel, type RequirementOf } from './decisionModel'
67
+ import type { CodecError } from './errors'
68
+ import type { SliceProjection } from './projection'
69
+ import type { Serializer } from './Serializer'
70
+ import type { Uuid } from './Uuid'
71
+
72
+ /**
73
+ * A decision unit: the projections whose folded state a command is decided
74
+ * against, and the `decide` step producing the events to append.
75
+ *
76
+ * `decide` may fail with a domain error `E` (a tagged error is idiomatic, so
77
+ * `@kairos-es/testing`'s `.thenError` can match its `_tag`). It receives the
78
+ * composed decision-model `state` and the `command`, and returns the non-empty
79
+ * batch of new events — effectfully, because each event is produced via
80
+ * `toEvent` (encode -> serialise), which requires `Serializer | Uuid | R`.
81
+ *
82
+ * The projection record is bounded by this package's `SliceProjection` (the
83
+ * schema-backed, state-erased bound for a heterogeneous record); the
84
+ * precise per-key state is recovered by core's shared `CompositeState`. The
85
+ * requirement `R` is the union carried by the record's slices' schemas.
86
+ */
87
+ export interface Decider<
88
+ P extends Record<string, SliceProjection>,
89
+ Command,
90
+ E = never,
91
+ R = never,
92
+ > {
93
+ readonly projections: (command: Command) => P
94
+ readonly decide: (
95
+ state: CompositeState<P>,
96
+ command: Command,
97
+ ) => Effect.Effect<
98
+ Arr.NonEmptyReadonlyArray<DcbEvent>,
99
+ E,
100
+ Serializer | Uuid | R
101
+ >
102
+ }
103
+
104
+ /**
105
+ * Build a `Decider` with all four of its type arguments INFERRED: the slice
106
+ * record `P` from what `projections` returns, the error union `E` and the extra
107
+ * requirement `R` from what `decide` returns. The command type is the one thing
108
+ * a caller still writes, once, as the annotation on the `projections` parameter
109
+ * — `Command` occurs only in parameter positions, so nothing else could supply
110
+ * it, and it is domain information (the command this decision is parametrised
111
+ * by) rather than plumbing.
112
+ *
113
+ * It adds no capability: the interface stays directly inhabitable and this
114
+ * returns exactly the object literal a caller would have written. What it
115
+ * changes is what an author must SPELL. An object literal annotated
116
+ * `Decider<P, Command, E>` has to state the whole slice record — a
117
+ * `ReturnType<typeof someProjection>` per key, a slice's state type being
118
+ * inferred by `projection().on().build()` and so having no name to write — plus
119
+ * the union of every error its decide branches can fail with. Dropping the
120
+ * annotation instead is not open to the author: without it `decide`'s `state`
121
+ * and `command` parameters have no contextual type and are implicitly `any`. So
122
+ * the cost landed hardest on the worked example, where some thirty lines of type
123
+ * plumbing sat between the reader and the three decisions that file exists to
124
+ * teach.
125
+ *
126
+ * `Eff` captures decide's WHOLE return type as one type parameter instead of
127
+ * inferring `E` and `R` from an `Effect<…, E, Serializer | Uuid | R>` shaped
128
+ * target, and that is the part that had to be got right rather than merely
129
+ * written down. A real `decide` returns a UNION of effects — one `Effect.fail`
130
+ * per rule it enforces, plus the produce path — and TypeScript infers a type
131
+ * parameter from a union source by choosing one constituent's candidate, not by
132
+ * unioning them. Against the shaped target `E` therefore came out as whichever
133
+ * branch won, and every other branch was then rejected against it. Inference to
134
+ * a NAKED type variable takes the union whole, which is why `Eff` is the
135
+ * parameter and `Effect.Effect.Error` / `Effect.Effect.Context` recover the two
136
+ * channels afterwards: those do distribute across a union of effects.
137
+ *
138
+ * The constraint on `Eff` is load-bearing twice over. It is what still refuses a
139
+ * `decide` that could produce an EMPTY batch, the one guarantee the shaped
140
+ * target used to give and the one `append` relies on. It is also what
141
+ * contextually types the produce path's `(event) => [event]` as a one-element
142
+ * TUPLE; unconstrained, that widens to `DcbEvent[]`, which is not a
143
+ * `NonEmptyReadonlyArray` and would fail at the call site.
144
+ *
145
+ * The single `as` is narrow and unavoidable: the declared return is a
146
+ * conditional over an unresolved `Eff`, so while `Eff` is still a type variable
147
+ * the checker cannot relate `(…) => Eff` to `(…) => Effect<…,
148
+ * Effect.Error<Eff>, …>`. The value is the two functions it was handed, so
149
+ * there is nothing for the cast to be wrong about.
150
+ *
151
+ * `R` excludes `Serializer | Uuid` because that is what `R` MEANS on `Decider`:
152
+ * the interface writes decide's requirement as `Serializer | Uuid | R`, so `R`
153
+ * is what the produce side needs BEYOND the codec's own two services. Leaving
154
+ * them in would state the same requirement twice and make an ordinary decider
155
+ * read as though it carried an extra one, though the set `runDecider` ends up
156
+ * asking for is identical either way. The exclusion cannot swallow a caller's
157
+ * own service by accident: a `Context.Tag`'s requirement type carries its key as
158
+ * a literal `Id` field, so only a tag declared under `@kairos-es/codec/Uuid` or
159
+ * `@kairos-es/codec/Serializer` matches — and a tag under that key IS that
160
+ * service, context being keyed by the same string at runtime.
161
+ */
162
+ export const decider = <
163
+ P extends Record<string, SliceProjection>,
164
+ Command,
165
+ Eff extends Effect.Effect<
166
+ Arr.NonEmptyReadonlyArray<DcbEvent>,
167
+ unknown,
168
+ unknown
169
+ >,
170
+ >(
171
+ projections: (command: Command) => P,
172
+ decide: (state: CompositeState<P>, command: Command) => Eff,
173
+ ): Decider<
174
+ P,
175
+ Command,
176
+ Effect.Effect.Error<Eff>,
177
+ Exclude<Effect.Effect.Context<Eff>, Serializer | Uuid>
178
+ > =>
179
+ ({ projections, decide }) as Decider<
180
+ P,
181
+ Command,
182
+ Effect.Effect.Error<Eff>,
183
+ Exclude<Effect.Effect.Context<Eff>, Serializer | Uuid>
184
+ >
185
+
186
+ /**
187
+ * Run the full DCB command cycle for a decider against the ambient
188
+ * `DcbEventStore`: build the decision model (real read + decode + fold), decide,
189
+ * and append under the read's append condition. Returns the last new position.
190
+ *
191
+ * The requirement is `DcbEventStore | Serializer | Uuid | R |
192
+ * RequirementOf<P>`: `Serializer` enters through both the decode
193
+ * (`buildDecisionModel`) and produce (`toEvent`) sides, `Uuid` through produce,
194
+ * the slices' `RequirementOf<P>` through decode, and `R` through produce. (For a
195
+ * def whose `Schema` carries a requirement, `RequirementOf<P>` and `R` are the
196
+ * same tag.) Any `decide` failure, a decode `CodecError`, or an
197
+ * `AppendConditionFailed` propagates on the error channel unchanged.
198
+ */
199
+ export const runDecider = <
200
+ P extends Record<string, SliceProjection>,
201
+ Command,
202
+ E,
203
+ R,
204
+ >(
205
+ decider: Decider<P, Command, E, R>,
206
+ command: Command,
207
+ ): Effect.Effect<
208
+ Position,
209
+ E | CodecError | AppendConditionFailed,
210
+ DcbEventStore | Serializer | Uuid | R | RequirementOf<P>
211
+ > =>
212
+ Effect.gen(function* () {
213
+ const store = yield* DcbEventStore
214
+ const { state, appendCondition } = yield* buildDecisionModel(
215
+ decider.projections(command),
216
+ )
217
+ const newEvents = yield* decider.decide(state, command)
218
+ return yield* store.append(newEvents, appendCondition)
219
+ })
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Read-and-fold over a slice record: read the composed query, decode each event
3
+ * through the matching def's `Schema` in an effectful `Stream.mapEffect` stage,
4
+ * then fold *purely* through core's `composeProjections`.
5
+ *
6
+ * TWO exports sit over that ONE body, the second expressed over the first.
7
+ * `modelAtHead` stops where the fold does, at `{ state, head }` — a
8
+ * materialisation of the record with no table, no checkpoint and no runner.
9
+ * `buildDecisionModel` adds the only part that is genuinely decision-specific:
10
+ * the `AppendCondition` derived from the same query and that same head (the DCB
11
+ * optimistic-concurrency contract). It is core's old
12
+ * `buildDecisionModel(Record<string, AnyProjection>)` surface, moved here
13
+ * because decode needs `Schema` + the `Serializer`.
14
+ *
15
+ * The fold stays pure and in core; only the decode stage is effectful, and that
16
+ * stage lives in `decode.ts` — `decodeSlices` — so the read side folds a live
17
+ * `subscribe` stream through the *same* decode this module folds a bounded
18
+ * `read` stream through.
19
+ *
20
+ * The result requirement is exactly `DcbEventStore | Serializer | R`, where `R`
21
+ * is the union of the record's slices' requirements — inferred with no cast,
22
+ * `R` being carried covariantly by each slice's `schemas` map value type.
23
+ * `Serializer` is not yielded here: `decodeSlices` reads it from context itself,
24
+ * so the requirement arrives through the decode stage rather than through a
25
+ * binding this module owns (see `decode.ts` for why).
26
+ */
27
+ import {
28
+ type AnyProjection,
29
+ type AppendCondition,
30
+ type CompositeState,
31
+ composeProjections,
32
+ DcbEventStore,
33
+ type Position,
34
+ } from '@kairos-es/core'
35
+ import { Effect, Stream } from 'effect'
36
+ import { decodeSlices } from './decode'
37
+ import type { CodecError } from './errors'
38
+ import type { SliceProjection } from './projection'
39
+ import type { Serializer } from './Serializer'
40
+
41
+ /** The union of requirements `R` carried by a record of `SliceProjection`s. */
42
+ export type RequirementOf<T extends Record<string, SliceProjection>> =
43
+ T[keyof T] extends SliceProjection<infer _S, infer R> ? R : never
44
+
45
+ /**
46
+ * Read the events for the composed slices' query, decode each through its def's
47
+ * `Schema`, fold into per-key state, and return `{ state, head }`: one slice
48
+ * record materialised at read time, with no table, no checkpoint and no runner.
49
+ *
50
+ * ## Why `head` comes back, and not bare `state`
51
+ *
52
+ * `head` is the consistency statement, and without it the state is a figure with
53
+ * no as-of — which is why the as-of is in the name. The fold stops at the
54
+ * position the read returned and is maintained no further, so `head` is the
55
+ * whole of what a caller can say about this state's currency: it is what lets
56
+ * the write path turn it into an `AppendCondition` (see `buildDecisionModel`),
57
+ * and what lets a read-time caller report, cache or compare a state against a
58
+ * position rather than against a wall clock.
59
+ *
60
+ * It is also the inversion worth naming out loud, because intuition runs the
61
+ * other way. A MAINTAINED view is consistent as of its CHECKPOINT, which trails
62
+ * the log's head by however far behind its runner is; this fold is consistent as
63
+ * of the head itself. So of two materialisations of one slice record, the one
64
+ * with no table, no checkpoint and no runner is the MOST current, and the table
65
+ * beside it is the stale one. Reach for a maintained view for the queries a
66
+ * single fold cannot serve — across many identities, ordered, joined, paginated
67
+ * — and never because it is fresher, because it is not.
68
+ *
69
+ * `head` is LOG-GLOBAL: it is the head the store's `read` returned, so it moves
70
+ * for an event no slice here selects. `test/decisionModel.test.ts` pins that
71
+ * behaviour EXPLICITLY, over four appended events one of which the composed query
72
+ * excludes by tag; `test/modelAtHead.test.ts` pins it incidentally, its own head
73
+ * assertion landing on that same excluded event's position. Two guards, one of them
74
+ * deliberate — reach for the explicit one when the property itself is what changes.
75
+ * Log-global neither tightens nor loosens the derived guard — there can be no
76
+ * event matching the query between the query's last match and the log head, so a
77
+ * query-filtered head would refuse exactly the same appends — which is precisely
78
+ * why the store's own figure is the one to carry: one fact about the log, not a
79
+ * per-query derivation, and the honest as-of for a state folded from everything
80
+ * the query selected up to it.
81
+ *
82
+ * ## Why this lives in the codec and not in `@kairos-es/read`
83
+ *
84
+ * In `@kairos-es/read` it would be the only export touching none of that
85
+ * package's machinery: no checkpoint, no `EventLogDurability` (R2 is vacuous
86
+ * where there is no checkpoint to outlive the log), no rung of that package's
87
+ * construction gate, and none of its test apparatus. The vocabulary is already
88
+ * this package's — ADR-0004 names the typed slice path's clients as "a decision
89
+ * model / materialisation / command" — and the body is `buildDecisionModel`'s,
90
+ * which is here for the reason the module doc gives.
91
+ */
92
+ export const modelAtHead = <T extends Record<string, SliceProjection>>(
93
+ projections: T,
94
+ ): Effect.Effect<
95
+ { readonly state: CompositeState<T>; readonly head: Position },
96
+ CodecError,
97
+ DcbEventStore | Serializer | RequirementOf<T>
98
+ > =>
99
+ Effect.gen(function* () {
100
+ const store = yield* DcbEventStore
101
+
102
+ // The shared, back-pressured decode stage, built from the SAME record the
103
+ // query below is composed from — which is the whole reason `decodeSlices`
104
+ // takes the slices rather than a pre-merged schema table.
105
+ const decode = decodeSlices(projections)
106
+
107
+ // The pure fold: core composes the record (slices are AnyProjections).
108
+ const composite = composeProjections(
109
+ projections as Record<string, AnyProjection>,
110
+ )
111
+ const { events, head } = yield* store.read(composite.query)
112
+
113
+ const decoded = decode(events)
114
+
115
+ const state = (yield* Stream.runFold(
116
+ decoded,
117
+ composite.initialState,
118
+ (acc, event) => composite.evolve(acc, event),
119
+ )) as CompositeState<T>
120
+
121
+ return { state, head }
122
+ })
123
+
124
+ /**
125
+ * The write side's decision model: `modelAtHead` plus the `AppendCondition` that
126
+ * reuses the read query as `failIfEventsMatch` and the read head as `after` (the
127
+ * DCB optimistic-concurrency contract).
128
+ *
129
+ * It is expressed OVER `modelAtHead` rather than beside it, and that is the point
130
+ * of the pair. Two functions reading, decoding and folding a slice record almost
131
+ * identically would need two sets of tests and would be free to drift on the
132
+ * decode stage, on the cast recovering `CompositeState<T>`, or on which record
133
+ * the query is composed from. Re-expressing makes this a decomposition instead
134
+ * of a near-duplicate: `modelAtHead` is the shared part, this function is the
135
+ * condition and nothing else, and each is shorter than the one function was.
136
+ *
137
+ * `composeProjections` therefore runs TWICE for one command — once inside
138
+ * `modelAtHead` for the read, once here for `failIfEventsMatch` — and that is
139
+ * deliberate rather than waste. It is a pure derivation of the record, so the two
140
+ * queries cannot disagree however either call site changes. The alternative,
141
+ * returning the query from `modelAtHead`, would put a third field on a READ-TIME
142
+ * API that no read-time caller wants, to save one pure merge over a handful of
143
+ * slices; a surface paying for a body's convenience is the worse trade.
144
+ *
145
+ * ## How to attribute the read, the decode and the fold — the rule, stated once
146
+ *
147
+ * Because this function is `Effect.map` over `modelAtHead`, prose about the codec
148
+ * splits into two kinds and only one of them may name this symbol:
149
+ *
150
+ * - Naming this function as the ENTRY POINT a caller invokes, or as the path
151
+ * something is reached THROUGH, is correct — "the `buildDecisionModel` fold", "the
152
+ * write path reaches decode through `buildDecisionModel`". Transitive reach is real
153
+ * reach, and this is the symbol a command actually calls.
154
+ * - Naming it as the function that CONTAINS the read, the decode or the fold is
155
+ * FALSE. All three are `modelAtHead`'s; this body adds an `AppendCondition` and
156
+ * nothing else.
157
+ *
158
+ * Worth writing down because the distinction is invisible from a call site and the
159
+ * mistake is systematic rather than careless: every such claim was TRUE before
160
+ * `modelAtHead` was extracted from here, so a doc written against the older shape
161
+ * reads perfectly and is wrong. The correction is to name `modelAtHead`, never to
162
+ * soften the sentence.
163
+ */
164
+ export const buildDecisionModel = <T extends Record<string, SliceProjection>>(
165
+ projections: T,
166
+ ): Effect.Effect<
167
+ {
168
+ readonly state: CompositeState<T>
169
+ readonly appendCondition: AppendCondition
170
+ },
171
+ CodecError,
172
+ DcbEventStore | Serializer | RequirementOf<T>
173
+ > =>
174
+ Effect.map(modelAtHead(projections), ({ state, head }) => ({
175
+ state,
176
+ appendCondition: {
177
+ failIfEventsMatch: composeProjections(
178
+ projections as Record<string, AnyProjection>,
179
+ ).query,
180
+ after: head,
181
+ },
182
+ }))