@playfast/reform 0.0.5 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +31 -10
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/calc/asyncCalc.test.ts +556 -0
  4. package/src/calc/asyncData.ts +9 -1
  5. package/src/calc/calc.test.ts +287 -0
  6. package/src/calc/calcFamily.test.ts +206 -0
  7. package/src/calc/compose.test.ts +68 -0
  8. package/src/channel/channel.ts +9 -5
  9. package/src/compose/host.ts +4 -1
  10. package/src/compose/props.ts +5 -1
  11. package/src/compose/ui.test.ts +62 -0
  12. package/src/compose/ui.ts +173 -44
  13. package/src/compose/ui.typecheck.ts +56 -0
  14. package/src/definition/definition.ts +2 -0
  15. package/src/event/event.test.ts +23 -0
  16. package/src/feature/feature.mount.test.ts +82 -0
  17. package/src/feature/feature.test.ts +60 -0
  18. package/src/feature/feature.typecheck.ts +108 -0
  19. package/src/index.ts +38 -2
  20. package/src/internal/capture.ts +4 -4
  21. package/src/internal/errors.test.ts +33 -0
  22. package/src/internal/errors.ts +92 -16
  23. package/src/internal/inspect.test.ts +28 -0
  24. package/src/internal/queryDriver.ts +1 -1
  25. package/src/internal/reuse.test.ts +116 -0
  26. package/src/internal/scheduler.ts +3 -1
  27. package/src/internal/sources.ts +2 -2
  28. package/src/internal/stateRegistry.ts +32 -0
  29. package/src/internal/store.test.ts +80 -0
  30. package/src/internal/track.ts +3 -1
  31. package/src/remote/remoteState.test.ts +695 -0
  32. package/src/remote/remoteState.typecheck.ts +195 -0
  33. package/src/runtime/bus.ts +6 -2
  34. package/src/runtime/hardening.test.ts +69 -0
  35. package/src/runtime/loop.test.ts +178 -0
  36. package/src/runtime/loop.ts +4 -2
  37. package/src/scene/seedScene.test.ts +169 -0
  38. package/src/state/state.ts +4 -1
  39. package/src/state/stateFamily.test.ts +138 -0
  40. package/src/state/stateFamily.ts +4 -1
  41. package/src/state/stateGroup.ts +31 -4
  42. package/src/state/token.ts +5 -4
  43. package/src/synced/syncedStore.ts +99 -0
  44. package/src/wire/tree.test.ts +81 -0
  45. package/src/wire/tree.ts +129 -0
  46. package/src/wire/triggers.test.ts +76 -0
  47. package/src/wire/triggers.ts +98 -0
