@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.
Files changed (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,6 +1,5 @@
1
- import { expect, it } from '@effect/vitest'
2
- import { test } from 'vitest'
3
- import { Duration, Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
1
+ import { expect, it } from "@effect/vitest";
2
+ import { Duration, Effect, Layer, Schema as S } from "effect";
4
3
  import {
5
4
  AsyncCalc,
6
5
  Calc,
@@ -24,280 +23,302 @@ import {
24
23
  StateGroup,
25
24
  Ui,
26
25
  ui,
27
- } from '../index'
26
+ } from "../index";
28
27
 
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.
34
-
35
- const tick = (ms = 20) => Effect.sleep(Duration.millis(ms))
28
+ const tick = (ms = 20) => Effect.sleep(Duration.millis(ms));
36
29
 
37
30
  const makeRecorder = () => {
38
- const events: Array<{ readonly tag: string; readonly priority: Priority }> = []
39
- const reducers: Array<{ readonly name: string; readonly eventTag: string }> = []
40
- const states: Array<string> = []
41
- const calcs: Array<string> = []
42
- const queries: Array<string> = []
31
+ const events: Array<{ readonly tag: string; readonly priority: Priority }> =
32
+ [];
33
+ const reducers: Array<{ readonly name: string; readonly eventTag: string }> =
34
+ [];
35
+ const states: Array<string> = [];
36
+ const calcs: Array<string> = [];
37
+ const queries: Array<string> = [];
43
38
  const procedures: Array<{
44
- readonly name: string
45
- readonly channel: string
46
- readonly eventTag: string
47
- }> = []
48
- const frames: Array<number> = []
49
- const ends = { reducer: 0, calc: 0, query: 0, procedure: 0, frame: 0 }
39
+ readonly name: string;
40
+ readonly channel: string;
41
+ readonly eventTag: string;
42
+ }> = [];
43
+ const frames: Array<number> = [];
44
+ const ends = { reducer: 0, calc: 0, query: 0, procedure: 0, frame: 0 };
50
45
  const instrumentation: Instrumentation = {
51
46
  eventDispatched: (tag, priority) => {
52
- events.push({ tag, priority })
47
+ events.push({ tag, priority });
53
48
  },
54
49
  reducerRun: (name, eventTag) => {
55
- reducers.push({ name, eventTag })
50
+ reducers.push({ name, eventTag });
56
51
  return () => {
57
- ends.reducer += 1
58
- }
52
+ ends.reducer += 1;
53
+ };
59
54
  },
60
55
  stateUpdated: (name) => {
61
- states.push(name)
56
+ states.push(name);
62
57
  },
63
58
  calcRecomputed: (name) => {
64
- calcs.push(name)
59
+ calcs.push(name);
65
60
  return () => {
66
- ends.calc += 1
67
- }
61
+ ends.calc += 1;
62
+ };
68
63
  },
69
64
  queryRun: (name) => {
70
- queries.push(name)
65
+ queries.push(name);
71
66
  return () => {
72
- ends.query += 1
73
- }
67
+ ends.query += 1;
68
+ };
74
69
  },
75
70
  procedureRun: (name, channel, eventTag) => {
76
- procedures.push({ name, channel, eventTag })
71
+ procedures.push({ name, channel, eventTag });
77
72
  return () => {
78
- ends.procedure += 1
79
- }
73
+ ends.procedure += 1;
74
+ };
80
75
  },
81
76
  uiRendered: () => () => {},
82
77
  frame: (eventCount) => {
83
- frames.push(eventCount)
78
+ frames.push(eventCount);
84
79
  return () => {
85
- ends.frame += 1
86
- }
80
+ ends.frame += 1;
81
+ };
87
82
  },
88
- }
89
- return { instrumentation, events, reducers, states, calcs, queries, procedures, frames, ends }
90
- }
83
+ };
84
+ return {
85
+ instrumentation,
86
+ events,
87
+ reducers,
88
+ states,
89
+ calcs,
90
+ queries,
91
+ procedures,
92
+ frames,
93
+ ends,
94
+ };
95
+ };
91
96
 
