@kairos-es/core 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 (89) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +33 -0
  3. package/dist/cjs/AppendCondition.js +26 -0
  4. package/dist/cjs/AppendCondition.js.map +1 -0
  5. package/dist/cjs/ContractStore.js +80 -0
  6. package/dist/cjs/ContractStore.js.map +1 -0
  7. package/dist/cjs/DcbEventStore.js +10 -0
  8. package/dist/cjs/DcbEventStore.js.map +1 -0
  9. package/dist/cjs/Event.js +89 -0
  10. package/dist/cjs/Event.js.map +1 -0
  11. package/dist/cjs/Projection.js +73 -0
  12. package/dist/cjs/Projection.js.map +1 -0
  13. package/dist/cjs/Query.js +221 -0
  14. package/dist/cjs/Query.js.map +1 -0
  15. package/dist/cjs/SubscribeMachine.js +235 -0
  16. package/dist/cjs/SubscribeMachine.js.map +1 -0
  17. package/dist/cjs/errors.js +22 -0
  18. package/dist/cjs/errors.js.map +1 -0
  19. package/dist/cjs/inMemory.js +191 -0
  20. package/dist/cjs/inMemory.js.map +1 -0
  21. package/dist/cjs/index.js +124 -0
  22. package/dist/cjs/index.js.map +1 -0
  23. package/dist/cjs/retryOnConflict.js +95 -0
  24. package/dist/cjs/retryOnConflict.js.map +1 -0
  25. package/dist/cjs/testing.js +54 -0
  26. package/dist/cjs/testing.js.map +1 -0
  27. package/dist/dts/AppendCondition.d.ts +23 -0
  28. package/dist/dts/AppendCondition.d.ts.map +1 -0
  29. package/dist/dts/ContractStore.d.ts +56 -0
  30. package/dist/dts/ContractStore.d.ts.map +1 -0
  31. package/dist/dts/DcbEventStore.d.ts +64 -0
  32. package/dist/dts/DcbEventStore.d.ts.map +1 -0
  33. package/dist/dts/Event.d.ts +104 -0
  34. package/dist/dts/Event.d.ts.map +1 -0
  35. package/dist/dts/Projection.d.ts +108 -0
  36. package/dist/dts/Projection.d.ts.map +1 -0
  37. package/dist/dts/Query.d.ts +145 -0
  38. package/dist/dts/Query.d.ts.map +1 -0
  39. package/dist/dts/SubscribeMachine.d.ts +179 -0
  40. package/dist/dts/SubscribeMachine.d.ts.map +1 -0
  41. package/dist/dts/errors.d.ts +17 -0
  42. package/dist/dts/errors.d.ts.map +1 -0
  43. package/dist/dts/inMemory.d.ts +76 -0
  44. package/dist/dts/inMemory.d.ts.map +1 -0
  45. package/dist/dts/index.d.ts +35 -0
  46. package/dist/dts/index.d.ts.map +1 -0
  47. package/dist/dts/retryOnConflict.d.ts +70 -0
  48. package/dist/dts/retryOnConflict.d.ts.map +1 -0
  49. package/dist/dts/testing.d.ts +39 -0
  50. package/dist/dts/testing.d.ts.map +1 -0
  51. package/dist/esm/AppendCondition.js +19 -0
  52. package/dist/esm/AppendCondition.js.map +1 -0
  53. package/dist/esm/ContractStore.js +73 -0
  54. package/dist/esm/ContractStore.js.map +1 -0
  55. package/dist/esm/DcbEventStore.js +3 -0
  56. package/dist/esm/DcbEventStore.js.map +1 -0
  57. package/dist/esm/Event.js +81 -0
  58. package/dist/esm/Event.js.map +1 -0
  59. package/dist/esm/Projection.js +66 -0
  60. package/dist/esm/Projection.js.map +1 -0
  61. package/dist/esm/Query.js +209 -0
  62. package/dist/esm/Query.js.map +1 -0
  63. package/dist/esm/SubscribeMachine.js +227 -0
  64. package/dist/esm/SubscribeMachine.js.map +1 -0
  65. package/dist/esm/errors.js +14 -0
  66. package/dist/esm/errors.js.map +1 -0
  67. package/dist/esm/inMemory.js +184 -0
  68. package/dist/esm/inMemory.js.map +1 -0
  69. package/dist/esm/index.js +38 -0
  70. package/dist/esm/index.js.map +1 -0
  71. package/dist/esm/package.json +4 -0
  72. package/dist/esm/retryOnConflict.js +87 -0
  73. package/dist/esm/retryOnConflict.js.map +1 -0
  74. package/dist/esm/testing.js +46 -0
  75. package/dist/esm/testing.js.map +1 -0
  76. package/package.json +39 -0
  77. package/src/AppendCondition.ts +18 -0
  78. package/src/ContractStore.ts +148 -0
  79. package/src/DcbEventStore.ts +77 -0
  80. package/src/Event.ts +135 -0
  81. package/src/Projection.ts +168 -0
  82. package/src/Query.ts +237 -0
  83. package/src/SubscribeMachine.ts +316 -0
  84. package/src/errors.ts +19 -0
  85. package/src/inMemory.ts +356 -0
  86. package/src/index.ts +37 -0
  87. package/src/retryOnConflict.ts +100 -0
  88. package/src/testing.ts +47 -0
  89. package/testing/package.json +6 -0
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@kairos-es/core",
3
+ "version": "0.0.0",
4
+ "description": "DCB-first event-sourcing contract and in-memory store for Effect v3",
5
+ "license": "BSD-3-Clause",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/neverbland/kairos-es.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "sideEffects": [],
12
+ "homepage": "https://github.com/neverbland/kairos-es",
13
+ "peerDependencies": {
14
+ "effect": "^3.22.0"
15
+ },
16
+ "main": "./dist/cjs/index.js",
17
+ "module": "./dist/esm/index.js",
18
+ "types": "./dist/dts/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/dts/index.d.ts",
22
+ "import": "./dist/esm/index.js",
23
+ "default": "./dist/cjs/index.js"
24
+ },
25
+ "./testing": {
26
+ "types": "./dist/dts/testing.d.ts",
27
+ "import": "./dist/esm/testing.js",
28
+ "default": "./dist/cjs/testing.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "typesVersions": {
33
+ "*": {
34
+ "testing": [
35
+ "./dist/dts/testing.d.ts"
36
+ ]
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The optimistic-concurrency guard for `append`. The append fails if any event
3
+ * matching `failIfEventsMatch` exists strictly after `after`. This is the whole
4
+ * DCB consistency mechanism: "fail if anything I based my decision on has
5
+ * changed since I read it."
6
+ *
7
+ * `after` is exact-optional (absent, never `undefined`) per
8
+ * `exactOptionalPropertyTypes`. Absent means "scan the whole log".
9
+ */
10
+ import { Schema } from 'effect'
11
+ import { Position } from './Event'
12
+ import { Query } from './Query'
13
+
14
+ export const AppendCondition = Schema.Struct({
15
+ failIfEventsMatch: Query,
16
+ after: Schema.optionalWith(Position, { exact: true }),
17
+ })
18
+ export type AppendCondition = typeof AppendCondition.Type
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `makeContractStore` — the shared contract-enforcement POLICY every
3
+ * `DcbEventStore` engine wears, factored out of the engines so the policy is
4
+ * DEFINITIONALLY identical across backends rather than identical by inspection.
5
+ *
6
+ * Each engine implements only the MECHANICS (`RawDcbEventStore`): the actual
7
+ * read/append/subscribe, free to surface infrastructure faults on the error
8
+ * channel (`never` for the pure in-memory engine; `SqlError` for either SQL one).
9
+ * The decorator wraps those mechanics with the policy the contract demands — the
10
+ * policy the first two engines hand-duplicated, with the Postgres one even
11
+ * needing a bespoke `catchAll`/`instanceof`:
12
+ *
13
+ * - `assertServableQuery` at every verb's entry (read, the append condition's
14
+ * guard query, subscribe) — a non-servable query is a DEFECT in every engine;
15
+ * - `ReadLimit` decode on `read`'s `limit` — a negative/non-integer limit is a
16
+ * DEFECT (so `read` keeps `E = never`), validated through the branded schema
17
+ * even at an untyped boundary that reaches the store as `unknown`;
18
+ * - `orDie` on read/subscribe infrastructure errors — `read`/`subscribe` are
19
+ * `E = never` on the contract, so a raw fault is a defect, not a channel value;
20
+ * - the `AppendConditionFailed`-else-die classification on append, matched by
21
+ * `_tag` and never by class identity — ONLY the contract conflict rides the
22
+ * error channel; every other fault is a defect.
23
+ *
24
+ * The throws are deferred into the returned Effect/Stream (via `Effect.suspend`
25
+ * / `Effect.sync`), so a validation failure surfaces as a DEFECT WHEN THE EFFECT
26
+ * RUNS — never as a synchronous throw at the call site. This gives the contract
27
+ * suite a single trusted choke point and makes a further backend impossible to get
28
+ * subtly wrong: it supplies mechanics, the policy is inherited whole. That is how
29
+ * the third engine, `@kairos-es/store-sqlite`, acquired the policy — it writes not
30
+ * a line of it.
31
+ */
32
+ import type { Array as Arr, Scope } from 'effect'
33
+ import { Effect, Predicate, Schema, Stream } from 'effect'
34
+ import type { AppendCondition } from './AppendCondition'
35
+ import type { DcbEventStore, ReadOptions, ReadResponse } from './DcbEventStore'
36
+ import type { DcbEvent, Position, SequencedEvent } from './Event'
37
+ import { ReadLimit } from './Event'
38
+ import type { AppendConditionFailed } from './errors'
39
+ import { assertServableQuery, type Query } from './Query'
40
+
41
+ /** Decode an unknown limit through `ReadLimit`, dying on a defect-worthy value. */
42
+ const decodeReadLimit = Schema.decodeUnknownSync(ReadLimit)
43
+
44
+ /**
45
+ * The MECHANICS half of a store: the same three verbs as the `DcbEventStore`
46
+ * contract, but free to surface infrastructure faults (`E`) on the error
47
+ * channel. `E` is `never` for a pure engine (in-memory) and e.g. `SqlError` for
48
+ * a database engine. `append` may additionally fail with the contract's
49
+ * `AppendConditionFailed`. `makeContractStore` turns this into the contract.
50
+ */
51
+ export interface RawDcbEventStore<E> {
52
+ readonly read: (
53
+ query: Query,
54
+ options?: ReadOptions,
55
+ ) => Effect.Effect<ReadResponse, E>
56
+ readonly append: (
57
+ events: Arr.NonEmptyReadonlyArray<DcbEvent>,
58
+ condition?: AppendCondition,
59
+ ) => Effect.Effect<Position, AppendConditionFailed | E>
60
+ readonly subscribe: (
61
+ query: Query,
62
+ after?: Position,
63
+ ) => Stream.Stream<SequencedEvent, E, Scope.Scope>
64
+ }
65
+
66
+ /**
67
+ * Wrap a raw engine's mechanics with the shared contract-enforcement policy,
68
+ * producing a full `DcbEventStore` service value.
69
+ */
70
+ export const makeContractStore = <E>(
71
+ raw: RawDcbEventStore<E>,
72
+ ): DcbEventStore['Type'] => ({
73
+ read: (query, options) =>
74
+ Effect.suspend(() => {
75
+ // Defects (surface when the Effect runs), keeping `read` at `E = never`.
76
+ assertServableQuery(query)
77
+ if (options?.limit !== undefined) {
78
+ decodeReadLimit(options.limit)
79
+ }
80
+ // Infrastructure faults are defects, not contract values.
81
+ return Effect.orDie(raw.read(query, options))
82
+ }),
83
+
84
+ append: (events, condition) =>
85
+ Effect.suspend(() => {
86
+ // An EMPTY batch is a plain-data caller bypassing the
87
+ // `NonEmptyReadonlyArray` type: there is nothing to append. The engines
88
+ // diverge on it at an unchecked boundary — Postgres inserts zero rows and
89
+ // its conditional function returns zero rows, indistinguishable from a
90
+ // conflict, so it surfaces a PHANTOM `AppendConditionFailed`, whereas
91
+ // in-memory silently returns the previous head (`start - 1n`). Make it a
92
+ // DEFECT here, at the one choke point, so the policy is definitionally
93
+ // identical across engines and zero-rows stays an unambiguous conflict
94
+ // signal (a throw inside the suspend becomes a defect, like
95
+ // `assertServableQuery`).
96
+ if (events.length === 0) {
97
+ throw new Error(
98
+ 'makeContractStore.append: the events batch is empty; append requires ' +
99
+ 'at least one event (a NonEmptyReadonlyArray). An empty batch is a ' +
100
+ 'plain-data caller bypassing the type — a defect, not a conflict.',
101
+ )
102
+ }
103
+ // A non-servable guard query is a defect in every engine.
104
+ if (condition !== undefined) {
105
+ assertServableQuery(condition.failIfEventsMatch)
106
+ }
107
+ // Only `AppendConditionFailed` belongs on the contract error channel;
108
+ // every other fault (a `SqlError`, a `55P03` lock-timeout abort, …) is
109
+ // infrastructure and surfaces OFF the shared contract as a defect.
110
+ //
111
+ // Classified by `_tag`, NEVER by `instanceof`, and this is the site where
112
+ // that matters most: it is the ONE gate every backend's conflicts pass
113
+ // through, so a class-identity check here is a single point of silent
114
+ // failure for the whole write side. `@kairos-es/core` ships dual ESM/CJS,
115
+ // so a consumer's graph can hold two distinct `AppendConditionFailed`
116
+ // classes for one error — an engine wired against one copy of core and
117
+ // wrapped by a `makeContractStore` from another. Under `instanceof` that
118
+ // engine's GENUINE conflicts fail the class check and are `Effect.die`d
119
+ // as infrastructure, which does not merely mislabel them: it takes the
120
+ // conflict OFF the error channel, where `retryOnConflict` (which already
121
+ // matches on the tag, and correctly) can never see it to retry. The tag
122
+ // is the error's identity across realms; `retryOnConflict.ts` makes the
123
+ // argument at length and this is its other end.
124
+ //
125
+ // `Predicate.isTagged` is total over `unknown` — an object check, then
126
+ // `_tag` equality — so a `null`, a string or a raw `SqlError` on the
127
+ // engine's channel simply answers false and dies as it should. It also
128
+ // narrows the raw `AppendConditionFailed | E` to the contract's single
129
+ // error, which is what keeps `append` at `E = AppendConditionFailed`.
130
+ return Effect.catchAll(raw.append(events, condition), (error) =>
131
+ Predicate.isTagged('AppendConditionFailed')(error)
132
+ ? Effect.fail(error)
133
+ : Effect.die(error),
134
+ )
135
+ }),
136
+
137
+ subscribe: (query, after) =>
138
+ // `Effect.sync` runs when the stream is executed; a throw from
139
+ // `assertServableQuery` becomes a stream defect (identical rule to
140
+ // read/append). Infrastructure faults on the live tail die (subscribe is
141
+ // `E = never`).
142
+ Stream.unwrap(
143
+ Effect.sync(() => {
144
+ assertServableQuery(query)
145
+ return Stream.orDie(raw.subscribe(query, after))
146
+ }),
147
+ ),
148
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The `DcbEventStore` contract: a `Context.Tag` with no bundled implementation.
3
+ * Consumers supply a `Layer` (the in-memory one lives in this package; each SQL
4
+ * engine is a package of its own — `@kairos-es/store-postgres` and
5
+ * `@kairos-es/store-sqlite`).
6
+ *
7
+ * Head semantics (ported from `popo.py:62-65`):
8
+ * - `read` with no `limit` => `head` = the global last position (ignoring the
9
+ * query filter) if the store is non-empty, else `ORIGIN` (0n).
10
+ * - `read` with a `limit` => `head` = the last *returned* event's position
11
+ * (post-filter, post-limit), or `ORIGIN` if none returned.
12
+ *
13
+ * `append` returns the last new position (read-your-writes). `subscribe` is
14
+ * catch-up then live and requires a `Scope` so the live subscription is cleaned
15
+ * up on scope close.
16
+ */
17
+ import type { Array as Arr, Effect, Scope, Stream } from 'effect'
18
+ import { Context } from 'effect'
19
+ import type { AppendCondition } from './AppendCondition'
20
+ import type { DcbEvent, Position, ReadLimit, SequencedEvent } from './Event'
21
+ import type { AppendConditionFailed } from './errors'
22
+ import type { Query } from './Query'
23
+
24
+ /** Options for a read: a lower position bound and/or a maximum count. */
25
+ export interface ReadOptions {
26
+ /** Only events strictly after this position (exclusive). Absent = from the start. */
27
+ readonly after?: Position
28
+ /**
29
+ * Cap the number of events returned (a branded non-negative integer). `0`
30
+ * returns none; absent = all matching events. A negative/non-integer value is
31
+ * a defect at the store boundary — see `ReadLimit`.
32
+ */
33
+ readonly limit?: ReadLimit
34
+ }
35
+
36
+ /**
37
+ * The result of a read: a stream of matching events (ascending by position)
38
+ * plus the consistency `head`.
39
+ */
40
+ export interface ReadResponse {
41
+ readonly events: Stream.Stream<SequencedEvent>
42
+ readonly head: Position
43
+ }
44
+
45
+ export class DcbEventStore extends Context.Tag('@kairos-es/core/DcbEventStore')<
46
+ DcbEventStore,
47
+ {
48
+ /**
49
+ * Read events matching `query` (empty query = all), optionally bounded by
50
+ * `after`/`limit`. See head semantics in the module docs.
51
+ */
52
+ readonly read: (
53
+ query: Query,
54
+ options?: ReadOptions,
55
+ ) => Effect.Effect<ReadResponse>
56
+
57
+ /**
58
+ * Append one or more events atomically. If `condition` is given, the append
59
+ * fails with `AppendConditionFailed` when its query matches any event after
60
+ * `condition.after`. Returns the last new position.
61
+ */
62
+ readonly append: (
63
+ events: Arr.NonEmptyReadonlyArray<DcbEvent>,
64
+ condition?: AppendCondition,
65
+ ) => Effect.Effect<Position, AppendConditionFailed>
66
+
67
+ /**
68
+ * Subscribe to matching events: first the backlog after `after` (catch-up),
69
+ * then live events as they are appended. Scoped: the live subscription is
70
+ * released when the scope closes.
71
+ */
72
+ readonly subscribe: (
73
+ query: Query,
74
+ after?: Position,
75
+ ) => Stream.Stream<SequencedEvent, never, Scope.Scope>
76
+ }
77
+ >() {}
package/src/Event.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Core domain types for the DCB event log: branded scalars and the wire
3
+ * `DcbEvent`/`SequencedEvent` shapes.
4
+ *
5
+ * `data` is opaque bytes at the store layer; the codec (`@kairos-es/codec`) turns typed
6
+ * payloads into these bytes. Ordering is by `position` alone; `occurredAt` is
7
+ * producer-supplied domain time and is informational only.
8
+ */
9
+ import { Schema } from 'effect'
10
+
11
+ /**
12
+ * A position in the single global event log: a branded, non-negative `bigint`.
13
+ *
14
+ * The log is 64-bit (`bigserial` in Postgres) and exceeds JS `number` safety at
15
+ * 2^53, so positions are a `bigint`. They are compared, never arithmetic'd.
16
+ * `Schema.BigInt` encodes to a decimal string (JSON-safe) and decodes to
17
+ * `bigint`. `0n` is the "before the first event" sentinel used by `after` and by
18
+ * an empty store's head.
19
+ */
20
+ export const Position = Schema.BigInt.pipe(
21
+ Schema.nonNegativeBigInt(),
22
+ Schema.brand('Position'),
23
+ )
24
+ export type Position = typeof Position.Type
25
+
26
+ /** The sentinel position meaning "before the first event". */
27
+ export const ORIGIN: Position = Position.make(0n)
28
+
29
+ /**
30
+ * A business identifier attached to an event, following the `key:value`
31
+ * convention (e.g. `student:s1`). Tags replace the per-stream id of classic
32
+ * event sourcing; a query matches an event when the event carries all of an
33
+ * item's tags (superset test).
34
+ */
35
+ export const Tag = Schema.String.pipe(
36
+ Schema.pattern(/^[^:]+:.+/, { identifier: 'Tag' }),
37
+ Schema.brand('Tag'),
38
+ )
39
+ export type Tag = typeof Tag.Type
40
+
41
+ /**
42
+ * A reusable tag identity: a `key` plus a `.make(value)` that composes the
43
+ * branded `Tag` `` `${key}:${value}` `` through core's `Tag` schema. Define an
44
+ * identity once (`const CourseId = tagIdentity('course')`) and reuse
45
+ * `CourseId.make(id)` everywhere that entity is tagged. The `key` is exposed for
46
+ * key-aware tooling (e.g. a tag-vocabulary index, a future retag pass).
47
+ *
48
+ * The same constructor authors an event's tags (in a codec `tagsFor` mapper,
49
+ * ADR-0006) and a query's tags, so the `key:value` string a mapper writes and
50
+ * the string a query matches are produced by one function and cannot drift. It
51
+ * lives here beside `Tag` — pure (no clock, no RNG, no effect) — so it is
52
+ * reachable from every tag-authoring site, including core's own query
53
+ * constructors.
54
+ *
55
+ * `key` and `value` are each a single colon-free atom: the `key:value` grammar
56
+ * splits on the first colon, so both are validated to be non-empty and contain
57
+ * no `:` — `key` once at construction, `value` on each `make`. This keeps
58
+ * composition unambiguous and catches the double-prefix footgun loudly:
59
+ * `CourseId.make('course:123')` throws rather than silently yielding
60
+ * `course:course:123`. Raw `Tag.make` remains the escape hatch for a value that
61
+ * must itself contain a colon.
62
+ */
63
+ export interface TagIdentity {
64
+ readonly key: string
65
+ readonly make: (value: string) => Tag
66
+ }
67
+
68
+ export const tagIdentity = (key: string): TagIdentity => {
69
+ if (key.length === 0 || key.includes(':')) {
70
+ throw new Error(
71
+ `tagIdentity: key must be non-empty and contain no ':' (got ${JSON.stringify(key)})`,
72
+ )
73
+ }
74
+ return {
75
+ key,
76
+ make: (value) => {
77
+ if (value.length === 0 || value.includes(':')) {
78
+ throw new Error(
79
+ `tagIdentity(${JSON.stringify(key)}).make: value must be non-empty and contain no ':' (got ${JSON.stringify(value)})`,
80
+ )
81
+ }
82
+ return Tag.make(`${key}:${value}`)
83
+ },
84
+ }
85
+ }
86
+
87
+ /**
88
+ * A read cap: a branded non-negative integer bounding how many events a `read`
89
+ * returns. `0` returns none; an absent limit returns all matching events.
90
+ *
91
+ * The bound lives in the type, not merely in prose: a negative or non-integer
92
+ * limit is a programming error, so it is a DEFECT at the store boundary — the
93
+ * store validates through this schema and dies rather than surfacing it on the
94
+ * error channel (`read` keeps `E = never`). Constructed at call sites with
95
+ * `ReadLimit.make(n)`.
96
+ */
97
+ export const ReadLimit = Schema.Int.pipe(
98
+ Schema.greaterThanOrEqualTo(0),
99
+ Schema.brand('ReadLimit'),
100
+ )
101
+ export type ReadLimit = typeof ReadLimit.Type
102
+
103
+ /**
104
+ * An event type name, a branded non-empty string. Versioning is by convention
105
+ * in the name (e.g. `course-registered.v2`); only non-emptiness is enforced.
106
+ */
107
+ export const EventType = Schema.String.pipe(
108
+ Schema.nonEmptyString(),
109
+ Schema.brand('EventType'),
110
+ )
111
+ export type EventType = typeof EventType.Type
112
+
113
+ /**
114
+ * An event as stored: an opaque payload plus its type, tags, a
115
+ * producer-supplied `uuid`, and producer-supplied `occurredAt` domain time.
116
+ *
117
+ * `data` is `Uint8Array` (opaque bytes); the store does not interpret it.
118
+ * `uuid` is producer-supplied to keep `core` deterministic (no RNG in core).
119
+ * `occurredAt` is informational only; ordering is by position alone.
120
+ */
121
+ export const DcbEvent = Schema.Struct({
122
+ type: EventType,
123
+ data: Schema.Uint8ArrayFromSelf,
124
+ tags: Schema.Array(Tag),
125
+ uuid: Schema.UUID,
126
+ occurredAt: Schema.DateTimeUtc,
127
+ })
128
+ export type DcbEvent = typeof DcbEvent.Type
129
+
130
+ /** A persisted event: the event together with the position it was written at. */
131
+ export const SequencedEvent = Schema.Struct({
132
+ event: DcbEvent,
133
+ position: Position,
134
+ })
135
+ export type SequencedEvent = typeof SequencedEvent.Type
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Pure projections and the composition primitive they fold through.
3
+ *
4
+ * A `Projection<S>` = `{ initialState, query, evolve }`: a query selecting the
5
+ * events it cares about and a pure `evolve` folding an already-*decoded* event
6
+ * into state. Decoding (wire bytes -> typed payload via `Schema`) is not core's
7
+ * job — it needs `Schema` and a `Serializer`, which core must not depend on —
8
+ * so `evolve` folds a `DecodedEvent` (the sequenced envelope plus an opaque
9
+ * `payload`). `composeProjections` merges several projections into one whose
10
+ * state is a named record.
11
+ *
12
+ * There is no builder and no decision-model orchestration here: both are the
13
+ * decode-aware `@kairos-es/codec` package's job (its `projection(...)` builder
14
+ * produces schema-backed projections, and its `modelAtHead` performs the effectful
15
+ * decode then folds through `composeProjections`, which `buildDecisionModel` builds
16
+ * on). Core keeps only the pure fold primitives.
17
+ *
18
+ * `Projection<S>` is *invariant* in `S`, so a heterogeneous record of
19
+ * projections (different state per key) cannot be bounded by
20
+ * `Projection<unknown>`. `AnyProjection` is the single authoritative,
21
+ * state-erased bound that owns that unsoundness in one place (built only via
22
+ * `AnyProjection.of`), so no downstream package writes `Projection<any>`; the
23
+ * precise per-key state is recovered by `StateOf` / `CompositeState`.
24
+ *
25
+ * The fold itself is pure and Effect-free.
26
+ */
27
+ import type { SequencedEvent } from './Event'
28
+ import type { Query } from './Query'
29
+ import { matchesSequenced } from './Query'
30
+
31
+ // The wildcard query, built directly (not via the public `query` surface).
32
+ // Decision-model composition assembles `Query` values from plain data because
33
+ // it may legitimately produce shapes the public fast-path surface withholds:
34
+ // composing a type-only projection with tagged ones yields an OR that mixes a
35
+ // tagless item with tagged items. That is the one restriction edge left
36
+ // un-enforced by construction, deliberately, in this internal layer.
37
+ const WILDCARD: Query = { items: [] }
38
+
39
+ /**
40
+ * An already-decoded event handed to a projection's fold: the sequenced wire
41
+ * envelope (position, type, tags, timings) plus the decoded `payload`.
42
+ *
43
+ * Core does not decode — it only folds events another layer has already decoded
44
+ * — so `payload` is `unknown` at this layer. `@kairos-es/codec`'s builder emits
45
+ * a projection whose `evolve` narrows `payload` per matched event type.
46
+ */
47
+ export interface DecodedEvent {
48
+ readonly sequenced: SequencedEvent
49
+ readonly payload: unknown
50
+ }
51
+
52
+ /**
53
+ * A pure projection: the events it reads (`query`), its starting state, and a
54
+ * fold step.
55
+ *
56
+ * `evolve` contract: a projection is only ever invoked with an event that
57
+ * matches its `query`. `composeProjections` enforces this — it gates every
58
+ * sub-projection on its own query before dispatching — so an `evolve` may
59
+ * assume the event matched and need not re-check `matchesSequenced` itself. (It
60
+ * still dispatches by the event's type, which selects the fold; that is fold
61
+ * *selection*, not query gating.) `payload` has already been decoded by the
62
+ * codec layer.
63
+ */
64
+ export interface Projection<S> {
65
+ readonly initialState: S
66
+ readonly query: Query
67
+ readonly evolve: (state: S, event: DecodedEvent) => S
68
+ }
69
+
70
+ /**
71
+ * A state-erased view of a `Projection<S>`: the single authoritative bound for
72
+ * a *heterogeneous* record of projections. `Projection<S>` is invariant in `S`
73
+ * (S is both a parameter and the result of `evolve`), so `Record<string,
74
+ * Projection<unknown>>` rejects a record whose keys fold different state types.
75
+ * Rather than leak `Projection<any>` into every package that holds such a
76
+ * record, this type hides the invariant `evolve` behind an erased
77
+ * `(unknown) => unknown` surface and carries `S` only through the covariant
78
+ * `initialState`, so `Record<string, AnyProjection>` is sound with no `any`
79
+ * anywhere. Build one only via `AnyProjection.of`; recover the precise per-key
80
+ * `S` with `StateOf` / `CompositeState`. The `evolve` contract is the same as
81
+ * `Projection`'s: it is only ever called with an event matching `query`
82
+ * (`composeProjections` gates first), so an implementation may assume the match.
83
+ */
84
+ export interface AnyProjection<out S = unknown> {
85
+ readonly initialState: S
86
+ readonly query: Query
87
+ readonly evolve: (state: unknown, event: DecodedEvent) => unknown
88
+ }
89
+
90
+ export const AnyProjection = {
91
+ /**
92
+ * View a typed `Projection<S>` as an opaque `AnyProjection<S>`. This is the
93
+ * one place the invariant `evolve` is widened to the erased surface, so the
94
+ * unsoundness of a heterogeneous projection record is discharged here and
95
+ * nowhere else. `composeProjections` calls `evolve` with each projection's
96
+ * own state, so the widening is sound at runtime.
97
+ */
98
+ of: <S>(projection: Projection<S>): AnyProjection<S> =>
99
+ projection as unknown as AnyProjection<S>,
100
+ } as const
101
+
102
+ /** Recover the state type a projection folds to. */
103
+ export type StateOf<P> = P extends AnyProjection<infer S> ? S : never
104
+
105
+ /**
106
+ * The record of named sub-states a composed projection (or decision model)
107
+ * folds to: each key's precise state type, recovered from its `AnyProjection`.
108
+ */
109
+ export type CompositeState<T extends Record<string, AnyProjection>> = {
110
+ readonly [K in keyof T]: StateOf<T[K]>
111
+ }
112
+
113
+ /** Merge two queries by OR-ing their items; either wildcard yields a wildcard. */
114
+ const mergeQuery = (left: Query, right: Query): Query => {
115
+ if (left.items.length === 0 || right.items.length === 0) {
116
+ return WILDCARD
117
+ }
118
+ return { items: [...left.items, ...right.items] }
119
+ }
120
+
121
+ /**
122
+ * Compose named projections into one whose state is a record of sub-states and
123
+ * whose query is the OR-merge of all sub-queries. Each sub-projection only
124
+ * folds events matching its own query — the guarantee that `Projection.evolve`'s
125
+ * contract lets sub-projections rely on (so they need not re-gate themselves).
126
+ *
127
+ * The fold always writes each *matching* sub-result into a fresh record and
128
+ * returns it (no `changed` short-circuit, no `evolved !== prev` reference
129
+ * check): an in-place-mutating fold that returns the same reference would
130
+ * otherwise be silently dropped. At decision-model scale the extra copy is nil.
131
+ */
132
+ export const composeProjections = <T extends Record<string, AnyProjection>>(
133
+ projections: T,
134
+ ): Projection<CompositeState<T>> => {
135
+ const keys = Object.keys(projections) as ReadonlyArray<keyof T & string>
136
+
137
+ const initialState = Object.fromEntries(
138
+ keys.map((key) => [key, projections[key].initialState]),
139
+ ) as CompositeState<T>
140
+
141
+ // Seeding the reduce with the wildcard would swallow everything (mergeQuery
142
+ // treats an empty-items query as a wildcard), so fold the sub-queries
143
+ // directly with no seed.
144
+ const mergedQuery =
145
+ keys.length === 0
146
+ ? WILDCARD
147
+ : keys
148
+ .map((key) => projections[key].query)
149
+ .reduce((acc, q) => mergeQuery(acc, q))
150
+
151
+ return {
152
+ initialState,
153
+ query: mergedQuery,
154
+ evolve: (state, decoded) => {
155
+ const next = { ...state } as { [K in keyof T]: StateOf<T[K]> }
156
+ for (const key of keys) {
157
+ const projection = projections[key]
158
+ if (!matchesSequenced(decoded.sequenced, projection.query)) {
159
+ continue
160
+ }
161
+ next[key] = projection.evolve(state[key], decoded) as StateOf<
162
+ T[typeof key]
163
+ >
164
+ }
165
+ return next
166
+ },
167
+ }
168
+ }