@playfast/reform 0.1.0 → 1.1.1

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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +78 -173
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -202
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +48 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +10 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +131 -0
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,311 +1,58 @@
1
- import { Context, Effect, Either, Layer, MutableRef, Option, Runtime, Schema } from 'effect'
2
- import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Layer,
5
+ MutableRef,
6
+ Option,
7
+ Runtime,
8
+ Schema,
9
+ } from 'effect'
10
+ import { type AsyncData, narrowStore, widenStore } from '../calc/asyncData'
3
11
  import * as Channel from '../channel/channel'
4
- import { type Manifest, yieldableClass } from '../definition/definition'
5
12
  import * as Event from '../event/event'
6
13
  import {
7
14
  bumpRevision,
8
- type GatedOf,
9
- gatedFlag,
10
15
  makeQueryDriver,
11
16
  RevisionSchema,
12
17
  revisionZero,
13
18
  } from '../internal/queryDriver'
14
- import { reuse as shareStructure } from '../internal/reuse'
15
19
  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'
20
+ import { type InputStores } from '../internal/sources'
21
+ import { makeStore, type Store } from '../internal/store'
24
22
  import * as Reducer from '../reducer/reducer'
25
- import { Bus, narrowHandled, publish } from '../runtime/bus'
26
- import { type ReducerEntry, Reducers } from '../runtime/loop'
23
+ import { Bus, publish } from '../runtime/bus'
24
+ import { Reducers } from '../runtime/loop'
27
25
  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
