@playfast/reform 0.0.1 → 0.0.3
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 +551 -53
- package/package.json +15 -13
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/{dist/dts/calc/asyncData.d.ts → src/calc/asyncData.ts} +53 -20
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +265 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/dts/compose/host.d.ts → src/compose/host.ts} +6 -8
- package/{dist/esm/compose/props.js → src/compose/props.ts} +3 -4
- package/src/compose/provide.ts +70 -0
- package/{dist/dts/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.ts +97 -0
- package/src/definition/definition.ts +74 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/{dist/dts/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/index.ts +140 -0
- package/{dist/dts/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/dts/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/{dist/esm/internal/errors.js → src/internal/errors.ts} +41 -26
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +91 -0
- package/{dist/dts/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/src/internal/store.ts +105 -0
- package/{dist/dts/internal/track.d.ts → src/internal/track.ts} +15 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.ts +571 -0
- package/{dist/cjs/runtime/bus.js → src/runtime/bus.ts} +23 -12
- package/src/runtime/loop.ts +169 -0
- package/src/scene/scene.ts +76 -0
- package/src/state/state.ts +91 -0
- package/src/state/stateFamily.ts +171 -0
- package/src/state/stateGroup.ts +78 -0
- package/{dist/dts/state/token.d.ts → src/state/token.ts} +21 -13
- package/{dist/dts/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/dts/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/dist/cjs/boundary/boundary.js +0 -86
- package/dist/cjs/calc/asyncCalc.js +0 -128
- package/dist/cjs/calc/asyncData.js +0 -37
- package/dist/cjs/calc/calc.js +0 -58
- package/dist/cjs/calc/calcFamily.js +0 -127
- package/dist/cjs/channel/channel.js +0 -142
- package/dist/cjs/compose/composition.js +0 -50
- package/dist/cjs/compose/host.js +0 -8
- package/dist/cjs/compose/props.js +0 -14
- package/dist/cjs/compose/provide.js +0 -30
- package/dist/cjs/compose/slot.js +0 -27
- package/dist/cjs/compose/ui.js +0 -61
- package/dist/cjs/definition/definition.js +0 -46
- package/dist/cjs/event/event.js +0 -36
- package/dist/cjs/event/eventGroup.js +0 -7
- package/dist/cjs/feature/feature.js +0 -102
- package/dist/cjs/index.js +0 -116
- package/dist/cjs/internal/capture.js +0 -14
- package/dist/cjs/internal/ctx.js +0 -2
- package/dist/cjs/internal/errors.js +0 -62
- package/dist/cjs/internal/inspect.js +0 -36
- package/dist/cjs/internal/queryDriver.js +0 -138
- package/dist/cjs/internal/reuse.js +0 -71
- package/dist/cjs/internal/scheduler.js +0 -73
- package/dist/cjs/internal/seeds.js +0 -19
- package/dist/cjs/internal/sources.js +0 -61
- package/dist/cjs/internal/store.js +0 -77
- package/dist/cjs/internal/track.js +0 -22
- package/dist/cjs/package.json +0 -4
- package/dist/cjs/procedure/procedure.js +0 -52
- package/dist/cjs/reducer/reducer.js +0 -64
- package/dist/cjs/remote/remoteState.js +0 -307
- package/dist/cjs/runtime/loop.js +0 -119
- package/dist/cjs/scene/scene.js +0 -36
- package/dist/cjs/state/state.js +0 -47
- package/dist/cjs/state/stateFamily.js +0 -101
- package/dist/cjs/state/stateGroup.js +0 -47
- package/dist/cjs/state/token.js +0 -23
- package/dist/cjs/ui/node.js +0 -2
- package/dist/cjs/ui/trigger.js +0 -2
- package/dist/dts/boundary/boundary.d.ts +0 -72
- package/dist/dts/boundary/boundary.d.ts.map +0 -1
- package/dist/dts/calc/asyncCalc.d.ts +0 -91
- package/dist/dts/calc/asyncCalc.d.ts.map +0 -1
- package/dist/dts/calc/asyncData.d.ts.map +0 -1
- package/dist/dts/calc/calc.d.ts +0 -57
- package/dist/dts/calc/calc.d.ts.map +0 -1
- package/dist/dts/calc/calcFamily.d.ts +0 -57
- package/dist/dts/calc/calcFamily.d.ts.map +0 -1
- package/dist/dts/channel/channel.d.ts +0 -115
- package/dist/dts/channel/channel.d.ts.map +0 -1
- package/dist/dts/compose/composition.d.ts +0 -72
- package/dist/dts/compose/composition.d.ts.map +0 -1
- package/dist/dts/compose/host.d.ts.map +0 -1
- package/dist/dts/compose/props.d.ts +0 -13
- package/dist/dts/compose/props.d.ts.map +0 -1
- package/dist/dts/compose/provide.d.ts +0 -22
- package/dist/dts/compose/provide.d.ts.map +0 -1
- package/dist/dts/compose/slot.d.ts.map +0 -1
- package/dist/dts/compose/ui.d.ts +0 -50
- package/dist/dts/compose/ui.d.ts.map +0 -1
- package/dist/dts/definition/definition.d.ts +0 -33
- package/dist/dts/definition/definition.d.ts.map +0 -1
- package/dist/dts/event/event.d.ts +0 -33
- package/dist/dts/event/event.d.ts.map +0 -1
- package/dist/dts/event/eventGroup.d.ts +0 -9
- package/dist/dts/event/eventGroup.d.ts.map +0 -1
- package/dist/dts/feature/feature.d.ts.map +0 -1
- package/dist/dts/index.d.ts +0 -43
- package/dist/dts/index.d.ts.map +0 -1
- package/dist/dts/internal/capture.d.ts.map +0 -1
- package/dist/dts/internal/ctx.d.ts.map +0 -1
- package/dist/dts/internal/errors.d.ts +0 -69
- package/dist/dts/internal/errors.d.ts.map +0 -1
- package/dist/dts/internal/inspect.d.ts +0 -17
- package/dist/dts/internal/inspect.d.ts.map +0 -1
- package/dist/dts/internal/queryDriver.d.ts +0 -65
- package/dist/dts/internal/queryDriver.d.ts.map +0 -1
- package/dist/dts/internal/reuse.d.ts +0 -10
- package/dist/dts/internal/reuse.d.ts.map +0 -1
- package/dist/dts/internal/scheduler.d.ts +0 -47
- package/dist/dts/internal/scheduler.d.ts.map +0 -1
- package/dist/dts/internal/seeds.d.ts.map +0 -1
- package/dist/dts/internal/sources.d.ts +0 -39
- package/dist/dts/internal/sources.d.ts.map +0 -1
- package/dist/dts/internal/store.d.ts +0 -47
- package/dist/dts/internal/store.d.ts.map +0 -1
- package/dist/dts/internal/track.d.ts.map +0 -1
- package/dist/dts/procedure/procedure.d.ts +0 -40
- package/dist/dts/procedure/procedure.d.ts.map +0 -1
- package/dist/dts/reducer/reducer.d.ts +0 -44
- package/dist/dts/reducer/reducer.d.ts.map +0 -1
- package/dist/dts/remote/remoteState.d.ts +0 -119
- package/dist/dts/remote/remoteState.d.ts.map +0 -1
- package/dist/dts/runtime/bus.d.ts +0 -27
- package/dist/dts/runtime/bus.d.ts.map +0 -1
- package/dist/dts/runtime/loop.d.ts +0 -45
- package/dist/dts/runtime/loop.d.ts.map +0 -1
- package/dist/dts/scene/scene.d.ts +0 -44
- package/dist/dts/scene/scene.d.ts.map +0 -1
- package/dist/dts/state/state.d.ts +0 -37
- package/dist/dts/state/state.d.ts.map +0 -1
- package/dist/dts/state/stateFamily.d.ts +0 -79
- package/dist/dts/state/stateFamily.d.ts.map +0 -1
- package/dist/dts/state/stateGroup.d.ts +0 -36
- package/dist/dts/state/stateGroup.d.ts.map +0 -1
- package/dist/dts/state/token.d.ts.map +0 -1
- package/dist/dts/ui/node.d.ts.map +0 -1
- package/dist/dts/ui/trigger.d.ts.map +0 -1
- package/dist/esm/boundary/boundary.js +0 -83
- package/dist/esm/boundary/boundary.js.map +0 -1
- package/dist/esm/calc/asyncCalc.js +0 -95
- package/dist/esm/calc/asyncCalc.js.map +0 -1
- package/dist/esm/calc/asyncData.js +0 -34
- package/dist/esm/calc/asyncData.js.map +0 -1
- package/dist/esm/calc/calc.js +0 -58
- package/dist/esm/calc/calc.js.map +0 -1
- package/dist/esm/calc/calcFamily.js +0 -124
- package/dist/esm/calc/calcFamily.js.map +0 -1
- package/dist/esm/channel/channel.js +0 -136
- package/dist/esm/channel/channel.js.map +0 -1
- package/dist/esm/compose/composition.js +0 -46
- package/dist/esm/compose/composition.js.map +0 -1
- package/dist/esm/compose/host.js +0 -5
- package/dist/esm/compose/host.js.map +0 -1
- package/dist/esm/compose/props.js.map +0 -1
- package/dist/esm/compose/provide.js +0 -28
- package/dist/esm/compose/provide.js.map +0 -1
- package/dist/esm/compose/slot.js +0 -23
- package/dist/esm/compose/slot.js.map +0 -1
- package/dist/esm/compose/ui.js +0 -57
- package/dist/esm/compose/ui.js.map +0 -1
- package/dist/esm/definition/definition.js +0 -42
- package/dist/esm/definition/definition.js.map +0 -1
- package/dist/esm/event/event.js +0 -30
- package/dist/esm/event/event.js.map +0 -1
- package/dist/esm/event/eventGroup.js +0 -4
- package/dist/esm/event/eventGroup.js.map +0 -1
- package/dist/esm/feature/feature.js +0 -98
- package/dist/esm/feature/feature.js.map +0 -1
- package/dist/esm/index.js +0 -45
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal/capture.js +0 -11
- package/dist/esm/internal/capture.js.map +0 -1
- package/dist/esm/internal/ctx.js +0 -2
- package/dist/esm/internal/ctx.js.map +0 -1
- package/dist/esm/internal/errors.js.map +0 -1
- package/dist/esm/internal/inspect.js +0 -32
- package/dist/esm/internal/inspect.js.map +0 -1
- package/dist/esm/internal/queryDriver.js +0 -134
- package/dist/esm/internal/queryDriver.js.map +0 -1
- package/dist/esm/internal/reuse.js +0 -68
- package/dist/esm/internal/reuse.js.map +0 -1
- package/dist/esm/internal/scheduler.js +0 -69
- package/dist/esm/internal/scheduler.js.map +0 -1
- package/dist/esm/internal/seeds.js +0 -17
- package/dist/esm/internal/seeds.js.map +0 -1
- package/dist/esm/internal/sources.js +0 -59
- package/dist/esm/internal/sources.js.map +0 -1
- package/dist/esm/internal/store.js +0 -73
- package/dist/esm/internal/store.js.map +0 -1
- package/dist/esm/internal/track.js +0 -18
- package/dist/esm/internal/track.js.map +0 -1
- package/dist/esm/package.json +0 -4
- package/dist/esm/procedure/procedure.js +0 -50
- package/dist/esm/procedure/procedure.js.map +0 -1
- package/dist/esm/reducer/reducer.js +0 -63
- package/dist/esm/reducer/reducer.js.map +0 -1
- package/dist/esm/remote/remoteState.js +0 -270
- package/dist/esm/remote/remoteState.js.map +0 -1
- package/dist/esm/runtime/bus.js +0 -20
- package/dist/esm/runtime/bus.js.map +0 -1
- package/dist/esm/runtime/loop.js +0 -116
- package/dist/esm/runtime/loop.js.map +0 -1
- package/dist/esm/scene/scene.js +0 -31
- package/dist/esm/scene/scene.js.map +0 -1
- package/dist/esm/state/state.js +0 -43
- package/dist/esm/state/state.js.map +0 -1
- package/dist/esm/state/stateFamily.js +0 -96
- package/dist/esm/state/stateFamily.js.map +0 -1
- package/dist/esm/state/stateGroup.js +0 -46
- package/dist/esm/state/stateGroup.js.map +0 -1
- package/dist/esm/state/token.js +0 -20
- package/dist/esm/state/token.js.map +0 -1
- package/dist/esm/ui/node.js +0 -2
- package/dist/esm/ui/node.js.map +0 -1
- package/dist/esm/ui/trigger.js +0 -2
- package/dist/esm/ui/trigger.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,26 +1,85 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# `@playfast/reform`
|
|
4
|
+
|
|
5
|
+
**The renderer-neutral core of the reform framework.**
|
|
6
|
+
State, events, reducers, derived values, async & remote data, and compositions — headless, typed end to end, and provable without a DOM.
|
|
7
|
+
|
|
8
|
+
[](https://www.npmjs.com/package/@playfast/reform)
|
|
9
|
+
[](#license)
|
|
10
|
+
[](https://effect.website)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/react`](https://www.npmjs.com/package/@playfast/react), [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native)) turns a closed *scene* into a live tree, and [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bun add @playfast/reform effect
|
|
22
|
+
# npm install @playfast/reform effect · pnpm add @playfast/reform effect
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`effect` is a peer dependency. `react` is an optional peer (only the React-facing primitives need it).
|
|
26
|
+
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
- [The core idea: definition / implementation split](#the-core-idea-definition--implementation-split)
|
|
30
|
+
- [The model](#the-model)
|
|
31
|
+
- **Reference**
|
|
32
|
+
- [State, StateGroup, StateFamily](#state)
|
|
33
|
+
- [Event, EventGroup](#event)
|
|
34
|
+
- [Reducer](#reducer)
|
|
35
|
+
- [Calc, CalcFamily](#calc)
|
|
36
|
+
- [AsyncCalc & AsyncData](#asynccalc)
|
|
37
|
+
- [RemoteState](#remotestate)
|
|
38
|
+
- [Boundary](#boundary)
|
|
39
|
+
- [Procedure & Channel](#procedure)
|
|
40
|
+
- [Composition, ui, slot, provide](#composition)
|
|
41
|
+
- [Feature (lazy code-splitting)](#feature)
|
|
42
|
+
- [Scene](#scene)
|
|
43
|
+
- [Engine & runtime surface](#engine)
|
|
44
|
+
- [Putting it together](#putting-it-together)
|
|
45
|
+
- [The reform family](#the-reform-family)
|
|
46
|
+
- [License](#license)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## The core idea: definition / implementation split
|
|
51
|
+
|
|
52
|
+
Every primitive comes in two halves. `X.make(…)` is a **reflectable definition** — a manifest plus a DI tag, safe to import anywhere and to inspect. `X.live(…)` provides its **behavior** as a layer. Nothing self-registers on import, so the dependency graph is explicit, tree-shakeable, and fully testable.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { Schema as S } from 'effect'
|
|
56
|
+
import { State, Event, Reducer } from '@playfast/reform'
|
|
57
|
+
|
|
58
|
+
class Count extends State.make('count', S.Number) {}
|
|
59
|
+
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
60
|
+
|
|
61
|
+
class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
|
|
62
|
+
const BumpLive = Reducer.live(Bump, (n, e) => n + e.by)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A `State` lives in a plain reactive store; reducers only *return* values, so by construction they are the **only** writers. Reads return the current snapshot through Effect.
|
|
66
|
+
|
|
67
|
+
### The primitives at a glance
|
|
68
|
+
|
|
69
|
+
| primitive | definition | implementation |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| [`State` / `StateGroup` / `StateFamily`](#state) | `State.make(name, schema, opts?)` | `.live(initial)` |
|
|
72
|
+
| [`Event` / `EventGroup`](#event) | `Event.make(name, schema)` | — (pure data) |
|
|
73
|
+
| [`Reducer`](#reducer) | `Reducer.make(name, { states / family, events })` | `.live(fold)` |
|
|
74
|
+
| [`Calc` / `CalcFamily`](#calc) | `Calc.make(name, { inputs, output })` | `.live(fn)` |
|
|
75
|
+
| [`AsyncCalc`](#asynccalc) | `AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })` | `.live({ query, … })` |
|
|
76
|
+
| [`RemoteState`](#remotestate) | `RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents })` | `.live({ query, send, apply, … })` |
|
|
77
|
+
| [`Boundary`](#boundary) | `Boundary.make(name, { over })` | `.live({ once? })` |
|
|
78
|
+
| [`Procedure`](#procedure) | `Procedure.make(name, { events, channel })` | `.live(fn*)` |
|
|
79
|
+
| [`Channel`](#procedure) | `Channel.make(name, { policy })` | `.live()` |
|
|
80
|
+
| [`Composition`](#composition) | `Composition.make(name, manifest)` | `.live(fn*)` |
|
|
81
|
+
| [`ui` / `slot`](#composition) | `ui(name)<C>()` / `slot(name)<Comp>()` | `Ui.make` / `provide` |
|
|
82
|
+
| [`Feature`](#feature) | `Feature.make(name, config)` | (eager `module` / lazy `load`) |
|
|
24
83
|
|
|
25
84
|
## The model
|
|
26
85
|
|
|
@@ -32,33 +91,472 @@ procedure ──► Bus (Normal)┘ │
|
|
|
32
91
|
└────────────── reads state, dispatches ◄──── calc (derived, memoized)
|
|
33
92
|
```
|
|
34
93
|
|
|
35
|
-
- **
|
|
36
|
-
|
|
37
|
-
- **
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
- **The bus** is one `PubSub`. A central drain loop batches events per microtask and runs matching reducers — `High`-priority (UI) folds before `Normal`-priority (procedure) folds within a frame. **Procedures** consume the bus on their own forked fibers, scheduled by their **channel**'s concurrency policy.
|
|
95
|
+
- **Derived reads** take any `Source` (a state member, a `Calc`, an `AsyncCalc`, a `RemoteState`) and accept an optional `invalidateBy` key projection that bounds recompute to a value-equal key change.
|
|
96
|
+
- **Notifications** coalesce into a single microtask flush, which the host's `useSyncExternalStore` bridge binds to.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
<a name="state"></a>
|
|
101
|
+
## State · StateGroup · StateFamily
|
|
102
|
+
|
|
103
|
+
Reactive stores held outside Effect. Reducers are the sole writers; everything else reads.
|
|
104
|
+
|
|
105
|
+
### `State`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
State.make(name, schema, options?) // → StateClass
|
|
109
|
+
State.live(State, initial) // → Layer<Store<value>>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`options` is `{ title?, description? }` (reflectable metadata). A `State` class is itself yieldable — `yield* Count` reads its current value.
|
|
113
|
+
|
|
114
|
+
### `StateGroup`
|
|
115
|
+
|
|
116
|
+
A bundle of related states provided together. `StateGroup.select(Group, 'name')` returns a `StateToken` (a `Source`) used both as a calc input and as a yieldable read.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { State, StateGroup } from '@playfast/reform'
|
|
120
|
+
|
|
121
|
+
class Count extends State.make('count', S.Number) {}
|
|
122
|
+
class Step extends State.make('step', S.Number) {}
|
|
123
|
+
class Counter extends StateGroup.make(Count, Step) {}
|
|
124
|
+
|
|
125
|
+
const CounterLive = StateGroup.live(Counter, { count: 0, step: 1 }) // all members required
|
|
126
|
+
|
|
127
|
+
// read inside any Effect / composition:
|
|
128
|
+
const count = yield* StateGroup.select(Counter, 'count')
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `StateFamily`
|
|
132
|
+
|
|
133
|
+
A keyed collection — one store per key over a shared schema.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
StateFamily.make(name, keySchema, valueSchema, options?) // → StateFamilyClass
|
|
137
|
+
StateFamily.live(Family, initial | (key) => value, { evictWhenUnused? }?)
|
|
138
|
+
|
|
139
|
+
class Items extends StateFamily.make('items', S.String, ItemSchema) {}
|
|
140
|
+
const ItemsLive = StateFamily.live(Items, (id) => blankItem(id), { evictWhenUnused: true })
|
|
141
|
+
|
|
142
|
+
const item = yield* StateFamily.read(Items, id)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`evictWhenUnused: true` ref-counts each key and drops it on the next microtask once its last subscriber leaves (re-access re-seeds from `initial`). A family reducer fold may return `StateFamily.Tombstone` to evict a key.
|
|
146
|
+
|
|
147
|
+
> **`StateToken` / `Source` / `AnySource`** — `Source<N, A>` is the structural shape (`{ name, store }`) that `StateToken`, `Calc`, `AsyncCalc`, and `RemoteState` all satisfy, so any of them can feed a calc's `inputs`. `AnySource = Source<string, any>`.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
<a name="event"></a>
|
|
152
|
+
## Event · EventGroup
|
|
153
|
+
|
|
154
|
+
Events are pure tagged data — definitions only, no `.live`.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
Event.make(name, schema) // → EventClass; EventOf<N, P> = { _tag: N } & P
|
|
158
|
+
EventGroup.make(...events) // → bundle, used in Reducer/Composition manifests
|
|
159
|
+
|
|
160
|
+
class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(Todo) })) {}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Dispatching.** Two idioms, two priorities:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// From a UI view — High priority, synchronous:
|
|
167
|
+
const toggle = yield* Event.trigger(ToggledTodo) // toggle: (payload) => void
|
|
168
|
+
// ...later: <input onChange={() => toggle({ id })} />
|
|
169
|
+
|
|
170
|
+
// From a procedure — Normal priority:
|
|
171
|
+
yield* Event.dispatch(TodoUpserted, { todo })
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`Event.trigger` returns a plain callback (`Trigger<P>`) you hand to the view; `Event.dispatch` returns an `Effect` you yield inside logic.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
<a name="reducer"></a>
|
|
179
|
+
## Reducer
|
|
180
|
+
|
|
181
|
+
The only writers of state. A fold is a **pure, synchronous** `(value, event) => value` (async values throw at startup).
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
Reducer.make(name, { states: [State], events: [...] }) // state reducer
|
|
185
|
+
Reducer.make(name, { family: Family, keyOf, events: [...] }) // family reducer
|
|
186
|
+
Reducer.live(Reducer, fold)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
State reducers receive and return the whole state value; family reducers receive and return one entry (or `StateFamily.Tombstone`). The idiomatic fold matches on the event tag with Effect's `Match`:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { Match } from 'effect'
|
|
193
|
+
|
|
194
|
+
const FeedReducerLive = Reducer.live(FeedReducer, (feed, event) =>
|
|
195
|
+
Match.value(event).pipe(
|
|
196
|
+
Match.tags({
|
|
197
|
+
StartedLoading: (): Feed => ({ _tag: 'Loading' }),
|
|
198
|
+
LoadedTodos: ({ todos }): Feed => ({ _tag: 'Ok', todos }),
|
|
199
|
+
FailedTodos: ({ message }): Feed => ({ _tag: 'Error', message }),
|
|
200
|
+
TodoRemoved: ({ id }) => onOk(feed, Array.filter((t) => t.id !== id)),
|
|
201
|
+
}),
|
|
202
|
+
Match.exhaustive,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
An event outside the reducer's declared `events` never reaches the fold; the drain loop owns every `store.set`, so logic can never write state directly.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
<a name="calc"></a>
|
|
212
|
+
## Calc · CalcFamily
|
|
213
|
+
|
|
214
|
+
Synchronous derived values — memoized projections over one or more sources.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
Calc.make(name, { inputs, output }) // inputs: ReadonlyArray<Source>
|
|
218
|
+
Calc.live(Calc, (inputs) => output, { invalidateBy?, reuse? }?)
|
|
219
|
+
|
|
220
|
+
class IsPositive extends Calc.make('IsPositive', {
|
|
221
|
+
inputs: [StateGroup.select(Counter, 'count')],
|
|
222
|
+
output: S.Boolean,
|
|
223
|
+
}) {}
|
|
224
|
+
const IsPositiveLive = Calc.live(IsPositive, ({ count }) => count > 0)
|
|
225
|
+
|
|
226
|
+
const positive = yield* IsPositive // read the memoized value
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`inputs` are keyed in the compute argument by each source's **name**. A calc recomputes only when its invalidation key changes (default: all input values; override with `invalidateBy: (inputs) => [...]`). `reuse: true` does structural sharing on the output, keeping unchanged subtree identities stable across recomputes.
|
|
230
|
+
|
|
231
|
+
**`CalcFamily`** parameterizes a calc by key, one memoized store per key over shared inputs — each member notifies only its own subscribers:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
class GroupView extends CalcFamily.make('GroupView', { key: GroupId, inputs: [Board], output: GroupSchema }) {}
|
|
235
|
+
const GroupViewLive = CalcFamily.live(GroupView, (id) => ({ Board }) => project(Board, id), { evictWhenUnused: true })
|
|
236
|
+
|
|
237
|
+
const view = yield* CalcFamily.read(GroupView, groupId)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
<a name="asynccalc"></a>
|
|
243
|
+
## AsyncCalc & AsyncData
|
|
244
|
+
|
|
245
|
+
Server reads with a stale-while-revalidate lifecycle. The query re-runs reactively from its inputs.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })
|
|
249
|
+
AsyncCalc.live(AsyncCalc, {
|
|
250
|
+
query, // (inputs) => Effect<A, E, R>
|
|
251
|
+
invalidateBy?, // (inputs) => ReadonlyArray<unknown> — refetch only when this key changes
|
|
252
|
+
invalidateOn?, // ReadonlyArray<Event> — also refetch on these events
|
|
253
|
+
coalesce?, // 'switch' (default, latest-wins) | 'trailing' (one trailing refetch after a burst)
|
|
254
|
+
reuse?, // structural-share Success values across refetches
|
|
255
|
+
disabled?, // (inputs) => boolean — gate the query off (Idle); gatable calcs only
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- `error` omitted ⇒ the query is infallible (no `Error` arm). `alwaysOn: true` ⇒ no `Idle` arm and `disabled` is rejected.
|
|
260
|
+
- Reading `yield* MyAsyncCalc` yields an **`AsyncData<A, E, Gated>`**:
|
|
261
|
+
|
|
262
|
+
| arm | `_tag` | fields | when |
|
|
263
|
+
| --- | --- | --- | --- |
|
|
264
|
+
| `AsyncIdle` | `'Idle'` | — | gated query is `disabled` |
|
|
265
|
+
| `AsyncLoading` | `'Loading'` | — | first fetch, no value yet |
|
|
266
|
+
| `AsyncSuccess` | `'Success'` | `value`, `refetching` | succeeded (`refetching: true` while re-fetching) |
|
|
267
|
+
| `AsyncError` | `'Error'` | `error`, `refetching` | failed |
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
271
|
+
inputs: [StateGroup.select(Counter, 'count')],
|
|
272
|
+
output: S.Number,
|
|
273
|
+
alwaysOn: true,
|
|
274
|
+
}) {}
|
|
275
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
276
|
+
query: ({ count }) => Effect.succeed(count * 2),
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const data = yield* Doubled
|
|
280
|
+
Match.value(data).pipe(
|
|
281
|
+
Match.tag('Loading', () => spinner),
|
|
282
|
+
Match.tag('Success', ({ value }) => render(value)),
|
|
283
|
+
Match.exhaustive,
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
<a name="remotestate"></a>
|
|
290
|
+
## RemoteState
|
|
291
|
+
|
|
292
|
+
Server-owned state with optimistic mutations, fused into one primitive. **Remote state is derived-only:** the only writer is the server, the only write surface is dispatching a declared *intent*, and the visible value is `pending.reduce(apply, serverTruth)`.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents }) // intents: Event definitions
|
|
296
|
+
RemoteState.live(Remote, {
|
|
297
|
+
query, // (inputs) => Effect<A, E, R> — same as AsyncCalc
|
|
298
|
+
send, // (intent) => Effect<_, _, R2> — deliver one intent to the server
|
|
299
|
+
apply, // (value, intent) => value — pure, total, idempotent overlay fold
|
|
300
|
+
channel?, // send lane (default: a generated `merge` channel)
|
|
301
|
+
invalidateBy?, invalidateOn?, coalesce?, reuse?, disabled?, // inherited
|
|
302
|
+
})
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Class surface:
|
|
306
|
+
|
|
307
|
+
- `yield* Board` → the **overlaid** `AsyncData<A, E, Gated>` (server truth with pending intents applied).
|
|
308
|
+
- `Board.truth` → the un-overlaid query lifecycle (a `Source`), for chrome that must show raw server state.
|
|
309
|
+
- `Board.pending` → read-only `Source` of `ReadonlyArray<PendingIntent<I>>` (`{ opId, intent, status: 'sending' | 'confirmed' }`).
|
|
310
|
+
- `Board.Failed` → a public `Event` carrying `FailedIntent` (`{ intent, error }`) for toasts / retry UX.
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
class Board extends RemoteState.make('Board', {
|
|
314
|
+
inputs: [Session, StateGroup.select(Router, 'route')],
|
|
315
|
+
output: BoardSnapshot,
|
|
316
|
+
error: S.String,
|
|
317
|
+
intents: [ItemAddIntent, ItemRenameIntent],
|
|
318
|
+
}) {}
|
|
319
|
+
|
|
320
|
+
const BoardLive = RemoteState.live(Board, {
|
|
321
|
+
query: ({ route }) => loadBoard(route.boardId),
|
|
322
|
+
send: (intent) => Match.value(intent).pipe(
|
|
323
|
+
Match.tag('ItemAddIntent', ({ groupId, name }) => client.AddItem({ groupId, name })),
|
|
324
|
+
Match.tag('ItemRenameIntent', ({ itemId, name }) => client.RenameItem({ id: itemId, name })),
|
|
325
|
+
Match.exhaustive,
|
|
326
|
+
),
|
|
327
|
+
apply: (snapshot, intent) => applyIntent(snapshot, intent), // idempotent
|
|
328
|
+
invalidateOn: [BoardChanged],
|
|
329
|
+
coalesce: 'trailing',
|
|
330
|
+
})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**Semantics.** A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and `Board.Failed` fires — there is **no rollback machinery**, the derivation just converges. The **generation rule** guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation *g* is settled only by a later-generation `Success` run.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
<a name="boundary"></a>
|
|
338
|
+
## Boundary
|
|
339
|
+
|
|
340
|
+
Merges the lifecycle arms of several async sources into one first-load signal, so a subtree shows a single fallback instead of per-query spinners.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
Boundary.make(name, { over }) // over: ReadonlyArray<AsyncCalc | RemoteState | ...>
|
|
344
|
+
Boundary.live(Boundary, { once? }?)
|
|
345
|
+
|
|
346
|
+
class BootBoundary extends Boundary.make('BootBoundary', { over: [Session, BootstrapQuery, Board] }) {}
|
|
347
|
+
const BootBoundaryLive = Boundary.live(BootBoundary, { once: true })
|
|
348
|
+
|
|
349
|
+
const boot = yield* BootBoundary // BoundaryState
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`yield* BootBoundary` yields a `BoundaryState`:
|
|
353
|
+
|
|
354
|
+
- **`BoundaryPending`** — some source is on its first load.
|
|
355
|
+
- **`BoundaryReady`** — every source has settled (`Success`, even `refetching`, or a deliberately gated `Idle`).
|
|
356
|
+
- **`BoundaryErrored`** — a source errored before first value; carries `errors: ReadonlyArray<unknown>`.
|
|
357
|
+
|
|
358
|
+
`once: true` latches: once `Ready`, it stays `Ready` (a boot boundary won't re-splash when a later navigation first-loads a route-gated query). It latches only on converged values, never mid-flush.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
<a name="procedure"></a>
|
|
363
|
+
## Procedure & Channel
|
|
364
|
+
|
|
365
|
+
Procedures are side-effecting reactions to events, running on forked fibers. A **channel** is the named concurrency lane they run on.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
Channel.make(name, { policy }) // policy: 'merge' | 'latest' | 'debounce' | 'throttle' | 'exclusive'
|
|
369
|
+
Channel.live(Channel)
|
|
370
|
+
|
|
371
|
+
Procedure.make(name, { events, channel })
|
|
372
|
+
Procedure.live(Procedure, function* (event) { … })
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
The generator body receives the matched event, can read services from context, and dispatches follow-up events with `Event.dispatch`:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
class ActionsChannel extends Channel.make('Actions', { policy: { _tag: 'merge' } }) {}
|
|
379
|
+
|
|
380
|
+
class CreateTodo extends Procedure.make('CreateTodo', { events: [SubmittedNewTodo], channel: ActionsChannel }) {}
|
|
381
|
+
const CreateTodoLive = Procedure.live(CreateTodo, function* (event) {
|
|
382
|
+
const client = yield* TodosClient
|
|
383
|
+
const result = yield* Effect.either(client.AddTodo({ text: event.text }))
|
|
384
|
+
yield* Match.value(result).pipe(
|
|
385
|
+
Match.tags({
|
|
386
|
+
Right: ({ right }) => Event.dispatch(TodoUpserted, { todo: right }),
|
|
387
|
+
Left: ({ left }) => Event.dispatch(FailedTodos, { message: String(left) }),
|
|
388
|
+
}),
|
|
389
|
+
Match.exhaustive,
|
|
390
|
+
)
|
|
391
|
+
})
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Policies: `merge` (unbounded concurrency), `latest` (new event cancels the in-flight run), `exclusive` (serialized), `debounce`/`throttle` (rate-shaping). Many procedures may share one channel.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
<a name="composition"></a>
|
|
399
|
+
## Composition · ui · slot · provide
|
|
400
|
+
|
|
401
|
+
A **composition** is a unit of logic that reads state and renders a typed UI contract. The contract (`ui`) and its presentation are authored separately, so logic stays renderer-neutral.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
// 1. declare the contract — props the view receives, events it can fire
|
|
405
|
+
class CounterUi extends ui('Counter')<{
|
|
406
|
+
props: { count: number }
|
|
407
|
+
events: { bump: Trigger<{}> }
|
|
408
|
+
}>() {}
|
|
409
|
+
|
|
410
|
+
// 2. declare the composition and what it reads
|
|
411
|
+
class Counter extends Composition.make('Counter', { ui: CounterUi, states: [Count] }) {}
|
|
412
|
+
|
|
413
|
+
// 3. implement the logic — read sources, return the view applied to computed props
|
|
414
|
+
const CounterLive = Composition.live(Counter, function* () {
|
|
415
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
416
|
+
const bump = yield* Event.trigger(Bumped)
|
|
417
|
+
const view = yield* CounterUi
|
|
418
|
+
return view({ count }, { bump })
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// 4. provide a presentation for the contract (DOM/native/custom)
|
|
422
|
+
const CounterView = provide(CounterUi, Ui.make(CounterUi, ({ count }, _slots, { bump }) =>
|
|
423
|
+
<button onClick={() => bump({})}>{count}</button>
|
|
424
|
+
))
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
- **`Composition.make(name, manifest)`** — manifest declares `ui` plus the `states` / `calcs` / `events` / `slots` it touches (all reflectable).
|
|
428
|
+
- **`Composition.live(comp, fn*)`** — the generator reads sources and returns a `Node` (the view applied to props/events).
|
|
429
|
+
- **`slot(name)<Comp>()`** — a hole a parent composition declares (`slots: { body: BodySlot }`) and fills with `provide(BodySlot, ChildComposition)` (or a `Feature`). Children render through the host.
|
|
430
|
+
- **`provide(...)`** — binds a UI presentation to a contract, or fills a slot with a composition / feature. Returns a `Layer`.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
<a name="feature"></a>
|
|
435
|
+
## Feature
|
|
436
|
+
|
|
437
|
+
The lazy/eager code-split unit. Definitions stay eager and reflectable; the heavy implementation can load on demand as an **Effect** (never a bare Promise), so code-splitting composes with the rest of the layer graph.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { Feature, featureModule, lazyImport, mountFeature } from '@playfast/reform'
|
|
441
|
+
|
|
442
|
+
// eager: ship the module with the definition
|
|
443
|
+
class Counter extends Feature.make('counter', {
|
|
444
|
+
composition: CounterComp,
|
|
445
|
+
module: featureModule([], CounterLive),
|
|
446
|
+
boot: [Event.construct(Tick, {})],
|
|
447
|
+
}) {}
|
|
448
|
+
|
|
449
|
+
// lazy: defer the module behind an import
|
|
450
|
+
class Reports extends Feature.make('reports', {
|
|
451
|
+
loadingStrategy: 'lazy',
|
|
452
|
+
load: lazyImport(() => import('./reports.module')),
|
|
453
|
+
placeholder: { loading: SpinnerComp, failed: RetryComp },
|
|
454
|
+
}) {}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
A feature shares the app's engine but gets its own scope — `mountFeature(binding, engineContext)` loads it, builds its layer, dispatches its boot events, and disposes everything when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`; the host paints the placeholders while it loads.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
<a name="scene"></a>
|
|
462
|
+
## Scene
|
|
463
|
+
|
|
464
|
+
A scene bundles a root composition with the closed wiring that runs it — the single handle the React host, React Native host, and proofs all consume.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
|
|
468
|
+
seedScene(base, seeds) // tooling: override seed values
|
|
469
|
+
isScene(value) // reflection guard
|
|
470
|
+
|
|
471
|
+
const AppScene = scene(AppRoot, {
|
|
472
|
+
provide: [Engine, AppStateLive, AppLogicLive, AppViews],
|
|
473
|
+
boot: [Event.construct(AppStarted, {})],
|
|
474
|
+
})
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) to mount or to [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
<a name="engine"></a>
|
|
482
|
+
## Engine & runtime surface
|
|
483
|
+
|
|
484
|
+
`Engine` is the layer that ties a runtime together: it provides the `Bus` (one `PubSub`), the `Reducers` / `Channels` / `Procedures` registries, and forks the single drain loop. Merge it at the root of your app's layers:
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
import { Engine } from '@playfast/reform'
|
|
488
|
+
|
|
489
|
+
const AppLayer = Layer.mergeAll(
|
|
490
|
+
Engine,
|
|
491
|
+
StateGroup.live(AppStates, AppSeeds),
|
|
492
|
+
AppReducersLive,
|
|
493
|
+
AppProceduresLive,
|
|
494
|
+
AppClientsLive,
|
|
495
|
+
)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Also exported for hosts and headless tests: `Bus` / `publish(priority, event)` / `Priority` (`'High' | 'Normal'`); the `Reducers` / `Channels` / `Procedures` registries and their `ReducerEntry` / `ProcedureEntry` shapes; `CaptureSink` (the capturing UI sink proofs assert against); the tagged errors (`DuplicateRegistration`, `FeatureLoadFailed`, `InvalidProvideTarget`, `SlotRenderingUnavailable`, `UnknownGroupState`, `AsyncReducer`); and the **notification scheduler** — `Notifications` / `notificationsLayer` / `makeScheduler` / `defaultScheduler`, which hosts provide upstream for per-runtime isolation (concurrent SSR, multiple mounted roots). Everything else coalesces on the process-wide default scheduler.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Putting it together
|
|
503
|
+
|
|
504
|
+
A minimal counter, end to end:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
import { Schema as S, Layer, Match } from 'effect'
|
|
508
|
+
import {
|
|
509
|
+
State, StateGroup, Event, Reducer, Composition, ui, provide, Ui, Engine, scene,
|
|
510
|
+
type Trigger,
|
|
511
|
+
} from '@playfast/reform'
|
|
512
|
+
|
|
513
|
+
// state + event + reducer
|
|
514
|
+
class Count extends State.make('count', S.Number) {}
|
|
515
|
+
class Counters extends StateGroup.make(Count) {}
|
|
516
|
+
class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
|
|
517
|
+
class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {}
|
|
518
|
+
|
|
519
|
+
// contract + composition
|
|
520
|
+
class CounterUi extends ui('Counter')<{
|
|
521
|
+
props: { count: number }
|
|
522
|
+
events: { bump: Trigger<{ by: number }> }
|
|
523
|
+
}>() {}
|
|
524
|
+
class Counter extends Composition.make('Counter', { ui: CounterUi, states: [Count] }) {}
|
|
525
|
+
|
|
526
|
+
const logic = Layer.mergeAll(
|
|
527
|
+
StateGroup.live(Counters, { count: 0 }),
|
|
528
|
+
Reducer.live(Bump, (n, e) => n + e.by),
|
|
529
|
+
Composition.live(Counter, function* () {
|
|
530
|
+
const count = yield* StateGroup.select(Counters, 'count')
|
|
531
|
+
const bump = yield* Event.trigger(Bumped)
|
|
532
|
+
return (yield* CounterUi)({ count }, { bump })
|
|
533
|
+
}),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
// a DOM presentation (rendered by @playfast/react)
|
|
537
|
+
const view = provide(CounterUi, Ui.make(CounterUi, ({ count }, _s, { bump }) =>
|
|
538
|
+
<button onClick={() => bump({ by: 1 })}>count: {count}</button>
|
|
539
|
+
))
|
|
540
|
+
|
|
541
|
+
export const CounterScene = scene(Counter, { provide: [Engine, logic, view] })
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Mount it with [`@playfast/react`](https://www.npmjs.com/package/@playfast/react), or prove it headlessly with [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) — the same `CounterScene` value.
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## The reform family
|
|
549
|
+
|
|
550
|
+
| Package | Role |
|
|
551
|
+
| --- | --- |
|
|
552
|
+
| **`@playfast/reform`** | Renderer-neutral core (this package) |
|
|
553
|
+
| [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) | React / DOM host |
|
|
554
|
+
| [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native) | React Native host |
|
|
555
|
+
| [`@playfast/forms`](https://www.npmjs.com/package/@playfast/forms) | Headless form state |
|
|
556
|
+
| [`@playfast/forms-react`](https://www.npmjs.com/package/@playfast/forms-react) | Typed JSX mapping for forms |
|
|
557
|
+
| [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) | Headless testing toolkit |
|
|
558
|
+
| [`@playfast/eslint-plugin`](https://www.npmjs.com/package/@playfast/eslint-plugin) | Lint rules for reform conventions |
|
|
559
|
+
|
|
560
|
+
## License
|
|
561
|
+
|
|
562
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": [],
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"module": "./dist/esm/index.js",
|
|
8
|
-
"types": "./dist/dts/index.d.ts",
|
|
9
|
-
"main": "./dist/cjs/index.js",
|
|
10
7
|
"exports": {
|
|
11
8
|
"./package.json": "./package.json",
|
|
12
|
-
".":
|
|
13
|
-
"types": "./dist/dts/index.d.ts",
|
|
14
|
-
"import": "./dist/esm/index.js",
|
|
15
|
-
"default": "./dist/cjs/index.js"
|
|
16
|
-
}
|
|
9
|
+
".": "./src/index.ts"
|
|
17
10
|
},
|
|
18
11
|
"files": [
|
|
19
|
-
"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"!src/**/*.test.ts",
|
|
15
|
+
"!src/**/*.test.tsx",
|
|
16
|
+
"!src/**/*.typecheck.ts",
|
|
17
|
+
"!src/**/*.typecheck.tsx"
|
|
20
18
|
],
|
|
21
|
-
"
|
|
22
|
-
"
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run"
|
|
23
22
|
},
|
|
24
23
|
"peerDependencies": {
|
|
25
24
|
"effect": "*",
|
|
@@ -29,5 +28,8 @@
|
|
|
29
28
|
"react": {
|
|
30
29
|
"optional": true
|
|
31
30
|
}
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
32
34
|
}
|
|
33
|
-
}
|
|
35
|
+
}
|