@playfast/reform 0.0.2 → 0.0.4

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 (230) hide show
  1. package/package.json +15 -13
  2. package/src/boundary/boundary.ts +192 -0
  3. package/src/calc/asyncCalc.ts +267 -0
  4. package/{dist/dts/calc/asyncData.d.ts → src/calc/asyncData.ts} +53 -20
  5. package/src/calc/calc.ts +120 -0
  6. package/src/calc/calcFamily.ts +194 -0
  7. package/src/calc/compose.ts +69 -0
  8. package/src/channel/channel.ts +265 -0
  9. package/src/compose/composition.ts +112 -0
  10. package/{dist/dts/compose/host.d.ts → src/compose/host.ts} +6 -8
  11. package/{dist/esm/compose/props.js → src/compose/props.ts} +3 -4
  12. package/src/compose/provide.ts +70 -0
  13. package/{dist/dts/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  14. package/src/compose/ui.ts +97 -0
  15. package/src/definition/definition.ts +74 -0
  16. package/src/event/event.ts +65 -0
  17. package/src/event/eventGroup.ts +14 -0
  18. package/{dist/dts/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  19. package/src/index.ts +140 -0
  20. package/{dist/dts/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  21. package/{dist/dts/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  22. package/{dist/esm/internal/errors.js → src/internal/errors.ts} +41 -26
  23. package/src/internal/inspect.ts +34 -0
  24. package/src/internal/queryDriver.ts +247 -0
  25. package/src/internal/reuse.ts +73 -0
  26. package/src/internal/scheduler.ts +91 -0
  27. package/{dist/dts/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  28. package/src/internal/sources.ts +104 -0
  29. package/src/internal/store.ts +105 -0
  30. package/{dist/dts/internal/track.d.ts → src/internal/track.ts} +15 -11
  31. package/src/procedure/procedure.ts +89 -0
  32. package/src/reducer/reducer.ts +137 -0
  33. package/src/remote/remoteState.ts +571 -0
  34. package/{dist/cjs/runtime/bus.js → src/runtime/bus.ts} +23 -12
  35. package/src/runtime/loop.ts +169 -0
  36. package/src/scene/scene.ts +76 -0
  37. package/src/state/state.ts +91 -0
  38. package/src/state/stateFamily.ts +171 -0
  39. package/src/state/stateGroup.ts +78 -0
  40. package/{dist/dts/state/token.d.ts → src/state/token.ts} +21 -13
  41. package/{dist/dts/ui/node.d.ts → src/ui/node.ts} +3 -3
  42. package/{dist/dts/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  43. package/dist/cjs/boundary/boundary.js +0 -86
  44. package/dist/cjs/calc/asyncCalc.js +0 -128
  45. package/dist/cjs/calc/asyncData.js +0 -37
  46. package/dist/cjs/calc/calc.js +0 -58
  47. package/dist/cjs/calc/calcFamily.js +0 -127
  48. package/dist/cjs/channel/channel.js +0 -142
  49. package/dist/cjs/compose/composition.js +0 -50
  50. package/dist/cjs/compose/host.js +0 -8
  51. package/dist/cjs/compose/props.js +0 -14
  52. package/dist/cjs/compose/provide.js +0 -30
  53. package/dist/cjs/compose/slot.js +0 -27
  54. package/dist/cjs/compose/ui.js +0 -61
  55. package/dist/cjs/definition/definition.js +0 -46
  56. package/dist/cjs/event/event.js +0 -36
  57. package/dist/cjs/event/eventGroup.js +0 -7
  58. package/dist/cjs/feature/feature.js +0 -102
  59. package/dist/cjs/index.js +0 -116
  60. package/dist/cjs/internal/capture.js +0 -14
  61. package/dist/cjs/internal/ctx.js +0 -2
  62. package/dist/cjs/internal/errors.js +0 -62
  63. package/dist/cjs/internal/inspect.js +0 -36
  64. package/dist/cjs/internal/queryDriver.js +0 -138
  65. package/dist/cjs/internal/reuse.js +0 -71
  66. package/dist/cjs/internal/scheduler.js +0 -73
  67. package/dist/cjs/internal/seeds.js +0 -19
  68. package/dist/cjs/internal/sources.js +0 -61
  69. package/dist/cjs/internal/store.js +0 -77
  70. package/dist/cjs/internal/track.js +0 -22
  71. package/dist/cjs/package.json +0 -4
  72. package/dist/cjs/procedure/procedure.js +0 -52
  73. package/dist/cjs/reducer/reducer.js +0 -64
  74. package/dist/cjs/remote/remoteState.js +0 -307
  75. package/dist/cjs/runtime/loop.js +0 -119
  76. package/dist/cjs/scene/scene.js +0 -36
  77. package/dist/cjs/state/state.js +0 -47
  78. package/dist/cjs/state/stateFamily.js +0 -101
  79. package/dist/cjs/state/stateGroup.js +0 -47
  80. package/dist/cjs/state/token.js +0 -23
  81. package/dist/cjs/ui/node.js +0 -2
  82. package/dist/cjs/ui/trigger.js +0 -2
  83. package/dist/dts/boundary/boundary.d.ts +0 -72
  84. package/dist/dts/boundary/boundary.d.ts.map +0 -1
  85. package/dist/dts/calc/asyncCalc.d.ts +0 -91
  86. package/dist/dts/calc/asyncCalc.d.ts.map +0 -1
  87. package/dist/dts/calc/asyncData.d.ts.map +0 -1
  88. package/dist/dts/calc/calc.d.ts +0 -57
  89. package/dist/dts/calc/calc.d.ts.map +0 -1
  90. package/dist/dts/calc/calcFamily.d.ts +0 -57
  91. package/dist/dts/calc/calcFamily.d.ts.map +0 -1
  92. package/dist/dts/channel/channel.d.ts +0 -115
  93. package/dist/dts/channel/channel.d.ts.map +0 -1
  94. package/dist/dts/compose/composition.d.ts +0 -72
  95. package/dist/dts/compose/composition.d.ts.map +0 -1
  96. package/dist/dts/compose/host.d.ts.map +0 -1
  97. package/dist/dts/compose/props.d.ts +0 -13
  98. package/dist/dts/compose/props.d.ts.map +0 -1
  99. package/dist/dts/compose/provide.d.ts +0 -22
  100. package/dist/dts/compose/provide.d.ts.map +0 -1
  101. package/dist/dts/compose/slot.d.ts.map +0 -1
  102. package/dist/dts/compose/ui.d.ts +0 -50
  103. package/dist/dts/compose/ui.d.ts.map +0 -1
  104. package/dist/dts/definition/definition.d.ts +0 -33
  105. package/dist/dts/definition/definition.d.ts.map +0 -1
  106. package/dist/dts/event/event.d.ts +0 -33
  107. package/dist/dts/event/event.d.ts.map +0 -1
  108. package/dist/dts/event/eventGroup.d.ts +0 -9
  109. package/dist/dts/event/eventGroup.d.ts.map +0 -1
  110. package/dist/dts/feature/feature.d.ts.map +0 -1
  111. package/dist/dts/index.d.ts +0 -43
  112. package/dist/dts/index.d.ts.map +0 -1
  113. package/dist/dts/internal/capture.d.ts.map +0 -1
  114. package/dist/dts/internal/ctx.d.ts.map +0 -1
  115. package/dist/dts/internal/errors.d.ts +0 -69
  116. package/dist/dts/internal/errors.d.ts.map +0 -1
  117. package/dist/dts/internal/inspect.d.ts +0 -17
  118. package/dist/dts/internal/inspect.d.ts.map +0 -1
  119. package/dist/dts/internal/queryDriver.d.ts +0 -65
  120. package/dist/dts/internal/queryDriver.d.ts.map +0 -1
  121. package/dist/dts/internal/reuse.d.ts +0 -10
  122. package/dist/dts/internal/reuse.d.ts.map +0 -1
  123. package/dist/dts/internal/scheduler.d.ts +0 -47
  124. package/dist/dts/internal/scheduler.d.ts.map +0 -1
  125. package/dist/dts/internal/seeds.d.ts.map +0 -1
  126. package/dist/dts/internal/sources.d.ts +0 -39
  127. package/dist/dts/internal/sources.d.ts.map +0 -1
  128. package/dist/dts/internal/store.d.ts +0 -47
  129. package/dist/dts/internal/store.d.ts.map +0 -1
  130. package/dist/dts/internal/track.d.ts.map +0 -1
  131. package/dist/dts/procedure/procedure.d.ts +0 -40
  132. package/dist/dts/procedure/procedure.d.ts.map +0 -1
  133. package/dist/dts/reducer/reducer.d.ts +0 -44
  134. package/dist/dts/reducer/reducer.d.ts.map +0 -1
  135. package/dist/dts/remote/remoteState.d.ts +0 -119
  136. package/dist/dts/remote/remoteState.d.ts.map +0 -1
  137. package/dist/dts/runtime/bus.d.ts +0 -27
  138. package/dist/dts/runtime/bus.d.ts.map +0 -1
  139. package/dist/dts/runtime/loop.d.ts +0 -45
  140. package/dist/dts/runtime/loop.d.ts.map +0 -1
  141. package/dist/dts/scene/scene.d.ts +0 -44
  142. package/dist/dts/scene/scene.d.ts.map +0 -1
  143. package/dist/dts/state/state.d.ts +0 -37
  144. package/dist/dts/state/state.d.ts.map +0 -1
  145. package/dist/dts/state/stateFamily.d.ts +0 -79
  146. package/dist/dts/state/stateFamily.d.ts.map +0 -1
  147. package/dist/dts/state/stateGroup.d.ts +0 -36
  148. package/dist/dts/state/stateGroup.d.ts.map +0 -1
  149. package/dist/dts/state/token.d.ts.map +0 -1
  150. package/dist/dts/ui/node.d.ts.map +0 -1
  151. package/dist/dts/ui/trigger.d.ts.map +0 -1
  152. package/dist/esm/boundary/boundary.js +0 -83
  153. package/dist/esm/boundary/boundary.js.map +0 -1
  154. package/dist/esm/calc/asyncCalc.js +0 -95
  155. package/dist/esm/calc/asyncCalc.js.map +0 -1
  156. package/dist/esm/calc/asyncData.js +0 -34
  157. package/dist/esm/calc/asyncData.js.map +0 -1
  158. package/dist/esm/calc/calc.js +0 -58
  159. package/dist/esm/calc/calc.js.map +0 -1
  160. package/dist/esm/calc/calcFamily.js +0 -124
  161. package/dist/esm/calc/calcFamily.js.map +0 -1
  162. package/dist/esm/channel/channel.js +0 -136
  163. package/dist/esm/channel/channel.js.map +0 -1
  164. package/dist/esm/compose/composition.js +0 -46
  165. package/dist/esm/compose/composition.js.map +0 -1
  166. package/dist/esm/compose/host.js +0 -5
  167. package/dist/esm/compose/host.js.map +0 -1
  168. package/dist/esm/compose/props.js.map +0 -1
  169. package/dist/esm/compose/provide.js +0 -28
  170. package/dist/esm/compose/provide.js.map +0 -1
  171. package/dist/esm/compose/slot.js +0 -23
  172. package/dist/esm/compose/slot.js.map +0 -1
  173. package/dist/esm/compose/ui.js +0 -57
  174. package/dist/esm/compose/ui.js.map +0 -1
  175. package/dist/esm/definition/definition.js +0 -42
  176. package/dist/esm/definition/definition.js.map +0 -1
  177. package/dist/esm/event/event.js +0 -30
  178. package/dist/esm/event/event.js.map +0 -1
  179. package/dist/esm/event/eventGroup.js +0 -4
  180. package/dist/esm/event/eventGroup.js.map +0 -1
  181. package/dist/esm/feature/feature.js +0 -98
  182. package/dist/esm/feature/feature.js.map +0 -1
  183. package/dist/esm/index.js +0 -45
  184. package/dist/esm/index.js.map +0 -1
  185. package/dist/esm/internal/capture.js +0 -11
  186. package/dist/esm/internal/capture.js.map +0 -1
  187. package/dist/esm/internal/ctx.js +0 -2
  188. package/dist/esm/internal/ctx.js.map +0 -1
  189. package/dist/esm/internal/errors.js.map +0 -1
  190. package/dist/esm/internal/inspect.js +0 -32
  191. package/dist/esm/internal/inspect.js.map +0 -1
  192. package/dist/esm/internal/queryDriver.js +0 -134
  193. package/dist/esm/internal/queryDriver.js.map +0 -1
  194. package/dist/esm/internal/reuse.js +0 -68
  195. package/dist/esm/internal/reuse.js.map +0 -1
  196. package/dist/esm/internal/scheduler.js +0 -69
  197. package/dist/esm/internal/scheduler.js.map +0 -1
  198. package/dist/esm/internal/seeds.js +0 -17
  199. package/dist/esm/internal/seeds.js.map +0 -1
  200. package/dist/esm/internal/sources.js +0 -59
  201. package/dist/esm/internal/sources.js.map +0 -1
  202. package/dist/esm/internal/store.js +0 -73
  203. package/dist/esm/internal/store.js.map +0 -1
  204. package/dist/esm/internal/track.js +0 -18
  205. package/dist/esm/internal/track.js.map +0 -1
  206. package/dist/esm/package.json +0 -4
  207. package/dist/esm/procedure/procedure.js +0 -50
  208. package/dist/esm/procedure/procedure.js.map +0 -1
  209. package/dist/esm/reducer/reducer.js +0 -63
  210. package/dist/esm/reducer/reducer.js.map +0 -1
  211. package/dist/esm/remote/remoteState.js +0 -270
  212. package/dist/esm/remote/remoteState.js.map +0 -1
  213. package/dist/esm/runtime/bus.js +0 -20
  214. package/dist/esm/runtime/bus.js.map +0 -1
  215. package/dist/esm/runtime/loop.js +0 -116
  216. package/dist/esm/runtime/loop.js.map +0 -1
  217. package/dist/esm/scene/scene.js +0 -31
  218. package/dist/esm/scene/scene.js.map +0 -1
  219. package/dist/esm/state/state.js +0 -43
  220. package/dist/esm/state/state.js.map +0 -1
  221. package/dist/esm/state/stateFamily.js +0 -96
  222. package/dist/esm/state/stateFamily.js.map +0 -1
  223. package/dist/esm/state/stateGroup.js +0 -46
  224. package/dist/esm/state/stateGroup.js.map +0 -1
  225. package/dist/esm/state/token.js +0 -20
  226. package/dist/esm/state/token.js.map +0 -1
  227. package/dist/esm/ui/node.js +0 -2
  228. package/dist/esm/ui/node.js.map +0 -1
  229. package/dist/esm/ui/trigger.js +0 -2
  230. package/dist/esm/ui/trigger.js.map +0 -1
@@ -0,0 +1,169 @@
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
+ export class Reducers extends Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>() {}
43
+
44
+ /** Drop an item from a `Map<string, Array>` bucket, pruning the key when it empties. */
45
+ const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, item: T): void => {
46
+ const bucket = map.get(key)
47
+ if (bucket === undefined) return
48
+ const next = bucket.filter((entry) => entry !== item)
49
+ if (next.length === 0) map.delete(key)
50
+ else map.set(key, next)
51
+ }
52
+
53
+ const makeReducerRegistry = (): ReducerRegistry => {
54
+ const entries: Array<ReducerEntry> = []
55
+ const byTag = new Map<string, Array<ReducerEntry>>()
56
+ return {
57
+ entries,
58
+ byTag,
59
+ register: (entry) => {
60
+ entries.push(entry)
61
+ for (const tag of entry.handles) {
62
+ const bucket = byTag.get(tag)
63
+ if (bucket === undefined) byTag.set(tag, [entry])
64
+ else bucket.push(entry)
65
+ }
66
+ },
67
+ unregister: (entry) => {
68
+ const index = entries.indexOf(entry)
69
+ if (index >= 0) entries.splice(index, 1)
70
+ for (const tag of entry.handles) dropFromBuckets(byTag, tag, entry)
71
+ },
72
+ }
73
+ }
74
+
75
+ export const reducersLayer = Layer.sync(Reducers, makeReducerRegistry)
76
+
77
+ /** High before Normal; within a priority class, dispatch order is preserved. */
78
+ const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
79
+
80
+ // The single drain loop: one fiber that frame-batches the bus. Each tick it
81
+ // harvests every event dispatched in that microtask, runs all matching reducers
82
+ // in ONE synchronous pass (so the store notifier flushes once for the batch),
83
+ // then routes each event to the channels its procedures run on.
84
+ const drain = Effect.gen(function* () {
85
+ const bus = yield* Bus
86
+ const reducers = yield* Reducers
87
+ const { byName: channels } = yield* Channels
88
+ const procedures = yield* Procedures
89
+ const subscription = yield* PubSub.subscribe(bus)
90
+
91
+ yield* Effect.forkScoped(
92
+ Effect.forever(
93
+ Effect.gen(function* () {
94
+ // Block until the first event, then drain the rest of this tick.
95
+ const first = yield* Queue.take(subscription)
96
+ const rest = yield* Queue.takeAll(subscription)
97
+ const frame = [first, ...Chunk.toArray(rest)]
98
+
99
+ // Stable priority order: High-dispatched (UI) events fold before
100
+ // Normal-dispatched (procedure follow-up) events in the same frame. A
101
+ // two-bucket partition does this in O(m) with no comparator — push order
102
+ // preserves intra-priority dispatch order — instead of sorting the frame.
103
+ const high: Array<Envelope> = []
104
+ const normal: Array<Envelope> = []
105
+ for (const envelope of frame) {
106
+ if (rank(envelope.priority) === 0) high.push(envelope)
107
+ else normal.push(envelope)
108
+ }
109
+ const ordered = high.concat(normal)
110
+
111
+ // One synchronous block: every reducer write for the frame coalesces into
112
+ // a single notification flush. Routing to channels happens here too, so
113
+ // procedure bodies (run later, off their channel fibers) see post-batch
114
+ // state. No `yield*` in between, or the flush could fire early.
115
+ const failures: Array<{ readonly event: Tagged; readonly error: unknown }> = []
116
+ yield* Effect.sync(() => {
117
+ for (const envelope of ordered) {
118
+ for (const reducer of reducers.byTag.get(envelope.event._tag) ?? []) {
119
+ // Isolate each fold: a synchronous throw in one reducer must not
120
+ // abandon the rest of the frame nor (via the `forever` below) kill
121
+ // the drain fiber and freeze the whole app. Collect and log after.
122
+ try {
123
+ reducer.apply(envelope.event)
124
+ } catch (error) {
125
+ failures.push({ event: envelope.event, error })
126
+ }
127
+ }
128
+ }
129
+ for (const envelope of ordered) {
130
+ // Offer to each DISTINCT channel once. Routing per-procedure would
131
+ // offer a shared channel multiple times for one event, and each offer
132
+ // re-runs every procedure on it — duplicate execution.
133
+ for (const channelName of procedures.channelsByTag.get(envelope.event._tag) ?? []) {
134
+ channels.get(channelName)?.offer(envelope.event)
135
+ }
136
+ }
137
+ })
138
+ yield* Effect.forEach(
139
+ failures,
140
+ (failure) =>
141
+ Effect.logError(
142
+ `reform: reducer threw handling '${failure.event._tag}'`,
143
+ failure.error,
144
+ ),
145
+ { discard: true },
146
+ )
147
+ }).pipe(
148
+ // Belt-and-suspenders: even a defect outside the isolated fold (a bug in
149
+ // the loop itself) is logged and the tick restarts, so the bus never goes
150
+ // permanently deaf.
151
+ Effect.catchAllCause((cause) => Effect.logError('reform: drain tick crashed', cause)),
152
+ ),
153
+ ),
154
+ )
155
+ })
156
+
157
+ /**
158
+ * The reform engine: provides the bus + reducer/channel/procedure registries +
159
+ * the per-runtime notification scheduler to the application and forks the single
160
+ * drain loop. Registration mutates the live collections, so reducers/procedures/
161
+ * channels merged alongside are picked up before the first dispatch (boot).
162
+ */
163
+ export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures> = Layer.scopedDiscard(
164
+ drain,
165
+ ).pipe(
166
+ Layer.provideMerge(
167
+ Layer.mergeAll(busLayer, reducersLayer, channelsLayer, proceduresLayer, notificationsLayer),
168
+ ),
169
+ )
@@ -0,0 +1,76 @@
1
+ import { Layer } from 'effect'
2
+ import type { CompositionClass, CompositionService } from '../compose/composition'
3
+ import type { UiContract } from '../compose/ui'
4
+ import { CurrentSeedOverrides } from '../internal/seeds'
5
+ import type { Bus, Tagged } from '../runtime/bus'
6
+
7
+ // A scene is a VALUE — a composition plus the closed wiring that runs it: the
8
+ // layers that supply its logic/views (each seeding its own live state) and the
9
+ // boot events to dispatch once the runtime is live. Nothing self-registers on
10
+ // import. A scene is the single handle every consumer takes instead of a bare
11
+ // composition: the react host renders it, a proof runs against it, and the dev
12
+ // tool previews it — all from the same closed `provide`.
13
+ //
14
+ // There is no seeded-`state` dict: variation (Default/Empty/Errored) comes from
15
+ // providing different closed layers, exactly as production wiring does. The
16
+ // composition's contract `C` is preserved (not erased to `unknown`) so a proof's
17
+ // facade stays fully typed from `scene.composition`.
18
+
19
+ /**
20
+ * The services a built reform runtime exposes to its host: every composition's
21
+ * logic and the event `Bus`. A scene's `provide` must close to these (a fully
22
+ * wired app layer provides far more; this is the subset hosts read directly).
23
+ */
24
+ export type MountedServices = CompositionService | Bus
25
+
26
+ export interface Scene<C extends UiContract = UiContract> {
27
+ readonly kind: 'Scene'
28
+ /** The composition to run, with its contract preserved for typed consumers. */
29
+ readonly composition: CompositionClass<unknown, C>
30
+ /** Closed wiring (logic + views), each layer seeding its own live state. */
31
+ readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
32
+ /** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
33
+ readonly boot?: ReadonlyArray<Tagged>
34
+ }
35
+
36
+ /**
37
+ * Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
38
+ * The composition's contract flows through `C`, so consumers stay typed.
39
+ */
40
+ export const scene = <C extends UiContract>(
41
+ composition: CompositionClass<unknown, C>,
42
+ config: {
43
+ readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
44
+ readonly boot?: ReadonlyArray<Tagged>
45
+ },
46
+ ): Scene<C> => ({
47
+ kind: 'Scene',
48
+ composition,
49
+ provide: config.provide,
50
+ ...(config.boot !== undefined ? { boot: config.boot } : {}),
51
+ })
52
+
53
+ /**
54
+ * Overlay seed values onto a CLOSED scene — the tooling/test seam (dev-tool
55
+ * inspector overrides, proofs). Scenes stay closed for authors: this does not
56
+ * reopen `provide`, it wraps each layer with `Layer.locally(CurrentSeedOverrides,
57
+ * seeds)` so `State.live` boots matching stores from the override instead of
58
+ * the authored seed.
59
+ *
60
+ * Keys are state MEMBER NAMES as reflected in `manifest.states` (e.g. `count`),
61
+ * not group names. If two groups in one scene share a member name, both receive
62
+ * the override (documented limitation). Values are schema-validated at build:
63
+ * an invalid value silently falls back to the authored seed. `StateFamily`
64
+ * entries are not covered.
65
+ */
66
+ export const seedScene = <C extends UiContract>(
67
+ base: Scene<C>,
68
+ seeds: Readonly<Record<string, unknown>>,
69
+ ): Scene<C> =>
70
+ Object.keys(seeds).length === 0
71
+ ? base
72
+ : { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
73
+
74
+ /** Reflection guard: is this exported value a scene? */
75
+ export const isScene = (v: unknown): v is Scene =>
76
+ typeof v === 'object' && v !== null && (v as { readonly kind?: unknown }).kind === 'Scene'
@@ -0,0 +1,91 @@
1
+ import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import { resolveScheduler } from '../internal/scheduler'
4
+ import { CurrentSeedOverrides } from '../internal/seeds'
5
+ import { makeStore, type Store } from '../internal/store'
6
+ import { readTracked } from '../internal/track'
7
+
8
+ export interface StateOptions {
9
+ readonly title?: string
10
+ readonly description?: string
11
+ }
12
+
13
+ export interface StateManifest<N extends string, A> extends Manifest {
14
+ readonly kind: 'State'
15
+ readonly name: N
16
+ readonly schema: Schema.Schema<A, any>
17
+ readonly title?: string
18
+ readonly description?: string
19
+ }
20
+
21
+ export interface StateClass<out N extends string, in out A> extends Effect.Effect<A, never, Store<A>> {
22
+ new (): {}
23
+ readonly manifest: StateManifest<N, A>
24
+ /** Internal DI tag holding the live store. The loop reads/writes through it. */
25
+ readonly store: Context.Tag<Store<A>, Store<A>>
26
+ }
27
+
28
+ export type AnyState = StateClass<string, any>
29
+ export type StateValue<S> = S extends StateClass<string, infer A> ? A : never
30
+ export type StateName<S> = S extends StateClass<infer N, any> ? N : never
31
+
32
+ /**
33
+ * Atomic reactive state. `State.make` is the *definition* (a reflectable
34
+ * manifest + an internal store tag); it carries no starting value — the seed is
35
+ * an implementation detail supplied at create-time by `State.live(state, seed)`.
36
+ * Composed into a `StateGroup` exactly as `@effect/rpc` composes rpcs.
37
+ */
38
+ export const make = <const N extends string, A>(
39
+ name: N,
40
+ schema: Schema.Schema<A, any>,
41
+ options: StateOptions = {},
42
+ ): StateClass<N, A> => {
43
+ const store = Context.GenericTag<Store<A>, Store<A>>(`reform/state/${name}`)
44
+ const manifest: StateManifest<N, A> = {
45
+ kind: 'State',
46
+ name,
47
+ schema,
48
+ ...(options.title !== undefined ? { title: options.title } : {}),
49
+ ...(options.description !== undefined ? { description: options.description } : {}),
50
+ }
51
+ const read = Effect.flatMap(store, readTracked)
52
+ return yieldableClass(read, { manifest, store })
53
+ }
54
+
55
+ /**
56
+ * Resolve the value a store should boot from: the ambient seed override when
57
+ * one is present (and valid) for this state's member name, else the authored
58
+ * `initial`. Reads `CurrentSeedOverrides` — empty outside the tooling seam
59
+ * (`Scene.seedScene`), so production wiring always boots from `initial`. An
60
+ * override is decoded against the state's schema; an invalid value silently
61
+ * falls back to `initial` — a half-typed override from the dev tool must never
62
+ * crash a preview.
63
+ */
64
+ const resolveSeed = <S extends AnyState>(
65
+ state: S,
66
+ initial: StateValue<S>,
67
+ ): Effect.Effect<StateValue<S>> =>
68
+ Effect.map(FiberRef.get(CurrentSeedOverrides), (overrides) =>
69
+ state.manifest.name in overrides
70
+ ? Option.getOrElse(
71
+ Schema.decodeUnknownOption(state.manifest.schema)(overrides[state.manifest.name]),
72
+ () => initial,
73
+ )
74
+ : initial,
75
+ )
76
+
77
+ /**
78
+ * Allocate a state's store, seeded with `initial` — `State.live(Feed, seed)`.
79
+ * The seed lives here (implementation), not on the definition, so the same
80
+ * definition can boot from different values in app vs. test wiring.
81
+ */
82
+ export const live = <S extends AnyState>(
83
+ state: S,
84
+ initial: StateValue<S>,
85
+ ): Layer.Layer<Store<StateValue<S>>> =>
86
+ Layer.effect(
87
+ state.store,
88
+ Effect.zipWith(resolveScheduler, resolveSeed(state, initial), (scheduler, seed) =>
89
+ makeStore(seed, scheduler),
90
+ ),
91
+ )
@@ -0,0 +1,171 @@
1
+ import { Context, Effect, Layer, type Schema } from 'effect'
2
+ import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { resolveScheduler, type Scheduler } from '../internal/scheduler'
4
+ import { makeStore, type Store } from '../internal/store'
5
+ import { readTracked } from '../internal/track'
6
+
7
+ /**
8
+ * A reducer fold returns this to evict the key it folded over, instead of a new
9
+ * value. The loop drops the entry's store from the family, so a normalized
10
+ * collection (chat messages, search hits, transient rows) does not grow without
11
+ * bound — the one fix for the otherwise-unbounded per-key `Map`.
12
+ */
13
+ export const Tombstone: unique symbol = Symbol.for('reform/StateFamily/Tombstone')
14
+ export type Tombstone = typeof Tombstone
15
+
16
+ /** Per-key stores, created lazily so a write to one key wakes only its subscribers. */
17
+ export interface FamilyStore<K, V> {
18
+ at(key: K): Store<V>
19
+ /**
20
+ * Drop a key's store so it stops occupying memory. A live subscriber keeps its
21
+ * own closure over the previous store, so eviction is for keys whose entity is
22
+ * gone (the component is unmounting) — re-`at`-ing the key allocates a fresh,
23
+ * reseeded store.
24
+ */
25
+ forget(key: K): void
26
+ /** Drop every key's store — a wholesale reset of the family. */
27
+ clear(): void
28
+ /** Number of live keys — for tests/diagnostics. */
29
+ size(): number
30
+ }
31
+
32
+ /** Options for a family's keyed store. */
33
+ export interface FamilyOptions {
34
+ /**
35
+ * Drop a key's store automatically once it has no live subscribers (checked on
36
+ * the next microtask, so a same-commit re-subscribe — e.g. a key that just moved
37
+ * position in a list — cancels the eviction). This bounds an otherwise-unbounded
38
+ * family without explicit `Tombstone`s, but it also DISCARDS the key's value —
39
+ * re-`at`-ing a dropped key reseeds from `initial`. Use only for keys whose value
40
+ * is disposable when nothing renders them (search hits, transient rows); for a
41
+ * source-of-truth entity that may be temporarily off-screen (a virtualized list),
42
+ * keep this off and reclaim with an explicit reducer `Tombstone`. Default: off.
43
+ */
44
+ readonly evictWhenUnused?: boolean
45
+ }
46
+
47
+ const makeFamilyStore = <K, V>(
48
+ seed: (key: K) => V,
49
+ scheduler: Scheduler,
50
+ options: FamilyOptions = {},
51
+ ): FamilyStore<K, V> => {
52
+ const entries = new Map<K, Store<V>>()
53
+ const evictWhenUnused = options.evictWhenUnused === true
54
+ // Live subscriber count per key — maintained only when eviction is on.
55
+ const subscribers = new Map<K, number>()
56
+
57
+ // Wrap a store's `subscribe` to ref-count, evicting the key when it falls idle.
58
+ // `get`/`set`/`getVersion` are delegated unchanged, so the loop writes and the
59
+ // host reads see the same cell; only subscribe/unsubscribe are instrumented.
60
+ const refCounted = (key: K, store: Store<V>): Store<V> => ({
61
+ ...store,
62
+ subscribe: (listener) => {
63
+ subscribers.set(key, (subscribers.get(key) ?? 0) + 1)
64
+ const off = store.subscribe(listener)
65
+ const released = { done: false }
66
+ return () => {
67
+ if (released.done) return
68
+ released.done = true
69
+ off()
70
+ const remaining = (subscribers.get(key) ?? 1) - 1
71
+ if (remaining > 0) {
72
+ subscribers.set(key, remaining)
73
+ return
74
+ }
75
+ subscribers.delete(key)
76
+ queueMicrotask(() => {
77
+ if ((subscribers.get(key) ?? 0) === 0) entries.delete(key)
78
+ })
79
+ }
80
+ },
81
+ })
82
+
83
+ return {
84
+ at: (key) => {
85
+ const existing = entries.get(key)
86
+ if (existing !== undefined) return existing
87
+ const base = makeStore(seed(key), scheduler)
88
+ const created = evictWhenUnused ? refCounted(key, base) : base
89
+ entries.set(key, created)
90
+ return created
91
+ },
92
+ forget: (key) => {
93
+ entries.delete(key)
94
+ subscribers.delete(key)
95
+ },
96
+ clear: () => {
97
+ entries.clear()
98
+ subscribers.clear()
99
+ },
100
+ size: () => entries.size,
101
+ }
102
+ }
103
+
104
+ export interface StateFamilyManifest<N extends string, K, V> extends Manifest {
105
+ readonly kind: 'StateFamily'
106
+ readonly name: N
107
+ readonly key: Schema.Schema<K, any>
108
+ readonly value: Schema.Schema<V, any>
109
+ readonly title?: string
110
+ readonly description?: string
111
+ }
112
+
113
+ export interface StateFamilyClass<out N extends string, in out K, in out V> {
114
+ /** Instance carries the key phantom so `Family['Key']` resolves in `keyOf` types. */
115
+ new (): { readonly Key: K }
116
+ readonly manifest: StateFamilyManifest<N, K, V>
117
+ readonly store: Context.Tag<FamilyStore<K, V>, FamilyStore<K, V>>
118
+ }
119
+
120
+ export type AnyFamily = StateFamilyClass<string, any, any>
121
+ export type FamilyKey<F> = F extends StateFamilyClass<any, infer K, any> ? K : never
122
+ export type FamilyValue<F> = F extends StateFamilyClass<any, any, infer V> ? V : never
123
+
124
+ /**
125
+ * Normalized keyed state: one logical State per key, backed by a single family
126
+ * store. Scales with the collection without re-rendering unrelated entries.
127
+ */
128
+ export const make = <const N extends string, K, V>(
129
+ name: N,
130
+ key: Schema.Schema<K, any>,
131
+ value: Schema.Schema<V, any>,
132
+ options: { readonly title?: string; readonly description?: string } = {},
133
+ ): StateFamilyClass<N, K, V> => {
134
+ const store = Context.GenericTag<FamilyStore<K, V>, FamilyStore<K, V>>(`reform/family/${name}`)
135
+ const manifest: StateFamilyManifest<N, K, V> = {
136
+ kind: 'StateFamily',
137
+ name,
138
+ key,
139
+ value,
140
+ ...(options.title !== undefined ? { title: options.title } : {}),
141
+ ...(options.description !== undefined ? { description: options.description } : {}),
142
+ }
143
+ return definitionClass<StateFamilyClass<N, K, V>>({ manifest, store })
144
+ }
145
+
146
+ /**
147
+ * Read one entry by key — `StateFamily.read(ItemUi, id)`. Inside a render it
148
+ * subscribes that key's slice; elsewhere it just snapshots (D5).
149
+ */
150
+ export const read = <N extends string, K, V>(
151
+ family: StateFamilyClass<N, K, V>,
152
+ key: K,
153
+ ): Effect.Effect<V, never, FamilyStore<K, V>> =>
154
+ Effect.flatMap(family.store, (fs) => readTracked(fs.at(key)))
155
+
156
+ /**
157
+ * Allocate a family's keyed store — `StateFamily.live(ItemUi, seed)`. The seed
158
+ * is a plain value or a factory; a factory receives the key, so a new entry can
159
+ * derive its starting value from its own id (`(id) => ({ ...blank, id }))`).
160
+ */
161
+ export const live = <N extends string, K, V>(
162
+ family: StateFamilyClass<N, K, V>,
163
+ initial: V | ((key: K) => V),
164
+ options: FamilyOptions = {},
165
+ ): Layer.Layer<FamilyStore<K, V>> => {
166
+ const seed: (key: K) => V = typeof initial === 'function' ? (initial as (key: K) => V) : () => initial
167
+ return Layer.effect(
168
+ family.store,
169
+ Effect.map(resolveScheduler, (scheduler) => makeFamilyStore<K, V>(seed, scheduler, options)),
170
+ )
171
+ }
@@ -0,0 +1,78 @@
1
+ import { Layer } from 'effect'
2
+ import { definitionClass } from '../definition/definition'
3
+ import { UnknownGroupState } from '../internal/errors'
4
+ // A group's members each carry a `store` tag, so the shared `StoresOf` (used for
5
+ // calc inputs too) distributes them into the union of distinct stores their
6
+ // `live` layer provides — no group-specific mapped type needed.
7
+ import { type StoresOf } from '../internal/sources'
8
+ import { type AnyState, live as stateLive, type StateClass, type StateName } from './state'
9
+ import { StateToken } from './token'
10
+
11
+ type ValueForName<Members extends ReadonlyArray<AnyState>, N extends string> =
12
+ Extract<Members[number], StateClass<N, any>> extends StateClass<any, infer A> ? A : never
13
+
14
+ export interface StateGroupClass<Members extends ReadonlyArray<AnyState>> {
15
+ new (): {}
16
+ readonly kind: 'StateGroup'
17
+ readonly members: Members
18
+ /** Members indexed by name, so `select` is an O(1) lookup instead of a scan. */
19
+ readonly byName: ReadonlyMap<string, AnyState>
20
+ }
21
+
22
+ export type AnyStateGroup = StateGroupClass<ReadonlyArray<AnyState>>
23
+
24
+ // The seed record `StateGroup.live` requires: one entry per member, keyed by the
25
+ // member's name and typed to that member's value (so a missing or mistyped seed
26
+ // is a compile error). Written against the group class, so call sites read
27
+ // `GroupSeeds<typeof TodosStates>`.
28
+ export type GroupSeeds<G extends AnyStateGroup> =
29
+ G extends StateGroupClass<infer Members>
30
+ ? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
31
+ : never
32
+
33
+ /** Compose atomic States into a group provided (and addressed) as a unit. */
34
+ export const make = <const Members extends ReadonlyArray<AnyState>>(
35
+ ...members: Members
36
+ ): StateGroupClass<Members> =>
37
+ definitionClass<StateGroupClass<Members>>({
38
+ kind: 'StateGroup' as const,
39
+ members,
40
+ byName: new Map(members.map((m) => [m.manifest.name, m] as const)),
41
+ })
42
+
43
+ /**
44
+ * Address a member by its tag — `StateGroup.select(TodosStates, 'feed')` —
45
+ * yielding a `StateToken` for reads and calc inputs (still `yield*`-able).
46
+ *
47
+ * Data-first only (not `dual`): the member name's type depends on the group, and
48
+ * a group is not `Pipeable`, so a data-last form would be both more verbose and
49
+ * harder to type than the direct call. Effect reserves `dual` for operators on a
50
+ * `Pipeable` self with an independent value arg (e.g. `ref.pipe(Ref.set(v))`);
51
+ * this is not that shape.
52
+ */
53
+ export const select = <Members extends ReadonlyArray<AnyState>, N extends StateName<Members[number]>>(
54
+ group: StateGroupClass<Members>,
55
+ name: N,
56
+ ): StateToken<N, ValueForName<Members, N>> => {
57
+ const member = group.byName.get(name)
58
+ if (member === undefined) throw new UnknownGroupState({ name })
59
+ return new StateToken(name, member.store) as StateToken<N, ValueForName<Members, N>>
60
+ }
61
+
62
+ /**
63
+ * Allocate every member's store as one merged layer, each seeded from `seeds`
64
+ * keyed by member name — `StateGroup.live(BoardStates, { board: { _tag: 'Idle' } })`.
65
+ */
66
+ export const live = <Members extends ReadonlyArray<AnyState>>(
67
+ group: StateGroupClass<Members>,
68
+ seeds: GroupSeeds<StateGroupClass<Members>>,
69
+ ): Layer.Layer<StoresOf<Members>> => {
70
+ // Index the typed seed record by the member's runtime name — a genuine
71
+ // reflection boundary (string key into a mapped type). Each member's store
72
+ // layer is then merged; the union of stores is exactly `StoresOf<Members>`,
73
+ // which `reduce`'s single-layer accumulator can't express, so restate it.
74
+ const seedRecord = seeds as Record<string, unknown>
75
+ return group.members
76
+ .map((m) => stateLive(m, seedRecord[m.manifest.name]))
77
+ .reduce((a, b) => Layer.merge(a, b)) as Layer.Layer<StoresOf<Members>>
78
+ }
@@ -1,16 +1,24 @@
1
- import { Context, Effect, Effectable } from 'effect';
2
- import type { Store } from '../internal/store.js';
1
+ import { Context, Effect, Effectable } from 'effect'
2
+ import type { Store } from '../internal/store'
3
+ import { readTracked } from '../internal/track'
4
+
3
5
  /**
4
6
  * A reference to one slice of state: yieldable to its current value (`yield*`
5
7
  * reads + — under React — subscribes) and carrying the metadata calcs need
6
8
  * (its `name` and backing store tag). Returned by `StateGroup.select(group, name)`.
7
9
  */
8
- export declare class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
9
- readonly name: N;
10
- readonly store: Context.Tag<Store<A>, Store<A>>;
11
- constructor(name: N, store: Context.Tag<Store<A>, Store<A>>);
12
- commit(): Effect.Effect<A, never, Store<A>>;
10
+ export class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
11
+ constructor(
12
+ readonly name: N,
13
+ readonly store: Context.Tag<Store<A>, Store<A>>,
14
+ ) {
15
+ super()
16
+ }
17
+ commit(): Effect.Effect<A, never, Store<A>> {
18
+ return Effect.flatMap(this.store, readTracked)
19
+ }
13
20
  }
21
+
14
22
  /**
15
23
  * Anything a derived value can read and depend on: a named slice backed by a
16
24
  * subscribable store tag. A `StateToken` (group member), a `Calc`, and an
@@ -21,10 +29,10 @@ export declare class StateToken<out N extends string, in out A> extends Effectab
21
29
  * satisfy the same shape so the three are interchangeable as calc inputs.
22
30
  */
23
31
  export interface Source<out N extends string, in out A> {
24
- readonly name: N;
25
- readonly store: Context.Tag<Store<A>, Store<A>>;
32
+ readonly name: N
33
+ readonly store: Context.Tag<Store<A>, Store<A>>
26
34
  }
27
- export type AnySource = Source<string, any>;
28
- export type SourceName<S> = S extends Source<infer N, any> ? N : never;
29
- export type SourceValue<S> = S extends Source<any, infer A> ? A : never;
30
- //# sourceMappingURL=token.d.ts.map
35
+
36
+ export type AnySource = Source<string, any>
37
+ export type SourceName<S> = S extends Source<infer N, any> ? N : never
38
+ export type SourceValue<S> = S extends Source<any, infer A> ? A : never
@@ -1,9 +1,9 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ReactNode } from 'react'
2
+
2
3
  /**
3
4
  * What a view renders. Reform targets React, so a node is a `ReactNode` — this
4
5
  * is a *type-only* dependency: core never imports the React runtime nor renders
5
6
  * anything itself (that is `@reform/react`). Typing it precisely lets the `.ui`
6
7
  * presentations author real JSX and lets slots be valid components.
7
8
  */
8
- export type Node = ReactNode;
9
- //# sourceMappingURL=node.d.ts.map
9
+ export type Node = ReactNode
@@ -3,5 +3,4 @@
3
3
  * (High priority) through the runtime. The UI never sees Effect — it just calls
4
4
  * `events.submit({ text })`.
5
5
  */
6
- export type Trigger<P> = (payload: P) => void;
7
- //# sourceMappingURL=trigger.d.ts.map
6
+ export type Trigger<P> = (payload: P) => void