@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
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
export { type Decider, decider, runDecider } from './decider.js';
|
|
54
|
+
export { buildDecisionModel, modelAtHead, type RequirementOf, } from './decisionModel.js';
|
|
55
|
+
export { decodeSlices } from './decode.js';
|
|
56
|
+
export { CodecError, UnknownEventType } from './errors.js';
|
|
57
|
+
export { type EventDef, type EventDefAny, event, type TagsFor } from './event.js';
|
|
58
|
+
export { type ProjectionBuilder, projection, type SliceProjection, } from './projection.js';
|
|
59
|
+
export { makeRegistry, type Registry } from './registry.js';
|
|
60
|
+
export { Serializer, SerializerDefault } from './Serializer.js';
|
|
61
|
+
export { toEvent } from './toEvent.js';
|
|
62
|
+
export { Uuid, UuidLive, UuidTest } from './Uuid.js';
|
|
63
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,OAAO,EAAE,KAAK,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAC7D,OAAO,EACL,kBAAkB,EAClB,WAAW,EACX,KAAK,aAAa,GACnB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AACvD,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,EAAE,KAAK,OAAO,EAAE,MAAM,SAAS,CAAA;AAC9E,OAAO,EACL,KAAK,iBAAiB,EACtB,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,YAAY,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAA;AACxD,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAA"}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { type AnyProjection, type Tag } from '@kairos-es/core';
|
|
21
|
+
import type { Schema } from 'effect';
|
|
22
|
+
import type { EventDef } from './event.js';
|
|
23
|
+
/**
|
|
24
|
+
* A schema-backed, directly-composable projection: an `AnyProjection<S>` plus
|
|
25
|
+
* the `type -> Schema` map for decode. The map's value type carries the union
|
|
26
|
+
* `R` of its defs' requirements covariantly, so `RequirementOf` recovers `R`
|
|
27
|
+
* (and the `out R` annotation keeps union-widening sound) with no phantom marker.
|
|
28
|
+
*/
|
|
29
|
+
export interface SliceProjection<out S = unknown, out R = unknown> extends AnyProjection<S> {
|
|
30
|
+
readonly schemas: ReadonlyMap<string, Schema.Schema<unknown, unknown, R>>;
|
|
31
|
+
}
|
|
32
|
+
/** The fluent builder: accumulate `.on(def, fold)` steps, then `.build()`. */
|
|
33
|
+
export interface ProjectionBuilder<S, R = never> {
|
|
34
|
+
/**
|
|
35
|
+
* Fold events of `def`'s type: `fold` receives the decoded, typed `payload`.
|
|
36
|
+
* Adds `def`'s requirement `R2` to the accumulated `R`.
|
|
37
|
+
*
|
|
38
|
+
* Throws if this chain has already `.on`-ed that event type — see the guard in
|
|
39
|
+
* `projection` for why a repeat is always a mistake here, where across two
|
|
40
|
+
* slices it need not be.
|
|
41
|
+
*/
|
|
42
|
+
on<A, I, R2>(def: EventDef<A, I, R2>, fold: (state: S, payload: A) => S): ProjectionBuilder<S, R | R2>;
|
|
43
|
+
/**
|
|
44
|
+
* Materialise the slice. The query is a single item over the `.on`-ed defs'
|
|
45
|
+
* types (branded via `EventType.make`) and `options.tags` (default none).
|
|
46
|
+
*/
|
|
47
|
+
build(options?: {
|
|
48
|
+
readonly tags?: ReadonlyArray<Tag>;
|
|
49
|
+
}): SliceProjection<S, R>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Start a slice for `initialState`. Returns a builder with no defs yet
|
|
53
|
+
* (`R = never`); each `.on(def, fold)` widens `R` by the def's requirement.
|
|
54
|
+
*/
|
|
55
|
+
export declare const projection: <S>(initialState: S) => ProjectionBuilder<S, never>;
|
|
56
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../../src/projection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EACL,KAAK,aAAa,EAIlB,KAAK,GAAG,EACT,MAAM,iBAAiB,CAAA;AACxB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACpC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAEvC;;;;;GAKG;AACH,MAAM,WAAW,eAAe,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,CAAC,CAAC,GAAG,OAAO,CAC/D,SAAQ,aAAa,CAAC,CAAC,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,CAAA;CAC1E;AAED,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK;IAC7C;;;;;;;OAOG;IACH,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EACT,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,EACvB,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,CAAC,GAChC,iBAAiB,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAA;IAC/B;;;OAGG;IACH,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;CAC/E;AAED;;;GAGG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,cAAc,CAAC,KAAG,iBAAiB,CAAC,CAAC,EAAE,KAAK,CAiGzE,CAAA"}
|
|
@@ -0,0 +1,46 @@
|
|
|
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 } from 'effect';
|
|
29
|
+
import { type CodecError, UnknownEventType } from './errors.js';
|
|
30
|
+
import type { EventDef, EventDefAny } from './event.js';
|
|
31
|
+
import { Serializer } from './Serializer.js';
|
|
32
|
+
/** A registry decoding arbitrary wire events; `R` is the union of def requirements. */
|
|
33
|
+
export interface Registry<R> {
|
|
34
|
+
readonly decode: (event: DcbEvent) => Effect.Effect<{
|
|
35
|
+
readonly type: string;
|
|
36
|
+
readonly payload: unknown;
|
|
37
|
+
}, UnknownEventType | CodecError, Serializer | R>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a `Registry` from a def barrel. Throws at construction on a duplicate
|
|
41
|
+
* type; `R` is the union of the defs' requirements.
|
|
42
|
+
*/
|
|
43
|
+
type RequirementOfDefs<Defs extends ReadonlyArray<EventDefAny>> = Defs[number] extends EventDef<any, any, infer R> ? R : never;
|
|
44
|
+
export declare const makeRegistry: <Defs extends ReadonlyArray<EventDefAny>>(defs: Defs) => Registry<RequirementOfDefs<Defs>>;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,EAAE,MAAM,EAAe,MAAM,QAAQ,CAAA;AAE5C,OAAO,EAAE,KAAK,UAAU,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAC5D,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAEzC,uFAAuF;AACvF,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,CACf,KAAK,EAAE,QAAQ,KACZ,MAAM,CAAC,MAAM,CAChB;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;KAAE,EACpD,gBAAgB,GAAG,UAAU,EAC7B,UAAU,GAAG,CAAC,CACf,CAAA;CACF;AAED;;;GAGG;AACH,KAAK,iBAAiB,CAAC,IAAI,SAAS,aAAa,CAAC,WAAW,CAAC,IAE5D,IAAI,CAAC,MAAM,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAA;AAE9D,eAAO,MAAM,YAAY,GAAI,IAAI,SAAS,aAAa,CAAC,WAAW,CAAC,EAClE,MAAM,IAAI,KACT,QAAQ,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAgClC,CAAA"}
|
|
@@ -0,0 +1,34 @@
|
|
|
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 } from 'effect';
|
|
16
|
+
import { CodecError } from './errors.js';
|
|
17
|
+
import type { EventDef } from './event.js';
|
|
18
|
+
import { Serializer } from './Serializer.js';
|
|
19
|
+
import { Uuid } from './Uuid.js';
|
|
20
|
+
/**
|
|
21
|
+
* Encode `payload` under `def` and stamp it into a wire `DcbEvent`, deriving its
|
|
22
|
+
* `tags` from `def.tagsFor(payload)` (a throwing mapper surfaces as a
|
|
23
|
+
* `CodecError`, not a defect). The tags are stamped exactly as the mapper
|
|
24
|
+
* returns them — no dedup or reordering. `type` and `tags` are cleartext
|
|
25
|
+
* wire-envelope fields (the query surface), so a `tagsFor` mapper may only key
|
|
26
|
+
* off cleartext payload fields (ADR-0006). `uuid` / `occurredAt` are
|
|
27
|
+
* exact-optional overrides; absent, they come from the `Uuid` service and the
|
|
28
|
+
* Effect clock.
|
|
29
|
+
*/
|
|
30
|
+
export declare const toEvent: <A, I, R>(def: EventDef<A, I, R>, payload: A, options?: {
|
|
31
|
+
readonly uuid?: string;
|
|
32
|
+
readonly occurredAt?: DateTime.Utc;
|
|
33
|
+
}) => Effect.Effect<DcbEvent, CodecError, Serializer | Uuid | R>;
|
|
34
|
+
//# sourceMappingURL=toEvent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toEvent.d.ts","sourceRoot":"","sources":["../../src/toEvent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAA;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AACrC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAE7B;;;;;;;;;GASG;AACH,eAAO,MAAM,OAAO,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAC7B,KAAK,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EACtB,SAAS,CAAC,EACV,UAAU;IACR,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAA;CACnC,KACA,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,CAAC,CAyBxD,CAAA"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pluggable serialiser: the `Uint8Array` <-> value boundary applied
|
|
3
|
+
* *outside* `Schema`. Encode is `Schema.encode` then `serialise`; decode is
|
|
4
|
+
* `deserialise` then `Schema.decodeUnknown`. Both codec paths read the same
|
|
5
|
+
* `Serializer` from context, so encode and decode cannot mismatch; swapping
|
|
6
|
+
* JSON for msgpack is a different layer with no schema or call-site changes.
|
|
7
|
+
*
|
|
8
|
+
* `deserialise` may throw synchronously (corrupt bytes): wrapping it as a
|
|
9
|
+
* `CodecError` is the *caller's* concern — every decode site wraps it in
|
|
10
|
+
* `Effect.try`. Keeping the service interface plain (no Effect) lets a layer be
|
|
11
|
+
* a trivial `Layer.succeed`.
|
|
12
|
+
*/
|
|
13
|
+
import { Context, Layer } from 'effect';
|
|
14
|
+
export class Serializer extends /*#__PURE__*/Context.Tag('@kairos-es/codec/Serializer')() {}
|
|
15
|
+
const encoder = /*#__PURE__*/new TextEncoder();
|
|
16
|
+
const decoder = /*#__PURE__*/new TextDecoder();
|
|
17
|
+
/**
|
|
18
|
+
* The shipped default: JSON over UTF-8. `serialise` = `JSON.stringify` then
|
|
19
|
+
* `TextEncoder`; `deserialise` = `TextDecoder` then `JSON.parse` (which throws
|
|
20
|
+
* synchronously on corrupt bytes, wrapped to `CodecError` at the call site).
|
|
21
|
+
*/
|
|
22
|
+
export const SerializerDefault = /*#__PURE__*/Layer.succeed(Serializer, {
|
|
23
|
+
serialise: v => encoder.encode(JSON.stringify(v)),
|
|
24
|
+
deserialise: b => JSON.parse(decoder.decode(b))
|
|
25
|
+
});
|
|
26
|
+
//# sourceMappingURL=Serializer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Serializer.js","names":["Context","Layer","Serializer","Tag","encoder","TextEncoder","decoder","TextDecoder","SerializerDefault","succeed","serialise","v","encode","JSON","stringify","deserialise","b","parse","decode"],"sources":["../../src/Serializer.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;AAYA,SAASA,OAAO,EAAEC,KAAK,QAAQ,QAAQ;AAEvC,OAAM,MAAOC,UAAW,sBAAQF,OAAO,CAACG,GAAG,CAAC,6BAA6B,CAAC,EAMvE;AAEH,MAAMC,OAAO,gBAAG,IAAIC,WAAW,EAAE;AACjC,MAAMC,OAAO,gBAAG,IAAIC,WAAW,EAAE;AAEjC;;;;;AAKA,OAAO,MAAMC,iBAAiB,gBAA4BP,KAAK,CAACQ,OAAO,CACrEP,UAAU,EACV;EACEQ,SAAS,EAAGC,CAAC,IAAKP,OAAO,CAACQ,MAAM,CAACC,IAAI,CAACC,SAAS,CAACH,CAAC,CAAC,CAAC;EACnDI,WAAW,EAAGC,CAAC,IAAKH,IAAI,CAACI,KAAK,CAACX,OAAO,CAACY,MAAM,CAACF,CAAC,CAAC;CACjD,CACF","ignoreList":[]}
|
package/dist/esm/Uuid.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The uuid service: `toEvent` stamps each wire event's `uuid` from here, keeping
|
|
3
|
+
* `core` RNG-free (and letting suites make events comparable). Overridable per
|
|
4
|
+
* `toEvent` call via `options.uuid`.
|
|
5
|
+
*
|
|
6
|
+
* `UuidLive` uses WebCrypto (`globalThis.crypto.randomUUID`) so the codec stays
|
|
7
|
+
* platform-neutral with no `node:crypto`. `UuidTest` is a deterministic layer
|
|
8
|
+
* whose per-layer counter yields stable RFC-4122 v4-shaped uuids
|
|
9
|
+
* (`00000000-0000-4000-8000-<12 hex>`), so a suite that builds one `UuidTest`
|
|
10
|
+
* layer gets a repeatable sequence.
|
|
11
|
+
*/
|
|
12
|
+
import { makeSequentialUuid } from '@kairos-es/core/testing';
|
|
13
|
+
import { Context, Effect, Layer } from 'effect';
|
|
14
|
+
export class Uuid extends /*#__PURE__*/Context.Tag('@kairos-es/codec/Uuid')() {}
|
|
15
|
+
/** Live uuid generation via WebCrypto (no `node:crypto`). */
|
|
16
|
+
export const UuidLive = /*#__PURE__*/Layer.succeed(Uuid, {
|
|
17
|
+
generate: /*#__PURE__*/Effect.sync(() => globalThis.crypto.randomUUID())
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* A deterministic uuid layer for suites, wrapping core's shared
|
|
21
|
+
* `makeSequentialUuid` (the single source of truth for the counter scheme).
|
|
22
|
+
* Each layer instance builds a fresh generator, so it owns its own counter and
|
|
23
|
+
* yields a stable v4-shaped sequence `00000000-0000-4000-8000-<12 hex>` starting
|
|
24
|
+
* at `...000000000000`. Generation is wrapped in `Effect.sync` because the
|
|
25
|
+
* generator advances its counter (a side effect on each call).
|
|
26
|
+
*/
|
|
27
|
+
export const UuidTest = /*#__PURE__*/Layer.sync(Uuid, () => {
|
|
28
|
+
const next = makeSequentialUuid();
|
|
29
|
+
return {
|
|
30
|
+
generate: Effect.sync(next)
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=Uuid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Uuid.js","names":["makeSequentialUuid","Context","Effect","Layer","Uuid","Tag","UuidLive","succeed","generate","sync","globalThis","crypto","randomUUID","UuidTest","next"],"sources":["../../src/Uuid.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;AAWA,SAASA,kBAAkB,QAAQ,yBAAyB;AAC5D,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,QAAQ,QAAQ;AAE/C,OAAM,MAAOC,IAAK,sBAAQH,OAAO,CAACI,GAAG,CAAC,uBAAuB,CAAC,EAG3D;AAEH;AACA,OAAO,MAAMC,QAAQ,gBAAsBH,KAAK,CAACI,OAAO,CAACH,IAAI,EAAE;EAC7DI,QAAQ,eAAEN,MAAM,CAACO,IAAI,CAAC,MAAMC,UAAU,CAACC,MAAM,CAACC,UAAU,EAAE;CAC3D,CAAC;AAEF;;;;;;;;AAQA,OAAO,MAAMC,QAAQ,gBAAsBV,KAAK,CAACM,IAAI,CAACL,IAAI,EAAE,MAAK;EAC/D,MAAMU,IAAI,GAAGd,kBAAkB,EAAE;EACjC,OAAO;IAAEQ,QAAQ,EAAEN,MAAM,CAACO,IAAI,CAACK,IAAI;EAAC,CAAE;AACxC,CAAC,CAAC","ignoreList":[]}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `Decider`: the write side's unit of decision. It pairs the projections a
|
|
3
|
+
* command handler reads (its decision model) with an effectful `decide` step
|
|
4
|
+
* that turns the folded state plus a command into the events to append.
|
|
5
|
+
*
|
|
6
|
+
* This is PRODUCTION code and it lives beside the `buildDecisionModel` it
|
|
7
|
+
* drives. It began in `@kairos-es/testing` because the Given/When/Then surface
|
|
8
|
+
* was its first consumer, but nothing in it is test apparatus: every import
|
|
9
|
+
* resolves to this package or to `@kairos-es/core`, and a test framework is
|
|
10
|
+
* never one of them. That home was a real hazard rather than a tidiness
|
|
11
|
+
* complaint — `@kairos-es/testing` peers `@effect/vitest`, so a command handler
|
|
12
|
+
* typed against `Decider` (as this repo's own worked example is, in `src/` and
|
|
13
|
+
* not `test/`) named a package whose sibling module imports `expect` and `it` at
|
|
14
|
+
* top level, and a handler that actually RAN a command would have
|
|
15
|
+
* runtime-imported `runDecider` from it, loading a test framework into a
|
|
16
|
+
* production module graph. `@kairos-es/read/testing` documents that same hazard
|
|
17
|
+
* as the reason a subpath may add no peer; the difference here is that this is
|
|
18
|
+
* not a testing surface at all, so the fix is a home rather than a rule.
|
|
19
|
+
*
|
|
20
|
+
* `@kairos-es/testing` now imports these two symbols like any other consumer,
|
|
21
|
+
* and NO compatibility re-export is left behind there: nothing is published yet,
|
|
22
|
+
* so a re-export would be permanent clutter bought for no consumer, and it would
|
|
23
|
+
* preserve in that package's public surface exactly the coupling the move
|
|
24
|
+
* exists to remove. That is the argument ADR-0008 made when `retryOnConflict`
|
|
25
|
+
* moved to the package that owns the error it matches, and it holds here for the
|
|
26
|
+
* same reason — a write-side consumer depending on the wrong package is
|
|
27
|
+
* backwards, whichever direction the dependency points.
|
|
28
|
+
*
|
|
29
|
+
* `projections` is a *function of the command* because a DCB decision model is
|
|
30
|
+
* parametrised by the command's own identifiers (e.g. narrow to
|
|
31
|
+
* `course:{command.courseId}`), which keeps the consistency boundary — and the
|
|
32
|
+
* `failIfEventsMatch` guard the read produces — as tight as the decision
|
|
33
|
+
* actually needs. This mirrors dcb-events, where the command handler calls
|
|
34
|
+
* `buildDecisionModel(store, { ...projections built from command.data })`.
|
|
35
|
+
*
|
|
36
|
+
* `runDecider` runs the DCB command cycle against whatever `DcbEventStore` is in
|
|
37
|
+
* context: build the decision model for the command (a real `read` + decode +
|
|
38
|
+
* fold via `buildDecisionModel`), decide, then `append` the new events under the
|
|
39
|
+
* append condition the read produced. That IS the optimistic-concurrency
|
|
40
|
+
* contract, in production and in a scenario alike — which is also why the
|
|
41
|
+
* Given/When/Then surface built on it exercises the real fold and the real store
|
|
42
|
+
* rather than a mock. It is a free function rather than a `Context.Tag` service,
|
|
43
|
+
* so it is still not the "production command-runner service method" a retrying
|
|
44
|
+
* `Layer` would need (ADR-0008): the correct retry remains
|
|
45
|
+
* `retryOnConflict(runDecider(...))`, wrapping the whole cycle so a fresh read
|
|
46
|
+
* re-derives the condition.
|
|
47
|
+
*
|
|
48
|
+
* The `decide` step produces its events effectfully (they are encoded via
|
|
49
|
+
* `toEvent`, so it requires `Serializer | Uuid | R`) and may fail on its own
|
|
50
|
+
* error channel (a domain rule violation, e.g. "course already exists"); the
|
|
51
|
+
* append may fail with `AppendConditionFailed` if a concurrent writer moved the
|
|
52
|
+
* boundary, and decode may fail with `CodecError`. All surface on the error
|
|
53
|
+
* channel, so a caller handles all three the same way — and
|
|
54
|
+
* `@kairos-es/testing`'s `.thenError(tag)` can assert any of them.
|
|
55
|
+
*/
|
|
56
|
+
import { DcbEventStore } from '@kairos-es/core';
|
|
57
|
+
import { Effect } from 'effect';
|
|
58
|
+
import { buildDecisionModel } from "./decisionModel.js";
|
|
59
|
+
/**
|
|
60
|
+
* Build a `Decider` with all four of its type arguments INFERRED: the slice
|
|
61
|
+
* record `P` from what `projections` returns, the error union `E` and the extra
|
|
62
|
+
* requirement `R` from what `decide` returns. The command type is the one thing
|
|
63
|
+
* a caller still writes, once, as the annotation on the `projections` parameter
|
|
64
|
+
* — `Command` occurs only in parameter positions, so nothing else could supply
|
|
65
|
+
* it, and it is domain information (the command this decision is parametrised
|
|
66
|
+
* by) rather than plumbing.
|
|
67
|
+
*
|
|
68
|
+
* It adds no capability: the interface stays directly inhabitable and this
|
|
69
|
+
* returns exactly the object literal a caller would have written. What it
|
|
70
|
+
* changes is what an author must SPELL. An object literal annotated
|
|
71
|
+
* `Decider<P, Command, E>` has to state the whole slice record — a
|
|
72
|
+
* `ReturnType<typeof someProjection>` per key, a slice's state type being
|
|
73
|
+
* inferred by `projection().on().build()` and so having no name to write — plus
|
|
74
|
+
* the union of every error its decide branches can fail with. Dropping the
|
|
75
|
+
* annotation instead is not open to the author: without it `decide`'s `state`
|
|
76
|
+
* and `command` parameters have no contextual type and are implicitly `any`. So
|
|
77
|
+
* the cost landed hardest on the worked example, where some thirty lines of type
|
|
78
|
+
* plumbing sat between the reader and the three decisions that file exists to
|
|
79
|
+
* teach.
|
|
80
|
+
*
|
|
81
|
+
* `Eff` captures decide's WHOLE return type as one type parameter instead of
|
|
82
|
+
* inferring `E` and `R` from an `Effect<…, E, Serializer | Uuid | R>` shaped
|
|
83
|
+
* target, and that is the part that had to be got right rather than merely
|
|
84
|
+
* written down. A real `decide` returns a UNION of effects — one `Effect.fail`
|
|
85
|
+
* per rule it enforces, plus the produce path — and TypeScript infers a type
|
|
86
|
+
* parameter from a union source by choosing one constituent's candidate, not by
|
|
87
|
+
* unioning them. Against the shaped target `E` therefore came out as whichever
|
|
88
|
+
* branch won, and every other branch was then rejected against it. Inference to
|
|
89
|
+
* a NAKED type variable takes the union whole, which is why `Eff` is the
|
|
90
|
+
* parameter and `Effect.Effect.Error` / `Effect.Effect.Context` recover the two
|
|
91
|
+
* channels afterwards: those do distribute across a union of effects.
|
|
92
|
+
*
|
|
93
|
+
* The constraint on `Eff` is load-bearing twice over. It is what still refuses a
|
|
94
|
+
* `decide` that could produce an EMPTY batch, the one guarantee the shaped
|
|
95
|
+
* target used to give and the one `append` relies on. It is also what
|
|
96
|
+
* contextually types the produce path's `(event) => [event]` as a one-element
|
|
97
|
+
* TUPLE; unconstrained, that widens to `DcbEvent[]`, which is not a
|
|
98
|
+
* `NonEmptyReadonlyArray` and would fail at the call site.
|
|
99
|
+
*
|
|
100
|
+
* The single `as` is narrow and unavoidable: the declared return is a
|
|
101
|
+
* conditional over an unresolved `Eff`, so while `Eff` is still a type variable
|
|
102
|
+
* the checker cannot relate `(…) => Eff` to `(…) => Effect<…,
|
|
103
|
+
* Effect.Error<Eff>, …>`. The value is the two functions it was handed, so
|
|
104
|
+
* there is nothing for the cast to be wrong about.
|
|
105
|
+
*
|
|
106
|
+
* `R` excludes `Serializer | Uuid` because that is what `R` MEANS on `Decider`:
|
|
107
|
+
* the interface writes decide's requirement as `Serializer | Uuid | R`, so `R`
|
|
108
|
+
* is what the produce side needs BEYOND the codec's own two services. Leaving
|
|
109
|
+
* them in would state the same requirement twice and make an ordinary decider
|
|
110
|
+
* read as though it carried an extra one, though the set `runDecider` ends up
|
|
111
|
+
* asking for is identical either way. The exclusion cannot swallow a caller's
|
|
112
|
+
* own service by accident: a `Context.Tag`'s requirement type carries its key as
|
|
113
|
+
* a literal `Id` field, so only a tag declared under `@kairos-es/codec/Uuid` or
|
|
114
|
+
* `@kairos-es/codec/Serializer` matches — and a tag under that key IS that
|
|
115
|
+
* service, context being keyed by the same string at runtime.
|
|
116
|
+
*/
|
|
117
|
+
export const decider = (projections, decide) => ({
|
|
118
|
+
projections,
|
|
119
|
+
decide
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* Run the full DCB command cycle for a decider against the ambient
|
|
123
|
+
* `DcbEventStore`: build the decision model (real read + decode + fold), decide,
|
|
124
|
+
* and append under the read's append condition. Returns the last new position.
|
|
125
|
+
*
|
|
126
|
+
* The requirement is `DcbEventStore | Serializer | Uuid | R |
|
|
127
|
+
* RequirementOf<P>`: `Serializer` enters through both the decode
|
|
128
|
+
* (`buildDecisionModel`) and produce (`toEvent`) sides, `Uuid` through produce,
|
|
129
|
+
* the slices' `RequirementOf<P>` through decode, and `R` through produce. (For a
|
|
130
|
+
* def whose `Schema` carries a requirement, `RequirementOf<P>` and `R` are the
|
|
131
|
+
* same tag.) Any `decide` failure, a decode `CodecError`, or an
|
|
132
|
+
* `AppendConditionFailed` propagates on the error channel unchanged.
|
|
133
|
+
*/
|
|
134
|
+
export const runDecider = (decider, command) => Effect.gen(function* () {
|
|
135
|
+
const store = yield* DcbEventStore;
|
|
136
|
+
const {
|
|
137
|
+
state,
|
|
138
|
+
appendCondition
|
|
139
|
+
} = yield* buildDecisionModel(decider.projections(command));
|
|
140
|
+
const newEvents = yield* decider.decide(state, command);
|
|
141
|
+
return yield* store.append(newEvents, appendCondition);
|
|
142
|
+
});
|
|
143
|
+
//# sourceMappingURL=decider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decider.js","names":["DcbEventStore","Effect","buildDecisionModel","decider","projections","decide","runDecider","command","gen","store","state","appendCondition","newEvents","append"],"sources":["../../src/decider.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8DA,SAASA,aAAa,QAAQ,iBAAiB;AAE/C,SAASC,MAAM,QAAQ,QAAQ;AAC/B,SAASC,kBAAkB,QAA4B,oBAAiB;AAsCxE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0DA,OAAO,MAAMC,OAAO,GAAGA,CASrBC,WAAoC,EACpCC,MAA2D,MAO1D;EAAED,WAAW;EAAEC;AAAM,CAAE,CAKvB;AAEH;;;;;;;;;;;;;AAaA,OAAO,MAAMC,UAAU,GAAGA,CAMxBH,OAAkC,EAClCI,OAAgB,KAMhBN,MAAM,CAACO,GAAG,CAAC,aAAS;EAClB,MAAMC,KAAK,GAAG,OAAOT,aAAa;EAClC,MAAM;IAAEU,KAAK;IAAEC;EAAe,CAAE,GAAG,OAAOT,kBAAkB,CAC1DC,OAAO,CAACC,WAAW,CAACG,OAAO,CAAC,CAC7B;EACD,MAAMK,SAAS,GAAG,OAAOT,OAAO,CAACE,MAAM,CAACK,KAAK,EAAEH,OAAO,CAAC;EACvD,OAAO,OAAOE,KAAK,CAACI,MAAM,CAACD,SAAS,EAAED,eAAe,CAAC;AACxD,CAAC,CAAC","ignoreList":[]}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-and-fold over a slice record: read the composed query, decode each event
|
|
3
|
+
* through the matching def's `Schema` in an effectful `Stream.mapEffect` stage,
|
|
4
|
+
* then fold *purely* through core's `composeProjections`.
|
|
5
|
+
*
|
|
6
|
+
* TWO exports sit over that ONE body, the second expressed over the first.
|
|
7
|
+
* `modelAtHead` stops where the fold does, at `{ state, head }` — a
|
|
8
|
+
* materialisation of the record with no table, no checkpoint and no runner.
|
|
9
|
+
* `buildDecisionModel` adds the only part that is genuinely decision-specific:
|
|
10
|
+
* the `AppendCondition` derived from the same query and that same head (the DCB
|
|
11
|
+
* optimistic-concurrency contract). It is core's old
|
|
12
|
+
* `buildDecisionModel(Record<string, AnyProjection>)` surface, moved here
|
|
13
|
+
* because decode needs `Schema` + the `Serializer`.
|
|
14
|
+
*
|
|
15
|
+
* The fold stays pure and in core; only the decode stage is effectful, and that
|
|
16
|
+
* stage lives in `decode.ts` — `decodeSlices` — so the read side folds a live
|
|
17
|
+
* `subscribe` stream through the *same* decode this module folds a bounded
|
|
18
|
+
* `read` stream through.
|
|
19
|
+
*
|
|
20
|
+
* The result requirement is exactly `DcbEventStore | Serializer | R`, where `R`
|
|
21
|
+
* is the union of the record's slices' requirements — inferred with no cast,
|
|
22
|
+
* `R` being carried covariantly by each slice's `schemas` map value type.
|
|
23
|
+
* `Serializer` is not yielded here: `decodeSlices` reads it from context itself,
|
|
24
|
+
* so the requirement arrives through the decode stage rather than through a
|
|
25
|
+
* binding this module owns (see `decode.ts` for why).
|
|
26
|
+
*/
|
|
27
|
+
import { composeProjections, DcbEventStore } from '@kairos-es/core';
|
|
28
|
+
import { Effect, Stream } from 'effect';
|
|
29
|
+
import { decodeSlices } from "./decode.js";
|
|
30
|
+
/**
|
|
31
|
+
* Read the events for the composed slices' query, decode each through its def's
|
|
32
|
+
* `Schema`, fold into per-key state, and return `{ state, head }`: one slice
|
|
33
|
+
* record materialised at read time, with no table, no checkpoint and no runner.
|
|
34
|
+
*
|
|
35
|
+
* ## Why `head` comes back, and not bare `state`
|
|
36
|
+
*
|
|
37
|
+
* `head` is the consistency statement, and without it the state is a figure with
|
|
38
|
+
* no as-of — which is why the as-of is in the name. The fold stops at the
|
|
39
|
+
* position the read returned and is maintained no further, so `head` is the
|
|
40
|
+
* whole of what a caller can say about this state's currency: it is what lets
|
|
41
|
+
* the write path turn it into an `AppendCondition` (see `buildDecisionModel`),
|
|
42
|
+
* and what lets a read-time caller report, cache or compare a state against a
|
|
43
|
+
* position rather than against a wall clock.
|
|
44
|
+
*
|
|
45
|
+
* It is also the inversion worth naming out loud, because intuition runs the
|
|
46
|
+
* other way. A MAINTAINED view is consistent as of its CHECKPOINT, which trails
|
|
47
|
+
* the log's head by however far behind its runner is; this fold is consistent as
|
|
48
|
+
* of the head itself. So of two materialisations of one slice record, the one
|
|
49
|
+
* with no table, no checkpoint and no runner is the MOST current, and the table
|
|
50
|
+
* beside it is the stale one. Reach for a maintained view for the queries a
|
|
51
|
+
* single fold cannot serve — across many identities, ordered, joined, paginated
|
|
52
|
+
* — and never because it is fresher, because it is not.
|
|
53
|
+
*
|
|
54
|
+
* `head` is LOG-GLOBAL: it is the head the store's `read` returned, so it moves
|
|
55
|
+
* for an event no slice here selects. `test/decisionModel.test.ts` pins that
|
|
56
|
+
* behaviour EXPLICITLY, over four appended events one of which the composed query
|
|
57
|
+
* excludes by tag; `test/modelAtHead.test.ts` pins it incidentally, its own head
|
|
58
|
+
* assertion landing on that same excluded event's position. Two guards, one of them
|
|
59
|
+
* deliberate — reach for the explicit one when the property itself is what changes.
|
|
60
|
+
* Log-global neither tightens nor loosens the derived guard — there can be no
|
|
61
|
+
* event matching the query between the query's last match and the log head, so a
|
|
62
|
+
* query-filtered head would refuse exactly the same appends — which is precisely
|
|
63
|
+
* why the store's own figure is the one to carry: one fact about the log, not a
|
|
64
|
+
* per-query derivation, and the honest as-of for a state folded from everything
|
|
65
|
+
* the query selected up to it.
|
|
66
|
+
*
|
|
67
|
+
* ## Why this lives in the codec and not in `@kairos-es/read`
|
|
68
|
+
*
|
|
69
|
+
* In `@kairos-es/read` it would be the only export touching none of that
|
|
70
|
+
* package's machinery: no checkpoint, no `EventLogDurability` (R2 is vacuous
|
|
71
|
+
* where there is no checkpoint to outlive the log), no rung of that package's
|
|
72
|
+
* construction gate, and none of its test apparatus. The vocabulary is already
|
|
73
|
+
* this package's — ADR-0004 names the typed slice path's clients as "a decision
|
|
74
|
+
* model / materialisation / command" — and the body is `buildDecisionModel`'s,
|
|
75
|
+
* which is here for the reason the module doc gives.
|
|
76
|
+
*/
|
|
77
|
+
export const modelAtHead = projections => Effect.gen(function* () {
|
|
78
|
+
const store = yield* DcbEventStore;
|
|
79
|
+
// The shared, back-pressured decode stage, built from the SAME record the
|
|
80
|
+
// query below is composed from — which is the whole reason `decodeSlices`
|
|
81
|
+
// takes the slices rather than a pre-merged schema table.
|
|
82
|
+
const decode = decodeSlices(projections);
|
|
83
|
+
// The pure fold: core composes the record (slices are AnyProjections).
|
|
84
|
+
const composite = composeProjections(projections);
|
|
85
|
+
const {
|
|
86
|
+
events,
|
|
87
|
+
head
|
|
88
|
+
} = yield* store.read(composite.query);
|
|
89
|
+
const decoded = decode(events);
|
|
90
|
+
const state = yield* Stream.runFold(decoded, composite.initialState, (acc, event) => composite.evolve(acc, event));
|
|
91
|
+
return {
|
|
92
|
+
state,
|
|
93
|
+
head
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* The write side's decision model: `modelAtHead` plus the `AppendCondition` that
|
|
98
|
+
* reuses the read query as `failIfEventsMatch` and the read head as `after` (the
|
|
99
|
+
* DCB optimistic-concurrency contract).
|
|
100
|
+
*
|
|
101
|
+
* It is expressed OVER `modelAtHead` rather than beside it, and that is the point
|
|
102
|
+
* of the pair. Two functions reading, decoding and folding a slice record almost
|
|
103
|
+
* identically would need two sets of tests and would be free to drift on the
|
|
104
|
+
* decode stage, on the cast recovering `CompositeState<T>`, or on which record
|
|
105
|
+
* the query is composed from. Re-expressing makes this a decomposition instead
|
|
106
|
+
* of a near-duplicate: `modelAtHead` is the shared part, this function is the
|
|
107
|
+
* condition and nothing else, and each is shorter than the one function was.
|
|
108
|
+
*
|
|
109
|
+
* `composeProjections` therefore runs TWICE for one command — once inside
|
|
110
|
+
* `modelAtHead` for the read, once here for `failIfEventsMatch` — and that is
|
|
111
|
+
* deliberate rather than waste. It is a pure derivation of the record, so the two
|
|
112
|
+
* queries cannot disagree however either call site changes. The alternative,
|
|
113
|
+
* returning the query from `modelAtHead`, would put a third field on a READ-TIME
|
|
114
|
+
* API that no read-time caller wants, to save one pure merge over a handful of
|
|
115
|
+
* slices; a surface paying for a body's convenience is the worse trade.
|
|
116
|
+
*
|
|
117
|
+
* ## How to attribute the read, the decode and the fold — the rule, stated once
|
|
118
|
+
*
|
|
119
|
+
* Because this function is `Effect.map` over `modelAtHead`, prose about the codec
|
|
120
|
+
* splits into two kinds and only one of them may name this symbol:
|
|
121
|
+
*
|
|
122
|
+
* - Naming this function as the ENTRY POINT a caller invokes, or as the path
|
|
123
|
+
* something is reached THROUGH, is correct — "the `buildDecisionModel` fold", "the
|
|
124
|
+
* write path reaches decode through `buildDecisionModel`". Transitive reach is real
|
|
125
|
+
* reach, and this is the symbol a command actually calls.
|
|
126
|
+
* - Naming it as the function that CONTAINS the read, the decode or the fold is
|
|
127
|
+
* FALSE. All three are `modelAtHead`'s; this body adds an `AppendCondition` and
|
|
128
|
+
* nothing else.
|
|
129
|
+
*
|
|
130
|
+
* Worth writing down because the distinction is invisible from a call site and the
|
|
131
|
+
* mistake is systematic rather than careless: every such claim was TRUE before
|
|
132
|
+
* `modelAtHead` was extracted from here, so a doc written against the older shape
|
|
133
|
+
* reads perfectly and is wrong. The correction is to name `modelAtHead`, never to
|
|
134
|
+
* soften the sentence.
|
|
135
|
+
*/
|
|
136
|
+
export const buildDecisionModel = projections => Effect.map(modelAtHead(projections), ({
|
|
137
|
+
state,
|
|
138
|
+
head
|
|
139
|
+
}) => ({
|
|
140
|
+
state,
|
|
141
|
+
appendCondition: {
|
|
142
|
+
failIfEventsMatch: composeProjections(projections).query,
|
|
143
|
+
after: head
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
//# sourceMappingURL=decisionModel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decisionModel.js","names":["composeProjections","DcbEventStore","Effect","Stream","decodeSlices","modelAtHead","projections","gen","store","decode","composite","events","head","read","query","decoded","state","runFold","initialState","acc","event","evolve","buildDecisionModel","map","appendCondition","failIfEventsMatch","after"],"sources":["../../src/decisionModel.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAIEA,kBAAkB,EAClBC,aAAa,QAER,iBAAiB;AACxB,SAASC,MAAM,EAAEC,MAAM,QAAQ,QAAQ;AACvC,SAASC,YAAY,QAAQ,aAAU;AASvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CA,OAAO,MAAMC,WAAW,GACtBC,WAAc,IAMdJ,MAAM,CAACK,GAAG,CAAC,aAAS;EAClB,MAAMC,KAAK,GAAG,OAAOP,aAAa;EAElC;EACA;EACA;EACA,MAAMQ,MAAM,GAAGL,YAAY,CAACE,WAAW,CAAC;EAExC;EACA,MAAMI,SAAS,GAAGV,kBAAkB,CAClCM,WAA4C,CAC7C;EACD,MAAM;IAAEK,MAAM;IAAEC;EAAI,CAAE,GAAG,OAAOJ,KAAK,CAACK,IAAI,CAACH,SAAS,CAACI,KAAK,CAAC;EAE3D,MAAMC,OAAO,GAAGN,MAAM,CAACE,MAAM,CAAC;EAE9B,MAAMK,KAAK,GAAI,OAAOb,MAAM,CAACc,OAAO,CAClCF,OAAO,EACPL,SAAS,CAACQ,YAAY,EACtB,CAACC,GAAG,EAAEC,KAAK,KAAKV,SAAS,CAACW,MAAM,CAACF,GAAG,EAAEC,KAAK,CAAC,CACvB;EAEvB,OAAO;IAAEJ,KAAK;IAAEJ;EAAI,CAAE;AACxB,CAAC,CAAC;AAEJ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,OAAO,MAAMU,kBAAkB,GAC7BhB,WAAc,IASdJ,MAAM,CAACqB,GAAG,CAAClB,WAAW,CAACC,WAAW,CAAC,EAAE,CAAC;EAAEU,KAAK;EAAEJ;AAAI,CAAE,MAAM;EACzDI,KAAK;EACLQ,eAAe,EAAE;IACfC,iBAAiB,EAAEzB,kBAAkB,CACnCM,WAA4C,CAC7C,CAACQ,KAAK;IACPY,KAAK,EAAEd;;CAEV,CAAC,CAAC","ignoreList":[]}
|