@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.
- package/README.md +75 -3
- package/dist/cjs/decider.js +44 -69
- package/dist/cjs/decider.js.map +1 -1
- package/dist/cjs/decision.js +45 -0
- package/dist/cjs/decision.js.map +1 -0
- package/dist/cjs/decisionModel.js +76 -20
- package/dist/cjs/decisionModel.js.map +1 -1
- package/dist/cjs/index.js +63 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/precondition.js +191 -0
- package/dist/cjs/precondition.js.map +1 -0
- package/dist/cjs/readLedger.js +224 -0
- package/dist/cjs/readLedger.js.map +1 -0
- package/dist/dts/decider.d.ts +57 -8
- package/dist/dts/decider.d.ts.map +1 -1
- package/dist/dts/decision.d.ts +161 -0
- package/dist/dts/decision.d.ts.map +1 -0
- package/dist/dts/decisionModel.d.ts +33 -13
- package/dist/dts/decisionModel.d.ts.map +1 -1
- package/dist/dts/index.d.ts +69 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/precondition.d.ts +244 -0
- package/dist/dts/precondition.d.ts.map +1 -0
- package/dist/dts/readLedger.d.ts +263 -0
- package/dist/dts/readLedger.d.ts.map +1 -0
- package/dist/esm/decider.js +42 -67
- package/dist/esm/decider.js.map +1 -1
- package/dist/esm/decision.js +37 -0
- package/dist/esm/decision.js.map +1 -0
- package/dist/esm/decisionModel.js +77 -21
- package/dist/esm/decisionModel.js.map +1 -1
- package/dist/esm/index.js +69 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/precondition.js +182 -0
- package/dist/esm/precondition.js.map +1 -0
- package/dist/esm/readLedger.js +212 -0
- package/dist/esm/readLedger.js.map +1 -0
- package/package.json +2 -2
- package/src/decider.ts +98 -19
- package/src/decision.ts +174 -0
- package/src/decisionModel.ts +87 -23
- package/src/index.ts +87 -0
- package/src/precondition.ts +371 -0
- package/src/readLedger.ts +394 -0
package/src/decider.ts
CHANGED
|
@@ -52,20 +52,36 @@
|
|
|
52
52
|
* boundary, and decode may fail with `CodecError`. All surface on the error
|
|
53
53
|
* channel, so a caller handles all three the same way — and
|
|
54
54
|
* `@kairos-es/testing`'s `.thenError(tag)` can assert any of them.
|
|
55
|
+
*
|
|
56
|
+
* `decide` may also answer `Unchanged` — "already done, nothing to append" — but
|
|
57
|
+
* only where its type says so: `Decider`'s fifth type parameter `O` defaults to
|
|
58
|
+
* the non-empty batch, and `runDecider` returns `DecisionResult<O>`, which is
|
|
59
|
+
* plain `Position` unless `O` admits `Unchanged`. So a decider that cannot say
|
|
60
|
+
* "already done" keeps exactly the result type it always had — an untyped one
|
|
61
|
+
* and one built in a wrapper generic in its success type included — and one
|
|
62
|
+
* that can makes its caller handle `Position | Unchanged`. `runDecider` returns an
|
|
63
|
+
* `Unchanged` without calling `append`, so `append`'s refusal of an empty batch
|
|
64
|
+
* is untouched and never met. `decision.ts` owns why it is a success rather than
|
|
65
|
+
* an error or an empty batch, and why it is recognised by a type id.
|
|
55
66
|
*/
|
|
56
67
|
|
|
57
68
|
import type {
|
|
58
69
|
AppendConditionFailed,
|
|
59
70
|
CompositeState,
|
|
60
71
|
DcbEvent,
|
|
61
|
-
Position,
|
|
62
72
|
} from '@kairos-es/core'
|
|
63
73
|
import { DcbEventStore } from '@kairos-es/core'
|
|
64
74
|
import type { Array as Arr } from 'effect'
|
|
65
75
|
import { Effect } from 'effect'
|
|
76
|
+
import {
|
|
77
|
+
type DecisionOutput,
|
|
78
|
+
type DecisionResult,
|
|
79
|
+
isUnchanged,
|
|
80
|
+
} from './decision'
|
|
66
81
|
import { buildDecisionModel, type RequirementOf } from './decisionModel'
|
|
67
82
|
import type { CodecError } from './errors'
|
|
68
83
|
import type { SliceProjection } from './projection'
|
|
84
|
+
import type { TaglessSliceInLedger } from './readLedger'
|
|
69
85
|
import type { Serializer } from './Serializer'
|
|
70
86
|
import type { Uuid } from './Uuid'
|
|
71
87
|
|
|
@@ -83,22 +99,26 @@ import type { Uuid } from './Uuid'
|
|
|
83
99
|
* schema-backed, state-erased bound for a heterogeneous record); the
|
|
84
100
|
* precise per-key state is recovered by core's shared `CompositeState`. The
|
|
85
101
|
* requirement `R` is the union carried by the record's slices' schemas.
|
|
102
|
+
*
|
|
103
|
+
* `O` is what `decide` may succeed with. It defaults to the non-empty batch, and
|
|
104
|
+
* a decider that can answer "already done" widens it to `DecisionOutput`
|
|
105
|
+
* (`NonEmptyReadonlyArray<DcbEvent> | Unchanged`). It is LAST and DEFAULTED so
|
|
106
|
+
* that every `Decider<P, Command, E, R>` already written means exactly what it
|
|
107
|
+
* meant. Its bound is `DecisionOutput`, which still excludes a possibly-empty
|
|
108
|
+
* array — the widening adds an answer and removes no guarantee.
|
|
86
109
|
*/
|
|
87
110
|
export interface Decider<
|
|
88
111
|
P extends Record<string, SliceProjection>,
|
|
89
112
|
Command,
|
|
90
113
|
E = never,
|
|
91
114
|
R = never,
|
|
115
|
+
O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
92
116
|
> {
|
|
93
117
|
readonly projections: (command: Command) => P
|
|
94
118
|
readonly decide: (
|
|
95
119
|
state: CompositeState<P>,
|
|
96
120
|
command: Command,
|
|
97
|
-
) => Effect.Effect<
|
|
98
|
-
Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
99
|
-
E,
|
|
100
|
-
Serializer | Uuid | R
|
|
101
|
-
>
|
|
121
|
+
) => Effect.Effect<O, E, Serializer | Uuid | R>
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
/**
|
|
@@ -148,6 +168,22 @@ export interface Decider<
|
|
|
148
168
|
* Effect.Error<Eff>, …>`. The value is the two functions it was handed, so
|
|
149
169
|
* there is nothing for the cast to be wrong about.
|
|
150
170
|
*
|
|
171
|
+
* The success channel is how a decider opts into `Unchanged` without writing a
|
|
172
|
+
* fifth type argument, and it is why this function is declared TWICE. The first
|
|
173
|
+
* declaration bounds `Eff` by the non-empty batch and returns a `Decider` with
|
|
174
|
+
* the DEFAULT `O`; the second bounds it by `DecisionOutput` and returns one whose
|
|
175
|
+
* `O` is `DecisionOutput`. A `decide` with a branch returning
|
|
176
|
+
* `Decision.unchanged(…)` fails the first bound and lands on the second, and one
|
|
177
|
+
* with no such branch keeps the default. Either way `O` is one of the two a
|
|
178
|
+
* caller ever writes, never the inferred success type passed through: inference
|
|
179
|
+
* is more precise than anyone writes — the produce path's one-element tuple, say
|
|
180
|
+
* — and a `Decider<…, readonly [DcbEvent]>` would not be the
|
|
181
|
+
* `Decider<P, Command, E, R>` its caller spells, though it behaves identically.
|
|
182
|
+
* Asking by ASSIGNABILITY rather than by a conditional over `Eff`'s success is
|
|
183
|
+
* what keeps an untyped (`any`) success, and a success that is a type variable
|
|
184
|
+
* bounded by the batch, on the default; `decision.ts` owns that argument, and
|
|
185
|
+
* the one type query the pair cannot answer as before.
|
|
186
|
+
*
|
|
151
187
|
* `R` excludes `Serializer | Uuid` because that is what `R` MEANS on `Decider`:
|
|
152
188
|
* the interface writes decide's requirement as `Serializer | Uuid | R`, so `R`
|
|
153
189
|
* is what the produce side needs BEYOND the codec's own two services. Leaving
|
|
@@ -159,7 +195,7 @@ export interface Decider<
|
|
|
159
195
|
* `@kairos-es/codec/Serializer` matches — and a tag under that key IS that
|
|
160
196
|
* service, context being keyed by the same string at runtime.
|
|
161
197
|
*/
|
|
162
|
-
export
|
|
198
|
+
export function decider<
|
|
163
199
|
P extends Record<string, SliceProjection>,
|
|
164
200
|
Command,
|
|
165
201
|
Eff extends Effect.Effect<
|
|
@@ -175,38 +211,74 @@ export const decider = <
|
|
|
175
211
|
Command,
|
|
176
212
|
Effect.Effect.Error<Eff>,
|
|
177
213
|
Exclude<Effect.Effect.Context<Eff>, Serializer | Uuid>
|
|
178
|
-
>
|
|
179
|
-
|
|
214
|
+
>
|
|
215
|
+
export function decider<
|
|
216
|
+
P extends Record<string, SliceProjection>,
|
|
217
|
+
Command,
|
|
218
|
+
Eff extends Effect.Effect<DecisionOutput, unknown, unknown>,
|
|
219
|
+
>(
|
|
220
|
+
projections: (command: Command) => P,
|
|
221
|
+
decide: (state: CompositeState<P>, command: Command) => Eff,
|
|
222
|
+
): Decider<
|
|
223
|
+
P,
|
|
224
|
+
Command,
|
|
225
|
+
Effect.Effect.Error<Eff>,
|
|
226
|
+
Exclude<Effect.Effect.Context<Eff>, Serializer | Uuid>,
|
|
227
|
+
DecisionOutput
|
|
228
|
+
>
|
|
229
|
+
export function decider<P extends Record<string, SliceProjection>, Command>(
|
|
230
|
+
projections: (command: Command) => P,
|
|
231
|
+
decide: (
|
|
232
|
+
state: CompositeState<P>,
|
|
233
|
+
command: Command,
|
|
234
|
+
) => Effect.Effect<DecisionOutput, unknown, unknown>,
|
|
235
|
+
): Decider<P, Command, unknown, unknown, DecisionOutput> {
|
|
236
|
+
return { projections, decide } as Decider<
|
|
180
237
|
P,
|
|
181
238
|
Command,
|
|
182
|
-
|
|
183
|
-
|
|
239
|
+
unknown,
|
|
240
|
+
unknown,
|
|
241
|
+
DecisionOutput
|
|
184
242
|
>
|
|
243
|
+
}
|
|
185
244
|
|
|
186
245
|
/**
|
|
187
246
|
* Run the full DCB command cycle for a decider against the ambient
|
|
188
247
|
* `DcbEventStore`: build the decision model (real read + decode + fold), decide,
|
|
189
|
-
* and append under the read's append condition. Returns the last new position
|
|
248
|
+
* and append under the read's append condition. Returns the last new position —
|
|
249
|
+
* or, when `decide` answered `Unchanged`, that `Unchanged`, having appended
|
|
250
|
+
* nothing. The result type is `DecisionResult<O>`, so it is plain `Position` for
|
|
251
|
+
* every decider that cannot answer `Unchanged`, including one whose `O` is still
|
|
252
|
+
* a type variable bounded by the batch. ONE signature, deliberately:
|
|
253
|
+
* `decision.ts` says why that alias, and not an overload, is what carries it.
|
|
190
254
|
*
|
|
191
255
|
* The requirement is `DcbEventStore | Serializer | Uuid | R |
|
|
192
256
|
* RequirementOf<P>`: `Serializer` enters through both the decode
|
|
193
257
|
* (`buildDecisionModel`) and produce (`toEvent`) sides, `Uuid` through produce,
|
|
194
258
|
* the slices' `RequirementOf<P>` through decode, and `R` through produce. (For a
|
|
195
259
|
* def whose `Schema` carries a requirement, `RequirementOf<P>` and `R` are the
|
|
196
|
-
* same tag.) Any `decide` failure, a decode `CodecError`,
|
|
197
|
-
* `AppendConditionFailed
|
|
260
|
+
* same tag.) Any `decide` failure, a decode `CodecError`, an
|
|
261
|
+
* `AppendConditionFailed`, or the read's own `TaglessSliceInLedger` propagates on
|
|
262
|
+
* the error channel unchanged.
|
|
263
|
+
*
|
|
264
|
+
* ⚠ INSIDE a bracket this function is REFUSED, as a defect, at the store's shared
|
|
265
|
+
* append decorator, and `decideOnly` is what a body reaches for instead.
|
|
266
|
+
* `index.ts` states that pair, why each refuses exactly where the other is the
|
|
267
|
+
* answer, and why neither can be expressed over the other. Read it before
|
|
268
|
+
* changing either signature; this file does not restate the argument.
|
|
198
269
|
*/
|
|
199
270
|
export const runDecider = <
|
|
200
271
|
P extends Record<string, SliceProjection>,
|
|
201
272
|
Command,
|
|
202
273
|
E,
|
|
203
274
|
R,
|
|
275
|
+
O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
204
276
|
>(
|
|
205
|
-
decider: Decider<P, Command, E, R>,
|
|
277
|
+
decider: Decider<P, Command, E, R, O>,
|
|
206
278
|
command: Command,
|
|
207
279
|
): Effect.Effect<
|
|
208
|
-
|
|
209
|
-
E | CodecError | AppendConditionFailed,
|
|
280
|
+
DecisionResult<O>,
|
|
281
|
+
E | CodecError | AppendConditionFailed | TaglessSliceInLedger,
|
|
210
282
|
DcbEventStore | Serializer | Uuid | R | RequirementOf<P>
|
|
211
283
|
> =>
|
|
212
284
|
Effect.gen(function* () {
|
|
@@ -214,6 +286,13 @@ export const runDecider = <
|
|
|
214
286
|
const { state, appendCondition } = yield* buildDecisionModel(
|
|
215
287
|
decider.projections(command),
|
|
216
288
|
)
|
|
217
|
-
const
|
|
218
|
-
|
|
289
|
+
const output: DecisionOutput = yield* decider.decide(state, command)
|
|
290
|
+
// "Already done" never reaches `append`: an empty batch stays a defect there,
|
|
291
|
+
// and this is not one. The two casts restate `DecisionResult<O>`, which the
|
|
292
|
+
// checker cannot relate to either branch while `O` is still a type variable;
|
|
293
|
+
// the branch taken is exactly the one the conditional selects.
|
|
294
|
+
if (isUnchanged(output)) {
|
|
295
|
+
return output as DecisionResult<O>
|
|
296
|
+
}
|
|
297
|
+
return (yield* store.append(output, appendCondition)) as DecisionResult<O>
|
|
219
298
|
})
|
package/src/decision.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unchanged`: a decision's answer when there is nothing to do because the work
|
|
3
|
+
* is already done, and the two types that let a decision say so without costing
|
|
4
|
+
* any existing caller its `Position`.
|
|
5
|
+
*
|
|
6
|
+
* ## Why a decision needs a third answer
|
|
7
|
+
*
|
|
8
|
+
* A decision had two answers: a non-empty batch of events to append, or a
|
|
9
|
+
* failure. The idempotent case fitted neither. A webhook redelivered after its
|
|
10
|
+
* first delivery was recorded, or a recorder re-running after a conflict whose
|
|
11
|
+
* winning write already closed the item, has nothing to append — and nothing
|
|
12
|
+
* WRONG has happened, so a failure is the wrong channel: a caller would have to
|
|
13
|
+
* tell "already done" from a genuine refusal by matching a tag, and a retry
|
|
14
|
+
* policy keyed on the error channel would retry a success. An empty batch is not
|
|
15
|
+
* the answer either. `append` refuses one as a defect, and the `Decider`'s
|
|
16
|
+
* `NonEmptyReadonlyArray` bound is what makes that refusal unreachable from a
|
|
17
|
+
* decision, a guarantee `decider.ts` calls load-bearing. So "already done" gets a
|
|
18
|
+
* value of its own, and it is a SUCCESS: an idempotent skip is never retried.
|
|
19
|
+
*
|
|
20
|
+
* ## Why it is recognised by a type id, and never by `instanceof` or a `_tag`
|
|
21
|
+
*
|
|
22
|
+
* This package ships dual ESM/CJS, so one consumer's graph can hold two copies of
|
|
23
|
+
* this module and therefore two distinct `Unchanged` classes. Under `instanceof`,
|
|
24
|
+
* a value built by one copy fails the other's check, and the runner would treat
|
|
25
|
+
* "already done" as a batch and hand it to `append` — the same single point of
|
|
26
|
+
* silent failure `@kairos-es/core` argues against, in its contract store, for
|
|
27
|
+
* `AppendConditionFailed`. That error falls back on its `_tag`, and a tag is
|
|
28
|
+
* enough there because the error channel is the library's own. A DECISION's
|
|
29
|
+
* success channel is not: an application's own union may well carry a member
|
|
30
|
+
* tagged `'Unchanged'`, and a runner that skipped on the bare tag would swallow
|
|
31
|
+
* that value and append nothing, silently. `Symbol.for` gives an identity that is
|
|
32
|
+
* shared across both builds (the global symbol registry is per runtime, not per
|
|
33
|
+
* module copy) and that no application value carries by accident. `isUnchanged`
|
|
34
|
+
* is the one test, and every runner in this package uses it.
|
|
35
|
+
*
|
|
36
|
+
* ## Why the widening is a defaulted type parameter, not a second entry point
|
|
37
|
+
*
|
|
38
|
+
* `Decider` gains a fifth type parameter `O`, defaulting to the non-empty batch.
|
|
39
|
+
* A second entry point would have meant a second `Decider`, a second runner, a
|
|
40
|
+
* second `transact` and a second `decideOnly` — against a surface whose whole
|
|
41
|
+
* argument is that `runDecider` and `decideOnly` are ONE pair.
|
|
42
|
+
*
|
|
43
|
+
* ## Why `DecisionResult` is shaped as it is, and why `decider` is overloaded
|
|
44
|
+
*
|
|
45
|
+
* The promise is that a decider or body that cannot answer `Unchanged` keeps
|
|
46
|
+
* exactly the `Position` result it had. Two kinds of success make that harder
|
|
47
|
+
* than it looks, and both were measured against call shapes that compiled
|
|
48
|
+
* before the widening.
|
|
49
|
+
*
|
|
50
|
+
* - An UNTYPED success. `any` satisfies every question a conditional can ask of
|
|
51
|
+
* it: `Unchanged extends any` holds, and `any extends Unchanged` takes BOTH
|
|
52
|
+
* branches. So a decision nobody typed would read as one that can answer
|
|
53
|
+
* `Unchanged`, and its caller's `(position: Position) => …` would stop
|
|
54
|
+
* compiling. `DecisionResult` therefore checks for `any` FIRST, and answers
|
|
55
|
+
* `Position`.
|
|
56
|
+
* - A GENERIC success. In a wrapper generic in the success type
|
|
57
|
+
* (`<A extends NonEmptyReadonlyArray<DcbEvent>, …>`), a conditional over `A`
|
|
58
|
+
* stays DEFERRED, and a deferred conditional is `Position` only if the checker
|
|
59
|
+
* can see that it must be. It can for one shape and not for the other:
|
|
60
|
+
* `Unchanged extends O ? …` is related through the union of its two branches,
|
|
61
|
+
* which admits `Unchanged`; `O extends Unchanged ? …`, DISTRIBUTIVE over `O`,
|
|
62
|
+
* is related through itself instantiated at `O`'s bound — and at a bound of
|
|
63
|
+
* the non-empty batch that is `Position`. So `DecisionResult` is written the
|
|
64
|
+
* second way, and `runDecider` and `transact` keep ONE signature each, which
|
|
65
|
+
* is what lets `body.pipe(transact(p))` still infer its error and requirement
|
|
66
|
+
* channels; an overloaded step would lose both to `unknown`.
|
|
67
|
+
*
|
|
68
|
+
* `decider(…)` cannot be served by a conditional at all. Its `O` is derived from
|
|
69
|
+
* the SUCCESS of an effect type variable, and `Effect.Effect.Success` of a type
|
|
70
|
+
* variable is itself deferred, so a wrapper that copies `decider`'s own generics
|
|
71
|
+
* would get a `Decider` whose `O` nothing can resolve, and a runner result that
|
|
72
|
+
* is not `Position`. So `decider` is declared TWICE, which asks the question by
|
|
73
|
+
* ASSIGNABILITY instead: FIRST for an effect whose success is the non-empty
|
|
74
|
+
* batch, returning a `Decider` with the default `O`, and only then for any
|
|
75
|
+
* `DecisionOutput`, returning one whose `O` is `DecisionOutput`. A type
|
|
76
|
+
* variable bounded by the batch fits the first, and so does `any`; a `decide`
|
|
77
|
+
* with a branch answering `Unchanged` does not, and falls through to the second.
|
|
78
|
+
* Keep that ORDER: the checker takes the first declaration that fits, so
|
|
79
|
+
* swapped, every decider would be widened.
|
|
80
|
+
*
|
|
81
|
+
* Neither reaches a type QUERY. `ReturnType<typeof runDecider>` reads `O` at its
|
|
82
|
+
* bound, which admits `Unchanged`, so its success is widened; instantiated
|
|
83
|
+
* (`typeof runDecider<P, Command, E, R>`), `O` takes its default and the answer
|
|
84
|
+
* is `Position` again. `ReturnType<typeof decider<…>>` reads the LAST
|
|
85
|
+
* declaration, so it is the widened `Decider` whatever effect type it is given;
|
|
86
|
+
* a decider VALUE is typed by the declaration that fitted it.
|
|
87
|
+
*
|
|
88
|
+
* `DecisionOutput` still refuses a possibly-empty array — a `DcbEvent[]` from a
|
|
89
|
+
* `filter`, say — at compile time, because a plain array is neither non-empty nor
|
|
90
|
+
* `Unchanged`. The widening adds an answer; it removes no guarantee.
|
|
91
|
+
*/
|
|
92
|
+
import type { DcbEvent, Position } from '@kairos-es/core'
|
|
93
|
+
import { type Array as Arr, Data, Predicate } from 'effect'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The type id `isUnchanged` recognises. `Symbol.for`, so both halves of the dual
|
|
97
|
+
* build — and any two copies of this package in one graph — agree on it.
|
|
98
|
+
*/
|
|
99
|
+
export const UnchangedTypeId: unique symbol = Symbol.for(
|
|
100
|
+
'@kairos-es/codec/Unchanged',
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
/** The type of `UnchangedTypeId`. */
|
|
104
|
+
export type UnchangedTypeId = typeof UnchangedTypeId
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* "Nothing to do: this work is already done." A runner that receives it appends
|
|
108
|
+
* nothing and returns it in place of a `Position`.
|
|
109
|
+
*
|
|
110
|
+
* `reason` is for a human reading a trace or a test failure, and nothing in the
|
|
111
|
+
* library reads it. A `Data.TaggedClass`, so two values with the same reason are
|
|
112
|
+
* `Equal` and a test can compare them structurally.
|
|
113
|
+
*
|
|
114
|
+
* ⚠ Test for it with `isUnchanged`, never with `instanceof` or `_tag` — the
|
|
115
|
+
* module documentation says why each of those is wrong.
|
|
116
|
+
*/
|
|
117
|
+
export class Unchanged extends Data.TaggedClass('Unchanged')<{
|
|
118
|
+
readonly reason?: string
|
|
119
|
+
}> {
|
|
120
|
+
readonly [UnchangedTypeId]: UnchangedTypeId = UnchangedTypeId
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether `u` is an `Unchanged`, by its type id. Total over `unknown`, and
|
|
125
|
+
* realm-safe across the dual ESM/CJS build. An application value that merely
|
|
126
|
+
* carries `_tag: 'Unchanged'` answers `false`, deliberately.
|
|
127
|
+
*/
|
|
128
|
+
export const isUnchanged = (u: unknown): u is Unchanged =>
|
|
129
|
+
Predicate.hasProperty(u, UnchangedTypeId)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The constructors a `decide` step reaches for. One member today; it is a
|
|
133
|
+
* namespace object rather than a bare function so the call site reads as what it
|
|
134
|
+
* is, `Decision.unchanged('already dispatched')`.
|
|
135
|
+
*/
|
|
136
|
+
export const Decision: {
|
|
137
|
+
readonly unchanged: (reason?: string) => Unchanged
|
|
138
|
+
} = {
|
|
139
|
+
unchanged: (reason) => new Unchanged(reason === undefined ? {} : { reason }),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Everything a decision may succeed with: a non-empty batch of events to
|
|
144
|
+
* append, or `Unchanged`. A plain, possibly-empty array is NOT a member, which is
|
|
145
|
+
* what keeps an empty batch unreachable from a decision.
|
|
146
|
+
*/
|
|
147
|
+
export type DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent> | Unchanged
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What a runner returns for a decision that succeeds with `O`: `Position` unless
|
|
151
|
+
* `O` admits `Unchanged`, and `Position | Unchanged` when it does.
|
|
152
|
+
*
|
|
153
|
+
* Asked of each member of `O` — DISTRIBUTIVE — so a union `O` answers
|
|
154
|
+
* `Position | Unchanged` exactly when one of its members is `Unchanged`, a batch
|
|
155
|
+
* answers `Position` with no `| never` residue, and an `O` that is a type
|
|
156
|
+
* variable bounded by the batch is seen to be `Position`, which is what a
|
|
157
|
+
* wrapper generic in its success type needs. `O` that can ONLY be `Unchanged`
|
|
158
|
+
* answers `Unchanged`, the precise result and assignable wherever
|
|
159
|
+
* `Position | Unchanged` is expected.
|
|
160
|
+
*
|
|
161
|
+
* Two members are checked BEFORE the distribution, and both answer `Position`,
|
|
162
|
+
* the result each had before `Unchanged` existed. `any`, because a distributive
|
|
163
|
+
* conditional over `any` takes both branches and would widen every untyped
|
|
164
|
+
* decision. And `never` — a body that only ever fails — because distributing
|
|
165
|
+
* over `never` answers `never`, which a variable once typed with a `Position`
|
|
166
|
+
* result would no longer accept. The module documentation owns the rest.
|
|
167
|
+
*/
|
|
168
|
+
export type DecisionResult<O> = 0 extends 1 & O
|
|
169
|
+
? Position
|
|
170
|
+
: [O] extends [never]
|
|
171
|
+
? Position
|
|
172
|
+
: O extends Unchanged
|
|
173
|
+
? Unchanged
|
|
174
|
+
: Position
|
package/src/decisionModel.ts
CHANGED
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* `modelAtHead` stops where the fold does, at `{ state, head }` — a
|
|
8
8
|
* materialisation of the record with no table, no checkpoint and no runner.
|
|
9
9
|
* `buildDecisionModel` adds the only part that is genuinely decision-specific:
|
|
10
|
-
* the `AppendCondition`
|
|
11
|
-
*
|
|
12
|
-
* `buildDecisionModel(Record<string, AnyProjection>)` surface, moved here
|
|
10
|
+
* the `AppendCondition` (the DCB optimistic-concurrency contract). It is core's
|
|
11
|
+
* old `buildDecisionModel(Record<string, AnyProjection>)` surface, moved here
|
|
13
12
|
* because decode needs `Schema` + the `Serializer`.
|
|
14
13
|
*
|
|
14
|
+
* BOTH exports also record into the ambient read ledger when one is in context,
|
|
15
|
+
* which is what makes a decision that walks a chain of identifiers guard on every
|
|
16
|
+
* hop rather than on its last. Recording is `modelAtHead`'s, so it happens
|
|
17
|
+
* whatever symbol performed the read; outside a bracket there is no ledger,
|
|
18
|
+
* nothing is recorded and no behaviour changes. `readLedger.ts` owns the whole
|
|
19
|
+
* argument, including why the recorder is this module rather than a
|
|
20
|
+
* layered-only entry point of its own.
|
|
21
|
+
*
|
|
15
22
|
* The fold stays pure and in core; only the decode stage is effectful, and that
|
|
16
23
|
* stage lives in `decode.ts` — `decodeSlices` — so the read side folds a live
|
|
17
24
|
* `subscribe` stream through the *same* decode this module folds a bounded
|
|
@@ -32,10 +39,16 @@ import {
|
|
|
32
39
|
DcbEventStore,
|
|
33
40
|
type Position,
|
|
34
41
|
} from '@kairos-es/core'
|
|
35
|
-
import { Effect, Stream } from 'effect'
|
|
42
|
+
import { Effect, Option, Stream } from 'effect'
|
|
36
43
|
import { decodeSlices } from './decode'
|
|
37
44
|
import type { CodecError } from './errors'
|
|
38
45
|
import type { SliceProjection } from './projection'
|
|
46
|
+
import {
|
|
47
|
+
ambientLayeredCondition,
|
|
48
|
+
assertServableAddition,
|
|
49
|
+
recordQuery,
|
|
50
|
+
type TaglessSliceInLedger,
|
|
51
|
+
} from './readLedger'
|
|
39
52
|
import type { Serializer } from './Serializer'
|
|
40
53
|
|
|
41
54
|
/** The union of requirements `R` carried by a record of `SliceProjection`s. */
|
|
@@ -66,6 +79,16 @@ export type RequirementOf<T extends Record<string, SliceProjection>> =
|
|
|
66
79
|
* single fold cannot serve — across many identities, ordered, joined, paginated
|
|
67
80
|
* — and never because it is fresher, because it is not.
|
|
68
81
|
*
|
|
82
|
+
* ⚠ AND INSIDE A BRACKET IT IS NOT THE FLOOR THE APPEND WILL USE. Everything
|
|
83
|
+
* above is a statement about THIS read, and it stays true; what changes is that a
|
|
84
|
+
* bracket guards on the EARLIEST head across every hop it walked, which on any hop
|
|
85
|
+
* after the first is a strictly lower number than the one returned here. So a
|
|
86
|
+
* caller who reads `head` inside a bracket and reasons about what the append will
|
|
87
|
+
* refuse is reasoning from the wrong figure — the guard is wider than this head
|
|
88
|
+
* says. Nothing here is wrong; the trap is assuming the two are the same number,
|
|
89
|
+
* and it is named because it is exactly the assumption that holds outside a
|
|
90
|
+
* bracket and quietly stops holding inside one. `readLedger.ts` owns the floor.
|
|
91
|
+
*
|
|
69
92
|
* `head` is LOG-GLOBAL: it is the head the store's `read` returned, so it moves
|
|
70
93
|
* for an event no slice here selects. `test/decisionModel.test.ts` pins that
|
|
71
94
|
* behaviour EXPLICITLY, over four appended events one of which the composed query
|
|
@@ -93,7 +116,7 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
|
|
|
93
116
|
projections: T,
|
|
94
117
|
): Effect.Effect<
|
|
95
118
|
{ readonly state: CompositeState<T>; readonly head: Position },
|
|
96
|
-
CodecError,
|
|
119
|
+
CodecError | TaglessSliceInLedger,
|
|
97
120
|
DcbEventStore | Serializer | RequirementOf<T>
|
|
98
121
|
> =>
|
|
99
122
|
Effect.gen(function* () {
|
|
@@ -108,6 +131,21 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
|
|
|
108
131
|
const composite = composeProjections(
|
|
109
132
|
projections as Record<string, AnyProjection>,
|
|
110
133
|
)
|
|
134
|
+
|
|
135
|
+
// Both ledger calls below are no-ops outside a bracket and neither touches
|
|
136
|
+
// the requirement channel; `readLedger.ts` owns why recording happens HERE
|
|
137
|
+
// rather than at a layered-only entry point of its own.
|
|
138
|
+
//
|
|
139
|
+
// Refuse an unservable union BEFORE the read is paid for: the question is
|
|
140
|
+
// answerable from the queries alone, so a hop that would kill the append two
|
|
141
|
+
// layers away fails at the read that caused it instead.
|
|
142
|
+
yield* assertServableAddition(projections as Record<string, AnyProjection>)
|
|
143
|
+
|
|
144
|
+
// EAGER: the query is filed before a single event is streamed, so a read that
|
|
145
|
+
// opens and never completes still widens the guard. What comes back is the
|
|
146
|
+
// effect that completes the hop; outside a bracket it does nothing.
|
|
147
|
+
const closeHop = yield* recordQuery(composite.query)
|
|
148
|
+
|
|
111
149
|
const { events, head } = yield* store.read(composite.query)
|
|
112
150
|
|
|
113
151
|
const decoded = decode(events)
|
|
@@ -118,6 +156,11 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
|
|
|
118
156
|
(acc, event) => composite.evolve(acc, event),
|
|
119
157
|
)) as CompositeState<T>
|
|
120
158
|
|
|
159
|
+
// LAZY: only once the fold has run to completion. A read that failed
|
|
160
|
+
// part-way folded a PREFIX, and a head recorded for it is a claim it did not
|
|
161
|
+
// earn — so the line below is unreachable on that path, deliberately.
|
|
162
|
+
yield* closeHop(head)
|
|
163
|
+
|
|
121
164
|
return { state, head }
|
|
122
165
|
})
|
|
123
166
|
|
|
@@ -134,17 +177,19 @@ export const modelAtHead = <T extends Record<string, SliceProjection>>(
|
|
|
134
177
|
* of a near-duplicate: `modelAtHead` is the shared part, this function is the
|
|
135
178
|
* condition and nothing else, and each is shorter than the one function was.
|
|
136
179
|
*
|
|
137
|
-
* `composeProjections` therefore runs
|
|
138
|
-
* `modelAtHead` for the read, once here for `failIfEventsMatch` — and
|
|
139
|
-
* deliberate rather than waste. It is a pure derivation of the record, so
|
|
140
|
-
* queries cannot disagree however either call site changes. The
|
|
141
|
-
* returning the query from `modelAtHead`, would put a third field on
|
|
142
|
-
* API that no read-time caller wants, to save one pure merge over a
|
|
143
|
-
* slices; a surface paying for a body's convenience is the worse
|
|
180
|
+
* Outside a bracket `composeProjections` therefore runs twice for one command —
|
|
181
|
+
* once inside `modelAtHead` for the read, once here for `failIfEventsMatch` — and
|
|
182
|
+
* that is deliberate rather than waste. It is a pure derivation of the record, so
|
|
183
|
+
* the two queries cannot disagree however either call site changes. The
|
|
184
|
+
* alternative, returning the query from `modelAtHead`, would put a third field on
|
|
185
|
+
* a READ-TIME API that no read-time caller wants, to save one pure merge over a
|
|
186
|
+
* handful of slices; a surface paying for a body's convenience is the worse
|
|
187
|
+
* trade. Inside a bracket the second merge is not reached at all: the ledger's
|
|
188
|
+
* own derivation composes every recorded hop instead.
|
|
144
189
|
*
|
|
145
190
|
* ## How to attribute the read, the decode and the fold — the rule, stated once
|
|
146
191
|
*
|
|
147
|
-
* Because this function is
|
|
192
|
+
* Because this function is expressed OVER `modelAtHead`, prose about the codec
|
|
148
193
|
* splits into two kinds and only one of them may name this symbol:
|
|
149
194
|
*
|
|
150
195
|
* - Naming this function as the ENTRY POINT a caller invokes, or as the path
|
|
@@ -168,15 +213,34 @@ export const buildDecisionModel = <T extends Record<string, SliceProjection>>(
|
|
|
168
213
|
readonly state: CompositeState<T>
|
|
169
214
|
readonly appendCondition: AppendCondition
|
|
170
215
|
},
|
|
171
|
-
CodecError,
|
|
216
|
+
CodecError | TaglessSliceInLedger,
|
|
172
217
|
DcbEventStore | Serializer | RequirementOf<T>
|
|
173
218
|
> =>
|
|
174
|
-
Effect.
|
|
175
|
-
state,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const { state, head } = yield* modelAtHead(projections)
|
|
221
|
+
|
|
222
|
+
// INSIDE A BRACKET the condition comes from the ledger — every query the
|
|
223
|
+
// bracket walked, at the earliest head observed — and not from this one read.
|
|
224
|
+
// That is the change's whole ergonomic claim: the call whose current
|
|
225
|
+
// behaviour is the hazard becomes CORRECT inside a bracket, with nothing at
|
|
226
|
+
// the call site changing beyond entering one. The cost is that one expression
|
|
227
|
+
// means two things depending on ambient context, and that is the trade taken.
|
|
228
|
+
//
|
|
229
|
+
// `modelAtHead` has just recorded a hop, so inside a bracket the derivation
|
|
230
|
+
// cannot be empty; `None` here means there is no ledger at all.
|
|
231
|
+
const ambient = yield* ambientLayeredCondition
|
|
232
|
+
if (Option.isSome(ambient)) {
|
|
233
|
+
return { state, appendCondition: ambient.value }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// OUTSIDE a bracket this is byte-for-byte what shipped.
|
|
237
|
+
return {
|
|
238
|
+
state,
|
|
239
|
+
appendCondition: {
|
|
240
|
+
failIfEventsMatch: composeProjections(
|
|
241
|
+
projections as Record<string, AnyProjection>,
|
|
242
|
+
).query,
|
|
243
|
+
after: head,
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,72 @@
|
|
|
34
34
|
* error union are written once, in the two function bodies, rather than restated
|
|
35
35
|
* as an annotation above them.
|
|
36
36
|
*
|
|
37
|
+
* A decision may also answer `Unchanged` — "already done, nothing to append" —
|
|
38
|
+
* built with `Decision.unchanged(reason?)` and recognised ONLY by
|
|
39
|
+
* `isUnchanged`, by its `UnchangedTypeId`. It is a success, not an error and not
|
|
40
|
+
* an empty batch: every runner returns it in place of a `Position` and appends
|
|
41
|
+
* nothing. `Decider`'s fifth type parameter carries the widening and defaults to
|
|
42
|
+
* the non-empty batch, and each runner's result is `DecisionResult<O>`, so a
|
|
43
|
+
* decider or body that cannot answer `Unchanged` keeps a plain `Position` — an
|
|
44
|
+
* untyped one and one generic in its success type included — and its caller is
|
|
45
|
+
* never asked to handle it. `decision.ts` owns the argument, and the one type
|
|
46
|
+
* query that does read differently.
|
|
47
|
+
*
|
|
48
|
+
* `transact(precondition)(body)` is the layered write path, for a decision whose
|
|
49
|
+
* second query cannot be written until an earlier read has been folded. It opens
|
|
50
|
+
* an ambient read ledger, evaluates the precondition inside it, hands that
|
|
51
|
+
* precondition's output to the body as a service, and appends what the body
|
|
52
|
+
* returned under the union of every query either of them read, at the earliest
|
|
53
|
+
* head observed. No condition reaches the caller. `unsafeAppend(events, reason)`
|
|
54
|
+
* is the one sanctioned way out. `precondition.ts` owns the argument for the
|
|
55
|
+
* bracket and everything on it, and `readLedger.ts` for the ledger the seam rests
|
|
56
|
+
* on — including why the derivation itself is deliberately not on this surface.
|
|
57
|
+
*
|
|
58
|
+
* ## ⚠ `runDecider` and `decideOnly` are TWO DISPOSITIONS OF ONE CYCLE
|
|
59
|
+
*
|
|
60
|
+
* They are read as a pair or not at all, because each refuses exactly where the
|
|
61
|
+
* other is the answer:
|
|
62
|
+
*
|
|
63
|
+
* | | outside a bracket | inside a bracket |
|
|
64
|
+
* | ------------ | ---------------------------------------- | ----------------------------------- |
|
|
65
|
+
* | `runDecider` | ✅ the ordinary single-hop case | ⛔ a DEFECT, refused at the store |
|
|
66
|
+
* | `decideOnly` | ⛔ unrunnable, and `tsc` says so | ✅ the layered case |
|
|
67
|
+
*
|
|
68
|
+
* Both carry `Unchanged` the same way, which is what keeps them one pair:
|
|
69
|
+
* `runDecider` returns it having appended nothing, and `decideOnly` passes it to
|
|
70
|
+
* the body unaltered, where `transact` returns it having appended nothing —
|
|
71
|
+
* and, having no write to guard, without refusing an empty bracket as
|
|
72
|
+
* `NoReadsRecorded`.
|
|
73
|
+
*
|
|
74
|
+
* ⚠ *Unrunnable* rather than *unwritable*, and the difference is measured. A bare
|
|
75
|
+
* `decideOnly(d, cmd)` outside a bracket COMPILES, and so does an exported helper
|
|
76
|
+
* over it: its `ReadLedger` requirement simply propagates, as any unmet requirement
|
|
77
|
+
* does. The refusal lands where the context is DISCHARGED — an `Effect.provide`, a
|
|
78
|
+
* `runPromise`, an annotation asking for `R = never` — which may be another file.
|
|
79
|
+
* So `pnpm check` catches it without anything running, which is the whole gain over
|
|
80
|
+
* the runtime defect this replaced; what it does not do is underline the call.
|
|
81
|
+
*
|
|
82
|
+
* Neither is expressed over the other, and that is forced rather than chosen: a
|
|
83
|
+
* definition of `runDecider` as *"`decideOnly`, then append"* would have to call
|
|
84
|
+
* `decideOnly` outside a bracket, which does not typecheck, and the append it then
|
|
85
|
+
* made inside one would be refused. The two refusals are the same fact seen from
|
|
86
|
+
* either side — an append inside a bracket belongs to the bracket — so any
|
|
87
|
+
* expression of one over the other has to defeat one of them.
|
|
88
|
+
*
|
|
89
|
+
* ⚠ The two refusals arrive at DIFFERENT MOMENTS, which is the part a caller acts
|
|
90
|
+
* on. `runDecider` inside a bracket is refused when it RUNS, at core's shared append
|
|
91
|
+
* decorator — so the same refusal also meets a raw `store.append` and a runner one
|
|
92
|
+
* call away in a helper. `decideOnly` outside a bracket is refused by `tsc`, at the
|
|
93
|
+
* discharge point above, by naming `ReadLedger` in its requirement channel. Why each
|
|
94
|
+
* was given the instrument it was is a DECISION and belongs to the record that took
|
|
95
|
+
* it: ADR-0014's `Status`. A lint rule reaches the first case earlier still, while it
|
|
96
|
+
* is being written; `biome-plugins/README.md` in the source repository owns what a
|
|
97
|
+
* rule there can and cannot see.
|
|
98
|
+
*
|
|
99
|
+
* The remedy is one identifier away in each direction, which is the whole reason a
|
|
100
|
+
* refusal is affordable at all. `decider.ts` and `precondition.ts` carry the two
|
|
101
|
+
* halves.
|
|
102
|
+
*
|
|
37
103
|
* `modelAtHead(slices)` is that same read-decode-fold WITHOUT the
|
|
38
104
|
* `AppendCondition`: `{ state, head }`, one slice record materialised at read
|
|
39
105
|
* time with no table, no checkpoint and no runner, current as of the `head` its
|
|
@@ -52,6 +118,14 @@
|
|
|
52
118
|
*/
|
|
53
119
|
|
|
54
120
|
export { type Decider, decider, runDecider } from './decider'
|
|
121
|
+
export {
|
|
122
|
+
Decision,
|
|
123
|
+
type DecisionOutput,
|
|
124
|
+
type DecisionResult,
|
|
125
|
+
isUnchanged,
|
|
126
|
+
Unchanged,
|
|
127
|
+
UnchangedTypeId,
|
|
128
|
+
} from './decision'
|
|
55
129
|
export {
|
|
56
130
|
buildDecisionModel,
|
|
57
131
|
modelAtHead,
|
|
@@ -60,11 +134,24 @@ export {
|
|
|
60
134
|
export { decodeSlices } from './decode'
|
|
61
135
|
export { CodecError, UnknownEventType } from './errors'
|
|
62
136
|
export { type EventDef, type EventDefAny, event, type TagsFor } from './event'
|
|
137
|
+
export {
|
|
138
|
+
decideOnly,
|
|
139
|
+
type Precondition,
|
|
140
|
+
transact,
|
|
141
|
+
unsafeAppend,
|
|
142
|
+
} from './precondition'
|
|
63
143
|
export {
|
|
64
144
|
type ProjectionBuilder,
|
|
65
145
|
projection,
|
|
66
146
|
type SliceProjection,
|
|
67
147
|
} from './projection'
|
|
148
|
+
export {
|
|
149
|
+
type CloseHop,
|
|
150
|
+
type Hop,
|
|
151
|
+
NoReadsRecorded,
|
|
152
|
+
ReadLedger,
|
|
153
|
+
TaglessSliceInLedger,
|
|
154
|
+
} from './readLedger'
|
|
68
155
|
export { makeRegistry, type Registry } from './registry'
|
|
69
156
|
export { Serializer, SerializerDefault } from './Serializer'
|
|
70
157
|
export { toEvent } from './toEvent'
|