@playfast/reform 1.1.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 +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +62 -16
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +516 -18
- package/src/calc/asyncCalc.test.ts +207 -175
- package/src/calc/asyncCalc.ts +168 -39
- package/src/calc/calc.test.ts +3 -5
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +80 -22
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +128 -11
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +346 -62
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +1317 -197
- package/src/feature/feature.typecheck.ts +253 -61
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +120 -15
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +3 -1
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/remoteState.test.ts +515 -339
- package/src/remote/remoteState.ts +572 -107
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +157 -105
- package/src/runtime/appRuntime.ts +394 -33
- package/src/runtime/eventBudget.test.ts +1 -4
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/hardening.test.ts +4 -9
- package/src/runtime/instrumentation.test.ts +174 -192
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +36 -0
- package/src/runtime/loop.ts +69 -40
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +14 -0
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/channel/procedureRegistry.ts +0 -90
- package/src/feature/featureBinding.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
- package/src/remote/remoteStateSend.ts +0 -64
|
@@ -1,57 +1,417 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Context,
|
|
3
3
|
Effect,
|
|
4
|
+
Either,
|
|
5
|
+
Equal,
|
|
6
|
+
Iterable,
|
|
4
7
|
Layer,
|
|
5
8
|
MutableRef,
|
|
6
9
|
Option,
|
|
7
10
|
Runtime,
|
|
8
11
|
Schema,
|
|
9
12
|
} from 'effect'
|
|
10
|
-
import {
|
|
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'
|
|
11
22
|
import * as Channel from '../channel/channel'
|
|
23
|
+
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
12
24
|
import * as Event from '../event/event'
|
|
13
25
|
import {
|
|
14
26
|
bumpRevision,
|
|
27
|
+
type GatedOf,
|
|
28
|
+
gatedFlag,
|
|
15
29
|
makeQueryDriver,
|
|
30
|
+
type QueryCodec,
|
|
16
31
|
RevisionSchema,
|
|
17
32
|
revisionZero,
|
|
18
33
|
} from '../internal/queryDriver'
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
34
|
+
import { inspectable } from '../internal/inspect'
|
|
35
|
+
import { reuse as shareStructure } from '../internal/reuse'
|
|
36
|
+
import { resolveScheduler, type Scheduler } from '../internal/scheduler'
|
|
37
|
+
import {
|
|
38
|
+
type InputsObject,
|
|
39
|
+
type InputStores,
|
|
40
|
+
type InvalidateBy,
|
|
41
|
+
sameKey,
|
|
42
|
+
} from '../internal/sources'
|
|
43
|
+
import { makeDerivedStore, type Store } from '../internal/store'
|
|
44
|
+
import { readTracked } from '../internal/track'
|
|
22
45
|
import * as Reducer from '../reducer/reducer'
|
|
23
|
-
import { Bus, publish } from '../runtime/bus'
|
|
24
|
-
import { Reducers } from '../runtime/loop'
|
|
46
|
+
import { Bus, narrowHandled, publish } from '../runtime/bus'
|
|
47
|
+
import { type ReducerEntry, Reducers } from '../runtime/loop'
|
|
25
48
|
import * as State from '../state/state'
|
|
26
|
-
import { type AnySource } from '../state/token'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export type
|
|
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
|
+
|
|
54
|
+
export interface PendingIntent<I> {
|
|
55
|
+
readonly opId: string
|
|
56
|
+
readonly intent: I
|
|
57
|
+
readonly status: 'sending' | 'confirmed'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface FailedIntent<I> {
|
|
61
|
+
readonly intent: I
|
|
62
|
+
readonly error: unknown
|
|
63
|
+
}
|
|
64
|
+
|
|
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.
|
|
96
|
+
interface QueueFoldEventExternalApi<I> {
|
|
97
|
+
readonly _tag: string
|
|
98
|
+
readonly opId: string
|
|
99
|
+
readonly intent?: I
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface RemoteStateSchemaReflection {
|
|
103
|
+
readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RemoteStateManifestExternalApi<N extends string> extends Manifest {
|
|
107
|
+
readonly kind: 'RemoteState'
|
|
108
|
+
readonly name: N
|
|
109
|
+
readonly output: RemoteStateSchemaReflection
|
|
110
|
+
readonly error?: RemoteStateSchemaReflection
|
|
111
|
+
readonly gated: boolean
|
|
112
|
+
readonly intents: ReadonlyArray<string>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RemoteStateClass<
|
|
116
|
+
N extends string,
|
|
117
|
+
out Inputs extends ReadonlyArray<AnySource>,
|
|
118
|
+
in out Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
119
|
+
in out A,
|
|
120
|
+
in out E,
|
|
121
|
+
in out Gated extends boolean,
|
|
122
|
+
> extends EffectType<AsyncData<A, E, Gated>, never, RemoteStateVisibleStore<N>> {
|
|
123
|
+
new (): {}
|
|
124
|
+
readonly manifest: RemoteStateManifestExternalApi<N>
|
|
125
|
+
readonly codec: QueryCodec<A>
|
|
126
|
+
readonly store: Context.Tag<RemoteStateVisibleStore<N>, Store<AsyncData<A, E, Gated>>>
|
|
127
|
+
readonly name: N
|
|
128
|
+
readonly inputs: Inputs
|
|
129
|
+
readonly intents: Intents
|
|
130
|
+
readonly gated: Gated
|
|
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.
|
|
134
|
+
readonly pending: StateToken<
|
|
135
|
+
`${N}/pending`,
|
|
136
|
+
ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>,
|
|
137
|
+
RemoteStatePendingStore<N>
|
|
138
|
+
>
|
|
139
|
+
readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface RemoteStateConfigExternalApi<
|
|
143
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
144
|
+
Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
145
|
+
OutputSchema extends Schema.Schema.AnyNoContext,
|
|
146
|
+
ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
|
|
147
|
+
AlwaysOn extends boolean,
|
|
148
|
+
> {
|
|
149
|
+
readonly inputs: Inputs
|
|
150
|
+
readonly output: OutputSchema
|
|
151
|
+
readonly error?: ErrorSchema
|
|
152
|
+
readonly alwaysOn?: AlwaysOn
|
|
153
|
+
readonly intents: Intents
|
|
154
|
+
}
|
|
155
|
+
|
|
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
|
+
|
|
160
|
+
export const make = <
|
|
161
|
+
const N extends string,
|
|
162
|
+
const Inputs extends ReadonlyArray<AnySource>,
|
|
163
|
+
const Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
164
|
+
OutputSchema extends Schema.Schema.AnyNoContext,
|
|
165
|
+
ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
|
|
166
|
+
const AlwaysOn extends boolean = false,
|
|
167
|
+
>(
|
|
168
|
+
name: N,
|
|
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
|
|
180
|
+
type I = Event.EventType<Intents[number]>
|
|
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`)
|
|
193
|
+
const gated = gatedFlag(config.alwaysOn)
|
|
194
|
+
const manifest: RemoteStateManifestExternalApi<N> = {
|
|
195
|
+
kind: 'RemoteState',
|
|
196
|
+
name,
|
|
197
|
+
output: config.output,
|
|
198
|
+
gated,
|
|
199
|
+
intents: config.intents.map((event) => event.tag),
|
|
200
|
+
...(config.error !== undefined ? { error: config.error } : {}),
|
|
201
|
+
}
|
|
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.
|
|
207
|
+
const truthName: `${N}/truth` = `${name}/truth`
|
|
208
|
+
const pendingName: `${N}/pending` = `${name}/pending`
|
|
209
|
+
const failedName: `${N}/Failed` = `${name}/Failed`
|
|
210
|
+
const read = Effect.flatMap(store, readTracked)
|
|
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
|
+
),
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
return remote
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type RemoteStateLiveExternalApi<
|
|
236
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
237
|
+
Intents extends ReadonlyArray<Event.AnyEvent>,
|
|
238
|
+
A,
|
|
239
|
+
E,
|
|
240
|
+
Gated extends boolean,
|
|
241
|
+
R,
|
|
242
|
+
R2,
|
|
243
|
+
SettledEvent extends Event.AnyEvent,
|
|
244
|
+
> = {
|
|
245
|
+
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
246
|
+
// Any failure settles and emits Failed; truth comes only from the invalidated query.
|
|
247
|
+
readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
|
|
248
|
+
readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
|
|
249
|
+
readonly channel?: Channel.ChannelClass
|
|
250
|
+
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
251
|
+
readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
|
|
252
|
+
readonly coalesce?: 'switch' | 'trailing'
|
|
253
|
+
readonly reuse?: boolean
|
|
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.
|
|
259
|
+
readonly persist?:
|
|
260
|
+
| boolean
|
|
261
|
+
| { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
|
|
262
|
+
} & (Gated extends true
|
|
263
|
+
? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
|
|
264
|
+
: { readonly disabled?: never })
|
|
265
|
+
|
|
266
|
+
interface SettleLink {
|
|
267
|
+
readonly requested: () => number
|
|
268
|
+
readonly register: (opId: string, waitFor: number) => void
|
|
269
|
+
}
|
|
270
|
+
|
|
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
|
+
}
|
|
55
415
|
|
|
56
416
|
export const live = <
|
|
57
417
|
N extends string,
|
|
@@ -67,21 +427,17 @@ export const live = <
|
|
|
67
427
|
remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
|
|
68
428
|
config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2, SettledEvent>,
|
|
69
429
|
): Layer.Layer<
|
|
70
|
-
|
|
|
71
|
-
| Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
|
|
430
|
+
RemoteStateVisibleStore<N> | RemoteStateTruthStore<N> | RemoteStatePendingStore<N>,
|
|
72
431
|
never,
|
|
73
|
-
|
|
74
|
-
| R
|
|
75
|
-
| R2
|
|
76
|
-
| Reducers
|
|
77
|
-
| Channel.Procedures
|
|
78
|
-
| Channel.Channels
|
|
79
|
-
| Bus
|
|
432
|
+
InputStores<Inputs> | R | R2 | Reducers | Channel.Procedures | Channel.Channels | Bus
|
|
80
433
|
> => {
|
|
81
434
|
type I = Event.EventType<Intents[number]>
|
|
82
435
|
const name = remote.manifest.name
|
|
83
436
|
const truthTag = remote.truth.store
|
|
84
437
|
const pendingTag = remote.pending.store
|
|
438
|
+
const pendingQueueTag = Context.GenericTag<PendingQueue<I>>(
|
|
439
|
+
`reform/remoteState/${name}/pendingQueue`,
|
|
440
|
+
)
|
|
85
441
|
|
|
86
442
|
const Queued = Event.make(
|
|
87
443
|
`${name}/Queued`,
|
|
@@ -107,53 +463,57 @@ export const live = <
|
|
|
107
463
|
|
|
108
464
|
const driverLayer = Layer.scopedContext(
|
|
109
465
|
Effect.gen(function* () {
|
|
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
|
|
111
|
-
const cfg = config as RemoteStateLiveRuntimeExternalApi<Inputs, A, E, R>
|
|
112
466
|
const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
|
|
113
467
|
const runtime = yield* Effect.runtime<Bus>()
|
|
114
468
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
>([])
|
|
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 }>>()
|
|
119
472
|
const onSettled = (generation: number) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
),
|
|
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
|
+
},
|
|
130
482
|
)
|
|
131
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
|
+
}
|
|
132
492
|
|
|
133
|
-
|
|
134
|
-
const persistOption = cfg.persist
|
|
493
|
+
const persistOption = config.persist
|
|
135
494
|
const persistKey =
|
|
136
495
|
persistOption === true || persistOption === undefined || persistOption === false
|
|
137
496
|
? name
|
|
138
|
-
: persistOption.key ?? name
|
|
497
|
+
: (persistOption.key ?? name)
|
|
139
498
|
const persist =
|
|
140
499
|
persistOption === undefined || persistOption === false
|
|
141
500
|
? undefined
|
|
142
|
-
: { key: persistKey,
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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)
|
|
147
507
|
const driver = yield* makeQueryDriver({
|
|
148
508
|
name,
|
|
149
509
|
label: 'RemoteState',
|
|
150
510
|
gated: remote.gated,
|
|
151
511
|
inputs: remote.inputs,
|
|
152
|
-
query:
|
|
153
|
-
invalidateBy:
|
|
154
|
-
disabled:
|
|
155
|
-
coalesce:
|
|
156
|
-
reuse:
|
|
512
|
+
query: config.query,
|
|
513
|
+
invalidateBy: config.invalidateBy,
|
|
514
|
+
disabled: config.disabled,
|
|
515
|
+
coalesce: config.coalesce,
|
|
516
|
+
reuse: config.reuse,
|
|
157
517
|
persist,
|
|
158
518
|
extraKey,
|
|
159
519
|
onSettled,
|
|
@@ -173,7 +533,7 @@ export const live = <
|
|
|
173
533
|
Runtime.runSync(runtime)(
|
|
174
534
|
publish(
|
|
175
535
|
'Normal',
|
|
176
|
-
|
|
536
|
+
settledConfig.event.buildUnknown(settledConfig.payload(settledTruth)),
|
|
177
537
|
),
|
|
178
538
|
)
|
|
179
539
|
}
|
|
@@ -185,8 +545,7 @@ export const live = <
|
|
|
185
545
|
}
|
|
186
546
|
const link: SettleLink = {
|
|
187
547
|
requested: driver.requested,
|
|
188
|
-
register: (opId, waitFor) =>
|
|
189
|
-
MutableRef.update(waiters, (all) => [...all, { opId, waitFor }]),
|
|
548
|
+
register: (opId, waitFor) => registerWaiter({ opId, waitFor }),
|
|
190
549
|
}
|
|
191
550
|
return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
|
|
192
551
|
Context.add(linkTag, link),
|
|
@@ -194,42 +553,148 @@ export const live = <
|
|
|
194
553
|
}),
|
|
195
554
|
)
|
|
196
555
|
|
|
197
|
-
const overlayLayer =
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
556
|
+
const overlayLayer = Layer.scoped(
|
|
557
|
+
remote.store,
|
|
558
|
+
Effect.gen(function* () {
|
|
559
|
+
const scheduler = yield* resolveScheduler
|
|
560
|
+
const feedStore = widenStore<A, E, Gated>(yield* truthTag)
|
|
561
|
+
const queue = yield* pendingQueueTag
|
|
562
|
+
const memo = MutableRef.make<
|
|
563
|
+
{ readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
|
|
564
|
+
>(undefined)
|
|
565
|
+
const recompute = (): AnyAsyncData<A, E> => {
|
|
566
|
+
const feed = feedStore.getSnapshot()
|
|
567
|
+
const key = [feed, queue.revision()]
|
|
568
|
+
const prev = MutableRef.get(memo)
|
|
569
|
+
if (prev !== undefined && sameKey(key, prev.key)) {
|
|
570
|
+
return prev.output
|
|
571
|
+
}
|
|
572
|
+
// Full replay preserves observable value identity when apply allocates and reuse is off.
|
|
573
|
+
const applyPending = (base: A): A =>
|
|
574
|
+
queue.fold(base, (applied, entry) => config.apply(applied, entry.intent))
|
|
575
|
+
const overlaid: AnyAsyncData<A, E> =
|
|
576
|
+
feed._tag === 'Success'
|
|
577
|
+
? AsyncData.success(applyPending(feed.value), feed.refetching)
|
|
578
|
+
: feed
|
|
579
|
+
const reusable =
|
|
580
|
+
config.reuse === true &&
|
|
581
|
+
prev !== undefined &&
|
|
582
|
+
prev.output._tag === 'Success' &&
|
|
583
|
+
overlaid._tag === 'Success'
|
|
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)
|
|
590
|
+
MutableRef.set(memo, { key, output })
|
|
591
|
+
return output
|
|
592
|
+
}
|
|
593
|
+
const subscribe = (listener: () => void): (() => void) => {
|
|
594
|
+
const offFeed = feedStore.subscribe(listener)
|
|
595
|
+
const offQueue = queue.store.subscribe(listener)
|
|
596
|
+
return () => {
|
|
597
|
+
offFeed()
|
|
598
|
+
offQueue()
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const derived = makeDerivedStore(recompute, subscribe, scheduler)
|
|
602
|
+
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
603
|
+
return narrowStore<A, E, Gated>(derived.store)
|
|
604
|
+
}),
|
|
605
|
+
)
|
|
204
606
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
607
|
+
// Direct registry assembly avoids deferred conditional parameters while Intents is generic.
|
|
608
|
+
const queueReducerName = `${name}/pending`
|
|
609
|
+
const queueHandles: ReadonlySet<string> = new Set([Queued.tag, Acked.tag, Settled.tag])
|
|
610
|
+
const queueReducerLayer = Layer.scopedDiscard(
|
|
611
|
+
Effect.gen(function* () {
|
|
612
|
+
const reducers = yield* Reducers
|
|
613
|
+
if (reducers.entries.some((entry) => entry.name === queueReducerName)) {
|
|
614
|
+
yield* Effect.logWarning(`reform: duplicate reducer name '${queueReducerName}' registered`)
|
|
615
|
+
}
|
|
616
|
+
const queue = yield* pendingQueueTag
|
|
617
|
+
const entry: ReducerEntry = {
|
|
618
|
+
name: queueReducerName,
|
|
619
|
+
handles: queueHandles,
|
|
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
|
+
},
|
|
638
|
+
}
|
|
639
|
+
yield* Effect.acquireRelease(
|
|
640
|
+
Effect.sync(() => reducers.register(entry)),
|
|
641
|
+
() => Effect.sync(() => reducers.unregister(entry)),
|
|
642
|
+
)
|
|
643
|
+
}),
|
|
644
|
+
)
|
|
212
645
|
const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
|
|
213
646
|
const procedureName = `${name}/send`
|
|
214
647
|
const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
|
|
215
|
-
const sendProcedureLayer =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
648
|
+
const sendProcedureLayer = Layer.scopedDiscard(
|
|
649
|
+
Effect.gen(function* () {
|
|
650
|
+
const procedures = yield* Channel.Procedures
|
|
651
|
+
if (procedures.entries.some((entry) => entry.name === procedureName)) {
|
|
652
|
+
yield* Effect.logWarning(`reform: duplicate procedure name '${procedureName}' registered`)
|
|
653
|
+
}
|
|
654
|
+
const link = yield* linkTag
|
|
655
|
+
const runtime = yield* Effect.runtime<Bus | R2>()
|
|
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
|
+
),
|
|
678
|
+
})
|
|
679
|
+
})
|
|
680
|
+
const entry: Channel.ProcedureEntry = {
|
|
681
|
+
name: procedureName,
|
|
682
|
+
channelName: channel.manifest.name,
|
|
683
|
+
handles: sendHandles,
|
|
684
|
+
run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
|
|
685
|
+
}
|
|
686
|
+
yield* Effect.acquireRelease(
|
|
687
|
+
Effect.sync(() => procedures.register(entry)),
|
|
688
|
+
() => Effect.sync(() => procedures.unregister(entry)),
|
|
689
|
+
)
|
|
690
|
+
}),
|
|
691
|
+
)
|
|
227
692
|
|
|
228
|
-
const pendingStoreLayer = Layer.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
),
|
|
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
|
+
}),
|
|
233
698
|
)
|
|
234
699
|
return Layer.mergeAll(
|
|
235
700
|
overlayLayer,
|