@playfast/reform 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,7 +1,14 @@
1
- import { Layer, Struct } from 'effect'
2
- import type { CompositionClass, CompositionService } from '../compose/composition'
1
+ import { Layer, Predicate, Struct } from 'effect'
2
+ import type {
3
+ AnyComposition,
4
+ CompositionClass,
5
+ CompositionId,
6
+ CompositionReflection,
7
+ HasExactCompositionId,
8
+ } from '../compose/composition'
3
9
  import type { UiContract } from '../compose/ui'
4
10
  import type { EventOf } from '../event/event'
11
+ import { type EagerFeatureClass, type FeatureRequirement, tree } from '../feature/feature'
5
12
  import { CurrentSeedOverrides } from '../internal/seeds'
6
13
  import type { SeedsOf } from '../state/stateGroup'
7
14
  import type { Bus } from '../runtime/bus'
@@ -10,121 +17,257 @@ import {
10
17
  type Instrumentation,
11
18
  noopInstrumentation,
12
19
  } from '../runtime/instrumentation'
20
+ import type { EngineServices } from '../runtime/loop'
13
21
 
14
- // A scene is a VALUE — a composition plus the closed wiring that runs it: the
15
- // layers that supply its logic/views (each seeding its own live state) and the
16
- // boot events to dispatch once the runtime is live. Nothing self-registers on
17
- // import. A scene is the single handle every consumer takes instead of a bare
18
- // composition: the react host renders it, a proof runs against it, and the dev
19
- // tool previews it — all from the same closed `provide`.
20
- //
21
- // There is no seeded-`state` dict: variation (Default/Empty/Errored) comes from
22
- // providing different closed layers, exactly as production wiring does. The
23
- // composition's contract `C` is preserved (not erased to `unknown`) so a proof's
24
- // facade stays fully typed from `scene.composition`.
25
-
26
- /**
27
- * The services a built reform runtime exposes to its host: every composition's
28
- * logic and the event `Bus`. A scene's `provide` must close to these (a fully
29
- * wired app layer provides far more; this is the subset hosts read directly).
30
- */
31
- export type MountedServices = CompositionService | Bus
32
-
33
- // A scene's boot list holds CONSTRUCTED events (`Event.construct(E, payload)`),
34
- // not bare tagged objects — a lightweight nudge toward the real builder. It is
35
- // not a guarantee the event is handled by the wiring (reform carries no
36
- // type-level union of handled tags), only that it came from an event definition.
37
- type BootEvent = EventOf<string, unknown>
22
+ export type MountedServices = CompositionId<string> | Bus
23
+
24
+ export type SceneCapture<C extends UiContract, S extends ReadonlyArray<unknown>, Result> = <
25
+ Services,
26
+ P,
27
+ N extends string,
28
+ Identity,
29
+ >(
30
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
31
+ ) => Result
32
+
33
+ export type AnySceneCapture<Result> = <
34
+ C extends UiContract,
35
+ S extends ReadonlyArray<unknown>,
36
+ Services,
37
+ P,
38
+ N extends string,
39
+ Identity,
40
+ >(
41
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
42
+ ) => Result
43
+
44
+ interface SceneOptionalFields {
45
+ readonly boot: ReadonlyArray<BootEvent>
46
+ // Hosts record render spans outside the scene's layer context.
47
+ readonly instrumentation: Instrumentation
48
+ }
49
+
50
+ export interface AnySceneExternalApi extends Partial<SceneOptionalFields> {
51
+ readonly kind: 'Scene'
52
+ readonly composition: AnyComposition
53
+ readonly captureAny: <Result>(visit: AnySceneCapture<Result>) => Result
54
+ }
55
+
56
+ export type AnyScene = AnySceneExternalApi
38
57
 
39
58
  export interface Scene<
40
59
  C extends UiContract = UiContract,
41
60
  S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
