@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,94 +1,84 @@
1
- import { type Context, Effect, Layer, Option, type Scope } from 'effect'
1
+ import { Context, Effect, Layer, Option, type Scope } from 'effect'
2
2
  import { type Engine } from '../runtime/loop'
3
3
  import { type StoresOf } from '../internal/sources'
4
4
  import { FeatureLoadFailed } from '../internal/errors'
5
- import { type Manifest, definitionClass } from '../definition/definition'
5
+ import { definitionClass } from '../definition/definition'
6
6
  import { type CompositionClass } from '../compose/composition'
7
7
  import { type UiContract } from '../compose/ui'
8
8
  import { publish, type Tagged } from '../runtime/bus'
9
+ import type { AnyValue } from '../internal/variance'
10
+ import {
11
+ type FeatureBinding,
12
+ FeatureBindingTypeId,
13
+ type FeatureManifest,
14
+ } from './featureBinding'
15
+ export {
16
+ type FailedProps,
17
+ type FeatureBinding,
18
+ FeatureBindingTypeId,
19
+ type FeatureManifest,
20
+ isFeatureBinding,
21
+ type LoadedModule,
22
+ } from './featureBinding'
23
+ export type { FeatureBindingTypeId as FeatureBindingType } from './featureBinding'
9
24
 
10
- // A `Feature` is the unit of lazy code-split, ownership, reflection, and proof.
11
- // It splits on the seam Reform already has: definitions (manifests, schemas, the
12
- // composition class, placeholders) stay eager and reflectable; the heavy `.live`
13
- // layer loads on demand. The whole-app *shape* stays inspectable with no chunk
14
- // loaded. Everything here is additive — `provide(slot, Composition)` and eager
15
- // scenes are unchanged. See `LAZY.md` for the design rationale and variance proof.
16
-
17
- /**
18
- * The always-on engine services a feature builds against: the shared event `Bus`
19
- * plus the reducer/channel/procedure registries. Derived from the `Engine`
20
- * layer's output (`runtime/loop.ts`) so it never drifts. A feature layer may name
21
- * these only as OPEN requirements (its `RIn`) and must never *provide* them — that
22
- * is what keeps the engine single and shared across every lazily-mounted feature.
23
- */
25
+ // Features must only name EngineServices as open RIn never provide them (shared engine)
24
26
  export type EngineServices = Layer.Layer.Success<typeof Engine>
25
27
 
26
- /**
27
- * A shared-state carrier a feature may declare in `requires`: a `State`,
28
- * `StateFamily`, `Calc`, or `AsyncCalc` (each carrying a `store` tag), or a
29
- * `StateGroup` (carrying `members`, each with its own `store`). Per the design,
30
- * a feature depends on shared state/calc only — never on another feature.
31
- */
28
+ const FeatureInputBase: Context.TagClass<FeatureInput, 'reform/FeatureInput', unknown> = Context.Tag(
29
+ 'reform/FeatureInput',
30
+ )<FeatureInput, unknown>()
31
+ export class FeatureInput extends FeatureInputBase {}
32
+
32
33
  export type RequireCarrier =
33
- | { readonly store: Context.Tag<any, any> }
34
- | { readonly members: ReadonlyArray<{ readonly store: Context.Tag<any, any> }> }
34
+ | { readonly store: Context.Tag<AnyValue, AnyValue> }
35
+ | { readonly members: ReadonlyArray<{ readonly store: Context.Tag<AnyValue, AnyValue> }> }
35
36
 
