@systemfsoftware/effect-cell-types 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # @systemfsoftware/effect-cell-types
2
2
 
3
+ ## 5.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - A phase can no longer require a service. `Phases` drops `readContext` and
8
+ `writeContext`, and a read or write phase must return an effect whose context is
9
+ `never`.
10
+
11
+ Those two members were a claim about what a phase needed that nothing
12
+ recomputed. Whenever the surrounding stage was generic over `Phases`, the
13
+ compiler could not see through the type parameter to what the phase body
14
+ actually reached for, so declaring `never` for a body requiring four services
15
+ was accepted. The description then compiled clean and the missing service
16
+ surfaced only wherever it was finally applied, or nowhere at all.
17
+
18
+ Services now arrive the same way a phase's other inputs already do: as
19
+ parameters. Resolve them where you build the description, take them as an
20
+ argument, and provide them to the phase's own effect with
21
+ `Effect.provideContext`. Both mistakes that used to pass now fail — claiming
22
+ less than the body needs leaves the phase's context wider than `never`, which
23
+ the phase type rejects, and claiming more than it needs widens the requirement
24
+ of whoever builds the description, which surfaces where that builder is run.
25
+
26
+ `apply` therefore derives a context of `never` for every description, and a
27
+ caller can no longer be handed a requirement it never agreed to.
28
+
29
+ - The wire surface is now the seven symbols it always claimed to be: the mark,
30
+ the minted types, `mint`, `Fields` and `wire`. Fifteen convenience wrappers
31
+ around the schema library are gone. A member you built with a wrapper decodes
32
+ to exactly what it decoded to before; only the spelling changed.
33
+
34
+ To migrate, wrap the schema library member in `mint` where you used a wrapper:
35
+
36
+ - `string`, `number`, `boolean`, `integer` → `mint(S.String)`,
37
+ `mint(S.Finite)`, `mint(S.Boolean)`, `mint(S.Int)`
38
+ - `literal(...v)` → `mint(S.Literals([...v]))`; `union(...m)` →
39
+ `mint(S.Union([...m]))`; `tuple(...t)` → `mint(S.Tuple([...t]))`
40
+ - `nullOr(m)` / `undefinedOr(m)` / `nullishOr(m)` / `array(m)` /
41
+ `optional(m)` / `record(k, v)` / `suspend(t)` → the same letter under
42
+ `mint`, e.g. `mint(S.NullOr(m))`, `mint(S.Array(m))`, `mint(S.Record(k, v))`
43
+ - `refine(m, predicate)` → `mint(S.refine(predicate)(m))`
44
+
45
+ ### Minor Changes
46
+
47
+ - A write phase now receives what its own layer's read gathered, as a second
48
+ argument after the encoded output.
49
+
50
+ This is for the common shape where a write persists or reports on what the read
51
+ found while the decision in between narrowed to what it needed. Until now such a
52
+ write had no channel for that value, so the layer had to keep it in a mutable
53
+ binding beside the description — assigned during the read, read back during the
54
+ write — and then guard at runtime against a value that was in fact always there.
55
+ The argument replaces that binding and the guard with it.
56
+
57
+ Writes that do not want the value are unchanged: a write declaring a single
58
+ parameter still satisfies the phase type, so nothing you have already written
59
+ needs to move.
60
+
61
+ ## 4.0.0
62
+
63
+ ### Major Changes
64
+
65
+ - `Workflow.make` now takes two arguments: the command's schema class first, the decider second.
66
+
67
+ To migrate, declare the command as a `Schema.Class` or `Schema.TaggedClass` and pass it first. The decider's parameter type is inferred from that class, so its annotation can be dropped. A plain interface, a type alias, an object literal, a `Schema.Struct` and a primitive are all refused at the command position — an interface produces no value to pass, and the others are not schema classes.
68
+
69
+ The decider's own contract is unchanged: it still returns a `Result`, and a decision channel of `never`, an error channel of `never`, or an error channel carrying no tag are refused as before.
70
+
71
+ - `Tagged` no longer ships in the `Workflow` namespace.
72
+
73
+ The requirement it expressed is unchanged: a decision error must still carry a tag the consumer can
74
+ dispatch on, and `UntaggedError` still names the failure when one does not. Replace an annotation
75
+ that referred to `Workflow.Tagged` with the concrete error type, or with a `Schema.TaggedError`
76
+ class, which satisfies the channel directly.
77
+
78
+ ### Patch Changes
79
+
80
+ - The peer requirements for `effect` and for the Effect test-runner integration now accept any compatible `4.0.0-rc` release, instead of demanding one exact release candidate.
81
+
82
+ Installing alongside a newer release candidate no longer reports an unmet peer dependency or resolves a second copy of `effect` into the dependency tree.
83
+
3
84
  ## 3.0.0
4
85
 
5
86
  ### Major Changes
