@playfast/reform 1.1.1 → 1.2.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 (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -1,6 +1,7 @@
1
1
  import { expect, it } from '@effect/vitest'
2
2
  import { Duration, Effect, Layer, Schema as S, TestClock } from 'effect'
3
3
  import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
4
+ import { makeFlightGate, until } from '../testkit/flight.testkit'
4
5
 
5
6
  it.live('a frame of events drains in one pass with a single notification flush', () => {
6
7
  class Counter extends State.make('counter', S.Number) {}
@@ -22,7 +23,11 @@ it.live('a frame of events drains in one pass with a single notification flush',
22
23
  yield* publish('Normal', Event.construct(Bumped, { to: 1 }))
23
24
  yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
24
25
  yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
25
- yield* Effect.sleep(Duration.millis(20))
26
+ // The flush is the frame's last act, so one notification means the fold is done.
27
+ yield* until(
28
+ () => notifications.n,
29
+ (n) => n >= 1,
30
+ )
26
31
 
27
32
  expect(store.get()).toBe(3)
28
33
  expect(notifications.n).toBe(1)
@@ -34,11 +39,12 @@ it.live('an exclusive channel serializes procedures that share it', () => {
34
39
  class StartB extends Event.make('StartB', S.Struct({})) {}
35
40
  class Exclusive extends Channel.make('Exclusive', { policy: { _tag: 'exclusive' } }) {}
36
41
 
42
+ const gate = makeFlightGate()
37
43
  const concurrency = { active: 0, max: 0 }
38
44
  const guarded = function* () {
39
45
  concurrency.active += 1
40
46
  concurrency.max = Math.max(concurrency.max, concurrency.active)
41
- yield* Effect.sleep(Duration.millis(15))
47
+ yield* gate.through(Effect.void)
42
48
  concurrency.active -= 1
43
49
  }
44
50
 
@@ -52,9 +58,20 @@ it.live('an exclusive channel serializes procedures that share it', () => {
52
58
  )
53
59
 
54
60
  return Effect.gen(function* () {
61
+ // Parking the first run IS the overlap: a shared lane that is not exclusive
62
+ // would start the second procedure while the first sits at the gate.
63
+ yield* gate.hold
55
64
  yield* publish('Normal', Event.construct(StartA, {}))
56
65
  yield* publish('Normal', Event.construct(StartB, {}))
57
- yield* Effect.sleep(Duration.millis(60))
66
+ yield* gate.awaitEntry(1)
67
+ expect(gate.entered()).toBe(1)
68
+
69
+ yield* gate.release
70
+ yield* gate.awaitEntry(2)
71
+ yield* until(
72
+ () => concurrency.active,
73
+ (active) => active === 0,
74
+ )
58
75
  expect(concurrency.max).toBe(1)
59
76
  }).pipe(Effect.provide(TestLayer))
60
77
  })
@@ -79,7 +96,11 @@ it.live('two procedures sharing a channel each run once for one shared event', (
79
96
 
80
97
  return Effect.gen(function* () {
81
98
  yield* publish('High', Event.construct(Shared, {}))
82
- yield* Effect.sleep(Duration.millis(20))
99
+ yield* until(
100
+ () => runs.a + runs.b,
101
+ (total) => total >= 2,
102
+ )
103
+ // A duplicated offer would land in the same frame and double one of them.
83
104
  expect(runs.a).toBe(1)
84
105
  expect(runs.b).toBe(1)
85
106
  }).pipe(Effect.provide(TestLayer))
@@ -126,10 +147,12 @@ it.live('a reducer ignores events outside its declared set (no-op)', () => {
126
147
  return Effect.gen(function* () {
127
148
  const store = yield* Counter.store
128
149
  yield* publish('Normal', Event.construct(Unrelated, {}))
129
- yield* Effect.sleep(Duration.millis(10))
130
- expect(store.get()).toBe(0)
131
150
  yield* publish('Normal', Event.construct(Bump, {}))
132
- yield* Effect.sleep(Duration.millis(10))
151
+ yield* until(
152
+ () => store.get(),
153
+ (n) => n > 0,
154
+ )
155
+ // Both events fold in one frame: a reducer that also took Unrelated reads 2.
133
156
  expect(store.get()).toBe(1)
134
157
  }).pipe(Effect.provide(TestLayer))
135
158
  })
@@ -159,3 +182,57 @@ it.effect('a latest channel cancels the in-flight run when a new event arrives',
159
182
  expect(counts.completed).toBe(1)
160
183
  }).pipe(Effect.provide(TestLayer))
161
184
  })
185
+
186
+ it.live('a fold returning the previous reference keeps the exact store value', () => {
187
+ const Viewer = S.Union(
188
+ S.TaggedStruct('Closed', {}),
189
+ S.TaggedStruct('Open', { revision: S.Number }),
190
+ )
191
+ class ViewerState extends State.make('viewer', Viewer) {}
192
+ class Marker extends State.make('marker', S.Number) {}
193
+ class Poked extends Event.make('Poked', S.Struct({})) {}
194
+ class PokeReducer extends Reducer.make('PokeReducer', {
195
+ states: [ViewerState],
196
+ events: [Poked],
197
+ }) {}
198
+ // Closed is a fixpoint: the fold hands back the previous reference untouched.
199
+ const PokeReducerLive = Reducer.live(PokeReducer, (viewer) =>
200
+ viewer._tag === 'Open' ? { ...viewer, revision: viewer.revision + 1 } : viewer,
201
+ )
202
+ class MarkReducer extends Reducer.make('MarkReducer', {
203
+ states: [Marker],
204
+ events: [Poked],
205
+ }) {}
206
+ const MarkReducerLive = Reducer.live(MarkReducer, (n) => n + 1)
207
+
208
+ const TestLayer = Layer.mergeAll(PokeReducerLive, MarkReducerLive).pipe(
209
+ Layer.provideMerge(
210
+ Layer.mergeAll(State.live(ViewerState, { _tag: 'Closed' }), State.live(Marker, 0), Engine),
211
+ ),
212
+ )
213
+
214
+ return Effect.gen(function* () {
215
+ const store = yield* ViewerState.store
216
+ const marker = yield* Marker.store
217
+ const before = store.get()
218
+ const notifications = { n: 0 }
219
+ const flushed = { n: 0 }
220
+ store.subscribe(() => {
221
+ notifications.n += 1
222
+ })
223
+ marker.subscribe(() => {
224
+ flushed.n += 1
225
+ })
226
+ yield* publish('Normal', Event.construct(Poked, {}))
227
+ // The marker folds on the same event and shares the scheduler's flush: once
228
+ // its listener fires, a viewer notification would have run in that batch too.
229
+ yield* until(
230
+ () => flushed.n,
231
+ (n) => n >= 1,
232
+ )
233
+ // Reference identity is the repaint signal: an unchanged fold must not be
234
+ // re-decoded into a fresh, structurally-equal value that wakes subscribers.
235
+ expect(store.get()).toBe(before)
236
+ expect(notifications.n).toBe(0)
237
+ }).pipe(Effect.provide(TestLayer))
238
+ })
@@ -1,10 +1,6 @@
1
1
  import { Array as Arr, Cause, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
2
- import {
3
- Channels,
4
- channelsLayer,
5
- Procedures,
6
- proceduresLayer,
7
- } from '../channel/channel'
2
+ import { Channels, channelsLayer, Procedures, proceduresLayer } from '../channel/channel'
3
+ import { makeBucketCache } from '../internal/bucketCache'
8
4
  import { notificationsLayer } from '../internal/scheduler'
9
5
  import { Bus, busLayer, type Envelope, type Tagged } from './bus'
10
6
  import { enforceBudget, makeTickCounter, resolveCurrentEventBudget } from './eventBudget'
@@ -19,56 +15,78 @@ export interface ReducerEntry {
19
15
 
20
16
  export interface ReducerRegistry {
21
17
  readonly entries: Array<ReducerEntry>
22
- readonly byTag: Map<string, Array<ReducerEntry>>
18
+ readonly byName: Map<string, Set<ReducerEntry>>
19
+ readonly byTag: Map<string, Set<ReducerEntry>>
20
+ readonly tagged: (tag: string) => ReadonlyArray<ReducerEntry>
23
21
  readonly register: (entry: ReducerEntry) => void
24
22
  readonly unregister: (entry: ReducerEntry) => void
25
23
  }
26
24
 
27
- const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> =
28
- Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
25
+ const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> = Context.Tag(
26
+ 'reform/Reducers',
27
+ )<Reducers, ReducerRegistry>()
29
28
  export class Reducers extends ReducersBase {}
30
29
 
31
- const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T): void => {
30
+ const dropFromBuckets = <T>(map: Map<string, Set<T>>, key: string, target: T): void => {
32
31
  const bucket = map.get(key)
33
32
  if (bucket === undefined) {
34
33
  return
35
34
  }
36
- const next = bucket.filter((entry) => entry !== target)
37
- if (next.length === 0) {
35
+ bucket.delete(target)
36
+ if (bucket.size === 0) {
38
37
  map.delete(key)
39
- } else {
40
- map.set(key, next)
41
38
  }
42
39
  }
43
40
 
44
41
  const makeReducerRegistry = (): ReducerRegistry => {
45
- const byTag = new Map<string, Array<ReducerEntry>>()
46
- const state: { entries: Array<ReducerEntry> } = { entries: [] }
42
+ const entries = new Set<ReducerEntry>()
43
+ const byName = new Map<string, Set<ReducerEntry>>()
44
+ const byTag = new Map<string, Set<ReducerEntry>>()
45
+ const tagged = makeBucketCache(byTag)
46
+ const snapshot: { value: Option.Option<Array<ReducerEntry>> } = {
47
+ value: Option.some([]),
48
+ }
47
49
  return {
48
50
  get entries() {
49
- return state.entries
51
+ return Option.getOrElse(snapshot.value, () => {
52
+ const current = Array.from(entries)
53
+ snapshot.value = Option.some(current)
54
+ return current
55
+ })
50
56
  },
57
+ byName,
51
58
  byTag,
59
+ tagged: tagged.read,
52
60
  register: (entry) => {
53
- state.entries = [...state.entries, entry]
61
+ entries.add(entry)
62
+ snapshot.value = Option.none()
63
+ getOrCreateBucket(byName, entry.name).add(entry)
54
64
  entry.handles.forEach((tag) => {
55
- const bucket = byTag.get(tag)
56
- if (bucket === undefined) {
57
- byTag.set(tag, [entry])
58
- } else {
59
- byTag.set(tag, [...bucket, entry])
60
- }
65
+ getOrCreateBucket(byTag, tag).add(entry)
66
+ tagged.invalidate(tag)
61
67
  })
62
68
  },
63
69
  unregister: (entry) => {
64
- state.entries = state.entries.filter((existing) => existing !== entry)
65
- entry.handles.forEach((tag) => dropFromBuckets(byTag, tag, entry))
70
+ entries.delete(entry)
71
+ snapshot.value = Option.none()
72
+ dropFromBuckets(byName, entry.name, entry)
73
+ entry.handles.forEach((tag) => {
74
+ dropFromBuckets(byTag, tag, entry)
75
+ tagged.invalidate(tag)
76
+ })
66
77
  },
67
78
  }
68
79
  }
69
80
 
70
- const bucketOf = <V>(map: Map<string, Array<V>>, tag: string): Array<V> =>
71
- Option.getOrElse(Option.fromNullable(map.get(tag)), (): Array<V> => [])
81
+ const getOrCreateBucket = <T>(map: Map<string, Set<T>>, key: string): Set<T> => {
82
+ const existing = map.get(key)
83
+ if (existing !== undefined) {
84
+ return existing
85
+ }
86
+ const created = new Set<T>()
87
+ map.set(key, created)
88
+ return created
89
+ }
72
90
 
73
91
  const isolateApply = (
74
92
  reducer: ReducerEntry,
@@ -78,9 +96,10 @@ const isolateApply = (
78
96
  const end = instrumentation.reducerRun(reducer.name, event._tag)
79
97
  return Effect.runSync(
80
98
  Effect.match(
81
- Effect.try({ try: () => reducer.apply(event), catch: (error) => error }).pipe(
82
- Effect.ensuring(Effect.sync(end)),
83
- ),
99
+ Effect.try({
100
+ try: () => reducer.apply(event),
101
+ catch: (error) => error,
102
+ }).pipe(Effect.ensuring(Effect.sync(end))),
84
103
  {
85
104
  onFailure: (error) => Option.some({ event, error }),
86
105
  onSuccess: () => Option.none(),
@@ -91,7 +110,7 @@ const isolateApply = (
91
110
 
92
111
  export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
93
112
 
94
- // High before Normal; partition preserves intra-priority dispatch order
113
+ // Partitioning keeps dispatch order stable within each priority.
95
114
  const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
96
115
 
97
116
  const drain = Effect.gen(function* () {
@@ -100,8 +119,8 @@ const drain = Effect.gen(function* () {
100
119
  const { byName: channels } = yield* Channels
101
120
  const procedures = yield* Procedures
102
121
  const instrumentation = yield* resolveInstrumentation
103
- // One counter per engine: feature child runtimes share the app budget
104
122
  const budget = yield* resolveCurrentEventBudget
123
+ // Child feature runtimes share this engine-level counter with the root.
105
124
  const counter = makeTickCounter(budget)
106
125
  const subscription = yield* PubSub.subscribe(bus)
107
126
 
@@ -115,25 +134,25 @@ const drain = Effect.gen(function* () {
115
134
  const [high, normal] = Arr.partition(frame, (envelope) => rank(envelope.priority) !== 0)
116
135
  const ordered = [...high, ...normal]
117
136
 
118
- // Budget before folding EventLoopOverflow is this tick's only typed failure
137
+ // Check before folding so runaway frames stop at the hard limit.
119
138
  const sample = yield* Effect.sync(() => counter.add(ordered.length))
120
139
  yield* enforceBudget(budget, sample)
121
140
 
122
- // Sync block: coalesce reducer writes into one flush; no yield* mid-frame
141
+ // One synchronous block coalesces reducer writes into a single flush.
123
142
  const failures = yield* Effect.sync(() => {
124
143
  const endFrame = instrumentation.frame(ordered.length)
125
144
  ordered.forEach((envelope) =>
126
145
  instrumentation.eventDispatched(envelope.event._tag, envelope.priority),
127
146
  )
128
- // Isolate folds so one throw cannot kill the drain fiber
129
147
  const collected = Arr.flatMap(ordered, (envelope) =>
130
- Arr.filterMap(bucketOf(reducers.byTag, envelope.event._tag), (reducer) =>
148
+ // A reducer defect must not abandon the remaining frame.
149
+ Arr.filterMap(reducers.tagged(envelope.event._tag), (reducer) =>
131
150
  isolateApply(reducer, envelope.event, instrumentation),
132
151
  ),
133
152
  )
134
- // Offer each distinct channel once (per-procedure would double-run shared channels)
135
153
  ordered.forEach((envelope) => {
136
- bucketOf(procedures.channelsByTag, envelope.event._tag).forEach((channelName) => {
154
+ // channelsFor is distinct; per-procedure offers would double-run shared channels.
155
+ procedures.channelsFor(envelope.event._tag).forEach((channelName) => {
137
156
  channels.get(channelName)?.offer(envelope.event)
138
157
  })
139
158
  })
@@ -150,7 +169,7 @@ const drain = Effect.gen(function* () {
150
169
  { discard: true },
151
170
  )
152
171
  }).pipe(
153
- // Only typed failures (overflow) escape; defects restart the tick so the bus never goes deaf
172
+ // Typed overflow halts the loop; defects are logged and the next tick continues.
154
173
  Effect.catchAllCause((cause) =>
155
174
  Option.match(Cause.failureOption(cause), {
156
175
  onSome: () => Effect.failCause(cause),
@@ -180,3 +199,13 @@ export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures | Querie
180
199
  ),
181
200
  ),
182
201
  )
202
+
203
+ export const EngineRequirements: readonly [
204
+ typeof Bus,
205
+ typeof Reducers,
206
+ typeof Channels,
207
+ typeof Procedures,
208
+ typeof Queries,
209
+ ] = [Bus, Reducers, Channels, Procedures, Queries]
210
+
211
+ export type EngineServices = Layer.Layer.Success<typeof Engine>
@@ -0,0 +1,124 @@
1
+ import type { Context, Effect, Option } from 'effect'
2
+ import type {
3
+ CompositionActivation,
4
+ CompositionId,
5
+ RequiredSlotIdentities,
6
+ } from '../compose/composition'
7
+ import type { UiContract } from '../compose/ui'
8
+ import type {
9
+ AnyFeatureBinding,
10
+ CapturedFeatureBinding,
11
+ FeatureBinding,
12
+ FeatureRequirement,
13
+ } from '../feature/feature'
14
+ import type {
15
+ AnyFeatureGraphSummary,
16
+ FeatureSummaryCompositionIdentity,
17
+ FeatureSummaryContract,
18
+ FeatureSummaryInput,
19
+ FeatureSummaryMountProps,
20
+ FeatureSummaryName,
21
+ FeatureSummaryOpenServices,
22
+ FeatureSummaryUsesInput,
23
+ } from '../graph/closure'
24
+ import type { FeatureLoadFailed } from '../internal/errors'
25
+ import type { Node } from '../ui/node'
26
+ import type { Priority, Tagged } from './bus'
27
+ import type { Instrumentation } from './instrumentation'
28
+ import type { EngineServices } from './loop'
29
+
30
+ export const UnspecifiedRuntimeServicesTypeId: unique symbol = Symbol.for(
31
+ 'reform/UnspecifiedRuntimeServices',
32
+ )
33
+ export type UnspecifiedRuntimeServicesTypeId = typeof UnspecifiedRuntimeServicesTypeId
34
+ export interface UnspecifiedRuntimeServices {
35
+ readonly [UnspecifiedRuntimeServicesTypeId]: UnspecifiedRuntimeServicesTypeId
36
+ }
37
+
38
+ export type FeatureMountHandlersExternalApi<UsesInput extends boolean, Input, Services> = {
39
+ readonly onLive: (runtime: RuntimeHandle<Services>) => void
40
+ readonly onFailed: (error: FeatureLoadFailed) => void
41
+ } & (UsesInput extends true ? { readonly props: Input } : { readonly props?: never })
42
+
43
+ export type FeatureMountHandlers<
44
+ UsesInput extends boolean,
45
+ Input,
46
+ Services,
47
+ > = FeatureMountHandlersExternalApi<UsesInput, Input, Services>
48
+
49
+ export type RuntimeFeatureAvailability<Services, OpenServices> = [
50
+ EngineServices | OpenServices,
51
+ ] extends [Services]
52
+ ? object
53
+ : {
54
+ readonly missingFeatureServices: Exclude<EngineServices | OpenServices, Services>
55
+ }
56
+
57
+ export interface AnyFeatureMountHandlers {
58
+ readonly props: unknown
59
+ readonly onLive: <Services>(runtime: RuntimeHandle<Services>) => void
60
+ readonly onFailed: (error: FeatureLoadFailed) => void
61
+ }
62
+
63
+ export interface CapturedFeatureMountHandlers<
64
+ Props,
65
+ HostServices,
66
+ FeatureServices,
67
+ C extends UiContract,
68
+ N extends string,
69
+ Identity,
70
+ > {
71
+ readonly props: Props
72
+ readonly onLive: (
73
+ runtime: RuntimeHandle<
74
+ HostServices | FeatureServices | CompositionId<N, Identity, RequiredSlotIdentities<C>>
75
+ >,
76
+ ) => void
77
+ readonly onFailed: (error: FeatureLoadFailed) => void
78
+ }
79
+
80
+ export interface RuntimeHandle<Services = UnspecifiedRuntimeServices> {
81
+ read<Identifier extends Services, Service>(tag: Context.Tag<Identifier, Service>): Service
82
+ readOption<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Option.Option<Service>
83
+ runRender(effect: Effect.Effect<Node, never, never>): Node
84
+ dispatch(priority: Priority, event: Tagged): void
85
+ mountScoped(activation: CompositionActivation): () => void
86
+ mountFeature<
87
+ Summary extends AnyFeatureGraphSummary,
88
+ ROut,
89
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
90
+ >(
91
+ binding: [EngineServices | FeatureSummaryOpenServices<Summary>] extends [Services]
92
+ ? FeatureBinding<Summary, ROut, DirectRequires>
93
+ : never,
94
+ handlers: FeatureMountHandlers<
95
+ FeatureSummaryUsesInput<Summary>,
96
+ FeatureSummaryInput<Summary>,
97
+ Services | ROut
98
+ >,
99
+ ): () => void
100
+ mountCapturedFeature<
101
+ Summary extends AnyFeatureGraphSummary,
102
+ ROut,
103
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
104
+ >(
105
+ binding: CapturedFeatureBinding<Summary, ROut, DirectRequires> &
106
+ RuntimeFeatureAvailability<Services, FeatureSummaryOpenServices<Summary>>,
107
+ handlers: CapturedFeatureMountHandlers<
108
+ FeatureSummaryMountProps<Summary>,
109
+ Services,
110
+ ROut,
111
+ FeatureSummaryContract<Summary>,
112
+ FeatureSummaryName<Summary>,
113
+ FeatureSummaryCompositionIdentity<Summary>
114
+ >,
115
+ ): () => void
116
+ mountAnyFeature(binding: AnyFeatureBinding, handlers: AnyFeatureMountHandlers): () => void
117
+ readonly instrumentation: Instrumentation
118
+ }
119
+
120
+ export interface AppRuntime<Services = UnspecifiedRuntimeServices> extends RuntimeHandle<Services> {
121
+ boot(): void
122
+ dispose(): void
123
+ isDisposed(): boolean
124
+ }
@@ -19,11 +19,13 @@ const GreetingBase: Context.TagClass<Greeting, 'featureScene.Greeting', Greeting
19
19
  class Greeting extends GreetingBase {}
20
20
 
21
21
  class Started extends Event.make('featureScene.Started', S.Struct({})) {}
22
- class RootUi extends ui('featureScene.RootUi')<{ props: { readonly greeting: string } }>() {}
22
+ class RootUi extends ui('featureScene.RootUi')<{
23
+ props: { readonly greeting: string }
24
+ }>() {}
23
25
  class RootComp extends Composition.make('featureScene.RootComp', {
24
26
  title: 'Feature root',
25
27
  ui: RootUi,
26
- }) {}
28
+ })<RootComp>() {}
27
29
 
28
30
  const RootLive = Composition.live(RootComp, function* () {
29
31
  const greeting = yield* Greeting