@playfast/reform 0.0.2 → 0.0.4

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 (230) hide show
  1. package/package.json +15 -13
  2. package/src/boundary/boundary.ts +192 -0
  3. package/src/calc/asyncCalc.ts +267 -0
  4. package/{dist/dts/calc/asyncData.d.ts → src/calc/asyncData.ts} +53 -20
  5. package/src/calc/calc.ts +120 -0
  6. package/src/calc/calcFamily.ts +194 -0
  7. package/src/calc/compose.ts +69 -0
  8. package/src/channel/channel.ts +265 -0
  9. package/src/compose/composition.ts +112 -0
  10. package/{dist/dts/compose/host.d.ts → src/compose/host.ts} +6 -8
  11. package/{dist/esm/compose/props.js → src/compose/props.ts} +3 -4
  12. package/src/compose/provide.ts +70 -0
  13. package/{dist/dts/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  14. package/src/compose/ui.ts +97 -0
  15. package/src/definition/definition.ts +74 -0
  16. package/src/event/event.ts +65 -0
  17. package/src/event/eventGroup.ts +14 -0
  18. package/{dist/dts/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  19. package/src/index.ts +140 -0
  20. package/{dist/dts/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  21. package/{dist/dts/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  22. package/{dist/esm/internal/errors.js → src/internal/errors.ts} +41 -26
  23. package/src/internal/inspect.ts +34 -0
  24. package/src/internal/queryDriver.ts +247 -0
  25. package/src/internal/reuse.ts +73 -0
  26. package/src/internal/scheduler.ts +91 -0
  27. package/{dist/dts/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  28. package/src/internal/sources.ts +104 -0
  29. package/src/internal/store.ts +105 -0
  30. package/{dist/dts/internal/track.d.ts → src/internal/track.ts} +15 -11
  31. package/src/procedure/procedure.ts +89 -0
  32. package/src/reducer/reducer.ts +137 -0
  33. package/src/remote/remoteState.ts +571 -0
  34. package/{dist/cjs/runtime/bus.js → src/runtime/bus.ts} +23 -12
  35. package/src/runtime/loop.ts +169 -0
  36. package/src/scene/scene.ts +76 -0
  37. package/src/state/state.ts +91 -0
  38. package/src/state/stateFamily.ts +171 -0
  39. package/src/state/stateGroup.ts +78 -0
  40. package/{dist/dts/state/token.d.ts → src/state/token.ts} +21 -13
  41. package/{dist/dts/ui/node.d.ts → src/ui/node.ts} +3 -3
  42. package/{dist/dts/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  43. package/dist/cjs/boundary/boundary.js +0 -86
  44. package/dist/cjs/calc/asyncCalc.js +0 -128
  45. package/dist/cjs/calc/asyncData.js +0 -37
  46. package/dist/cjs/calc/calc.js +0 -58
  47. package/dist/cjs/calc/calcFamily.js +0 -127
  48. package/dist/cjs/channel/channel.js +0 -142
  49. package/dist/cjs/compose/composition.js +0 -50
  50. package/dist/cjs/compose/host.js +0 -8
  51. package/dist/cjs/compose/props.js +0 -14
  52. package/dist/cjs/compose/provide.js +0 -30
  53. package/dist/cjs/compose/slot.js +0 -27
  54. package/dist/cjs/compose/ui.js +0 -61
  55. package/dist/cjs/definition/definition.js +0 -46
  56. package/dist/cjs/event/event.js +0 -36
  57. package/dist/cjs/event/eventGroup.js +0 -7
  58. package/dist/cjs/feature/feature.js +0 -102
  59. package/dist/cjs/index.js +0 -116
  60. package/dist/cjs/internal/capture.js +0 -14
  61. package/dist/cjs/internal/ctx.js +0 -2
  62. package/dist/cjs/internal/errors.js +0 -62
  63. package/dist/cjs/internal/inspect.js +0 -36
  64. package/dist/cjs/internal/queryDriver.js +0 -138
  65. package/dist/cjs/internal/reuse.js +0 -71
  66. package/dist/cjs/internal/scheduler.js +0 -73
  67. package/dist/cjs/internal/seeds.js +0 -19
  68. package/dist/cjs/internal/sources.js +0 -61
  69. package/dist/cjs/internal/store.js +0 -77
  70. package/dist/cjs/internal/track.js +0 -22
  71. package/dist/cjs/package.json +0 -4
  72. package/dist/cjs/procedure/procedure.js +0 -52
  73. package/dist/cjs/reducer/reducer.js +0 -64
  74. package/dist/cjs/remote/remoteState.js +0 -307
  75. package/dist/cjs/runtime/loop.js +0 -119
  76. package/dist/cjs/scene/scene.js +0 -36
  77. package/dist/cjs/state/state.js +0 -47
  78. package/dist/cjs/state/stateFamily.js +0 -101
  79. package/dist/cjs/state/stateGroup.js +0 -47
  80. package/dist/cjs/state/token.js +0 -23
  81. package/dist/cjs/ui/node.js +0 -2
  82. package/dist/cjs/ui/trigger.js +0 -2
  83. package/dist/dts/boundary/boundary.d.ts +0 -72
  84. package/dist/dts/boundary/boundary.d.ts.map +0 -1
  85. package/dist/dts/calc/asyncCalc.d.ts +0 -91
  86. package/dist/dts/calc/asyncCalc.d.ts.map +0 -1
  87. package/dist/dts/calc/asyncData.d.ts.map +0 -1
  88. package/dist/dts/calc/calc.d.ts +0 -57
  89. package/dist/dts/calc/calc.d.ts.map +0 -1
  90. package/dist/dts/calc/calcFamily.d.ts +0 -57
  91. package/dist/dts/calc/calcFamily.d.ts.map +0 -1
  92. package/dist/dts/channel/channel.d.ts +0 -115
  93. package/dist/dts/channel/channel.d.ts.map +0 -1
  94. package/dist/dts/compose/composition.d.ts +0 -72
  95. package/dist/dts/compose/composition.d.ts.map +0 -1
  96. package/dist/dts/compose/host.d.ts.map +0 -1
  97. package/dist/dts/compose/props.d.ts +0 -13
  98. package/dist/dts/compose/props.d.ts.map +0 -1
  99. package/dist/dts/compose/provide.d.ts +0 -22
  100. package/dist/dts/compose/provide.d.ts.map +0 -1
  101. package/dist/dts/compose/slot.d.ts.map +0 -1
  102. package/dist/dts/compose/ui.d.ts +0 -50
  103. package/dist/dts/compose/ui.d.ts.map +0 -1
  104. package/dist/dts/definition/definition.d.ts +0 -33
  105. package/dist/dts/definition/definition.d.ts.map +0 -1
  106. package/dist/dts/event/event.d.ts +0 -33
  107. package/dist/dts/event/event.d.ts.map +0 -1
  108. package/dist/dts/event/eventGroup.d.ts +0 -9
  109. package/dist/dts/event/eventGroup.d.ts.map +0 -1
  110. package/dist/dts/feature/feature.d.ts.map +0 -1
  111. package/dist/dts/index.d.ts +0 -43
  112. package/dist/dts/index.d.ts.map +0 -1
  113. package/dist/dts/internal/capture.d.ts.map +0 -1
  114. package/dist/dts/internal/ctx.d.ts.map +0 -1
  115. package/dist/dts/internal/errors.d.ts +0 -69
  116. package/dist/dts/internal/errors.d.ts.map +0 -1
  117. package/dist/dts/internal/inspect.d.ts +0 -17
  118. package/dist/dts/internal/inspect.d.ts.map +0 -1
  119. package/dist/dts/internal/queryDriver.d.ts +0 -65
  120. package/dist/dts/internal/queryDriver.d.ts.map +0 -1
  121. package/dist/dts/internal/reuse.d.ts +0 -10
  122. package/dist/dts/internal/reuse.d.ts.map +0 -1
  123. package/dist/dts/internal/scheduler.d.ts +0 -47
  124. package/dist/dts/internal/scheduler.d.ts.map +0 -1
  125. package/dist/dts/internal/seeds.d.ts.map +0 -1
  126. package/dist/dts/internal/sources.d.ts +0 -39
  127. package/dist/dts/internal/sources.d.ts.map +0 -1
  128. package/dist/dts/internal/store.d.ts +0 -47
  129. package/dist/dts/internal/store.d.ts.map +0 -1
  130. package/dist/dts/internal/track.d.ts.map +0 -1
  131. package/dist/dts/procedure/procedure.d.ts +0 -40
  132. package/dist/dts/procedure/procedure.d.ts.map +0 -1
  133. package/dist/dts/reducer/reducer.d.ts +0 -44
  134. package/dist/dts/reducer/reducer.d.ts.map +0 -1
  135. package/dist/dts/remote/remoteState.d.ts +0 -119
  136. package/dist/dts/remote/remoteState.d.ts.map +0 -1
  137. package/dist/dts/runtime/bus.d.ts +0 -27
  138. package/dist/dts/runtime/bus.d.ts.map +0 -1
  139. package/dist/dts/runtime/loop.d.ts +0 -45
  140. package/dist/dts/runtime/loop.d.ts.map +0 -1
  141. package/dist/dts/scene/scene.d.ts +0 -44
  142. package/dist/dts/scene/scene.d.ts.map +0 -1
  143. package/dist/dts/state/state.d.ts +0 -37
  144. package/dist/dts/state/state.d.ts.map +0 -1
  145. package/dist/dts/state/stateFamily.d.ts +0 -79
  146. package/dist/dts/state/stateFamily.d.ts.map +0 -1
  147. package/dist/dts/state/stateGroup.d.ts +0 -36
  148. package/dist/dts/state/stateGroup.d.ts.map +0 -1
  149. package/dist/dts/state/token.d.ts.map +0 -1
  150. package/dist/dts/ui/node.d.ts.map +0 -1
  151. package/dist/dts/ui/trigger.d.ts.map +0 -1
  152. package/dist/esm/boundary/boundary.js +0 -83
  153. package/dist/esm/boundary/boundary.js.map +0 -1
  154. package/dist/esm/calc/asyncCalc.js +0 -95
  155. package/dist/esm/calc/asyncCalc.js.map +0 -1
  156. package/dist/esm/calc/asyncData.js +0 -34
  157. package/dist/esm/calc/asyncData.js.map +0 -1
  158. package/dist/esm/calc/calc.js +0 -58
  159. package/dist/esm/calc/calc.js.map +0 -1
  160. package/dist/esm/calc/calcFamily.js +0 -124
  161. package/dist/esm/calc/calcFamily.js.map +0 -1
  162. package/dist/esm/channel/channel.js +0 -136
  163. package/dist/esm/channel/channel.js.map +0 -1
  164. package/dist/esm/compose/composition.js +0 -46
  165. package/dist/esm/compose/composition.js.map +0 -1
  166. package/dist/esm/compose/host.js +0 -5
  167. package/dist/esm/compose/host.js.map +0 -1
  168. package/dist/esm/compose/props.js.map +0 -1
  169. package/dist/esm/compose/provide.js +0 -28
  170. package/dist/esm/compose/provide.js.map +0 -1
  171. package/dist/esm/compose/slot.js +0 -23
  172. package/dist/esm/compose/slot.js.map +0 -1
  173. package/dist/esm/compose/ui.js +0 -57
  174. package/dist/esm/compose/ui.js.map +0 -1
  175. package/dist/esm/definition/definition.js +0 -42
  176. package/dist/esm/definition/definition.js.map +0 -1
  177. package/dist/esm/event/event.js +0 -30
  178. package/dist/esm/event/event.js.map +0 -1
  179. package/dist/esm/event/eventGroup.js +0 -4
  180. package/dist/esm/event/eventGroup.js.map +0 -1
  181. package/dist/esm/feature/feature.js +0 -98
  182. package/dist/esm/feature/feature.js.map +0 -1
  183. package/dist/esm/index.js +0 -45
  184. package/dist/esm/index.js.map +0 -1
  185. package/dist/esm/internal/capture.js +0 -11
  186. package/dist/esm/internal/capture.js.map +0 -1
  187. package/dist/esm/internal/ctx.js +0 -2
  188. package/dist/esm/internal/ctx.js.map +0 -1
  189. package/dist/esm/internal/errors.js.map +0 -1
  190. package/dist/esm/internal/inspect.js +0 -32
  191. package/dist/esm/internal/inspect.js.map +0 -1
  192. package/dist/esm/internal/queryDriver.js +0 -134
  193. package/dist/esm/internal/queryDriver.js.map +0 -1
  194. package/dist/esm/internal/reuse.js +0 -68
  195. package/dist/esm/internal/reuse.js.map +0 -1
  196. package/dist/esm/internal/scheduler.js +0 -69
  197. package/dist/esm/internal/scheduler.js.map +0 -1
  198. package/dist/esm/internal/seeds.js +0 -17
  199. package/dist/esm/internal/seeds.js.map +0 -1
  200. package/dist/esm/internal/sources.js +0 -59
  201. package/dist/esm/internal/sources.js.map +0 -1
  202. package/dist/esm/internal/store.js +0 -73
  203. package/dist/esm/internal/store.js.map +0 -1
  204. package/dist/esm/internal/track.js +0 -18
  205. package/dist/esm/internal/track.js.map +0 -1
  206. package/dist/esm/package.json +0 -4
  207. package/dist/esm/procedure/procedure.js +0 -50
  208. package/dist/esm/procedure/procedure.js.map +0 -1
  209. package/dist/esm/reducer/reducer.js +0 -63
  210. package/dist/esm/reducer/reducer.js.map +0 -1
  211. package/dist/esm/remote/remoteState.js +0 -270
  212. package/dist/esm/remote/remoteState.js.map +0 -1
  213. package/dist/esm/runtime/bus.js +0 -20
  214. package/dist/esm/runtime/bus.js.map +0 -1
  215. package/dist/esm/runtime/loop.js +0 -116
  216. package/dist/esm/runtime/loop.js.map +0 -1
  217. package/dist/esm/scene/scene.js +0 -31
  218. package/dist/esm/scene/scene.js.map +0 -1
  219. package/dist/esm/state/state.js +0 -43
  220. package/dist/esm/state/state.js.map +0 -1
  221. package/dist/esm/state/stateFamily.js +0 -96
  222. package/dist/esm/state/stateFamily.js.map +0 -1
  223. package/dist/esm/state/stateGroup.js +0 -46
  224. package/dist/esm/state/stateGroup.js.map +0 -1
  225. package/dist/esm/state/token.js +0 -20
  226. package/dist/esm/state/token.js.map +0 -1
  227. package/dist/esm/ui/node.js +0 -2
  228. package/dist/esm/ui/node.js.map +0 -1
  229. package/dist/esm/ui/trigger.js +0 -2
  230. package/dist/esm/ui/trigger.js.map +0 -1
package/package.json CHANGED
@@ -1,25 +1,24 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "sideEffects": [],
6
6
  "license": "MIT",
7
- "module": "./dist/esm/index.js",
8
- "types": "./dist/dts/index.d.ts",
9
- "main": "./dist/cjs/index.js",
10
7
  "exports": {
11
8
  "./package.json": "./package.json",
12
- ".": {
13
- "types": "./dist/dts/index.d.ts",
14
- "import": "./dist/esm/index.js",
15
- "default": "./dist/cjs/index.js"
16
- }
9
+ ".": "./src/index.ts"
17
10
  },
18
11
  "files": [
19
- "dist"
12
+ "src",
13
+ "README.md",
14
+ "!src/**/*.test.ts",
15
+ "!src/**/*.test.tsx",
16
+ "!src/**/*.typecheck.ts",
17
+ "!src/**/*.typecheck.tsx"
20
18
  ],
21
- "publishConfig": {
22
- "access": "public"
19
+ "scripts": {
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run"
23
22
  },
24
23
  "peerDependencies": {
25
24
  "effect": "*",
@@ -29,5 +28,8 @@
29
28
  "react": {
30
29
  "optional": true
31
30
  }
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
32
34
  }
33
- }
35
+ }
@@ -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
+ )
@@ -0,0 +1,267 @@
1
+ import { Context, Effect, Layer, Option, Schema } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import { type AnyEvent } from '../event/event'
4
+ import {
5
+ type InputsObject,
6
+ type InputStores,
7
+ type InvalidateBy,
8
+ } from '../internal/sources'
9
+ import {
10
+ bumpRevision,
11
+ type GatedOf,
12
+ gatedFlag,
13
+ makeQueryDriver,
14
+ RevisionSchema,
15
+ revisionZero,
16
+ } from '../internal/queryDriver'
17
+ import { type Store } from '../internal/store'
18
+ import { readTracked } from '../internal/track'
19
+ import * as Reducer from '../reducer/reducer'
20
+ import { Reducers } from '../runtime/loop'
21
+ import * as State from '../state/state'
22
+ import { type AnySource } from '../state/token'
23
+ import { type AsyncData, narrowStore } from './asyncData'
24
+
25
+ // `AsyncCalc` is `Calc`'s async sibling: a derived store whose value is the
26
+ // lifecycle of an Effect (a query), recomputed reactively from its inputs — the
27
+ // React-Query model, but inside the reform graph and without dispatching events.
28
+ // Definition (`make`) is reflectable data; implementation (`live`) is the effect.
29
+ // The reactive machinery lives in `internal/queryDriver` (shared with
30
+ // `RemoteState`); this module owns the definition shape and the `invalidateOn`
31
+ // sugar.
32
+
33
+ export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
34
+ readonly kind: 'AsyncCalc'
35
+ readonly name: N
36
+ readonly output: Schema.Schema<A, any>
37
+ readonly error?: Schema.Schema<E, any>
38
+ /** Whether the query can be disabled (drives the `Idle` arm). */
39
+ readonly gated: boolean
40
+ }
41
+
42
+ export interface AsyncCalcClass<
43
+ out N extends string,
44
+ out Inputs extends ReadonlyArray<AnySource>,
45
+ in out A,
46
+ in out E,
47
+ in out Gated extends boolean,
48
+ > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
49
+ new (): {}
50
+ readonly manifest: AsyncCalcManifest<N, A, E>
51
+ readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
52
+ /** The calc's name, so it doubles as a `Source` input to another (async) calc. */
53
+ readonly name: N
54
+ /** Marker data read by `AsyncCalc.live`. */
55
+ readonly inputs: Inputs
56
+ /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
57
+ readonly gated: Gated
58
+ }
59
+
60
+ export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E, AlwaysOn extends boolean> {
61
+ readonly inputs: Inputs
62
+ /** Schema of the `Success` value. */
63
+ readonly output: Schema.Schema<A, any>
64
+ /** Schema of the failure. Omitted ⇒ the query is infallible and there is no `Error` arm. */
65
+ readonly error?: Schema.Schema<E, any>
66
+ /** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
67
+ readonly alwaysOn?: AlwaysOn
68
+ }
69
+
70
+ /**
71
+ * The `.live` config: the effect plus the reactive logic. `disabled` is only
72
+ * accepted for a gateable query (no `alwaysOn`); `invalidateBy` is the same
73
+ * `queryKey` projection `Calc` uses.
74
+ */
75
+ export type AsyncCalcLive<
76
+ Inputs extends ReadonlyArray<AnySource>,
77
+ A,
78
+ E,
79
+ Gated extends boolean,
80
+ R,
81
+ > = {
82
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
83
+ readonly invalidateBy?: InvalidateBy<Inputs>
84
+ /**
85
+ * Refetch conciliation. `'switch'` (default): a new key cancels the in-flight
86
+ * run (latest-wins). `'trailing'`: the in-flight run completes; key changes
87
+ * that arrive meanwhile conflate to the latest, and exactly ONE trailing
88
+ * refetch runs after it settles — a burst of N invalidations during one
89
+ * flight costs 2 fetches, not N cancel-restarts.
90
+ */
91
+ readonly coalesce?: 'switch' | 'trailing'
92
+ /**
93
+ * Structural sharing for `Success` values: reconcile each refetch result
94
+ * against the previous one, substituting previous nodes wherever value
95
+ * equality holds — refetches returning mostly-identical data keep unchanged
96
+ * subtree identities, so downstream memo boundaries skip them. Same pass as
97
+ * `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
98
+ */
99
+ readonly reuse?: boolean
100
+ } & (Gated extends true
101
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
102
+ : { readonly disabled?: never })
103
+
104
+ /**
105
+ * Define an async derived value. `output`/`error` schemas and `alwaysOn` shape
106
+ * the value type, so `yield* MyQuery` is typed to exactly the arms that can
107
+ * occur. The query effect itself is supplied by `AsyncCalc.live`.
108
+ */
109
+ export const make = <
110
+ const N extends string,
111
+ const Inputs extends ReadonlyArray<AnySource>,
112
+ A,
113
+ E = never,
114
+ const AlwaysOn extends boolean = false,
115
+ >(
116
+ name: N,
117
+ config: AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
118
+ ): AsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> => {
119
+ const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
120
+ `reform/asyncCalc/${name}`,
121
+ )
122
+ // Runtime mirror of the type-level `Gated`, typed as its literal in one place.
123
+ const gated = gatedFlag(config.alwaysOn)
124
+ const manifest: AsyncCalcManifest<N, A, E> = {
125
+ kind: 'AsyncCalc',
126
+ name,
127
+ output: config.output,
128
+ gated,
129
+ ...(config.error !== undefined ? { error: config.error } : {}),
130
+ }
131
+ const read = Effect.flatMap(store, readTracked)
132
+ return yieldableClass(read, {
133
+ manifest,
134
+ store,
135
+ name,
136
+ inputs: config.inputs,
137
+ gated,
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Wire the query and its reactive logic. The query runs on the runtime (its `R`
143
+ * — RPC clients, etc. — is captured from the layer context). A change to an
144
+ * input re-runs it latest-wins (a new run cancels the in-flight one; or, with
145
+ * `coalesce: 'trailing'`, lets it finish and runs one trailing refetch); while a
146
+ * re-run is in flight the last `Success`/`Error` is kept with `refetching: true`.
147
+ * Re-runs are driven by inputs / `invalidateBy` / `disabled` — and, with
148
+ * `invalidateOn`, by listed events: pure sugar that generates a hidden revision
149
+ * state + reducer (the sole writer) and appends the revision to the calc's key,
150
+ * so an event-driven refetch is still just key movement under `Equal.equals`.
151
+ * The hidden pair stays internal — the user's `query`/`invalidateBy` see exactly
152
+ * the declared inputs — and costs the layer one extra requirement: the
153
+ * `Reducers` registry (the `Engine`).
154
+ */
155
+ export function live<
156
+ N extends string,
157
+ Inputs extends ReadonlyArray<AnySource>,
158
+ A,
159
+ E,
160
+ Gated extends boolean,
161
+ R,
162
+ >(
163
+ calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
164
+ config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
165
+ readonly invalidateOn: ReadonlyArray<AnyEvent>
166
+ },
167
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R | Reducers>
168
+ export function live<
169
+ N extends string,
170
+ Inputs extends ReadonlyArray<AnySource>,
171
+ A,
172
+ E,
173
+ Gated extends boolean,
174
+ R,
175
+ >(
176
+ calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
177
+ config: AsyncCalcLive<Inputs, A, E, Gated, R>,
178
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R>
179
+ export function live<
180
+ N extends string,
181
+ Inputs extends ReadonlyArray<AnySource>,
182
+ A,
183
+ E,
184
+ Gated extends boolean,
185
+ R,
186
+ >(
187
+ calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
188
+ config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
189
+ readonly invalidateOn?: ReadonlyArray<AnyEvent>
190
+ },
191
+ // The wide-R implementation signature behind precise overloads — the same
192
+ // seam `Reducer.live` uses; both overload returns are assignable (RIn is
193
+ // covariant), so no value is ever cast.
194
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, any> {
195
+ // The hidden revision pair, built once per `live` call. Namespacing the state
196
+ // by the calc's name (`reform/state/${calcName}/invalidateOn`) keeps the tag
197
+ // out of any plausible user namespace; the duplicate-reducer check is
198
+ // warning-only and calc names are unique by convention.
199
+ const revisionState =
200
+ config.invalidateOn === undefined
201
+ ? undefined
202
+ : State.make(`${calc.manifest.name}/invalidateOn`, RevisionSchema)
203
+
204
+ // Scoped so the source subscriptions and the driver fiber are released when
205
+ // the layer's scope closes (each proof/test builds and disposes its runtime).
206
+ const driver = Layer.scoped(
207
+ calc.store,
208
+ Effect.gen(function* () {
209
+ // `disabled` is rejected at the type level for non-gated calcs (erased to
210
+ // `never` there); read it through a loose view for the runtime.
211
+ const cfg = config as {
212
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
213
+ readonly invalidateBy?: InvalidateBy<Inputs>
214
+ readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
215
+ readonly coalesce?: 'switch' | 'trailing'
216
+ readonly reuse?: boolean
217
+ }
218
+
219
+ // The hidden revision store, read requirement-free (`serviceOption`): the
220
+ // assembly below always provides it alongside this driver, and feeding it
221
+ // through `extraKey` (not `wireSources`) keeps it out of the snapshot —
222
+ // the user's `query` and `invalidateBy` receive exactly
223
+ // `InputsObject<Inputs>`, with no hidden property to leak into
224
+ // spread-into-RPC payloads.
225
+ const revision =
226
+ revisionState === undefined
227
+ ? undefined
228
+ : Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
229
+ const driver = yield* makeQueryDriver({
230
+ name: calc.manifest.name,
231
+ label: 'AsyncCalc',
232
+ gated: calc.gated,
233
+ inputs: calc.inputs,
234
+ query: cfg.query,
235
+ invalidateBy: cfg.invalidateBy,
236
+ disabled: cfg.disabled,
237
+ coalesce: cfg.coalesce,
238
+ reuse: cfg.reuse,
239
+ extraKey:
240
+ revision === undefined
241
+ ? undefined
242
+ : {
243
+ read: () => revision.getSnapshot(),
244
+ subscribe: (listener) => revision.subscribe(listener),
245
+ },
246
+ })
247
+
248
+ // The internal store is the full union; narrow to the definition's arms.
249
+ return narrowStore<A, E, Gated>(driver.store)
250
+ }),
251
+ )
252
+
253
+ if (config.invalidateOn === undefined || revisionState === undefined) return driver
254
+ // The hidden reducer: an ordinary `Reducer` folding every listed event to
255
+ // n + 1 — the revision's sole writer, registered/unregistered with this
256
+ // layer's scope like any user reducer. `Layer.provide` builds the hidden
257
+ // state once and feeds the same store to both the driver and the reducer,
258
+ // while the outer context (user inputs, the `Reducers` registry) passes
259
+ // through untouched.
260
+ const revisionReducer = Reducer.make(`${calc.manifest.name}/invalidateOn`, {
261
+ states: [revisionState],
262
+ events: config.invalidateOn,
263
+ })
264
+ return Layer.merge(driver, Reducer.live(revisionReducer, bumpRevision)).pipe(
265
+ Layer.provide(State.live(revisionState, revisionZero)),
266
+ )
267
+ }
@@ -1,49 +1,81 @@
1
- import { type Store } from '../internal/store.js';
1
+ import { type Store } from '../internal/store'
2
+
3
+ // The value an `AsyncCalc` holds: a tagged union of the query's lifecycle,
4
+ // consumed with `effect`'s `Match` (`Match.value(data).pipe(Match.tag('Success', …))`).
5
+ //
6
+ // The shape is narrowed by the definition, so you only handle arms that can
7
+ // occur: `Idle` exists only for a gateable query (no `alwaysOn`), and `Error`
8
+ // exists only when the query declares an `error` schema. There is no separate
9
+ // "refreshing" arm — a re-fetch keeps the last `Success`/`Error` and flips its
10
+ // `refetching` flag (stale-while-revalidate), so the UI never flashes empty.
11
+
2
12
  /** A gated query that is currently switched off (only when not `alwaysOn`). */
3
13
  export interface AsyncIdle {
4
- readonly _tag: 'Idle';
14
+ readonly _tag: 'Idle'
5
15
  }
16
+
6
17
  /** The first fetch, before any value exists. */
7
18
  export interface AsyncLoading {
8
- readonly _tag: 'Loading';
19
+ readonly _tag: 'Loading'
9
20
  }
21
+
10
22
  /** A resolved value; `refetching` is true while a background re-fetch runs. */
11
23
  export interface AsyncSuccess<A> {
12
- readonly _tag: 'Success';
13
- readonly value: A;
14
- readonly refetching: boolean;
24
+ readonly _tag: 'Success'
25
+ readonly value: A
26
+ readonly refetching: boolean
15
27
  }
28
+
16
29
  /** A failed query; `refetching` is true while a background re-fetch runs. */
17
30
  export interface AsyncError<E> {
18
- readonly _tag: 'Error';
19
- readonly error: E;
20
- readonly refetching: boolean;
31
+ readonly _tag: 'Error'
32
+ readonly error: E
33
+ readonly refetching: boolean
21
34
  }
35
+
22
36
  /**
23
37
  * The query lifecycle. `Gated` adds the `Idle` arm (a disable-able query); a
24
38
  * non-`never` `E` adds the `Error` arm. Both are decided at `AsyncCalc.make`, so
25
39
  * `yield* MyQuery` is typed to exactly the arms that can occur.
26
40
  */
27
- export type AsyncData<A, E = never, Gated extends boolean = false> = (Gated extends true ? AsyncIdle : never) | AsyncLoading | AsyncSuccess<A> | ([E] extends [never] ? never : AsyncError<E>);
41
+ export type AsyncData<A, E = never, Gated extends boolean = false> =
42
+ | (Gated extends true ? AsyncIdle : never)
43
+ | AsyncLoading
44
+ | AsyncSuccess<A>
45
+ | ([E] extends [never] ? never : AsyncError<E>)
46
+
28
47
  /** The full (un-narrowed) union — used internally by the live store. */
29
- export type AnyAsyncData<A, E> = AsyncIdle | AsyncLoading | AsyncSuccess<A> | AsyncError<E>;
48
+ export type AnyAsyncData<A, E> = AsyncIdle | AsyncLoading | AsyncSuccess<A> | AsyncError<E>
49
+
50
+ const idle: AsyncIdle = { _tag: 'Idle' }
51
+ const loading: AsyncLoading = { _tag: 'Loading' }
52
+ const success = <A>(value: A, refetching = false): AsyncSuccess<A> => ({
53
+ _tag: 'Success',
54
+ value,
55
+ refetching,
56
+ })
57
+ const error = <E>(err: E, refetching = false): AsyncError<E> => ({
58
+ _tag: 'Error',
59
+ error: err,
60
+ refetching,
61
+ })
62
+
30
63
  /**
31
64
  * Constructors for the arms, namespaced under the same name as the type so call
32
65
  * sites read `AsyncData.success(v)` / `AsyncData.error(e)` — no `error` import
33
66
  * shadowing the keyword and one obvious home for every arm.
34
67
  */
35
- export declare const AsyncData: {
36
- readonly idle: AsyncIdle;
37
- readonly loading: AsyncLoading;
38
- readonly success: <A>(value: A, refetching?: boolean) => AsyncSuccess<A>;
39
- readonly error: <E>(err: E, refetching?: boolean) => AsyncError<E>;
40
- };
68
+ export const AsyncData = { idle, loading, success, error } as const
69
+
41
70
  /**
42
71
  * Narrow the live store (which holds the full `AnyAsyncData` union) to the arms
43
72
  * the definition actually permits (`Gated`/`E`). The single documented home for
44
73
  * that narrowing, so `AsyncCalc.live` returns it without an inline cast.
45
74
  */
46
- export declare const narrowStore: <A, E, Gated extends boolean>(store: Store<AnyAsyncData<A, E>>) => Store<AsyncData<A, E, Gated>>;
75
+ export const narrowStore = <A, E, Gated extends boolean>(
76
+ store: Store<AnyAsyncData<A, E>>,
77
+ ): Store<AsyncData<A, E, Gated>> => store as unknown as Store<AsyncData<A, E, Gated>>
78
+
47
79
  /**
48
80
  * The read-only inverse of `narrowStore`: widen a definition-narrowed store back
49
81
  * to the full union, so a consumer (the `RemoteState` overlay) can dispatch on the
@@ -51,5 +83,6 @@ export declare const narrowStore: <A, E, Gated extends boolean>(store: Store<Any
51
83
  * Sound for reads only — every narrowed value IS an `AnyAsyncData` — so callers
52
84
  * must never `set` through the widened view (derived stores ignore `set` anyway).
53
85
  */
54
- export declare const widenStore: <A, E, Gated extends boolean>(store: Store<AsyncData<A, E, Gated>>) => Store<AnyAsyncData<A, E>>;
55
- //# sourceMappingURL=asyncData.d.ts.map
86
+ export const widenStore = <A, E, Gated extends boolean>(
87
+ store: Store<AsyncData<A, E, Gated>>,
88
+ ): Store<AnyAsyncData<A, E>> => store as unknown as Store<AnyAsyncData<A, E>>