92
- it.live('the engine reports frames, events, reducer spans, and state updates', () => {
93
- const recorder = makeRecorder()
94
- class Counter extends State.make('instr-counter', S.Number) {}
95
- class Bumped extends Event.make('InstrBumped', S.Struct({ to: S.Number })) {}
96
- class Fold extends Reducer.make('InstrFold', { states: [Counter], events: [Bumped] }) {}
97
- const FoldLive = Reducer.live(Fold, (_, event) => event.to)
97
+ it.live(
98
+ "the engine reports frames, events, reducer spans, and state updates",
99
+ () => {
100
+ const recorder = makeRecorder();
101
+ class Counter extends State.make("instr-counter", S.Number) {}
102
+ class Bumped extends Event.make(
103
+ "InstrBumped",
104
+ S.Struct({ to: S.Number }),
105
+ ) {}
106
+ class Fold extends Reducer.make("InstrFold", {
107
+ states: [Counter],
108
+ events: [Bumped],
109
+ }) {}
110
+ const FoldLive = Reducer.live(Fold, (_, event) => event.to);
98
111
 
99
- const TestLayer = FoldLive.pipe(
100
- Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
101
- Layer.locally(CurrentInstrumentation, recorder.instrumentation),
102
- )
112
+ const TestLayer = FoldLive.pipe(
113
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
114
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
115
+ );
103
116
 
104
- return Effect.gen(function* () {
105
- yield* publish('High', Event.construct(Bumped, { to: 1 }))
106
- yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
107
- yield* tick()
117
+ return Effect.gen(function* () {
118
+ yield* publish("High", Event.construct(Bumped, { to: 1 }));
119
+ yield* publish("Normal", Event.construct(Bumped, { to: 2 }));
120
+ yield* tick();
108
121
 
109
- // Both events landed in one frame; High folds before Normal.
110
- expect(recorder.frames).toEqual([2])
111
- expect(recorder.ends.frame).toBe(1)
112
- expect(recorder.events).toEqual([
113
- { tag: 'InstrBumped', priority: 'High' },
114
- { tag: 'InstrBumped', priority: 'Normal' },
115
- ])
116
- expect(recorder.reducers).toEqual([
117
- { name: 'InstrFold', eventTag: 'InstrBumped' },
118
- { name: 'InstrFold', eventTag: 'InstrBumped' },
119
- ])
120
- expect(recorder.ends.reducer).toBe(2)
121
- // Two genuinely new values (0 -> 1 -> 2), one update per fold.
122
- expect(recorder.states).toEqual(['instr-counter', 'instr-counter'])
123
- }).pipe(Effect.provide(TestLayer))
124
- })
122
+ expect(recorder.frames).toEqual([2]);
123
+ expect(recorder.ends.frame).toBe(1);
124
+ expect(recorder.events).toEqual([
125
+ { tag: "InstrBumped", priority: "High" },
126
+ { tag: "InstrBumped", priority: "Normal" },
127
+ ]);
128
+ expect(recorder.reducers).toEqual([
129
+ { name: "InstrFold", eventTag: "InstrBumped" },
130
+ { name: "InstrFold", eventTag: "InstrBumped" },
131
+ ]);
132
+ expect(recorder.ends.reducer).toBe(2);
133
+ expect(recorder.states).toEqual(["instr-counter", "instr-counter"]);
134
+ }).pipe(Effect.provide(TestLayer));
135
+ },
136
+ );
125
137
 
