@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,528 @@
1
+ /**
2
+ * `ProjectionStore` — the read side's single port: a scalar checkpoint plus the
3
+ * atomic bracket a read model's own view writes are committed INSIDE (ADR-0007).
4
+ *
5
+ * It is deliberately one port rather than two (a checkpoint store and a view
6
+ * store), because the whole exactly-once property of a projection is that the
7
+ * view write and the checkpoint advance land together or not at all. Splitting
8
+ * them would put the bracket in the caller's hands, and a forgotten bracket is
9
+ * silent: the view would commit without the checkpoint advancing and the next
10
+ * pass would apply the same batch again. So `commit` takes `viewWrites` as an
11
+ * ARGUMENT — the caller hands its writes in, the port owns the transaction — and
12
+ * there is no operation on this interface that advances a checkpoint outside it.
13
+ *
14
+ * The checkpoint is a SCALAR `Position`, not a set of processed positions and not
15
+ * a high-water mark with gap tracking. That is licensed by the store's
16
+ * exclusive-lock append (ADR-0002): id-allocation order equals commit order, so
17
+ * once a position is visible every lower position is either already visible or
18
+ * permanently absent. A reader may therefore process strictly ascending and
19
+ * treat an absent position as absent forever. Positions are compared, never
20
+ * arithmetic'd, and never assumed contiguous.
21
+ *
22
+ * The port is MULTI-KEY — every operation takes a `CheckpointKey` — because one
23
+ * `SqlClient` legitimately serves many read models, and a store must keep every
24
+ * key independent. One MATERIALISATION of a read model, however, has exactly ONE
25
+ * key, so it is handed the `KeyedProjectionStore` façade at the bottom of this
26
+ * module rather than the port itself: `forKey` binds a store to a key once, and from
27
+ * there no operation has a key to get wrong. Both surfaces are real and neither
28
+ * replaces the other. A read model may have SEVERAL materialisations, one per store;
29
+ * the `CheckpointKey` doc below says what that does and does not do to key
30
+ * uniqueness.
31
+ *
32
+ * This module is dependency-free by design — branded ids, two tagged errors, two
33
+ * interfaces and the one-line façade that relates them. `@kairos-es/read` ships the
34
+ * in-memory implementation (the rule being that the package defining a port ships
35
+ * that port's dependency-free implementation, as `core` does for the in-memory
36
+ * event store); every dependency-bearing backend is its own package.
37
+ */
38
+ import type { Position } from '@kairos-es/core'
39
+ import { Data, Effect, Schema } from 'effect'
40
+
41
+ /**
42
+ * The name of a read model, as a branded non-empty string.
43
+ *
44
+ * Branded rather than a bare `string` for the same reason every other kairos-es
45
+ * identifier is (`Position`, `Tag`, `EventType`): the projection name and the
46
+ * partition name are both non-empty strings with identical shape, so an
47
+ * unbranded pair is trivially transposable at a call site and the mistake would
48
+ * surface only as a projection silently reading somebody else's checkpoint.
49
+ * Construct with `ProjectionId.make(...)`.
50
+ */
51
+ export const ProjectionId = Schema.String.pipe(
52
+ Schema.nonEmptyString(),
53
+ Schema.brand('ProjectionId'),
54
+ )
55
+ export type ProjectionId = typeof ProjectionId.Type
56
+
57
+ /**
58
+ * The partition of a read model's checkpoint, as a branded non-empty string.
59
+ *
60
+ * `DEFAULT_PARTITION` is the single value the RUNNER uses, because stream
61
+ * sharding is deliberately not offered: DCB events carry a tag matrix, so two
62
+ * shards can own rows for the same tag while each holds its own checkpoint —
63
+ * both guarded compare-and-sets then succeed and the lost update goes undetected
64
+ * (the guard arbitrates cursor progress, not row ownership). Nothing on this port
65
+ * restricts the value, and a `ProjectionStore` must keep every distinct
66
+ * `(projection, partition)` pair independent — the shared contract suite commits
67
+ * to a second partition precisely to prove that. The dimension is in the KEY so
68
+ * that a restricted, opt-in sharding capability — for read models that provably
69
+ * need no cross-tag ordering and whose every row is owned by one partition value
70
+ * — could be added later with no migration of the checkpoint's shape or storage.
71
+ */
72
+ export const PartitionId = Schema.String.pipe(
73
+ Schema.nonEmptyString(),
74
+ Schema.brand('PartitionId'),
75
+ )
76
+ export type PartitionId = typeof PartitionId.Type
77
+
78
+ /** The partition value every runner uses while sharding is cut (ADR-0007). */
79
+ export const DEFAULT_PARTITION: PartitionId = PartitionId.make('default')
80
+
81
+ /**
82
+ * What a checkpoint is stored under: the read model plus its partition.
83
+ *
84
+ * **A key is unique WITHIN ONE STORE**, and stating the scope is load-bearing
85
+ * rather than pedantic: without it, the uniqueness rule below points a reader at the
86
+ * opposite of the right answer for a read model materialised more than once. The
87
+ * scope is a consequence of R1 rather than a policy — the checkpoint is co-located
88
+ * with its view so the pair commits atomically, so a key is only ever resolved
89
+ * against the store holding it, and there is no global namespace to collide in.
90
+ *
91
+ * Two DIFFERENT read models never share a key in one store, and by R3 read models
92
+ * that share one subscription share one cursor and therefore ONE key — a composed
93
+ * bundle checkpoints as a unit and rebuilds as a unit. But ONE read model
94
+ * materialised in several stores SHARES one `ProjectionId` across them, which is
95
+ * what its single identity means, and which
96
+ * `@kairos-es/read-postgres`'s `test/courseRosterGraduation.test.ts` asserts as its
97
+ * acceptance criterion. Distinctness is forced only where two materialisations of
98
+ * one read model COHABIT a store, which is what `PartitionId` is for: two of them
99
+ * in one store under one key would be two runners over one cursor, each advancing
100
+ * the other's, and the read side's construction gate refuses that pairing rather
101
+ * than leaving it to be discovered — a rung only a multi-materialisation call can
102
+ * reach, a lone materialisation having no sibling to collide with.
103
+ */
104
+ export interface CheckpointKey {
105
+ readonly projection: ProjectionId
106
+ readonly partition: PartitionId
107
+ }
108
+
109
+ /**
110
+ * Build a `CheckpointKey`, defaulting the partition — the constructor nearly
111
+ * every call site wants, so that the sharding dimension stays in the key shape
112
+ * without appearing in ordinary wiring code.
113
+ */
114
+ export const checkpointKey = (
115
+ projection: ProjectionId,
116
+ partition: PartitionId = DEFAULT_PARTITION,
117
+ ): CheckpointKey => ({ projection, partition })
118
+
119
+ /**
120
+ * How far a store's state survives: `'ephemeral'` dies with the process,
121
+ * `'durable'` outlives it.
122
+ *
123
+ * Declared rather than inferred so that R2 — checkpoint durability must not
124
+ * EXCEED event-log durability — is checkable at construction instead of
125
+ * invisible at runtime. This type is the VIEW's half of that comparison;
126
+ * `EventLogDurability` is the log's, and `EventLogDurability.ts` is where R2's
127
+ * argument and the silent failure it prevents are written.
128
+ */
129
+ export type Durability = 'ephemeral' | 'durable'
130
+
131
+ /**
132
+ * An infrastructure fault from a view store — a connection dropped, a statement
133
+ * failed, the checkpoint table is missing.
134
+ *
135
+ * This is a value on the ERROR channel rather than a defect, which inverts the
136
+ * store contract's rule (`DcbEventStore` dies on infrastructure faults, ADR-0002)
137
+ * and does so on purpose: the read side has a supervisor above it, and the
138
+ * supervisor's whole job is to tell "retry the subscription with backoff" from
139
+ * "give up and report a stalled projection". A defect carries no such
140
+ * distinction. `cause` is exact-optional — only set when there is one.
141
+ */
142
+ export class ProjectionStoreError extends Data.TaggedError(
143
+ 'ProjectionStoreError',
144
+ )<{
145
+ readonly reason: string
146
+ readonly cause?: unknown
147
+ }> {
148
+ /**
149
+ * `reason` again, as the `Error` field that says why — so the fault renders
150
+ * ITSELF.
151
+ *
152
+ * `Data.TaggedError` sets `name` to the tag and leaves `message` empty, so
153
+ * without this a `ProjectionStoreError` stringifies to `'ProjectionStoreError'`
154
+ * and the sentence explaining the fault reaches a log line only where something
155
+ * OUTSIDE the class knows to go and fetch `reason`. With it,
156
+ * `Error.prototype.toString` yields `'ProjectionStoreError: <reason>'`, and
157
+ * `String(fault)`, `Cause.pretty` (which reads `message` off anything `instanceof
158
+ * Error`) and the projection supervisor's `reason` annotation all take it from the
159
+ * one place it is declared. That matters most for exactly the audience this error
160
+ * exists for: the supervisor above the read side, whose log line is what an
161
+ * operator sees when a view store starts refusing to answer.
162
+ *
163
+ * ## What filling `message` reaches — and what it deliberately leaves alone
164
+ *
165
+ * Not the supervisor's two log lines: `message` is the field `Cause.pretty` reads
166
+ * off anything `instanceof Error`, so this changes how a `ProjectionStoreError`
167
+ * prints EVERYWHERE it is reported — a failed fibre's `cause` annotation on all
168
+ * three shipped loggers, `Effect.logError(message, cause)`, an unhandled
169
+ * failure's report, and any reporter or test runner that reads `.message`. That
170
+ * blast radius is the point of doing it on the class instead of at one call site:
171
+ * one declaration improves every reader at once, and none of them has to know
172
+ * this class's field names.
173
+ *
174
+ * What it does NOT change is the STRUCTURED shape. `Data.Error` overrides
175
+ * `YieldableError`'s `toJSON` with `{ ...plainArgs, ...this }` — own enumerables
176
+ * plus the constructor args, and a prototype accessor is neither — so
177
+ * `JSON.stringify(fault)` and an `Effect.logError(fault)` that JSON-encodes its
178
+ * object message emit exactly the `reason`/`_tag` they always did. The humane
179
+ * rendering gains a sentence; the machine-parsed one is untouched.
180
+ *
181
+ * A GETTER rather than a second field, so `reason` stays the single declaration
182
+ * and the two cannot disagree. Safe as a prototype accessor because
183
+ * `Data.TaggedError`'s constructor is `super(args?.message, …)` followed by
184
+ * `Object.assign(this, args)` (verified against `effect@3.22`'s `Data.ts`) and
185
+ * `message` is not among the fields above, so nothing shadows it with an own
186
+ * property.
187
+ *
188
+ * Both mechanics — that accessor safety and the `toJSON` shape above it — are
189
+ * argued HERE and nowhere else, for every read-side fault that fills the field:
190
+ * `CheckpointSuperseded` below, `PipelineDied` in `ProjectionRunner.ts` and
191
+ * `ProjectionStalled` in `superviseOnProgress.ts` each point back rather than
192
+ * re-derive, and none of the four declares a `message` field for that
193
+ * `Object.assign` to shadow its accessor with. One version pin to re-verify on an
194
+ * `effect` bump, not four.
195
+ */
196
+ override get message(): string {
197
+ return this.reason
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The guarded compare-and-set lost: the stored checkpoint no longer equals
203
+ * `expected`, so somebody else advanced this key.
204
+ *
205
+ * A tagged ERROR, never a returned outcome value. A returned
206
+ * `Advanced | Superseded` only rolls the view writes back if the caller remembers
207
+ * to convert it into a failure, and forgetting is silent corruption — the view
208
+ * commits while the checkpoint stays put, and the next pass double-applies the
209
+ * batch. On the error channel the abort is automatic and cannot be ignored.
210
+ *
211
+ * It carries the key and the expectation but deliberately NOT the stored
212
+ * position, mirroring `AppendConditionFailed` (ADR-0002) for the same reason: the
213
+ * recovery path re-reads the checkpoint, so a carried position would already be
214
+ * stale by the time anything acted on it, and carrying one would invite exactly
215
+ * the resume-from-the-conflict-point bug that omitting it prevents.
216
+ *
217
+ * Like its sibling `ProjectionStoreError` it fills `message`, from the expectation
218
+ * it already carries. The getter below says what the field reaches, and also
219
+ * records the reasoning it REPLACED — this class once left `message` empty on a
220
+ * ground that could not be true, and that is worth being able to recognise again.
221
+ */
222
+ export class CheckpointSuperseded extends Data.TaggedError(
223
+ 'CheckpointSuperseded',
224
+ )<{
225
+ readonly key: CheckpointKey
226
+ readonly expected: Position
227
+ }> {
228
+ /**
229
+ * The sentence this class's own first paragraph writes, plus the expectation the
230
+ * guard turned on — as the `Error` field that says why, so the fault renders
231
+ * ITSELF.
232
+ *
233
+ * `Data.TaggedError` sets `name` to the tag and leaves `message` empty, and
234
+ * `Cause.pretty` substitutes its own `'An error has occurred'` for an empty one,
235
+ * so unfilled this fault printed through a `Cause` reads
236
+ * `'CheckpointSuperseded: An error has occurred'` — a line naming neither a guard,
237
+ * nor a checkpoint, nor an expectation. Filled, `Error.prototype.toString` yields
238
+ * the tag followed by the sentence, everywhere the fault is reported. How far that
239
+ * reaches and what it deliberately leaves alone is set out on
240
+ * `ProjectionStoreError` above; the argument is identical for every fault that
241
+ * fills the field, so it is made once, on the one an operator meets most.
242
+ *
243
+ * ## Why this field was once left EMPTY, and why that reason was wrong
244
+ *
245
+ * Recorded rather than quietly deleted, because a rationale that cannot be true is
246
+ * worse than a missing one and the shape of this mistake is easy to repeat. The
247
+ * argument ran: `expected` is a `bigint`; a `bigint` is not JSON-serialisable, and
248
+ * `effect@3.22`'s `Logger.json` throws outright on one nested in an annotation
249
+ * value and loses the whole line with it; therefore filling `message` would push
250
+ * that value class into the field every renderer reaches for first.
251
+ *
252
+ * The measurement is true and stays load-bearing. The inference from it is a
253
+ * category error. `message` is typed `string`, and TEMPLATE INTERPOLATION of a
254
+ * `bigint` yields its decimal digits, so nothing downstream of this getter can see
255
+ * a `bigint` through it. The hazard is about the values handed to a LOGGER as
256
+ * ANNOTATIONS — which is exactly where the projection supervisor applies it, in
257
+ * `shouldRestart`, whose `position` annotation is `String(stored)` — and it says
258
+ * nothing whatever about a string rendered from one. Nor was the substitute the
259
+ * old argument offered a substitute: the supervisor's `position` annotation is the
260
+ * progress signal it RE-READ after the failure, not this expectation. Meanwhile
261
+ * the cost of the omission was real and was being paid on every `Cause.pretty` of
262
+ * a superseded commit.
263
+ *
264
+ * What the correction does NOT touch is the other ground this class states, which
265
+ * stands unchanged: no STORED position is carried, because the recovery path
266
+ * re-reads and a carried one would already be stale. That decision is about a
267
+ * RECOVERY INPUT — what a caller might wrongly resume from. This getter is a
268
+ * human-readable label over a field the class already carries, and `expected` is
269
+ * exactly as stale in the label as it is in the payload: a reader is being told
270
+ * what the guard expected, never what to resume from.
271
+ *
272
+ * A GETTER rather than a `message` field, so `expected` stays the single
273
+ * declaration. Why a prototype accessor is safe here, and why a structured log of
274
+ * this fault still emits the same `key`/`expected`/`_tag` it always did, are set
275
+ * out once for all four of these getters on `ProjectionStoreError` above.
276
+ */
277
+ override get message(): string {
278
+ return `the stored checkpoint no longer equals the expectation (${this.expected})`
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Where read models materialise: a checkpoint and the view it tracks, in one store
284
+ * so each pair can be committed atomically.
285
+ *
286
+ * Each such pair is ONE MATERIALISATION of a read model, and a read model may have
287
+ * several across several stores — which is why the `CheckpointKey` doc above scopes
288
+ * uniqueness to one store rather than globally, and why R1 is what forces that
289
+ * scope.
290
+ *
291
+ * **Multi-key on purpose, and NOT the surface a read model is given.** Every
292
+ * operation takes a `CheckpointKey`, because one store instance — one `SqlClient`,
293
+ * one connection pool — serves however many read models an application runs, and
294
+ * keeping their keys independent is a contract obligation the shared suite proves.
295
+ * One materialisation has exactly one key, though, so it is handed
296
+ * `forKey(store, key)` instead: see `KeyedProjectionStore` below for why the
297
+ * difference is structural rather than stylistic. Implement THIS interface; hand out
298
+ * THAT one.
299
+ *
300
+ * **Annotate an implementation at its DEFINITION site** — `const store:
301
+ * ProjectionStore = { … }`, rather than leaning on the factory's return type alone
302
+ * — so a drift from this interface is a compile error THERE, on the offending
303
+ * member, instead of an opaque mismatch at the export. Both shipped
304
+ * implementations do it and say so in one line; a new backend should too.
305
+ *
306
+ * **The contract a NON-TRANSACTIONAL implementation must honour.** A SQL store
307
+ * runs `commit` in a transaction, so a `viewWrites` that fails PART-WAY is rolled
308
+ * back whole. An in-memory store has no transaction and cannot undo the writes
309
+ * that already landed before the failure. That single divergence is discharged by
310
+ * a requirement on the CALLER of a non-transactional store rather than by
311
+ * capability tiers in the shared contract suite: the per-batch view write MUST be
312
+ * a single atomic effect — fold the batch purely, then perform ONE write (see
313
+ * `foldIntoRef`, which is exactly that shape). Under that requirement a part-way
314
+ * failure is unreachable, every implementation's observable behaviour is
315
+ * identical, and one suite tests them all with no per-backend exemptions. State
316
+ * it loudly at any site that hands a multi-write `apply` to an in-memory store:
317
+ * that, not the store, is the bug.
318
+ *
319
+ * R1 (atomicity) is what this interface IS. The atomic unit is the VIEW's own
320
+ * transaction and never the event log's, so a view in a DIFFERENT database from the
321
+ * log is still exactly-once: the checkpoint travels with the view, and re-reading
322
+ * the log from a position needs no transaction at all, being deterministic and
323
+ * side-effect-free.
324
+ *
325
+ * A view store offering no transaction at all — a search index, an object store, an
326
+ * HTTP API — cannot implement this interface honestly: there is nothing for the
327
+ * checkpoint advance to ride along inside, so such a view is at-least-once and its
328
+ * writes must be IDEMPOTENT. That is a different design and not this port, but it
329
+ * has a recommended shape, so it is written down rather than left to be
330
+ * rediscovered: make every write POSITION-KEYED so re-applying an event no-ops —
331
+ * in SQL terms an `INSERT … ON CONFLICT DO NOTHING` keyed by the event's position,
332
+ * and in a search index or an object store the same idea, the document or object
333
+ * key derived from the position so a replay overwrites itself instead of
334
+ * accumulating. The guarantee then degrades from exactly-once to "at least once,
335
+ * and twice is indistinguishable from once".
336
+ *
337
+ * It is RECOMMENDED rather than mandated (and no helper is shipped for it)
338
+ * because the library cannot force it: the position-keying lives in the read
339
+ * model's own rows, whose shape this port knows nothing about. Nothing here can
340
+ * check it, so the honest move is to document the pattern and say plainly that
341
+ * the obligation is the reader's.
342
+ */
343
+ export interface ProjectionStore {
344
+ /**
345
+ * How far this store's state survives — the R2 input. Checked against the
346
+ * event log's durability at runner construction.
347
+ */
348
+ readonly durability: Durability
349
+
350
+ /**
351
+ * The stored position for `key`. A key that has never been committed reads
352
+ * back as `ORIGIN` (`0n`), so a fresh read model and a reset read model are
353
+ * indistinguishable and both simply catch up from the start of the log.
354
+ */
355
+ readonly readCheckpoint: (
356
+ key: CheckpointKey,
357
+ ) => Effect.Effect<Position, ProjectionStoreError>
358
+
359
+ /**
360
+ * Apply `viewWrites` and advance the checkpoint from `expected` to `next`
361
+ * ATOMICALLY: both land or neither does.
362
+ *
363
+ * The view writes are passed IN rather than the caller opening its own
364
+ * transaction, so the bracket cannot be forgotten and no path exists to a
365
+ * checkpoint advance outside it. `viewWrites` is an arbitrary `Effect`, so
366
+ * nothing that belongs in the same transaction is excluded — the caller's own
367
+ * statements join it through the same client — and its `E`/`R` pass straight
368
+ * through this signature untouched, so a projection keeps its own error and
369
+ * requirement types.
370
+ *
371
+ * Fails `CheckpointSuperseded` when the stored position no longer equals
372
+ * `expected`, and that failure ABORTS the whole commit: the view writes must
373
+ * have no observable effect afterwards. That abort is the exactly-once
374
+ * mechanism, so it is the one behaviour the shared contract suite proves
375
+ * against a known-broken store rather than assuming.
376
+ *
377
+ * `next` is used AS IS. It is the last processed event's position, never
378
+ * `expected + 1` or any other arithmetic: positions are strictly increasing but
379
+ * not gapless, so a jump is normal and an increment would be wrong.
380
+ */
381
+ readonly commit: <E, R>(args: {
382
+ readonly key: CheckpointKey
383
+ readonly expected: Position
384
+ readonly next: Position
385
+ readonly viewWrites: Effect.Effect<void, E, R>
386
+ }) => Effect.Effect<void, E | CheckpointSuperseded | ProjectionStoreError, R>
387
+
388
+ /**
389
+ * Return `key` to `ORIGIN`, so the next run catches up from the start of the
390
+ * log — the rebuild path.
391
+ *
392
+ * It resets the CHECKPOINT only, never the view: what a rebuild must do with
393
+ * existing rows (truncate them, or write into a fresh table and swap) is the
394
+ * read model's business, and the port has no idea what its rows are.
395
+ */
396
+ readonly resetCheckpoint: (
397
+ key: CheckpointKey,
398
+ ) => Effect.Effect<void, ProjectionStoreError>
399
+ }
400
+
401
+ /**
402
+ * A `ProjectionStore` BOUND to one `CheckpointKey`: the same three operations,
403
+ * with no key to pass and therefore none to get wrong.
404
+ *
405
+ * **Why this exists: an invariant that was convention is now structure.** "One
406
+ * materialisation, one key, one runner" (ADR-0007) was true of every wiring in this
407
+ * repo and of every wiring a reader would write, but nothing enforced it. A
408
+ * `ReadModel` carrying `key` and `store` as INDEPENDENT fields admits two DIFFERENT
409
+ * read models authored against the same key in one store, or one read model whose
410
+ * `apply` reads a neighbour's checkpoint, and neither is a type error — the mistake
411
+ * would surface as one projection silently advancing another's cursor, which is
412
+ * exactly the failure mode `ProjectionId` is branded to prevent at the level below.
413
+ * Binding the key to the store ONCE, at the wiring site, makes the pairing the only
414
+ * thing there is: `commit` no longer has a key argument, so there is no key to
415
+ * transpose, omit, or refresh from the wrong variable.
416
+ *
417
+ * **`runProjections` is the same move one level up, and the collision gate is what
418
+ * binding alone cannot reach.** Where this façade binds a key to a store, that
419
+ * function binds ONE slice record and ONE `ProjectionId` across N materialisations
420
+ * and does the `forKey` call per entry itself, so "one read model, N
421
+ * materialisations, one identity" stops being a convention too. What binding cannot
422
+ * catch is the one incoherence that is invisible AFTER it: two entries bound to the
423
+ * same store under equal keys are each a perfectly well-formed
424
+ * `KeyedProjectionStore`, and `forKey` deliberately does not expose the store it
425
+ * closed over, so nothing downstream can ask whether two of them are the same. That
426
+ * question is only answerable at the UNBOUND port, which is where
427
+ * the read side's construction gate asks it.
428
+ *
429
+ * It is a FAÇADE and not a replacement. The multi-key port underneath is a real
430
+ * requirement — one client serves many read models, and per-key independence is
431
+ * contract behaviour the shared suite proves against every backend — so `forKey`
432
+ * narrows a store for one consumer rather than narrowing the port for everyone.
433
+ *
434
+ * `readCheckpoint` and `resetCheckpoint` are Effect VALUES rather than
435
+ * zero-argument functions. An `Effect` is a lazy description, so a value is
436
+ * re-runnable and a no-argument call has nothing left to express; `yield*
437
+ * view.readCheckpoint` is then the whole operation, with no vestigial `()` to
438
+ * suggest an argument might one day return.
439
+ */
440
+ export interface KeyedProjectionStore {
441
+ /**
442
+ * The key this store is bound to.
443
+ *
444
+ * Carried rather than dropped because the RUNNER still needs it: its log
445
+ * annotations and its `PipelineDied`/`ProjectionStalled` payloads name BOTH halves —
446
+ * an operator greps on `projection` and `partition` together — and its
447
+ * construction-time defect messages name at least the projection. That is the whole
448
+ * reason the pair travels as one value: an operator's first question about a stalled
449
+ * projection is which one, and in a store where two materialisations cohabit the
450
+ * partition is the half that answers it. It
451
+ * is exposed for READING; nothing here takes it as an argument, which is the
452
+ * point.
453
+ */
454
+ readonly key: CheckpointKey
455
+
456
+ /** The underlying store's durability — the R2 input, passed straight through. */
457
+ readonly durability: Durability
458
+
459
+ /** The stored position for this store's key. `ORIGIN` when never committed. */
460
+ readonly readCheckpoint: Effect.Effect<Position, ProjectionStoreError>
461
+
462
+ /**
463
+ * Apply `viewWrites` and advance this key's checkpoint from `expected` to
464
+ * `next` atomically — `ProjectionStore.commit` with the one argument a caller
465
+ * could get wrong already supplied.
466
+ */
467
+ readonly commit: <E, R>(args: {
468
+ readonly expected: Position
469
+ readonly next: Position
470
+ readonly viewWrites: Effect.Effect<void, E, R>
471
+ }) => Effect.Effect<void, E | CheckpointSuperseded | ProjectionStoreError, R>
472
+
473
+ /** Return this key to `ORIGIN` — the rebuild path, checkpoint only. */
474
+ readonly resetCheckpoint: Effect.Effect<void, ProjectionStoreError>
475
+ }
476
+
477
+ /**
478
+ * Bind a `ProjectionStore` to one `CheckpointKey`, producing the surface a read
479
+ * model is actually given.
480
+ *
481
+ * A FREE FUNCTION rather than a method on `ProjectionStore`, decided that way for
482
+ * two reasons beyond the obvious one (a single implementation, which every present
483
+ * and future backend then gets for nothing rather than re-deriving — and could
484
+ * re-derive WRONGLY, since a `forKey` that closed over the wrong key would be
485
+ * invisible until two read models started trading cursors).
486
+ *
487
+ * The sharper reason is DECORATORS. Wrapping a store is an established move here —
488
+ * a test's never-committing view store, a durability override — and every one of
489
+ * them is written `{ ...inner, commit: … }`. Were `forKey` a method, that spread
490
+ * would copy the INNER store's own `forKey`, whose closure still points at the
491
+ * inner store, so `forKey(decorated, key)` would silently route straight past the
492
+ * decoration. As a free function it reads `store.commit` off the value it is
493
+ * handed, so a decorated store decorates.
494
+ *
495
+ * The second reason is the port's implementation burden: `ProjectionStore` stays
496
+ * three methods, which is what a backend has to get right, and no implementation
497
+ * can drift on a member that is pure derivation.
498
+ *
499
+ * `Effect.suspend` on the two no-argument members preserves the port's per-call
500
+ * construction timing exactly: an implementation is entitled to build its effect
501
+ * when its method is called (the SQL store binds parameters there), so suspending
502
+ * keeps `yield* view.readCheckpoint` indistinguishable from
503
+ * `yield* store.readCheckpoint(key)` rather than freezing one construction for the
504
+ * store's lifetime.
505
+ */
506
+ export const forKey = (
507
+ store: ProjectionStore,
508
+ key: CheckpointKey,
509
+ ): KeyedProjectionStore => ({
510
+ key,
511
+ durability: store.durability,
512
+ readCheckpoint: Effect.suspend(() => store.readCheckpoint(key)),
513
+ // Spelled out field by field rather than `{ key, ...args }`: the bound key is
514
+ // then the ONLY key in the call, which is the whole claim this façade makes, and
515
+ // no spread order or stray property can put a different one there.
516
+ commit: <E, R>(args: {
517
+ readonly expected: Position
518
+ readonly next: Position
519
+ readonly viewWrites: Effect.Effect<void, E, R>
520
+ }): Effect.Effect<void, E | CheckpointSuperseded | ProjectionStoreError, R> =>
521
+ store.commit({
522
+ key,
523
+ expected: args.expected,
524
+ next: args.next,
525
+ viewWrites: args.viewWrites,
526
+ }),
527
+ resetCheckpoint: Effect.suspend(() => store.resetCheckpoint(key)),
528
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `foldIntoRef` — the folded-state read model's `apply`: fold a batch PURELY
3
+ * through `composeProjections`, then perform exactly ONE write.
4
+ *
5
+ * This is the shape the `ProjectionStore` port's single-atomic-effect requirement
6
+ * asks for, and it is why that requirement costs nothing. A non-transactional view
7
+ * store cannot roll back a `viewWrites` that fails part-way; a fold followed by a
8
+ * single `Ref.update` has no part-way to fail at — the fold is pure (it cannot
9
+ * fail, and it touches nothing observable) and the write is a single atomic step.
10
+ * So the one divergence between a transactional and a non-transactional
11
+ * `ProjectionStore` is unreachable for a read model written this way, which is
12
+ * what keeps the shared contract suite free of capability tiers.
13
+ *
14
+ * It is deliberately a thin helper over primitives that already exist rather than
15
+ * a new fold abstraction: the composition is `core`'s `composeProjections`, the
16
+ * slices are the codec's `projection().on().build()`, and this module only wires
17
+ * the two to a `Ref`. Nothing here is required — a read model with its own
18
+ * multi-statement SQL `apply` ignores it entirely and relies on the transaction
19
+ * instead. It is the RECOMMENDED `apply` for an in-memory view specifically.
20
+ */
21
+ import type { SliceProjection } from '@kairos-es/codec'
22
+ import {
23
+ type CompositeState,
24
+ composeProjections,
25
+ type DecodedEvent,
26
+ } from '@kairos-es/core'
27
+ import type { Array as Arr } from 'effect'
28
+ import { type Effect, Ref } from 'effect'
29
+
30
+ /**
31
+ * Build the per-batch `apply` for a read model whose whole view is `view`.
32
+ *
33
+ * `slices` must be the SAME record the read model's subscription query is derived
34
+ * from, so the events the runner delivers are exactly the events these folds
35
+ * expect; `composeProjections` gates each slice on its own query before
36
+ * dispatching, so a slice never sees an event it did not ask for.
37
+ *
38
+ * The returned effect is infallible (`E = never`) and requirement-free
39
+ * (`R = never`), which is the whole point: handed to `ProjectionStore.commit` as
40
+ * `viewWrites`, it adds nothing to the commit's error or requirement channels, so
41
+ * the ONLY way that commit can fail is the checkpoint guard.
42
+ */
43
+ export const foldIntoRef = <T extends Record<string, SliceProjection>>(
44
+ slices: T,
45
+ view: Ref.Ref<CompositeState<T>>,
46
+ ): ((
47
+ batch: Arr.NonEmptyReadonlyArray<DecodedEvent>,
48
+ ) => Effect.Effect<void>) => {
49
+ // Composed ONCE, at wiring time, not per batch: the composition is pure and
50
+ // derives the merged query and the dispatch table, so rebuilding it on every
51
+ // batch would only burn allocations.
52
+ const composite = composeProjections(slices)
53
+
54
+ return (batch) =>
55
+ // One `Ref.update`, folding the whole batch inside the update function. Not a
56
+ // fold of `Ref.update`s: N updates are N observable states, so an interrupt or
57
+ // a concurrent reader could see the view half-way through a batch, and on a
58
+ // lost checkpoint guard a partly-applied batch would already be visible. One
59
+ // update makes the batch atomic in the same sense the checkpoint advance is.
60
+ Ref.update(view, (state) =>
61
+ batch.reduce((acc, decoded) => composite.evolve(acc, decoded), state),
62
+ )
63
+ }