@playfast/reform 0.0.7 → 0.0.8

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 (237) hide show
  1. package/package.json +2 -2
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/boundary/boundary.ts +192 -0
  4. package/src/calc/asyncCalc.test.ts +556 -0
  5. package/src/calc/asyncCalc.ts +267 -0
  6. package/src/calc/asyncData.ts +96 -0
  7. package/src/calc/calc.test.ts +287 -0
  8. package/src/calc/calc.ts +120 -0
  9. package/src/calc/calcFamily.test.ts +206 -0
  10. package/src/calc/calcFamily.ts +194 -0
  11. package/src/calc/compose.test.ts +68 -0
  12. package/src/calc/compose.ts +69 -0
  13. package/src/channel/channel.ts +269 -0
  14. package/src/compose/composition.ts +112 -0
  15. package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
  16. package/{dist/compose/props.js → src/compose/props.ts} +7 -5
  17. package/src/compose/provide.ts +70 -0
  18. package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  19. package/src/compose/ui.test.ts +62 -0
  20. package/src/compose/ui.ts +226 -0
  21. package/src/compose/ui.typecheck.ts +56 -0
  22. package/src/definition/definition.ts +76 -0
  23. package/src/event/event.test.ts +23 -0
  24. package/src/event/event.ts +65 -0
  25. package/src/event/eventGroup.ts +14 -0
  26. package/src/feature/feature.mount.test.ts +82 -0
  27. package/src/feature/feature.test.ts +60 -0
  28. package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  29. package/src/feature/feature.typecheck.ts +108 -0
  30. package/src/index.ts +176 -0
  31. package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  32. package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  33. package/src/internal/errors.test.ts +33 -0
  34. package/src/internal/errors.ts +145 -0
  35. package/src/internal/inspect.test.ts +28 -0
  36. package/src/internal/inspect.ts +34 -0
  37. package/src/internal/queryDriver.ts +247 -0
  38. package/src/internal/reuse.test.ts +116 -0
  39. package/src/internal/reuse.ts +73 -0
  40. package/src/internal/scheduler.ts +93 -0
  41. package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  42. package/src/internal/sources.ts +104 -0
  43. package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
  44. package/src/internal/store.test.ts +80 -0
  45. package/src/internal/store.ts +105 -0
  46. package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
  47. package/src/procedure/procedure.ts +89 -0
  48. package/src/reducer/reducer.ts +137 -0
  49. package/src/remote/remoteState.test.ts +695 -0
  50. package/src/remote/remoteState.ts +571 -0
  51. package/src/remote/remoteState.typecheck.ts +195 -0
  52. package/src/runtime/bus.ts +40 -0
  53. package/src/runtime/hardening.test.ts +69 -0
  54. package/src/runtime/loop.test.ts +178 -0
  55. package/src/runtime/loop.ts +171 -0
  56. package/src/scene/scene.ts +76 -0
  57. package/src/scene/seedScene.test.ts +169 -0
  58. package/src/state/state.ts +94 -0
  59. package/src/state/stateFamily.test.ts +138 -0
  60. package/src/state/stateFamily.ts +174 -0
  61. package/src/state/stateGroup.ts +105 -0
  62. package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
  63. package/src/synced/syncedStore.ts +99 -0
  64. package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
  65. package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  66. package/src/wire/tree.test.ts +81 -0
  67. package/src/wire/tree.ts +129 -0
  68. package/src/wire/triggers.test.ts +76 -0
  69. package/src/wire/triggers.ts +98 -0
  70. package/dist/boundary/boundary.d.ts +0 -72
  71. package/dist/boundary/boundary.d.ts.map +0 -1
  72. package/dist/boundary/boundary.js +0 -83
  73. package/dist/boundary/boundary.js.map +0 -1
  74. package/dist/calc/asyncCalc.d.ts +0 -91
  75. package/dist/calc/asyncCalc.d.ts.map +0 -1
  76. package/dist/calc/asyncCalc.js +0 -95
  77. package/dist/calc/asyncCalc.js.map +0 -1
  78. package/dist/calc/asyncData.d.ts +0 -57
  79. package/dist/calc/asyncData.d.ts.map +0 -1
  80. package/dist/calc/asyncData.js +0 -34
  81. package/dist/calc/asyncData.js.map +0 -1
  82. package/dist/calc/calc.d.ts +0 -57
  83. package/dist/calc/calc.d.ts.map +0 -1
  84. package/dist/calc/calc.js +0 -58
  85. package/dist/calc/calc.js.map +0 -1
  86. package/dist/calc/calcFamily.d.ts +0 -57
  87. package/dist/calc/calcFamily.d.ts.map +0 -1
  88. package/dist/calc/calcFamily.js +0 -124
  89. package/dist/calc/calcFamily.js.map +0 -1
  90. package/dist/calc/compose.d.ts +0 -16
  91. package/dist/calc/compose.d.ts.map +0 -1
  92. package/dist/calc/compose.js +0 -7
  93. package/dist/calc/compose.js.map +0 -1
  94. package/dist/channel/channel.d.ts +0 -115
  95. package/dist/channel/channel.d.ts.map +0 -1
  96. package/dist/channel/channel.js +0 -138
  97. package/dist/channel/channel.js.map +0 -1
  98. package/dist/compose/composition.d.ts +0 -72
  99. package/dist/compose/composition.d.ts.map +0 -1
  100. package/dist/compose/composition.js +0 -46
  101. package/dist/compose/composition.js.map +0 -1
  102. package/dist/compose/host.d.ts.map +0 -1
  103. package/dist/compose/host.js +0 -6
  104. package/dist/compose/host.js.map +0 -1
  105. package/dist/compose/props.d.ts +0 -13
  106. package/dist/compose/props.d.ts.map +0 -1
  107. package/dist/compose/props.js.map +0 -1
  108. package/dist/compose/provide.d.ts +0 -22
  109. package/dist/compose/provide.d.ts.map +0 -1
  110. package/dist/compose/provide.js +0 -28
  111. package/dist/compose/provide.js.map +0 -1
  112. package/dist/compose/slot.d.ts.map +0 -1
  113. package/dist/compose/slot.js +0 -23
  114. package/dist/compose/slot.js.map +0 -1
  115. package/dist/compose/ui.d.ts +0 -135
  116. package/dist/compose/ui.d.ts.map +0 -1
  117. package/dist/compose/ui.js +0 -63
  118. package/dist/compose/ui.js.map +0 -1
  119. package/dist/definition/definition.d.ts +0 -33
  120. package/dist/definition/definition.d.ts.map +0 -1
  121. package/dist/definition/definition.js +0 -42
  122. package/dist/definition/definition.js.map +0 -1
  123. package/dist/event/event.d.ts +0 -33
  124. package/dist/event/event.d.ts.map +0 -1
  125. package/dist/event/event.js +0 -30
  126. package/dist/event/event.js.map +0 -1
  127. package/dist/event/eventGroup.d.ts +0 -9
  128. package/dist/event/eventGroup.d.ts.map +0 -1
  129. package/dist/event/eventGroup.js +0 -4
  130. package/dist/event/eventGroup.js.map +0 -1
  131. package/dist/feature/feature.d.ts.map +0 -1
  132. package/dist/feature/feature.js +0 -98
  133. package/dist/feature/feature.js.map +0 -1
  134. package/dist/index.d.ts +0 -53
  135. package/dist/index.d.ts.map +0 -1
  136. package/dist/index.js +0 -62
  137. package/dist/index.js.map +0 -1
  138. package/dist/internal/capture.d.ts.map +0 -1
  139. package/dist/internal/capture.js +0 -12
  140. package/dist/internal/capture.js.map +0 -1
  141. package/dist/internal/ctx.d.ts.map +0 -1
  142. package/dist/internal/ctx.js +0 -2
  143. package/dist/internal/ctx.js.map +0 -1
  144. package/dist/internal/errors.d.ts +0 -89
  145. package/dist/internal/errors.d.ts.map +0 -1
  146. package/dist/internal/errors.js +0 -102
  147. package/dist/internal/errors.js.map +0 -1
  148. package/dist/internal/inspect.d.ts +0 -17
  149. package/dist/internal/inspect.d.ts.map +0 -1
  150. package/dist/internal/inspect.js +0 -32
  151. package/dist/internal/inspect.js.map +0 -1
  152. package/dist/internal/queryDriver.d.ts +0 -65
  153. package/dist/internal/queryDriver.d.ts.map +0 -1
  154. package/dist/internal/queryDriver.js +0 -134
  155. package/dist/internal/queryDriver.js.map +0 -1
  156. package/dist/internal/reuse.d.ts +0 -10
  157. package/dist/internal/reuse.d.ts.map +0 -1
  158. package/dist/internal/reuse.js +0 -68
  159. package/dist/internal/reuse.js.map +0 -1
  160. package/dist/internal/scheduler.d.ts +0 -47
  161. package/dist/internal/scheduler.d.ts.map +0 -1
  162. package/dist/internal/scheduler.js +0 -70
  163. package/dist/internal/scheduler.js.map +0 -1
  164. package/dist/internal/seeds.d.ts.map +0 -1
  165. package/dist/internal/seeds.js +0 -17
  166. package/dist/internal/seeds.js.map +0 -1
  167. package/dist/internal/sources.d.ts +0 -39
  168. package/dist/internal/sources.d.ts.map +0 -1
  169. package/dist/internal/sources.js +0 -59
  170. package/dist/internal/sources.js.map +0 -1
  171. package/dist/internal/stateRegistry.d.ts +0 -2
  172. package/dist/internal/stateRegistry.d.ts.map +0 -1
  173. package/dist/internal/stateRegistry.js.map +0 -1
  174. package/dist/internal/store.d.ts +0 -47
  175. package/dist/internal/store.d.ts.map +0 -1
  176. package/dist/internal/store.js +0 -73
  177. package/dist/internal/store.js.map +0 -1
  178. package/dist/internal/track.d.ts.map +0 -1
  179. package/dist/internal/track.js +0 -19
  180. package/dist/internal/track.js.map +0 -1
  181. package/dist/procedure/procedure.d.ts +0 -40
  182. package/dist/procedure/procedure.d.ts.map +0 -1
  183. package/dist/procedure/procedure.js +0 -50
  184. package/dist/procedure/procedure.js.map +0 -1
  185. package/dist/reducer/reducer.d.ts +0 -44
  186. package/dist/reducer/reducer.d.ts.map +0 -1
  187. package/dist/reducer/reducer.js +0 -63
  188. package/dist/reducer/reducer.js.map +0 -1
  189. package/dist/remote/remoteState.d.ts +0 -119
  190. package/dist/remote/remoteState.d.ts.map +0 -1
  191. package/dist/remote/remoteState.js +0 -270
  192. package/dist/remote/remoteState.js.map +0 -1
  193. package/dist/runtime/bus.d.ts +0 -27
  194. package/dist/runtime/bus.d.ts.map +0 -1
  195. package/dist/runtime/bus.js +0 -21
  196. package/dist/runtime/bus.js.map +0 -1
  197. package/dist/runtime/loop.d.ts +0 -45
  198. package/dist/runtime/loop.d.ts.map +0 -1
  199. package/dist/runtime/loop.js +0 -117
  200. package/dist/runtime/loop.js.map +0 -1
  201. package/dist/scene/scene.d.ts +0 -44
  202. package/dist/scene/scene.d.ts.map +0 -1
  203. package/dist/scene/scene.js +0 -31
  204. package/dist/scene/scene.js.map +0 -1
  205. package/dist/state/state.d.ts +0 -37
  206. package/dist/state/state.d.ts.map +0 -1
  207. package/dist/state/state.js +0 -46
  208. package/dist/state/state.js.map +0 -1
  209. package/dist/state/stateFamily.d.ts +0 -79
  210. package/dist/state/stateFamily.d.ts.map +0 -1
  211. package/dist/state/stateFamily.js +0 -99
  212. package/dist/state/stateFamily.js.map +0 -1
  213. package/dist/state/stateGroup.d.ts +0 -50
  214. package/dist/state/stateGroup.d.ts.map +0 -1
  215. package/dist/state/stateGroup.js +0 -54
  216. package/dist/state/stateGroup.js.map +0 -1
  217. package/dist/state/token.d.ts.map +0 -1
  218. package/dist/state/token.js +0 -20
  219. package/dist/state/token.js.map +0 -1
  220. package/dist/synced/syncedStore.d.ts +0 -51
  221. package/dist/synced/syncedStore.d.ts.map +0 -1
  222. package/dist/synced/syncedStore.js +0 -43
  223. package/dist/synced/syncedStore.js.map +0 -1
  224. package/dist/ui/node.d.ts.map +0 -1
  225. package/dist/ui/node.js +0 -2
  226. package/dist/ui/node.js.map +0 -1
  227. package/dist/ui/trigger.d.ts.map +0 -1
  228. package/dist/ui/trigger.js +0 -2
  229. package/dist/ui/trigger.js.map +0 -1
  230. package/dist/wire/tree.d.ts +0 -66
  231. package/dist/wire/tree.d.ts.map +0 -1
  232. package/dist/wire/tree.js +0 -60
  233. package/dist/wire/tree.js.map +0 -1
  234. package/dist/wire/triggers.d.ts +0 -44
  235. package/dist/wire/triggers.d.ts.map +0 -1
  236. package/dist/wire/triggers.js +0 -31
  237. package/dist/wire/triggers.js.map +0 -1
