@playfast/reform 1.2.0 → 1.2.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/package.json +1 -1
- package/src/boundary/boundary.test.ts +26 -23
- package/src/calc/asyncCalc.invalidate.test.ts +258 -55
- package/src/calc/asyncCalc.test.ts +225 -85
- package/src/calc/asyncCalc.ts +27 -141
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +10 -11
- package/src/calc/calcFamily.test.ts +12 -12
- package/src/channel/channel.ts +8 -106
- package/src/channel/procedures.ts +108 -0
- package/src/compose/composition.ts +49 -246
- package/src/compose/compositionTypes.ts +249 -0
- package/src/event/event.fromSource.test.ts +12 -12
- package/src/feature/feature.ts +50 -1210
- package/src/feature/feature.typecheck.ts +2 -61
- package/src/feature/featureBinding.ts +158 -0
- package/src/feature/featureClass.ts +162 -0
- package/src/feature/featureConfig.ts +192 -0
- package/src/feature/featureFactory.ts +163 -0
- package/src/feature/featureModule.ts +136 -0
- package/src/feature/featureModule.typecheck.ts +63 -0
- package/src/feature/featureNativeTree.ts +152 -0
- package/src/feature/featureRequirement.ts +82 -0
- package/src/feature/featureVariants.ts +234 -0
- package/src/feature/mountFeature.ts +54 -0
- package/src/graph/closure.ts +45 -226
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -0
- package/src/internal/queryDriver.ts +20 -89
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverTypes.ts +8 -10
- package/src/internal/store.test.ts +27 -6
- package/src/remote/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +28 -22
- package/src/remote/remoteState.ts +63 -489
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +122 -0
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +9 -5
- package/src/runtime/appRuntime.test.ts +40 -16
- package/src/runtime/appRuntime.ts +17 -472
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +38 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +18 -4
- package/src/runtime/instrumentation.test.ts +49 -12
- package/src/runtime/loop.test.ts +51 -10
- package/src/runtime/runtimeHandle.ts +124 -0
- package/src/state/stateFamily.test.ts +25 -8
- package/src/testkit/flight.testkit.ts +9 -0
|
@@ -13,17 +13,7 @@ import {
|
|
|
13
13
|
import { Channels, Procedures } from '../channel/channel'
|
|
14
14
|
import { QueryStore } from '../internal/queryStore'
|
|
15
15
|
import { Reducers } from '../runtime/loop'
|
|
16
|
-
|
|
17
|
-
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
18
|
-
|
|
19
|
-
// Bounded poll — fixed tick budgets flake under parallel-suite host load
|
|
20
|
-
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
21
|
-
Effect.suspend(() => {
|
|
22
|
-
const a = read()
|
|
23
|
-
return pred(a) || rounds <= 0
|
|
24
|
-
? Effect.succeed(a)
|
|
25
|
-
: Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
|
|
26
|
-
})
|
|
16
|
+
import { makeFlightGate, tick, until } from '../testkit/flight.testkit'
|
|
27
17
|
|
|
28
18
|
const Item = S.Struct({ id: S.String, name: S.String })
|
|
29
19
|
type Item = typeof Item.Type
|
|
@@ -284,9 +274,10 @@ it.live(
|
|
|
284
274
|
intents: [Added, Renamed],
|
|
285
275
|
alwaysOn: true,
|
|
286
276
|
}) {}
|
|
277
|
+
const gate = makeFlightGate()
|
|
287
278
|
const ItemsLive = RemoteState.live(Items, {
|
|
288
|
-
query: () => server.fetch(
|
|
289
|
-
send: (intent) => server.receive(intent,
|
|
279
|
+
query: () => gate.through(server.fetch(0)),
|
|
280
|
+
send: (intent) => server.receive(intent, 0),
|
|
290
281
|
apply: applyIntent,
|
|
291
282
|
invalidateOn: [Poke],
|
|
292
283
|
// Trailing lets pre-ack run land — stale Success must not settle
|
|
@@ -312,22 +303,25 @@ it.live(
|
|
|
312
303
|
if (v._tag === 'Success') frames.push(v.value.length)
|
|
313
304
|
})
|
|
314
305
|
|
|
306
|
+
// The gen-2 fetch is pinned open, so the intent dispatched after it can only be
|
|
307
|
+
// settled by a later run — the stale snapshot is structurally denied the chance.
|
|
308
|
+
yield* gate.hold
|
|
315
309
|
yield* Event.dispatch(Poke, {})
|
|
316
|
-
yield*
|
|
310
|
+
yield* gate.awaitEntry(2)
|
|
311
|
+
expect(server.state.fetches).toBe(2)
|
|
317
312
|
yield* Event.dispatch(Added, { id: 'b', name: 'beta' })
|
|
318
313
|
|
|
319
|
-
// Gen-2 Success settles nobody (generation rule)
|
|
320
314
|
yield* until(
|
|
321
|
-
() =>
|
|
322
|
-
(
|
|
315
|
+
() => queue.get(),
|
|
316
|
+
(q) => q[0]?.status === 'confirmed',
|
|
323
317
|
)
|
|
324
|
-
yield* tick(25)
|
|
325
318
|
expect(queue.get()).toHaveLength(1)
|
|
326
319
|
expect(queue.get()[0]).toMatchObject({ status: 'confirmed' })
|
|
327
320
|
const midflight = store.get()
|
|
328
321
|
expect(midflight).toMatchObject({ _tag: 'Success' })
|
|
329
322
|
if (midflight._tag === 'Success') expect(midflight.value).toHaveLength(2)
|
|
330
323
|
|
|
324
|
+
yield* gate.release
|
|
331
325
|
yield* until(
|
|
332
326
|
() => queue.get(),
|
|
333
327
|
(q) => q.length === 0,
|
|
@@ -617,7 +611,6 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
|
|
|
617
611
|
const source = yield* StateGroup.select(Inputs, 'page').store
|
|
618
612
|
const store = yield* Items.store
|
|
619
613
|
const queue = yield* Items.pending.store
|
|
620
|
-
yield* tick()
|
|
621
614
|
expect(store.get()._tag).toBe('Idle')
|
|
622
615
|
|
|
623
616
|
// Intents still send while gated Idle; settle waits for re-enable
|
|
@@ -626,7 +619,11 @@ it.live('gated: intents queue and send while Idle; re-enabling converges and set
|
|
|
626
619
|
() => server.state.items,
|
|
627
620
|
(items) => items.length === 2,
|
|
628
621
|
)
|
|
629
|
-
|
|
622
|
+
// The ack is the only thing that could settle the intent — assert the instant it lands.
|
|
623
|
+
yield* until(
|
|
624
|
+
() => queue.get(),
|
|
625
|
+
(q) => q[0]?.status === 'confirmed',
|
|
626
|
+
)
|
|
630
627
|
expect(store.get()._tag).toBe('Idle')
|
|
631
628
|
expect(queue.get()).toHaveLength(1)
|
|
632
629
|
expect(server.state.fetches).toBe(0)
|
|
@@ -774,8 +771,13 @@ it.live('a query Error passes through by reference with intents still pending',
|
|
|
774
771
|
intents: [Added, Renamed],
|
|
775
772
|
alwaysOn: true,
|
|
776
773
|
}) {}
|
|
774
|
+
const attempts = { n: 0 }
|
|
777
775
|
const ItemsLive = RemoteState.live(Items, {
|
|
778
|
-
query: () =>
|
|
776
|
+
query: () =>
|
|
777
|
+
Effect.suspend(() => {
|
|
778
|
+
attempts.n += 1
|
|
779
|
+
return Effect.fail('boom')
|
|
780
|
+
}),
|
|
779
781
|
send: (intent) => server.receive(intent, 10),
|
|
780
782
|
apply: applyIntent,
|
|
781
783
|
})
|
|
@@ -799,11 +801,15 @@ it.live('a query Error passes through by reference with intents still pending',
|
|
|
799
801
|
() => queue.get(),
|
|
800
802
|
(q) => q.length === 1 && q[0]?.status === 'confirmed',
|
|
801
803
|
)
|
|
804
|
+
// Only a completed run could settle the intent — wait for the post-ack one to land.
|
|
805
|
+
yield* until(
|
|
806
|
+
() => attempts.n,
|
|
807
|
+
(n) => n >= 2,
|
|
808
|
+
)
|
|
802
809
|
yield* until(
|
|
803
810
|
() => store.get(),
|
|
804
811
|
(v) => v._tag === 'Error' && v.refetching === false,
|
|
805
812
|
)
|
|
806
|
-
yield* tick(20)
|
|
807
813
|
expect(queue.get()).toHaveLength(1)
|
|
808
814
|
expect(store.get()).toBe(truth.get())
|
|
809
815
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -1,417 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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'
|
|
1
|
+
import { Context, Effect, Iterable, Layer, MutableRef, Option, Runtime, Schema } from 'effect'
|
|
2
|
+
import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
|
|
22
3
|
import * as Channel from '../channel/channel'
|
|
23
|
-
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
24
4
|
import * as Event from '../event/event'
|
|
25
5
|
import {
|
|
26
6
|
bumpRevision,
|
|
27
|
-
type GatedOf,
|
|
28
|
-
gatedFlag,
|
|
29
7
|
makeQueryDriver,
|
|
30
|
-
type QueryCodec,
|
|
31
8
|
RevisionSchema,
|
|
32
9
|
revisionZero,
|
|
33
10
|
} from '../internal/queryDriver'
|
|
34
|
-
import { inspectable } from '../internal/inspect'
|
|
35
11
|
import { reuse as shareStructure } from '../internal/reuse'
|
|
36
|
-
import { resolveScheduler
|
|
37
|
-
import {
|
|
38
|
-
|
|
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'
|
|
12
|
+
import { resolveScheduler } from '../internal/scheduler'
|
|
13
|
+
import { type InputStores, sameKey } from '../internal/sources'
|
|
14
|
+
import { makeDerivedStore } from '../internal/store'
|
|
45
15
|
import * as Reducer from '../reducer/reducer'
|
|
46
|
-
import { Bus,
|
|
47
|
-
import {
|
|
16
|
+
import { Bus, publish } from '../runtime/bus'
|
|
17
|
+
import { Reducers } from '../runtime/loop'
|
|
48
18
|
import * as State from '../state/state'
|
|
49
|
-
import { type AnySource
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
export type
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}
|
|
19
|
+
import { type AnySource } from '../state/token'
|
|
20
|
+
import { makePendingQueue, type PendingQueue } from './pendingQueue'
|
|
21
|
+
import { looseSchema } from './remoteStateMake'
|
|
22
|
+
import { queueReducerLayer, sendProcedureLayer } from './remoteStateSend'
|
|
23
|
+
import type {
|
|
24
|
+
RemoteStateClass,
|
|
25
|
+
RemoteStateLiveExternalApi,
|
|
26
|
+
RemoteStatePendingStore,
|
|
27
|
+
RemoteStateSettled,
|
|
28
|
+
RemoteStateTruthStore,
|
|
29
|
+
RemoteStateVisibleStore,
|
|
30
|
+
SettleLink,
|
|
31
|
+
WaiterRegistration,
|
|
32
|
+
} from './remoteStateTypes'
|
|
33
|
+
|
|
34
|
+
export { make } from './remoteStateMake'
|
|
35
|
+
export {
|
|
36
|
+
RemoteStatePendingStoreTypeId,
|
|
37
|
+
RemoteStateTruthStoreTypeId,
|
|
38
|
+
RemoteStateVisibleStoreTypeId,
|
|
39
|
+
} from './remoteStateTypes'
|
|
40
|
+
export type {
|
|
41
|
+
FailedIntent,
|
|
42
|
+
PendingIntent,
|
|
43
|
+
RemoteStateClass,
|
|
44
|
+
RemoteStateConfigExternalApi,
|
|
45
|
+
RemoteStateLiveExternalApi,
|
|
46
|
+
RemoteStateManifestExternalApi,
|
|
47
|
+
RemoteStatePendingStore,
|
|
48
|
+
RemoteStateSchemaReflection,
|
|
49
|
+
RemoteStateSettled,
|
|
50
|
+
RemoteStateTruthStore,
|
|
51
|
+
RemoteStateVisibleStore,
|
|
52
|
+
} from './remoteStateTypes'
|
|
415
53
|
|
|
416
54
|
export const live = <
|
|
417
55
|
N extends string,
|
|
@@ -604,91 +242,27 @@ export const live = <
|
|
|
604
242
|
}),
|
|
605
243
|
)
|
|
606
244
|
|
|
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
|
-
)
|
|
645
245
|
const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
)
|
|
246
|
+
const intentEvents = {
|
|
247
|
+
queued: Queued,
|
|
248
|
+
acked: Acked,
|
|
249
|
+
settled: Settled,
|
|
250
|
+
invalidated: Invalidated,
|
|
251
|
+
}
|
|
252
|
+
const pendingReducer = queueReducerLayer({
|
|
253
|
+
name: `${name}/pending`,
|
|
254
|
+
queueTag: pendingQueueTag,
|
|
255
|
+
events: intentEvents,
|
|
256
|
+
})
|
|
257
|
+
const sendProcedure = sendProcedureLayer({
|
|
258
|
+
name: `${name}/send`,
|
|
259
|
+
channel,
|
|
260
|
+
handles: new Set(remote.intents.map((event) => event.tag)),
|
|
261
|
+
linkTag,
|
|
262
|
+
failed: remote.Failed,
|
|
263
|
+
send: config.send,
|
|
264
|
+
events: intentEvents,
|
|
265
|
+
})
|
|
692
266
|
|
|
693
267
|
const pendingStoreLayer = Layer.scopedContext(
|
|
694
268
|
Effect.map(resolveScheduler, (scheduler) => {
|
|
@@ -698,9 +272,9 @@ export const live = <
|
|
|
698
272
|
)
|
|
699
273
|
return Layer.mergeAll(
|
|
700
274
|
overlayLayer,
|
|
701
|
-
|
|
275
|
+
pendingReducer,
|
|
702
276
|
Reducer.live(revisionReducer, bumpRevision),
|
|
703
|
-
|
|
277
|
+
sendProcedure,
|
|
704
278
|
Channel.live(channel),
|
|
705
279
|
).pipe(
|
|
706
280
|
Layer.provideMerge(driverLayer),
|