@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
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'