126
- it.live('an equal-value write is not reported as a state update', () => {
127
- const recorder = makeRecorder()
128
- class Cell extends State.make('instr-cell', S.Number) {}
129
- class Wrote extends Event.make('InstrWrote', S.Struct({ to: S.Number })) {}
130
- class Write extends Reducer.make('InstrWrite', { states: [Cell], events: [Wrote] }) {}
131
- const WriteLive = Reducer.live(Write, (_, event) => event.to)
138
+ it.live("an equal-value write is not reported as a state update", () => {
139
+ const recorder = makeRecorder();
140
+ class Cell extends State.make("instr-cell", S.Number) {}
141
+ class Wrote extends Event.make("InstrWrote", S.Struct({ to: S.Number })) {}
142
+ class Write extends Reducer.make("InstrWrite", {
143
+ states: [Cell],
144
+ events: [Wrote],
145
+ }) {}
146
+ const WriteLive = Reducer.live(Write, (_, event) => event.to);
132
147
 
133
148
  const TestLayer = WriteLive.pipe(
134
149
  Layer.provideMerge(Layer.mergeAll(State.live(Cell, 7), Engine)),
135
150
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
136
- )
151
+ );
137
152
 
138
153
  return Effect.gen(function* () {
139
- yield* publish('High', Event.construct(Wrote, { to: 7 }))
140
- yield* tick()
141
- // The reducer ran, but the store's Equal gate suppressed the write.
142
- expect(recorder.reducers.length).toBe(1)
143
- expect(recorder.states).toEqual([])
144
- }).pipe(Effect.provide(TestLayer))
145
- })
154
+ yield* publish("High", Event.construct(Wrote, { to: 7 }));
155
+ yield* tick();
156
+ expect(recorder.reducers.length).toBe(1);
157
+ expect(recorder.states).toEqual([]);
158
+ }).pipe(Effect.provide(TestLayer));
159
+ });
146
160
 
147
- it.live('calc recomputes are spanned; memo hits are not counted', () => {
148
- const recorder = makeRecorder()
149
- class Count extends State.make('instr-calc-count', S.Number) {}
161
+ it.live("calc recomputes are spanned; memo hits are not counted", () => {
162
+ const recorder = makeRecorder();
163
+ class Count extends State.make("instr-calc-count", S.Number) {}
150
164
  class Inputs extends StateGroup.make(Count) {}
151
- class Doubled extends Calc.make('InstrDoubled', {
152
- inputs: [StateGroup.select(Inputs, 'instr-calc-count')],
165
+ class Doubled extends Calc.make("InstrDoubled", {
166
+ inputs: [StateGroup.select(Inputs, "instr-calc-count")],
153
167
  output: S.Number,
154
168
  }) {}
155
- const DoubledLive = Calc.live(Doubled, (inputs) => inputs['instr-calc-count'] * 2)
169
+ const DoubledLive = Calc.live(
170
+ Doubled,
171
+ (inputs) => inputs["instr-calc-count"] * 2,
172
+ );
156
173
 
157
174
  const TestLayer = DoubledLive.pipe(
158
- Layer.provideMerge(StateGroup.live(Inputs, { 'instr-calc-count': 5 })),
175
+ Layer.provideMerge(StateGroup.live(Inputs, { "instr-calc-count": 5 })),
159
176
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
160
- )
177
+ );
161
178
 
162
179
  return Effect.gen(function* () {
163
- const derived = yield* Doubled.store
164
- // The derived store computes once at build; repeated reads hit the memo.
165
- const builds = recorder.calcs.length
166
- expect(derived.get()).toBe(10)
167
- expect(derived.get()).toBe(10)
168
- expect(recorder.calcs.length).toBe(builds)
180
+ const derived = yield* Doubled.store;
181
+ const builds = recorder.calcs.length;
182
+ expect(derived.get()).toBe(10);
183
+ expect(derived.get()).toBe(10);
184
+ expect(recorder.calcs.length).toBe(builds);
169
185
 
170
- const source = yield* StateGroup.select(Inputs, 'instr-calc-count').store
171
- source.set(6)
172
- yield* tick()
173
- expect(derived.get()).toBe(12)
174
- expect(recorder.calcs.length).toBe(builds + 1)
175
- expect(recorder.calcs.every((name) => name === 'InstrDoubled')).toBe(true)
176
- expect(recorder.ends.calc).toBe(recorder.calcs.length)
177
- // The calc's output moved twice (build seeds silently; 10 -> 12 notifies once).
178
- expect(recorder.states).toContain('InstrDoubled')
179
- }).pipe(Effect.provide(TestLayer))
180
- })
186
+ const source = yield* StateGroup.select(Inputs, "instr-calc-count").store;
187
+ source.set(6);
188
+ yield* tick();
189
+ expect(derived.get()).toBe(12);
190
+ expect(recorder.calcs.length).toBe(builds + 1);
191
+ expect(recorder.calcs.every((name) => name === "InstrDoubled")).toBe(true);
192
+ expect(recorder.ends.calc).toBe(recorder.calcs.length);
193
+ expect(recorder.states).toContain("InstrDoubled");
194
+ }).pipe(Effect.provide(TestLayer));
195
+ });
181
196
 
