@playfast/reform 0.0.2 → 0.0.3

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
@@ -0,0 +1,571 @@
1
+ import { Context, Effect, Either, Layer, MutableRef, Option, Runtime, Schema } from 'effect'
2
+ import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
3
+ import * as Channel from '../channel/channel'
4
+ import { type Manifest, yieldableClass } from '../definition/definition'
5
+ import * as Event from '../event/event'
6
+ import {
7
+ bumpRevision,
8
+ type GatedOf,
9
+ gatedFlag,
10
+ makeQueryDriver,
11
+ RevisionSchema,
12
+ revisionZero,
13
+ } from '../internal/queryDriver'
14
+ import { reuse as shareStructure } from '../internal/reuse'
15
+ import { resolveScheduler } from '../internal/scheduler'
16
+ import {
17
+ type InputsObject,
18
+ type InputStores,
19
+ type InvalidateBy,
20
+ sameKey,
21
+ } from '../internal/sources'
22
+ import { makeDerivedStore, makeStore, type Store } from '../internal/store'
23
+ import { readTracked } from '../internal/track'
24
+ import * as Reducer from '../reducer/reducer'
25
+ import { Bus, narrowHandled, publish } from '../runtime/bus'
26
+ import { type ReducerEntry, Reducers } from '../runtime/loop'
27
+ import * as State from '../state/state'
28
+ import { type AnySource, StateToken } from '../state/token'
29
+
30
+ // `RemoteState` is server-owned state with optimistic mutations, as ONE
31
+ // primitive. The model: remote state is *derived only* — the visible value is
32
+ // `pending.reduce(apply, serverTruth)`, where `serverTruth` is a query's
33
+ // lifecycle (the AsyncCalc machinery) and `pending` is the queue of intents
34
+ // dispatched but not yet reflected by the server. The only write surface is
35
+ // dispatching an intent event; there is no rollback machinery — a failed or
36
+ // settled intent simply leaves the queue and the derivation converges.
37
+ //
38
+ // Lifecycle of one intent (dispatch → visible → durable):
39
+ // 1. Dispatched like any event; the send procedure mints an `opId` and the
40
+ // hidden queue reducer appends `{opId, intent, status: 'sending'}` — the
41
+ // overlay shows the change in the same flush (no fetch fired yet).
42
+ // 2. `send(intent)` runs on the channel. Failure (or a defect, or a cancel
43
+ // by a `latest`/`exclusive` channel): the intent settles immediately and
44
+ // the public `Failed` event carries `{intent, error}` — the overlay
45
+ // reverts by construction.
46
+ // 3. Success: status flips to `'confirmed'` and the query is invalidated.
47
+ // 4. THE GENERATION RULE: an intent acked while query-run generation g was
48
+ // the newest requested may be settled only by a run with generation > g
49
+ // that lands `Success`. A refetch that started before the ack can never
50
+ // settle it (it may predate the server applying the mutation), and an
51
+ // `Error` run settles nobody — so the optimistic change never visibly
52
+ // vanishes and reappears while the server converges.
53
+ // 5. The settle lands in the same scheduler flush as the converged value:
54
+ // the overlay swaps optimistic-over-stale for server truth atomically
55
+ // from a subscriber's point of view.
56
+ //
57
+ // Queue order = dispatch order = apply order. While the query is disabled the
58
+ // view is `Idle` but intents still queue and send; settles wait for the run
59
+ // after re-enable. `apply` must be IDEMPOTENT against server truth that
60
+ // already carries the intent: between the refetch landing and the settle
61
+ // folding (adjacent flushes) the intent is applied over a value that already
62
+ // contains it — `apply` seeing its own effect must be a no-op (e.g. skip an
63
+ // insert whose id is already present). Everything is scoped: registry entries
64
+ // unregister and the driver fiber/subscriptions/channel release with the
65
+ // layer's scope.
66
+
67
+ /** One queued intent: the dispatched event value plus the lifecycle metadata. */
68
+ export interface PendingIntent<I> {
69
+ readonly opId: string
70
+ /** The dispatched intent event (`{ _tag, ...payload }`). */
71
+ readonly intent: I
72
+ /** `'sending'` until the RPC acks; `'confirmed'` until the post-ack refetch lands. */
73
+ readonly status: 'sending' | 'confirmed'
74
+ }
75
+
76
+ /** The payload of the public `Failed` event: which intent, and what `send` raised. */
77
+ export interface FailedIntent<I> {
78
+ readonly intent: I
79
+ readonly error: unknown
80
+ }
81
+
82
+ export interface RemoteStateManifest<N extends string, A, E> extends Manifest {
83
+ readonly kind: 'RemoteState'
84
+ readonly name: N
85
+ /** Schema of the server value (`Success` arm / `apply`'s domain). */
86
+ readonly output: Schema.Schema<A, any>
87
+ readonly error?: Schema.Schema<E, any>
88
+ /** Whether the query can be disabled (drives the `Idle` arm). */
89
+ readonly gated: boolean
90
+ /** The mutation vocabulary — intent event tags, reflectable without running logic. */
91
+ readonly intents: ReadonlyArray<string>
92
+ }
93
+
94
+ export interface RemoteStateClass<
95
+ out N extends string,
96
+ out Inputs extends ReadonlyArray<AnySource>,
97
+ in out Intents extends ReadonlyArray<Event.AnyEvent>,
98
+ in out A,
99
+ in out E,
100
+ in out Gated extends boolean,
101
+ > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
102
+ new (): {}
103
+ readonly manifest: RemoteStateManifest<N, A, E>
104
+ /** The OVERLAID view (`pending.reduce(apply, truth)`) — what `yield*` reads. */
105
+ readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
106
+ /** The name, so the class doubles as a `Source` input to another calc. */
107
+ readonly name: N
108
+ /** Marker data read by `RemoteState.live`. */
109
+ readonly inputs: Inputs
110
+ /** The intent event definitions (marker data; read by `RemoteState.live`). */
111
+ readonly intents: Intents
112
+ /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
113
+ readonly gated: Gated
114
+ /** The un-overlaid query lifecycle — for chrome that must show server truth. */
115
+ readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>>
116
+ /**
117
+ * The pending queue as a read-only `Source` (badges, "saving…" chrome). Not a
118
+ * `StateClass`, so no user reducer can target it — the hidden queue reducer
119
+ * is its sole writer.
120
+ */
121
+ readonly pending: StateToken<
122
+ `${N}/pending`,
123
+ ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>
124
+ >
125
+ /** Public fact: a send failed; its intent left the queue and the view reverted. */
126
+ readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
127
+ }
128
+
129
+ export interface RemoteStateConfig<
130
+ Inputs extends ReadonlyArray<AnySource>,
131
+ Intents extends ReadonlyArray<Event.AnyEvent>,
132
+ A,
133
+ E,
134
+ AlwaysOn extends boolean,
135
+ > {
136
+ readonly inputs: Inputs
137
+ /** Schema of the server value. */
138
+ readonly output: Schema.Schema<A, any>
139
+ /** Schema of the query failure. Omitted ⇒ infallible query, no `Error` arm. */
140
+ readonly error?: Schema.Schema<E, any>
141
+ /** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
142
+ readonly alwaysOn?: AlwaysOn
143
+ /** The mutation vocabulary: ordinary `Event.make` definitions. */
144
+ readonly intents: Intents
145
+ }
146
+
147
+ // Intent payloads are typed by the definition's `Intents`, but `Intents` is an
148
+ // open type parameter inside `make`/`live`, so the runtime schemas can only be
149
+ // structural (`Schema.Unknown` in the intent slot). Event schemas are
150
+ // reflective metadata — never decoded on the dispatch path — so the typed view
151
+ // is granted here once (the `narrowStore`/`narrowHandled` style; `any`
152
+ // short-circuits the schema's invariance, no value is cast).
153
+ const looseSchema = <P>(schema: Schema.Schema<any, any>): Schema.Schema<P, any> => schema
154
+
155
+ /**
156
+ * Define a remote state: a query (like `AsyncCalc.make`) plus the intent
157
+ * events that mutate it. `yield* TheClass` reads the overlaid view;
158
+ * `TheClass.truth` / `TheClass.pending` / `TheClass.Failed` expose the rest of
159
+ * the surface. The query/send/apply logic is supplied by `RemoteState.live`.
160
+ */
161
+ export const make = <
162
+ const N extends string,
163
+ const Inputs extends ReadonlyArray<AnySource>,
164
+ const Intents extends ReadonlyArray<Event.AnyEvent>,
165
+ A,
166
+ E = never,
167
+ const AlwaysOn extends boolean = false,
168
+ >(
169
+ name: N,
170
+ config: RemoteStateConfig<Inputs, Intents, A, E, AlwaysOn>,
171
+ ): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
172
+ type I = Event.EventType<Intents[number]>
173
+ const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
174
+ `reform/remoteState/${name}`,
175
+ )
176
+ const truthTag = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
177
+ `reform/remoteState/${name}/truth`,
178
+ )
179
+ const pendingTag = Context.GenericTag<Store<ReadonlyArray<PendingIntent<I>>>>(
180
+ `reform/remoteState/${name}/pending`,
181
+ )
182
+ const gated = gatedFlag(config.alwaysOn)
183
+ const manifest: RemoteStateManifest<N, A, E> = {
184
+ kind: 'RemoteState',
185
+ name,
186
+ output: config.output,
187
+ gated,
188
+ intents: config.intents.map((event) => event.tag),
189
+ ...(config.error !== undefined ? { error: config.error } : {}),
190
+ }
191
+ // Annotated consts so the template literal expressions keep their literal
192
+ // types (contextual typing) — the statics must match the interface exactly.
193
+ const truthName: `${N}/truth` = `${name}/truth`
194
+ const pendingName: `${N}/pending` = `${name}/pending`
195
+ const failedName: `${N}/Failed` = `${name}/Failed`
196
+ const read = Effect.flatMap(store, readTracked)
197
+ return yieldableClass(read, {
198
+ manifest,
199
+ store,
200
+ name,
201
+ inputs: config.inputs,
202
+ intents: config.intents,
203
+ gated,
204
+ truth: new StateToken(truthName, truthTag),
205
+ pending: new StateToken(pendingName, pendingTag),
206
+ Failed: Event.make(
207
+ failedName,
208
+ looseSchema<FailedIntent<I>>(
209
+ Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
210
+ ),
211
+ ),
212
+ })
213
+ }
214
+
215
+ /**
216
+ * The `.live` config: the query (same contract as `AsyncCalc.live`), the
217
+ * delivery effect, and the pure per-intent fold.
218
+ */
219
+ export type RemoteStateLive<
220
+ Inputs extends ReadonlyArray<AnySource>,
221
+ Intents extends ReadonlyArray<Event.AnyEvent>,
222
+ A,
223
+ E,
224
+ Gated extends boolean,
225
+ R,
226
+ R2,
227
+ > = {
228
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
229
+ /**
230
+ * Deliver one intent to the server. ANY failure (typed failure or defect)
231
+ * settles the intent and dispatches `Failed`; the result value is ignored —
232
+ * truth comes back through the invalidated query, never through the response.
233
+ */
234
+ readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
235
+ /**
236
+ * The pure per-intent fold over the server value (typically one
237
+ * `Match.exhaustive` over the intent union). MUST be idempotent against
238
+ * truth that already carries the intent — see the module doc.
239
+ */
240
+ readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
241
+ /** The send lane. Default: a generated `${name}/sends` merge channel. */
242
+ readonly channel?: Channel.ChannelClass
243
+ readonly invalidateBy?: InvalidateBy<Inputs>
244
+ /** Extra refetch triggers (realtime pushes, unrelated mutations) — `AsyncCalc`'s sugar. */
245
+ readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
246
+ /** Refetch conciliation — see `AsyncCalc.live`. Mutation bursts favor `'trailing'`. */
247
+ readonly coalesce?: 'switch' | 'trailing'
248
+ /** Structural sharing across refetches AND overlay recomputes — see `AsyncCalc.live`. */
249
+ readonly reuse?: boolean
250
+ } & (Gated extends true
251
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
252
+ : { readonly disabled?: never })
253
+
254
+ /** The seam between the send procedure and the query driver (module-private). */
255
+ interface SettleLink {
256
+ /** The driver's highest REQUESTED run generation (see `QueryDriver.requested`). */
257
+ readonly requested: () => number
258
+ /** Settle `opId` when a run with generation ≥ `waitFor` lands `Success`. */
259
+ readonly register: (opId: string, waitFor: number) => void
260
+ }
261
+
262
+ /**
263
+ * Wire a remote state: the query driver (truth), the overlay (visible view),
264
+ * the hidden queue reducer, the send procedure, and the settle link between
265
+ * them — one layer. Hidden names minted per call, namespaced by the remote
266
+ * state's name: events `…/Queued`/`…/Acked`/`…/Settled`/`…/Invalidated`, state
267
+ * `…/revision`, reducers `…/pending`/`…/revision`, procedure `…/send`, default
268
+ * channel `…/sends`.
269
+ */
270
+ export const live = <
271
+ N extends string,
272
+ Inputs extends ReadonlyArray<AnySource>,
273
+ Intents extends ReadonlyArray<Event.AnyEvent>,
274
+ A,
275
+ E,
276
+ Gated extends boolean,
277
+ R,
278
+ R2,
279
+ >(
280
+ remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
281
+ config: RemoteStateLive<Inputs, Intents, A, E, Gated, R, R2>,
282
+ ): Layer.Layer<
283
+ | Store<AsyncData<A, E, Gated>>
284
+ | Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
285
+ never,
286
+ | InputStores<Inputs>
287
+ | R
288
+ | R2
289
+ | Reducers
290
+ | Channel.Procedures
291
+ | Channel.Channels
292
+ | Bus
293
+ > => {
294
+ type I = Event.EventType<Intents[number]>
295
+ const name = remote.manifest.name
296
+ const truthTag = remote.truth.store
297
+ const pendingTag = remote.pending.store
298
+
299
+ // The hidden event vocabulary. `Queued` carries the intent (the queue
300
+ // reducer's only source of it); `Acked`/`Settled` name the op; `Invalidated`
301
+ // bumps the hidden revision exactly like an `invalidateOn` event.
302
+ const Queued = Event.make(
303
+ `${name}/Queued`,
304
+ looseSchema<{ readonly opId: string; readonly intent: I }>(
305
+ Schema.Struct({ opId: Schema.String, intent: Schema.Unknown }),
306
+ ),
307
+ )
308
+ const Acked = Event.make(`${name}/Acked`, Schema.Struct({ opId: Schema.String }))
309
+ const Settled = Event.make(`${name}/Settled`, Schema.Struct({ opId: Schema.String }))
310
+ const Invalidated = Event.make(`${name}/Invalidated`, Schema.Struct({}))
311
+
312
+ // The hidden revision pair (the `invalidateOn` machinery, unconditional here:
313
+ // `Invalidated` always drives it; user `invalidateOn` events join the same fold).
314
+ const revisionState = State.make(`${name}/revision`, RevisionSchema)
315
+ const revisionReducer = Reducer.make(`${name}/revision`, {
316
+ states: [revisionState],
317
+ events: [Invalidated, ...(config.invalidateOn ?? [])],
318
+ })
319
+
320
+ // The settle link, keyed per live so two remote states never cross-resolve.
321
+ const linkTag = Context.GenericTag<SettleLink>(`reform/remoteState/${name}/link`)
322
+
323
+ // ── 1. Driver + link: the truth store and the generation bookkeeping. ──────
324
+ const driverLayer = Layer.scopedContext(
325
+ Effect.gen(function* () {
326
+ // `disabled` is rejected at the type level for non-gated definitions
327
+ // (erased to `never` there); read it through a loose view for the runtime.
328
+ const cfg = config as {
329
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
330
+ readonly invalidateBy?: InvalidateBy<Inputs>
331
+ readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
332
+ readonly coalesce?: 'switch' | 'trailing'
333
+ readonly reuse?: boolean
334
+ }
335
+ // Requirement-free read (`serviceOption`): the assembly below always
336
+ // provides the revision store alongside this driver.
337
+ const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
338
+ const runtime = yield* Effect.runtime<Bus>()
339
+
340
+ // Settle waiters: a plain registry, not fibers parked per intent — so the
341
+ // settle path is independent of the user channel's policy (`latest`/
342
+ // `exclusive` lanes can't orphan a pending entry by cancelling its fiber).
343
+ const waiters = MutableRef.make<
344
+ ReadonlyArray<{ readonly opId: string; readonly waitFor: number }>
345
+ >([])
346
+ const onSettled = (generation: number) => {
347
+ const due = MutableRef.get(waiters).filter((waiter) => waiter.waitFor <= generation)
348
+ if (due.length === 0) return
349
+ MutableRef.update(waiters, (all) => all.filter((waiter) => waiter.waitFor > generation))
350
+ // Dispatch synchronously (`publish` only enqueues on the unbounded bus):
351
+ // the settle is enqueued in the same flush as the converged value write.
352
+ for (const waiter of due) {
353
+ Runtime.runSync(runtime)(
354
+ publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
355
+ )
356
+ }
357
+ }
358
+
359
+ const driver = yield* makeQueryDriver({
360
+ name,
361
+ label: 'RemoteState',
362
+ gated: remote.gated,
363
+ inputs: remote.inputs,
364
+ query: cfg.query,
365
+ invalidateBy: cfg.invalidateBy,
366
+ disabled: cfg.disabled,
367
+ coalesce: cfg.coalesce,
368
+ reuse: cfg.reuse,
369
+ extraKey:
370
+ revision === undefined
371
+ ? undefined
372
+ : {
373
+ read: () => revision.getSnapshot(),
374
+ subscribe: (listener) => revision.subscribe(listener),
375
+ },
376
+ onSettled,
377
+ })
378
+ const link: SettleLink = {
379
+ requested: driver.requested,
380
+ register: (opId, waitFor) =>
381
+ MutableRef.update(waiters, (all) => [...all, { opId, waitFor }]),
382
+ }
383
+ return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
384
+ Context.add(linkTag, link),
385
+ )
386
+ }),
387
+ )
388
+
389
+ // ── 2. Overlay: visible = pending.reduce(apply, truth). ────────────────────
390
+ const overlayLayer = Layer.scoped(
391
+ remote.store,
392
+ Effect.gen(function* () {
393
+ const scheduler = yield* resolveScheduler
394
+ // Widened so the arm dispatch below is a plain tag union (read-only view).
395
+ const feedStore = widenStore<A, E, Gated>(yield* truthTag)
396
+ const queueStore = yield* pendingTag
397
+ const memo = MutableRef.make<
398
+ { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
399
+ >(undefined)
400
+ const recompute = (): AnyAsyncData<A, E> => {
401
+ const feed = feedStore.getSnapshot()
402
+ const pending = queueStore.getSnapshot()
403
+ const key = [feed, pending]
404
+ const prev = MutableRef.get(memo)
405
+ if (prev !== undefined && sameKey(key, prev.key)) return prev.output
406
+ // `apply` runs over every `Success` — including `refetching: true`, so
407
+ // queued intents stay visible while the server converges; the other
408
+ // arms pass through by reference.
409
+ const overlaid: AnyAsyncData<A, E> =
410
+ feed._tag === 'Success'
411
+ ? AsyncData.success(
412
+ pending.reduce((value, entry) => config.apply(value, entry.intent), feed.value),
413
+ feed.refetching,
414
+ )
415
+ : feed
416
+ // With `reuse`, also reconcile consecutive overlay outputs: the fold
417
+ // rebuilds the value per recompute, so untouched subtrees would
418
+ // otherwise lose identity every time the queue moves.
419
+ const output =
420
+ config.reuse === true &&
421
+ prev !== undefined &&
422
+ prev.output._tag === 'Success' &&
423
+ overlaid._tag === 'Success'
424
+ ? AsyncData.success(shareStructure(prev.output.value, overlaid.value), overlaid.refetching)
425
+ : overlaid
426
+ MutableRef.set(memo, { key, output })
427
+ return output
428
+ }
429
+ const subscribe = (listener: () => void): (() => void) => {
430
+ const offFeed = feedStore.subscribe(listener)
431
+ const offQueue = queueStore.subscribe(listener)
432
+ return () => {
433
+ offFeed()
434
+ offQueue()
435
+ }
436
+ }
437
+ const derived = makeDerivedStore(recompute, subscribe, scheduler)
438
+ yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
439
+ return narrowStore<A, E, Gated>(derived.store)
440
+ }),
441
+ )
442
+
443
+ // ── 3. Queue reducer: the pending queue's SOLE writer. ─────────────────────
444
+ // Assembled directly against the registry rather than through `Reducer.live`,
445
+ // whose parameter types are conditionals TS keeps deferred while `Intents` is
446
+ // an open type parameter (user code instantiates the public surface
447
+ // concretely, so this is the one generic caller).
448
+ const queueReducerName = `${name}/pending`
449
+ const queueHandles: ReadonlySet<string> = new Set([Queued.tag, Acked.tag, Settled.tag])
450
+ const foldQueue = (
451
+ queue: ReadonlyArray<PendingIntent<I>>,
452
+ event: { readonly _tag: string; readonly opId: string; readonly intent?: I },
453
+ ): ReadonlyArray<PendingIntent<I>> =>
454
+ // `Queued` is the arm carrying the intent (the `undefined` guard is for the
455
+ // type only — every `Queued` carries one). An ack/settle for an id that is
456
+ // absent (or already confirmed) keeps the same reference — a true no-op.
457
+ event._tag === Queued.tag
458
+ ? event.intent === undefined
459
+ ? queue
460
+ : [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
461
+ : event._tag === Acked.tag
462
+ ? queue.some((entry) => entry.opId === event.opId && entry.status === 'sending')
463
+ ? queue.map((entry) =>
464
+ entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
465
+ )
466
+ : queue
467
+ : queue.some((entry) => entry.opId === event.opId)
468
+ ? queue.filter((entry) => entry.opId !== event.opId)
469
+ : queue
470
+ const queueReducerLayer = Layer.scopedDiscard(
471
+ Effect.gen(function* () {
472
+ const reducers = yield* Reducers
473
+ if (reducers.entries.some((entry) => entry.name === queueReducerName)) {
474
+ yield* Effect.logWarning(`reform: duplicate reducer name '${queueReducerName}' registered`)
475
+ }
476
+ const store = yield* pendingTag
477
+ const entry: ReducerEntry = {
478
+ name: queueReducerName,
479
+ handles: queueHandles,
480
+ // The loop only invokes `apply` for events in `handles`; `narrowHandled`
481
+ // restores that typed view from the erased envelope.
482
+ apply: (event) => store.set(foldQueue(store.get(), narrowHandled(event))),
483
+ }
484
+ yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
485
+ Effect.sync(() => reducers.unregister(entry)),
486
+ )
487
+ }),
488
+ )
489
+
490
+ // ── 4. Send procedure: intent dispatch → deliver → ack/settle bookkeeping. ─
491
+ // Hand-assembled `ProcedureEntry` for the same deferred-conditional reason as
492
+ // the queue reducer (the `Procedure.live` internals, verbatim).
493
+ const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
494
+ const procedureName = `${name}/send`
495
+ const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
496
+ const sendProcedureLayer = Layer.scopedDiscard(
497
+ Effect.gen(function* () {
498
+ const procedures = yield* Channel.Procedures
499
+ if (procedures.entries.some((entry) => entry.name === procedureName)) {
500
+ yield* Effect.logWarning(`reform: duplicate procedure name '${procedureName}' registered`)
501
+ }
502
+ const link = yield* linkTag
503
+ // Snapshot the body's full context (Bus + the send's R2) so `run` is total.
504
+ const runtime = yield* Effect.runtime<Bus | R2>()
505
+ const deliver = (intent: I): Effect.Effect<void, never, Bus | R2> =>
506
+ Effect.gen(function* () {
507
+ const opId = yield* Effect.sync(() => crypto.randomUUID())
508
+ yield* Event.dispatch(Queued, { opId, intent })
509
+ const outcome = yield* config.send(intent).pipe(
510
+ // A defect in `send` is a bug, but it must not strand the entry:
511
+ // fold it into the failure arm so the intent settles and `Failed`
512
+ // fires (the channel would otherwise swallow it after logging).
513
+ Effect.catchAllDefect((defect) => Effect.fail(defect)),
514
+ Effect.either,
515
+ // A cancelled send (a `latest`/`exclusive` user channel tearing the
516
+ // run down) settles too: the overlay reverts, and if the request
517
+ // did reach the server the next refetch shows it as truth.
518
+ Effect.onInterrupt(() => Event.dispatch(Settled, { opId })),
519
+ )
520
+ yield* Either.match(outcome, {
521
+ onLeft: (error) =>
522
+ Effect.zipRight(
523
+ Event.dispatch(remote.Failed, { intent, error }),
524
+ Event.dispatch(Settled, { opId }),
525
+ ),
526
+ onRight: () =>
527
+ // Register the waiter BEFORE the invalidation bump: `Invalidated`
528
+ // routes through the drain loop later, so the run it triggers is
529
+ // necessarily requested after `requested()` here — the waiter can
530
+ // never be satisfied by a refetch that predates the ack.
531
+ Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
532
+ Effect.zipRight(Event.dispatch(Acked, { opId })),
533
+ Effect.zipRight(Event.dispatch(Invalidated, {})),
534
+ ),
535
+ })
536
+ })
537
+ const entry: Channel.ProcedureEntry = {
538
+ name: procedureName,
539
+ channelName: channel.manifest.name,
540
+ handles: sendHandles,
541
+ run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
542
+ }
543
+ yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
544
+ Effect.sync(() => procedures.unregister(entry)),
545
+ )
546
+ }),
547
+ )
548
+
549
+ // ── Assembly. ───────────────────────────────────────────────────────────────
550
+ // The pending store backs both the overlay and the queue reducer; the hidden
551
+ // revision store feeds both the driver (`extraKey`) and its reducer; the
552
+ // channel `.live` is idempotent by name+policy, so a user channel shared with
553
+ // other procedures wires up exactly once.
554
+ const pendingStoreLayer = Layer.effect(
555
+ pendingTag,
556
+ Effect.map(resolveScheduler, (scheduler) =>
557
+ makeStore<ReadonlyArray<PendingIntent<I>>>([], scheduler),
558
+ ),
559
+ )
560
+ return Layer.mergeAll(
561
+ overlayLayer,
562
+ queueReducerLayer,
563
+ Reducer.live(revisionReducer, bumpRevision),
564
+ sendProcedureLayer,
565
+ Channel.live(channel),
566
+ ).pipe(
567
+ Layer.provideMerge(driverLayer),
568
+ Layer.provide(State.live(revisionState, revisionZero)),
569
+ Layer.provideMerge(pendingStoreLayer),
570
+ )
571
+ }
@@ -1,7 +1,10 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.publish = exports.busLayer = exports.Bus = exports.narrowHandled = void 0;
4
- const effect_1 = require("effect");
1
+ import { Context, Effect, Layer, PubSub } from 'effect'
2
+
3
+ /** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
4
+ export interface Tagged {
5
+ readonly _tag: string
6
+ }
7
+
5
8
  /**
6
9
  * Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
7
10
  * loop and channels only route events whose tag is in a handler's `handles`, so
@@ -9,17 +12,25 @@ const effect_1 = require("effect");
9
12
  * generic `Tagged`, and this restores the typed view. The one documented home
10
13
  * for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
11
14
  */
