@kairos-es/codec 0.0.0 → 1.0.0-alpha.1

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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The ambient read ledger: the hops a bracket walked, and the single derivation
3
+ * that turns them into one `AppendCondition`.
4
+ *
5
+ * ## The gap this closes, and why the fix has to be ambient
6
+ *
7
+ * A decision model whose second query cannot be written until an earlier read has
8
+ * been folded — the identifier it needs is data that fold produced — guards its
9
+ * append on only the LAST read if it reaches for `buildDecisionModel` there. The
10
+ * append then succeeds on a premise that is no longer true, and nothing warns: it
11
+ * compiles, it runs, and it is silent. Two things must both change for the
12
+ * condition to be sound, and fixing either alone still lets the stale write
13
+ * through. Keeping the union of queries but taking the last head misses, because
14
+ * the invalidating event lands in the window between the two heads. Keeping the
15
+ * earliest head but taking the last query misses, because the invalidating event
16
+ * fails that query's TYPE arm before its tag arm is reached — which is why adding
17
+ * a tag to the last query cannot recover it.
18
+ *
19
+ * The ledger is an OPTIONAL AMBIENT SERVICE rather than a scope object a caller
20
+ * threads, and that is the whole design. A scope object's guarantee is "every read
21
+ * through this object", which is not "every read": `modelAtHead` stays exported
22
+ * and stays in scope inside the bracket, so a read that reached for the familiar
23
+ * name — or that happened inside a helper the body merely called — drops a hop
24
+ * silently. Here `modelAtHead` ITSELF records, so a read is recorded whatever
25
+ * symbol performed it and at whatever call depth. `Effect.serviceOption` is what
26
+ * makes that additive: it reads the service if present and adds NOTHING to the
27
+ * requirement channel, so `modelAtHead`'s published signature is untouched and no
28
+ * existing caller provides anything.
29
+ *
30
+ * ## Why the query is recorded eagerly and the head only on completion
31
+ *
32
+ * A `Hop` is `{ query, head? }` and the two halves are recorded at different
33
+ * moments: the query before a single event is streamed, the head once the fold has
34
+ * run to completion. So a read that opened and never finished widens the guard
35
+ * without contributing a floor, and both halves fail safe.
36
+ *
37
+ * The laziness is deliberate protection rather than an accident of where the line
38
+ * sits. Recording the head at read-return is available and MUST NOT be done: a
39
+ * read that failed part-way folded only a PREFIX, and a head recorded for it is a
40
+ * claim the hop did not earn — the floor would sit above events that read never
41
+ * folded, and nothing would refuse an append those events invalidate.
42
+ *
43
+ * ## An invalidated ledger must REFUSE to derive, never derive narrowly
44
+ *
45
+ * Nothing here evicts a hop, so there is no eviction policy to specify — but the
46
+ * rule is stated now because the moment a bound is added, and an unbounded walk on
47
+ * a large log will want one, the tempting implementation is to drop the oldest hop
48
+ * and carry on. A ledger that has lost hops must become a poison value and refuse
49
+ * to produce a condition at all. Deriving from a PARTIAL set is worse than
50
+ * refusing, because it looks like it worked: an emptied ledger produces a NARROWER
51
+ * condition and a silent success, which is the original hazard wearing a fix's
52
+ * name. The valve is coarsen, then poison. Never forget.
53
+ *
54
+ * ## What is deliberately NOT exported from the package
55
+ *
56
+ * `deriveLayeredCondition` is PACKAGE-internal and reaches no entry point. Two
57
+ * traps stay reachable for a caller who assembles a condition by hand, both
58
+ * demonstrated: concatenating query items drops a wildcard hop, an empty item list
59
+ * being absorbing under OR while an empty concatenation contributes nothing; and a
60
+ * MAXIMUM over heads reads as naturally as a minimum, two reducers of identical
61
+ * signature where "the position I read at" argues just as fluently for the later
62
+ * one. Offering the derivation as a public step would put both back within reach
63
+ * of the callers the bracket exists to protect.
64
+ */
65
+ import type {
66
+ AnyProjection,
67
+ AppendCondition,
68
+ Position,
69
+ Query,
70
+ } from '@kairos-es/core'
71
+ import {
72
+ composeProjections,
73
+ ORIGIN,
74
+ servableQueryFault,
75
+ unionQueries,
76
+ } from '@kairos-es/core'
77
+ import { Context, Data, Effect, Layer, Option, Ref } from 'effect'
78
+
79
+ /**
80
+ * One recorded read: the query it rode, and the head it observed.
81
+ *
82
+ * `head` is a genuinely OPTIONAL key and not `Position | undefined`, because
83
+ * `exactOptionalPropertyTypes` is on and the absent case must be an absent key.
84
+ * Absent means the read did not complete, which the derivation below treats as
85
+ * the widest possible floor.
86
+ */
87
+ export interface Hop {
88
+ readonly query: Query
89
+ readonly head?: Position
90
+ }
91
+
92
+ /**
93
+ * The ambient ledger, present only inside a bracket.
94
+ *
95
+ * ⚠ A RECORDING path reads it with `Effect.serviceOption` and never
96
+ * `yield* ReadLedger`: its absence is the normal case there, and costing the
97
+ * requirement channel nothing is exactly what makes this change additive.
98
+ * `modelAtHead` is that path and is why the rule exists.
99
+ *
100
+ * A BRACKET-ONLY function is the opposite case and yields the tag deliberately.
101
+ * `decideOnly` does, and naming the requirement is what turns *"call me inside a
102
+ * bracket"* from a runtime defect into a compile error — presence being the one
103
+ * direction the type system can decide here. So the test is what the function
104
+ * means outside a bracket: nothing (record ambiently), or nothing sensible
105
+ * (require it).
106
+ *
107
+ * Recording is two-phase, and `record`'s own doc below owns why and what shape the
108
+ * second phase takes.
109
+ */
110
+ export class ReadLedger extends Context.Tag('@kairos-es/codec/ReadLedger')<
111
+ ReadLedger,
112
+ {
113
+ /**
114
+ * File a query, and hand back the effect that will fill in its head.
115
+ *
116
+ * Two phases and not one `record(hop)`, which is what the eager/lazy split
117
+ * above needs: a one-shot recorder cannot express a hop whose head never
118
+ * arrives, and that is the case the whole fail-safe direction turns on.
119
+ *
120
+ * The second phase comes back as a CLOSURE rather than as a slot number the
121
+ * caller carries to a second method. The closure holds its own slot, so
122
+ * nothing threads an index through the reader, nothing looks the service up a
123
+ * second time to spend it, and there is no slot value for a caller to hold
124
+ * wrongly.
125
+ */
126
+ readonly record: (query: Query) => Effect.Effect<CloseHop>
127
+ readonly hops: Effect.Effect<ReadonlyArray<Hop>>
128
+ }
129
+ >() {}
130
+
131
+ /**
132
+ * Fill in a recorded hop's head. Never called if the read does not complete.
133
+ *
134
+ * On the package's public surface because it is already ON it: `record` returns
135
+ * one, `ReadLedger` is exported, so a consumer holding the tag holds this type and
136
+ * could not name it. `Hop` was exported and this was not — the same interface, two
137
+ * treatments.
138
+ */
139
+ export type CloseHop = (head: Position) => Effect.Effect<void>
140
+
141
+ /**
142
+ * Raised when a bracket closes having recorded no read at all.
143
+ *
144
+ * On the ERROR channel and deliberately split from the nested-append DEFECT,
145
+ * because the two are different mistakes. An empty bracket can arise from a
146
+ * data-dependent branch that produces events without reading — a genuinely
147
+ * unconditional creation, written inside a bracket by mistake — where a nested
148
+ * append is always a wiring fault. An error-channel value lets a caller decide
149
+ * which it is.
150
+ *
151
+ * Its job is narrower than it looks, and the narrowing is worth stating because
152
+ * the wider reading is the tempting one. Once a bracket's guard is itself a fold,
153
+ * a body that reads nothing still has a hop, so this cannot fire — it is the guard
154
+ * against an unguarded UNCONDITIONAL bracket, not against an unguarded bracket. A
155
+ * bracket that looks guarded and is not is NOT what this catches and never was,
156
+ * and nothing else catches it either — `precondition.ts` owns that gap and the
157
+ * rule a reader has to keep in its place. A refusal taken BEFORE reading is not
158
+ * this case either: it short-circuits and surfaces as the refusal.
159
+ */
160
+ export class NoReadsRecorded extends Data.TaggedError('NoReadsRecorded')<
161
+ Record<never, never>
162
+ > {
163
+ /**
164
+ * The sentence, as the `Error` field that says why — so the fault renders
165
+ * ITSELF, exactly as this package's other two errors do (`errors.ts` owns the
166
+ * argument for the mechanism and it is not restated here).
167
+ *
168
+ * A getter over a constant rather than a constructor field, because there is no
169
+ * datum to vary: every raise of this error means one thing.
170
+ */
171
+ override get message(): string {
172
+ return (
173
+ 'the bracket closed having recorded no read, so there is no append condition ' +
174
+ 'to derive. A decision that reads nothing is unconditional — say so with the ' +
175
+ 'escape hatch, which takes a reason, rather than by opening a bracket.'
176
+ )
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Raised when recording a hop would leave the bracket's accumulated union
182
+ * unservable.
183
+ *
184
+ * The store refuses a multi-item query in which any item is tagless, and it
185
+ * refuses it by THROWING inside the append decorator, which becomes a defect:
186
+ * uncatchable, one call and one layer away from the `build()` that chose no tags.
187
+ * A slice built without tags defaults to none, so it is perfectly servable alone
188
+ * and kills the append the moment it is unioned. This moves that failure onto the
189
+ * error channel, to the read that caused it, naming the record entry to fix.
190
+ *
191
+ * It refuses an unservable UNION and not every tagless hop, which is the easy
192
+ * mistake to make in the other direction: one tagless slice read on its own is
193
+ * servable and must keep working. Two cases reach it — a tagless hop unioned with
194
+ * another, and a single hop whose own RECORD holds two tagless slices, which
195
+ * composes to a multi-item query with no second hop needed.
196
+ */
197
+ export class TaglessSliceInLedger extends Data.TaggedError(
198
+ 'TaglessSliceInLedger',
199
+ )<{ readonly sliceKey: string }> {
200
+ /** The offending slice and the remedy, in one sentence. */
201
+ override get message(): string {
202
+ return (
203
+ `slice '${this.sliceKey}' was built with no tags, and this bracket now reads ` +
204
+ 'more than one slice. A layered append condition unions every query it ' +
205
+ 'recorded, and the store refuses a multi-item query if any item is tagless. ' +
206
+ 'Pass `build({ tags: [...] })` for this slice.'
207
+ )
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The ONE derivation: the union of every query walked, at the earliest head
213
+ * observed. Package-internal, for the reason the module doc gives.
214
+ *
215
+ * The union goes through core's own `unionQueries` — the merge
216
+ * `composeProjections` folds a record through, reached without one — and never a
217
+ * spread of `items`, because a wildcard hop (`items: []`) is ABSORBING under OR
218
+ * while a spread of an empty list contributes nothing, so hand-rolling the union
219
+ * narrows a match-everything hop to its sibling, silently and in the unsafe
220
+ * direction. A
221
+ * repeated or overlapping hop is harmless because this derives ONCE from a merged
222
+ * set and matching is idempotent over an OR; that property belongs to deriving
223
+ * once and would be lost by a refactor to per-hop evaluation.
224
+ *
225
+ * ## When any hop is headless the floor is `ORIGIN`, and the alternative is unsound
226
+ *
227
+ * The earliest head that actually ARRIVED is refused, and is named here as refused
228
+ * rather than merely not chosen, because it is what a reader writes by accident
229
+ * when told to take the earliest head. With one headless hop among completed hops
230
+ * the union looks right and the floor betrays it, sitting already above the
231
+ * invalidating event.
232
+ *
233
+ * `ORIGIN`'s cost is measured rather than described: it refuses every append to
234
+ * any identity that already has history, so it is not a conservative default but a
235
+ * hard stop. It is affordable only because conflict retry wraps the bracket from
236
+ * OUTSIDE and re-reads, which is the rule the whole shape depends on.
237
+ */
238
+ export const deriveLayeredCondition = (
239
+ hops: ReadonlyArray<Hop>,
240
+ ): AppendCondition | undefined => {
241
+ if (hops.length === 0) {
242
+ return undefined
243
+ }
244
+ const merged = unionQueries(hops.map((hop) => hop.query))
245
+
246
+ const arrived = hops.flatMap((hop) =>
247
+ hop.head === undefined ? [] : [hop.head],
248
+ )
249
+ // A headless hop widens the guard to the whole log. Unioning its query is the
250
+ // half that is not in dispute; the floor is where the two readings diverge.
251
+ const after =
252
+ arrived.length === hops.length
253
+ ? arrived.reduce((a, b) => (b < a ? b : a))
254
+ : ORIGIN
255
+
256
+ return { failIfEventsMatch: merged, after }
257
+ }
258
+
259
+ /**
260
+ * A fresh ledger and the `Layer` providing it: a `Ref` of hops behind the two-phase
261
+ * `record` the service declares.
262
+ *
263
+ * The hops effect is handed back beside the layer because the bracket reads them
264
+ * AFTER the body has run and the layer is out of scope, which is the same moment it
265
+ * stops being able to reach the service.
266
+ */
267
+ export const makeReadLedger: Effect.Effect<{
268
+ readonly layer: Layer.Layer<ReadLedger>
269
+ readonly hops: Effect.Effect<ReadonlyArray<Hop>>
270
+ }> = Effect.gen(function* () {
271
+ const ref = yield* Ref.make<ReadonlyArray<Hop>>([])
272
+ return {
273
+ layer: Layer.succeed(ReadLedger, {
274
+ record: (query: Query) =>
275
+ Ref.modify(ref, (hops) => [
276
+ // The close effect closes over the slot this hop just took, so the slot
277
+ // never becomes a value anything else has to carry or interpret.
278
+ (head: Position) =>
279
+ Ref.update(ref, (current) =>
280
+ current.map((hop, index) =>
281
+ index === hops.length ? { query: hop.query, head } : hop,
282
+ ),
283
+ ),
284
+ [...hops, { query }],
285
+ ]),
286
+ hops: Ref.get(ref),
287
+ }),
288
+ // The same effect the service exposes, reachable AFTER the layer is out of
289
+ // scope — which is exactly when the bracket derives.
290
+ hops: Ref.get(ref),
291
+ }
292
+ })
293
+
294
+ /**
295
+ * Run `f` against the ambient ledger, or answer `absent` when there is none.
296
+ *
297
+ * "Outside a bracket this is a no-op" is ONE fact stated once, rather than one
298
+ * per consumer free to drift from the others. Every ambient lookup in this module
299
+ * goes through here, which is also what keeps `Effect.serviceOption` — the reason
300
+ * nothing enters the requirement channel — from being a detail each caller has to
301
+ * remember.
302
+ */
303
+ const withLedger = <A, E = never>(
304
+ absent: A,
305
+ f: (ledger: Context.Tag.Service<ReadLedger>) => Effect.Effect<A, E>,
306
+ ): Effect.Effect<A, E> =>
307
+ Effect.flatMap(Effect.serviceOption(ReadLedger), (ledger) =>
308
+ Option.isNone(ledger) ? Effect.succeed(absent) : f(ledger.value),
309
+ )
310
+
311
+ /** A close effect that does nothing: what a read outside a bracket gets back. */
312
+ const NO_CLOSE: CloseHop = () => Effect.void
313
+
314
+ /**
315
+ * File a query into the ambient ledger if there is one, and hand back the effect
316
+ * that completes it. Outside a bracket the whole thing is a no-op, and the caller
317
+ * still writes one line rather than a conditional.
318
+ */
319
+ export const recordQuery = (query: Query): Effect.Effect<CloseHop> =>
320
+ withLedger(NO_CLOSE, (ledger) => ledger.record(query))
321
+
322
+ /** The tagless slice keys in a record, in declaration order. */
323
+ const taglessKeys = (
324
+ projections: Record<string, AnyProjection>,
325
+ ): ReadonlyArray<string> =>
326
+ Object.entries(projections)
327
+ .filter(([, projection]) =>
328
+ projection.query.items.some((item) => item.tags.length === 0),
329
+ )
330
+ .map(([key]) => key)
331
+
332
+ /** Would recording this record's query leave the accumulated union unservable? */
333
+ const unionWouldBeUnservable = (
334
+ existing: ReadonlyArray<Hop>,
335
+ incoming: Query,
336
+ ): boolean =>
337
+ servableQueryFault(
338
+ unionQueries([...existing.map((h) => h.query), incoming]),
339
+ ) !== undefined
340
+
341
+ /**
342
+ * Refuse a read that would leave the bracket's accumulated union unservable,
343
+ * BEFORE the read is paid for.
344
+ *
345
+ * Servability is answerable from the queries alone, so the check runs when a hop is
346
+ * recorded rather than at append time — the eager half of the eager/lazy split doing
347
+ * one more job. Outside a bracket nothing is unioned and nothing is unservable, so
348
+ * the ordinary single-hop caller is untaxed.
349
+ *
350
+ * It judges the PROSPECTIVE UNION and asks core for the verdict, which is what makes
351
+ * both failing shapes one case rather than two. An earlier version tested each
352
+ * recorded query separately and treated a FIRST hop as unconditionally fine — but a
353
+ * hop is a RECORD, and a record of two tagless slices composes to a multi-item query
354
+ * that is unservable on its own. That read died at the store, uncatchably, on the
355
+ * exact path this error exists to keep off the defect channel. A grammar with two
356
+ * homes drifts at the shapes neither author had in mind, so there is one home and it
357
+ * is `servableQueryFault`'s.
358
+ *
359
+ * Where the OFFENDING slice is THIS record's the ledger can name its key; where it
360
+ * belongs to an earlier hop it can only say so, a recorded hop keeping its query
361
+ * rather than the key it was composed from. That asymmetry is diagnostic-only and is
362
+ * why the message is built from a key that may be a placeholder.
363
+ */
364
+ export const assertServableAddition = (
365
+ projections: Record<string, AnyProjection>,
366
+ ): Effect.Effect<void, TaglessSliceInLedger> =>
367
+ withLedger<void, TaglessSliceInLedger>(undefined, (ledger) =>
368
+ Effect.flatMap(ledger.hops, (existing) => {
369
+ const incoming = composeProjections(projections).query
370
+ if (!unionWouldBeUnservable(existing, incoming)) {
371
+ return Effect.void
372
+ }
373
+ // The offending key where the tagless slice is THIS record's, and a
374
+ // placeholder where it belongs to an earlier hop — a recorded hop keeps its
375
+ // query rather than the key it was composed from.
376
+ const offending = taglessKeys(projections)[0] ?? '<an earlier hop>'
377
+ return new TaglessSliceInLedger({ sliceKey: offending })
378
+ }),
379
+ )
380
+
381
+ /**
382
+ * The condition the ambient ledger derives right now, or `None` outside a bracket.
383
+ *
384
+ * This is what makes `buildDecisionModel` correct inside a bracket: the call whose
385
+ * current behaviour is the hazard derives from every hop the bracket walked rather
386
+ * than from its own single read.
387
+ */
388
+ export const ambientLayeredCondition: Effect.Effect<
389
+ Option.Option<AppendCondition>
390
+ > = withLedger(Option.none<AppendCondition>(), (ledger) =>
391
+ Effect.map(ledger.hops, (hops) =>
392
+ Option.fromNullable(deriveLayeredCondition(hops)),
393
+ ),
394
+ )