@playfast/reform 1.0.1 → 1.1.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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,96 +1,47 @@
1
- import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, Option, Scope } from 'effect'
1
+ import { Cause, Context, Effect, Exit, Fiber, Layer, ManagedRuntime, Option, Scope } from 'effect'
2
2
  import { FeatureLoadFailed, forceSync } from '../internal/errors'
3
3
  import { type FeatureBinding, mountFeature } from '../feature/feature'
4
4
  import { type Node } from '../ui/node'
5
5
  import { type MountedServices, type Scene, sceneInstrumentation } from '../scene/scene'
6
6
  import { publish, type Priority, type Tagged } from './bus'
7
+ import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
7
8
  import { type Instrumentation } from './instrumentation'
8
9
 
9
- // The renderer-neutral scene runtime: one `ManagedRuntime` over a scene's closed
10
- // layers, with the read/render/dispatch/feature-mount surface every host shares.
11
- // `@playfast/reform-react` builds its React lifecycle (StrictMode-deferred
12
- // dispose, providers) ON TOP of this; headless tests, proofs, and drive scripts
13
- // consume it directly — the "testing runtime" with no React and no harness.
14
-
15
- /** The lazy-feature lifecycle callbacks a host hands to `mountFeature`. */
16
- export interface FeatureMountHandlers {
10
+ export interface FeatureMountHandlersExternalApi {
11
+ readonly props?: unknown
17
12
  readonly onLive: (runtime: RuntimeHandle) => void
18
13
  readonly onFailed: (error: FeatureLoadFailed) => void
19
14
  }
15
+ export type FeatureMountHandlers = FeatureMountHandlersExternalApi
20
16
 
21
- /**
22
- * The capabilities a host needs from a built reform runtime — what `Compose`
23
- * (React) and the proof facade depend on. A feature mount yields a child handle
24
- * over the feature's merged context, sharing the parent's render/dispatch.
25
- */
26
17
  export interface RuntimeHandle {
27
- /**
28
- * Resolve any reform DI tag (a composition's mounted logic, a slot's wired
29
- * child) from the app context. The mounted runtime provides every reform
30
- * service, so this never fails for tags from the same app.
31
- */
32
- read<A>(tag: Context.Tag<A, A>): A
33
- /** Run a fully-provided, synchronous render to its node (D2). */
18
+ read<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service
34
19
  runRender(effect: Effect.Effect<Node, never, never>): Node
35
- /** Dispatch a runtime/boot event onto the bus. */
36
20
  dispatch(priority: Priority, event: Tagged): void
37
- /**
38
- * Mount a feature against this runtime: load its module (lazy = dynamic import),
39
- * build its `.live` layer on a fresh scope sharing this runtime's engine, and
40
- * call `onLive` with a child runtime that reads the feature's composition/stores
41
- * (and the shared services), or `onFailed` with a typed `FeatureLoadFailed`.
42
- * Returns a disposer that closes the feature's scope — stopping its fibers and
43
- * reclaiming its reducers/stores.
44
- */
45
21
  mountFeature(binding: FeatureBinding, handlers: FeatureMountHandlers): () => void
46
- /**
47
- * The scene's profiling hooks (`noopInstrumentation` unless `profileScene`d).
48
- * On the handle (not only the app runtime) because render spans are recorded
49
- * by whatever holds the handle — React `Compose`, the proof engine — and a
50
- * feature's child handle must record into the same profiler as its parent.
51
- */
52
22
  readonly instrumentation: Instrumentation
53
23
  }
54
24
 
55
- /** A built scene runtime: the handle plus its boot/dispose lifecycle. */
56
25
  export interface AppRuntime extends RuntimeHandle {
57
- /** Dispatch the scene's boot events, once — later calls are no-ops. */
58
26
  boot(): void
59
- /** Interrupt the runtime and latch the handle inert. Immediate and idempotent. */
60
27
  dispose(): void
61
- /** True once disposed, so a host can rebuild rather than reuse a dead runtime. */
62
28
  isDisposed(): boolean
63
29
  }
64
30
 
65
- /**
66
- * Build the concrete runtime over a `ManagedRuntime` made from a scene's closed
67
- * layers. The mounted runtime provides every reform tag, so reading any one
68
- * needs no further context; we erase the requirement the generic tag carries.
69
- */
70
- export const makeAppRuntime = (scene: Scene): AppRuntime => {
31
+ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRuntime => {
71
32
  const instrumentation = sceneInstrumentation(scene)
72
- // A scene always provides at least one layer; merge them into the app layer.
73
- const layer = scene.provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
33
+ const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
34
+ const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
74
35
  const runtime = ManagedRuntime.make(layer)
75
- // The full app context (all services), captured once. A feature's `.live` layer
76
- // is built against this so it shares the one engine (bus + registries). Typed as
77
- // the host's `MountedServices` subset; at runtime it carries every service.
78
36
  const rootContext = forceSync(() => runtime.runSync(Effect.context<MountedServices>()))
79
37
 
80
- // Past actual dispose the `ManagedRuntime` is interrupted and every `runSync` /
81
- // `runFork` against it throws `ManagedRuntime disposed`. A host can still render
82
- // a tearing-down tree, and forked fibers (a RemoteState load, a boot dispatch)
83
- // outlive the host cleanup — so the runtime is made inert past dispose instead
84
- // of throwing into a render. A `const` latch mutated in place.
38
+ // Inert past dispose: host may still render tearing-down trees; forked fibers outlive cleanup
85
39
  const status = { disposed: false, booted: false }
86
40
 
87
- // A render is a self-contained `Effect<Node, never, never>` (no service needs),
88
- // so once the managed runtime is gone it still runs on the default runtime —
89
- // yielding a real node from the current store values, never a throw mid-render.
41
+ // Self-contained Effect runs on default runtime after dispose (never throw mid-render)
90
42
  const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
91
43
  status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
92
44
 
93
- // A dispatch past dispose has no live bus to reach; drop it.
94
45
  const dispatch = (priority: Priority, event: Tagged): void => {
95
46
  if (status.disposed) {
96
47
  return
@@ -98,43 +49,50 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
98
49
  runtime.runFork(publish(priority, event))
99
50
  }
100
51
 
101
- // A child runtime over a feature's merged context (feature services + the shared
102
- // engine). Reads resolve from that context; renders/dispatch share the
103
- // inert-past-dispose helpers above.
104
- const makeChildRuntime = (context: Context.Context<unknown>): RuntimeHandle => ({
105
- read: <A>(tag: Context.Tag<A, A>): A => Context.unsafeGet(context, tag),
52
+ const makeChildRuntime = <R>(context: Context.Context<R>): RuntimeHandle => ({
53
+ read: <Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service =>
54
+ Context.unsafeGet(context, tag),
106
55
  runRender,
107
56
  dispatch,
108
- mountFeature: (binding, handlers) => mountFeatureOnto(binding, handlers),
57
+ mountFeature: (binding, handlers) => mountFeatureOnto(context, binding, handlers),
109
58
  instrumentation,
110
59
  })
111
60
 
112
- const mountFeatureOnto = (
61
+ const mountFeatureOnto = <R>(
62
+ hostContext: Context.Context<R>,
113
63
  binding: FeatureBinding,
114
64
  handlers: FeatureMountHandlers,
115
65
  ): (() => void) => {
116
- // Don't mount onto a runtime that's already torn down — the tree is unmounting.
117
66
  if (status.disposed) {
118
67
  return () => {}
119
68
  }
120
69
  const scope = Effect.runSync(Scope.make())
121
- runtime.runFork(
122
- mountFeature(binding, rootContext).pipe(
70
+ const fiber = runtime.runFork(
71
+ mountFeature(binding, hostContext, handlers.props).pipe(
123
72
  Effect.provideService(Scope.Scope, scope),
124
73
  Effect.matchCause({
125
- onFailure: (cause) =>
126
- handlers.onFailed(
127
- Option.getOrElse(Cause.failureOption(cause), () => new FeatureLoadFailed({ cause })),
128
- ),
74
+ onFailure: (cause) => {
75
+ // Disposer-cancelled mounts are not failures
76
+ if (!Cause.isInterruptedOnly(cause)) {
77
+ handlers.onFailed(
78
+ Option.getOrElse(
79
+ Cause.failureOption(cause),
80
+ () => new FeatureLoadFailed({ cause }),
81
+ ),
82
+ )
83
+ }
84
+ },
129
85
  onSuccess: (context) => handlers.onLive(makeChildRuntime(context)),
130
86
  }),
131
87
  ),
132
88
  )
133
89
  return () => {
134
- // The scope's finalizers (reducer/store reclaim) are self-contained, so when
135
- // the managed runtime is already disposed they close on the default runtime
136
- // rather than throwing through a dead `runFork`.
137
- const close = Scope.close(scope, Exit.succeed(undefined))
90
+ // Interrupt sync before scheduler: Scope.close alone does not interrupt (StrictMode)
91
+ fiber.unsafeInterruptAsFork(fiber.id())
92
+ // Await mount so mid-build finalizers register before scope reclaim; close on default runtime if disposed
93
+ const close = Fiber.await(fiber).pipe(
94
+ Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
95
+ )
138
96
  if (status.disposed) {
139
97
  Effect.runFork(close)
140
98
  } else {
@@ -144,13 +102,12 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
144
102
  }
145
103
 
146
104
  const app: AppRuntime = {
147
- // Read from the captured context (it carries every service), not
148
- // `runtime.runSync(tag)`, so a read from a tearing-down tree can't throw
149
- // `ManagedRuntime disposed` — and it drops the cast the runSync form needed.
150
- read: <A>(tag: Context.Tag<A, A>): A => Context.unsafeGet(rootContext, tag),
105
+ // Read captured context so tearing-down trees never hit ManagedRuntime disposed
106
+ read: <Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service =>
107
+ Context.unsafeGet(rootContext, tag),
151
108
  runRender,
152
109
  dispatch,
153
- mountFeature: (binding, handlers) => mountFeatureOnto(binding, handlers),
110
+ mountFeature: (binding, handlers) => mountFeatureOnto(rootContext, binding, handlers),
154
111
  boot: () => {
155
112
  if (status.disposed || status.booted) {
156
113
  return
@@ -1,22 +1,14 @@
1
1
  import { Context, Effect, Layer, PubSub } from 'effect'
2
2
 
3
- /** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
4
3
  export interface Tagged {
5
4
  readonly _tag: string
6
5
  }
7
6
 
8
- /**
9
- * Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
10
- * loop and channels only route events whose tag is in a handler's `handles`, so
11
- * at runtime the envelope IS one of those events; the registries store it as the
12
- * generic `Tagged`, and this restores the typed view. The one documented home
13
- * for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
14
- */
7
+ // Re-narrow routed Tagged to handler event union (handles set guarantees membership)
15
8
  export const narrowHandled = <E>(event: Tagged): E =>
16
9
  // oxlint-disable-next-line reform-rules/no-type-assertion -- documented runtime re-narrow of a routed Tagged envelope to its handler's declared event union (see jsdoc above)
17
10
  event as never
18
11
 
19
- /** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
20
12
  export type Priority = 'High' | 'Normal'
21
13
 
22
14
  export interface Envelope {
@@ -28,14 +20,9 @@ const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Co
28
20
  'reform/Bus',
29
21
  )<Bus, PubSub.PubSub<Envelope>>()
30
22
 
31
- /** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
32
23
  export class Bus extends BusBase {}
33
24
 
34
- // Unbounded so `publish` never suspends: UI triggers dispatch synchronously
35
- // (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
36
- // backpressure to the UI thread. The single drain loop empties the bus every
37
- // microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
38
- // memory is preferred over never dropping (publish stays synchronous either way).
25
+ // Unbounded so publish never suspends (UI runSync); drain empties every microtask
39
26
  export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
40
27
 
41
28
  export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
@@ -0,0 +1,155 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, LogLevel, Logger, Option, Schema as S } from 'effect'
3
+ import { Engine, Event, publish, Reducer, State } from '../index'
4
+ import type { EventLoopOverflow } from '../internal/errors'
5
+ import {
6
+ CurrentEventBudget,
7
+ type EventBudget,
8
+ makeDefaultBudget,
9
+ makeTickCounter,
10
+ resolveEventBudget,
11
+ } from './eventBudget'
12
+
13
+ it('defaults resolve to errorAt 10_000, with the warning gated on dev', () => {
14
+ expect(makeDefaultBudget(true).errorAt).toBe(10_000)
15
+ expect(makeDefaultBudget(true).warningAt).toEqual(Option.some(500))
16
+ expect(makeDefaultBudget(false).warningAt).toEqual(Option.none())
17
+
18
+ const resolved = resolveEventBudget({})
19
+ expect(resolved.errorAt).toBe(10_000)
20
+
21
+ expect(resolveEventBudget({ maxEventsPerJsTick: { errorAt: 42 } }).errorAt).toBe(42)
22
+ expect(resolveEventBudget({ maxEventsPerJsTick: { warningAt: 7 } }).errorAt).toBe(10_000)
23
+ expect(resolveEventBudget({ maxEventsPerJsTick: { warningAt: 7 } }).warningAt).toEqual(
24
+ Option.some(7),
25
+ )
26
+ })
27
+
28
+ it('the tick counter accumulates across frames and warns exactly once', () => {
29
+ const budget: EventBudget = {
30
+ errorAt: 1_000,
31
+ warningAt: Option.some(5),
32
+ onOverflow: () => {},
33
+ }
34
+ const counter = makeTickCounter(budget)
35
+ expect(counter.add(3)).toEqual({ total: 3, warnNow: false })
36
+ expect(counter.add(3)).toEqual({ total: 6, warnNow: true })
37
+ expect(counter.add(3)).toEqual({ total: 9, warnNow: false })
38
+ })
39
+
40
+ it.live('crossing the soft limit logs one warning; the frame still folds', () => {
41
+ class Counter extends State.make('counter', S.Number) {}
42
+ class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
43
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
44
+ const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
45
+
46
+ const budget: EventBudget = {
47
+ errorAt: 1_000,
48
+ warningAt: Option.some(5),
49
+ onOverflow: () => {},
50
+ }
51
+ const TestLayer = BumpReducerLive.pipe(
52
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
53
+ Layer.locally(CurrentEventBudget, budget),
54
+ )
55
+
56
+ const warnings: Array<string> = []
57
+ const recordingLogger = Logger.replace(
58
+ Logger.defaultLogger,
59
+ Logger.make(({ logLevel, message }) => {
60
+ if (logLevel._tag === 'Warning') {
61
+ warnings.push(String(message))
62
+ }
63
+ }),
64
+ )
65
+
66
+ return Effect.gen(function* () {
67
+ const store = yield* Counter.store
68
+ yield* Effect.forEach(
69
+ Array.from({ length: 10 }, (_, index) => index + 1),
70
+ (to) => publish('Normal', Event.construct(Bumped, { to })),
71
+ { discard: true },
72
+ )
73
+ yield* Effect.sleep(Duration.millis(20))
74
+
75
+ expect(store.get()).toBe(10)
76
+ const budgetWarnings = warnings.filter((message) => message.includes('reform:'))
77
+ expect(budgetWarnings).toHaveLength(1)
78
+ }).pipe(Effect.provide(TestLayer), Effect.provide(recordingLogger))
79
+ })
80
+
81
+ it.live('crossing the hard limit halts the runtime with EventLoopOverflow', () => {
82
+ class Counter extends State.make('counter', S.Number) {}
83
+ class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
84
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
85
+ const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
86
+
87
+ const captured: Array<EventLoopOverflow> = []
88
+ const budget: EventBudget = {
89
+ errorAt: 50,
90
+ warningAt: Option.none(),
91
+ onOverflow: (error) => captured.push(error),
92
+ }
93
+ const TestLayer = BumpReducerLive.pipe(
94
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
95
+ Layer.locally(CurrentEventBudget, budget),
96
+ )
97
+
98
+ return Effect.gen(function* () {
99
+ const store = yield* Counter.store
100
+ yield* Effect.forEach(
101
+ Array.from({ length: 60 }, (_, index) => index + 1),
102
+ (to) => publish('Normal', Event.construct(Bumped, { to })),
103
+ { discard: true },
104
+ )
105
+ yield* Effect.sleep(Duration.millis(20))
106
+
107
+ expect(captured).toHaveLength(1)
108
+ expect(captured[0]?._tag).toBe('reform/EventLoopOverflow')
109
+ expect(captured[0]!.count).toBeGreaterThan(captured[0]!.limit)
110
+
111
+ const before = store.get()
112
+ yield* publish('Normal', Event.construct(Bumped, { to: 9_999 }))
113
+ yield* Effect.sleep(Duration.millis(20))
114
+ expect(store.get()).toBe(before)
115
+ }).pipe(
116
+ Effect.provide(TestLayer),
117
+ Logger.withMinimumLogLevel(LogLevel.None),
118
+ )
119
+ })
120
+
121
+ it.live('the window resets when the event loop yields — bursty traffic never trips', () => {
122
+ class Counter extends State.make('counter', S.Number) {}
123
+ class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
124
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
125
+ const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
126
+
127
+ const captured: Array<EventLoopOverflow> = []
128
+ const budget: EventBudget = {
129
+ errorAt: 50,
130
+ warningAt: Option.none(),
131
+ onOverflow: (error) => captured.push(error),
132
+ }
133
+ const TestLayer = BumpReducerLive.pipe(
134
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
135
+ Layer.locally(CurrentEventBudget, budget),
136
+ )
137
+
138
+ const burst = (from: number) =>
139
+ Effect.forEach(
140
+ Array.from({ length: 40 }, (_, index) => from + index),
141
+ (to) => publish('Normal', Event.construct(Bumped, { to })),
142
+ { discard: true },
143
+ )
144
+
145
+ return Effect.gen(function* () {
146
+ const store = yield* Counter.store
147
+ yield* burst(1)
148
+ yield* Effect.sleep(Duration.millis(10))
149
+ yield* burst(41)
150
+ yield* Effect.sleep(Duration.millis(10))
151
+
152
+ expect(captured).toHaveLength(0)
153
+ expect(store.get()).toBe(80)
154
+ }).pipe(Effect.provide(TestLayer))
155
+ })
@@ -0,0 +1,119 @@
1
+ import { Boolean as Bool, Effect, FiberRef, GlobalValue, MutableRef, Option } from 'effect'
2
+ import { isDev } from '../internal/env'
3
+ import { EventLoopOverflow } from '../internal/errors'
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
+ }
13
+ }
14
+
15
+ export interface EventBudget {
16
+ readonly errorAt: number
17
+ readonly warningAt: Option.Option<number>
18
+ readonly onOverflow: (error: EventLoopOverflow) => void
19
+ }
20
+
21
+ const rethrowOverflow = (error: EventLoopOverflow): void =>
22
+ queueMicrotask(() => {
23
+ // oxlint-disable-next-line reform-rules/no-throw -- only way to surface the halt to the host's global error handler
24
+ throw error
25
+ })
26
+
27
+ const DEFAULT_ERROR_AT = 10_000
28
+ const DEFAULT_WARNING_AT = 500
29
+
30
+ export const makeDefaultBudget = (dev: boolean): EventBudget => ({
31
+ errorAt: DEFAULT_ERROR_AT,
32
+ warningAt: Bool.match(dev, {
33
+ onTrue: () => Option.some(DEFAULT_WARNING_AT),
34
+ onFalse: () => Option.none<number>(),
35
+ }),
36
+ onOverflow: rethrowOverflow,
37
+ })
38
+
39
+ export const defaultEventBudget: EventBudget = makeDefaultBudget(isDev)
40
+
41
+ export const resolveEventBudget = (options: ReformOptions): EventBudget => {
42
+ const limits = Option.fromNullable(options.maxEventsPerJsTick)
43
+ return {
44
+ errorAt: Option.getOrElse(
45
+ Option.flatMapNullable(limits, (limit) => limit.errorAt),
46
+ () => defaultEventBudget.errorAt,
47
+ ),
48
+ warningAt: Option.orElse(
49
+ Option.flatMapNullable(limits, (limit) => limit.warningAt),
50
+ () => defaultEventBudget.warningAt,
51
+ ),
52
+ onOverflow: defaultEventBudget.onOverflow,
53
+ }
54
+ }
55
+
56
+ export const CurrentEventBudget: FiberRef.FiberRef<EventBudget> = GlobalValue.globalValue(
57
+ Symbol.for('reform/CurrentEventBudget'),
58
+ () => FiberRef.unsafeMake<EventBudget>(defaultEventBudget),
59
+ )
60
+
61
+ export const resolveCurrentEventBudget: Effect.Effect<EventBudget> =
62
+ FiberRef.get(CurrentEventBudget)
63
+
64
+ export interface TickSample {
65
+ readonly total: number
66
+ readonly warnNow: boolean
67
+ }
68
+
69
+ export interface TickCounter {
70
+ readonly add: (eventCount: number) => TickSample
71
+ }
72
+
73
+ export const makeTickCounter = (budget: EventBudget): TickCounter => {
74
+ const count = MutableRef.make(0)
75
+ const armed = MutableRef.make(false)
76
+ const warned = MutableRef.make(false)
77
+ const reset = (): void => {
78
+ MutableRef.set(count, 0)
79
+ MutableRef.set(armed, false)
80
+ MutableRef.set(warned, false)
81
+ }
82
+ return {
83
+ add: (eventCount) => {
84
+ if (!MutableRef.get(armed)) {
85
+ MutableRef.set(armed, true)
86
+ // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- the macrotask boundary IS the tick window; only a real timer can observe "the event loop yielded" from this synchronous hot path
87
+ setTimeout(reset, 0)
88
+ }
89
+ const total = MutableRef.get(count) + eventCount
90
+ MutableRef.set(count, total)
91
+ const crossed = Option.match(budget.warningAt, {
92
+ onNone: () => false,
93
+ onSome: (warningAt) => total >= warningAt,
94
+ })
95
+ const warnNow = crossed && !MutableRef.get(warned)
96
+ if (warnNow) {
97
+ MutableRef.set(warned, true)
98
+ }
99
+ return { total, warnNow }
100
+ },
101
+ }
102
+ }
103
+
104
+ export const enforceBudget: (
105
+ budget: EventBudget,
106
+ sample: TickSample,
107
+ ) => Effect.Effect<void, EventLoopOverflow> = Effect.fn('enforceBudget')(function* (
108
+ budget: EventBudget,
109
+ sample: TickSample,
110
+ ): Effect.fn.Return<void, EventLoopOverflow> {
111
+ if (sample.total > budget.errorAt) {
112
+ return yield* Effect.fail(new EventLoopOverflow({ count: sample.total, limit: budget.errorAt }))
113
+ }
114
+ if (sample.warnNow) {
115
+ yield* Effect.logWarning(
116
+ `reform: ${sample.total} events processed without yielding to the event loop — possible event feedback loop`,
117
+ )
118
+ }
119
+ })
@@ -1,10 +1,6 @@
1
1
  import { expect, it } from '@effect/vitest'
2
- import { Cause, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S } from 'effect'
3
- import { Channel, Engine, Event, publish, Reducer, State } from '../index'
4
-
5
- // Robustness guards for the drain loop and registries: a buggy reducer must not
6
- // take down the bus, and a name clash that would silently drop a registration
7
- // must be rejected.
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'
8
4
 
9
5
  it.live('a reducer that throws is isolated; the loop keeps draining', () => {
10
6
  class Counter extends State.make('counter', S.Number) {}
@@ -24,18 +20,15 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
24
20
  return Effect.gen(function* () {
25
21
  const store = yield* Counter.store
26
22
 
27
- // A fold that throws is caught and logged — the drain fiber survives.
28
23
  yield* publish('Normal', Event.construct(Bad, {}))
29
24
  yield* Effect.sleep(Duration.millis(10))
30
25
  expect(store.get()).toBe(0)
31
26
 
32
- // A later, healthy event still processes: the bus did not go deaf.
33
27
  yield* publish('Normal', Event.construct(Good, {}))
34
28
  yield* Effect.sleep(Duration.millis(10))
35
29
  expect(store.get()).toBe(1)
36
30
  }).pipe(
37
31
  Effect.provide(TestLayer),
38
- // The isolated failure logs an error; silence it so the passing test is quiet.
39
32
  Logger.withMinimumLogLevel(LogLevel.None),
40
33
  )
41
34
  })
@@ -56,6 +49,19 @@ it.effect('two different channels registered under one name are rejected', () =>
56
49
  })
57
50
  })
58
51
 
52
+ it.effect('structurally identical channel policies do not die', () => {
53
+ class LatestA extends Channel.make('SamePolicy', { policy: { _tag: 'latest' } }) {}
54
+ class LatestB extends Channel.make('SamePolicy', { policy: { _tag: 'latest' } }) {}
55
+ const layer = Layer.mergeAll(Channel.live(LatestA), Channel.live(LatestB)).pipe(
56
+ Layer.provideMerge(Engine),
57
+ )
58
+
59
+ return Effect.gen(function* () {
60
+ const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
61
+ expect(Exit.isSuccess(exit)).toBe(true)
62
+ })
63
+ })
64
+
59
65
  it.effect('the same channel registered twice is idempotent (shared lane)', () => {
60
66
  class Lane extends Channel.make('Lane', { policy: { _tag: 'exclusive' } }) {}
61
67
  const layer = Layer.mergeAll(Channel.live(Lane), Channel.live(Lane)).pipe(
@@ -67,3 +73,62 @@ it.effect('the same channel registered twice is idempotent (shared lane)', () =>
67
73
  expect(Exit.isSuccess(exit)).toBe(true)
68
74
  })
69
75
  })
76
+
77
+ it.live('remount overlap keeps the channel alive', () => {
78
+ class Fired extends Event.make('OverlapFired', S.Struct({})) {}
79
+ class FirstLane extends Channel.make('OverlapLane', { policy: { _tag: 'latest' } }) {}
80
+ class SecondLane extends Channel.make('OverlapLane', { policy: { _tag: 'latest' } }) {}
81
+ class Work extends Procedure.make('OverlapWork', { events: [Fired], channel: SecondLane }) {}
82
+
83
+ const runs = { n: 0 }
84
+ const WorkLive = Procedure.live(Work, function* () {
85
+ runs.n += 1
86
+ })
87
+
88
+ return Effect.gen(function* () {
89
+ const channels = yield* Channels
90
+ const firstScope = yield* Scope.make()
91
+ const secondScope = yield* Scope.make()
92
+
93
+ yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
94
+ yield* Layer.buildWithScope(
95
+ Layer.mergeAll(Channel.live(SecondLane), WorkLive),
96
+ secondScope,
97
+ )
98
+ yield* Scope.close(firstScope, Exit.void)
99
+
100
+ yield* publish('Normal', Event.construct(Fired, {}))
101
+ yield* Effect.sleep(Duration.millis(20))
102
+ expect(runs.n).toBe(1)
103
+
104
+ yield* Scope.close(secondScope, Exit.void)
105
+ expect(channels.byName.has('OverlapLane')).toBe(false)
106
+ }).pipe(Effect.provide(Engine))
107
+ })
108
+
109
+ it.effect('last-release cleanup allows a fresh channel registration', () => {
110
+ class FirstLane extends Channel.make('CleanupLane', { policy: { _tag: 'merge' } }) {}
111
+ class SecondLane extends Channel.make('CleanupLane', { policy: { _tag: 'merge' } }) {}
112
+ class FreshLane extends Channel.make('CleanupLane', { policy: { _tag: 'merge' } }) {}
113
+
114
+ return Effect.gen(function* () {
115
+ const channels = yield* Channels
116
+ const firstScope = yield* Scope.make()
117
+ const secondScope = yield* Scope.make()
118
+ const freshScope = yield* Scope.make()
119
+
120
+ yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
121
+ yield* Layer.buildWithScope(Channel.live(SecondLane), secondScope)
122
+ yield* Scope.close(firstScope, Exit.void)
123
+ expect(channels.byName.has('CleanupLane')).toBe(true)
124
+
125
+ yield* Scope.close(secondScope, Exit.void)
126
+ expect(channels.byName.has('CleanupLane')).toBe(false)
127
+
128
+ yield* Layer.buildWithScope(Channel.live(FreshLane), freshScope)
129
+ expect(channels.byName.has('CleanupLane')).toBe(true)
130
+
131
+ yield* Scope.close(freshScope, Exit.void)
132
+ expect(channels.byName.has('CleanupLane')).toBe(false)
133
+ }).pipe(Effect.provide(Engine))
134
+ })