@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.
- 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 +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- 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 +72 -174
- 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 -229
- 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 +42 -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 +0 -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 +40 -83
- 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 +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- 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/runtime/loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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(
|
package/src/runtime/queries.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/scene/scene.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 &&
|