@@ -0,0 +1,195 @@
1
+ // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
+ // These guard the cast-free seam: if a guarantee regresses, a `@ts-expect-error`
3
+ // goes unused or an assignment fails, and `tsc` breaks.
4
+
5
+ import { Context, Effect, Layer, Match, Schema as S } from 'effect'
6
+ import * as Boundary from '../boundary/boundary'
7
+ import type { AsyncData } from '../calc/asyncData'
8
+ import { type Channels, type Procedures } from '../channel/channel'
9
+ import * as Event from '../event/event'
10
+ import type { Store } from '../internal/store'
11
+ import * as Reducer from '../reducer/reducer'
12
+ import type { Bus } from '../runtime/bus'
13
+ import { type Reducers } from '../runtime/loop'
14
+ import * as State from '../state/state'
15
+ import * as StateGroup from '../state/stateGroup'
16
+ import { type AnySource, type Source } from '../state/token'
17
+ import * as RemoteState from './remoteState'
18
+
19
+ const AddIntentBase: Event.EventClass<'TAddIntent', { readonly id: string; readonly name: string }> =
20
+ Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
21
+ class AddIntent extends AddIntentBase {}
22
+ const RenameIntentBase: Event.EventClass<
23
+ 'TRenameIntent',
24
+ { readonly id: string; readonly name: string }
25
+ > = Event.make('TRenameIntent', S.Struct({ id: S.String, name: S.String }))
26
+ class RenameIntent extends RenameIntentBase {}
27
+ type Intent = Event.EventType<typeof AddIntent> | Event.EventType<typeof RenameIntent>
28
+
29
+ const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Number)
30
+ class Page extends PageBase {}
31
+ const InputsBase: StateGroup.StateGroupClass<readonly [typeof Page]> = StateGroup.make(Page)
32
+ class Inputs extends InputsBase {}
33
+ const page: Source<'tcPage', number> = StateGroup.select(Inputs, 'tcPage')
34
+
35
+ const Row: S.Struct<{ id: typeof S.String; name: typeof S.String }> = S.Struct({
36
+ id: S.String,
37
+ name: S.String,
38
+ })
39
+ type Row = typeof Row.Type
40
+
41
+ const SendDepBase: Context.TagClass<
42
+ SendDep,
43
+ 'typecheck/SendDep',
44
+ { readonly go: Effect.Effect<void> }
45
+ > = Context.Tag('typecheck/SendDep')<SendDep, { readonly go: Effect.Effect<void> }>()
46
+ class SendDep extends SendDepBase {}
47
+
48
+ const ItemsBase: RemoteState.RemoteStateClass<
49
+ 'TItems',
50
+ readonly [typeof page],
51
+ readonly [typeof AddIntent, typeof RenameIntent],
52
+ ReadonlyArray<Row>,
53
+ string,
54
+ true
55
+ > = RemoteState.make('TItems', {
56
+ inputs: [page],
57
+ output: S.Array(Row),
58
+ error: S.String,
59
+ intents: [AddIntent, RenameIntent],
60
+ })
61
+ class Items extends ItemsBase {}
62
+
63
+ // --- intent-union inference: apply/send receive exactly the declared union ---
64
+
65
+ // POSITIVE: a Match.exhaustive fold over the declared intents compiles, `send`'s
66
+ // R2 flows from its body, and gated `disabled` is accepted.
67
+ export const okLive: Layer.Layer<
68
+ | Store<AsyncData<ReadonlyArray<Row>, string, true>>
69
+ | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
70
+ never,
71
+ Store<number> | SendDep | Reducers | Procedures | Channels | Bus
72
+ > = RemoteState.live(Items, {
73
+ query: ({ tcPage }) =>
74
+ tcPage > 0 ? Effect.succeed<ReadonlyArray<Row>>([]) : Effect.fail('empty page'),
75
+ send: () => Effect.flatMap(SendDep, (dep) => dep.go),
76
+ apply: (rows, intent) =>
77
+ Match.value(intent).pipe(
78
+ Match.tag('TAddIntent', ({ id, name }) => [...rows, { id, name }]),
79
+ Match.tag('TRenameIntent', ({ id, name }) =>
80
+ rows.map((row) => (row.id === id ? { ...row, name } : row)),
81
+ ),
82
+ Match.exhaustive,
83
+ ),
84
+ disabled: ({ tcPage }) => tcPage < 0,
85
+ })
86
+
87
+ // NEGATIVE: a handler typed for an undeclared tag does not fit the union.
88
+ export const badIntent: Layer.Layer<
89
+ | Store<AsyncData<ReadonlyArray<Row>, string, true>>
90
+ | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
91
+ never,
92
+ Store<number> | Reducers | Procedures | Channels | Bus
93
+ > = RemoteState.live(Items, {
94
+ query: () => Effect.succeed<ReadonlyArray<Row>>([]),
95
+ send: () => Effect.void,
96
+ // @ts-expect-error 'TDeleteIntent' is not in the declared intent union
97
+ apply: (rows: ReadonlyArray<Row>, intent: { readonly _tag: 'TDeleteIntent' }) => rows,
98
+ })
99
+
100
+ // --- layer requirement honesty: RIn is complete and exact ---
101
+
102
+ // POSITIVE: the layer's RIn is exactly the input stores, the query/send context,
103
+ // and the engine registries — and its ROut names the overlay + pending stores.
104
+ export const okLayer: Layer.Layer<
105
+ Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
106
+ never,
107
+ Store<number> | SendDep | Reducers | Procedures | Channels | Bus
108
+ > = okLive
109
+
110
+ // NEGATIVE: dropping the send dependency (R2) from the annotation fails —
111
+ // requirements cannot be silently widened away.
112
+ // @ts-expect-error the send body's R2 must appear in the layer's requirements
113
+ export const badLayer: Layer.Layer<
114
+ Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
115
+ never,
116
+ Store<number> | Reducers | Procedures | Channels | Bus
117
+ > = okLive
118
+
119
+ // --- alwaysOn / error narrowing: the read carries exactly the possible arms ---
120
+
121
+ const AlwaysItemsBase: RemoteState.RemoteStateClass<
122
+ 'TAlwaysItems',
123
+ readonly [typeof page],
124
+ readonly [typeof AddIntent],
125
+ ReadonlyArray<Row>,
126
+ never,
127
+ false
128
+ > = RemoteState.make('TAlwaysItems', {
129
+ inputs: [page],
130
+ output: S.Array(Row),
131
+ intents: [AddIntent],
132
+ alwaysOn: true,
133
+ })
134
+ class AlwaysItems extends AlwaysItemsBase {}
135
+
136
+ // NEGATIVE: an alwaysOn remote state rejects `disabled` (erased to never).
137
+ export const badDisabled: Layer.Layer<
138
+ | Store<AsyncData<ReadonlyArray<Row>, never, false>>
139
+ | Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
140
+ never,
141
+ Store<number> | Reducers | Procedures | Channels | Bus
142
+ > = RemoteState.live(AlwaysItems, {
143
+ query: () => Effect.succeed<ReadonlyArray<Row>>([]),
144
+ send: () => Effect.void,
145
+ apply: (rows) => rows,
146
+ // @ts-expect-error an alwaysOn remote state cannot be disabled
147
+ disabled: () => false,
148
+ })
149
+
150
+ declare const alwaysArms: Effect.Effect.Success<typeof AlwaysItems>['_tag']
151
+ declare const gatedArms: Effect.Effect.Success<typeof Items>['_tag']
152
+ declare const idleArm: 'Idle'
153
+ declare const errorArm: 'Error'
154
+ declare const fourArms: 'Idle' | 'Loading' | 'Success' | 'Error'
155
+
156
+ // POSITIVE: alwaysOn + no `error` schema ⇒ at most Loading | Success…
157
+ export const alwaysExact: 'Loading' | 'Success' = alwaysArms
158
+ // …and gated + `error` schema ⇒ exactly all four arms (both directions).
159
+ export const gatedSubset: 'Idle' | 'Loading' | 'Success' | 'Error' = gatedArms
160
+ export const gatedAll: Effect.Effect.Success<typeof Items>['_tag'] = fourArms
161
+
162
+ // NEGATIVE: the excluded arms really are gone from the read type.
163
+ // @ts-expect-error an alwaysOn remote state has no Idle arm
164
+ export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
165
+ // @ts-expect-error no `error` schema ⇒ no Error arm
166
+ export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
167
+
168
+ // --- surface shape: Sources where expected, never a reducer target ---
169
+
170
+ // POSITIVE: the class itself is a `Source` (a calc input / boundary coverage),
171
+ // valued at the overlaid lifecycle.
172
+ export const classAsSource: Source<'TItems', AsyncData<ReadonlyArray<Row>, string, true>> = Items
173
+ export const classAsAnySource: AnySource = Items
174
+
175
+ // POSITIVE: `pending` is an ordinary read-only `Source` (badges, "saving…" chrome).
176
+ export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
177
+ Items.pending
178
+
179
+ // POSITIVE: `truth` is the un-overlaid lifecycle under the same arms.
180
+ export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
181
+ Items.truth
182
+
183
+ // POSITIVE: a boundary covers the remote state like any lifecycle source.
184
+ export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
185
+ Boundary.make('TItemsBoundary', { over: [Items] })
186
+
187
+ // NEGATIVE: the pending queue is NOT user state — `Reducer.make` cannot target
188
+ // it (it is a `StateToken`, not a `StateClass`; the hidden queue reducer is the
189
+ // sole writer).
190
+ export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
191
+ // @ts-expect-error the pending queue is not a reducible state
192
+ Reducer.make('TBadReducer', {
193
+ states: [Items.pending],
194
+ events: [AddIntent],
195
+ })
@@ -0,0 +1,40 @@
1
+ import { Context, Effect, Layer, PubSub } from 'effect'
2
+
3
+ /** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
4
+ export interface Tagged {
5
+ readonly _tag: string
6
+ }
7
+
8
+ /**
9
+ * Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
10
+ * loop and channels only route events whose tag is in a handler's `handles`, so
11
+ * at runtime the envelope IS one of those events; the registries store it as the
12
+ * generic `Tagged`, and this restores the typed view. The one documented home
13
+ * for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
14
+ */
15
+ export const narrowHandled = <E>(event: Tagged): E => event as never
16
+
17
+ /** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
18
+ export type Priority = 'High' | 'Normal'
19
+
20
+ export interface Envelope {
21
+ readonly priority: Priority
22
+ readonly event: Tagged
23
+ }
24
+
25
+ const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Context.Tag(
26
+ 'reform/Bus',
27
+ )<Bus, PubSub.PubSub<Envelope>>()
28
+
29
+ /** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
30
+ export class Bus extends BusBase {}
31
+
32
+ // Unbounded so `publish` never suspends: UI triggers dispatch synchronously
33
+ // (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
34
+ // backpressure to the UI thread. The single drain loop empties the bus every
35
+ // microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
36
+ // memory is preferred over never dropping (publish stays synchronous either way).
37
+ export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
38
+
39
+ export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
40
+ Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event }))
@@ -0,0 +1,69 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Cause, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S } from 'effect'
3
+ import { Channel, Engine, Event, publish, Reducer, State } from '../index'
4
+
5
+ // Robustness guards for the drain loop and registries: a buggy reducer must not
6
+ // take down the bus, and a name clash that would silently drop a registration
7
+ // must be rejected.
8
+
9
+ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
10
+ class Counter extends State.make('counter', S.Number) {}
11
+ class Good extends Event.make('Good', S.Struct({})) {}
12
+ class Bad extends Event.make('Bad', S.Struct({})) {}
13
+ class GoodReducer extends Reducer.make('GoodReducer', { states: [Counter], events: [Good] }) {}
14
+ class BadReducer extends Reducer.make('BadReducer', { states: [Counter], events: [Bad] }) {}
15
+ const GoodLive = Reducer.live(GoodReducer, (n) => n + 1)
16
+ const BadLive = Reducer.live(BadReducer, () => {
17
+ throw new Error('boom')
18
+ })
19
+
20
+ const TestLayer = Layer.mergeAll(GoodLive, BadLive).pipe(
21
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
22
+ )
23
+
24
+ return Effect.gen(function* () {
25
+ const store = yield* Counter.store
26
+
27
+ // A fold that throws is caught and logged — the drain fiber survives.
28
+ yield* publish('Normal', Event.construct(Bad, {}))
29
+ yield* Effect.sleep(Duration.millis(10))
30
+ expect(store.get()).toBe(0)
31
+
32
+ // A later, healthy event still processes: the bus did not go deaf.
33
+ yield* publish('Normal', Event.construct(Good, {}))
34
+ yield* Effect.sleep(Duration.millis(10))
35
+ expect(store.get()).toBe(1)
36
+ }).pipe(
37
+ Effect.provide(TestLayer),
38
+ // The isolated failure logs an error; silence it so the passing test is quiet.
39
+ Logger.withMinimumLogLevel(LogLevel.None),
40
+ )
41
+ })
42
+
43
+ it.effect('two different channels registered under one name are rejected', () => {
44
+ class Merge extends Channel.make('Dup', { policy: { _tag: 'merge' } }) {}
45
+ class Latest extends Channel.make('Dup', { policy: { _tag: 'latest' } }) {}
46
+ const layer = Layer.mergeAll(Channel.live(Merge), Channel.live(Latest)).pipe(
47
+ Layer.provideMerge(Engine),
48
+ )
49
+
50
+ return Effect.gen(function* () {
51
+ const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
52
+ expect(Exit.isFailure(exit)).toBe(true)
53
+ if (Exit.isFailure(exit)) {
54
+ expect(Cause.pretty(exit.cause)).toContain('duplicate Channel')
55
+ }
56
+ })
57
+ })
58
+
59
+ it.effect('the same channel registered twice is idempotent (shared lane)', () => {
60
+ class Lane extends Channel.make('Lane', { policy: { _tag: 'exclusive' } }) {}
61
+ const layer = Layer.mergeAll(Channel.live(Lane), Channel.live(Lane)).pipe(
62
+ Layer.provideMerge(Engine),
63
+ )
64
+
65
+ return Effect.gen(function* () {
66
+ const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
67
+ expect(Exit.isSuccess(exit)).toBe(true)
68
+ })
69
+ })
@@ -0,0 +1,178 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Schema as S, TestClock } from 'effect'
3
+ import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
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
+ it.live('a frame of events drains in one pass with a single notification flush', () => {
10
+ class Counter extends State.make('counter', S.Number) {}
11
+ class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
12
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
13
+ const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
14
+
15
+ const TestLayer = BumpReducerLive.pipe(
16
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
17
+ )
18
+
19
+ return Effect.gen(function* () {
20
+ const store = yield* Counter.store
21
+ const notifications = { n: 0 }
22
+ store.subscribe(() => {
23
+ notifications.n += 1
24
+ })
25
+
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
+ yield* publish('Normal', Event.construct(Bumped, { to: 1 }))
29
+ yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
30
+ yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
31
+ yield* Effect.sleep(Duration.millis(20))
32
+
33
+ expect(store.get()).toBe(3)
34
+ expect(notifications.n).toBe(1)
35
+ }).pipe(Effect.provide(TestLayer))
36
+ })
37
+
38
+ it.live('an exclusive channel serializes procedures that share it', () => {
39
+ class StartA extends Event.make('StartA', S.Struct({})) {}
40
+ class StartB extends Event.make('StartB', S.Struct({})) {}
41
+ class Exclusive extends Channel.make('Exclusive', { policy: { _tag: 'exclusive' } }) {}
42
+
43
+ const concurrency = { active: 0, max: 0 }
44
+ const guarded = function* () {
45
+ concurrency.active += 1
46
+ concurrency.max = Math.max(concurrency.max, concurrency.active)
47
+ yield* Effect.sleep(Duration.millis(15))
48
+ concurrency.active -= 1
49
+ }
50
+
51
+ class ProcA extends Procedure.make('ProcA', { events: [StartA], channel: Exclusive }) {}
52
+ class ProcB extends Procedure.make('ProcB', { events: [StartB], channel: Exclusive }) {}
53
+ const ProcALive = Procedure.live(ProcA, guarded)
54
+ const ProcBLive = Procedure.live(ProcB, guarded)
55
+
56
+ const TestLayer = Layer.mergeAll(Channel.live(Exclusive), ProcALive, ProcBLive).pipe(
57
+ Layer.provideMerge(Engine),
58
+ )
59
+
60
+ return Effect.gen(function* () {
61
+ yield* publish('Normal', Event.construct(StartA, {}))
62
+ yield* publish('Normal', Event.construct(StartB, {}))
63
+ yield* Effect.sleep(Duration.millis(60))
64
+ // Without exclusivity both would run concurrently (max === 2).
65
+ expect(concurrency.max).toBe(1)
66
+ }).pipe(Effect.provide(TestLayer))
67
+ })
68
+
69
+ 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
+ class Shared extends Event.make('Shared', S.Struct({})) {}
74
+ class Lane extends Channel.make('Lane', { policy: { _tag: 'merge' } }) {}
75
+
76
+ const runs = { a: 0, b: 0 }
77
+ class ProcA extends Procedure.make('ProcA', { events: [Shared], channel: Lane }) {}
78
+ class ProcB extends Procedure.make('ProcB', { events: [Shared], channel: Lane }) {}
79
+ const ProcALive = Procedure.live(ProcA, function* () {
80
+ runs.a += 1
81
+ })
82
+ const ProcBLive = Procedure.live(ProcB, function* () {
83
+ runs.b += 1
84
+ })
85
+
86
+ const TestLayer = Layer.mergeAll(Channel.live(Lane), ProcALive, ProcBLive).pipe(
87
+ Layer.provideMerge(Engine),
88
+ )
89
+
90
+ return Effect.gen(function* () {
91
+ yield* publish('High', Event.construct(Shared, {}))
92
+ yield* Effect.sleep(Duration.millis(20))
93
+ expect(runs.a).toBe(1)
94
+ expect(runs.b).toBe(1)
95
+ }).pipe(Effect.provide(TestLayer))
96
+ })
97
+
98
+ it.effect('a debounce channel collapses a burst into a single run', () => {
99
+ class Typed extends Event.make('Typed', S.Struct({})) {}
100
+ class Debounced extends Channel.make('Debounced', {
101
+ policy: { _tag: 'debounce', duration: Duration.millis(30) },
102
+ }) {}
103
+
104
+ const runs = { n: 0 }
105
+ class Search extends Procedure.make('Search', { events: [Typed], channel: Debounced }) {}
106
+ const SearchLive = Procedure.live(Search, function* () {
107
+ runs.n += 1
108
+ })
109
+
110
+ const TestLayer = Layer.mergeAll(Channel.live(Debounced), SearchLive).pipe(
111
+ Layer.provideMerge(Engine),
112
+ )
113
+
114
+ return Effect.gen(function* () {
115
+ yield* publish('Normal', Event.construct(Typed, {}))
116
+ yield* publish('Normal', Event.construct(Typed, {}))
117
+ yield* publish('Normal', Event.construct(Typed, {}))
118
+ // Let the loop route the frame into the channel and arm the debounce window.
119
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
120
+ // Advance the simulated clock past the window: the burst emits once.
121
+ yield* TestClock.adjust(Duration.millis(31))
122
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
123
+ expect(runs.n).toBe(1)
124
+ }).pipe(Effect.provide(TestLayer))
125
+ })
126
+
127
+ it.live('a reducer ignores events outside its declared set (no-op)', () => {
128
+ class Counter extends State.make('counter', S.Number) {}
129
+ class Bump extends Event.make('Bump', S.Struct({})) {}
130
+ class Unrelated extends Event.make('Unrelated', S.Struct({})) {}
131
+ class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bump] }) {}
132
+ const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
133
+
134
+ const TestLayer = BumpReducerLive.pipe(
135
+ Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
136
+ )
137
+
138
+ return Effect.gen(function* () {
139
+ const store = yield* Counter.store
140
+ // An event the reducer doesn't declare never reaches its fold.
141
+ yield* publish('Normal', Event.construct(Unrelated, {}))
142
+ yield* Effect.sleep(Duration.millis(10))
143
+ expect(store.get()).toBe(0)
144
+ // A declared event does.
145
+ yield* publish('Normal', Event.construct(Bump, {}))
146
+ yield* Effect.sleep(Duration.millis(10))
147
+ expect(store.get()).toBe(1)
148
+ }).pipe(Effect.provide(TestLayer))
149
+ })
150
+
151
+ it.effect('a latest channel cancels the in-flight run when a new event arrives', () => {
152
+ class Tick extends Event.make('Tick', S.Struct({})) {}
153
+ class Latest extends Channel.make('Latest', { policy: { _tag: 'latest' } }) {}
154
+
155
+ const counts = { started: 0, completed: 0 }
156
+ class Work extends Procedure.make('Work', { events: [Tick], channel: Latest }) {}
157
+ const WorkLive = Procedure.live(Work, function* () {
158
+ counts.started += 1
159
+ yield* Effect.sleep(Duration.millis(50))
160
+ counts.completed += 1
161
+ })
162
+
163
+ const TestLayer = Layer.mergeAll(Channel.live(Latest), WorkLive).pipe(Layer.provideMerge(Engine))
164
+
165
+ return Effect.gen(function* () {
166
+ // First tick starts a run that parks on its 50ms sleep.
167
+ yield* publish('Normal', Event.construct(Tick, {}))
168
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
169
+ // Second tick switches the channel, interrupting the first run mid-sleep.
170
+ yield* publish('Normal', Event.construct(Tick, {}))
171
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
172
+ yield* TestClock.adjust(Duration.millis(60))
173
+ yield* Effect.yieldNow().pipe(Effect.repeatN(20))
174
+ // Both runs began, but only the latest survived to completion.
175
+ expect(counts.started).toBe(2)
176
+ expect(counts.completed).toBe(1)
177
+ }).pipe(Effect.provide(TestLayer))
178
+ })
@@ -0,0 +1,171 @@
1
+ import { Chunk, Context, Effect, Layer, PubSub, Queue } from 'effect'
2
+ import {
3
+ Channels,
4
+ channelsLayer,
5
+ Procedures,
6
+ proceduresLayer,
7
+ } from '../channel/channel'
8
+ import { notificationsLayer } from '../internal/scheduler'
9
+ import { Bus, busLayer, type Envelope, type Tagged } from './bus'
10
+
11
+ /**
12
+ * A registered reducer, with its target store(s) already captured at
13
+ * registration. `apply` is a pure synchronous write, so the loop needs no
14
+ * state services in its own context — it just routes events to writers.
15
+ */
16
+ export interface ReducerEntry {
17
+ /** The reducer's name — used only to warn on duplicate registration. */
18
+ readonly name: string
19
+ readonly handles: ReadonlySet<string>
20
+ readonly apply: (event: Tagged) => void
21
+ }
22
+
23
+ /**
24
+ * Mutable collector the central loop drains; reducer `.live` layers register
25
+ * here. `byTag` indexes entries by the event tags they handle, so dispatch is
26
+ * O(matching reducers) per event rather than O(all reducers) — the difference
27
+ * that matters once an app has hundreds of reducers.
28
+ */
29
+ export interface ReducerRegistry {
30
+ readonly entries: Array<ReducerEntry>
31
+ readonly byTag: Map<string, Array<ReducerEntry>>
32
+ readonly register: (entry: ReducerEntry) => void
33
+ /**
34
+ * Remove a previously-registered entry. Eager reducers (built on the root scope)
35
+ * never call this; a lazy feature's `Reducer.live` registers on mount and
36
+ * unregisters on unmount (scope close), so an unmounted feature stops folding and
37
+ * its entry is reclaimed instead of leaking + writing to an orphaned store.
38
+ */
39
+ readonly unregister: (entry: ReducerEntry) => void
40
+ }
41
+
42
+ const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> =
43
+ Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
44
+ export class Reducers extends ReducersBase {}
45
+
46
+ /** Drop an item from a `Map<string, Array>` bucket, pruning the key when it empties. */
47
+ const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, item: T): void => {
48
+ const bucket = map.get(key)
49
+ if (bucket === undefined) return
50
+ const next = bucket.filter((entry) => entry !== item)
51
+ if (next.length === 0) map.delete(key)
52
+ else map.set(key, next)
53
+ }
54
+
55
+ const makeReducerRegistry = (): ReducerRegistry => {
56
+ const entries: Array<ReducerEntry> = []
57
+ const byTag = new Map<string, Array<ReducerEntry>>()
58
+ return {
59
+ entries,
60
+ byTag,
61
+ register: (entry) => {
62
+ entries.push(entry)
63
+ for (const tag of entry.handles) {
64
+ const bucket = byTag.get(tag)
65
+ if (bucket === undefined) byTag.set(tag, [entry])
66
+ else bucket.push(entry)
67
+ }
68
+ },
69
+ unregister: (entry) => {
70
+ const index = entries.indexOf(entry)
71
+ if (index >= 0) entries.splice(index, 1)
72
+ for (const tag of entry.handles) dropFromBuckets(byTag, tag, entry)
73
+ },
74
+ }
75
+ }
76
+
77
+ export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
78
+
79
+ /** High before Normal; within a priority class, dispatch order is preserved. */
80
+ const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
81
+
82
+ // The single drain loop: one fiber that frame-batches the bus. Each tick it
83
+ // harvests every event dispatched in that microtask, runs all matching reducers
84
+ // in ONE synchronous pass (so the store notifier flushes once for the batch),
85
+ // then routes each event to the channels its procedures run on.
86
+ const drain = Effect.gen(function* () {
87
+ const bus = yield* Bus
88
+ const reducers = yield* Reducers
89
+ const { byName: channels } = yield* Channels
90
+ const procedures = yield* Procedures
91
+ const subscription = yield* PubSub.subscribe(bus)
92
+
93
+ yield* Effect.forkScoped(
94
+ Effect.forever(
95
+ Effect.gen(function* () {
96
+ // Block until the first event, then drain the rest of this tick.
97
+ const first = yield* Queue.take(subscription)
98
+ const rest = yield* Queue.takeAll(subscription)
99
+ const frame = [first, ...Chunk.toArray(rest)]
100
+
101
+ // Stable priority order: High-dispatched (UI) events fold before
102
+ // Normal-dispatched (procedure follow-up) events in the same frame. A
103
+ // two-bucket partition does this in O(m) with no comparator — push order
104
+ // preserves intra-priority dispatch order — instead of sorting the frame.
105
+ const high: Array<Envelope> = []
106
+ const normal: Array<Envelope> = []
107
+ for (const envelope of frame) {
108
+ if (rank(envelope.priority) === 0) high.push(envelope)
109
+ else normal.push(envelope)
110
+ }
111
+ const ordered = high.concat(normal)
112
+
113
+ // One synchronous block: every reducer write for the frame coalesces into
114
+ // a single notification flush. Routing to channels happens here too, so
115
+ // procedure bodies (run later, off their channel fibers) see post-batch
116
+ // state. No `yield*` in between, or the flush could fire early.
117
+ const failures: Array<{ readonly event: Tagged; readonly error: unknown }> = []
118
+ yield* Effect.sync(() => {
119
+ for (const envelope of ordered) {
120
+ for (const reducer of reducers.byTag.get(envelope.event._tag) ?? []) {
121
+ // Isolate each fold: a synchronous throw in one reducer must not
122
+ // abandon the rest of the frame nor (via the `forever` below) kill
123
+ // the drain fiber and freeze the whole app. Collect and log after.
124
+ try {
125
+ reducer.apply(envelope.event)
126
+ } catch (error) {
127
+ failures.push({ event: envelope.event, error })
128
+ }
129
+ }
130
+ }
131
+ for (const envelope of ordered) {
132
+ // Offer to each DISTINCT channel once. Routing per-procedure would
133
+ // offer a shared channel multiple times for one event, and each offer
134
+ // re-runs every procedure on it — duplicate execution.
135
+ for (const channelName of procedures.channelsByTag.get(envelope.event._tag) ?? []) {
136
+ channels.get(channelName)?.offer(envelope.event)
137
+ }
138
+ }
139
+ })
140
+ yield* Effect.forEach(
141
+ failures,
142
+ (failure) =>
143
+ Effect.logError(
144
+ `reform: reducer threw handling '${failure.event._tag}'`,
145
+ failure.error,
146
+ ),
147
+ { discard: true },
148
+ )
149
+ }).pipe(
150
+ // Belt-and-suspenders: even a defect outside the isolated fold (a bug in
151
+ // the loop itself) is logged and the tick restarts, so the bus never goes
152
+ // permanently deaf.
153
+ Effect.catchAllCause((cause) => Effect.logError('reform: drain tick crashed', cause)),
154
+ ),
155
+ ),
156
+ )
157
+ })
158
+
159
+ /**
160
+ * The reform engine: provides the bus + reducer/channel/procedure registries +
161
+ * the per-runtime notification scheduler to the application and forks the single
162
+ * drain loop. Registration mutates the live collections, so reducers/procedures/
163
+ * channels merged alongside are picked up before the first dispatch (boot).
164
+ */
165
+ export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures> = Layer.scopedDiscard(
166
+ drain,
167
+ ).pipe(
168
+ Layer.provideMerge(
169
+ Layer.mergeAll(busLayer, reducersLayer, channelsLayer, proceduresLayer, notificationsLayer),
170
+ ),
171
+ )