@playfast/reform 1.0.1 → 1.1.1

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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  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 +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,4 +1,4 @@
1
- import { Array as Arr, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
1
+ import { Array as Arr, Cause, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
2
2
  import {
3
3
  Channels,
4
4
  channelsLayer,
@@ -7,37 +7,20 @@ import {
7
7
  } from '../channel/channel'
8
8
  import { notificationsLayer } from '../internal/scheduler'
9
9
  import { Bus, busLayer, type Envelope, type Tagged } from './bus'
10
+ import { enforceBudget, makeTickCounter, resolveCurrentEventBudget } from './eventBudget'
10
11
  import { type Instrumentation, resolveInstrumentation } from './instrumentation'
11
12
  import { Queries, queriesLayer } from './queries'
12
13
 
13
- /**
14
- * A registered reducer, with its target store(s) already captured at
15
- * registration. `apply` is a pure synchronous write, so the loop needs no
16
- * state services in its own context — it just routes events to writers.
17
- */
18
14
  export interface ReducerEntry {
19
- /** The reducer's name — used only to warn on duplicate registration. */
20
15
  readonly name: string
21
16
  readonly handles: ReadonlySet<string>
22
17
  readonly apply: (event: Tagged) => void
23
18
  }
24
19
 
25
- /**
26
- * Mutable collector the central loop drains; reducer `.live` layers register
27
- * here. `byTag` indexes entries by the event tags they handle, so dispatch is
28
- * O(matching reducers) per event rather than O(all reducers) — the difference
29
- * that matters once an app has hundreds of reducers.
30
- */
31
20
  export interface ReducerRegistry {
32
21
  readonly entries: Array<ReducerEntry>
33
22
  readonly byTag: Map<string, Array<ReducerEntry>>
34
23
  readonly register: (entry: ReducerEntry) => void
35
- /**
36
- * Remove a previously-registered entry. Eager reducers (built on the root scope)
37
- * never call this; a lazy feature's `Reducer.live` registers on mount and
38
- * unregisters on unmount (scope close), so an unmounted feature stops folding and
39
- * its entry is reclaimed instead of leaking + writing to an orphaned store.
40
- */
41
24
  readonly unregister: (entry: ReducerEntry) => void
42
25
  }
43
26
 
@@ -45,7 +28,6 @@ const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistr
45
28
  Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
46
29
  export class Reducers extends ReducersBase {}
47
30
 
48
- /** Drop a target from a `Map<string, Array>` bucket, pruning the key when it empties. */
49
31
  const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T): void => {
50
32
  const bucket = map.get(key)
51
33
  if (bucket === undefined) {
@@ -61,8 +43,6 @@ const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T):
61
43
 
62
44
  const makeReducerRegistry = (): ReducerRegistry => {
63
45
  const byTag = new Map<string, Array<ReducerEntry>>()
64
- // Single live array reference, replaced immutably on register/unregister and
65
- // exposed through the `entries` getter so consumers always read the current set.
66
46
  const state: { entries: Array<ReducerEntry> } = { entries: [] }
67
47
  return {
68
48
  get entries() {
@@ -87,11 +67,9 @@ const makeReducerRegistry = (): ReducerRegistry => {
87
67
  }
88
68
  }
89
69
 
90
- /** Read a bucket array, defaulting to empty without a falsy fallback. */
91
70
  const bucketOf = <V>(map: Map<string, Array<V>>, tag: string): Array<V> =>
92
71
  Option.getOrElse(Option.fromNullable(map.get(tag)), (): Array<V> => [])
93
72
 
94
- /** Run one reducer in isolation; a synchronous throw becomes a collected failure. */
95
73
  const isolateApply = (
96
74
  reducer: ReducerEntry,
97
75
  event: Tagged,
@@ -113,57 +91,47 @@ const isolateApply = (
113
91
 
114
92
  export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
115
93
 
116
- /** High before Normal; within a priority class, dispatch order is preserved. */
94
+ // High before Normal; partition preserves intra-priority dispatch order
117
95
  const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
118
96
 
119
- // The single drain loop: one fiber that frame-batches the bus. Each tick it
120
- // harvests every event dispatched in that microtask, runs all matching reducers
121
- // in ONE synchronous pass (so the store notifier flushes once for the batch),
122
- // then routes each event to the channels its procedures run on.
123
97
  const drain = Effect.gen(function* () {
124
98
  const bus = yield* Bus
125
99
  const reducers = yield* Reducers
126
100
  const { byName: channels } = yield* Channels
127
101
  const procedures = yield* Procedures
128
- // Captured once at engine build; the noop instance costs nothing per frame.
129
102
  const instrumentation = yield* resolveInstrumentation
103
+ // One counter per engine: feature child runtimes share the app budget
104
+ const budget = yield* resolveCurrentEventBudget
105
+ const counter = makeTickCounter(budget)
130
106
  const subscription = yield* PubSub.subscribe(bus)
131
107
 
132
108
  yield* Effect.forkScoped(
133
109
  Effect.forever(
134
110
  Effect.gen(function* () {
135
- // Block until the first event, then drain the rest of this tick.
136
111
  const first = yield* Queue.take(subscription)
137
112
  const rest = yield* Queue.takeAll(subscription)
138
113
  const frame = [first, ...Chunk.toArray(rest)]
139
114
 
140
- // Stable priority order: High-dispatched (UI) events fold before
141
- // Normal-dispatched (procedure follow-up) events in the same frame. A
142
- // two-bucket partition does this in O(m) with no comparator — partition
143
- // preserves intra-priority dispatch order — instead of sorting the frame.
144
115
  const [high, normal] = Arr.partition(frame, (envelope) => rank(envelope.priority) !== 0)
145
116
  const ordered = [...high, ...normal]
146
117
 
147
- // One synchronous block: every reducer write for the frame coalesces into
148
- // a single notification flush. Routing to channels happens here too, so
149
- // procedure bodies (run later, off their channel fibers) see post-batch
150
- // state. No `yield*` in between, or the flush could fire early.
118
+ // Budget before folding EventLoopOverflow is this tick's only typed failure
119
+ const sample = yield* Effect.sync(() => counter.add(ordered.length))
120
+ yield* enforceBudget(budget, sample)
121
+
122
+ // Sync block: coalesce reducer writes into one flush; no yield* mid-frame
151
123
  const failures = yield* Effect.sync(() => {
152
124
  const endFrame = instrumentation.frame(ordered.length)
153
125
  ordered.forEach((envelope) =>
154
126
  instrumentation.eventDispatched(envelope.event._tag, envelope.priority),
155
127
  )
156
- // Isolate each fold: a synchronous throw in one reducer must not abandon
157
- // the rest of the frame nor (via the `forever` below) kill the drain fiber
158
- // and freeze the whole app. Collect failures here, log after the block.
128
+ // Isolate folds so one throw cannot kill the drain fiber
159
129
  const collected = Arr.flatMap(ordered, (envelope) =>
160
130
  Arr.filterMap(bucketOf(reducers.byTag, envelope.event._tag), (reducer) =>
161
131
  isolateApply(reducer, envelope.event, instrumentation),
162
132
  ),
163
133
  )
164
- // Offer to each DISTINCT channel once. Routing per-procedure would offer a
165
- // shared channel multiple times for one event, and each offer re-runs every
166
- // procedure on it — duplicate execution.
134
+ // Offer each distinct channel once (per-procedure would double-run shared channels)
167
135
  ordered.forEach((envelope) => {
168
136
  bucketOf(procedures.channelsByTag, envelope.event._tag).forEach((channelName) => {
169
137
  channels.get(channelName)?.offer(envelope.event)
@@ -182,21 +150,23 @@ const drain = Effect.gen(function* () {
182
150
  { discard: true },
183
151
  )
184
152
  }).pipe(
185
- // Belt-and-suspenders: even a defect outside the isolated fold (a bug in
186
- // the loop itself) is logged and the tick restarts, so the bus never goes
187
- // permanently deaf.
188
- Effect.catchAllCause((cause) => Effect.logError('reform: drain tick crashed', cause)),
153
+ // Only typed failures (overflow) escape; defects restart the tick so the bus never goes deaf
154
+ Effect.catchAllCause((cause) =>
155
+ Option.match(Cause.failureOption(cause), {
156
+ onSome: () => Effect.failCause(cause),
157
+ onNone: () => Effect.logError('reform: drain tick crashed', cause),
158
+ }),
159
+ ),
160
+ ),
161
+ ).pipe(
162
+ Effect.tapErrorCause((cause) =>
163
+ Effect.logError('reform: event budget exceeded — the drain loop has been halted', cause),
189
164
  ),
165
+ Effect.tapError((overflow) => Effect.sync(() => budget.onOverflow(overflow))),
190
166
  ),
191
167
  )
192
168
  })
193
169
 
194
- /**
195
- * The reform engine: provides the bus + reducer/channel/procedure registries +
196
- * the per-runtime notification scheduler to the application and forks the single
197
- * drain loop. Registration mutates the live collections, so reducers/procedures/
198
- * channels merged alongside are picked up before the first dispatch (boot).
199
- */
200
170
  export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures | Queries> =
201
171
  Layer.scopedDiscard(drain).pipe(
202
172
  Layer.provideMerge(
@@ -1,25 +1,11 @@
1
1
  import { Context, Layer } from 'effect'
2
2
  import { type QueryState } from '../calc/queryState'
3
3
 
4
- // The `Queries` registry: the runtime-wide index of live query handles, the same
5
- // shape as `Reducers`/`Channels`/`Procedures` (`loop.ts`, `channel.ts`). Each
6
- // `AsyncCalc.live` driver registers its handle here scoped — so `AsyncCalc.invalidate`
7
- // / `AsyncCalc.refetch` (and any provider layer) can act on a calc by name without
8
- // holding its store tag. A base in-memory registry ships in `Engine`; provider
9
- // layers (persistence, focus/online managers) build on top by calling the handles.
10
- //
11
- // The per-calc generics are erased through function signatures (reads are
12
- // covariant; `subscribe` is uniform), so the heterogeneous registry needs no cast.
13
-
14
4
  export interface QueryHandle {
15
5
  readonly name: string
16
- /** Mark the query's value stale (`isStale := true`). Does not fetch. */
17
6
  readonly invalidate: () => void
18
- /** Force a run of the current key, bypassing the no-op-key guard. */
19
7
  readonly refetch: () => void
20
- /** Read the current state (erased to the open value type). */
21
8
  readonly snapshot: () => QueryState<unknown, unknown>
22
- /** Subscribe to state changes; returns an unsubscribe thunk. */
23
9
  readonly subscribe: (listener: () => void) => () => void
24
10
  }
25
11
 
@@ -41,9 +27,6 @@ const makeQueryRegistry = (): QueryRegistry => {
41
27
  register: (handle) => {
42
28
  byName.set(handle.name, handle)
43
29
  },
44
- // Only drop the entry if it is still the one we registered: a same-named
45
- // re-registration (re-mount before the old scope's finalizer runs) must not
46
- // be clobbered by the stale handle's unregister.
47
30
  unregister: (handle) => {
48
31
  if (byName.get(handle.name) === handle) {
49
32
  byName.delete(handle.name)
@@ -0,0 +1,59 @@
1
+ import { Context, Effect, Layer, Schema as S } from 'effect'
2
+ import { expect, test } from 'vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { mount } from '../compose/structure'
5
+ import { ui } from '../compose/ui'
6
+ import * as Event from '../event/event'
7
+ import * as Feature from '../feature/feature'
8
+ import { featureModule } from '../feature/feature'
9
+ import { makeAppRuntime } from '../runtime/appRuntime'
10
+ import { Engine } from '../runtime/loop'
11
+ import { featureScene } from './scene'
12
+
13
+ interface GreetingService {
14
+ readonly value: string
15
+ }
16
+
17
+ const GreetingBase: Context.TagClass<Greeting, 'featureScene.Greeting', GreetingService> =
18
+ Context.Tag('featureScene.Greeting')<Greeting, GreetingService>()
19
+ class Greeting extends GreetingBase {}
20
+
21
+ class Started extends Event.make('featureScene.Started', S.Struct({})) {}
22
+ class RootUi extends ui('featureScene.RootUi')<{ props: { readonly greeting: string } }>() {}
23
+ class RootComp extends Composition.make('featureScene.RootComp', {
24
+ title: 'Feature root',
25
+ ui: RootUi,
26
+ }) {}
27
+
28
+ const RootLive = Composition.live(RootComp, function* () {
29
+ const greeting = yield* Greeting
30
+ return mount({ props: { greeting: greeting.value }, slots: {} })
31
+ })
32
+
33
+ class RootFeature extends Feature.make('featureScene.root', {
34
+ composition: RootComp,
35
+ module: featureModule([Greeting], RootLive),
36
+ boot: [Event.construct(Started, {})],
37
+ }) {}
38
+
39
+ test('featureScene closes an eager root Feature with its typed requirements and boot', () => {
40
+ const root = featureScene(RootFeature, {
41
+ provide: Layer.mergeAll(Engine, Layer.succeed(Greeting, { value: 'hello' })),
42
+ })
43
+ const app = makeAppRuntime(root)
44
+
45
+ try {
46
+ const rendered = Effect.runSync(
47
+ Composition.render(app.read(RootComp.tag), {
48
+ props: {},
49
+ tracker: { add: () => {} },
50
+ }),
51
+ )
52
+
53
+ expect(root.composition).toBe(RootComp)
54
+ expect(root.boot).toEqual([Event.construct(Started, {})])
55
+ expect(rendered.props).toEqual({ greeting: 'hello' })
56
+ } finally {
57
+ app.dispose()
58
+ }
59
+ })
@@ -2,6 +2,12 @@ import { Layer, Struct } from 'effect'
2
2
  import type { CompositionClass, CompositionService } from '../compose/composition'
3
3
  import type { UiContract } from '../compose/ui'
4
4
  import type { EventOf } from '../event/event'
5
+ import type {
6
+ EagerFeatureClass,
7
+ EngineServices,
8
+ FeatureRequirement,
9
+ ProvidedBy,
10
+ } from '../feature/feature'
5
11
  import { CurrentSeedOverrides } from '../internal/seeds'
6
12
  import type { SeedsOf } from '../state/stateGroup'
7
13
  import type { Bus } from '../runtime/bus'
@@ -11,29 +17,8 @@ import {
11
17
  noopInstrumentation,
12
18
  } from '../runtime/instrumentation'
13
19
 
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
20
  export type MountedServices = CompositionService | Bus
32
21
 
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
22
  type BootEvent = EventOf<string, unknown>
38
23
 
39
24
  export interface Scene<
@@ -41,35 +26,21 @@ export interface Scene<
41
26
  S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
42
27
  > {
43
28
  readonly kind: 'Scene'
44
- /** The composition to run, with its contract + states preserved for typed consumers. */
45
29
  readonly composition: CompositionClass<unknown, C, S>
46
- /** Closed wiring (logic + views), each layer seeding its own live state. */
47
30
  readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
48
- /** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
49
31
  // 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
32
  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
- */
33
+ // instrumentation on the scene (not only layers): host render spans live outside layer context
57
34
  // 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
35
  readonly instrumentation?: Instrumentation
59
36
  }
60
37
 
61
- /** Closed wiring + optional boot events handed to {@link scene}. */
62
38
  export interface SceneConfig {
63
39
  readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
64
40
  // 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
41
  readonly boot?: ReadonlyArray<BootEvent>
66
42
  }
67
43
 
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
44
  export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
74
45
  composition: CompositionClass<unknown, C, S>,
75
46
  config: SceneConfig,
@@ -80,19 +51,31 @@ export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
80
51
  ...(config.boot !== undefined ? { boot: config.boot } : {}),
81
52
  })
82
53
 
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
- */
54
+ export interface FeatureSceneConfig<
55
+ Requires extends ReadonlyArray<FeatureRequirement>,
56
+ > {
57
+ readonly provide: Layer.Layer<EngineServices | ProvidedBy<Requires>, never, never>
58
+ }
59
+
60
+ export const featureScene = <
61
+ P,
62
+ C extends UiContract,
63
+ ROut,
64
+ Requires extends ReadonlyArray<FeatureRequirement>,
65
+ >(
66
+ feature: EagerFeatureClass<P, C, ROut, Requires>,
67
+ config: FeatureSceneConfig<Requires>,
68
+ ): Scene<C> => {
69
+ const app = feature.eagerModule.layer.pipe(Layer.provideMerge(config.provide))
70
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- close the generic root module's erased output to the host-readable Scene subset
71
+ const closed = app as Layer.Layer<MountedServices, never, never>
72
+ return scene(feature.composition, {
73
+ provide: [closed],
74
+ boot: feature.binding.boot,
75
+ })
76
+ }
77
+
78
+ // Tooling seam: wrap closed layers with seed overrides; keys are member names (not group names)
96
79
  export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
97
80
  base: Scene<C, S>,
98
81
  seeds: SeedsOf<S>,
@@ -101,14 +84,6 @@ export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>
101
84
  ? base
102
85
  : { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
103
86
 
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
87
  export const profiledScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
113
88
  base: Scene<C, S>,
114
89
  instrumentation: Instrumentation,
@@ -118,11 +93,9 @@ export const profiledScene = <C extends UiContract, S extends ReadonlyArray<unkn
118
93
  provide: base.provide.map(Layer.locally(CurrentInstrumentation, instrumentation)),
119
94
  })
120
95
 
121
- /** The instrumentation a host records render spans into — noop when unprofiled. */
122
96
  export const sceneInstrumentation = (candidate: Scene): Instrumentation =>
123
97
  candidate.instrumentation ?? noopInstrumentation
124
98
 
125
- /** Reflection guard: is this exported value a scene? */
126
99
  export const isScene = (candidate: unknown): candidate is Scene =>
127
100
  typeof candidate === 'object' &&
128
101
  candidate !== null &&