@playfast/reform 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
|
@@ -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,40 +27,13 @@ export const noopInstrumentation: Instrumentation = {
|
|
|
54
27
|
frame: () => noopSpanEnd,
|
|
55
28
|
}
|
|
56
29
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> =
|
|
71
|
-
GlobalValue.globalValue(Symbol.for('reform/CurrentInstrumentation'), () =>
|
|
72
|
-
FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
|
|
73
|
-
)
|
|
30
|
+
export const CurrentInstrumentation: FiberRef.FiberRef<Instrumentation> = GlobalValue.globalValue(
|
|
31
|
+
Symbol.for('reform/CurrentInstrumentation'),
|
|
32
|
+
() => FiberRef.unsafeMake<Instrumentation>(noopInstrumentation),
|
|
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
|
-
|
|
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
|
-
export const stateUpdateHook = (
|
|
88
|
-
instrumentation: Instrumentation,
|
|
89
|
-
name: string,
|
|
90
|
-
): (() => void) =>
|
|
91
|
-
instrumentation === noopInstrumentation
|
|
92
|
-
? noopSpanEnd
|
|
93
|
-
: () => instrumentation.stateUpdated(name)
|
|
38
|
+
export const stateUpdateHook = (instrumentation: Instrumentation, name: string): (() => void) =>
|
|
39
|
+
instrumentation === noopInstrumentation ? noopSpanEnd : () => 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,16 +149,49 @@ 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))
|
|
178
161
|
})
|
|
162
|
+
|
|
163
|
+
it.live('a fold returning the previous reference keeps the exact store value', () => {
|
|
164
|
+
const Viewer = S.Union(
|
|
165
|
+
S.TaggedStruct('Closed', {}),
|
|
166
|
+
S.TaggedStruct('Open', { revision: S.Number }),
|
|
167
|
+
)
|
|
168
|
+
class ViewerState extends State.make('viewer', Viewer) {}
|
|
169
|
+
class Poked extends Event.make('Poked', S.Struct({})) {}
|
|
170
|
+
class PokeReducer extends Reducer.make('PokeReducer', {
|
|
171
|
+
states: [ViewerState],
|
|
172
|
+
events: [Poked],
|
|
173
|
+
}) {}
|
|
174
|
+
// Closed is a fixpoint: the fold hands back the previous reference untouched.
|
|
175
|
+
const PokeReducerLive = Reducer.live(PokeReducer, (viewer) =>
|
|
176
|
+
viewer._tag === 'Open' ? { ...viewer, revision: viewer.revision + 1 } : viewer,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const TestLayer = PokeReducerLive.pipe(
|
|
180
|
+
Layer.provideMerge(Layer.mergeAll(State.live(ViewerState, { _tag: 'Closed' }), Engine)),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return Effect.gen(function* () {
|
|
184
|
+
const store = yield* ViewerState.store
|
|
185
|
+
const before = store.get()
|
|
186
|
+
const notifications = { n: 0 }
|
|
187
|
+
store.subscribe(() => {
|
|
188
|
+
notifications.n += 1
|
|
189
|
+
})
|
|
190
|
+
yield* publish('Normal', Event.construct(Poked, {}))
|
|
191
|
+
yield* Effect.sleep(Duration.millis(20))
|
|
192
|
+
// Reference identity is the repaint signal: an unchanged fold must not be
|
|
193
|
+
// re-decoded into a fresh, structurally-equal value that wakes subscribers.
|
|
194
|
+
expect(store.get()).toBe(before)
|
|
195
|
+
expect(notifications.n).toBe(0)
|
|
196
|
+
}).pipe(Effect.provide(TestLayer))
|
|
197
|
+
})
|
package/src/runtime/loop.ts
CHANGED
|
@@ -1,97 +1,93 @@
|
|
|
1
|
-
import { Array as Arr, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
channelsLayer,
|
|
5
|
-
Procedures,
|
|
6
|
-
proceduresLayer,
|
|
7
|
-
} from '../channel/channel'
|
|
1
|
+
import { Array as Arr, Cause, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
|
|
2
|
+
import { Channels, channelsLayer, Procedures, proceduresLayer } from '../channel/channel'
|
|
3
|
+
import { makeBucketCache } from '../internal/bucketCache'
|
|
8
4
|
import { notificationsLayer } from '../internal/scheduler'
|
|
9
5
|
import { Bus, busLayer, type Envelope, type Tagged } from './bus'
|
|
6
|
+
import { enforceBudget, makeTickCounter, resolveCurrentEventBudget } from './eventBudget'
|
|
10
7
|
import { type Instrumentation, resolveInstrumentation } from './instrumentation'
|
|
11
8
|
import { Queries, queriesLayer } from './queries'
|
|
12
9
|
|
|
13
|
-
/**
|
|
14
|
-
* A registered reducer, with its target store(s) already captured at
|
|
15
|
-
* registration. `apply` is a pure synchronous write, so the loop needs no
|
|
16
|
-
* state services in its own context — it just routes events to writers.
|
|
17
|
-
*/
|
|
18
10
|
export interface ReducerEntry {
|
|
19
|
-
/** The reducer's name — used only to warn on duplicate registration. */
|
|
20
11
|
readonly name: string
|
|
21
12
|
readonly handles: ReadonlySet<string>
|
|
22
13
|
readonly apply: (event: Tagged) => void
|
|
23
14
|
}
|
|
24
15
|
|
|
25
|
-
/**
|
|
26
|
-
* Mutable collector the central loop drains; reducer `.live` layers register
|
|
27
|
-
* here. `byTag` indexes entries by the event tags they handle, so dispatch is
|
|
28
|
-
* O(matching reducers) per event rather than O(all reducers) — the difference
|
|
29
|
-
* that matters once an app has hundreds of reducers.
|
|
30
|
-
*/
|
|
31
16
|
export interface ReducerRegistry {
|
|
32
17
|
readonly entries: Array<ReducerEntry>
|
|
33
|
-
readonly
|
|
18
|
+
readonly byName: Map<string, Set<ReducerEntry>>
|
|
19
|
+
readonly byTag: Map<string, Set<ReducerEntry>>
|
|
20
|
+
readonly tagged: (tag: string) => ReadonlyArray<ReducerEntry>
|
|
34
21
|
readonly register: (entry: ReducerEntry) => void
|
|
35
|
-
/**
|
|
36
|
-
* Remove a previously-registered entry. Eager reducers (built on the root scope)
|
|
37
|
-
* never call this; a lazy feature's `Reducer.live` registers on mount and
|
|
38
|
-
* unregisters on unmount (scope close), so an unmounted feature stops folding and
|
|
39
|
-
* its entry is reclaimed instead of leaking + writing to an orphaned store.
|
|
40
|
-
*/
|
|
41
22
|
readonly unregister: (entry: ReducerEntry) => void
|
|
42
23
|
}
|
|
43
24
|
|
|
44
|
-
const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> =
|
|
45
|
-
|
|
25
|
+
const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> = Context.Tag(
|
|
26
|
+
'reform/Reducers',
|
|
27
|
+
)<Reducers, ReducerRegistry>()
|
|
46
28
|
export class Reducers extends ReducersBase {}
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T): void => {
|
|
30
|
+
const dropFromBuckets = <T>(map: Map<string, Set<T>>, key: string, target: T): void => {
|
|
50
31
|
const bucket = map.get(key)
|
|
51
32
|
if (bucket === undefined) {
|
|
52
33
|
return
|
|
53
34
|
}
|
|
54
|
-
|
|
55
|
-
if (
|
|
35
|
+
bucket.delete(target)
|
|
36
|
+
if (bucket.size === 0) {
|
|
56
37
|
map.delete(key)
|
|
57
|
-
} else {
|
|
58
|
-
map.set(key, next)
|
|
59
38
|
}
|
|
60
39
|
}
|
|
61
40
|
|
|
62
41
|
const makeReducerRegistry = (): ReducerRegistry => {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
42
|
+
const entries = new Set<ReducerEntry>()
|
|
43
|
+
const byName = new Map<string, Set<ReducerEntry>>()
|
|
44
|
+
const byTag = new Map<string, Set<ReducerEntry>>()
|
|
45
|
+
const tagged = makeBucketCache(byTag)
|
|
46
|
+
const snapshot: { value: Option.Option<Array<ReducerEntry>> } = {
|
|
47
|
+
value: Option.some([]),
|
|
48
|
+
}
|
|
67
49
|
return {
|
|
68
50
|
get entries() {
|
|
69
|
-
return
|
|
51
|
+
return Option.getOrElse(snapshot.value, () => {
|
|
52
|
+
const current = Array.from(entries)
|
|
53
|
+
snapshot.value = Option.some(current)
|
|
54
|
+
return current
|
|
55
|
+
})
|
|
70
56
|
},
|
|
57
|
+
byName,
|
|
71
58
|
byTag,
|
|
59
|
+
tagged: tagged.read,
|
|
72
60
|
register: (entry) => {
|
|
73
|
-
|
|
61
|
+
entries.add(entry)
|
|
62
|
+
snapshot.value = Option.none()
|
|
63
|
+
getOrCreateBucket(byName, entry.name).add(entry)
|
|
74
64
|
entry.handles.forEach((tag) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
byTag.set(tag, [entry])
|
|
78
|
-
} else {
|
|
79
|
-
byTag.set(tag, [...bucket, entry])
|
|
80
|
-
}
|
|
65
|
+
getOrCreateBucket(byTag, tag).add(entry)
|
|
66
|
+
tagged.invalidate(tag)
|
|
81
67
|
})
|
|
82
68
|
},
|
|
83
69
|
unregister: (entry) => {
|
|
84
|
-
|
|
85
|
-
|
|
70
|
+
entries.delete(entry)
|
|
71
|
+
snapshot.value = Option.none()
|
|
72
|
+
dropFromBuckets(byName, entry.name, entry)
|
|
73
|
+
entry.handles.forEach((tag) => {
|
|
74
|
+
dropFromBuckets(byTag, tag, entry)
|
|
75
|
+
tagged.invalidate(tag)
|
|
76
|
+
})
|
|
86
77
|
},
|
|
87
78
|
}
|
|
88
79
|
}
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
81
|
+
const getOrCreateBucket = <T>(map: Map<string, Set<T>>, key: string): Set<T> => {
|
|
82
|
+
const existing = map.get(key)
|
|
83
|
+
if (existing !== undefined) {
|
|
84
|
+
return existing
|
|
85
|
+
}
|
|
86
|
+
const created = new Set<T>()
|
|
87
|
+
map.set(key, created)
|
|
88
|
+
return created
|
|
89
|
+
}
|
|
93
90
|
|
|
94
|
-
/** Run one reducer in isolation; a synchronous throw becomes a collected failure. */
|
|
95
91
|
const isolateApply = (
|
|
96
92
|
reducer: ReducerEntry,
|
|
97
93
|
event: Tagged,
|
|
@@ -100,9 +96,10 @@ const isolateApply = (
|
|
|
100
96
|
const end = instrumentation.reducerRun(reducer.name, event._tag)
|
|
101
97
|
return Effect.runSync(
|
|
102
98
|
Effect.match(
|
|
103
|
-
Effect.try({
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
Effect.try({
|
|
100
|
+
try: () => reducer.apply(event),
|
|
101
|
+
catch: (error) => error,
|
|
102
|
+
}).pipe(Effect.ensuring(Effect.sync(end))),
|
|
106
103
|
{
|
|
107
104
|
onFailure: (error) => Option.some({ event, error }),
|
|
108
105
|
onSuccess: () => Option.none(),
|
|
@@ -113,59 +110,49 @@ const isolateApply = (
|
|
|
113
110
|
|
|
114
111
|
export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
|
|
115
112
|
|
|
116
|
-
|
|
113
|
+
// Partitioning keeps dispatch order stable within each priority.
|
|
117
114
|
const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
|
|
118
115
|
|
|
119
|
-
// The single drain loop: one fiber that frame-batches the bus. Each tick it
|
|
120
|
-
// harvests every event dispatched in that microtask, runs all matching reducers
|
|
121
|
-
// in ONE synchronous pass (so the store notifier flushes once for the batch),
|
|
122
|
-
// then routes each event to the channels its procedures run on.
|
|
123
116
|
const drain = Effect.gen(function* () {
|
|
124
117
|
const bus = yield* Bus
|
|
125
118
|
const reducers = yield* Reducers
|
|
126
119
|
const { byName: channels } = yield* Channels
|
|
127
120
|
const procedures = yield* Procedures
|
|
128
|
-
// Captured once at engine build; the noop instance costs nothing per frame.
|
|
129
121
|
const instrumentation = yield* resolveInstrumentation
|
|
122
|
+
const budget = yield* resolveCurrentEventBudget
|
|
123
|
+
// Child feature runtimes share this engine-level counter with the root.
|
|
124
|
+
const counter = makeTickCounter(budget)
|
|
130
125
|
const subscription = yield* PubSub.subscribe(bus)
|
|
131
126
|
|
|
132
127
|
yield* Effect.forkScoped(
|
|
133
128
|
Effect.forever(
|
|
134
129
|
Effect.gen(function* () {
|
|
135
|
-
// Block until the first event, then drain the rest of this tick.
|
|
136
130
|
const first = yield* Queue.take(subscription)
|
|
137
131
|
const rest = yield* Queue.takeAll(subscription)
|
|
138
132
|
const frame = [first, ...Chunk.toArray(rest)]
|
|
139
133
|
|
|
140
|
-
// Stable priority order: High-dispatched (UI) events fold before
|
|
141
|
-
// Normal-dispatched (procedure follow-up) events in the same frame. A
|
|
142
|
-
// two-bucket partition does this in O(m) with no comparator — partition
|
|
143
|
-
// preserves intra-priority dispatch order — instead of sorting the frame.
|
|
144
134
|
const [high, normal] = Arr.partition(frame, (envelope) => rank(envelope.priority) !== 0)
|
|
145
135
|
const ordered = [...high, ...normal]
|
|
146
136
|
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
137
|
+
// Check before folding so runaway frames stop at the hard limit.
|
|
138
|
+
const sample = yield* Effect.sync(() => counter.add(ordered.length))
|
|
139
|
+
yield* enforceBudget(budget, sample)
|
|
140
|
+
|
|
141
|
+
// One synchronous block coalesces reducer writes into a single flush.
|
|
151
142
|
const failures = yield* Effect.sync(() => {
|
|
152
143
|
const endFrame = instrumentation.frame(ordered.length)
|
|
153
144
|
ordered.forEach((envelope) =>
|
|
154
145
|
instrumentation.eventDispatched(envelope.event._tag, envelope.priority),
|
|
155
146
|
)
|
|
156
|
-
// Isolate each fold: a synchronous throw in one reducer must not abandon
|
|
157
|
-
// the rest of the frame nor (via the `forever` below) kill the drain fiber
|
|
158
|
-
// and freeze the whole app. Collect failures here, log after the block.
|
|
159
147
|
const collected = Arr.flatMap(ordered, (envelope) =>
|
|
160
|
-
|
|
148
|
+
// A reducer defect must not abandon the remaining frame.
|
|
149
|
+
Arr.filterMap(reducers.tagged(envelope.event._tag), (reducer) =>
|
|
161
150
|
isolateApply(reducer, envelope.event, instrumentation),
|
|
162
151
|
),
|
|
163
152
|
)
|
|
164
|
-
// Offer to each DISTINCT channel once. Routing per-procedure would offer a
|
|
165
|
-
// shared channel multiple times for one event, and each offer re-runs every
|
|
166
|
-
// procedure on it — duplicate execution.
|
|
167
153
|
ordered.forEach((envelope) => {
|
|
168
|
-
|
|
154
|
+
// channelsFor is distinct; per-procedure offers would double-run shared channels.
|
|
155
|
+
procedures.channelsFor(envelope.event._tag).forEach((channelName) => {
|
|
169
156
|
channels.get(channelName)?.offer(envelope.event)
|
|
170
157
|
})
|
|
171
158
|
})
|
|
@@ -182,21 +169,23 @@ const drain = Effect.gen(function* () {
|
|
|
182
169
|
{ discard: true },
|
|
183
170
|
)
|
|
184
171
|
}).pipe(
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
172
|
+
// Typed overflow halts the loop; defects are logged and the next tick continues.
|
|
173
|
+
Effect.catchAllCause((cause) =>
|
|
174
|
+
Option.match(Cause.failureOption(cause), {
|
|
175
|
+
onSome: () => Effect.failCause(cause),
|
|
176
|
+
onNone: () => Effect.logError('reform: drain tick crashed', cause),
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
).pipe(
|
|
181
|
+
Effect.tapErrorCause((cause) =>
|
|
182
|
+
Effect.logError('reform: event budget exceeded — the drain loop has been halted', cause),
|
|
189
183
|
),
|
|
184
|
+
Effect.tapError((overflow) => Effect.sync(() => budget.onOverflow(overflow))),
|
|
190
185
|
),
|
|
191
186
|
)
|
|
192
187
|
})
|
|
193
188
|
|
|
194
|
-
/**
|
|
195
|
-
* The reform engine: provides the bus + reducer/channel/procedure registries +
|
|
196
|
-
* the per-runtime notification scheduler to the application and forks the single
|
|
197
|
-
* drain loop. Registration mutates the live collections, so reducers/procedures/
|
|
198
|
-
* channels merged alongside are picked up before the first dispatch (boot).
|
|
199
|
-
*/
|
|
200
189
|
export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures | Queries> =
|
|
201
190
|
Layer.scopedDiscard(drain).pipe(
|
|
202
191
|
Layer.provideMerge(
|
|
@@ -210,3 +199,13 @@ export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures | Querie
|
|
|
210
199
|
),
|
|
211
200
|
),
|
|
212
201
|
)
|
|
202
|
+
|
|
203
|
+
export const EngineRequirements: readonly [
|
|
204
|
+
typeof Bus,
|
|
205
|
+
typeof Reducers,
|
|
206
|
+
typeof Channels,
|
|
207
|
+
typeof Procedures,
|
|
208
|
+
typeof Queries,
|
|
209
|
+
] = [Bus, Reducers, Channels, Procedures, Queries]
|
|
210
|
+
|
|
211
|
+
export type EngineServices = Layer.Layer.Success<typeof Engine>
|
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)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
import * as Composition from '../compose/composition'
|
|
4
|
+
import { mount } from '../compose/structure'
|
|
5
|
+
import { ui } from '../compose/ui'
|
|
6
|
+
import * as Event from '../event/event'
|
|
7
|
+
import * as Feature from '../feature/feature'
|
|
8
|
+
import { featureModule } from '../feature/feature'
|
|
9
|
+
import { makeAppRuntime } from '../runtime/appRuntime'
|
|
10
|
+
import { Engine } from '../runtime/loop'
|
|
11
|
+
import { featureScene } from './scene'
|
|
12
|
+
|
|
13
|
+
interface GreetingService {
|
|
14
|
+
readonly value: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const GreetingBase: Context.TagClass<Greeting, 'featureScene.Greeting', GreetingService> =
|
|
18
|
+
Context.Tag('featureScene.Greeting')<Greeting, GreetingService>()
|
|
19
|
+
class Greeting extends GreetingBase {}
|
|
20
|
+
|
|
21
|
+
class Started extends Event.make('featureScene.Started', S.Struct({})) {}
|
|
22
|
+
class RootUi extends ui('featureScene.RootUi')<{
|
|
23
|
+
props: { readonly greeting: string }
|
|
24
|
+
}>() {}
|
|
25
|
+
class RootComp extends Composition.make('featureScene.RootComp', {
|
|
26
|
+
title: 'Feature root',
|
|
27
|
+
ui: RootUi,
|
|
28
|
+
})<RootComp>() {}
|
|
29
|
+
|
|
30
|
+
const RootLive = Composition.live(RootComp, function* () {
|
|
31
|
+
const greeting = yield* Greeting
|
|
32
|
+
return mount({ props: { greeting: greeting.value }, slots: {} })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
class RootFeature extends Feature.make('featureScene.root', {
|
|
36
|
+
composition: RootComp,
|
|
37
|
+
module: featureModule([Greeting], RootLive),
|
|
38
|
+
boot: [Event.construct(Started, {})],
|
|
39
|
+
}) {}
|
|
40
|
+
|
|
41
|
+
test('featureScene closes an eager root Feature with its typed requirements and boot', () => {
|
|
42
|
+
const root = featureScene(RootFeature, {
|
|
43
|
+
provide: Layer.mergeAll(Engine, Layer.succeed(Greeting, { value: 'hello' })),
|
|
44
|
+
})
|
|
45
|
+
const app = makeAppRuntime(root)
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const rendered = Effect.runSync(
|
|
49
|
+
Composition.render(app.read(RootComp.tag), {
|
|
50
|
+
props: {},
|
|
51
|
+
tracker: { add: () => {} },
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(root.composition).toBe(RootComp)
|
|
56
|
+
expect(root.boot).toEqual([Event.construct(Started, {})])
|
|
57
|
+
expect(rendered.props).toEqual({ greeting: 'hello' })
|
|
58
|
+
} finally {
|
|
59
|
+
app.dispose()
|
|
60
|
+
}
|
|
61
|
+
})
|