@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.
- package/README.md +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +82 -33
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +733 -32
- package/src/calc/asyncCalc.test.ts +397 -225
- package/src/calc/asyncCalc.ts +115 -100
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +13 -16
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +90 -32
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +25 -6
- package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +168 -81
- package/src/compose/compositionTypes.ts +249 -0
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +222 -262
- package/src/feature/feature.typecheck.ts +224 -91
- package/src/feature/featureBinding.ts +130 -20
- 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 +222 -0
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +61 -25
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +11 -11
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/store.test.ts +27 -6
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +536 -354
- package/src/remote/remoteState.ts +145 -106
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +95 -37
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +104 -0
- package/src/runtime/appRuntime.test.ts +187 -111
- package/src/runtime/appRuntime.ts +36 -130
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +39 -12
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +22 -13
- package/src/runtime/instrumentation.test.ts +213 -194
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +84 -7
- package/src/runtime/loop.ts +69 -40
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +39 -8
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +84 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
|
@@ -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,12 +120,9 @@ 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
|
-
}).pipe(
|
|
116
|
-
Effect.provide(TestLayer),
|
|
117
|
-
Logger.withMinimumLogLevel(LogLevel.None),
|
|
118
|
-
)
|
|
125
|
+
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
119
126
|
})
|
|
120
127
|
|
|
121
128
|
it.live('the window resets when the event loop yields — bursty traffic never trips', () => {
|
|
@@ -124,13 +131,26 @@ it.live('the window resets when the event loop yields — bursty traffic never t
|
|
|
124
131
|
class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
|
|
125
132
|
const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
|
|
126
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
|
+
|
|
127
147
|
const captured: Array<EventLoopOverflow> = []
|
|
128
148
|
const budget: EventBudget = {
|
|
129
149
|
errorAt: 50,
|
|
130
150
|
warningAt: Option.none(),
|
|
131
151
|
onOverflow: (error) => captured.push(error),
|
|
132
152
|
}
|
|
133
|
-
const TestLayer = BumpReducerLive.pipe(
|
|
153
|
+
const TestLayer = Layer.mergeAll(BumpReducerLive, Channel.live(Turn), WaitedLive).pipe(
|
|
134
154
|
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
135
155
|
Layer.locally(CurrentEventBudget, budget),
|
|
136
156
|
)
|
|
@@ -145,10 +165,17 @@ it.live('the window resets when the event loop yields — bursty traffic never t
|
|
|
145
165
|
return Effect.gen(function* () {
|
|
146
166
|
const store = yield* Counter.store
|
|
147
167
|
yield* burst(1)
|
|
148
|
-
yield*
|
|
149
|
-
yield*
|
|
150
|
-
|
|
168
|
+
yield* publish('Normal', Event.construct(Yielded, {}))
|
|
169
|
+
yield* until(
|
|
170
|
+
() => turns.n,
|
|
171
|
+
(n) => n >= 1,
|
|
172
|
+
)
|
|
151
173
|
|
|
174
|
+
yield* burst(41)
|
|
175
|
+
yield* until(
|
|
176
|
+
() => ({ count: store.get(), overflows: captured.length }),
|
|
177
|
+
({ count, overflows }) => count === 80 || overflows > 0,
|
|
178
|
+
)
|
|
152
179
|
expect(captured).toHaveLength(0)
|
|
153
180
|
expect(store.get()).toBe(80)
|
|
154
181
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -2,16 +2,17 @@ import { Boolean as Bool, Effect, FiberRef, GlobalValue, MutableRef, Option } fr
|
|
|
2
2
|
import { isDev } from '../internal/env'
|
|
3
3
|
import { EventLoopOverflow } from '../internal/errors'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
readonly
|
|
8
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
|
|
9
|
-
readonly errorAt?: number
|
|
10
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
|
|
11
|
-
readonly warningAt?: number
|
|
12
|
-
}
|
|
5
|
+
interface EventLimitFields {
|
|
6
|
+
readonly errorAt: number
|
|
7
|
+
readonly warningAt: number
|
|
13
8
|
}
|
|
14
9
|
|
|
10
|
+
interface ReformOptionFields {
|
|
11
|
+
readonly maxEventsPerJsTick: Partial<EventLimitFields>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReformOptions extends Partial<ReformOptionFields> {}
|
|
15
|
+
|
|
15
16
|
export interface EventBudget {
|
|
16
17
|
readonly errorAt: number
|
|
17
18
|
readonly warningAt: Option.Option<number>
|
|
@@ -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,6 +1,8 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import { Cause,
|
|
3
|
-
import { Channel,
|
|
2
|
+
import { Cause, Effect, Exit, Layer, LogLevel, Logger, Schema as S, Scope } from 'effect'
|
|
3
|
+
import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
|
+
import { Channels } from '../channel/channel'
|
|
5
|
+
import { until } from '../testkit/flight.testkit'
|
|
4
6
|
|
|
5
7
|
it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
6
8
|
class Counter extends State.make('counter', S.Number) {}
|
|
@@ -9,7 +11,9 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
|
9
11
|
class GoodReducer extends Reducer.make('GoodReducer', { states: [Counter], events: [Good] }) {}
|
|
10
12
|
class BadReducer extends Reducer.make('BadReducer', { states: [Counter], events: [Bad] }) {}
|
|
11
13
|
const GoodLive = Reducer.live(GoodReducer, (n) => n + 1)
|
|
14
|
+
const badRuns = { n: 0 }
|
|
12
15
|
const BadLive = Reducer.live(BadReducer, () => {
|
|
16
|
+
badRuns.n += 1
|
|
13
17
|
throw new Error('boom')
|
|
14
18
|
})
|
|
15
19
|
|
|
@@ -21,16 +25,20 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
|
21
25
|
const store = yield* Counter.store
|
|
22
26
|
|
|
23
27
|
yield* publish('Normal', Event.construct(Bad, {}))
|
|
24
|
-
|
|
28
|
+
// The throwing fold ran and was isolated; its frame wrote nothing.
|
|
29
|
+
yield* until(
|
|
30
|
+
() => badRuns.n,
|
|
31
|
+
(n) => n === 1,
|
|
32
|
+
)
|
|
25
33
|
expect(store.get()).toBe(0)
|
|
26
34
|
|
|
27
35
|
yield* publish('Normal', Event.construct(Good, {}))
|
|
28
|
-
yield*
|
|
36
|
+
yield* until(
|
|
37
|
+
() => store.get(),
|
|
38
|
+
(n) => n === 1,
|
|
39
|
+
)
|
|
29
40
|
expect(store.get()).toBe(1)
|
|
30
|
-
}).pipe(
|
|
31
|
-
Effect.provide(TestLayer),
|
|
32
|
-
Logger.withMinimumLogLevel(LogLevel.None),
|
|
33
|
-
)
|
|
41
|
+
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
34
42
|
})
|
|
35
43
|
|
|
36
44
|
it.effect('two different channels registered under one name are rejected', () => {
|
|
@@ -91,14 +99,15 @@ it.live('remount overlap keeps the channel alive', () => {
|
|
|
91
99
|
const secondScope = yield* Scope.make()
|
|
92
100
|
|
|
93
101
|
yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
|
|
94
|
-
yield* Layer.buildWithScope(
|
|
95
|
-
Layer.mergeAll(Channel.live(SecondLane), WorkLive),
|
|
96
|
-
secondScope,
|
|
97
|
-
)
|
|
102
|
+
yield* Layer.buildWithScope(Layer.mergeAll(Channel.live(SecondLane), WorkLive), secondScope)
|
|
98
103
|
yield* Scope.close(firstScope, Exit.void)
|
|
99
104
|
|
|
100
105
|
yield* publish('Normal', Event.construct(Fired, {}))
|
|
101
|
-
|
|
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
|
+
)
|
|
102
111
|
expect(runs.n).toBe(1)
|
|
103
112
|
|
|
104
113
|
yield* Scope.close(secondScope, Exit.void)
|