@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.
- package/LICENSE +28 -0
- package/README.md +538 -0
- package/dist/cjs/EventLogDurability.js +184 -0
- package/dist/cjs/EventLogDurability.js.map +1 -0
- package/dist/cjs/ProjectionRunner.js +478 -0
- package/dist/cjs/ProjectionRunner.js.map +1 -0
- package/dist/cjs/ProjectionStore.js +233 -0
- package/dist/cjs/ProjectionStore.js.map +1 -0
- package/dist/cjs/foldIntoRef.js +36 -0
- package/dist/cjs/foldIntoRef.js.map +1 -0
- package/dist/cjs/inMemoryProjectionStore.js +138 -0
- package/dist/cjs/inMemoryProjectionStore.js.map +1 -0
- package/dist/cjs/index.js +128 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/projectionWiringFault.js +532 -0
- package/dist/cjs/projectionWiringFault.js.map +1 -0
- package/dist/cjs/runProjection.js +117 -0
- package/dist/cjs/runProjection.js.map +1 -0
- package/dist/cjs/runProjections.js +144 -0
- package/dist/cjs/runProjections.js.map +1 -0
- package/dist/cjs/superviseOnProgress.js +580 -0
- package/dist/cjs/superviseOnProgress.js.map +1 -0
- package/dist/cjs/testing.js +143 -0
- package/dist/cjs/testing.js.map +1 -0
- package/dist/dts/EventLogDurability.d.ts +182 -0
- package/dist/dts/EventLogDurability.d.ts.map +1 -0
- package/dist/dts/ProjectionRunner.d.ts +557 -0
- package/dist/dts/ProjectionRunner.d.ts.map +1 -0
- package/dist/dts/ProjectionStore.d.ts +475 -0
- package/dist/dts/ProjectionStore.d.ts.map +1 -0
- package/dist/dts/foldIntoRef.d.ts +39 -0
- package/dist/dts/foldIntoRef.d.ts.map +1 -0
- package/dist/dts/inMemoryProjectionStore.d.ts +11 -0
- package/dist/dts/inMemoryProjectionStore.d.ts.map +1 -0
- package/dist/dts/index.d.ts +185 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/projectionWiringFault.d.ts +260 -0
- package/dist/dts/projectionWiringFault.d.ts.map +1 -0
- package/dist/dts/runProjection.d.ts +185 -0
- package/dist/dts/runProjection.d.ts.map +1 -0
- package/dist/dts/runProjections.d.ts +480 -0
- package/dist/dts/runProjections.d.ts.map +1 -0
- package/dist/dts/superviseOnProgress.d.ts +587 -0
- package/dist/dts/superviseOnProgress.d.ts.map +1 -0
- package/dist/dts/testing.d.ts +207 -0
- package/dist/dts/testing.d.ts.map +1 -0
- package/dist/esm/EventLogDurability.js +175 -0
- package/dist/esm/EventLogDurability.js.map +1 -0
- package/dist/esm/ProjectionRunner.js +468 -0
- package/dist/esm/ProjectionRunner.js.map +1 -0
- package/dist/esm/ProjectionStore.js +223 -0
- package/dist/esm/ProjectionStore.js.map +1 -0
- package/dist/esm/foldIntoRef.js +29 -0
- package/dist/esm/foldIntoRef.js.map +1 -0
- package/dist/esm/inMemoryProjectionStore.js +131 -0
- package/dist/esm/inMemoryProjectionStore.js.map +1 -0
- package/dist/esm/index.js +185 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/projectionWiringFault.js +524 -0
- package/dist/esm/projectionWiringFault.js.map +1 -0
- package/dist/esm/runProjection.js +109 -0
- package/dist/esm/runProjection.js.map +1 -0
- package/dist/esm/runProjections.js +137 -0
- package/dist/esm/runProjections.js.map +1 -0
- package/dist/esm/superviseOnProgress.js +571 -0
- package/dist/esm/superviseOnProgress.js.map +1 -0
- package/dist/esm/testing.js +133 -0
- package/dist/esm/testing.js.map +1 -0
- package/package.json +41 -0
- package/src/EventLogDurability.ts +201 -0
- package/src/ProjectionRunner.ts +923 -0
- package/src/ProjectionStore.ts +528 -0
- package/src/foldIntoRef.ts +63 -0
- package/src/inMemoryProjectionStore.ts +163 -0
- package/src/index.ts +218 -0
- package/src/projectionWiringFault.ts +694 -0
- package/src/runProjection.ts +270 -0
- package/src/runProjections.ts +623 -0
- package/src/superviseOnProgress.ts +897 -0
- package/src/testing.ts +290 -0
- 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
|
+
)
|