@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.
- 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 +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- 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 +72 -174
- 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 -229
- 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 +42 -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 +0 -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 +40 -83
- 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 +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- 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
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
73
|
-
const layer =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
//
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
const close =
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
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
|
+
})
|