182
- it.live('async calc query runs are spanned start-to-settle', () => {
183
- const recorder = makeRecorder()
184
- class Count extends State.make('instr-query-count', S.Number) {}
197
+ it.live("async calc query runs are spanned start-to-settle", () => {
198
+ const recorder = makeRecorder();
199
+ class Count extends State.make("instr-query-count", S.Number) {}
185
200
  class Inputs extends StateGroup.make(Count) {}
186
- class Fetched extends AsyncCalc.make('InstrFetched', {
187
- inputs: [StateGroup.select(Inputs, 'instr-query-count')],
201
+ class Fetched extends AsyncCalc.make("InstrFetched", {
202
+ inputs: [StateGroup.select(Inputs, "instr-query-count")],
188
203
  output: S.Number,
189
204
  alwaysOn: true,
190
205
  }) {}
191
206
  const FetchedLive = AsyncCalc.live(Fetched, {
192
- query: (inputs) => Effect.succeed(inputs['instr-query-count'] * 2).pipe(Effect.delay(Duration.millis(5))),
193
- })
207
+ query: (inputs) =>
208
+ Effect.succeed(inputs["instr-query-count"] * 2).pipe(
209
+ Effect.delay(Duration.millis(5)),
210
+ ),
211
+ });
194
212
 
195
213
  const TestLayer = FetchedLive.pipe(
196
- Layer.provideMerge(StateGroup.live(Inputs, { 'instr-query-count': 3 })),
214
+ Layer.provideMerge(StateGroup.live(Inputs, { "instr-query-count": 3 })),
197
215
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
198
- )
216
+ );
199
217
 
200
218
  return Effect.gen(function* () {
201
- const store = yield* Fetched.store
202
- yield* tick(40)
203
- // The run fiber picked the request, started the span, and ended it on settle.
204
- expect(store.get()._tag).toBe('Success')
205
- expect(recorder.queries).toEqual(['InstrFetched'])
206
- expect(recorder.ends.query).toBe(1)
207
- }).pipe(Effect.provide(TestLayer))
208
- })
219
+ const store = yield* Fetched.store;
220
+ yield* tick(40);
221
+ expect(store.get()._tag).toBe("Success");
222
+ expect(recorder.queries).toEqual(["InstrFetched"]);
223
+ expect(recorder.ends.query).toBe(1);
224
+ }).pipe(Effect.provide(TestLayer));
225
+ });
209
226
 
