@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.
- package/LICENSE +28 -0
- package/README.md +46 -0
- package/dist/cjs/Serializer.js +34 -0
- package/dist/cjs/Serializer.js.map +1 -0
- package/dist/cjs/Uuid.js +41 -0
- package/dist/cjs/Uuid.js.map +1 -0
- package/dist/cjs/decider.js +152 -0
- package/dist/cjs/decider.js.map +1 -0
- package/dist/cjs/decisionModel.js +155 -0
- package/dist/cjs/decisionModel.js.map +1 -0
- package/dist/cjs/decode.js +170 -0
- package/dist/cjs/decode.js.map +1 -0
- package/dist/cjs/deserialiseThenDecode.js +20 -0
- package/dist/cjs/deserialiseThenDecode.js.map +1 -0
- package/dist/cjs/errors.js +94 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/event.js +26 -0
- package/dist/cjs/event.js.map +1 -0
- package/dist/cjs/index.js +112 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/projection.js +118 -0
- package/dist/cjs/projection.js.map +1 -0
- package/dist/cjs/registry.js +41 -0
- package/dist/cjs/registry.js.map +1 -0
- package/dist/cjs/toEvent.js +51 -0
- package/dist/cjs/toEvent.js.map +1 -0
- package/dist/dts/Serializer.d.ts +27 -0
- package/dist/dts/Serializer.d.ts.map +1 -0
- package/dist/dts/Uuid.d.ts +19 -0
- package/dist/dts/Uuid.d.ts.map +1 -0
- package/dist/dts/decider.d.ts +157 -0
- package/dist/dts/decider.d.ts.map +1 -0
- package/dist/dts/decisionModel.d.ts +129 -0
- package/dist/dts/decisionModel.d.ts.map +1 -0
- package/dist/dts/decode.d.ts +92 -0
- package/dist/dts/decode.d.ts.map +1 -0
- package/dist/dts/deserialiseThenDecode.d.ts +62 -0
- package/dist/dts/deserialiseThenDecode.d.ts.map +1 -0
- package/dist/dts/errors.d.ts +79 -0
- package/dist/dts/errors.d.ts.map +1 -0
- package/dist/dts/event.d.ts +71 -0
- package/dist/dts/event.d.ts.map +1 -0
- package/dist/dts/index.d.ts +63 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/projection.d.ts +56 -0
- package/dist/dts/projection.d.ts.map +1 -0
- package/dist/dts/registry.d.ts +46 -0
- package/dist/dts/registry.d.ts.map +1 -0
- package/dist/dts/toEvent.d.ts +34 -0
- package/dist/dts/toEvent.d.ts.map +1 -0
- package/dist/esm/Serializer.js +26 -0
- package/dist/esm/Serializer.js.map +1 -0
- package/dist/esm/Uuid.js +33 -0
- package/dist/esm/Uuid.js.map +1 -0
- package/dist/esm/decider.js +143 -0
- package/dist/esm/decider.js.map +1 -0
- package/dist/esm/decisionModel.js +146 -0
- package/dist/esm/decisionModel.js.map +1 -0
- package/dist/esm/decode.js +163 -0
- package/dist/esm/decode.js.map +1 -0
- package/dist/esm/deserialiseThenDecode.js +13 -0
- package/dist/esm/deserialiseThenDecode.js.map +1 -0
- package/dist/esm/errors.js +85 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/event.js +19 -0
- package/dist/esm/event.js.map +1 -0
- package/dist/esm/index.js +63 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/projection.js +110 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/registry.js +34 -0
- package/dist/esm/registry.js.map +1 -0
- package/dist/esm/toEvent.js +44 -0
- package/dist/esm/toEvent.js.map +1 -0
- package/package.json +31 -0
- package/src/Serializer.ts +37 -0
- package/src/Uuid.ts +36 -0
- package/src/decider.ts +219 -0
- package/src/decisionModel.ts +182 -0
- package/src/decode.ts +256 -0
- package/src/deserialiseThenDecode.ts +76 -0
- package/src/errors.ts +91 -0
- package/src/event.ts +90 -0
- package/src/index.ts +71 -0
- package/src/projection.ts +163 -0
- package/src/registry.ts +87 -0
- package/src/toEvent.ts +63 -0
package/src/decode.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared decode stage: the wire-bytes -> typed-payload half of the codec
|
|
3
|
+
* seam, lifted out of `buildDecisionModel` into a `Stream` combinator so every
|
|
4
|
+
* consumer of a slice record decodes through *one* implementation.
|
|
5
|
+
*
|
|
6
|
+
* Two consumers, one stage. `modelAtHead` folds a bounded `read` stream into
|
|
7
|
+
* decision state, which is how `buildDecisionModel` gets one; the read side folds
|
|
8
|
+
* an unbounded `subscribe` stream into a materialised view. They differ in
|
|
9
|
+
* everything *downstream* of decode (bounded vs live, a pure fold vs a
|
|
10
|
+
* transactional commit) and in nothing upstream of it — same slice record, same `type -> Schema` lookup, same
|
|
11
|
+
* `Serializer`, same failure taxonomy — so decode is exactly where the seam
|
|
12
|
+
* belongs. Duplicating it would let the two paths drift: a read side with its
|
|
13
|
+
* own copy could silently accept an event the decision path rejects, or reword
|
|
14
|
+
* a `CodecError` reason, and nothing would catch it.
|
|
15
|
+
*
|
|
16
|
+
* Why a `Stream` combinator rather than an `Effect` over a materialised array:
|
|
17
|
+
* decode must stay back-pressured. `Stream.mapEffect` with no `concurrency`
|
|
18
|
+
* option decodes one event at a time, pulling only as fast as the consumer
|
|
19
|
+
* folds, and that is what lets a projection catch up over a backlog far larger
|
|
20
|
+
* than memory.
|
|
21
|
+
*
|
|
22
|
+
* Be precise about what adding `concurrency` here would and would not cost,
|
|
23
|
+
* because the obvious reason is the wrong one. Effect v3's `mapEffect` is
|
|
24
|
+
* order-PRESERVING even when concurrent — reordering needs the separate
|
|
25
|
+
* `unordered: true` option (verified against the installed
|
|
26
|
+
* `effect@3.22`'s `Stream.d.ts`, and probed at runtime) — so a bare
|
|
27
|
+
* `concurrency` would NOT hand the fold its events out of position order. What
|
|
28
|
+
* it would actually cost is the back-pressure above, and it would run
|
|
29
|
+
* application-authored `Schema` transforms (an ADR-0004 decryption hook, say)
|
|
30
|
+
* concurrently, which their authors have no reason to expect. `unordered: true`
|
|
31
|
+
* is the option that would be an outright correctness bug against an
|
|
32
|
+
* order-sensitive fold. Sequential is the right default for both reasons; the
|
|
33
|
+
* order argument alone does not carry it.
|
|
34
|
+
*
|
|
35
|
+
* Why these failures are values and not defects: corrupt bytes, an absent
|
|
36
|
+
* schema and a `Schema` mismatch are *data* problems an application can
|
|
37
|
+
* legitimately handle (quarantine the event, fail the command, alert), so they
|
|
38
|
+
* belong on the error channel as `CodecError` — see `errors.ts`. The three
|
|
39
|
+
* `reason` strings are observable surface; treat them as fixed, not cosmetic.
|
|
40
|
+
*
|
|
41
|
+
* Two of those three are no longer this module's to reword. `deserialise` and
|
|
42
|
+
* `decode` are fixed in `deserialiseThenDecode`, the two-step this stage shares
|
|
43
|
+
* with the general (registry) path, precisely so that a claim about surface has
|
|
44
|
+
* ONE implementation behind it rather than a copy per path. The absent-schema
|
|
45
|
+
* reason stays here, because the two paths deliberately disagree about what a
|
|
46
|
+
* lookup miss means — `UnknownEventType` there, ADR-0005 — and that helper's doc
|
|
47
|
+
* carries the argument.
|
|
48
|
+
*/
|
|
49
|
+
import type { DecodedEvent, SequencedEvent } from '@kairos-es/core'
|
|
50
|
+
import { Effect, type Schema, Stream } from 'effect'
|
|
51
|
+
// Type-only, so the `decisionModel` <-> `decode` edge is erased at emit and
|
|
52
|
+
// there is no runtime import cycle. `RequirementOf` deliberately stays with
|
|
53
|
+
// `buildDecisionModel`, its original home and primary consumer.
|
|
54
|
+
import type { RequirementOf } from './decisionModel'
|
|
55
|
+
import { deserialiseThenDecode } from './deserialiseThenDecode'
|
|
56
|
+
import { CodecError } from './errors'
|
|
57
|
+
import type { SliceProjection } from './projection'
|
|
58
|
+
import { Serializer } from './Serializer'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Merge every slice's `type -> Schema` map into one decode lookup.
|
|
62
|
+
*
|
|
63
|
+
* A record of slices is the unit an application composes, but decode is keyed
|
|
64
|
+
* by the wire event's runtime `type` string, so the per-slice maps have to
|
|
65
|
+
* collapse into one flat table first. A type reachable from two slices is
|
|
66
|
+
* therefore a collision the merge has to rule on, and it rules by REFERENCE
|
|
67
|
+
* EQUALITY: the same `Schema` arriving twice is absorbed, two *different*
|
|
68
|
+
* `Schema`s under one type string are refused with a throw.
|
|
69
|
+
*
|
|
70
|
+
* **Why reference equality is the discriminator.** The legitimate case — the one
|
|
71
|
+
* the old last-writer-wins was written for — is two slices that `.on` the same
|
|
72
|
+
* `EventDef`, which is how one event feeds two views. `event()` builds its
|
|
73
|
+
* `Schema.Struct` once and every def hands out that one object, so those two
|
|
74
|
+
* slices hold the identical reference and whichever entry survives the merge
|
|
75
|
+
* decodes identically; `decode.test.ts` asserts that under both key orderings
|
|
76
|
+
* rather than leaving it as a claim. Nothing weaker is sound and nothing
|
|
77
|
+
* stronger is needed: two distinct defs can be structurally identical today and
|
|
78
|
+
* diverge on the next commit, so structural comparison would license exactly the
|
|
79
|
+
* pairing that is about to go wrong.
|
|
80
|
+
*
|
|
81
|
+
* **Why the other case cannot stay last-writer-wins.** Two distinct defs can
|
|
82
|
+
* share a `type` string — a copied def, or two versions accidentally given one
|
|
83
|
+
* name — and then the surviving schema is whichever slice `Object.keys` visits
|
|
84
|
+
* last, an ordering the record does not express and its author never chose.
|
|
85
|
+
* Every event of that type is then decoded against the wrong def for one of the
|
|
86
|
+
* slices, and where the two shapes overlap that failure is SILENT rather than a
|
|
87
|
+
* `CodecError`: `Schema.decodeUnknown` defaults `onExcessProperty` to `'ignore'`
|
|
88
|
+
* (`effect@3.22`'s `ParseResult.ts` runs its excess-key loop only under
|
|
89
|
+
* `'error'` or `'preserve'`), so a field the other def declared is dropped, not
|
|
90
|
+
* reported, and the fold downstream is handed a payload quietly missing it.
|
|
91
|
+
*
|
|
92
|
+
* **Why a throw, and why here rather than at the read side's wiring gate.**
|
|
93
|
+
* `makeRegistry` already treats a duplicated type as a construction-time defect
|
|
94
|
+
* and throws (ADR-0005, `totality.test.ts`); this is the same mistake reached
|
|
95
|
+
* through the slice path, so it gets the same mechanism and the same opening
|
|
96
|
+
* words rather than a second convention for one rule. It belongs to the codec
|
|
97
|
+
* because BOTH consumers need it and only one of them is on the read side: the
|
|
98
|
+
* write path reaches this through `buildDecisionModel` and never goes near the read
|
|
99
|
+
* side's wiring gate. Neither consumer can be mid-stream when it fires —
|
|
100
|
+
* the write path merges before its first `store.read`, and the read side
|
|
101
|
+
* merges after that gate and before it forks the pipeline — and inside
|
|
102
|
+
* their `Effect.gen`s a throw becomes a DEFECT rather than an escaping exception
|
|
103
|
+
* (`effect@3.22`'s `fiberRuntime.ts` run loop converts a thrown value with
|
|
104
|
+
* `core.die`), which is the classification a mis-wiring already has on both
|
|
105
|
+
* sides of the library.
|
|
106
|
+
*
|
|
107
|
+
* The sentence names BOTH slices. The two halves of this mistake are in
|
|
108
|
+
* different entries of the record, and a message naming only the one that
|
|
109
|
+
* happened to be visited second sends its author to look in the wrong place.
|
|
110
|
+
*
|
|
111
|
+
* The cast re-attaches `R`. Indexing `T` by a plain `string` key erases each
|
|
112
|
+
* slice to the `SliceProjection` *constraint*, whose requirement parameter
|
|
113
|
+
* defaults to `unknown`, so the concrete per-slice `R` is not recoverable from
|
|
114
|
+
* the value side. `RequirementOf<T>` recovers it from the *type* side, and the
|
|
115
|
+
* assertion is sound because a slice's own requirement is always a member of
|
|
116
|
+
* that union. Doing it once here is why no call site needs a cast.
|
|
117
|
+
*
|
|
118
|
+
* Module-private, and deliberately so: see `decodeSlices` for why the merge is
|
|
119
|
+
* not a step a caller is trusted to take.
|
|
120
|
+
*/
|
|
121
|
+
const mergeSliceSchemas = <T extends Record<string, SliceProjection>>(
|
|
122
|
+
projections: T,
|
|
123
|
+
): ReadonlyMap<string, Schema.Schema<unknown, unknown, RequirementOf<T>>> => {
|
|
124
|
+
const schemas = new Map<
|
|
125
|
+
string,
|
|
126
|
+
Schema.Schema<unknown, unknown, RequirementOf<T>>
|
|
127
|
+
>()
|
|
128
|
+
// Which slice contributed each entry, kept in lockstep with `schemas` purely
|
|
129
|
+
// so the refusal can name both sides of a collision. Its presence is also what
|
|
130
|
+
// the check below reads, rather than a second `schemas.has`, so the narrowing
|
|
131
|
+
// that makes the earlier key a `string` falls out of the check itself.
|
|
132
|
+
const contributedBy = new Map<string, string>()
|
|
133
|
+
for (const key of Object.keys(projections)) {
|
|
134
|
+
const slice = projections[key]
|
|
135
|
+
for (const [type, schema] of slice.schemas) {
|
|
136
|
+
const owner = contributedBy.get(type)
|
|
137
|
+
if (owner !== undefined && schemas.get(type) !== schema) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`duplicate event type '${type}' in slices '${owner}' and '${key}': ` +
|
|
140
|
+
'they hold different Schemas for one type, so decode would use ' +
|
|
141
|
+
'whichever slice merged last. Two slices may share an event type ' +
|
|
142
|
+
'only by sharing one EventDef.',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
schemas.set(
|
|
146
|
+
type,
|
|
147
|
+
schema as Schema.Schema<unknown, unknown, RequirementOf<T>>,
|
|
148
|
+
)
|
|
149
|
+
contributedBy.set(type, key)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return schemas
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The decode stage: look the event's `type` up in `schemas`, run the shared
|
|
157
|
+
* `deserialiseThenDecode` over the wire bytes, and emit a `DecodedEvent` (the
|
|
158
|
+
* sequenced envelope plus its typed payload) for the pure fold downstream. The
|
|
159
|
+
* lookup and the envelope are what this stage adds; the two-step in the middle,
|
|
160
|
+
* and the two `reason` strings it fails with, belong to that helper.
|
|
161
|
+
*
|
|
162
|
+
* Curried on `schemas` so a caller builds the lookup once — per decision model,
|
|
163
|
+
* per read model — and reuses the stage across every stream it drives.
|
|
164
|
+
*
|
|
165
|
+
* `Serializer` is read from context *inside* the stage (once, when the stream
|
|
166
|
+
* starts, via `Stream.unwrap`) rather than being pre-yielded and closed over by
|
|
167
|
+
* the caller. That keeps the serialisation dependency wholly inside this
|
|
168
|
+
* module: a consumer cannot hand in a different serialiser than the one its
|
|
169
|
+
* layer provides, so encode (`toEvent`) and decode provably read the same
|
|
170
|
+
* `Serializer` — the invariant `Serializer.ts` rests on. The obligation is
|
|
171
|
+
* still visible, because `Serializer` surfaces in the returned stream's `R`;
|
|
172
|
+
* it just is not satisfiable by hand.
|
|
173
|
+
*
|
|
174
|
+
* The stage is transparent to the input stream's own `E` and `R`: a bounded
|
|
175
|
+
* `read` stream (`never`, `never`) and a live `subscribe` stream (`never`,
|
|
176
|
+
* `Scope`) both flow through unchanged, gaining only `CodecError` and
|
|
177
|
+
* `Serializer`.
|
|
178
|
+
*
|
|
179
|
+
* Module-private: `decodeSlices` is the public shape. Keeping the schema-map
|
|
180
|
+
* form here rather than inlining it into `decodeSlices` is what makes the
|
|
181
|
+
* "build the lookup once, reuse the stage" property explicit in the types.
|
|
182
|
+
*/
|
|
183
|
+
const decodeEvents =
|
|
184
|
+
<RS>(schemas: ReadonlyMap<string, Schema.Schema<unknown, unknown, RS>>) =>
|
|
185
|
+
<E, R>(
|
|
186
|
+
events: Stream.Stream<SequencedEvent, E, R>,
|
|
187
|
+
): Stream.Stream<DecodedEvent, E | CodecError, R | RS | Serializer> =>
|
|
188
|
+
Stream.unwrap(
|
|
189
|
+
Effect.map(Serializer, (serializer) =>
|
|
190
|
+
events.pipe(
|
|
191
|
+
Stream.mapEffect((sequenced) => {
|
|
192
|
+
const schema = schemas.get(sequenced.event.type)
|
|
193
|
+
if (schema === undefined) {
|
|
194
|
+
return Effect.fail(
|
|
195
|
+
new CodecError({
|
|
196
|
+
reason: `no schema for event type '${sequenced.event.type}'`,
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
return deserialiseThenDecode(
|
|
201
|
+
serializer,
|
|
202
|
+
schema,
|
|
203
|
+
sequenced.event.data,
|
|
204
|
+
).pipe(
|
|
205
|
+
Effect.map((payload): DecodedEvent => ({ sequenced, payload })),
|
|
206
|
+
)
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The public decode stage: give it the slice record, get back a `Stream`
|
|
214
|
+
* combinator that decodes every event those slices can handle.
|
|
215
|
+
*
|
|
216
|
+
* **Why one call rather than two.** The lookup and the stage were briefly two
|
|
217
|
+
* public symbols — merge the record, then hand the map to the stage — and every
|
|
218
|
+
* consumer in the repo composed them in exactly that order and did nothing else
|
|
219
|
+
* with the intermediate map. A two-step protocol with one legal composition is
|
|
220
|
+
* not flexibility, it is a way to get it wrong: a caller can merge the wrong
|
|
221
|
+
* record, or a record that is not the one its `Query` was composed from, and
|
|
222
|
+
* that second mistake is silent. In the read side it would decode a live
|
|
223
|
+
* subscription against schemas the subscription never selects — every event
|
|
224
|
+
* arriving would fail `no schema for event type`, framed as a data problem when
|
|
225
|
+
* it is a wiring one. Taking the slices directly removes the intermediate value
|
|
226
|
+
* a caller could substitute, so the schemas provably come from the same record
|
|
227
|
+
* the query does.
|
|
228
|
+
*
|
|
229
|
+
* **Still curried, deliberately.** The merge is a pure derivation of the slice
|
|
230
|
+
* record, so both consumers build the stage ONCE — per decision model, per read
|
|
231
|
+
* model — and reuse it across every stream they drive. Rebuilding the table per
|
|
232
|
+
* stream (let alone per restart) would only burn allocations, and the currying
|
|
233
|
+
* is what makes "built once" the natural way to call this rather than a
|
|
234
|
+
* discipline.
|
|
235
|
+
*
|
|
236
|
+
* **It can REFUSE the record**, and does so the moment it is called rather than
|
|
237
|
+
* once events are flowing: a type reachable from two slices under two DIFFERENT
|
|
238
|
+
* `Schema`s throws, in the same words `makeRegistry` uses. See
|
|
239
|
+
* `mergeSliceSchemas` for the rule, why reference equality is its discriminator
|
|
240
|
+
* and why a throw is the right mechanism. Both in-repo consumers call this
|
|
241
|
+
* before there is a stream to fail, so inside their `Effect.gen`s the refusal is
|
|
242
|
+
* a defect at construction.
|
|
243
|
+
*
|
|
244
|
+
* `RequirementOf<T>` surfaces in the result's `R`, so a slice whose `Schema`
|
|
245
|
+
* carries a requirement (an application-authored decryption transform, ADR-0004)
|
|
246
|
+
* propagates it to the caller with no cast at the call site.
|
|
247
|
+
*/
|
|
248
|
+
export const decodeSlices = <T extends Record<string, SliceProjection>>(
|
|
249
|
+
slices: T,
|
|
250
|
+
): (<E, R>(
|
|
251
|
+
events: Stream.Stream<SequencedEvent, E, R>,
|
|
252
|
+
) => Stream.Stream<
|
|
253
|
+
DecodedEvent,
|
|
254
|
+
E | CodecError,
|
|
255
|
+
R | RequirementOf<T> | Serializer
|
|
256
|
+
>) => decodeEvents(mergeSliceSchemas(slices))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-step every decode in this package performs, in one implementation:
|
|
3
|
+
* deserialise the wire bytes through the `Serializer` its caller already holds,
|
|
4
|
+
* then `Schema.decodeUnknown` the result, each half's failure wrapped as the
|
|
5
|
+
* `CodecError` whose `reason` says WHICH half failed.
|
|
6
|
+
*
|
|
7
|
+
* **What it is the single home of.** The two `reason` strings `'deserialise'`
|
|
8
|
+
* and `'decode'`, and their ORDER. Why those strings are observable surface —
|
|
9
|
+
* fixed rather than cosmetic, because an application quarantines an event,
|
|
10
|
+
* alerts or fails a command off them — is argued in `decode.ts`'s module doc and
|
|
11
|
+
* is not restated here. What this module adds is that the argument had TWO
|
|
12
|
+
* implementations standing behind it: the slice stage (`decodeEvents`) and the
|
|
13
|
+
* general/registry path (`makeRegistry`'s `decode`) each wrote the pair out, in
|
|
14
|
+
* one package, and only the slice copy was pinned by a test. A reworded `reason`
|
|
15
|
+
* on the registry path would have been caught by nothing. Sharing the step
|
|
16
|
+
* DELETES that drift channel rather than alarming it, which is why this exists
|
|
17
|
+
* as a function and not as a second test.
|
|
18
|
+
*
|
|
19
|
+
* **The order is contract, not accident.** Deserialise has to run first because
|
|
20
|
+
* `Schema.decodeUnknown` has nothing to read until it has, and the observable
|
|
21
|
+
* consequence is that the two reasons PARTITION the causes an operator acts on:
|
|
22
|
+
* rotten bytes are always `'deserialise'` and never `'decode'`, so a storage or
|
|
23
|
+
* producer problem never arrives dressed as schema drift. Swapping the labels
|
|
24
|
+
* would not break a decode; it would send every incident to the wrong team.
|
|
25
|
+
*
|
|
26
|
+
* **Why a `Serializer` VALUE rather than reading the tag here.** The two callers
|
|
27
|
+
* acquire it at deliberately different moments and this step must not flatten
|
|
28
|
+
* that. `decodeEvents` reads it ONCE when the stream starts (`Stream.unwrap`), so
|
|
29
|
+
* a backlog of a million events does not re-read context per event; the
|
|
30
|
+
* registry's `decode` is a per-event `Effect` and yields it inside its own
|
|
31
|
+
* `Effect.gen`. Taking the resolved service preserves both, and keeps
|
|
32
|
+
* `Serializer` off this signature's `R`, so the requirement stays declared where
|
|
33
|
+
* the acquisition actually happens — which is where a reader goes looking for it.
|
|
34
|
+
*
|
|
35
|
+
* **Why the LOOKUP is not folded in**, though both callers do one immediately
|
|
36
|
+
* before calling this: the two paths disagree about what a miss MEANS, and the
|
|
37
|
+
* disagreement is deliberate. The slice path fails `CodecError` with
|
|
38
|
+
* `no schema for event type '<type>'` — a caller named the type and the record it
|
|
39
|
+
* supplied has no entry for it, so the wiring is wrong. The registry path fails
|
|
40
|
+
* `UnknownEventType` — the type arrived off the WIRE and the registry does not
|
|
41
|
+
* know it, which is ADR-0005 decode-totality and the one fault a rolling
|
|
42
|
+
* deployment is EXPECTED to meet. `errors.ts` argues why those two sentences stay
|
|
43
|
+
* greppable apart. Folding the lookup in would mean a per-caller miss
|
|
44
|
+
* constructor: a second seam, bought to share the one thing the two paths
|
|
45
|
+
* genuinely do not have in common.
|
|
46
|
+
*
|
|
47
|
+
* `R` is the SCHEMA's requirement, propagated untouched — an ADR-0004 decryption
|
|
48
|
+
* transform reaches the caller's `R` through here with no cast. The three
|
|
49
|
+
* parameters are positional because their types are mutually unassignable, so a
|
|
50
|
+
* transposed call is a compile error rather than a silent inversion.
|
|
51
|
+
*
|
|
52
|
+
* PACKAGE-INTERNAL: exported to its two callers and no further, deliberately
|
|
53
|
+
* absent from `index.ts`. It is a shared step, not a capability on offer — a
|
|
54
|
+
* consumer holding bytes and a `Schema` wants `decodeSlices` or a `Registry`,
|
|
55
|
+
* both of which do the lookup this one refuses to.
|
|
56
|
+
*/
|
|
57
|
+
import type { Context } from 'effect'
|
|
58
|
+
import { Effect, Schema } from 'effect'
|
|
59
|
+
import { CodecError } from './errors'
|
|
60
|
+
import type { Serializer } from './Serializer'
|
|
61
|
+
|
|
62
|
+
export const deserialiseThenDecode = <R>(
|
|
63
|
+
serializer: Context.Tag.Service<Serializer>,
|
|
64
|
+
schema: Schema.Schema<unknown, unknown, R>,
|
|
65
|
+
bytes: Uint8Array,
|
|
66
|
+
): Effect.Effect<unknown, CodecError, R> =>
|
|
67
|
+
Effect.try({
|
|
68
|
+
try: () => serializer.deserialise(bytes),
|
|
69
|
+
catch: (cause) => new CodecError({ reason: 'deserialise', cause }),
|
|
70
|
+
}).pipe(
|
|
71
|
+
Effect.flatMap((raw) =>
|
|
72
|
+
Schema.decodeUnknown(schema)(raw).pipe(
|
|
73
|
+
Effect.mapError((cause) => new CodecError({ reason: 'decode', cause })),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
)
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged errors for the codec. Decode failures are values on the error channel,
|
|
3
|
+
* never defects.
|
|
4
|
+
*
|
|
5
|
+
* `CodecError` covers every serialisation/`Schema` failure on the codec path
|
|
6
|
+
* (deserialise threw, `Schema.decode`/`encode` produced a `ParseError`, a
|
|
7
|
+
* wrong/absent encryption key surfaced through a `Schema` transform, or a
|
|
8
|
+
* `tagsFor` mapper threw while deriving an event's tags). It carries a
|
|
9
|
+
* human-readable `reason` and the underlying `cause` (exact-optional).
|
|
10
|
+
* `UnknownEventType` is raised only on the general (registry) path, when a wire
|
|
11
|
+
* event's runtime type string is outside the registered set — the home of
|
|
12
|
+
* ADR-0005 decode-totality.
|
|
13
|
+
*/
|
|
14
|
+
import { Data } from 'effect'
|
|
15
|
+
|
|
16
|
+
/** A serialisation or `Schema` decode/encode failure on the codec path. */
|
|
17
|
+
export class CodecError extends Data.TaggedError('CodecError')<{
|
|
18
|
+
readonly reason: string
|
|
19
|
+
readonly cause?: unknown
|
|
20
|
+
}> {
|
|
21
|
+
/**
|
|
22
|
+
* `reason` again, as the `Error` field that says why — so the fault renders
|
|
23
|
+
* ITSELF.
|
|
24
|
+
*
|
|
25
|
+
* `Data.TaggedError` sets `name` to the tag and leaves `message` empty, so
|
|
26
|
+
* without this a `CodecError` stringifies to `'CodecError'`, and the sentence
|
|
27
|
+
* naming the schema, the event type or the missing key that failed reaches a log
|
|
28
|
+
* line only where the code doing the logging knows to reach in for `reason`.
|
|
29
|
+
* Filled, `Error.prototype.toString` yields `'CodecError: <reason>'`, which is
|
|
30
|
+
* what `Cause.pretty` (it reads `message` off anything `instanceof Error`) and the
|
|
31
|
+
* read side's projection supervisor both report — neither of them holding a table
|
|
32
|
+
* of this class's fields to keep in step with it.
|
|
33
|
+
*
|
|
34
|
+
* This class is on the package's PUBLISHED surface, so be exact about what the
|
|
35
|
+
* field moves. Everything that renders the error for a HUMAN: `String(err)`,
|
|
36
|
+
* `Cause.pretty` and therefore a failed fibre's `cause` annotation on every
|
|
37
|
+
* shipped logger, and an unhandled failure's report. Nothing that renders it for
|
|
38
|
+
* a MACHINE: `Data.Error` overrides `YieldableError`'s `toJSON` with
|
|
39
|
+
* `{ ...plainArgs, ...this }` — own enumerables plus the constructor args, and a
|
|
40
|
+
* prototype accessor is neither — so `JSON.stringify(err)` still emits the same
|
|
41
|
+
* `reason`/`_tag` and a structured log of a raw `CodecError` keeps its shape.
|
|
42
|
+
*
|
|
43
|
+
* A GETTER rather than a second field, so `reason` stays the single declaration.
|
|
44
|
+
* It is visible because `Data.TaggedError`'s constructor is
|
|
45
|
+
* `super(args?.message, …)` followed by `Object.assign(this, args)` (verified
|
|
46
|
+
* against `effect@3.22`'s `Data.ts`): the args above carry no `message`, so
|
|
47
|
+
* nothing shadows this prototype accessor with an own property.
|
|
48
|
+
*/
|
|
49
|
+
override get message(): string {
|
|
50
|
+
return this.reason
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A wire event whose runtime type string is outside the registry's known set. */
|
|
55
|
+
export class UnknownEventType extends Data.TaggedError('UnknownEventType')<{
|
|
56
|
+
readonly type: string
|
|
57
|
+
}> {
|
|
58
|
+
/**
|
|
59
|
+
* The offending type, in a sentence saying what the registry did with it — so the
|
|
60
|
+
* fault renders ITSELF, exactly as `CodecError` above does.
|
|
61
|
+
*
|
|
62
|
+
* `Data.TaggedError` sets `name` to the tag and leaves `message` empty, so without
|
|
63
|
+
* this a decode that met an unregistered type stringifies to `'UnknownEventType'`
|
|
64
|
+
* — the one datum that makes the failure actionable, the type string itself,
|
|
65
|
+
* reaching a log line only where the logging code knows to reach in for `type`.
|
|
66
|
+
* Filled, `Error.prototype.toString` yields the tag followed by
|
|
67
|
+
* `event type 'nope.v1' is not in the registry`, which is what `Cause.pretty`
|
|
68
|
+
* prints and what an operator greps for. It matters here in particular because
|
|
69
|
+
* this class is the home of ADR-0005 decode-totality: a wire event outside the
|
|
70
|
+
* registered set is the expected consequence of a deployment reading a log a newer
|
|
71
|
+
* writer has extended, and the first question is always WHICH type — which the tag
|
|
72
|
+
* alone cannot answer.
|
|
73
|
+
*
|
|
74
|
+
* The wording deliberately differs from `CodecError`'s. That one says there is no
|
|
75
|
+
* schema for a type, and is raised on the TYPED path, where a caller named the
|
|
76
|
+
* type and the schema map it supplied has no entry for it. This one is the general
|
|
77
|
+
* (registry) path, where the type arrived off the WIRE and the registry is what
|
|
78
|
+
* does not know it. Distinct sentences keep the two greppable apart, which a
|
|
79
|
+
* shared phrasing would cost for nothing.
|
|
80
|
+
*
|
|
81
|
+
* A GETTER rather than a second field, so `type` stays the single declaration.
|
|
82
|
+
* What filling this field reaches, what it deliberately leaves untouched, and why
|
|
83
|
+
* a prototype accessor is visible at all are argued once on `CodecError` above and
|
|
84
|
+
* not restated here — it is the same package, the same published surface and the
|
|
85
|
+
* same mechanism, and two copies of that paragraph would be two things to keep in
|
|
86
|
+
* step. The read side's four getters point at one home for the identical reason.
|
|
87
|
+
*/
|
|
88
|
+
override get message(): string {
|
|
89
|
+
return `event type '${this.type}' is not in the registry`
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/event.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event definitions: a tagged `Schema` built through `event(type, fields,
|
|
3
|
+
* tagsFor)`. Each def is a `Schema<Payload, Wire, R>` plus its branded versioned
|
|
4
|
+
* `type` (ADR-0005) captured *beside* the payload, and a required `tagsFor`
|
|
5
|
+
* mapper deriving the event's tags from its decoded payload (ADR-0006).
|
|
6
|
+
*
|
|
7
|
+
* The single `EventType` brand is applied once, here, so no consumer ever casts
|
|
8
|
+
* a type string: `def.type` is already assignable to core's `EventType`. The
|
|
9
|
+
* payload `A`, wire encoding `I`, and requirement `R` are inferred from the
|
|
10
|
+
* `Schema.Struct` of `fields` — including any requirement a field's `Schema`
|
|
11
|
+
* transform carries (e.g. an application `Encrypted(...)` field contributes its
|
|
12
|
+
* `CipherKey` to `R`).
|
|
13
|
+
*
|
|
14
|
+
* `tagsFor` is the only way to tag an event: `toEvent` derives the wire tags by
|
|
15
|
+
* calling it, and there is no `tags` option to override them. Its
|
|
16
|
+
* `NonEmptyReadonlyArray<Tag>` return makes "every event carries at least one
|
|
17
|
+
* tag" a compile-time guarantee (returning `[]` is a type error at the def
|
|
18
|
+
* site), and its input being only this event's payload guarantees `tag ⊆ event
|
|
19
|
+
* data` structurally. An event with no natural business identity returns an
|
|
20
|
+
* explicit app-defined `system:<name>` tag, never an empty array.
|
|
21
|
+
*
|
|
22
|
+
* A mapper must be a *pure* function of its payload and key off fields in their
|
|
23
|
+
* schema-canonical form (normalising in the mapper to match a field's schema),
|
|
24
|
+
* so the tag it derives is stable under the schema round-trip: a later retag
|
|
25
|
+
* re-derives tags from decoded history, and `tagsFor(decode(encode(p)))` must
|
|
26
|
+
* equal `tagsFor(p)` for that to be sound. Plain scalar fields satisfy this
|
|
27
|
+
* trivially; a field whose schema canonicalises on decode (trim, lowercase,
|
|
28
|
+
* normalise) needs the mapper to apply the same normalisation (ADR-0006).
|
|
29
|
+
*/
|
|
30
|
+
import type { EventType, Tag } from '@kairos-es/core'
|
|
31
|
+
import type { Array as Arr } from 'effect'
|
|
32
|
+
import { Schema } from 'effect'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A tag mapper: derives an event's tags from its decoded payload, returning at
|
|
36
|
+
* least one `Tag`. Named so a standalone mapper can be typed and shared (e.g.
|
|
37
|
+
* across two versioned defs of one logical event) and so the signature is
|
|
38
|
+
* written once, not per use site.
|
|
39
|
+
*/
|
|
40
|
+
export type TagsFor<A> = (payload: A) => Arr.NonEmptyReadonlyArray<Tag>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An event definition: its branded `type`, the `Schema` mapping its typed
|
|
44
|
+
* payload `A` to its wire encoding `I` (requiring `R`), and the `tagsFor` mapper
|
|
45
|
+
* that derives the event's tags from a decoded payload. `Schema` is invariant in
|
|
46
|
+
* `A`/`I`, so `EventDef` cannot be `out A`/`out I`.
|
|
47
|
+
*/
|
|
48
|
+
export interface EventDef<A = unknown, I = unknown, R = never> {
|
|
49
|
+
readonly type: EventType
|
|
50
|
+
readonly schema: Schema.Schema<A, I, R>
|
|
51
|
+
readonly tagsFor: TagsFor<A>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The heterogeneous bound (mirrors `Schema.Schema.Any`) for arrays/records of
|
|
56
|
+
* mixed defs, where the precise `A`/`I` differ per element. `tagsFor` erases to
|
|
57
|
+
* `(a: any) => …`, which is harmless: only the write path (`toEvent`) calls it,
|
|
58
|
+
* and the registry/decode path never does (stored events already carry tags).
|
|
59
|
+
*/
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: the sanctioned heterogeneous bound (mirrors Schema.Schema.Any); A/I differ per element, so any is the only sound erasure.
|
|
61
|
+
export type EventDefAny = EventDef<any, any, unknown>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Define an event from a versioned `type` literal, its payload `fields`, and its
|
|
65
|
+
* required `tagsFor` mapper. Builds `Schema.Struct(fields)` and brands the type
|
|
66
|
+
* literal *once* (the single sanctioned cast). The returned def is typed so
|
|
67
|
+
* `def.type` is a branded literal assignable to core's `EventType` with no
|
|
68
|
+
* consumer cast, `A`/`I`/`R` are inferred from the struct, and `tagsFor`
|
|
69
|
+
* receives the def's decoded payload type.
|
|
70
|
+
*/
|
|
71
|
+
export const event = <
|
|
72
|
+
const Type extends string,
|
|
73
|
+
Fields extends Schema.Struct.Fields,
|
|
74
|
+
>(
|
|
75
|
+
type: Type,
|
|
76
|
+
fields: Fields,
|
|
77
|
+
tagsFor: TagsFor<Schema.Struct.Type<Fields>>,
|
|
78
|
+
): {
|
|
79
|
+
readonly type: Type & EventType
|
|
80
|
+
readonly schema: Schema.Struct<Fields>
|
|
81
|
+
readonly tagsFor: TagsFor<Schema.Struct.Type<Fields>>
|
|
82
|
+
} => {
|
|
83
|
+
const schema = Schema.Struct(fields)
|
|
84
|
+
return {
|
|
85
|
+
// The single EventType brand, applied once inside event(); no consumer casts.
|
|
86
|
+
type: type as Type & EventType,
|
|
87
|
+
schema,
|
|
88
|
+
tagsFor,
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/codec — the schema-first codec seam, mapping typed domain events
|
|
3
|
+
* to and from the store's opaque wire bytes, with an application-owned
|
|
4
|
+
* encryption seam (ADR-0004).
|
|
5
|
+
*
|
|
6
|
+
* Two entry points:
|
|
7
|
+
* - The *typed slice path* (primary): `event(type, fields, tagsFor)` defines a
|
|
8
|
+
* tagged `Schema` plus a required mapper deriving the event's tags from its
|
|
9
|
+
* payload (ADR-0006); `projection(init).on(def, fold)...build()` composes the
|
|
10
|
+
* specific defs a decision model consumes, inferring its state and requirement
|
|
11
|
+
* `R`; `modelAtHead(slices)` reads, decodes through each def's `Schema`, and folds
|
|
12
|
+
* purely, and `buildDecisionModel(slices)` adds the `AppendCondition` to it. A handler for an un-imported event is a compile error, and
|
|
13
|
+
* a mistyped type cannot silently match nothing.
|
|
14
|
+
* - The *general path* (minimal): `makeRegistry(defs)` decodes an arbitrary wire
|
|
15
|
+
* event by its runtime type string; an unknown type is a typed
|
|
16
|
+
* `UnknownEventType` (ADR-0005 decode-totality). The payload stays `unknown`
|
|
17
|
+
* on this path: the type is discovered at runtime, so no def was named to
|
|
18
|
+
* narrow it (see `registry.ts`).
|
|
19
|
+
*
|
|
20
|
+
* `toEvent(def, payload, { uuid?, occurredAt? })` produces a wire event (encode
|
|
21
|
+
* -> serialise, tags derived from `def.tagsFor`, clock + uuid from services).
|
|
22
|
+
* Serialisation is a pluggable `Serializer` (JSON
|
|
23
|
+
* default). Encryption, when needed, is an *application*-authored `Schema`
|
|
24
|
+
* transform on a payload field; the codec only propagates its requirement and
|
|
25
|
+
* error channels — it owns no cipher and imports no `node:crypto`.
|
|
26
|
+
*
|
|
27
|
+
* `Decider` + `runDecider(decider, command)` close that path into the write
|
|
28
|
+
* side's whole command cycle: build the decision model, decide, append under the
|
|
29
|
+
* read's own condition. They live HERE, beside the `buildDecisionModel` they
|
|
30
|
+
* drive, rather than in `@kairos-es/testing` where they began — that package
|
|
31
|
+
* peers `@effect/vitest`, and a production command handler must not name it (see
|
|
32
|
+
* `decider.ts` for the full argument). `decider(projections, decide)` builds one
|
|
33
|
+
* with all four type arguments inferred, so a command's slice record and its
|
|
34
|
+
* error union are written once, in the two function bodies, rather than restated
|
|
35
|
+
* as an annotation above them.
|
|
36
|
+
*
|
|
37
|
+
* `modelAtHead(slices)` is that same read-decode-fold WITHOUT the
|
|
38
|
+
* `AppendCondition`: `{ state, head }`, one slice record materialised at read
|
|
39
|
+
* time with no table, no checkpoint and no runner, current as of the `head` its
|
|
40
|
+
* read returned — which is a stronger currency claim than a maintained view's
|
|
41
|
+
* checkpoint can make. `buildDecisionModel` is expressed over it, so the write
|
|
42
|
+
* path's decision model and a read-time materialisation share one body rather
|
|
43
|
+
* than two that could drift (see `decisionModel.ts` for both arguments).
|
|
44
|
+
*
|
|
45
|
+
* `decodeSlices(slices)` is the decode stage `modelAtHead` folds through,
|
|
46
|
+
* exported because the read side needs the *same* decode over a live
|
|
47
|
+
* `subscribe` stream. Exposing it keeps that single implementation shared
|
|
48
|
+
* rather than reimplemented downstream. It is deliberately ONE symbol taking
|
|
49
|
+
* the slice record, not a merge-then-decode pair: the pair had exactly one
|
|
50
|
+
* legal composition, and its only degree of freedom was to decode against a
|
|
51
|
+
* record other than the one the query came from (see `decode.ts`).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export { type Decider, decider, runDecider } from './decider'
|
|
55
|
+
export {
|
|
56
|
+
buildDecisionModel,
|
|
57
|
+
modelAtHead,
|
|
58
|
+
type RequirementOf,
|
|
59
|
+
} from './decisionModel'
|
|
60
|
+
export { decodeSlices } from './decode'
|
|
61
|
+
export { CodecError, UnknownEventType } from './errors'
|
|
62
|
+
export { type EventDef, type EventDefAny, event, type TagsFor } from './event'
|
|
63
|
+
export {
|
|
64
|
+
type ProjectionBuilder,
|
|
65
|
+
projection,
|
|
66
|
+
type SliceProjection,
|
|
67
|
+
} from './projection'
|
|
68
|
+
export { makeRegistry, type Registry } from './registry'
|
|
69
|
+
export { Serializer, SerializerDefault } from './Serializer'
|
|
70
|
+
export { toEvent } from './toEvent'
|
|
71
|
+
export { Uuid, UuidLive, UuidTest } from './Uuid'
|