@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,226 @@
1
+ import type { FunctionComponent } from 'react'
2
+ import { Context, Effect, Option, type Schema } from 'effect'
3
+ import { type Manifest, yieldableClass } from '../definition/definition'
4
+ import { CaptureSink } from '../internal/capture'
5
+ import { SlotRenderingUnavailable } from '../internal/errors'
6
+ import type { Trigger } from '../ui/trigger'
7
+ import { CurrentSlots } from './host'
8
+ import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './slot'
9
+
10
+ export interface UiContract {
11
+ props: unknown
12
+ slots?: Record<string, SlotInstance<any>>
13
+ events?: Record<string, Trigger<any>>
14
+ }
15
+
16
+ type PropsOf<C extends UiContract> = C['props']
17
+ type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
18
+ // A slot is a React COMPONENT whose props are OPTIONAL (a `Partial`): a parent that
19
+ // drives a child supplies them (`<slots.Item id=… />` / `createElement(slots.Item, {…})`),
20
+ // but a placeholder render (the remote client, where the per-child props were captured
21
+ // server-side and the framework expands the children) omits them (`<slots.Item />`).
22
+ // One `Ui.make` view thus serves both renderers. The child still reads its own
23
+ // props/state — these are only the external props the parent feeds, advisory at the
24
+ // slot boundary. Typing it as `FunctionComponent` makes both JSX and `createElement` work.
25
+ //
26
+ // `slotKey` is the universal KEYED-SLOT selector (see `WireNode.key`): for a LIST slot
27
+ // invoked once per item (`<slots.Row slotKey={id} />`), the remote client renders only the
28
+ // one wire child whose React `key` matches, instead of every child of that slot at every
29
+ // call site (which duplicates the whole list under each call). It is framework-handled, not
30
+ // forwarded to the child, so it is allowed on EVERY slot and never collides with a child's
31
+ // own props. Omit it for singleton slots (`<slots.Create />`) — they render all children.
32
+ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
33
+ ? {
34
+ // `slotKey` goes INSIDE the `Partial<…>` (not intersected outside) so that when a slot's
35
+ // child props are `any` the whole thing still collapses to `any` (`any & X = any`), keeping
36
+ // the component assignable to a bare `(props: unknown) => Node` slot-renderer shape. An
37
+ // outer intersection would leave a required-shape object that `unknown` can't satisfy.
38
+ readonly [K in keyof S]: FunctionComponent<
39
+ Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
40
+ >
41
+ }
42
+ : Record<never, never>
43
+
44
+ /** What the composition logic calls: props + handlers in, a node out. Slots are bound by the renderer. */
45
+ export type LogicView<C extends UiContract> = (props: PropsOf<C>, events?: EventsOf<C>) => Node
46
+
47
+ /** What `.make` authors: the pure presentation. Slots are injected by the renderer. */
48
+ export type ViewImpl<C extends UiContract> = (
49
+ props: PropsOf<C>,
50
+ slots: SlotsOf<C>,
51
+ events: EventsOf<C>,
52
+ ) => Node
53
+
54
+ /** Nominal brand identifying a UI contract at runtime (Effect's `TypeId` idiom). */
55
+ export const UiTypeId: unique symbol = Symbol.for('reform/Ui')
56
+ export type UiTypeId = typeof UiTypeId
57
+
58
+ /**
59
+ * The reflectable descriptor a contract carries. The wire `props` / `events`
60
+ * schemas are present *only* on the schema (wired) form — the type-only form
61
+ * carries neither, so a local contract has no schema cost and no schema surface.
62
+ */
63
+ export interface UiManifest extends Manifest {
64
+ readonly kind: 'Ui'
65
+ // `any` in the encoded/context slots is required by Schema's variance for
66
+ // branded/Class schemas; only the decoded type is ever read off these.
67
+ readonly props?: Schema.Schema<any, any>
68
+ readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
69
+ }
70
+
71
+ /** A `UiManifest` whose wire schema is present — the runtime brand of a remote-capable contract. */
72
+ export interface WiredUiManifest extends UiManifest {
73
+ readonly props: Schema.Schema<any, any>
74
+ }
75
+
76
+ export interface UiClass<C extends UiContract>
77
+ extends Effect.Effect<LogicView<C>, never, ViewImpl<C>> {
78
+ new (): {}
79
+ readonly [UiTypeId]: UiTypeId
80
+ readonly manifest: UiManifest
81
+ /** Internal DI tag the `Ui.make` presentation is provided under. */
82
+ readonly impl: Context.Tag<ViewImpl<C>, ViewImpl<C>>
83
+ }
84
+
85
+ /**
86
+ * Recover a contract's `UiContract` type from its `Ui` class — `Ui.Contract<typeof
87
+ * SomeUi>`. Lets a consumer (a proof, a reflection tool) name the contract derived
88
+ * from a wired `ui('X', {…})` without re-declaring it.
89
+ */
90
+ export type Contract<U> = U extends UiClass<infer C> ? C : never
91
+
92
+ /**
93
+ * A contract authored with wire schemas. Structurally a `UiClass<C>` whose
94
+ * manifest *carries* the schemas (`WiredUiManifest`), so it is the subtype the
95
+ * remote seam (`provideRemote`, the wire registry) demands while plain `provide`
96
+ * / `scene` / `Composition.make` keep accepting any `UiClass<C>`.
97
+ */
98
+ export type WiredUi<C extends UiContract> = UiClass<C> & { readonly manifest: WiredUiManifest }
99
+
100
+ /** The wire schemas a contract is authored from: props, a payload schema per event, and
101
+ * (type-only) the slots the view fills. Slots carry no wire schema — the children that
102
+ * fill them are their own contracts — but declaring them here threads typed slots into
103
+ * the derived contract, so a WIRED contract can also have slots (e.g. a parent whose
104
+ * props the client reads AND that renders slot children). */
105
+ export interface WireSchemas {
106
+ readonly props: Schema.Schema<any, any>
107
+ readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
108
+ // Slots are declared with their slot CLASSES (`slot('X')<typeof Child>()`), the
109
+ // same values `Composition.make({ slots })` takes — NOT `SlotInstance`s. The
110
+ // contract's slot facade is derived from them below.
111
+ readonly slots?: Readonly<Record<string, SlotClass>>
112
+ }
113
+
114
+ type DerivedEvents<Sch extends WireSchemas> = Sch extends {
115
+ readonly events: infer E extends Record<string, Schema.Schema<any, any>>
116
+ }
117
+ ? { readonly [K in keyof E]: Trigger<Schema.Schema.Type<E[K]>> }
118
+ : Record<never, never>
119
+
120
+ type DerivedSlots<Sch extends WireSchemas> = Sch extends {
121
+ readonly slots: infer S extends Record<string, SlotClass>
122
+ }
123
+ ? { readonly [K in keyof S]: InstanceType<S[K]> }
124
+ : Record<never, never>
125
+
126
+ /**
127
+ * The `UiContract` a set of wire schemas denotes — props and event-payload
128
+ * *types* derived from the schemas via `Schema.Type`, plus any declared slots. The
129
+ * schema is the single source of truth; the contract type is inferred, never written
130
+ * a second time.
131
+ */
132
+ export type DerivedContract<Sch extends WireSchemas> = {
133
+ readonly props: Schema.Schema.Type<Sch['props']>
134
+ readonly slots: DerivedSlots<Sch>
135
+ readonly events: DerivedEvents<Sch>
136
+ }
137
+
138
+ /** Whether a value is a UI contract — discriminates a `provide` target by brand. */
139
+ export const isUi = (u: unknown): u is UiClass<UiContract> =>
140
+ (typeof u === 'function' || typeof u === 'object') && u !== null && UiTypeId in u
141
+
142
+ const buildUi = <C extends UiContract, M extends UiManifest>(
143
+ name: string,
144
+ manifest: M,
145
+ ): UiClass<C> & { readonly manifest: M } => {
146
+ const impl = Context.GenericTag<ViewImpl<C>, ViewImpl<C>>(`reform/ui/${name}`)
147
+ const read = Effect.gen(function* () {
148
+ const render = yield* impl
149
+ // Slots are bound by the host (`@reform/react`) per composition instance.
150
+ // Core has no renderer, so without a host every slot access throws.
151
+ const host = yield* Effect.serviceOption(CurrentSlots)
152
+ const slots = new Proxy({} as SlotsOf<C>, {
153
+ get(_t, key) {
154
+ if (Option.isNone(host)) {
155
+ throw new SlotRenderingUnavailable({ slot: String(key) })
156
+ }
157
+ return host.value.slot(String(key))
158
+ },
159
+ })
160
+ // A capturing host (proofs, the dev tool) reports each render's observable
161
+ // surface without replacing the presentation. Absent in production.
162
+ const sink = yield* Effect.serviceOption(CaptureSink)
163
+ return (props: PropsOf<C>, events?: EventsOf<C>) => {
164
+ if (Option.isSome(sink)) {
165
+ sink.value.record({
166
+ name,
167
+ props,
168
+ events: (events ?? {}) as Record<string, Trigger<unknown>>,
169
+ })
170
+ }
171
+ return render(props, slots, (events ?? {}) as EventsOf<C>)
172
+ }
173
+ })
174
+ return yieldableClass(read, {
175
+ [UiTypeId]: UiTypeId,
176
+ manifest,
177
+ impl,
178
+ })
179
+ }
180
+
181
+ /**
182
+ * A renderer-neutral UI contract. The logic resolves it (`yield* TodoAppUi`) to
183
+ * a `(props, events) => node` view with slots already bound; `.make` authors the
184
+ * presentation. Core defines the contract; `@reform/react` does the rendering.
185
+ *
186
+ * Two authoring forms, one source of truth each:
187
+ *
188
+ * - **Type-only (local):** `ui('Counter')<{ props: { count: number } }>()` — no
189
+ * schema, no ceremony, exactly as before.
190
+ * - **Schema (wired):** `ui('Counter', { props: S.Struct({ count: S.Number }) })`
191
+ * — the schema *is* the definition; the contract type is derived via
192
+ * `Schema.Type` (never re-written), and the result is a `WiredUi` the remote
193
+ * seam accepts. Make a local contract remote by rewriting this one line.
194
+ */
195
+ export function ui(name: string): <C extends UiContract>() => UiClass<C>
196
+ export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
197
+ export function ui(name: string, schemas?: WireSchemas): unknown {
198
+ if (schemas === undefined) {
199
+ return <C extends UiContract>(): UiClass<C> => buildUi<C, UiManifest>(name, { kind: 'Ui', name })
200
+ }
201
+ const eventFields = schemas.events === undefined ? {} : { events: schemas.events }
202
+ const manifest: WiredUiManifest = { kind: 'Ui', name, props: schemas.props, ...eventFields }
203
+ return buildUi<DerivedContract<WireSchemas>, WiredUiManifest>(name, manifest)
204
+ }
205
+
206
+ /** Brand under which a `Ui.make` view carries its own contract. */
207
+ export const UiViewContract: unique symbol = Symbol.for('reform/ui/view-contract')
208
+ export type UiViewContract = typeof UiViewContract
209
+
210
+ /**
211
+ * A `Ui.make` view that carries its contract, so a consumer can recover the
212
+ * contract (its wire name + props schema) straight from the view — no separate
213
+ * registration call. This is what makes the remote client view-set
214
+ * (`remoteViews<AppContract>({ Some: SomeView, … })`) work with the very same
215
+ * `Ui.make` views the local renderer uses.
216
+ */
217
+ export type MadeView<C extends UiContract> = ViewImpl<C> & { readonly [UiViewContract]: UiClass<C> }
218
+
219
+ /**
220
+ * Author the pure presentation for a contract — `Ui.make(TodoAppUi, (props,
221
+ * slots, events) => node)`. The contract argument fixes the view's types AND is
222
+ * carried on the returned view (`MadeView`), so the same view is reusable as a
223
+ * remote client presentation. Wire the result with `provide(contract, view)`.
224
+ */
225
+ export const make = <C extends UiContract>(contract: UiClass<C>, view: ViewImpl<C>): MadeView<C> =>
226
+ Object.assign(view, { [UiViewContract]: contract })
@@ -0,0 +1,56 @@
1
+ // Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
2
+ // These guard the schema-first seam: the wired form is the single source of
3
+ // truth (props/event types derived, never re-written), a wired contract is a
4
+ // plain `UiClass` (so `provide` / `scene` / `Composition.make` accept it), and
5
+ // the remote seam (`WiredUi`) rejects a type-only contract. If a guarantee
6
+ // regresses, a `@ts-expect-error` goes unused or an assignment fails, and `tsc`
7
+ // breaks.
8
+
9
+ import { Schema as S } from 'effect'
10
+ import {
11
+ type Contract,
12
+ type DerivedContract,
13
+ type MadeView,
14
+ make,
15
+ ui,
16
+ type UiClass,
17
+ type UiContract,
18
+ type WiredUi,
19
+ } from './ui'
20
+
21
+ declare function acceptsAny<C extends UiContract>(contract: UiClass<C>): void
22
+ declare function acceptsWired<C extends UiContract>(contract: WiredUi<C>): void
23
+
24
+ class LocalUi extends ui('Local')<{ props: { n: number } }>() {}
25
+ const wiredSchemas: {
26
+ readonly props: S.Struct<{ n: typeof S.Number }>
27
+ readonly events: { readonly bump: S.Struct<{ by: typeof S.Number }> }
28
+ } = {
29
+ props: S.Struct({ n: S.Number }),
30
+ events: { bump: S.Struct({ by: S.Number }) },
31
+ }
32
+ const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas>> = ui('Wired', wiredSchemas)
33
+ class WiredUiClass extends WiredUiClassBase {}
34
+
35
+ // POSITIVE: both forms are a `UiClass` — plain wiring (provide/scene/Composition) accepts either.
36
+ acceptsAny(LocalUi)
37
+ acceptsAny(WiredUiClass)
38
+
39
+ // POSITIVE: the schema form is a `WiredUi` — the remote seam accepts it.
40
+ acceptsWired(WiredUiClass)
41
+
42
+ // NEGATIVE: a type-only contract has no wire schema, so the remote seam rejects it.
43
+ // @ts-expect-error -- LocalUi is `UiClass`, not `WiredUi`
44
+ acceptsWired(LocalUi)
45
+
46
+ // POSITIVE: props/event types are DERIVED from the schemas — authoring a presentation
47
+ // against the wired contract sees `n: number` and `bump: Trigger<{ by: number }>`,
48
+ // none of it written a second time.
49
+ export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
50
+ WiredUiClass,
51
+ ({ n }, _slots, { bump }) => {
52
+ const _n: number = n
53
+ bump({ by: _n })
54
+ return `${_n}`
55
+ },
56
+ )
@@ -0,0 +1,76 @@
1
+ import { Effect } from 'effect'
2
+ import { attachInspectable } from '../internal/inspect'
3
+
4
+ /** A definition's `toJSON`: its reflectable manifest, else its raw statics. */
5
+ const describe = (statics: object): unknown => (statics as { manifest?: unknown }).manifest ?? statics
6
+
7
+ /**
8
+ * The reflectable descriptor every primitive carries (principle #6). The editor
9
+ * and proof packages read these without running any logic.
10
+ */
11
+ export type Kind =
12
+ | 'State'
13
+ | 'StateGroup'
14
+ | 'StateFamily'
15
+ | 'Event'
16
+ | 'EventGroup'
17
+ | 'Reducer'
18
+ | 'Calc'
19
+ | 'CalcFamily'
20
+ | 'AsyncCalc'
21
+ | 'Resource'
22
+ | 'RemoteState'
23
+ | 'SyncedStore'
24
+ | 'Boundary'
25
+ | 'Procedure'
26
+ | 'Channel'
27
+ | 'Composition'
28
+ | 'Ui'
29
+ | 'Slot'
30
+ | 'Feature'
31
+
32
+ export interface Manifest {
33
+ readonly kind: Kind
34
+ readonly name: string
35
+ }
36
+
37
+ /**
38
+ * Build a class whose *static* side is itself an Effect, so `yield* TheClass`
39
+ * runs `read` and returns its value (the same mechanism `Context.Tag` uses).
40
+ * Subclasses inherit the protocol through the prototype chain, so the user's
41
+ * `class FeedState extends State.make(...) {}` is yieldable too.
42
+ *
43
+ * `statics` (manifest, `.live`, tag, …) are attached as own properties
44
+ * and likewise inherited by the subclass.
45
+ */
46
+ export const yieldableClass = <A, E, R, Statics extends object>(
47
+ read: Effect.Effect<A, E, R>,
48
+ statics: Statics,
49
+ ): { new (): {} } & Effect.Effect<A, E, R> & Statics => {
50
+ class Base {}
51
+ Object.setPrototypeOf(Base, read)
52
+ // `defineProperty` over `Reflect.ownKeys`, not `Object.assign` over
53
+ // `Object.entries`: a static can shadow a non-writable own property of the
54
+ // class function (notably `name`, which a `Source`-shaped definition like a
55
+ // Calc carries) and `assign` would throw on those; and `ownKeys` (unlike
56
+ // `entries`) carries symbol-keyed statics — the `TypeId` brands.
57
+ for (const key of Reflect.ownKeys(statics)) {
58
+ const value = (statics as Record<PropertyKey, unknown>)[key]
59
+ Object.defineProperty(Base, key, { value, writable: true, enumerable: true, configurable: true })
60
+ }
61
+ // Print like an Effect value (the manifest), not `[object Object]`.
62
+ attachInspectable(Base, () => describe(statics))
63
+ return Base as never
64
+ }
65
+
66
+ /**
67
+ * Build a *definition class*: an empty constructible carrying static marker data
68
+ * (manifest, tag, policy, members, …) that the editor and proof packages reflect
69
+ * on. Definition interfaces declare phantom, type-only fields — a composition's
70
+ * `Props`, a family instance's `Key` — that exist purely for inference and have
71
+ * no runtime value, so the assembled object cannot *structurally* satisfy the
72
+ * interface. The one assertion to `Class` lives here, documented, instead of a
73
+ * scattered `as never` / `as unknown as` at every `*.make`.
74
+ */
75
+ export const definitionClass = <Class>(statics: object): Class =>
76
+ attachInspectable(Object.assign(class {}, statics), () => describe(statics)) as unknown as Class
@@ -0,0 +1,23 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Schema as S } from 'effect'
3
+ import { Event } from '../index'
4
+
5
+ // An event is a pure data fact: `Event.construct` builds the tagged value the
6
+ // bus carries, and the class carries the reflectable `manifest` + `tag`.
7
+
8
+ test('Event.construct builds the tagged value `{ _tag, ...payload }`', () => {
9
+ class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
10
+ expect(Event.construct(Added, { id: 'x' })).toEqual({ _tag: 'Added', id: 'x' })
11
+ })
12
+
13
+ test('an event carries its reflectable manifest and tag', () => {
14
+ class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
15
+ expect(Added.tag).toBe('Added')
16
+ expect(Added.manifest.kind).toBe('Event')
17
+ expect(Added.manifest.name).toBe('Added')
18
+ })
19
+
20
+ test('empty-payload events construct to just their tag', () => {
21
+ class Pinged extends Event.make('Pinged', S.Struct({})) {}
22
+ expect(Event.construct(Pinged, {})).toEqual({ _tag: 'Pinged' })
23
+ })
@@ -0,0 +1,65 @@
1
+ import { Effect, PubSub, Runtime, type Schema } from 'effect'
2
+ import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { Bus, publish, type Tagged } from '../runtime/bus'
4
+ import type { Trigger } from '../ui/trigger'
5
+
6
+ /** The dispatched value of an event: `{ _tag, ...payload }`, matched with `Match.tags`. */
7
+ export type EventOf<N extends string, P> = Tagged & { readonly _tag: N } & P
8
+
9
+ export interface EventManifest<N extends string, P> extends Manifest {
10
+ readonly kind: 'Event'
11
+ readonly name: N
12
+ readonly schema: Schema.Schema<P, any>
13
+ }
14
+
15
+ export interface EventClass<out N extends string, in out P> {
16
+ new (): {}
17
+ readonly manifest: EventManifest<N, P>
18
+ readonly tag: N
19
+ /** Carrier for `Event.construct` — builds the tagged value `{ _tag, ...payload }`. */
20
+ readonly build: (payload: P) => EventOf<N, P>
21
+ /** Carrier for `Event.trigger` — the UI callback effect (High priority). */
22
+ readonly trigger: Effect.Effect<Trigger<P>, never, Bus>
23
+ }
24
+
25
+ export type AnyEvent = EventClass<string, any>
26
+ export type EventType<E> = E extends EventClass<infer N, infer P> ? EventOf<N, P> : never
27
+
28
+ /** A pure data fact. Events depend on nothing — only reducers tie them to state. */
29
+ export const make = <const N extends string, P>(
30
+ name: N,
31
+ schema: Schema.Schema<P, any>,
32
+ ): EventClass<N, P> => {
33
+ const build = (payload: P): EventOf<N, P> => ({ _tag: name, ...payload })
34
+ const trigger = Effect.gen(function* () {
35
+ const bus = yield* Bus
36
+ const runtime = yield* Effect.runtime<never>()
37
+ // Dispatch synchronously: on the unbounded bus `publish` only enqueues and
38
+ // returns, so `runSync` never suspends and no fiber is forked per UI event.
39
+ // The drain loop still folds reducers on its own fiber — publish is pure offer.
40
+ return (payload: P) => {
41
+ Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: build(payload) }))
42
+ }
43
+ })
44
+ return definitionClass<EventClass<N, P>>({
45
+ manifest: { kind: 'Event' as const, name, schema },
46
+ tag: name,
47
+ build,
48
+ trigger,
49
+ })
50
+ }
51
+
52
+ /** Construct the tagged value for an event (used for `boot` events). */
53
+ export const construct = <N extends string, P>(event: EventClass<N, P>, payload: P): EventOf<N, P> =>
54
+ event.build(payload)
55
+
56
+ /** Dispatch as a follow-up (Normal priority) from a procedure/effect. */
57
+ export const dispatch = <N extends string, P>(
58
+ event: EventClass<N, P>,
59
+ payload: P,
60
+ ): Effect.Effect<void, never, Bus> => publish('Normal', event.build(payload))
61
+
62
+ /** A callback for the UI that dispatches at High priority via the runtime. */
63
+ export const trigger = <N extends string, P>(
64
+ event: EventClass<N, P>,
65
+ ): Effect.Effect<Trigger<P>, never, Bus> => event.trigger
@@ -0,0 +1,14 @@
1
+ import { definitionClass } from '../definition/definition'
2
+ import type { AnyEvent } from './event'
3
+
4
+ export interface EventGroupClass<Members extends ReadonlyArray<AnyEvent>> {
5
+ new (): {}
6
+ readonly kind: 'EventGroup'
7
+ readonly members: Members
8
+ }
9
+
10
+ /** Compose events into a group, the same variadic shape as `StateGroup`/`RpcGroup`. */
11
+ export const make = <const Members extends ReadonlyArray<AnyEvent>>(
12
+ ...members: Members
13
+ ): EventGroupClass<Members> =>
14
+ definitionClass<EventGroupClass<Members>>({ kind: 'EventGroup' as const, members })
@@ -0,0 +1,82 @@
1
+ import { type Cause, Duration, Effect, Exit, Layer, ManagedRuntime, Scope } from 'effect'
2
+ import { Schema as S } from 'effect'
3
+ import { expect, test } from 'vitest'
4
+ import * as Composition from '../compose/composition'
5
+ import { ui } from '../compose/ui'
6
+ import * as Event from '../event/event'
7
+ import * as Reducer from '../reducer/reducer'
8
+ import * as State from '../state/state'
9
+ import { publish } from '../runtime/bus'
10
+ import { Engine, Reducers } from '../runtime/loop'
11
+ import * as Feature from './feature'
12
+ import { type EngineServices, featureModule, mountFeature } from './feature'
13
+
14
+ // A self-contained feature: a Count state + a reducer that folds Tick. The base
15
+ // seeds Count; the logic provides the reducer over it and leaves the engine
16
+ // (Reducers registry) OPEN, so `mountFeature` builds it against the live engine.
17
+ class Tick extends Event.make('Tick', S.Struct({})) {}
18
+ class Count extends State.make('count', S.Number) {}
19
+ class CountReducer extends Reducer.make('CountReducer', { states: [Count], events: [Tick] }) {}
20
+
21
+ const mod = featureModule(
22
+ [],
23
+ Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
24
+ )
25
+
26
+ // A trivial composition to hang the feature off (definitions only).
27
+ class CtrUi extends ui('CtrUi')<{ props: {}; events: {} }>() {}
28
+ class CtrComp extends Composition.make('CtrComp', { title: 'Counter', ui: CtrUi }) {}
29
+
30
+ class CounterFeature extends Feature.make('counter', {
31
+ composition: CtrComp,
32
+ module: mod,
33
+ boot: [Event.construct(Tick, {})],
34
+ }) {}
35
+
36
+ // Poll a read until it satisfies a predicate (the loop drains on a forked fiber).
37
+ const waitUntil = <A, R>(
38
+ read: Effect.Effect<A, never, R>,
39
+ pred: (a: A) => boolean,
40
+ ): Effect.Effect<A, Cause.TimeoutException, R> =>
41
+ Effect.flatMap(read, (a) =>
42
+ pred(a)
43
+ ? Effect.succeed(a)
44
+ : Effect.flatMap(Effect.sleep(Duration.millis(1)), () => waitUntil(read, pred)),
45
+ ).pipe(Effect.timeout(Duration.seconds(2)))
46
+
47
+ test('a mounted feature registers on the shared loop, boots, and folds dispatched events', async () => {
48
+ const runtime = ManagedRuntime.make(Engine)
49
+ try {
50
+ await runtime.runPromise(
51
+ Effect.gen(function* () {
52
+ const engineContext = yield* Effect.context<EngineServices>()
53
+ const reducers = yield* Reducers
54
+ const baseline = reducers.entries.length
55
+
56
+ const scope = yield* Scope.make()
57
+ const ctx = yield* mountFeature(CounterFeature.binding, engineContext).pipe(
58
+ Effect.provideService(Scope.Scope, scope),
59
+ )
60
+ // Registered into the SHARED registry.
61
+ expect(reducers.entries.length).toBe(baseline + 1)
62
+
63
+ // boot dispatched Tick → Count folds to 1 on the shared loop.
64
+ const booted = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 1)
65
+ expect(booted).toBe(1)
66
+
67
+ // A further dispatch on the shared bus folds again → 2.
68
+ yield* publish('High', Event.construct(Tick, {}))
69
+ const folded = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 2)
70
+ expect(folded).toBe(2)
71
+
72
+ // Unmount: closing the feature scope unregisters its reducer — the entry is
73
+ // reclaimed, not left folding onto an orphaned store.
74
+ yield* Scope.close(scope, Exit.succeed(undefined))
75
+ expect(reducers.entries.length).toBe(baseline)
76
+ expect(reducers.byTag.get('Tick')).toBeUndefined()
77
+ }),
78
+ )
79
+ } finally {
80
+ await runtime.dispose()
81
+ }
82
+ })
@@ -0,0 +1,60 @@
1
+ import { Effect, Layer } from 'effect'
2
+ import { expect, test } from 'vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { ui } from '../compose/ui'
5
+ import * as Feature from './feature'
6
+ import { featureModule, isFeature, isFeatureBinding, lazyImport } from './feature'
7
+
8
+ // Minimal composition surface to hang features off — definitions only, no logic.
9
+ class TestUi extends ui('TestUi')<{ props: {}; events: {} }>() {}
10
+ class TestComp extends Composition.make('TestComp', { title: 'Test', ui: TestUi }) {}
11
+ class FailComp extends Composition.make('FailComp', { title: 'Fail', ui: TestUi }) {}
12
+
13
+ // A trivial loaded module: empty layer, no shared requirements.
14
+ const mod = featureModule([], Layer.empty)
15
+
16
+ class LazyFeature extends Feature.make('lazyFeature', {
17
+ loadingStrategy: 'lazy',
18
+ composition: TestComp,
19
+ load: lazyImport(() => Promise.resolve({ default: mod })),
20
+ placeholder: { loading: TestComp, failed: FailComp },
21
+ }) {}
22
+
23
+ class EagerFeature extends Feature.make('eagerFeature', {
24
+ composition: TestComp,
25
+ module: mod,
26
+ }) {}
27
+
28
+ test('a lazy feature reflects as a Feature manifest with placeholders, no chunk loaded', () => {
29
+ expect(LazyFeature.manifest.kind).toBe('Feature')
30
+ expect(LazyFeature.manifest.name).toBe('lazyFeature')
31
+ expect(LazyFeature.manifest.strategy).toBe('lazy')
32
+ expect(LazyFeature.manifest.composition.name).toBe('TestComp')
33
+ expect(LazyFeature.manifest.placeholder?.loading.name).toBe('TestComp')
34
+ expect(LazyFeature.manifest.placeholder?.failed.name).toBe('FailComp')
35
+ expect(LazyFeature.eagerModule).toBeUndefined()
36
+ })
37
+
38
+ test('an eager feature reflects as default strategy with its module available', () => {
39
+ expect(EagerFeature.manifest.strategy).toBe('default')
40
+ expect(EagerFeature.manifest.placeholder).toBeUndefined()
41
+ expect(EagerFeature.eagerModule).toBe(mod)
42
+ })
43
+
44
+ test('isFeature / isFeatureBinding discriminate', () => {
45
+ expect(isFeature(LazyFeature)).toBe(true)
46
+ expect(isFeature(TestComp)).toBe(false)
47
+ expect(isFeature(null)).toBe(false)
48
+ expect(isFeatureBinding(LazyFeature.binding)).toBe(true)
49
+ expect(isFeatureBinding(TestComp)).toBe(false)
50
+ })
51
+
52
+ test('load is an Effect (not a thenable) that yields the module and its requires', async () => {
53
+ // No `.then` on `load` — it is an Effect, run on the Effect runtime.
54
+ expect(typeof (LazyFeature.load as { then?: unknown }).then).toBe('undefined')
55
+ const loaded = await Effect.runPromise(LazyFeature.load)
56
+ expect(loaded.requires).toEqual([])
57
+ expect(loaded).toBe(mod)
58
+ // default strategy resolves immediately to its module.
59
+ expect(await Effect.runPromise(EagerFeature.load)).toBe(mod)
60
+ })