@playfast/reform 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -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,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* Effect.sleep(Duration.millis(20))
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* Effect.sleep(Duration.millis(10))
149
- yield* burst(41)
150
- yield* Effect.sleep(Duration.millis(10))
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
- export interface ReformOptions {
6
- // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
7
- readonly maxEventsPerJsTick?: {
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, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S, Scope } from 'effect'
3
- import { Channel, Channels, Engine, Event, Procedure, publish, Reducer, State } from '../index'
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
- 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
+ )
25
33
  expect(store.get()).toBe(0)
26
34
 
27
35
  yield* publish('Normal', Event.construct(Good, {}))
28
- yield* Effect.sleep(Duration.millis(10))
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
- 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
+ )
102
111
  expect(runs.n).toBe(1)
103
112
 
104
113
  yield* Scope.close(secondScope, Exit.void)