@playfast/reform 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/package.json +2 -2
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/boundary/boundary.ts +192 -0
  4. package/src/calc/asyncCalc.test.ts +556 -0
  5. package/src/calc/asyncCalc.ts +267 -0
  6. package/src/calc/asyncData.ts +96 -0
  7. package/src/calc/calc.test.ts +287 -0
  8. package/src/calc/calc.ts +120 -0
  9. package/src/calc/calcFamily.test.ts +206 -0
  10. package/src/calc/calcFamily.ts +194 -0
  11. package/src/calc/compose.test.ts +68 -0
  12. package/src/calc/compose.ts +69 -0
  13. package/src/channel/channel.ts +269 -0
  14. package/src/compose/composition.ts +146 -0
  15. package/src/compose/host.ts +12 -0
  16. package/{dist/compose/props.js → src/compose/props.ts} +7 -5
  17. package/src/compose/provide.ts +70 -0
  18. package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  19. package/src/compose/structure.test.ts +90 -0
  20. package/src/compose/structure.ts +145 -0
  21. package/src/compose/ui.test.ts +34 -0
  22. package/src/compose/ui.ts +204 -0
  23. package/src/compose/ui.typecheck.ts +56 -0
  24. package/src/definition/definition.ts +76 -0
  25. package/src/event/event.test.ts +23 -0
  26. package/src/event/event.ts +65 -0
  27. package/src/event/eventGroup.ts +14 -0
  28. package/src/feature/feature.mount.test.ts +82 -0
  29. package/src/feature/feature.test.ts +60 -0
  30. package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  31. package/src/feature/feature.typecheck.ts +108 -0
  32. package/src/index.ts +191 -0
  33. package/src/internal/capture.ts +38 -0
  34. package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  35. package/src/internal/errors.test.ts +33 -0
  36. package/src/internal/errors.ts +133 -0
  37. package/src/internal/inspect.test.ts +28 -0
  38. package/src/internal/inspect.ts +34 -0
  39. package/src/internal/queryDriver.ts +247 -0
  40. package/src/internal/reuse.test.ts +116 -0
  41. package/src/internal/reuse.ts +73 -0
  42. package/src/internal/scheduler.ts +93 -0
  43. package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  44. package/src/internal/sources.ts +104 -0
  45. package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
  46. package/src/internal/store.test.ts +80 -0
  47. package/src/internal/store.ts +105 -0
  48. package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
  49. package/src/procedure/procedure.ts +89 -0
  50. package/src/reducer/reducer.ts +137 -0
  51. package/src/remote/remoteState.test.ts +695 -0
  52. package/src/remote/remoteState.ts +571 -0
  53. package/src/remote/remoteState.typecheck.ts +195 -0
  54. package/src/runtime/bus.ts +40 -0
  55. package/src/runtime/hardening.test.ts +69 -0
  56. package/src/runtime/loop.test.ts +178 -0
  57. package/src/runtime/loop.ts +171 -0
  58. package/src/scene/scene.ts +88 -0
  59. package/src/scene/seedScene.test.ts +176 -0
  60. package/src/state/state.ts +94 -0
  61. package/src/state/stateFamily.test.ts +138 -0
  62. package/src/state/stateFamily.ts +174 -0
  63. package/src/state/stateGroup.ts +125 -0
  64. package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
  65. package/src/synced/syncedStore.ts +99 -0
  66. package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
  67. package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  68. package/src/wire/tree.test.ts +81 -0
  69. package/src/wire/tree.ts +129 -0
  70. package/src/wire/triggers.test.ts +76 -0
  71. package/src/wire/triggers.ts +98 -0
  72. package/dist/boundary/boundary.d.ts +0 -72
  73. package/dist/boundary/boundary.d.ts.map +0 -1
  74. package/dist/boundary/boundary.js +0 -83
  75. package/dist/boundary/boundary.js.map +0 -1
  76. package/dist/calc/asyncCalc.d.ts +0 -91
  77. package/dist/calc/asyncCalc.d.ts.map +0 -1
  78. package/dist/calc/asyncCalc.js +0 -95
  79. package/dist/calc/asyncCalc.js.map +0 -1
  80. package/dist/calc/asyncData.d.ts +0 -57
  81. package/dist/calc/asyncData.d.ts.map +0 -1
  82. package/dist/calc/asyncData.js +0 -34
  83. package/dist/calc/asyncData.js.map +0 -1
  84. package/dist/calc/calc.d.ts +0 -57
  85. package/dist/calc/calc.d.ts.map +0 -1
  86. package/dist/calc/calc.js +0 -58
  87. package/dist/calc/calc.js.map +0 -1
  88. package/dist/calc/calcFamily.d.ts +0 -57
  89. package/dist/calc/calcFamily.d.ts.map +0 -1
  90. package/dist/calc/calcFamily.js +0 -124
  91. package/dist/calc/calcFamily.js.map +0 -1
  92. package/dist/calc/compose.d.ts +0 -16
  93. package/dist/calc/compose.d.ts.map +0 -1
  94. package/dist/calc/compose.js +0 -7
  95. package/dist/calc/compose.js.map +0 -1
  96. package/dist/channel/channel.d.ts +0 -115
  97. package/dist/channel/channel.d.ts.map +0 -1
  98. package/dist/channel/channel.js +0 -138
  99. package/dist/channel/channel.js.map +0 -1
  100. package/dist/compose/composition.d.ts +0 -72
  101. package/dist/compose/composition.d.ts.map +0 -1
  102. package/dist/compose/composition.js +0 -46
  103. package/dist/compose/composition.js.map +0 -1
  104. package/dist/compose/host.d.ts +0 -17
  105. package/dist/compose/host.d.ts.map +0 -1
  106. package/dist/compose/host.js +0 -6
  107. package/dist/compose/host.js.map +0 -1
  108. package/dist/compose/props.d.ts +0 -13
  109. package/dist/compose/props.d.ts.map +0 -1
  110. package/dist/compose/props.js.map +0 -1
  111. package/dist/compose/provide.d.ts +0 -22
  112. package/dist/compose/provide.d.ts.map +0 -1
  113. package/dist/compose/provide.js +0 -28
  114. package/dist/compose/provide.js.map +0 -1
  115. package/dist/compose/slot.d.ts.map +0 -1
  116. package/dist/compose/slot.js +0 -23
  117. package/dist/compose/slot.js.map +0 -1
  118. package/dist/compose/ui.d.ts +0 -135
  119. package/dist/compose/ui.d.ts.map +0 -1
  120. package/dist/compose/ui.js +0 -63
  121. package/dist/compose/ui.js.map +0 -1
  122. package/dist/definition/definition.d.ts +0 -33
  123. package/dist/definition/definition.d.ts.map +0 -1
  124. package/dist/definition/definition.js +0 -42
  125. package/dist/definition/definition.js.map +0 -1
  126. package/dist/event/event.d.ts +0 -33
  127. package/dist/event/event.d.ts.map +0 -1
  128. package/dist/event/event.js +0 -30
  129. package/dist/event/event.js.map +0 -1
  130. package/dist/event/eventGroup.d.ts +0 -9
  131. package/dist/event/eventGroup.d.ts.map +0 -1
  132. package/dist/event/eventGroup.js +0 -4
  133. package/dist/event/eventGroup.js.map +0 -1
  134. package/dist/feature/feature.d.ts.map +0 -1
  135. package/dist/feature/feature.js +0 -98
  136. package/dist/feature/feature.js.map +0 -1
  137. package/dist/index.d.ts +0 -53
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js +0 -62
  140. package/dist/index.js.map +0 -1
  141. package/dist/internal/capture.d.ts +0 -28
  142. package/dist/internal/capture.d.ts.map +0 -1
  143. package/dist/internal/capture.js +0 -12
  144. package/dist/internal/capture.js.map +0 -1
  145. package/dist/internal/ctx.d.ts.map +0 -1
  146. package/dist/internal/ctx.js +0 -2
  147. package/dist/internal/ctx.js.map +0 -1
  148. package/dist/internal/errors.d.ts +0 -89
  149. package/dist/internal/errors.d.ts.map +0 -1
  150. package/dist/internal/errors.js +0 -102
  151. package/dist/internal/errors.js.map +0 -1
  152. package/dist/internal/inspect.d.ts +0 -17
  153. package/dist/internal/inspect.d.ts.map +0 -1
  154. package/dist/internal/inspect.js +0 -32
  155. package/dist/internal/inspect.js.map +0 -1
  156. package/dist/internal/queryDriver.d.ts +0 -65
  157. package/dist/internal/queryDriver.d.ts.map +0 -1
  158. package/dist/internal/queryDriver.js +0 -134
  159. package/dist/internal/queryDriver.js.map +0 -1
  160. package/dist/internal/reuse.d.ts +0 -10
  161. package/dist/internal/reuse.d.ts.map +0 -1
  162. package/dist/internal/reuse.js +0 -68
  163. package/dist/internal/reuse.js.map +0 -1
  164. package/dist/internal/scheduler.d.ts +0 -47
  165. package/dist/internal/scheduler.d.ts.map +0 -1
  166. package/dist/internal/scheduler.js +0 -70
  167. package/dist/internal/scheduler.js.map +0 -1
  168. package/dist/internal/seeds.d.ts.map +0 -1
  169. package/dist/internal/seeds.js +0 -17
  170. package/dist/internal/seeds.js.map +0 -1
  171. package/dist/internal/sources.d.ts +0 -39
  172. package/dist/internal/sources.d.ts.map +0 -1
  173. package/dist/internal/sources.js +0 -59
  174. package/dist/internal/sources.js.map +0 -1
  175. package/dist/internal/stateRegistry.d.ts +0 -2
  176. package/dist/internal/stateRegistry.d.ts.map +0 -1
  177. package/dist/internal/stateRegistry.js.map +0 -1
  178. package/dist/internal/store.d.ts +0 -47
  179. package/dist/internal/store.d.ts.map +0 -1
  180. package/dist/internal/store.js +0 -73
  181. package/dist/internal/store.js.map +0 -1
  182. package/dist/internal/track.d.ts.map +0 -1
  183. package/dist/internal/track.js +0 -19
  184. package/dist/internal/track.js.map +0 -1
  185. package/dist/procedure/procedure.d.ts +0 -40
  186. package/dist/procedure/procedure.d.ts.map +0 -1
  187. package/dist/procedure/procedure.js +0 -50
  188. package/dist/procedure/procedure.js.map +0 -1
  189. package/dist/reducer/reducer.d.ts +0 -44
  190. package/dist/reducer/reducer.d.ts.map +0 -1
  191. package/dist/reducer/reducer.js +0 -63
  192. package/dist/reducer/reducer.js.map +0 -1
  193. package/dist/remote/remoteState.d.ts +0 -119
  194. package/dist/remote/remoteState.d.ts.map +0 -1
  195. package/dist/remote/remoteState.js +0 -270
  196. package/dist/remote/remoteState.js.map +0 -1
  197. package/dist/runtime/bus.d.ts +0 -27
  198. package/dist/runtime/bus.d.ts.map +0 -1
  199. package/dist/runtime/bus.js +0 -21
  200. package/dist/runtime/bus.js.map +0 -1
  201. package/dist/runtime/loop.d.ts +0 -45
  202. package/dist/runtime/loop.d.ts.map +0 -1
  203. package/dist/runtime/loop.js +0 -117
  204. package/dist/runtime/loop.js.map +0 -1
  205. package/dist/scene/scene.d.ts +0 -44
  206. package/dist/scene/scene.d.ts.map +0 -1
  207. package/dist/scene/scene.js +0 -31
  208. package/dist/scene/scene.js.map +0 -1
  209. package/dist/state/state.d.ts +0 -37
  210. package/dist/state/state.d.ts.map +0 -1
  211. package/dist/state/state.js +0 -46
  212. package/dist/state/state.js.map +0 -1
  213. package/dist/state/stateFamily.d.ts +0 -79
  214. package/dist/state/stateFamily.d.ts.map +0 -1
  215. package/dist/state/stateFamily.js +0 -99
  216. package/dist/state/stateFamily.js.map +0 -1
  217. package/dist/state/stateGroup.d.ts +0 -50
  218. package/dist/state/stateGroup.d.ts.map +0 -1
  219. package/dist/state/stateGroup.js +0 -54
  220. package/dist/state/stateGroup.js.map +0 -1
  221. package/dist/state/token.d.ts.map +0 -1
  222. package/dist/state/token.js +0 -20
  223. package/dist/state/token.js.map +0 -1
  224. package/dist/synced/syncedStore.d.ts +0 -51
  225. package/dist/synced/syncedStore.d.ts.map +0 -1
  226. package/dist/synced/syncedStore.js +0 -43
  227. package/dist/synced/syncedStore.js.map +0 -1
  228. package/dist/ui/node.d.ts.map +0 -1
  229. package/dist/ui/node.js +0 -2
  230. package/dist/ui/node.js.map +0 -1
  231. package/dist/ui/trigger.d.ts.map +0 -1
  232. package/dist/ui/trigger.js +0 -2
  233. package/dist/ui/trigger.js.map +0 -1
  234. package/dist/wire/tree.d.ts +0 -66
  235. package/dist/wire/tree.d.ts.map +0 -1
  236. package/dist/wire/tree.js +0 -60
  237. package/dist/wire/tree.js.map +0 -1
  238. package/dist/wire/triggers.d.ts +0 -44
  239. package/dist/wire/triggers.d.ts.map +0 -1
  240. package/dist/wire/triggers.js +0 -31
  241. package/dist/wire/triggers.js.map +0 -1