210
- it.live('procedure runs are spanned with their channel and trigger tag', () => {
211
- const recorder = makeRecorder()
212
- class Kicked extends Event.make('InstrKicked', S.Struct({})) {}
213
- class Lane extends Channel.make('InstrLane', { policy: { _tag: 'merge' } }) {}
214
- class Work extends Procedure.make('InstrWork', { events: [Kicked], channel: Lane }) {}
227
+ it.live("procedure runs are spanned with their channel and trigger tag", () => {
228
+ const recorder = makeRecorder();
229
+ class Kicked extends Event.make("InstrKicked", S.Struct({})) {}
230
+ class Lane extends Channel.make("InstrLane", { policy: { _tag: "merge" } }) {}
231
+ class Work extends Procedure.make("InstrWork", {
232
+ events: [Kicked],
233
+ channel: Lane,
234
+ }) {}
215
235
  const WorkLive = Procedure.live(Work, function* () {
216
- yield* tick(5)
217
- })
236
+ yield* tick(5);
237
+ });
218
238
 
219
239
  const TestLayer = Layer.mergeAll(Channel.live(Lane), WorkLive).pipe(
220
240
  Layer.provideMerge(Engine),
221
241
  Layer.locally(CurrentInstrumentation, recorder.instrumentation),
222
- )
242
+ );
223
243
 
224
244
  return Effect.gen(function* () {
225
- yield* publish('Normal', Event.construct(Kicked, {}))
226
- yield* tick(40)
245
+ yield* publish("Normal", Event.construct(Kicked, {}));
246
+ yield* tick(40);
227
247
  expect(recorder.procedures).toEqual([
228
- { name: 'InstrWork', channel: 'InstrLane', eventTag: 'InstrKicked' },
229
- ])
230
- expect(recorder.ends.procedure).toBe(1)
231
- }).pipe(Effect.provide(TestLayer))
232
- })
248
+ { name: "InstrWork", channel: "InstrLane", eventTag: "InstrKicked" },
249
+ ]);
250
+ expect(recorder.ends.procedure).toBe(1);
251
+ }).pipe(Effect.provide(TestLayer));
252
+ });
233
253
 
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
- class SceneCount extends State.make('instr-scene-count', S.Number) {}
254
+ class SceneCount extends State.make("instr-scene-count", S.Number) {}
238
255
  class SceneStates extends StateGroup.make(SceneCount) {}
239
- class SceneBumped extends Event.make('InstrSceneBumped', S.Struct({})) {}
240
- class SceneBump extends Reducer.make('InstrSceneBump', {
256
+ class SceneBumped extends Event.make("InstrSceneBumped", S.Struct({})) {}
257
+ class SceneBump extends Reducer.make("InstrSceneBump", {
241
258
  states: [SceneCount],
242
259
  events: [SceneBumped],
243
260
  }) {}
244
- const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1)
261
+ const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1);
245
262
 
246
- class SceneUi extends ui('InstrScene')<{ props: { count: number } }>() {}
247
- class SceneRoot extends Composition.make('InstrScene', {
248
- title: 'InstrScene',
263
+ class SceneUi extends ui("InstrScene")<{ props: { count: number } }>() {}
264
+ class SceneRoot extends Composition.make("InstrScene", {
265
+ title: "InstrScene",
249
266
  states: [SceneStates],
250
267
  events: [SceneBumped],
251
268
  ui: SceneUi,
252
269
  }) {}
253
270
  const SceneRootLive = Composition.live(SceneRoot, function* () {
254
- const count = yield* StateGroup.select(SceneStates, 'instr-scene-count')
255
- return mount({ props: { count }, slots: {} })
256
- })
271
+ const count = yield* StateGroup.select(SceneStates, "instr-scene-count");
272
+ return mount({ props: { count }, slots: {} });
273
+ });
257
274
 
258
275
  const SceneApp = SceneRootLive.pipe(
259
- Layer.provideMerge(provide(SceneUi, Ui.make(SceneUi, () => null))),
276
+ Layer.provideMerge(
277
+ provide(
278
+ SceneUi,
279
+ Ui.make(SceneUi, () => null),
280
+ ),
281
+ ),
260
282
  Layer.provideMerge(
261
283
  Layer.mergeAll(SceneBumpLive).pipe(
262
- Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(SceneStates, { 'instr-scene-count': 0 }))),
284
+ Layer.provideMerge(
285
+ Layer.mergeAll(
286
+ Engine,
287
+ StateGroup.live(SceneStates, { "instr-scene-count": 0 }),
288
+ ),
289
+ ),
263
290
  ),