- // The erased event envelope the queue reducer folds over (bus boundary shape ⇒
83
- // `ExternalApi` postfix exempts the optional `intent` field). Only `Queued`
84
- // carries an `intent`; ack/settle envelopes name an op only.
85
- interface QueueFoldEventExternalApi<I> {
86
- readonly _tag: string
87
- readonly opId: string
88
- readonly intent?: I
89
- }
90
-
91
- // Reflective metadata read by chrome/dev tools (boundary shape ⇒ `ExternalApi`
92
- // postfix exempts it from `no-optional-fields`).
93
- export interface RemoteStateManifestExternalApi<N extends string, A, E> extends Manifest {
94
- readonly kind: 'RemoteState'
95
- readonly name: N
96
- /** Schema of the server value (`Success` arm / `apply`'s domain). */
97
- readonly output: Schema.Schema<A, any>
98
- readonly error?: Schema.Schema<E, any>
99
- /** Whether the query can be disabled (drives the `Idle` arm). */
100
- readonly gated: boolean
101
- /** The mutation vocabulary — intent event tags, reflectable without running logic. */
102
- readonly intents: ReadonlyArray<string>
103
- }
104
-
105
- export interface RemoteStateClass<
106
- out N extends string,
107
- out Inputs extends ReadonlyArray<AnySource>,
108
- in out Intents extends ReadonlyArray<Event.AnyEvent>,
109
- in out A,
110
- in out E,
111
- in out Gated extends boolean,
112
- > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
113
- new (): {}
114
- readonly manifest: RemoteStateManifestExternalApi<N, A, E>
115
- /** The OVERLAID view (`pending.reduce(apply, truth)`) — what `yield*` reads. */
116
- readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
117
- /** The name, so the class doubles as a `Source` input to another calc. */
118
- readonly name: N
119
- /** Marker data read by `RemoteState.live`. */
120
- readonly inputs: Inputs
121
- /** The intent event definitions (marker data; read by `RemoteState.live`). */
122
- readonly intents: Intents
123
- /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
124
- readonly gated: Gated
125
- /** The un-overlaid query lifecycle — for chrome that must show server truth. */
126
- readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>>
127
- /**
128
- * The pending queue as a read-only `Source` (badges, "saving…" chrome). Not a
129
- * `StateClass`, so no user reducer can target it — the hidden queue reducer
130
- * is its sole writer.
131
- */
132
- readonly pending: StateToken<
133
- `${N}/pending`,
134
- ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>
135
- >
136
- /** Public fact: a send failed; its intent left the queue and the view reverted. */
137
- readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
138
- }
139
-
140
- // Public definition-surface config (boundary shape ⇒ `ExternalApi` postfix
141
- // exempts the optional option fields from `no-optional-fields`).
142
- export interface RemoteStateConfigExternalApi<
143
- Inputs extends ReadonlyArray<AnySource>,
144
- Intents extends ReadonlyArray<Event.AnyEvent>,
145
- A,
146
- E,
147
- AlwaysOn extends boolean,
148
- > {
149
- readonly inputs: Inputs
150
- /** Schema of the server value. */
151
- readonly output: Schema.Schema<A, any>
152
- /** Schema of the query failure. Omitted ⇒ infallible query, no `Error` arm. */
153
- readonly error?: Schema.Schema<E, any>
154
- /** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
155
- readonly alwaysOn?: AlwaysOn
156
- /** The mutation vocabulary: ordinary `Event.make` definitions. */
157
- readonly intents: Intents
158
- }
159
-
160
- // Intent payloads are typed by the definition's `Intents`, but `Intents` is an
161
- // open type parameter inside `make`/`live`, so the runtime schemas can only be
162
- // structural (`Schema.Unknown` in the intent slot). Event schemas are
163
- // reflective metadata — never decoded on the dispatch path — so the typed view
164
- // is granted here once (the `narrowStore`/`narrowHandled` style; `any`
165
- // short-circuits the schema's invariance, no value is cast).
166
- const looseSchema = <P>(schema: Schema.Schema<any, any>): Schema.Schema<P, any> => schema
167
-
168
- /**
169
- * Define a remote state: a query (like `AsyncCalc.make`) plus the intent
170
- * events that mutate it. `yield* TheClass` reads the overlaid view;
171
- * `TheClass.truth` / `TheClass.pending` / `TheClass.Failed` expose the rest of
172
- * the surface. The query/send/apply logic is supplied by `RemoteState.live`.
173
- */
174
- export const make = <
175
- const N extends string,
176
- const Inputs extends ReadonlyArray<AnySource>,
177
- const Intents extends ReadonlyArray<Event.AnyEvent>,
178
- A,
179
- E = never,
180
- const AlwaysOn extends boolean = false,
181
- >(
182
- name: N,
183
- config: RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
184
- ): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
185
- type I = Event.EventType<Intents[number]>
186
- const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
187
- `reform/remoteState/${name}`,
188
- )
189
- const truthTag = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
190
- `reform/remoteState/${name}/truth`,
191
- )
192
- const pendingTag = Context.GenericTag<Store<ReadonlyArray<PendingIntent<I>>>>(
193
- `reform/remoteState/${name}/pending`,
194
- )
195
- const gated = gatedFlag(config.alwaysOn)
196
- const manifest: RemoteStateManifestExternalApi<N, A, E> = {
197
- kind: 'RemoteState',
198
- name,
199
- output: config.output,
200
- gated,
201
- intents: config.intents.map((event) => event.tag),
202
- ...(config.error !== undefined ? { error: config.error } : {}),
203
- }
204
- // Annotated consts so the template literal expressions keep their literal
205
- // types (contextual typing) — the statics must match the interface exactly.
206
- const truthName: `${N}/truth` = `${name}/truth`
207
- const pendingName: `${N}/pending` = `${name}/pending`
208
- const failedName: `${N}/Failed` = `${name}/Failed`
209
- const read = Effect.flatMap(store, readTracked)
210
- return yieldableClass(read, {
211
- manifest,
212
- store,
213
- name,
214
- inputs: config.inputs,
215
- intents: config.intents,
216
- gated,
217
- truth: new StateToken(truthName, truthTag),
218
- pending: new StateToken(pendingName, pendingTag),
219
- Failed: Event.make(
220
- failedName,
221
- looseSchema<FailedIntent<I>>(
222
- Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
223
- ),
224
- ),
225
- })
226
- }
227
-
228
- /**
229
- * The `.live` config: the query (same contract as `AsyncCalc.live`), the
230
- * delivery effect, and the pure per-intent fold.
231
- */
232
- export type RemoteStateLiveExternalApi<
233
- Inputs extends ReadonlyArray<AnySource>,
234
- Intents extends ReadonlyArray<Event.AnyEvent>,
235
- A,
236
- E,
237
- Gated extends boolean,
238
- R,
239
- R2,
240
- > = {
241
- readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
242
- /**
243
- * Deliver one intent to the server. ANY failure (typed failure or defect)
244
- * settles the intent and dispatches `Failed`; the result value is ignored —
245
- * truth comes back through the invalidated query, never through the response.
246
- */
247
- readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
248
- /**
249
- * The pure per-intent fold over the server value (typically one
250
- * `Match.exhaustive` over the intent union). MUST be idempotent against
251
- * truth that already carries the intent — see the module doc.
252
- */
253
- readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
254
- /** The send lane. Default: a generated `${name}/sends` merge channel. */
255
- readonly channel?: Channel.ChannelClass
256
- readonly invalidateBy?: InvalidateBy<Inputs>
257
- /** Extra refetch triggers (realtime pushes, unrelated mutations) — `AsyncCalc`'s sugar. */
258
- readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
259
- /** Refetch conciliation — see `AsyncCalc.live`. Mutation bursts favor `'trailing'`. */
260
- readonly coalesce?: 'switch' | 'trailing'
261
- /** Structural sharing across refetches AND overlay recomputes — see `AsyncCalc.live`. */
262
- readonly reuse?: boolean
263
- /**
264
- * Persist the converged `Success` truth through the optional host `QueryStore`
265
- * — see `AsyncCalc.live`. Only the settled SERVER value is written: the driver's
266
- * `persistWrite` runs in the success lane after `settleSuccess`, while the
267
- * optimistic overlay lives in a separate downstream store the driver never
268
- * sees — so a persisted value can never capture an un-acked mutation. `true`
269
- * keys by the remote state's name; `{ key }` overrides (a function of the
270
- * inputs for a keyed family). Values (de)serialize through the `output` schema.
271
- */
272
- readonly persist?:
273
- | boolean
274
- | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
275
- } & (Gated extends true
276
- ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
277
- : { readonly disabled?: never })
278
-
279
- // Loose runtime view: `disabled` is erased to `never` for non-gated definitions
280
- // at the type level, so the driver reads config back through this (boundary ⇒
281
- // `ExternalApi` postfix exempts its optional fields).
282
- type RemoteStateLiveRuntimeExternalApi<Inputs extends ReadonlyArray<AnySource>, A, E, R> = {
283
- readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
284
- readonly invalidateBy?: InvalidateBy<Inputs>
285
- readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
286
- readonly coalesce?: 'switch' | 'trailing'
287
- readonly reuse?: boolean
288
- readonly persist?:
289
- | boolean
290
- | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
291
- }
292
-
293
- /** The seam between the send procedure and the query driver (module-private). */
294
- interface SettleLink {
295
- /** The driver's highest REQUESTED run generation (see `QueryDriver.requested`). */
296
- readonly requested: () => number
297
- /** Settle `opId` when a run with generation ≥ `waitFor` lands `Success`. */
298
- readonly register: (opId: string, waitFor: number) => void
299
- }
26
+ import { type AnySource } from '../state/token'
27
+ import {
28
+ looseSchema,
29
+ type PendingIntent,
30
+ type RemoteStateClass,
31
+ type RemoteStateSettled,
32
+ } from './remoteStateDefinition'
33
+ import type {
34
+ RemoteStateLiveExternalApi,
35
+ RemoteStateLiveRuntimeExternalApi,
36
+ SettleLink,
37
+ } from './remoteStateLiveTypes'
38
+ import { makeOverlayLayer, makeQueueReducerLayer } from './remoteStateLayers'
39
+ import { makeSendProcedureLayer } from './remoteStateSend'
40
+
41
+ // Generation rule: intent acked at gen g settles only on Success with gen > g (no flicker).
42
+ // apply must be idempotent: between refetch landing and settle, intent is applied over truth that already has it.
43
+
44
+ export {
45
+ type FailedIntent,
46
+ make,
47
+ type PendingIntent,
48
+ type RemoteStateClass,
49
+ type RemoteStateConfigExternalApi,
50
+ type RemoteStateManifestExternalApi,
51
+ type RemoteStateSettled,
52
+ } from './remoteStateDefinition'
53
+
54
+ export type { RemoteStateLiveExternalApi } from './remoteStateLiveTypes'
300
55
 
