@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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
|
@@ -1,311 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
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,
|
|
26
|
-
import {
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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 =
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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) =>
|