264
291
  ),
265
- )
266
- const InstrScene = scene(SceneRoot, { provide: [SceneApp] })
292
+ );
293
+ const InstrScene = scene(SceneRoot, { provide: [SceneApp] });
267
294
 
268
295
  const runScene = (target: typeof InstrScene) => {
269
- const layer = target.provide.reduce((a, b) => Layer.merge(a, b))
270
- const runtime = ManagedRuntime.make(layer)
296
+ const layer = target.provide.reduce((a, b) => Layer.merge(a, b));
271
297
  const drive = Effect.gen(function* () {
272
- yield* publish('High', Event.construct(SceneBumped, {}))
273
- yield* tick()
274
- })
275
- return { runtime, drive }
276
- }
277
-
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)
298
+ yield* publish("High", Event.construct(SceneBumped, {}));
299
+ yield* tick();
300
+ });
301
+ return { layer, drive };
302
+ };
282
303
 
283
- const { runtime, drive } = runScene(profiled)
284
- try {
285
- await runtime.runPromise(drive)
286
- expect(recorder.reducers).toEqual([{ name: 'InstrSceneBump', eventTag: 'InstrSceneBumped' }])
287
- expect(recorder.states).toEqual(['instr-scene-count'])
288
- } finally {
289
- await runtime.dispose()
290
- }
304
+ it.scopedLive(
305
+ "profiledScene reaches nested layers and carries the handle",
306
+ () =>
307
+ Effect.gen(function* () {
308
+ const recorder = makeRecorder();
309
+ const profiled = profiledScene(InstrScene, recorder.instrumentation);
310
+ expect(sceneInstrumentation(profiled)).toBe(recorder.instrumentation);
291
311
 
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)
299
- expect(recorder.reducers.length).toBe(before)
300
- } finally {
301
- await original.runtime.dispose()
302
- }
303
- })
312
+ const profiledHarness = runScene(profiled);
313
+ yield* profiledHarness.drive.pipe(Effect.provide(profiledHarness.layer));
314
+ expect(recorder.reducers).toEqual([
315
+ { name: "InstrSceneBump", eventTag: "InstrSceneBumped" },
316
+ ]);
317
+ expect(recorder.states).toEqual(["instr-scene-count"]);
318
+ const before = recorder.reducers.length;
319
+ expect(sceneInstrumentation(InstrScene)).toBe(noopInstrumentation);
320
+ const original = runScene(InstrScene);
321
+ yield* original.drive.pipe(Effect.provide(original.layer));
322
+ expect(recorder.reducers.length).toBe(before);
323
+ }),
324
+ );
@@ -1,48 +1,21 @@
1
1
  import { Effect, FiberRef, GlobalValue } from 'effect'
2
2
  import type { Priority } from './bus'
3
3
 
4
- /**
5
- * The profiler SPI — a synchronous, Effect-free hook surface the engine calls on
6
- * its hot paths (the drain loop, store writes, calc recomputes, renders). It is
7
- * Effect-free for the same reason `Store`/`Scheduler` are: these sites run
8
- * synchronously inside a reduce frame or a React render, where forking or even
9
- * building an Effect per call would dominate the very costs being measured.
10
- *
11
- * Span-shaped hooks return a {@link SpanEnd} thunk: call it when the measured
12
- * work completes. The default implementation is {@link noopInstrumentation};
13
- * every hook site captures its instance ONCE at layer build time (see
14
- * `resolveInstrumentation`) and may skip name plumbing entirely when the
15
- * captured instance IS the noop (identity check), so an unprofiled runtime pays
16
- * nothing per operation.
17
- */
18
4
  export type SpanEnd = () => void
19
5
 
