@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,923 @@
1
+ /**
2
+ * `ProjectionRunner` — the read side's daemon: ONE `subscribe(query, checkpoint)`
3
+ * stream per `ReadModel`, decoded through the codec's shared decode stage,
4
+ * micro-batched, committed atomically with its checkpoint, and supervised
5
+ * (ADR-0007).
6
+ *
7
+ * ## The pipeline, and why every stage is the stage it is
8
+ *
9
+ * `subscribe -> decode -> groupedWithin -> mapEffect(commit, concurrency 1) ->
10
+ * runDrain`. Each arrow is a back-pressure boundary, and that is the point: a
11
+ * projection must be able to catch up over a backlog far larger than memory, so
12
+ * nothing in the chain may buffer without bound and nothing may run ahead of the
13
+ * committer.
14
+ *
15
+ * - **`subscribe`, not `read`.** The store's `subscribe` is catch-up-then-live
16
+ * behind one contract, so the runner has no catch-up mode and no live mode and
17
+ * therefore no transition between them to get wrong. It also means the runner
18
+ * never learns which engine it is running against (`store-postgres`'s
19
+ * LISTEN/NOTIFY wake over `core`'s shared poll machine, `store-sqlite`'s
20
+ * unwoken ride on that same poll, or the in-memory layer's `PubSub` wake over
21
+ * the same machine again).
22
+ * - **The codec's `decodeSlices`, not a local copy.** The read side needs exactly
23
+ * the decode stage the write side's own fold needs, so it shares that one
24
+ * implementation; a read-side copy could silently accept an event the decision
25
+ * path rejects. Decode is deliberately sequential (`Stream.mapEffect` with no
26
+ * `concurrency`), because a fold is order-sensitive and decode order must stay
27
+ * position order.
28
+ * - **`groupedWithin`, not per-event commit.** One transaction per event would
29
+ * pay a round-trip per event on catch-up; one transaction per *window* amortises
30
+ * it while keeping the transaction small. The window is a latency ceiling too:
31
+ * a lone live event waits at most `batchWindow` rather than for a full batch.
32
+ * - **`mapEffect` at concurrency 1.** The single writer. The checkpoint is a
33
+ * scalar guarded compare-and-set, so two batches in flight would race their own
34
+ * read model's cursor — one would lose its guard and the pipeline would restart
35
+ * for nothing. Concurrency 1 also gives the natural back-pressure: the stream
36
+ * pulls the next window only when the previous commit has landed.
37
+ *
38
+ * ## Supervision lives NEXT DOOR, and that is the point
39
+ *
40
+ * `DcbEventStore.subscribe` is `E = never` — a store that cannot serve the live
41
+ * tail *dies*. So the runner's boundary converts DEATH into a typed
42
+ * `PipelineDied` and hands it to a supervisor, because the checkpoint is the
43
+ * recovery mechanism for infrastructure death, not merely crash-restart hygiene:
44
+ * a restart re-reads the stored checkpoint and re-subscribes from it, which is the
45
+ * same code path a cold boot takes.
46
+ *
47
+ * The supervisor itself is `superviseOnProgress`, a module of its own — but this
48
+ * package's own, not a general combinator on offer to anybody (its module doc says
49
+ * why, and records what generalising it would cost). Its whole input from here is
50
+ * the checkpoint key, `readCheckpoint` as a progress signal, the RESOLVED restart
51
+ * tuning and its two hooks (`onRestart` on every restart, `onStalled` once at the
52
+ * give-up); it knows nothing of `subscribe`, decoding, batching, committing or
53
+ * slices, which is what lets it be stated, documented and TESTED on its own terms:
54
+ * retry while an externally observed signal moves, give up when it stops. What that
55
+ * buys the runner is that everything below is one attempt, `runOnce`, with no
56
+ * supervision state threaded through it; what it buys the supervisor is that its
57
+ * subtle parts (the `ORIGIN` seeding, the breaker being consulted before the sleep,
58
+ * the interruption trap on the re-read, the counter resetting on progress, the
59
+ * five-minute default budget) are pinned against a stub rather than through this
60
+ * whole pipeline. Its module doc carries the reasoning, including why a
61
+ * `CheckpointSuperseded` loser resynchronises instead of tripping the breaker.
62
+ *
63
+ * ## The two entry points live NEXT DOOR
64
+ *
65
+ * What this module holds is the SUBSTRATE: the `ProjectionRunner` handle, the two
66
+ * option sets a caller tunes it with, and the three phases every projection call runs
67
+ * — `resolveProjection`, `prepareProjection` (where the construction gate is
68
+ * consulted) and `buildAndFork`. It exports no entry point of its own.
69
+ * `runProjection.ts` is the N = 1 one and `runProjections.ts` the N >= 1 one; both
70
+ * import from here and nothing here imports from either, which is what lets the
71
+ * substrate be read without either caller in view. `runProjection.ts`'s module doc
72
+ * owns the argument for that arrangement.
73
+ *
74
+ * ## Platform-neutral
75
+ *
76
+ * No `node:*`, no `Date`, no host RNG: the batch window and the backoff are
77
+ * Effect `Duration`s scheduled through the `Clock`, and the jitter is Effect's
78
+ * `Random` service, so a test can drive both deterministically.
79
+ */
80
+ import type {
81
+ CodecError,
82
+ RequirementOf,
83
+ Serializer,
84
+ SliceProjection,
85
+ } from '@kairos-es/codec'
86
+ import { decodeSlices } from '@kairos-es/codec'
87
+ import {
88
+ type AnyProjection,
89
+ composeProjections,
90
+ DcbEventStore,
91
+ type DecodedEvent,
92
+ type Query,
93
+ type SequencedEvent,
94
+ } from '@kairos-es/core'
95
+ import {
96
+ Array as Arr,
97
+ Chunk,
98
+ Data,
99
+ type Duration,
100
+ Effect,
101
+ type Fiber,
102
+ Ref,
103
+ Schema,
104
+ type Scope,
105
+ Stream,
106
+ } from 'effect'
107
+ import { EventLogDurability } from './EventLogDurability'
108
+ import type {
109
+ CheckpointKey,
110
+ CheckpointSuperseded,
111
+ Durability,
112
+ KeyedProjectionStore,
113
+ ProjectionStoreError,
114
+ } from './ProjectionStore'
115
+ import {
116
+ type MaterialisationWiring,
117
+ projectionWiringFault,
118
+ } from './projectionWiringFault'
119
+ import {
120
+ DEFAULT_RESTART_MAX_DELAY,
121
+ DEFAULT_RESTART_MIN_DELAY,
122
+ DEFAULT_RESTART_RESET_AFTER,
123
+ type ProjectionStalled,
124
+ type ProjectionSupervisionOptions,
125
+ type ResolvedSupervisionTuning,
126
+ superviseOnProgress,
127
+ } from './superviseOnProgress'
128
+
129
+ /**
130
+ * Maximum events per commit: a branded POSITIVE integer.
131
+ *
132
+ * Branded because `batchSize` is the tuning figure whose invalid values are
133
+ * absorbed most quietly. `Stream.groupedWithin(0, window)` does not throw and
134
+ * does not merely under-batch: it emits EMPTY chunks in a tight loop without
135
+ * ever pulling the subscription, so a projection wired with `batchSize: 0`
136
+ * spins a CPU for ever, commits nothing, stays at `ORIGIN`, never fails, never
137
+ * restarts and logs nothing — the exact silent-stall class the supervisor below
138
+ * exists to make loud, arriving by a route the supervisor cannot see (the
139
+ * breaker only counts RESTARTS, and a spinning stream never ends a run). A
140
+ * fractional or `NaN` size is the same mistake by a different route.
141
+ *
142
+ * The SHAPE mirrors core's `ReadLimit` — `Schema.Int`, a bound, a brand, the
143
+ * same fail-at-the-boundary discipline — but deliberately NOT `ReadLimit`
144
+ * itself, which is `>= 0`. Zero is the whole difference between the two: a
145
+ * `read` capped at zero events is a meaningful (if useless) request, whereas a
146
+ * COMMIT of zero events is not a request at all. Reusing `ReadLimit` here would
147
+ * import the one value that has to be rejected.
148
+ *
149
+ * A distinct brand from `MaxNoProgressRestarts` despite the identical
150
+ * refinement, because the two are still writable side by side in one flat
151
+ * options literal, are both bare integers, and differ by an order of magnitude
152
+ * in meaning — nominality is what makes a transposition fail to typecheck rather
153
+ * than silently retune the runner. That the two now tune different mechanisms
154
+ * (this one the pipeline, that one the supervisor) is a second reason for the
155
+ * distinction, not a replacement for the first.
156
+ *
157
+ * Construct with `BatchSize.make(n)`, which throws a `ParseError` at the call
158
+ * site rather than deferring the mistake to the first stream pull.
159
+ */
160
+ export const BatchSize = Schema.Int.pipe(
161
+ Schema.positive(),
162
+ Schema.brand('BatchSize'),
163
+ )
164
+ export type BatchSize = typeof BatchSize.Type
165
+
166
+ /**
167
+ * Half the store's `catchUpPageSize` default (512), so a commit transaction stays
168
+ * comfortably smaller than one catch-up page while still amortising the
169
+ * round-trip across a couple of hundred events.
170
+ */
171
+ const DEFAULT_BATCH_SIZE: BatchSize = BatchSize.make(256)
172
+
173
+ /**
174
+ * An order of magnitude under the store's 1-second `pollInterval` — the live
175
+ * tail's own latency floor — so the batching window never dominates end-to-end
176
+ * lag.
177
+ */
178
+ const DEFAULT_BATCH_WINDOW: Duration.DurationInput = '50 millis'
179
+
180
+ /**
181
+ * How the PIPELINE is tuned: how much a commit carries, and how long a partial
182
+ * batch waits for company.
183
+ *
184
+ * Separate from `ProjectionSupervisionOptions` because these are dials on a
185
+ * different machine. These two size a TRANSACTION and a latency ceiling, in
186
+ * milliseconds and events, and their consequences are visible within one batch;
187
+ * the supervision figures size a RECOVERY POLICY measured in minutes of outage,
188
+ * and nothing about them is observable until something has already failed.
189
+ * Reading them as one undifferentiated bag is how `maxNoProgressRestarts` gets
190
+ * retuned as though it were a throughput knob.
191
+ *
192
+ * Every figure is a build-time DEFAULT, not a contract: the mechanisms are fixed
193
+ * by ADR-0007, the numbers are judgement calls sized against
194
+ * `@kairos-es/store-postgres`'s own defaults and are meant to be overridden by a
195
+ * deployment that knows its own latency budget (and by tests, which want tiny
196
+ * windows).
197
+ *
198
+ * A judgement call is still not a free choice: zero, negative, fractional and
199
+ * infinite figures are not "aggressive tuning", they are wirings whose failure
200
+ * mode is a hot loop or a permanently frozen view with nothing on any error
201
+ * channel. So this surface fails at its boundary, exactly as `Tag`, `ReadLimit`
202
+ * and `ReadPostgresConfig` do — `BatchSize` is BRANDED, so a bad value is
203
+ * rejected at the caller's own `.make(...)` and a plain integer literal does not
204
+ * compile at all, while `batchWindow` is judged by the read side's construction
205
+ * gate before anything is forked, because `Duration.DurationInput` is a union with
206
+ * no primitive to refine. `projectionWiringFault` carries that reasoning, and
207
+ * judges the supervisor's three restart durations in the same pass under the same
208
+ * rule — one home for the bound, whichever machine the figure tunes.
209
+ */
210
+ export interface ProjectionPipelineOptions {
211
+ /**
212
+ * Maximum events per commit. Default `256` — half the store's `catchUpPageSize`
213
+ * default, so one transaction stays small while still amortising round-trips
214
+ * across a catch-up. Raise it for throughput, lower it to shrink the
215
+ * transaction (and the amount of work a lost checkpoint guard discards).
216
+ *
217
+ * A branded positive integer: write `BatchSize.make(512)`, not `512`.
218
+ */
219
+ readonly batchSize?: BatchSize
220
+
221
+ /**
222
+ * How long a partial batch waits before committing anyway. Default
223
+ * `'50 millis'` — an order of magnitude under the store's 1-second
224
+ * `pollInterval`, so batching never dominates end-to-end lag. This is a latency
225
+ * ceiling, not a delay: a full `batchSize` commits immediately.
226
+ *
227
+ * Positive and finite, checked at construction.
228
+ */
229
+ readonly batchWindow?: Duration.DurationInput
230
+ }
231
+
232
+ /**
233
+ * Everything `runProjection` can be tuned with: the pipeline's two figures plus
234
+ * the supervisor's four and its two hooks.
235
+ *
236
+ * ## Why FLAT, when the two halves are deliberately distinct types
237
+ *
238
+ * A nested `{ pipeline, supervision }` would state the split at every call site,
239
+ * and it was rejected for a reason worth recording. Overriding one figure of a
240
+ * shared tuning constant is the dominant idiom here — `{ ...RUNNER_OPTIONS,
241
+ * batchSize: ONE_PER_BATCH }` appears throughout this repo's tests and examples
242
+ * — and under a nested shape the obvious spelling of that,
243
+ * `{ ...OPTIONS, supervision: { onRestart } }`, SILENTLY DROPS every other
244
+ * supervision figure, because a spread is shallow. That is precisely the class of
245
+ * quiet mis-tuning the branding and the construction-time checks exist to
246
+ * prevent, and a shape that invites it would be paying in silent bugs for a
247
+ * distinction that types can carry for free.
248
+ *
249
+ * So the distinction lives in the TYPES: two documented interfaces a reader (or
250
+ * an IDE) can see the boundary in, one flat object a caller writes. The two are
251
+ * disjoint by construction, and `buildAndFork` splits them by forwarding each half
252
+ * to the machine that reads it — the pipeline's two figures into the stream, and the
253
+ * supervision half into `superviseOnProgress` as one spread of whatever the caller
254
+ * wrote plus the three RESOLVED durations named after it. That second split is
255
+ * carried by types too (`UnresolvedSupervisionOptions` and
256
+ * `ResolvedSupervisionTuning`); the call site says how.
257
+ */
258
+ export interface ProjectionRunnerOptions
259
+ extends ProjectionPipelineOptions,
260
+ ProjectionSupervisionOptions {}
261
+
262
+ /**
263
+ * ONE materialisation's wiring with every default already applied: the bound view,
264
+ * the key and durability read off it, and the five figures that will actually run.
265
+ *
266
+ * This is the RESOLVE phase's whole output, and every field of it is either judged
267
+ * by the construction gate or used by the pipeline below — usually both, which is
268
+ * the point. `batchSize` is the one exception in the other direction: it is BRANDED,
269
+ * so the gate has nothing left to judge, but it is resolved here anyway because a
270
+ * default the pipeline applied for itself would be a figure resolved in a second
271
+ * place.
272
+ *
273
+ * The three restart figures are not restated here: this interface EXTENDS
274
+ * `ResolvedSupervisionTuning`, which is the type `superviseOnProgress` requires and
275
+ * the type `UnresolvedSupervisionOptions` is the complement of. One declaration
276
+ * therefore serves both ends — the resolve step cannot produce a set of figures the
277
+ * supervisor does not require, and `buildAndFork` cannot forward a set missing one.
278
+ * It stays FLAT despite the `extends`, and deliberately: `MaterialisationWiring`
279
+ * reads those fields flat, so a nested `supervision` sub-object would ripple into
280
+ * the construction gate and its pure suite for no gain.
281
+ *
282
+ * PACKAGE-INTERNAL and deliberately not re-exported: it is the seam between the two
283
+ * runner entry points, not a shape a caller ever writes. Nothing on it mentions the
284
+ * read model's `E` or its `R`, which is what lets one monomorphic record be shared
285
+ * by a single wiring and by a LIST of them — see `runProjections.ts` for why a type
286
+ * parameterised by one `Materialisation` in this position would not work.
287
+ */
288
+ export interface ResolvedProjection extends ResolvedSupervisionTuning {
289
+ /** The view store this materialisation writes into, bound to its key. */
290
+ readonly view: KeyedProjectionStore
291
+
292
+ /**
293
+ * The key the commits will use, read OFF the bound view rather than beside it:
294
+ * the read model cannot name a key its store is not bound to, so annotations,
295
+ * defect messages and failure payloads all report the key that actually runs.
296
+ */
297
+ readonly key: CheckpointKey
298
+
299
+ /** The VIEW's half of R2, passed straight through from the bound store. */
300
+ readonly viewDurability: Durability
301
+
302
+ readonly batchSize: BatchSize
303
+ readonly batchWindow: Duration.DurationInput
304
+ }
305
+
306
+ /**
307
+ * RESOLVE: apply every default and read both call-independent facts off the bound
308
+ * view, so the gate can judge exactly the figures the pipeline will run.
309
+ *
310
+ * A PURE function — no `Effect`, no context, no clock — which is what lets BOTH
311
+ * runner entry points share one resolution site. That is the whole reason it exists
312
+ * as a function rather than as a paragraph of `runProjection`'s body: the gate is
313
+ * only as good as the argument it is handed, and two resolution sites are free to
314
+ * drift into approving a figure the pipeline does not use. One site, and the figure
315
+ * that was judged cannot differ from the figure that runs, for one wiring and for N
316
+ * of them alike.
317
+ *
318
+ * The five `??` defaults are resolved HERE and ONCE. `batchSize` and `batchWindow`
319
+ * go into the pipeline, the three restart durations into the supervision options,
320
+ * where `superviseOnProgress` requires all three for exactly that reason.
321
+ *
322
+ * `maxNoProgressRestarts` is deliberately absent, as it is from the gate: it is
323
+ * BRANDED, so its boundary was the caller's own `.make(...)`, nothing here has to
324
+ * resolve it, and its default stays where its wall-clock arithmetic is documented.
325
+ *
326
+ * It takes the BOUND view rather than the unbound port because `key` and
327
+ * `viewDurability` are its two reads and both are on the bound façade. The UNBOUND
328
+ * store is still the only thing that can answer "are these two entries the same
329
+ * store?" — `forKey` deliberately does not expose what it closed over — so the gate
330
+ * takes that identity as a field of its own, from whichever caller holds one.
331
+ */
332
+ export const resolveProjection = (
333
+ view: KeyedProjectionStore,
334
+ options?: ProjectionRunnerOptions,
335
+ ): ResolvedProjection => ({
336
+ view,
337
+ key: view.key,
338
+ viewDurability: view.durability,
339
+ batchSize: options?.batchSize ?? DEFAULT_BATCH_SIZE,
340
+ batchWindow: options?.batchWindow ?? DEFAULT_BATCH_WINDOW,
341
+ restartMinDelay: options?.restartMinDelay ?? DEFAULT_RESTART_MIN_DELAY,
342
+ restartMaxDelay: options?.restartMaxDelay ?? DEFAULT_RESTART_MAX_DELAY,
343
+ restartResetAfter: options?.restartResetAfter ?? DEFAULT_RESTART_RESET_AFTER,
344
+ })
345
+
346
+ /**
347
+ * A defect surfaced from the drain, converted into a value the supervisor can act
348
+ * on.
349
+ *
350
+ * **Why the conversion exists.** `DcbEventStore.subscribe` is `E = never` by
351
+ * contract (ADR-0002: infrastructure faults are defects, not channel values), so a
352
+ * store whose live tail fails beyond its own retry budget DIES rather than failing.
353
+ * Converting that death into a typed error at the runner's boundary is what makes
354
+ * it actionable: a defect can only be caught, whereas a value can be counted,
355
+ * logged, matched on, and fed to a supervisor that decides between backoff and
356
+ * give-up.
357
+ *
358
+ * **Why the boundary is DRAIN-WIDE**, which is what the name reports. The
359
+ * conversion sits outside the whole `runDrain`, so what arrives is a defect from
360
+ * any stage of it: the subscription's own death, the codec's decode, the read
361
+ * model's `apply`, or a `ProjectionStore` implementation that dies where the port
362
+ * says it should fail. They all end the run identically, and the progress-keyed
363
+ * breaker is the right arbiter for all of them, since it restarts while the
364
+ * checkpoint moves and gives up with a logged `ProjectionStalled` when it does
365
+ * not. Narrowing the conversion to the subscription stage would instead let an
366
+ * `apply` defect escape `Effect.retry` — which sees failures, never defects — and
367
+ * kill the daemon fibre outright: no restart, no `ProjectionStalled`, no logged
368
+ * give-up, and since `runProjection` is infallible, no route to an operator at
369
+ * all. The boundary must equally not WIDEN to `catchAllCause`; the comment at the
370
+ * conversion site sets out why.
371
+ *
372
+ * `defect` is `unknown` because it is somebody else's defect and the runner must
373
+ * not pretend to know its shape.
374
+ */
375
+ export class PipelineDied extends Data.TaggedError('PipelineDied')<{
376
+ readonly key: CheckpointKey
377
+ readonly defect: unknown
378
+ }> {
379
+ /**
380
+ * Why this class fills `message` at all: so the fault renders ITSELF.
381
+ *
382
+ * `Data.TaggedError` sets `name` to the tag and leaves `message` empty, so an
383
+ * unfilled tagged error stringifies to its bare tag — "the pipeline died", with
384
+ * no hint of what killed it, and `PipelineDied: ECONNRESET` is not the same log
385
+ * line. Filled, `Error.prototype.toString` yields `name: message`, so
386
+ * `String(fault)`, `Cause.pretty` (which reads `message` off anything
387
+ * `instanceof Error`) and every log line built from either report the field that
388
+ * says WHY — and no table of tag-to-label outside the class has to be kept in
389
+ * step with the fields inside it.
390
+ *
391
+ * `String(this.defect)` rather than anything cleverer, for exactly the reason
392
+ * `defect` is `unknown` above: plucking a `.message`, sniffing a `_tag` or
393
+ * `JSON.stringify`-ing it would each be the runner pretending to know the shape
394
+ * of somebody else's defect, and the last of them THROWS on a `bigint`.
395
+ *
396
+ * A GETTER rather than a `message` field, which keeps `defect` the single
397
+ * declaration of the fact. Why a prototype accessor is safe here, how far filling
398
+ * the field reaches and the structured `toJSON` shape it deliberately leaves
399
+ * untouched are set out once for all four of these getters on
400
+ * `ProjectionStoreError` in `ProjectionStore.ts`.
401
+ */
402
+ override get message(): string {
403
+ return String(this.defect)
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Everything one run of the pipeline can fail with: the runner's own four faults
409
+ * plus the read model's `E`, which passes through `apply` and `commit` untouched.
410
+ *
411
+ * Internal, because it is the supervisor's INPUT rather than anything a caller
412
+ * sees: the supervisor collapses all of it into `ProjectionStalled` (or into
413
+ * another restart), so the only failure on the daemon's error channel is that.
414
+ */
415
+ type RunnerFault<E> =
416
+ | CheckpointSuperseded
417
+ | CodecError
418
+ | ProjectionStoreError
419
+ | PipelineDied
420
+ | E
421
+
422
+ /**
423
+ * A running projection: the bound view store it maintains, the key it maintains it
424
+ * under, and the daemon fibre doing the maintaining.
425
+ *
426
+ * The fibre is exposed rather than hidden so a caller can `Fiber.poll` it (is this
427
+ * projection still up?), `Fiber.await` it, or `Fiber.interrupt` it early — the
428
+ * last being the documented answer to "give up immediately", which
429
+ * `MaxNoProgressRestarts` deliberately does not offer as a supervision policy. It
430
+ * is NOT the teardown mechanism: the fibre is forked into the enclosing `Scope`,
431
+ * so closing that scope interrupts it deterministically and waits for it, which is
432
+ * what makes a shutdown graceful — the in-flight commit either completes or was
433
+ * never started, and either way the checkpoint matches the view.
434
+ *
435
+ * A full `RuntimeFiber` rather than something `Fiber.await`-shaped (an
436
+ * `Effect<never, ProjectionStalled>`, which would leave awaiting the terminal
437
+ * failure as the only thing a caller could do), for two reasons that arrived
438
+ * together. Awaiting is no longer how a stall is noticed — `onStalled` is, and it
439
+ * works under `projectionLayer` too, where there IS no handle because the daemon
440
+ * is discarded on purpose — so narrowing this type would be narrowing towards the
441
+ * weaker of the two routes. And the two capabilities it would remove, polling and
442
+ * interrupting, are the ones with no equivalent on an awaitable: "is it still
443
+ * running?" cannot be asked of an effect that only completes when it is not, and
444
+ * the early stop is guidance this repo gives elsewhere in prose.
445
+ *
446
+ * ## Why the BOUND STORE is carried, and why here rather than beside the handle
447
+ *
448
+ * Because it is what tells N runners of ONE read model apart wherever their keys do
449
+ * not — which is the ordinary case, a distinct `PartitionId` being the escape hatch
450
+ * for two materialisations COHABITING one store rather than the normal shape. Under
451
+ * one `ProjectionId`, wherever no entry named a partition, the N `CheckpointKey`s are
452
+ * EQUAL — which is the intended shape, a key naming a cursor WITHIN a store, so equal
453
+ * keys across two stores are one read model materialised twice rather than a clash —
454
+ * and a handle carrying only that key therefore cannot name which cursor it advances.
455
+ * `runProjections` did the `forKey` binding for the caller, so without this field
456
+ * every observer of a multi-materialisation call had to re-derive a binding the
457
+ * library had already made, from a key and a store it had to pair up again by hand.
458
+ *
459
+ * Carried ON the runner rather than returned beside it (`{ runner, store }` per
460
+ * entry) because of where the two entry points get their store from. `runProjection`
461
+ * is HANDED the bound store by its caller, so a pair would hand back what that caller
462
+ * already holds; `runProjections` is the one place the caller does not hold it. One
463
+ * field on the handle covers both, and it keeps "one materialisation, one key, one
464
+ * runner" readable off a single value.
465
+ *
466
+ * What it does NOT do is pair a runner back with the entry that asked for it. An
467
+ * entry hands `runProjections` an UNBOUND port and `forKey` deliberately does not
468
+ * expose what it closed over, so this field can be OBSERVED but not matched against a
469
+ * store the caller still holds; the returned ENTRY ORDER is what states that pairing,
470
+ * and `runProjections` says so where it fixes the order.
471
+ */
472
+ export interface ProjectionRunner {
473
+ /**
474
+ * The key this projection advances — `store.key`, so it names the cursor the
475
+ * commits actually use rather than one stated beside it.
476
+ *
477
+ * Kept as a field of its own although the store carries it, because it is what
478
+ * almost every reader wants (log lines, a `ProjectionStalled` payload, an
479
+ * operator's "which projection?") and reaching it through the store would be a
480
+ * hop for nothing.
481
+ */
482
+ readonly key: CheckpointKey
483
+
484
+ /**
485
+ * The view store this daemon commits through, bound to `key` — the runner's own
486
+ * cursor, and the discriminator among N materialisations of one read model.
487
+ *
488
+ * Exposed for OBSERVING: `readCheckpoint` (how far has THIS materialisation got?),
489
+ * `durability` and `key`. `commit` and `resetCheckpoint` are on it because
490
+ * `KeyedProjectionStore` is one interface, and neither should be called while this
491
+ * runner is up — the daemon is this cursor's SINGLE WRITER (ADR-0007), so a write
492
+ * from anywhere else races the guarded advance it owns and costs the loser a
493
+ * restart. A rebuild takes the runner down first (close the enclosing `Scope`) and
494
+ * resets the cursor after.
495
+ */
496
+ readonly store: KeyedProjectionStore
497
+
498
+ /** The daemon fibre. Interrupted when the enclosing `Scope` closes. */
499
+ readonly fibre: Fiber.RuntimeFiber<void, ProjectionStalled>
500
+ }
501
+
502
+ /**
503
+ * The decode stage a build step is handed: `decodeSlices`' return type, named.
504
+ *
505
+ * It stays GENERIC in the input stream's own error and requirement, exactly as
506
+ * `decodeSlices` is, so one stage built for a call can be applied to every
507
+ * subscription in it. Naming the type is what makes passing the stage as an
508
+ * ARGUMENT possible without instantiating it at the first call site — which is what
509
+ * `runProjections` needs, since it builds one stage and hands it to N pipelines.
510
+ *
511
+ * `RSlices` is the slice record's OWN requirement, which rides out in the decoded
512
+ * stream's `R`. It is the REQUIREMENT rather than the record it came from because
513
+ * `RequirementOf<T>` is a conditional type: nothing could recover `T` from a stage
514
+ * handed over as a value, so a record parameter would be uninferable here.
515
+ *
516
+ * TWO references to this alias are LOAD-BEARING: `PreparedProjection.decode`, the
517
+ * SOURCE, where the stage is handed over, and `buildAndFork`'s own `decode`
518
+ * parameter, the TARGET, where it is taken. With source and target both written
519
+ * through the alias, TypeScript infers straight from its type ARGUMENT
520
+ * and never has to unify two generic signatures' return unions, which it cannot do
521
+ * here (`SR` and `RSlices` are both naked variables in one union, so it gives up and
522
+ * infers `unknown`). Widen EITHER and the failure is loud in both entry points at
523
+ * once — `RSlices` collapses to `unknown` and neither declared requirement is
524
+ * satisfiable — which is measured rather than asserted.
525
+ *
526
+ * `prepareProjection`'s own local is annotated through the alias too, and that one is
527
+ * DOCUMENTARY rather than load-bearing — it restates `decodeSlices`' declared return
528
+ * type, so dropping it compiles clean. Worth saying, because the field and the local
529
+ * look like one precaution written twice and only one of them is holding anything up.
530
+ */
531
+ export type DecodeStage<RSlices> = <SE, SR>(
532
+ events: Stream.Stream<SequencedEvent, SE, SR>,
533
+ ) => Stream.Stream<DecodedEvent, SE | CodecError, SR | RSlices | Serializer>
534
+
535
+ /**
536
+ * PREPARE's whole output: the ONE composed query every subscription in a call
537
+ * subscribes with, and the ONE decode stage every pipeline in it runs.
538
+ *
539
+ * Both are pure derivations of a single `slices` value and both are handed straight
540
+ * to `buildAndFork`, which holds no slice record of its own — so there is no second
541
+ * record either could have been derived differently from. PACKAGE-INTERNAL, like
542
+ * `ResolvedProjection` beside it: it is a seam between the phases, not a shape a
543
+ * caller ever writes.
544
+ */
545
+ export interface PreparedProjection<T extends Record<string, SliceProjection>> {
546
+ /** `composeProjections`' merged query, shared by the call's N subscriptions. */
547
+ readonly query: Query
548
+
549
+ /** `decodeSlices`' stage, shared by the call's N pipelines. */
550
+ readonly decode: DecodeStage<RequirementOf<T>>
551
+ }
552
+
553
+ /**
554
+ * PREPARE: the whole middle of a projection call — the log's durability, the merged
555
+ * query, the construction gate's verdict and the decode stage — between the
556
+ * per-entry RESOLVE above and the per-entry BUILD below.
557
+ *
558
+ * ## Why it is a phase of its own
559
+ *
560
+ * Everything in it is a function of the CALL rather than of any one materialisation,
561
+ * and both entry points ran these same five steps in this same order before it
562
+ * existed. `runProjection` is the N = 1 case: it hands over a singleton entry list
563
+ * and its own preamble and gets back the pair `runProjections` gets back for N.
564
+ *
565
+ * ## What the ONE body is FOR: the gate is consulted before the codec
566
+ *
567
+ * This is worth more than the duplication it removes. `decodeSlices` refuses a slice
568
+ * record carrying two DISTINCT `Schema`s under one event type, and refuses it by
569
+ * THROWING — which inside this `Effect.gen` is a defect, exactly as the gate's
570
+ * verdict is. A record wrong in BOTH ways therefore has two sentences available and
571
+ * nothing but the ORDER of two statements decides which one its author reads. The
572
+ * gate's is the right one: it is the read side's own rule about the wiring in front
573
+ * of that author, where the codec's is about a merge the write path shares and
574
+ * `decode.ts` owns.
575
+ *
576
+ * That order is now a property of ONE body — the gate call above, the `decodeSlices`
577
+ * call below — for both entry points at once. It used to be a claim two function
578
+ * bodies each made in prose, free to drift, and pinned for only one of them. It is
579
+ * pinned for both now: `test/runProjections.test.ts`'s "the GATE is consulted before
580
+ * the codec" case and `test/runner.construction.test.ts`'s sibling each drive a
581
+ * record breaking both rules and assert which sentence comes back. Neither RULE is
582
+ * under test in either — `packages/codec`'s decode suite and
583
+ * `projectionWiringFault.test.ts` own those.
584
+ *
585
+ * ## What the caller keeps
586
+ *
587
+ * The PREAMBLE, and only the preamble: `runProjection` names the projection and its
588
+ * partition, `runProjections` the projection alone, and this function joins whichever
589
+ * it was to the gate's sentence about the RULE with one `': '`. That is the division
590
+ * `projectionWiringFault`'s doc fixes — the gate knows the rule, the entry point
591
+ * knows whose wiring it is — and moving the single `Effect.die` here changes only
592
+ * WHERE the two halves meet, never which half writes which. It is built eagerly, one
593
+ * interpolation per construction, rather than as a thunk: a call that is about to
594
+ * subscribe to an event log does not need that deferred, and a thunk reads worse at
595
+ * both call sites.
596
+ *
597
+ * ## Why both derivations happen HERE
598
+ *
599
+ * The runner needs only the merged QUERY from the composition — the fold is the read
600
+ * model's own `apply` — but it must be the SAME merge a fold would use, so it comes
601
+ * from `core`'s `composeProjections` rather than a local query union that could drift
602
+ * from it. It is also the gate's input, and the gate can only judge it at
603
+ * construction because the union is a pure function of the slices.
604
+ *
605
+ * Both derivations run ONCE per call rather than once per entry or once per restart,
606
+ * so N subscriptions share a single `Query` object and N pipelines a single merged
607
+ * `type -> Schema` table. Sharing the decode stage is safe because the `Serializer`
608
+ * is read from context INSIDE it, per stream, so one stage does not freeze one
609
+ * serialiser across entries. `DecodeStage`'s own doc says which references to that
610
+ * alias hold the inference up, and which do not.
611
+ *
612
+ * The cast erases each slice's own state and event types down to `AnyProjection`. It
613
+ * is sound because `composeProjections` only ever folds a slice through its OWN
614
+ * `evolve`, never across two, so the per-slice pairing the erasure hides is the one
615
+ * thing it cannot violate — and `T`'s precise shape is recovered on the way out, in
616
+ * `CompositeState<T>`. It is unavoidable rather than merely convenient: a slice
617
+ * record is keyed by a caller's literal union, and no signature in `core` is generic
618
+ * over that union AND over each member's state, so the variance has to be discharged
619
+ * somewhere. Once, now, rather than at each entry point.
620
+ *
621
+ * ## Why `EventLogDurability` is read from CONTEXT here
622
+ *
623
+ * Because it is R2's log half, and one log is ONE fact —
624
+ * `EventLogDurability.ts` argues why it is a service declared beside the store layer
625
+ * rather than a field per read model. Reading it once for the CALL is what that
626
+ * argument already wanted, and it is deliberately not part of `resolveProjection`,
627
+ * which is per ENTRY and pure. It is this function's only requirement, and through it
628
+ * the reason both entry points declare one.
629
+ */
630
+ export const prepareProjection = <T extends Record<string, SliceProjection>>(
631
+ slices: T,
632
+ materialisations: ReadonlyArray<MaterialisationWiring>,
633
+ preamble: string,
634
+ ): Effect.Effect<PreparedProjection<T>, never, EventLogDurability> =>
635
+ Effect.gen(function* () {
636
+ // The LOG's half of R2, from CONTEXT rather than from any read model. Each
637
+ // entry's VIEW half already rode in on the resolved wiring.
638
+ const eventLogDurability = yield* EventLogDurability
639
+
640
+ const composite = composeProjections(
641
+ slices as Record<string, AnyProjection>,
642
+ )
643
+
644
+ // ## ASSERT — one gate, one verdict, one `Effect.die`
645
+ //
646
+ // Five rules over four vocabularies, in the order `projectionWiringFault`
647
+ // declares and argues; this function supplies the values and classifies the
648
+ // verdict, and its caller supplied the preamble that says WHOSE wiring is wrong.
649
+ // Both entry points reach this before they fork anything, so a rejected wiring
650
+ // has read no checkpoint and opened no subscription.
651
+ const fault = projectionWiringFault({
652
+ eventLogDurability,
653
+ query: composite.query,
654
+ slices,
655
+ materialisations,
656
+ })
657
+ if (fault !== undefined) {
658
+ return yield* Effect.die(new Error(`${preamble}: ${fault}`))
659
+ }
660
+
661
+ // BELOW the gate, and that is the ordering this phase exists to own.
662
+ // `decodeSlices` carries the CODEC's own construction refusal — two DISTINCT
663
+ // `Schema`s under one event type — and it THROWS, which here is a second defect;
664
+ // a record wrong in both ways must meet the gate's sentence first. Moving this
665
+ // call above the gate would still die with nothing forked, only with the wrong
666
+ // sentence, which is why the doc above names the two suites that pin it.
667
+ //
668
+ // It takes the RECORD rather than a pre-merged schema table for the same reason
669
+ // the query is composed here: the decode lookup and the subscription query
670
+ // cannot then come from different records.
671
+ const decode: DecodeStage<RequirementOf<T>> = decodeSlices(slices)
672
+
673
+ return { query: composite.query, decode }
674
+ })
675
+
676
+ /**
677
+ * BUILD and FORK: one resolved wiring plus the three shared derivations in, one
678
+ * supervised daemon out.
679
+ *
680
+ * This is everything the two runner entry points have in common from the gate's
681
+ * verdict onwards — the subscription, the decode, the micro-batching, the guarded
682
+ * commit, the supervisor and the `forkScoped`. It is package-internal and takes no
683
+ * `ReadModel`, so `runProjections` reaches it directly rather than by re-entering
684
+ * `runProjection` per entry, which is what lets one call resolve, judge and derive
685
+ * ONCE and still run N identical pipelines. `read-postgres`'s
686
+ * `test/courseRosterGraduation.test.ts` rests on the two callers running literally
687
+ * the same pipeline, so this function staying the only build step is load-bearing.
688
+ *
689
+ * It requires no `EventLogDurability`. That service is read by `prepareProjection`
690
+ * above — the one place it is read at all — for the gate, and has no part in what
691
+ * runs; the two public entry points DECLARE it only because they run that phase.
692
+ *
693
+ * `query` and `decode` arrive as ARGUMENTS rather than being derived here from a
694
+ * slice record, which is a structural gain rather than a cost: the build step holds
695
+ * no slice record at all, so there is no second record it could derive a different
696
+ * query or a different decode table from. It is the same move `decodeSlices` itself
697
+ * made when it collapsed a two-step protocol into one call.
698
+ */
699
+ export const buildAndFork = <RSlices, E, R>(
700
+ resolved: ResolvedProjection,
701
+ build: {
702
+ readonly query: Query
703
+ readonly decode: DecodeStage<RSlices>
704
+ readonly apply: (
705
+ batch: Arr.NonEmptyReadonlyArray<DecodedEvent>,
706
+ ) => Effect.Effect<void, E, R>
707
+ readonly options: ProjectionRunnerOptions | undefined
708
+ },
709
+ ): Effect.Effect<
710
+ ProjectionRunner,
711
+ never,
712
+ Scope.Scope | DcbEventStore | Serializer | RSlices | R
713
+ > =>
714
+ Effect.gen(function* () {
715
+ const {
716
+ view,
717
+ key,
718
+ batchSize,
719
+ batchWindow,
720
+ restartMinDelay,
721
+ restartMaxDelay,
722
+ restartResetAfter,
723
+ } = resolved
724
+ const store = yield* DcbEventStore
725
+
726
+ /**
727
+ * One run of the pipeline: resolve the resume point, subscribe from it, and
728
+ * drain until the stream ends, is interrupted, or dies.
729
+ *
730
+ * `Effect.scoped` per RUN, not per runner, so a restart releases the previous
731
+ * subscription's resources (the store's listener, its poll fibre) before the
732
+ * next one acquires them — otherwise every restart would leak a subscription
733
+ * into the runner's outer scope.
734
+ */
735
+ const runOnce: Effect.Effect<
736
+ void,
737
+ RunnerFault<E>,
738
+ Serializer | RSlices | R
739
+ > = Effect.gen(function* () {
740
+ // Re-read on EVERY attempt. This is the resynchronisation: after a lost
741
+ // checkpoint guard the stored position is whatever the winner advanced it
742
+ // to, and the restart picks that up instead of resuming from a stale
743
+ // expectation.
744
+ const stored = yield* view.readCheckpoint
745
+
746
+ // The expectation for the next guarded advance, threaded through the run.
747
+ // A `Ref` rather than a fold accumulator because the batch committer is a
748
+ // `Stream.mapEffect` callback; correctness rests on the single-writer
749
+ // property (concurrency 1), so there is exactly one fibre reading and
750
+ // writing this.
751
+ const expected = yield* Ref.make(stored)
752
+
753
+ const commitBatch = (
754
+ batch: Chunk.Chunk<DecodedEvent>,
755
+ ): Effect.Effect<
756
+ void,
757
+ E | CheckpointSuperseded | ProjectionStoreError,
758
+ R
759
+ > =>
760
+ Effect.gen(function* () {
761
+ const events = Chunk.toReadonlyArray(batch)
762
+
763
+ // Total on the empty chunk. `groupedWithin` is `aggregateWithin` over a
764
+ // `collectAllN` sink on a `spaced` schedule, so a window that elapses
765
+ // with nothing buffered has an empty collection to emit; even if a
766
+ // given version suppressed that, resting the runner's correctness on an
767
+ // undocumented internal would be a bug waiting for an upgrade. There is
768
+ // nothing to apply and no last position, and committing `expected` onto
769
+ // itself would be a pointless write that could lose its guard to a
770
+ // legitimate winner and restart the pipeline for nothing.
771
+ if (!Arr.isNonEmptyReadonlyArray(events)) {
772
+ return
773
+ }
774
+
775
+ // The last event's position, AS IS. Never `expected + 1` and never any
776
+ // other arithmetic: positions are strictly increasing but NOT gapless
777
+ // (a rolled-back append burns ids), so the gap between two delivered
778
+ // events is normal and an increment would invent a position that either
779
+ // never existed or belongs to an event this query does not match.
780
+ const next = Arr.lastNonEmpty(events).sequenced.position
781
+ const from = yield* Ref.get(expected)
782
+
783
+ // The port owns the bracket: the view writes go IN, so the checkpoint
784
+ // advance cannot happen outside the transaction that carries them. A
785
+ // lost guard fails here, which aborts the whole commit and restarts the
786
+ // run — `expected` is deliberately NOT advanced on a failure. There is
787
+ // no `key` argument: the store is bound to one, so the batch cannot be
788
+ // committed against a cursor other than the one it was read from.
789
+ yield* view.commit({
790
+ expected: from,
791
+ next,
792
+ viewWrites: build.apply(events),
793
+ })
794
+
795
+ yield* Ref.set(expected, next)
796
+ })
797
+
798
+ yield* store.subscribe(build.query, stored).pipe(
799
+ build.decode,
800
+ Stream.groupedWithin(batchSize, batchWindow),
801
+ Stream.mapEffect(commitBatch, { concurrency: 1 }),
802
+ Stream.runDrain,
803
+ // `catchAllDefect`, NOT `catchAllCause`. The subscription's failure mode
804
+ // is a DEFECT (`subscribe` is `E = never`) and that is what the
805
+ // supervisor needs as a value — but the OTHER way this drain ends is
806
+ // INTERRUPTION, when the enclosing `Scope` closes at shutdown.
807
+ // `catchAllCause` would swallow that too, turning a graceful teardown
808
+ // into a `PipelineDied` that the supervisor would dutifully retry —
809
+ // restarting a subscription inside a scope that is being torn down.
810
+ // `catchAllDefect` re-fails any cause carrying no defect, so interruption
811
+ // propagates untouched and the fibre dies when it is told to.
812
+ //
813
+ // Placed outside the WHOLE drain, so it converts a defect from any stage
814
+ // — decode, `apply`, a store implementation that dies — and not only the
815
+ // subscription's own death; the fault says PIPELINE for that reason.
816
+ // `PipelineDied`'s doc carries the reasoning (in short: they all end the
817
+ // run identically, and a narrower conversion would let an `apply` defect
818
+ // bypass the supervisor entirely).
819
+ Effect.catchAllDefect((defect) => new PipelineDied({ key, defect })),
820
+ )
821
+ }).pipe(Effect.scoped)
822
+
823
+ // ## SUPERVISE — one attempt in, a supervised daemon out
824
+ //
825
+ // What goes with it is the whole of what supervision knows about this runner:
826
+ // the key it reports under, the bound store's `readCheckpoint` as the progress
827
+ // signal, whatever the caller wrote in the UNRESOLVED half of its supervision
828
+ // options, and the three restart durations RESOLVED above.
829
+ //
830
+ // What DOES the work is that those three were resolved ONCE, in
831
+ // `resolveProjection`, so the supervisor receives exactly the figures the gate
832
+ // judged — the argument is on `SuperviseOnProgressOptions` in
833
+ // `superviseOnProgress.ts`, which requires all three for exactly that reason.
834
+ //
835
+ // WHICH FIELDS travel is carried by the TYPES, not by an enumeration of the
836
+ // forwardable ones. `UnresolvedSupervisionOptions` — the type
837
+ // `SuperviseOnProgressOptions` is built from — is `ProjectionSupervisionOptions`
838
+ // MINUS the resolved three, so a new OPTIONAL supervision field lands in it and
839
+ // travels through this call with no edit here, while a new RESOLVED figure lands
840
+ // in `ResolvedSupervisionTuning` instead and leaves the literal below missing a
841
+ // required property. Neither direction rests on a comment or on a test
842
+ // remembering to be extended, which is what the enumeration this replaced could
843
+ // not say.
844
+ //
845
+ // The REST-DESTRUCTURE is the other half, and it is about the OBJECT rather than
846
+ // its type. `build.options` is the caller's own FLAT record, so a raw
847
+ // `restartMinDelay` it wrote is copied by a spread whatever the spread's static
848
+ // type says it holds; discarded here, it is not in the object to be copied at
849
+ // all. Without it the resolved figure would stay on top only while it was written
850
+ // LAST in the literal, and a transposition of two adjacent lines would typecheck
851
+ // and then run the unjudged figure — a rule that has to be remembered is one an
852
+ // edit reading as pure tidying can break.
853
+ //
854
+ // The three discards are the one place a resolved field is named twice, and they
855
+ // cannot silently fall out of step: a fourth resolved figure stops the literal
856
+ // below compiling until it is named there, and its author is then one line from
857
+ // this destructure.
858
+ //
859
+ // The caller's two PIPELINE figures ride along in the copy and are inert: the
860
+ // supervisor reads its options by name and has no field either could reach.
861
+ // Excluding them would be two more discards for nothing.
862
+ //
863
+ // What is destructured is the OPTIONS and not `resolved`: that record is flat and
864
+ // also carries `view`, `viewDurability`, `batchSize` and `batchWindow`, so
865
+ // spreading it wholesale would ride four dead fields into a supervisor's options
866
+ // object — the hazard `runProjections.ts` names where it builds its entry literal
867
+ // explicitly.
868
+ //
869
+ // `?? {}` rather than the conditional spreads this replaced.
870
+ // `exactOptionalPropertyTypes` bars setting an optional key to an explicit
871
+ // `undefined`, and this sets none: spreading an object contributes only the keys
872
+ // it has, and an absent whole options object contributes none at all.
873
+ //
874
+ // The progress signal goes in as a VALUE, so nothing has been read from the
875
+ // view store yet: a runner rejected above still has not touched it.
876
+ //
877
+ // Nothing is handed in for RENDERING the runner's faults on the supervisor's
878
+ // log lines. All four set `message`, so `String(fault)` is
879
+ // `Error.prototype.toString` — `'ProjectionStoreError: ECONNRESET'` — and each
880
+ // fault labels itself wherever it is reported, from the field that says why.
881
+ // `CheckpointSuperseded`'s is the interesting one and its own doc carries the
882
+ // argument: its `expected` is a `bigint`, but a `bigint` interpolated into a
883
+ // `string` is digits, so the value class the log reduction below keeps away from
884
+ // a serialiser never reaches one through `message`.
885
+ //
886
+ // The residual error is `ProjectionStalled` and nothing else: the schedule
887
+ // never terminates on its own, so the only way out with a failure is the
888
+ // breaker giving up. `superviseOnProgress` carries that reasoning.
889
+ const {
890
+ restartMinDelay: _rawMinDelay,
891
+ restartMaxDelay: _rawMaxDelay,
892
+ restartResetAfter: _rawResetAfter,
893
+ ...supervisionOverrides
894
+ }: ProjectionRunnerOptions = build.options ?? {}
895
+
896
+ const daemon: Effect.Effect<
897
+ void,
898
+ ProjectionStalled,
899
+ Serializer | RSlices | R
900
+ > = superviseOnProgress(runOnce, {
901
+ key,
902
+ progress: view.readCheckpoint,
903
+ ...supervisionOverrides,
904
+ restartMinDelay,
905
+ restartMaxDelay,
906
+ restartResetAfter,
907
+ })
908
+
909
+ // ## FORK
910
+ //
911
+ // `forkScoped`, so the daemon's lifetime IS the caller's scope: no manual
912
+ // shutdown handle to forget, and scope close interrupts and awaits the fibre
913
+ // deterministically. A plain `Effect.fork` would tie it to the calling
914
+ // fibre's lifetime, which for a wiring effect that returns immediately would
915
+ // kill the daemon on the spot.
916
+ const fibre = yield* Effect.forkScoped(daemon)
917
+
918
+ // The BOUND view goes back with the fibre, which is what lets a returned handle
919
+ // name its own cursor. It is the same object the pipeline above commits through,
920
+ // not a re-binding: `runProjections` resolved it from a store its caller never
921
+ // bound, and equal keys across N entries leave it as the only discriminator.
922
+ return { key, store: view, fibre }
923
+ })