@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
package/src/testing.ts ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Test-support for the READ side, shared across every kairos-es suite — and
3
+ * across a consumer's own suites — via the published `@kairos-es/read/testing`
4
+ * subpath.
5
+ *
6
+ * ## Why the read side needs a testing surface at all
7
+ *
8
+ * A projection is a DAEMON. Every assertion about a read model is therefore an
9
+ * assertion about a forked fibre that has not necessarily finished, so the very
10
+ * first thing anybody writing a read-model test has to invent is "run this
11
+ * projection until it has consumed the log, THEN look at the view". There is no
12
+ * other way to phrase it: `runProjection` returns as soon as the daemon is
13
+ * forked, on purpose, because starting a projection is not the same event as a
14
+ * projection having caught up.
15
+ *
16
+ * Left uninvented, that gap is filled by a sleep — and a sleep is the one answer
17
+ * that is wrong in both directions at once. Too short and the suite is flaky on a
18
+ * loaded machine; too long and every case pays for the worst machine anybody runs
19
+ * it on. So the answer here is to POLL THE OBSERVABLE STATE: re-read the stored
20
+ * checkpoint until it reaches the position under test, under a generous ceiling
21
+ * whose only job is to turn a genuine hang into a failed assertion NAMING the
22
+ * position that was never reached, rather than an undiagnosable framework
23
+ * timeout. A slow machine then takes longer; it does not fail.
24
+ *
25
+ * That reasoning was written out three times in this repo — in `read`'s own
26
+ * runner suite, in `read-postgres`'s live-log cases and in the worked example —
27
+ * with three slightly different timeout strings, which is the usual evidence that
28
+ * a library is missing a surface rather than that its tests are untidy. It is
29
+ * written once here, and everything else imports it.
30
+ *
31
+ * ## Why a published SUBPATH rather than a package
32
+ *
33
+ * `@kairos-es/core/testing` is the precedent and the test this module has to
34
+ * pass: a subpath shares its package's dependency surface, so it may exist only
35
+ * if it adds NO peer to that surface. Nothing below imports anything
36
+ * `@kairos-es/read` does not already peer (`effect`, `@kairos-es/core`,
37
+ * `@kairos-es/codec`), and in particular nothing here imports a test FRAMEWORK.
38
+ * There is no `expect`, no `it`, no `@effect/vitest`: what a caller gets is plain
39
+ * `Effect`s (and one pure function, `lastPosition`) run under whatever runner it
40
+ * already has. That is what keeps `@kairos-es/read`'s published peer set
41
+ * unchanged, and it is why this is not `@kairos-es/testing`, which peers
42
+ * `@effect/vitest` because a Given/When/Then surface genuinely needs one.
43
+ *
44
+ * ## The boundary against `@kairos-es/projection-store-contract-tests`
45
+ *
46
+ * That package publishes `keyFor` / `position` / `storedPosition` /
47
+ * `advanceCheckpoint`, and the overlap is apparent rather than real. Those are
48
+ * tools for proving the MULTI-KEY PORT: they brand raw strings and bigints into
49
+ * the port's domain types and perform the setup moves a guard case needs, they
50
+ * are addressed to somebody implementing a `ProjectionStore` BACKEND, and the
51
+ * package is private and unpublished. This module is addressed to somebody
52
+ * running a READ MODEL — an application author, or one of this repo's own suites
53
+ * — and every export that observes a view takes the `KeyedProjectionStore` a read
54
+ * model is actually handed, never a `(store, key)` pair. (`lastPosition` takes no
55
+ * store at all: it derives a convergence TARGET from what `append` returned, which
56
+ * is not an observation of a view.) Same domain, different surface, different
57
+ * publication status. Neither imports the other: `read` cannot depend on a suite
58
+ * that already depends on `read`.
59
+ *
60
+ * ## Platform-neutral, like the rest of the package
61
+ *
62
+ * No `node:*`, no `Date`, no host RNG. The poll interval and the ceiling are
63
+ * Effect `Duration`s scheduled through the `Clock`, so a caller who can drive the
64
+ * clock still can.
65
+ */
66
+ import type {
67
+ RequirementOf,
68
+ Serializer,
69
+ SliceProjection,
70
+ } from '@kairos-es/codec'
71
+ import type { DcbEventStore, Position } from '@kairos-es/core'
72
+ import { Array as Arr, type Duration, Effect, Option, type Scope } from 'effect'
73
+ import type { EventLogDurability } from './EventLogDurability'
74
+ import type {
75
+ ProjectionRunner,
76
+ ProjectionRunnerOptions,
77
+ } from './ProjectionRunner'
78
+ import type { KeyedProjectionStore } from './ProjectionStore'
79
+ import { type ReadModel, runProjection } from './runProjection'
80
+
81
+ /**
82
+ * How often the waiting fibre re-reads the checkpoint. Small, because the wait
83
+ * ends on the FIRST read that has got there and a coarse interval would add its
84
+ * own latency to every case; not zero, because each read is a real store
85
+ * operation (a SQL round trip on a durable backend).
86
+ */
87
+ const DEFAULT_POLL_INTERVAL: Duration.DurationInput = '2 millis'
88
+
89
+ /**
90
+ * The ceiling on convergence. Deliberately far larger than any well-behaved case
91
+ * needs: it exists to turn a genuine hang into a FAILED assertion naming the
92
+ * position that was never reached, not to police how fast a runner is on a loaded
93
+ * machine. Backends whose live tail has its own poll interval — Postgres wakes on
94
+ * NOTIFY but GUARANTEES delivery by polling — should raise it to several of those
95
+ * intervals rather than lower it.
96
+ */
97
+ const DEFAULT_TIMEOUT: Duration.DurationInput = '10 seconds'
98
+
99
+ /**
100
+ * How a convergence wait is paced and bounded.
101
+ *
102
+ * Both figures are per-DEPLOYMENT judgements rather than contract, in the same
103
+ * sense the runner's own tuning is: an in-memory log converges in microseconds
104
+ * and a containerised Postgres does not, so the defaults suit the former and the
105
+ * latter says so at its call site.
106
+ */
107
+ export interface AwaitCheckpointOptions {
108
+ /** How often to re-read the stored checkpoint. Default `'2 millis'`. */
109
+ readonly pollInterval?: Duration.DurationInput
110
+
111
+ /**
112
+ * How long to wait before treating the projection as hung. Default
113
+ * `'10 seconds'`. Exceeding it is a DEFECT, not a failure — see
114
+ * `awaitCheckpoint`.
115
+ */
116
+ readonly timeout?: Duration.DurationInput
117
+ }
118
+
119
+ /**
120
+ * The stored checkpoint of a read model's bound view store, right now.
121
+ *
122
+ * `orDie` because a `ProjectionStoreError` means the test's own fixture cannot
123
+ * read its own checkpoint. The port puts that on the ERROR channel for the
124
+ * runner's supervisor, which has to tell retry-with-backoff from give-up
125
+ * (ADR-0007) — but a test has no supervisor and nothing to retry, so surfacing it
126
+ * as a defect keeps a broken fixture clearly distinct from the case's own
127
+ * assertion failing, and keeps every signature here `E = never` so a case never
128
+ * has to widen its own error channel to look at a view.
129
+ */
130
+ export const storedCheckpoint = (
131
+ store: KeyedProjectionStore,
132
+ ): Effect.Effect<Position> => Effect.orDie(store.readCheckpoint)
133
+
134
+ /**
135
+ * Wait until the stored checkpoint has reached `target`, then return it.
136
+ *
137
+ * The read side's answer to "has the projection caught up yet?", and the reason
138
+ * a read-model test needs no sleep: it polls the OBSERVABLE state, so it ends the
139
+ * instant the runner has actually got there and no sooner. Comparison is `>=`
140
+ * rather than `===` and that is load-bearing — positions are strictly increasing
141
+ * but NOT gapless, so a target derived from an append may be overshot by a batch
142
+ * that carried later events too, and an equality test would then wait for a
143
+ * position the log will never store.
144
+ *
145
+ * A KEYED store rather than a `(store, key)` pair, matching the shape the package
146
+ * settled on everywhere else: a read model's materialisation carries
147
+ * `forKey(store, key)` INSTEAD of a key field, so "one materialisation, one key" is
148
+ * structural rather than conventional, and the same argument applies to observing
149
+ * one. There is no key to transpose, the timeout message names `store.key` and
150
+ * therefore cannot name a key the poll did not use, and `readModel.store` is
151
+ * already exactly the right argument. A caller holding the multi-key port writes
152
+ * `forKey(store, key)`, which is the library's own one-line adapter and is
153
+ * `store.readCheckpoint(key)` with a `suspend` around it — so a suite deliberately
154
+ * observing through the RAW port (as `read-postgres`' live-log cases do, to keep
155
+ * "the façade wrote where the port reads" an observation rather than an assumption)
156
+ * loses nothing.
157
+ *
158
+ * A read model materialised N ways (`runProjections`) is N of these observations,
159
+ * one per store, and there is deliberately no plural helper for it. Under one
160
+ * `ProjectionId` and the default partition the N keys are IDENTICAL — a key names a
161
+ * cursor within a store — so the STORE is what distinguishes the materialisations,
162
+ * and each runner carries its own: `awaitCheckpoint(runners[i].store, target)` is
163
+ * already the per-entry observation, over the very binding the call made. So a plural
164
+ * helper would take nothing a caller does not already hold, and the N waits are one
165
+ * `Effect.all` over the runners it was handed — concurrently, since waiting on them
166
+ * in turn would prove only that each converges once the others have.
167
+ *
168
+ * Exceeding the timeout is a DEFECT, so this stays `E = never` and a case need
169
+ * not thread a timeout error it has no intention of handling. It is the right
170
+ * classification anyway: a projection that never converged is a broken test or a
171
+ * broken library, never an outcome to assert on.
172
+ */
173
+ export const awaitCheckpoint = (
174
+ store: KeyedProjectionStore,
175
+ target: Position,
176
+ options?: AwaitCheckpointOptions,
177
+ ): Effect.Effect<Position> =>
178
+ Effect.repeat(
179
+ Effect.zipRight(
180
+ Effect.sleep(options?.pollInterval ?? DEFAULT_POLL_INTERVAL),
181
+ storedCheckpoint(store),
182
+ ),
183
+ { until: (stored) => stored >= target },
184
+ ).pipe(
185
+ Effect.timeoutFail({
186
+ duration: options?.timeout ?? DEFAULT_TIMEOUT,
187
+ onTimeout: () =>
188
+ new Error(
189
+ `awaitCheckpoint: the checkpoint for projection ` +
190
+ `'${store.key.projection}' (partition '${store.key.partition}') ` +
191
+ `never reached position ${target}`,
192
+ ),
193
+ }),
194
+ Effect.orDie,
195
+ )
196
+
197
+ /**
198
+ * Everything `runProjectionUntil` takes: the runner's own tuning plus the wait's.
199
+ *
200
+ * ONE FLAT object, for the reason `ProjectionRunnerOptions` is itself flat —
201
+ * `{ ...OPTIONS, oneField }` is the dominant override idiom in this repo, and a
202
+ * spread is shallow, so a nested shape would silently drop a whole group's
203
+ * siblings. The two halves stay distinct TYPES, which is where the distinction
204
+ * belongs; the field names are disjoint, so nothing is ambiguous at a call site.
205
+ */
206
+ export interface RunProjectionUntilOptions
207
+ extends ProjectionRunnerOptions,
208
+ AwaitCheckpointOptions {}
209
+
210
+ /**
211
+ * Start the projection maintaining `readModel` and wait until its checkpoint has
212
+ * reached `target` — "run this until it has consumed the log", as one call.
213
+ *
214
+ * This is the surface the read side was missing. `runProjection` returns once the
215
+ * daemon is FORKED, which is the only honest thing it can do, so every read-model
216
+ * test is otherwise a two-step dance the author has to know to write; this pairs
217
+ * the two steps so that a case's next line can assert on the view.
218
+ *
219
+ * It returns the `ProjectionRunner` rather than swallowing it, because everything
220
+ * a case might do next needs the handle: `Fiber.poll` it (is the daemon still
221
+ * up?), `Fiber.interrupt` it early, or await it. The scope is still the teardown
222
+ * mechanism — this forks into the CALLER's `Scope`, exactly as `runProjection`
223
+ * does, so closing that scope interrupts the daemon and waits for it.
224
+ *
225
+ * ## What it deliberately is NOT
226
+ *
227
+ * A TEST helper, not a production freshness API. It reports nothing about lag, it
228
+ * cannot enumerate checkpoints, and no consumer should reach for it to make a
229
+ * request wait for its own write to be projected — a read model is eventually
230
+ * consistent by construction and a `waitUntilProcessed` on the hot path would be
231
+ * a way to pretend otherwise. That is why it lives behind `/testing` and not on
232
+ * the package's main entry. Where a request genuinely must see its own write, the
233
+ * honest answer is not to wait on a MAINTAINED materialisation at all: fold the log
234
+ * at query time with `modelAtHead` from `@kairos-es/codec`, which is consistent as
235
+ * of the `head` its read returned and therefore never behind a checkpoint.
236
+ *
237
+ * It also does not race the daemon's fibre against the wait. A projection that
238
+ * gives up fails `ProjectionStalled`, and it would be possible to surface that
239
+ * here instead of timing out — but the daemon's failure and the convergence
240
+ * ceiling are two independent clocks, and a helper whose diagnosis depended on
241
+ * which fired first is a worse instrument than one that always says the same
242
+ * thing. A case that wants the stall itself has the fibre returned above, and
243
+ * `onStalled` besides.
244
+ */
245
+ export const runProjectionUntil = <
246
+ T extends Record<string, SliceProjection>,
247
+ E,
248
+ R,
249
+ >(
250
+ readModel: ReadModel<T, E, R>,
251
+ target: Position,
252
+ options?: RunProjectionUntilOptions,
253
+ ): Effect.Effect<
254
+ ProjectionRunner,
255
+ never,
256
+ | Scope.Scope
257
+ | DcbEventStore
258
+ | EventLogDurability
259
+ | Serializer
260
+ | RequirementOf<T>
261
+ | R
262
+ > =>
263
+ Effect.tap(runProjection(readModel, options), () =>
264
+ // The read model's OWN bound store, so the cursor being waited on is
265
+ // necessarily the cursor the runner advances. A `store` parameter here would
266
+ // reintroduce exactly the pair `KeyedProjectionStore` exists to abolish.
267
+ awaitCheckpoint(readModel.store, target, options),
268
+ )
269
+
270
+ /**
271
+ * The LAST position of a sequence — the convergence target, derived from what
272
+ * `append` actually reported.
273
+ *
274
+ * The companion to the two waits above, and the reason it is a helper rather than
275
+ * `positions[positions.length - 1]`: a fixture that appended nothing would
276
+ * otherwise yield `undefined`, and a wait for `undefined` is either a type error
277
+ * at best or a vacuous assertion at worst. Failing loudly at the point the
278
+ * fixture is wrong is worth four lines.
279
+ *
280
+ * Targets are built this way — from the positions the log HANDED OUT — rather
281
+ * than from a literal `1n..Nn`, because positions are strictly increasing and NOT
282
+ * gapless: a rolled-back append burns an id, and a tag-narrowed subscription
283
+ * never delivers the positions it does not match. Nothing here does arithmetic on
284
+ * a position.
285
+ */
286
+ export const lastPosition = (positions: ReadonlyArray<Position>): Position =>
287
+ Option.getOrThrowWith(
288
+ Arr.last(positions),
289
+ () => new Error('test fixture: no events were appended'),
290
+ )
@@ -0,0 +1,6 @@
1
+ {
2
+ "sideEffects": [],
3
+ "main": "../dist/cjs/testing.js",
4
+ "module": "../dist/esm/testing.js",
5
+ "types": "../dist/dts/testing.d.ts"
6
+ }