@playfast/reform 1.2.0 → 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.
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +26 -23
- package/src/calc/asyncCalc.invalidate.test.ts +258 -55
- package/src/calc/asyncCalc.test.ts +225 -85
- package/src/calc/asyncCalc.ts +27 -141
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +10 -11
- package/src/calc/calcFamily.test.ts +12 -12
- package/src/channel/channel.ts +8 -106
- package/src/channel/procedures.ts +108 -0
- package/src/compose/composition.ts +49 -246
- package/src/compose/compositionTypes.ts +249 -0
- package/src/event/event.fromSource.test.ts +12 -12
- package/src/feature/feature.ts +50 -1210
- package/src/feature/feature.typecheck.ts +2 -61
- package/src/feature/featureBinding.ts +158 -0
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +45 -226
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/internal/queryDriver.ts +20 -89
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverTypes.ts +8 -10
- package/src/internal/store.test.ts +27 -6
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +28 -22
- package/src/remote/remoteState.ts +63 -489
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +122 -0
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +9 -5
- package/src/runtime/appRuntime.test.ts +40 -16
- package/src/runtime/appRuntime.ts +17 -472
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +38 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +18 -4
- package/src/runtime/instrumentation.test.ts +49 -12
- package/src/runtime/loop.test.ts +51 -10
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/state/stateFamily.test.ts +25 -8
- package/src/testkit/flight.testkit.ts +9 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Context, Effect, Exit, Fiber, Layer, ManagedRuntime, Option, Scope } from 'effect'
|
|
2
|
+
import type { CompositionActivation, CompositionId, RequiredSlotIdentities } from '../compose/composition'
|
|
3
|
+
import type { UiContract } from '../compose/ui'
|
|
4
|
+
import { type AnyFeatureBinding, type CapturedFeatureBinding, type FeatureBinding, FeatureInput, type FeatureRequirement } from '../feature/feature'
|
|
5
|
+
import type {
|
|
6
|
+
AnyFeatureGraphSummary,
|
|
7
|
+
FeatureSummaryCompositionIdentity,
|
|
8
|
+
FeatureSummaryContract,
|
|
9
|
+
FeatureSummaryInput,
|
|
10
|
+
FeatureSummaryMountProps,
|
|
11
|
+
FeatureSummaryName,
|
|
12
|
+
FeatureSummaryOpenServices,
|
|
13
|
+
FeatureSummaryUsesInput,
|
|
14
|
+
} from '../graph/closure'
|
|
15
|
+
import { forceSync } from '../internal/errors'
|
|
16
|
+
import { type Node } from '../ui/node'
|
|
17
|
+
import { type CapturedScene, sceneInstrumentation } from '../scene/scene'
|
|
18
|
+
import { Bus, publish, type Priority, type Tagged } from './bus'
|
|
19
|
+
import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
|
|
20
|
+
import { featureMountFailure, forkFeatureMount, hasFeatureRequirements } from './featureMount'
|
|
21
|
+
import type {
|
|
22
|
+
AnyFeatureMountHandlers,
|
|
23
|
+
AppRuntime,
|
|
24
|
+
CapturedFeatureMountHandlers,
|
|
25
|
+
FeatureMountHandlers,
|
|
26
|
+
RuntimeHandle,
|
|
27
|
+
} from './runtimeHandle'
|
|
28
|
+
|
|
29
|
+
export const makeCapturedAppRuntime = <
|
|
30
|
+
C extends UiContract,
|
|
31
|
+
S extends ReadonlyArray<unknown>,
|
|
32
|
+
Services,
|
|
33
|
+
P,
|
|
34
|
+
N extends string,
|
|
35
|
+
Identity,
|
|
36
|
+
>(
|
|
37
|
+
scene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
38
|
+
options: ReformOptions = {},
|
|
39
|
+
): AppRuntime<Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>> => {
|
|
40
|
+
const instrumentation = sceneInstrumentation(scene)
|
|
41
|
+
const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
|
|
42
|
+
const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
|
|
43
|
+
const runtime = ManagedRuntime.make(layer)
|
|
44
|
+
const capturedContext = forceSync(() => runtime.runSync(Effect.context<Services>()))
|
|
45
|
+
const rootCompositionService = Option.getOrThrow(
|
|
46
|
+
Context.getOption(capturedContext, scene.composition.tag),
|
|
47
|
+
)
|
|
48
|
+
// Re-add the exact tag so the captured context retains the root's nominal identity.
|
|
49
|
+
const rootContext = Context.add(capturedContext, scene.composition.tag, rootCompositionService)
|
|
50
|
+
const rootBus = Context.getOption(rootContext, Bus)
|
|
51
|
+
|
|
52
|
+
// Hosts can still render tearing-down trees after the managed runtime is disposed.
|
|
53
|
+
const status = { disposed: false, booted: false }
|
|
54
|
+
const activationDisposers = new Set<() => void>()
|
|
55
|
+
const mountHost = {
|
|
56
|
+
runFork: (effect: Effect.Effect<void, never, never>) => runtime.runFork(effect),
|
|
57
|
+
isDisposed: () => status.disposed,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A closed render Effect remains safe on the default runtime after disposal.
|
|
61
|
+
const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
|
|
62
|
+
status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
|
|
63
|
+
|
|
64
|
+
const dispatch = (priority: Priority, event: Tagged): void => {
|
|
65
|
+
if (status.disposed || Option.isNone(rootBus)) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
runtime.runFork(publish(priority, event).pipe(Effect.provideService(Bus, rootBus.value)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mountScoped = (activation: CompositionActivation): (() => void) => {
|
|
72
|
+
if (status.disposed) {
|
|
73
|
+
return () => {}
|
|
74
|
+
}
|
|
75
|
+
const scope = Effect.runSync(Scope.make())
|
|
76
|
+
const liveness = { active: true }
|
|
77
|
+
const fiber = runtime.runFork(
|
|
78
|
+
Layer.build(activation).pipe(Effect.provideService(Scope.Scope, scope)),
|
|
79
|
+
)
|
|
80
|
+
const dispose = (): void => {
|
|
81
|
+
if (!liveness.active) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
liveness.active = false
|
|
85
|
+
fiber.unsafeInterruptAsFork(fiber.id())
|
|
86
|
+
const close = Fiber.await(fiber).pipe(
|
|
87
|
+
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
88
|
+
Effect.ensuring(Effect.sync(() => activationDisposers.delete(dispose))),
|
|
89
|
+
)
|
|
90
|
+
Effect.runFork(close)
|
|
91
|
+
}
|
|
92
|
+
activationDisposers.add(dispose)
|
|
93
|
+
return dispose
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const makeChildRuntime = <ChildServices>(
|
|
97
|
+
context: Context.Context<ChildServices>,
|
|
98
|
+
): RuntimeHandle<ChildServices> => ({
|
|
99
|
+
read: <Identifier extends ChildServices, Service>(
|
|
100
|
+
tag: Context.Tag<Identifier, Service>,
|
|
101
|
+
): Service => Context.get(context, tag),
|
|
102
|
+
readOption: <Identifier, Service>(
|
|
103
|
+
tag: Context.Tag<Identifier, Service>,
|
|
104
|
+
): Option.Option<Service> => Context.getOption(context, tag),
|
|
105
|
+
runRender,
|
|
106
|
+
dispatch,
|
|
107
|
+
mountScoped,
|
|
108
|
+
mountFeature: (binding, handlers) => mountFeatureOnto(context, binding, handlers),
|
|
109
|
+
mountCapturedFeature: (binding, handlers) =>
|
|
110
|
+
mountCapturedFeatureOnto(context, binding, handlers),
|
|
111
|
+
mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(context, binding, handlers),
|
|
112
|
+
instrumentation,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const mountFeatureOnto = <
|
|
116
|
+
HostServices,
|
|
117
|
+
Summary extends AnyFeatureGraphSummary,
|
|
118
|
+
ROut,
|
|
119
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
120
|
+
>(
|
|
121
|
+
hostContext: Context.Context<HostServices>,
|
|
122
|
+
binding: FeatureBinding<Summary, ROut, DirectRequires>,
|
|
123
|
+
handlers: FeatureMountHandlers<
|
|
124
|
+
FeatureSummaryUsesInput<Summary>,
|
|
125
|
+
FeatureSummaryInput<Summary>,
|
|
126
|
+
HostServices | ROut
|
|
127
|
+
>,
|
|
128
|
+
): (() => void) => {
|
|
129
|
+
if (status.disposed) {
|
|
130
|
+
return () => {}
|
|
131
|
+
}
|
|
132
|
+
const scope = Effect.runSync(Scope.make())
|
|
133
|
+
const mount = Effect.flatMap(binding.load, (loaded) => {
|
|
134
|
+
if (
|
|
135
|
+
!hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
|
|
136
|
+
hostContext,
|
|
137
|
+
loaded.requires,
|
|
138
|
+
)
|
|
139
|
+
) {
|
|
140
|
+
return Effect.fail(
|
|
141
|
+
featureMountFailure(
|
|
142
|
+
`Feature ${binding.manifest.name} requires services missing from the host runtime`,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
return Effect.gen(function* () {
|
|
147
|
+
const bus = Context.get(hostContext, Bus)
|
|
148
|
+
const featureContext = Context.add(hostContext, FeatureInput, handlers.props)
|
|
149
|
+
const context = yield* Layer.build(
|
|
150
|
+
Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
|
|
151
|
+
)
|
|
152
|
+
yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
|
|
153
|
+
discard: true,
|
|
154
|
+
}).pipe(Effect.provideService(Bus, bus))
|
|
155
|
+
const merged = Context.merge(hostContext, context)
|
|
156
|
+
yield* Effect.sync(() => handlers.onLive(makeChildRuntime<HostServices | ROut>(merged)))
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
return forkFeatureMount(mountHost, scope, mount, handlers.onFailed)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const mountCapturedFeatureOnto = <
|
|
163
|
+
HostServices,
|
|
164
|
+
Summary extends AnyFeatureGraphSummary,
|
|
165
|
+
ROut,
|
|
166
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
167
|
+
>(
|
|
168
|
+
hostContext: Context.Context<HostServices>,
|
|
169
|
+
binding: CapturedFeatureBinding<Summary, ROut, DirectRequires>,
|
|
170
|
+
handlers: CapturedFeatureMountHandlers<
|
|
171
|
+
FeatureSummaryMountProps<Summary>,
|
|
172
|
+
HostServices,
|
|
173
|
+
ROut,
|
|
174
|
+
FeatureSummaryContract<Summary>,
|
|
175
|
+
FeatureSummaryName<Summary>,
|
|
176
|
+
FeatureSummaryCompositionIdentity<Summary>
|
|
177
|
+
>,
|
|
178
|
+
): (() => void) => {
|
|
179
|
+
if (status.disposed) {
|
|
180
|
+
return () => {}
|
|
181
|
+
}
|
|
182
|
+
const scope = Effect.runSync(Scope.make())
|
|
183
|
+
const mount = Effect.flatMap(binding.load, (loaded) => {
|
|
184
|
+
if (
|
|
185
|
+
!hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
|
|
186
|
+
hostContext,
|
|
187
|
+
loaded.requires,
|
|
188
|
+
)
|
|
189
|
+
) {
|
|
190
|
+
return Effect.fail(
|
|
191
|
+
featureMountFailure(
|
|
192
|
+
`Feature ${binding.manifest.name} requires services missing from the host runtime`,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
const input = loaded.usesInput ? binding.parseInput(handlers.props) : Option.some(undefined)
|
|
197
|
+
return Option.match(input, {
|
|
198
|
+
onNone: () =>
|
|
199
|
+
Effect.fail(
|
|
200
|
+
featureMountFailure(`Feature ${binding.manifest.name} received invalid props`),
|
|
201
|
+
),
|
|
202
|
+
onSome: (props) =>
|
|
203
|
+
Effect.gen(function* () {
|
|
204
|
+
const bus = Context.get(hostContext, Bus)
|
|
205
|
+
const featureContext = Context.add(hostContext, FeatureInput, props)
|
|
206
|
+
const context = yield* Layer.build(
|
|
207
|
+
Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
|
|
208
|
+
)
|
|
209
|
+
yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
|
|
210
|
+
discard: true,
|
|
211
|
+
}).pipe(Effect.provideService(Bus, bus))
|
|
212
|
+
const merged = Context.merge(hostContext, context)
|
|
213
|
+
const compositionService = Option.getOrThrow(
|
|
214
|
+
Context.getOption(merged, binding.composition.tag),
|
|
215
|
+
)
|
|
216
|
+
const rooted = Context.add(merged, binding.composition.tag, compositionService)
|
|
217
|
+
yield* Effect.sync(() => handlers.onLive(makeChildRuntime(rooted)))
|
|
218
|
+
}),
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
return forkFeatureMount(mountHost, scope, mount, handlers.onFailed)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const mountAnyFeatureOnto = <HostServices>(
|
|
225
|
+
hostContext: Context.Context<HostServices>,
|
|
226
|
+
binding: AnyFeatureBinding,
|
|
227
|
+
handlers: AnyFeatureMountHandlers,
|
|
228
|
+
): (() => void) =>
|
|
229
|
+
binding.capture((exactBinding) =>
|
|
230
|
+
mountCapturedFeatureOnto(hostContext, exactBinding, {
|
|
231
|
+
props: handlers.props,
|
|
232
|
+
onLive: (childRuntime) => handlers.onLive(childRuntime),
|
|
233
|
+
onFailed: handlers.onFailed,
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
type RootServices = Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>
|
|
238
|
+
const app: AppRuntime<RootServices> = {
|
|
239
|
+
// Captured reads stay available while the managed runtime is tearing down.
|
|
240
|
+
read: <Identifier extends RootServices, Service>(
|
|
241
|
+
tag: Context.Tag<Identifier, Service>,
|
|
242
|
+
): Service => Context.get(rootContext, tag),
|
|
243
|
+
readOption: <Identifier, Service>(
|
|
244
|
+
tag: Context.Tag<Identifier, Service>,
|
|
245
|
+
): Option.Option<Service> => Context.getOption(rootContext, tag),
|
|
246
|
+
runRender,
|
|
247
|
+
dispatch,
|
|
248
|
+
mountScoped,
|
|
249
|
+
mountFeature: (binding, handlers) => mountFeatureOnto(rootContext, binding, handlers),
|
|
250
|
+
mountCapturedFeature: (binding, handlers) =>
|
|
251
|
+
mountCapturedFeatureOnto(rootContext, binding, handlers),
|
|
252
|
+
mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(rootContext, binding, handlers),
|
|
253
|
+
boot: () => {
|
|
254
|
+
if (status.disposed || status.booted) {
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
status.booted = true
|
|
258
|
+
Option.fromNullable(scene.boot)
|
|
259
|
+
.pipe(Option.getOrElse((): ReadonlyArray<Tagged> => []))
|
|
260
|
+
.forEach((event) => dispatch('High', event))
|
|
261
|
+
},
|
|
262
|
+
dispose: () => {
|
|
263
|
+
if (status.disposed) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
status.disposed = true
|
|
267
|
+
activationDisposers.forEach((dispose) => dispose())
|
|
268
|
+
void runtime.dispose()
|
|
269
|
+
},
|
|
270
|
+
isDisposed: () => status.disposed,
|
|
271
|
+
instrumentation,
|
|
272
|
+
}
|
|
273
|
+
return app
|
|
274
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
2
|
import { Duration, Effect, Layer, LogLevel, Logger, Option, Schema as S } from 'effect'
|
|
3
|
-
import { Engine, Event, publish, Reducer, State } from '../index'
|
|
3
|
+
import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
4
|
import type { EventLoopOverflow } from '../internal/errors'
|
|
5
|
+
import { flush, until } from '../testkit/flight.testkit'
|
|
5
6
|
import {
|
|
6
7
|
CurrentEventBudget,
|
|
7
8
|
type EventBudget,
|
|
@@ -70,7 +71,11 @@ it.live('crossing the soft limit logs one warning; the frame still folds', () =>
|
|
|
70
71
|
(to) => publish('Normal', Event.construct(Bumped, { to })),
|
|
71
72
|
{ discard: true },
|
|
72
73
|
)
|
|
73
|
-
|
|
74
|
+
// The budget is enforced ahead of the fold, so a folded frame has been sampled.
|
|
75
|
+
yield* until(
|
|
76
|
+
() => store.get(),
|
|
77
|
+
(n) => n === 10,
|
|
78
|
+
)
|
|
74
79
|
|
|
75
80
|
expect(store.get()).toBe(10)
|
|
76
81
|
const budgetWarnings = warnings.filter((message) => message.includes('reform:'))
|
|
@@ -102,7 +107,12 @@ it.live('crossing the hard limit halts the runtime with EventLoopOverflow', () =
|
|
|
102
107
|
(to) => publish('Normal', Event.construct(Bumped, { to })),
|
|
103
108
|
{ discard: true },
|
|
104
109
|
)
|
|
105
|
-
|
|
110
|
+
// onOverflow runs off the loop's error channel: seeing it means the drain
|
|
111
|
+
// fiber has already exited, so nothing published after can ever fold.
|
|
112
|
+
yield* until(
|
|
113
|
+
() => captured.length,
|
|
114
|
+
(n) => n >= 1,
|
|
115
|
+
)
|
|
106
116
|
|
|
107
117
|
expect(captured).toHaveLength(1)
|
|
108
118
|
expect(captured[0]?._tag).toBe('reform/EventLoopOverflow')
|
|
@@ -110,7 +120,7 @@ it.live('crossing the hard limit halts the runtime with EventLoopOverflow', () =
|
|
|
110
120
|
|
|
111
121
|
const before = store.get()
|
|
112
122
|
yield* publish('Normal', Event.construct(Bumped, { to: 9_999 }))
|
|
113
|
-
yield*
|
|
123
|
+
yield* flush()
|
|
114
124
|
expect(store.get()).toBe(before)
|
|
115
125
|
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
116
126
|
})
|
|
@@ -121,13 +131,26 @@ it.live('the window resets when the event loop yields — bursty traffic never t
|
|
|
121
131
|
class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
|
|
122
132
|
const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
|
|
123
133
|
|
|
134
|
+
// The reset rides a `setTimeout(0)` armed inside the frame, and no TestClock
|
|
135
|
+
// drives it. A debounced lane is the probe: its timer starts from that same
|
|
136
|
+
// frame's offer, so its run can only land behind the reset.
|
|
137
|
+
class Yielded extends Event.make('Yielded', S.Struct({})) {}
|
|
138
|
+
class Turn extends Channel.make('Turn', {
|
|
139
|
+
policy: { _tag: 'debounce', duration: Duration.millis(1) },
|
|
140
|
+
}) {}
|
|
141
|
+
class Waited extends Procedure.make('Waited', { events: [Yielded], channel: Turn }) {}
|
|
142
|
+
const turns = { n: 0 }
|
|
143
|
+
const WaitedLive = Procedure.live(Waited, function* () {
|
|
144
|
+
turns.n += 1
|
|
145
|
+
})
|
|
146
|
+
|
|
124
147
|
const captured: Array<EventLoopOverflow> = []
|
|
125
148
|
const budget: EventBudget = {
|
|
126
149
|
errorAt: 50,
|
|
127
150
|
warningAt: Option.none(),
|
|
128
151
|
onOverflow: (error) => captured.push(error),
|
|
129
152
|
}
|
|
130
|
-
const TestLayer = BumpReducerLive.pipe(
|
|
153
|
+
const TestLayer = Layer.mergeAll(BumpReducerLive, Channel.live(Turn), WaitedLive).pipe(
|
|
131
154
|
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
132
155
|
Layer.locally(CurrentEventBudget, budget),
|
|
133
156
|
)
|
|
@@ -142,10 +165,17 @@ it.live('the window resets when the event loop yields — bursty traffic never t
|
|
|
142
165
|
return Effect.gen(function* () {
|
|
143
166
|
const store = yield* Counter.store
|
|
144
167
|
yield* burst(1)
|
|
145
|
-
yield*
|
|
146
|
-
yield*
|
|
147
|
-
|
|
168
|
+
yield* publish('Normal', Event.construct(Yielded, {}))
|
|
169
|
+
yield* until(
|
|
170
|
+
() => turns.n,
|
|
171
|
+
(n) => n >= 1,
|
|
172
|
+
)
|
|
148
173
|
|
|
174
|
+
yield* burst(41)
|
|
175
|
+
yield* until(
|
|
176
|
+
() => ({ count: store.get(), overflows: captured.length }),
|
|
177
|
+
({ count, overflows }) => count === 80 || overflows > 0,
|
|
178
|
+
)
|
|
149
179
|
expect(captured).toHaveLength(0)
|
|
150
180
|
expect(store.get()).toBe(80)
|
|
151
181
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Cause, Context, Effect, Exit, Fiber, Option, Scope } from 'effect'
|
|
2
|
+
import type { FeatureRequirement } from '../feature/feature'
|
|
3
|
+
import { FeatureLoadFailed } from '../internal/errors'
|
|
4
|
+
import { EngineRequirements, type EngineServices } from './loop'
|
|
5
|
+
|
|
6
|
+
const isObjectLike = (candidate: unknown): candidate is object =>
|
|
7
|
+
(typeof candidate === 'object' || typeof candidate === 'function') && candidate !== null
|
|
8
|
+
|
|
9
|
+
const isContextTag = (candidate: unknown): candidate is Context.Tag<unknown, unknown> =>
|
|
10
|
+
isObjectLike(candidate) &&
|
|
11
|
+
Context.TagTypeId in candidate &&
|
|
12
|
+
'key' in candidate &&
|
|
13
|
+
typeof candidate.key === 'string'
|
|
14
|
+
|
|
15
|
+
const isUnknownArray = (candidate: unknown): candidate is ReadonlyArray<unknown> =>
|
|
16
|
+
Array.isArray(candidate)
|
|
17
|
+
|
|
18
|
+
const requirementAvailable = <Services>(
|
|
19
|
+
context: Context.Context<Services>,
|
|
20
|
+
requirement: unknown,
|
|
21
|
+
): boolean => {
|
|
22
|
+
if (isContextTag(requirement)) {
|
|
23
|
+
return Option.isSome(Context.getOption(context, requirement))
|
|
24
|
+
}
|
|
25
|
+
if (!isObjectLike(requirement)) {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
if ('store' in requirement) {
|
|
29
|
+
return requirementAvailable(context, requirement.store)
|
|
30
|
+
}
|
|
31
|
+
if ('members' in requirement && isUnknownArray(requirement.members)) {
|
|
32
|
+
return requirement.members.every((member) => requirementAvailable(context, member))
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const hasFeatureRequirements = <
|
|
38
|
+
Services,
|
|
39
|
+
OpenServices,
|
|
40
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
41
|
+
>(
|
|
42
|
+
context: Context.Context<Services>,
|
|
43
|
+
requires: DirectRequires,
|
|
44
|
+
): context is Context.Context<Services | EngineServices | OpenServices> =>
|
|
45
|
+
EngineRequirements.every((tag) => requirementAvailable(context, tag)) &&
|
|
46
|
+
requires.every((requirement) => requirementAvailable(context, requirement))
|
|
47
|
+
|
|
48
|
+
export const featureMountFailure = (message: string): FeatureLoadFailed =>
|
|
49
|
+
new FeatureLoadFailed({ cause: message })
|
|
50
|
+
|
|
51
|
+
export interface FeatureMountHost {
|
|
52
|
+
readonly runFork: (effect: Effect.Effect<void, never, never>) => Fiber.RuntimeFiber<void, never>
|
|
53
|
+
readonly isDisposed: () => boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fork a feature mount onto the host runtime and hand back its disposer. Both
|
|
58
|
+
* the typed and captured mount paths settle failures and tear down identically.
|
|
59
|
+
*/
|
|
60
|
+
export const forkFeatureMount = (
|
|
61
|
+
host: FeatureMountHost,
|
|
62
|
+
scope: Scope.CloseableScope,
|
|
63
|
+
mount: Effect.Effect<void, FeatureLoadFailed, Scope.Scope>,
|
|
64
|
+
onFailed: (error: FeatureLoadFailed) => void,
|
|
65
|
+
): (() => void) => {
|
|
66
|
+
const fiber = host.runFork(
|
|
67
|
+
mount.pipe(
|
|
68
|
+
Effect.provideService(Scope.Scope, scope),
|
|
69
|
+
Effect.matchCause({
|
|
70
|
+
onFailure: (cause) => {
|
|
71
|
+
// Disposer interruption is cancellation, not a failed feature.
|
|
72
|
+
if (!Cause.isInterruptedOnly(cause)) {
|
|
73
|
+
onFailed(
|
|
74
|
+
Option.getOrElse(Cause.failureOption(cause), () => new FeatureLoadFailed({ cause })),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
onSuccess: () => undefined,
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
return () => {
|
|
83
|
+
// Interrupt first, then await finalizer registration before closing the scope.
|
|
84
|
+
fiber.unsafeInterruptAsFork(fiber.id())
|
|
85
|
+
const close = Fiber.await(fiber).pipe(
|
|
86
|
+
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
87
|
+
)
|
|
88
|
+
if (host.isDisposed()) {
|
|
89
|
+
Effect.runFork(close)
|
|
90
|
+
} else {
|
|
91
|
+
host.runFork(close)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import { Cause,
|
|
2
|
+
import { Cause, Effect, Exit, Layer, LogLevel, Logger, Schema as S, Scope } from 'effect'
|
|
3
3
|
import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
4
|
import { Channels } from '../channel/channel'
|
|
5
|
+
import { until } from '../testkit/flight.testkit'
|
|
5
6
|
|
|
6
7
|
it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
7
8
|
class Counter extends State.make('counter', S.Number) {}
|
|
@@ -10,7 +11,9 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
|
10
11
|
class GoodReducer extends Reducer.make('GoodReducer', { states: [Counter], events: [Good] }) {}
|
|
11
12
|
class BadReducer extends Reducer.make('BadReducer', { states: [Counter], events: [Bad] }) {}
|
|
12
13
|
const GoodLive = Reducer.live(GoodReducer, (n) => n + 1)
|
|
14
|
+
const badRuns = { n: 0 }
|
|
13
15
|
const BadLive = Reducer.live(BadReducer, () => {
|
|
16
|
+
badRuns.n += 1
|
|
14
17
|
throw new Error('boom')
|
|
15
18
|
})
|
|
16
19
|
|
|
@@ -22,11 +25,18 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
|
22
25
|
const store = yield* Counter.store
|
|
23
26
|
|
|
24
27
|
yield* publish('Normal', Event.construct(Bad, {}))
|
|
25
|
-
|
|
28
|
+
// The throwing fold ran and was isolated; its frame wrote nothing.
|
|
29
|
+
yield* until(
|
|
30
|
+
() => badRuns.n,
|
|
31
|
+
(n) => n === 1,
|
|
32
|
+
)
|
|
26
33
|
expect(store.get()).toBe(0)
|
|
27
34
|
|
|
28
35
|
yield* publish('Normal', Event.construct(Good, {}))
|
|
29
|
-
yield*
|
|
36
|
+
yield* until(
|
|
37
|
+
() => store.get(),
|
|
38
|
+
(n) => n === 1,
|
|
39
|
+
)
|
|
30
40
|
expect(store.get()).toBe(1)
|
|
31
41
|
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
32
42
|
})
|
|
@@ -93,7 +103,11 @@ it.live('remount overlap keeps the channel alive', () => {
|
|
|
93
103
|
yield* Scope.close(firstScope, Exit.void)
|
|
94
104
|
|
|
95
105
|
yield* publish('Normal', Event.construct(Fired, {}))
|
|
96
|
-
|
|
106
|
+
// One surviving lane under the shared name: a second would double the run.
|
|
107
|
+
yield* until(
|
|
108
|
+
() => runs.n,
|
|
109
|
+
(n) => n >= 1,
|
|
110
|
+
)
|
|
97
111
|
expect(runs.n).toBe(1)
|
|
98
112
|
|
|
99
113
|
yield* Scope.close(secondScope, Exit.void)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import {
|
|
2
|
+
import { Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import {
|
|
4
4
|
AsyncCalc,
|
|
5
5
|
Calc,
|
|
@@ -21,14 +21,13 @@ import {
|
|
|
21
21
|
Ui,
|
|
22
22
|
ui,
|
|
23
23
|
} from '../index'
|
|
24
|
+
import { makeFlightGate, until } from '../testkit/flight.testkit'
|
|
24
25
|
import {
|
|
25
26
|
CurrentInstrumentation,
|
|
26
27
|
type Instrumentation,
|
|
27
28
|
noopInstrumentation,
|
|
28
29
|
} from './instrumentation'
|
|
29
30
|
|
|
30
|
-
const tick = (ms = 20) => Effect.sleep(Duration.millis(ms))
|
|
31
|
-
|
|
32
31
|
const makeRecorder = () => {
|
|
33
32
|
const events: Array<{ readonly tag: string; readonly priority: Priority }> = []
|
|
34
33
|
const reducers: Array<{ readonly name: string; readonly eventTag: string }> = []
|
|
@@ -112,7 +111,10 @@ it.live('the engine reports frames, events, reducer spans, and state updates', (
|
|
|
112
111
|
return Effect.gen(function* () {
|
|
113
112
|
yield* publish('High', Event.construct(Bumped, { to: 1 }))
|
|
114
113
|
yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
|
|
115
|
-
yield*
|
|
114
|
+
yield* until(
|
|
115
|
+
() => recorder.ends.frame,
|
|
116
|
+
(n) => n >= 1,
|
|
117
|
+
)
|
|
116
118
|
|
|
117
119
|
expect(recorder.frames).toEqual([2])
|
|
118
120
|
expect(recorder.ends.frame).toBe(1)
|
|
@@ -146,7 +148,11 @@ it.live('an equal-value write is not reported as a state update', () => {
|
|
|
146
148
|
|
|
147
149
|
return Effect.gen(function* () {
|
|
148
150
|
yield* publish('High', Event.construct(Wrote, { to: 7 }))
|
|
149
|
-
|
|
151
|
+
// The state update, if any, is reported inside the reducer's span.
|
|
152
|
+
yield* until(
|
|
153
|
+
() => recorder.ends.reducer,
|
|
154
|
+
(n) => n >= 1,
|
|
155
|
+
)
|
|
150
156
|
expect(recorder.reducers.length).toBe(1)
|
|
151
157
|
expect(recorder.states).toEqual([])
|
|
152
158
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -176,7 +182,12 @@ it.live('calc recomputes are spanned; memo hits are not counted', () => {
|
|
|
176
182
|
|
|
177
183
|
const source = yield* StateGroup.select(Inputs, 'instr-calc-count').store
|
|
178
184
|
source.set(6)
|
|
179
|
-
|
|
185
|
+
// Wait on the recompute itself — pulling `get()` would recompute under the
|
|
186
|
+
// poller and hide the propagation that reports the derived state update.
|
|
187
|
+
yield* until(
|
|
188
|
+
() => recorder.calcs.length,
|
|
189
|
+
(n) => n === builds + 1,
|
|
190
|
+
)
|
|
180
191
|
expect(derived.get()).toBe(12)
|
|
181
192
|
expect(recorder.calcs.length).toBe(builds + 1)
|
|
182
193
|
expect(recorder.calcs.every((name) => name === 'InstrDoubled')).toBe(true)
|
|
@@ -194,9 +205,9 @@ it.live('async calc query runs are spanned start-to-settle', () => {
|
|
|
194
205
|
output: S.Number,
|
|
195
206
|
alwaysOn: true,
|
|
196
207
|
}) {}
|
|
208
|
+
const gate = makeFlightGate()
|
|
197
209
|
const FetchedLive = AsyncCalc.live(Fetched, {
|
|
198
|
-
query: (inputs) =>
|
|
199
|
-
Effect.succeed(inputs['instr-query-count'] * 2).pipe(Effect.delay(Duration.millis(5))),
|
|
210
|
+
query: (inputs) => gate.through(Effect.succeed(inputs['instr-query-count'] * 2)),
|
|
200
211
|
})
|
|
201
212
|
|
|
202
213
|
const TestLayer = FetchedLive.pipe(
|
|
@@ -205,8 +216,18 @@ it.live('async calc query runs are spanned start-to-settle', () => {
|
|
|
205
216
|
)
|
|
206
217
|
|
|
207
218
|
return Effect.gen(function* () {
|
|
219
|
+
yield* gate.hold
|
|
208
220
|
const store = yield* Fetched.store
|
|
209
|
-
|
|
221
|
+
// Parked mid-flight: the span is open, so its end must not have run yet.
|
|
222
|
+
yield* gate.awaitEntry(1)
|
|
223
|
+
expect(recorder.queries).toEqual(['InstrFetched'])
|
|
224
|
+
expect(recorder.ends.query).toBe(0)
|
|
225
|
+
|
|
226
|
+
yield* gate.release
|
|
227
|
+
yield* until(
|
|
228
|
+
() => store.get()._tag,
|
|
229
|
+
(tag) => tag === 'Success',
|
|
230
|
+
)
|
|
210
231
|
expect(store.get()._tag).toBe('Success')
|
|
211
232
|
expect(recorder.queries).toEqual(['InstrFetched'])
|
|
212
233
|
expect(recorder.ends.query).toBe(1)
|
|
@@ -221,8 +242,9 @@ it.live('procedure runs are spanned with their channel and trigger tag', () => {
|
|
|
221
242
|
events: [Kicked],
|
|
222
243
|
channel: Lane,
|
|
223
244
|
}) {}
|
|
245
|
+
const gate = makeFlightGate()
|
|
224
246
|
const WorkLive = Procedure.live(Work, function* () {
|
|
225
|
-
yield*
|
|
247
|
+
yield* gate.through(Effect.void)
|
|
226
248
|
})
|
|
227
249
|
|
|
228
250
|
const TestLayer = Layer.mergeAll(Channel.live(Lane), WorkLive).pipe(
|
|
@@ -231,11 +253,20 @@ it.live('procedure runs are spanned with their channel and trigger tag', () => {
|
|
|
231
253
|
)
|
|
232
254
|
|
|
233
255
|
return Effect.gen(function* () {
|
|
256
|
+
yield* gate.hold
|
|
234
257
|
yield* publish('Normal', Event.construct(Kicked, {}))
|
|
235
|
-
|
|
258
|
+
// Parked mid-run: the span opened with its channel and trigger, and stays open.
|
|
259
|
+
yield* gate.awaitEntry(1)
|
|
236
260
|
expect(recorder.procedures).toEqual([
|
|
237
261
|
{ name: 'InstrWork', channel: 'InstrLane', eventTag: 'InstrKicked' },
|
|
238
262
|
])
|
|
263
|
+
expect(recorder.ends.procedure).toBe(0)
|
|
264
|
+
|
|
265
|
+
yield* gate.release
|
|
266
|
+
yield* until(
|
|
267
|
+
() => recorder.ends.procedure,
|
|
268
|
+
(n) => n >= 1,
|
|
269
|
+
)
|
|
239
270
|
expect(recorder.ends.procedure).toBe(1)
|
|
240
271
|
}).pipe(Effect.provide(TestLayer))
|
|
241
272
|
})
|
|
@@ -281,8 +312,14 @@ const InstrScene = scene(SceneRoot, { provide: [SceneApp] })
|
|
|
281
312
|
const runScene = (target: typeof InstrScene) => {
|
|
282
313
|
const layer = target.provide.reduce((a, b) => Layer.merge(a, b))
|
|
283
314
|
const drive = Effect.gen(function* () {
|
|
315
|
+
// Each run builds its own state store, so the fold itself is the barrier —
|
|
316
|
+
// it settles whether or not the run is being recorded.
|
|
317
|
+
const count = yield* StateGroup.select(SceneStates, 'instr-scene-count').store
|
|
284
318
|
yield* publish('High', Event.construct(SceneBumped, {}))
|
|
285
|
-
yield*
|
|
319
|
+
yield* until(
|
|
320
|
+
() => count.get(),
|
|
321
|
+
(n) => n === 1,
|
|
322
|
+
)
|
|
286
323
|
})
|
|
287
324
|
return { layer, drive }
|
|
288
325
|
}
|