@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,694 @@
1
+ /**
2
+ * The read side's CONSTRUCTION GATE: every wiring the read side refuses ON ITS OWN
3
+ * RULES, judged in one place, in one declared order, each verdict one
4
+ * `string | undefined`.
5
+ *
6
+ * ## ONE entry point over five rules, at three arities
7
+ *
8
+ * `projectionWiringFault` judges ONE CALL: the log the whole graph subscribes to,
9
+ * the one composed query and the one slice record every materialisation shares, and
10
+ * a LIST of the resolved per-entry wirings. `runProjection` hands it a singleton and
11
+ * `runProjections` hands it N, and both do so before either forks anything.
12
+ *
13
+ * The rules divide by what they are a function of, not by who called:
14
+ *
15
+ * - the COLLISION rung is a function of the whole list, pairwise;
16
+ * - R2 and the tuning figures are functions of ONE entry, judged for every entry in
17
+ * turn;
18
+ * - the servable grammar and the per-slice tag rule are functions of the SHARED
19
+ * query and the SHARED slice record, so they are judged once for the call.
20
+ *
21
+ * One record with the shared fields stated once and the entries as a list is what
22
+ * lets all three arities compose in one `??` chain. What the gate is stays what it
23
+ * was: one home, one return type, one declared order (below), and a caller that
24
+ * classifies the verdict under a preamble it was handed.
25
+ *
26
+ * ## The ONE refusal that is deliberately not here
27
+ *
28
+ * Naming it is what keeps "one place" honest. A slice record holding two DISTINCT
29
+ * `Schema`s under one event type is refused by `@kairos-es/codec`'s
30
+ * `decodeSlices`, which `prepareProjection` calls a few lines BELOW this gate on
31
+ * behalf of both entry points — so a rejected record still dies as a defect with
32
+ * nothing forked, exactly as a rejected wiring does, but the sentence a reader
33
+ * meets is the codec's. That rule
34
+ * belongs to the codec because BOTH of its consumers need it and only one is on
35
+ * the read side: the write path reaches the same merge through
36
+ * `buildDecisionModel` and never comes near this module, so a rung here
37
+ * would be a second home for one rule, covering half the hazard. `decode.ts` owns
38
+ * the argument; this is a pointer, not a restatement.
39
+ *
40
+ * Which of the two a record wrong in BOTH ways meets FIRST is not this module's to
41
+ * state either, and it has exactly one home: `prepareProjection` in
42
+ * `ProjectionRunner.ts`, the single body in which the gate call sits above the
43
+ * `decodeSlices` call, for both entry points at once. It was a claim two function
44
+ * bodies each made in prose until that phase was extracted, and pinned for only one
45
+ * of them.
46
+ *
47
+ * ## Why this is a module and not a paragraph of `runProjection`
48
+ *
49
+ * `runProjection` was doing two unrelated jobs. One is to refuse a wiring that can
50
+ * only ever be wrong — five rules, none of which mentions a stream, a scope or a
51
+ * fibre, and only one of which so much as compares two stores for identity. The
52
+ * other is to build and fork a pipeline. The refusals had
53
+ * grown to roughly two fifths of that function's body and most of its doc, and
54
+ * three of them wrote their own `Effect.die` with their own copy of the preamble
55
+ * naming the projection.
56
+ *
57
+ * The precedent is this package's own: `superviseOnProgress` was a deep module
58
+ * trapped inside the same function, and pulling it out is what made a supervision
59
+ * quantity pinnable against a stub instead of through the whole pipeline. This is
60
+ * the other half of that function, and it is deeper still by the measure that
61
+ * matters here — its interface is a record in and a sentence out, while everything
62
+ * behind it is five unrelated rules over four vocabularies (a layer graph, a
63
+ * tuning literal, a query grammar, a set of view stores).
64
+ *
65
+ * The fifth rule arriving from a DIFFERENT caller is what the module having been
66
+ * extracted bought. `runProjections` needed a construction-time refusal of its own,
67
+ * and there was already a place for it — with a settled return type, a settled
68
+ * division of labour with its caller, and a suite that states a rule against a
69
+ * record literal. Inline, it would have been a sixth thing inside a second pipeline
70
+ * function, in a second style, with a second preamble. It landed as a RUNG of the
71
+ * one chain rather than as a second entry point, which is what keeps the declared
72
+ * order below a property of this file rather than of which caller a reader opened.
73
+ *
74
+ * ## Why `string | undefined`, and why the shared record takes no `CheckpointKey`
75
+ *
76
+ * `undefined` for a sound wiring, otherwise the SPECIFIC sentence: what is wrong,
77
+ * what it would do, and how to fix it. It does not throw, does not call `Effect.die`
78
+ * and does not return an `Either` — ONE exit route and one return type, which is
79
+ * what lets five rules over four vocabularies compose with `??` and be read as five
80
+ * lines.
81
+ *
82
+ * The shared half of the record names no projection and no partition. The per-entry
83
+ * list does carry a resolved `CheckpointKey` each, but for the collision rung to
84
+ * COMPARE rather than for anything to report: no rule interpolates a `key` into its
85
+ * sentence except the collision rung, which names the partition the two offenders
86
+ * share.
87
+ *
88
+ * ATTRIBUTION and CLASSIFICATION both sit outside. This gate's ONE caller,
89
+ * `prepareProjection`, owns the single `Effect.die`; the PREAMBLE naming the
90
+ * projection (and, where there is one to name, the partition) comes from one level
91
+ * further out still, from whichever entry point was called. So the preamble is
92
+ * written once per ENTRY POINT rather than once per rule — which is exactly what
93
+ * collapsed three `Effect.die` sites to one. The gate
94
+ * writes the third party to that sentence: where there is more than one entry, the
95
+ * per-entry rungs prefix their verdict with the offending INDEX, in the same
96
+ * `materialisations[i]` vocabulary the collision rung already uses. Over a singleton
97
+ * there is no index to name and none is written, so a single wiring's message is
98
+ * what it always was. The same arrangement repeats one level DOWN, inside this file:
99
+ * `positiveFiniteDuration` owns the `Duration` bound and each of the four tuning
100
+ * call sites supplies the `role` clause, because the predicate knows the rule and
101
+ * its caller knows what breaks.
102
+ *
103
+ * The classification half of that is worth spelling out. That a mis-wiring is a
104
+ * DEFECT rather
105
+ * than a channel value is a claim about `runProjection`'s and `runProjections`'
106
+ * signatures — both error channels are `never` because everything that can go wrong
107
+ * once a projection is RUNNING belongs to the supervisor — and a gate returning a
108
+ * string makes no such claim, so it can be called from a plain function, a test, or
109
+ * an `Effect.gen` without three different failure conventions.
110
+ *
111
+ * ## The ORDER is a decision, and this is the only place it is stated
112
+ *
113
+ * The rules run OUTWARD-IN, from the widest wiring decision to the narrowest.
114
+ *
115
+ * The order is over RULES and not over ENTRIES. Entry 1's layer-graph mistake is
116
+ * still wider than entry 0's options-literal mistake, so rung 2 is judged for every
117
+ * entry before rung 3 is judged for any. An entry-major loop would instead let ARRAY
118
+ * POSITION decide which fault an author sees, and position carries no meaning here:
119
+ * the entries are a set, and are a tuple only because inference needs one.
120
+ *
121
+ * 1. **The collision rung**, once over the whole SET. Two materialisations sharing
122
+ * one store under one key is a property of the WHOLE CALL rather than of any one
123
+ * wiring in it, so it is judged before any single wiring is looked at — the same
124
+ * outward-in principle, one level up. And a colliding pair is worth hearing about
125
+ * BEFORE a mis-tuned figure in one of them, because fixing the tuning would leave
126
+ * the two runners racing one cursor. It cannot fire below N = 2, so it is vacuous
127
+ * for the singleton `runProjection` passes.
128
+ * 2. **R2, durability ordering**, PER ENTRY in entry order. A property of the LAYER
129
+ * GRAPH — which log, which view store — decided before any read model existed. If
130
+ * that pairing is illegal then nothing else about the runner matters, because the
131
+ * view it maintains cannot be true however well tuned the daemon is.
132
+ * 3. **The tuning figures**, PER ENTRY in entry order. What the caller wrote in the
133
+ * options literal, a smaller and more local mistake than picking the wrong pair
134
+ * of layers.
135
+ * 4. **The store's own servable grammar**, once over the SHARED composed query,
136
+ * reported in the STORE's vocabulary by `core`'s own `assertServableQuery`.
137
+ * 5. **The read side's stricter per-slice tag rule**, once over the SHARED slice
138
+ * record, and it MUST stay after (4): it catches only what that grammar admits,
139
+ * since a lone type-only item is servable. Were it first, a tagless-plus-tagged
140
+ * union — a violation of the shared grammar — would be reported as a read-side
141
+ * slice convention instead of in the vocabulary every other caller of that
142
+ * grammar meets it in.
143
+ *
144
+ * Hoisting the two SHARED rungs ahead of the two per-entry ones, which the
145
+ * shared-versus-per-entry axis would suggest, was considered and rejected: it
146
+ * changes the message a single wiring wrong in two ways gets, which is a fixed
147
+ * constraint. (2)-(3) ahead of (4)-(5) also restores the relative order that held
148
+ * before the supervisor was extracted, when its three restart durations were checked
149
+ * with `batchWindow` rather than a module away. But the point of writing the order
150
+ * down is not the restoration: it is that the order is now FIVE LINES a reader can
151
+ * read, rather than an emergent property of which of two modules happens to look at
152
+ * a field first.
153
+ *
154
+ * ## Why the input is a record of ALREADY-RESOLVED values
155
+ *
156
+ * Every field is a value, not an option: the caller has already applied its
157
+ * defaults — through `resolveProjection`, the one resolution site both callers
158
+ * share — so the gate judges exactly the figures that will run and cannot be handed
159
+ * one default while the pipeline uses another. That also holds the defaults
160
+ * themselves to the rules they document, for one decode per runner. The keys are
161
+ * RESOLVED `CheckpointKey`s for the same reason and one more: a partition a caller
162
+ * omitted is `DEFAULT_PARTITION`, so a rung judging the raw options would miss the
163
+ * commonest form of the collision, which is two entries that name no partition at
164
+ * all.
165
+ *
166
+ * The two INTEGER options are absent from the record on purpose. `BatchSize` and
167
+ * `MaxNoProgressRestarts` are branded, so their boundary is the caller's own
168
+ * `.make(...)` — a bad value is refused a stack frame away from where it was
169
+ * written and never reaches here at all.
170
+ *
171
+ * `slices` is a SHARED field and is typed as narrowly as the check needs — a query
172
+ * per slice, nothing else — which keeps this module free of a `@kairos-es/codec`
173
+ * import for a rule that has no opinion about schemas, payloads or folds.
174
+ * `Record<string, SliceProjection>` satisfies it structurally, so the record an entry
175
+ * point was handed reaches this rung unconverted.
176
+ *
177
+ * An entry's `cursorIdentity` is typed `object` and never called: it is an identity
178
+ * token the collision rung compares by REFERENCE. `object` rather than `ProjectionStore`
179
+ * because a `KeyedProjectionStore` is not structurally one (`readCheckpoint` is an
180
+ * `Effect` on the façade and a function on the port), so a caller holding only a
181
+ * bound view could not fill the field; and because `object` forbids `undefined`,
182
+ * which would otherwise make two unidentified entries compare equal.
183
+ *
184
+ * It is INTERNAL: nothing here is re-exported from the package index. The gate is
185
+ * how the two runner entry points refuse a wiring, not a capability the library
186
+ * offers — a caller with a wiring to validate has `runProjection` or
187
+ * `runProjections` itself. BOTH judge all five rules before either forks anything.
188
+ */
189
+ import { assertServableQuery, type Query } from '@kairos-es/core'
190
+ import { Duration, Either, Option } from 'effect'
191
+ import { durabilityOrderingFault } from './EventLogDurability'
192
+ import type { CheckpointKey, Durability } from './ProjectionStore'
193
+
194
+ /**
195
+ * One `Duration.DurationInput` option, decoded and bounded: the `Duration` if it
196
+ * is positive and finite, otherwise the sentence explaining what it is and what
197
+ * that would do.
198
+ *
199
+ * FILE-PRIVATE, and applied four times, all of them by `tuningFault` below. It owns
200
+ * the BOUND and the shape of the sentence; each call site owns `role`, the clause
201
+ * saying what THIS option does and therefore what breaks at each degenerate end,
202
+ * because a generic "must be positive and finite" tells an author the rule but not
203
+ * the consequence, and the consequence is what makes the fix obvious. So the four
204
+ * call sites differ by exactly the thing that differs between them, and one bound is
205
+ * not stated four ways — the module doc's division, nested one level down.
206
+ *
207
+ * Three things about the checks are worth knowing rather than rediscovering:
208
+ *
209
+ * - **`Duration.decodeUnknown`, not `Duration.decode`.** The latter THROWS
210
+ * `Error('Invalid DurationInput')` on an unparseable string, which inside
211
+ * `Effect.gen` would already become a defect — but one whose message names
212
+ * neither the option nor the value. The `Option`-returning form (`decodeUnknown`
213
+ * is `Option.liftThrowable(decode)`) lets the fault carry both.
214
+ * - **`Duration.isZero` covers NEGATIVES too.** Every `Duration` constructor routes
215
+ * through one internal `make` that maps a non-positive `number` or `bigint` to the
216
+ * zero value, and the `[seconds, nanos]` form maps a `-Infinity` component to zero
217
+ * outright (verified against `effect@3.22`'s `Duration.ts`), so `-5`,
218
+ * `'-5 millis'` and `0` are indistinguishable by the time they are a `Duration`.
219
+ * There is therefore no separate "negative" case to test for — and the zero
220
+ * message has to SAY that, because a reader told "restartMinDelay is zero" while
221
+ * looking at `'-5 millis'` in their own wiring would otherwise conclude the check
222
+ * was reading the wrong field.
223
+ * - **Infinity is rejected everywhere, including `restartResetAfter`.** An infinite
224
+ * figure in any of these positions stops a TIMING mechanism from being one, and
225
+ * every claim the runner's docs make — a latency ceiling, a bounded backoff, a
226
+ * five-minute budget — stops being true. "Effectively never" remains expressible
227
+ * as a large finite figure, so a uniform rule costs a caller nothing and is one
228
+ * predicate rather than four. It needs its own check: `Duration.isZero` answers
229
+ * `false` for the infinite `Duration`, so the two bounds are independent.
230
+ *
231
+ * `String(input)`, never `JSON.stringify`, for the same reason `superviseOnProgress`
232
+ * reduces its log annotations with `String`: `DurationInput` admits a `bigint` of
233
+ * nanoseconds, and `JSON.stringify` THROWS on one — inside the very code path whose
234
+ * job is to explain a mistake clearly.
235
+ */
236
+ const positiveFiniteDuration = (
237
+ name: string,
238
+ input: Duration.DurationInput,
239
+ role: string,
240
+ ): Either.Either<Duration.Duration, string> => {
241
+ const decoded = Duration.decodeUnknown(input)
242
+ if (Option.isNone(decoded)) {
243
+ return Either.left(
244
+ `${name} is not a Duration.DurationInput (got ${String(input)}). Pass a ` +
245
+ "string like '50 millis', a Duration, a number of milliseconds, a " +
246
+ 'bigint of nanoseconds, or a [seconds, nanos] tuple.',
247
+ )
248
+ }
249
+ if (!Duration.isFinite(decoded.value)) {
250
+ return Either.left(`${name} is infinite (got ${String(input)}). ${role}`)
251
+ }
252
+ if (Duration.isZero(decoded.value)) {
253
+ return Either.left(
254
+ `${name} is zero (got ${String(input)}; Duration clamps any negative ` +
255
+ `input to zero, so a negative arrives here as zero too). ${role}`,
256
+ )
257
+ }
258
+ return Either.right(decoded.value)
259
+ }
260
+
261
+ /**
262
+ * The runner's four `Duration`s, decoded and bounded: `undefined` when the tuning
263
+ * is sound, otherwise the sentence naming the FIRST violation.
264
+ *
265
+ * ## WHY these are checked at all when both integer options are branded
266
+ *
267
+ * `Duration.DurationInput` is a UNION — a `Duration`, millis as a `number`, nanos
268
+ * as a `bigint`, a `[seconds, nanos]` tuple, or a `'50 millis'` string — so there
269
+ * is no single primitive for `Schema.Int`-plus-`Schema.brand` to refine, and
270
+ * validity is only knowable after `Duration.decode` runs. Branding one anyway would
271
+ * mean one of two losses: a constructor every call site must thread a raw input
272
+ * through, or a field narrowed to `Duration` alone, which makes
273
+ * `batchWindow: '50 millis'` — the ergonomic point of `DurationInput`, and what
274
+ * every test and example in this repo writes — unwritable. So the check happens at
275
+ * CONSTRUCTION instead, where the caller turns it into a defect. Same discipline as
276
+ * `BatchSize` and `MaxNoProgressRestarts` (fail at the boundary, report where the
277
+ * author can act), different mechanism, because the type is a different shape.
278
+ *
279
+ * ## Why all four together, when they tune two different machines
280
+ *
281
+ * `batchWindow` paces the PIPELINE and the three restart figures pace the
282
+ * SUPERVISOR, and those two modules deliberately know nothing of each other. But
283
+ * the mistake is one mistake — a degenerate figure whose failure mode is a hot loop
284
+ * or a permanently frozen view with nothing on any error channel — and an author
285
+ * who wrote `restartMinDelay: 0` beside `batchWindow: 0` should not have to fix one
286
+ * and re-run to be told about the other by a differently-worded message from a
287
+ * different module. `Either.all` short-circuits on the first violation, so one
288
+ * degenerate figure is one sentence naming one option; what the single home buys is
289
+ * that the sentence is built the same way whichever field it is about.
290
+ *
291
+ * ## The one CROSS-FIELD rule, and the one deliberately absent
292
+ *
293
+ * `restartMinDelay <= restartMaxDelay` is checked, in the spirit of
294
+ * `ReadPostgresConfig`'s own cross-field filter: the names assert an ordering, and
295
+ * inverting it is silently absorbed rather than rejected. `Schedule.union` takes the
296
+ * SHORTER of its two arms, so a min above the max deletes the exponential
297
+ * entirely — every restart sleeps exactly `restartMaxDelay`, the documented sawtooth
298
+ * never happens, and the no-progress budget is off by however far apart the two
299
+ * figures are. Equality is allowed: min == max is a deliberate flat pacing, not a
300
+ * contradiction.
301
+ *
302
+ * `restartResetAfter` is deliberately NOT cross-checked against the delays,
303
+ * although a reset threshold below `restartMinDelay` does flatten the backoff (the
304
+ * schedule resets before it can grow). The difference is that no ordering is implied
305
+ * there — the two figures measure different things, elapsed retrying time against
306
+ * one sleep — so a short reset is a tuning choice expressible another way
307
+ * (min == max), whereas min above max is a statement that contradicts itself.
308
+ * Rejecting the former would be inventing a policy rather than enforcing a rule.
309
+ *
310
+ * The words `invalid tuning` are this sentence's own, not the caller's preamble's:
311
+ * the preamble says which projection is mis-WIRED, and this says the wiring fault is
312
+ * a mis-set figure rather than a bad pair of layers or a mis-built slice. They are a
313
+ * CONSTANT rather than a literal at each of the two return sites below, so the phrase
314
+ * an operator greps for is written once even though the rule is one function.
315
+ */
316
+ const MIS_TUNED = 'invalid tuning —'
317
+
318
+ const tuningFault = (tuning: {
319
+ readonly batchWindow: Duration.DurationInput
320
+ readonly restartMinDelay: Duration.DurationInput
321
+ readonly restartMaxDelay: Duration.DurationInput
322
+ readonly restartResetAfter: Duration.DurationInput
323
+ }): string | undefined => {
324
+ const checked = Either.all({
325
+ batchWindow: positiveFiniteDuration(
326
+ 'batchWindow',
327
+ tuning.batchWindow,
328
+ 'batchWindow is how long a PARTIAL batch waits before committing anyway: ' +
329
+ 'at zero Stream.groupedWithin spins, emitting empty chunks without ever ' +
330
+ 'pulling the subscription; infinite means a partial batch never flushes, ' +
331
+ 'so a lone live event waits for batchSize-1 more events that may never ' +
332
+ 'come.',
333
+ ),
334
+ restartMinDelay: positiveFiniteDuration(
335
+ 'restartMinDelay',
336
+ tuning.restartMinDelay,
337
+ 'restartMinDelay is the BASE Schedule.exponential multiplies: at zero ' +
338
+ 'every delay in the series is zero and the supervisor restarts in a hot ' +
339
+ 'loop; infinite means the first restart never arrives.',
340
+ ),
341
+ restartMaxDelay: positiveFiniteDuration(
342
+ 'restartMaxDelay',
343
+ tuning.restartMaxDelay,
344
+ 'restartMaxDelay is the backoff CEILING, unioned with the exponential: ' +
345
+ 'Schedule.union takes the shorter arm, so at zero it caps every sleep to ' +
346
+ 'nothing and the supervisor restarts in a hot loop; infinite removes the ' +
347
+ 'cap and lets the exponential grow without bound.',
348
+ ),
349
+ restartResetAfter: positiveFiniteDuration(
350
+ 'restartResetAfter',
351
+ tuning.restartResetAfter,
352
+ 'restartResetAfter is the elapsed-retrying threshold at which the backoff ' +
353
+ 'starts over: at zero it resets on every decision so the backoff never ' +
354
+ 'grows, and infinite means it never resets — either way the sawtooth is ' +
355
+ "gone, and the no-progress budget's default is sized in WALL CLOCK " +
356
+ 'against that sawtooth, so it would no longer buy the minutes it ' +
357
+ 'documents.',
358
+ ),
359
+ })
360
+
361
+ if (Either.isLeft(checked)) {
362
+ return `${MIS_TUNED} ${checked.left}`
363
+ }
364
+
365
+ if (
366
+ Duration.greaterThan(
367
+ checked.right.restartMinDelay,
368
+ checked.right.restartMaxDelay,
369
+ )
370
+ ) {
371
+ return (
372
+ `${MIS_TUNED} restartMinDelay ` +
373
+ `(${Duration.format(checked.right.restartMinDelay)}) is greater than ` +
374
+ `restartMaxDelay (${Duration.format(checked.right.restartMaxDelay)}). ` +
375
+ 'Schedule.union takes the SHORTER of its two arms, so an inverted pair ' +
376
+ 'silences the exponential entirely: every restart would sleep exactly ' +
377
+ 'restartMaxDelay, the documented sawtooth would not happen, and the ' +
378
+ 'no-progress budget would be wrong by however far apart the two figures ' +
379
+ 'are. Swap them, or raise the ceiling.'
380
+ )
381
+ }
382
+
383
+ return undefined
384
+ }
385
+
386
+ /**
387
+ * The composed subscription query against the STORE's servable grammar:
388
+ * `undefined` when the store would serve it, otherwise `core`'s own sentence about
389
+ * why it would not.
390
+ *
391
+ * ## Why `core`'s `assertServableQuery` and not a predicate of our own
392
+ *
393
+ * It is the very function every engine applies inside `read`/`subscribe`/`append`,
394
+ * so calling it is what makes ONE grammar rather than two that agree until one is
395
+ * edited. A local re-implementation would be a second definition of servability
396
+ * living in a package that does not own the concept, free to drift in either
397
+ * direction: too strict and it refuses a query the store would happily serve, too
398
+ * lax and the violation resurfaces at the first stream pull, inside the forked
399
+ * fibre, as a `PipelineDied` that only becomes visible as a `ProjectionStalled`
400
+ * once the breaker trips on a checkpoint that never moved — several backoff sleeps
401
+ * later, describing a stall rather than the mis-built slice that caused it.
402
+ *
403
+ * ## Why the THROW is converted rather than propagated
404
+ *
405
+ * `assertServableQuery` throws, because its own callers are store methods where a
406
+ * throw inside an `Effect` is already the defect it should be. Here it is one of
407
+ * five rules whose results compose, so letting it throw would give this gate two
408
+ * exit routes — a returned sentence for four rules and an exception for the
409
+ * fifth — and every caller would then need both a `??` chain and a `try`. Catching
410
+ * it keeps ONE exit route, and the caller keeps one `Effect.die`.
411
+ *
412
+ * The message is passed through VERBATIM, which is the point of the conversion
413
+ * rather than a detail of it: the violation goes on being reported in the store's
414
+ * vocabulary, in the same words the same rule produces when a store rejects the
415
+ * same query, so an author who meets it twice meets it once. The `instanceof`
416
+ * fallback is for the shape of the contract rather than for anything observed —
417
+ * `throw` admits any value, and `String(error)` is the honest reading of one this
418
+ * module cannot inspect.
419
+ */
420
+ const servableQueryFault = (wiring: {
421
+ readonly query: Query
422
+ }): string | undefined => {
423
+ try {
424
+ assertServableQuery(wiring.query)
425
+ return undefined
426
+ } catch (error) {
427
+ return error instanceof Error ? error.message : String(error)
428
+ }
429
+ }
430
+
431
+ /**
432
+ * The read side's OWN per-slice rule: `undefined` when every slice carries at least
433
+ * one tag, otherwise the sentence naming the first slice that does not.
434
+ *
435
+ * Checked slice by slice because the composed grammar above cannot see it: a SINGLE
436
+ * tagless slice composes to one type-only item, which IS servable, so the store
437
+ * would run that subscription happily. It is still a programming error, for two
438
+ * reasons. It does not COMPOSE — adding a second, tagged slice later turns today's
439
+ * accepted query into a tagless-plus-tagged union the store rejects, so the mistake
440
+ * would surface as a break in an unrelated change rather than at the slice that
441
+ * caused it. And a read model that genuinely is global should SAY so with an
442
+ * explicit `system:…` tag, which is the convention every other boundary in the repo
443
+ * already follows (CONTEXT); saying it by omission reads as a forgotten tag.
444
+ *
445
+ * The sentence names the offending SLICE, the rule and the fix, and deliberately
446
+ * not the projection: the caller's preamble carries that, for every rule at once,
447
+ * together with the partition where the caller has one worth naming.
448
+ */
449
+ const taglessSliceFault = (wiring: {
450
+ readonly slices: Record<string, { readonly query: Query }>
451
+ }): string | undefined => {
452
+ for (const [name, slice] of Object.entries(wiring.slices)) {
453
+ const untagged =
454
+ slice.query.items.length === 0 ||
455
+ slice.query.items.some((item) => item.tags.length === 0)
456
+ if (untagged) {
457
+ return (
458
+ `read-model slice '${name}' carries NO tags. Every read-model slice ` +
459
+ 'must carry at least one tag: build it with .build({ tags: [...] }), ' +
460
+ 'and give a genuinely GLOBAL slice an explicit system tag ' +
461
+ "(tagIdentity('system').make('…')) rather than no tag at all. A " +
462
+ 'tagless slice is servable on its own — it is the single type-only fast ' +
463
+ 'path — which is exactly why it is rejected here rather than by the ' +
464
+ 'store: composing it with any tagged slice later would produce an ' +
465
+ 'unservable tagless-plus-tagged union query, so the mistake would ' +
466
+ 'surface far from its cause.'
467
+ )
468
+ }
469
+ }
470
+ return undefined
471
+ }
472
+
473
+ /**
474
+ * ONE materialisation as the gate sees it: an identity token, its resolved key, its
475
+ * half of R2 and the four `Duration`s that tune it.
476
+ *
477
+ * `ResolvedProjection` in `ProjectionRunner.ts` satisfies all of it but
478
+ * `cursorIdentity`, which is why both entry points can build an entry by spreading a
479
+ * resolved wiring beside the identity they hold.
480
+ *
481
+ * Exported to the PACKAGE and no further, which is one more symbol than the gate
482
+ * used to offer and still nothing a consumer can reach: `prepareProjection` — the
483
+ * phase that calls this gate on behalf of both entry points — names the entry LIST
484
+ * in its own signature, and a structural re-declaration there would be a second
485
+ * statement of what the gate judges, free to drift from this one.
486
+ *
487
+ * The field is NOT called `store`, and that is the point rather than taste. The two
488
+ * entry points put DIFFERENT objects in it — `runProjections` the UNBOUND port, which is
489
+ * the last surface at which sameness is askable since `forKey` deliberately does not
490
+ * expose what it closed over; `runProjection` the BOUND view it was handed, where a
491
+ * singleton makes the rung vacuous anyway — and each is right for its own arity.
492
+ * Under the name `store` those two meanings sat behind one word in a record built by
493
+ * SPREADING a `Materialisation`, whose own `store` field means only the first of
494
+ * them. Nothing would have complained if a future field on either side shadowed the
495
+ * other, the type being `object`. What the rung needs is not "a store" but "the value
496
+ * whose IDENTITY separates one cursor from another", which is what this name says.
497
+ *
498
+ * Typed `object` because the rung COMPARES it and never calls it (a
499
+ * `KeyedProjectionStore` is not structurally a `ProjectionStore`), and because
500
+ * `object` also refuses `undefined` — which would otherwise make two unidentified
501
+ * entries compare equal and manufacture a collision out of nothing.
502
+ *
503
+ * `batchSize` is deliberately absent along with `maxNoProgressRestarts`: both are
504
+ * branded, so both were bounded at the caller's own `.make(...)`.
505
+ */
506
+ export type MaterialisationWiring = {
507
+ readonly cursorIdentity: object
508
+ readonly key: CheckpointKey
509
+ readonly viewDurability: Durability
510
+ readonly batchWindow: Duration.DurationInput
511
+ readonly restartMinDelay: Duration.DurationInput
512
+ readonly restartMaxDelay: Duration.DurationInput
513
+ readonly restartResetAfter: Duration.DurationInput
514
+ }
515
+
516
+ /**
517
+ * Apply a PER-ENTRY rule to every entry in turn, returning the first sentence one of
518
+ * them yields — prefixed with the offending index where, and only where, there is
519
+ * more than one entry to index into.
520
+ *
521
+ * The condition is what keeps a single wiring's message byte-identical to what it
522
+ * was before the two callers shared this chain, and it keeps the module internally
523
+ * consistent: an index appears exactly where there is a set. The collision rung
524
+ * needs no such condition because it cannot fire below N = 2, so its indices are
525
+ * always meaningful.
526
+ *
527
+ * The prefix copies the collision rung's own vocabulary — `materialisations[i]` —
528
+ * rather than inventing a second way to name an entry, so an author who meets both
529
+ * meets one.
530
+ */
531
+ const perEntry = <A>(
532
+ entries: ReadonlyArray<A>,
533
+ rule: (entry: A) => string | undefined,
534
+ ): string | undefined => {
535
+ for (let index = 0; index < entries.length; index += 1) {
536
+ const fault = rule(entries[index])
537
+ if (fault !== undefined) {
538
+ return entries.length > 1
539
+ ? `materialisations[${index}] — ${fault}`
540
+ : fault
541
+ }
542
+ }
543
+ return undefined
544
+ }
545
+
546
+ /**
547
+ * Judge ONE CALL's whole wiring: `undefined` when it is sound, otherwise the
548
+ * sentence describing the FIRST rule it breaks.
549
+ *
550
+ * Five rules, `??`-chained in the outward-in order the module doc argues for and
551
+ * fixes. `??` rather than an array of predicates or a collected list of every
552
+ * violation, for two reasons: the first fault is the one to fix — the later rules
553
+ * judge a wiring whose wider decisions are already known to be wrong, so their
554
+ * verdicts would be noise — and a chain of five named calls is the whole
555
+ * implementation, which is what makes the declared order checkable against the code
556
+ * rather than merely asserted about it.
557
+ *
558
+ * The two PER-ENTRY rungs go through `perEntry`, which is what keeps the chain
559
+ * rule-major: every entry is judged by rung 2 before any entry is judged by rung 3.
560
+ *
561
+ * Every field is a RESOLVED value: see the module doc for why (and for why the two
562
+ * integer options are not here at all).
563
+ */
564
+ export const projectionWiringFault = (wiring: {
565
+ readonly eventLogDurability: Durability
566
+ readonly query: Query
567
+ readonly slices: Record<string, { readonly query: Query }>
568
+ readonly materialisations: ReadonlyArray<MaterialisationWiring>
569
+ }): string | undefined =>
570
+ materialisationCollisionFault(wiring) ??
571
+ perEntry(wiring.materialisations, (entry) =>
572
+ durabilityOrderingFault({
573
+ eventLogDurability: wiring.eventLogDurability,
574
+ viewDurability: entry.viewDurability,
575
+ }),
576
+ ) ??
577
+ perEntry(wiring.materialisations, tuningFault) ??
578
+ servableQueryFault(wiring) ??
579
+ taglessSliceFault(wiring)
580
+
581
+ /**
582
+ * The SET-LEVEL rung: `undefined` when no two materialisations of one read model
583
+ * would fight over one cursor, otherwise the sentence naming the offending pair and
584
+ * the fix.
585
+ *
586
+ * It is the FIRST rung in the declared order despite being the last symbol in this
587
+ * file, which is layout rather than precedence: the four per-wiring predicates sit
588
+ * above the chain that composes them, and this one — the newest and by far the
589
+ * longest — sits below it rather than pushing them apart. The module doc argues the
590
+ * order.
591
+ *
592
+ * FILE-PRIVATE, like every other rung: the chain is the only entry point, so a
593
+ * caller cannot reach one rule without the four that are declared to precede or
594
+ * follow it. It keeps its NAME because ADR-0007 cites the predicate by name.
595
+ *
596
+ * ## The rule
597
+ *
598
+ * A fault when two entries share the SAME store REFERENCE — compared by identity,
599
+ * never called, which is why the field is typed `object` — and their resolved
600
+ * `CheckpointKey`s are equal in both halves. Both conditions are needed and
601
+ * neither is sufficient, which is the whole content of the rule and the reason it
602
+ * takes resolved keys rather than a caller's raw options.
603
+ *
604
+ * Why the pairing is a defect: a `CheckpointKey` addresses ONE cursor within ONE
605
+ * store, so two runners over that pair are two writers over one scalar guarded
606
+ * compare-and-set. Every batch races it; one wins, the loser's `commit` fails
607
+ * `CheckpointSuperseded` and its own view writes are aborted with it; the loser then
608
+ * resynchronises from the winner's position and does it again. Nothing errors out to
609
+ * an operator — the supervisor's breaker deliberately does NOT trip, because the
610
+ * shared signal keeps moving, which is exactly the competing-writer case
611
+ * `superviseOnProgress` is built to absorb — so the observable result is two views
612
+ * each holding an arbitrary subset of the events, indefinitely, at the schedule's
613
+ * rate. It is the "one MATERIALISATION, one runner" invariant of ADR-0007 — which
614
+ * that record's own amendment is careful to word that way rather than per READ
615
+ * MODEL, a read model being materialisable N ways on purpose — broken in the one way
616
+ * a single `runProjection` call cannot break it.
617
+ *
618
+ * ## PAIRWISE, deliberately
619
+ *
620
+ * Two nested loops over N, where N is the number of ways one read model is
621
+ * materialised — two or three in every shape this exists for, and bounded by how many
622
+ * view stores an application has. A map keyed on the store plus the flattened key
623
+ * would be asymptotically better and worse in every way that matters here: it would
624
+ * need a key flattening (with the separator-injection question `mapKey` in
625
+ * `inMemoryProjectionStore.ts` had to settle) and a composite map key that cannot
626
+ * hold an object identity, to save microseconds on a list of three at construction
627
+ * time. Pairwise says the rule in the shape the rule is written in.
628
+ *
629
+ * ## The two bounds, which are DECISIONS and not oversights
630
+ *
631
+ * It does not reject equal keys across DIFFERENT stores. That is the sound and
632
+ * intended shape, and the reason this rung needs the store identity at all: one read
633
+ * model realised N ways shares ONE `ProjectionId`, because a `CheckpointKey` is a
634
+ * lookup identifier within one store rather than a global name. Two stores holding
635
+ * the same key are two materialisations of one read model, each with its own cursor,
636
+ * which is precisely what `runProjections` is for —
637
+ * `packages/read-postgres/test/courseRosterGraduation.test.ts` asserts that key
638
+ * equality across an in-memory and a SQL store as its acceptance criterion.
639
+ *
640
+ * And it cannot detect two SEPARATELY CONSTRUCTED stores pointed at one physical
641
+ * namespace — two `makeSqlProjectionStore` calls on the same schema and table
642
+ * prefix, say. Reference equality is blind to that by construction, and the
643
+ * same class of limit is already recorded for the SQL backend's own schema isolation
644
+ * in `packages/read-postgres/src/internal/ddl.ts`, which can reject an identifier it
645
+ * would truncate but cannot know what another config points at. What reference
646
+ * equality does catch is the realistic mistake: ONE store value passed twice, which
647
+ * is what the ergonomic shape of the call invites — a caller who has one store to
648
+ * hand writes it into both entries.
649
+ *
650
+ * The sentence names the two entries by INDEX and the partition they share, states
651
+ * what the pairing would do, and gives both fixes — the one that is almost always
652
+ * wanted (one materialisation whose single `apply` writes both views inside the one
653
+ * `commit`) and the escape hatch that makes genuine cohabitation legal (a distinct
654
+ * `partition`). It names no projection: the caller's preamble carries that, for every
655
+ * rule at once.
656
+ */
657
+ const materialisationCollisionFault = (wiring: {
658
+ readonly materialisations: ReadonlyArray<{
659
+ readonly cursorIdentity: object
660
+ readonly key: CheckpointKey
661
+ }>
662
+ }): string | undefined => {
663
+ const { materialisations } = wiring
664
+ for (let left = 0; left < materialisations.length; left += 1) {
665
+ for (let right = left + 1; right < materialisations.length; right += 1) {
666
+ const one = materialisations[left]
667
+ const other = materialisations[right]
668
+ if (
669
+ one.cursorIdentity === other.cursorIdentity &&
670
+ one.key.projection === other.key.projection &&
671
+ one.key.partition === other.key.partition
672
+ ) {
673
+ return (
674
+ `colliding materialisations — materialisations[${left}] and ` +
675
+ `materialisations[${right}] share ONE ProjectionStore under the SAME ` +
676
+ `CheckpointKey (partition '${one.key.partition}'), so they would be two ` +
677
+ 'runners over one cursor. Every batch would race the same guarded ' +
678
+ "advance: one wins, the loser's commit fails CheckpointSuperseded and " +
679
+ 'its own view writes are aborted with it, and because the shared ' +
680
+ 'checkpoint keeps moving the supervisor never gives up — so the two ' +
681
+ 'views would each hold an arbitrary subset of the events, ' +
682
+ 'indefinitely, with nothing on any error channel. SEVERAL VIEWS IN ONE ' +
683
+ 'STORE is a supported shape and this is not how to reach it: it wants ' +
684
+ 'ONE materialisation whose single apply writes both views inside the ' +
685
+ 'one commit, under one checkpoint. Where two materialisations genuinely ' +
686
+ 'must cohabit one store, give one of them its own partition — a ' +
687
+ 'distinct PartitionId is a distinct CheckpointKey and therefore a ' +
688
+ 'cursor of its own.'
689
+ )
690
+ }
691
+ }
692
+ }
693
+ return undefined
694
+ }