@playfast/reform 1.0.1 → 1.2.0

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 (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,5 +1,24 @@
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
+ Either,
5
+ Equal,
6
+ Iterable,
7
+ Layer,
8
+ MutableRef,
9
+ Option,
10
+ Runtime,
11
+ Schema,
12
+ } from 'effect'
13
+ import type { Effect as EffectType } from 'effect/Effect'
14
+ import {
15
+ type AnyAsyncData,
16
+ AsyncData,
17
+ type AsyncError,
18
+ type AsyncSuccess,
19
+ narrowStore,
20
+ widenStore,
21
+ } from '../calc/asyncData'
3
22
  import * as Channel from '../channel/channel'
4
23
  import { type Manifest, yieldableClass } from '../definition/definition'
5
24
  import * as Event from '../event/event'
@@ -8,192 +27,171 @@ import {
8
27
  type GatedOf,
9
28
  gatedFlag,
10
29
  makeQueryDriver,
30
+ type QueryCodec,
11
31
  RevisionSchema,
12
32
  revisionZero,
13
33
  } from '../internal/queryDriver'
34
+ import { inspectable } from '../internal/inspect'
14
35
  import { reuse as shareStructure } from '../internal/reuse'
15
- import { resolveScheduler } from '../internal/scheduler'
36
+ import { resolveScheduler, type Scheduler } from '../internal/scheduler'
16
37
  import {
17
38
  type InputsObject,
18
39
  type InputStores,
19
40
  type InvalidateBy,
20
41
  sameKey,
21
42
  } from '../internal/sources'
22
- import { makeDerivedStore, makeStore, type Store } from '../internal/store'
43
+ import { makeDerivedStore, type Store } from '../internal/store'
23
44
  import { readTracked } from '../internal/track'
24
45
  import * as Reducer from '../reducer/reducer'
25
46
  import { Bus, narrowHandled, publish } from '../runtime/bus'
26
47
  import { type ReducerEntry, Reducers } from '../runtime/loop'
27
48
  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. */
49
+ import { type AnySource, type SourceCapture, StateToken } from '../state/token'
50
+
51
+ // An intent acked at generation g settles only on Success with generation > g.
52
+ // `apply` must be idempotent while refetched truth and the pending intent overlap.
53
+
68
54
  export interface PendingIntent<I> {
69
55
  readonly opId: string
70
- /** The dispatched intent event (`{ _tag, ...payload }`). */
71
56
  readonly intent: I
72
- /** `'sending'` until the RPC acks; `'confirmed'` until the post-ack refetch lands. */
73
57
  readonly status: 'sending' | 'confirmed'
74
58
  }
75
59
 
76
- /** The payload of the public `Failed` event: which intent, and what `send` raised. */
77
60
  export interface FailedIntent<I> {
78
61
  readonly intent: I
79
62
  readonly error: unknown
80
63
  }
81
64
 
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.
65
+ export type RemoteStateSettled<A, E> = AsyncSuccess<A> | AsyncError<E>
66
+
67
+ export const RemoteStateVisibleStoreTypeId: unique symbol = Symbol.for(
68
+ 'reform/RemoteStateVisibleStore',
69
+ )
70
+ export type RemoteStateVisibleStoreTypeId = typeof RemoteStateVisibleStoreTypeId
71
+
72
+ export interface RemoteStateVisibleStore<N extends string> {
73
+ readonly [RemoteStateVisibleStoreTypeId]: N
74
+ }
75
+
76
+ export const RemoteStateTruthStoreTypeId: unique symbol = Symbol.for('reform/RemoteStateTruthStore')
77
+ export type RemoteStateTruthStoreTypeId = typeof RemoteStateTruthStoreTypeId
78
+
79
+ export interface RemoteStateTruthStore<N extends string> {
80
+ readonly [RemoteStateTruthStoreTypeId]: N
81
+ }
82
+
83
+ export const RemoteStatePendingStoreTypeId: unique symbol = Symbol.for(
84
+ 'reform/RemoteStatePendingStore',
85
+ )
86
+ export type RemoteStatePendingStoreTypeId = typeof RemoteStatePendingStoreTypeId
87
+
88
+ export interface RemoteStatePendingStore<N extends string> {
89
+ readonly [RemoteStatePendingStoreTypeId]: N
90
+ }
91
+
92
+ type EventPayload<Ev extends Event.AnyEvent> =
93
+ Ev extends Event.EventClass<string, infer P> ? P : never
94
+
95
+ // ExternalApi: optional `intent` only on Queued envelopes.
85
96
  interface QueueFoldEventExternalApi<I> {
86
97
  readonly _tag: string
87
98
  readonly opId: string
88
99
  readonly intent?: I
89
100
  }
