@kairos-es/codec 1.0.0-alpha.0 → 1.0.0-alpha.2

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 (44) hide show
  1. package/README.md +75 -3
  2. package/dist/cjs/decider.js +44 -69
  3. package/dist/cjs/decider.js.map +1 -1
  4. package/dist/cjs/decision.js +45 -0
  5. package/dist/cjs/decision.js.map +1 -0
  6. package/dist/cjs/decisionModel.js +76 -20
  7. package/dist/cjs/decisionModel.js.map +1 -1
  8. package/dist/cjs/index.js +63 -0
  9. package/dist/cjs/index.js.map +1 -1
  10. package/dist/cjs/precondition.js +191 -0
  11. package/dist/cjs/precondition.js.map +1 -0
  12. package/dist/cjs/readLedger.js +224 -0
  13. package/dist/cjs/readLedger.js.map +1 -0
  14. package/dist/dts/decider.d.ts +57 -8
  15. package/dist/dts/decider.d.ts.map +1 -1
  16. package/dist/dts/decision.d.ts +161 -0
  17. package/dist/dts/decision.d.ts.map +1 -0
  18. package/dist/dts/decisionModel.d.ts +33 -13
  19. package/dist/dts/decisionModel.d.ts.map +1 -1
  20. package/dist/dts/index.d.ts +69 -0
  21. package/dist/dts/index.d.ts.map +1 -1
  22. package/dist/dts/precondition.d.ts +244 -0
  23. package/dist/dts/precondition.d.ts.map +1 -0
  24. package/dist/dts/readLedger.d.ts +263 -0
  25. package/dist/dts/readLedger.d.ts.map +1 -0
  26. package/dist/esm/decider.js +42 -67
  27. package/dist/esm/decider.js.map +1 -1
  28. package/dist/esm/decision.js +37 -0
  29. package/dist/esm/decision.js.map +1 -0
  30. package/dist/esm/decisionModel.js +77 -21
  31. package/dist/esm/decisionModel.js.map +1 -1
  32. package/dist/esm/index.js +69 -0
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/precondition.js +182 -0
  35. package/dist/esm/precondition.js.map +1 -0
  36. package/dist/esm/readLedger.js +212 -0
  37. package/dist/esm/readLedger.js.map +1 -0
  38. package/package.json +2 -2
  39. package/src/decider.ts +98 -19
  40. package/src/decision.ts +174 -0
  41. package/src/decisionModel.ts +87 -23
  42. package/src/index.ts +87 -0
  43. package/src/precondition.ts +371 -0
  44. package/src/readLedger.ts +394 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * The bracket that owns the append, the precondition value it takes, and the two