301
- /**
302
- * Wire a remote state: the query driver (truth), the overlay (visible view),
303
- * the hidden queue reducer, the send procedure, and the settle link between
304
- * them — one layer. Hidden names minted per call, namespaced by the remote
305
- * state's name: events `…/Queued`/`…/Acked`/`…/Settled`/`…/Invalidated`, state
306
- * `…/revision`, reducers `…/pending`/`…/revision`, procedure `…/send`, default
307
- * channel `…/sends`.
308
- */
309
56
  export const live = <
310
57
  N extends string,
311
58
  Inputs extends ReadonlyArray<AnySource>,
@@ -315,9 +62,10 @@ export const live = <
315
62
  Gated extends boolean,
316
63
  R,
317
64
  R2,
65
+ SettledEvent extends Event.AnyEvent = never,
318
66
  >(
319
67
  remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
320
- config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2>,
68
+ config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2, SettledEvent>,
321
69
  ): Layer.Layer<
322
70
  | Store<AsyncData<A, E, Gated>>
323
71
  | Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
@@ -335,9 +83,6 @@ export const live = <
335
83
  const truthTag = remote.truth.store
336
84
  const pendingTag = remote.pending.store
337
85
 
338
- // The hidden event vocabulary. `Queued` carries the intent (the queue
339
- // reducer's only source of it); `Acked`/`Settled` name the op; `Invalidated`
340
- // bumps the hidden revision exactly like an `invalidateOn` event.
341
86
  const Queued = Event.make(
342
87
  `${name}/Queued`,
343
88
  looseSchema<{ readonly opId: string; readonly intent: I }>(
@@ -348,8 +93,6 @@ export const live = <
348
93
  const Settled = Event.make(`${name}/Settled`, Schema.Struct({ opId: Schema.String }))
349
94
  const Invalidated = Event.make(`${name}/Invalidated`, Schema.Struct({}))
350
95
 
351
- // The hidden revision pair (the `invalidateOn` machinery, unconditional here:
352
- // `Invalidated` always drives it; user `invalidateOn` events join the same fold).
353
96
  const revisionState = State.make(`${name}/revision`, RevisionSchema)
354
97
  const extraInvalidateOn = Option.getOrElse(
355
98
  Option.fromNullable(config.invalidateOn),
@@ -360,24 +103,16 @@ export const live = <
360
103
  events: [Invalidated, ...extraInvalidateOn],
361
104
  })
362
105
 
363
- // The settle link, keyed per live so two remote states never cross-resolve.
364
106
  const linkTag = Context.GenericTag<SettleLink>(`reform/remoteState/${name}/link`)
365
107
 
366
- // ── 1. Driver + link: the truth store and the generation bookkeeping. ──────
367
108
  const driverLayer = Layer.scopedContext(
368
109
  Effect.gen(function* () {
369
- // `disabled` is rejected at the type level for non-gated definitions
370
- // (erased to `never` there); read it through a loose view for the runtime.
371
110
  // oxlint-disable-next-line reform-rules/no-type-assertion -- type-level erasure seam: `disabled` is `never` for non-gated definitions; read it back through the runtime view
372
111
  const cfg = config as RemoteStateLiveRuntimeExternalApi<Inputs, A, E, R>
373
- // Requirement-free read (`serviceOption`): the assembly below always
374
- // provides the revision store alongside this driver.
375
112
  const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
376
113
  const runtime = yield* Effect.runtime<Bus>()
377
114
 
378
- // Settle waiters: a plain registry, not fibers parked per intent so the
379
- // settle path is independent of the user channel's policy (`latest`/
380
- // `exclusive` lanes can't orphan a pending entry by cancelling its fiber).
115
+ // Registry (not per-intent fibers): latest/exclusive cancel must not orphan waiters
381
116
  const waiters = MutableRef.make<
382
117
  ReadonlyArray<{ readonly opId: string; readonly waitFor: number }>
383
118
  >([])
@@ -387,8 +122,7 @@ export const live = <
387
122
  return
388
123
  }
389
124
  MutableRef.update(waiters, (all) => all.filter((waiter) => waiter.waitFor > generation))
390
- // Dispatch synchronously (`publish` only enqueues on the unbounded bus):
391
- // the settle is enqueued in the same flush as the converged value write.
125
+ // runSync: settle enqueued in same flush as converged value write
392
126
  due.forEach((waiter) =>
393
127
  Runtime.runSync(runtime)(
394
128
  publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
@@ -396,9 +130,7 @@ export const live = <
396
130
  )
397
131
  }
398
132
 
399
- // Persist mirrors `AsyncCalc.live`: only the settled truth reaches the
400
- // store (see the `persist` doc above), so it needs no extra success/overlay
401
- // guard. `true` keys by name; `{ key }` overrides (a function for a family).
133
+ // Persist only settled server truth (overlay is a separate store the driver never sees)
402
134
  const persistOption = cfg.persist
403
135
  const persistKey =
404
136
  persistOption === true || persistOption === undefined || persistOption === false
@@ -426,6 +158,31 @@ export const live = <
426
158
  extraKey,
427
159
  onSettled,
428
160
  })
161
+ const settledConfig = config.settled
162
+ if (settledConfig !== undefined) {
163
+ const truth = widenStore<A, E, Gated>(narrowStore<A, E, Gated>(driver.store))
164
+ const lastSettled = MutableRef.make<RemoteStateSettled<A, E> | undefined>(undefined)
165
+ const publishSettled = () => {
166
+ const settledTruth = truth.getSnapshot()
167
+ if (
168
+ (settledTruth._tag === 'Success' || settledTruth._tag === 'Error') &&
169
+ settledTruth.refetching === false &&
170
+ MutableRef.get(lastSettled) !== settledTruth
171
+ ) {
172
+ MutableRef.set(lastSettled, settledTruth)
173
+ Runtime.runSync(runtime)(
174
+ publish(
175
+ 'Normal',
176
+ Event.construct(settledConfig.event, settledConfig.payload(settledTruth)),
177
+ ),
178
+ )
179
+ }
180
+ }
181
+ const unsubscribe = truth.subscribe(publishSettled)
182
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
183
+ const scheduler = yield* resolveScheduler
184
+ scheduler.schedule([publishSettled])
185
+ }
429
186
  const link: SettleLink = {
430
187
  requested: driver.requested,
431
188
  register: (opId, waitFor) =>
@@ -437,181 +194,37 @@ export const live = <
437
194
  }),
