@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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.test.ts +26 -23
  3. package/src/calc/asyncCalc.invalidate.test.ts +258 -55
  4. package/src/calc/asyncCalc.test.ts +225 -85
  5. package/src/calc/asyncCalc.ts +27 -141
  6. package/src/calc/asyncCalcTypes.ts +147 -0
  7. package/src/calc/calc.test.ts +10 -11
  8. package/src/calc/calcFamily.test.ts +12 -12
  9. package/src/channel/channel.ts +8 -106
  10. package/src/channel/procedures.ts +108 -0
  11. package/src/compose/composition.ts +49 -246
  12. package/src/compose/compositionTypes.ts +249 -0
  13. package/src/event/event.fromSource.test.ts +12 -12
  14. package/src/feature/feature.ts +50 -1210
  15. package/src/feature/feature.typecheck.ts +2 -61
  16. package/src/feature/featureBinding.ts +158 -0
  17. package/src/feature/featureClass.ts +162 -0
  18. package/src/feature/featureConfig.ts +192 -0
  19. package/src/feature/featureFactory.ts +163 -0
  20. package/src/feature/featureModule.ts +136 -0
  21. package/src/feature/featureModule.typecheck.ts +63 -0
  22. package/src/feature/featureNativeTree.ts +152 -0
  23. package/src/feature/featureRequirement.ts +82 -0
  24. package/src/feature/featureVariants.ts +234 -0
  25. package/src/feature/mountFeature.ts +54 -0
  26. package/src/graph/closure.ts +45 -226
  27. package/src/graph/services.ts +95 -0
  28. package/src/graph/summary.ts +134 -0
  29. package/src/internal/queryDriver.ts +20 -89
  30. package/src/internal/queryDriverHydrate.ts +45 -0
  31. package/src/internal/queryDriverTypes.ts +8 -10
  32. package/src/internal/store.test.ts +27 -6
  33. package/src/remote/pendingQueue.ts +145 -0
  34. package/src/remote/remoteState.test.ts +28 -22
  35. package/src/remote/remoteState.ts +63 -489
  36. package/src/remote/remoteStateMake.ts +97 -0
  37. package/src/remote/remoteStateSend.ts +122 -0
  38. package/src/remote/remoteStateTypes.ts +155 -0
  39. package/src/runtime/appRuntime.activation.test.ts +9 -5
  40. package/src/runtime/appRuntime.test.ts +40 -16
  41. package/src/runtime/appRuntime.ts +17 -472
  42. package/src/runtime/capturedAppRuntime.ts +274 -0
  43. package/src/runtime/eventBudget.test.ts +38 -8
  44. package/src/runtime/featureMount.ts +94 -0
  45. package/src/runtime/hardening.test.ts +18 -4
  46. package/src/runtime/instrumentation.test.ts +49 -12
  47. package/src/runtime/loop.test.ts +51 -10
  48. package/src/runtime/runtimeHandle.ts +124 -0
  49. package/src/state/stateFamily.test.ts +25 -8
  50. 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
- yield* Effect.sleep(Duration.millis(20))
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
- yield* Effect.sleep(Duration.millis(20))
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* Effect.sleep(Duration.millis(20))
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* Effect.sleep(Duration.millis(10))
146
- yield* burst(41)
147
- yield* Effect.sleep(Duration.millis(10))
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, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S, Scope } from 'effect'
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
- yield* Effect.sleep(Duration.millis(10))
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* Effect.sleep(Duration.millis(10))
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
- yield* Effect.sleep(Duration.millis(20))
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 { Duration, Effect, Layer, Schema as S } from 'effect'
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* tick()
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
- yield* tick()
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
- yield* tick()
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
- yield* tick(40)
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* tick(5)
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
- yield* tick(40)
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* tick()
319
+ yield* until(
320
+ () => count.get(),
321
+ (n) => n === 1,
322
+ )
286
323
  })
287
324
  return { layer, drive }
288
325
  }