@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,623 @@
1
+ /**
2
+ * `runProjections` — one read model materialised N WAYS: one slice record and one
3
+ * `ProjectionId` for the whole call, one `Materialisation` per view store, and one
4
+ * `ProjectionRunner` forked for each.
5
+ *
6
+ * ## What it is for
7
+ *
8
+ * `ReadModel.store` records what a slice's GRADUATION from an in-memory view to
9
+ * Postgres does and does not change, and owns that claim. Per-read-model view-store
10
+ * selection is a FLOOR rather than a ceiling, though, and nothing in that claim says
11
+ * the two variants must be alternatives in TIME. A deployment can legitimately want
12
+ * both at once: a durable table somebody queries with SQL, beside an in-process view
13
+ * of the same slices holding one aggregate's hot figures with no round trip to reach
14
+ * them. Written as two `runProjection` calls that is two hand-built read models with
15
+ * the slice record named at both, and nothing placed to notice when the two copies
16
+ * drift apart.
17
+ *
18
+ * A hand-written pair also cannot get the REFUSAL the shape needs, and that is the
19
+ * half worth stating beside the sharing. Two materialisations bound to one store
20
+ * under one key would trade cursors, each advancing the other's; the gate's collision
21
+ * rung is what says so, and it is vacuous over the singleton each `runProjection`
22
+ * call hands it, having no sibling there to compare against. So the pairing is
23
+ * refused when the entries arrive together and unrefused when they are written
24
+ * separately, which is a property of the CALL rather than of any rule this module
25
+ * owns.
26
+ *
27
+ * ## The guarantee, at exactly its strength
28
+ *
29
+ * ONE QUERY, ONE DECODE, N APPLIES. The `slices` value is stated once for the call,
30
+ * and the two derivations from it are built ONCE for it — in `prepareProjection`,
31
+ * the phase `runProjection` shares — and handed to every entry: one
32
+ * `Query` object across the N subscriptions, one merged `type -> Schema` table across
33
+ * the N decode stages. There is no second place for either to be written
34
+ * differently, because there is no second place they are written at all — and now
35
+ * literally no second OBJECT either.
36
+ *
37
+ * It is NOT "N materialisations cannot drift", and nothing in this package should be
38
+ * worded that way. The FOLDS drift freely: a SQL `apply` that writes rows
39
+ * re-implements the fold rather than reusing `foldIntoRef`, so two materialisations
40
+ * of one slice record can disagree about what they made of the very same events.
41
+ * What is shared VERBATIM is the slices — the query and the decode schemas — and
42
+ * never the FOLD. Agreement between N materialised figures is therefore
43
+ * EMPIRICAL, something a test demonstrates by comparing the figures; it is not
44
+ * structural, and this function does not make it so.
45
+ *
46
+ * ## The guarantee is CONSTRUCTION-SCOPED, and that is enough
47
+ *
48
+ * This holds sharing at the moment of the CALL. Nothing afterwards carries it: each
49
+ * runner is an ordinary supervised pipeline from the fork onwards, and none of them
50
+ * can see its siblings. That is the same move `forKey` makes one level down — bind
51
+ * the pairing into the value every later operation flows through, so the incoherent
52
+ * thing is unwritable rather than merely unwritten — and it is sufficient here for a
53
+ * structural reason. The subscription's query and the decode table are both built
54
+ * FROM the slices at construction, in the one PREPARE phase, and are never rebuilt: a
55
+ * restart re-subscribes with the query it already holds. So there is no later moment at
56
+ * which one materialisation could come to hold a different query from its siblings,
57
+ * and no ongoing agreement for anything to police.
58
+ *
59
+ * ## Why a module of its own
60
+ *
61
+ * It composes the runner's three phases — `resolveProjection`, `prepareProjection`
62
+ * (which is where the construction gate is consulted) and `buildAndFork` — with
63
+ * `forKey`, and adds nothing to any of them: no pipeline
64
+ * stage, no supervision, no store operation, and no rule of its own at all, the
65
+ * collision being a rung of the one gate both entry points consult.
66
+ * The precedent for taking a whole job out of `ProjectionRunner.ts` is that module's
67
+ * own history, three times over: `superviseOnProgress.ts` and
68
+ * `projectionWiringFault.ts` were both extracted from it and each became testable on
69
+ * its own terms by being, and `runProjection.ts` — the SINGULAR entry point, this
70
+ * module's opposite number — was split off it in turn, leaving that file the shared
71
+ * pipeline substrate both entry points sit on. No count of lines is quoted here on
72
+ * purpose: the argument is that the file accretes JOBS, which is true of it at any
73
+ * length.
74
+ *
75
+ * ## Why there is no `projectionsLayer`
76
+ *
77
+ * `projectionLayer` is `Layer.scopedDiscard(runProjection(readModel, options))` and
78
+ * nothing else, so the layer shape for N materialisations is
79
+ * `Layer.scopedDiscard(runProjections(spec))` — which discards the N runners exactly
80
+ * as `projectionLayer` discards the one, and for the same reason (a projection is a
81
+ * background process, not a service). A second one-line wrapper would be a second
82
+ * home for one line, and the argument `projectionLayer`'s doc makes — that a `Layer`
83
+ * is how an application's graph owns a daemon's lifetime, with no bespoke startup or
84
+ * shutdown code — is already written down once. The one consequence worth carrying
85
+ * across is `projectionLayer`'s too: a discarded runner has no fibre to await, so a
86
+ * stall reaches the outside world only through the supervisor's `logError` and
87
+ * through `onStalled`, which under this function is a hook per materialisation.
88
+ */
89
+ import type {
90
+ RequirementOf,
91
+ Serializer,
92
+ SliceProjection,
93
+ } from '@kairos-es/codec'
94
+ import type { DcbEventStore, DecodedEvent } from '@kairos-es/core'
95
+ import { Array as Arr, Effect, type Scope } from 'effect'
96
+ import type { EventLogDurability } from './EventLogDurability'
97
+ import {
98
+ buildAndFork,
99
+ type ProjectionRunner,
100
+ type ProjectionRunnerOptions,
101
+ prepareProjection,
102
+ resolveProjection,
103
+ } from './ProjectionRunner'
104
+ import {
105
+ checkpointKey,
106
+ forKey,
107
+ type PartitionId,
108
+ type ProjectionId,
109
+ type ProjectionStore,
110
+ } from './ProjectionStore'
111
+
112
+ /**
113
+ * ONE way a read model is materialised: where its view lives, how a batch is
114
+ * written into it, and how its own runner is tuned.
115
+ *
116
+ * It is a `ReadModel` minus the two things `runProjections` supplies for the whole
117
+ * call — the slices, and the `forKey` binding — which is why the store here is the
118
+ * UNBOUND multi-key port rather than a `KeyedProjectionStore`.
119
+ *
120
+ * ## Why BOTH parameters survived the tuple fix, `E` included
121
+ *
122
+ * `E` was considered for removal and deliberately KEPT, so a reader meeting it here
123
+ * is not meeting an oversight. The case for dropping it was that one `E` shared
124
+ * across N entries fails to reconcile — but that was the SAME defect as `R`'s, and
125
+ * `MaterialisedProjection`'s tuple parameter fixes it for both by one mechanism:
126
+ * two entries with two distinct error classes now infer `ErrA | ErrB`. Removing the
127
+ * parameter would therefore delete a working, CHECKED type to route around a bug
128
+ * that is already fixed.
129
+ *
130
+ * Two things would have gone with it. The `ReadModel` correspondence this doc opens
131
+ * with: `ReadModel<T, E, R>` keeps its `E`, and typing an `apply`'s error as
132
+ * `unknown` here would mean the two were no longer the same shape — which
133
+ * `examples/course-subscriptions/src/courseRosterSql.ts` would be the first to
134
+ * notice, one value there serving as both. And a check a caller gets for free:
135
+ * `Materialisation<never>` states that an `apply` CANNOT fail and is verified,
136
+ * `Materialisation<SqlError>` that this one fails only that way. Under `unknown`
137
+ * both become unwritable and every `apply` typechecks, including one whose author
138
+ * got the error type wrong.
139
+ *
140
+ * The narrower version of that insight is real and IS taken, one level up:
141
+ * `MaterialisationAny` bounds BOTH positions with `unknown`, and loses nothing by
142
+ * it, because a CONSTRAINT is not a type anything declares a value at. Wherever a
143
+ * caller DOES declare one — a value typed `Materialisation<SqlError>`, or a domain
144
+ * interface of its own parameterised by that error, which is the shape
145
+ * `examples/course-subscriptions` takes — the check above is still theirs. The bound
146
+ * only has to admit the result.
147
+ */
148
+ export interface Materialisation<E = never, R = never> {
149
+ /**
150
+ * The multi-key view store this materialisation writes into, UNBOUND.
151
+ *
152
+ * Unbound because `runProjections` does the binding, from the one `ProjectionId`
153
+ * the call names and this entry's own `partition`. `MaterialisedProjection`'s own
154
+ * doc argues why that direction is load-bearing rather than merely convenient.
155
+ */
156
+ readonly store: ProjectionStore
157
+
158
+ /**
159
+ * THIS materialisation's per-batch view writes, handed to its own
160
+ * `ProjectionStore.commit` so they land inside the same transaction as its own
161
+ * checkpoint advance.
162
+ *
163
+ * Every obligation `ReadModel.apply` carries applies here unchanged and per
164
+ * entry: the batch is non-empty by construction, and a NON-TRANSACTIONAL store's
165
+ * `apply` must be a SINGLE atomic effect (fold the batch purely, then one write —
166
+ * `foldIntoRef`), because the port cannot roll back a multi-write `apply` that
167
+ * fails part-way. N materialisations means N independent answers to that: an
168
+ * in-memory entry beside a SQL one owes the requirement, and the SQL one does not
169
+ * discharge it on its behalf.
170
+ *
171
+ * ## ONE MATERIALISATION OWNS ONE VIEW TARGET, and the gate cannot check it
172
+ *
173
+ * Two entries whose `apply`s write the SAME view — the same `Ref`, the same row,
174
+ * the same table — are a defect, and one nothing here refuses. It is the exact
175
+ * mirror of the collision the gate DOES catch: that one is two entries sharing a
176
+ * CURSOR, this one is two entries sharing a TARGET. Both end with the view wrong
177
+ * indefinitely and nothing on any error channel.
178
+ *
179
+ * It is undetectable rather than merely unchecked, and the reason is structural:
180
+ * the view lives INSIDE this closure, so the library sees an opaque effect and has
181
+ * nothing to compare. Where the collision rung has two store references and two
182
+ * resolved keys to hold up against each other, here it has two functions.
183
+ *
184
+ * The failure is worth recognising because it looks nothing like a race. Both
185
+ * runners are sound single writers of their OWN cursors, each committing exactly
186
+ * once per batch; they simply both apply every event to one target, so the figures
187
+ * come out doubled (or last-write-wins, for a `set` rather than an increment)
188
+ * while both checkpoints advance cleanly to head. A caller reaching for a second
189
+ * materialisation almost always wants a second VIEW to go with the second store —
190
+ * and if the two views really are meant to be one, that is ONE materialisation
191
+ * with one `apply`, which is the same answer the collision rung's message gives.
192
+ */
193
+ readonly apply: (
194
+ batch: Arr.NonEmptyReadonlyArray<DecodedEvent>,
195
+ ) => Effect.Effect<void, E, R>
196
+
197
+ /**
198
+ * This materialisation's checkpoint PARTITION. Defaults to `DEFAULT_PARTITION`,
199
+ * which is what nearly every call wants.
200
+ *
201
+ * Present for the one shape that needs it: two materialisations that genuinely
202
+ * must COHABIT one `ProjectionStore`. Under one `ProjectionId` and the default
203
+ * partition their `CheckpointKey`s would be identical, so they would be two
204
+ * runners over ONE cursor, racing the same guarded advance on every batch — which
205
+ * `materialisationCollisionFault` refuses at construction, and whose sentence
206
+ * names this field as the escape hatch. A distinct `PartitionId` is a distinct
207
+ * `CheckpointKey` and therefore a cursor of its own.
208
+ *
209
+ * It is NOT sharding: nothing here splits one materialisation's stream, which
210
+ * ADR-0007 rules out and `PartitionId`'s own doc explains. Each partition still
211
+ * has exactly one runner over the whole composed query.
212
+ */
213
+ readonly partition?: PartitionId
214
+
215
+ /**
216
+ * This materialisation's own pipeline tuning and supervision hooks — the same
217
+ * object `runProjection` takes, per entry.
218
+ *
219
+ * ## Why PER ENTRY, and why there is no call-level options object
220
+ *
221
+ * The figures are genuinely per materialisation: a SQL `apply` and a `Ref` update
222
+ * want different batch sizes and different latency ceilings, and there is nothing
223
+ * about "one read model" that makes one transaction size right for both.
224
+ *
225
+ * The observability halves settle it. Each materialisation's checkpoint IS its
226
+ * progress signal, so `onRestart` and `onStalled` are about ONE of them: a restart
227
+ * is a rate to graph and a stall needs a human, and an operator has to be able to
228
+ * attribute either — collapsing N signals into one pair of hooks would report that
229
+ * something restarted while saying nothing about WHAT. There is a sharp
230
+ * consequence worth stating because it surprises: under one `ProjectionId` and the
231
+ * default partition the N `CheckpointKey`s are IDENTICAL — the intended shape, a
232
+ * key naming a cursor WITHIN a store — so `info.key` on the hooks does not
233
+ * discriminate the materialisations. For SUPERVISION the discriminator is WHICH
234
+ * ENTRY'S HOOK FIRED, which is exactly what a per-entry options object gives and a
235
+ * call-level one would take away.
236
+ *
237
+ * That argument is about the hooks specifically and does not generalise to every
238
+ * observer. A hook is handed a payload rather than a handle, so the discriminator
239
+ * a RETURNED runner has — `runners[i].store`, the bound cursor it maintains — is
240
+ * not in reach from inside one. An operator attributing a stall therefore closes
241
+ * over the entry, while a caller reading how far each materialisation has got asks
242
+ * its runner.
243
+ *
244
+ * A call-level object would also be a second home for every figure in
245
+ * `ProjectionRunnerOptions`, free to disagree with the per-entry one, with a merge
246
+ * order to document and get wrong. Sharing a tuning across entries is a `const`
247
+ * the caller writes once and names twice, which needs no library support.
248
+ */
249
+ readonly options?: ProjectionRunnerOptions
250
+ }
251
+
252
+ /**
253
+ * Any materialisation at all, whatever its `apply` fails with and whatever it
254
+ * needs — the CONSTRAINT on the tuple below, and not a type anything declares a
255
+ * value at.
256
+ *
257
+ * Both positions are `unknown` — the widest bound that is not `any`, so the tuple
258
+ * admits every materialisation while erasing nothing a reader could mistake for a
259
+ * check. The `R` half of that is what makes the body's ONE assertion MANDATORY: a
260
+ * wildcard bound would make it optional, and an optional assertion is one nobody
261
+ * writes.
262
+ *
263
+ * Under this bound an element of `Ms` is known only to be a
264
+ * `Materialisation<unknown, unknown>`, so the requirement `Effect.forEach` would
265
+ * infer from `entry.apply` is `unknown`, which satisfies no declared requirement:
266
+ * the module does not compile until the one `buildAndFork` call says what the
267
+ * entries actually need. It says it once and narrowly — on `entry.apply` alone, to
268
+ * exactly `MaterialisationContext<Ms>`, the alias the return type already names —
269
+ * and everything downstream is then inferred from that alias rather than from a
270
+ * wildcard, so the WHOLE requirement channel of the declared return type is CHECKED.
271
+ * Deleting `Serializer`, `DcbEventStore` or `MaterialisationContext<Ms>` itself from
272
+ * that union is a compile error, as is deleting the assertion; verified by mutation
273
+ * rather than reasoned about, all four.
274
+ *
275
+ * `any` in the `R` position also compiles, and compiles with no assertion at all,
276
+ * which is what makes it the tempting shape. It is the wrong trade and not a small
277
+ * one: `X | any` normalises to `any`, so the body's requirement erases ENTIRELY and
278
+ * every member of that union could be deleted — or a service the body never needs
279
+ * added — with the module still green. An `as` on one line is the narrow, visible
280
+ * form of the trade `@kairos-es/codec`'s `makeRegistry` makes with its single cast;
281
+ * a wildcard bound is the wide, silent form of the same thing, and the absence of an
282
+ * `as` is not the absence of an assertion.
283
+ *
284
+ * The `E` position is `unknown` for a duller reason and needs no assertion of its
285
+ * own: `E` never reaches this function's return type, `runProjections` being
286
+ * infallible, so no error an entry declares has anywhere to go but its supervisor.
287
+ */
288
+ type MaterialisationAny = Materialisation<unknown, unknown>
289
+
290
+ /**
291
+ * The union of requirements `R` carried by a TUPLE of materialisations — what makes
292
+ * two entries needing two different services compile with nothing annotated.
293
+ *
294
+ * ## Why a tuple parameter, and what the shape it replaced could not do
295
+ *
296
+ * The obvious signature is one `Materialisation<E, R>` for every entry, and it is
297
+ * WRONG in a way that only shows up on the very shape this function exists for.
298
+ * With one `R` shared across N entries, TypeScript collects a candidate from each
299
+ * element and then checks the rest against the first, so two entries needing two
300
+ * different services do not union — the second is reported as not assignable to the
301
+ * first, and the caller's only recourse is to compute the union by hand and annotate
302
+ * it. Heterogeneous view stores is the whole point of this function, and under that
303
+ * signature the heterogeneous case was the one that did not work.
304
+ *
305
+ * A tuple parameter has no single `R` to reconcile. `Ms` is inferred PER ELEMENT, so
306
+ * no candidate has to lose, and this alias then reads the union back off the
307
+ * inferred tuple, and is also the body's one assertion target, for the reason
308
+ * `MaterialisationAny` gives. It is the mechanism `Effect.all` uses for exactly this
309
+ * problem —
310
+ * `All.ReturnTuple` indexes its tuple with `T[number]` and infers through the
311
+ * variance struct, and `effect`'s own dtslint suite pins the result
312
+ * (`Effect.all([string, number])` is
313
+ * `Effect<[string, number], "err-1" | "err-2", "dep-1" | "dep-2">`).
314
+ *
315
+ * ## Why the brackets, when they do nothing here
316
+ *
317
+ * They are the idiom `Effect.Context` is written in and the form that stays correct
318
+ * under the one edit a later reader is most likely to make. They are NOT what
319
+ * produces the union, and this doc says so plainly because the alternative is a
320
+ * reader concluding they are magic and preserving them while "simplifying" the tuple
321
+ * parameter away — which is the change that actually breaks it.
322
+ *
323
+ * Distribution happens only when the checked type is a NAKED type parameter, and
324
+ * `Ms[number]` is an indexed access, so the conditional here does not distribute and
325
+ * the brackets suppress nothing. Both forms were compiled side by side over a
326
+ * two-entry tuple, an array of a union and the empty tuple, and agreed on all three.
327
+ * Should the checked type ever become a bare parameter, the brackets are what keeps
328
+ * the answer the same.
329
+ *
330
+ * `R` is COVARIANT in Effect v3 (`Effect<out A, out E, out R>`, and `_R:
331
+ * Covariant<R>` on the variance struct), which is why inferring across the elements
332
+ * yields a UNION and not an intersection.
333
+ */
334
+ type MaterialisationContext<
335
+ Ms extends Arr.NonEmptyReadonlyArray<MaterialisationAny>,
336
+ > = [Ms[number]] extends [Materialisation<infer _E, infer R>] ? R : never
337
+
338
+ /**
339
+ * One read model — its slices and its identity — plus the N ways it is materialised:
340
+ * the whole input of `runProjections`.
341
+ *
342
+ * ## Why the UNBOUND store per entry plus ONE `ProjectionId` for the call
343
+ *
344
+ * The alternative shape is N pre-bound `KeyedProjectionStore`s, and it was rejected
345
+ * for three reasons that are each load-bearing.
346
+ *
347
+ * It makes the PRIMARY GUARANTEE structural. One `slices` field cannot become two,
348
+ * so "one query, one decode" is a property of the type rather than of a caller's
349
+ * discipline — which is the whole point of the function, and would be given away by
350
+ * a shape that took N read models.
351
+ *
352
+ * It makes the KEY POLICY structural too. One `ProjectionId` for the call is what
353
+ * "one read model, N materialisations, ONE identity" MEANS, and it is the honest
354
+ * reading of a `CheckpointKey`: the key is a lookup identifier WITHIN one store, so
355
+ * N stores holding the same key are N materialisations of one read model, not a
356
+ * collision. N pre-bound stores would leave every caller free to fragment one read
357
+ * model's identity across N ids — nothing would break, and nothing would ever say
358
+ * so, until somebody went looking for one projection's cursors and found three
359
+ * names.
360
+ *
361
+ * And it is the only shape in which the collision check is POSSIBLE at all. `forKey`
362
+ * returns a fresh object literal that deliberately does not expose the store it
363
+ * closed over (so that a `{ ...inner, commit: … }` decorator decorates), so nothing
364
+ * downstream of a binding can compare two bound stores for store IDENTITY. The
365
+ * unbound port is the last point at which "these two entries are the same store" is
366
+ * a question anything can ask.
367
+ *
368
+ * All three are the same move as `forKey` itself, one level up: bind the pairing
369
+ * into the value every later operation flows through, so a wiring cannot express the
370
+ * incoherent thing.
371
+ *
372
+ * ## Why the materialisations are a NON-EMPTY array
373
+ *
374
+ * An empty list is a projection nobody maintains, forked silently — no runner, no
375
+ * checkpoint, no view, and no error either. The type refuses it a stack frame from
376
+ * where it was written, which is both earlier and cheaper than a rung of the
377
+ * construction gate. It is also why the return type is non-empty: the runners come
378
+ * back one per entry, in entry order.
379
+ *
380
+ * ## Why the materialisations are a TUPLE parameter and not `Arr.NonEmptyReadonlyArray<Materialisation<E, R>>`
381
+ *
382
+ * So that N entries needing N different services compile with nothing annotated.
383
+ * `MaterialisationContext` above owns that argument in full; what it means HERE is
384
+ * that the second parameter is the entries' own inferred tuple rather than a shared
385
+ * `E`/`R` pair, and that `Ms` is normally inferred and never written. Its default is
386
+ * what keeps the one-argument spelling — `MaterialisedProjection<Slices>` — reading
387
+ * as it always did, for the callers that only ever name the slices.
388
+ *
389
+ * ## What the tuple parameter COSTS: one assertion, on one line, at one call site
390
+ *
391
+ * It is stronger for CALLERS and costs the body exactly one `as`. Under the shape it
392
+ * replaced, the body's requirement came straight off `entry.apply`, which was
393
+ * `Effect<void, E, R>`. Under a tuple parameter an element is known only by its
394
+ * CONSTRAINT, so `entry.apply` reads as `Effect<void, unknown, unknown>` inside the
395
+ * body and the assembled effect satisfies no declared requirement until the
396
+ * `buildAndFork` call names one. `MaterialisationAny`'s doc owns that mechanic and
397
+ * the reason the bound is `unknown` rather than a wildcard; what it means HERE is
398
+ * the ONE thing the compiler is asked to take on trust.
399
+ *
400
+ * That one thing is narrow and worth stating exactly: an entry's `apply` requires no
401
+ * more than the union `MaterialisationContext<Ms>` reads back off the very tuple the
402
+ * entry came from. It is true by construction — the alias IS that union and the
403
+ * entry IS an element of that tuple — but TypeScript cannot verify it for an
404
+ * unresolved generic `Ms`, the alias being a deferred conditional over `Ms[number]`.
405
+ * Assert something wider there and callers would be asked for services no entry
406
+ * needs; assert `never` and they would be asked for nothing, and a genuinely missing
407
+ * service would surface as a runtime defect instead of a type error. Which is why
408
+ * the alias is written in exactly two places, the assertion and the return type, and
409
+ * they are the same expression.
410
+ *
411
+ * Everything downstream of that line is CHECKED, which is the whole return on
412
+ * keeping it narrow: the declared requirement channel is verified member by member,
413
+ * and the assertion itself cannot be quietly deleted by a later reader who finds it
414
+ * decorative. `MaterialisationAny` names the mutations that demonstrate both. The
415
+ * value channel is checked too — the runners come back one per entry and the
416
+ * non-emptiness is real.
417
+ *
418
+ * ## Why the runners come back as an ARRAY and not as a mapped TUPLE
419
+ *
420
+ * `{ readonly [K in keyof Ms]: ProjectionRunner }` compiles, and it makes a
421
+ * two-entry call hand back a `readonly [ProjectionRunner, ProjectionRunner]`. It was
422
+ * measured and declined. `Arr.With` — the type both `Arr.map` and `Effect.forEach`
423
+ * map their input through, `S extends NonEmptyReadonlyArray<any> ? NonEmptyArray<A>
424
+ * : Array<A>` in Effect v3 — has dropped the ARITY by the first of those two steps
425
+ * and nothing recovers it, so the mapped tuple needs a SECOND assertion, on the
426
+ * `Effect.forEach` result, and that one converts the value channel above from
427
+ * checked into asserted.
428
+ *
429
+ * What it buys back is arity and only arity. Every element is a `ProjectionRunner`,
430
+ * so it cannot express the pairing `runners[i]` ↔ `materialisations[i]` that the
431
+ * entry ORDER carries and that this module's prose states; and with
432
+ * `noUncheckedIndexedAccess` off, as it is across this repo, no call site reads
433
+ * differently either way. Trading a check for an assertion to type something no
434
+ * caller can use is the wrong direction.
435
+ */
436
+ export interface MaterialisedProjection<
437
+ T extends Record<string, SliceProjection>,
438
+ Ms extends
439
+ Arr.NonEmptyReadonlyArray<MaterialisationAny> = Arr.NonEmptyReadonlyArray<Materialisation>,
440
+ > {
441
+ /**
442
+ * The slices every materialisation shares: their composed query is the ONE
443
+ * subscription query and their merged schema table is the ONE decode lookup.
444
+ *
445
+ * Stated once for the call, which is the guarantee — see the module doc for what
446
+ * that guarantee does and does not extend to.
447
+ */
448
+ readonly slices: T
449
+
450
+ /**
451
+ * The read model's identity, shared by every materialisation.
452
+ *
453
+ * One name for one read model however many ways it is materialised, because a
454
+ * `CheckpointKey` identifies a cursor WITHIN a store: equal keys across two
455
+ * different stores are the intended shape of a graduated slice, not a clash.
456
+ */
457
+ readonly projection: ProjectionId
458
+
459
+ /** The N ways it is materialised, at least one. */
460
+ readonly materialisations: Ms
461
+ }
462
+
463
+ /**
464
+ * Start one daemon per materialisation, all forked into the caller's `Scope`.
465
+ *
466
+ * The returned effect is INFALLIBLE for the reason `runProjection`'s is: starting a
467
+ * projection cannot fail, because everything that can go wrong once one is running
468
+ * belongs to its own supervisor and lives on its own fibre's error channel. The
469
+ * runners come back in ENTRY ORDER, so `runners[i]` is `materialisations[i]`'s.
470
+ *
471
+ * Each runner carries the store THIS call bound for its entry, so `runners[i].store`
472
+ * is that materialisation's cursor and a caller observing one re-derives no binding
473
+ * it never wrote. It is also what tells the N handles apart: under one `ProjectionId`
474
+ * the keys are equal wherever no entry named a partition, so a runner carrying only a
475
+ * key could not name which of the N views it maintains.
476
+ *
477
+ * ## N = 1 is legal and carries nothing inert
478
+ *
479
+ * One entry is `runProjection` with the `forKey` binding done for you, and every
480
+ * field means the same thing it means at N = 3. The two run the identical phases —
481
+ * one `resolveProjection`, one `prepareProjection`, one `buildAndFork` — rather than
482
+ * merely offering the same surface, so there is no threshold at which a caller should
483
+ * switch functions: a read model that grows a second materialisation adds an entry.
484
+ *
485
+ * ## Every rule is judged before ANY entry is forked
486
+ *
487
+ * The construction gate takes the whole call — the shared log, query and slice
488
+ * record, plus the N resolved wirings — and this call reaches it ONCE, through
489
+ * `prepareProjection`, above the `Effect.forEach` below. So a call refused for ANY
490
+ * reason has forked NOTHING,
491
+ * not even the entries that were sound: not the collision, which is a property of
492
+ * the set, and not a degenerate `batchWindow` or an R2 violation on the last entry
493
+ * either. Half a call running is a worse state than none of it, and the pairing of N
494
+ * materialisations is one wiring decision.
495
+ *
496
+ * Two consequences of the gate judging a SET are worth stating rather than leaving
497
+ * to be met.
498
+ *
499
+ * A per-entry rung names its ENTRY. The gate prefixes its verdict with
500
+ * `materialisations[i]` where there is more than one entry, in the vocabulary the
501
+ * collision sentence already uses; this function's preamble names the PROJECTION and
502
+ * no partition, because under one `ProjectionId` the N keys are equal wherever no
503
+ * entry named a partition, so a partition never discriminated them.
504
+ *
505
+ * And the order is over RULES, not over entries. Where two entries break two
506
+ * DIFFERENT rules the earlier RULE wins whatever entry it is in — entry 0's
507
+ * degenerate `batchWindow` beside entry 1's illegal durability pairing reports entry
508
+ * 1's R2 fault, because R2 is the wider mistake wherever it sits. The gate's module
509
+ * doc argues that, and it is why the rules are judged rule-major rather than by
510
+ * walking the entries.
511
+ */
512
+ export const runProjections = <
513
+ T extends Record<string, SliceProjection>,
514
+ Ms extends Arr.NonEmptyReadonlyArray<MaterialisationAny>,
515
+ >(
516
+ spec: MaterialisedProjection<T, Ms>,
517
+ ): Effect.Effect<
518
+ Arr.NonEmptyReadonlyArray<ProjectionRunner>,
519
+ never,
520
+ | Scope.Scope
521
+ | DcbEventStore
522
+ | EventLogDurability
523
+ | Serializer
524
+ | RequirementOf<T>
525
+ | MaterialisationContext<Ms>
526
+ > =>
527
+ Effect.gen(function* () {
528
+ // ## RESOLVE — everything the gate has to judge, and every figure that runs
529
+ //
530
+ // Each entry's key and its five defaulted figures, through the same pure
531
+ // `resolveProjection` a single wiring goes through. Resolved here and carried,
532
+ // rather than derived twice: the gate below judges these values and the pipelines
533
+ // further down run from these same values, so what was checked cannot differ from
534
+ // what runs.
535
+ //
536
+ // Built as an EXPLICIT literal rather than by spreading the `Materialisation`,
537
+ // so every field on an entry has exactly one meaning and nothing dead rides
538
+ // along. A spread would carry the caller's `store` and `partition` into a record
539
+ // that already holds the BOUND view (from the resolve step) and the UNBOUND port
540
+ // under `cursorIdentity` — two names for one reference, one name for a different
541
+ // one, and a `partition` nothing downstream reads because `key` has absorbed it.
542
+ // That is a near-miss of the very hazard `cursorIdentity` was renamed to close.
543
+ //
544
+ // `cursorIdentity` is the UNBOUND port here, which is the last surface at which
545
+ // sameness is askable — `forKey` returns a fresh literal per call and does not
546
+ // expose what it closed over, so two bound views are never equal even over one
547
+ // store. `runProjection` passes its bound view instead, which is free at an arity
548
+ // where the rung cannot fire. `MaterialisationWiring`'s doc owns that argument.
549
+ const entries = Arr.map(spec.materialisations, (materialisation) => {
550
+ const key = checkpointKey(spec.projection, materialisation.partition)
551
+ return {
552
+ ...resolveProjection(
553
+ forKey(materialisation.store, key),
554
+ materialisation.options,
555
+ ),
556
+ cursorIdentity: materialisation.store,
557
+ apply: materialisation.apply,
558
+ options: materialisation.options,
559
+ }
560
+ })
561
+
562
+ // ## PREPARE — the log's durability, the merged query, the gate, the decode
563
+ //
564
+ // The shared middle, and the same call `runProjection` makes with a singleton.
565
+ // It reads the LOG's half of R2 from context — once for the call, because it IS
566
+ // one fact about one log — composes the ONE query the N subscriptions share,
567
+ // judges all five rungs over the whole entry list, and only then builds the ONE
568
+ // decode stage the N pipelines share. All of it above the `Effect.forEach` below,
569
+ // so a refused call has forked nothing.
570
+ //
571
+ // The order of the last two steps is `prepareProjection`'s to own, and it is why
572
+ // `decodeSlices` is not simply hoisted beside the composition: a record wrong in
573
+ // the codec's way AND in one of the gate's must meet the gate's sentence.
574
+ //
575
+ // The preamble is this function's own half of the defect message: the PROJECTION
576
+ // and no partition, because under one `ProjectionId` the N keys are equal wherever
577
+ // no entry named one, so a partition never discriminated them. The gate prefixes
578
+ // the entry index itself, where there is a set to index into.
579
+ const prepared = yield* prepareProjection(
580
+ spec.slices,
581
+ entries,
582
+ `runProjections: invalid wiring for projection '${spec.projection}'`,
583
+ )
584
+
585
+ // ## FORK — one runner per entry, SEQUENTIALLY
586
+ //
587
+ // No `concurrency` option, deliberately. `buildAndFork` returns as soon as its
588
+ // daemon is forked into this scope, so it does no waiting for a concurrent pass
589
+ // to overlap: concurrency would buy nothing and would cost the returned ORDER,
590
+ // which is what lets a caller pair a runner with the entry it maintains. The
591
+ // daemons themselves run concurrently regardless — that is what forking them is.
592
+ //
593
+ // A runner naming its own bound cursor does not make that order redundant. The
594
+ // entry hands over an UNBOUND port and `forKey` deliberately does not expose what
595
+ // it closed over, so nothing a caller still holds can be compared against
596
+ // `runners[i].store` to recover which entry it came from. The order is the only
597
+ // statement of that pairing, and it is why this fold stays sequential.
598
+ //
599
+ // Each entry reaches the SAME build step a single wiring reaches, with the same
600
+ // shared query and the same shared decode stage, so the N pipelines differ in
601
+ // nothing but their store, their `apply` and their tuning.
602
+ return yield* Effect.forEach(entries, (entry) =>
603
+ buildAndFork(entry, {
604
+ query: prepared.query,
605
+ decode: prepared.decode,
606
+ // THE one assertion in this function, and the reason the requirement
607
+ // channel declared above is checked rather than wished for. Inside the
608
+ // body an entry is known only by its constraint, so this `apply` reads as
609
+ // `Effect<void, unknown, unknown>`; naming the alias the return type
610
+ // already names is what carries the entries' real requirement through
611
+ // `buildAndFork`, where a wildcard bound would instead absorb the lot and
612
+ // leave every member of that union unverified. It is narrow on purpose —
613
+ // `entry.apply` alone, to one expression written in one other place —
614
+ // and it cannot be dropped: without it the module does not compile.
615
+ // `MaterialisationAny` owns the mechanic, `MaterialisedProjection` what
616
+ // the compiler is being asked to take on trust.
617
+ apply: entry.apply as (
618
+ batch: Arr.NonEmptyReadonlyArray<DecodedEvent>,
619
+ ) => Effect.Effect<void, unknown, MaterialisationContext<Ms>>,
620
+ options: entry.options,
621
+ }),
622
+ )
623
+ })