42
- > {
61
+ > extends AnySceneExternalApi {
62
+ readonly composition: AnyComposition & CompositionReflection<unknown, C, S>
63
+ readonly capture: <Result>(visit: SceneCapture<C, S, Result>) => Result
64
+ }
65
+
66
+ type BootEvent = EventOf<string, unknown>
67
+
68
+ export interface CapturedScene<
69
+ C extends UiContract,
70
+ S extends ReadonlyArray<unknown>,
71
+ Services,
72
+ P,
73
+ N extends string,
74
+ Identity = unknown,
75
+ > extends Scene<C, S> {
43
76
  readonly kind: 'Scene'
44
- /** The composition to run, with its contract + states preserved for typed consumers. */
45
- readonly composition: CompositionClass<unknown, C, S>
46
- /** Closed wiring (logic + views), each layer seeding its own live state. */
47
- readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
48
- /** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
49
- // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional boot read as `scene.boot ?? []` across non-batch hosts (drive/proof/remote/react/editor); Option would break them
50
- readonly boot?: ReadonlyArray<BootEvent>
51
- /**
52
- * The profiler hooks this scene records into, when profiled (`profiledScene`).
53
- * Hosts read it via {@link sceneInstrumentation} to time renders — kept on the
54
- * scene (not only inside the layers) because render spans live in the HOST
55
- * (React `Compose`, the proof engine), outside the runtime's layer context.
56
- */
57
- // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional carrier like `boot`; absent on every unprofiled scene, so Option would tax all authoring call sites
58
- readonly instrumentation?: Instrumentation
77
+ readonly composition: CompositionClass<P, C, S, N, Identity>
78
+ readonly provide: ReadonlyArray<Layer.Layer<Services, never, never>>
79
+ }
80
+
81
+ interface SceneConfigOptionalFields {
82
+ readonly boot: ReadonlyArray<BootEvent>
83
+ }
84
+
85
+ export interface SceneConfig<Services> extends Partial<SceneConfigOptionalFields> {
86
+ readonly provide: ReadonlyArray<Layer.Layer<Services, never, never>>
87
+ }
88
+
89
+ type SceneConfigIncludingRootComposition<Services, N extends string, Identity> =
90
+ HasExactCompositionId<Services, N, Identity> extends true
91
+ ? SceneConfig<Services>
92
+ : SceneConfig<Services> & {
93
+ readonly missingCompositionService: 'The scene must provide its root composition'
94
+ }
95
+
96
+ interface SceneValueConfigExternalApi<Services>
97
+ extends SceneConfig<Services>, Partial<Pick<SceneOptionalFields, 'instrumentation'>> {}
98
+
99
+ const makeSceneValue = <
100
+ P,
101
+ C extends UiContract,
102
+ S extends ReadonlyArray<unknown>,
103
+ N extends string,
104
+ Identity,
105
+ Services,
106
+ >(
107
+ composition: CompositionClass<P, C, S, N, Identity>,
108
+ config: SceneValueConfigExternalApi<Services>,
109
+ ): CapturedScene<C, S, Services, P, N, Identity> => {
110
+ const sceneValue: CapturedScene<C, S, Services, P, N, Identity> = {
111
+ kind: 'Scene',
112
+ composition,
113
+ provide: config.provide,
114
+ ...(config.boot !== undefined ? { boot: config.boot } : {}),
115
+ ...(config.instrumentation !== undefined ? { instrumentation: config.instrumentation } : {}),
116
+ capture: <Result>(visit: SceneCapture<C, S, Result>): Result => visit(sceneValue),
117
+ captureAny: <Result>(visit: AnySceneCapture<Result>): Result => visit(sceneValue),
118
+ }
119
+ return sceneValue
120
+ }
121
+
122
+ export const scene = <
123
+ P,
124
+ C extends UiContract,
125
+ S extends ReadonlyArray<unknown>,
126
+ N extends string,
127
+ Identity,
128
+ Services,
129
+ >(
130
+ composition: CompositionClass<P, C, S, N, Identity>,
131
+ config: SceneConfigIncludingRootComposition<Services, N, Identity>,
132
+ ): CapturedScene<C, S, Services, P, N, Identity> => {
133
+ return makeSceneValue(composition, config)
134
+ }
135
+
136
+ export interface FeatureSceneConfig<OpenServices> {
137
+ readonly provide: Layer.Layer<EngineServices | OpenServices, never, never>
59
138
  }
60
139
 
61
- /** Closed wiring + optional boot events handed to {@link scene}. */
62
- export interface SceneConfig {
63
- readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
64
- // oxlint-disable-next-line reform-rules/no-optional-fields -- mirrors Scene.boot; presence-optional to keep the authoring call site terse and back-compatible
65
- readonly boot?: ReadonlyArray<BootEvent>
140
+ export const featureScene = <
141
+ FeatureIdentity,
142
+ P,
143
+ C extends UiContract,
144
+ ROut,
145
+ DirectRequires extends ReadonlyArray<FeatureRequirement>,
146
+ OpenServices,
147
+ S extends ReadonlyArray<unknown>,
148
+ N extends string,
149
+ Identity,
150
+ >(
151
+ feature: EagerFeatureClass<
152
+ FeatureIdentity,
153
+ P,
154
+ C,
155
+ ROut,
156
+ DirectRequires,
157
+ OpenServices,
158
+ S,
159
+ N,
160
+ Identity
161
+ >,
162
+ config: FeatureSceneConfig<OpenServices>,
163
+ ): CapturedScene<C, S, ROut | EngineServices | OpenServices, P, N, Identity> => {
164
+ const native = tree<typeof feature>(feature.token)
165
+ const app = native.eagerModule.layer.pipe(Layer.provideMerge(config.provide))
166
+ return makeSceneValue(native.composition, {
167
+ provide: [app],
168
+ boot: native.binding.boot,
169
+ })
66
170
  }
67
171
 
68
- /**
69
- * Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
70
- * The composition's contract `C` and state tuple `S` flow through, so consumers
71
- * (the facade, `seedScene`) stay typed.
72
- */
73
- export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
74
- composition: CompositionClass<unknown, C, S>,
75
- config: SceneConfig,
76
- ): Scene<C, S> => ({
77
- kind: 'Scene',
78
- composition,
79
- provide: config.provide,
80
- ...(config.boot !== undefined ? { boot: config.boot } : {}),
81
- })
82
-
83
- /**
84
- * Overlay seed values onto a CLOSED scene — the tooling/test seam (dev-tool
85
- * inspector overrides, proofs). Scenes stay closed for authors: this does not
86
- * reopen `provide`, it wraps each layer with `Layer.locally(CurrentSeedOverrides,
87
- * seeds)` so `State.live` boots matching stores from the override instead of
88
- * the authored seed.
89
- *
90
- * Keys are state MEMBER NAMES as reflected in `manifest.states` (e.g. `count`),
91
- * not group names. If two groups in one scene share a member name, both receive
92
- * the override (documented limitation). Values are schema-validated at build:
93
- * an invalid value silently falls back to the authored seed. `StateFamily`
94
- * entries are not covered.
95
- */
96
- export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
172
+ export function seedScene<
173
+ C extends UiContract,
174
+ S extends ReadonlyArray<unknown>,
175
+ Services,
176
+ P,
177
+ N extends string,
178
+ Identity,
179
+ >(
180
+ base: CapturedScene<C, S, Services, P, N, Identity>,
181
+ seeds: SeedsOf<S>,
182
+ ): CapturedScene<C, S, Services, P, N, Identity>
183
+ export function seedScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
97
184
  base: Scene<C, S>,
98
185
  seeds: SeedsOf<S>,
99
- ): Scene<C, S> =>
100
- Struct.keys(seeds).length === 0
101
- ? base
102
- : { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
103
-
104
- /**
105
- * Overlay profiler hooks onto a CLOSED scene — the profiling analog of
106
- * {@link seedScene}. Wraps each provided layer with
107
- * `Layer.locally(CurrentInstrumentation, instr)` so engine/state/calc layers
108
- * capture `instr` at build time, and carries `instr` on the scene so hosts can
109
- * time renders. Scenes stay closed; production wiring never calls this —
110
- * `@playfast/reform-profiler`'s `profileScene` is the intended caller.
111
- */
112
- export const profiledScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
186
+ ): Scene<C, S>
187
+ export function seedScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
113
188
  base: Scene<C, S>,