package/README.md CHANGED
@@ -23,22 +23,28 @@ can satisfy, so the mistake is a compile error with the remediation attached (be
23
23
 
24
24
  ## The constructor
25
25
 
26
- Executors build a workflow from a plain decider with `make` — runtime identity, one cast across the branded return:
26
+ Executors build a workflow from the command's schema class and a decider over that class — runtime identity, one assertion across the branded return:
27
27
 
28
28
  ```ts
29
29
  import { make } from '@systemfsoftware/effect-cell-types'
30
30
  import { Result } from 'effect'
31
+ import * as S from 'effect/Schema'
32
+
33
+ export class DecideInput extends S.Class<DecideInput>('DecideInput')({
34
+ exitSuccess: S.Boolean,
35
+ }) {}
31
36
 
32
- export const decide = make<DecideInput, RestartDecision, RestartDecisionExhausted>(
33
- (
34
- input,
35
- ) => (input.exitSuccess
37
+ export const decide = make(
38
+ DecideInput,
39
+ (input) => (input.exitSuccess
36
40
  ? Result.succeed(new RestartDecisionContinue())
37
41
  : Result.fail(new RestartDecisionExhausted())),
38
42
  )
39
43
  ```
40
44
 
41
- The parameter is the plain function type, not `Workflow<C, D, E>`: the `never`-channel conditional lives on the **return** type, so a total decision (`Result<Decision, never>`) resolves to `UninhabitedError` and the call site fails with "This expression is not callable", while a `Promise`- or bare-value-returning decider is rejected at the argument. `make` is a runtime value, so consumers need it as an ordinary import only where they construct workflows; everywhere else `import type` still erases at compile time.
45
+ The command is constrained on the **value**, not on a type parameter inferred from the decider's parameter, and that is the whole mechanism. A constraint on such a parameter is a structural predicate, and TypeScript cannot express "this type came from a class declaration" so a marker placed there is just a property, and `interface Fake extends Marker {}` satisfies it. A declared type produces no value, so it cannot reach an argument position at all: an interface at the command position is refused with "only refers to a type, but is being used as a value here". `Schema.Class` and `Schema.TaggedClass` are both accepted; a `Schema.Struct`, a plain class, an object literal and a primitive are each refused.
46
+
47
+ No type argument needs writing: the command type comes from the class, so the decider's parameter needs no annotation. The `never`-channel conditional still lives on the **return** type, so a total decision (`Result<Decision, never>`) resolves to `UninhabitedError` and the call site fails with "This expression is not callable", while a `Promise`- or bare-value-returning decider is rejected at the argument. `make` is a runtime value, so consumers need it as an ordinary import only where they construct workflows; everywhere else `import type` still erases at compile time.
42
48
 
43
49
  ## Worked example
44
50
 
@@ -92,7 +98,8 @@ const restartIndicesFor = (
92
98
  )
93
99
 
94
100
  export const decideRestart = Workflow.make(
95
- (input: DecideInput): Result.Result<
101
+ DecideInput,
102
+ (input): Result.Result<
96
103
  RestartDecisionContinue | RestartDecisionRestart,
97
104
  RestartDecisionExhausted
98
105
  > =>
@@ -113,25 +120,28 @@ export const decideRestart = Workflow.make(
113
120
  )
114
121
  ```
115
122
 
116
- The shape to copy: one exported decision built by `Workflow.make`, whose body returns
117
- `Result` values via `Result.succeed` and `Result.fail`. The constructor infers the channels
118
- from the annotated return and is the only door to the `WorkflowBrand` conjunct — annotating
119
- a function `Workflow<…>` directly is now refused wherever the brand is demanded, because a
120
- workflow that never passed through `make` is not a decision anything may run. The error
121
- channel is a real variant (`RestartDecisionExhausted`) giving up is a decision the caller
122
- must branch on, so declaring the error channel `never` is rejected, not allowed.
123
+ The shape to copy: one exported decision built by `Workflow.make`, taking the command's
124
+ schema class and a decider over that class, whose body returns `Result` values via
125
+ `Result.succeed` and `Result.fail`. The command channel comes from the class, the decision
126
+ and error channels are inferred from the annotated return, and `make` is the only door to
127
+ the `WorkflowBrand` conjunct annotating a function `Workflow<…>` directly is still refused
128
+ wherever the brand is demanded, because a workflow that never passed through `make` is not a
129
+ decision anything may run. The error channel is a real variant (`RestartDecisionExhausted`) —
130
+ giving up is a decision the caller must branch on, so declaring the error channel `never` is
131
+ rejected, not allowed.
123
132
 
124
133
  ## What it rejects at compile time
125
134
 
126
- All five violations fail `tsc`; the messages below are what `tsc` reports (verified against this package and `effect@4.0.0-rc.108`).
135
+ All six violations fail `tsc`; the messages below are what `tsc` reports (verified against this package and `effect@4.0.0-rc.108`).
127
136
 
128
- | Violation | `tsc` reports | Why it is rejected |
129
- | -------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
130
- | A `Promise` return | `Type 'Promise<Decision>' is not assignable to type 'Result<Decision, Err>'` | a workflow is a synchronous pure decision; async work belongs in the executor shell around it |
131
- | An `Effect` return | `Type 'Effect<Decision, never, never>' is not assignable to type 'Result<Decision, Err>'` | the workflow returns a value, not an effect handle; the executor runs effects and hands the workflow its input |
132
- | `never` decision channel | `Type '...' is not assignable to type 'UninhabitedDecision'` | a workflow that can never produce a decision can never succeed |
133
- | `never` error channel | `Type '...' is not assignable to type 'UninhabitedError'` | a workflow that cannot fail decides nothing; move it to a `*.kernel.ts` |
134
- | A bare decider handed to `Cell.decide` | `Type '(command: Cmd) => Result<Dec, Err>' is not assignable to type 'WorkflowBrand'` | only a `Workflow.make` value satisfies `DecidePhase`; a lambda that skipped `make` is not a decision a description may run |
137
+ | Violation | `tsc` reports | Why it is rejected |
138
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
139
+ | A `Promise` return | `Type 'Promise<Decision>' is not assignable to type 'Result<Decision, Err>'` | a workflow is a synchronous pure decision; async work belongs in the executor shell around it |
140
+ | An `Effect` return | `Type 'Effect<Decision, never, never>' is not assignable to type 'Result<Decision, Err>'` | the workflow returns a value, not an effect handle; the executor runs effects and hands the workflow its input |
141
+ | `never` decision channel | `Type '...' is not assignable to type 'UninhabitedDecision'` | a workflow that can never produce a decision can never succeed |
142
+ | `never` error channel | `Type '...' is not assignable to type 'UninhabitedError'` | a workflow that cannot fail decides nothing; move it to a `*.kernel.ts` |
143
+ | A bare decider handed to `Cell.decide` | `Type '(command: Cmd) => Result<Dec, Err>' is not assignable to type 'WorkflowBrand'` | only a `Workflow.make` value satisfies `DecidePhase`; a lambda that skipped `make` is not a decision a description may run |
144
+ | A plain interface at the command position | `'Cmd' only refers to a type, but is being used as a value here` | the command is constrained on the value, and a declared type produces none — so there is no marker to smuggle |
135
145
 
136
146
  The two `never` cases are where the content-vs-filename distinction pays off. `Workflow<C, never, E>` resolves to `UninhabitedDecision` and `Workflow<C, D, never>` to `UninhabitedError` — interfaces whose only property is required and whose _type_ is the remediation, so the compile error points at the fix:
137
147
 
@@ -159,17 +169,17 @@ the outcome once:
159
169
  import { Workflow } from '@systemfsoftware/effect-cell-types'
160
170
  import { Result } from 'effect'
161
171
  import * as Match from 'effect/Match'
172
+ import * as S from 'effect/Schema'
162
173
 
163
174
  class Decision {}
164
175
  class Err {
165
176
  constructor(readonly reason: string) {}
166
177
  }
167
- interface Input {
168
- readonly valid: boolean
169
- }
178
+ class Input extends S.Class<Input>('Input')({ valid: S.Boolean }) {}
170
179
 
171
180
  const decide = Workflow.make(
172
- (input: Input): Result.Result<Decision, Err> =>
181
+ Input,
182
+ (input): Result.Result<Decision, Err> =>
173
183
  Result.gen(function*() {
174
184
  const outcome = Match.value(input).pipe(
175
185
  Match.when({ valid: false }, () => Result.fail(new Err('invalid input'))),
@@ -192,7 +202,8 @@ type — so an unreachable error channel is rejected rather than silently allowe
192
202
 
193
203
  ```ts
194
204
  const decide = Workflow.make(
195
- (input: Input): Result.Result<Decision, Err> =>
205
+ Input,
206
+ (input): Result.Result<Decision, Err> =>
196
207
  Result.gen(function*() {
197
208
  return new Decision()
198
209
  }),
package/dist/index.d.ts CHANGED
@@ -2,9 +2,10 @@ import * as Effect$1 from "effect/Effect";
2
2
  import { Effect } from "effect/Effect";
3
3
  import * as Result$1 from "effect/Result";
4
4
  import { Result } from "effect/Result";
5
- import { Schema, SchemaAST } from "effect";
5
+ import * as Schema$1 from "effect/Schema";
6
+ import { Schema } from "effect";
6
7
  declare namespace Workflow_d_exports {
7
- export { Inhabited, Tagged, UninhabitedDecision, UninhabitedError, UntaggedError, Workflow, WorkflowBrand, make };
8
+ export { Inhabited, UninhabitedDecision, UninhabitedError, UntaggedError, Workflow, WorkflowBrand, make };
8
9
  }
9
10
  /**
10
11
  * The nominal brand a workflow carries. `Workflow.make` is the only door that applies it,
@@ -40,10 +41,6 @@ interface UninhabitedError {
40
41
  interface UntaggedError {
41
42
  readonly __WORKFLOW_ERROR_CHANNEL_CARRIES_NO_TAG__: 'this error carries no _tag the consumer can dispatch on; declare it as an S.TaggedError';
42
43
  }
43
- /** The shape an error channel must have: a tag the consumer dispatches on. */
44
- interface Tagged {
45
- readonly _tag: string;
46
- }
47
44
  /**
48
45
  * A decider whose channels are both inhabited, or the marker naming which channel is not.
49
46
  *
@@ -56,15 +53,48 @@ interface Tagged {
56
53
  */
57
54
  type Workflow<Command, Decision, DecisionError> = [Decision] extends [never] ? UninhabitedDecision : [DecisionError] extends [never] ? UninhabitedError : ((command: Command) => Result<Decision, DecisionError>) & WorkflowBrand;
58
55
  /**
59
- * `unknown` when both channels are inhabited and the error carries a tag, so the intersection in
60
- * {@link make} collapses to the plain `Result` and neither inference nor the authoring surface
61
- * changes. Otherwise the marker the author must satisfy, which they cannot, which is the point.
56
+ * `unknown` when the error channel carries a tag a consumer can dispatch on, the {@link UntaggedError}
57
+ * marker otherwise. Two steps, both load-bearing: `'_tag' extends keyof E` asks whether the key is
58
+ * there, and `[E['_tag']] extends [string]` asks whether what it holds is dispatchable. Key presence
59
+ * alone admits `_tag: number`, `_tag?: string` and a `_tag()` method — none of which `Match.tag` can
60
+ * dispatch on, which is exactly what the marker claims to refuse.
61
+ *
62
+ * Both steps read the tag through `keyof` and an indexed access rather than declaring a `_tag`
63
+ * member, which is why the erased `Tagged` interface cannot come back: stating the requirement as a
64
+ * shape would write the very member this repo forbids.
65
+ */
66
+ type DispatchableTag<E> = '_tag' extends keyof E ? [E['_tag']] extends [string] ? unknown : UntaggedError : UntaggedError;
67
+ /**
68
+ * `unknown` when both channels are inhabited and the error carries a dispatchable tag, so the
69
+ * intersection in {@link make} collapses to the plain `Result` and neither inference nor the
70
+ * authoring surface changes. Otherwise the marker the author must satisfy, which they cannot, which
71
+ * is the point.
62
72
  */
63
- type Inhabited<Decision, DecisionError> = [Decision] extends [never] ? UninhabitedDecision : [DecisionError] extends [never] ? UninhabitedError : [DecisionError] extends [Tagged] ? unknown : UntaggedError;
73
+ type Inhabited<Decision, DecisionError> = [Decision] extends [never] ? UninhabitedDecision : [DecisionError] extends [never] ? UninhabitedError : DispatchableTag<DecisionError>;
64
74
  /**
65
- * Builds a workflow, refusing an uninhabited or untagged channel at this call rather than at
75
+ * Builds a workflow from the command's schema class and a decider over that class's
76
+ * instance type, refusing an uninhabited or untagged channel at this call rather than at
66
77
  * whoever first calls the result — which for a workflow nothing calls yet is never.
67
78
  *
79
+ * The command is constrained on the **value**, not on a type parameter inferred from the
80
+ * decider's parameter. That is the whole mechanism. Any constraint on such a parameter is a
81
+ * structural predicate, and TypeScript cannot say "this type came from a class declaration"
82
+ * — so a marker placed there is a property, every property is declarable, and
83
+ * `interface Fake extends Marker {}` satisfies it. A declared type produces no value, so it
84
+ * cannot reach an argument position at all: there is no marker to smuggle because there is
85
+ * no marker.
86
+ *
87
+ * The three parameters mirror `Schema.Class`'s own bound exactly, and that is load-bearing.
88
+ * `Class<Self, S, Inherited>` places `S` in both covariant (`S["Type"]`) and contravariant
89
+ * (`S["fields"]`) positions, so it is invariant in `S`: every *fixed* spelling —
90
+ * `Class<unknown, Struct<Struct.Fields>, unknown>` and its variants — rejects real command
91
+ * classes. Generic over `S` accepts them and still refuses a `Struct`, which lacks
92
+ * `identifier` and `extend`. `Class<any, any, any>` also works and is banned here.
93
+ *
94
+ * `Schema.TaggedClass` returns this same `Class` interface, so one constraint covers both
95
+ * factories with no union. The import is type-only: this package gains no runtime dependency
96
+ * on Effect Schema, and `make` stays the identity function it always was.
97
+ *
68
98
  * The markers ride the parameter function's return type, not the parameter as `Workflow<C, D, E>`:
69
99
  * a conditional type in parameter position resolves `D` and `E` to `unknown` and the markers become
70
100
  * unreachable. On the return type both still infer from the `Result` conjunct while the marker
@@ -78,13 +108,28 @@ type Inhabited<Decision, DecisionError> = [Decision] extends [never] ? Uninhabit
78
108
  * The narrowing goes through an assertion signature rather than an `as` cast: every narrowing
79
109
  * assertion trips `typescript(no-unsafe-type-assertion)`, and a suppression comment would hide the
80
110
  * one place this file could lie. It is sound rather than merely permitted — with both channels
81
- * inhabited `Workflow<C, D, E>` is `(command: C) => Result<D, E>` carrying the {@link WorkflowBrand}
82
- * conjunct, and otherwise the return type is a marker with no call signature, so the value handed
83
- * back is unobservable through it. The brand is applied here and nowhere else: the assertion adds
84
- * no runtime property, so `make` stays the identity function it always was, yet a value that did
85
- * not pass through this door fails the conjunct wherever a decision is run.
111
+ * inhabited `Workflow<Self, D, E>` is `(command: Self) => Result<D, E>` carrying the
112
+ * {@link WorkflowBrand} conjunct, and otherwise the return type is a marker with no call
113
+ * signature, so the value handed back is unobservable through it. The brand is applied here and
114
+ * nowhere else: the assertion adds no runtime property, yet a value that did not pass through this
115
+ * door fails the conjunct wherever a decision is run.
116
+ */
117
+ declare const make: <Self, S extends Schema$1.Constraint & {
118
+ readonly fields: Schema$1.Struct.Fields;
119
+ }, Inherited, D, E>(_command: Schema$1.Class<Self, S, Inherited>, decide: (command: Self) => Result<D, E> & Inhabited<D, E>) => Workflow<Self, D, E>;
120
+ //#endregion
121
+ //#region src/CanonicalDecide.workflow.d.ts
122
+ declare const CanonicalCommand_base: Schema$1.Class<CanonicalCommand, Schema$1.TaggedStruct<"CanonicalCommand", {}>, {}>;
123
+ /**
124
+ * The canonical command. `Workflow.make` constrains its first argument to a real
125
+ * schema class, so the canonical description needs one too — it carries no fields
126
+ * because the canonical's phases do nothing, and its only job is to be a genuine
127
+ * command value rather than a shape asserted into place.
128
+ *
129
+ * It is declared here rather than in a `*.schema.ts` because this is the owning
130
+ * single-segment `<stem>.workflow.ts`, which `schema-declaration-location` admits.
86
131
  */
87
- declare const make: <C, D, E>(decide: (command: C) => Result<D, E> & Inhabited<D, E>) => Workflow<C, D, E>;
132
+ declare class CanonicalCommand extends CanonicalCommand_base {}
88
133
  declare namespace Cell_d_exports {
89
134
  export { Convention, DESCRIPTION_MODULE, DecideDone, DecideNode, DecidePhase, DecodeDone, DecodeNode, DecodePhase, Description, EncodeDone, EncodeNode, EncodePhase, IO_CELLS, IoCellClassification, Layer, Phase, PhaseFact, Phases, ReadDone, ReadNode, ReadPhase, Vocabulary, WriteDone, WriteNode, WritePhase, apply, canonical, decide, decode, encode, read, vocabulary, write };
90
135
  }
@@ -107,16 +152,23 @@ interface Phases {
107
152
  readonly decodeError: unknown;
108
153
  readonly readError: unknown;
109
154
  readonly writeError: unknown;
110
- readonly readContext: unknown;
111
- readonly writeContext: unknown;
112
155
  }
113
156
  /**
114
157
  * A read gathers what the decision needs, and may gather a product across its interior;
115
158
  * that interior is not type-visible, so no I/O count is claimed or enforced here. A step that
116
159
  * mutates in order to report — bumping a counter and returning the resulting rate — is one
117
160
  * such product, and belongs here rather than in a layer of its own.
161
+ *
162
+ * The context channel is pinned `never`: a phase requires nothing. Services are resolved by
163
+ * whoever builds the description and handed to the phase as ordinary parameters, which is
164
+ * the same edge that already gathers the read's inputs. The alternative — a `readContext`
165
+ * member on the bag — let an author write `never` for a body that reaches for a service, and
166
+ * nothing checked the claim: under a stage generic over `Phases` the compiler cannot see the
167
+ * lambda's requirement at all, so the description compiled and the missing service surfaced
168
+ * only where it was finally applied, or nowhere. Pinning it makes the lie unrepresentable
169
+ * instead of merely discouraged, and leaves `apply`'s derived `R` honestly `never`.
118
170
  */
119
- type ReadPhase<P extends Phases> = (command: P['command']) => Effect$1.Effect<P['raw'], P['readError'], P['readContext']>;
171
+ type ReadPhase<P extends Phases> = (command: P['command']) => Effect$1.Effect<P['raw'], P['readError'], never>;
120
172
  /** Validation. Its `Left` is fatal: it reaches the derived error channel and no write runs. */
121
173
  type DecodePhase<P extends Phases> = (raw: P['raw']) => Result$1.Result<P['decoded'], P['decodeError']>;
122
174
  /**
@@ -131,7 +183,19 @@ type DecodePhase<P extends Phases> = (raw: P['raw']) => Result$1.Result<P['decod
131
183
  type DecidePhase<P extends Phases> = ((decoded: P['decoded']) => Result$1.Result<P['decision'], P['decisionError']>) & WorkflowBrand;
132
184
  /** Shapes what the write consumes. Total, so it receives both branches of the decision. */
133
185
  type EncodePhase<P extends Phases> = (outcome: Result$1.Result<P['decision'], P['decisionError']>) => P['output'];
134
- type WritePhase<P extends Phases> = (output: P['output']) => Effect$1.Effect<P['response'], P['writeError'], P['writeContext']>;
186
+ /**
187
+ * The write. It receives the encoded `output` and, as a second argument, the `raw` its own
188
+ * layer's read gathered.
189
+ *
190
+ * `raw` is there because a write is frequently the point that persists or reports what the
191
+ * read found, while the decision in between deliberately narrows to what it needed. Without
192
+ * this argument such a write has no channel for it and the layer smuggles the value through
193
+ * a closure — a `let` beside the description, assigned in the read and consulted in the
194
+ * write, which then needs a runtime guard for a value the fold has already produced. The
195
+ * argument is second, and a write that does not want it declares one parameter: a unary
196
+ * function satisfies this type, so every write written before it existed is unchanged.
197
+ */
198
+ type WritePhase<P extends Phases> = (output: P['output'], raw: P['raw']) => Effect$1.Effect<P['response'], P['writeError'], never>;
135
199
  /**
136
200
  * The invocation shape a folding consumer must use to call a phase's `run`:
137
201
  * - `'effect'` — `run` returns an `Effect`; yield it. (read, write)
@@ -305,7 +369,7 @@ declare const write: {
305
369
  * description's response is the last layer's. No scope is opened and interruptibility is
306
370
  * untouched, so a `Scope.Scope` a phase requires reaches the caller as part of the derived `R`.
307
371
  */
308
- declare const apply: <P extends Phases>(description: WriteDone<P>, command: P['command']) => Effect$1.Effect<P["response"], P["decodeError"] | P["readError"] | P["writeError"], P["readContext"] | P["writeContext"]>;
372
+ declare const apply: <P extends Phases>(description: WriteDone<P>, command: P['command']) => Effect$1.Effect<P["response"], P["decodeError"] | P["readError"] | P["writeError"], never>;
309
373
  /** One phase's vocabulary entry: what it is called, its purity, its invocation shape. */
310
374
  interface PhaseFact {
311
375
  readonly name: Phase<Phases>['name'];
@@ -333,6 +397,16 @@ interface Vocabulary {
333
397
  */
334
398
  readonly applier: 'apply';
335
399
  }
400
+ /**
401
+ * The bag the canonical description is built with. It is `Phases` with one member
402
+ * pinned: `decoded` is the canonical command class, because `canonicalDecide` is a
403
+ * decider over that class and a decider's parameter is contravariant — a phase typed
404
+ * `(decoded: unknown) => …` would demand that `unknown` be assignable to the command,
405
+ * which it is not. Every other member stays `unknown`, so nothing else narrows.
406
+ */
407
+ interface CanonicalPhases extends Phases {
408
+ readonly decoded: CanonicalCommand;
409
+ }
336
410
  /**
337
411
  * A canonical description, built through the public constructors with phases that do
338
412
  * nothing. It is exported so a consumer — a generator, a lint rule, a documenter — can
@@ -345,20 +419,20 @@ interface Vocabulary {
345
419
  * any other sequence fails to typecheck here — which is what keeps the derived order
346
420
  * non-circular: it is read off a value, and the value's shape is enforced by the types.
347
421
  */
348
- declare const canonical: WriteDone<Phases>;
422
+ declare const canonical: WriteDone<CanonicalPhases>;
349
423
  declare const vocabulary: Vocabulary;
350
424
  declare namespace Policy_d_exports {
351
425
  export { Policy };
352
426
  }
353
427
  type Policy<A, E, R> = (self: Effect<A, E, R>) => Effect<A, E, R>;
354
428
  declare namespace Wire_d_exports {
355
- export { AnyMinted, Fields, Mark, Minted, MintedField, array, boolean, integer, literal, mint, nullOr, nullishOr, number, optional, record, refine, string, suspend, tuple, undefinedOr, union, wire };
429
+ export { AnyMinted, Fields, Mark, Minted, MintedField, mint, wire };
356
430
  }
357
431
  /**
358
432
  * Marker a wire member carries once this workspace declares its type. The property type is the
359
433
  * fix, so the compiler diagnostic names it.
360
434
  *
361
- * It sits on the schema, never on the decoded value: `Schema.Type<typeof Wire.string>` is `string`.
435
+ * It sits on the schema, never on the decoded value: `Schema.Type<typeof Wire.mint(S.String)>` is `string`.
362
436
  */
363
437
  interface Mark {
364
438
  readonly __WIRE_MEMBER_IS_NOT_BUILT_FROM_THE_ALPHABET__: 'this member names a type this workspace does not declare; build it from Wire, or admit a foreign schema deliberately with Wire.mint';
@@ -372,8 +446,7 @@ type Minted<A, I = A> = Schema.Codec<A, I> & Mark;
372
446
  /** Any marked schema, for constraint positions. */
373
447
  type AnyMinted = Schema.Top & Mark;
374
448
  /**
375
- * Any marked struct member — a schema, or a property signature from {@link optional}.
376
- *
449
+ * Any marked struct member — a schema, or a property signature from `S.optional`.
377
450
  * The permissive `Constraint` arm rather than a narrower schema type, whose `never` variants
378
451
  * would fail the assignability check before the marker is reached, leaving the diagnostic to
379
452
  * report an unrelated type error.
@@ -382,37 +455,11 @@ type MintedField = Schema.Constraint & Mark;
382
455
  /**
383
456
  * Mark a member whose type this workspace declares.
384
457
  *
385
- * Prefer the alphabet below; reach for this only to admit a schema the alphabet cannot express,
386
- * including a vendor's own. The value returned is the one passed in, and its concrete type is
387
- * preserved, so a marked struct stays a struct and Effect's inference keeps working.
458
+ * The value returned is the one passed in, and its concrete type is preserved, so a marked
459
+ * struct stays a struct and Effect's inference keeps working. Reach for it to admit anything
460
+ * the schema library can express, including a vendor's own schema — deliberately.
388
461
  */
389
462
  declare const mint: <Field extends Schema.Constraint>(field: Field) => Field & Mark;
390
- declare const string: Schema.String & Mark;
391
- declare const number: Schema.Finite & Mark;
392
- declare const boolean: Schema.Boolean & Mark;
393
- declare const integer: Schema.Int & Mark;
394
- declare const literal: <const Literals extends ReadonlyArray<SchemaAST.LiteralValue>>(...literals: Literals) => Schema.Literals<Literals> & Mark;
395
- declare const nullOr: <A, I>(member: Minted<A, I>) => Minted<A | null, I | null>;
396
- declare const undefinedOr: <A, I>(member: Minted<A, I>) => Minted<A | undefined, I | undefined>;
397
- declare const nullishOr: <A, I>(member: Minted<A, I>) => Minted<A | null | undefined, I | null | undefined>;
398
- declare const array: <A, I>(member: Minted<A, I>) => Minted<ReadonlyArray<A>, ReadonlyArray<I>>;
399
- /** A field that may be absent from the payload entirely, as distinct from present and `undefined`. */
400
- declare const optional: <Member extends AnyMinted>(member: Member) => Schema.optional<Member> & Mark;
401
- declare const record: <K extends Schema.Record.Key & Mark, V extends AnyMinted>(key: K, value: V) => Schema.$Record<K, V> & Mark;
402
- declare const union: <Members extends readonly [AnyMinted, AnyMinted, ...Array<AnyMinted>]>(...members: Members) => Schema.Union<Members> & Mark;
403
- declare const tuple: <Elements extends ReadonlyArray<AnyMinted>>(...elements: Elements) => Schema.Tuple<Elements> & Mark;
404
- declare const suspend: <A, I>(thunk: () => Minted<A, I>) => Minted<A, I>;
405
- /**
406
- * Constrain a member's values. Effect's own `member.pipe(Schema.check(Schema.isMinLength(1)))`
407
- * preserves the mark, but returns the schema with the concrete decode/encode pair fused; this
408
- * combinator is the alphabet's own shape for the same operation, so a refined member keeps the
409
- * declaration this workspace makes of its type.
410
- *
411
- * `S.refine` needs a type-guard as its predicate argument; the workspace's predicate is a plain
412
- * `(a: A) => boolean` that narrows nothing, so a truth-preserving `value is A` guard is written
413
- * at this one call site rather than widening the public signature.
414
- */
415
- declare const refine: <A, I>(member: Minted<A, I>, predicate: (a: A) => boolean, annotations?: Schema.Annotations.Filter) => Minted<A, I>;
416
463
  type Fields = Record<string, MintedField>;
417
464
  declare const wire: <F extends Fields>(fields: F) => Schema.Struct<F> & Mark;
418
465
  //#endregion
package/dist/index.mjs CHANGED
@@ -4,13 +4,36 @@ import * as Effect$1 from "effect/Effect";
4
4
  import { dual } from "effect/Function";
5
5
  import * as Option from "effect/Option";
6
6
  import * as Result$1 from "effect/Result";
7
+ import * as S from "effect/Schema";
7
8
  import { Schema } from "effect";
9
+ S.TaggedError()("CanonicalDecideError", {});
10
+ //#endregion
8
11
  //#region src/Workflow.ts
9
12
  var Workflow_exports = /* @__PURE__ */ __exportAll({ make: () => make });
10
13
  /**
11
- * Builds a workflow, refusing an uninhabited or untagged channel at this call rather than at
14
+ * Builds a workflow from the command's schema class and a decider over that class's
15
+ * instance type, refusing an uninhabited or untagged channel at this call rather than at
12
16
  * whoever first calls the result — which for a workflow nothing calls yet is never.
13
17
  *
18
+ * The command is constrained on the **value**, not on a type parameter inferred from the
19
+ * decider's parameter. That is the whole mechanism. Any constraint on such a parameter is a
20
+ * structural predicate, and TypeScript cannot say "this type came from a class declaration"
21
+ * — so a marker placed there is a property, every property is declarable, and
22
+ * `interface Fake extends Marker {}` satisfies it. A declared type produces no value, so it
23
+ * cannot reach an argument position at all: there is no marker to smuggle because there is
24
+ * no marker.
25
+ *
26
+ * The three parameters mirror `Schema.Class`'s own bound exactly, and that is load-bearing.
27
+ * `Class<Self, S, Inherited>` places `S` in both covariant (`S["Type"]`) and contravariant
28
+ * (`S["fields"]`) positions, so it is invariant in `S`: every *fixed* spelling —
29
+ * `Class<unknown, Struct<Struct.Fields>, unknown>` and its variants — rejects real command
30
+ * classes. Generic over `S` accepts them and still refuses a `Struct`, which lacks
31
+ * `identifier` and `extend`. `Class<any, any, any>` also works and is banned here.
32
+ *
33
+ * `Schema.TaggedClass` returns this same `Class` interface, so one constraint covers both
34
+ * factories with no union. The import is type-only: this package gains no runtime dependency
35
+ * on Effect Schema, and `make` stays the identity function it always was.
36
+ *
14
37
  * The markers ride the parameter function's return type, not the parameter as `Workflow<C, D, E>`:
15
38
  * a conditional type in parameter position resolves `D` and `E` to `unknown` and the markers become
16
39
  * unreachable. On the return type both still infer from the `Result` conjunct while the marker
@@ -24,24 +47,34 @@ var Workflow_exports = /* @__PURE__ */ __exportAll({ make: () => make });
24
47
  * The narrowing goes through an assertion signature rather than an `as` cast: every narrowing
25
48
  * assertion trips `typescript(no-unsafe-type-assertion)`, and a suppression comment would hide the
26
49
  * one place this file could lie. It is sound rather than merely permitted — with both channels
27
- * inhabited `Workflow<C, D, E>` is `(command: C) => Result<D, E>` carrying the {@link WorkflowBrand}
28
- * conjunct, and otherwise the return type is a marker with no call signature, so the value handed
29
- * back is unobservable through it. The brand is applied here and nowhere else: the assertion adds
30
- * no runtime property, so `make` stays the identity function it always was, yet a value that did
31
- * not pass through this door fails the conjunct wherever a decision is run.
50
+ * inhabited `Workflow<Self, D, E>` is `(command: Self) => Result<D, E>` carrying the
51
+ * {@link WorkflowBrand} conjunct, and otherwise the return type is a marker with no call
52
+ * signature, so the value handed back is unobservable through it. The brand is applied here and
53
+ * nowhere else: the assertion adds no runtime property, yet a value that did not pass through this
54
+ * door fails the conjunct wherever a decision is run.
32
55
  */
33
- const make = (decide) => {
56
+ const make = (_command, decide) => {
34
57
  return decide;
35
58
  };
36
59
  //#endregion
37
60
  //#region src/CanonicalDecide.workflow.ts
38
61
  /**
62
+ * The canonical command. `Workflow.make` constrains its first argument to a real
63
+ * schema class, so the canonical description needs one too — it carries no fields
64
+ * because the canonical's phases do nothing, and its only job is to be a genuine
65
+ * command value rather than a shape asserted into place.
66
+ *
67
+ * It is declared here rather than in a `*.schema.ts` because this is the owning
68
+ * single-segment `<stem>.workflow.ts`, which `schema-declaration-location` admits.
69
+ */
70
+ var CanonicalCommand = class extends S.TaggedClass()("CanonicalCommand", {}) {};
71
+ /**
39
72
  * The canonical decider. Extracted so `make-file-location` only sees it inside a
40
73
  * single-segment `.workflow.ts` file, satisfying the restored taxonomy while
41
74
  * preserving the exact phantom-channel contract the `DecidePhase` brand and
42
75
  * the interpreter rely on.
43
76
  */
44
- const canonicalDecide = make((_decoded) => Result$1.succeed(void 0));
77
+ const canonicalDecide = make(CanonicalCommand, (_command) => Result$1.succeed(void 0));
45
78
  //#endregion
46
79
  //#region src/Cell.ts
47
80
  var Cell_exports = /* @__PURE__ */ __exportAll({
@@ -185,9 +218,15 @@ const runLayer = (layer, command) => Effect$1.gen(function* () {
185
218
  const last = phases[phases.length - 1];
186
219
  if (!last || last.name !== "write") return yield* Effect$1.die(/* @__PURE__ */ new Error("effect-cell-types: a layer reached the interpreter without a write phase closing it"));
187
220
  let value = command;
221
+ let raw = command;
188
222
  for (const phase of phases.slice(0, -1)) switch (phase.convention) {
189
223
  case "effect":
190
- value = yield* phase.run(value);
224
+ if (phase.name === "read") {
225
+ value = yield* phase.run(value);
226
+ raw = value;
227
+ break;
228
+ }
229
+ value = yield* phase.run(value, raw);
191
230
  break;
192
231
  case "either-fail":
193
232
  value = yield* Result$1.match(phase.run(value), {
@@ -207,7 +246,7 @@ const runLayer = (layer, command) => Effect$1.gen(function* () {
207
246
  return yield* Effect$1.die(/* @__PURE__ */ new Error(`effect-cell-types: unknown phase convention ${String(unreachable)}`));
208
247
  }
209
248
  }
210
- return yield* last.run(value);
249
+ return yield* last.run(value, raw);
211
250
  });
212
251
  /**
213
252
  * Applies a description. The return type is deliberately not annotated: `gen` accumulates
@@ -246,7 +285,7 @@ const apply = (description, command) => Effect$1.gen(function* () {
246
285
  * any other sequence fails to typecheck here — which is what keeps the derived order
247
286
  * non-circular: it is read off a value, and the value's shape is enforced by the types.
248
287
  */
249
- const canonical = write(encode(decide(decode(read(() => Effect$1.void), () => Result$1.succeed(void 0)), canonicalDecide), () => void 0), () => Effect$1.void);
288
+ const canonical = write(encode(decide(decode(read(() => Effect$1.void), () => Result$1.succeed(CanonicalCommand.make({}))), canonicalDecide), () => void 0), () => Effect$1.void);
250
289
  /**
251
290
  * The phase vocabulary, obtained by walking `canonical`. A consumer that needs the phase
252
291
  * names, their purity, or their order — a lint rule, a generator, a document — reads them
@@ -279,81 +318,38 @@ var Policy_exports = /* @__PURE__ */ __exportAll({});
279
318
  *
280
319
  * ```ts
281
320
  * import { Wire } from '@systemfsoftware/effect-cell-types'
282
- * import { Schema } from 'effect'
321
+ * import { Effect, Schema as S } from 'effect'
283
322
  *
284
323
  * const Invoice = Wire.wire({
285
- * id: Wire.string,
286
- * amountDue: Wire.nullOr(Wire.number),
287
- * status: Wire.literal('draft', 'open', 'paid'),
288
- * lineItems: Wire.array(Wire.string),
289
- * metadata: Wire.record(Wire.string, Wire.string),
290
- * deleted: Wire.optional(Wire.boolean),
324
+ * id: Wire.mint(S.String),
325
+ * amountDue: Wire.mint(S.NullOr(Wire.mint(S.Finite))),
326
+ * status: Wire.mint(S.Literals(['draft', 'open', 'paid'])),
327
+ * lineItems: Wire.mint(S.Array(Wire.mint(S.String))),
328
+ * metadata: Wire.mint(S.Record(Wire.mint(S.String), Wire.mint(S.String))),
329
+ * deleted: Wire.mint(S.optional(Wire.mint(S.Boolean))),
291
330
  * })
292
331
  *
293
332
  * // Decodes to your own type — `{ id: string; amountDue: number | null; ... }`.
294
- * const invoice = Schema.decodeUnknownSync(Invoice)(payload)
333
+ * const invoice = yield* Schema.decode(Invoice)(payload)
295
334
  * ```
296
335
  *
297
- * Only declarations that use the alphabet are constrained, and {@link mint} admits a foreign
298
- * schema deliberately. It is a guardrail, not a security boundary.
336
+ * Only declarations that name members through {@link mint} are constrained, and mint admits a
337
+ * foreign schema deliberately. It is a guardrail, not a security boundary.
299
338
  */
300
339
  var Wire_exports = /* @__PURE__ */ __exportAll({
301
- array: () => array,
302
- boolean: () => boolean,
303
- integer: () => integer,
304
- literal: () => literal,
305
340
  mint: () => mint,
306
- nullOr: () => nullOr,
307
- nullishOr: () => nullishOr,
308
- number: () => number,
309
- optional: () => optional,
310
- record: () => record,
311
- refine: () => refine,
312
- string: () => string,
313
- suspend: () => suspend,
314
- tuple: () => tuple,
315
- undefinedOr: () => undefinedOr,
316
- union: () => union,
317
341
  wire: () => wire
318
342
  });
319
343
  /**
320
344
  * Mark a member whose type this workspace declares.
321
345
  *
322
- * Prefer the alphabet below; reach for this only to admit a schema the alphabet cannot express,
323
- * including a vendor's own. The value returned is the one passed in, and its concrete type is
324
- * preserved, so a marked struct stays a struct and Effect's inference keeps working.
346
+ * The value returned is the one passed in, and its concrete type is preserved, so a marked
347
+ * struct stays a struct and Effect's inference keeps working. Reach for it to admit anything
348
+ * the schema library can express, including a vendor's own schema — deliberately.
325
349
  */
326
350
  const mint = (field) => {
327
- assertMinted(field);
328
351
  return field;
329
352
  };
330
- function assertMinted(_field) {}
331
- const string = mint(Schema.String);
332
- const number = mint(Schema.Finite);
333
- const boolean = mint(Schema.Boolean);
334
- const integer = mint(Schema.Int);
335
- const literal = (...literals) => mint(Schema.Literals(literals));
336
- const nullOr = (member) => mint(Schema.NullOr(member));
337
- const undefinedOr = (member) => mint(Schema.UndefinedOr(member));
338
- const nullishOr = (member) => mint(Schema.NullishOr(member));
339
- const array = (member) => mint(Schema.Array(member));
340
- /** A field that may be absent from the payload entirely, as distinct from present and `undefined`. */
341
- const optional = (member) => mint(Schema.optional(member));
342
- const record = (key, value) => mint(Schema.Record(key, value));
343
- const union = (...members) => mint(Schema.Union(members));
344
- const tuple = (...elements) => mint(Schema.Tuple(elements));
345
- const suspend = (thunk) => mint(Schema.suspend(thunk));
346
- /**
347
- * Constrain a member's values. Effect's own `member.pipe(Schema.check(Schema.isMinLength(1)))`
348
- * preserves the mark, but returns the schema with the concrete decode/encode pair fused; this
349
- * combinator is the alphabet's own shape for the same operation, so a refined member keeps the
350
- * declaration this workspace makes of its type.
351
- *
352
- * `S.refine` needs a type-guard as its predicate argument; the workspace's predicate is a plain
353
- * `(a: A) => boolean` that narrows nothing, so a truth-preserving `value is A` guard is written
354
- * at this one call site rather than widening the public signature.
355
- */
356
- const refine = (member, predicate, annotations) => mint(Schema.refine((value) => predicate(value), annotations)(member));
357
353
  const wire = (fields) => mint(Schema.Struct(fields));
358
354
  //#endregion
359
355
  export { Cell_exports as Cell, Policy_exports as Policy, Wire_exports as Wire, Workflow_exports as Workflow };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@systemfsoftware/effect-cell-types",
3
3
  "license": "Apache-2.0",
4
- "version": "3.0.0",
4
+ "version": "5.0.0",
5
5
  "author": "Ryan Lee <drdgvhbh@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -32,23 +32,23 @@
32
32
  "dist"
33
33
  ],
34
34
  "devDependencies": {
35
- "@effect/vitest": "4.0.0-rc.108",
35
+ "@effect/vitest": "4.0.0-rc.112",
36
36
  "@microsoft/api-extractor": "^7.58.7",
37
37
  "@systemfsoftware/arethetypeswrong-cli": "^1.1.1",
38
38
  "@types/node": "^24",
39
- "effect": "4.0.0-rc.108",
39
+ "effect": "^4.0.0-rc.112",
40
40
  "oxlint": "^1.77.0",
41
41
  "rimraf": "^6.1.3",
42
42
  "tsdown": "^0.22.14",
43
43
  "tstyche": "^7.1.0",
44
44
  "vitest": "^4.1.10",
45
- "@systemfsoftware/effect-gherkin-spec": "2.0.0",
46
- "@systemfsoftware/vitest-config": "^0.1.0",
45
+ "@systemfsoftware/effect-gherkin-spec": "2.0.1",
47
46
  "@systemfsoftware/tsconfig": "^1.3.3",
48
- "@systemfsoftware/oxlint-config": "^0.1.0"
47
+ "@systemfsoftware/oxlint-config": "^0.1.0",
48
+ "@systemfsoftware/vitest-config": "^0.1.0"
49
49
  },
50
50
  "peerDependencies": {
51
- "effect": "4.0.0-rc.108"
51
+ "effect": "^4.0.0-rc.112"
52
52
  },
53
53
  "publishConfig": {
54
54
  "provenance": true
@@ -58,7 +58,6 @@
58
58
  "build": "tsdown && pnpm api:check",
59
59
  "typecheck": "tsc --noEmit --incremental",
60
60
  "test": "vitest run --passWithNoTests",
61
- "test:run": "vitest run --passWithNoTests",
62
61
  "test:types": "TSTYCHE_TYPESCRIPT_MODULE=tstyche-typescript tstyche",
63
62
  "api:check": "api-extractor run",
64
63
  "api:update": "api-extractor run --local",