@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,88 @@
1
+ import { Layer } from 'effect'
2
+ import type { CompositionClass, CompositionService } from '../compose/composition'
3
+ import type { UiContract } from '../compose/ui'
4
+ import type { EventOf } from '../event/event'
5
+ import { CurrentSeedOverrides } from '../internal/seeds'
6
+ import type { SeedsOf } from '../state/stateGroup'
7
+ import type { Bus } from '../runtime/bus'
8
+
9
+ // A scene is a VALUE — a composition plus the closed wiring that runs it: the
10
+ // layers that supply its logic/views (each seeding its own live state) and the
11
+ // boot events to dispatch once the runtime is live. Nothing self-registers on
12
+ // import. A scene is the single handle every consumer takes instead of a bare
13
+ // composition: the react host renders it, a proof runs against it, and the dev
14
+ // tool previews it — all from the same closed `provide`.
15
+ //
16
+ // There is no seeded-`state` dict: variation (Default/Empty/Errored) comes from
17
+ // providing different closed layers, exactly as production wiring does. The
18
+ // composition's contract `C` is preserved (not erased to `unknown`) so a proof's
19
+ // facade stays fully typed from `scene.composition`.
20
+
21
+ /**
22
+ * The services a built reform runtime exposes to its host: every composition's
23
+ * logic and the event `Bus`. A scene's `provide` must close to these (a fully
24
+ * wired app layer provides far more; this is the subset hosts read directly).
25
+ */
26
+ export type MountedServices = CompositionService | Bus
27
+
28
+ // A scene's boot list holds CONSTRUCTED events (`Event.construct(E, payload)`),
29
+ // not bare tagged objects — a lightweight nudge toward the real builder. It is
30
+ // not a guarantee the event is handled by the wiring (reform carries no
31
+ // type-level union of handled tags), only that it came from an event definition.
32
+ type BootEvent = EventOf<string, unknown>
33
+
34
+ export interface Scene<
35
+ C extends UiContract = UiContract,
36
+ S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
37
+ > {
38
+ readonly kind: 'Scene'
39
+ /** The composition to run, with its contract + states preserved for typed consumers. */
40
+ readonly composition: CompositionClass<unknown, C, S>
41
+ /** Closed wiring (logic + views), each layer seeding its own live state. */
42
+ readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
43
+ /** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
44
+ readonly boot?: ReadonlyArray<BootEvent>
45
+ }
46
+
47
+ /**
48
+ * Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
49
+ * The composition's contract `C` and state tuple `S` flow through, so consumers
50
+ * (the facade, `seedScene`) stay typed.
51
+ */
52
+ export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
53
+ composition: CompositionClass<unknown, C, S>,
54
+ config: {
55
+ readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
56
+ readonly boot?: ReadonlyArray<BootEvent>
57
+ },
58
+ ): Scene<C, S> => ({
59
+ kind: 'Scene',
60
+ composition,
61
+ provide: config.provide,
62
+ ...(config.boot !== undefined ? { boot: config.boot } : {}),
63
+ })
64
+
65
+ /**
66
+ * Overlay seed values onto a CLOSED scene — the tooling/test seam (dev-tool
67
+ * inspector overrides, proofs). Scenes stay closed for authors: this does not
68
+ * reopen `provide`, it wraps each layer with `Layer.locally(CurrentSeedOverrides,
69
+ * seeds)` so `State.live` boots matching stores from the override instead of
70
+ * the authored seed.
71
+ *
72
+ * Keys are state MEMBER NAMES as reflected in `manifest.states` (e.g. `count`),
73
+ * not group names. If two groups in one scene share a member name, both receive
74
+ * the override (documented limitation). Values are schema-validated at build:
75
+ * an invalid value silently falls back to the authored seed. `StateFamily`
76
+ * entries are not covered.
77
+ */
78
+ export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
79
+ base: Scene<C, S>,
80
+ seeds: SeedsOf<S>,
81
+ ): Scene<C, S> =>
82
+ Object.keys(seeds).length === 0
83
+ ? base
84
+ : { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
85
+
86
+ /** Reflection guard: is this exported value a scene? */
87
+ export const isScene = (v: unknown): v is Scene =>
88
+ typeof v === 'object' && v !== null && (v as { readonly kind?: unknown }).kind === 'Scene'
@@ -0,0 +1,176 @@
1
+ import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
2
+ import { expect, test } from 'vitest'
3
+ import {
4
+ Composition,
5
+ Engine,
6
+ Event,
7
+ isStructure,
8
+ mount,
9
+ provide,
10
+ publish,
11
+ Reducer,
12
+ type RenderEnv,
13
+ type Scene,
14
+ scene,
15
+ seedScene,
16
+ State,
17
+ StateGroup,
18
+ Ui,
19
+ ui,
20
+ } from '../index'
21
+ import { CurrentSeedOverrides } from '../internal/seeds'
22
+
23
+ // `seedScene` is the tooling seam over CLOSED scenes: the app bundle below seeds
24
+ // `count: 1` itself (no open store), and the overlay must reach the nested
25
+ // `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
26
+ // pre-composed layers. The harness mirrors the dev-tool preview: merge the
27
+ // scene's `provide`, mount it in a ManagedRuntime, and read the root
28
+ // composition's rendered props through the headless Structure.
29
+
30
+ class CountState extends State.make('count', S.Number) {}
31
+ class MiniStates extends StateGroup.make(CountState) {}
32
+
33
+ class Bumped extends Event.make('Bumped', S.Struct({})) {}
34
+
35
+ class BumpReducer extends Reducer.make('BumpReducer', {
36
+ states: [CountState],
37
+ events: [Bumped],
38
+ }) {}
39
+ const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
40
+
41
+ class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
42
+ class Counter extends Composition.make('Counter', {
43
+ title: 'Counter',
44
+ states: [MiniStates],
45
+ events: [Bumped],
46
+ ui: CounterUi,
47
+ }) {}
48
+ const CounterLive = Composition.live(Counter, function* () {
49
+ const count = yield* StateGroup.select(MiniStates, 'count')
50
+ return mount({ props: { count }, slots: {} })
51
+ })
52
+
53
+ // The closed app layer a scene provides: views + logic + Engine, with the state
54
+ // group seeding its AUTHORED value `count: 1` itself.
55
+ const presentations = provide(CounterUi, Ui.make(CounterUi, () => null))
56
+ const Views = CounterLive.pipe(Layer.provideMerge(presentations))
57
+ const Logic = Layer.mergeAll(BumpReducerLive).pipe(
58
+ Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 1 }))),
59
+ )
60
+ const MiniApp = Views.pipe(Layer.provideMerge(Logic))
61
+
62
+ const CounterScene = scene(Counter, { provide: [MiniApp] })
63
+
64
+ // The dev-tool inspector overlays raw, user-typed seeds (`Record<string, unknown>`)
65
+ // — the untyped seam that `seedScene` wraps once a composition's state tuple is
66
+ // known. These two cases (absent key / schema-invalid value) are compile errors
67
+ // through the now-typed `seedScene`, so they're exercised here at the raw overlay
68
+ // to prove `resolveSeed`'s runtime defensiveness still falls back to the seed.
69
+ const overlayRawSeeds = (base: Scene, seeds: Readonly<Record<string, unknown>>): Scene => ({
70
+ ...base,
71
+ provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
72
+ })
73
+
74
+ const headlessEnv: RenderEnv = {
75
+ props: {},
76
+ tracker: { add: () => {} },
77
+ }
78
+
79
+ const isCountProps = (props: unknown): props is { readonly count: number } =>
80
+ typeof props === 'object' &&
81
+ props !== null &&
82
+ 'count' in props &&
83
+ typeof props.count === 'number'
84
+
85
+ // One ManagedRuntime per scene under test. Each `read` re-renders the root
86
+ // composition and reads the Structure built from the stores that runtime actually
87
+ // constructed.
88
+ const makeHarness = (s: Scene) => {
89
+ const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
90
+ const runtime = ManagedRuntime.make(layer)
91
+ const read = Effect.flatMap(Counter.tag, (c) =>
92
+ Composition.render(c, headlessEnv).pipe(
93
+ Effect.flatMap((frame) => {
94
+ if (!isStructure(frame)) {
95
+ return Effect.dieMessage('Counter must render a Structure')
96
+ }
97
+ return isCountProps(frame.props)
98
+ ? Effect.succeed(frame.props.count)
99
+ : Effect.dieMessage('Counter Structure props must include numeric count')
100
+ }),
101
+ ),
102
+ )
103
+ return { runtime, read }
104
+ }
105
+
106
+ // Drive a read until a predicate holds, handing forked fibers the scheduler.
107
+ const waitUntil = <A, R>(
108
+ read: Effect.Effect<A, never, R>,
109
+ pred: (a: A) => boolean,
110
+ rounds = 1000,
111
+ ): Effect.Effect<A, never, R> =>
112
+ Effect.flatMap(read, (a) =>
113
+ pred(a) || rounds <= 0
114
+ ? Effect.succeed(a)
115
+ : Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
116
+ )
117
+
118
+ test('a seeded scene boots its store from the override', async () => {
119
+ const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
120
+ try {
121
+ expect(await runtime.runPromise(read)).toBe(5)
122
+ } finally {
123
+ await runtime.dispose()
124
+ }
125
+ })
126
+
127
+ test('the live reducer drives the SAME overridden store', async () => {
128
+ const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
129
+ try {
130
+ const next = await runtime.runPromise(
131
+ Effect.gen(function* () {
132
+ expect(yield* read).toBe(5)
133
+ yield* publish('High', Event.construct(Bumped, {}))
134
+ return yield* waitUntil(read, (n) => n === 6)
135
+ }),
136
+ )
137
+ expect(next).toBe(6)
138
+ } finally {
139
+ await runtime.dispose()
140
+ }
141
+ })
142
+
143
+ test('an absent key leaves the authored seed in place', async () => {
144
+ const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { other: 1 }))
145
+ try {
146
+ expect(await runtime.runPromise(read)).toBe(1)
147
+ } finally {
148
+ await runtime.dispose()
149
+ }
150
+ })
151
+
152
+ test('a schema-invalid override falls back to the authored seed', async () => {
153
+ const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { count: 'not-a-number' }))
154
+ try {
155
+ expect(await runtime.runPromise(read)).toBe(1)
156
+ } finally {
157
+ await runtime.dispose()
158
+ }
159
+ })
160
+
161
+ test('seeding does not leak into the original scene', async () => {
162
+ const seeded = makeHarness(seedScene(CounterScene, { count: 5 }))
163
+ try {
164
+ expect(await seeded.runtime.runPromise(seeded.read)).toBe(5)
165
+ } finally {
166
+ await seeded.runtime.dispose()
167
+ }
168
+ // Building the ORIGINAL scene's layers again boots from the authored seed —
169
+ // the overlay lives only on the seeded variant's wrapped layers.
170
+ const original = makeHarness(CounterScene)
171
+ try {
172
+ expect(await original.runtime.runPromise(original.read)).toBe(1)
173
+ } finally {
174
+ await original.runtime.dispose()
175
+ }
176
+ })
@@ -0,0 +1,94 @@
1
+ import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import { resolveScheduler } from '../internal/scheduler'
4
+ import { CurrentSeedOverrides } from '../internal/seeds'
5
+ import { claimStateTag } from '../internal/stateRegistry'
6
+ import { makeStore, type Store } from '../internal/store'
7
+ import { readTracked } from '../internal/track'
8
+
9
+ export interface StateOptions {
10
+ readonly title?: string
11
+ readonly description?: string
12
+ }
13
+
14
+ export interface StateManifest<N extends string, A> extends Manifest {
15
+ readonly kind: 'State'
16
+ readonly name: N
17
+ readonly schema: Schema.Schema<A, any>
18
+ readonly title?: string
19
+ readonly description?: string
20
+ }
21
+
22
+ export interface StateClass<out N extends string, in out A> extends Effect.Effect<A, never, Store<A>> {
23
+ new (): {}
24
+ readonly manifest: StateManifest<N, A>
25
+ /** Internal DI tag holding the live store. The loop reads/writes through it. */
26
+ readonly store: Context.Tag<Store<A>, Store<A>>
27
+ }
28
+
29
+ export type AnyState = StateClass<string, any>
30
+ export type StateValue<S> = S extends StateClass<string, infer A> ? A : never
31
+ export type StateName<S> = S extends StateClass<infer N, any> ? N : never
32
+
33
+ /**
34
+ * Atomic reactive state. `State.make` is the *definition* (a reflectable
35
+ * manifest + an internal store tag); it carries no starting value — the seed is
36
+ * an implementation detail supplied at create-time by `State.live(state, seed)`.
37
+ * Composed into a `StateGroup` exactly as `@effect/rpc` composes rpcs.
38
+ */
39
+ export const make = <const N extends string, A>(
40
+ name: N,
41
+ schema: Schema.Schema<A, any>,
42
+ options: StateOptions = {},
43
+ ): StateClass<N, A> => {
44
+ const identifier = `reform/state/${name}`
45
+ claimStateTag(identifier)
46
+ const store = Context.GenericTag<Store<A>, Store<A>>(identifier)
47
+ const manifest: StateManifest<N, A> = {
48
+ kind: 'State',
49
+ name,
50
+ schema,
51
+ ...(options.title !== undefined ? { title: options.title } : {}),
52
+ ...(options.description !== undefined ? { description: options.description } : {}),
53
+ }
54
+ const read = Effect.flatMap(store, readTracked)
55
+ return yieldableClass(read, { manifest, store })
56
+ }
57
+
58
+ /**
59
+ * Resolve the value a store should boot from: the ambient seed override when
60
+ * one is present (and valid) for this state's member name, else the authored
61
+ * `initial`. Reads `CurrentSeedOverrides` — empty outside the tooling seam
62
+ * (`Scene.seedScene`), so production wiring always boots from `initial`. An
63
+ * override is decoded against the state's schema; an invalid value silently
64
+ * falls back to `initial` — a half-typed override from the dev tool must never
65
+ * crash a preview.
66
+ */
67
+ const resolveSeed = <S extends AnyState>(
68
+ state: S,
69
+ initial: StateValue<S>,
70
+ ): Effect.Effect<StateValue<S>> =>
71
+ Effect.map(FiberRef.get(CurrentSeedOverrides), (overrides) =>
72
+ state.manifest.name in overrides
73
+ ? Option.getOrElse(
74
+ Schema.decodeUnknownOption(state.manifest.schema)(overrides[state.manifest.name]),
75
+ () => initial,
76
+ )
77
+ : initial,
78
+ )
79
+
80
+ /**
81
+ * Allocate a state's store, seeded with `initial` — `State.live(Feed, seed)`.
82
+ * The seed lives here (implementation), not on the definition, so the same
83
+ * definition can boot from different values in app vs. test wiring.
84
+ */
85
+ export const live = <S extends AnyState>(
86
+ state: S,
87
+ initial: StateValue<S>,
88
+ ): Layer.Layer<Store<StateValue<S>>> =>
89
+ Layer.effect(
90
+ state.store,
91
+ Effect.zipWith(resolveScheduler, resolveSeed(state, initial), (scheduler, seed) =>
92
+ makeStore(seed, scheduler),
93
+ ),
94
+ )
@@ -0,0 +1,138 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
3
+ import { Engine, Event, publish, Reducer, StateFamily } from '../index'
4
+
5
+ // A family is normalized keyed state: one logical slice per key, so a write to
6
+ // one key must wake only that key's subscribers (the point of the primitive).
7
+
8
+ const tick = Effect.sleep(Duration.millis(1))
9
+
10
+ it.live("writing one key does not notify another key's subscribers", () => {
11
+ class Items extends StateFamily.make('items', S.String, S.Number) {}
12
+ const TestLayer = StateFamily.live(Items, 0)
13
+
14
+ return Effect.gen(function* () {
15
+ const family = yield* Items.store
16
+ const a = family.at('a')
17
+ const b = family.at('b')
18
+ const aWakes = { n: 0 }
19
+ const bWakes = { n: 0 }
20
+ a.subscribe(() => {
21
+ aWakes.n += 1
22
+ })
23
+ b.subscribe(() => {
24
+ bWakes.n += 1
25
+ })
26
+
27
+ a.set(1)
28
+ yield* tick
29
+ expect(aWakes.n).toBe(1)
30
+ expect(bWakes.n).toBe(0)
31
+ expect(b.get()).toBe(0)
32
+ }).pipe(Effect.provide(TestLayer))
33
+ })
34
+
35
+ it.live('at(key) returns the same store each call, so subscriptions are stable', () => {
36
+ class Items extends StateFamily.make('items', S.String, S.Number) {}
37
+ const TestLayer = StateFamily.live(Items, 0)
38
+
39
+ return Effect.gen(function* () {
40
+ const family = yield* Items.store
41
+ expect(family.at('k')).toBe(family.at('k'))
42
+ }).pipe(Effect.provide(TestLayer))
43
+ })
44
+
45
+ it.live('forget(key) drops the store; re-reading the key reseeds it', () => {
46
+ class Items extends StateFamily.make('items', S.String, S.Number) {}
47
+ const TestLayer = StateFamily.live(Items, 0)
48
+
49
+ return Effect.gen(function* () {
50
+ const family = yield* Items.store
51
+ const first = family.at('k')
52
+ first.set(42)
53
+ expect(family.at('k').get()).toBe(42)
54
+
55
+ family.forget('k')
56
+ // A fresh store, reseeded to the family's initial value — not the old 42.
57
+ const reseeded = family.at('k')
58
+ expect(reseeded).not.toBe(first)
59
+ expect(reseeded.get()).toBe(0)
60
+ }).pipe(Effect.provide(TestLayer))
61
+ })
62
+
63
+ it.live('evictWhenUnused drops a key once its last subscriber leaves', () => {
64
+ class Items extends StateFamily.make('items', S.String, S.Number) {}
65
+ const TestLayer = StateFamily.live(Items, 0, { evictWhenUnused: true })
66
+
67
+ return Effect.gen(function* () {
68
+ const family = yield* Items.store
69
+ const store = family.at('k')
70
+ const offA = store.subscribe(() => {})
71
+ const offB = store.subscribe(() => {})
72
+ expect(family.size()).toBe(1)
73
+
74
+ // One subscriber leaving keeps the key (another still reads it).
75
+ offA()
76
+ yield* tick
77
+ expect(family.size()).toBe(1)
78
+
79
+ // The last subscriber leaving evicts the key on the next microtask.
80
+ offB()
81
+ yield* tick
82
+ expect(family.size()).toBe(0)
83
+ // Re-reading reseeds a fresh store.
84
+ expect(family.at('k').get()).toBe(0)
85
+ }).pipe(Effect.provide(TestLayer))
86
+ })
87
+
88
+ it.live('evictWhenUnused: a same-tick re-subscribe cancels eviction', () => {
89
+ class Items extends StateFamily.make('items', S.String, S.Number) {}
90
+ const TestLayer = StateFamily.live(Items, 0, { evictWhenUnused: true })
91
+
92
+ return Effect.gen(function* () {
93
+ const family = yield* Items.store
94
+ const store = family.at('k')
95
+ store.set(7)
96
+ const off = store.subscribe(() => {})
97
+ off() // count → 0, eviction scheduled for the next microtask
98
+ store.subscribe(() => {}) // re-subscribe in the same tick → count back to 1
99
+ yield* tick
100
+ // Not evicted: the value survives and `at` returns the same live store.
101
+ expect(family.size()).toBe(1)
102
+ expect(family.at('k').get()).toBe(7)
103
+ }).pipe(Effect.provide(TestLayer))
104
+ })
105
+
106
+ it.live('a family reducer fold returning Tombstone evicts the key', () => {
107
+ class Items extends StateFamily.make('items', S.String, S.Number) {}
108
+ class Bumped extends Event.make('Bumped', S.Struct({ id: S.String })) {}
109
+ class Removed extends Event.make('Removed', S.Struct({ id: S.String })) {}
110
+ class ItemsReducer extends Reducer.make('ItemsReducer', {
111
+ family: Items,
112
+ keyOf: (event) => event.id,
113
+ events: [Bumped, Removed],
114
+ }) {}
115
+ const ItemsLive = Reducer.live(ItemsReducer, (n, event) =>
116
+ event._tag === 'Removed' ? StateFamily.Tombstone : n + 1,
117
+ )
118
+
119
+ const TestLayer = ItemsLive.pipe(
120
+ Layer.provideMerge(Layer.mergeAll(StateFamily.live(Items, 0), Engine)),
121
+ )
122
+
123
+ return Effect.gen(function* () {
124
+ const family = yield* Items.store
125
+
126
+ yield* publish('High', Event.construct(Bumped, { id: 'a' }))
127
+ yield* Effect.sleep(Duration.millis(5))
128
+ const before = family.at('a')
129
+ expect(before.get()).toBe(1)
130
+
131
+ // The fold returns the eviction sentinel: the key's store is dropped.
132
+ yield* publish('High', Event.construct(Removed, { id: 'a' }))
133
+ yield* Effect.sleep(Duration.millis(5))
134
+ const after = family.at('a')
135
+ expect(after).not.toBe(before)
136
+ expect(after.get()).toBe(0)
137
+ }).pipe(Effect.provide(TestLayer))
138
+ })
@@ -0,0 +1,174 @@
1
+ import { Context, Effect, Layer, type Schema } from 'effect'
2
+ import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { resolveScheduler, type Scheduler } from '../internal/scheduler'
4
+ import { claimStateTag } from '../internal/stateRegistry'
5
+ import { makeStore, type Store } from '../internal/store'
6
+ import { readTracked } from '../internal/track'
7
+
8
+ /**
9
+ * A reducer fold returns this to evict the key it folded over, instead of a new
10
+ * value. The loop drops the entry's store from the family, so a normalized
11
+ * collection (chat messages, search hits, transient rows) does not grow without
12
+ * bound — the one fix for the otherwise-unbounded per-key `Map`.
13
+ */
14
+ export const Tombstone: unique symbol = Symbol.for('reform/StateFamily/Tombstone')
15
+ export type Tombstone = typeof Tombstone
16
+
17
+ /** Per-key stores, created lazily so a write to one key wakes only its subscribers. */
18
+ export interface FamilyStore<K, V> {
19
+ at(key: K): Store<V>
20
+ /**
21
+ * Drop a key's store so it stops occupying memory. A live subscriber keeps its
22
+ * own closure over the previous store, so eviction is for keys whose entity is
23
+ * gone (the component is unmounting) — re-`at`-ing the key allocates a fresh,
24
+ * reseeded store.
25
+ */
26
+ forget(key: K): void
27
+ /** Drop every key's store — a wholesale reset of the family. */
28
+ clear(): void
29
+ /** Number of live keys — for tests/diagnostics. */
30
+ size(): number
31
+ }
32
+
33
+ /** Options for a family's keyed store. */
34
+ export interface FamilyOptions {
35
+ /**
36
+ * Drop a key's store automatically once it has no live subscribers (checked on
37
+ * the next microtask, so a same-commit re-subscribe — e.g. a key that just moved
38
+ * position in a list — cancels the eviction). This bounds an otherwise-unbounded
39
+ * family without explicit `Tombstone`s, but it also DISCARDS the key's value —
40
+ * re-`at`-ing a dropped key reseeds from `initial`. Use only for keys whose value
41
+ * is disposable when nothing renders them (search hits, transient rows); for a
42
+ * source-of-truth entity that may be temporarily off-screen (a virtualized list),
43
+ * keep this off and reclaim with an explicit reducer `Tombstone`. Default: off.
44
+ */
45
+ readonly evictWhenUnused?: boolean
46
+ }
47
+
48
+ const makeFamilyStore = <K, V>(
49
+ seed: (key: K) => V,
50
+ scheduler: Scheduler,
51
+ options: FamilyOptions = {},
52
+ ): FamilyStore<K, V> => {
53
+ const entries = new Map<K, Store<V>>()
54
+ const evictWhenUnused = options.evictWhenUnused === true
55
+ // Live subscriber count per key — maintained only when eviction is on.
56
+ const subscribers = new Map<K, number>()
57
+
58
+ // Wrap a store's `subscribe` to ref-count, evicting the key when it falls idle.
59
+ // `get`/`set`/`getVersion` are delegated unchanged, so the loop writes and the
60
+ // host reads see the same cell; only subscribe/unsubscribe are instrumented.
61
+ const refCounted = (key: K, store: Store<V>): Store<V> => ({
62
+ ...store,
63
+ subscribe: (listener) => {
64
+ subscribers.set(key, (subscribers.get(key) ?? 0) + 1)
65
+ const off = store.subscribe(listener)
66
+ const released = { done: false }
67
+ return () => {
68
+ if (released.done) return
69
+ released.done = true
70
+ off()
71
+ const remaining = (subscribers.get(key) ?? 1) - 1
72
+ if (remaining > 0) {
73
+ subscribers.set(key, remaining)
74
+ return
75
+ }
76
+ subscribers.delete(key)
77
+ queueMicrotask(() => {
78
+ if ((subscribers.get(key) ?? 0) === 0) entries.delete(key)
79
+ })
80
+ }
81
+ },
82
+ })
83
+
84
+ return {
85
+ at: (key) => {
86
+ const existing = entries.get(key)
87
+ if (existing !== undefined) return existing
88
+ const base = makeStore(seed(key), scheduler)
89
+ const created = evictWhenUnused ? refCounted(key, base) : base
90
+ entries.set(key, created)
91
+ return created
92
+ },
93
+ forget: (key) => {
94
+ entries.delete(key)
95
+ subscribers.delete(key)
96
+ },
97
+ clear: () => {
98
+ entries.clear()
99
+ subscribers.clear()
100
+ },
101
+ size: () => entries.size,
102
+ }
103
+ }
104
+
105
+ export interface StateFamilyManifest<N extends string, K, V> extends Manifest {
106
+ readonly kind: 'StateFamily'
107
+ readonly name: N
108
+ readonly key: Schema.Schema<K, any>
109
+ readonly value: Schema.Schema<V, any>
110
+ readonly title?: string
111
+ readonly description?: string
112
+ }
113
+
114
+ export interface StateFamilyClass<out N extends string, in out K, in out V> {
115
+ /** Instance carries the key phantom so `Family['Key']` resolves in `keyOf` types. */
116
+ new (): { readonly Key: K }
117
+ readonly manifest: StateFamilyManifest<N, K, V>
118
+ readonly store: Context.Tag<FamilyStore<K, V>, FamilyStore<K, V>>
119
+ }
120
+
121
+ export type AnyFamily = StateFamilyClass<string, any, any>
122
+ export type FamilyKey<F> = F extends StateFamilyClass<any, infer K, any> ? K : never
123
+ export type FamilyValue<F> = F extends StateFamilyClass<any, any, infer V> ? V : never
124
+
125
+ /**
126
+ * Normalized keyed state: one logical State per key, backed by a single family
127
+ * store. Scales with the collection without re-rendering unrelated entries.
128
+ */
129
+ export const make = <const N extends string, K, V>(
130
+ name: N,
131
+ key: Schema.Schema<K, any>,
132
+ value: Schema.Schema<V, any>,
133
+ options: { readonly title?: string; readonly description?: string } = {},
134
+ ): StateFamilyClass<N, K, V> => {
135
+ const identifier = `reform/family/${name}`
136
+ claimStateTag(identifier)
137
+ const store = Context.GenericTag<FamilyStore<K, V>, FamilyStore<K, V>>(identifier)
138
+ const manifest: StateFamilyManifest<N, K, V> = {
139
+ kind: 'StateFamily',
140
+ name,
141
+ key,
142
+ value,
143
+ ...(options.title !== undefined ? { title: options.title } : {}),
144
+ ...(options.description !== undefined ? { description: options.description } : {}),
145
+ }
146
+ return definitionClass<StateFamilyClass<N, K, V>>({ manifest, store })
147
+ }
148
+
149
+ /**
150
+ * Read one entry by key — `StateFamily.read(ItemUi, id)`. Inside a render it
151
+ * subscribes that key's slice; elsewhere it just snapshots (D5).
152
+ */
153
+ export const read = <N extends string, K, V>(
154
+ family: StateFamilyClass<N, K, V>,
155
+ key: K,
156
+ ): Effect.Effect<V, never, FamilyStore<K, V>> =>
157
+ Effect.flatMap(family.store, (fs) => readTracked(fs.at(key)))
158
+
159
+ /**
160
+ * Allocate a family's keyed store — `StateFamily.live(ItemUi, seed)`. The seed
161
+ * is a plain value or a factory; a factory receives the key, so a new entry can
162
+ * derive its starting value from its own id (`(id) => ({ ...blank, id }))`).
163
+ */
164
+ export const live = <N extends string, K, V>(
165
+ family: StateFamilyClass<N, K, V>,
166
+ initial: V | ((key: K) => V),
167
+ options: FamilyOptions = {},
168
+ ): Layer.Layer<FamilyStore<K, V>> => {
169
+ const seed: (key: K) => V = typeof initial === 'function' ? (initial as (key: K) => V) : () => initial
170
+ return Layer.effect(
171
+ family.store,
172
+ Effect.map(resolveScheduler, (scheduler) => makeFamilyStore<K, V>(seed, scheduler, options)),
173
+ )
174
+ }