@playfast/reform 0.0.1 → 0.0.3

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 (231) hide show
  1. package/README.md +551 -53
  2. package/package.json +15 -13
  3. package/src/boundary/boundary.ts +192 -0
  4. package/src/calc/asyncCalc.ts +267 -0
  5. package/{dist/dts/calc/asyncData.d.ts → src/calc/asyncData.ts} +53 -20
  6. package/src/calc/calc.ts +120 -0
  7. package/src/calc/calcFamily.ts +194 -0
  8. package/src/calc/compose.ts +69 -0
  9. package/src/channel/channel.ts +265 -0
  10. package/src/compose/composition.ts +112 -0
  11. package/{dist/dts/compose/host.d.ts → src/compose/host.ts} +6 -8
  12. package/{dist/esm/compose/props.js → src/compose/props.ts} +3 -4
  13. package/src/compose/provide.ts +70 -0
  14. package/{dist/dts/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  15. package/src/compose/ui.ts +97 -0
  16. package/src/definition/definition.ts +74 -0
  17. package/src/event/event.ts +65 -0
  18. package/src/event/eventGroup.ts +14 -0
  19. package/{dist/dts/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  20. package/src/index.ts +140 -0
  21. package/{dist/dts/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  22. package/{dist/dts/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  23. package/{dist/esm/internal/errors.js → src/internal/errors.ts} +41 -26
  24. package/src/internal/inspect.ts +34 -0
  25. package/src/internal/queryDriver.ts +247 -0
  26. package/src/internal/reuse.ts +73 -0
  27. package/src/internal/scheduler.ts +91 -0
  28. package/{dist/dts/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  29. package/src/internal/sources.ts +104 -0
  30. package/src/internal/store.ts +105 -0
  31. package/{dist/dts/internal/track.d.ts → src/internal/track.ts} +15 -11
  32. package/src/procedure/procedure.ts +89 -0
  33. package/src/reducer/reducer.ts +137 -0
  34. package/src/remote/remoteState.ts +571 -0
  35. package/{dist/cjs/runtime/bus.js → src/runtime/bus.ts} +23 -12
  36. package/src/runtime/loop.ts +169 -0
  37. package/src/scene/scene.ts +76 -0
  38. package/src/state/state.ts +91 -0
  39. package/src/state/stateFamily.ts +171 -0
  40. package/src/state/stateGroup.ts +78 -0
  41. package/{dist/dts/state/token.d.ts → src/state/token.ts} +21 -13
  42. package/{dist/dts/ui/node.d.ts → src/ui/node.ts} +3 -3
  43. package/{dist/dts/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  44. package/dist/cjs/boundary/boundary.js +0 -86
  45. package/dist/cjs/calc/asyncCalc.js +0 -128
  46. package/dist/cjs/calc/asyncData.js +0 -37
  47. package/dist/cjs/calc/calc.js +0 -58
  48. package/dist/cjs/calc/calcFamily.js +0 -127
  49. package/dist/cjs/channel/channel.js +0 -142
  50. package/dist/cjs/compose/composition.js +0 -50
  51. package/dist/cjs/compose/host.js +0 -8
  52. package/dist/cjs/compose/props.js +0 -14
  53. package/dist/cjs/compose/provide.js +0 -30
  54. package/dist/cjs/compose/slot.js +0 -27
  55. package/dist/cjs/compose/ui.js +0 -61
  56. package/dist/cjs/definition/definition.js +0 -46
  57. package/dist/cjs/event/event.js +0 -36
  58. package/dist/cjs/event/eventGroup.js +0 -7
  59. package/dist/cjs/feature/feature.js +0 -102
  60. package/dist/cjs/index.js +0 -116
  61. package/dist/cjs/internal/capture.js +0 -14
  62. package/dist/cjs/internal/ctx.js +0 -2
  63. package/dist/cjs/internal/errors.js +0 -62
  64. package/dist/cjs/internal/inspect.js +0 -36
  65. package/dist/cjs/internal/queryDriver.js +0 -138
  66. package/dist/cjs/internal/reuse.js +0 -71
  67. package/dist/cjs/internal/scheduler.js +0 -73
  68. package/dist/cjs/internal/seeds.js +0 -19
  69. package/dist/cjs/internal/sources.js +0 -61
  70. package/dist/cjs/internal/store.js +0 -77
  71. package/dist/cjs/internal/track.js +0 -22
  72. package/dist/cjs/package.json +0 -4
  73. package/dist/cjs/procedure/procedure.js +0 -52
  74. package/dist/cjs/reducer/reducer.js +0 -64
  75. package/dist/cjs/remote/remoteState.js +0 -307
  76. package/dist/cjs/runtime/loop.js +0 -119
  77. package/dist/cjs/scene/scene.js +0 -36
  78. package/dist/cjs/state/state.js +0 -47
  79. package/dist/cjs/state/stateFamily.js +0 -101
  80. package/dist/cjs/state/stateGroup.js +0 -47
  81. package/dist/cjs/state/token.js +0 -23
  82. package/dist/cjs/ui/node.js +0 -2
  83. package/dist/cjs/ui/trigger.js +0 -2
  84. package/dist/dts/boundary/boundary.d.ts +0 -72
  85. package/dist/dts/boundary/boundary.d.ts.map +0 -1
  86. package/dist/dts/calc/asyncCalc.d.ts +0 -91
  87. package/dist/dts/calc/asyncCalc.d.ts.map +0 -1
  88. package/dist/dts/calc/asyncData.d.ts.map +0 -1
  89. package/dist/dts/calc/calc.d.ts +0 -57
  90. package/dist/dts/calc/calc.d.ts.map +0 -1
  91. package/dist/dts/calc/calcFamily.d.ts +0 -57
  92. package/dist/dts/calc/calcFamily.d.ts.map +0 -1
  93. package/dist/dts/channel/channel.d.ts +0 -115
  94. package/dist/dts/channel/channel.d.ts.map +0 -1
  95. package/dist/dts/compose/composition.d.ts +0 -72
  96. package/dist/dts/compose/composition.d.ts.map +0 -1
  97. package/dist/dts/compose/host.d.ts.map +0 -1
  98. package/dist/dts/compose/props.d.ts +0 -13
  99. package/dist/dts/compose/props.d.ts.map +0 -1
  100. package/dist/dts/compose/provide.d.ts +0 -22
  101. package/dist/dts/compose/provide.d.ts.map +0 -1
  102. package/dist/dts/compose/slot.d.ts.map +0 -1
  103. package/dist/dts/compose/ui.d.ts +0 -50
  104. package/dist/dts/compose/ui.d.ts.map +0 -1
  105. package/dist/dts/definition/definition.d.ts +0 -33
  106. package/dist/dts/definition/definition.d.ts.map +0 -1
  107. package/dist/dts/event/event.d.ts +0 -33
  108. package/dist/dts/event/event.d.ts.map +0 -1
  109. package/dist/dts/event/eventGroup.d.ts +0 -9
  110. package/dist/dts/event/eventGroup.d.ts.map +0 -1
  111. package/dist/dts/feature/feature.d.ts.map +0 -1
  112. package/dist/dts/index.d.ts +0 -43
  113. package/dist/dts/index.d.ts.map +0 -1
  114. package/dist/dts/internal/capture.d.ts.map +0 -1
  115. package/dist/dts/internal/ctx.d.ts.map +0 -1
  116. package/dist/dts/internal/errors.d.ts +0 -69
  117. package/dist/dts/internal/errors.d.ts.map +0 -1
  118. package/dist/dts/internal/inspect.d.ts +0 -17
  119. package/dist/dts/internal/inspect.d.ts.map +0 -1
  120. package/dist/dts/internal/queryDriver.d.ts +0 -65
  121. package/dist/dts/internal/queryDriver.d.ts.map +0 -1
  122. package/dist/dts/internal/reuse.d.ts +0 -10
  123. package/dist/dts/internal/reuse.d.ts.map +0 -1
  124. package/dist/dts/internal/scheduler.d.ts +0 -47
  125. package/dist/dts/internal/scheduler.d.ts.map +0 -1
  126. package/dist/dts/internal/seeds.d.ts.map +0 -1
  127. package/dist/dts/internal/sources.d.ts +0 -39
  128. package/dist/dts/internal/sources.d.ts.map +0 -1
  129. package/dist/dts/internal/store.d.ts +0 -47
  130. package/dist/dts/internal/store.d.ts.map +0 -1
  131. package/dist/dts/internal/track.d.ts.map +0 -1
  132. package/dist/dts/procedure/procedure.d.ts +0 -40
  133. package/dist/dts/procedure/procedure.d.ts.map +0 -1
  134. package/dist/dts/reducer/reducer.d.ts +0 -44
  135. package/dist/dts/reducer/reducer.d.ts.map +0 -1
  136. package/dist/dts/remote/remoteState.d.ts +0 -119
  137. package/dist/dts/remote/remoteState.d.ts.map +0 -1
  138. package/dist/dts/runtime/bus.d.ts +0 -27
  139. package/dist/dts/runtime/bus.d.ts.map +0 -1
  140. package/dist/dts/runtime/loop.d.ts +0 -45
  141. package/dist/dts/runtime/loop.d.ts.map +0 -1
  142. package/dist/dts/scene/scene.d.ts +0 -44
  143. package/dist/dts/scene/scene.d.ts.map +0 -1
  144. package/dist/dts/state/state.d.ts +0 -37
  145. package/dist/dts/state/state.d.ts.map +0 -1
  146. package/dist/dts/state/stateFamily.d.ts +0 -79
  147. package/dist/dts/state/stateFamily.d.ts.map +0 -1
  148. package/dist/dts/state/stateGroup.d.ts +0 -36
  149. package/dist/dts/state/stateGroup.d.ts.map +0 -1
  150. package/dist/dts/state/token.d.ts.map +0 -1
  151. package/dist/dts/ui/node.d.ts.map +0 -1
  152. package/dist/dts/ui/trigger.d.ts.map +0 -1
  153. package/dist/esm/boundary/boundary.js +0 -83
  154. package/dist/esm/boundary/boundary.js.map +0 -1
  155. package/dist/esm/calc/asyncCalc.js +0 -95
  156. package/dist/esm/calc/asyncCalc.js.map +0 -1
  157. package/dist/esm/calc/asyncData.js +0 -34
  158. package/dist/esm/calc/asyncData.js.map +0 -1
  159. package/dist/esm/calc/calc.js +0 -58
  160. package/dist/esm/calc/calc.js.map +0 -1
  161. package/dist/esm/calc/calcFamily.js +0 -124
  162. package/dist/esm/calc/calcFamily.js.map +0 -1
  163. package/dist/esm/channel/channel.js +0 -136
  164. package/dist/esm/channel/channel.js.map +0 -1
  165. package/dist/esm/compose/composition.js +0 -46
  166. package/dist/esm/compose/composition.js.map +0 -1
  167. package/dist/esm/compose/host.js +0 -5
  168. package/dist/esm/compose/host.js.map +0 -1
  169. package/dist/esm/compose/props.js.map +0 -1
  170. package/dist/esm/compose/provide.js +0 -28
  171. package/dist/esm/compose/provide.js.map +0 -1
  172. package/dist/esm/compose/slot.js +0 -23
  173. package/dist/esm/compose/slot.js.map +0 -1
  174. package/dist/esm/compose/ui.js +0 -57
  175. package/dist/esm/compose/ui.js.map +0 -1
  176. package/dist/esm/definition/definition.js +0 -42
  177. package/dist/esm/definition/definition.js.map +0 -1
  178. package/dist/esm/event/event.js +0 -30
  179. package/dist/esm/event/event.js.map +0 -1
  180. package/dist/esm/event/eventGroup.js +0 -4
  181. package/dist/esm/event/eventGroup.js.map +0 -1
  182. package/dist/esm/feature/feature.js +0 -98
  183. package/dist/esm/feature/feature.js.map +0 -1
  184. package/dist/esm/index.js +0 -45
  185. package/dist/esm/index.js.map +0 -1
  186. package/dist/esm/internal/capture.js +0 -11
  187. package/dist/esm/internal/capture.js.map +0 -1
  188. package/dist/esm/internal/ctx.js +0 -2
  189. package/dist/esm/internal/ctx.js.map +0 -1
  190. package/dist/esm/internal/errors.js.map +0 -1
  191. package/dist/esm/internal/inspect.js +0 -32
  192. package/dist/esm/internal/inspect.js.map +0 -1
  193. package/dist/esm/internal/queryDriver.js +0 -134
  194. package/dist/esm/internal/queryDriver.js.map +0 -1
  195. package/dist/esm/internal/reuse.js +0 -68
  196. package/dist/esm/internal/reuse.js.map +0 -1
  197. package/dist/esm/internal/scheduler.js +0 -69
  198. package/dist/esm/internal/scheduler.js.map +0 -1
  199. package/dist/esm/internal/seeds.js +0 -17
  200. package/dist/esm/internal/seeds.js.map +0 -1
  201. package/dist/esm/internal/sources.js +0 -59
  202. package/dist/esm/internal/sources.js.map +0 -1
  203. package/dist/esm/internal/store.js +0 -73
  204. package/dist/esm/internal/store.js.map +0 -1
  205. package/dist/esm/internal/track.js +0 -18
  206. package/dist/esm/internal/track.js.map +0 -1
  207. package/dist/esm/package.json +0 -4
  208. package/dist/esm/procedure/procedure.js +0 -50
  209. package/dist/esm/procedure/procedure.js.map +0 -1
  210. package/dist/esm/reducer/reducer.js +0 -63
  211. package/dist/esm/reducer/reducer.js.map +0 -1
  212. package/dist/esm/remote/remoteState.js +0 -270
  213. package/dist/esm/remote/remoteState.js.map +0 -1
  214. package/dist/esm/runtime/bus.js +0 -20
  215. package/dist/esm/runtime/bus.js.map +0 -1
  216. package/dist/esm/runtime/loop.js +0 -116
  217. package/dist/esm/runtime/loop.js.map +0 -1
  218. package/dist/esm/scene/scene.js +0 -31
  219. package/dist/esm/scene/scene.js.map +0 -1
  220. package/dist/esm/state/state.js +0 -43
  221. package/dist/esm/state/state.js.map +0 -1
  222. package/dist/esm/state/stateFamily.js +0 -96
  223. package/dist/esm/state/stateFamily.js.map +0 -1
  224. package/dist/esm/state/stateGroup.js +0 -46
  225. package/dist/esm/state/stateGroup.js.map +0 -1
  226. package/dist/esm/state/token.js +0 -20
  227. package/dist/esm/state/token.js.map +0 -1
  228. package/dist/esm/ui/node.js +0 -2
  229. package/dist/esm/ui/node.js.map +0 -1
  230. package/dist/esm/ui/trigger.js +0 -2
  231. package/dist/esm/ui/trigger.js.map +0 -1
@@ -0,0 +1,105 @@
1
+ import { Equal, MutableRef } from 'effect'
2
+ import { type Inspectable, inspectable } from './inspect'
3
+ import { defaultScheduler, type Scheduler } from './scheduler'
4
+
5
+ /**
6
+ * A reactive cell living outside Effect. The single source of truth for one
7
+ * slice of state. `set` is internal: only the reduce loop ever holds a Store
8
+ * (reducers return values, the loop writes them), which is how "state changes
9
+ * only in the reduce loop" is enforced structurally — there is no public writer.
10
+ *
11
+ * This is the synchronous, React-facing analog of Effect's `SubscriptionRef`
12
+ * (a `Ref` + `PubSub` + a `changes` `Stream`). We don't use `SubscriptionRef`
13
+ * directly because `useSyncExternalStore` requires a *synchronous* `getSnapshot`
14
+ * and a *synchronous* `subscribe` returning an unsubscribe thunk, whereas
15
+ * `SubscriptionRef.get` is an `Effect` and its `changes` is a `Stream` (a fiber
16
+ * per subscription). Mutable state is held in a `MutableRef` (Effect's mutable
17
+ * cell), so there is no reassigned binding.
18
+ *
19
+ * `getSnapshot` + `subscribe` are the stable pair the React bridge binds to;
20
+ * `getSnapshot` returns the same reference until the value actually changes.
21
+ * `getVersion` returns a monotonic counter bumped on every real change — the
22
+ * host sums it across a render's tracked stores for a `useSyncExternalStore`
23
+ * snapshot, which closes the tear/dropped-update window a value snapshot can't.
24
+ */
25
+ export interface Store<in out A> extends Inspectable {
26
+ get(): A
27
+ set(value: A): void
28
+ getSnapshot(): A
29
+ getVersion(): number
30
+ subscribe(listener: () => void): () => void
31
+ }
32
+
33
+ export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler): Store<A> => {
34
+ // The store is a mutable reactive slot by design, so its value lives in a
35
+ // `MutableRef` we update in place (no reassigned binding).
36
+ const value = MutableRef.make(initial)
37
+ const version = MutableRef.make(0)
38
+ const listeners = new Set<() => void>()
39
+ return {
40
+ get: () => MutableRef.get(value),
41
+ getSnapshot: () => MutableRef.get(value),
42
+ getVersion: () => MutableRef.get(version),
43
+ set: (next) => {
44
+ if (Equal.equals(MutableRef.get(value), next)) return
45
+ MutableRef.set(value, next)
46
+ MutableRef.update(version, (n) => n + 1)
47
+ scheduler.schedule(listeners)
48
+ },
49
+ subscribe: (listener) => {
50
+ listeners.add(listener)
51
+ return () => {
52
+ listeners.delete(listener)
53
+ }
54
+ },
55
+ ...inspectable(() => ({ _id: 'reform/Store', value: MutableRef.get(value) })),
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A read-only `Store` whose value is computed from upstream sources.
61
+ *
62
+ * Reads are pull-fresh: `get`/`getSnapshot` call `compute` directly, so a
63
+ * headless snapshot always reflects the current source state without waiting for
64
+ * a notification (`compute` must memoize so an unchanged read returns a stable
65
+ * reference — `useSyncExternalStore` requires it). *Notifications*, by contrast,
66
+ * run through the same coalescing scheduler as `makeStore`: an upstream change
67
+ * recomputes once and wakes subscribers only when the output actually moves (by
68
+ * `Equal.equals`), so every store kind shares one notification timing. `set` is
69
+ * a no-op — the only writer is the upstream subscription.
70
+ */
71
+ export const makeDerivedStore = <A>(
72
+ compute: () => A,
73
+ subscribeUpstream: (onChange: () => void) => () => void,
74
+ scheduler: Scheduler = defaultScheduler,
75
+ ): { readonly store: Store<A>; readonly unsubscribe: () => void } => {
76
+ const listeners = new Set<() => void>()
77
+ const version = MutableRef.make(0)
78
+ // The last output we notified against — tracked only here, never touched by
79
+ // reads, so a `get` racing ahead of `onChange` can't suppress a notification.
80
+ const seen = MutableRef.make<{ readonly value: A } | undefined>({ value: compute() })
81
+ const onChange = () => {
82
+ const previous = MutableRef.get(seen)
83
+ const next = compute()
84
+ MutableRef.set(seen, { value: next })
85
+ if (previous === undefined || !Equal.equals(previous.value, next)) {
86
+ MutableRef.update(version, (n) => n + 1)
87
+ scheduler.schedule(listeners)
88
+ }
89
+ }
90
+ const unsubscribe = subscribeUpstream(onChange)
91
+ const store: Store<A> = {
92
+ get: compute,
93
+ getSnapshot: compute,
94
+ getVersion: () => MutableRef.get(version),
95
+ set: () => {},
96
+ subscribe: (listener) => {
97
+ listeners.add(listener)
98
+ return () => {
99
+ listeners.delete(listener)
100
+ }
101
+ },
102
+ ...inspectable(() => ({ _id: 'reform/Store', value: compute() })),
103
+ }
104
+ return { store, unsubscribe }
105
+ }
@@ -1,5 +1,6 @@
1
- import { Context, Effect } from 'effect';
2
- import type { Store } from './store.js';
1
+ import { Context, Effect, Option } from 'effect'
2
+ import type { Store } from './store'
3
+
3
4
  /**
4
5
  * Anything a render can depend on: a source it can subscribe to for changes and
5
6
  * snapshot a monotonic version from. `Store` (and the calc/family stores)
@@ -9,25 +10,28 @@ import type { Store } from './store.js';
9
10
  * window a per-store value snapshot cannot.
10
11
  */
11
12
  export interface Subscribable {
12
- subscribe(listener: () => void): () => void;
13
- getVersion(): number;
13
+ subscribe(listener: () => void): () => void
14
+ getVersion(): number
14
15
  }
16
+
15
17
  /** A per-render dependency sink. The renderer provides one; reads record into it. */
16
18
  export interface Tracker {
17
- add(source: Subscribable): void;
19
+ add(source: Subscribable): void
18
20
  }
19
- declare const CurrentTracker_base: Context.TagClass<CurrentTracker, "reform/Tracker", Tracker>;
21
+
20
22
  /**
21
23
  * The active render's dependency tracker (D5). Present only while a composition
22
24
  * is rendering under `@reform/react`; absent in procedures and headless reads.
23
25
  */
24
- export declare class CurrentTracker extends CurrentTracker_base {
25
- }
26
+ export class CurrentTracker extends Context.Tag('reform/Tracker')<CurrentTracker, Tracker>() {}
27
+
26
28
  /**
27
29
  * Read a store's current snapshot and, if a tracker is active, record the store
28
30
  * as a dependency of the current render. One read API, two behaviors by context
29
31
  * (D5): inside a render it subscribes the slice; elsewhere it just snapshots.
30
32
  */
31
- export declare const readTracked: <A>(store: Store<A>) => Effect.Effect<A>;
32
- export {};
33
- //# sourceMappingURL=track.d.ts.map
33
+ export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>
34
+ Effect.map(Effect.serviceOption(CurrentTracker), (tracker) => {
35
+ if (Option.isSome(tracker)) tracker.value.add(store)
36
+ return store.getSnapshot()
37
+ })
@@ -0,0 +1,89 @@
1
+ import { Effect, Layer } from 'effect'
2
+ import type { YieldWrap } from 'effect/Utils'
3
+ import { type ChannelClass, type ProcedureEntry, Procedures } from '../channel/channel'
4
+ import { type Manifest, definitionClass } from '../definition/definition'
5
+ import { type AnyEvent, type EventType } from '../event/event'
6
+ import { type Ctx } from '../internal/ctx'
7
+ import { Bus, narrowHandled } from '../runtime/bus'
8
+
9
+ /** The reflectable descriptor a procedure carries. */
10
+ export interface ProcedureManifest extends Manifest {
11
+ readonly kind: 'Procedure'
12
+ }
13
+
14
+ export interface ProcedureClass<Events extends ReadonlyArray<AnyEvent>> {
15
+ new (): {}
16
+ readonly manifest: ProcedureManifest
17
+ /** The trigger events (marker data; read by `Procedure.live`). */
18
+ readonly events: Events
19
+ /** The channel this procedure's runs are scheduled on (marker data). */
20
+ readonly channel: ChannelClass
21
+ }
22
+
23
+ export interface ProcedureConfig<Events extends ReadonlyArray<AnyEvent>> {
24
+ readonly events: Events
25
+ /** The scheduling lane. Replaces the old `concurrency` option. */
26
+ readonly channel: ChannelClass
27
+ }
28
+
29
+ export type AnyProcedure = ProcedureClass<ReadonlyArray<AnyEvent>>
30
+
31
+ /**
32
+ * A cross-event-loop flow. The *definition* declares its trigger events and the
33
+ * channel it runs on; the *implementation* is the effectful body. Procedures no
34
+ * longer subscribe to the bus themselves — the single drain loop routes events
35
+ * to the channel, which runs the registered bodies under its policy.
36
+ */
37
+ export const make = <const Events extends ReadonlyArray<AnyEvent>>(
38
+ name: string,
39
+ config: ProcedureConfig<Events>,
40
+ ): ProcedureClass<Events> =>
41
+ definitionClass<ProcedureClass<Events>>({
42
+ manifest: { kind: 'Procedure' as const, name } satisfies ProcedureManifest,
43
+ events: config.events,
44
+ channel: config.channel,
45
+ })
46
+
47
+ /**
48
+ * Register the flow body into its channel. The body may read state and dispatch
49
+ * events, but never writes state (the core invariant); its context requirements
50
+ * (RPC clients, etc.) are captured here so the channel fiber can run it with no
51
+ * outstanding R.
52
+ */
53
+ export const live = <
54
+ Events extends ReadonlyArray<AnyEvent>,
55
+ Eff extends YieldWrap<Effect.Effect<any, any, any>>,
56
+ Done,
57
+ >(
58
+ procedure: ProcedureClass<Events>,
59
+ body: (event: EventType<Events[number]>) => Generator<Eff, Done, never>,
60
+ ): Layer.Layer<never, never, Procedures | Bus | Ctx<Eff>> => {
61
+ const name = procedure.manifest.name
62
+ const channelName = procedure.channel.manifest.name
63
+ const handles = new Set(procedure.events.map((e) => e.tag))
64
+ // Scoped registration (see `Reducer.live`): an eager procedure's scope is the
65
+ // root runtime's, so behavior is unchanged; a lazy feature's procedure registers
66
+ // on mount and unregisters on unmount, so it stops running and is reclaimed.
67
+ return Layer.scopedDiscard(
68
+ Effect.gen(function* () {
69
+ const procedures = yield* Procedures
70
+ if (procedures.entries.some((e) => e.name === name)) {
71
+ yield* Effect.logWarning(`reform: duplicate procedure name '${name}' registered`)
72
+ }
73
+ // Snapshot the body's full context (Bus + RPC clients) so `run` is total.
74
+ const runtime = yield* Effect.runtime<Bus | Ctx<Eff>>()
75
+ const entry: ProcedureEntry = {
76
+ name,
77
+ channelName,
78
+ // The channel only routes events whose tag is in `handles`, so at runtime
79
+ // `event` is always one of `Events`; `narrowHandled` restores the body's
80
+ // declared event union from the erased `Tagged` envelope.
81
+ handles,
82
+ run: (event) => Effect.provide(Effect.gen(() => body(narrowHandled(event))), runtime),
83
+ }
84
+ yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
85
+ Effect.sync(() => procedures.unregister(entry)),
86
+ )
87
+ }),
88
+ )
89
+ }
@@ -0,0 +1,137 @@
1
+ import { Effect, Layer } from 'effect'
2
+ import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { AsyncReducer } from '../internal/errors'
4
+ import { type AnyEvent, type EventType } from '../event/event'
5
+ import type { Store } from '../internal/store'
6
+ import { type AnyState, type StateClass, type StateValue } from '../state/state'
7
+ import {
8
+ type AnyFamily,
9
+ type FamilyKey,
10
+ type FamilyStore,
11
+ type FamilyValue,
12
+ Tombstone,
13
+ } from '../state/stateFamily'
14
+ import { narrowHandled, type Tagged } from '../runtime/bus'
15
+ import { type ReducerEntry, Reducers } from '../runtime/loop'
16
+
17
+ /** The reflectable descriptor a reducer carries, like every other primitive's `*Manifest`. */
18
+ export interface ReducerManifest extends Manifest {
19
+ readonly kind: 'Reducer'
20
+ }
21
+
22
+ export interface StateReducerConfig<S extends AnyState, Events extends ReadonlyArray<AnyEvent>> {
23
+ readonly states: readonly [S]
24
+ readonly events: Events
25
+ }
26
+
27
+ export interface FamilyReducerConfig<F extends AnyFamily, Events extends ReadonlyArray<AnyEvent>> {
28
+ readonly family: F
29
+ readonly keyOf: (event: EventType<Events[number]>) => FamilyKey<F>
30
+ readonly events: Events
31
+ }
32
+
33
+ export interface StateReducerClass<S extends AnyState, Events extends ReadonlyArray<AnyEvent>> {
34
+ new (): {}
35
+ readonly manifest: ReducerManifest
36
+ /** Marker data read by `Reducer.live`. */
37
+ readonly config: StateReducerConfig<S, Events>
38
+ readonly handles: ReadonlySet<string>
39
+ }
40
+
41
+ export interface FamilyReducerClass<F extends AnyFamily, Events extends ReadonlyArray<AnyEvent>> {
42
+ new (): {}
43
+ readonly manifest: ReducerManifest
44
+ readonly config: FamilyReducerConfig<F, Events>
45
+ readonly handles: ReadonlySet<string>
46
+ }
47
+
48
+ /**
49
+ * Reducers are the only writers of state. The *definition* (`Reducer.make`)
50
+ * declares which state and events it touches; the *implementation*
51
+ * (`Reducer.live`) is the pure fold. An unhandled event is a no-op because
52
+ * it is never in `handles`.
53
+ */
54
+ export function make<S extends AnyState, const Events extends ReadonlyArray<AnyEvent>>(
55
+ name: string,
56
+ config: StateReducerConfig<S, Events>,
57
+ ): StateReducerClass<S, Events>
58
+ export function make<F extends AnyFamily, const Events extends ReadonlyArray<AnyEvent>>(
59
+ name: string,
60
+ config: FamilyReducerConfig<F, Events>,
61
+ ): FamilyReducerClass<F, Events>
62
+ export function make(
63
+ name: string,
64
+ config:
65
+ | StateReducerConfig<AnyState, ReadonlyArray<AnyEvent>>
66
+ | FamilyReducerConfig<AnyFamily, ReadonlyArray<AnyEvent>>,
67
+ ): unknown {
68
+ return definitionClass<unknown>({
69
+ manifest: { kind: 'Reducer' as const, name } satisfies ReducerManifest,
70
+ config,
71
+ handles: new Set(config.events.map((e) => e.tag)),
72
+ })
73
+ }
74
+
75
+ /** Reject an accidentally-async fold up front: a reducer must be a pure sync write. */
76
+ const sync = <A>(value: A): A => {
77
+ // Loose `!= null` so a reducer returning `undefined` doesn't crash the guard.
78
+ if (value != null && typeof (value as { then?: unknown }).then === 'function') {
79
+ throw new AsyncReducer()
80
+ }
81
+ return value
82
+ }
83
+
84
+ export function live<S extends AnyState, Events extends ReadonlyArray<AnyEvent>>(
85
+ reducer: StateReducerClass<S, Events>,
86
+ fold: (value: StateValue<S>, event: EventType<Events[number]>) => StateValue<S>,
87
+ ): Layer.Layer<never, never, Reducers | Store<StateValue<S>>>
88
+ export function live<F extends AnyFamily, Events extends ReadonlyArray<AnyEvent>>(
89
+ reducer: FamilyReducerClass<F, Events>,
90
+ fold: (entry: FamilyValue<F>, event: EventType<Events[number]>) => FamilyValue<F> | Tombstone,
91
+ ): Layer.Layer<never, never, Reducers | FamilyStore<FamilyKey<F>, FamilyValue<F>>>
92
+ export function live(
93
+ reducer:
94
+ | StateReducerClass<AnyState, ReadonlyArray<AnyEvent>>
95
+ | FamilyReducerClass<AnyFamily, ReadonlyArray<AnyEvent>>,
96
+ fold: (acc: any, event: any) => any,
97
+ ): Layer.Layer<never, never, any> {
98
+ const { config, handles } = reducer
99
+ const name = reducer.manifest.name
100
+ // Scoped registration: register on build, unregister on scope close. For an eager
101
+ // app the scope is the root runtime's (closes only at dispose), so behavior is
102
+ // unchanged; for a lazy feature the scope is the feature's, so unmount removes the
103
+ // entry — it stops folding and is reclaimed instead of leaking onto a dead store.
104
+ return Layer.scopedDiscard(
105
+ Effect.gen(function* () {
106
+ const reducers = yield* Reducers
107
+ if (reducers.entries.some((e) => e.name === name)) {
108
+ yield* Effect.logWarning(`reform: duplicate reducer name '${name}' registered`)
109
+ }
110
+ const entry: ReducerEntry = yield* Effect.gen(function* () {
111
+ if ('family' in config) {
112
+ const family = yield* config.family.store
113
+ return {
114
+ name,
115
+ handles,
116
+ apply: (event: Tagged) => {
117
+ // The loop only invokes `apply` for events in `handles`, so `event`
118
+ // is one of this reducer's declared events; `narrowHandled` restores
119
+ // that typed view from the erased `Tagged` envelope `keyOf` is fed.
120
+ const key = config.keyOf(narrowHandled(event))
121
+ const next = fold(family.at(key).get(), event)
122
+ // A fold may return the eviction sentinel to drop the key's store
123
+ // (bounding an otherwise-unbounded family) instead of a new value.
124
+ if (next === Tombstone) family.forget(key)
125
+ else family.at(key).set(sync(next))
126
+ },
127
+ }
128
+ }
129
+ const store = yield* config.states[0].store
130
+ return { name, handles, apply: (event: Tagged) => store.set(sync(fold(store.get(), event))) }
131
+ })
132
+ yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
133
+ Effect.sync(() => reducers.unregister(entry)),
134
+ )
135
+ }),
136
+ )
137
+ }