@kairos-es/read 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 (82) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +538 -0
  3. package/dist/cjs/EventLogDurability.js +184 -0
  4. package/dist/cjs/EventLogDurability.js.map +1 -0
  5. package/dist/cjs/ProjectionRunner.js +478 -0
  6. package/dist/cjs/ProjectionRunner.js.map +1 -0
  7. package/dist/cjs/ProjectionStore.js +233 -0
  8. package/dist/cjs/ProjectionStore.js.map +1 -0
  9. package/dist/cjs/foldIntoRef.js +36 -0
  10. package/dist/cjs/foldIntoRef.js.map +1 -0
  11. package/dist/cjs/inMemoryProjectionStore.js +138 -0
  12. package/dist/cjs/inMemoryProjectionStore.js.map +1 -0
  13. package/dist/cjs/index.js +128 -0
  14. package/dist/cjs/index.js.map +1 -0
  15. package/dist/cjs/projectionWiringFault.js +532 -0
  16. package/dist/cjs/projectionWiringFault.js.map +1 -0
  17. package/dist/cjs/runProjection.js +117 -0
  18. package/dist/cjs/runProjection.js.map +1 -0
  19. package/dist/cjs/runProjections.js +144 -0
  20. package/dist/cjs/runProjections.js.map +1 -0
  21. package/dist/cjs/superviseOnProgress.js +580 -0
  22. package/dist/cjs/superviseOnProgress.js.map +1 -0
  23. package/dist/cjs/testing.js +143 -0
  24. package/dist/cjs/testing.js.map +1 -0
  25. package/dist/dts/EventLogDurability.d.ts +182 -0
  26. package/dist/dts/EventLogDurability.d.ts.map +1 -0
  27. package/dist/dts/ProjectionRunner.d.ts +557 -0
  28. package/dist/dts/ProjectionRunner.d.ts.map +1 -0
  29. package/dist/dts/ProjectionStore.d.ts +475 -0
  30. package/dist/dts/ProjectionStore.d.ts.map +1 -0
  31. package/dist/dts/foldIntoRef.d.ts +39 -0
  32. package/dist/dts/foldIntoRef.d.ts.map +1 -0
  33. package/dist/dts/inMemoryProjectionStore.d.ts +11 -0
  34. package/dist/dts/inMemoryProjectionStore.d.ts.map +1 -0
  35. package/dist/dts/index.d.ts +185 -0
  36. package/dist/dts/index.d.ts.map +1 -0
  37. package/dist/dts/projectionWiringFault.d.ts +260 -0
  38. package/dist/dts/projectionWiringFault.d.ts.map +1 -0
  39. package/dist/dts/runProjection.d.ts +185 -0
  40. package/dist/dts/runProjection.d.ts.map +1 -0
  41. package/dist/dts/runProjections.d.ts +480 -0
  42. package/dist/dts/runProjections.d.ts.map +1 -0
  43. package/dist/dts/superviseOnProgress.d.ts +587 -0
  44. package/dist/dts/superviseOnProgress.d.ts.map +1 -0
  45. package/dist/dts/testing.d.ts +207 -0
  46. package/dist/dts/testing.d.ts.map +1 -0
  47. package/dist/esm/EventLogDurability.js +175 -0
  48. package/dist/esm/EventLogDurability.js.map +1 -0
  49. package/dist/esm/ProjectionRunner.js +468 -0
  50. package/dist/esm/ProjectionRunner.js.map +1 -0
  51. package/dist/esm/ProjectionStore.js +223 -0
  52. package/dist/esm/ProjectionStore.js.map +1 -0
  53. package/dist/esm/foldIntoRef.js +29 -0
  54. package/dist/esm/foldIntoRef.js.map +1 -0
  55. package/dist/esm/inMemoryProjectionStore.js +131 -0
  56. package/dist/esm/inMemoryProjectionStore.js.map +1 -0
  57. package/dist/esm/index.js +185 -0
  58. package/dist/esm/index.js.map +1 -0
  59. package/dist/esm/package.json +4 -0
  60. package/dist/esm/projectionWiringFault.js +524 -0
  61. package/dist/esm/projectionWiringFault.js.map +1 -0
  62. package/dist/esm/runProjection.js +109 -0
  63. package/dist/esm/runProjection.js.map +1 -0
  64. package/dist/esm/runProjections.js +137 -0
  65. package/dist/esm/runProjections.js.map +1 -0
  66. package/dist/esm/superviseOnProgress.js +571 -0
  67. package/dist/esm/superviseOnProgress.js.map +1 -0
  68. package/dist/esm/testing.js +133 -0
  69. package/dist/esm/testing.js.map +1 -0
  70. package/package.json +41 -0
  71. package/src/EventLogDurability.ts +201 -0
  72. package/src/ProjectionRunner.ts +923 -0
  73. package/src/ProjectionStore.ts +528 -0
  74. package/src/foldIntoRef.ts +63 -0
  75. package/src/inMemoryProjectionStore.ts +163 -0
  76. package/src/index.ts +218 -0
  77. package/src/projectionWiringFault.ts +694 -0
  78. package/src/runProjection.ts +270 -0
  79. package/src/runProjections.ts +623 -0
  80. package/src/superviseOnProgress.ts +897 -0
  81. package/src/testing.ts +290 -0
  82. package/testing/package.json +6 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `runProjection` — the read side's SINGULAR entry point: one read model, one view