438
195
  )
439
196
 
440
- // ── 2. Overlay: visible = pending.reduce(apply, truth). ────────────────────
441
- const overlayLayer = Layer.scoped(
442
- remote.store,
443
- Effect.gen(function* () {
444
- const scheduler = yield* resolveScheduler
445
- // Widened so the arm dispatch below is a plain tag union (read-only view).
446
- const feedStore = widenStore<A, E, Gated>(yield* truthTag)
447
- const queueStore = yield* pendingTag
448
- const memo = MutableRef.make<
449
- { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
450
- >(undefined)
451
- const recompute = (): AnyAsyncData<A, E> => {
452
- const feed = feedStore.getSnapshot()
453
- const pending = queueStore.getSnapshot()
454
- const key = [feed, pending]
455
- const prev = MutableRef.get(memo)
456
- if (prev !== undefined && sameKey(key, prev.key)) {
457
- return prev.output
458
- }
459
- // `apply` runs over every `Success` — including `refetching: true`, so
460
- // queued intents stay visible while the server converges; the other
461
- // arms pass through by reference.
462
- const applyPending = (base: A): A =>
463
- pending.reduce((applied, entry) => config.apply(applied, entry.intent), base)
464
- const overlaid: AnyAsyncData<A, E> =
465
- feed._tag === 'Success' ? AsyncData.success(applyPending(feed.value), feed.refetching) : feed
466
- // With `reuse`, also reconcile consecutive overlay outputs: the fold
467
- // rebuilds the value per recompute, so untouched subtrees would
468
- // otherwise lose identity every time the queue moves.
469
- const output =
470
- config.reuse === true &&
471
- prev !== undefined &&
472
- prev.output._tag === 'Success' &&
473
- overlaid._tag === 'Success'
474
- ? AsyncData.success(shareStructure(prev.output.value, overlaid.value), overlaid.refetching)
475
- : overlaid
476
- MutableRef.set(memo, { key, output })
477
- return output
478
- }
479
- const subscribe = (listener: () => void): (() => void) => {
480
- const offFeed = feedStore.subscribe(listener)
481
- const offQueue = queueStore.subscribe(listener)
482
- return () => {
483
- offFeed()
484
- offQueue()
485
- }
486
- }
487
- const derived = makeDerivedStore(recompute, subscribe, scheduler)
488
- yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
489
- return narrowStore<A, E, Gated>(derived.store)
490
- }),
491
- )
492
-
493
- // ── 3. Queue reducer: the pending queue's SOLE writer. ─────────────────────
494
- // Assembled directly against the registry rather than through `Reducer.live`,
495
- // whose parameter types are conditionals TS keeps deferred while `Intents` is
496
- // an open type parameter (user code instantiates the public surface
497
- // concretely, so this is the one generic caller).
498
- const queueReducerName = `${name}/pending`
499
- const queueHandles: ReadonlySet<string> = new Set([Queued.tag, Acked.tag, Settled.tag])
500
- const foldQueue = (
501
- queue: ReadonlyArray<PendingIntent<I>>,
502
- event: QueueFoldEventExternalApi<I>,
503
- ): ReadonlyArray<PendingIntent<I>> => {
504
- // `Queued` is the arm carrying the intent (the `undefined` guard is for the
505
- // type only — every `Queued` carries one).
506
- if (event._tag === Queued.tag) {
507
- if (event.intent === undefined) {
508
- return queue
509
- }
510
- return [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
511
- }
512
- // An ack flips a still-sending op to confirmed; an ack for an id that is
513
- // absent (or already confirmed) keeps the same reference — a true no-op.
514
- if (event._tag === Acked.tag) {
515
- const stillSending = queue.some(
516
- (entry) => entry.opId === event.opId && entry.status === 'sending',
517
- )
518
- if (!stillSending) {
519
- return queue
520
- }
521
- return queue.map((entry) =>
522
- entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
523
- )
524
- }
525
- // Settle drops the op if present; absent ⇒ same reference, a true no-op.
526
- if (!queue.some((entry) => entry.opId === event.opId)) {
527
- return queue
528
- }
529
- return queue.filter((entry) => entry.opId !== event.opId)
530
- }
531
- const queueReducerLayer = Layer.scopedDiscard(
532
- Effect.gen(function* () {
533
- const reducers = yield* Reducers
534
- if (reducers.entries.some((entry) => entry.name === queueReducerName)) {
535
- yield* Effect.logWarning(`reform: duplicate reducer name '${queueReducerName}' registered`)
536
- }
537
- const store = yield* pendingTag
538
- const entry: ReducerEntry = {
539
- name: queueReducerName,
540
- handles: queueHandles,
541
- // The loop only invokes `apply` for events in `handles`; `narrowHandled`
542
- // restores that typed view from the erased envelope.
543
- apply: (event) => store.set(foldQueue(store.get(), narrowHandled(event))),
544
- }
545
- yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
546
- Effect.sync(() => reducers.unregister(entry)),
547
- )
548
- }),
549
- )
197
+ const overlayLayer = makeOverlayLayer<I, A, E, Gated>({
198
+ apply: config.apply,
199
+ pendingTag,
200
+ reuse: config.reuse,
201
+ storeTag: remote.store,
202
+ truthTag,
203
+ })
550
204
 
551
- // ── 4. Send procedure: intent dispatch → deliver → ack/settle bookkeeping. ─
552
- // Hand-assembled `ProcedureEntry` for the same deferred-conditional reason as
553
- // the queue reducer (the `Procedure.live` internals, verbatim).
205
+ const queueReducerLayer = makeQueueReducerLayer<I>({
206
+ ackedTag: Acked.tag,
207
+ name: `${name}/pending`,
208
+ pendingTag,
209
+ queuedTag: Queued.tag,
210
+ settledTag: Settled.tag,
211
+ })
554
212
  const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
555
213
  const procedureName = `${name}/send`
556
214
  const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
557
- const sendProcedureLayer = Layer.scopedDiscard(
558
- Effect.gen(function* () {
559
- const procedures = yield* Channel.Procedures
560
- if (procedures.entries.some((entry) => entry.name === procedureName)) {
561
- yield* Effect.logWarning(`reform: duplicate procedure name '${procedureName}' registered`)
562
- }
563
- const link = yield* linkTag
564
- // Snapshot the body's full context (Bus + the send's R2) so `run` is total.
565
- const runtime = yield* Effect.runtime<Bus | R2>()
566
- const deliver = Effect.fn('deliver')(
567
- function* (intent: I): Effect.fn.Return<void, never, Bus | R2> {
568
- const opId = yield* Effect.sync(() => crypto.randomUUID())
569
- yield* Event.dispatch(Queued, { opId, intent })
570
- const outcome = yield* config.send(intent).pipe(
571
- // A defect in `send` is a bug, but it must not strand the entry:
572
- // fold it into the failure arm so the intent settles and `Failed`
573
- // fires (the channel would otherwise swallow it after logging).
574
- Effect.catchAllDefect((defect) => Effect.fail(defect)),
575
- Effect.either,
576
- // A cancelled send (a `latest`/`exclusive` user channel tearing the
577
- // run down) settles too: the overlay reverts, and if the request
578
- // did reach the server the next refetch shows it as truth.
579
- Effect.onInterrupt(() => Event.dispatch(Settled, { opId })),
580
- )
581
- yield* Either.match(outcome, {
582
- onLeft: (error) =>
583
- Effect.zipRight(
584
- Event.dispatch(remote.Failed, { intent, error }),
585
- Event.dispatch(Settled, { opId }),
586
- ),
587
- onRight: () =>
588
- // Register the waiter BEFORE the invalidation bump: `Invalidated`
589
- // routes through the drain loop later, so the run it triggers is
590
- // necessarily requested after `requested()` here — the waiter can
591
- // never be satisfied by a refetch that predates the ack.
592
- Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
593
- Effect.zipRight(Event.dispatch(Acked, { opId })),
594
- Effect.zipRight(Event.dispatch(Invalidated, {})),
595
- ),
596
- })
597
- })
598
- const entry: Channel.ProcedureEntry = {
599
- name: procedureName,
600
- channelName: channel.manifest.name,
601
- handles: sendHandles,
602
- run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
603
- }
604
- yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
605
- Effect.sync(() => procedures.unregister(entry)),
606
- )
607
- }),
608
- )
215
+ const sendProcedureLayer = makeSendProcedureLayer<I, R2>({
216
+ acked: Acked,
217
+ channel,
218
+ failed: remote.Failed,
219
+ handles: sendHandles,
220
+ invalidated: Invalidated,
221
+ linkTag,
222
+ name: procedureName,
223
+ queued: Queued,
224
+ send: config.send,
225
+ settled: Settled,
226
+ })
609
227
 
610
- // ── Assembly. ───────────────────────────────────────────────────────────────
611
- // The pending store backs both the overlay and the queue reducer; the hidden
612
- // revision store feeds both the driver (`extraKey`) and its reducer; the
613
- // channel `.live` is idempotent by name+policy, so a user channel shared with
614
- // other procedures wires up exactly once.
615
228
  const pendingStoreLayer = Layer.effect(
616
229
  pendingTag,
617
230
  Effect.map(resolveScheduler, (scheduler) =>