@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.7",
4
+ "version": "0.0.8",
5
5
  "type": "module",
6
6
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
7
7
  "keywords": [
@@ -28,7 +28,7 @@
28
28
  "./*": "./src/*.ts"
29
29
  },
30
30
  "files": [
31
- "dist",
31
+ "src",
32
32
  "README.md"
33
33
  ],
34
34
  "scripts": {
@@ -0,0 +1,301 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
+ import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
4
+
5
+ // Boundary merges several async lifecycles into one Pending/Errored/Ready value
6
+ // — the Suspense-boundary analog, value-level. The behaviours under test: the
7
+ // arm mapping (Loading pends, Idle and SWR-refetching count Ready, Error
8
+ // surfaces), the chained-gate settle-gap pin, the `once` latch, and the
9
+ // notification economy (arm-stable churn wakes nobody).
10
+
11
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
12
+
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('pending while ANY covered query is on its first load; one Ready when all settle', () => {
24
+ class Seed extends State.make('seed', S.Number) {}
25
+ class Inputs extends StateGroup.make(Seed) {}
26
+ class Fast extends AsyncCalc.make('Fast', {
27
+ inputs: [StateGroup.select(Inputs, 'seed')],
28
+ output: S.Number,
29
+ alwaysOn: true,
30
+ }) {}
31
+ class Slow extends AsyncCalc.make('Slow', {
32
+ inputs: [StateGroup.select(Inputs, 'seed')],
33
+ output: S.Number,
34
+ alwaysOn: true,
35
+ }) {}
36
+ class Gate extends Boundary.make('Gate', { over: [Fast, Slow] }) {}
37
+ const TestLayer = Boundary.live(Gate).pipe(
38
+ Layer.provideMerge(
39
+ Layer.merge(
40
+ AsyncCalc.live(Fast, { query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('10 millis')) }),
41
+ AsyncCalc.live(Slow, { query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('40 millis')) }),
42
+ ),
43
+ ),
44
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
45
+ )
46
+ return Effect.gen(function* () {
47
+ const store = yield* Gate.store
48
+ const observed: Array<string> = []
49
+ store.subscribe(() => {
50
+ observed.push(store.get()._tag)
51
+ })
52
+ expect(store.get()._tag).toBe('Pending')
53
+
54
+ // The fast query settles first — still Pending (the slow one holds it).
55
+ yield* tick(20)
56
+ expect(store.get()._tag).toBe('Pending')
57
+
58
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
59
+ // Exactly ONE notification fired across the whole boot: Pending→Pending
60
+ // churn (Fast settling) was absorbed by the stable arm reference.
61
+ expect(observed).toEqual(['Ready'])
62
+ }).pipe(Effect.provide(TestLayer))
63
+ })
64
+
65
+ it.live('a gated query that is OFF counts Ready — the off arm must not hold the gate', () => {
66
+ class Flag extends State.make('flag', S.Boolean) {}
67
+ class Inputs extends StateGroup.make(Flag) {}
68
+ class Always extends AsyncCalc.make('Always', {
69
+ inputs: [StateGroup.select(Inputs, 'flag')],
70
+ output: S.Number,
71
+ alwaysOn: true,
72
+ }) {}
73
+ class Gated extends AsyncCalc.make('Gated', {
74
+ inputs: [StateGroup.select(Inputs, 'flag')],
75
+ output: S.Number,
76
+ }) {}
77
+ class Gate extends Boundary.make('Gate', { over: [Always, Gated] }) {}
78
+ const TestLayer = Boundary.live(Gate).pipe(
79
+ Layer.provideMerge(
80
+ Layer.merge(
81
+ AsyncCalc.live(Always, { query: () => Effect.succeed(1) }),
82
+ AsyncCalc.live(Gated, { query: () => Effect.succeed(2), disabled: ({ flag }) => !flag }),
83
+ ),
84
+ ),
85
+ Layer.provideMerge(StateGroup.live(Inputs, { flag: false })),
86
+ )
87
+ return Effect.gen(function* () {
88
+ const store = yield* Gate.store
89
+ // The gated query is Idle (deliberately off): only the always-on first
90
+ // load pends, and Ready arrives without ever waiting on the Idle arm.
91
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
92
+ expect(store.get()._tag).toBe('Ready')
93
+ }).pipe(Effect.provide(TestLayer))
94
+ })
95
+
96
+ it.live('an SWR refetch never re-pends — Success with refetching: true is Ready', () => {
97
+ class Seed extends State.make('seed', S.Number) {}
98
+ class Inputs extends StateGroup.make(Seed) {}
99
+ class Q extends AsyncCalc.make('Q', {
100
+ inputs: [StateGroup.select(Inputs, 'seed')],
101
+ output: S.Number,
102
+ alwaysOn: true,
103
+ }) {}
104
+ class Gate extends Boundary.make('Gate', { over: [Q] }) {}
105
+ const TestLayer = Boundary.live(Gate).pipe(
106
+ Layer.provideMerge(
107
+ AsyncCalc.live(Q, {
108
+ query: ({ seed }) => Effect.succeed(seed * 2).pipe(Effect.delay('15 millis')),
109
+ }),
110
+ ),
111
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
112
+ )
113
+ return Effect.gen(function* () {
114
+ const seed = yield* StateGroup.select(Inputs, 'seed').store
115
+ const qStore = yield* Q.store
116
+ const store = yield* Gate.store
117
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
118
+
119
+ // Move the input: the query goes Success+refetching (SWR) — Ready holds.
120
+ seed.set(2)
121
+ yield* until(() => qStore.get(), (q) => q._tag === 'Success' && q.refetching)
122
+ expect(store.get()._tag).toBe('Ready')
123
+ yield* until(() => qStore.get(), (q) => q._tag === 'Success' && !q.refetching)
124
+ expect(store.get()._tag).toBe('Ready')
125
+ }).pipe(Effect.provide(TestLayer))
126
+ })
127
+
128
+ it.live('a first-load failure surfaces as Errored with the failure, and a retry recovers', () => {
129
+ class Seed extends State.make('seed', S.Number) {}
130
+ class Inputs extends StateGroup.make(Seed) {}
131
+ class Poke extends Event.make('Poke', S.Struct({})) {}
132
+ const healthy = { now: false }
133
+ class Q extends AsyncCalc.make('Q', {
134
+ inputs: [StateGroup.select(Inputs, 'seed')],
135
+ output: S.Number,
136
+ error: S.instanceOf(Boom),
137
+ alwaysOn: true,
138
+ }) {}
139
+ class Gate extends Boundary.make('Gate', { over: [Q] }) {}
140
+ const TestLayer = Boundary.live(Gate).pipe(
141
+ Layer.provideMerge(
142
+ AsyncCalc.live(Q, {
143
+ query: () =>
144
+ healthy.now ? Effect.succeed(1) : Effect.fail(new Boom({ message: 'server down' })),
145
+ invalidateOn: [Poke],
146
+ }),
147
+ ),
148
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
149
+ Layer.provideMerge(Engine),
150
+ )
151
+ return Effect.gen(function* () {
152
+ const store = yield* Gate.store
153
+ const errored = yield* until(() => store.get(), (s) => s._tag === 'Errored')
154
+ if (errored._tag === 'Errored') {
155
+ expect(errored.errors).toHaveLength(1)
156
+ const failure = errored.errors[0]
157
+ expect(failure instanceof Boom && failure.message).toBe('server down')
158
+ }
159
+
160
+ // The retry path: the server heals, a listed fact refetches, Ready lands.
161
+ healthy.now = true
162
+ yield* Event.dispatch(Poke, {})
163
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
164
+ expect(store.get()._tag).toBe('Ready')
165
+ }).pipe(Effect.provide(TestLayer))
166
+ })
167
+
168
+ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
169
+ // B enables only when A has succeeded — the app's Session → Bootstrap shape.
170
+ // The dangerous instant: A flips Success while B is still Idle (Idle counts
171
+ // Ready), so a naive merge could notify Ready before B's driver flips it to
172
+ // Loading. Layer dependency order (B's driver subscribes to A's store before
173
+ // the boundary does) plus the one-microtask fixpoint flush close the gap;
174
+ // this records every notification to prove no intermediate Ready escapes.
175
+ class Seed extends State.make('seed', S.Number) {}
176
+ class Inputs extends StateGroup.make(Seed) {}
177
+ class A extends AsyncCalc.make('A', {
178
+ inputs: [StateGroup.select(Inputs, 'seed')],
179
+ output: S.Number,
180
+ alwaysOn: true,
181
+ }) {}
182
+ class B extends AsyncCalc.make('B', {
183
+ inputs: [A],
184
+ output: S.Number,
185
+ }) {}
186
+ class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
187
+ const TestLayer = Boundary.live(Gate).pipe(
188
+ Layer.provideMerge(
189
+ AsyncCalc.live(B, {
190
+ query: () => Effect.succeed(2).pipe(Effect.delay('15 millis')),
191
+ disabled: ({ A: a }) => a._tag !== 'Success',
192
+ }),
193
+ ),
194
+ Layer.provideMerge(
195
+ AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
196
+ ),
197
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
198
+ )
199
+ return Effect.gen(function* () {
200
+ const store = yield* Gate.store
201
+ const observed: Array<string> = []
202
+ store.subscribe(() => {
203
+ observed.push(store.get()._tag)
204
+ })
205
+ expect(store.get()._tag).toBe('Pending')
206
+
207
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
208
+ yield* tick(30) // let any straggler notification land before judging
209
+ // ONE Ready, at the end — never a Ready while B's hop was still pending.
210
+ expect(observed).toEqual(['Ready'])
211
+ }).pipe(Effect.provide(TestLayer))
212
+ })
213
+
214
+ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Ready', () => {
215
+ // The app's boot shape: AsyncCalc A → plain Calc projecting it → AsyncCalc B
216
+ // gated on the projection. Propagation crosses TWO scheduler-flush rounds,
217
+ // so when A settles there is a mid-flush frame where B has not yet flipped
218
+ // to Loading — a recompute-time latch would freeze that intermediate Ready
219
+ // and the boundary would abandon B's whole first load (caught live by the
220
+ // boot-frames walk: nav in, boards card still spinning).
221
+ class Seed extends State.make('seed', S.Number) {}
222
+ class Inputs extends StateGroup.make(Seed) {}
223
+ class A extends AsyncCalc.make('A', {
224
+ inputs: [StateGroup.select(Inputs, 'seed')],
225
+ output: S.Number,
226
+ alwaysOn: true,
227
+ }) {}
228
+ class Hop extends Calc.make('Hop', { inputs: [A], output: S.Number }) {}
229
+ class B extends AsyncCalc.make('B', {
230
+ inputs: [Hop],
231
+ output: S.Number,
232
+ }) {}
233
+ class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
234
+ const TestLayer = Boundary.live(Gate, { once: true }).pipe(
235
+ Layer.provideMerge(
236
+ AsyncCalc.live(B, {
237
+ query: () => Effect.succeed(2).pipe(Effect.delay('25 millis')),
238
+ disabled: ({ Hop: hop }) => hop < 0,
239
+ }),
240
+ ),
241
+ Layer.provideMerge(Calc.live(Hop, ({ A: a }) => (a._tag === 'Success' ? a.value : -1))),
242
+ Layer.provideMerge(
243
+ AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
244
+ ),
245
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
246
+ )
247
+ return Effect.gen(function* () {
248
+ const bStore = yield* B.store
249
+ const store = yield* Gate.store
250
+ // While B's first load is in flight the boundary MUST still be Pending —
251
+ // a poisoned latch would already report Ready here.
252
+ yield* until(() => bStore.get()._tag, (t) => t === 'Loading')
253
+ expect(store.get()._tag).toBe('Pending')
254
+ yield* until(() => bStore.get()._tag, (t) => t === 'Success')
255
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
256
+ expect(store.get()._tag).toBe('Ready')
257
+ }).pipe(Effect.provide(TestLayer))
258
+ })
259
+
260
+ it.live('once: a covered gate enabling AFTER the latch never re-pends the boundary', () => {
261
+ // The boot-boundary semantics: a route-gated query (the board) first loads
262
+ // on a later navigation — its own screen owns that loading state, the shell
263
+ // splash must not come back.
264
+ class Route extends State.make('routeFlag', S.Boolean) {}
265
+ class Inputs extends StateGroup.make(Route) {}
266
+ class Always extends AsyncCalc.make('Always', {
267
+ inputs: [StateGroup.select(Inputs, 'routeFlag')],
268
+ output: S.Number,
269
+ alwaysOn: true,
270
+ }) {}
271
+ class Routed extends AsyncCalc.make('Routed', {
272
+ inputs: [StateGroup.select(Inputs, 'routeFlag')],
273
+ output: S.Number,
274
+ }) {}
275
+ class Gate extends Boundary.make('Gate', { over: [Always, Routed] }) {}
276
+ const TestLayer = Boundary.live(Gate, { once: true }).pipe(
277
+ Layer.provideMerge(
278
+ Layer.merge(
279
+ AsyncCalc.live(Always, { query: () => Effect.succeed(1) }),
280
+ AsyncCalc.live(Routed, {
281
+ query: () => Effect.succeed(2).pipe(Effect.delay('20 millis')),
282
+ disabled: ({ routeFlag }) => !routeFlag,
283
+ }),
284
+ ),
285
+ ),
286
+ Layer.provideMerge(StateGroup.live(Inputs, { routeFlag: false })),
287
+ )
288
+ return Effect.gen(function* () {
289
+ const route = yield* StateGroup.select(Inputs, 'routeFlag').store
290
+ const routedStore = yield* Routed.store
291
+ const store = yield* Gate.store
292
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
293
+
294
+ // "Navigate": the routed query enables and goes Loading — latched Ready holds.
295
+ route.set(true)
296
+ yield* until(() => routedStore.get()._tag, (t) => t === 'Loading')
297
+ expect(store.get()._tag).toBe('Ready')
298
+ yield* until(() => routedStore.get()._tag, (t) => t === 'Success')
299
+ expect(store.get()._tag).toBe('Ready')
300
+ }).pipe(Effect.provide(TestLayer))
301
+ })
@@ -0,0 +1,192 @@
1
+ import { Context, Data, Effect, Layer, MutableRef, Predicate } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import { resolveScheduler } from '../internal/scheduler'
4
+ import { type InputStores, sameKey, wireSources } from '../internal/sources'
5
+ import { makeDerivedStore, type Store } from '../internal/store'
6
+ import { readTracked } from '../internal/track'
7
+ import { type Source } from '../state/token'
8
+
9
+ // Boundary: the Suspense-boundary analog, value-level. One derived store that
10
+ // merges the lifecycles of several async sources into a single
11
+ // Pending/Errored/Ready value, so ONE surface (a splash, a skeleton) covers a
12
+ // whole subtree's first load instead of every query consumer painting its own.
13
+ //
14
+ // Reform can do this without React's throw-a-promise machinery because
15
+ // fetching is layer-driven, not render-driven: an `AsyncCalc` runs because its
16
+ // layer is built, never because something rendered. A boundary therefore only
17
+ // *aggregates* lifecycles — it cannot create render-waterfalls, and content it
18
+ // reveals already has its data.
19
+ //
20
+ // The arm mapping IS the semantics, and most of it falls out of `AsyncData`:
21
+ // Loading → Pending (the arm only exists before the first value,
22
+ // so a boundary is first-load-only by construction)
23
+ // Success → Ready (even `refetching: true` — SWR refetches never
24
+ // re-show the fallback; no transition machinery)
25
+ // Idle → Ready (a gated query that is off is DELIBERATELY off —
26
+ // a signed-out viewer must not wait on a query
27
+ // that only runs signed-in)
28
+ // Error → Errored (the error-boundary half, also value-level)
29
+ //
30
+ // Chained gates (B enables when A succeeds) cannot leak a Ready between hops:
31
+ // the boundary's layer requires every covered store, so each driver subscribes
32
+ // upstream BEFORE the boundary does, the scheduler flush runs listeners in
33
+ // subscription order to a fixpoint within one microtask, and reads are
34
+ // pull-fresh — by the time any subscriber reads, the next hop is already
35
+ // Loading. `boundary.test.ts` pins this with a recorded-notification proof.
36
+
37
+ /** Some covered source is still on its FIRST load — show the fallback. */
38
+ export interface BoundaryPending {
39
+ readonly _tag: 'Pending'
40
+ }
41
+
42
+ /** Some covered source failed before its first value. */
43
+ export interface BoundaryErrored {
44
+ readonly _tag: 'Errored'
45
+ /** The failures, in `over` order (heterogeneous — display via `String`). */
46
+ readonly errors: ReadonlyArray<unknown>
47
+ }
48
+
49
+ /** Every covered source has settled (or is deliberately gated off). */
50
+ export interface BoundaryReady {
51
+ readonly _tag: 'Ready'
52
+ }
53
+
54
+ export type BoundaryState = BoundaryPending | BoundaryErrored | BoundaryReady
55
+
56
+ // Stable arm references: repeated recomputes return the same object, so the
57
+ // derived store's Equal gate (and `useSyncExternalStore`'s stable-snapshot
58
+ // contract) hold without a wrapper. `Errored` carries data, so it is a `Data`
59
+ // struct — value-equal failures coalesce the same way.
60
+ const pendingArm: BoundaryState = { _tag: 'Pending' }
61
+ const readyArm: BoundaryState = { _tag: 'Ready' }
62
+ const erroredArm = (errors: ReadonlyArray<unknown>): BoundaryState =>
63
+ Data.struct({ _tag: 'Errored' as const, errors: Data.array(errors) })
64
+
65
+ /**
66
+ * Anything a boundary can cover: a `Source` whose value is an async lifecycle —
67
+ * an `AsyncCalc`, or anything lifecycle-valued built over one (e.g. a
68
+ * `RemoteState` overlay). The constraint is the same erased `Source` every calc
69
+ * input uses (store invariance rules out a fixed lifecycle value type — each
70
+ * definition's union carries its own `Gated`/`E` arms); the captured `const`
71
+ * tuple keeps each member's precise store requirement, and the runtime merge
72
+ * reads arms through structural guards, so a non-lifecycle source simply
73
+ * counts as settled.
74
+ */
75
+ export type AnyLifecycleSource = Source<string, any>
76
+
77
+ export interface BoundaryManifest<N extends string> extends Manifest {
78
+ readonly kind: 'Boundary'
79
+ readonly name: N
80
+ }
81
+
82
+ export interface BoundaryClass<out N extends string, out Over extends ReadonlyArray<AnyLifecycleSource>>
83
+ extends Effect.Effect<BoundaryState, never, Store<BoundaryState>> {
84
+ new (): {}
85
+ readonly manifest: BoundaryManifest<N>
86
+ readonly store: Context.Tag<Store<BoundaryState>, Store<BoundaryState>>
87
+ /** The boundary's name, so it doubles as a `Source` input to a calc. */
88
+ readonly name: N
89
+ /** The covered lifecycle sources — read by `live` and the editor's graph. */
90
+ readonly over: Over
91
+ }
92
+
93
+ export interface BoundaryConfig<Over extends ReadonlyArray<AnyLifecycleSource>> {
94
+ readonly over: Over
95
+ }
96
+
97
+ export interface BoundaryOptions {
98
+ /**
99
+ * Latch: once Ready, stay Ready. A BOOT boundary wants this — a covered
100
+ * route-gated query that first enables on a later navigation (Idle →
101
+ * Loading) must not re-splash the whole shell; its own screen owns that
102
+ * loading state. Without the latch the boundary re-pends on any covered
103
+ * first load, which is what a per-screen boundary wants.
104
+ */
105
+ readonly once?: boolean
106
+ }
107
+
108
+ /**
109
+ * Define a boundary over async sources. `yield* MyBoundary` reads the merged
110
+ * `Pending | Errored | Ready` lifecycle; the consuming view renders ONE
111
+ * fallback on Pending and its content slot on Ready — content reveals
112
+ * together, with data already present (fetching never waited on rendering).
113
+ */
114
+ export const make = <const N extends string, const Over extends ReadonlyArray<AnyLifecycleSource>>(
115
+ name: N,
116
+ config: BoundaryConfig<Over>,
117
+ ): BoundaryClass<N, Over> => {
118
+ const store = Context.GenericTag<Store<BoundaryState>, Store<BoundaryState>>(
119
+ `reform/boundary/${name}`,
120
+ )
121
+ const manifest: BoundaryManifest<N> = { kind: 'Boundary', name }
122
+ const read = Effect.flatMap(store, readTracked)
123
+ return yieldableClass(read, { manifest, store, name, over: config.over })
124
+ }
125
+
126
+ // The snapshot values arrive through `wireSources`' erased key projection
127
+ // (`ReadonlyArray<unknown>`), so read the two facts the merge needs through
128
+ // honest structural guards — no cast back to `AnyAsyncData`.
129
+ const tagOf = (arm: unknown): string =>
130
+ Predicate.hasProperty(arm, '_tag') && Predicate.isString(arm._tag) ? arm._tag : ''
131
+ const errorOf = (arm: unknown): ReadonlyArray<unknown> =>
132
+ Predicate.hasProperty(arm, 'error') && tagOf(arm) === 'Error' ? [arm.error] : []
133
+
134
+ /**
135
+ * Wire the merge. A derived store over the covered stores (sensed through the
136
+ * same `wireSources` plumbing every calc uses): recomputes are memoized on the
137
+ * input snapshots and the arms are stable references, so input churn that does
138
+ * not move the merged arm wakes nobody.
139
+ */
140
+ export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSource>>(
141
+ boundary: BoundaryClass<N, Over>,
142
+ options: BoundaryOptions = {},
143
+ ): Layer.Layer<Store<BoundaryState>, never, InputStores<Over>> =>
144
+ // Scoped so the source subscriptions are released with the layer's scope.
145
+ Layer.scoped(
146
+ boundary.store,
147
+ Effect.gen(function* () {
148
+ const scheduler = yield* resolveScheduler
149
+ const sources = yield* wireSources(boundary.over)
150
+
151
+ const latched = MutableRef.make(false)
152
+ const memo = MutableRef.make<
153
+ { readonly key: ReadonlyArray<unknown>; readonly value: BoundaryState } | undefined
154
+ >(undefined)
155
+ const recompute = (): BoundaryState => {
156
+ if (options.once === true && MutableRef.get(latched)) return readyArm
157
+ const arms = sources.keyOf(sources.snapshot())
158
+ const prev = MutableRef.get(memo)
159
+ if (prev !== undefined && sameKey(arms, prev.key)) return prev.value
160
+ const errors = arms.flatMap(errorOf)
161
+ const value =
162
+ errors.length > 0
163
+ ? erroredArm(errors)
164
+ : arms.some((arm) => tagOf(arm) === 'Loading')
165
+ ? pendingArm
166
+ : readyArm
167
+ MutableRef.set(memo, { key: arms, value })
168
+ return value
169
+ }
170
+
171
+ const derived = makeDerivedStore(recompute, sources.subscribe, scheduler)
172
+ yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
173
+
174
+ if (options.once === true) {
175
+ // Latch on CONVERGED values only — at build and on (coalesced,
176
+ // post-fixpoint) notifications — never inside `recompute`. A chained
177
+ // gate with a plain Calc hop between the async stores (bootstrap →
178
+ // current-workspace → boards) propagates over TWO flush rounds, so a
179
+ // mid-flush recompute can observe the frame where the downstream
180
+ // driver has not yet flipped its query to Loading. That intermediate
181
+ // Ready self-corrects within the flush for readers, but a latch taken
182
+ // there would freeze it; a subscriber runs only after the fixpoint,
183
+ // where the value is converged.
184
+ if (recompute()._tag === 'Ready') MutableRef.set(latched, true)
185
+ const offSelf = derived.store.subscribe(() => {
186
+ if (derived.store.get()._tag === 'Ready') MutableRef.set(latched, true)
187
+ })
188
+ yield* Effect.addFinalizer(() => Effect.sync(offSelf))
189
+ }
190
+ return derived.store
191
+ }),
192
+ )