@@ -0,0 +1,267 @@
1
+ import { Context, Effect, Layer, Option, Schema } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import { type AnyEvent } from '../event/event'
4
+ import {
5
+ type InputsObject,
6
+ type InputStores,
7
+ type InvalidateBy,
8
+ } from '../internal/sources'
9
+ import {
10
+ bumpRevision,
11
+ type GatedOf,
12
+ gatedFlag,
13
+ makeQueryDriver,
14
+ RevisionSchema,
15
+ revisionZero,
16
+ } from '../internal/queryDriver'
17
+ import { type Store } from '../internal/store'
18
+ import { readTracked } from '../internal/track'
19
+ import * as Reducer from '../reducer/reducer'
20
+ import { Reducers } from '../runtime/loop'
21
+ import * as State from '../state/state'
22
+ import { type AnySource } from '../state/token'
23
+ import { type AsyncData, narrowStore } from './asyncData'
24
+
25
+ // `AsyncCalc` is `Calc`'s async sibling: a derived store whose value is the
26
+ // lifecycle of an Effect (a query), recomputed reactively from its inputs — the
27
+ // React-Query model, but inside the reform graph and without dispatching events.
28
+ // Definition (`make`) is reflectable data; implementation (`live`) is the effect.
29
+ // The reactive machinery lives in `internal/queryDriver` (shared with
30
+ // `RemoteState`); this module owns the definition shape and the `invalidateOn`
31
+ // sugar.
32
+
33
+ export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
34
+ readonly kind: 'AsyncCalc'
35
+ readonly name: N
36
+ readonly output: Schema.Schema<A, any>
37
+ readonly error?: Schema.Schema<E, any>
38
+ /** Whether the query can be disabled (drives the `Idle` arm). */
39
+ readonly gated: boolean
40
+ }
41
+
42
+ export interface AsyncCalcClass<
43
+ out N extends string,
44
+ out Inputs extends ReadonlyArray<AnySource>,
45
+ in out A,
46
+ in out E,
47
+ in out Gated extends boolean,
48
+ > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
49
+ new (): {}
50
+ readonly manifest: AsyncCalcManifest<N, A, E>
51
+ readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
52
+ /** The calc's name, so it doubles as a `Source` input to another (async) calc. */
53
+ readonly name: N
54
+ /** Marker data read by `AsyncCalc.live`. */
55
+ readonly inputs: Inputs
56
+ /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
57
+ readonly gated: Gated
58
+ }
59
+
60
+ export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E, AlwaysOn extends boolean> {
61
+ readonly inputs: Inputs
62
+ /** Schema of the `Success` value. */
63
+ readonly output: Schema.Schema<A, any>
64
+ /** Schema of the failure. Omitted ⇒ the query is infallible and there is no `Error` arm. */
65
+ readonly error?: Schema.Schema<E, any>
66
+ /** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
67
+ readonly alwaysOn?: AlwaysOn
68
+ }
69
+
70
+ /**
71
+ * The `.live` config: the effect plus the reactive logic. `disabled` is only
72
+ * accepted for a gateable query (no `alwaysOn`); `invalidateBy` is the same
73
+ * `queryKey` projection `Calc` uses.
74
+ */
75
+ export type AsyncCalcLive<
76
+ Inputs extends ReadonlyArray<AnySource>,
77
+ A,
78
+ E,
79
+ Gated extends boolean,
80
+ R,
81
+ > = {
82
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
83
+ readonly invalidateBy?: InvalidateBy<Inputs>
84
+ /**
85
+ * Refetch conciliation. `'switch'` (default): a new key cancels the in-flight
86
+ * run (latest-wins). `'trailing'`: the in-flight run completes; key changes
87
+ * that arrive meanwhile conflate to the latest, and exactly ONE trailing
88
+ * refetch runs after it settles — a burst of N invalidations during one
89
+ * flight costs 2 fetches, not N cancel-restarts.
90
+ */
91
+ readonly coalesce?: 'switch' | 'trailing'
92
+ /**
93
+ * Structural sharing for `Success` values: reconcile each refetch result
94
+ * against the previous one, substituting previous nodes wherever value
95
+ * equality holds — refetches returning mostly-identical data keep unchanged
96
+ * subtree identities, so downstream memo boundaries skip them. Same pass as
97
+ * `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
98
+ */
99
+ readonly reuse?: boolean
100
+ } & (Gated extends true
101
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
102
+ : { readonly disabled?: never })
103
+
104
+ /**
105
+ * Define an async derived value. `output`/`error` schemas and `alwaysOn` shape
106
+ * the value type, so `yield* MyQuery` is typed to exactly the arms that can
107
+ * occur. The query effect itself is supplied by `AsyncCalc.live`.
108
+ */
109
+ export const make = <
110
+ const N extends string,
111
+ const Inputs extends ReadonlyArray<AnySource>,
112
+ A,
113
+ E = never,
114
+ const AlwaysOn extends boolean = false,
115
+ >(
116
+ name: N,
117
+ config: AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
118
+ ): AsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> => {
119
+ const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
120
+ `reform/asyncCalc/${name}`,
121
+ )
122
+ // Runtime mirror of the type-level `Gated`, typed as its literal in one place.
123
+ const gated = gatedFlag(config.alwaysOn)
124
+ const manifest: AsyncCalcManifest<N, A, E> = {
125
+ kind: 'AsyncCalc',
126
+ name,
127
+ output: config.output,
128
+ gated,
129
+ ...(config.error !== undefined ? { error: config.error } : {}),
130
+ }
131
+ const read = Effect.flatMap(store, readTracked)
132
+ return yieldableClass(read, {
133
+ manifest,
134
+ store,
135
+ name,
136
+ inputs: config.inputs,
137
+ gated,
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Wire the query and its reactive logic. The query runs on the runtime (its `R`
143
+ * — RPC clients, etc. — is captured from the layer context). A change to an
144
+ * input re-runs it latest-wins (a new run cancels the in-flight one; or, with
145
+ * `coalesce: 'trailing'`, lets it finish and runs one trailing refetch); while a
146
+ * re-run is in flight the last `Success`/`Error` is kept with `refetching: true`.
147
+ * Re-runs are driven by inputs / `invalidateBy` / `disabled` — and, with
148
+ * `invalidateOn`, by listed events: pure sugar that generates a hidden revision
149
+ * state + reducer (the sole writer) and appends the revision to the calc's key,
150
+ * so an event-driven refetch is still just key movement under `Equal.equals`.
151
+ * The hidden pair stays internal — the user's `query`/`invalidateBy` see exactly
152
+ * the declared inputs — and costs the layer one extra requirement: the
153
+ * `Reducers` registry (the `Engine`).
154
+ */
155
+ export function live<
156
+ N extends string,
157
+ Inputs extends ReadonlyArray<AnySource>,
158
+ A,
159
+ E,
160
+ Gated extends boolean,
161
+ R,
162
+ >(
163
+ calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
164
+ config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
165
+ readonly invalidateOn: ReadonlyArray<AnyEvent>
166
+ },
167
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R | Reducers>
168
+ export function live<
169
+ N extends string,
170
+ Inputs extends ReadonlyArray<AnySource>,
171
+ A,
172
+ E,
173
+ Gated extends boolean,
174
+ R,
175
+ >(
176
+ calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
177
+ config: AsyncCalcLive<Inputs, A, E, Gated, R>,
178
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R>
179
+ export function live<
180
+ N extends string,
181
+ Inputs extends ReadonlyArray<AnySource>,
182
+ A,
183
+ E,
184
+ Gated extends boolean,
185
+ R,
186
+ >(
187
+ calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
188
+ config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
189
+ readonly invalidateOn?: ReadonlyArray<AnyEvent>
190
+ },
191
+ // The wide-R implementation signature behind precise overloads — the same
192
+ // seam `Reducer.live` uses; both overload returns are assignable (RIn is
193
+ // covariant), so no value is ever cast.
194
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, any> {
195
+ // The hidden revision pair, built once per `live` call. Namespacing the state
196
+ // by the calc's name (`reform/state/${calcName}/invalidateOn`) keeps the tag
197
+ // out of any plausible user namespace; the duplicate-reducer check is
198
+ // warning-only and calc names are unique by convention.
199
+ const revisionState =
200
+ config.invalidateOn === undefined
201
+ ? undefined
202
+ : State.make(`${calc.manifest.name}/invalidateOn`, RevisionSchema)
203
+
204
+ // Scoped so the source subscriptions and the driver fiber are released when
205
+ // the layer's scope closes (each proof/test builds and disposes its runtime).
206
+ const driver = Layer.scoped(
207
+ calc.store,
208
+ Effect.gen(function* () {
209
+ // `disabled` is rejected at the type level for non-gated calcs (erased to
210
+ // `never` there); read it through a loose view for the runtime.
211
+ const cfg = config as {
212
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
213
+ readonly invalidateBy?: InvalidateBy<Inputs>
214
+ readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
215
+ readonly coalesce?: 'switch' | 'trailing'
216
+ readonly reuse?: boolean
217
+ }
218
+
219
+ // The hidden revision store, read requirement-free (`serviceOption`): the
220
+ // assembly below always provides it alongside this driver, and feeding it
221
+ // through `extraKey` (not `wireSources`) keeps it out of the snapshot —
222
+ // the user's `query` and `invalidateBy` receive exactly
223
+ // `InputsObject<Inputs>`, with no hidden property to leak into
224
+ // spread-into-RPC payloads.
225
+ const revision =
226
+ revisionState === undefined
227
+ ? undefined
228
+ : Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
229
+ const driver = yield* makeQueryDriver({
230
+ name: calc.manifest.name,
231
+ label: 'AsyncCalc',
232
+ gated: calc.gated,
233
+ inputs: calc.inputs,
234
+ query: cfg.query,
235
+ invalidateBy: cfg.invalidateBy,
236
+ disabled: cfg.disabled,
237
+ coalesce: cfg.coalesce,
238
+ reuse: cfg.reuse,
239
+ extraKey:
240
+ revision === undefined
241
+ ? undefined
242
+ : {
243
+ read: () => revision.getSnapshot(),
244
+ subscribe: (listener) => revision.subscribe(listener),
245
+ },
246
+ })
247
+
248
+ // The internal store is the full union; narrow to the definition's arms.
249
+ return narrowStore<A, E, Gated>(driver.store)
250
+ }),
251
+ )
252
+
253
+ if (config.invalidateOn === undefined || revisionState === undefined) return driver
254
+ // The hidden reducer: an ordinary `Reducer` folding every listed event to
255
+ // n + 1 — the revision's sole writer, registered/unregistered with this
256
+ // layer's scope like any user reducer. `Layer.provide` builds the hidden
257
+ // state once and feeds the same store to both the driver and the reducer,
258
+ // while the outer context (user inputs, the `Reducers` registry) passes
259
+ // through untouched.
260
+ const revisionReducer = Reducer.make(`${calc.manifest.name}/invalidateOn`, {
261
+ states: [revisionState],
262
+ events: config.invalidateOn,
263
+ })
264
+ return Layer.merge(driver, Reducer.live(revisionReducer, bumpRevision)).pipe(
265
+ Layer.provide(State.live(revisionState, revisionZero)),
266
+ )
267
+ }
@@ -0,0 +1,96 @@
1
+ import { type Store } from '../internal/store'
2
+
3
+ // The value an `AsyncCalc` holds: a tagged union of the query's lifecycle,
4
+ // consumed with `effect`'s `Match` (`Match.value(data).pipe(Match.tag('Success', …))`).
5
+ //
6
+ // The shape is narrowed by the definition, so you only handle arms that can
7
+ // occur: `Idle` exists only for a gateable query (no `alwaysOn`), and `Error`
8
+ // exists only when the query declares an `error` schema. There is no separate
9
+ // "refreshing" arm — a re-fetch keeps the last `Success`/`Error` and flips its
10
+ // `refetching` flag (stale-while-revalidate), so the UI never flashes empty.
11
+
12
+ /** A gated query that is currently switched off (only when not `alwaysOn`). */
13
+ export interface AsyncIdle {
14
+ readonly _tag: 'Idle'
15
+ }
16
+
17
+ /** The first fetch, before any value exists. */
18
+ export interface AsyncLoading {
19
+ readonly _tag: 'Loading'
20
+ }
21
+
22
+ /** A resolved value; `refetching` is true while a background re-fetch runs. */
23
+ export interface AsyncSuccess<A> {
24
+ readonly _tag: 'Success'
25
+ readonly value: A
26
+ readonly refetching: boolean
27
+ }
28
+
29
+ /** A failed query; `refetching` is true while a background re-fetch runs. */
30
+ export interface AsyncError<E> {
31
+ readonly _tag: 'Error'
32
+ readonly error: E
33
+ readonly refetching: boolean
34
+ }
35
+
36
+ /**
37
+ * The query lifecycle. `Gated` adds the `Idle` arm (a disable-able query); a
38
+ * non-`never` `E` adds the `Error` arm. Both are decided at `AsyncCalc.make`, so
39
+ * `yield* MyQuery` is typed to exactly the arms that can occur.
40
+ */
41
+ export type AsyncData<A, E = never, Gated extends boolean = false> =
42
+ | (Gated extends true ? AsyncIdle : never)
43
+ | AsyncLoading
44
+ | AsyncSuccess<A>
45
+ | ([E] extends [never] ? never : AsyncError<E>)
46
+
47
+ /** The full (un-narrowed) union — used internally by the live store. */
48
+ export type AnyAsyncData<A, E> = AsyncIdle | AsyncLoading | AsyncSuccess<A> | AsyncError<E>
49
+
50
+ const idle: AsyncIdle = { _tag: 'Idle' }
51
+ const loading: AsyncLoading = { _tag: 'Loading' }
52
+ const success = <A>(value: A, refetching = false): AsyncSuccess<A> => ({
53
+ _tag: 'Success',
54
+ value,
55
+ refetching,
56
+ })
57
+ const error = <E>(err: E, refetching = false): AsyncError<E> => ({
58
+ _tag: 'Error',
59
+ error: err,
60
+ refetching,
61
+ })
62
+
63
+ /** The arm-constructor namespace exposed as `AsyncData`. */
64
+ export interface AsyncDataConstructors {
65
+ readonly idle: AsyncIdle
66
+ readonly loading: AsyncLoading
67
+ readonly success: <A>(value: A, refetching?: boolean) => AsyncSuccess<A>
68
+ readonly error: <E>(err: E, refetching?: boolean) => AsyncError<E>
69
+ }
70
+
71
+ /**
72
+ * Constructors for the arms, namespaced under the same name as the type so call
73
+ * sites read `AsyncData.success(v)` / `AsyncData.error(e)` — no `error` import
74
+ * shadowing the keyword and one obvious home for every arm.
75
+ */
76
+ export const AsyncData: AsyncDataConstructors = { idle, loading, success, error }
77
+
78
+ /**
79
+ * Narrow the live store (which holds the full `AnyAsyncData` union) to the arms
80
+ * the definition actually permits (`Gated`/`E`). The single documented home for
81
+ * that narrowing, so `AsyncCalc.live` returns it without an inline cast.
82
+ */
83
+ export const narrowStore = <A, E, Gated extends boolean>(
84
+ store: Store<AnyAsyncData<A, E>>,
85
+ ): Store<AsyncData<A, E, Gated>> => store as unknown as Store<AsyncData<A, E, Gated>>
86
+
87
+ /**
88
+ * The read-only inverse of `narrowStore`: widen a definition-narrowed store back
89
+ * to the full union, so a consumer (the `RemoteState` overlay) can dispatch on the
90
+ * lifecycle arms without carrying the definition's `Gated`/`E` conditionals.
91
+ * Sound for reads only — every narrowed value IS an `AnyAsyncData` — so callers
92
+ * must never `set` through the widened view (derived stores ignore `set` anyway).
93
+ */
94
+ export const widenStore = <A, E, Gated extends boolean>(
95
+ store: Store<AsyncData<A, E, Gated>>,
96
+ ): Store<AnyAsyncData<A, E>> => store as unknown as Store<AnyAsyncData<A, E>>
@@ -0,0 +1,287 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
3
+ import { Calc, State, StateGroup } from '../index'
4
+
5
+ // The calc store memoizes on input identity and — the behaviour under test —
6
+ // only wakes its subscribers when a source change actually moves the output.
7
+
8
+ const tick = Effect.sleep(Duration.millis(1))
9
+
10
+ it.live('a source change that does not move the output notifies no subscriber', () => {
11
+ class Count extends State.make('count', S.Number) {}
12
+ class Inputs extends StateGroup.make(Count) {}
13
+ // `isPositive` is stable across 1 -> 2 (still true) but flips on 2 -> -1.
14
+ class IsPositive extends Calc.make('IsPositive', {
15
+ inputs: [StateGroup.select(Inputs, 'count')],
16
+ output: S.Boolean,
17
+ }) {}
18
+ const runs = { n: 0 }
19
+ const IsPositiveLive = Calc.live(IsPositive, ({ count }) => {
20
+ runs.n += 1
21
+ return count > 0
22
+ })
23
+
24
+ const TestLayer = IsPositiveLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
25
+
26
+ return Effect.gen(function* () {
27
+ const source = yield* StateGroup.select(Inputs, 'count').store
28
+ const derived = yield* IsPositive.store
29
+ const notifications = { n: 0 }
30
+ derived.subscribe(() => {
31
+ notifications.n += 1
32
+ })
33
+
34
+ expect(derived.get()).toBe(true)
35
+
36
+ // 1 -> 2: inputs changed, recompute runs, but the output (true) is unchanged.
37
+ source.set(2)
38
+ yield* tick
39
+ expect(notifications.n).toBe(0)
40
+ expect(derived.get()).toBe(true)
41
+
42
+ // 2 -> -1: output flips to false — exactly one notification.
43
+ source.set(-1)
44
+ yield* tick
45
+ expect(notifications.n).toBe(1)
46
+ expect(derived.get()).toBe(false)
47
+ }).pipe(Effect.provide(TestLayer))
48
+ })
49
+
50
+ it.live('reading does not recompute while inputs are unchanged (memoized)', () => {
51
+ class Count extends State.make('count', S.Number) {}
52
+ class Inputs extends StateGroup.make(Count) {}
53
+ class Doubled extends Calc.make('Doubled', {
54
+ inputs: [StateGroup.select(Inputs, 'count')],
55
+ output: S.Number,
56
+ }) {}
57
+ const runs = { n: 0 }
58
+ const DoubledLive = Calc.live(Doubled, ({ count }) => {
59
+ runs.n += 1
60
+ return count * 2
61
+ })
62
+
63
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
64
+
65
+ return Effect.gen(function* () {
66
+ const derived = yield* Doubled.store
67
+ const before = runs.n
68
+ // Repeated reads with no input change hit the memo — no extra compute.
69
+ expect(derived.get()).toBe(10)
70
+ expect(derived.get()).toBe(10)
71
+ expect(runs.n).toBe(before)
72
+ }).pipe(Effect.provide(TestLayer))
73
+ })
74
+
75
+ it.live('a calc can depend on another calc as an input source', () => {
76
+ class Count extends State.make('count', S.Number) {}
77
+ class Inputs extends StateGroup.make(Count) {}
78
+ class Doubled extends Calc.make('Doubled', {
79
+ inputs: [StateGroup.select(Inputs, 'count')],
80
+ output: S.Number,
81
+ }) {}
82
+ const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
83
+ // `Doubled` (a Calc) is used directly as an input source; its name is the key.
84
+ class PlusDoubled extends Calc.make('PlusDoubled', {
85
+ inputs: [StateGroup.select(Inputs, 'count'), Doubled],
86
+ output: S.Number,
87
+ }) {}
88
+ const PlusDoubledLive = Calc.live(PlusDoubled, ({ count, Doubled }) => count + Doubled)
89
+
90
+ const TestLayer = PlusDoubledLive.pipe(
91
+ Layer.provideMerge(DoubledLive),
92
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
93
+ )
94
+
95
+ return Effect.gen(function* () {
96
+ const source = yield* StateGroup.select(Inputs, 'count').store
97
+ const derived = yield* PlusDoubled.store
98
+ expect(derived.get()).toBe(15) // 5 + (5*2)
99
+ source.set(3)
100
+ yield* tick
101
+ expect(derived.get()).toBe(9) // 3 + (3*2)
102
+ }).pipe(Effect.provide(TestLayer))
103
+ })
104
+
105
+ it.live('a diamond dependency resolves in one flush with a single leaf notification', () => {
106
+ // count ─┬─► A (count+1) ─┐
107
+ // └─► B (count+10)─┴─► C (A+B). A change to `count` moves both A and B;
108
+ // C must converge to the new value and wake its subscriber exactly once.
109
+ class Count extends State.make('count', S.Number) {}
110
+ class Inputs extends StateGroup.make(Count) {}
111
+ class A extends Calc.make('A', {
112
+ inputs: [StateGroup.select(Inputs, 'count')],
113
+ output: S.Number,
114
+ }) {}
115
+ const ALive = Calc.live(A, ({ count }) => count + 1)
116
+ class B extends Calc.make('B', {
117
+ inputs: [StateGroup.select(Inputs, 'count')],
118
+ output: S.Number,
119
+ }) {}
120
+ const BLive = Calc.live(B, ({ count }) => count + 10)
121
+ class C extends Calc.make('C', { inputs: [A, B], output: S.Number }) {}
122
+ const CLive = Calc.live(C, ({ A, B }) => A + B)
123
+
124
+ const TestLayer = CLive.pipe(
125
+ Layer.provideMerge(Layer.mergeAll(ALive, BLive)),
126
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 0 })),
127
+ )
128
+
129
+ return Effect.gen(function* () {
130
+ const source = yield* StateGroup.select(Inputs, 'count').store
131
+ const c = yield* C.store
132
+ const wakes = { n: 0 }
133
+ c.subscribe(() => {
134
+ wakes.n += 1
135
+ })
136
+ expect(c.get()).toBe(11) // (0+1) + (0+10)
137
+
138
+ source.set(5)
139
+ yield* tick
140
+ expect(c.get()).toBe(21) // (5+1) + (5+10)
141
+ expect(wakes.n).toBe(1)
142
+ }).pipe(Effect.provide(TestLayer))
143
+ })
144
+
145
+ it.live('invalidateBy: a calc recomputes only when the projected key moves', () => {
146
+ class A extends State.make('a', S.Number) {}
147
+ class B extends State.make('b', S.Number) {}
148
+ class Inputs extends StateGroup.make(A, B) {}
149
+ const runs = { n: 0 }
150
+ class Sum extends Calc.make('Sum', {
151
+ inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
152
+ output: S.Number,
153
+ }) {}
154
+ const SumLive = Calc.live(
155
+ Sum,
156
+ ({ a, b }) => {
157
+ runs.n += 1
158
+ return a + b
159
+ },
160
+ { invalidateBy: ({ a }) => [a] }, // only `a` is in the key
161
+ )
162
+ const TestLayer = SumLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
163
+
164
+ return Effect.gen(function* () {
165
+ const sa = yield* StateGroup.select(Inputs, 'a').store
166
+ const sb = yield* StateGroup.select(Inputs, 'b').store
167
+ const sum = yield* Sum.store
168
+ const base = runs.n
169
+ expect(sum.get()).toBe(2)
170
+
171
+ // `b` moves but the key (just `a`) is unchanged — no recompute, value retained.
172
+ sb.set(100)
173
+ yield* tick
174
+ expect(runs.n).toBe(base)
175
+ expect(sum.get()).toBe(2)
176
+
177
+ // `a` moves — recompute, now seeing the current `b`.
178
+ sa.set(5)
179
+ yield* tick
180
+ expect(sum.get()).toBe(105)
181
+ }).pipe(Effect.provide(TestLayer))
182
+ })
183
+
184
+ it.live('inputs are keyed by the make() name even when the subclass binding differs', () => {
185
+ class Count extends State.make('count', S.Number) {}
186
+ class Inputs extends StateGroup.make(Count) {}
187
+ // The binding name ('Renamed…') deliberately differs from the make() name
188
+ // ('feed') — a class declaration defines its OWN static `name` from the
189
+ // binding, shadowing the explicit one. Minifiers rename bindings, so keying
190
+ // the snapshot off the static would make this input undefined in production
191
+ // builds; the inputs object must key by the manifest's make() name.
192
+ class RenamedByTheMinifier extends Calc.make('feed', {
193
+ inputs: [StateGroup.select(Inputs, 'count')],
194
+ output: S.Number,
195
+ }) {}
196
+ class Downstream extends Calc.make('Downstream', {
197
+ inputs: [RenamedByTheMinifier],
198
+ output: S.Number,
199
+ }) {}
200
+ const seen: Array<unknown> = []
201
+ const FeedLive = Calc.live(RenamedByTheMinifier, ({ count }) => count * 2)
202
+ const DownstreamLive = Calc.live(Downstream, ({ feed }) => {
203
+ seen.push(feed)
204
+ return feed + 1
205
+ })
206
+
207
+ const TestLayer = DownstreamLive.pipe(
208
+ Layer.provideMerge(FeedLive),
209
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
210
+ )
211
+
212
+ return Effect.gen(function* () {
213
+ // The subclass's own static name is the (would-be-minified) binding name…
214
+ expect(RenamedByTheMinifier.name).toBe('RenamedByTheMinifier')
215
+ // …but the snapshot keys by the make() name, so `feed` is defined.
216
+ const downstream = yield* Downstream.store
217
+ expect(downstream.get()).toBe(7)
218
+ expect(seen.every((v) => v !== undefined)).toBe(true)
219
+ }).pipe(Effect.provide(TestLayer))
220
+ })
221
+
222
+ it.live('reuse: an unchanged subtree keeps its identity across recompute', () => {
223
+ class A extends State.make('a', S.Number) {}
224
+ class B extends State.make('b', S.Number) {}
225
+ class Inputs extends StateGroup.make(A, B) {}
226
+ const Pair = S.Struct({
227
+ left: S.Struct({ value: S.Number }),
228
+ right: S.Struct({ value: S.Number }),
229
+ })
230
+ class Split extends Calc.make('Split', {
231
+ inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
232
+ output: Pair,
233
+ }) {}
234
+ const SplitLive = Calc.live(
235
+ Split,
236
+ ({ a, b }) => ({ left: { value: a }, right: { value: b } }),
237
+ { reuse: true },
238
+ )
239
+ const TestLayer = SplitLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
240
+
241
+ return Effect.gen(function* () {
242
+ const b = yield* StateGroup.select(Inputs, 'b').store
243
+ const derived = yield* Split.store
244
+ const before = derived.get()
245
+
246
+ // Only `b` moves: the root and `right` are fresh, `left` keeps its identity.
247
+ b.set(2)
248
+ yield* tick
249
+ const after = derived.get()
250
+ expect(after).not.toBe(before)
251
+ expect(after.left).toBe(before.left)
252
+ expect(after.right).not.toBe(before.right)
253
+ expect(after.right.value).toBe(2)
254
+ }).pipe(Effect.provide(TestLayer))
255
+ })
256
+
257
+ it.live('reuse: a value-equal recompute keeps the previous reference and wakes nobody', () => {
258
+ class Count extends State.make('count', S.Number) {}
259
+ class Inputs extends StateGroup.make(Count) {}
260
+ const Box = S.Struct({ positive: S.Boolean })
261
+ class IsPositive extends Calc.make('IsPositive', {
262
+ inputs: [StateGroup.select(Inputs, 'count')],
263
+ output: Box,
264
+ }) {}
265
+ // A plain-object output: WITHOUT reuse every recompute is a fresh identity
266
+ // (and would notify); with it, a value-equal recompute returns the previous
267
+ // reference and the Equal gate stays silent.
268
+ const IsPositiveLive = Calc.live(IsPositive, ({ count }) => ({ positive: count > 0 }), {
269
+ reuse: true,
270
+ })
271
+ const TestLayer = IsPositiveLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
272
+
273
+ return Effect.gen(function* () {
274
+ const source = yield* StateGroup.select(Inputs, 'count').store
275
+ const derived = yield* IsPositive.store
276
+ const notifications = { n: 0 }
277
+ derived.subscribe(() => {
278
+ notifications.n += 1
279
+ })
280
+ const before = derived.get()
281
+
282
+ source.set(2)
283
+ yield* tick
284
+ expect(derived.get()).toBe(before)
285
+ expect(notifications.n).toBe(0)
286
+ }).pipe(Effect.provide(TestLayer))
287
+ })