@playfast/reform 1.1.1 → 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/README.md +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +82 -33
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +733 -32
- package/src/calc/asyncCalc.test.ts +397 -225
- package/src/calc/asyncCalc.ts +115 -100
- package/src/calc/asyncCalcTypes.ts +147 -0
- package/src/calc/calc.test.ts +13 -16
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +90 -32
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +25 -6
- package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +168 -81
- package/src/compose/compositionTypes.ts +249 -0
- 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 +222 -262
- package/src/feature/feature.typecheck.ts +224 -91
- package/src/feature/featureBinding.ts +130 -20
- 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 +222 -0
- package/src/graph/services.ts +95 -0
- package/src/graph/summary.ts +134 -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 +61 -25
- package/src/internal/queryDriverHydrate.ts +45 -0
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +11 -11
- 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/store.test.ts +27 -6
- 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/pendingQueue.ts +145 -0
- package/src/remote/remoteState.test.ts +536 -354
- package/src/remote/remoteState.ts +145 -106
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/remote/remoteStateMake.ts +97 -0
- package/src/remote/remoteStateSend.ts +95 -37
- package/src/remote/remoteStateTypes.ts +155 -0
- package/src/runtime/appRuntime.activation.test.ts +104 -0
- package/src/runtime/appRuntime.test.ts +187 -111
- package/src/runtime/appRuntime.ts +36 -130
- package/src/runtime/capturedAppRuntime.ts +274 -0
- package/src/runtime/eventBudget.test.ts +39 -12
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/featureMount.ts +94 -0
- package/src/runtime/hardening.test.ts +22 -13
- package/src/runtime/instrumentation.test.ts +213 -194
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +84 -7
- package/src/runtime/loop.ts +69 -40
- package/src/runtime/runtimeHandle.ts +124 -0
- 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 +39 -8
- 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 +84 -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/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
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import { Data,
|
|
3
|
-
import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
|
|
2
|
+
import { Data, Effect, Layer, MutableRef, Schema as S } from 'effect'
|
|
3
|
+
import { AsyncCalc, Engine, Event, Reducer, State, StateGroup } from '../index'
|
|
4
|
+
import { makeFlightGate, until } from '../testkit/flight.testkit'
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
interface Notifier {
|
|
9
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The notification scheduler drains every listener of a write in one pass, and a
|
|
13
|
+
// driver subscribes before the test does: our own notification proves its
|
|
14
|
+
// trigger already ran, so an inert change can be asserted without waiting.
|
|
15
|
+
const afterNotify = <A, E, R>(
|
|
16
|
+
source: Notifier,
|
|
17
|
+
write: Effect.Effect<A, E, R>,
|
|
18
|
+
): Effect.Effect<void, E, R> =>
|
|
8
19
|
Effect.suspend(() => {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
const fired = MutableRef.make(false)
|
|
21
|
+
const off = source.subscribe(() => MutableRef.set(fired, true))
|
|
22
|
+
return write.pipe(
|
|
23
|
+
Effect.zipRight(
|
|
24
|
+
until(
|
|
25
|
+
() => MutableRef.get(fired),
|
|
26
|
+
(value) => value,
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
Effect.ensuring(Effect.sync(off)),
|
|
30
|
+
Effect.asVoid,
|
|
31
|
+
)
|
|
13
32
|
})
|
|
14
33
|
|
|
15
|
-
class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
|
|
16
|
-
|
|
17
34
|
it.live('runs the query and resolves Loading -> Success', () => {
|
|
18
35
|
class Count extends State.make('count', S.Number) {}
|
|
19
36
|
class Inputs extends StateGroup.make(Count) {}
|
|
@@ -22,16 +39,24 @@ it.live('runs the query and resolves Loading -> Success', () => {
|
|
|
22
39
|
output: S.Number,
|
|
23
40
|
alwaysOn: true,
|
|
24
41
|
}) {}
|
|
42
|
+
const gate = makeFlightGate()
|
|
43
|
+
// Armed before the layer builds, so the very first flight parks at the gate.
|
|
44
|
+
Effect.runSync(gate.hold)
|
|
25
45
|
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
26
|
-
query: ({ count }) => Effect.succeed(count * 2)
|
|
46
|
+
query: ({ count }) => gate.through(Effect.succeed(count * 2)),
|
|
27
47
|
})
|
|
28
48
|
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
|
|
29
49
|
|
|
30
50
|
return Effect.gen(function* () {
|
|
31
51
|
const store = yield* Doubled.store
|
|
52
|
+
yield* gate.awaitEntry(1)
|
|
32
53
|
expect(store.get()._tag).toBe('Loading')
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
|
|
55
|
+
yield* gate.release
|
|
56
|
+
const value = yield* until(
|
|
57
|
+
() => store.get(),
|
|
58
|
+
(state) => state._tag === 'Success',
|
|
59
|
+
)
|
|
35
60
|
expect(value._tag).toBe('Success')
|
|
36
61
|
if (value._tag === 'Success') {
|
|
37
62
|
expect(value.value).toBe(10)
|
|
@@ -56,8 +81,10 @@ it.live('a failing query resolves to Error carrying the failure', () => {
|
|
|
56
81
|
|
|
57
82
|
return Effect.gen(function* () {
|
|
58
83
|
const store = yield* Q.store
|
|
59
|
-
yield*
|
|
60
|
-
|
|
84
|
+
const value = yield* until(
|
|
85
|
+
() => store.get(),
|
|
86
|
+
(state) => state._tag === 'Error',
|
|
87
|
+
)
|
|
61
88
|
expect(value._tag).toBe('Error')
|
|
62
89
|
if (value._tag === 'Error') {
|
|
63
90
|
expect(value.error.message).toBe('nope')
|
|
@@ -74,23 +101,33 @@ it.live('a dependency change re-fetches, keeping the last value with refetching:
|
|
|
74
101
|
output: S.Number,
|
|
75
102
|
alwaysOn: true,
|
|
76
103
|
}) {}
|
|
104
|
+
const gate = makeFlightGate()
|
|
77
105
|
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
78
|
-
query: ({ count }) => Effect.succeed(count * 2)
|
|
106
|
+
query: ({ count }) => gate.through(Effect.succeed(count * 2)),
|
|
79
107
|
})
|
|
80
108
|
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
|
|
81
109
|
|
|
82
110
|
return Effect.gen(function* () {
|
|
83
111
|
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
84
112
|
const store = yield* Doubled.store
|
|
85
|
-
yield*
|
|
113
|
+
yield* until(
|
|
114
|
+
() => store.get(),
|
|
115
|
+
(v) => v._tag === 'Success',
|
|
116
|
+
)
|
|
86
117
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
87
118
|
|
|
119
|
+
// Hold the re-fetch at the gate: the previous value stays visible under it.
|
|
120
|
+
yield* gate.hold
|
|
88
121
|
source.set(7)
|
|
89
|
-
yield*
|
|
122
|
+
yield* gate.awaitEntry(2)
|
|
90
123
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
|
|
91
124
|
|
|
92
|
-
yield*
|
|
93
|
-
|
|
125
|
+
yield* gate.release
|
|
126
|
+
const settled = yield* until(
|
|
127
|
+
() => store.get(),
|
|
128
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
129
|
+
)
|
|
130
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
|
|
94
131
|
}).pipe(Effect.provide(TestLayer))
|
|
95
132
|
})
|
|
96
133
|
|
|
@@ -103,24 +140,35 @@ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
|
|
|
103
140
|
output: S.Number,
|
|
104
141
|
alwaysOn: true,
|
|
105
142
|
}) {}
|
|
143
|
+
const gate = makeFlightGate()
|
|
106
144
|
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
107
145
|
query: ({ count }) =>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
146
|
+
gate.through(
|
|
147
|
+
Effect.sync(() => {
|
|
148
|
+
runs.n += 1
|
|
149
|
+
return count * 2
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
112
152
|
})
|
|
113
153
|
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
114
154
|
|
|
115
155
|
return Effect.gen(function* () {
|
|
116
156
|
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
117
157
|
const store = yield* Doubled.store
|
|
118
|
-
yield*
|
|
158
|
+
yield* until(
|
|
159
|
+
() => store.get(),
|
|
160
|
+
(v) => v._tag === 'Success',
|
|
161
|
+
)
|
|
119
162
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
|
|
120
163
|
|
|
164
|
+
// `4` lands while `3` is still parked at the gate, so it can only win by
|
|
165
|
+
// cancelling it.
|
|
166
|
+
yield* gate.hold
|
|
121
167
|
source.set(3)
|
|
122
|
-
yield*
|
|
168
|
+
yield* gate.awaitEntry(2)
|
|
123
169
|
source.set(4)
|
|
170
|
+
yield* gate.awaitEntry(3)
|
|
171
|
+
yield* gate.release
|
|
124
172
|
const settled = yield* until(
|
|
125
173
|
() => store.get(),
|
|
126
174
|
(v) => v._tag === 'Success' && v.refetching === false && v.value !== 2,
|
|
@@ -136,8 +184,9 @@ it.live('a gated query starts Idle while disabled and runs once enabled', () =>
|
|
|
136
184
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
137
185
|
output: S.Number,
|
|
138
186
|
}) {}
|
|
187
|
+
const gate = makeFlightGate()
|
|
139
188
|
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
140
|
-
query: ({ count }) => Effect.succeed(count * 2),
|
|
189
|
+
query: ({ count }) => gate.through(Effect.succeed(count * 2)),
|
|
141
190
|
disabled: ({ count }) => count < 0,
|
|
142
191
|
})
|
|
143
192
|
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })))
|
|
@@ -145,12 +194,16 @@ it.live('a gated query starts Idle while disabled and runs once enabled', () =>
|
|
|
145
194
|
return Effect.gen(function* () {
|
|
146
195
|
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
147
196
|
const store = yield* Doubled.store
|
|
148
|
-
yield* tick()
|
|
149
197
|
expect(store.get()._tag).toBe('Idle')
|
|
150
198
|
|
|
151
199
|
source.set(5)
|
|
152
|
-
yield*
|
|
153
|
-
|
|
200
|
+
const settled = yield* until(
|
|
201
|
+
() => store.get(),
|
|
202
|
+
(v) => v._tag === 'Success',
|
|
203
|
+
)
|
|
204
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
|
|
205
|
+
// Exactly one flight ever ran: the disabled window produced none.
|
|
206
|
+
expect(gate.entered()).toBe(1)
|
|
154
207
|
}).pipe(Effect.provide(TestLayer))
|
|
155
208
|
})
|
|
156
209
|
|
|
@@ -164,12 +217,15 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
|
|
|
164
217
|
output: S.Number,
|
|
165
218
|
alwaysOn: true,
|
|
166
219
|
}) {}
|
|
220
|
+
const gate = makeFlightGate()
|
|
167
221
|
const QLive = AsyncCalc.live(Q, {
|
|
168
222
|
query: ({ page }) =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
223
|
+
gate.through(
|
|
224
|
+
Effect.sync(() => {
|
|
225
|
+
runs.n += 1
|
|
226
|
+
return page
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
173
229
|
invalidateBy: ({ page }) => [page],
|
|
174
230
|
})
|
|
175
231
|
const TestLayer = QLive.pipe(
|
|
@@ -180,17 +236,27 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
|
|
|
180
236
|
const status = yield* StateGroup.select(Inputs, 'status').store
|
|
181
237
|
const page = yield* StateGroup.select(Inputs, 'page').store
|
|
182
238
|
const store = yield* Q.store
|
|
183
|
-
yield*
|
|
239
|
+
yield* until(
|
|
240
|
+
() => store.get(),
|
|
241
|
+
(v) => v._tag === 'Success',
|
|
242
|
+
)
|
|
184
243
|
expect(runs.n).toBe(1)
|
|
185
244
|
|
|
186
|
-
status
|
|
187
|
-
|
|
245
|
+
// Holding the gate means any flight the status churn triggered would still
|
|
246
|
+
// be marked fetching here, and could not have settled back out of view.
|
|
247
|
+
yield* gate.hold
|
|
248
|
+
yield* afterNotify(status, Effect.sync(() => status.set('b')))
|
|
249
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
|
|
188
250
|
expect(runs.n).toBe(1)
|
|
251
|
+
yield* gate.release
|
|
189
252
|
|
|
190
253
|
page.set(2)
|
|
191
|
-
yield*
|
|
254
|
+
const settled = yield* until(
|
|
255
|
+
() => store.get(),
|
|
256
|
+
(v) => v._tag === 'Success' && v.refetching === false && v.value === 2,
|
|
257
|
+
)
|
|
192
258
|
expect(runs.n).toBe(2)
|
|
193
|
-
expect(
|
|
259
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 2 })
|
|
194
260
|
}).pipe(Effect.provide(TestLayer))
|
|
195
261
|
})
|
|
196
262
|
|
|
@@ -216,7 +282,10 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
|
|
|
216
282
|
return Effect.gen(function* () {
|
|
217
283
|
const source = yield* StateGroup.select(Inputs, 'page').store
|
|
218
284
|
const store = yield* Rows.store
|
|
219
|
-
const first = yield* until(
|
|
285
|
+
const first = yield* until(
|
|
286
|
+
() => store.get(),
|
|
287
|
+
(v) => v._tag === 'Success',
|
|
288
|
+
)
|
|
220
289
|
if (first._tag !== 'Success') return
|
|
221
290
|
|
|
222
291
|
source.set(2)
|
|
@@ -231,80 +300,107 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
|
|
|
231
300
|
}).pipe(Effect.provide(TestLayer))
|
|
232
301
|
})
|
|
233
302
|
|
|
234
|
-
it.live(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
()
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
303
|
+
it.live(
|
|
304
|
+
'coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch',
|
|
305
|
+
() => {
|
|
306
|
+
class Count extends State.make('count', S.Number) {}
|
|
307
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
308
|
+
const completed: number[] = []
|
|
309
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
310
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
311
|
+
output: S.Number,
|
|
312
|
+
alwaysOn: true,
|
|
313
|
+
}) {}
|
|
314
|
+
const gate = makeFlightGate()
|
|
315
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
316
|
+
query: ({ count }) =>
|
|
317
|
+
gate.through(
|
|
318
|
+
Effect.sync(() => {
|
|
319
|
+
completed.push(count)
|
|
320
|
+
return count * 2
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
coalesce: 'trailing',
|
|
324
|
+
})
|
|
325
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
326
|
+
|
|
327
|
+
return Effect.gen(function* () {
|
|
328
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
329
|
+
const store = yield* Doubled.store
|
|
330
|
+
yield* until(
|
|
331
|
+
() => store.get(),
|
|
332
|
+
(v) => v._tag === 'Success',
|
|
333
|
+
)
|
|
334
|
+
expect(completed).toEqual([1])
|
|
335
|
+
|
|
336
|
+
// Hold the `2` flight at the gate so 3 and 4 land, in that order, while it
|
|
337
|
+
// is in flight.
|
|
338
|
+
yield* gate.hold
|
|
339
|
+
source.set(2)
|
|
340
|
+
yield* gate.awaitEntry(2)
|
|
341
|
+
yield* afterNotify(source, Effect.sync(() => source.set(3)))
|
|
342
|
+
yield* afterNotify(source, Effect.sync(() => source.set(4)))
|
|
343
|
+
yield* gate.release
|
|
344
|
+
const settled = yield* until(
|
|
345
|
+
() => store.get(),
|
|
346
|
+
(v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
|
|
347
|
+
)
|
|
348
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
|
|
349
|
+
expect(completed).toEqual([1, 2, 4])
|
|
350
|
+
}).pipe(Effect.provide(TestLayer))
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
it.live(
|
|
355
|
+
'coalesce trailing: the stale settle re-flags refetching until the trailing run lands',
|
|
356
|
+
() => {
|
|
357
|
+
class Count extends State.make('count', S.Number) {}
|
|
358
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
359
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
360
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
361
|
+
output: S.Number,
|
|
362
|
+
alwaysOn: true,
|
|
363
|
+
}) {}
|
|
364
|
+
// Two gates: one parks the flight the burst interrupts, the other parks the
|
|
365
|
+
// trailing run, so the stale settle in between is a stable state, not a race.
|
|
366
|
+
const firstGate = makeFlightGate()
|
|
367
|
+
const trailingGate = makeFlightGate()
|
|
368
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
369
|
+
query: ({ count }) =>
|
|
370
|
+
count === 3
|
|
371
|
+
? trailingGate.through(Effect.succeed(count * 2))
|
|
372
|
+
: firstGate.through(Effect.succeed(count * 2)),
|
|
373
|
+
coalesce: 'trailing',
|
|
374
|
+
})
|
|
375
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
376
|
+
|
|
377
|
+
return Effect.gen(function* () {
|
|
378
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
379
|
+
const store = yield* Doubled.store
|
|
380
|
+
yield* until(
|
|
381
|
+
() => store.get(),
|
|
382
|
+
(v) => v._tag === 'Success',
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
yield* firstGate.hold
|
|
386
|
+
yield* trailingGate.hold
|
|
387
|
+
source.set(2)
|
|
388
|
+
yield* firstGate.awaitEntry(2)
|
|
389
|
+
yield* afterNotify(source, Effect.sync(() => source.set(3)))
|
|
390
|
+
|
|
391
|
+
yield* firstGate.release
|
|
392
|
+
yield* trailingGate.awaitEntry(1)
|
|
393
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
|
|
394
|
+
|
|
395
|
+
yield* trailingGate.release
|
|
396
|
+
const settled = yield* until(
|
|
397
|
+
() => store.get(),
|
|
398
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
399
|
+
)
|
|
400
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
|
|
401
|
+
}).pipe(Effect.provide(TestLayer))
|
|
402
|
+
},
|
|
403
|
+
)
|
|
308
404
|
|
|
309
405
|
it.live('coalesce trailing: a disable while a request waits in the slot skips the run', () => {
|
|
310
406
|
class Count extends State.make('count', S.Number) {}
|
|
@@ -314,12 +410,15 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
|
|
|
314
410
|
inputs: [StateGroup.select(Inputs, 'count')],
|
|
315
411
|
output: S.Number,
|
|
316
412
|
}) {}
|
|
413
|
+
const gate = makeFlightGate()
|
|
317
414
|
const QLive = AsyncCalc.live(Q, {
|
|
318
415
|
query: ({ count }) =>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
416
|
+
gate.through(
|
|
417
|
+
Effect.sync(() => {
|
|
418
|
+
completed.push(count)
|
|
419
|
+
return count * 2
|
|
420
|
+
}),
|
|
421
|
+
),
|
|
323
422
|
disabled: ({ count }) => count < 0,
|
|
324
423
|
coalesce: 'trailing',
|
|
325
424
|
})
|
|
@@ -328,19 +427,29 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
|
|
|
328
427
|
return Effect.gen(function* () {
|
|
329
428
|
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
330
429
|
const store = yield* Q.store
|
|
331
|
-
yield* until(
|
|
430
|
+
yield* until(
|
|
431
|
+
() => store.get(),
|
|
432
|
+
(v) => v._tag === 'Success',
|
|
433
|
+
)
|
|
332
434
|
|
|
435
|
+
// 3 queues behind the held `2` flight, then the disable overtakes it: the
|
|
436
|
+
// queued run must be dropped, not executed on release.
|
|
437
|
+
yield* gate.hold
|
|
333
438
|
source.set(2)
|
|
334
|
-
yield*
|
|
335
|
-
source.set(3)
|
|
336
|
-
yield*
|
|
337
|
-
|
|
338
|
-
yield* tick(60)
|
|
439
|
+
yield* gate.awaitEntry(2)
|
|
440
|
+
yield* afterNotify(source, Effect.sync(() => source.set(3)))
|
|
441
|
+
yield* afterNotify(source, Effect.sync(() => source.set(-1)))
|
|
442
|
+
yield* gate.release
|
|
339
443
|
expect(store.get()._tag).toBe('Idle')
|
|
340
444
|
expect(completed).toEqual([1, 2])
|
|
341
445
|
|
|
446
|
+
// The queue is FIFO: once 5 has landed, 3's fate is settled, and it must not
|
|
447
|
+
// appear between them.
|
|
342
448
|
source.set(5)
|
|
343
|
-
const settled = yield* until(
|
|
449
|
+
const settled = yield* until(
|
|
450
|
+
() => store.get(),
|
|
451
|
+
(v) => v._tag === 'Success',
|
|
452
|
+
)
|
|
344
453
|
expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
|
|
345
454
|
expect(completed).toEqual([1, 2, 5])
|
|
346
455
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -356,12 +465,15 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
|
|
|
356
465
|
output: S.Number,
|
|
357
466
|
alwaysOn: true,
|
|
358
467
|
}) {}
|
|
468
|
+
const gate = makeFlightGate()
|
|
359
469
|
const QLive = AsyncCalc.live(Q, {
|
|
360
470
|
query: ({ count }) =>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
471
|
+
gate.through(
|
|
472
|
+
Effect.sync(() => {
|
|
473
|
+
runs.n += 1
|
|
474
|
+
return count * 2
|
|
475
|
+
}),
|
|
476
|
+
),
|
|
365
477
|
invalidateOn: [Poke],
|
|
366
478
|
})
|
|
367
479
|
const TestLayer = QLive.pipe(
|
|
@@ -371,99 +483,135 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
|
|
|
371
483
|
|
|
372
484
|
return Effect.gen(function* () {
|
|
373
485
|
const store = yield* Q.store
|
|
374
|
-
yield*
|
|
486
|
+
yield* until(
|
|
487
|
+
() => store.get(),
|
|
488
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
489
|
+
)
|
|
375
490
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
|
|
376
491
|
expect(runs.n).toBe(1)
|
|
377
492
|
|
|
493
|
+
yield* gate.hold
|
|
378
494
|
yield* Event.dispatch(Poke, {})
|
|
379
|
-
yield*
|
|
495
|
+
yield* gate.awaitEntry(2)
|
|
380
496
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
|
|
381
|
-
yield* tick(30)
|
|
382
|
-
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
|
|
383
|
-
expect(runs.n).toBe(2)
|
|
384
|
-
}).pipe(Effect.provide(TestLayer))
|
|
385
|
-
})
|
|
386
497
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
class Q extends AsyncCalc.make('Q', {
|
|
394
|
-
inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
|
|
395
|
-
output: S.Number,
|
|
396
|
-
alwaysOn: true,
|
|
397
|
-
}) {}
|
|
398
|
-
const QLive = AsyncCalc.live(Q, {
|
|
399
|
-
query: ({ page }) =>
|
|
400
|
-
Effect.sync(() => {
|
|
401
|
-
runs.n += 1
|
|
402
|
-
return page
|
|
403
|
-
}),
|
|
404
|
-
invalidateBy: ({ page }) => [page],
|
|
405
|
-
invalidateOn: [Poke],
|
|
406
|
-
})
|
|
407
|
-
const TestLayer = QLive.pipe(
|
|
408
|
-
Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
|
|
409
|
-
Layer.provideMerge(Engine),
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
return Effect.gen(function* () {
|
|
413
|
-
const status = yield* StateGroup.select(Inputs, 'status').store
|
|
414
|
-
yield* tick()
|
|
415
|
-
expect(runs.n).toBe(1)
|
|
416
|
-
|
|
417
|
-
status.set('b')
|
|
418
|
-
yield* tick()
|
|
419
|
-
expect(runs.n).toBe(1)
|
|
420
|
-
|
|
421
|
-
yield* Event.dispatch(Poke, {})
|
|
422
|
-
yield* tick()
|
|
498
|
+
yield* gate.release
|
|
499
|
+
const settled = yield* until(
|
|
500
|
+
() => store.get(),
|
|
501
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
502
|
+
)
|
|
503
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
|
|
423
504
|
expect(runs.n).toBe(2)
|
|
424
505
|
}).pipe(Effect.provide(TestLayer))
|
|
425
506
|
})
|
|
426
507
|
|
|
427
|
-
it.live(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
508
|
+
it.live(
|
|
509
|
+
'invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches',
|
|
510
|
+
() => {
|
|
511
|
+
class Status extends State.make('status', S.String) {}
|
|
512
|
+
class Page extends State.make('page', S.Number) {}
|
|
513
|
+
class Inputs extends StateGroup.make(Status, Page) {}
|
|
514
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
515
|
+
const runs = { n: 0 }
|
|
516
|
+
class Q extends AsyncCalc.make('Q', {
|
|
517
|
+
inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
|
|
518
|
+
output: S.Number,
|
|
519
|
+
alwaysOn: true,
|
|
520
|
+
}) {}
|
|
521
|
+
const gate = makeFlightGate()
|
|
522
|
+
const QLive = AsyncCalc.live(Q, {
|
|
523
|
+
query: ({ page }) =>
|
|
524
|
+
gate.through(
|
|
525
|
+
Effect.sync(() => {
|
|
526
|
+
runs.n += 1
|
|
527
|
+
return page
|
|
528
|
+
}),
|
|
529
|
+
),
|
|
530
|
+
invalidateBy: ({ page }) => [page],
|
|
531
|
+
invalidateOn: [Poke],
|
|
532
|
+
})
|
|
533
|
+
const TestLayer = QLive.pipe(
|
|
534
|
+
Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
|
|
535
|
+
Layer.provideMerge(Engine),
|
|
536
|
+
)
|
|
455
537
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
538
|
+
return Effect.gen(function* () {
|
|
539
|
+
const status = yield* StateGroup.select(Inputs, 'status').store
|
|
540
|
+
const store = yield* Q.store
|
|
541
|
+
yield* until(
|
|
542
|
+
() => store.get(),
|
|
543
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
544
|
+
)
|
|
545
|
+
expect(runs.n).toBe(1)
|
|
546
|
+
|
|
547
|
+
yield* gate.hold
|
|
548
|
+
yield* afterNotify(status, Effect.sync(() => status.set('b')))
|
|
549
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
|
|
550
|
+
expect(runs.n).toBe(1)
|
|
551
|
+
yield* gate.release
|
|
552
|
+
|
|
553
|
+
yield* Event.dispatch(Poke, {})
|
|
554
|
+
yield* gate.awaitEntry(2)
|
|
555
|
+
expect(runs.n).toBe(2)
|
|
556
|
+
}).pipe(Effect.provide(TestLayer))
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
it.live(
|
|
561
|
+
'invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once',
|
|
562
|
+
() => {
|
|
563
|
+
class Count extends State.make('count', S.Number) {}
|
|
564
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
565
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
566
|
+
// A second reducer on the same event: reducers of one frame apply in a single
|
|
567
|
+
// pass, so this state landing proves the query's revision write landed too.
|
|
568
|
+
class Pokes extends State.make('pokes', S.Number) {}
|
|
569
|
+
const PokesReducer = Reducer.make('pokes', { states: [Pokes], events: [Poke] })
|
|
570
|
+
const runs = { n: 0 }
|
|
571
|
+
class Q extends AsyncCalc.make('Q', {
|
|
572
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
573
|
+
output: S.Number,
|
|
574
|
+
}) {}
|
|
575
|
+
const QLive = AsyncCalc.live(Q, {
|
|
576
|
+
query: ({ count }) =>
|
|
577
|
+
Effect.sync(() => {
|
|
578
|
+
runs.n += 1
|
|
579
|
+
return count * 2
|
|
580
|
+
}),
|
|
581
|
+
disabled: ({ count }) => count < 0,
|
|
582
|
+
invalidateOn: [Poke],
|
|
583
|
+
})
|
|
584
|
+
const TestLayer = Layer.mergeAll(
|
|
585
|
+
QLive,
|
|
586
|
+
Reducer.live(PokesReducer, (seen) => seen + 1),
|
|
587
|
+
).pipe(
|
|
588
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
|
|
589
|
+
Layer.provideMerge(State.live(Pokes, 0)),
|
|
590
|
+
Layer.provideMerge(Engine),
|
|
591
|
+
)
|
|
460
592
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
593
|
+
return Effect.gen(function* () {
|
|
594
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
595
|
+
const pokes = yield* Pokes.store
|
|
596
|
+
const store = yield* Q.store
|
|
597
|
+
expect(store.get()._tag).toBe('Idle')
|
|
598
|
+
|
|
599
|
+
// An enqueued run marks the state fetching before it can run, so staying
|
|
600
|
+
// Idle across the frame is proof that nothing was enqueued.
|
|
601
|
+
yield* afterNotify(pokes, Event.dispatch(Poke, {}))
|
|
602
|
+
expect(store.get()._tag).toBe('Idle')
|
|
603
|
+
expect(runs.n).toBe(0)
|
|
604
|
+
|
|
605
|
+
source.set(5)
|
|
606
|
+
const settled = yield* until(
|
|
607
|
+
() => store.get(),
|
|
608
|
+
(v) => v._tag === 'Success',
|
|
609
|
+
)
|
|
610
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
|
|
611
|
+
expect(runs.n).toBe(1)
|
|
612
|
+
}).pipe(Effect.provide(TestLayer))
|
|
613
|
+
},
|
|
614
|
+
)
|
|
467
615
|
|
|
468
616
|
it.live('invalidateOn: two async calcs keep independent hidden revisions', () => {
|
|
469
617
|
class Count extends State.make('count', S.Number) {}
|
|
@@ -481,38 +629,62 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
|
|
|
481
629
|
output: S.Number,
|
|
482
630
|
alwaysOn: true,
|
|
483
631
|
}) {}
|
|
632
|
+
const gateA = makeFlightGate()
|
|
633
|
+
const gateB = makeFlightGate()
|
|
484
634
|
const TestLayer = Layer.mergeAll(
|
|
485
635
|
AsyncCalc.live(QA, {
|
|
486
636
|
query: ({ count }) =>
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
637
|
+
gateA.through(
|
|
638
|
+
Effect.sync(() => {
|
|
639
|
+
runs.a += 1
|
|
640
|
+
return count
|
|
641
|
+
}),
|
|
642
|
+
),
|
|
491
643
|
invalidateOn: [PokeA],
|
|
492
644
|
}),
|
|
493
645
|
AsyncCalc.live(QB, {
|
|
494
646
|
query: ({ count }) =>
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
647
|
+
gateB.through(
|
|
648
|
+
Effect.sync(() => {
|
|
649
|
+
runs.b += 1
|
|
650
|
+
return count
|
|
651
|
+
}),
|
|
652
|
+
),
|
|
499
653
|
invalidateOn: [PokeB],
|
|
500
654
|
}),
|
|
501
|
-
).pipe(
|
|
502
|
-
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
503
|
-
Layer.provideMerge(Engine),
|
|
504
|
-
)
|
|
655
|
+
).pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })), Layer.provideMerge(Engine))
|
|
505
656
|
|
|
506
657
|
return Effect.gen(function* () {
|
|
507
|
-
yield*
|
|
658
|
+
const storeA = yield* QA.store
|
|
659
|
+
const storeB = yield* QB.store
|
|
660
|
+
yield* until(
|
|
661
|
+
() => storeA.get()._tag === 'Success' && storeB.get()._tag === 'Success',
|
|
662
|
+
(both) => both,
|
|
663
|
+
)
|
|
508
664
|
expect(runs).toEqual({ a: 1, b: 1 })
|
|
509
665
|
|
|
666
|
+
// B's gate is held, so a revision leak from PokeA would leave B visibly
|
|
667
|
+
// fetching instead of settling back out of sight.
|
|
668
|
+
yield* gateB.hold
|
|
510
669
|
yield* Event.dispatch(PokeA, {})
|
|
511
|
-
yield*
|
|
670
|
+
yield* gateA.awaitEntry(2)
|
|
671
|
+
yield* until(
|
|
672
|
+
() => storeA.get(),
|
|
673
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
674
|
+
)
|
|
675
|
+
expect(storeB.get()).toMatchObject({ _tag: 'Success', refetching: false })
|
|
512
676
|
expect(runs).toEqual({ a: 2, b: 1 })
|
|
677
|
+
yield* gateB.release
|
|
513
678
|
|
|
679
|
+
yield* gateA.hold
|
|
514
680
|
yield* Event.dispatch(PokeB, {})
|
|
515
|
-
yield*
|
|
681
|
+
yield* gateB.awaitEntry(2)
|
|
682
|
+
yield* until(
|
|
683
|
+
() => storeB.get(),
|
|
684
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
685
|
+
)
|
|
686
|
+
expect(storeA.get()).toMatchObject({ _tag: 'Success', refetching: false })
|
|
516
687
|
expect(runs).toEqual({ a: 2, b: 2 })
|
|
688
|
+
yield* gateA.release
|
|
517
689
|
}).pipe(Effect.provide(TestLayer))
|
|
518
690
|
})
|