@@ -0,0 +1,287 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
3
+ import { Calc, State, StateGroup } from '../index'
4
+
5
+ // The calc store memoizes on input identity and — the behaviour under test —
6
+ // only wakes its subscribers when a source change actually moves the output.
7
+
8
+ const tick = Effect.sleep(Duration.millis(1))
9
+
10
+ it.live('a source change that does not move the output notifies no subscriber', () => {
11
+ class Count extends State.make('count', S.Number) {}
12
+ class Inputs extends StateGroup.make(Count) {}
13
+ // `isPositive` is stable across 1 -> 2 (still true) but flips on 2 -> -1.
14
+ class IsPositive extends Calc.make('IsPositive', {
15
+ inputs: [StateGroup.select(Inputs, 'count')],
16
+ output: S.Boolean,
17
+ }) {}
18
+ const runs = { n: 0 }
19
+ const IsPositiveLive = Calc.live(IsPositive, ({ count }) => {
20
+ runs.n += 1
21
+ return count > 0
22
+ })
23
+
24
+ const TestLayer = IsPositiveLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
25
+
26
+ return Effect.gen(function* () {
27
+ const source = yield* StateGroup.select(Inputs, 'count').store
28
+ const derived = yield* IsPositive.store
29
+ const notifications = { n: 0 }
30
+ derived.subscribe(() => {
31
+ notifications.n += 1
32
+ })
33
+
34
+ expect(derived.get()).toBe(true)
35
+
36
+ // 1 -> 2: inputs changed, recompute runs, but the output (true) is unchanged.
37
+ source.set(2)
38
+ yield* tick
39
+ expect(notifications.n).toBe(0)
40
+ expect(derived.get()).toBe(true)
41
+
42
+ // 2 -> -1: output flips to false — exactly one notification.
43
+ source.set(-1)
44
+ yield* tick
45
+ expect(notifications.n).toBe(1)
46
+ expect(derived.get()).toBe(false)
47
+ }).pipe(Effect.provide(TestLayer))
48
+ })
49
+
50
+ it.live('reading does not recompute while inputs are unchanged (memoized)', () => {
51
+ class Count extends State.make('count', S.Number) {}
52
+ class Inputs extends StateGroup.make(Count) {}
53
+ class Doubled extends Calc.make('Doubled', {
54
+ inputs: [StateGroup.select(Inputs, 'count')],
55
+ output: S.Number,
56
+ }) {}
57
+ const runs = { n: 0 }
58
+ const DoubledLive = Calc.live(Doubled, ({ count }) => {
59
+ runs.n += 1
60
+ return count * 2
61
+ })
62
+
63
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
64
+
65
+ return Effect.gen(function* () {
66
+ const derived = yield* Doubled.store
67
+ const before = runs.n
68
+ // Repeated reads with no input change hit the memo — no extra compute.
69
+ expect(derived.get()).toBe(10)
70
+ expect(derived.get()).toBe(10)
71
+ expect(runs.n).toBe(before)
72
+ }).pipe(Effect.provide(TestLayer))
73
+ })
74
+
75
+ it.live('a calc can depend on another calc as an input source', () => {
76
+ class Count extends State.make('count', S.Number) {}
77
+ class Inputs extends StateGroup.make(Count) {}
78
+ class Doubled extends Calc.make('Doubled', {
79
+ inputs: [StateGroup.select(Inputs, 'count')],
80
+ output: S.Number,
81
+ }) {}
82
+ const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
83
+ // `Doubled` (a Calc) is used directly as an input source; its name is the key.
84
+ class PlusDoubled extends Calc.make('PlusDoubled', {
85
+ inputs: [StateGroup.select(Inputs, 'count'), Doubled],
86
+ output: S.Number,
87
+ }) {}
88
+ const PlusDoubledLive = Calc.live(PlusDoubled, ({ count, Doubled }) => count + Doubled)
89
+
90
+ const TestLayer = PlusDoubledLive.pipe(
91
+ Layer.provideMerge(DoubledLive),
92
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
93
+ )
94
+
95
+ return Effect.gen(function* () {
96
+ const source = yield* StateGroup.select(Inputs, 'count').store
97
+ const derived = yield* PlusDoubled.store
98
+ expect(derived.get()).toBe(15) // 5 + (5*2)
99
+ source.set(3)
100
+ yield* tick
101
+ expect(derived.get()).toBe(9) // 3 + (3*2)
102
+ }).pipe(Effect.provide(TestLayer))
103
+ })
104
+
105
+ it.live('a diamond dependency resolves in one flush with a single leaf notification', () => {
106
+ // count ─┬─► A (count+1) ─┐
107
+ // └─► B (count+10)─┴─► C (A+B). A change to `count` moves both A and B;
108
+ // C must converge to the new value and wake its subscriber exactly once.
109
+ class Count extends State.make('count', S.Number) {}
110
+ class Inputs extends StateGroup.make(Count) {}
111
+ class A extends Calc.make('A', {
112
+ inputs: [StateGroup.select(Inputs, 'count')],
113
+ output: S.Number,
114
+ }) {}
115
+ const ALive = Calc.live(A, ({ count }) => count + 1)
116
+ class B extends Calc.make('B', {
117
+ inputs: [StateGroup.select(Inputs, 'count')],
118
+ output: S.Number,
119
+ }) {}
120
+ const BLive = Calc.live(B, ({ count }) => count + 10)
121
+ class C extends Calc.make('C', { inputs: [A, B], output: S.Number }) {}
122
+ const CLive = Calc.live(C, ({ A, B }) => A + B)
123
+
124
+ const TestLayer = CLive.pipe(
125
+ Layer.provideMerge(Layer.mergeAll(ALive, BLive)),
126
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 0 })),
127
+ )
128
+
129
+ return Effect.gen(function* () {
130
+ const source = yield* StateGroup.select(Inputs, 'count').store
131
+ const c = yield* C.store
132
+ const wakes = { n: 0 }
133
+ c.subscribe(() => {
134
+ wakes.n += 1
135
+ })
136
+ expect(c.get()).toBe(11) // (0+1) + (0+10)
137
+
138
+ source.set(5)
139
+ yield* tick
140
+ expect(c.get()).toBe(21) // (5+1) + (5+10)
141
+ expect(wakes.n).toBe(1)
142
+ }).pipe(Effect.provide(TestLayer))
143
+ })
144
+
145
+ it.live('invalidateBy: a calc recomputes only when the projected key moves', () => {
146
+ class A extends State.make('a', S.Number) {}
147
+ class B extends State.make('b', S.Number) {}
148
+ class Inputs extends StateGroup.make(A, B) {}
149
+ const runs = { n: 0 }
150
+ class Sum extends Calc.make('Sum', {
151
+ inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
152
+ output: S.Number,
153
+ }) {}
154
+ const SumLive = Calc.live(
155
+ Sum,
156
+ ({ a, b }) => {
157
+ runs.n += 1
158
+ return a + b
159
+ },
160
+ { invalidateBy: ({ a }) => [a] }, // only `a` is in the key
161
+ )
162
+ const TestLayer = SumLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
163
+
164
+ return Effect.gen(function* () {
165
+ const sa = yield* StateGroup.select(Inputs, 'a').store
166
+ const sb = yield* StateGroup.select(Inputs, 'b').store
167
+ const sum = yield* Sum.store
168
+ const base = runs.n
169
+ expect(sum.get()).toBe(2)
170
+
171
+ // `b` moves but the key (just `a`) is unchanged — no recompute, value retained.
172
+ sb.set(100)
173
+ yield* tick
174
+ expect(runs.n).toBe(base)
175
+ expect(sum.get()).toBe(2)
176
+
177
+ // `a` moves — recompute, now seeing the current `b`.
178
+ sa.set(5)
179
+ yield* tick
180
+ expect(sum.get()).toBe(105)
181
+ }).pipe(Effect.provide(TestLayer))
182
+ })
183
+
184
+ it.live('inputs are keyed by the make() name even when the subclass binding differs', () => {
185
+ class Count extends State.make('count', S.Number) {}
186
+ class Inputs extends StateGroup.make(Count) {}
187
+ // The binding name ('Renamed…') deliberately differs from the make() name
188
+ // ('feed') — a class declaration defines its OWN static `name` from the
189
+ // binding, shadowing the explicit one. Minifiers rename bindings, so keying
190
+ // the snapshot off the static would make this input undefined in production
191
+ // builds; the inputs object must key by the manifest's make() name.
192
+ class RenamedByTheMinifier extends Calc.make('feed', {
193
+ inputs: [StateGroup.select(Inputs, 'count')],
194
+ output: S.Number,
195
+ }) {}
196
+ class Downstream extends Calc.make('Downstream', {
197
+ inputs: [RenamedByTheMinifier],
198
+ output: S.Number,
199
+ }) {}
200
+ const seen: Array<unknown> = []
201
+ const FeedLive = Calc.live(RenamedByTheMinifier, ({ count }) => count * 2)
202
+ const DownstreamLive = Calc.live(Downstream, ({ feed }) => {
203
+ seen.push(feed)
204
+ return feed + 1
205
+ })
206
+
207
+ const TestLayer = DownstreamLive.pipe(
208
+ Layer.provideMerge(FeedLive),
209
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
210
+ )
211
+
212
+ return Effect.gen(function* () {
213
+ // The subclass's own static name is the (would-be-minified) binding name…
214
+ expect(RenamedByTheMinifier.name).toBe('RenamedByTheMinifier')
215
+ // …but the snapshot keys by the make() name, so `feed` is defined.
216
+ const downstream = yield* Downstream.store
217
+ expect(downstream.get()).toBe(7)
218
+ expect(seen.every((v) => v !== undefined)).toBe(true)
219
+ }).pipe(Effect.provide(TestLayer))
220
+ })
221
+
222
+ it.live('reuse: an unchanged subtree keeps its identity across recompute', () => {
223
+ class A extends State.make('a', S.Number) {}
224
+ class B extends State.make('b', S.Number) {}
225
+ class Inputs extends StateGroup.make(A, B) {}
226
+ const Pair = S.Struct({
227
+ left: S.Struct({ value: S.Number }),
228
+ right: S.Struct({ value: S.Number }),
229
+ })
230
+ class Split extends Calc.make('Split', {
231
+ inputs: [StateGroup.select(Inputs, 'a'), StateGroup.select(Inputs, 'b')],
232
+ output: Pair,
233
+ }) {}
234
+ const SplitLive = Calc.live(
235
+ Split,
236
+ ({ a, b }) => ({ left: { value: a }, right: { value: b } }),
237
+ { reuse: true },
238
+ )
239
+ const TestLayer = SplitLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { a: 1, b: 1 })))
240
+
241
+ return Effect.gen(function* () {
242
+ const b = yield* StateGroup.select(Inputs, 'b').store
243
+ const derived = yield* Split.store
244
+ const before = derived.get()
245
+
246
+ // Only `b` moves: the root and `right` are fresh, `left` keeps its identity.
247
+ b.set(2)
248
+ yield* tick
249
+ const after = derived.get()
250
+ expect(after).not.toBe(before)
251
+ expect(after.left).toBe(before.left)
252
+ expect(after.right).not.toBe(before.right)
253
+ expect(after.right.value).toBe(2)
254
+ }).pipe(Effect.provide(TestLayer))
255
+ })
256
+
257
+ it.live('reuse: a value-equal recompute keeps the previous reference and wakes nobody', () => {
258
+ class Count extends State.make('count', S.Number) {}
259
+ class Inputs extends StateGroup.make(Count) {}
260
+ const Box = S.Struct({ positive: S.Boolean })
261
+ class IsPositive extends Calc.make('IsPositive', {
262
+ inputs: [StateGroup.select(Inputs, 'count')],
263
+ output: Box,
264
+ }) {}
265
+ // A plain-object output: WITHOUT reuse every recompute is a fresh identity
266
+ // (and would notify); with it, a value-equal recompute returns the previous
267
+ // reference and the Equal gate stays silent.
268
+ const IsPositiveLive = Calc.live(IsPositive, ({ count }) => ({ positive: count > 0 }), {
269
+ reuse: true,
270
+ })
271
+ const TestLayer = IsPositiveLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
272
+
273
+ return Effect.gen(function* () {
274
+ const source = yield* StateGroup.select(Inputs, 'count').store
275
+ const derived = yield* IsPositive.store
276
+ const notifications = { n: 0 }
277
+ derived.subscribe(() => {
278
+ notifications.n += 1
279
+ })
280
+ const before = derived.get()
281
+
282
+ source.set(2)
283
+ yield* tick
284
+ expect(derived.get()).toBe(before)
285
+ expect(notifications.n).toBe(0)
286
+ }).pipe(Effect.provide(TestLayer))
287
+ })
@@ -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,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
+ })
@@ -84,7 +84,9 @@ export interface ProcedureRegistry {
84
84
  readonly unregister: (entry: ProcedureEntry) => void
85
85
  }
