@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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
|
@@ -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)
|
package/src/runtime/loop.test.ts
CHANGED
|
@@ -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))
|
package/src/runtime/loop.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(
|
package/src/runtime/queries.ts
CHANGED
|
@@ -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)
|