@playfast/reform 0.0.7 → 0.0.9

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 (241) 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 +146 -0
  15. package/src/compose/host.ts +12 -0
  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/structure.test.ts +90 -0
  20. package/src/compose/structure.ts +145 -0
  21. package/src/compose/ui.test.ts +34 -0
  22. package/src/compose/ui.ts +204 -0
  23. package/src/compose/ui.typecheck.ts +56 -0
  24. package/src/definition/definition.ts +76 -0
  25. package/src/event/event.test.ts +23 -0
  26. package/src/event/event.ts +65 -0
  27. package/src/event/eventGroup.ts +14 -0
  28. package/src/feature/feature.mount.test.ts +82 -0
  29. package/src/feature/feature.test.ts +60 -0
  30. package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  31. package/src/feature/feature.typecheck.ts +108 -0
  32. package/src/index.ts +191 -0
  33. package/src/internal/capture.ts +38 -0
  34. package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  35. package/src/internal/errors.test.ts +33 -0
  36. package/src/internal/errors.ts +133 -0
  37. package/src/internal/inspect.test.ts +28 -0
  38. package/src/internal/inspect.ts +34 -0
  39. package/src/internal/queryDriver.ts +247 -0
  40. package/src/internal/reuse.test.ts +116 -0
  41. package/src/internal/reuse.ts +73 -0
  42. package/src/internal/scheduler.ts +93 -0
  43. package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  44. package/src/internal/sources.ts +104 -0
  45. package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
  46. package/src/internal/store.test.ts +80 -0
  47. package/src/internal/store.ts +105 -0
  48. package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
  49. package/src/procedure/procedure.ts +89 -0
  50. package/src/reducer/reducer.ts +137 -0
  51. package/src/remote/remoteState.test.ts +695 -0
  52. package/src/remote/remoteState.ts +571 -0
  53. package/src/remote/remoteState.typecheck.ts +195 -0
  54. package/src/runtime/bus.ts +40 -0
  55. package/src/runtime/hardening.test.ts +69 -0
  56. package/src/runtime/loop.test.ts +178 -0
  57. package/src/runtime/loop.ts +171 -0
  58. package/src/scene/scene.ts +88 -0
  59. package/src/scene/seedScene.test.ts +176 -0
  60. package/src/state/state.ts +94 -0
  61. package/src/state/stateFamily.test.ts +138 -0
  62. package/src/state/stateFamily.ts +174 -0
  63. package/src/state/stateGroup.ts +125 -0
  64. package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
  65. package/src/synced/syncedStore.ts +99 -0
  66. package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
  67. package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  68. package/src/wire/tree.test.ts +81 -0
  69. package/src/wire/tree.ts +129 -0
  70. package/src/wire/triggers.test.ts +76 -0
  71. package/src/wire/triggers.ts +98 -0
  72. package/dist/boundary/boundary.d.ts +0 -72
  73. package/dist/boundary/boundary.d.ts.map +0 -1
  74. package/dist/boundary/boundary.js +0 -83
  75. package/dist/boundary/boundary.js.map +0 -1
  76. package/dist/calc/asyncCalc.d.ts +0 -91
  77. package/dist/calc/asyncCalc.d.ts.map +0 -1
  78. package/dist/calc/asyncCalc.js +0 -95
  79. package/dist/calc/asyncCalc.js.map +0 -1
  80. package/dist/calc/asyncData.d.ts +0 -57
  81. package/dist/calc/asyncData.d.ts.map +0 -1
  82. package/dist/calc/asyncData.js +0 -34
  83. package/dist/calc/asyncData.js.map +0 -1
  84. package/dist/calc/calc.d.ts +0 -57
  85. package/dist/calc/calc.d.ts.map +0 -1
  86. package/dist/calc/calc.js +0 -58
  87. package/dist/calc/calc.js.map +0 -1
  88. package/dist/calc/calcFamily.d.ts +0 -57
  89. package/dist/calc/calcFamily.d.ts.map +0 -1
  90. package/dist/calc/calcFamily.js +0 -124
  91. package/dist/calc/calcFamily.js.map +0 -1
  92. package/dist/calc/compose.d.ts +0 -16
  93. package/dist/calc/compose.d.ts.map +0 -1
  94. package/dist/calc/compose.js +0 -7
  95. package/dist/calc/compose.js.map +0 -1
  96. package/dist/channel/channel.d.ts +0 -115
  97. package/dist/channel/channel.d.ts.map +0 -1
  98. package/dist/channel/channel.js +0 -138
  99. package/dist/channel/channel.js.map +0 -1
  100. package/dist/compose/composition.d.ts +0 -72
  101. package/dist/compose/composition.d.ts.map +0 -1
  102. package/dist/compose/composition.js +0 -46
  103. package/dist/compose/composition.js.map +0 -1
  104. package/dist/compose/host.d.ts +0 -17
  105. package/dist/compose/host.d.ts.map +0 -1
  106. package/dist/compose/host.js +0 -6
  107. package/dist/compose/host.js.map +0 -1
  108. package/dist/compose/props.d.ts +0 -13
  109. package/dist/compose/props.d.ts.map +0 -1
  110. package/dist/compose/props.js.map +0 -1
  111. package/dist/compose/provide.d.ts +0 -22
  112. package/dist/compose/provide.d.ts.map +0 -1
  113. package/dist/compose/provide.js +0 -28
  114. package/dist/compose/provide.js.map +0 -1
  115. package/dist/compose/slot.d.ts.map +0 -1
  116. package/dist/compose/slot.js +0 -23
  117. package/dist/compose/slot.js.map +0 -1
  118. package/dist/compose/ui.d.ts +0 -135
  119. package/dist/compose/ui.d.ts.map +0 -1
  120. package/dist/compose/ui.js +0 -63
  121. package/dist/compose/ui.js.map +0 -1
  122. package/dist/definition/definition.d.ts +0 -33
  123. package/dist/definition/definition.d.ts.map +0 -1
  124. package/dist/definition/definition.js +0 -42
  125. package/dist/definition/definition.js.map +0 -1
  126. package/dist/event/event.d.ts +0 -33
  127. package/dist/event/event.d.ts.map +0 -1
  128. package/dist/event/event.js +0 -30
  129. package/dist/event/event.js.map +0 -1
  130. package/dist/event/eventGroup.d.ts +0 -9
  131. package/dist/event/eventGroup.d.ts.map +0 -1
  132. package/dist/event/eventGroup.js +0 -4
  133. package/dist/event/eventGroup.js.map +0 -1
  134. package/dist/feature/feature.d.ts.map +0 -1
  135. package/dist/feature/feature.js +0 -98
  136. package/dist/feature/feature.js.map +0 -1
  137. package/dist/index.d.ts +0 -53
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js +0 -62
  140. package/dist/index.js.map +0 -1
  141. package/dist/internal/capture.d.ts +0 -28
  142. package/dist/internal/capture.d.ts.map +0 -1
  143. package/dist/internal/capture.js +0 -12
  144. package/dist/internal/capture.js.map +0 -1
  145. package/dist/internal/ctx.d.ts.map +0 -1
  146. package/dist/internal/ctx.js +0 -2
  147. package/dist/internal/ctx.js.map +0 -1
  148. package/dist/internal/errors.d.ts +0 -89
  149. package/dist/internal/errors.d.ts.map +0 -1
  150. package/dist/internal/errors.js +0 -102
  151. package/dist/internal/errors.js.map +0 -1
  152. package/dist/internal/inspect.d.ts +0 -17
  153. package/dist/internal/inspect.d.ts.map +0 -1
  154. package/dist/internal/inspect.js +0 -32
  155. package/dist/internal/inspect.js.map +0 -1
  156. package/dist/internal/queryDriver.d.ts +0 -65
  157. package/dist/internal/queryDriver.d.ts.map +0 -1
  158. package/dist/internal/queryDriver.js +0 -134
  159. package/dist/internal/queryDriver.js.map +0 -1
  160. package/dist/internal/reuse.d.ts +0 -10
  161. package/dist/internal/reuse.d.ts.map +0 -1
  162. package/dist/internal/reuse.js +0 -68
  163. package/dist/internal/reuse.js.map +0 -1
  164. package/dist/internal/scheduler.d.ts +0 -47
  165. package/dist/internal/scheduler.d.ts.map +0 -1
  166. package/dist/internal/scheduler.js +0 -70
  167. package/dist/internal/scheduler.js.map +0 -1
  168. package/dist/internal/seeds.d.ts.map +0 -1
  169. package/dist/internal/seeds.js +0 -17
  170. package/dist/internal/seeds.js.map +0 -1
  171. package/dist/internal/sources.d.ts +0 -39
  172. package/dist/internal/sources.d.ts.map +0 -1
  173. package/dist/internal/sources.js +0 -59
  174. package/dist/internal/sources.js.map +0 -1
  175. package/dist/internal/stateRegistry.d.ts +0 -2
  176. package/dist/internal/stateRegistry.d.ts.map +0 -1
  177. package/dist/internal/stateRegistry.js.map +0 -1
  178. package/dist/internal/store.d.ts +0 -47
  179. package/dist/internal/store.d.ts.map +0 -1
  180. package/dist/internal/store.js +0 -73
  181. package/dist/internal/store.js.map +0 -1
  182. package/dist/internal/track.d.ts.map +0 -1
  183. package/dist/internal/track.js +0 -19
  184. package/dist/internal/track.js.map +0 -1
  185. package/dist/procedure/procedure.d.ts +0 -40
  186. package/dist/procedure/procedure.d.ts.map +0 -1
  187. package/dist/procedure/procedure.js +0 -50
  188. package/dist/procedure/procedure.js.map +0 -1
  189. package/dist/reducer/reducer.d.ts +0 -44
  190. package/dist/reducer/reducer.d.ts.map +0 -1
  191. package/dist/reducer/reducer.js +0 -63
  192. package/dist/reducer/reducer.js.map +0 -1
  193. package/dist/remote/remoteState.d.ts +0 -119
  194. package/dist/remote/remoteState.d.ts.map +0 -1
  195. package/dist/remote/remoteState.js +0 -270
  196. package/dist/remote/remoteState.js.map +0 -1
  197. package/dist/runtime/bus.d.ts +0 -27
  198. package/dist/runtime/bus.d.ts.map +0 -1
  199. package/dist/runtime/bus.js +0 -21
  200. package/dist/runtime/bus.js.map +0 -1
  201. package/dist/runtime/loop.d.ts +0 -45
  202. package/dist/runtime/loop.d.ts.map +0 -1
  203. package/dist/runtime/loop.js +0 -117
  204. package/dist/runtime/loop.js.map +0 -1
  205. package/dist/scene/scene.d.ts +0 -44
  206. package/dist/scene/scene.d.ts.map +0 -1
  207. package/dist/scene/scene.js +0 -31
  208. package/dist/scene/scene.js.map +0 -1
  209. package/dist/state/state.d.ts +0 -37
  210. package/dist/state/state.d.ts.map +0 -1
  211. package/dist/state/state.js +0 -46
  212. package/dist/state/state.js.map +0 -1
  213. package/dist/state/stateFamily.d.ts +0 -79
  214. package/dist/state/stateFamily.d.ts.map +0 -1
  215. package/dist/state/stateFamily.js +0 -99
  216. package/dist/state/stateFamily.js.map +0 -1
  217. package/dist/state/stateGroup.d.ts +0 -50
  218. package/dist/state/stateGroup.d.ts.map +0 -1
  219. package/dist/state/stateGroup.js +0 -54
  220. package/dist/state/stateGroup.js.map +0 -1
  221. package/dist/state/token.d.ts.map +0 -1
  222. package/dist/state/token.js +0 -20
  223. package/dist/state/token.js.map +0 -1
  224. package/dist/synced/syncedStore.d.ts +0 -51
  225. package/dist/synced/syncedStore.d.ts.map +0 -1
  226. package/dist/synced/syncedStore.js +0 -43
  227. package/dist/synced/syncedStore.js.map +0 -1
  228. package/dist/ui/node.d.ts.map +0 -1
  229. package/dist/ui/node.js +0 -2
  230. package/dist/ui/node.js.map +0 -1
  231. package/dist/ui/trigger.d.ts.map +0 -1
  232. package/dist/ui/trigger.js +0 -2
  233. package/dist/ui/trigger.js.map +0 -1
  234. package/dist/wire/tree.d.ts +0 -66
  235. package/dist/wire/tree.d.ts.map +0 -1
  236. package/dist/wire/tree.js +0 -60
  237. package/dist/wire/tree.js.map +0 -1
  238. package/dist/wire/triggers.d.ts +0 -44
  239. package/dist/wire/triggers.d.ts.map +0 -1
  240. package/dist/wire/triggers.js +0 -31
  241. package/dist/wire/triggers.js.map +0 -1
