@playfast/reform 0.1.0 → 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 +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  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 +78 -173
  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 -202
  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 +48 -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 +10 -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 +131 -0
  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 +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  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
@@ -0,0 +1,131 @@
1
+ import { Cause, Context, Effect, Exit, Fiber, Layer, ManagedRuntime, Option, Scope } from 'effect'
2
+ import { FeatureLoadFailed, forceSync } from '../internal/errors'
3
+ import { type FeatureBinding, mountFeature } from '../feature/feature'
4
+ import { type Node } from '../ui/node'
5
+ import { type MountedServices, type Scene, sceneInstrumentation } from '../scene/scene'
6
+ import { publish, type Priority, type Tagged } from './bus'
7
+ import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
8
+ import { type Instrumentation } from './instrumentation'
9
+
10
+ export interface FeatureMountHandlersExternalApi {
11
+ readonly props?: unknown
12
+ readonly onLive: (runtime: RuntimeHandle) => void
13
+ readonly onFailed: (error: FeatureLoadFailed) => void
14
+ }
15
+ export type FeatureMountHandlers = FeatureMountHandlersExternalApi
16
+
17
+ export interface RuntimeHandle {
18
+ read<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service
19
+ runRender(effect: Effect.Effect<Node, never, never>): Node
20
+ dispatch(priority: Priority, event: Tagged): void
21
+ mountFeature(binding: FeatureBinding, handlers: FeatureMountHandlers): () => void
22
+ readonly instrumentation: Instrumentation
23
+ }
24
+
25
+ export interface AppRuntime extends RuntimeHandle {
26
+ boot(): void
27
+ dispose(): void
28
+ isDisposed(): boolean
29
+ }
30
+
31
+ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRuntime => {
32
+ const instrumentation = sceneInstrumentation(scene)
33
+ const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
34
+ const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
35
+ const runtime = ManagedRuntime.make(layer)
36
+ const rootContext = forceSync(() => runtime.runSync(Effect.context<MountedServices>()))
37
+
38
+ // Inert past dispose: host may still render tearing-down trees; forked fibers outlive cleanup
39
+ const status = { disposed: false, booted: false }
40
+
41
+ // Self-contained Effect runs on default runtime after dispose (never throw mid-render)
42
+ const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
43
+ status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
44
+
45
+ const dispatch = (priority: Priority, event: Tagged): void => {
46
+ if (status.disposed) {
47
+ return
48
+ }
49
+ runtime.runFork(publish(priority, event))
50
+ }
51
+
52
+ const makeChildRuntime = <R>(context: Context.Context<R>): RuntimeHandle => ({
53
+ read: <Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service =>
54
+ Context.unsafeGet(context, tag),
55
+ runRender,
56
+ dispatch,
57
+ mountFeature: (binding, handlers) => mountFeatureOnto(context, binding, handlers),
58
+ instrumentation,
59
+ })
60
+
61
+ const mountFeatureOnto = <R>(
62
+ hostContext: Context.Context<R>,
63
+ binding: FeatureBinding,
64
+ handlers: FeatureMountHandlers,
65
+ ): (() => void) => {
66
+ if (status.disposed) {
67
+ return () => {}
68
+ }
69
+ const scope = Effect.runSync(Scope.make())
70
+ const fiber = runtime.runFork(
71
+ mountFeature(binding, hostContext, handlers.props).pipe(
72
+ Effect.provideService(Scope.Scope, scope),
73
+ Effect.matchCause({
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
+ },
85
+ onSuccess: (context) => handlers.onLive(makeChildRuntime(context)),
86
+ }),
87
+ ),
88
+ )
89
+ return () => {
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
+ )
96
+ if (status.disposed) {
97
+ Effect.runFork(close)
98
+ } else {
99
+ runtime.runFork(close)
100
+ }
101
+ }
102
+ }
103
+
104
+ const app: AppRuntime = {
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),
108
+ runRender,
109
+ dispatch,
110
+ mountFeature: (binding, handlers) => mountFeatureOnto(rootContext, binding, handlers),
111
+ boot: () => {
112
+ if (status.disposed || status.booted) {
113
+ return
114
+ }
115
+ status.booted = true
116
+ Option.fromNullable(scene.boot)
117
+ .pipe(Option.getOrElse((): ReadonlyArray<Tagged> => []))
118
+ .forEach((event) => dispatch('High', event))
119
+ },
120
+ dispose: () => {
121
+ if (status.disposed) {
122
+ return
123
+ }
124
+ status.disposed = true
125
+ void runtime.dispose()
126
+ },
127
+ isDisposed: () => status.disposed,
128
+ instrumentation,
129
+ }
130
+ return app
131
+ }
@@ -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
+ })