@playfast/reform 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/package.json +2 -2
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/boundary/boundary.ts +192 -0
  4. package/src/calc/asyncCalc.test.ts +556 -0
  5. package/src/calc/asyncCalc.ts +267 -0
  6. package/src/calc/asyncData.ts +96 -0
  7. package/src/calc/calc.test.ts +287 -0
  8. package/src/calc/calc.ts +120 -0
  9. package/src/calc/calcFamily.test.ts +206 -0
  10. package/src/calc/calcFamily.ts +194 -0
  11. package/src/calc/compose.test.ts +68 -0
  12. package/src/calc/compose.ts +69 -0
  13. package/src/channel/channel.ts +269 -0
  14. package/src/compose/composition.ts +112 -0
  15. package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
  16. package/{dist/compose/props.js → src/compose/props.ts} +7 -5
  17. package/src/compose/provide.ts +70 -0
  18. package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  19. package/src/compose/ui.test.ts +62 -0
  20. package/src/compose/ui.ts +226 -0
  21. package/src/compose/ui.typecheck.ts +56 -0
  22. package/src/definition/definition.ts +76 -0
  23. package/src/event/event.test.ts +23 -0
  24. package/src/event/event.ts +65 -0
  25. package/src/event/eventGroup.ts +14 -0
  26. package/src/feature/feature.mount.test.ts +82 -0
  27. package/src/feature/feature.test.ts +60 -0
  28. package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  29. package/src/feature/feature.typecheck.ts +108 -0
  30. package/src/index.ts +176 -0
  31. package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  32. package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  33. package/src/internal/errors.test.ts +33 -0
  34. package/src/internal/errors.ts +145 -0
  35. package/src/internal/inspect.test.ts +28 -0
  36. package/src/internal/inspect.ts +34 -0
  37. package/src/internal/queryDriver.ts +247 -0
  38. package/src/internal/reuse.test.ts +116 -0
  39. package/src/internal/reuse.ts +73 -0
  40. package/src/internal/scheduler.ts +93 -0
  41. package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  42. package/src/internal/sources.ts +104 -0
  43. package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
  44. package/src/internal/store.test.ts +80 -0
  45. package/src/internal/store.ts +105 -0
  46. package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
  47. package/src/procedure/procedure.ts +89 -0
  48. package/src/reducer/reducer.ts +137 -0
  49. package/src/remote/remoteState.test.ts +695 -0
  50. package/src/remote/remoteState.ts +571 -0
  51. package/src/remote/remoteState.typecheck.ts +195 -0
  52. package/src/runtime/bus.ts +40 -0
  53. package/src/runtime/hardening.test.ts +69 -0
  54. package/src/runtime/loop.test.ts +178 -0
  55. package/src/runtime/loop.ts +171 -0
  56. package/src/scene/scene.ts +76 -0
  57. package/src/scene/seedScene.test.ts +169 -0
  58. package/src/state/state.ts +94 -0
  59. package/src/state/stateFamily.test.ts +138 -0
  60. package/src/state/stateFamily.ts +174 -0
  61. package/src/state/stateGroup.ts +105 -0
  62. package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
  63. package/src/synced/syncedStore.ts +99 -0
  64. package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
  65. package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  66. package/src/wire/tree.test.ts +81 -0
  67. package/src/wire/tree.ts +129 -0
  68. package/src/wire/triggers.test.ts +76 -0
  69. package/src/wire/triggers.ts +98 -0
  70. package/dist/boundary/boundary.d.ts +0 -72
  71. package/dist/boundary/boundary.d.ts.map +0 -1
  72. package/dist/boundary/boundary.js +0 -83
  73. package/dist/boundary/boundary.js.map +0 -1
  74. package/dist/calc/asyncCalc.d.ts +0 -91
  75. package/dist/calc/asyncCalc.d.ts.map +0 -1
  76. package/dist/calc/asyncCalc.js +0 -95
  77. package/dist/calc/asyncCalc.js.map +0 -1
  78. package/dist/calc/asyncData.d.ts +0 -57
  79. package/dist/calc/asyncData.d.ts.map +0 -1
  80. package/dist/calc/asyncData.js +0 -34
  81. package/dist/calc/asyncData.js.map +0 -1
  82. package/dist/calc/calc.d.ts +0 -57
  83. package/dist/calc/calc.d.ts.map +0 -1
  84. package/dist/calc/calc.js +0 -58
  85. package/dist/calc/calc.js.map +0 -1
  86. package/dist/calc/calcFamily.d.ts +0 -57
  87. package/dist/calc/calcFamily.d.ts.map +0 -1
  88. package/dist/calc/calcFamily.js +0 -124
  89. package/dist/calc/calcFamily.js.map +0 -1
  90. package/dist/calc/compose.d.ts +0 -16
  91. package/dist/calc/compose.d.ts.map +0 -1
  92. package/dist/calc/compose.js +0 -7
  93. package/dist/calc/compose.js.map +0 -1
  94. package/dist/channel/channel.d.ts +0 -115
  95. package/dist/channel/channel.d.ts.map +0 -1
  96. package/dist/channel/channel.js +0 -138
  97. package/dist/channel/channel.js.map +0 -1
  98. package/dist/compose/composition.d.ts +0 -72
  99. package/dist/compose/composition.d.ts.map +0 -1
  100. package/dist/compose/composition.js +0 -46
  101. package/dist/compose/composition.js.map +0 -1
  102. package/dist/compose/host.d.ts.map +0 -1
  103. package/dist/compose/host.js +0 -6
  104. package/dist/compose/host.js.map +0 -1
  105. package/dist/compose/props.d.ts +0 -13
  106. package/dist/compose/props.d.ts.map +0 -1
  107. package/dist/compose/props.js.map +0 -1
  108. package/dist/compose/provide.d.ts +0 -22
  109. package/dist/compose/provide.d.ts.map +0 -1
  110. package/dist/compose/provide.js +0 -28
  111. package/dist/compose/provide.js.map +0 -1
  112. package/dist/compose/slot.d.ts.map +0 -1
  113. package/dist/compose/slot.js +0 -23
  114. package/dist/compose/slot.js.map +0 -1
  115. package/dist/compose/ui.d.ts +0 -135
  116. package/dist/compose/ui.d.ts.map +0 -1
  117. package/dist/compose/ui.js +0 -63
  118. package/dist/compose/ui.js.map +0 -1
  119. package/dist/definition/definition.d.ts +0 -33
  120. package/dist/definition/definition.d.ts.map +0 -1
  121. package/dist/definition/definition.js +0 -42
  122. package/dist/definition/definition.js.map +0 -1
  123. package/dist/event/event.d.ts +0 -33
  124. package/dist/event/event.d.ts.map +0 -1
  125. package/dist/event/event.js +0 -30
  126. package/dist/event/event.js.map +0 -1
  127. package/dist/event/eventGroup.d.ts +0 -9
  128. package/dist/event/eventGroup.d.ts.map +0 -1
  129. package/dist/event/eventGroup.js +0 -4
  130. package/dist/event/eventGroup.js.map +0 -1
  131. package/dist/feature/feature.d.ts.map +0 -1
  132. package/dist/feature/feature.js +0 -98
  133. package/dist/feature/feature.js.map +0 -1
  134. package/dist/index.d.ts +0 -53
  135. package/dist/index.d.ts.map +0 -1
  136. package/dist/index.js +0 -62
  137. package/dist/index.js.map +0 -1
  138. package/dist/internal/capture.d.ts.map +0 -1
  139. package/dist/internal/capture.js +0 -12
  140. package/dist/internal/capture.js.map +0 -1
  141. package/dist/internal/ctx.d.ts.map +0 -1
  142. package/dist/internal/ctx.js +0 -2
  143. package/dist/internal/ctx.js.map +0 -1
  144. package/dist/internal/errors.d.ts +0 -89
  145. package/dist/internal/errors.d.ts.map +0 -1
  146. package/dist/internal/errors.js +0 -102
  147. package/dist/internal/errors.js.map +0 -1
  148. package/dist/internal/inspect.d.ts +0 -17
  149. package/dist/internal/inspect.d.ts.map +0 -1
  150. package/dist/internal/inspect.js +0 -32
  151. package/dist/internal/inspect.js.map +0 -1
  152. package/dist/internal/queryDriver.d.ts +0 -65
  153. package/dist/internal/queryDriver.d.ts.map +0 -1
  154. package/dist/internal/queryDriver.js +0 -134
  155. package/dist/internal/queryDriver.js.map +0 -1
  156. package/dist/internal/reuse.d.ts +0 -10
  157. package/dist/internal/reuse.d.ts.map +0 -1
  158. package/dist/internal/reuse.js +0 -68
  159. package/dist/internal/reuse.js.map +0 -1
  160. package/dist/internal/scheduler.d.ts +0 -47
  161. package/dist/internal/scheduler.d.ts.map +0 -1
  162. package/dist/internal/scheduler.js +0 -70
  163. package/dist/internal/scheduler.js.map +0 -1
  164. package/dist/internal/seeds.d.ts.map +0 -1
  165. package/dist/internal/seeds.js +0 -17
  166. package/dist/internal/seeds.js.map +0 -1
  167. package/dist/internal/sources.d.ts +0 -39
  168. package/dist/internal/sources.d.ts.map +0 -1
  169. package/dist/internal/sources.js +0 -59
  170. package/dist/internal/sources.js.map +0 -1
  171. package/dist/internal/stateRegistry.d.ts +0 -2
  172. package/dist/internal/stateRegistry.d.ts.map +0 -1
  173. package/dist/internal/stateRegistry.js.map +0 -1
  174. package/dist/internal/store.d.ts +0 -47
  175. package/dist/internal/store.d.ts.map +0 -1
  176. package/dist/internal/store.js +0 -73
  177. package/dist/internal/store.js.map +0 -1
  178. package/dist/internal/track.d.ts.map +0 -1
  179. package/dist/internal/track.js +0 -19
  180. package/dist/internal/track.js.map +0 -1
  181. package/dist/procedure/procedure.d.ts +0 -40
  182. package/dist/procedure/procedure.d.ts.map +0 -1
  183. package/dist/procedure/procedure.js +0 -50
  184. package/dist/procedure/procedure.js.map +0 -1
  185. package/dist/reducer/reducer.d.ts +0 -44
  186. package/dist/reducer/reducer.d.ts.map +0 -1
  187. package/dist/reducer/reducer.js +0 -63
  188. package/dist/reducer/reducer.js.map +0 -1
  189. package/dist/remote/remoteState.d.ts +0 -119
  190. package/dist/remote/remoteState.d.ts.map +0 -1
  191. package/dist/remote/remoteState.js +0 -270
  192. package/dist/remote/remoteState.js.map +0 -1
  193. package/dist/runtime/bus.d.ts +0 -27
  194. package/dist/runtime/bus.d.ts.map +0 -1
  195. package/dist/runtime/bus.js +0 -21
  196. package/dist/runtime/bus.js.map +0 -1
  197. package/dist/runtime/loop.d.ts +0 -45
  198. package/dist/runtime/loop.d.ts.map +0 -1
  199. package/dist/runtime/loop.js +0 -117
  200. package/dist/runtime/loop.js.map +0 -1
  201. package/dist/scene/scene.d.ts +0 -44
  202. package/dist/scene/scene.d.ts.map +0 -1
  203. package/dist/scene/scene.js +0 -31
  204. package/dist/scene/scene.js.map +0 -1
  205. package/dist/state/state.d.ts +0 -37
  206. package/dist/state/state.d.ts.map +0 -1
  207. package/dist/state/state.js +0 -46
  208. package/dist/state/state.js.map +0 -1
  209. package/dist/state/stateFamily.d.ts +0 -79
  210. package/dist/state/stateFamily.d.ts.map +0 -1
  211. package/dist/state/stateFamily.js +0 -99
  212. package/dist/state/stateFamily.js.map +0 -1
  213. package/dist/state/stateGroup.d.ts +0 -50
  214. package/dist/state/stateGroup.d.ts.map +0 -1
  215. package/dist/state/stateGroup.js +0 -54
  216. package/dist/state/stateGroup.js.map +0 -1
  217. package/dist/state/token.d.ts.map +0 -1
  218. package/dist/state/token.js +0 -20
  219. package/dist/state/token.js.map +0 -1
  220. package/dist/synced/syncedStore.d.ts +0 -51
  221. package/dist/synced/syncedStore.d.ts.map +0 -1
  222. package/dist/synced/syncedStore.js +0 -43
  223. package/dist/synced/syncedStore.js.map +0 -1
  224. package/dist/ui/node.d.ts.map +0 -1
  225. package/dist/ui/node.js +0 -2
  226. package/dist/ui/node.js.map +0 -1
  227. package/dist/ui/trigger.d.ts.map +0 -1
  228. package/dist/ui/trigger.js +0 -2
  229. package/dist/ui/trigger.js.map +0 -1
  230. package/dist/wire/tree.d.ts +0 -66
  231. package/dist/wire/tree.d.ts.map +0 -1
  232. package/dist/wire/tree.js +0 -60
  233. package/dist/wire/tree.js.map +0 -1
  234. package/dist/wire/triggers.d.ts +0 -44
  235. package/dist/wire/triggers.d.ts.map +0 -1
  236. package/dist/wire/triggers.js +0 -31
  237. package/dist/wire/triggers.js.map +0 -1
