@kairos-es/codec 1.0.0-alpha.1 → 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.
@@ -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/index.ts CHANGED
@@ -34,6 +34,17 @@
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
+ *
37
48
  * `transact(precondition)(body)` is the layered write path, for a decision whose
38
49
  * second query cannot be written until an earlier read has been folded. It opens
39
50
  * an ambient read ledger, evaluates the precondition inside it, hands that
@@ -54,6 +65,12 @@
54
65
  * | `runDecider` | ✅ the ordinary single-hop case | ⛔ a DEFECT, refused at the store |
55
66
  * | `decideOnly` | ⛔ unrunnable, and `tsc` says so | ✅ the layered case |
56
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
+ *
57
74
  * ⚠ *Unrunnable* rather than *unwritable*, and the difference is measured. A bare
58
75
  * `decideOnly(d, cmd)` outside a bracket COMPILES, and so does an exported helper
59
76
  * over it: its `ReadLedger` requirement simply propagates, as any unmet requirement
@@ -101,6 +118,14 @@
101
118
  */
102
119
 
103
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'
104
129
  export {
105
130
  buildDecisionModel,
106
131
  modelAtHead,
@@ -24,6 +24,21 @@
24
24
  * ⚠ The name says *declare, then do*. It is NOT a database transaction: an append
25
25
  * under a condition is not two-phase, and nothing rolls back.
26
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
+ *
27
42
  * ## Order is the design, and it is the one line here that must not move
28
43
  *
29
44
  * The precondition is evaluated INSIDE the bracket and never before it. Evaluated
@@ -91,6 +106,11 @@ import { AppendIsOwned, DcbEventStore } from '@kairos-es/core'
91
106
  import type { Array as Arr } from 'effect'
92
107
  import { Context, Effect, Layer } from 'effect'
93
108
  import type { Decider } from './decider'
109
+ import {
110
+ type DecisionOutput,
111
+ type DecisionResult,
112
+ isUnchanged,
113
+ } from './decision'
94
114
  import { modelAtHead, type RequirementOf } from './decisionModel'
95
115
  import type { CodecError } from './errors'
96
116
  import type { SliceProjection } from './projection'
@@ -134,7 +154,8 @@ export interface Precondition<Id, Service, E, R> {
134
154
 
135
155
  /**
136
156
  * The DCB command cycle MINUS the append: read the decision model, decode, fold,
137
- * decide, and RETURN the events for the bracket to append.
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.
138
159
  *
139
160
  * Bracket-only, and the refusal is STATIC — see the section below for the mechanism
140
161
  * and, more importantly, for where the error actually lands, which is not where a
@@ -189,11 +210,12 @@ export const decideOnly = <
189
210
  Command,
190
211
  E,
191
212
  R,
213
+ O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>,
192
214
  >(
193
- decider: Decider<P, Command, E, R>,
215
+ decider: Decider<P, Command, E, R, O>,
194
216
  command: Command,
195
217
  ): Effect.Effect<
196
- Arr.NonEmptyReadonlyArray<DcbEvent>,
218
+ O,
197
219
  E | CodecError | TaglessSliceInLedger,
198
220
  ReadLedger | DcbEventStore | Serializer | Uuid | R | RequirementOf<P>
199
221
  > =>
@@ -275,13 +297,19 @@ export const unsafeAppend = (
275
297
  * appends without opening one, and its events usually match the union the bracket
276
298
  * is about to guard on, so the bracket's own close would be refused by a condition
277
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.
278
306
  */
279
307
  export const transact =
280
308
  <Id, Service, PE, PR>(precondition: Precondition<Id, Service, PE, PR>) =>
281
- <E, R>(
282
- body: Effect.Effect<Arr.NonEmptyReadonlyArray<DcbEvent>, E, R>,
309
+ <E, R, O extends DecisionOutput = Arr.NonEmptyReadonlyArray<DcbEvent>>(
310
+ body: Effect.Effect<O, E, R>,
283
311
  ): Effect.Effect<
284
- Position,
312
+ DecisionResult<O>,
285
313
  E | PE | NoReadsRecorded | AppendConditionFailed,
286
314
  | Exclude<Exclude<R, Id>, ReadLedger | AppendIsOwned>
287
315
  | Exclude<PR, ReadLedger | AppendIsOwned>
@@ -291,7 +319,7 @@ export const transact =
291
319
  const store = yield* DcbEventStore
292
320
  const ledger = yield* makeReadLedger
293
321
 
294
- const events = yield* Effect.gen(function* () {
322
+ const output: DecisionOutput = yield* Effect.gen(function* () {
295
323
  // ORDER IS THE DESIGN. The precondition runs first and runs HERE, with the
296
324
  // ledger already in context, so its reads are hops in the very condition
297
325
  // that will guard the body's write.
@@ -324,10 +352,20 @@ export const transact =
324
352
  ),
325
353
  )
326
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
+
327
365
  const condition = deriveLayeredCondition(yield* ledger.hops)
328
366
  if (condition === undefined) {
329
367
  return yield* new NoReadsRecorded()
330
368
  }
331
369
 
332
- return yield* store.append(events, condition)
370
+ return (yield* store.append(output, condition)) as DecisionResult<O>
333
371
  })