@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.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. 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 { test } from 'vitest'
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
- // The profiler SPI: every engine hot path consults `CurrentInstrumentation` once
30
- // at layer build time. These tests inject a recording instance the same way
31
- // `profileScene` will — `Layer.locally` on the wiring — and assert each hook
32
- // fires with the right names/counts, that spans end, and that the seam does not
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 { instrumentation, events, reducers, states, calcs, queries, procedures, frames, ends }
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', { states: [Counter], events: [Bumped] }) {}
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', { states: [Cell], events: [Wrote] }) {}
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) => Effect.succeed(inputs['instr-query-count'] * 2).pipe(Effect.delay(Duration.millis(5))),
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', { events: [Kicked], channel: Lane }) {}
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(provide(SceneUi, Ui.make(SceneUi, () => null))),
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(Layer.mergeAll(Engine, StateGroup.live(SceneStates, { 'instr-scene-count': 0 }))),
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 { runtime, drive }
287
+ return { layer, drive }
276
288
  }
277
289
 
278
- test('profiledScene reaches nested layers and carries the handle', async () => {
279
- const recorder = makeRecorder()
280
- const profiled = profiledScene(InstrScene, recorder.instrumentation)
281
- expect(sceneInstrumentation(profiled)).toBe(recorder.instrumentation)
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
- const { runtime, drive } = runScene(profiled)
284
- try {
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
- } finally {
289
- await runtime.dispose()
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
- } finally {
301
- await original.runtime.dispose()
302
- }
303
- })
305
+ }),
306
+ )