189
+ seeds: SeedsOf<S>,
190
+ ): Scene<C, S> {
191
+ if (Struct.keys(seeds).length === 0) {
192
+ return base
193
+ }
194
+ // Tooling overlays closed layers; member names, not group names, are the keys.
195
+ return base.capture<Scene<C, S>>(
196
+ (exactScene): Scene<C, S> =>
197
+ makeSceneValue(exactScene.composition, {
198
+ provide: exactScene.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
199
+ ...(exactScene.boot === undefined ? {} : { boot: exactScene.boot }),
200
+ ...(exactScene.instrumentation === undefined
201
+ ? {}
202
+ : { instrumentation: exactScene.instrumentation }),
203
+ }),
204
+ )
205
+ }
206
+
207
+ export const seedAnyScene = (
208
+ base: AnyScene,
209
+ seeds: Readonly<Record<string, unknown>>,
210
+ ): AnyScene => {
211
+ if (Struct.keys(seeds).length === 0) {
212
+ return base
213
+ }
214
+ return base.captureAny<AnyScene>(
215
+ (exactScene): AnyScene =>
216
+ makeSceneValue(exactScene.composition, {
217
+ provide: exactScene.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
218
+ ...(exactScene.boot === undefined ? {} : { boot: exactScene.boot }),
219
+ ...(exactScene.instrumentation === undefined
220
+ ? {}
221
+ : { instrumentation: exactScene.instrumentation }),
222
+ }),
223
+ )
224
+ }
225
+
226
+ export function profiledScene<
227
+ C extends UiContract,
228
+ S extends ReadonlyArray<unknown>,
229
+ Services,
230
+ P,
231
+ N extends string,
232
+ Identity,
233
+ >(
234
+ base: CapturedScene<C, S, Services, P, N, Identity>,
114
235
  instrumentation: Instrumentation,
115
- ): Scene<C, S> => ({
116
- ...base,
117
- instrumentation,
118
- provide: base.provide.map(Layer.locally(CurrentInstrumentation, instrumentation)),
119
- })
120
-
121
- /** The instrumentation a host records render spans into — noop when unprofiled. */
122
- export const sceneInstrumentation = (candidate: Scene): Instrumentation =>
236
+ ): CapturedScene<C, S, Services, P, N, Identity>
237
+ export function profiledScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
238
+ base: Scene<C, S>,
239
+ instrumentation: Instrumentation,
240
+ ): Scene<C, S>
241
+ export function profiledScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
242
+ base: Scene<C, S>,
243
+ instrumentation: Instrumentation,
244
+ ): Scene<C, S> {
245
+ return base.capture<Scene<C, S>>(
246
+ (exactScene): Scene<C, S> =>
247
+ makeSceneValue(exactScene.composition, {
248
+ provide: exactScene.provide.map(Layer.locally(CurrentInstrumentation, instrumentation)),
249
+ ...(exactScene.boot === undefined ? {} : { boot: exactScene.boot }),
250
+ instrumentation,
251
+ }),
252
+ )
253
+ }
254
+
255
+ export const sceneInstrumentation = (candidate: AnyScene): Instrumentation =>
123
256
  candidate.instrumentation ?? noopInstrumentation
