@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.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
|
@@ -1,5 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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,
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
readonly
|
|
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
|
-
|
|
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
|
|
122
|
+
> extends EffectType<AsyncData<A, E, Gated>, never, RemoteStateVisibleStore<N>> {
|
|
113
123
|
new (): {}
|
|
114
|
-
readonly manifest: RemoteStateManifestExternalApi<N
|
|
115
|
-
|
|
116
|
-
readonly store: Context.Tag<
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
readonly
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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,
|
|
184
|
-
): RemoteStateClass<
|
|
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<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
|
323
|
-
| Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
|
|
430
|
+
RemoteStateVisibleStore<N> | RemoteStateTruthStore<N> | RemoteStatePendingStore<N>,
|
|
324
431
|
never,
|
|
325
|
-
|
|
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
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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,
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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:
|
|
421
|
-
invalidateBy:
|
|
422
|
-
disabled:
|
|
423
|
-
coalesce:
|
|
424
|
-
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
574
|
+
queue.fold(base, (applied, entry) => config.apply(applied, entry.intent))
|
|
464
575
|
const overlaid: AnyAsyncData<A, E> =
|
|
465
|
-
feed._tag === 'Success'
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
?
|
|
475
|
-
:
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
616
|
+
const queue = yield* pendingQueueTag
|
|
538
617
|
const entry: ReducerEntry = {
|
|
539
618
|
name: queueReducerName,
|
|
540
619
|
handles: queueHandles,
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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(
|
|
546
|
-
Effect.sync(() => reducers.
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
),
|
|
587
|
-
|
|
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(
|
|
605
|
-
Effect.sync(() => procedures.
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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,
|