12
- const narrowHandled = (event) => event;
13
- exports.narrowHandled = narrowHandled;
14
- /** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
15
- class Bus extends effect_1.Context.Tag('reform/Bus')() {
15
+ export const narrowHandled = <E>(event: Tagged): E => event as never
16
+
17
+ /** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
18
+ export type Priority = 'High' | 'Normal'
19
+
20
+ export interface Envelope {
21
+ readonly priority: Priority
22
+ readonly event: Tagged
16
23
  }
17
- exports.Bus = Bus;
24
+
25
+ /** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
26
+ export class Bus extends Context.Tag('reform/Bus')<Bus, PubSub.PubSub<Envelope>>() {}
27
+
18
28
  // Unbounded so `publish` never suspends: UI triggers dispatch synchronously
19
29
  // (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
20
30
  // backpressure to the UI thread. The single drain loop empties the bus every
21
31
  // microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
22
32
  // memory is preferred over never dropping (publish stays synchronous either way).
23
- exports.busLayer = effect_1.Layer.scoped(Bus, effect_1.PubSub.unbounded());
24
- const publish = (priority, event) => effect_1.Effect.flatMap(Bus, (bus) => effect_1.PubSub.publish(bus, { priority, event }));
25
- exports.publish = publish;
33
+ export const busLayer = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
34
+
35
+ export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
36
+ Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event }))