124
257
 
125
- /** Reflection guard: is this exported value a scene? */
126
- export const isScene = (candidate: unknown): candidate is Scene =>
127
- typeof candidate === 'object' &&
128
- candidate !== null &&
129
- 'kind' in candidate &&
130
- candidate.kind === 'Scene'
258
+ const isReflectableComposition = (candidate: unknown): candidate is AnyComposition =>
259
+ typeof candidate === 'function' &&
260
+ Predicate.hasProperty(candidate, 'manifest') &&
261
+ Predicate.isRecord(candidate.manifest) &&
262
+ candidate.manifest['kind'] === 'Composition' &&
263
+ Predicate.hasProperty(candidate, 'parseProps') &&
264
+ typeof candidate.parseProps === 'function' &&
265
+ Predicate.hasProperty(candidate, 'capture') &&
266
+ typeof candidate.capture === 'function'
267
+
268
+ export const isScene = (candidate: unknown): candidate is AnyScene =>
269
+ Predicate.isRecord(candidate) &&
270
+ candidate['kind'] === 'Scene' &&
271
+ isReflectableComposition(candidate['composition']) &&
272
+ typeof candidate['capture'] === 'function' &&
273
+ typeof candidate['captureAny'] === 'function'
@@ -1,5 +1,5 @@
1
- import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
2
- import { expect, test } from 'vitest'
1
+ import { Effect, Layer, Schema as S } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
3
  import {
4
4
  Composition,
5
5
  Engine,
@@ -10,7 +10,6 @@ import {
10
10
  publish,
11
11
  Reducer,
12
12
  type RenderEnv,
13
- type Scene,
14
13
  scene,
15
14
  seedScene,
16
15
  State,
@@ -20,13 +19,6 @@ import {
20
19
  } from '../index'
21
20
  import { CurrentSeedOverrides } from '../internal/seeds'
22
21
 
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
22
  class CountState extends State.make('count', S.Number) {}
31
23
  class MiniStates extends StateGroup.make(CountState) {}
32
24
 
@@ -44,15 +36,16 @@ class Counter extends Composition.make('Counter', {
44
36
  states: [MiniStates],
45
37
  events: [Bumped],
46
38
  ui: CounterUi,
47
- }) {}
39
+ })<Counter>() {}
48
40
  const CounterLive = Composition.live(Counter, function* () {
49
41
  const count = yield* StateGroup.select(MiniStates, 'count')
50
42
  return mount({ props: { count }, slots: {} })
51
43
  })