36
- /**
37
- * The Context services a `requires` tuple makes available to a feature layer: a
38
- * `StateGroup` contributes its members' stores (the shared `StoresOf`); every
39
- * other carrier contributes its own `store` service. This is the upper bound a
40
- * feature's open `RIn` is checked against. Because `Layer`'s `RIn` is covariant
41
- * (`Layer.d.ts` — `out RIn`), a layer that reads anything outside
42
- * `EngineServices | ProvidedBy<requires>` is simply not assignable, so the check
43
- * is ordinary assignability — no `as`, no `infer` gymnastics in user code.
44
- */
45
- export type ProvidedBy<Requires extends ReadonlyArray<RequireCarrier>> = {
46
- [I in keyof Requires]: Requires[I] extends {
37
+ export type FeatureRequirement = RequireCarrier | Context.Tag<AnyValue, AnyValue>
38
+
39
+ // Covariant RIn: layer reading outside EngineServices | ProvidedBy is unassignable
40
+ export type ProvidedBy<Requires extends ReadonlyArray<FeatureRequirement>> = {
41
+ [I in keyof Requires]: Requires[I] extends Context.Tag<infer Identifier, AnyValue>
42
+ ? Identifier
43
+ : Requires[I] extends {
47
44
  readonly members: infer M extends ReadonlyArray<unknown>
48
45
  }
49
46
  ? StoresOf<M>
50
- : Requires[I] extends { readonly store: Context.Tag<infer Svc, any> }
47
+ : Requires[I] extends { readonly store: Context.Tag<infer Svc, AnyValue> }
51
48
  ? Svc
52
49
  : never
53
50
  }[number]
54
51
 
55
- /**
56
- * A loaded feature's two artifacts. `requires` is the runtime-enumerable list the
57
- * editor reads (types are erased, so the carriers are kept as values); `layer` is
58
- * the heavy `.live` half. The bound on `layer`'s `RIn` —
59
- * `EngineServices | ProvidedBy<Requires>` — makes the runtime list provably equal
60
- * the layer's real requirement set: list too little and the layer no longer type-
61
- * checks here. Built by `featureModule`, which is the one sanctioned way to publish
62
- * a feature's lazy half.
63
- */
64
- export interface FeatureModule<ROut, Requires extends ReadonlyArray<RequireCarrier>> {
52
+ export interface FeatureModule<
53
+ ROut,
54
+ Requires extends ReadonlyArray<FeatureRequirement>,
55
+ UsesInput extends boolean = false,
56
+ > {
65
57
  readonly requires: Requires
66
- readonly layer: Layer.Layer<ROut, never, EngineServices | ProvidedBy<Requires>>
58
+ readonly usesInput: UsesInput
59
+ readonly layer: Layer.Layer<
60
+ ROut,
61
+ never,
62
+ | EngineServices
63
+ | ProvidedBy<Requires>
64
+ | (UsesInput extends true ? FeatureInput : never)
65
+ >
67
66
  }
68
67
 
69
- /**
70
- * Publish a feature's lazy half: pair its `requires` (a runtime tuple of shared
71
- * carriers) with its `layer`. The covariant `RIn` bound does the checking — a
72
- * layer that reaches for any service outside `EngineServices | ProvidedBy<requires>`
73
- * is not assignable, so it fails to compile right here, next to the layer, with no
74
- * cast. Listing an unused carrier is harmless; omitting a needed one is a type error.
75
- *
76
- * The bound also forbids a feature from *providing* `EngineServices` (they may only
77
- * be open `RIn`), structurally enforcing the liveness rule: a feature can never
78
- * bundle its own engine — it always shares the root's bus and registries.
79
- */
80
- export const featureModule = <const Requires extends ReadonlyArray<RequireCarrier>, ROut>(
68
+ export const featureModule = <const Requires extends ReadonlyArray<FeatureRequirement>, ROut>(
81
69
  requires: Requires,
82
70
  layer: Layer.Layer<ROut, never, EngineServices | ProvidedBy<Requires>>,
83
- ): FeatureModule<ROut, Requires> => ({ requires, layer })
71
+ ): FeatureModule<ROut, Requires, false> => ({ requires, usesInput: false, layer })
72
+
73
+ export const featureModuleFromInput = <
74
+ const Requires extends ReadonlyArray<FeatureRequirement>,
75
+ ROut,
76
+ >(
77
+ requires: Requires,
78
+ layer: Layer.Layer<ROut, never, EngineServices | ProvidedBy<Requires> | FeatureInput>,
79
+ ): FeatureModule<ROut, Requires, true> => ({ requires, usesInput: true, layer })
84
80
 
85
- /**
86
- * Wrap a dynamic `import()` of a feature's `.live` module as an `Effect` — Reform
87
- * never exposes a Promise/`await` on the feature surface. The unavoidable thenable
88
- * is sealed here; a rejected import (missing chunk, network failure, module-eval
89
- * throw) surfaces as a typed `FeatureLoadFailed` the host hands to
90
- * `placeholder.failed`. The module's default export is its `FeatureModule`.
91
- */
81
+ // Seals Promise import as Effect; rejection → FeatureLoadFailed
92
82
  export const lazyImport = <M>(
93
83
  thunk: () => Promise<{ readonly default: M }>,
94
84
  ): Effect.Effect<M, FeatureLoadFailed> =>
@@ -97,105 +87,53 @@ export const lazyImport = <M>(
97
87
  catch: (cause) => new FeatureLoadFailed({ cause }),
98
88
  }).pipe(Effect.map((module) => module.default))
99
89
 
100
- /** The props the host injects into a lazy feature's `failed` placeholder. */
101
- export interface FailedProps {
102
- readonly error: FeatureLoadFailed
103
- readonly retry: () => void
104
- }
105
-
106
- /**
107
- * The reflectable shape of a feature: its strategy, the composition it mounts,
108
- * and (when lazy) its placeholder compositions — all as manifests, so the editor
109
- * route-map renders the whole feature graph with no chunk loaded. `boot` is
110
- * runtime data and lives on the `FeatureBinding`, not here.
111
- */
112
- export interface FeatureManifest extends Manifest {
113
- readonly kind: 'Feature'
114
- readonly name: string
115
- readonly strategy: 'lazy' | 'default'
116
- readonly composition: Manifest & { readonly kind: 'Composition' }
117
- readonly placeholder: Option.Option<{
118
- readonly loading: Manifest & { readonly kind: 'Composition' }
119
- readonly failed: Manifest & { readonly kind: 'Composition' }
120
- }>
121
- }
122
-
123
- /**
124
- * A feature's loaded artifacts with type params erased, for the renderer-neutral,
125
- * strategy-uniform host. The erasure is cast-free *into*: because `Layer`'s `ROut`
126
- * is contravariant and `RIn` covariant, every `Layer<ROut, never, R>` is assignable
127
- * to `Layer<never, never, unknown>`, so any `FeatureModule<…>` is a `LoadedModule`.
128
- * The host re-attaches the concrete root context when it builds `layer` (one
129
- * documented boundary cast there, mirroring `buildRuntime`).
130
- */
131
- export interface LoadedModule {
132
- readonly requires: ReadonlyArray<RequireCarrier>
133
- readonly layer: Layer.Layer<never, never, unknown>
134
- }
135
-
136
- /** Nominal brand identifying the value a `provide(slot, Feature)` puts under a slot tag. */
137
- export const FeatureBindingTypeId: unique symbol = Symbol.for('reform/FeatureBinding')
138
- export type FeatureBindingTypeId = typeof FeatureBindingTypeId
139
-
140
- /**
141
- * What `provide(slot, Feature)` binds under the slot tag — everything the host
142
- * needs to mount the feature, with types erased (the host is renderer-neutral and
143
- * strategy-uniform). For `default` the host renders `composition` directly; for
144
- * `lazy` it drives `load` through an `AsyncData` lifecycle, painting `placeholder`
145
- * around it. The `composition`/`placeholder` here are the same `CompositionClass`
146
- * values a normal slot fill carries, so the host's existing `Compose` path renders
147
- * each.
148
- */
149
- export interface FeatureBinding {
150
- readonly [FeatureBindingTypeId]: FeatureBindingTypeId
151
- readonly manifest: FeatureManifest
152
- readonly composition: CompositionClass<unknown>
153
- readonly strategy: 'lazy' | 'default'
154
- readonly load: Effect.Effect<LoadedModule, FeatureLoadFailed>
155
- // oxlint-disable-next-line reform-rules/no-optional-fields -- read at the react host seam via `binding.placeholder === undefined`; an Option here would force a cross-package edit (react host) owned by another agent
156
- readonly placeholder?: {
157
- readonly loading: CompositionClass<unknown>
158
- readonly failed: CompositionClass<unknown>
159
- }
160
- readonly boot: ReadonlyArray<Tagged>
161
- }
162
-
163
- /** Whether a slot's bound child is a lazy/eager feature (vs a plain composition). */
164
- export const isFeatureBinding = (candidate: unknown): candidate is FeatureBinding =>
165
- typeof candidate === 'object' && candidate !== null && FeatureBindingTypeId in candidate
166
-
167
- /**
168
- * A feature *definition*: a reflectable manifest (`kind: 'Feature'`) that carries
169
- * the eager half (composition + placeholders) and an Effect `load` for the heavy
170
- * `.live` half. `Props`/`Contract` flow from the composition; `Requires` is the
171
- * shared-carrier tuple the loaded module declares. `load` is an `Effect`, never a
172
- * Promise — consumers `yield* Feature.load`.
173
- */
174
90
  export interface FeatureClass<
175
91
  P,
176
92
  C extends UiContract,
177
93
  ROut,
178
- Requires extends ReadonlyArray<RequireCarrier>,
94
+ Requires extends ReadonlyArray<FeatureRequirement>,
95
+ SupportRequires extends ReadonlyArray<FeatureRequirement> = readonly [],
179
96
  > {
180
97
  new (): {}
181
98
  readonly manifest: FeatureManifest
182
- /** Phantom carrying the mounted composition's props type (for `provide` checks). */
183
99
  readonly Props: P
184
- /** Phantom carrying the composition's UI contract (for typed reflection consumers). */
185
100
  readonly Contract: C
186
- /** Phantom carrying the declared shared-carrier tuple. */
101
+ readonly composition: CompositionClass<P, C>
187
102
  readonly Requires: Requires
188
- /** Load the feature's module — `yield* Feature.load` its `FeatureModule`, or `FeatureLoadFailed`. */
189
- readonly load: Effect.Effect<FeatureModule<ROut, Requires>, FeatureLoadFailed>
190
- /** The value `provide(slot, Feature)` binds under the slot tag. */
103
+ readonly load: Effect.Effect<FeatureModule<ROut, Requires, boolean>, FeatureLoadFailed>
191
104
  readonly binding: FeatureBinding
192
- /** The eagerly-available module present only for `loadingStrategy: 'default'`. */
193
- readonly eagerModule: FeatureModule<ROut, Requires> | undefined
105
+ readonly eagerModule: FeatureModule<ROut, Requires, false> | undefined
106
+ readonly supportModule: FeatureModule<never, SupportRequires, false> | undefined
194
107
  }
195
108
 
196
- export type AnyFeature = FeatureClass<any, any, any, ReadonlyArray<RequireCarrier>>
109
+ export interface EagerFeatureClass<
110
+ P,
111
+ C extends UiContract,
112
+ ROut,
113
+ Requires extends ReadonlyArray<FeatureRequirement>,
114
+ > extends FeatureClass<P, C, ROut, Requires> {
115
+ readonly eagerModule: FeatureModule<ROut, Requires, false>
116
+ readonly supportModule: undefined
117
+ }
118
+
119
+ export interface LazyFeatureClass<
120
+ P,
121
+ C extends UiContract,
122
+ ROut,
123
+ Requires extends ReadonlyArray<FeatureRequirement>,
124
+ SupportRequires extends ReadonlyArray<FeatureRequirement> = readonly [],
125
+ > extends FeatureClass<P, C, ROut, Requires, SupportRequires> {
126
+ readonly eagerModule: undefined
127
+ }
128
+
129
+ export type AnyFeature = FeatureClass<
130
+ AnyValue,
131
+ AnyValue,
132
+ AnyValue,
133
+ ReadonlyArray<FeatureRequirement>,
134
+ ReadonlyArray<FeatureRequirement>
135
+ >
197
136
 
198
- /** Whether a value is a feature definition (its target shape in `provide(slot, Feature)`). */
199
137
  export const isFeature = (candidate: unknown): candidate is AnyFeature =>
200
138
  (typeof candidate === 'function' || typeof candidate === 'object') &&
201
139
  candidate !== null &&
@@ -205,56 +143,98 @@ export const isFeature = (candidate: unknown): candidate is AnyFeature =>
205
143
  'kind' in candidate.manifest &&
206
144
  candidate.manifest.kind === 'Feature'
207
145
 
208
- /**
209
- * The placeholders a lazy feature must ship — both eager compositions (their
210
- * `.live` is in the eager bundle, so they paint the load gap). `loading` receives
211
- * the feature's props; `failed` receives `{ error, retry }`. Typed loosely
212
- * (`CompositionClass<any>`) because the host injects these props directly — they
213
- * are not schema-decoded inputs.
214
- */
215
146
  export interface Placeholders {
216
- readonly loading: CompositionClass<any, UiContract>
217
- readonly failed: CompositionClass<any, UiContract>
147
+ readonly loading: CompositionClass<AnyValue, UiContract>
148
+ readonly failed: CompositionClass<AnyValue, UiContract>
149
+ }
150
+
151
+ interface FeatureConfigBaseExternalApi<P, C extends UiContract> {
152
+ readonly composition: CompositionClass<P, C>
153
+ readonly boot?: ReadonlyArray<Tagged>
218
154
  }
219
155
 
220
- type FeatureConfigExternalApi<
156
+ type EagerFeatureConfigExternalApi<
221
157
  P,
222
158
  C extends UiContract,
223
159
  ROut,
224
- Requires extends ReadonlyArray<RequireCarrier>,
225
- > = {
226
- readonly composition: CompositionClass<P, C>
227
- readonly boot?: ReadonlyArray<Tagged>
228
- } & (
229
- | {
230
- // EAGER: the module is statically present and merged into the runtime at
231
- // boot. No load gap, so no placeholder.
232
- readonly loadingStrategy?: 'default'
233
- readonly module: FeatureModule<ROut, Requires>
234
- }
235
- | {
236
- // LAZY: the module loads on mount via an Effect. Placeholders required.
237
- readonly loadingStrategy: 'lazy'
238
- readonly load: Effect.Effect<FeatureModule<ROut, Requires>, FeatureLoadFailed>
239
- readonly placeholder: Placeholders
240
- }
241
- )
160
+ Requires extends ReadonlyArray<FeatureRequirement>,
161
+ > = FeatureConfigBaseExternalApi<P, C> & {
162
+ readonly loadingStrategy?: 'default'
163
+ readonly module: FeatureModule<ROut, Requires, false>
164
+ }
242
165
 
243
- /**
244
- * Define a feature. The whole-app shape (this manifest, the composition, the
245
- * placeholders) stays eager and reflectable; the `.live` layer in `module`/`load`
246
- * is the deferred chunk. Flip `loadingStrategy` from `'default'` to `'lazy'` and
247
- * the discriminated config *requires* `placeholder` — the compiler walks you to a
248
- * complete code-split feature.
249
- */
250
- export const make = <P, C extends UiContract, ROut, Requires extends ReadonlyArray<RequireCarrier>>(
166
+ type LazyFeatureConfigExternalApi<
167
+ P,
168
+ C extends UiContract,
169
+ ROut,
170
+ Requires extends ReadonlyArray<FeatureRequirement>,
171
+ SupportOut,
172
+ SupportRequires extends ReadonlyArray<FeatureRequirement>,
173
+ > = FeatureConfigBaseExternalApi<P, C> & {
174
+ readonly loadingStrategy: 'lazy'
175
+ readonly load: Effect.Effect<FeatureModule<ROut, Requires, boolean>, FeatureLoadFailed>
176
+ readonly placeholder: Placeholders
177
+ readonly eager?: FeatureModule<SupportOut, SupportRequires, false>
178
+ }
179
+
180
+ export function make<
181
+ P,
182
+ C extends UiContract,
183
+ ROut,
184
+ Requires extends ReadonlyArray<FeatureRequirement>,
185
+ >(
186
+ name: string,
187
+ config: EagerFeatureConfigExternalApi<P, C, ROut, Requires>,
188
+ ): EagerFeatureClass<P, C, ROut, Requires>
189
+ export function make<
190
+ P,
191
+ C extends UiContract,
192
+ ROut,
193
+ Requires extends ReadonlyArray<FeatureRequirement>,
194
+ SupportOut,
195
+ SupportRequires extends ReadonlyArray<FeatureRequirement>,
196
+ >(
197
+ name: string,
198
+ config: LazyFeatureConfigExternalApi<
199
+ P,
200
+ C,
201
+ ROut,
202
+ Requires,
203
+ SupportOut,
204
+ SupportRequires
205
+ > & { readonly eager: FeatureModule<SupportOut, SupportRequires, false> },
206
+ ): LazyFeatureClass<P, C, ROut, Requires, SupportRequires>
207
+ export function make<
208
+ P,
209
+ C extends UiContract,
210
+ ROut,
211
+ Requires extends ReadonlyArray<FeatureRequirement>,
212
+ >(
251
213
  name: string,
252
- config: FeatureConfigExternalApi<P, C, ROut, Requires>,
253
- ): FeatureClass<P, C, ROut, Requires> => {
214
+ config: LazyFeatureConfigExternalApi<P, C, ROut, Requires, never, readonly []>,
215
+ ): LazyFeatureClass<P, C, ROut, Requires>
216
+ export function make(
217
+ name: string,
218
+ config:
219
+ | EagerFeatureConfigExternalApi<
220
+ AnyValue,
221
+ UiContract,
222
+ never,
223
+ ReadonlyArray<FeatureRequirement>
224
+ >
225
+ | LazyFeatureConfigExternalApi<
226
+ AnyValue,
227
+ UiContract,
228
+ never,
229
+ ReadonlyArray<FeatureRequirement>,
230
+ never,
231
+ ReadonlyArray<FeatureRequirement>
232
+ >,
233
+ ): AnyFeature {
254
234
  const strategy: 'lazy' | 'default' = config.loadingStrategy ?? 'default'
255
- const load: Effect.Effect<FeatureModule<ROut, Requires>, FeatureLoadFailed> =
256
- 'load' in config ? config.load : Effect.succeed(config.module)
235
+ const load = 'load' in config ? config.load : Effect.succeed(config.module)
257
236
  const eagerModule = 'module' in config ? config.module : undefined
237
+ const supportModule = 'eager' in config ? config.eager : undefined
258
238
  const placeholder = 'placeholder' in config ? config.placeholder : undefined
259
239
 
260
240
  const manifest: FeatureManifest = {
@@ -275,43 +255,34 @@ export const make = <P, C extends UiContract, ROut, Requires extends ReadonlyArr
275
255
  manifest,
276
256
  composition: config.composition,
277
257
  strategy,
278
- // The host is strategy-uniform: `default`'s `load` resolves immediately. The
279
- // assignment to the erased `LoadedModule` needs no cast — see `LoadedModule`.
280
258
  load,
281
259
  ...(placeholder ? { placeholder: { loading: placeholder.loading, failed: placeholder.failed } } : {}),
282
260
  boot: Option.getOrElse(Option.fromNullable(config.boot), () => []),
283
261
  }
284
262
 
285
- return definitionClass<FeatureClass<P, C, ROut, Requires>>({ manifest, load, binding, eagerModule })
263
+ return definitionClass<AnyFeature>({
264
+ manifest,
265
+ load,
266
+ binding,
267
+ eagerModule,
268
+ supportModule,
269
+ composition: config.composition,
270
+ })
286
271
  }
287
272
 
288
- /**
289
- * Mount a feature against a live engine. The renderer-neutral lifecycle, run on a
290
- * per-feature `Scope`: load the module (an Effect — lazy = dynamic import; default
291
- * = immediate), build its `.live` layer against the host's live `engineContext`
292
- * (so its reducers/procedures register into the SHARED registries and its fibers
293
- * fork onto this scope), then dispatch the feature's `boot`. Returns the merged
294
- * context — engine services + the feature's own services — for the host to read
295
- * the feature's composition and stores. Closing the scope disposes exactly this
296
- * feature: its fibers stop and its stores are reclaimed.
297
- *
298
- * The single boundary cast (mirroring `buildRuntime`): `module.layer` is erased to
299
- * `LoadedModule` so the host stays strategy- and type-uniform. The feature's real
300
- * `RIn` was verified cast-free at `provide(slot, Feature)` and scene wiring; here
301
- * we re-attach the concrete `engineContext` it builds against. `R` is the engine
302
- * context's service set, supplied by the host.
303
- */
304
273
  // oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
305
274
  export const mountFeature = <R>(
306
275
  binding: FeatureBinding,
307
276
  engineContext: Context.Context<R>,
277
+ props?: unknown,
308
278
  ): Effect.Effect<Context.Context<unknown>, FeatureLoadFailed, Scope.Scope> =>
309
279
  Effect.gen(function* () {
310
- const { layer } = yield* binding.load
280
+ const module = yield* binding.load
311
281
  // oxlint-disable-next-line reform-rules/no-type-assertion -- documented erasure boundary: re-attach the concrete engine context to the type-erased `LoadedModule` layer (mirrors `buildRuntime`); RIn was verified cast-free at `provide`/scene wiring
312
- const featureLayer = layer as Layer.Layer<unknown, never, R>
282
+ const featureLayer = module.layer as Layer.Layer<unknown, never, R | FeatureInput>
283
+ const featureContext = Context.add(engineContext, FeatureInput, props)
313
284
  const context = yield* Layer.build(
314
- Layer.provideMerge(featureLayer, Layer.succeedContext(engineContext)),
285
+ Layer.provideMerge(featureLayer, Layer.succeedContext(featureContext)),
315
286
  )
316
287
  yield* Effect.forEach(binding.boot, (event) => publish('High', event), { discard: true }).pipe(
317
288
  Effect.provide(context),
@@ -1,8 +1,4 @@
1
- // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
- // These guard the cast-free seam: if a guarantee regresses, a `@ts-expect-error`
3
- // goes unused or an assignment fails, and `tsc` breaks.
4
-
5
- import { Layer, Schema as S } from 'effect'
1
+ import { Context, Layer, Schema as S } from 'effect'
6
2
  import type { Store } from '../internal/store'
7
3
  import * as Composition from '../compose/composition'
8
4
  import { provide } from '../compose/provide'
@@ -21,26 +17,34 @@ const TodosStatesBase: StateGroup.StateGroupClass<readonly [typeof FeedState, ty
21
17
  StateGroup.make(FeedState, FilterState)
22
18
  class TodosStates extends TodosStatesBase {}
23
19
 
24
- // --- featureModule: the covariant-RIn bound (cast-free requirement honesty) ---
20
+ interface ClockService {
21
+ readonly now: () => number
22
+ }
23
+ const ClockBase: Context.TagClass<Clock, 'feature.typecheck.Clock', ClockService> = Context.Tag(
24
+ 'feature.typecheck.Clock',
25
+ )<Clock, ClockService>()
26
+ class Clock extends ClockBase {}
25
27
 
26
- // A feature layer that reads FeedState's store and the shared engine — nothing else.
27
28
  declare const readsFeed: Layer.Layer<{ readonly _out: true }, never, Store<string> | EngineServices>
28
29
 
29
- // POSITIVE: declaring the State it reads in `requires` covers the read — compiles.
30
30
  export const okState: FeatureModule<{ readonly _out: true }, readonly [typeof FeedState]> =
31
31
  featureModule([FeedState], readsFeed)
32
32
 
33
- // POSITIVE: a StateGroup contributes its members' stores via `ProvidedBy` — compiles.
34
33
  export const okGroup: FeatureModule<{ readonly _out: true }, readonly [typeof TodosStates]> =
35
34
  featureModule([TodosStates], readsFeed)
36
35
 
37
- // NEGATIVE: empty `requires` provides only the engine, not Store<string>; covariant
38
- // `RIn` makes the layer unassignable to the declared bound.
39
36
  export const badEmpty: FeatureModule<{ readonly _out: true }, readonly []> =
40
37
  // @ts-expect-error feature layer reads a store it does not declare in `requires`
41
38
  featureModule([], readsFeed)
42
39
 
43
- // --- provide(slot, Feature): RIn surfacing + composition match ---
40
+ declare const readsClock: Layer.Layer<{ readonly _clock: true }, never, Clock | EngineServices>
41
+
42
+ export const okService: FeatureModule<{ readonly _clock: true }, readonly [typeof Clock]> =
43
+ featureModule([Clock], readsClock)
44
+
45
+ export const badService: FeatureModule<{ readonly _clock: true }, readonly []> =
46
+ // @ts-expect-error feature layer reads an ordinary Context service it did not declare
47
+ featureModule([], readsClock)
44
48
 
45
49
  const AppUiBase: UiClass<{ props: { readonly a: string }; events: {} }> = ui('AppUi')<{
46
50
  props: { readonly a: string }
@@ -70,11 +74,21 @@ const AppFeatureBase: Feature.FeatureClass<
70
74
  })
71
75
  class AppFeature extends AppFeatureBase {}
72
76
 
73
- // POSITIVE: the wired layer's open `RIn` is exactly `EngineServices` (requires=[]),
74
- // so a scene must provide `Core` — surfaced cast-free by the overload.
75
77
  export const wired: Layer.Layer<SlotChild, never, EngineServices> = provide(AppSlot, AppFeature)
76
78
 
77
- // A feature over a DIFFERENT contract must not fill `AppSlot`.
79
+ class SupportedAppFeature extends Feature.make('supportedAppFeature', {
80
+ loadingStrategy: 'lazy',
81
+ composition: AppComp,
82
+ load: lazyImport(async () => ({ default: featureModule([], Layer.empty) })),
83
+ eager: okService,
84
+ placeholder: { loading: AppComp, failed: AppComp },
85
+ }) {}
86
+
87
+ export const wiredWithSupport: Layer.Layer<SlotChild, never, EngineServices | Clock> = provide(
88
+ AppSlot,
89
+ SupportedAppFeature,
90
+ )
91
+
78
92
  const OtherUiBase: UiClass<{ props: { readonly n: number }; events: {} }> = ui('OtherUi')<{
79
93
  props: { readonly n: number }
80
94
  events: {}
@@ -100,7 +114,6 @@ const OtherFeatureBase: Feature.FeatureClass<
100
114
  })
101
115
  class OtherFeature extends OtherFeatureBase {}
102
116
 
103
- // NEGATIVE: OtherFeature's composition props don't match AppSlot's child composition.
104
117
  // @ts-expect-error feature composition does not match the slot's child type
105
118
  export const mismatched: Layer.Layer<SlotChild, never, EngineServices> = provide(
106
119
  AppSlot,
@@ -0,0 +1,48 @@
1
+ import { Effect, Layer, Option } from 'effect'
2
+ import type { Manifest } from '../definition/definition'
3
+ import type { CompositionClass } from '../compose/composition'
4
+ import type { Tagged } from '../runtime/bus'
5
+ import type { FeatureLoadFailed } from '../internal/errors'
6
+ import type { FeatureRequirement } from './feature'
7
+
8
+ export interface FailedProps {
9
+ readonly error: FeatureLoadFailed
10
+ readonly retry: () => void
11
+ }
12
+
13
+ export interface FeatureManifest extends Manifest {
14
+ readonly kind: 'Feature'
15
+ readonly name: string
16
+ readonly strategy: 'lazy' | 'default'
17
+ readonly composition: Manifest & { readonly kind: 'Composition' }
18
+ readonly placeholder: Option.Option<{
19
+ readonly loading: Manifest & { readonly kind: 'Composition' }
20
+ readonly failed: Manifest & { readonly kind: 'Composition' }
21
+ }>
22
+ }
23
+
24
+ export interface LoadedModule {
25
+ readonly requires: ReadonlyArray<FeatureRequirement>
26
+ readonly usesInput: boolean
27
+ readonly layer: Layer.Layer<never, never, unknown>
28
+ }
29
+
30
+ export const FeatureBindingTypeId: unique symbol = Symbol.for('reform/FeatureBinding')
31
+ export type FeatureBindingTypeId = typeof FeatureBindingTypeId
32
+
33
+ export interface FeatureBindingExternalApi {
34
+ readonly [FeatureBindingTypeId]: FeatureBindingTypeId
35
+ readonly manifest: FeatureManifest
36
+ readonly composition: CompositionClass<unknown>
37
+ readonly strategy: 'lazy' | 'default'
38
+ readonly load: Effect.Effect<LoadedModule, FeatureLoadFailed>
39
+ readonly placeholder?: {
40
+ readonly loading: CompositionClass<unknown>
41
+ readonly failed: CompositionClass<unknown>
42
+ }
43
+ readonly boot: ReadonlyArray<Tagged>
44
+ }
45
+ export type FeatureBinding = FeatureBindingExternalApi
46
+
47
+ export const isFeatureBinding = (candidate: unknown): candidate is FeatureBinding =>
48
+ typeof candidate === 'object' && candidate !== null && FeatureBindingTypeId in candidate