90
101
 
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 {
102
+ export interface RemoteStateSchemaReflection {
103
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
104
+ }
105
+
106
+ export interface RemoteStateManifestExternalApi<N extends string> extends Manifest {
94
107
  readonly kind: 'RemoteState'
95
108
  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). */
109
+ readonly output: RemoteStateSchemaReflection
110
+ readonly error?: RemoteStateSchemaReflection
100
111
  readonly gated: boolean
101
- /** The mutation vocabulary — intent event tags, reflectable without running logic. */
102
112
  readonly intents: ReadonlyArray<string>
103
113
  }
104
114
 
105
115
  export interface RemoteStateClass<
106
- out N extends string,
116
+ N extends string,
107
117
  out Inputs extends ReadonlyArray<AnySource>,
108
118
  in out Intents extends ReadonlyArray<Event.AnyEvent>,
109
119
  in out A,
110
120
  in out E,
111
121
  in out Gated extends boolean,
112
- > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
122
+ > extends EffectType<AsyncData<A, E, Gated>, never, RemoteStateVisibleStore<N>> {
113
123
  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. */
124
+ readonly manifest: RemoteStateManifestExternalApi<N>
125
+ readonly codec: QueryCodec<A>
126
+ readonly store: Context.Tag<RemoteStateVisibleStore<N>, Store<AsyncData<A, E, Gated>>>
118
127
  readonly name: N
119
- /** Marker data read by `RemoteState.live`. */
120
128
  readonly inputs: Inputs
121
- /** The intent event definitions (marker data; read by `RemoteState.live`). */
122
129
  readonly intents: Intents
123
- /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
124
130
  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
- */
131
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
132
+ readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>, RemoteStateTruthStore<N>>
133
+ // pending is a Source, not a StateClass, so user reducers cannot target it.
132
134
  readonly pending: StateToken<
133
135
  `${N}/pending`,
134
- ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>
136
+ ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>,
137
+ RemoteStatePendingStore<N>
135
138
  >
136
- /** Public fact: a send failed; its intent left the queue and the view reverted. */
137
139
  readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
138
140
  }
139
141
 
140
- // Public definition-surface config (boundary shape ⇒ `ExternalApi` postfix
141
- // exempts the optional option fields from `no-optional-fields`).
142
142
  export interface RemoteStateConfigExternalApi<
143
143
  Inputs extends ReadonlyArray<AnySource>,
144
144
  Intents extends ReadonlyArray<Event.AnyEvent>,
145
- A,
146
- E,
145
+ OutputSchema extends Schema.Schema.AnyNoContext,
146
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
147
147
  AlwaysOn extends boolean,