20
6
  export interface Instrumentation {
21
- /** An event entered the drain frame (one call per envelope, per frame). */
22
7
  eventDispatched(tag: string, priority: Priority): void
23
- /** A reducer is folding one event; ends when its synchronous `apply` returns. */
24
8
  reducerRun(name: string, eventTag: string): SpanEnd
25
- /** A store accepted a genuinely new value (post-`Equal` gate). */
26
9
  stateUpdated(name: string): void
27
- /** A calc's projection actually ran (memo hits are not counted). */
28
10
  calcRecomputed(name: string): SpanEnd
29
- /** An async calc's query effect started; ends when it resolves/fails/cancels. */
30
11
  queryRun(name: string): SpanEnd
31
- /** A procedure body started on its channel; ends when the fiber completes. */
32
12
  procedureRun(name: string, channel: string, eventTag: string): SpanEnd
33
- /** A composition rendered one frame (host-injected: React `Compose` / proof engine). */
34
13
  uiRendered(composition: string): SpanEnd
35
- /** One drain tick: folds + routing for `eventCount` envelopes. */
36
14
  frame(eventCount: number): SpanEnd
37
15
  }
38
16
 
39
17
  const noopSpanEnd: SpanEnd = () => {}
40
18
 
41
- /**
42
- * The shared do-nothing instrumentation. A single instance (and a single shared
43
- * `SpanEnd`), so hook sites can detect "profiling off" by identity and skip
44
- * their argument construction entirely.
45
- */
46
19
  export const noopInstrumentation: Instrumentation = {
47
20
  eventDispatched: () => {},
48
21
  reducerRun: () => noopSpanEnd,
@@ -54,36 +27,14 @@ export const noopInstrumentation: Instrumentation = {
54
27
  frame: () => noopSpanEnd,
55
28
  }
56
29
 
57
- /**
58
- * The sanctioned profiling seam for CLOSED scenes — the same mechanism as
59
- * `CurrentSeedOverrides` (`internal/seeds.ts`): a scene's layers are
60
- * pre-composed, but a FiberRef set via `Layer.locally(ref, value)(layer)` IS
61
- * visible inside the construction effects of nested layers. Engine/state/calc
62
- * layers consult this ref once at build time and capture the resolved instance
63
- * into their hot-path closures.
64
- *
65
- * Defaults to {@link noopInstrumentation} — production wiring never touches it.
66
- * `profileScene` (in `@playfast/reform-profiler`) is the intended writer.
67
- * `globalValue` keeps a single ref instance even if the module is loaded twice
68
- * (duplicated bundles, HMR), so the writer and the reader always agree.
69
- */
70
30
  export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> =
71
31
  GlobalValue.globalValue(Symbol.for('reform/CurrentInstrumentation'), () =>
72
32
  FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
73
33
  )
74
34
 
75
- /**
76
- * Resolve the ambient instrumentation at layer build time. Layers capture the
77
- * result into their closures; they must not re-read it per operation.
78
- */
79
35
  export const resolveInstrumentation: Effect.Effect<Instrumentation> =
80
36
  FiberRef.get(CurrentInstrumentation)
81
37
 
82
- /**
83
- * The store `onChange` hook for one named state/calc: a bound
84
- * `stateUpdated(name)` closure when profiled, the shared no-op otherwise — so
85
- * an unprofiled store's write path never constructs the name-carrying call.
86
- */
87
38
  export const stateUpdateHook = (
88
39
  instrumentation: Instrumentation,
89
40
  name: string,
@@ -2,10 +2,6 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Duration, Effect, Layer, Schema as S, TestClock } from 'effect'
3
3
  import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
4
4
 
5
- // Headless tests for the single frame-batched drain loop and channels, written
6
- // with `@effect/vitest`: `it.live` for cases that lean on the real microtask
7
- // queue (the store notifier), `it.effect` + `TestClock` for time-based channels.
8
-
9
5
  it.live('a frame of events drains in one pass with a single notification flush', () => {
10
6
  class Counter extends State.make('counter', S.Number) {}
11
7
  class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
@@ -23,8 +19,6 @@ it.live('a frame of events drains in one pass with a single notification flush',
23
19
  notifications.n += 1
24
20
  })
25
21
 
26
- // Three events in one tick batch into one frame, fold in order, and the
27
- // coalesced store notifier wakes the subscriber exactly once.
28
22
  yield* publish('Normal', Event.construct(Bumped, { to: 1 }))
29
23
  yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
30
24
  yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
@@ -61,15 +55,11 @@ it.live('an exclusive channel serializes procedures that share it', () => {
61
55
  yield* publish('Normal', Event.construct(StartA, {}))
62
56
  yield* publish('Normal', Event.construct(StartB, {}))
63
57
  yield* Effect.sleep(Duration.millis(60))
64
- // Without exclusivity both would run concurrently (max === 2).
65
58
  expect(concurrency.max).toBe(1)
66
59
  }).pipe(Effect.provide(TestLayer))
67
60
  })