3
+ * functions that make a decision inside it writable.
4
+ *
5
+ * ## What the bracket is for, and why no condition reaches the caller
6
+ *
7
+ * `transact(precondition)(body)` evaluates a guard, hands its output to the body
8
+ * as a service, and appends whatever the body produced under the union of every
9
+ * read EITHER of them performed. A caller writes no condition, no union, no floor
10
+ * and no `min`.
11
+ *
12
+ * That is not an ergonomic preference. Owning the append does not make a wrong
13
+ * condition a type error — it makes one UNWRITABLE, which is the stronger
14
+ * guarantee: a returned condition is a condition a caller can decline to use, and
15
+ * a decision that looks guarded and is not is the exact failure this seam exists
16
+ * to close. It is what Axon Framework 5 does, where the condition is married to
17
+ * the events at prepare-commit and the caller never holds one.
18
+ *
19
+ * One thing is deliberately NOT borrowed from that prior art. There, an append
20
+ * with no prior read leaves the condition unset and the append unguarded, so the
21
+ * method name reads as unconditional while the behaviour is not. `NoReadsRecorded`
22
+ * closes that door here and must not be lost.
23
+ *
24
+ * ⚠ The name says *declare, then do*. It is NOT a database transaction: an append
25
+ * under a condition is not two-phase, and nothing rolls back.
26
+ *
27
+ * ## A body may answer `Unchanged`, and then there is nothing to guard
28
+ *
29
+ * The body's success is a `DecisionOutput`: a non-empty batch, or `Unchanged` —
30
+ * "already done". On `Unchanged` the bracket appends nothing, derives no
31
+ * condition and returns the `Unchanged`, and the result type is
32
+ * `DecisionResult<O>`, so a body that cannot answer it keeps a plain `Position`.
33
+ * `decideOnly` passes a decider's `Unchanged` through to the body unaltered.
34
+ *
35
+ * It is NOT refused as `NoReadsRecorded`, even from a bracket that read nothing,
36
+ * and that is not a hole in the refusal. `NoReadsRecorded` exists because an
37
+ * append with no prior read would go out UNGUARDED while looking guarded. An
38
+ * `Unchanged` appends nothing, so there is no write for a condition to guard and
39
+ * nothing for the refusal to protect. The same empty bracket returning EVENTS is
40
+ * still refused, exactly as before. `decision.ts` owns what `Unchanged` is.
41
+ *
42
+ * ## Order is the design, and it is the one line here that must not move
43
+ *
44
+ * The precondition is evaluated INSIDE the bracket and never before it. Evaluated
45
+ * before, its reads are not part of what it protects — the ledger does not exist
46
+ * yet, so its hops are lost and the write is guarded only by the business read.
47
+ * The check would look exactly as it does now and would guard nothing.
48
+ *
49
+ * ## Why the precondition's output crosses as a SERVICE
50
+ *
51
+ * A body that needs authorisation names the tag in its requirement channel, so
52
+ * `transact` must provide it, so the body CANNOT BE RUN without a precondition.
53
+ * That is a PRESENCE check, and presence is the one direction the type system can
54
+ * decide here — `Exclude<R, Id>` in the return type is where it is spent. The
55
+ * alternative, a curried parameter, buys nothing equivalent: a body is free to
56
+ * ignore a parameter.
57
+ *
58
+ * ## Composition is a combinator, not a list
59
+ *
60
+ * `transact` takes exactly ONE precondition. A variadic argument asserts
61
+ * INDEPENDENCE, and the interesting case is DEPENDENT — *may act for this
62
+ * organisation*, then *may sign in that organisation* — which a flat list cannot
63
+ * express and which is the very shape this change exists for. A dependent pair
64
+ * needs nothing anyway: a precondition is an ordinary `Effect` evaluated inside
65
+ * the open ledger, so two compose with `Effect.gen` and both sets of reads land in
66
+ * the same condition. An INDEPENDENT set wants a combinator over a RECORD,
67
+ * evaluated SEQUENTIALLY — fail-fast is the point, and concurrent reads are
68
+ * precisely the case where the earliest-head reduction stops being redundant. That
69
+ * combinator is purely additive and is not shipped here.
70
+ *
71
+ * ## Conflict retry goes OUTSIDE the bracket, never inside
72
+ *
73
+ * `retryOnConflict(transact(p)(body))`, never a retry within the body. The bracket
74
+ * takes an opaque `Effect`, so no type can express that rule and the lint gate is
75
+ * what enforces it: in the source repository, `biome-plugins/noRetryInBracket.grit`
76
+ * owns why and `test/retryInBracket.test.ts` executes it.
77
+ *
78
+ * ## ⚠⚠ THE ONE MISTAKE NOTHING CATCHES: a view read inside a bracket
79
+ *
80
+ * **A projection read, a raw SQL read, or any read that does not fold through
81
+ * `modelAtHead`, performed inside a bracket, contributes NO HOP.** Its query is
82
+ * absent from the derived union and its head is absent from the floor, so the
83
+ * decision rests on data the condition does not guard — and the bracket looks
84
+ * guarded, which is the exact failure this seam exists to close, wearing the fix's
85
+ * own name.
86
+ *
87
+ * **Nothing will tell you — not here and not in your own tree.** Not the type
88
+ * system: a requirement channel is ADDITIVE, so it can demand a capability's
89
+ * presence and never its absence. Not `NoReadsRecorded`: that fires on an EMPTY
90
+ * bracket and never on an under-populated one, so a bracket holding one genuine fold
91
+ * beside a view read passes it. And not a lint rule — one was written for exactly
92
+ * this, kept for a while and deleted, because a syntactic gate has no call graph and
93
+ * no types, so it could only name a receiver by convention, and because a lint
94
+ * plugin runs only where its own config is and so never travels with the library.
95
+ * `biome-plugins/README.md` in the source repository records the test that rule
96
+ * failed.
97
+ *
98
+ * So this is a rule a reader keeps, and knowing it is the guarantee: **inside a
99
+ * bracket, read through `modelAtHead`. If a read must
100
+ * consult a materialised view, do it OUTSIDE the bracket** — where it is ordinary
101
+ * code, because its answer is then not part of what the append is guarded on.
102
+ * ADR-0014's *decision read* rule is the test for which one a given read is.
103
+ */
104
+ import type { AppendConditionFailed, DcbEvent, Position } from '@kairos-es/core'
105
+ import { AppendIsOwned, DcbEventStore } from '@kairos-es/core'
106
+ import type { Array as Arr } from 'effect'
107
+ import { Context, Effect, Layer } from 'effect'
108
+ import type { Decider } from './decider'
109
+ import {
110
+ type DecisionOutput,
111
+ type DecisionResult,
112
+ isUnchanged,
113
+ } from './decision'
114
+ import { modelAtHead, type RequirementOf } from './decisionModel'
115
+ import type { CodecError } from './errors'
116
+ import type { SliceProjection } from './projection'
117
+ import {
118
+ deriveLayeredCondition,
119
+ makeReadLedger,
120
+ NoReadsRecorded,
121
+ ReadLedger,
122
+ type TaglessSliceInLedger,
123
+ } from './readLedger'
124
+ import type { Serializer } from './Serializer'
125
+ import type { Uuid } from './Uuid'
126
+
127
+ /**
128
+ * A named, reusable guard plus the tag its output is handed across.
129
+ *
130
+ * A VALUE and not a function of the command, deliberately: the point of the seam
131
+ * is that a guard is written once, centrally, and referenced by however many
132
+ * commands need it. A command supplies its own arguments when it BUILDS the value
133
+ * (`mayActFor(userId, orgId, verb)`), and what `transact` receives is the built
134
+ * thing.
135
+ *
136
+ * `Service` is fully generic and the library never reads it. Every consumer-side
137
+ * authorisation shape — a plain resolved identifier, a branded proof, a gate with
138
+ * a private minting closure — is application code this genericity accommodates
139
+ * without the library knowing anything about it. That is a requirement rather than
140
+ * an incidental property: hard-coding it would foreclose a consumer's later
141
+ * choice, and it costs nothing to keep.
142
+ *
143
+ * ⚠ `evaluate` is an `Effect`, so it is a FIELD OF AN OBJECT LITERAL and its
144
+ * expression is evaluated when the `Precondition` VALUE is built — before the
145
+ * bracket opens and before any cheap check inside it runs. Anything a builder does
146
+ * eagerly there (parsing an argument, resolving an identifier) happens outside the
147
+ * ordering guarantee above, and a throw there is an uncatchable defect on the very
148
+ * path meant to deny the input cheaply. Wrap such work in `Effect.suspend`.
149
+ */
150
+ export interface Precondition<Id, Service, E, R> {
151
+ readonly tag: Context.Tag<Id, Service>
152
+ readonly evaluate: Effect.Effect<Service, E, R>
153
+ }
154
+
155
+ /**
156
+ * The DCB command cycle MINUS the append: read the decision model, decode, fold,
157
+ * decide, and RETURN the events for the bracket to append — or the decider's
158
+ * `Unchanged`, passed through as it came, for the bracket to append nothing.
159
+ *
160
+ * Bracket-only, and the refusal is STATIC — see the section below for the mechanism
161
+ * and, more importantly, for where the error actually lands, which is not where a
162
+ * reader first expects. Forced through anyway, being outside a bracket is a DEFECT
163
+ * rather than an error-channel value: it is not a data-dependent outcome a caller
164
+ * could handle, and the events produced would be dropped with nothing to append them.
165
+ *
166
+ * It is also what makes the nesting refusal liveable. A command runner inside a
167
+ * bracket is refused at the store, and this is the function a builder reaches for
168
+ * instead — one identifier away. A refusal is only affordable when the fix is that
169
+ * close.
170
+ *
171
+ * `modelAtHead` and NOT `buildDecisionModel`: this function must not derive a
172
+ * condition at all. The bracket owns that, and owning it in ONE place is what
173
+ * stops a caller appending under the last hop's condition alone.
174
+ *
175
+ * ## Bracket-only is a REQUIREMENT, so `tsc` refuses it rather than a run
176
+ *
177
+ * `ReadLedger` is named in the requirement channel and `transact` is the only thing
178
+ * that provides it, so a program that calls this outside a bracket cannot be RUN,
179
+ * and `tsc` is what says so — nothing has to execute for a caller to be told.
180
+ *
181
+ * ⚠ Measured, because the obvious stronger reading is wrong: the call site itself
182
+ * COMPILES. A bare `decideOnly(d, cmd)` outside a bracket is fine, and so is an
183
+ * exported helper over it; the requirement just propagates, as any unmet requirement
184
+ * does. What fails is the DISCHARGE — `Effect.provide`, `runPromise`, an annotation
185
+ * asking for `R = never` — possibly in another file. That is still a static refusal
186
+ * and it is the whole gain over the runtime defect this replaced. It is not a
187
+ * squiggle under the mistake.
188
+ *
189
+ * That is a PRESENCE check, and presence is the direction the type system can
190
+ * decide here — the same direction `transact`'s `Exclude<R, Id>` already spends on
191
+ * the precondition's output. ADR-0014's 2026-08-29 amendment carries the rule that
192
+ * follows: look for a presence check before reaching for a defect. This function
193
+ * shipped a defect first, and the check was available all along.
194
+ *
195
+ * ⚠ Two lines carry it and their JOBS differ. The RETURN ANNOTATION is the surface a
196
+ * consumer is judged against, and what the discharge above meets. The
197
+ * `yield* ReadLedger` keeps the requirement in the INFERRED type, so it survives the
198
+ * annotation being edited, and it dies at run time for the audience no type reaches
199
+ * — a JavaScript consumer runs no compile step — rather than handing back events
200
+ * with no bracket to append them. Nothing here READS the ledger; `modelAtHead` does
201
+ * the recording, ambiently, through `Effect.serviceOption`.
202
+ *
203
+ * ⚠ Do not try to write down which check catches which deletion. It was tried twice
204
+ * and was wrong twice, and `test/appendOwnership.test.ts` in the source repository
205
+ * says why the second failure means there should be no third attempt. Plant the
206
+ * mutation instead.
207
+ */
208
+ export const decideOnly = <
209
+ P extends Record<string, SliceProjection>,
210
+ Command,
211
+ E,
212
+ R,
213
+ O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
214
+ >(
215
+ decider: Decider<P, Command, E, R, O>,
216
+ command: Command,
217
+ ): Effect.Effect<
218
+ O,
219
+ E | CodecError | TaglessSliceInLedger,
220
+ ReadLedger | DcbEventStore | Serializer | Uuid | R | RequirementOf<P>
221
+ > =>
222
+ Effect.gen(function* () {
223
+ yield* ReadLedger
224
+
225
+ const { state } = yield* modelAtHead(decider.projections(command))
226
+ return yield* decider.decide(state, command)
227
+ })
228
+
229
+ /**
230
+ * The sanctioned exception to the nesting refusal: append these events under NO
231
+ * condition, and say in the identifier and in a required `reason` why that is
232
+ * deliberate.
233
+ *
234
+ * It covers three cases and no others: deliberate narrowing, a genuinely
235
+ * unconditional creation, and a second append inside one bracket. The `reason` is
236
+ * ceremony on purpose, and ADR-0014 decision 6 owns why a hatch has to cost more
237
+ * than the safe path — that argument is a DECISION and belongs to the record that
238
+ * took it, so it is not restated here. How biome turns a required argument into a
239
+ * review signal is the source repository's `biome-plugins/unsafeAppendReported.grit`.
240
+ *
241
+ * ⚠ It BYPASSES the derived condition rather than composing with it. A caller who
242
+ * genuinely needs *the union, minus one event type* must restate the whole
243
+ * condition here. If that turns up in practice, a composing override is the
244
+ * additive answer and this hatch is not it.
245
+ *
246
+ * `Effect.mapInputContext` over
247
+ * `Context.omit(AppendIsOwned)` is what lets it through the store's ownership
248
+ * refusal — the one route, and `AppendIsOwned.ts` owns why removing the marker is
249
+ * the shape rather than a second tag or a sanctioning flag.
250
+ */
251
+ export const unsafeAppend = (
252
+ events: Arr.NonEmptyReadonlyArray<DcbEvent>,
253
+ // Required by the signature and never read, which IS the mechanism: the value a
254
+ // caller has to write is what a reviewer sees in the diff, and `tsc` refuses the
255
+ // call that omits it. A lint rule asserting the same arity existed and was
256
+ // deleted, on the ground that the type already refuses it — which made that
257
+ // ground load-bearing, so `test/appendOwnership.test.ts` in the source repository
258
+ // pins it. Reading the value here would give it a second job and a second place to
259
+ // be wrong.
260
+ //
261
+ // NOT `_reason`, though it is unused. This name SHIPS — it is what a consumer's
262
+ // editor offers and what the published `.d.ts` carries — and a leading underscore
263
+ // is this ecosystem's way of saying "ignore me", which is the opposite of what a
264
+ // required argument whose whole purpose is to be read by a human should say.
265
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: unread on purpose; this name ships in the .d.ts and an underscore would tell a consumer to ignore it
266
+ reason: string,
267
+ ): Effect.Effect<Position, AppendConditionFailed, DcbEventStore> =>
268
+ Effect.flatMap(DcbEventStore, (store) =>
269
+ // ⛔ DO NOT SIMPLIFY THIS. The annotation reads as "map an empty context to an
270
+ // empty context", and the types say nothing else — but `Context.omit` runs
271
+ // against the RUNTIME context, which inside a bracket holds `AppendIsOwned`,
272
+ // and removing it is the entire reason this function can append at all. That
273
+ // marker is deliberately absent from every signature (it is read through
274
+ // `Effect.serviceOption`, which is what keeps it out of `append`'s requirement
275
+ // channel), so nothing here will fail to compile if this is deleted — what
276
+ // catches it is `test/appendOwnership.test.ts` in the source repository, not the
277
+ // type checker.
278
+ Effect.mapInputContext(
279
+ store.append(events),
280
+ (context: Context.Context<never>) => Context.omit(AppendIsOwned)(context),
281
+ ),
282
+ )
283
+
284
+ /**
285
+ * Open a bracket over ONE decision: evaluate the precondition inside it, hand its
286
+ * output to the body under the precondition's tag, and append what the body
287
+ * produced under the union of every read either of them performed.
288
+ *
289
+ * Curried so the precondition reads as the thing the command DECLARES and the body
290
+ * reads as the thing it DOES.
291
+ *
292
+ * The ownership marker is provided ALONGSIDE the ledger and to the body only, so
293
+ * any append inside the body — a nested bracket, a command runner, a raw
294
+ * `store.append` — is refused at the store as a defect, while the bracket's own
295
+ * closing append below runs outside that scope and is unaffected. That is why the
296
+ * refusal is on APPENDING rather than on opening a bracket: a command runner
297
+ * appends without opening one, and its events usually match the union the bracket
298
+ * is about to guard on, so the bracket's own close would be refused by a condition
299
+ * its own body invalidated.
300
+ *
301
+ * The body may succeed with `Unchanged` (see the module documentation), and the
302
+ * result is then `DecisionResult<O>`: `Position` for a body that cannot answer
303
+ * it, `Position | Unchanged` for one that can. `O` is last and defaulted, so an
304
+ * explicit `<E, R>` written against the narrower signature still means what it
305
+ * did.
306
+ */
307
+ export const transact =
308
+ <Id, Service, PE, PR>(precondition: Precondition<Id, Service, PE, PR>) =>
309
+ <E, R, O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>>(
310
+ body: Effect.Effect<O, E, R>,
311
+ ): Effect.Effect<
312
+ DecisionResult<O>,
313
+ E | PE | NoReadsRecorded | AppendConditionFailed,
314
+ | Exclude<Exclude<R, Id>, ReadLedger | AppendIsOwned>
315
+ | Exclude<PR, ReadLedger | AppendIsOwned>
316
+ | DcbEventStore
317
+ > =>
318
+ Effect.gen(function* () {
319
+ const store = yield* DcbEventStore
320
+ const ledger = yield* makeReadLedger
321
+
322
+ const output: DecisionOutput = yield* Effect.gen(function* () {
323
+ // ORDER IS THE DESIGN. The precondition runs first and runs HERE, with the
324
+ // ledger already in context, so its reads are hops in the very condition
325
+ // that will guard the body's write.
326
+ const proof = yield* precondition.evaluate
327
+ return yield* body.pipe(Effect.provideService(precondition.tag, proof))
328
+ }).pipe(
329
+ // ONE layer, not two provisions, and it is the ANNOTATION above that makes
330
+ // that mandatory rather than tidy.
331
+ //
332
+ // TypeScript does not simplify a deferred conditional over an unresolved
333
+ // type variable, so `Exclude<Exclude<X, A>, B>` and
334
+ // `Exclude<X, A | B>` are unrelated types while `X` is still a parameter,
335
+ // and every provision here adds one level of nesting the annotation has to
336
+ // mirror EXACTLY. Both halves measured, each as a `TS2719` at the return
337
+ // annotation: two chained provisions refuse the flat
338
+ // `Exclude<PR, ReadLedger | AppendIsOwned>`, and even with this merge the
339
+ // flat `Exclude<R, Id | ReadLedger | AppendIsOwned>` is refused too,
340
+ // because the precondition's own tag is provided separately and further in.
341
+ // So `PR` is flat and `R` is one deep, and that asymmetry is the pipeline
342
+ // showing through rather than a slip.
343
+ //
344
+ // Merging says what is true anyway, which is why it is the fix rather than
345
+ // a cast: the ledger and the ownership marker have one lifetime and one
346
+ // meaning between them, and that meaning is *you are inside a bracket*.
347
+ Effect.provide(
348
+ Layer.merge(
349
+ ledger.layer,
350
+ Layer.succeed(AppendIsOwned, { by: 'transact' }),
351
+ ),
352
+ ),
353
+ )
354
+
355
+ // "Already done": nothing to append, so no condition to derive and nothing
356
+ // for `NoReadsRecorded` to protect — that refusal guards a WRITE that would
357
+ // go out unguarded, and there is none. It must stay BEFORE the derivation,
358
+ // or an empty bracket answering `Unchanged` would be refused for a write it
359
+ // never asked for. The casts restate `DecisionResult<O>`, which the checker
360
+ // cannot relate to either branch while `O` is still a type variable.
361
+ if (isUnchanged(output)) {
362
+ return output as DecisionResult<O>
363
+ }
364
+
365
+ const condition = deriveLayeredCondition(yield* ledger.hops)
366
+ if (condition === undefined) {
367
+ return yield* new NoReadsRecorded()
368
+ }
369
+
370
+ return (yield* store.append(output, condition)) as DecisionResult<O>
371
+ })