@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,695 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Match, Schema as S } from 'effect'
3
+ import {
4
+ Channels,
5
+ Engine,
6
+ Event,
7
+ Procedures,
8
+ Reducer,
9
+ Reducers,
10
+ RemoteState,
11
+ State,
12
+ StateGroup,
13
+ } from '../index'
14
+
15
+ // RemoteState is server-owned state with optimistic mutations as one primitive:
16
+ // visible = pending.reduce(apply, serverTruth). Dispatching an intent event is
17
+ // the only write surface; a settled or failed intent leaves the queue and the
18
+ // derivation converges. The headline guarantee is the GENERATION RULE: an
19
+ // intent acked at query-run generation g settles only on a Success run with a
20
+ // later generation — the optimistic change never flickers out mid-convergence.
21
+
22
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
23
+
24
+ // Poll until the predicate holds (bounded) — for assertions that race real
25
+ // timers under parallel-suite host load, where a fixed `tick` budget flakes.
26
+ const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
27
+ Effect.suspend(() => {
28
+ const a = read()
29
+ return pred(a) || rounds <= 0
30
+ ? Effect.succeed(a)
31
+ : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
32
+ })
33
+
34
+ // ── Shared fixture: a tiny mutable item server. ─────────────────────────────
35
+
36
+ const Item = S.Struct({ id: S.String, name: S.String })
37
+ type Item = typeof Item.Type
38
+
39
+ class Added extends Event.make('Added', S.Struct({ id: S.String, name: S.String })) {}
40
+ class Renamed extends Event.make('Renamed', S.Struct({ id: S.String, name: S.String })) {}
41
+ type Intent = Event.EventType<typeof Added> | Event.EventType<typeof Renamed>
42
+
43
+ // The per-intent fold — idempotent against truth that already carries the
44
+ // intent (an add whose id landed is skipped; a rename converges naturally).
45
+ const applyIntent = (items: ReadonlyArray<Item>, intent: Intent): ReadonlyArray<Item> =>
46
+ Match.value(intent).pipe(
47
+ Match.tag('Added', ({ id, name }) =>
48
+ items.some((item) => item.id === id) ? items : [...items, { id, name }],
49
+ ),
50
+ Match.tag('Renamed', ({ id, name }) =>
51
+ items.map((item) => (item.id === id ? { ...item, name } : item)),
52
+ ),
53
+ Match.exhaustive,
54
+ )
55
+
56
+ // A server double whose query SNAPSHOTS state at request arrival (like a real
57
+ // DB read) and whose send applies the mutation right before acking.
58
+ const makeServer = (initial: ReadonlyArray<Item>) => {
59
+ const state = { items: initial, fetches: 0 }
60
+ return {
61
+ state,
62
+ fetch: (delayMs: number) =>
63
+ Effect.suspend(() => {
64
+ state.fetches += 1
65
+ const snapshot = state.items
66
+ return Effect.as(tick(delayMs), snapshot)
67
+ }),
68
+ receive: (intent: Intent, delayMs: number) =>
69
+ Effect.flatMap(tick(delayMs), () =>
70
+ Effect.sync(() => {
71
+ state.items = applyIntent(state.items, intent)
72
+ }),
73
+ ),
74
+ }
75
+ }
76
+
77
+ it.live('an intent overlays instantly — no fetch fired, truth untouched', () => {
78
+ class Page extends State.make('page', S.Number) {}
79
+ class Inputs extends StateGroup.make(Page) {}
80
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
81
+ class Items extends RemoteState.make('Items', {
82
+ inputs: [StateGroup.select(Inputs, 'page')],
83
+ output: S.Array(Item),
84
+ intents: [Added, Renamed],
85
+ alwaysOn: true,
86
+ }) {}
87
+ const ItemsLive = RemoteState.live(Items, {
88
+ query: () => server.fetch(5),
89
+ // Slow send: the pure-optimistic window stays open for the assertions.
90
+ send: (intent) => server.receive(intent, 80),
91
+ apply: applyIntent,
92
+ })
93
+ const TestLayer = ItemsLive.pipe(
94
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
95
+ Layer.provideMerge(Engine),
96
+ )
97
+
98
+ return Effect.gen(function* () {
99
+ const store = yield* Items.store
100
+ const truth = yield* Items.truth.store
101
+ const queue = yield* Items.pending.store
102
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
103
+ expect(server.state.fetches).toBe(1)
104
+
105
+ // Dispatch the intent: the overlay shows it before the RPC or any refetch.
106
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
107
+ const optimistic = yield* until(
108
+ () => store.get(),
109
+ (v) => v._tag === 'Success' && v.value.length === 2,
110
+ )
111
+ if (optimistic._tag === 'Success') {
112
+ expect(optimistic.value[1]).toEqual({ id: 'b', name: 'beta' })
113
+ }
114
+ expect(queue.get()).toMatchObject([{ status: 'sending' }])
115
+ // Nothing remote happened yet: one fetch ever, truth still one row.
116
+ expect(server.state.fetches).toBe(1)
117
+ expect(truth.get()).toMatchObject({ _tag: 'Success' })
118
+ const t = truth.get()
119
+ if (t._tag === 'Success') expect(t.value).toHaveLength(1)
120
+ }).pipe(Effect.provide(TestLayer))
121
+ })
122
+
123
+ it.live('convergence: the send acks, the refetch lands, the queue drains — and no frame ever lacked the row', () => {
124
+ class Page extends State.make('page', S.Number) {}
125
+ class Inputs extends StateGroup.make(Page) {}
126
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
127
+ class Items extends RemoteState.make('Items', {
128
+ inputs: [StateGroup.select(Inputs, 'page')],
129
+ output: S.Array(Item),
130
+ intents: [Added, Renamed],
131
+ alwaysOn: true,
132
+ }) {}
133
+ const ItemsLive = RemoteState.live(Items, {
134
+ query: () => server.fetch(15),
135
+ send: (intent) => server.receive(intent, 15),
136
+ apply: applyIntent,
137
+ })
138
+ const TestLayer = ItemsLive.pipe(
139
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
140
+ Layer.provideMerge(Engine),
141
+ )
142
+
143
+ return Effect.gen(function* () {
144
+ const store = yield* Items.store
145
+ const queue = yield* Items.pending.store
146
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
147
+
148
+ // FLICKER GUARD: log every Success frame from dispatch to convergence; the
149
+ // row must be present in all of them (the old settle-on-ack idiom fails
150
+ // this — the row vanished between the RPC response and the refetch landing).
151
+ const frames: Array<number> = []
152
+ const off = store.subscribe(() => {
153
+ const v = store.get()
154
+ if (v._tag === 'Success') frames.push(v.value.length)
155
+ })
156
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
157
+ // Dispatch routes through the bus: wait for the entry to LAND before
158
+ // waiting for it to leave (an empty read here would be vacuous).
159
+ yield* until(() => queue.get(), (q) => q.length === 1)
160
+ yield* until(() => queue.get(), (q) => q.length === 0)
161
+ off()
162
+ expect(frames.length).toBeGreaterThan(0)
163
+ expect(frames.every((n) => n === 2)).toBe(true)
164
+
165
+ const settled = yield* until(
166
+ () => store.get(),
167
+ (v) => v._tag === 'Success' && v.refetching === false,
168
+ )
169
+ if (settled._tag === 'Success') {
170
+ expect(settled.value).toEqual([
171
+ { id: 'a', name: 'alpha' },
172
+ { id: 'b', name: 'beta' },
173
+ ])
174
+ }
175
+ expect(server.state.fetches).toBe(2)
176
+ }).pipe(Effect.provide(TestLayer))
177
+ })
178
+
179
+ it.live('THE GENERATION RULE: a refetch that started before the ack can never settle the intent', () => {
180
+ class Page extends State.make('page', S.Number) {}
181
+ class Inputs extends StateGroup.make(Page) {}
182
+ class Poke extends Event.make('Poke', S.Struct({})) {}
183
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
184
+ class Items extends RemoteState.make('Items', {
185
+ inputs: [StateGroup.select(Inputs, 'page')],
186
+ output: S.Array(Item),
187
+ intents: [Added, Renamed],
188
+ alwaysOn: true,
189
+ }) {}
190
+ const ItemsLive = RemoteState.live(Items, {
191
+ // Slow query: its snapshot is taken at request arrival, so a run started
192
+ // before the send acks carries truth WITHOUT the mutation.
193
+ query: () => server.fetch(60),
194
+ send: (intent) => server.receive(intent, 15),
195
+ apply: applyIntent,
196
+ invalidateOn: [Poke],
197
+ // Trailing: the pre-ack run completes and LANDS (switch would cancel it),
198
+ // which is exactly the stale-Success the rule must refuse to settle on.
199
+ coalesce: 'trailing',
200
+ })
201
+ const TestLayer = ItemsLive.pipe(
202
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
203
+ Layer.provideMerge(Engine),
204
+ )
205
+
206
+ return Effect.gen(function* () {
207
+ const store = yield* Items.store
208
+ const queue = yield* Items.pending.store
209
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
210
+ expect(server.state.fetches).toBe(1)
211
+
212
+ const frames: Array<number> = []
213
+ const off = store.subscribe(() => {
214
+ const v = store.get()
215
+ if (v._tag === 'Success') frames.push(v.value.length)
216
+ })
217
+
218
+ // A refetch starts (generation 2, snapshot WITHOUT the row)…
219
+ yield* Event.dispatch(Poke, {})
220
+ yield* tick(5)
221
+ // …and the intent is dispatched + acked while it is still in flight. The
222
+ // ack registers a waiter for generation 3 and queues the trailing refetch.
223
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
224
+
225
+ // The stale run (gen 2) lands its Success. The rule: it settles NOBODY —
226
+ // the entry stays pending and the overlay keeps the row over stale truth.
227
+ yield* until(() => server.state.fetches, (n) => n === 2)
228
+ yield* tick(25)
229
+ expect(queue.get()).toHaveLength(1)
230
+ expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
231
+ const midflight = store.get()
232
+ expect(midflight).toMatchObject({ _tag: 'Success' })
233
+ if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
234
+
235
+ // The post-ack run (gen 3) lands with the mutation: NOW the entry settles.
236
+ yield* until(() => queue.get(), (q) => q.length === 0)
237
+ off()
238
+ expect(server.state.fetches).toBe(3)
239
+ // The frames before the intent legitimately show one row (the Poke refetch
240
+ // flips `refetching` and notifies). The flicker property is: once the
241
+ // optimistic row APPEARS, no later frame may lack it.
242
+ const firstOptimistic = frames.indexOf(2)
243
+ expect(firstOptimistic).toBeGreaterThanOrEqual(0)
244
+ expect(frames.slice(firstOptimistic).every((n) => n === 2)).toBe(true)
245
+ const settled = store.get()
246
+ if (settled._tag === 'Success') {
247
+ expect(settled.value).toEqual([
248
+ { id: 'a', name: 'alpha' },
249
+ { id: 'b', name: 'beta' },
250
+ ])
251
+ }
252
+ }).pipe(Effect.provide(TestLayer))
253
+ })
254
+
255
+ it.live('a failed send settles the intent, reverts the overlay, and Failed reaches a user reducer', () => {
256
+ class Page extends State.make('page', S.Number) {}
257
+ class Inputs extends StateGroup.make(Page) {}
258
+ class FailLog extends State.make('failLog', S.Array(S.String)) {}
259
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
260
+ class Items extends RemoteState.make('Items', {
261
+ inputs: [StateGroup.select(Inputs, 'page')],
262
+ output: S.Array(Item),
263
+ intents: [Added, Renamed],
264
+ alwaysOn: true,
265
+ }) {}
266
+ const ItemsLive = RemoteState.live(Items, {
267
+ query: () => server.fetch(5),
268
+ send: () => Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(15))),
269
+ apply: applyIntent,
270
+ })
271
+ // The toast/retry seam: an ordinary reducer on the public Failed event.
272
+ const LogReducer = Reducer.make('failLog', { states: [FailLog], events: [Items.Failed] })
273
+ const TestLayer = Layer.mergeAll(
274
+ ItemsLive,
275
+ Reducer.live(LogReducer, (log, event) => [...log, `${event.intent._tag}|${String(event.error)}`]),
276
+ ).pipe(
277
+ Layer.provideMerge(State.live(FailLog, [])),
278
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
279
+ Layer.provideMerge(Engine),
280
+ )
281
+
282
+ return Effect.gen(function* () {
283
+ const store = yield* Items.store
284
+ const queue = yield* Items.pending.store
285
+ const log = yield* FailLog.store
286
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
287
+
288
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
289
+ yield* until(() => store.get(), (v) => v._tag === 'Success' && v.value.length === 2)
290
+ expect(queue.get()).toHaveLength(1)
291
+
292
+ // The send fails: entry leaves the queue, the view reverts by construction,
293
+ // and the Failed payload carried the intent + the error.
294
+ yield* until(() => queue.get(), (q) => q.length === 0)
295
+ const reverted = yield* until(
296
+ () => store.get(),
297
+ (v) => v._tag === 'Success' && v.value.length === 1,
298
+ )
299
+ if (reverted._tag === 'Success') {
300
+ expect(reverted.value).toEqual([{ id: 'a', name: 'alpha' }])
301
+ }
302
+ yield* until(() => log.get(), (l) => l.length === 1)
303
+ expect(log.get()).toEqual(['Added|rejected'])
304
+ // No invalidation on failure: the one boot fetch is still the only one.
305
+ expect(server.state.fetches).toBe(1)
306
+ }).pipe(Effect.provide(TestLayer))
307
+ })
308
+
309
+ it.live('two intents apply in dispatch order and settle together on the post-ack run', () => {
310
+ class Page extends State.make('page', S.Number) {}
311
+ class Inputs extends StateGroup.make(Page) {}
312
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
313
+ class Items extends RemoteState.make('Items', {
314
+ inputs: [StateGroup.select(Inputs, 'page')],
315
+ output: S.Array(Item),
316
+ intents: [Added, Renamed],
317
+ alwaysOn: true,
318
+ }) {}
319
+ const ItemsLive = RemoteState.live(Items, {
320
+ query: () => server.fetch(15),
321
+ send: (intent) => server.receive(intent, 15),
322
+ apply: applyIntent,
323
+ })
324
+ const TestLayer = ItemsLive.pipe(
325
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
326
+ Layer.provideMerge(Engine),
327
+ )
328
+
329
+ return Effect.gen(function* () {
330
+ const store = yield* Items.store
331
+ const queue = yield* Items.pending.store
332
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
333
+
334
+ // Order matters: the rename targets the row the add creates. Reversed
335
+ // application would rename nothing and show 'beta'.
336
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
337
+ yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
338
+ yield* until(() => queue.get(), (q) => q.length === 2)
339
+ const optimistic = store.get()
340
+ expect(optimistic).toMatchObject({ _tag: 'Success' })
341
+ if (optimistic._tag === 'Success') {
342
+ expect(optimistic.value[1]).toEqual({ id: 'b', name: 'BETA' })
343
+ }
344
+
345
+ yield* until(() => queue.get(), (q) => q.length === 0)
346
+ const settled = yield* until(
347
+ () => store.get(),
348
+ (v) => v._tag === 'Success' && v.refetching === false,
349
+ )
350
+ if (settled._tag === 'Success') {
351
+ expect(settled.value).toEqual([
352
+ { id: 'a', name: 'alpha' },
353
+ { id: 'b', name: 'BETA' },
354
+ ])
355
+ }
356
+ }).pipe(Effect.provide(TestLayer))
357
+ })
358
+
359
+ it.live('one intent failing leaves the other pending and converging', () => {
360
+ class Page extends State.make('page', S.Number) {}
361
+ class Inputs extends StateGroup.make(Page) {}
362
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
363
+ class Items extends RemoteState.make('Items', {
364
+ inputs: [StateGroup.select(Inputs, 'page')],
365
+ output: S.Array(Item),
366
+ intents: [Added, Renamed],
367
+ alwaysOn: true,
368
+ }) {}
369
+ const ItemsLive = RemoteState.live(Items, {
370
+ query: () => server.fetch(10),
371
+ // The rename is rejected; the add is much slower and succeeds — so the
372
+ // partial state (one failed, one in flight) is observable.
373
+ send: (intent) =>
374
+ intent._tag === 'Renamed'
375
+ ? Effect.fail('rejected' as const).pipe(Effect.delay(Duration.millis(50)))
376
+ : server.receive(intent, 150),
377
+ apply: applyIntent,
378
+ })
379
+ const TestLayer = ItemsLive.pipe(
380
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
381
+ Layer.provideMerge(Engine),
382
+ )
383
+
384
+ return Effect.gen(function* () {
385
+ const store = yield* Items.store
386
+ const queue = yield* Items.pending.store
387
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
388
+
389
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
390
+ yield* Event.dispatch(Renamed, { id: 'a', name: 'ALPHA' })
391
+ // Both folded: the overlay shows the rename optimistically.
392
+ yield* until(
393
+ () => store.get(),
394
+ (v) => v._tag === 'Success' && v.value[0]?.name === 'ALPHA',
395
+ )
396
+ expect(queue.get()).toHaveLength(2)
397
+
398
+ // The rename fails and leaves; the add stays optimistic and intact.
399
+ yield* until(
400
+ () => store.get(),
401
+ (v) => v._tag === 'Success' && v.value[0]?.name === 'alpha',
402
+ )
403
+ expect(queue.get()).toHaveLength(1)
404
+ expect(queue.get()[0]).toMatchObject({ status: 'sending', intent: { _tag: 'Added' } })
405
+ const partial = store.get()
406
+ if (partial._tag === 'Success') {
407
+ expect(partial.value).toEqual([
408
+ { id: 'a', name: 'alpha' },
409
+ { id: 'b', name: 'beta' },
410
+ ])
411
+ }
412
+
413
+ // The add converges independently.
414
+ yield* until(() => queue.get(), (q) => q.length === 0)
415
+ const settled = yield* until(
416
+ () => store.get(),
417
+ (v) => v._tag === 'Success' && v.refetching === false,
418
+ )
419
+ if (settled._tag === 'Success') {
420
+ expect(settled.value).toEqual([
421
+ { id: 'a', name: 'alpha' },
422
+ { id: 'b', name: 'beta' },
423
+ ])
424
+ }
425
+ }).pipe(Effect.provide(TestLayer))
426
+ })
427
+
428
+ it.live("pending exposes the lifecycle: 'sending' -> 'confirmed' -> gone", () => {
429
+ class Page extends State.make('page', S.Number) {}
430
+ class Inputs extends StateGroup.make(Page) {}
431
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
432
+ class Items extends RemoteState.make('Items', {
433
+ inputs: [StateGroup.select(Inputs, 'page')],
434
+ output: S.Array(Item),
435
+ intents: [Added, Renamed],
436
+ alwaysOn: true,
437
+ }) {}
438
+ const ItemsLive = RemoteState.live(Items, {
439
+ // A slow refetch keeps the 'confirmed' window wide enough to observe.
440
+ query: () => server.fetch(40),
441
+ send: (intent) => server.receive(intent, 40),
442
+ apply: applyIntent,
443
+ })
444
+ const TestLayer = ItemsLive.pipe(
445
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
446
+ Layer.provideMerge(Engine),
447
+ )
448
+
449
+ return Effect.gen(function* () {
450
+ const store = yield* Items.store
451
+ const queue = yield* Items.pending.store
452
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
453
+
454
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
455
+ const sending = yield* until(() => queue.get(), (q) => q.length === 1)
456
+ expect(sending[0]).toMatchObject({ status: 'sending', intent: { _tag: 'Added', id: 'b' } })
457
+
458
+ const confirmed = yield* until(
459
+ () => queue.get(),
460
+ (q) => q.length === 1 && q[0]?.status === 'confirmed',
461
+ )
462
+ expect(confirmed[0]).toMatchObject({ status: 'confirmed' })
463
+
464
+ yield* until(() => queue.get(), (q) => q.length === 0)
465
+ expect(queue.get()).toEqual([])
466
+ }).pipe(Effect.provide(TestLayer))
467
+ })
468
+
469
+ it.live('gated: intents queue and send while Idle; re-enabling converges and settles', () => {
470
+ class Page extends State.make('page', S.Number) {}
471
+ class Inputs extends StateGroup.make(Page) {}
472
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
473
+ class Items extends RemoteState.make('Items', {
474
+ inputs: [StateGroup.select(Inputs, 'page')],
475
+ output: S.Array(Item),
476
+ intents: [Added, Renamed],
477
+ }) {}
478
+ const ItemsLive = RemoteState.live(Items, {
479
+ query: () => server.fetch(5),
480
+ send: (intent) => server.receive(intent, 10),
481
+ apply: applyIntent,
482
+ disabled: ({ page }) => page < 0,
483
+ })
484
+ const TestLayer = ItemsLive.pipe(
485
+ Layer.provideMerge(StateGroup.live(Inputs, { page: -1 })),
486
+ Layer.provideMerge(Engine),
487
+ )
488
+
489
+ return Effect.gen(function* () {
490
+ const source = yield* StateGroup.select(Inputs, 'page').store
491
+ const store = yield* Items.store
492
+ const queue = yield* Items.pending.store
493
+ yield* tick()
494
+ expect(store.get()._tag).toBe('Idle')
495
+
496
+ // The intent still sends while the query is gated off: the server takes
497
+ // the mutation, but the view stays Idle and the entry waits for a run.
498
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
499
+ yield* until(() => server.state.items, (items) => items.length === 2)
500
+ yield* tick(20)
501
+ expect(store.get()._tag).toBe('Idle')
502
+ expect(queue.get()).toHaveLength(1)
503
+ expect(server.state.fetches).toBe(0)
504
+
505
+ // Re-enable: the run lands with the mutation and settles the waiter.
506
+ source.set(1)
507
+ yield* until(() => queue.get(), (q) => q.length === 0)
508
+ const settled = yield* until(
509
+ () => store.get(),
510
+ (v) => v._tag === 'Success' && v.refetching === false,
511
+ )
512
+ if (settled._tag === 'Success') {
513
+ expect(settled.value).toEqual([
514
+ { id: 'a', name: 'alpha' },
515
+ { id: 'b', name: 'beta' },
516
+ ])
517
+ }
518
+ }).pipe(Effect.provide(TestLayer))
519
+ })
520
+
521
+ it.live('reuse: untouched element identities survive the whole optimistic round-trip', () => {
522
+ class Page extends State.make('page', S.Number) {}
523
+ class Inputs extends StateGroup.make(Page) {}
524
+ const server = makeServer([
525
+ { id: 'a', name: 'alpha' },
526
+ { id: 'b', name: 'beta' },
527
+ ])
528
+ class Items extends RemoteState.make('Items', {
529
+ inputs: [StateGroup.select(Inputs, 'page')],
530
+ output: S.Array(Item),
531
+ intents: [Added, Renamed],
532
+ alwaysOn: true,
533
+ }) {}
534
+ const ItemsLive = RemoteState.live(Items, {
535
+ // Fresh objects every fetch — only `reuse` can preserve identities.
536
+ query: () => Effect.map(server.fetch(10), (items) => items.map((item) => ({ ...item }))),
537
+ send: (intent) => server.receive(intent, 10),
538
+ apply: applyIntent,
539
+ reuse: true,
540
+ })
541
+ const TestLayer = ItemsLive.pipe(
542
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
543
+ Layer.provideMerge(Engine),
544
+ )
545
+
546
+ return Effect.gen(function* () {
547
+ const store = yield* Items.store
548
+ const queue = yield* Items.pending.store
549
+ const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
550
+ if (first._tag !== 'Success') return
551
+ const untouched = first.value[0]
552
+
553
+ yield* Event.dispatch(Renamed, { id: 'b', name: 'BETA' })
554
+ yield* until(() => queue.get(), (q) => q.length === 1)
555
+ yield* until(() => queue.get(), (q) => q.length === 0)
556
+ const settled = yield* until(
557
+ () => store.get(),
558
+ (v) => v._tag === 'Success' && v.refetching === false,
559
+ )
560
+ if (settled._tag !== 'Success') return
561
+ // Row a was never touched: same identity through the overlay recompute AND
562
+ // the convergence refetch. Row b moved: fresh.
563
+ expect(settled.value[0]).toBe(untouched)
564
+ expect(settled.value[1]).not.toBe(first.value[1])
565
+ expect(settled.value[1]).toEqual({ id: 'b', name: 'BETA' })
566
+ }).pipe(Effect.provide(TestLayer))
567
+ })
568
+
569
+ it.live('invalidateOn + coalesce trailing compose with the mutation lifecycle', () => {
570
+ class Page extends State.make('page', S.Number) {}
571
+ class Inputs extends StateGroup.make(Page) {}
572
+ class BoardChanged extends Event.make('BoardChanged', S.Struct({})) {}
573
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
574
+ class Items extends RemoteState.make('Items', {
575
+ inputs: [StateGroup.select(Inputs, 'page')],
576
+ output: S.Array(Item),
577
+ intents: [Added, Renamed],
578
+ alwaysOn: true,
579
+ }) {}
580
+ const ItemsLive = RemoteState.live(Items, {
581
+ query: () => server.fetch(10),
582
+ send: (intent) => server.receive(intent, 10),
583
+ apply: applyIntent,
584
+ invalidateOn: [BoardChanged],
585
+ coalesce: 'trailing',
586
+ })
587
+ const TestLayer = ItemsLive.pipe(
588
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
589
+ Layer.provideMerge(Engine),
590
+ )
591
+
592
+ return Effect.gen(function* () {
593
+ const store = yield* Items.store
594
+ const queue = yield* Items.pending.store
595
+ yield* until(() => store.get(), (v) => v._tag === 'Success')
596
+ expect(server.state.fetches).toBe(1)
597
+
598
+ // A realtime push refetches like any invalidateOn event.
599
+ yield* Event.dispatch(BoardChanged, {})
600
+ yield* until(() => server.state.fetches, (n) => n === 2)
601
+
602
+ // The mutation lifecycle still converges under trailing conciliation.
603
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
604
+ yield* until(() => queue.get(), (q) => q.length === 1)
605
+ yield* until(() => queue.get(), (q) => q.length === 0)
606
+ const settled = yield* until(
607
+ () => store.get(),
608
+ (v) => v._tag === 'Success' && v.refetching === false,
609
+ )
610
+ if (settled._tag === 'Success') {
611
+ expect(settled.value).toEqual([
612
+ { id: 'a', name: 'alpha' },
613
+ { id: 'b', name: 'beta' },
614
+ ])
615
+ }
616
+ }).pipe(Effect.provide(TestLayer))
617
+ })
618
+
619
+ it.live('a query Error passes through by reference with intents still pending', () => {
620
+ class Page extends State.make('page', S.Number) {}
621
+ class Inputs extends StateGroup.make(Page) {}
622
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
623
+ class Items extends RemoteState.make('Items', {
624
+ inputs: [StateGroup.select(Inputs, 'page')],
625
+ output: S.Array(Item),
626
+ error: S.String,
627
+ intents: [Added, Renamed],
628
+ alwaysOn: true,
629
+ }) {}
630
+ const ItemsLive = RemoteState.live(Items, {
631
+ query: () => Effect.fail('boom').pipe(Effect.delay(Duration.millis(5))),
632
+ send: (intent) => server.receive(intent, 10),
633
+ apply: applyIntent,
634
+ })
635
+ const TestLayer = ItemsLive.pipe(
636
+ Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })),
637
+ Layer.provideMerge(Engine),
638
+ )
639
+
640
+ return Effect.gen(function* () {
641
+ const store = yield* Items.store
642
+ const truth = yield* Items.truth.store
643
+ const queue = yield* Items.pending.store
644
+ yield* until(() => store.get(), (v) => v._tag === 'Error')
645
+
646
+ // The send succeeds and invalidates, but every run lands Error: an Error
647
+ // run settles NOBODY, so the entry stays pending (confirmed) — and the
648
+ // overlay passes the Error arm through by reference, never applying.
649
+ yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
650
+ yield* until(() => queue.get(), (q) => q.length === 1 && q[0]?.status === 'confirmed')
651
+ yield* until(() => store.get(), (v) => v._tag === 'Error' && v.refetching === false)
652
+ yield* tick(20)
653
+ expect(queue.get()).toHaveLength(1)
654
+ expect(store.get()).toBe(truth.get())
655
+ }).pipe(Effect.provide(TestLayer))
656
+ })
657
+
658
+ it.live('teardown: closing the scope unregisters the reducers, the procedure, and the channel', () => {
659
+ class Page extends State.make('page', S.Number) {}
660
+ class Inputs extends StateGroup.make(Page) {}
661
+ const server = makeServer([{ id: 'a', name: 'alpha' }])
662
+ class Items extends RemoteState.make('Items', {
663
+ inputs: [StateGroup.select(Inputs, 'page')],
664
+ output: S.Array(Item),
665
+ intents: [Added, Renamed],
666
+ alwaysOn: true,
667
+ }) {}
668
+ const ItemsLive = RemoteState.live(Items, {
669
+ query: () => server.fetch(5),
670
+ send: (intent) => server.receive(intent, 5),
671
+ apply: applyIntent,
672
+ }).pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
673
+
674
+ return Effect.gen(function* () {
675
+ const reducers = yield* Reducers
676
+ const procedures = yield* Procedures
677
+ const channels = yield* Channels
678
+
679
+ yield* Effect.scoped(
680
+ Effect.gen(function* () {
681
+ yield* Layer.build(ItemsLive)
682
+ expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(true)
683
+ expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(true)
684
+ expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(true)
685
+ expect(channels.byName.has('Items/sends')).toBe(true)
686
+ }),
687
+ )
688
+
689
+ // Scope closed: every registration is gone (a lazy feature unmounting).
690
+ expect(reducers.entries.some((entry) => entry.name === 'Items/pending')).toBe(false)
691
+ expect(reducers.entries.some((entry) => entry.name === 'Items/revision')).toBe(false)
692
+ expect(procedures.entries.some((entry) => entry.name === 'Items/send')).toBe(false)
693
+ expect(channels.byName.has('Items/sends')).toBe(false)
694
+ }).pipe(Effect.provide(Engine))
695
+ })