@@ -0,0 +1,120 @@
1
+ import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import {
4
+ type InputsObject,
5
+ type InputStores,
6
+ type InvalidateBy,
7
+ sameKey,
8
+ wireSources,
9
+ } from '../internal/sources'
10
+ import { resolveScheduler } from '../internal/scheduler'
11
+ import { reuse } from '../internal/reuse'
12
+ import { makeDerivedStore, type Store } from '../internal/store'
13
+ import { readTracked } from '../internal/track'
14
+ import { type AnySource } from '../state/token'
15
+
16
+ // Re-exported so `Calc.InputsObject` / `Calc.InvalidateBy` keep their home here
17
+ // even though the reactive plumbing now lives in `internal/sources`.
18
+ export { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
19
+
20
+ export interface CalcOptions<Inputs extends ReadonlyArray<AnySource>> {
21
+ /**
22
+ * Custom invalidation: recompute only when the projected key changes by value
23
+ * equality (React-Query's `queryKey`). Omitted ⇒ recompute when any input
24
+ * value changes. Shared with `AsyncCalc`.
25
+ */
26
+ readonly invalidateBy?: InvalidateBy<Inputs>
27
+ /**
28
+ * Structural sharing: reconcile each recomputed output against the previous
29
+ * one, substituting previous nodes wherever value equality holds — so
30
+ * identities only move where values moved and downstream memo boundaries
31
+ * (React subtrees) skip unchanged regions. Referentially transparent (the
32
+ * result is value-equal to the fresh output); costs one O(output) walk per
33
+ * recompute, so opt in for large projections.
34
+ */
35
+ readonly reuse?: boolean
36
+ }
37
+
38
+ export interface CalcManifest<N extends string, Out> extends Manifest {
39
+ readonly kind: 'Calc'
40
+ readonly name: N
41
+ readonly output: Schema.Schema<Out, any>
42
+ }
43
+
44
+ export interface CalcClass<out N extends string, out Inputs extends ReadonlyArray<AnySource>, in out Out>
45
+ extends Effect.Effect<Out, never, Store<Out>> {
46
+ new (): {}
47
+ readonly manifest: CalcManifest<N, Out>
48
+ readonly store: Context.Tag<Store<Out>, Store<Out>>
49
+ /** The calc's name, so it doubles as a `Source` input to another calc. */
50
+ readonly name: N
51
+ /** Marker data read by `Calc.live`. */
52
+ readonly inputs: Inputs
53
+ }
54
+
55
+ export interface CalcConfig<Inputs extends ReadonlyArray<AnySource>, Out> {
56
+ readonly inputs: Inputs
57
+ readonly output: Schema.Schema<Out, any>
58
+ }
59
+
60
+ /**
61
+ * A derived, memoized value over explicit inputs (state members, other calcs, or
62
+ * async calcs). The *definition* fixes the inputs and the output schema; the
63
+ * *implementation* is the pure projection. Recomputes only when an input changes;
64
+ * `yield* Calc` reads the memoized value.
65
+ */
66
+ export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, Out>(
67
+ name: N,
68
+ config: CalcConfig<Inputs, Out>,
69
+ ): CalcClass<N, Inputs, Out> => {
70
+ const store = Context.GenericTag<Store<Out>, Store<Out>>(`reform/calc/${name}`)
71
+ const manifest: CalcManifest<N, Out> = { kind: 'Calc', name, output: config.output }
72
+ const read = Effect.flatMap(store, readTracked)
73
+ return yieldableClass(read, { manifest, store, name, inputs: config.inputs })
74
+ }
75
+
76
+ /**
77
+ * Wire the pure projection for a calc. Recomputes only when its invalidation key
78
+ * changes (by default every input value; override with `invalidateBy`), memoized
79
+ * on that key; `yield* Calc` reads the memoized value. The derived store reuses
80
+ * the shared coalescing scheduler, so a recompute that yields an equal value
81
+ * wakes no subscriber.
82
+ */
83
+ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, Out>(
84
+ calc: CalcClass<N, Inputs, Out>,
85
+ compute: (inputs: InputsObject<Inputs>) => Out,
86
+ options: CalcOptions<Inputs> = {},
87
+ ): Layer.Layer<Store<Out>, never, InputStores<Inputs>> =>
88
+ // Scoped so the source subscriptions are released when the layer's scope
89
+ // closes (each proof/test builds and disposes its own runtime).
90
+ Layer.scoped(
91
+ calc.store,
92
+ Effect.gen(function* () {
93
+ const scheduler = yield* resolveScheduler
94
+ const sources = yield* wireSources(calc.inputs, options.invalidateBy)
95
+
96
+ // Memo cell: key and output captured together so reads are always
97
+ // consistent (no separate output that could lag the key it was computed for).
98
+ const memo = MutableRef.make<
99
+ { readonly key: ReadonlyArray<unknown>; readonly output: Out } | undefined
100
+ >(undefined)
101
+ const recompute = (): Out => {
102
+ const args = sources.snapshot()
103
+ const key = sources.keyOf(args)
104
+ const prev = MutableRef.get(memo)
105
+ if (prev !== undefined && sameKey(key, prev.key)) return prev.output
106
+ const fresh = compute(args)
107
+ // With `reuse`, a recompute that lands value-equal to the previous
108
+ // output returns the previous reference — the derived store's Equal
109
+ // gate then wakes no subscriber at all.
110
+ const output =
111
+ options.reuse === true && prev !== undefined ? reuse(prev.output, fresh) : fresh
112
+ MutableRef.set(memo, { key, output })
113
+ return output
114
+ }
115
+
116
+ const derived = makeDerivedStore(recompute, sources.subscribe, scheduler)
117
+ yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
118
+ return derived.store
119
+ }),
120
+ )
@@ -0,0 +1,206 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Context, Duration, Effect, Exit, Layer, Schema as S, Scope } from 'effect'
3
+ import { CalcFamily, State, StateGroup } from '../index'
4
+
5
+ // CalcFamily: per-key derived stores over shared inputs. The behaviours under
6
+ // test: members derive + memoize independently, a member only notifies when ITS
7
+ // output moves, and every removal path releases the member's upstream
8
+ // subscription (a derived member holds one; a StateFamily entry doesn't).
9
+
10
+ const tick = Effect.sleep(Duration.millis(1))
11
+
12
+ // Shared fixture shape: a record of named counters; each member projects one.
13
+ const Board = S.Record({ key: S.String, value: S.Number })
14
+
15
+ const fixture = () => {
16
+ class Feed extends State.make('feed', Board) {}
17
+ class Inputs extends StateGroup.make(Feed) {}
18
+ const runs = new Map<string, number>()
19
+ class Slice extends CalcFamily.make('Slice', {
20
+ key: S.String,
21
+ inputs: [StateGroup.select(Inputs, 'feed')],
22
+ output: S.Number,
23
+ }) {}
24
+ const SliceLive = CalcFamily.live(Slice, (key) => ({ feed }) => {
25
+ runs.set(key, (runs.get(key) ?? 0) + 1)
26
+ return feed[key] ?? 0
27
+ })
28
+ return { Inputs, Slice, SliceLive, runs }
29
+ }
30
+
31
+ it.live('a member derives from the shared inputs and recomputes when they change', () => {
32
+ const { Inputs, Slice, SliceLive } = fixture()
33
+ const TestLayer = SliceLive.pipe(
34
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1, b: 10 } })),
35
+ )
36
+ return Effect.gen(function* () {
37
+ const feed = yield* StateGroup.select(Inputs, 'feed').store
38
+ expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
39
+ expect(yield* CalcFamily.read(Slice, 'b')).toBe(10)
40
+
41
+ feed.set({ a: 2, b: 10 })
42
+ yield* tick
43
+ expect(yield* CalcFamily.read(Slice, 'a')).toBe(2)
44
+ }).pipe(Effect.provide(TestLayer))
45
+ })
46
+
47
+ it.live('a member only notifies when its own slice of the projection moves', () => {
48
+ const { Inputs, Slice, SliceLive } = fixture()
49
+ const TestLayer = SliceLive.pipe(
50
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1, b: 10 } })),
51
+ )
52
+ return Effect.gen(function* () {
53
+ const feed = yield* StateGroup.select(Inputs, 'feed').store
54
+ const family = yield* Slice.store
55
+ const wakes = { a: 0, b: 0 }
56
+ family.at('a').subscribe(() => {
57
+ wakes.a += 1
58
+ })
59
+ family.at('b').subscribe(() => {
60
+ wakes.b += 1
61
+ })
62
+
63
+ // Only `a` moves: b's member recomputes but its output is Equal — silent.
64
+ feed.set({ a: 2, b: 10 })
65
+ yield* tick
66
+ expect(wakes).toEqual({ a: 1, b: 0 })
67
+
68
+ feed.set({ a: 2, b: 11 })
69
+ yield* tick
70
+ expect(wakes).toEqual({ a: 1, b: 1 })
71
+ }).pipe(Effect.provide(TestLayer))
72
+ })
73
+
74
+ it.live('reading does not recompute while the shared inputs are unchanged (memoized per member)', () => {
75
+ const { Inputs, Slice, SliceLive, runs } = fixture()
76
+ const TestLayer = SliceLive.pipe(
77
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
78
+ )
79
+ return Effect.gen(function* () {
80
+ yield* CalcFamily.read(Slice, 'a')
81
+ yield* CalcFamily.read(Slice, 'a')
82
+ yield* CalcFamily.read(Slice, 'a')
83
+ expect(runs.get('a')).toBe(1)
84
+ }).pipe(Effect.provide(TestLayer))
85
+ })
86
+
87
+ it.live('invalidateBy: shared-input churn outside the key recomputes no member', () => {
88
+ class Feed extends State.make('feed', Board) {}
89
+ class Noise extends State.make('noise', S.Number) {}
90
+ class Inputs extends StateGroup.make(Feed, Noise) {}
91
+ const runs = new Map<string, number>()
92
+ class Slice extends CalcFamily.make('Slice', {
93
+ key: S.String,
94
+ inputs: [StateGroup.select(Inputs, 'feed'), StateGroup.select(Inputs, 'noise')],
95
+ output: S.Number,
96
+ }) {}
97
+ const SliceLive = CalcFamily.live(
98
+ Slice,
99
+ (key) => ({ feed }) => {
100
+ runs.set(key, (runs.get(key) ?? 0) + 1)
101
+ return feed[key] ?? 0
102
+ },
103
+ { invalidateBy: ({ feed }) => [feed] },
104
+ )
105
+ const TestLayer = SliceLive.pipe(
106
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 }, noise: 0 })),
107
+ )
108
+ return Effect.gen(function* () {
109
+ const noise = yield* StateGroup.select(Inputs, 'noise').store
110
+ expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
111
+ expect(runs.get('a')).toBe(1)
112
+
113
+ noise.set(99)
114
+ yield* tick
115
+ expect(yield* CalcFamily.read(Slice, 'a')).toBe(1)
116
+ expect(runs.get('a')).toBe(1)
117
+ }).pipe(Effect.provide(TestLayer))
118
+ })
119
+
120
+ it.live('forget releases the member and its upstream subscription', () => {
121
+ const { Inputs, Slice, SliceLive, runs } = fixture()
122
+ const TestLayer = SliceLive.pipe(
123
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
124
+ )
125
+ return Effect.gen(function* () {
126
+ const feed = yield* StateGroup.select(Inputs, 'feed').store
127
+ const family = yield* Slice.store
128
+ family.at('a')
129
+ expect(family.size()).toBe(1)
130
+
131
+ family.forget('a')
132
+ expect(family.size()).toBe(0)
133
+ // The dropped member no longer recomputes on upstream churn.
134
+ const before = runs.get('a') ?? 0
135
+ feed.set({ a: 2 })
136
+ yield* tick
137
+ expect(runs.get('a') ?? 0).toBe(before)
138
+
139
+ // Re-`at`-ing allocates a fresh member over the current snapshot.
140
+ expect(family.at('a').get()).toBe(2)
141
+ }).pipe(Effect.provide(TestLayer))
142
+ })
143
+
144
+ it.live('evictWhenUnused: an idle member is dropped on the next microtask and unsubscribes', () => {
145
+ class Feed extends State.make('feed', Board) {}
146
+ class Inputs extends StateGroup.make(Feed) {}
147
+ const runs = new Map<string, number>()
148
+ class Slice extends CalcFamily.make('Slice', {
149
+ key: S.String,
150
+ inputs: [StateGroup.select(Inputs, 'feed')],
151
+ output: S.Number,
152
+ }) {}
153
+ const SliceLive = CalcFamily.live(
154
+ Slice,
155
+ (key) => ({ feed }) => {
156
+ runs.set(key, (runs.get(key) ?? 0) + 1)
157
+ return feed[key] ?? 0
158
+ },
159
+ { evictWhenUnused: true },
160
+ )
161
+ const TestLayer = SliceLive.pipe(
162
+ Layer.provideMerge(StateGroup.live(Inputs, { feed: { a: 1 } })),
163
+ )
164
+ return Effect.gen(function* () {
165
+ const feed = yield* StateGroup.select(Inputs, 'feed').store
166
+ const family = yield* Slice.store
167
+ const off = family.at('a').subscribe(() => {})
168
+ expect(family.size()).toBe(1)
169
+
170
+ off()
171
+ yield* tick
172
+ expect(family.size()).toBe(0)
173
+ const before = runs.get('a') ?? 0
174
+ feed.set({ a: 2 })
175
+ yield* tick
176
+ expect(runs.get('a') ?? 0).toBe(before)
177
+ }).pipe(Effect.provide(TestLayer))
178
+ })
179
+
180
+ it.live('the layer finalizer clears every member (no live subscriptions leak)', () => {
181
+ const { Inputs, Slice, SliceLive, runs } = fixture()
182
+ return Effect.scoped(
183
+ Effect.gen(function* () {
184
+ // Build the input stores once, then the family in its OWN scope over that
185
+ // exact context — so the stores outlive the family and we can prove the
186
+ // members' upstream subscriptions died with it.
187
+ const storesCtx = yield* Layer.build(StateGroup.live(Inputs, { feed: { a: 1, b: 2 } }))
188
+ const feed = Context.get(storesCtx, StateGroup.select(Inputs, 'feed').store)
189
+ const scope = yield* Scope.make()
190
+ const familyCtx = yield* Layer.buildWithScope(SliceLive, scope).pipe(
191
+ Effect.provide(storesCtx),
192
+ )
193
+ const family = Context.get(familyCtx, Slice.store)
194
+ family.at('a')
195
+ family.at('b')
196
+ expect(family.size()).toBe(2)
197
+
198
+ yield* Scope.close(scope, Exit.void)
199
+ expect(family.size()).toBe(0)
200
+ const before = [runs.get('a') ?? 0, runs.get('b') ?? 0]
201
+ feed.set({ a: 9, b: 9 })
202
+ yield* tick
203
+ expect([runs.get('a') ?? 0, runs.get('b') ?? 0]).toEqual(before)
204
+ }),
205
+ )
206
+ })
@@ -0,0 +1,194 @@
1
+ import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
2
+ import { type Manifest, definitionClass } from '../definition/definition'
3
+ import {
4
+ type InputsObject,
5
+ type InputStores,
6
+ type InvalidateBy,
7
+ sameKey,
8
+ wireSources,
9
+ } from '../internal/sources'
10
+ import { resolveScheduler } from '../internal/scheduler'
11
+ import { makeDerivedStore, type Store } from '../internal/store'
12
+ import { readTracked } from '../internal/track'
13
+ import { type FamilyOptions, type FamilyStore } from '../state/stateFamily'
14
+ import { type AnySource } from '../state/token'
15
+
16
+ // `CalcFamily` is `Calc`'s keyed sibling, modeled on `StateFamily`: one derived,
17
+ // memoized store per key over a SHARED set of inputs. Where a monolithic calc
18
+ // rebuilds its whole output on any change (waking every consumer), a family
19
+ // member recomputes lazily and notifies only when ITS slice of the projection
20
+ // moves (the derived store's Equal gate) — per-row/per-group UI subscribes per
21
+ // key and unrelated changes never wake it. Members are pure projections; a
22
+ // Reducer cannot target a CalcFamily (its config requires a `StateFamily`
23
+ // manifest), so calc purity holds structurally.
24
+
25
+ export interface CalcFamilyManifest<N extends string, K, Out> extends Manifest {
26
+ readonly kind: 'CalcFamily'
27
+ readonly name: N
28
+ readonly key: Schema.Schema<K, any>
29
+ readonly output: Schema.Schema<Out, any>
30
+ }
31
+
32
+ export interface CalcFamilyClass<
33
+ out N extends string,
34
+ out Inputs extends ReadonlyArray<AnySource>,
35
+ in out K,
36
+ in out Out,
37
+ > {
38
+ /** Instance carries the key phantom so `Family['Key']` resolves in key types. */
39
+ new (): { readonly Key: K }
40
+ readonly manifest: CalcFamilyManifest<N, K, Out>
41
+ readonly store: Context.Tag<FamilyStore<K, Out>, FamilyStore<K, Out>>
42
+ /** Marker data read by `CalcFamily.live` and the editor's graph builder. */
43
+ readonly inputs: Inputs
44
+ }
45
+
46
+ export interface CalcFamilyConfig<Inputs extends ReadonlyArray<AnySource>, K, Out> {
47
+ readonly key: Schema.Schema<K, any>
48
+ readonly inputs: Inputs
49
+ readonly output: Schema.Schema<Out, any>
50
+ }
51
+
52
+ /**
53
+ * Define a keyed derived value: per-key memoized projections over shared
54
+ * inputs. The *definition* fixes the key/output schemas and the inputs; the
55
+ * *implementation* (the per-key projection) is supplied by `CalcFamily.live`.
56
+ */
57
+ export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, K, Out>(
58
+ name: N,
59
+ config: CalcFamilyConfig<Inputs, K, Out>,
60
+ ): CalcFamilyClass<N, Inputs, K, Out> => {
61
+ const store = Context.GenericTag<FamilyStore<K, Out>, FamilyStore<K, Out>>(
62
+ `reform/calcFamily/${name}`,
63
+ )
64
+ const manifest: CalcFamilyManifest<N, K, Out> = {
65
+ kind: 'CalcFamily',
66
+ name,
67
+ key: config.key,
68
+ output: config.output,
69
+ }
70
+ return definitionClass<CalcFamilyClass<N, Inputs, K, Out>>({
71
+ manifest,
72
+ store,
73
+ inputs: config.inputs,
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Read one member by key — `CalcFamily.read(GroupProjection, id)`. Inside a
79
+ * render it subscribes exactly that member's store (unrelated members never
80
+ * wake the subtree); elsewhere it just snapshots — the same one-read-API
81
+ * contract as `StateFamily.read`.
82
+ */
83
+ export const read = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
84
+ family: CalcFamilyClass<N, Inputs, K, Out>,
85
+ key: K,
86
+ ): Effect.Effect<Out, never, FamilyStore<K, Out>> =>
87
+ Effect.flatMap(family.store, (fs) => readTracked(fs.at(key)))
88
+
89
+ export interface CalcFamilyLiveOptions<Inputs extends ReadonlyArray<AnySource>>
90
+ extends FamilyOptions {
91
+ /**
92
+ * Family-wide invalidation key over the SHARED inputs (the member key plays
93
+ * no part — it is fixed per member). Same contract as `Calc`'s.
94
+ */
95
+ readonly invalidateBy?: InvalidateBy<Inputs>
96
+ }
97
+
98
+ /**
99
+ * Wire the per-key projection. `compute` is curried — `(key) => (inputs) =>
100
+ * Out` — so per-key construction (closing over key-derived constants) runs once
101
+ * per member while the inner projection runs per recompute. Each member is a
102
+ * lazy derived store over the shared wired sources: memoized on the family's
103
+ * invalidation key, notifying only when its own output moves. Unlike a
104
+ * `StateFamily` entry, a member holds an upstream subscription, so every
105
+ * removal path (forget / clear / eviction / the layer finalizer) releases it.
106
+ */
107
+ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
108
+ family: CalcFamilyClass<N, Inputs, K, Out>,
109
+ compute: (key: K) => (inputs: InputsObject<Inputs>) => Out,
110
+ options: CalcFamilyLiveOptions<Inputs> = {},
111
+ ): Layer.Layer<FamilyStore<K, Out>, never, InputStores<Inputs>> =>
112
+ Layer.scoped(
113
+ family.store,
114
+ Effect.gen(function* () {
115
+ const scheduler = yield* resolveScheduler
116
+ // Wired ONCE for the whole family — members share the sensing surface.
117
+ const sources = yield* wireSources(family.inputs, options.invalidateBy)
118
+ const entries = new Map<K, { readonly store: Store<Out>; readonly unsubscribe: () => void }>()
119
+ const evictWhenUnused = options.evictWhenUnused === true
120
+ // Live subscriber count per key — maintained only when eviction is on.
121
+ const subscribers = new Map<K, number>()
122
+
123
+ const dropEntry = (key: K): void => {
124
+ const entry = entries.get(key)
125
+ if (entry !== undefined) entry.unsubscribe()
126
+ entries.delete(key)
127
+ subscribers.delete(key)
128
+ }
129
+
130
+ // One member: the same memo block as `Calc.live`, with the key closed over.
131
+ const createMember = (key: K): { readonly store: Store<Out>; readonly unsubscribe: () => void } => {
132
+ const body = compute(key)
133
+ const memo = MutableRef.make<
134
+ { readonly key: ReadonlyArray<unknown>; readonly output: Out } | undefined
135
+ >(undefined)
136
+ const recompute = (): Out => {
137
+ const args = sources.snapshot()
138
+ const memoKey = sources.keyOf(args)
139
+ const prev = MutableRef.get(memo)
140
+ if (prev !== undefined && sameKey(memoKey, prev.key)) return prev.output
141
+ const output = body(args)
142
+ MutableRef.set(memo, { key: memoKey, output })
143
+ return output
144
+ }
145
+ return makeDerivedStore(recompute, sources.subscribe, scheduler)
146
+ }
147
+
148
+ // The `StateFamily` ref-count idiom: evict on the next microtask once a
149
+ // key falls idle (a same-commit re-subscribe cancels it) — here eviction
150
+ // also releases the member's upstream subscription via `dropEntry`.
151
+ const refCounted = (key: K, store: Store<Out>): Store<Out> => ({
152
+ ...store,
153
+ subscribe: (listener) => {
154
+ subscribers.set(key, (subscribers.get(key) ?? 0) + 1)
155
+ const off = store.subscribe(listener)
156
+ const released = { done: false }
157
+ return () => {
158
+ if (released.done) return
159
+ released.done = true
160
+ off()
161
+ const remaining = (subscribers.get(key) ?? 1) - 1
162
+ if (remaining > 0) {
163
+ subscribers.set(key, remaining)
164
+ return
165
+ }
166
+ subscribers.delete(key)
167
+ queueMicrotask(() => {
168
+ if ((subscribers.get(key) ?? 0) === 0) dropEntry(key)
169
+ })
170
+ }
171
+ },
172
+ })
173
+
174
+ const familyStore: FamilyStore<K, Out> = {
175
+ at: (key) => {
176
+ const existing = entries.get(key)
177
+ if (existing !== undefined) return existing.store
178
+ const member = createMember(key)
179
+ const created = evictWhenUnused
180
+ ? { store: refCounted(key, member.store), unsubscribe: member.unsubscribe }
181
+ : member
182
+ entries.set(key, created)
183
+ return created.store
184
+ },
185
+ forget: dropEntry,
186
+ clear: () => {
187
+ for (const key of [...entries.keys()]) dropEntry(key)
188
+ },
189
+ size: () => entries.size,
190
+ }
191
+ yield* Effect.addFinalizer(() => Effect.sync(() => familyStore.clear()))
192
+ return familyStore
193
+ }),
194
+ )
@@ -0,0 +1,68 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Layer, Schema as S } from 'effect'
3
+ import { Calc, composeCalcs, State, StateGroup } from '../index'
4
+
5
+ // `composeCalcs` wires a calc-on-calc chain in dependency order. The shape under
6
+ // test is the one that defeats a flat `Layer.mergeAll`: a linear chain where each
7
+ // calc's only input is the previous calc — count → Doubled → PlusOne → Tripled.
8
+
9
+ it.live('composeCalcs wires a linear calc chain; the result needs only the base state', () => {
10
+ class Count extends State.make('count', S.Number) {}
11
+ class Inputs extends StateGroup.make(Count) {}
12
+
13
+ class Doubled extends Calc.make('Doubled', {
14
+ inputs: [StateGroup.select(Inputs, 'count')],
15
+ output: S.Number,
16
+ }) {}
17
+ const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
18
+
19
+ class PlusOne extends Calc.make('PlusOne', { inputs: [Doubled], output: S.Number }) {}
20
+ const PlusOneLive = Calc.live(PlusOne, ({ Doubled }) => Doubled + 1)
21
+
22
+ class Tripled extends Calc.make('Tripled', { inputs: [PlusOne], output: S.Number }) {}
23
+ const TripledLive = Calc.live(Tripled, ({ PlusOne }) => PlusOne * 3)
24
+
25
+ // Leaf-first, then upstreams in dependency order. The composed layer's only
26
+ // remaining requirement is the base `count` store — which the type system
27
+ // proves by letting us provide *only* the state group below.
28
+ // The chain's only remaining requirement is the base `count` store — provide
29
+ // (and expose, via provideMerge) just the state group and everything builds.
30
+ const Chain = composeCalcs(TripledLive, PlusOneLive, DoubledLive)
31
+ const TestLayer = Chain.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
32
+
33
+ return Effect.gen(function* () {
34
+ const source = yield* StateGroup.select(Inputs, 'count').store
35
+ const tripled = yield* Tripled.store
36
+ // count 5 → Doubled 10 → PlusOne 11 → Tripled 33
37
+ expect(tripled.get()).toBe(33)
38
+
39
+ source.set(2)
40
+ yield* Effect.yieldNow()
41
+ // count 2 → Doubled 4 → PlusOne 5 → Tripled 15
42
+ expect(tripled.get()).toBe(15)
43
+ }).pipe(Effect.provide(TestLayer))
44
+ })
45
+
46
+ it.live('composeCalcs(leaf, upstream) matches the hand-written provideMerge chain', () => {
47
+ class Count extends State.make('count', S.Number) {}
48
+ class Inputs extends StateGroup.make(Count) {}
49
+ class Doubled extends Calc.make('Doubled', {
50
+ inputs: [StateGroup.select(Inputs, 'count')],
51
+ output: S.Number,
52
+ }) {}
53
+ const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
54
+ class PlusDoubled extends Calc.make('PlusDoubled', {
55
+ inputs: [StateGroup.select(Inputs, 'count'), Doubled],
56
+ output: S.Number,
57
+ }) {}
58
+ const PlusDoubledLive = Calc.live(PlusDoubled, ({ count, Doubled }) => count + Doubled)
59
+
60
+ const TestLayer = composeCalcs(PlusDoubledLive, DoubledLive).pipe(
61
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
62
+ )
63
+
64
+ return Effect.gen(function* () {
65
+ const derived = yield* PlusDoubled.store
66
+ expect(derived.get()).toBe(15) // 5 + (5*2), same as the manual-pipe test
67
+ }).pipe(Effect.provide(TestLayer))
68
+ })
@@ -0,0 +1,69 @@
1
+ import { Layer } from 'effect'
2
+
3
+ // Wiring a calc-on-calc chain (`FilteredAgents` ← `AllAgents` ← `AgentsQuery`)
4
+ // is the one calc-graph shape that trips people up: each upstream calc's store
5
+ // must be *provided into* the downstream live, in dependency order. A flat
6
+ // `Layer.mergeAll(FilteredLive, AllLive, QueryLive)` does NOT do this — `mergeAll`
7
+ // merges outputs but never provides members to one another, so the upstream
8
+ // calc stores stay as unmet `Store<…>` requirements (the TS2322 the migration
9
+ // hit). The correct shape is `leaf.pipe(provideMerge(u1), provideMerge(u2), …)`;
10
+ // `composeCalcs` is that fold under one name so the right thing is the easy thing.
11
+
12
+ /**
13
+ * Compose a calc-dependency chain: provide each upstream calc's `.live` layer
14
+ * into the downstream one, nearest dependency first. The result exposes every
15
+ * calc store in the chain and requires only the chain's *external* inputs (the
16
+ * base state stores) — never the intermediate calc stores.
17
+ *
18
+ * Order is leaf-first, then its upstreams in dependency order:
19
+ * `composeCalcs(FilteredLive, AllLive, QueryLive)` ≡
20
+ * `FilteredLive.pipe(Layer.provideMerge(AllLive), Layer.provideMerge(QueryLive))`.
21
+ * For chains deeper than three upstreams, nest `composeCalcs`.
22
+ */
23
+ export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn>(
24
+ leaf: Layer.Layer<LOut, LErr, LIn>,
25
+ upstream: Layer.Layer<AOut, AErr, AIn>,
26
+ ): Layer.Layer<LOut | AOut, LErr | AErr, AIn | Exclude<LIn, AOut>>
27
+ export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn, BOut, BErr, BIn>(
28
+ leaf: Layer.Layer<LOut, LErr, LIn>,
29
+ upstream1: Layer.Layer<AOut, AErr, AIn>,
30
+ upstream2: Layer.Layer<BOut, BErr, BIn>,
31
+ ): Layer.Layer<
32
+ LOut | AOut | BOut,
33
+ LErr | AErr | BErr,
34
+ BIn | Exclude<AIn | Exclude<LIn, AOut>, BOut>
35
+ >
36
+ export function composeCalcs<
37
+ LOut,
38
+ LErr,
39
+ LIn,
40
+ AOut,
41
+ AErr,
42
+ AIn,
43
+ BOut,
44
+ BErr,
45
+ BIn,
46
+ COut,
47
+ CErr,
48
+ CIn,
49
+ >(
50
+ leaf: Layer.Layer<LOut, LErr, LIn>,
51
+ upstream1: Layer.Layer<AOut, AErr, AIn>,
52
+ upstream2: Layer.Layer<BOut, BErr, BIn>,
53
+ upstream3: Layer.Layer<COut, CErr, CIn>,
54
+ ): Layer.Layer<
55
+ LOut | AOut | BOut | COut,
56
+ LErr | AErr | BErr | CErr,
57
+ CIn | Exclude<BIn | Exclude<AIn | Exclude<LIn, AOut>, BOut>, COut>
58
+ >
59
+ export function composeCalcs(
60
+ leaf: Layer.Layer<unknown, unknown, unknown>,
61
+ ...upstream: ReadonlyArray<Layer.Layer<unknown, unknown, unknown>>
62
+ ): Layer.Layer<unknown, unknown, unknown> {
63
+ // Fold leaf-first: each upstream is provided into the accumulated downstream,
64
+ // so dependency order is exactly the argument order.
65
+ return upstream.reduce<Layer.Layer<unknown, unknown, unknown>>(
66
+ (downstream, up) => Layer.provideMerge(downstream, up),
67
+ leaf,
68
+ )
69
+ }