86
86
 
87
- export class Procedures extends Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>() {}
87
+ const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
88
+ Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
89
+ export class Procedures extends ProceduresBase {}
88
90
 
89
91
  /** Get a map entry, creating and inserting it on first access (avoids `let`). */
90
92
  const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
@@ -143,7 +145,7 @@ const makeProcedureRegistry = (): ProcedureRegistry => {
143
145
  }
144
146
  }
145
147
 
146
- export const proceduresLayer = Layer.sync(Procedures, makeProcedureRegistry)
148
+ export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(Procedures, makeProcedureRegistry)
147
149
 
148
150
  /** A live channel: the single loop offers events here; the channel drives them. */
149
151
  export interface ChannelRuntime {
@@ -154,12 +156,14 @@ export interface ChannelRuntime {
154
156
  }
155
157
 
156
158
  /** Registry of live channels, keyed by name. The single loop routes through it. */
157
- export class Channels extends Context.Tag('reform/Channels')<
159
+ const ChannelsBase: Context.TagClass<
158
160
  Channels,
161
+ 'reform/Channels',
159
162
  { readonly byName: Map<string, ChannelRuntime> }
160
- >() {}
163
+ > = Context.Tag('reform/Channels')<Channels, { readonly byName: Map<string, ChannelRuntime> }>()
164
+ export class Channels extends ChannelsBase {}
161
165
 
162
- export const channelsLayer = Layer.sync(Channels, () => ({ byName: new Map() }))
166
+ export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
163
167
 
164
168
  /**
165
169
  * A scheduling lane for procedures. The *definition* (`Channel.make`) is a
@@ -11,5 +11,8 @@ export interface SlotHost {
11
11
  slot(name: string): (props: unknown) => Node
12
12
  }
13
13
 
14
+ const CurrentSlotsBase: Context.TagClass<CurrentSlots, 'reform/Slots', SlotHost> =
15
+ Context.Tag('reform/Slots')<CurrentSlots, SlotHost>()
16
+
14
17
  /** The active composition's slot bindings, provided per render by the host. */
15
- export class CurrentSlots extends Context.Tag('reform/Slots')<CurrentSlots, SlotHost>() {}
18
+ export class CurrentSlots extends CurrentSlotsBase {}
@@ -7,4 +7,8 @@ import { Context } from 'effect'
7
7
  * (Per-instance typing of Props is DESIGN #26 — for now the value is opaque and
8
8
  * narrowed at the use site against the composition's declared props schema.)
9
9
  */
10
- export class Props extends Context.Tag('reform/Props')<Props, any>() {}
10
+ const PropsBase: Context.TagClass<Props, 'reform/Props', any> = Context.Tag('reform/Props')<
11
+ Props,
12
+ any
13
+ >()
14
+ export class Props extends PropsBase {}
@@ -0,0 +1,62 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Effect, Schema as S } from 'effect'
3
+ import { Ui, isUi, ui } from '../index'
4
+ import { CaptureSink, type UiCapture } from '../internal/capture'
5
+ import type { Trigger } from '../ui/trigger'
6
+
7
+ // `ui` has two authoring forms with one source of truth each: the type-only form
8
+ // (local, no schema) and the schema form (wired, the type is *derived*). Both are
9
+ // the same contract at runtime; only the wired form carries reflectable schemas.
10
+
11
+ test('type-only `ui` carries no wire schema', () => {
12
+ class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
13
+ expect(CounterUi.manifest.kind).toBe('Ui')
14
+ expect(CounterUi.manifest.name).toBe('Counter')
15
+ expect(CounterUi.manifest.props).toBeUndefined()
16
+ expect(CounterUi.manifest.events).toBeUndefined()
17
+ expect(isUi(CounterUi)).toBe(true)
18
+ })
19
+
20
+ test('schema `ui` carries props and per-event wire schemas', () => {
21
+ class CounterUi extends ui('Counter', {
22
+ props: S.Struct({ count: S.Number }),
23
+ events: { bump: S.Struct({ by: S.Number }) },
24
+ }) {}
25
+ expect(CounterUi.manifest.kind).toBe('Ui')
26
+ expect(CounterUi.manifest.name).toBe('Counter')
27
+ expect(S.isSchema(CounterUi.manifest.props)).toBe(true)
28
+ expect(S.isSchema(CounterUi.manifest.events?.['bump'])).toBe(true)
29
+ expect(isUi(CounterUi)).toBe(true)
30
+ })
31
+
32
+ test('schema `ui` with no events omits the events schema', () => {
33
+ class TitleUi extends ui('Title', { props: S.Struct({ text: S.String }) }) {}
34
+ expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
35
+ expect(TitleUi.manifest.events).toBeUndefined()
36
+ })
37
+
38
+ test('a contract resolves its presentation and reports to a capturing sink', () => {
39
+ class CounterUi extends ui('Counter', {
40
+ props: S.Struct({ count: S.Number }),
41
+ events: { bump: S.Struct({ by: S.Number }) },
42
+ }) {}
43
+
44
+ const view = Ui.make(CounterUi, ({ count }) => `count:${count}`)
45
+ const records: UiCapture[] = []
46
+ const sink = { record: (capture: UiCapture): void => void records.push(capture) }
47
+
48
+ const logicView = Effect.runSync(
49
+ CounterUi.pipe(
50
+ Effect.provideService(CounterUi.impl, view),
51
+ Effect.provideService(CaptureSink, sink),
52
+ ),
53
+ )
54
+
55
+ const bump: Trigger<{ by: number }> = () => {}
56
+ const node = logicView({ count: 3 }, { bump })
57
+
58
+ expect(node).toBe('count:3')
59
+ expect(records).toHaveLength(1)
60
+ expect(records[0]?.name).toBe('Counter')
61
+ expect(records[0]?.props).toEqual({ count: 3 })
62
+ })