52
44
 
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))
45
+ const presentations = provide(
46
+ CounterUi,
47
+ Ui.make(CounterUi, () => null),
48
+ )
56
49
  const Views = CounterLive.pipe(Layer.provideMerge(presentations))
57
50
  const Logic = Layer.mergeAll(BumpReducerLive).pipe(
58
51
  Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 1 }))),
@@ -61,12 +54,10 @@ const MiniApp = Views.pipe(Layer.provideMerge(Logic))
61
54
 
62
55
  const CounterScene = scene(Counter, { provide: [MiniApp] })
63
56
 
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 => ({
57
+ const overlayRawSeeds = (
58
+ base: typeof CounterScene,
59
+ seeds: Readonly<Record<string, unknown>>,
60
+ ): typeof CounterScene => ({
70
61
  ...base,
71
62
  provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
72
63
  })
@@ -77,17 +68,10 @@ const headlessEnv: RenderEnv = {
77
68
  }
78
69
 
79
70
  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) => {
71
+ typeof props === 'object' && props !== null && 'count' in props && typeof props.count === 'number'
72
+
73
+ const makeHarness = (s: typeof CounterScene) => {
89
74
  const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
90
- const runtime = ManagedRuntime.make(layer)
91
75
  const read = Effect.flatMap(Counter.tag, (c) =>
92
76
  Composition.render(c, headlessEnv).pipe(
93
77
  Effect.flatMap((frame) => {
@@ -100,10 +84,9 @@ const makeHarness = (s: Scene) => {
100
84
  }),
101
85
  ),
102
86
  )
103
- return { runtime, read }
87
+ return { layer, read }
104
88
  }
105
89
 
106
- // Drive a read until a predicate holds, handing forked fibers the scheduler.
107
90
  const waitUntil = <A, R>(
108
91
  read: Effect.Effect<A, never, R>,
109
92
  pred: (a: A) => boolean,
@@ -115,62 +98,42 @@ const waitUntil = <A, R>(
115
98
  : Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
116
99
  )
117
100
 
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
- }
101
+ it.scopedLive('a seeded scene boots its store from the override', () => {
102
+ const { layer, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
103
+ return Effect.gen(function* () {
104
+ expect(yield* read).toBe(5)
105
+ }).pipe(Effect.provide(layer))
125
106
  })
126
107
 
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
- )
108
+ it.scopedLive('the live reducer drives the SAME overridden store', () => {
109
+ const { layer, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
110
+ return Effect.gen(function* () {
111
+ expect(yield* read).toBe(5)
112
+ yield* publish('High', Event.construct(Bumped, {}))
113
+ const next = yield* waitUntil(read, (n) => n === 6)
137
114
  expect(next).toBe(6)
138
- } finally {
139
- await runtime.dispose()
140
- }
115
+ }).pipe(Effect.provide(layer))
141
116
  })
142
117
 
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
- }
118
+ it.scopedLive('an absent key leaves the authored seed in place', () => {
119
+ const { layer, read } = makeHarness(overlayRawSeeds(CounterScene, { other: 1 }))
120
+ return Effect.gen(function* () {
121
+ expect(yield* read).toBe(1)
122
+ }).pipe(Effect.provide(layer))
150
123
  })
151
124
 
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
- }
125
+ it.scopedLive('a schema-invalid override falls back to the authored seed', () => {
126
+ const { layer, read } = makeHarness(overlayRawSeeds(CounterScene, { count: 'not-a-number' }))
127
+ return Effect.gen(function* () {
128
+ expect(yield* read).toBe(1)
129
+ }).pipe(Effect.provide(layer))
159
130
  })
160
131
 
161
- test('seeding does not leak into the original scene', async () => {
132
+ it.scopedLive('seeding does not leak into the original scene', () => {
162
133
  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
134
  const original = makeHarness(CounterScene)
171
- try {
172
- expect(await original.runtime.runPromise(original.read)).toBe(1)
173
- } finally {
174
- await original.runtime.dispose()
175
- }
135
+ return Effect.gen(function* () {
136
+ expect(yield* seeded.read.pipe(Effect.provide(seeded.layer))).toBe(5)
137
+ expect(yield* original.read.pipe(Effect.provide(original.layer))).toBe(1)
138
+ })
176
139
  })
@@ -0,0 +1,68 @@
1
+ import { Layer, Schema as S } from 'effect'
2
+ import type { ProvidedBy } from '../feature/feature'
3
+ import * as State from './state'
4
+ import * as StateFamily from './stateFamily'
5
+
6
+ type Equal<Left, Right> =
7
+ (<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2
8
+ ? true
9
+ : false
10
+ type Expect<Value extends true> = Value
11
+
12
+ class FirstState extends State.make('nominal.first', S.Number) {}
13
+ class SecondState extends State.make('nominal.second', S.Number) {}
14
+ class FirstFamily extends StateFamily.make('nominal.firstFamily', S.String, S.Number) {}
15
+ class SecondFamily extends StateFamily.make('nominal.secondFamily', S.String, S.Number) {}
16
+
17
+ const FirstStateLive = State.live(FirstState, 0)
18
+ const SecondStateLive = State.live(SecondState, 0)
19
+ const FirstFamilyLive = StateFamily.live(FirstFamily, 0)
20
+ const SecondFamilyLive = StateFamily.live(SecondFamily, 0)
21
+
22
+ type StateLiveKeepsDefinitionIdentity = Expect<
23
+ Equal<Layer.Layer.Success<typeof FirstStateLive>, State.StateStore<'nominal.first', number>>
24
+ >
25
+ type FamilyLiveKeepsDefinitionIdentity = Expect<
26
+ Equal<
27
+ Layer.Layer.Success<typeof FirstFamilyLive>,
28
+ StateFamily.StateFamilyStore<'nominal.firstFamily', string, number>
29
+ >
30
+ >
31
+ type WrongStateDoesNotCloseRequirement = Expect<
32
+ Equal<
33
+ Exclude<State.StateStore<'nominal.first', number>, Layer.Layer.Success<typeof SecondStateLive>>,
34
+ State.StateStore<'nominal.first', number>
35
+ >
36
+ >
37
+ type WrongFamilyDoesNotCloseRequirement = Expect<
38
+ Equal<
39
+ Exclude<
40
+ StateFamily.StateFamilyStore<'nominal.firstFamily', string, number>,
41
+ Layer.Layer.Success<typeof SecondFamilyLive>
42
+ >,
43
+ StateFamily.StateFamilyStore<'nominal.firstFamily', string, number>
44
+ >
45
+ >
46
+ type ProvidedByKeepsStateIdentity = Expect<
47
+ Equal<ProvidedBy<readonly [typeof FirstState]>, State.StateStore<'nominal.first', number>>
48
+ >
49
+ type ProvidedByKeepsFamilyIdentity = Expect<
50
+ Equal<
51
+ ProvidedBy<readonly [typeof FirstFamily]>,
52
+ StateFamily.StateFamilyStore<'nominal.firstFamily', string, number>
53
+ >
54
+ >
55
+
56
+ type StateIdentityProofs = [
57
+ StateLiveKeepsDefinitionIdentity,
58
+ FamilyLiveKeepsDefinitionIdentity,
59
+ WrongStateDoesNotCloseRequirement,
60
+ WrongFamilyDoesNotCloseRequirement,
61
+ ProvidedByKeepsStateIdentity,
62
+ ProvidedByKeepsFamilyIdentity,
63
+ ]
64
+ declare const stateIdentityProofs: StateIdentityProofs
65
+
66
+ void stateIdentityProofs
67
+
68
+ export {}
@@ -0,0 +1,17 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Layer, Schema as S } from 'effect'
3
+ import { State } from '../index'
4
+
5
+ it.live('same-shaped states resolve their own stores', () => {
6
+ class First extends State.make('state.nominal.first', S.Number) {}
7
+ class Second extends State.make('state.nominal.second', S.Number) {}
8
+ const Live = Layer.mergeAll(State.live(First, 1), State.live(Second, 2))
9
+
10
+ return Effect.gen(function* () {
11
+ const first = yield* First
12
+ const second = yield* Second
13
+
14
+ expect(first).toBe(1)
15
+ expect(second).toBe(2)
16
+ }).pipe(Effect.provide(Live))
17
+ })