68
61
 
69
62
  it.live('two procedures sharing a channel each run once for one shared event', () => {
70
- // Both procedures handle the SAME event on the SAME channel. The loop must
71
- // offer the event to the channel once, so each procedure runs exactly once —
72
- // not once per matching procedure (which would double every run).
73
63
  class Shared extends Event.make('Shared', S.Struct({})) {}
74
64
  class Lane extends Channel.make('Lane', { policy: { _tag: 'merge' } }) {}
75
65
 
@@ -115,9 +105,7 @@ it.effect('a debounce channel collapses a burst into a single run', () => {
115
105
  yield* publish('Normal', Event.construct(Typed, {}))
116
106
  yield* publish('Normal', Event.construct(Typed, {}))
117
107
  yield* publish('Normal', Event.construct(Typed, {}))
118
- // Let the loop route the frame into the channel and arm the debounce window.
119
108
  yield* Effect.yieldNow().pipe(Effect.repeatN(20))
120
- // Advance the simulated clock past the window: the burst emits once.
121
109
  yield* TestClock.adjust(Duration.millis(31))
122
110
  yield* Effect.yieldNow().pipe(Effect.repeatN(20))
123
111
  expect(runs.n).toBe(1)
@@ -137,11 +125,9 @@ it.live('a reducer ignores events outside its declared set (no-op)', () => {
137
125
 
138
126
  return Effect.gen(function* () {
139
127
  const store = yield* Counter.store
140
- // An event the reducer doesn't declare never reaches its fold.
141
128
  yield* publish('Normal', Event.construct(Unrelated, {}))
142
129
  yield* Effect.sleep(Duration.millis(10))
143
130
  expect(store.get()).toBe(0)
144
- // A declared event does.
145
131
  yield* publish('Normal', Event.construct(Bump, {}))
146
132
  yield* Effect.sleep(Duration.millis(10))
147
133
  expect(store.get()).toBe(1)
@@ -163,15 +149,12 @@ it.effect('a latest channel cancels the in-flight run when a new event arrives',
163
149
  const TestLayer = Layer.mergeAll(Channel.live(Latest), WorkLive).pipe(Layer.provideMerge(Engine))
164
150
 
165
151
  return Effect.gen(function* () {
166
- // First tick starts a run that parks on its 50ms sleep.
167
152
  yield* publish('Normal', Event.construct(Tick, {}))
168
153
  yield* Effect.yieldNow().pipe(Effect.repeatN(20))
169
- // Second tick switches the channel, interrupting the first run mid-sleep.
170
154
  yield* publish('Normal', Event.construct(Tick, {}))
171
155
  yield* Effect.yieldNow().pipe(Effect.repeatN(20))
172
156
  yield* TestClock.adjust(Duration.millis(60))
173
157
  yield* Effect.yieldNow().pipe(Effect.repeatN(20))
174
- // Both runs began, but only the latest survived to completion.
175
158
  expect(counts.started).toBe(2)
176
159
  expect(counts.completed).toBe(1)
177
160
  }).pipe(Effect.provide(TestLayer))