@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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- 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
|
+
}
|
package/src/runtime/bus.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|