@playfast/reform 0.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  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 +78 -173
  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 -202
  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 +48 -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 +10 -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 +131 -0
  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 +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  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
@@ -0,0 +1,324 @@
1
+ import { expect, it } from "@effect/vitest";
2
+ import { Duration, Effect, Layer, Schema as S } from "effect";
3
+ import {
4
+ AsyncCalc,
5
+ Calc,
6
+ Channel,
7
+ Composition,
8
+ CurrentInstrumentation,
9
+ Engine,
10
+ Event,
11
+ type Instrumentation,
12
+ mount,
13
+ noopInstrumentation,
14
+ type Priority,
15
+ Procedure,
16
+ profiledScene,
17
+ provide,
18
+ publish,
19
+ Reducer,
20
+ scene,
21
+ sceneInstrumentation,
22
+ State,
23
+ StateGroup,
24
+ Ui,
25
+ ui,
26
+ } from "../index";
27
+
28
+ const tick = (ms = 20) => Effect.sleep(Duration.millis(ms));
29
+
30
+ const makeRecorder = () => {
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> = [];
38
+ const procedures: Array<{
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 };
45
+ const instrumentation: Instrumentation = {
46
+ eventDispatched: (tag, priority) => {
47
+ events.push({ tag, priority });
48
+ },
49
+ reducerRun: (name, eventTag) => {
50
+ reducers.push({ name, eventTag });
51
+ return () => {
52
+ ends.reducer += 1;
53
+ };
54
+ },
55
+ stateUpdated: (name) => {
56
+ states.push(name);
57
+ },
58
+ calcRecomputed: (name) => {
59
+ calcs.push(name);
60
+ return () => {
61
+ ends.calc += 1;
62
+ };
63
+ },
64
+ queryRun: (name) => {
65
+ queries.push(name);
66
+ return () => {
67
+ ends.query += 1;
68
+ };
69
+ },
70
+ procedureRun: (name, channel, eventTag) => {
71
+ procedures.push({ name, channel, eventTag });
72
+ return () => {
73
+ ends.procedure += 1;
74
+ };
75
+ },
76
+ uiRendered: () => () => {},
77
+ frame: (eventCount) => {
78
+ frames.push(eventCount);
79
+ return () => {
80
+ ends.frame += 1;
81
+ };
82
+ },
83
+ };
84
+ return {
85
+ instrumentation,
86
+ events,
87
+ reducers,
88
+ states,
89
+ calcs,
90
+ queries,
91
+ procedures,
92
+ frames,
93
+ ends,
94
+ };
95
+ };
96
+
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);
111
+
112
+ const TestLayer = FoldLive.pipe(
113
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
114
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
115
+ );
116
+
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();
121
+
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
+ );
137
+
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);
147
+
148
+ const TestLayer = WriteLive.pipe(
149
+ Layer.provideMerge(Layer.mergeAll(State.live(Cell, 7), Engine)),
150
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
151
+ );
152
+
153
+ return Effect.gen(function* () {
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
+ });
160
+
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) {}
164
+ class Inputs extends StateGroup.make(Count) {}
165
+ class Doubled extends Calc.make("InstrDoubled", {
166
+ inputs: [StateGroup.select(Inputs, "instr-calc-count")],
167
+ output: S.Number,
168
+ }) {}
169
+ const DoubledLive = Calc.live(
170
+ Doubled,
171
+ (inputs) => inputs["instr-calc-count"] * 2,
172
+ );
173
+
174
+ const TestLayer = DoubledLive.pipe(
175
+ Layer.provideMerge(StateGroup.live(Inputs, { "instr-calc-count": 5 })),
176
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
177
+ );
178
+
179
+ return Effect.gen(function* () {
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);
185
+
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
+ });
196
+
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) {}
200
+ class Inputs extends StateGroup.make(Count) {}
201
+ class Fetched extends AsyncCalc.make("InstrFetched", {
202
+ inputs: [StateGroup.select(Inputs, "instr-query-count")],
203
+ output: S.Number,
204
+ alwaysOn: true,
205
+ }) {}
206
+ const FetchedLive = AsyncCalc.live(Fetched, {
207
+ query: (inputs) =>
208
+ Effect.succeed(inputs["instr-query-count"] * 2).pipe(
209
+ Effect.delay(Duration.millis(5)),
210
+ ),
211
+ });
212
+
213
+ const TestLayer = FetchedLive.pipe(
214
+ Layer.provideMerge(StateGroup.live(Inputs, { "instr-query-count": 3 })),
215
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
216
+ );
217
+
218
+ return Effect.gen(function* () {
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
+ });
226
+
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
+ }) {}
235
+ const WorkLive = Procedure.live(Work, function* () {
236
+ yield* tick(5);
237
+ });
238
+
239
+ const TestLayer = Layer.mergeAll(Channel.live(Lane), WorkLive).pipe(
240
+ Layer.provideMerge(Engine),
241
+ Layer.locally(CurrentInstrumentation, recorder.instrumentation),
242
+ );
243
+
244
+ return Effect.gen(function* () {
245
+ yield* publish("Normal", Event.construct(Kicked, {}));
246
+ yield* tick(40);
247
+ expect(recorder.procedures).toEqual([
248
+ { name: "InstrWork", channel: "InstrLane", eventTag: "InstrKicked" },
249
+ ]);
250
+ expect(recorder.ends.procedure).toBe(1);
251
+ }).pipe(Effect.provide(TestLayer));
252
+ });
253
+
254
+ class SceneCount extends State.make("instr-scene-count", S.Number) {}
255
+ class SceneStates extends StateGroup.make(SceneCount) {}
256
+ class SceneBumped extends Event.make("InstrSceneBumped", S.Struct({})) {}
257
+ class SceneBump extends Reducer.make("InstrSceneBump", {
258
+ states: [SceneCount],
259
+ events: [SceneBumped],
260
+ }) {}
261
+ const SceneBumpLive = Reducer.live(SceneBump, (n) => n + 1);
262
+
263
+ class SceneUi extends ui("InstrScene")<{ props: { count: number } }>() {}
264
+ class SceneRoot extends Composition.make("InstrScene", {
265
+ title: "InstrScene",
266
+ states: [SceneStates],
267
+ events: [SceneBumped],
268
+ ui: SceneUi,
269
+ }) {}
270
+ const SceneRootLive = Composition.live(SceneRoot, function* () {
271
+ const count = yield* StateGroup.select(SceneStates, "instr-scene-count");
272
+ return mount({ props: { count }, slots: {} });
273
+ });
274
+
275
+ const SceneApp = SceneRootLive.pipe(
276
+ Layer.provideMerge(
277
+ provide(
278
+ SceneUi,
279
+ Ui.make(SceneUi, () => null),
280
+ ),
281
+ ),
282
+ Layer.provideMerge(
283
+ Layer.mergeAll(SceneBumpLive).pipe(
284
+ Layer.provideMerge(
285
+ Layer.mergeAll(
286
+ Engine,
287
+ StateGroup.live(SceneStates, { "instr-scene-count": 0 }),
288
+ ),
289
+ ),
290
+ ),
291
+ ),
292
+ );
293
+ const InstrScene = scene(SceneRoot, { provide: [SceneApp] });
294
+
295
+ const runScene = (target: typeof InstrScene) => {
296
+ const layer = target.provide.reduce((a, b) => Layer.merge(a, b));
297
+ const drive = Effect.gen(function* () {
298
+ yield* publish("High", Event.construct(SceneBumped, {}));
299
+ yield* tick();
300
+ });
301
+ return { layer, drive };
302
+ };
303
+
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);
311
+
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
+ );
@@ -0,0 +1,44 @@
1
+ import { Effect, FiberRef, GlobalValue } from 'effect'
2
+ import type { Priority } from './bus'
3
+
4
+ export type SpanEnd = () => void
5
+
6
+ export interface Instrumentation {
7
+ eventDispatched(tag: string, priority: Priority): void
8
+ reducerRun(name: string, eventTag: string): SpanEnd
9
+ stateUpdated(name: string): void
10
+ calcRecomputed(name: string): SpanEnd
11
+ queryRun(name: string): SpanEnd
12
+ procedureRun(name: string, channel: string, eventTag: string): SpanEnd
13
+ uiRendered(composition: string): SpanEnd
14
+ frame(eventCount: number): SpanEnd
15
+ }
16
+
17
+ const noopSpanEnd: SpanEnd = () => {}
18
+
19
+ export const noopInstrumentation: Instrumentation = {
20
+ eventDispatched: () => {},
21
+ reducerRun: () => noopSpanEnd,
22
+ stateUpdated: () => {},
23
+ calcRecomputed: () => noopSpanEnd,
24
+ queryRun: () => noopSpanEnd,
25
+ procedureRun: () => noopSpanEnd,
26
+ uiRendered: () => noopSpanEnd,
27
+ frame: () => noopSpanEnd,
28
+ }
29
+
30
+ export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> =
31
+ GlobalValue.globalValue(Symbol.for('reform/CurrentInstrumentation'), () =>
32
+ FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
33
+ )
34
+
35
+ export const resolveInstrumentation: Effect.Effect<Instrumentation> =
36
+ FiberRef.get(CurrentInstrumentation)
37
+
38
+ export const stateUpdateHook = (
39
+ instrumentation: Instrumentation,
40
+ name: string,
41
+ ): (() => void) =>
42
+ instrumentation === noopInstrumentation
43
+ ? noopSpanEnd
44
+ : () => instrumentation.stateUpdated(name)
@@ -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))
@@ -1,4 +1,4 @@
1
- import { Array as Arr, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
1
+ import { Array as Arr, Cause, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
2
2
  import {
3
3
  Channels,
4
4
  channelsLayer,
@@ -7,36 +7,20 @@ import {
7
7
  } from '../channel/channel'
8
8
  import { notificationsLayer } from '../internal/scheduler'
9
9
  import { Bus, busLayer, type Envelope, type Tagged } from './bus'
10
+ import { enforceBudget, makeTickCounter, resolveCurrentEventBudget } from './eventBudget'
11
+ import { type Instrumentation, resolveInstrumentation } from './instrumentation'
10
12
  import { Queries, queriesLayer } from './queries'
11
13
 
12
- /**
13
- * A registered reducer, with its target store(s) already captured at
14
- * registration. `apply` is a pure synchronous write, so the loop needs no
15
- * state services in its own context — it just routes events to writers.
16
- */
17
14
  export interface ReducerEntry {
18
- /** The reducer's name — used only to warn on duplicate registration. */
19
15
  readonly name: string
20
16
  readonly handles: ReadonlySet<string>
21
17
  readonly apply: (event: Tagged) => void
22
18
  }
23
19
 
24
- /**
25
- * Mutable collector the central loop drains; reducer `.live` layers register
26
- * here. `byTag` indexes entries by the event tags they handle, so dispatch is
27
- * O(matching reducers) per event rather than O(all reducers) — the difference
28
- * that matters once an app has hundreds of reducers.
29
- */
30
20
  export interface ReducerRegistry {
31
21
  readonly entries: Array<ReducerEntry>
32
22
  readonly byTag: Map<string, Array<ReducerEntry>>
33
23
  readonly register: (entry: ReducerEntry) => void
34
- /**
35
- * Remove a previously-registered entry. Eager reducers (built on the root scope)
36
- * never call this; a lazy feature's `Reducer.live` registers on mount and
37
- * unregisters on unmount (scope close), so an unmounted feature stops folding and
38
- * its entry is reclaimed instead of leaking + writing to an orphaned store.
39
- */
40
24
  readonly unregister: (entry: ReducerEntry) => void
41
25
  }
42
26
 
@@ -44,7 +28,6 @@ const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistr
44
28
  Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
45
29
  export class Reducers extends ReducersBase {}
46
30
 
47
- /** Drop a target from a `Map<string, Array>` bucket, pruning the key when it empties. */
48
31
  const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T): void => {
49
32
  const bucket = map.get(key)
50
33
  if (bucket === undefined) {
@@ -60,8 +43,6 @@ const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T):
60
43
 
61
44
  const makeReducerRegistry = (): ReducerRegistry => {
62
45
  const byTag = new Map<string, Array<ReducerEntry>>()
63
- // Single live array reference, replaced immutably on register/unregister and
64
- // exposed through the `entries` getter so consumers always read the current set.
65
46
  const state: { entries: Array<ReducerEntry> } = { entries: [] }
66
47
  return {
67
48
  get entries() {
@@ -86,77 +67,77 @@ const makeReducerRegistry = (): ReducerRegistry => {
86
67
  }
87
68
  }
88
69
 
89
- /** Read a bucket array, defaulting to empty without a falsy fallback. */
90
70
  const bucketOf = <V>(map: Map<string, Array<V>>, tag: string): Array<V> =>
91
71
  Option.getOrElse(Option.fromNullable(map.get(tag)), (): Array<V> => [])
92
72
 
93
- /** Run one reducer in isolation; a synchronous throw becomes a collected failure. */
94
73
  const isolateApply = (
95
74
  reducer: ReducerEntry,
96
75
  event: Tagged,
97
- ): Option.Option<{ readonly event: Tagged; readonly error: unknown }> =>
98
- Effect.runSync(
76
+ instrumentation: Instrumentation,
77
+ ): Option.Option<{ readonly event: Tagged; readonly error: unknown }> => {
78
+ const end = instrumentation.reducerRun(reducer.name, event._tag)
79
+ return Effect.runSync(
99
80
  Effect.match(
100
- Effect.try({ try: () => reducer.apply(event), catch: (error) => error }),
81
+ Effect.try({ try: () => reducer.apply(event), catch: (error) => error }).pipe(
82
+ Effect.ensuring(Effect.sync(end)),
83
+ ),
101
84
  {
102
85
  onFailure: (error) => Option.some({ event, error }),
103
86
  onSuccess: () => Option.none(),
104
87
  },
105
88
  ),
106
89
  )
90
+ }
107
91
 
108
92
  export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
109
93
 
110
- /** High before Normal; within a priority class, dispatch order is preserved. */
94
+ // High before Normal; partition preserves intra-priority dispatch order
111
95
  const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
112
96
 
113
- // The single drain loop: one fiber that frame-batches the bus. Each tick it
114
- // harvests every event dispatched in that microtask, runs all matching reducers
115
- // in ONE synchronous pass (so the store notifier flushes once for the batch),
116
- // then routes each event to the channels its procedures run on.
117
97
  const drain = Effect.gen(function* () {
118
98
  const bus = yield* Bus
119
99
  const reducers = yield* Reducers
120
100
  const { byName: channels } = yield* Channels
121
101
  const procedures = yield* Procedures
102
+ const instrumentation = yield* resolveInstrumentation
103
+ // One counter per engine: feature child runtimes share the app budget
104
+ const budget = yield* resolveCurrentEventBudget
105
+ const counter = makeTickCounter(budget)
122
106
  const subscription = yield* PubSub.subscribe(bus)
123
107
 
124
108
  yield* Effect.forkScoped(
125
109
  Effect.forever(
126
110
  Effect.gen(function* () {
127
- // Block until the first event, then drain the rest of this tick.
128
111
  const first = yield* Queue.take(subscription)
129
112
  const rest = yield* Queue.takeAll(subscription)
130
113
  const frame = [first, ...Chunk.toArray(rest)]
131
114
 
132
- // Stable priority order: High-dispatched (UI) events fold before
133
- // Normal-dispatched (procedure follow-up) events in the same frame. A
134
- // two-bucket partition does this in O(m) with no comparator — partition
135
- // preserves intra-priority dispatch order — instead of sorting the frame.
136
115
  const [high, normal] = Arr.partition(frame, (envelope) => rank(envelope.priority) !== 0)
137
116
  const ordered = [...high, ...normal]
138
117
 
139
- // One synchronous block: every reducer write for the frame coalesces into
140
- // a single notification flush. Routing to channels happens here too, so
141
- // procedure bodies (run later, off their channel fibers) see post-batch
142
- // state. No `yield*` in between, or the flush could fire early.
118
+ // Budget before folding EventLoopOverflow is this tick's only typed failure
119
+ const sample = yield* Effect.sync(() => counter.add(ordered.length))
120
+ yield* enforceBudget(budget, sample)
121
+
122
+ // Sync block: coalesce reducer writes into one flush; no yield* mid-frame
143
123
  const failures = yield* Effect.sync(() => {
144
- // Isolate each fold: a synchronous throw in one reducer must not abandon
145
- // the rest of the frame nor (via the `forever` below) kill the drain fiber
146
- // and freeze the whole app. Collect failures here, log after the block.
124
+ const endFrame = instrumentation.frame(ordered.length)
125
+ ordered.forEach((envelope) =>
126
+ instrumentation.eventDispatched(envelope.event._tag, envelope.priority),
127
+ )
128
+ // Isolate folds so one throw cannot kill the drain fiber
147
129
  const collected = Arr.flatMap(ordered, (envelope) =>
148
130
  Arr.filterMap(bucketOf(reducers.byTag, envelope.event._tag), (reducer) =>
149
- isolateApply(reducer, envelope.event),
131
+ isolateApply(reducer, envelope.event, instrumentation),
150
132
  ),
151
133
  )
152
- // Offer to each DISTINCT channel once. Routing per-procedure would offer a
153
- // shared channel multiple times for one event, and each offer re-runs every
154
- // procedure on it — duplicate execution.
134
+ // Offer each distinct channel once (per-procedure would double-run shared channels)
155
135
  ordered.forEach((envelope) => {
156
136
  bucketOf(procedures.channelsByTag, envelope.event._tag).forEach((channelName) => {
157
137
  channels.get(channelName)?.offer(envelope.event)
158
138
  })
159
139
  })
140
+ endFrame()
160
141
  return collected
161
142
  })
162
143
  yield* Effect.forEach(
@@ -169,21 +150,23 @@ const drain = Effect.gen(function* () {
169
150
  { discard: true },
170
151
  )
171
152
  }).pipe(
172
- // Belt-and-suspenders: even a defect outside the isolated fold (a bug in
173
- // the loop itself) is logged and the tick restarts, so the bus never goes
174
- // permanently deaf.
175
- Effect.catchAllCause((cause) => Effect.logError('reform: drain tick crashed', cause)),
153
+ // Only typed failures (overflow) escape; defects restart the tick so the bus never goes deaf
154
+ Effect.catchAllCause((cause) =>
155
+ Option.match(Cause.failureOption(cause), {
156
+ onSome: () => Effect.failCause(cause),
157
+ onNone: () => Effect.logError('reform: drain tick crashed', cause),
158
+ }),
159
+ ),
160
+ ),
161
+ ).pipe(
162
+ Effect.tapErrorCause((cause) =>
163
+ Effect.logError('reform: event budget exceeded — the drain loop has been halted', cause),
176
164
  ),
165
+ Effect.tapError((overflow) => Effect.sync(() => budget.onOverflow(overflow))),
177
166
  ),
178
167
  )
179
168
  })
180
169
 
181
- /**
182
- * The reform engine: provides the bus + reducer/channel/procedure registries +
183
- * the per-runtime notification scheduler to the application and forks the single
184
- * drain loop. Registration mutates the live collections, so reducers/procedures/
185
- * channels merged alongside are picked up before the first dispatch (boot).
186
- */
187
170
  export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures | Queries> =
188
171
  Layer.scopedDiscard(drain).pipe(
189
172
  Layer.provideMerge(
@@ -1,25 +1,11 @@
1
1
  import { Context, Layer } from 'effect'
2
2
  import { type QueryState } from '../calc/queryState'
3
3
 
4
- // The `Queries` registry: the runtime-wide index of live query handles, the same
5
- // shape as `Reducers`/`Channels`/`Procedures` (`loop.ts`, `channel.ts`). Each
6
- // `AsyncCalc.live` driver registers its handle here scoped — so `AsyncCalc.invalidate`
7
- // / `AsyncCalc.refetch` (and any provider layer) can act on a calc by name without
8
- // holding its store tag. A base in-memory registry ships in `Engine`; provider
9
- // layers (persistence, focus/online managers) build on top by calling the handles.
10
- //
11
- // The per-calc generics are erased through function signatures (reads are
12
- // covariant; `subscribe` is uniform), so the heterogeneous registry needs no cast.
13
-
14
4
  export interface QueryHandle {
15
5
  readonly name: string
16
- /** Mark the query's value stale (`isStale := true`). Does not fetch. */
17
6
  readonly invalidate: () => void
18
- /** Force a run of the current key, bypassing the no-op-key guard. */
19
7
  readonly refetch: () => void
20
- /** Read the current state (erased to the open value type). */
21
8
  readonly snapshot: () => QueryState<unknown, unknown>
22
- /** Subscribe to state changes; returns an unsubscribe thunk. */
23
9
  readonly subscribe: (listener: () => void) => () => void
24
10
  }
25
11
 
@@ -41,9 +27,6 @@ const makeQueryRegistry = (): QueryRegistry => {
41
27
  register: (handle) => {
42
28
  byName.set(handle.name, handle)
43
29
  },
44
- // Only drop the entry if it is still the one we registered: a same-named
45
- // re-registration (re-mount before the old scope's finalizer runs) must not
46
- // be clobbered by the stale handle's unregister.
47
30
  unregister: (handle) => {
48
31
  if (byName.get(handle.name) === handle) {
49
32
  byName.delete(handle.name)