@@ -0,0 +1,556 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
+ import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
4
+
5
+ // AsyncCalc is a derived store whose value is the lifecycle of an Effect. It runs
6
+ // the query reactively from its inputs (latest-wins), keeps the last value while
7
+ // re-fetching (stale-while-revalidate), and goes Idle when a gated query disables.
8
+
9
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
10
+
11
+ // Poll until the predicate holds (bounded) — for assertions that race real
12
+ // timers under parallel-suite host load, where a fixed `tick` budget flakes.
13
+ const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
14
+ Effect.suspend(() => {
15
+ const a = read()
16
+ return pred(a) || rounds <= 0
17
+ ? Effect.succeed(a)
18
+ : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
19
+ })
20
+
21
+ class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
22
+
23
+ it.live('runs the query and resolves Loading -> Success', () => {
24
+ class Count extends State.make('count', S.Number) {}
25
+ class Inputs extends StateGroup.make(Count) {}
26
+ class Doubled extends AsyncCalc.make('Doubled', {
27
+ inputs: [StateGroup.select(Inputs, 'count')],
28
+ output: S.Number,
29
+ alwaysOn: true,
30
+ }) {}
31
+ const DoubledLive = AsyncCalc.live(Doubled, {
32
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
33
+ })
34
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
35
+
36
+ return Effect.gen(function* () {
37
+ const store = yield* Doubled.store
38
+ // Before the forked query resolves, the value is Loading.
39
+ expect(store.get()._tag).toBe('Loading')
40
+ yield* tick(40)
41
+ const value = store.get()
42
+ expect(value._tag).toBe('Success')
43
+ if (value._tag === 'Success') {
44
+ expect(value.value).toBe(10)
45
+ expect(value.refetching).toBe(false)
46
+ }
47
+ }).pipe(Effect.provide(TestLayer))
48
+ })
49
+
50
+ it.live('a failing query resolves to Error carrying the failure', () => {
51
+ class Count extends State.make('count', S.Number) {}
52
+ class Inputs extends StateGroup.make(Count) {}
53
+ class Q extends AsyncCalc.make('Q', {
54
+ inputs: [StateGroup.select(Inputs, 'count')],
55
+ output: S.Number,
56
+ error: S.instanceOf(Boom),
57
+ alwaysOn: true,
58
+ }) {}
59
+ const QLive = AsyncCalc.live(Q, {
60
+ query: () => Effect.fail(new Boom({ message: 'nope' })),
61
+ })
62
+ const TestLayer = QLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
63
+
64
+ return Effect.gen(function* () {
65
+ const store = yield* Q.store
66
+ yield* tick()
67
+ const value = store.get()
68
+ expect(value._tag).toBe('Error')
69
+ if (value._tag === 'Error') {
70
+ expect(value.error.message).toBe('nope')
71
+ expect(value.refetching).toBe(false)
72
+ }
73
+ }).pipe(Effect.provide(TestLayer))
74
+ })
75
+
76
+ it.live('a dependency change re-fetches, keeping the last value with refetching: true', () => {
77
+ class Count extends State.make('count', S.Number) {}
78
+ class Inputs extends StateGroup.make(Count) {}
79
+ class Doubled extends AsyncCalc.make('Doubled', {
80
+ inputs: [StateGroup.select(Inputs, 'count')],
81
+ output: S.Number,
82
+ alwaysOn: true,
83
+ }) {}
84
+ const DoubledLive = AsyncCalc.live(Doubled, {
85
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
86
+ })
87
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
88
+
89
+ return Effect.gen(function* () {
90
+ const source = yield* StateGroup.select(Inputs, 'count').store
91
+ const store = yield* Doubled.store
92
+ yield* tick(40)
93
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
94
+
95
+ // Move the input: the prior value is kept while the new fetch is in flight.
96
+ source.set(7)
97
+ yield* tick(5)
98
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
99
+
100
+ yield* tick(40)
101
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
102
+ }).pipe(Effect.provide(TestLayer))
103
+ })
104
+
105
+ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
106
+ class Count extends State.make('count', S.Number) {}
107
+ class Inputs extends StateGroup.make(Count) {}
108
+ const runs = { n: 0 }
109
+ class Doubled extends AsyncCalc.make('Doubled', {
110
+ inputs: [StateGroup.select(Inputs, 'count')],
111
+ output: S.Number,
112
+ alwaysOn: true,
113
+ }) {}
114
+ const DoubledLive = AsyncCalc.live(Doubled, {
115
+ query: ({ count }) =>
116
+ Effect.sync(() => {
117
+ runs.n += 1
118
+ return count * 2
119
+ }).pipe(Effect.delay(Duration.millis(25))),
120
+ })
121
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
122
+
123
+ return Effect.gen(function* () {
124
+ const source = yield* StateGroup.select(Inputs, 'count').store
125
+ const store = yield* Doubled.store
126
+ yield* tick(40)
127
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
128
+
129
+ // Two changes in quick succession: the first run is switched out before it
130
+ // can write, so only the latest value lands (poll — fixed sleeps flake when
131
+ // a loaded host starves the 25ms query timer).
132
+ source.set(3)
133
+ yield* tick(5)
134
+ source.set(4)
135
+ const settled = yield* until(
136
+ () => store.get(),
137
+ (v) => v._tag === 'Success' && v.refetching === false && v.value !== 2,
138
+ )
139
+ expect(settled).toMatchObject({ _tag: 'Success', value: 8 })
140
+ }).pipe(Effect.provide(TestLayer))
141
+ })
142
+
143
+ it.live('a gated query starts Idle while disabled and runs once enabled', () => {
144
+ class Count extends State.make('count', S.Number) {}
145
+ class Inputs extends StateGroup.make(Count) {}
146
+ class Doubled extends AsyncCalc.make('Doubled', {
147
+ inputs: [StateGroup.select(Inputs, 'count')],
148
+ output: S.Number,
149
+ }) {}
150
+ const DoubledLive = AsyncCalc.live(Doubled, {
151
+ query: ({ count }) => Effect.succeed(count * 2),
152
+ disabled: ({ count }) => count < 0,
153
+ })
154
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })))
155
+
156
+ return Effect.gen(function* () {
157
+ const source = yield* StateGroup.select(Inputs, 'count').store
158
+ const store = yield* Doubled.store
159
+ yield* tick()
160
+ expect(store.get()._tag).toBe('Idle')
161
+
162
+ source.set(5)
163
+ yield* tick()
164
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
165
+ }).pipe(Effect.provide(TestLayer))
166
+ })
167
+
168
+ it.live('invalidateBy: an input change outside the key does not re-fetch', () => {
169
+ class Status extends State.make('status', S.String) {}
170
+ class Page extends State.make('page', S.Number) {}
171
+ class Inputs extends StateGroup.make(Status, Page) {}
172
+ const runs = { n: 0 }
173
+ class Q extends AsyncCalc.make('Q', {
174
+ inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
175
+ output: S.Number,
176
+ alwaysOn: true,
177
+ }) {}
178
+ const QLive = AsyncCalc.live(Q, {
179
+ query: ({ page }) =>
180
+ Effect.sync(() => {
181
+ runs.n += 1
182
+ return page
183
+ }),
184
+ // Only `page` is part of the key; `status` churn must not re-fetch.
185
+ invalidateBy: ({ page }) => [page],
186
+ })
187
+ const TestLayer = QLive.pipe(
188
+ Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
189
+ )
190
+
191
+ return Effect.gen(function* () {
192
+ const status = yield* StateGroup.select(Inputs, 'status').store
193
+ const page = yield* StateGroup.select(Inputs, 'page').store
194
+ const store = yield* Q.store
195
+ yield* tick()
196
+ expect(runs.n).toBe(1)
197
+
198
+ // status moves, key unchanged -> no re-fetch.
199
+ status.set('b')
200
+ yield* tick()
201
+ expect(runs.n).toBe(1)
202
+
203
+ // page moves, key changes -> one re-fetch.
204
+ page.set(2)
205
+ yield* tick()
206
+ expect(runs.n).toBe(2)
207
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
208
+ }).pipe(Effect.provide(TestLayer))
209
+ })
210
+
211
+ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element identities', () => {
212
+ class Page extends State.make('page', S.Number) {}
213
+ class Inputs extends StateGroup.make(Page) {}
214
+ const Row = S.Struct({ id: S.Number, name: S.String })
215
+ class Rows extends AsyncCalc.make('Rows', {
216
+ inputs: [StateGroup.select(Inputs, 'page')],
217
+ output: S.Array(Row),
218
+ alwaysOn: true,
219
+ }) {}
220
+ const RowsLive = AsyncCalc.live(Rows, {
221
+ // Fresh objects every run — only the row matching `page` differs.
222
+ query: ({ page }) =>
223
+ Effect.succeed([
224
+ { id: 1, name: 'one' },
225
+ { id: 2, name: page > 1 ? `two@${page}` : 'two' },
226
+ ]),
227
+ reuse: true,
228
+ })
229
+ const TestLayer = RowsLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
230
+
231
+ return Effect.gen(function* () {
232
+ const source = yield* StateGroup.select(Inputs, 'page').store
233
+ const store = yield* Rows.store
234
+ const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
235
+ if (first._tag !== 'Success') return
236
+
237
+ source.set(2)
238
+ const second = yield* until(
239
+ () => store.get(),
240
+ (v) => v._tag === 'Success' && v.refetching === false && v.value !== first.value,
241
+ )
242
+ if (second._tag !== 'Success') return
243
+ // Row 1 didn't move: same identity. Row 2 did: fresh.
244
+ expect(second.value[0]).toBe(first.value[0])
245
+ expect(second.value[1]).not.toBe(first.value[1])
246
+ expect(second.value[1]).toEqual({ id: 2, name: 'two@2' })
247
+ }).pipe(Effect.provide(TestLayer))
248
+ })
249
+
250
+ it.live('coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch', () => {
251
+ class Count extends State.make('count', S.Number) {}
252
+ class Inputs extends StateGroup.make(Count) {}
253
+ // Completion log: the sync body runs AFTER the delay, so the order proves
254
+ // which runs actually finished (and that the in-flight one was not cancelled).
255
+ const completed: number[] = []
256
+ class Doubled extends AsyncCalc.make('Doubled', {
257
+ inputs: [StateGroup.select(Inputs, 'count')],
258
+ output: S.Number,
259
+ alwaysOn: true,
260
+ }) {}
261
+ const DoubledLive = AsyncCalc.live(Doubled, {
262
+ query: ({ count }) =>
263
+ Effect.sync(() => {
264
+ completed.push(count)
265
+ return count * 2
266
+ }).pipe(Effect.delay(Duration.millis(25))),
267
+ coalesce: 'trailing',
268
+ })
269
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
270
+
271
+ return Effect.gen(function* () {
272
+ const source = yield* StateGroup.select(Inputs, 'count').store
273
+ const store = yield* Doubled.store
274
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
275
+ expect(completed).toEqual([1])
276
+
277
+ // Three key changes during the first change's flight: the in-flight run (2)
278
+ // completes and lands, the middle change (3) is conflated away, and exactly
279
+ // one trailing run fetches the latest key (4).
280
+ source.set(2)
281
+ yield* tick(5)
282
+ source.set(3)
283
+ yield* tick(5)
284
+ source.set(4)
285
+ const settled = yield* until(
286
+ () => store.get(),
287
+ (v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
288
+ )
289
+ expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
290
+ expect(completed).toEqual([1, 2, 4])
291
+ }).pipe(Effect.provide(TestLayer))
292
+ })
293
+
294
+ it.live('coalesce trailing: the stale settle re-flags refetching until the trailing run lands', () => {
295
+ class Count extends State.make('count', S.Number) {}
296
+ class Inputs extends StateGroup.make(Count) {}
297
+ class Doubled extends AsyncCalc.make('Doubled', {
298
+ inputs: [StateGroup.select(Inputs, 'count')],
299
+ output: S.Number,
300
+ alwaysOn: true,
301
+ }) {}
302
+ const DoubledLive = AsyncCalc.live(Doubled, {
303
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(30))),
304
+ coalesce: 'trailing',
305
+ })
306
+ const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
307
+
308
+ return Effect.gen(function* () {
309
+ const source = yield* StateGroup.select(Inputs, 'count').store
310
+ const store = yield* Doubled.store
311
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
312
+
313
+ // Two changes: when the in-flight result (4) lands, a trailing run is still
314
+ // pending — the value must read as syncing, not settled.
315
+ source.set(2)
316
+ yield* tick(5)
317
+ source.set(3)
318
+ const midflight = yield* until(
319
+ () => store.get(),
320
+ (v) => v._tag === 'Success' && v.value === 4,
321
+ )
322
+ expect(midflight).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
323
+
324
+ const settled = yield* until(
325
+ () => store.get(),
326
+ (v) => v._tag === 'Success' && v.refetching === false,
327
+ )
328
+ expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
329
+ }).pipe(Effect.provide(TestLayer))
330
+ })
331
+
332
+ it.live('coalesce trailing: a disable while a request waits in the slot skips the run', () => {
333
+ class Count extends State.make('count', S.Number) {}
334
+ class Inputs extends StateGroup.make(Count) {}
335
+ const completed: number[] = []
336
+ class Q extends AsyncCalc.make('Q', {
337
+ inputs: [StateGroup.select(Inputs, 'count')],
338
+ output: S.Number,
339
+ }) {}
340
+ const QLive = AsyncCalc.live(Q, {
341
+ query: ({ count }) =>
342
+ Effect.sync(() => {
343
+ completed.push(count)
344
+ return count * 2
345
+ }).pipe(Effect.delay(Duration.millis(25))),
346
+ disabled: ({ count }) => count < 0,
347
+ coalesce: 'trailing',
348
+ })
349
+ const TestLayer = QLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
350
+
351
+ return Effect.gen(function* () {
352
+ const source = yield* StateGroup.select(Inputs, 'count').store
353
+ const store = yield* Q.store
354
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
355
+
356
+ // 2 starts a flight, 3 waits in the slot, then the gate closes: the flight's
357
+ // stale result must not land (Idle), and the queued request must not run.
358
+ source.set(2)
359
+ yield* tick(5)
360
+ source.set(3)
361
+ yield* tick(2)
362
+ source.set(-1)
363
+ yield* tick(60)
364
+ expect(store.get()._tag).toBe('Idle')
365
+ expect(completed).toEqual([1, 2])
366
+
367
+ // Re-enabling re-runs (the driver forgot its key when switched off).
368
+ source.set(5)
369
+ const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
370
+ expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
371
+ expect(completed).toEqual([1, 2, 5])
372
+ }).pipe(Effect.provide(TestLayer))
373
+ })
374
+
375
+ // `invalidateOn` is sugar for a hidden revision state + reducer appended to the
376
+ // key — these proofs need the Engine (the Reducers registry + bus + drain loop).
377
+
378
+ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: true', () => {
379
+ class Count extends State.make('count', S.Number) {}
380
+ class Inputs extends StateGroup.make(Count) {}
381
+ class Poke extends Event.make('Poke', S.Struct({})) {}
382
+ const runs = { n: 0 }
383
+ class Q extends AsyncCalc.make('Q', {
384
+ inputs: [StateGroup.select(Inputs, 'count')],
385
+ output: S.Number,
386
+ alwaysOn: true,
387
+ }) {}
388
+ const QLive = AsyncCalc.live(Q, {
389
+ query: ({ count }) =>
390
+ Effect.sync(() => {
391
+ runs.n += 1
392
+ return count * 2
393
+ }).pipe(Effect.delay(Duration.millis(15))),
394
+ invalidateOn: [Poke],
395
+ })
396
+ const TestLayer = QLive.pipe(
397
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 2 })),
398
+ Layer.provideMerge(Engine),
399
+ )
400
+
401
+ return Effect.gen(function* () {
402
+ const store = yield* Q.store
403
+ yield* tick(30)
404
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
405
+ expect(runs.n).toBe(1)
406
+
407
+ // The fact lands: the hidden revision bumps, the key moves, the value is
408
+ // kept while the re-fetch is in flight (SWR), then settles.
409
+ yield* Event.dispatch(Poke, {})
410
+ yield* tick(5)
411
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
412
+ yield* tick(30)
413
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
414
+ expect(runs.n).toBe(2)
415
+ }).pipe(Effect.provide(TestLayer))
416
+ })
417
+
418
+ it.live('invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches', () => {
419
+ class Status extends State.make('status', S.String) {}
420
+ class Page extends State.make('page', S.Number) {}
421
+ class Inputs extends StateGroup.make(Status, Page) {}
422
+ class Poke extends Event.make('Poke', S.Struct({})) {}
423
+ const runs = { n: 0 }
424
+ class Q extends AsyncCalc.make('Q', {
425
+ inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
426
+ output: S.Number,
427
+ alwaysOn: true,
428
+ }) {}
429
+ const QLive = AsyncCalc.live(Q, {
430
+ query: ({ page }) =>
431
+ Effect.sync(() => {
432
+ runs.n += 1
433
+ return page
434
+ }),
435
+ invalidateBy: ({ page }) => [page],
436
+ invalidateOn: [Poke],
437
+ })
438
+ const TestLayer = QLive.pipe(
439
+ Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
440
+ Layer.provideMerge(Engine),
441
+ )
442
+
443
+ return Effect.gen(function* () {
444
+ const status = yield* StateGroup.select(Inputs, 'status').store
445
+ yield* tick()
446
+ expect(runs.n).toBe(1)
447
+
448
+ // Outside the user key: still no re-fetch (the hidden revision rides along
449
+ // WITHOUT widening the key to every input).
450
+ status.set('b')
451
+ yield* tick()
452
+ expect(runs.n).toBe(1)
453
+
454
+ // The listed event always re-fetches.
455
+ yield* Event.dispatch(Poke, {})
456
+ yield* tick()
457
+ expect(runs.n).toBe(2)
458
+ }).pipe(Effect.provide(TestLayer))
459
+ })
460
+
461
+ it.live('invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once', () => {
462
+ class Count extends State.make('count', S.Number) {}
463
+ class Inputs extends StateGroup.make(Count) {}
464
+ class Poke extends Event.make('Poke', S.Struct({})) {}
465
+ const runs = { n: 0 }
466
+ class Q extends AsyncCalc.make('Q', {
467
+ inputs: [StateGroup.select(Inputs, 'count')],
468
+ output: S.Number,
469
+ }) {}
470
+ const QLive = AsyncCalc.live(Q, {
471
+ query: ({ count }) =>
472
+ Effect.sync(() => {
473
+ runs.n += 1
474
+ return count * 2
475
+ }),
476
+ disabled: ({ count }) => count < 0,
477
+ invalidateOn: [Poke],
478
+ })
479
+ const TestLayer = QLive.pipe(
480
+ Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
481
+ Layer.provideMerge(Engine),
482
+ )
483
+
484
+ return Effect.gen(function* () {
485
+ const source = yield* StateGroup.select(Inputs, 'count').store
486
+ const store = yield* Q.store
487
+ yield* tick()
488
+ expect(store.get()._tag).toBe('Idle')
489
+
490
+ // A bump while gated off runs nothing — the driver shows Idle and forgets
491
+ // its key, so the eventual enable always re-runs.
492
+ yield* Event.dispatch(Poke, {})
493
+ yield* tick()
494
+ expect(store.get()._tag).toBe('Idle')
495
+ expect(runs.n).toBe(0)
496
+
497
+ source.set(5)
498
+ yield* tick()
499
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
500
+ expect(runs.n).toBe(1)
501
+ }).pipe(Effect.provide(TestLayer))
502
+ })
503
+
504
+ it.live('invalidateOn: two async calcs keep independent hidden revisions', () => {
505
+ class Count extends State.make('count', S.Number) {}
506
+ class Inputs extends StateGroup.make(Count) {}
507
+ class PokeA extends Event.make('PokeA', S.Struct({})) {}
508
+ class PokeB extends Event.make('PokeB', S.Struct({})) {}
509
+ const runs = { a: 0, b: 0 }
510
+ class QA extends AsyncCalc.make('QA', {
511
+ inputs: [StateGroup.select(Inputs, 'count')],
512
+ output: S.Number,
513
+ alwaysOn: true,
514
+ }) {}
515
+ class QB extends AsyncCalc.make('QB', {
516
+ inputs: [StateGroup.select(Inputs, 'count')],
517
+ output: S.Number,
518
+ alwaysOn: true,
519
+ }) {}
520
+ const TestLayer = Layer.mergeAll(
521
+ AsyncCalc.live(QA, {
522
+ query: ({ count }) =>
523
+ Effect.sync(() => {
524
+ runs.a += 1
525
+ return count
526
+ }),
527
+ invalidateOn: [PokeA],
528
+ }),
529
+ AsyncCalc.live(QB, {
530
+ query: ({ count }) =>
531
+ Effect.sync(() => {
532
+ runs.b += 1
533
+ return count
534
+ }),
535
+ invalidateOn: [PokeB],
536
+ }),
537
+ ).pipe(
538
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
539
+ Layer.provideMerge(Engine),
540
+ )
541
+
542
+ return Effect.gen(function* () {
543
+ yield* tick()
544
+ expect(runs).toEqual({ a: 1, b: 1 })
545
+
546
+ // Each calc's hidden revision is namespaced by its own name: A's fact moves
547
+ // only A.
548
+ yield* Event.dispatch(PokeA, {})
549
+ yield* tick()
550
+ expect(runs).toEqual({ a: 2, b: 1 })
551
+
552
+ yield* Event.dispatch(PokeB, {})
553
+ yield* tick()
554
+ expect(runs).toEqual({ a: 2, b: 2 })
555
+ }).pipe(Effect.provide(TestLayer))
556
+ })