@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,163 @@
1
+ /**
2
+ * The typed slice path (primary): a schema-backed projection and the fluent
3
+ * builder that produces it.
4
+ *
5
+ * A `SliceProjection<S, R>` is a core `AnyProjection<S>` (directly composable —
6
+ * its `evolve` folds a `DecodedEvent`) enriched with the `type -> Schema` map
7
+ * `decodeSlices` merges and decodes through. That map's value type carries `R`
8
+ * covariantly (`Schema<unknown, unknown, R>`), so the union of the defs'
9
+ * requirements flows to the decision model with no cast (and no phantom marker).
10
+ * Because `evolve` is already erased, a built slice drops straight into
11
+ * `composeProjections` — no `AnyProjection.of` ceremony.
12
+ *
13
+ * `projection(initialState).on(def, fold)...build()` infers the state `S` and
14
+ * the union `R` of the `.on`-ed defs' requirements, derives the read `Query`'s
15
+ * event-types from the defs' branded type literals (no `as EventType`), and
16
+ * hands each `fold` its decoded, typed payload. A handler for an un-imported
17
+ * event is therefore a compile error, and a mistyped type cannot silently match
18
+ * nothing.
19
+ */
20
+ import {
21
+ type AnyProjection,
22
+ type DecodedEvent,
23
+ EventType,
24
+ type Query,
25
+ type Tag,
26
+ } from '@kairos-es/core'
27
+ import type { Schema } from 'effect'
28
+ import type { EventDef } from './event'
29
+
30
+ /**
31
+ * A schema-backed, directly-composable projection: an `AnyProjection<S>` plus
32
+ * the `type -> Schema` map for decode. The map's value type carries the union
33
+ * `R` of its defs' requirements covariantly, so `RequirementOf` recovers `R`
34
+ * (and the `out R` annotation keeps union-widening sound) with no phantom marker.
35
+ */
36
+ export interface SliceProjection<out S = unknown, out R = unknown>
37
+ extends AnyProjection<S> {
38
+ readonly schemas: ReadonlyMap<string, Schema.Schema<unknown, unknown, R>>
39
+ }
40
+
41
+ /** The fluent builder: accumulate `.on(def, fold)` steps, then `.build()`. */
42
+ export interface ProjectionBuilder<S, R = never> {
43
+ /**
44
+ * Fold events of `def`'s type: `fold` receives the decoded, typed `payload`.
45
+ * Adds `def`'s requirement `R2` to the accumulated `R`.
46
+ *
47
+ * Throws if this chain has already `.on`-ed that event type — see the guard in
48
+ * `projection` for why a repeat is always a mistake here, where across two
49
+ * slices it need not be.
50
+ */
51
+ on<A, I, R2>(
52
+ def: EventDef<A, I, R2>,
53
+ fold: (state: S, payload: A) => S,
54
+ ): ProjectionBuilder<S, R | R2>
55
+ /**
56
+ * Materialise the slice. The query is a single item over the `.on`-ed defs'
57
+ * types (branded via `EventType.make`) and `options.tags` (default none).
58
+ */
59
+ build(options?: { readonly tags?: ReadonlyArray<Tag> }): SliceProjection<S, R>
60
+ }
61
+
62
+ /**
63
+ * Start a slice for `initialState`. Returns a builder with no defs yet
64
+ * (`R = never`); each `.on(def, fold)` widens `R` by the def's requirement.
65
+ */
66
+ export const projection = <S>(initialState: S): ProjectionBuilder<S, never> => {
67
+ // Persistent builder: `make` closes over the accumulated maps, and each
68
+ // `.on` copies them into a fresh builder rather than mutating in place, so
69
+ // no two derived builders ever share mutable state (reusing an intermediate
70
+ // builder can't cross-contaminate slices).
71
+ //
72
+ // `make` is generic in the accumulated requirement `R` — the parameter the
73
+ // builder is *about*. Pinning it to `never` and casting the result back would
74
+ // make the recursion typecheck by discarding the very thing each `.on` adds;
75
+ // threading `R` means the implementation states the same widening the
76
+ // `ProjectionBuilder` interface promises, and `on` returns without a cast.
77
+ const make = <R>(
78
+ schemas: ReadonlyMap<string, Schema.Schema<unknown, unknown, R>>,
79
+ folds: ReadonlyMap<string, (state: S, payload: unknown) => S>,
80
+ ): ProjectionBuilder<S, R> => ({
81
+ on: <A, I, R2>(
82
+ def: EventDef<A, I, R2>,
83
+ fold: (state: S, payload: A) => S,
84
+ ): ProjectionBuilder<S, R | R2> => {
85
+ // A repeated type WITHIN one chain is unambiguously a mistake, so this is
86
+ // a plain `has` rather than the reference-equality rule `mergeSliceSchemas`
87
+ // applies ACROSS slices. There, two entries for one type are the intended
88
+ // way to feed one event into two views, and the schemas being one object is
89
+ // what makes the collision harmless. Here there is only ever one fold per
90
+ // type — `evolve` dispatches by type into a single map — so a second `.on`
91
+ // cannot mean "as well as", only "instead of", and it silently discards
92
+ // the author's first fold along with its schema. The asymmetry is the
93
+ // point: the same `has` up there would outlaw the legitimate case.
94
+ if (schemas.has(def.type)) {
95
+ throw new Error(
96
+ `duplicate event type '${def.type}' in slice: .on was called twice ` +
97
+ 'for it, and the second fold would silently replace the first. ' +
98
+ 'Handle both cases in one fold.',
99
+ )
100
+ }
101
+ // The entry type is annotated `R | R2` rather than inferred, and that
102
+ // annotation is what carries the requirement forward — `build` reads `R`
103
+ // straight off this map, so `RequirementOf` ultimately reads it from here.
104
+ // Widening the copied `R` entries into it costs nothing: `Schema` is
105
+ // declared `<in out A, in out I, out R>` upstream, hence covariant in its
106
+ // requirement.
107
+ const nextSchemas = new Map<
108
+ string,
109
+ Schema.Schema<unknown, unknown, R | R2>
110
+ >(schemas)
111
+ // Erasing `A`/`I` is the one unavoidable cast, and `as unknown as` is what
112
+ // it takes: `Schema` is INVARIANT in both, so `Schema<A, I, R2>` and
113
+ // `Schema<unknown, unknown, R2>` are unrelated in either direction. It is
114
+ // sound because the payload is only ever read back through `evolve`, which
115
+ // re-narrows by event type via the matching `fold`. The target names `R2`
116
+ // and not `never` because `R2` is what the def actually requires — an
117
+ // `Encrypted` field's `CipherKey` is not nothing — so the cast erases only
118
+ // what it has to and states the rest truthfully.
119
+ nextSchemas.set(
120
+ def.type,
121
+ def.schema as unknown as Schema.Schema<unknown, unknown, R2>,
122
+ )
123
+ const nextFolds = new Map(folds)
124
+ // Contravariance in the payload parameter, so this one is a cast too: the
125
+ // map holds folds over `unknown`, and `evolve` supplies the payload the
126
+ // matching def decoded, which is the `A` this fold was written against.
127
+ nextFolds.set(def.type, fold as (state: S, payload: unknown) => S)
128
+ return make<R | R2>(nextSchemas, nextFolds)
129
+ },
130
+ build: (options) => {
131
+ // Internal-layer Query assembly from plain data (see core's Projection.ts
132
+ // note): a type-only-plus-optional-tags item the public surface withholds.
133
+ const types = [...schemas.keys()].map((t) => EventType.make(t))
134
+ const query: Query = {
135
+ items: [{ types, tags: options?.tags ?? [] }],
136
+ }
137
+ return {
138
+ initialState,
139
+ query,
140
+ // Defensive copy: hand out a fresh Map, not the builder's own. `schemas`
141
+ // is typed `ReadonlyMap`, but that is a compile-time guard only — a
142
+ // caller who casts it back to `Map` could otherwise mutate the slice's
143
+ // live decode table after `build`, desyncing it from `query` (whose
144
+ // event-types were snapshotted from these keys just above). The copy is
145
+ // cheap (a handful of keys) and makes the slice genuinely immutable.
146
+ schemas: new Map(schemas),
147
+ // Erased evolve: dispatch by the sequenced event's type to the matching
148
+ // fold over the decoded payload. Per the `AnyProjection.evolve` contract
149
+ // (core), a projection is only ever handed events matching its `query` —
150
+ // `composeProjections` gates each sub before dispatch — so this needs no
151
+ // second `matchesSequenced` check; the `fold === undefined` fallback is
152
+ // the honest total-function guard (evolve is typed over any DecodedEvent).
153
+ evolve: (state, decoded: DecodedEvent) => {
154
+ const fold = folds.get(decoded.sequenced.event.type)
155
+ return fold === undefined ? state : fold(state as S, decoded.payload)
156
+ },
157
+ }
158
+ },
159
+ })
160
+ // Explicit `never`: an empty `Map` literal gives inference nothing to read the
161
+ // requirement off, and "no defs yet" is exactly `never`, not `unknown`.
162
+ return make<never>(new Map(), new Map())
163
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The general (registry) path (minimal): decode an arbitrary wire event by its
3
+ * runtime type string. This is the one place a type outside the known set
4
+ * surfaces as a typed `UnknownEventType` — the home of ADR-0005 decode-totality.
5
+ *
6
+ * `makeRegistry(defs)` builds a `type -> Schema` map with a construction-time
7
+ * uniqueness guard (a duplicate type throws at wiring, not at runtime). Its
8
+ * requirement `R` is the union of the defs' requirements. `decode` looks the
9
+ * type up, then runs `deserialiseThenDecode` over the bytes, yielding
10
+ * `{ type, payload: unknown }`. That `unknown` is the point of the path rather
11
+ * than a gap in it: the type is discovered at RUNTIME off the wire event, so the
12
+ * caller has named no def and the return is deliberately NOT a discriminated
13
+ * union over `Defs`. What this path owes its caller is decode TOTALITY — every
14
+ * known type decodes, an unknown one fails as `UnknownEventType` — and not typed
15
+ * access. A caller who wants a typed payload names the def it expects and takes
16
+ * the typed slice path (`projection(init).on(def, fold)`), where each fold
17
+ * receives its payload already decoded and narrowed.
18
+ *
19
+ * What is this path's OWN and what it merely shares. The lookup is its own: a
20
+ * miss here is `UnknownEventType`, where the same miss on the typed path is a
21
+ * `CodecError` about a schema the caller did not supply — the asymmetry ADR-0005
22
+ * exists for. Everything after the lookup is the two-step both paths perform, so
23
+ * it is `deserialiseThenDecode` that owns it and fixes the `deserialise` /
24
+ * `decode` `reason` strings; this module holding a second copy is exactly how one
25
+ * of them would come to be reworded on one path only.
26
+ */
27
+ import type { DcbEvent } from '@kairos-es/core'
28
+ import { Effect, type Schema } from 'effect'
29
+ import { deserialiseThenDecode } from './deserialiseThenDecode'
30
+ import { type CodecError, UnknownEventType } from './errors'
31
+ import type { EventDef, EventDefAny } from './event'
32
+ import { Serializer } from './Serializer'
33
+
34
+ /** A registry decoding arbitrary wire events; `R` is the union of def requirements. */
35
+ export interface Registry<R> {
36
+ readonly decode: (
37
+ event: DcbEvent,
38
+ ) => Effect.Effect<
39
+ { readonly type: string; readonly payload: unknown },
40
+ UnknownEventType | CodecError,
41
+ Serializer | R
42
+ >
43
+ }
44
+
45
+ /**
46
+ * Build a `Registry` from a def barrel. Throws at construction on a duplicate
47
+ * type; `R` is the union of the defs' requirements.
48
+ */
49
+ type RequirementOfDefs<Defs extends ReadonlyArray<EventDefAny>> =
50
+ // biome-ignore lint/suspicious/noExplicitAny: match the heterogeneous EventDefAny bound to infer the union of requirements R; A/I are irrelevant here.
51
+ Defs[number] extends EventDef<any, any, infer R> ? R : never
52
+
53
+ export const makeRegistry = <Defs extends ReadonlyArray<EventDefAny>>(
54
+ defs: Defs,
55
+ ): Registry<RequirementOfDefs<Defs>> => {
56
+ const map = new Map<string, Schema.Schema<unknown, unknown, unknown>>()
57
+ for (const def of defs) {
58
+ if (map.has(def.type)) {
59
+ throw new Error(`duplicate event type '${def.type}' in registry`)
60
+ }
61
+ map.set(def.type, def.schema)
62
+ }
63
+ return {
64
+ decode: (event) =>
65
+ Effect.gen(function* () {
66
+ const schema = map.get(event.type)
67
+ if (schema === undefined) {
68
+ return yield* Effect.fail(new UnknownEventType({ type: event.type }))
69
+ }
70
+ const serializer = yield* Serializer
71
+ const payload = yield* deserialiseThenDecode(
72
+ serializer,
73
+ schema,
74
+ event.data,
75
+ )
76
+ return { type: event.type, payload }
77
+ // The schema map erases `R` to `unknown`; the declared `R` is recovered
78
+ // from `Defs` at the type level, so the gen block's inferred requirement
79
+ // (`unknown`) is re-stated as `Serializer | R` here, which is this
80
+ // module's only cast.
81
+ }) as Effect.Effect<
82
+ { readonly type: string; readonly payload: unknown },
83
+ UnknownEventType | CodecError,
84
+ Serializer | RequirementOfDefs<Defs>
85
+ >,
86
+ }
87
+ }
package/src/toEvent.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Event production (codec edge-helper): turn a typed payload into a wire
3
+ * `DcbEvent`. Encode is `Schema.encode(def.schema)` (a `ParseError` becomes a
4
+ * `CodecError`; any encryption transform runs here, *before* serialisation)
5
+ * then `serialiser.serialise`. The wire `tags` are derived from the def's
6
+ * required `tagsFor` mapper (ADR-0006) — they are never hand-passed, so two
7
+ * command sites emitting the same event type cannot tag it differently. A mapper
8
+ * that throws (e.g. an ill-formed `key:value` rejected by `Tag.make`) is
9
+ * surfaced on the `CodecError` channel, not as a defect. `uuid`
10
+ * comes from `options.uuid` or the `Uuid` service; `occurredAt` from
11
+ * `options.occurredAt` or the Effect clock (`DateTime.now`), keeping `core`
12
+ * clock- and RNG-free while staying overridable for backdated imports.
13
+ */
14
+ import type { DcbEvent } from '@kairos-es/core'
15
+ import { DateTime, Effect, Schema } from 'effect'
16
+ import { CodecError } from './errors'
17
+ import type { EventDef } from './event'
18
+ import { Serializer } from './Serializer'
19
+ import { Uuid } from './Uuid'
20
+
21
+ /**
22
+ * Encode `payload` under `def` and stamp it into a wire `DcbEvent`, deriving its
23
+ * `tags` from `def.tagsFor(payload)` (a throwing mapper surfaces as a
24
+ * `CodecError`, not a defect). The tags are stamped exactly as the mapper
25
+ * returns them — no dedup or reordering. `type` and `tags` are cleartext
26
+ * wire-envelope fields (the query surface), so a `tagsFor` mapper may only key
27
+ * off cleartext payload fields (ADR-0006). `uuid` / `occurredAt` are
28
+ * exact-optional overrides; absent, they come from the `Uuid` service and the
29
+ * Effect clock.
30
+ */
31
+ export const toEvent = <A, I, R>(
32
+ def: EventDef<A, I, R>,
33
+ payload: A,
34
+ options?: {
35
+ readonly uuid?: string
36
+ readonly occurredAt?: DateTime.Utc
37
+ },
38
+ ): Effect.Effect<DcbEvent, CodecError, Serializer | Uuid | R> =>
39
+ Effect.gen(function* () {
40
+ // Derive tags first. A mapper is app code that can throw (e.g. `Tag.make`
41
+ // rejects an ill-formed `key:value`), and the codec's contract is that such
42
+ // failures are values on the `CodecError` channel, never defects (errors.ts).
43
+ // Deriving before the uuid/clock effects also means a bad tag fails without
44
+ // consuming the `Uuid` sequence.
45
+ const tags = yield* Effect.try({
46
+ try: () => def.tagsFor(payload),
47
+ catch: (cause) => new CodecError({ reason: 'tags', cause }),
48
+ })
49
+ const serializer = yield* Serializer
50
+ const encoded = yield* Schema.encode(def.schema)(payload).pipe(
51
+ Effect.mapError((cause) => new CodecError({ reason: 'encode', cause })),
52
+ )
53
+ const data = serializer.serialise(encoded)
54
+ const uuid = options?.uuid ?? (yield* (yield* Uuid).generate)
55
+ const occurredAt = options?.occurredAt ?? (yield* DateTime.now)
56
+ return {
57
+ type: def.type,
58
+ data,
59
+ tags,
60
+ uuid,
61
+ occurredAt,
62
+ }
63
+ })