@playfast/reform 1.0.1 → 1.2.0
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 +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- 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 +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
|
@@ -0,0 +1,152 @@
|
|
|
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(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it.live('the window resets when the event loop yields — bursty traffic never trips', () => {
|
|
119
|
+
class Counter extends State.make('counter', S.Number) {}
|
|
120
|
+
class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
|
|
121
|
+
class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
|
|
122
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
|
|
123
|
+
|
|
124
|
+
const captured: Array<EventLoopOverflow> = []
|
|
125
|
+
const budget: EventBudget = {
|
|
126
|
+
errorAt: 50,
|
|
127
|
+
warningAt: Option.none(),
|
|
128
|
+
onOverflow: (error) => captured.push(error),
|
|
129
|
+
}
|
|
130
|
+
const TestLayer = BumpReducerLive.pipe(
|
|
131
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
132
|
+
Layer.locally(CurrentEventBudget, budget),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const burst = (from: number) =>
|
|
136
|
+
Effect.forEach(
|
|
137
|
+
Array.from({ length: 40 }, (_, index) => from + index),
|
|
138
|
+
(to) => publish('Normal', Event.construct(Bumped, { to })),
|
|
139
|
+
{ discard: true },
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return Effect.gen(function* () {
|
|
143
|
+
const store = yield* Counter.store
|
|
144
|
+
yield* burst(1)
|
|
145
|
+
yield* Effect.sleep(Duration.millis(10))
|
|
146
|
+
yield* burst(41)
|
|
147
|
+
yield* Effect.sleep(Duration.millis(10))
|
|
148
|
+
|
|
149
|
+
expect(captured).toHaveLength(0)
|
|
150
|
+
expect(store.get()).toBe(80)
|
|
151
|
+
}).pipe(Effect.provide(TestLayer))
|
|
152
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
interface EventLimitFields {
|
|
6
|
+
readonly errorAt: number
|
|
7
|
+
readonly warningAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ReformOptionFields {
|
|
11
|
+
readonly maxEventsPerJsTick: Partial<EventLimitFields>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReformOptions extends Partial<ReformOptionFields> {}
|
|
15
|
+
|
|
16
|
+
export interface EventBudget {
|
|
17
|
+
readonly errorAt: number
|
|
18
|
+
readonly warningAt: Option.Option<number>
|
|
19
|
+
readonly onOverflow: (error: EventLoopOverflow) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rethrowOverflow = (error: EventLoopOverflow): void =>
|
|
23
|
+
queueMicrotask(() => {
|
|
24
|
+
// oxlint-disable-next-line reform-rules/no-throw -- only way to surface the halt to the host's global error handler
|
|
25
|
+
throw error
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ERROR_AT = 10_000
|
|
29
|
+
const DEFAULT_WARNING_AT = 500
|
|
30
|
+
|
|
31
|
+
export const makeDefaultBudget = (dev: boolean): EventBudget => ({
|
|
32
|
+
errorAt: DEFAULT_ERROR_AT,
|
|
33
|
+
warningAt: Bool.match(dev, {
|
|
34
|
+
onTrue: () => Option.some(DEFAULT_WARNING_AT),
|
|
35
|
+
onFalse: () => Option.none<number>(),
|
|
36
|
+
}),
|
|
37
|
+
onOverflow: rethrowOverflow,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const defaultEventBudget: EventBudget = makeDefaultBudget(isDev)
|
|
41
|
+
|
|
42
|
+
export const resolveEventBudget = (options: ReformOptions): EventBudget => {
|
|
43
|
+
const limits = Option.fromNullable(options.maxEventsPerJsTick)
|
|
44
|
+
return {
|
|
45
|
+
errorAt: Option.getOrElse(
|
|
46
|
+
Option.flatMapNullable(limits, (limit) => limit.errorAt),
|
|
47
|
+
() => defaultEventBudget.errorAt,
|
|
48
|
+
),
|
|
49
|
+
warningAt: Option.orElse(
|
|
50
|
+
Option.flatMapNullable(limits, (limit) => limit.warningAt),
|
|
51
|
+
() => defaultEventBudget.warningAt,
|
|
52
|
+
),
|
|
53
|
+
onOverflow: defaultEventBudget.onOverflow,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const CurrentEventBudget: FiberRef.FiberRef<EventBudget> = GlobalValue.globalValue(
|
|
58
|
+
Symbol.for('reform/CurrentEventBudget'),
|
|
59
|
+
() => FiberRef.unsafeMake<EventBudget>(defaultEventBudget),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
export const resolveCurrentEventBudget: Effect.Effect<EventBudget> =
|
|
63
|
+
FiberRef.get(CurrentEventBudget)
|
|
64
|
+
|
|
65
|
+
export interface TickSample {
|
|
66
|
+
readonly total: number
|
|
67
|
+
readonly warnNow: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TickCounter {
|
|
71
|
+
readonly add: (eventCount: number) => TickSample
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const makeTickCounter = (budget: EventBudget): TickCounter => {
|
|
75
|
+
const count = MutableRef.make(0)
|
|
76
|
+
const armed = MutableRef.make(false)
|
|
77
|
+
const warned = MutableRef.make(false)
|
|
78
|
+
const reset = (): void => {
|
|
79
|
+
MutableRef.set(count, 0)
|
|
80
|
+
MutableRef.set(armed, false)
|
|
81
|
+
MutableRef.set(warned, false)
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
add: (eventCount) => {
|
|
85
|
+
if (!MutableRef.get(armed)) {
|
|
86
|
+
MutableRef.set(armed, true)
|
|
87
|
+
// 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
|
|
88
|
+
setTimeout(reset, 0)
|
|
89
|
+
}
|
|
90
|
+
const total = MutableRef.get(count) + eventCount
|
|
91
|
+
MutableRef.set(count, total)
|
|
92
|
+
const crossed = Option.match(budget.warningAt, {
|
|
93
|
+
onNone: () => false,
|
|
94
|
+
onSome: (warningAt) => total >= warningAt,
|
|
95
|
+
})
|
|
96
|
+
const warnNow = crossed && !MutableRef.get(warned)
|
|
97
|
+
if (warnNow) {
|
|
98
|
+
MutableRef.set(warned, true)
|
|
99
|
+
}
|
|
100
|
+
return { total, warnNow }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const enforceBudget: (
|
|
106
|
+
budget: EventBudget,
|
|
107
|
+
sample: TickSample,
|
|
108
|
+
) => Effect.Effect<void, EventLoopOverflow> = Effect.fn('enforceBudget')(function* (
|
|
109
|
+
budget: EventBudget,
|
|
110
|
+
sample: TickSample,
|
|
111
|
+
): Effect.fn.Return<void, EventLoopOverflow> {
|
|
112
|
+
if (sample.total > budget.errorAt) {
|
|
113
|
+
return yield* Effect.fail(new EventLoopOverflow({ count: sample.total, limit: budget.errorAt }))
|
|
114
|
+
}
|
|
115
|
+
if (sample.warnNow) {
|
|
116
|
+
yield* Effect.logWarning(
|
|
117
|
+
`reform: ${sample.total} events processed without yielding to the event loop — possible event feedback loop`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
@@ -1,10 +1,7 @@
|
|
|
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, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
|
+
import { Channels } from '../channel/channel'
|
|
8
5
|
|
|
9
6
|
it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
10
7
|
class Counter extends State.make('counter', S.Number) {}
|
|
@@ -24,20 +21,14 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
|
24
21
|
return Effect.gen(function* () {
|
|
25
22
|
const store = yield* Counter.store
|
|
26
23
|
|
|
27
|
-
// A fold that throws is caught and logged — the drain fiber survives.
|
|
28
24
|
yield* publish('Normal', Event.construct(Bad, {}))
|
|
29
25
|
yield* Effect.sleep(Duration.millis(10))
|
|
30
26
|
expect(store.get()).toBe(0)
|
|
31
27
|
|
|
32
|
-
// A later, healthy event still processes: the bus did not go deaf.
|
|
33
28
|
yield* publish('Normal', Event.construct(Good, {}))
|
|
34
29
|
yield* Effect.sleep(Duration.millis(10))
|
|
35
30
|
expect(store.get()).toBe(1)
|
|
36
|
-
}).pipe(
|
|
37
|
-
Effect.provide(TestLayer),
|
|
38
|
-
// The isolated failure logs an error; silence it so the passing test is quiet.
|
|
39
|
-
Logger.withMinimumLogLevel(LogLevel.None),
|
|
40
|
-
)
|
|
31
|
+
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
41
32
|
})
|
|
42
33
|
|
|
43
34
|
it.effect('two different channels registered under one name are rejected', () => {
|
|
@@ -56,6 +47,19 @@ it.effect('two different channels registered under one name are rejected', () =>
|
|
|
56
47
|
})
|
|
57
48
|
})
|
|
58
49
|
|
|
50
|
+
it.effect('structurally identical channel policies do not die', () => {
|
|
51
|
+
class LatestA extends Channel.make('SamePolicy', { policy: { _tag: 'latest' } }) {}
|
|
52
|
+
class LatestB extends Channel.make('SamePolicy', { policy: { _tag: 'latest' } }) {}
|
|
53
|
+
const layer = Layer.mergeAll(Channel.live(LatestA), Channel.live(LatestB)).pipe(
|
|
54
|
+
Layer.provideMerge(Engine),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return Effect.gen(function* () {
|
|
58
|
+
const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
|
|
59
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
59
63
|
it.effect('the same channel registered twice is idempotent (shared lane)', () => {
|
|
60
64
|
class Lane extends Channel.make('Lane', { policy: { _tag: 'exclusive' } }) {}
|
|
61
65
|
const layer = Layer.mergeAll(Channel.live(Lane), Channel.live(Lane)).pipe(
|
|
@@ -67,3 +71,59 @@ it.effect('the same channel registered twice is idempotent (shared lane)', () =>
|
|
|
67
71
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
68
72
|
})
|
|
69
73
|
})
|
|
74
|
+
|
|
75
|
+
it.live('remount overlap keeps the channel alive', () => {
|
|
76
|
+
class Fired extends Event.make('OverlapFired', S.Struct({})) {}
|
|
77
|
+
class FirstLane extends Channel.make('OverlapLane', { policy: { _tag: 'latest' } }) {}
|
|
78
|
+
class SecondLane extends Channel.make('OverlapLane', { policy: { _tag: 'latest' } }) {}
|
|
79
|
+
class Work extends Procedure.make('OverlapWork', { events: [Fired], channel: SecondLane }) {}
|
|
80
|
+
|
|
81
|
+
const runs = { n: 0 }
|
|
82
|
+
const WorkLive = Procedure.live(Work, function* () {
|
|
83
|
+
runs.n += 1
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return Effect.gen(function* () {
|
|
87
|
+
const channels = yield* Channels
|
|
88
|
+
const firstScope = yield* Scope.make()
|
|
89
|
+
const secondScope = yield* Scope.make()
|
|
90
|
+
|
|
91
|
+
yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
|
|
92
|
+
yield* Layer.buildWithScope(Layer.mergeAll(Channel.live(SecondLane), WorkLive), secondScope)
|
|
93
|
+
yield* Scope.close(firstScope, Exit.void)
|
|
94
|
+
|
|
95
|
+
yield* publish('Normal', Event.construct(Fired, {}))
|
|
96
|
+
yield* Effect.sleep(Duration.millis(20))
|
|
97
|
+
expect(runs.n).toBe(1)
|
|
98
|
+
|
|
99
|
+
yield* Scope.close(secondScope, Exit.void)
|
|
100
|
+
expect(channels.byName.has('OverlapLane')).toBe(false)
|
|
101
|
+
}).pipe(Effect.provide(Engine))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it.effect('last-release cleanup allows a fresh channel registration', () => {
|
|
105
|
+
class FirstLane extends Channel.make('CleanupLane', { policy: { _tag: 'merge' } }) {}
|
|
106
|
+
class SecondLane extends Channel.make('CleanupLane', { policy: { _tag: 'merge' } }) {}
|
|
107
|
+
class FreshLane extends Channel.make('CleanupLane', { policy: { _tag: 'merge' } }) {}
|
|
108
|
+
|
|
109
|
+
return Effect.gen(function* () {
|
|
110
|
+
const channels = yield* Channels
|
|
111
|
+
const firstScope = yield* Scope.make()
|
|
112
|
+
const secondScope = yield* Scope.make()
|
|
113
|
+
const freshScope = yield* Scope.make()
|
|
114
|
+
|
|
115
|
+
yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
|
|
116
|
+
yield* Layer.buildWithScope(Channel.live(SecondLane), secondScope)
|
|
117
|
+
yield* Scope.close(firstScope, Exit.void)
|
|
118
|
+
expect(channels.byName.has('CleanupLane')).toBe(true)
|
|
119
|
+
|
|
120
|
+
yield* Scope.close(secondScope, Exit.void)
|
|
121
|
+
expect(channels.byName.has('CleanupLane')).toBe(false)
|
|
122
|
+
|
|
123
|
+
yield* Layer.buildWithScope(Channel.live(FreshLane), freshScope)
|
|
124
|
+
expect(channels.byName.has('CleanupLane')).toBe(true)
|
|
125
|
+
|
|
126
|
+
yield* Scope.close(freshScope, Exit.void)
|
|
127
|
+
expect(channels.byName.has('CleanupLane')).toBe(false)
|
|
128
|
+
}).pipe(Effect.provide(Engine))
|
|
129
|
+
})
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import {
|
|
3
|
-
import { Duration, Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
|
|
2
|
+
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
4
3
|
import {
|
|
5
4
|
AsyncCalc,
|
|
6
5
|
Calc,
|
|
7
6
|
Channel,
|
|
8
7
|
Composition,
|
|
9
|
-
CurrentInstrumentation,
|
|
10
8
|
Engine,
|
|
11
9
|
Event,
|
|
12
|
-
type Instrumentation,
|
|
13
10
|
mount,
|
|
14
|
-
noopInstrumentation,
|
|
15
11
|
type Priority,
|
|
16
12
|
Procedure,
|
|
17
13
|
profiledScene,
|
|
@@ -25,12 +21,11 @@ import {
|
|
|
25
21
|
Ui,
|
|
26
22
|
ui,
|
|
27
23
|
} from '../index'
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// leak into unprofiled wiring.
|
|
24
|
+
import {
|
|
25
|
+
CurrentInstrumentation,
|
|
26
|
+
type Instrumentation,
|
|
27
|
+
noopInstrumentation,
|
|
28
|
+
} from './instrumentation'
|
|
34
29
|
|
|
35
30
|
const tick = (ms = 20) => Effect.sleep(Duration.millis(ms))
|
|
36
31
|
|
|
@@ -86,14 +81,27 @@ const makeRecorder = () => {
|
|
|
86
81
|
}
|
|
87
82
|
},
|
|
88
83
|
}
|
|
89
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
instrumentation,
|
|
86
|
+
events,
|
|
87
|
+
reducers,
|
|
88
|
+
states,
|
|
89
|
+
calcs,
|
|
90
|
+
queries,
|
|
91
|
+
procedures,
|
|
92
|
+
frames,
|
|
93
|
+
ends,
|
|
94
|
+
}
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
it.live('the engine reports frames, events, reducer spans, and state updates', () => {
|
|
93
98
|
const recorder = makeRecorder()
|
|
94
99
|
class Counter extends State.make('instr-counter', S.Number) {}
|
|
95
100
|
class Bumped extends Event.make('InstrBumped', S.Struct({ to: S.Number })) {}
|
|
96
|
-
class Fold extends Reducer.make('InstrFold', {
|
|
101
|
+
class Fold extends Reducer.make('InstrFold', {
|
|
102
|
+
states: [Counter],
|
|
103
|
+
events: [Bumped],
|
|
104
|
+
}) {}
|
|
97
105
|
const FoldLive = Reducer.live(Fold, (_, event) => event.to)
|
|
98
106
|
|
|
99
107
|
const TestLayer = FoldLive.pipe(
|
|
@@ -106,7 +114,6 @@ it.live('the engine reports frames, events, reducer spans, and state updates', (
|
|
|
106
114
|
yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
|
|
107
115
|
yield* tick()
|
|
108
116
|
|
|
109
|
-
// Both events landed in one frame; High folds before Normal.
|
|
110
117
|
expect(recorder.frames).toEqual([2])
|
|
111
118
|
expect(recorder.ends.frame).toBe(1)
|
|
112
119
|
expect(recorder.events).toEqual([
|
|
@@ -118,7 +125,6 @@ it.live('the engine reports frames, events, reducer spans, and state updates', (
|
|
|
118
125
|
{ name: 'InstrFold', eventTag: 'InstrBumped' },
|
|
119
126
|
])
|
|
120
127
|
expect(recorder.ends.reducer).toBe(2)
|
|
121
|
-
// Two genuinely new values (0 -> 1 -> 2), one update per fold.
|
|
122
128
|
expect(recorder.states).toEqual(['instr-counter', 'instr-counter'])
|
|
123
129
|
}).pipe(Effect.provide(TestLayer))
|
|
124
130
|
})
|
|
@@ -127,7 +133,10 @@ it.live('an equal-value write is not reported as a state update', () => {
|
|
|
127
133
|
const recorder = makeRecorder()
|
|
128
134
|
class Cell extends State.make('instr-cell', S.Number) {}
|
|
129
135
|
class Wrote extends Event.make('InstrWrote', S.Struct({ to: S.Number })) {}
|
|
130
|
-
class Write extends Reducer.make('InstrWrite', {
|
|
136
|
+
class Write extends Reducer.make('InstrWrite', {
|
|
137
|
+
states: [Cell],
|
|
138
|
+
events: [Wrote],
|
|
139
|
+
}) {}
|
|
131
140
|
const WriteLive = Reducer.live(Write, (_, event) => event.to)
|
|
132
141
|
|
|
133
142
|
const TestLayer = WriteLive.pipe(
|
|
@@ -138,7 +147,6 @@ it.live('an equal-value write is not reported as a state update', () => {
|
|
|
138
147
|
return Effect.gen(function* () {
|
|
139
148
|
yield* publish('High', Event.construct(Wrote, { to: 7 }))
|
|
140
149
|
yield* tick()
|
|
141
|
-
// The reducer ran, but the store's Equal gate suppressed the write.
|
|
142
150
|
expect(recorder.reducers.length).toBe(1)
|
|
143
151
|
expect(recorder.states).toEqual([])
|
|
144
152
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -161,7 +169,6 @@ it.live('calc recomputes are spanned; memo hits are not counted', () => {
|
|
|
161
169
|
|
|
162
170
|
return Effect.gen(function* () {
|
|
163
171
|
const derived = yield* Doubled.store
|
|
164
|
-
// The derived store computes once at build; repeated reads hit the memo.
|
|
165
172
|
const builds = recorder.calcs.length
|
|
166
173
|
expect(derived.get()).toBe(10)
|
|
167
174
|
expect(derived.get()).toBe(10)
|
|
@@ -174,7 +181,6 @@ it.live('calc recomputes are spanned; memo hits are not counted', () => {
|
|
|
174
181
|
expect(recorder.calcs.length).toBe(builds + 1)
|
|
175
182
|
expect(recorder.calcs.every((name) => name === 'InstrDoubled')).toBe(true)
|
|
176
183
|
expect(recorder.ends.calc).toBe(recorder.calcs.length)
|
|
177
|
-
// The calc's output moved twice (build seeds silently; 10 -> 12 notifies once).
|
|
178
184
|
expect(recorder.states).toContain('InstrDoubled')
|
|
179
185
|
}).pipe(Effect.provide(TestLayer))
|
|
180
186
|
})
|
|
@@ -189,7 +195,8 @@ it.live('async calc query runs are spanned start-to-settle', () => {
|
|
|
189
195
|
alwaysOn: true,
|
|
190
196
|
}) {}
|
|
191
197
|
const FetchedLive = AsyncCalc.live(Fetched, {
|
|
192
|
-
query: (inputs) =>
|
|
198
|
+
query: (inputs) =>
|
|
199
|
+
Effect.succeed(inputs['instr-query-count'] * 2).pipe(Effect.delay(Duration.millis(5))),
|
|
193
200
|
})
|
|
194
201
|
|
|
195
202
|
const TestLayer = FetchedLive.pipe(
|
|
@@ -200,7 +207,6 @@ it.live('async calc query runs are spanned start-to-settle', () => {
|
|
|
200
207
|
return Effect.gen(function* () {
|
|
201
208
|
const store = yield* Fetched.store
|
|
202
209
|
yield* tick(40)
|
|
203
|
-
// The run fiber picked the request, started the span, and ended it on settle.
|
|
204
210
|
expect(store.get()._tag).toBe('Success')
|
|
205
211
|
expect(recorder.queries).toEqual(['InstrFetched'])
|
|
206
212
|
expect(recorder.ends.query).toBe(1)
|
|
@@ -211,7 +217,10 @@ it.live('procedure runs are spanned with their channel and trigger tag', () => {
|
|
|
211
217
|
const recorder = makeRecorder()
|
|
212
218
|
class Kicked extends Event.make('InstrKicked', S.Struct({})) {}
|
|
213
219
|
class Lane extends Channel.make('InstrLane', { policy: { _tag: 'merge' } }) {}
|
|
214
|
-
class Work extends Procedure.make('InstrWork', {
|
|
220
|
+
class Work extends Procedure.make('InstrWork', {
|
|
221
|
+
events: [Kicked],
|
|
222
|
+
channel: Lane,
|
|
223
|
+
}) {}
|
|
215
224
|
const WorkLive = Procedure.live(Work, function* () {
|
|
216
225
|
yield* tick(5)
|
|
217
226
|
})
|
|
@@ -231,9 +240,6 @@ it.live('procedure runs are spanned with their channel and trigger tag', () => {
|
|
|
231
240
|
}).pipe(Effect.provide(TestLayer))
|
|
232
241
|
})
|
|
233
242
|
|
|
234
|
-
// ── The scene seam: `profiledScene` must reach layers inside a CLOSED scene and
|
|
235
|
-
// carry the handle for hosts, without leaking into the original scene. ─────────
|
|
236
|
-
|
|
237
243
|
class SceneCount extends State.make('instr-scene-count', S.Number) {}
|
|
238
244
|
class SceneStates extends StateGroup.make(SceneCount) {}
|
|
239
245
|
class SceneBumped extends Event.make('InstrSceneBumped', S.Struct({})) {}
|
|
@@ -249,17 +255,24 @@ class SceneRoot extends Composition.make('InstrScene', {
|
|
|
249
255
|
states: [SceneStates],
|
|
250
256
|
events: [SceneBumped],
|
|
251
257
|
ui: SceneUi,
|
|
252
|
-
}) {}
|
|
258
|
+
})<SceneRoot>() {}
|
|
253
259
|
const SceneRootLive = Composition.live(SceneRoot, function* () {
|
|
254
260
|
const count = yield* StateGroup.select(SceneStates, 'instr-scene-count')
|
|
255
261
|
return mount({ props: { count }, slots: {} })
|
|
256
262
|
})
|
|
257
263
|
|
|
258
264
|
const SceneApp = SceneRootLive.pipe(
|
|
259
|
-
Layer.provideMerge(
|
|
265
|
+
Layer.provideMerge(
|
|
266
|
+
provide(
|
|
267
|
+
SceneUi,
|
|
268
|
+
Ui.make(SceneUi, () => null),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
260
271
|
Layer.provideMerge(
|
|
261
272
|
Layer.mergeAll(SceneBumpLive).pipe(
|
|
262
|
-
Layer.provideMerge(
|
|
273
|
+
Layer.provideMerge(
|
|
274
|
+
Layer.mergeAll(Engine, StateGroup.live(SceneStates, { 'instr-scene-count': 0 })),
|
|
275
|
+
),
|
|
263
276
|
),
|
|
264
277
|
),
|
|
265
278
|
)
|
|
@@ -267,37 +280,27 @@ const InstrScene = scene(SceneRoot, { provide: [SceneApp] })
|
|
|
267
280
|
|
|
268
281
|
const runScene = (target: typeof InstrScene) => {
|
|
269
282
|
const layer = target.provide.reduce((a, b) => Layer.merge(a, b))
|
|
270
|
-
const runtime = ManagedRuntime.make(layer)
|
|
271
283
|
const drive = Effect.gen(function* () {
|
|
272
284
|
yield* publish('High', Event.construct(SceneBumped, {}))
|
|
273
285
|
yield* tick()
|
|
274
286
|
})
|
|
275
|
-
return {
|
|
287
|
+
return { layer, drive }
|
|
276
288
|
}
|
|
277
289
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
290
|
+
it.scopedLive('profiledScene reaches nested layers and carries the handle', () =>
|
|
291
|
+
Effect.gen(function* () {
|
|
292
|
+
const recorder = makeRecorder()
|
|
293
|
+
const profiled = profiledScene(InstrScene, recorder.instrumentation)
|
|
294
|
+
expect(sceneInstrumentation(profiled)).toBe(recorder.instrumentation)
|
|
282
295
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await runtime.runPromise(drive)
|
|
296
|
+
const profiledHarness = runScene(profiled)
|
|
297
|
+
yield* profiledHarness.drive.pipe(Effect.provide(profiledHarness.layer))
|
|
286
298
|
expect(recorder.reducers).toEqual([{ name: 'InstrSceneBump', eventTag: 'InstrSceneBumped' }])
|
|
287
299
|
expect(recorder.states).toEqual(['instr-scene-count'])
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// Building the ORIGINAL scene again records nothing — the overlay lives only
|
|
293
|
-
// on the profiled variant's wrapped layers.
|
|
294
|
-
const before = recorder.reducers.length
|
|
295
|
-
expect(sceneInstrumentation(InstrScene)).toBe(noopInstrumentation)
|
|
296
|
-
const original = runScene(InstrScene)
|
|
297
|
-
try {
|
|
298
|
-
await original.runtime.runPromise(original.drive)
|
|
300
|
+
const before = recorder.reducers.length
|
|
301
|
+
expect(sceneInstrumentation(InstrScene)).toBe(noopInstrumentation)
|
|
302
|
+
const original = runScene(InstrScene)
|
|
303
|
+
yield* original.drive.pipe(Effect.provide(original.layer))
|
|
299
304
|
expect(recorder.reducers.length).toBe(before)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
})
|
|
305
|
+
}),
|
|
306
|
+
)
|