@playfast/reform 0.0.5 → 0.0.8
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 +31 -10
- package/src/boundary/boundary.test.ts +301 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncData.ts +9 -1
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/channel/channel.ts +9 -5
- package/src/compose/host.ts +4 -1
- package/src/compose/props.ts +5 -1
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +173 -44
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +2 -0
- package/src/event/event.test.ts +23 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +38 -2
- package/src/internal/capture.ts +4 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +92 -16
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/queryDriver.ts +1 -1
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/scheduler.ts +3 -1
- package/src/internal/sources.ts +2 -2
- package/src/internal/stateRegistry.ts +32 -0
- package/src/internal/store.test.ts +80 -0
- package/src/internal/track.ts +3 -1
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +6 -2
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +4 -2
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +4 -1
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +4 -1
- package/src/state/stateGroup.ts +31 -4
- package/src/state/token.ts +5 -4
- package/src/synced/syncedStore.ts +99 -0
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
|
|
4
|
+
|
|
5
|
+
// AsyncCalc is a derived store whose value is the lifecycle of an Effect. It runs
|
|
6
|
+
// the query reactively from its inputs (latest-wins), keeps the last value while
|
|
7
|
+
// re-fetching (stale-while-revalidate), and goes Idle when a gated query disables.
|
|
8
|
+
|
|
9
|
+
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
10
|
+
|
|
11
|
+
// Poll until the predicate holds (bounded) — for assertions that race real
|
|
12
|
+
// timers under parallel-suite host load, where a fixed `tick` budget flakes.
|
|
13
|
+
const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
|
|
14
|
+
Effect.suspend(() => {
|
|
15
|
+
const a = read()
|
|
16
|
+
return pred(a) || rounds <= 0
|
|
17
|
+
? Effect.succeed(a)
|
|
18
|
+
: Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
|
|
22
|
+
|
|
23
|
+
it.live('runs the query and resolves Loading -> Success', () => {
|
|
24
|
+
class Count extends State.make('count', S.Number) {}
|
|
25
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
26
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
27
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
28
|
+
output: S.Number,
|
|
29
|
+
alwaysOn: true,
|
|
30
|
+
}) {}
|
|
31
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
32
|
+
query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
|
|
33
|
+
})
|
|
34
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
|
|
35
|
+
|
|
36
|
+
return Effect.gen(function* () {
|
|
37
|
+
const store = yield* Doubled.store
|
|
38
|
+
// Before the forked query resolves, the value is Loading.
|
|
39
|
+
expect(store.get()._tag).toBe('Loading')
|
|
40
|
+
yield* tick(40)
|
|
41
|
+
const value = store.get()
|
|
42
|
+
expect(value._tag).toBe('Success')
|
|
43
|
+
if (value._tag === 'Success') {
|
|
44
|
+
expect(value.value).toBe(10)
|
|
45
|
+
expect(value.refetching).toBe(false)
|
|
46
|
+
}
|
|
47
|
+
}).pipe(Effect.provide(TestLayer))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it.live('a failing query resolves to Error carrying the failure', () => {
|
|
51
|
+
class Count extends State.make('count', S.Number) {}
|
|
52
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
53
|
+
class Q extends AsyncCalc.make('Q', {
|
|
54
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
55
|
+
output: S.Number,
|
|
56
|
+
error: S.instanceOf(Boom),
|
|
57
|
+
alwaysOn: true,
|
|
58
|
+
}) {}
|
|
59
|
+
const QLive = AsyncCalc.live(Q, {
|
|
60
|
+
query: () => Effect.fail(new Boom({ message: 'nope' })),
|
|
61
|
+
})
|
|
62
|
+
const TestLayer = QLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
63
|
+
|
|
64
|
+
return Effect.gen(function* () {
|
|
65
|
+
const store = yield* Q.store
|
|
66
|
+
yield* tick()
|
|
67
|
+
const value = store.get()
|
|
68
|
+
expect(value._tag).toBe('Error')
|
|
69
|
+
if (value._tag === 'Error') {
|
|
70
|
+
expect(value.error.message).toBe('nope')
|
|
71
|
+
expect(value.refetching).toBe(false)
|
|
72
|
+
}
|
|
73
|
+
}).pipe(Effect.provide(TestLayer))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it.live('a dependency change re-fetches, keeping the last value with refetching: true', () => {
|
|
77
|
+
class Count extends State.make('count', S.Number) {}
|
|
78
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
79
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
80
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
81
|
+
output: S.Number,
|
|
82
|
+
alwaysOn: true,
|
|
83
|
+
}) {}
|
|
84
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
85
|
+
query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
|
|
86
|
+
})
|
|
87
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
|
|
88
|
+
|
|
89
|
+
return Effect.gen(function* () {
|
|
90
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
91
|
+
const store = yield* Doubled.store
|
|
92
|
+
yield* tick(40)
|
|
93
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
94
|
+
|
|
95
|
+
// Move the input: the prior value is kept while the new fetch is in flight.
|
|
96
|
+
source.set(7)
|
|
97
|
+
yield* tick(5)
|
|
98
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
|
|
99
|
+
|
|
100
|
+
yield* tick(40)
|
|
101
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 14, refetching: false })
|
|
102
|
+
}).pipe(Effect.provide(TestLayer))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
|
|
106
|
+
class Count extends State.make('count', S.Number) {}
|
|
107
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
108
|
+
const runs = { n: 0 }
|
|
109
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
110
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
111
|
+
output: S.Number,
|
|
112
|
+
alwaysOn: true,
|
|
113
|
+
}) {}
|
|
114
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
115
|
+
query: ({ count }) =>
|
|
116
|
+
Effect.sync(() => {
|
|
117
|
+
runs.n += 1
|
|
118
|
+
return count * 2
|
|
119
|
+
}).pipe(Effect.delay(Duration.millis(25))),
|
|
120
|
+
})
|
|
121
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
122
|
+
|
|
123
|
+
return Effect.gen(function* () {
|
|
124
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
125
|
+
const store = yield* Doubled.store
|
|
126
|
+
yield* tick(40)
|
|
127
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
|
|
128
|
+
|
|
129
|
+
// Two changes in quick succession: the first run is switched out before it
|
|
130
|
+
// can write, so only the latest value lands (poll — fixed sleeps flake when
|
|
131
|
+
// a loaded host starves the 25ms query timer).
|
|
132
|
+
source.set(3)
|
|
133
|
+
yield* tick(5)
|
|
134
|
+
source.set(4)
|
|
135
|
+
const settled = yield* until(
|
|
136
|
+
() => store.get(),
|
|
137
|
+
(v) => v._tag === 'Success' && v.refetching === false && v.value !== 2,
|
|
138
|
+
)
|
|
139
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 8 })
|
|
140
|
+
}).pipe(Effect.provide(TestLayer))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it.live('a gated query starts Idle while disabled and runs once enabled', () => {
|
|
144
|
+
class Count extends State.make('count', S.Number) {}
|
|
145
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
146
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
147
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
148
|
+
output: S.Number,
|
|
149
|
+
}) {}
|
|
150
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
151
|
+
query: ({ count }) => Effect.succeed(count * 2),
|
|
152
|
+
disabled: ({ count }) => count < 0,
|
|
153
|
+
})
|
|
154
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })))
|
|
155
|
+
|
|
156
|
+
return Effect.gen(function* () {
|
|
157
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
158
|
+
const store = yield* Doubled.store
|
|
159
|
+
yield* tick()
|
|
160
|
+
expect(store.get()._tag).toBe('Idle')
|
|
161
|
+
|
|
162
|
+
source.set(5)
|
|
163
|
+
yield* tick()
|
|
164
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
|
|
165
|
+
}).pipe(Effect.provide(TestLayer))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it.live('invalidateBy: an input change outside the key does not re-fetch', () => {
|
|
169
|
+
class Status extends State.make('status', S.String) {}
|
|
170
|
+
class Page extends State.make('page', S.Number) {}
|
|
171
|
+
class Inputs extends StateGroup.make(Status, Page) {}
|
|
172
|
+
const runs = { n: 0 }
|
|
173
|
+
class Q extends AsyncCalc.make('Q', {
|
|
174
|
+
inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
|
|
175
|
+
output: S.Number,
|
|
176
|
+
alwaysOn: true,
|
|
177
|
+
}) {}
|
|
178
|
+
const QLive = AsyncCalc.live(Q, {
|
|
179
|
+
query: ({ page }) =>
|
|
180
|
+
Effect.sync(() => {
|
|
181
|
+
runs.n += 1
|
|
182
|
+
return page
|
|
183
|
+
}),
|
|
184
|
+
// Only `page` is part of the key; `status` churn must not re-fetch.
|
|
185
|
+
invalidateBy: ({ page }) => [page],
|
|
186
|
+
})
|
|
187
|
+
const TestLayer = QLive.pipe(
|
|
188
|
+
Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return Effect.gen(function* () {
|
|
192
|
+
const status = yield* StateGroup.select(Inputs, 'status').store
|
|
193
|
+
const page = yield* StateGroup.select(Inputs, 'page').store
|
|
194
|
+
const store = yield* Q.store
|
|
195
|
+
yield* tick()
|
|
196
|
+
expect(runs.n).toBe(1)
|
|
197
|
+
|
|
198
|
+
// status moves, key unchanged -> no re-fetch.
|
|
199
|
+
status.set('b')
|
|
200
|
+
yield* tick()
|
|
201
|
+
expect(runs.n).toBe(1)
|
|
202
|
+
|
|
203
|
+
// page moves, key changes -> one re-fetch.
|
|
204
|
+
page.set(2)
|
|
205
|
+
yield* tick()
|
|
206
|
+
expect(runs.n).toBe(2)
|
|
207
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
|
|
208
|
+
}).pipe(Effect.provide(TestLayer))
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it.live('reuse: a refetch returning mostly-equal data keeps unchanged element identities', () => {
|
|
212
|
+
class Page extends State.make('page', S.Number) {}
|
|
213
|
+
class Inputs extends StateGroup.make(Page) {}
|
|
214
|
+
const Row = S.Struct({ id: S.Number, name: S.String })
|
|
215
|
+
class Rows extends AsyncCalc.make('Rows', {
|
|
216
|
+
inputs: [StateGroup.select(Inputs, 'page')],
|
|
217
|
+
output: S.Array(Row),
|
|
218
|
+
alwaysOn: true,
|
|
219
|
+
}) {}
|
|
220
|
+
const RowsLive = AsyncCalc.live(Rows, {
|
|
221
|
+
// Fresh objects every run — only the row matching `page` differs.
|
|
222
|
+
query: ({ page }) =>
|
|
223
|
+
Effect.succeed([
|
|
224
|
+
{ id: 1, name: 'one' },
|
|
225
|
+
{ id: 2, name: page > 1 ? `two@${page}` : 'two' },
|
|
226
|
+
]),
|
|
227
|
+
reuse: true,
|
|
228
|
+
})
|
|
229
|
+
const TestLayer = RowsLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { page: 1 })))
|
|
230
|
+
|
|
231
|
+
return Effect.gen(function* () {
|
|
232
|
+
const source = yield* StateGroup.select(Inputs, 'page').store
|
|
233
|
+
const store = yield* Rows.store
|
|
234
|
+
const first = yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
235
|
+
if (first._tag !== 'Success') return
|
|
236
|
+
|
|
237
|
+
source.set(2)
|
|
238
|
+
const second = yield* until(
|
|
239
|
+
() => store.get(),
|
|
240
|
+
(v) => v._tag === 'Success' && v.refetching === false && v.value !== first.value,
|
|
241
|
+
)
|
|
242
|
+
if (second._tag !== 'Success') return
|
|
243
|
+
// Row 1 didn't move: same identity. Row 2 did: fresh.
|
|
244
|
+
expect(second.value[0]).toBe(first.value[0])
|
|
245
|
+
expect(second.value[1]).not.toBe(first.value[1])
|
|
246
|
+
expect(second.value[1]).toEqual({ id: 2, name: 'two@2' })
|
|
247
|
+
}).pipe(Effect.provide(TestLayer))
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it.live('coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch', () => {
|
|
251
|
+
class Count extends State.make('count', S.Number) {}
|
|
252
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
253
|
+
// Completion log: the sync body runs AFTER the delay, so the order proves
|
|
254
|
+
// which runs actually finished (and that the in-flight one was not cancelled).
|
|
255
|
+
const completed: number[] = []
|
|
256
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
257
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
258
|
+
output: S.Number,
|
|
259
|
+
alwaysOn: true,
|
|
260
|
+
}) {}
|
|
261
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
262
|
+
query: ({ count }) =>
|
|
263
|
+
Effect.sync(() => {
|
|
264
|
+
completed.push(count)
|
|
265
|
+
return count * 2
|
|
266
|
+
}).pipe(Effect.delay(Duration.millis(25))),
|
|
267
|
+
coalesce: 'trailing',
|
|
268
|
+
})
|
|
269
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
270
|
+
|
|
271
|
+
return Effect.gen(function* () {
|
|
272
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
273
|
+
const store = yield* Doubled.store
|
|
274
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
275
|
+
expect(completed).toEqual([1])
|
|
276
|
+
|
|
277
|
+
// Three key changes during the first change's flight: the in-flight run (2)
|
|
278
|
+
// completes and lands, the middle change (3) is conflated away, and exactly
|
|
279
|
+
// one trailing run fetches the latest key (4).
|
|
280
|
+
source.set(2)
|
|
281
|
+
yield* tick(5)
|
|
282
|
+
source.set(3)
|
|
283
|
+
yield* tick(5)
|
|
284
|
+
source.set(4)
|
|
285
|
+
const settled = yield* until(
|
|
286
|
+
() => store.get(),
|
|
287
|
+
(v) => v._tag === 'Success' && v.refetching === false && v.value === 8,
|
|
288
|
+
)
|
|
289
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 8, refetching: false })
|
|
290
|
+
expect(completed).toEqual([1, 2, 4])
|
|
291
|
+
}).pipe(Effect.provide(TestLayer))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it.live('coalesce trailing: the stale settle re-flags refetching until the trailing run lands', () => {
|
|
295
|
+
class Count extends State.make('count', S.Number) {}
|
|
296
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
297
|
+
class Doubled extends AsyncCalc.make('Doubled', {
|
|
298
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
299
|
+
output: S.Number,
|
|
300
|
+
alwaysOn: true,
|
|
301
|
+
}) {}
|
|
302
|
+
const DoubledLive = AsyncCalc.live(Doubled, {
|
|
303
|
+
query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(30))),
|
|
304
|
+
coalesce: 'trailing',
|
|
305
|
+
})
|
|
306
|
+
const TestLayer = DoubledLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
307
|
+
|
|
308
|
+
return Effect.gen(function* () {
|
|
309
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
310
|
+
const store = yield* Doubled.store
|
|
311
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
312
|
+
|
|
313
|
+
// Two changes: when the in-flight result (4) lands, a trailing run is still
|
|
314
|
+
// pending — the value must read as syncing, not settled.
|
|
315
|
+
source.set(2)
|
|
316
|
+
yield* tick(5)
|
|
317
|
+
source.set(3)
|
|
318
|
+
const midflight = yield* until(
|
|
319
|
+
() => store.get(),
|
|
320
|
+
(v) => v._tag === 'Success' && v.value === 4,
|
|
321
|
+
)
|
|
322
|
+
expect(midflight).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
|
|
323
|
+
|
|
324
|
+
const settled = yield* until(
|
|
325
|
+
() => store.get(),
|
|
326
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
327
|
+
)
|
|
328
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 6 })
|
|
329
|
+
}).pipe(Effect.provide(TestLayer))
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it.live('coalesce trailing: a disable while a request waits in the slot skips the run', () => {
|
|
333
|
+
class Count extends State.make('count', S.Number) {}
|
|
334
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
335
|
+
const completed: number[] = []
|
|
336
|
+
class Q extends AsyncCalc.make('Q', {
|
|
337
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
338
|
+
output: S.Number,
|
|
339
|
+
}) {}
|
|
340
|
+
const QLive = AsyncCalc.live(Q, {
|
|
341
|
+
query: ({ count }) =>
|
|
342
|
+
Effect.sync(() => {
|
|
343
|
+
completed.push(count)
|
|
344
|
+
return count * 2
|
|
345
|
+
}).pipe(Effect.delay(Duration.millis(25))),
|
|
346
|
+
disabled: ({ count }) => count < 0,
|
|
347
|
+
coalesce: 'trailing',
|
|
348
|
+
})
|
|
349
|
+
const TestLayer = QLive.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })))
|
|
350
|
+
|
|
351
|
+
return Effect.gen(function* () {
|
|
352
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
353
|
+
const store = yield* Q.store
|
|
354
|
+
yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
355
|
+
|
|
356
|
+
// 2 starts a flight, 3 waits in the slot, then the gate closes: the flight's
|
|
357
|
+
// stale result must not land (Idle), and the queued request must not run.
|
|
358
|
+
source.set(2)
|
|
359
|
+
yield* tick(5)
|
|
360
|
+
source.set(3)
|
|
361
|
+
yield* tick(2)
|
|
362
|
+
source.set(-1)
|
|
363
|
+
yield* tick(60)
|
|
364
|
+
expect(store.get()._tag).toBe('Idle')
|
|
365
|
+
expect(completed).toEqual([1, 2])
|
|
366
|
+
|
|
367
|
+
// Re-enabling re-runs (the driver forgot its key when switched off).
|
|
368
|
+
source.set(5)
|
|
369
|
+
const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
|
|
370
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
|
|
371
|
+
expect(completed).toEqual([1, 2, 5])
|
|
372
|
+
}).pipe(Effect.provide(TestLayer))
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// `invalidateOn` is sugar for a hidden revision state + reducer appended to the
|
|
376
|
+
// key — these proofs need the Engine (the Reducers registry + bus + drain loop).
|
|
377
|
+
|
|
378
|
+
it.live('invalidateOn: dispatching a listed event re-fetches with refetching: true', () => {
|
|
379
|
+
class Count extends State.make('count', S.Number) {}
|
|
380
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
381
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
382
|
+
const runs = { n: 0 }
|
|
383
|
+
class Q extends AsyncCalc.make('Q', {
|
|
384
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
385
|
+
output: S.Number,
|
|
386
|
+
alwaysOn: true,
|
|
387
|
+
}) {}
|
|
388
|
+
const QLive = AsyncCalc.live(Q, {
|
|
389
|
+
query: ({ count }) =>
|
|
390
|
+
Effect.sync(() => {
|
|
391
|
+
runs.n += 1
|
|
392
|
+
return count * 2
|
|
393
|
+
}).pipe(Effect.delay(Duration.millis(15))),
|
|
394
|
+
invalidateOn: [Poke],
|
|
395
|
+
})
|
|
396
|
+
const TestLayer = QLive.pipe(
|
|
397
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 2 })),
|
|
398
|
+
Layer.provideMerge(Engine),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return Effect.gen(function* () {
|
|
402
|
+
const store = yield* Q.store
|
|
403
|
+
yield* tick(30)
|
|
404
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
|
|
405
|
+
expect(runs.n).toBe(1)
|
|
406
|
+
|
|
407
|
+
// The fact lands: the hidden revision bumps, the key moves, the value is
|
|
408
|
+
// kept while the re-fetch is in flight (SWR), then settles.
|
|
409
|
+
yield* Event.dispatch(Poke, {})
|
|
410
|
+
yield* tick(5)
|
|
411
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
|
|
412
|
+
yield* tick(30)
|
|
413
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
|
|
414
|
+
expect(runs.n).toBe(2)
|
|
415
|
+
}).pipe(Effect.provide(TestLayer))
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it.live('invalidateOn: composes with invalidateBy — non-key churn stays inert, the event re-fetches', () => {
|
|
419
|
+
class Status extends State.make('status', S.String) {}
|
|
420
|
+
class Page extends State.make('page', S.Number) {}
|
|
421
|
+
class Inputs extends StateGroup.make(Status, Page) {}
|
|
422
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
423
|
+
const runs = { n: 0 }
|
|
424
|
+
class Q extends AsyncCalc.make('Q', {
|
|
425
|
+
inputs: [StateGroup.select(Inputs, 'status'), StateGroup.select(Inputs, 'page')],
|
|
426
|
+
output: S.Number,
|
|
427
|
+
alwaysOn: true,
|
|
428
|
+
}) {}
|
|
429
|
+
const QLive = AsyncCalc.live(Q, {
|
|
430
|
+
query: ({ page }) =>
|
|
431
|
+
Effect.sync(() => {
|
|
432
|
+
runs.n += 1
|
|
433
|
+
return page
|
|
434
|
+
}),
|
|
435
|
+
invalidateBy: ({ page }) => [page],
|
|
436
|
+
invalidateOn: [Poke],
|
|
437
|
+
})
|
|
438
|
+
const TestLayer = QLive.pipe(
|
|
439
|
+
Layer.provideMerge(StateGroup.live(Inputs, { status: 'a', page: 1 })),
|
|
440
|
+
Layer.provideMerge(Engine),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return Effect.gen(function* () {
|
|
444
|
+
const status = yield* StateGroup.select(Inputs, 'status').store
|
|
445
|
+
yield* tick()
|
|
446
|
+
expect(runs.n).toBe(1)
|
|
447
|
+
|
|
448
|
+
// Outside the user key: still no re-fetch (the hidden revision rides along
|
|
449
|
+
// WITHOUT widening the key to every input).
|
|
450
|
+
status.set('b')
|
|
451
|
+
yield* tick()
|
|
452
|
+
expect(runs.n).toBe(1)
|
|
453
|
+
|
|
454
|
+
// The listed event always re-fetches.
|
|
455
|
+
yield* Event.dispatch(Poke, {})
|
|
456
|
+
yield* tick()
|
|
457
|
+
expect(runs.n).toBe(2)
|
|
458
|
+
}).pipe(Effect.provide(TestLayer))
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it.live('invalidateOn: a listed event while disabled stays Idle, and enabling re-fetches once', () => {
|
|
462
|
+
class Count extends State.make('count', S.Number) {}
|
|
463
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
464
|
+
class Poke extends Event.make('Poke', S.Struct({})) {}
|
|
465
|
+
const runs = { n: 0 }
|
|
466
|
+
class Q extends AsyncCalc.make('Q', {
|
|
467
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
468
|
+
output: S.Number,
|
|
469
|
+
}) {}
|
|
470
|
+
const QLive = AsyncCalc.live(Q, {
|
|
471
|
+
query: ({ count }) =>
|
|
472
|
+
Effect.sync(() => {
|
|
473
|
+
runs.n += 1
|
|
474
|
+
return count * 2
|
|
475
|
+
}),
|
|
476
|
+
disabled: ({ count }) => count < 0,
|
|
477
|
+
invalidateOn: [Poke],
|
|
478
|
+
})
|
|
479
|
+
const TestLayer = QLive.pipe(
|
|
480
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: -1 })),
|
|
481
|
+
Layer.provideMerge(Engine),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return Effect.gen(function* () {
|
|
485
|
+
const source = yield* StateGroup.select(Inputs, 'count').store
|
|
486
|
+
const store = yield* Q.store
|
|
487
|
+
yield* tick()
|
|
488
|
+
expect(store.get()._tag).toBe('Idle')
|
|
489
|
+
|
|
490
|
+
// A bump while gated off runs nothing — the driver shows Idle and forgets
|
|
491
|
+
// its key, so the eventual enable always re-runs.
|
|
492
|
+
yield* Event.dispatch(Poke, {})
|
|
493
|
+
yield* tick()
|
|
494
|
+
expect(store.get()._tag).toBe('Idle')
|
|
495
|
+
expect(runs.n).toBe(0)
|
|
496
|
+
|
|
497
|
+
source.set(5)
|
|
498
|
+
yield* tick()
|
|
499
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 10 })
|
|
500
|
+
expect(runs.n).toBe(1)
|
|
501
|
+
}).pipe(Effect.provide(TestLayer))
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it.live('invalidateOn: two async calcs keep independent hidden revisions', () => {
|
|
505
|
+
class Count extends State.make('count', S.Number) {}
|
|
506
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
507
|
+
class PokeA extends Event.make('PokeA', S.Struct({})) {}
|
|
508
|
+
class PokeB extends Event.make('PokeB', S.Struct({})) {}
|
|
509
|
+
const runs = { a: 0, b: 0 }
|
|
510
|
+
class QA extends AsyncCalc.make('QA', {
|
|
511
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
512
|
+
output: S.Number,
|
|
513
|
+
alwaysOn: true,
|
|
514
|
+
}) {}
|
|
515
|
+
class QB extends AsyncCalc.make('QB', {
|
|
516
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
517
|
+
output: S.Number,
|
|
518
|
+
alwaysOn: true,
|
|
519
|
+
}) {}
|
|
520
|
+
const TestLayer = Layer.mergeAll(
|
|
521
|
+
AsyncCalc.live(QA, {
|
|
522
|
+
query: ({ count }) =>
|
|
523
|
+
Effect.sync(() => {
|
|
524
|
+
runs.a += 1
|
|
525
|
+
return count
|
|
526
|
+
}),
|
|
527
|
+
invalidateOn: [PokeA],
|
|
528
|
+
}),
|
|
529
|
+
AsyncCalc.live(QB, {
|
|
530
|
+
query: ({ count }) =>
|
|
531
|
+
Effect.sync(() => {
|
|
532
|
+
runs.b += 1
|
|
533
|
+
return count
|
|
534
|
+
}),
|
|
535
|
+
invalidateOn: [PokeB],
|
|
536
|
+
}),
|
|
537
|
+
).pipe(
|
|
538
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
539
|
+
Layer.provideMerge(Engine),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
return Effect.gen(function* () {
|
|
543
|
+
yield* tick()
|
|
544
|
+
expect(runs).toEqual({ a: 1, b: 1 })
|
|
545
|
+
|
|
546
|
+
// Each calc's hidden revision is namespaced by its own name: A's fact moves
|
|
547
|
+
// only A.
|
|
548
|
+
yield* Event.dispatch(PokeA, {})
|
|
549
|
+
yield* tick()
|
|
550
|
+
expect(runs).toEqual({ a: 2, b: 1 })
|
|
551
|
+
|
|
552
|
+
yield* Event.dispatch(PokeB, {})
|
|
553
|
+
yield* tick()
|
|
554
|
+
expect(runs).toEqual({ a: 2, b: 2 })
|
|
555
|
+
}).pipe(Effect.provide(TestLayer))
|
|
556
|
+
})
|
package/src/calc/asyncData.ts
CHANGED
|
@@ -60,12 +60,20 @@ const error = <E>(err: E, refetching = false): AsyncError<E> => ({
|
|
|
60
60
|
refetching,
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
/** The arm-constructor namespace exposed as `AsyncData`. */
|
|
64
|
+
export interface AsyncDataConstructors {
|
|
65
|
+
readonly idle: AsyncIdle
|
|
66
|
+
readonly loading: AsyncLoading
|
|
67
|
+
readonly success: <A>(value: A, refetching?: boolean) => AsyncSuccess<A>
|
|
68
|
+
readonly error: <E>(err: E, refetching?: boolean) => AsyncError<E>
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
/**
|
|
64
72
|
* Constructors for the arms, namespaced under the same name as the type so call
|
|
65
73
|
* sites read `AsyncData.success(v)` / `AsyncData.error(e)` — no `error` import
|
|
66
74
|
* shadowing the keyword and one obvious home for every arm.
|
|
67
75
|
*/
|
|
68
|
-
export const AsyncData = { idle, loading, success, error }
|
|
76
|
+
export const AsyncData: AsyncDataConstructors = { idle, loading, success, error }
|
|
69
77
|
|
|
70
78
|
/**
|
|
71
79
|
* Narrow the live store (which holds the full `AnyAsyncData` union) to the arms
|