3
+ * store, one supervised daemon forked into the caller's `Scope`; plus
4
+ * `projectionLayer`, the same call shaped as a `Layer` so an application's graph owns
5
+ * that daemon's lifetime.
6
+ *
7
+ * ## Two entry points, one substrate
8
+ *
9
+ * This module and `runProjections.ts` are the read side's TWO entry points, and they
10
+ * are deliberately symmetrical one arity apart: N = 1 here, N >= 1 there. Neither
11
+ * builds a pipeline of its own and neither carries a rule of its own. Both run the
12
+ * three phases in `ProjectionRunner.ts` — `resolveProjection`, `prepareProjection`
13
+ * (which is where the construction gate `projectionWiringFault.ts` is consulted, for
14
+ * both of them, before either forks anything) and `buildAndFork` — so what differs
15
+ * between them is the shape of their INPUT and nothing else.
16
+ *
17
+ * Neither calls the other, and that follows from those input shapes rather than being
18
+ * an omission: a `ReadModel` carries a BOUND `KeyedProjectionStore`, a
19
+ * `Materialisation` an UNBOUND `ProjectionStore` plus the one `ProjectionId` the call
20
+ * names, and `forKey` deliberately does not expose what it closed over — so there is
21
+ * no route back from the first shape to the second. `runProjections.ts` owns that
22
+ * argument in full; this is the pointer to it, not a second home for it.
23
+ *
24
+ * ## Why a module of its own rather than a section of the substrate
25
+ *
26
+ * Because a file declaring the `ProjectionRunner` handle, the two option sets, the
27
+ * three shared phases AND one of the two entry points reads as though that entry
28
+ * point were the privileged one. It is not: it is the N = 1 case, and a read model
29
+ * that grows a second materialisation moves to the sibling without changing anything
30
+ * about how it is run. Keeping the substrate under the name of the type it declares,
31
+ * and each entry point in a file named for the function it exports, is what makes
32
+ * that symmetry visible from the directory listing.
33
+ */
34
+ import type {
35
+ RequirementOf,
36
+ Serializer,
37
+ SliceProjection,
38
+ } from '@kairos-es/codec'
39
+ import type { DcbEventStore, DecodedEvent } from '@kairos-es/core'
40
+ import { type Array as Arr, Effect, Layer, type Scope } from 'effect'
41
+ import type { EventLogDurability } from './EventLogDurability'
42
+ import {
43
+ buildAndFork,
44
+ type ProjectionRunner,
45
+ type ProjectionRunnerOptions,
46
+ prepareProjection,
47
+ resolveProjection,
48
+ } from './ProjectionRunner'
49
+ import type { KeyedProjectionStore } from './ProjectionStore'
50
+
51
+ /**
52
+ * One read model: where it materialises (under which key), which slices drive it,
53
+ * and how a batch is applied.
54
+ *
55
+ * The slices are load-bearing twice over: their composed query is the
56
+ * subscription's query (so the runner reads exactly the events the folds want,
57
+ * with no separate query to keep in step), and their merged `type -> Schema` map
58
+ * is the decode lookup. There is no way to declare one without the other.
59
+ *
60
+ * The other half of R2 — the EVENT LOG's durability — is deliberately NOT a field
61
+ * here: it is declared once beside the store layer as the `EventLogDurability`
62
+ * service and read from context. `EventLogDurability.ts` argues why, and states
63
+ * the rule both halves feed.
64
+ *
65
+ * By R3, read models sharing one subscription share one cursor and therefore one
66
+ * `CheckpointKey`, one view store and one durability class — which is why a
67
+ * composed bundle is expressed as ONE `ReadModel` over several slices rather than
68
+ * several `ReadModel`s over one subscription.
69
+ *
70
+ * That bound is about sharing one SUBSCRIPTION, and the boundary is worth stating
71
+ * because the sentence above otherwise reads as one materialisation per read model.
72
+ * It says nothing about materialising ONE read model more than once: several
73
+ * materialisations of one read model deliberately take N subscriptions, N cursors
74
+ * and N checkpoints, exactly because R1 and R2 forbid them to share any of the
75
+ * three. Wire that with `runProjections`, which holds one `slices` value and one
76
+ * `ProjectionId` across N materialisations so their query and their decode cannot
77
+ * diverge, while each keeps its own store, its own `apply`, its own checkpoint and
78
+ * its own supervision hooks. What it does NOT hold is the folds, so agreement
79
+ * between the figures two materialisations arrive at stays empirical.
80
+ */
81
+ export interface ReadModel<
82
+ T extends Record<string, SliceProjection>,
83
+ E = never,
84
+ R = never,
85
+ > {
86
+ /**
87
+ * The view store this read model materialises into, ALREADY BOUND to the
88
+ * checkpoint it advances (`forKey(store, key)`).
89
+ *
90
+ * A VALUE on the read model rather than a context tag, because the view store
91
+ * is selected per MATERIALISATION: one deployment can run an in-memory admin view
92
+ * and a durable production view against the same event log, and a slice can
93
+ * graduate from the first to the second with its `slices`, its composed query,
94
+ * its `CheckpointKey` and this runner untouched. What necessarily changes is
95
+ * this field and `apply` below — the two that name the view store — and nothing
96
+ * else.
97
+ *
98
+ * Graduation is SEQUENTIAL, memory and then Postgres, and it is not the only
99
+ * shape the selection permits: the same slice record can feed both AT ONCE, which
100
+ * is what `runProjections` wires. This field stays SINGULAR either way — one
101
+ * materialisation, one store, one key, one runner — and N materialisations are N
102
+ * of these values over one `slices` value, never one value holding a list. A list
103
+ * would imply one atomic `commit` across two stores, which R1 says is unsound;
104
+ * the demonstration of the simultaneous shape is
105
+ * `@kairos-es/read-postgres`'s `test/courseRosterGraduation.test.ts`, and the
106
+ * worked production wiring is `examples/course-subscriptions`'s
107
+ * `src/courseRosterMaterialisations.ts`.
108
+ *
109
+ * KEYED rather than a `(key, store)` pair, for the reason `KeyedProjectionStore`
110
+ * in `ProjectionStore.ts` argues — it owns why binding a key to a store turns
111
+ * "one materialisation, one key, one runner" from convention into structure. What
112
+ * is local to this field is that the key stays readable as `store.key`, which is
113
+ * where the runner's log annotations and failure payloads get it.
114
+ */
115
+ readonly store: KeyedProjectionStore
116
+
117
+ /** The slices whose composed query drives the subscription and whose schemas decode. */
118
+ readonly slices: T
119
+
120
+ /**
121
+ * The per-batch view writes, handed to `ProjectionStore.commit` so they land
122
+ * inside the same transaction as the checkpoint advance.
123
+ *
124
+ * The batch is non-empty by construction, so `apply` needs no empty case. If
125
+ * the store is NON-transactional this must be a SINGLE atomic effect (fold the
126
+ * batch purely, then one write — see `foldIntoRef`): the port cannot roll back
127
+ * a multi-write `apply` that fails part-way, and that requirement, not the
128
+ * store, is where the obligation sits.
129
+ */
130
+ readonly apply: (
131
+ batch: Arr.NonEmptyReadonlyArray<DecodedEvent>,
132
+ ) => Effect.Effect<void, E, R>
133
+ }
134
+
135
+ /**
136
+ * Start the daemon maintaining `readModel`, forked into the caller's `Scope`.
137
+ *
138
+ * The returned effect is INFALLIBLE (`E = never`): starting a projection cannot
139
+ * fail, because everything that can go wrong once it is running is the
140
+ * supervisor's business and lives on the fibre's error channel.
141
+ *
142
+ * ## What can go wrong BEFORE the fork is a programming error, and those are defects
143
+ *
144
+ * Which wirings are refused on the READ SIDE'S rules, why each one is a wiring
145
+ * nothing at runtime would ever notice, and the order they are judged in are all
146
+ * `projectionWiringFault`'s — one gate over five rules, whose only relation to this
147
+ * function is that it is the one place they can be applied before anything exists to
148
+ * be wrong. The gate judges a SET of materialisations, and this function supplies a
149
+ * SINGLETON: the two PER-ENTRY rungs judge that one wiring, the two that are
150
+ * functions of the shared query and slice record judge it once and would do so
151
+ * whatever N was, and the collision rung is a property of the set and is vacuous over
152
+ * one entry, having no sibling to be paired with. That is also why nothing here carries
153
+ * an entry index — the gate names one only where there is a set to index into.
154
+ * A further refusal is the CODEC's and fires just below the gate; `prepareProjection`
155
+ * owns that ordering for both entry points, and the gate's doc says why the rule
156
+ * stays in the codec. This function keeps the two claims that are its own.
157
+ *
158
+ * The verdict is a DEFECT rather than a value on the error channel, because no
159
+ * application can handle its own mis-wiring: the only repair is to change the
160
+ * wiring, and a channel value would ask every caller to write a handler for a
161
+ * mistake that has already been made by the time the program runs. `Effect.die`
162
+ * with an `Error` is the same classification the store gives a mis-built query
163
+ * through `assertServableQuery`, which is one of the rules.
164
+ *
165
+ * And it lands on THIS effect before the `forkScoped` at the foot of `buildAndFork`:
166
+ * the gate is consulted while the daemon is still being
167
+ * assembled, so a rejected wiring has read no checkpoint, opened no subscription
168
+ * and left no half-started fibre behind. That is what makes the defect safe to
169
+ * raise rather than merely early, and every construction case in this package
170
+ * asserts the stored checkpoint is still `ORIGIN` afterwards to keep it true.
171
+ *
172
+ * The preamble on the message is written HERE, once, naming the projection and the
173
+ * partition — the gate returns the sentence about the RULE, and this function knows
174
+ * WHO broke it. The single `Effect.die` that joins them is `prepareProjection`'s,
175
+ * which is why there is one for both entry points rather than one per rule.
176
+ */
177
+ export const runProjection = <T extends Record<string, SliceProjection>, E, R>(
178
+ readModel: ReadModel<T, E, R>,
179
+ options?: ProjectionRunnerOptions,
180
+ ): Effect.Effect<
181
+ ProjectionRunner,
182
+ never,
183
+ | Scope.Scope
184
+ | DcbEventStore
185
+ | EventLogDurability
186
+ | Serializer
187
+ | RequirementOf<T>
188
+ | R
189
+ > =>
190
+ Effect.gen(function* () {
191
+ // ## RESOLVE — every value the gate has to judge, and every figure that runs
192
+ //
193
+ // `resolveProjection` is a pure function shared with `runProjections`, so the
194
+ // defaults, the key and the view's durability are read in ONE place for both
195
+ // callers: the figure that was judged cannot differ from the figure that runs.
196
+ const view = readModel.store
197
+ const resolved = resolveProjection(view, options)
198
+
199
+ // ## PREPARE — the log's durability, the merged query, the gate, the decode
200
+ //
201
+ // The shared middle, and the same call `runProjections` makes: it reads
202
+ // `EventLogDurability` from context, composes the query, consults the gate and
203
+ // only then builds the decode stage — an order `prepareProjection` owns on behalf
204
+ // of both entry points, and whose reasons are on it rather than restated here.
205
+ //
206
+ // The entry list is a SINGLETON, over which the collision rung has no pair to
207
+ // find. Its `cursorIdentity` is what that rung compares by reference — the BOUND
208
+ // view here, which `runProjections` could not use and does not, since a lone
209
+ // entry can only ever be compared with itself and the choice is therefore free at
210
+ // this arity.
211
+ //
212
+ // The preamble is this function's own half of the defect message and the only
213
+ // half it writes: the gate returns the sentence about the RULE, and this names
214
+ // the projection and the partition whose wiring broke it. No entry index, because
215
+ // there is no set to index into.
216
+ const prepared = yield* prepareProjection(
217
+ readModel.slices,
218
+ [{ ...resolved, cursorIdentity: view }],
219
+ `runProjection: invalid wiring for projection ` +
220
+ `'${resolved.key.projection}' (partition ` +
221
+ `'${resolved.key.partition}')`,
222
+ )
223
+
224
+ // ## BUILD AND FORK — the pipeline, the read side's own rules now satisfied
225
+ return yield* buildAndFork(resolved, {
226
+ query: prepared.query,
227
+ decode: prepared.decode,
228
+ apply: readModel.apply,
229
+ options,
230
+ })
231
+ })
232
+
233
+ /**
234
+ * `runProjection` as a `Layer` whose own scope owns the daemon.
235
+ *
236
+ * It provides NOTHING (`ROut = never`): a projection is a background process, not
237
+ * a service, and nothing should be able to depend on it as one — a read model is
238
+ * queried through its view store, never through the runner. What the `Layer` buys
239
+ * is lifecycle: dropped into an application's layer graph, the daemon starts when
240
+ * the graph is built and is interrupted when it is released, alongside the store
241
+ * layers it reads from, with no bespoke startup or shutdown code.
242
+ *
243
+ * Discarding the runner discards its fibre too, which is correct and has one
244
+ * consequence worth wiring for: under this `Layer` — the recommended shape — a
245
+ * `ProjectionStalled` has no fibre to be awaited on, so it reaches the outside
246
+ * world only through the supervisor's `Effect.logError` and through `onStalled`.
247
+ * That hook is the reason a stall need not be a log line somebody happens to grep
248
+ * for; pass one in `options` if a stalled projection should fail a health check,
249
+ * exit non-zero, or page.
250
+ *
251
+ * `Layer.scopedDiscard` discharges the `Scope` the fork needs; the store, its
252
+ * `EventLogDurability` declaration, the `Serializer`, the slices' requirements and
253
+ * the read model's own `R` stay on the layer's inputs, so they are satisfied the
254
+ * same way every other layer's are. `EventLogDurability` being an INPUT is what
255
+ * makes the wiring read correctly: the declaration is provided once, alongside the
256
+ * `DcbEventStore` layer it describes, and every projection layer in the graph is
257
+ * then satisfied from that one declaration.
258
+ */
259
+ export const projectionLayer = <
260
+ T extends Record<string, SliceProjection>,
261
+ E,
262
+ R,
263
+ >(
264
+ readModel: ReadModel<T, E, R>,
265
+ options?: ProjectionRunnerOptions,
266
+ ): Layer.Layer<
267
+ never,
268
+ never,
269
+ DcbEventStore | EventLogDurability | Serializer | RequirementOf<T> | R
270
+ > => Layer.scopedDiscard(runProjection(readModel, options))