148
148
  > {
149
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`. */
150
+ readonly output: OutputSchema
151
+ readonly error?: ErrorSchema
155
152
  readonly alwaysOn?: AlwaysOn
156
- /** The mutation vocabulary: ordinary `Event.make` definitions. */
157
153
  readonly intents: Intents
158
154
  }
159
155
 
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
- */
156
+ // Schema.Unknown is structural because Intents is open at make-time; the declaration preserves its authored payload type.
157
+ const looseSchema = <P>(schema: Schema.Schema.AnyNoContext): Schema.Schema<P, P, never> =>
158
+ Schema.declare((input): input is P => Schema.is(schema)(input))
159
+
174
160
  export const make = <
175
161
  const N extends string,
176
162
  const Inputs extends ReadonlyArray<AnySource>,
177
163
  const Intents extends ReadonlyArray<Event.AnyEvent>,
178
- A,
179
- E = never,
164
+ OutputSchema extends Schema.Schema.AnyNoContext,
165
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
180
166
  const AlwaysOn extends boolean = false,
181
167
  >(
182
168
  name: N,
183
- config: RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
184
- ): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
169
+ config: RemoteStateConfigExternalApi<Inputs, Intents, OutputSchema, ErrorSchema, AlwaysOn>,
170
+ ): RemoteStateClass<
171
+ N,
172
+ Inputs,
173
+ Intents,
174
+ Schema.Schema.Type<OutputSchema>,
175
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
176
+ GatedOf<AlwaysOn>
177
+ > => {
178
+ type A = Schema.Schema.Type<OutputSchema>
179
+ type E = ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never
185
180
  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
- )
181
+ const store = Context.GenericTag<
182
+ RemoteStateVisibleStore<N>,
183
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
184
+ >(`reform/remoteState/${name}`)
185
+ const truthTag = Context.GenericTag<
186
+ RemoteStateTruthStore<N>,
187
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
188
+ >(`reform/remoteState/${name}/truth`)
189
+ const pendingTag = Context.GenericTag<
190
+ RemoteStatePendingStore<N>,
191
+ Store<ReadonlyArray<PendingIntent<I>>>
192
+ >(`reform/remoteState/${name}/pending`)
195
193
  const gated = gatedFlag(config.alwaysOn)
196
- const manifest: RemoteStateManifestExternalApi<N, A, E> = {
194
+ const manifest: RemoteStateManifestExternalApi<N> = {
197
195
  kind: 'RemoteState',
198
196
  name,
199
197
  output: config.output,
@@ -201,34 +199,39 @@ export const make = <
201
199
  intents: config.intents.map((event) => event.tag),
202
200
  ...(config.error !== undefined ? { error: config.error } : {}),
203
201
  }
204
- // Annotated consts so the template literal expressions keep their literal
205
- // types (contextual typing) — the statics must match the interface exactly.
202
+ const codec: QueryCodec<A> = {
203
+ encode: Schema.encode(config.output),
204
+ decode: Schema.decodeUnknown(config.output),
205
+ }
206
+ // Annotations preserve template-literal types for the public statics.
206
207
  const truthName: `${N}/truth` = `${name}/truth`
207
208
  const pendingName: `${N}/pending` = `${name}/pending`
208
209
  const failedName: `${N}/Failed` = `${name}/Failed`
209
210
  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 }),
211
+ const remote: RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> = yieldableClass(
212
+ read,
213
+ {
214
+ manifest,
215
+ codec,
216
+ store,
217
+ name,
218
+ inputs: config.inputs,
219
+ intents: config.intents,
220
+ gated,
221
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(remote),
222
+ truth: new StateToken(truthName, truthTag),
223
+ pending: new StateToken(pendingName, pendingTag),
224
+ Failed: Event.make(
225
+ failedName,
226
+ looseSchema<FailedIntent<I>>(
227
+ Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
228
+ ),
223
229
  ),
224
- ),
225
- })
230
+ },
231
+ )
232
+ return remote
226
233
  }
227
234
 
228
- /**
229
- * The `.live` config: the query (same contract as `AsyncCalc.live`), the
230
- * delivery effect, and the pure per-intent fold.
231
- */
232
235
  export type RemoteStateLiveExternalApi<
233
236
  Inputs extends ReadonlyArray<AnySource>,
234
237
  Intents extends ReadonlyArray<Event.AnyEvent>,
@@ -237,38 +240,22 @@ export type RemoteStateLiveExternalApi<
237
240
  Gated extends boolean,
238
241
  R,
239
242
  R2,
243
+ SettledEvent extends Event.AnyEvent,
240
244
  > = {
241
245
  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
- */
246
+ // Any failure settles and emits Failed; truth comes only from the invalidated query.
247
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
248
  readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
254
- /** The send lane. Default: a generated `${name}/sends` merge channel. */
255
249
  readonly channel?: Channel.ChannelClass
256
250
  readonly invalidateBy?: InvalidateBy<Inputs>
257
- /** Extra refetch triggers (realtime pushes, unrelated mutations) — `AsyncCalc`'s sugar. */
258
251
  readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
259
- /** Refetch conciliation — see `AsyncCalc.live`. Mutation bursts favor `'trailing'`. */
260
252
  readonly coalesce?: 'switch' | 'trailing'
261
- /** Structural sharing across refetches AND overlay recomputes — see `AsyncCalc.live`. */
262
253
  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
- */
254
+ readonly settled?: {
255
+ readonly event: SettledEvent
256
+ readonly payload: (result: RemoteStateSettled<A, E>) => EventPayload<SettledEvent>
257
+ }
258
+ // Only settled server truth is persisted; the optimistic overlay is a separate store.
272
259
  readonly persist?:
273
260
  | boolean
274
261
  | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
@@ -276,36 +263,156 @@ export type RemoteStateLiveExternalApi<
276
263
  ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
277
264
  : { readonly disabled?: never })
278
265
 
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
266
  interface SettleLink {
295
- /** The driver's highest REQUESTED run generation (see `QueryDriver.requested`). */
296
267
  readonly requested: () => number
297
- /** Settle `opId` when a run with generation ≥ `waitFor` lands `Success`. */
298
268
  readonly register: (opId: string, waitFor: number) => void
299
269
  }
300
270
 
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
- */
271
+ interface WaiterRegistration {
272
+ readonly opId: string
273
+ readonly waitFor: number
274
+ }
275
+
276
+ interface PendingNode<I> {
277
+ readonly entry: MutableRef.MutableRef<PendingIntent<I>>
278
+ }
279
+
280
+ // Duplicate opIds retain every node so indexed updates preserve the old map semantics.
281
+ interface PendingOpEntry<I> {
282
+ readonly nodes: Set<PendingNode<I>>
283
+ }
284
+
285
+ interface PendingQueue<I> {
286
+ readonly store: Store<ReadonlyArray<PendingIntent<I>>>
287
+ readonly revision: () => number
288
+ readonly fold: <B>(initial: B, combine: (accumulator: B, entry: PendingIntent<I>) => B) => B
289
+ readonly append: (entry: PendingIntent<I>) => void
290
+ readonly ack: (opId: string) => void
291
+ readonly settle: (opId: string) => void
292
+ }
293
+
294
+ const makePendingQueue = <I>(scheduler: Scheduler): PendingQueue<I> => {
295
+ // Set insertion order preserves dispatch/apply order while allowing O(1) updates.
296
+ const ordered = new Set<PendingNode<I>>()
297
+ const byOpId = new Map<string, PendingOpEntry<I>>()
298
+ const listeners = new Set<() => void>()
299
+ const revision = MutableRef.make(0)
300
+ const initial: ReadonlyArray<PendingIntent<I>> = []
301
+ const snapshot = MutableRef.make<Option.Option<ReadonlyArray<PendingIntent<I>>>>(
302
+ Option.some(initial),
303
+ )
304
+
305
+ const materialize = (): ReadonlyArray<PendingIntent<I>> => {
306
+ const cached = MutableRef.get(snapshot)
307
+ if (Option.isSome(cached)) {
308
+ return cached.value
309
+ }
310
+ const current = Array.from(ordered, (node) => MutableRef.get(node.entry))
311
+ MutableRef.set(snapshot, Option.some(current))
312
+ return current
313
+ }
314
+
315
+ const announce = (next: Option.Option<ReadonlyArray<PendingIntent<I>>>): void => {
316
+ MutableRef.set(snapshot, next)
317
+ MutableRef.update(revision, (current) => current + 1)
318
+ scheduler.schedule(listeners)
319
+ }
320
+
321
+ const link = (entry: PendingIntent<I>): void => {
322
+ const node: PendingNode<I> = {
323
+ entry: MutableRef.make(entry),
324
+ }
325
+ ordered.add(node)
326
+ const indexed = byOpId.get(entry.opId)
327
+ if (indexed === undefined) {
328
+ byOpId.set(entry.opId, { nodes: new Set([node]) })
329
+ } else {
330
+ indexed.nodes.add(node)
331
+ }
332
+ }
333
+
334
+ const clear = (): void => {
335
+ ordered.clear()
336
+ byOpId.clear()
337
+ }
338
+
339
+ const fold = <B>(initialValue: B, combine: (accumulator: B, entry: PendingIntent<I>) => B): B => {
340
+ const folded = MutableRef.make(initialValue)
341
+ ordered.forEach((node) => {
342
+ MutableRef.update(folded, (accumulator) => combine(accumulator, MutableRef.get(node.entry)))
343
+ })
344
+ return MutableRef.get(folded)
345
+ }
346
+
347
+ const append = (entry: PendingIntent<I>): void => {
348
+ link(entry)
349
+ announce(Option.none())
350
+ }
351
+
352
+ const ack = (opId: string): void => {
353
+ const indexed = byOpId.get(opId)
354
+ if (indexed === undefined) {
355
+ return
356
+ }
357
+ const stillSending = MutableRef.make(false)
358
+ indexed.nodes.forEach((node) => {
359
+ if (MutableRef.get(node.entry).status === 'sending') {
360
+ MutableRef.set(stillSending, true)
361
+ }
362
+ })
363
+ if (!MutableRef.get(stillSending)) {
364
+ return
365
+ }
366
+ // Rebuild every duplicate once one is sending, matching the former array map.
367
+ indexed.nodes.forEach((node) => {
368
+ MutableRef.update(node.entry, (entry) => ({ ...entry, status: 'confirmed' as const }))
369
+ })
370
+ announce(Option.none())
371
+ }
372
+
373
+ const settle = (opId: string): void => {
374
+ const indexed = byOpId.get(opId)
375
+ if (indexed === undefined) {
376
+ return
377
+ }
378
+ indexed.nodes.forEach((node) => {
379
+ ordered.delete(node)
380
+ })
381
+ byOpId.delete(opId)
382
+ announce(Option.none())
383
+ }
384
+
385
+ const store: Store<ReadonlyArray<PendingIntent<I>>> = {
386
+ get: materialize,
387
+ getSnapshot: materialize,
388
+ getVersion: () => MutableRef.get(revision),
389
+ set: (next) => {
390
+ if (Equal.equals(materialize(), next)) {
391
+ return
392
+ }
393
+ clear()
394
+ next.forEach(link)
395
+ announce(Option.some(next))
396
+ },
397
+ subscribe: (listener) => {
398
+ listeners.add(listener)
399
+ return () => {
400
+ listeners.delete(listener)
401
+ }
402
+ },
403
+ ...inspectable(() => ({ _id: 'reform/Store', value: materialize() })),
404
+ }
405
+
406
+ return {
407
+ store,
408
+ revision: () => MutableRef.get(revision),
409
+ fold,
410
+ append,
411
+ ack,
412
+ settle,
413
+ }
414
+ }
415
+
309
416
  export const live = <
310
417
  N extends string,
311
418
  Inputs extends ReadonlyArray<AnySource>,
@@ -315,29 +422,23 @@ export const live = <
315
422
  Gated extends boolean,
316
423
  R,
317
424
  R2,
425
+ SettledEvent extends Event.AnyEvent = never,
318
426
  >(
319
427
  remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
320
- config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2>,
428
+ config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2, SettledEvent>,
321
429
  ): Layer.Layer<
322
- | Store<AsyncData<A, E, Gated>>
323
- | Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
430
+ RemoteStateVisibleStore<N> | RemoteStateTruthStore<N> | RemoteStatePendingStore<N>,
324
431
  never,
325
- | InputStores<Inputs>
326
- | R
327
- | R2
328
- | Reducers
329
- | Channel.Procedures
330
- | Channel.Channels
331
- | Bus
432
+ InputStores<Inputs> | R | R2 | Reducers | Channel.Procedures | Channel.Channels | Bus
332
433
  > => {
333
434
  type I = Event.EventType<Intents[number]>
334
435
  const name = remote.manifest.name
335
436
  const truthTag = remote.truth.store
336
437
  const pendingTag = remote.pending.store
438
+ const pendingQueueTag = Context.GenericTag<PendingQueue<I>>(
439
+ `reform/remoteState/${name}/pendingQueue`,
440
+ )
337
441
 
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
442
  const Queued = Event.make(
342
443
  `${name}/Queued`,
343
444
  looseSchema<{ readonly opId: string; readonly intent: I }>(
@@ -348,8 +449,6 @@ export const live = <
348
449
  const Settled = Event.make(`${name}/Settled`, Schema.Struct({ opId: Schema.String }))
349
450
  const Invalidated = Event.make(`${name}/Invalidated`, Schema.Struct({}))
350
451
 
351
- // The hidden revision pair (the `invalidateOn` machinery, unconditional here:
352
- // `Invalidated` always drives it; user `invalidateOn` events join the same fold).
353
452
  const revisionState = State.make(`${name}/revision`, RevisionSchema)
354
453
  const extraInvalidateOn = Option.getOrElse(
355
454
  Option.fromNullable(config.invalidateOn),
@@ -360,76 +459,93 @@ export const live = <
360
459
  events: [Invalidated, ...extraInvalidateOn],
361
460
  })
362
461
 
363
- // The settle link, keyed per live so two remote states never cross-resolve.
364
462
  const linkTag = Context.GenericTag<SettleLink>(`reform/remoteState/${name}/link`)
365
463
 
366
- // ── 1. Driver + link: the truth store and the generation bookkeeping. ──────
367
464
  const driverLayer = Layer.scopedContext(
368
465
  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
- // 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
- 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
466
  const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
376
467
  const runtime = yield* Effect.runtime<Bus>()
377
468
 
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).
381
- const waiters = MutableRef.make<
382
- ReadonlyArray<{ readonly opId: string; readonly waitFor: number }>
383
- >([])
469
+ // Buckets, not cancellable fibers, keep waiters alive under latest/exclusive policies.
470
+ // Monotonic waitFor values make insertion order match generation order.
471
+ const waiters = new Map<number, Set<{ readonly opId: string }>>()
384
472
  const onSettled = (generation: number) => {
385
- const due = MutableRef.get(waiters).filter((waiter) => waiter.waitFor <= generation)
386
- if (due.length === 0) {
387
- return
388
- }
389
- 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.
392
- due.forEach((waiter) =>
393
- Runtime.runSync(runtime)(
394
- publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
395
- ),
473
+ Iterable.forEach(
474
+ Iterable.takeWhile(waiters, ([waitFor]) => waitFor <= generation),
475
+ ([waitFor, generationWaiters]) => {
476
+ waiters.delete(waitFor)
477
+ // runSync enqueues settles in the same flush as converged truth.
478
+ generationWaiters.forEach(({ opId }) =>
479
+ Runtime.runSync(runtime)(publish('Normal', Event.construct(Settled, { opId }))),
480
+ )
481
+ },
396
482
  )
397
483
  }
484
+ const registerWaiter = ({ opId, waitFor }: WaiterRegistration): void => {
485
+ const generationWaiters = waiters.get(waitFor)
486
+ if (generationWaiters === undefined) {
487
+ waiters.set(waitFor, new Set([{ opId }]))
488
+ } else {
489
+ generationWaiters.add({ opId })
490
+ }
491
+ }
398
492
 
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).
402
- const persistOption = cfg.persist
493
+ const persistOption = config.persist
403
494
  const persistKey =
404
495
  persistOption === true || persistOption === undefined || persistOption === false
405
496
  ? name
406
- : persistOption.key ?? name
497
+ : (persistOption.key ?? name)
407
498
  const persist =
408
499
  persistOption === undefined || persistOption === false
409
500
  ? undefined
410
- : { key: persistKey, schema: remote.manifest.output }
411
- const extraKey =
412
- revision === undefined
413
- ? undefined
414
- : { read: () => revision.getSnapshot(), subscribe: (listener: () => void) => revision.subscribe(listener) }
501
+ : { key: persistKey, codec: remote.codec }
502
+ const revisionKey = (source: NonNullable<typeof revision>) => ({
503
+ read: () => source.getSnapshot(),
504
+ subscribe: (listener: () => void) => source.subscribe(listener),
505
+ })
506
+ const extraKey = revision === undefined ? undefined : revisionKey(revision)
415
507
  const driver = yield* makeQueryDriver({
416
508
  name,
417
509
  label: 'RemoteState',
418
510
  gated: remote.gated,
419
511
  inputs: remote.inputs,
420
- query: cfg.query,
421
- invalidateBy: cfg.invalidateBy,
422
- disabled: cfg.disabled,
423
- coalesce: cfg.coalesce,
424
- reuse: cfg.reuse,
512
+ query: config.query,
513
+ invalidateBy: config.invalidateBy,
514
+ disabled: config.disabled,
515
+ coalesce: config.coalesce,
516
+ reuse: config.reuse,
425
517
  persist,
426
518
  extraKey,
427
519
  onSettled,
428
520
  })
521
+ const settledConfig = config.settled
522
+ if (settledConfig !== undefined) {
523
+ const truth = widenStore<A, E, Gated>(narrowStore<A, E, Gated>(driver.store))
524
+ const lastSettled = MutableRef.make<RemoteStateSettled<A, E> | undefined>(undefined)
525
+ const publishSettled = () => {
526
+ const settledTruth = truth.getSnapshot()
527
+ if (
528
+ (settledTruth._tag === 'Success' || settledTruth._tag === 'Error') &&
529
+ settledTruth.refetching === false &&
530
+ MutableRef.get(lastSettled) !== settledTruth
531
+ ) {
532
+ MutableRef.set(lastSettled, settledTruth)
533
+ Runtime.runSync(runtime)(
534
+ publish(
535
+ 'Normal',
536
+ settledConfig.event.buildUnknown(settledConfig.payload(settledTruth)),
537
+ ),
538
+ )
539
+ }
540
+ }
541
+ const unsubscribe = truth.subscribe(publishSettled)
542
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
543
+ const scheduler = yield* resolveScheduler
544
+ scheduler.schedule([publishSettled])
545
+ }
429
546
  const link: SettleLink = {
430
547
  requested: driver.requested,
431
- register: (opId, waitFor) =>
432
- MutableRef.update(waiters, (all) => [...all, { opId, waitFor }]),
548
+ register: (opId, waitFor) => registerWaiter({ opId, waitFor }),
433
549
  }
434
550
  return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
435
551
  Context.add(linkTag, link),
@@ -437,48 +553,46 @@ export const live = <
437
553
  }),
438
554
  )
439
555
 
440
- // ── 2. Overlay: visible = pending.reduce(apply, truth). ────────────────────
441
556
  const overlayLayer = Layer.scoped(
442
557
  remote.store,
443
558
  Effect.gen(function* () {
444
559
  const scheduler = yield* resolveScheduler
445
- // Widened so the arm dispatch below is a plain tag union (read-only view).
446
560
  const feedStore = widenStore<A, E, Gated>(yield* truthTag)
447
- const queueStore = yield* pendingTag
561
+ const queue = yield* pendingQueueTag
448
562
  const memo = MutableRef.make<
449
563
  { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
450
564
  >(undefined)
451
565
  const recompute = (): AnyAsyncData<A, E> => {
452
566
  const feed = feedStore.getSnapshot()
453
- const pending = queueStore.getSnapshot()
454
- const key = [feed, pending]
567
+ const key = [feed, queue.revision()]
455
568
  const prev = MutableRef.get(memo)
456
569
  if (prev !== undefined && sameKey(key, prev.key)) {
457
570
  return prev.output
458
571
  }
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.
572
+ // Full replay preserves observable value identity when apply allocates and reuse is off.
462
573
  const applyPending = (base: A): A =>
463
- pending.reduce((applied, entry) => config.apply(applied, entry.intent), base)
574
+ queue.fold(base, (applied, entry) => config.apply(applied, entry.intent))
464
575
  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 =
576
+ feed._tag === 'Success'
577
+ ? AsyncData.success(applyPending(feed.value), feed.refetching)
578
+ : feed
579
+ const reusable =
470
580
  config.reuse === true &&
471
581
  prev !== undefined &&
472
582
  prev.output._tag === 'Success' &&
473
583
  overlaid._tag === 'Success'
474
- ? AsyncData.success(shareStructure(prev.output.value, overlaid.value), overlaid.refetching)
475
- : overlaid
584
+ ? shareStructure(prev.output.value, overlaid.value)
585
+ : undefined
586
+ const output: AnyAsyncData<A, E> =
587
+ reusable === undefined || overlaid._tag !== 'Success'
588
+ ? overlaid
589
+ : AsyncData.success(reusable, overlaid.refetching)
476
590
  MutableRef.set(memo, { key, output })
477
591
  return output
478
592
  }
479
593
  const subscribe = (listener: () => void): (() => void) => {
480
594
  const offFeed = feedStore.subscribe(listener)
481
- const offQueue = queueStore.subscribe(listener)
595
+ const offQueue = queue.store.subscribe(listener)
482
596
  return () => {
483
597
  offFeed()
484
598
  offQueue()
@@ -490,67 +604,44 @@ export const live = <
490
604
  }),
491
605
  )
492
606
 
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).
607
+ // Direct registry assembly avoids deferred conditional parameters while Intents is generic.
498
608
  const queueReducerName = `${name}/pending`
499
609
  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
610
  const queueReducerLayer = Layer.scopedDiscard(
532
611
  Effect.gen(function* () {
533
612
  const reducers = yield* Reducers
534
613
  if (reducers.entries.some((entry) => entry.name === queueReducerName)) {
535
614
  yield* Effect.logWarning(`reform: duplicate reducer name '${queueReducerName}' registered`)
536
615
  }
537
- const store = yield* pendingTag
616
+ const queue = yield* pendingQueueTag
538
617
  const entry: ReducerEntry = {
539
618
  name: queueReducerName,
540
619
  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))),
620
+ apply: (event) => {
621
+ const handled = narrowHandled<QueueFoldEventExternalApi<I>>(event)
622
+ if (handled._tag === Queued.tag) {
623
+ if (handled.intent !== undefined) {
624
+ queue.append({
625
+ opId: handled.opId,
626
+ intent: handled.intent,
627
+ status: 'sending',
628
+ })
629
+ }
630
+ return
631
+ }
632
+ if (handled._tag === Acked.tag) {
633
+ queue.ack(handled.opId)
634
+ return
635
+ }
636
+ queue.settle(handled.opId)
637
+ },
544
638
  }
545
- yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
546
- Effect.sync(() => reducers.unregister(entry)),
639
+ yield* Effect.acquireRelease(
640
+ Effect.sync(() => reducers.register(entry)),
641
+ () => Effect.sync(() => reducers.unregister(entry)),
547
642
  )
548
643
  }),
549
644
  )
550
-
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).
554
645
  const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
555
646
  const procedureName = `${name}/send`
556
647
  const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
@@ -561,62 +652,49 @@ export const live = <
561
652
  yield* Effect.logWarning(`reform: duplicate procedure name '${procedureName}' registered`)
562
653
  }
563
654
  const link = yield* linkTag
564
- // Snapshot the body's full context (Bus + the send's R2) so `run` is total.
565
655
  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
- })
656
+ const deliver = Effect.fn('deliver')(function* (
657
+ intent: I,
658
+ ): Effect.fn.Return<void, never, Bus | R2> {
659
+ const opId = yield* Effect.sync(() => crypto.randomUUID())
660
+ yield* Event.dispatch(Queued, { opId, intent })
661
+ const outcome = yield* config.send(intent).pipe(
662
+ Effect.catchAllDefect((defect) => Effect.fail(defect)),
663
+ Effect.either,
664
+ Effect.onInterrupt(() => Event.dispatch(Settled, { opId })),
665
+ )
666
+ yield* Either.match(outcome, {
667
+ onLeft: (error) =>
668
+ Effect.zipRight(
669
+ Event.dispatch(remote.Failed, { intent, error }),
670
+ Event.dispatch(Settled, { opId }),
671
+ ),
672
+ onRight: () =>
673
+ // Register before invalidation so a pre-ack refetch cannot settle this intent.
674
+ Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
675
+ Effect.zipRight(Event.dispatch(Acked, { opId })),
676
+ Effect.zipRight(Event.dispatch(Invalidated, {})),
677
+ ),
597
678
  })
679
+ })
598
680
  const entry: Channel.ProcedureEntry = {
599
681
  name: procedureName,
600
682
  channelName: channel.manifest.name,
601
683
  handles: sendHandles,
602
684
  run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
603
685
  }
604
- yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
605
- Effect.sync(() => procedures.unregister(entry)),
686
+ yield* Effect.acquireRelease(
687
+ Effect.sync(() => procedures.register(entry)),
688
+ () => Effect.sync(() => procedures.unregister(entry)),
606
689
  )
607
690
  }),
608
691
  )
609
692
 
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
- const pendingStoreLayer = Layer.effect(
616
- pendingTag,
617
- Effect.map(resolveScheduler, (scheduler) =>
618
- makeStore<ReadonlyArray<PendingIntent<I>>>([], scheduler),
619
- ),
693
+ const pendingStoreLayer = Layer.scopedContext(
694
+ Effect.map(resolveScheduler, (scheduler) => {
695
+ const queue = makePendingQueue<I>(scheduler)
696
+ return Context.make(pendingTag, queue.store).pipe(Context.add(pendingQueueTag, queue))
697
+ }),
620
698
  )
621
699
  return Layer.mergeAll(
622
700
  overlayLayer,