@playfast/reform 0.1.0 → 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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
package/src/feature/feature.ts
CHANGED
|
@@ -1,94 +1,84 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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<
|
|
34
|
-
| { readonly members: ReadonlyArray<{ readonly store: Context.Tag<
|
|
34
|
+
| { readonly store: Context.Tag<AnyValue, AnyValue> }
|
|
35
|
+
| { readonly members: ReadonlyArray<{ readonly store: Context.Tag<AnyValue, AnyValue> }> }
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,
|
|
47
|
+
: Requires[I] extends { readonly store: Context.Tag<infer Svc, AnyValue> }
|
|
51
48
|
? Svc
|
|
52
49
|
: never
|
|
53
50
|
}[number]
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
101
|
+
readonly composition: CompositionClass<P, C>
|
|
187
102
|
readonly Requires: Requires
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
readonly
|
|
105
|
+
readonly eagerModule: FeatureModule<ROut, Requires, false> | undefined
|
|
106
|
+
readonly supportModule: FeatureModule<never, SupportRequires, false> | undefined
|
|
194
107
|
}
|
|
195
108
|
|
|
196
|
-
export
|
|
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<
|
|
217
|
-
readonly failed: CompositionClass<
|
|
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
|
|
156
|
+
type EagerFeatureConfigExternalApi<
|
|
221
157
|
P,
|
|
222
158
|
C extends UiContract,
|
|
223
159
|
ROut,
|
|
224
|
-
Requires extends ReadonlyArray<
|
|
225
|
-
> = {
|
|
226
|
-
readonly
|
|
227
|
-
readonly
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
253
|
-
):
|
|
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.
|
|
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<
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|