@playfast/reform 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -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
- * The sanctioned profiling seam for CLOSED scenes — the same mechanism as
59
- * `CurrentSeedOverrides` (`internal/seeds.ts`): a scene's layers are
60
- * pre-composed, but a FiberRef set via `Layer.locally(ref, value)(layer)` IS
61
- * visible inside the construction effects of nested layers. Engine/state/calc
62
- * layers consult this ref once at build time and capture the resolved instance
63
- * into their hot-path closures.
64
- *
65
- * Defaults to {@link noopInstrumentation} — production wiring never touches it.
66
- * `profileScene` (in `@playfast/reform-profiler`) is the intended writer.
67
- * `globalValue` keeps a single ref instance even if the module is loaded twice
68
- * (duplicated bundles, HMR), so the writer and the reader always agree.
69
- */
70
- 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
- * The store `onChange` hook for one named state/calc: a bound
84
- * `stateUpdated(name)` closure when profiled, the shared no-op otherwise — so
85
- * an unprofiled store's write path never constructs the name-carrying call.
86
- */
87
- 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)
@@ -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
+ })
@@ -1,97 +1,93 @@
1
- import { Array as Arr, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
2
- import {
3
- Channels,
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 byTag: Map<string, Array<ReducerEntry>>
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
- Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
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
- /** Drop a target from a `Map<string, Array>` bucket, pruning the key when it empties. */
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
- const next = bucket.filter((entry) => entry !== target)
55
- if (next.length === 0) {
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 byTag = new Map<string, Array<ReducerEntry>>()
64
- // Single live array reference, replaced immutably on register/unregister and
65
- // exposed through the `entries` getter so consumers always read the current set.
66
- const state: { entries: Array<ReducerEntry> } = { entries: [] }
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 state.entries
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
- state.entries = [...state.entries, entry]
61
+ entries.add(entry)
62
+ snapshot.value = Option.none()
63
+ getOrCreateBucket(byName, entry.name).add(entry)
74
64
  entry.handles.forEach((tag) => {
75
- const bucket = byTag.get(tag)
76
- if (bucket === undefined) {
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
- state.entries = state.entries.filter((existing) => existing !== entry)
85
- entry.handles.forEach((tag) => dropFromBuckets(byTag, tag, entry))
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
- /** Read a bucket array, defaulting to empty without a falsy fallback. */
91
- const bucketOf = <V>(map: Map<string, Array<V>>, tag: string): Array<V> =>
92
- Option.getOrElse(Option.fromNullable(map.get(tag)), (): Array<V> => [])
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({ try: () => reducer.apply(event), catch: (error) => error }).pipe(
104
- Effect.ensuring(Effect.sync(end)),
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
- /** High before Normal; within a priority class, dispatch order is preserved. */
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
- // One synchronous block: every reducer write for the frame coalesces into
148
- // a single notification flush. Routing to channels happens here too, so
149
- // procedure bodies (run later, off their channel fibers) see post-batch
150
- // state. No `yield*` in between, or the flush could fire early.
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
- Arr.filterMap(bucketOf(reducers.byTag, envelope.event._tag), (reducer) =>
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
- bucketOf(procedures.channelsByTag, envelope.event._tag).forEach((channelName) => {
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
- // Belt-and-suspenders: even a defect outside the isolated fold (a bug in
186
- // the loop itself) is logged and the tick restarts, so the bus never goes
187
- // permanently deaf.
188
- Effect.catchAllCause((cause) => Effect.logError('reform: drain tick crashed', cause)),
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>
@@ -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
+ })