@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,8 +1,30 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import {
|
|
3
|
-
import { AsyncCalc, Engine,
|
|
2
|
+
import { Effect, Layer, ManagedRuntime, MutableRef, Option, Schema as S } from 'effect'
|
|
3
|
+
import { AsyncCalc, Engine, forceSync, RemoteState, State, StateGroup } from '../index'
|
|
4
|
+
import { QueryStore } from '../internal/queryStore'
|
|
5
|
+
import { Queries } from '../runtime/queries'
|
|
6
|
+
import { makeFlightGate, until } from '../testkit/flight.testkit'
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
// Yielding through a runtime resumes at the tail of its scheduler queue, so work
|
|
9
|
+
// already scheduled there — a hydration continuation, a consumer wake — has run.
|
|
10
|
+
const scheduled: Effect.Effect<void> = Effect.yieldNow()
|
|
11
|
+
|
|
12
|
+
interface Notifier {
|
|
13
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// One write notifies every listener in a single scheduler drain, and the driver
|
|
17
|
+
// subscribed before the test did: our own notification proves its trigger ran.
|
|
18
|
+
const afterNotify = (source: Notifier, write: () => void): Effect.Effect<void> =>
|
|
19
|
+
Effect.suspend(() => {
|
|
20
|
+
const fired = MutableRef.make(false)
|
|
21
|
+
const off = source.subscribe(() => MutableRef.set(fired, true))
|
|
22
|
+
write()
|
|
23
|
+
return until(
|
|
24
|
+
() => MutableRef.get(fired),
|
|
25
|
+
(value) => value,
|
|
26
|
+
).pipe(Effect.ensuring(Effect.sync(off)), Effect.asVoid)
|
|
27
|
+
})
|
|
6
28
|
|
|
7
29
|
const isStale = (name: string) =>
|
|
8
30
|
Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
|
|
@@ -17,6 +39,37 @@ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
|
17
39
|
return { map, layer }
|
|
18
40
|
}
|
|
19
41
|
|
|
42
|
+
const controlled = <A>() => {
|
|
43
|
+
const resolver = MutableRef.make<(value: A) => void>(() => {})
|
|
44
|
+
const promise = new Promise<A>((resume) => {
|
|
45
|
+
MutableRef.set(resolver, resume)
|
|
46
|
+
})
|
|
47
|
+
return { promise, resolve: (value: A) => MutableRef.get(resolver)(value) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const promiseQueryStore = (get: (key: string) => Promise<Option.Option<unknown>>) => {
|
|
51
|
+
const map = new Map<string, unknown>()
|
|
52
|
+
const layer = Layer.succeed(QueryStore, {
|
|
53
|
+
get: (key) => Effect.promise(() => get(key)),
|
|
54
|
+
set: (key, value) => Effect.sync(() => void map.set(key, value)),
|
|
55
|
+
remove: (key) => Effect.sync(() => void map.delete(key)),
|
|
56
|
+
})
|
|
57
|
+
return { map, layer }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rearmOnEveryChange = (subscribe: (listener: () => void) => () => void): (() => void) => {
|
|
61
|
+
const current = MutableRef.make<() => void>(() => {})
|
|
62
|
+
const arm = () => {
|
|
63
|
+
const unsubscribe = subscribe(() => {
|
|
64
|
+
MutableRef.get(current)()
|
|
65
|
+
arm()
|
|
66
|
+
})
|
|
67
|
+
MutableRef.set(current, unsubscribe)
|
|
68
|
+
}
|
|
69
|
+
arm()
|
|
70
|
+
return () => MutableRef.get(current)()
|
|
71
|
+
}
|
|
72
|
+
|
|
20
73
|
it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
|
|
21
74
|
class Count extends State.make('count', S.Number) {}
|
|
22
75
|
class Inputs extends StateGroup.make(Count) {}
|
|
@@ -40,16 +93,24 @@ it.live('invalidate marks stale without fetching; a fresh subscriber then refetc
|
|
|
40
93
|
|
|
41
94
|
return Effect.gen(function* () {
|
|
42
95
|
const store = yield* Q.store
|
|
43
|
-
yield*
|
|
96
|
+
yield* until(
|
|
97
|
+
() => store.get(),
|
|
98
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
99
|
+
)
|
|
44
100
|
expect(runs.n).toBe(1)
|
|
45
101
|
|
|
102
|
+
// A refetch would mark the state fetching inside `invalidate` itself, so the
|
|
103
|
+
// reader-less mark is observable without waiting for anything.
|
|
46
104
|
yield* AsyncCalc.invalidate(Q)
|
|
47
|
-
yield* tick()
|
|
48
105
|
expect(yield* isStale('Q')).toBe(true)
|
|
106
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', refetching: false })
|
|
49
107
|
expect(runs.n).toBe(1)
|
|
50
108
|
|
|
51
109
|
store.subscribe(() => {})
|
|
52
|
-
yield*
|
|
110
|
+
yield* until(
|
|
111
|
+
() => store.get(),
|
|
112
|
+
(v) => v._tag === 'Success' && v.refetching === false && runs.n > 1,
|
|
113
|
+
)
|
|
53
114
|
expect(runs.n).toBe(2)
|
|
54
115
|
expect(yield* isStale('Q')).toBe(false)
|
|
55
116
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -64,12 +125,15 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
|
|
|
64
125
|
output: S.Number,
|
|
65
126
|
alwaysOn: true,
|
|
66
127
|
}) {}
|
|
128
|
+
const gate = makeFlightGate()
|
|
67
129
|
const QLive = AsyncCalc.live(Q, {
|
|
68
130
|
query: ({ count }) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
131
|
+
gate.through(
|
|
132
|
+
Effect.sync(() => {
|
|
133
|
+
runs.n += 1
|
|
134
|
+
return count
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
73
137
|
})
|
|
74
138
|
const TestLayer = QLive.pipe(
|
|
75
139
|
Layer.provideMerge(StateGroup.live(Inputs, { count: 7 })),
|
|
@@ -79,15 +143,26 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
|
|
|
79
143
|
return Effect.gen(function* () {
|
|
80
144
|
const store = yield* Q.store
|
|
81
145
|
store.subscribe(() => {})
|
|
82
|
-
yield*
|
|
146
|
+
yield* until(
|
|
147
|
+
() => store.get(),
|
|
148
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
149
|
+
)
|
|
83
150
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
84
151
|
expect(runs.n).toBe(1)
|
|
85
152
|
|
|
153
|
+
// The auto-refetch is parked at the gate, so "kept the value while
|
|
154
|
+
// refetching" is a stable state rather than a window to catch.
|
|
155
|
+
yield* gate.hold
|
|
86
156
|
yield* AsyncCalc.invalidate(Q)
|
|
87
|
-
yield*
|
|
157
|
+
yield* gate.awaitEntry(2)
|
|
88
158
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
|
|
89
|
-
|
|
90
|
-
|
|
159
|
+
|
|
160
|
+
yield* gate.release
|
|
161
|
+
const settled = yield* until(
|
|
162
|
+
() => store.get(),
|
|
163
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
164
|
+
)
|
|
165
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
91
166
|
expect(runs.n).toBe(2)
|
|
92
167
|
}).pipe(Effect.provide(TestLayer))
|
|
93
168
|
})
|
|
@@ -114,41 +189,667 @@ it.live('refetch forces a run regardless of staleness', () => {
|
|
|
114
189
|
)
|
|
115
190
|
|
|
116
191
|
return Effect.gen(function* () {
|
|
117
|
-
yield*
|
|
192
|
+
const store = yield* Q.store
|
|
193
|
+
yield* until(
|
|
194
|
+
() => store.get(),
|
|
195
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
196
|
+
)
|
|
118
197
|
expect(runs.n).toBe(1)
|
|
119
198
|
expect(yield* isStale('Q')).toBe(false)
|
|
120
199
|
|
|
121
200
|
yield* AsyncCalc.refetch(Q)
|
|
122
|
-
yield*
|
|
201
|
+
yield* until(
|
|
202
|
+
() => runs.n,
|
|
203
|
+
(n) => n > 1,
|
|
204
|
+
)
|
|
123
205
|
expect(runs.n).toBe(2)
|
|
124
206
|
}).pipe(Effect.provide(TestLayer))
|
|
125
207
|
})
|
|
126
208
|
|
|
127
|
-
it.live(
|
|
128
|
-
|
|
209
|
+
it.live(
|
|
210
|
+
'persist: hydrates from the QueryStore (stale) then writes the settled value through',
|
|
211
|
+
() => {
|
|
212
|
+
class Count extends State.make('count', S.Number) {}
|
|
213
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
214
|
+
const store = fakeQueryStore({ P: 99 })
|
|
215
|
+
class Q extends AsyncCalc.make('Q', {
|
|
216
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
217
|
+
output: S.Number,
|
|
218
|
+
alwaysOn: true,
|
|
219
|
+
}) {}
|
|
220
|
+
const gate = makeFlightGate()
|
|
221
|
+
// Armed before the layer builds: the revalidation parks, so the hydrated
|
|
222
|
+
// (stale) paint is a stable state rather than a window to catch.
|
|
223
|
+
Effect.runSync(gate.hold)
|
|
224
|
+
const QLive = AsyncCalc.live(Q, {
|
|
225
|
+
query: ({ count }) => gate.through(Effect.succeed(count * 2)),
|
|
226
|
+
persist: { key: 'P' },
|
|
227
|
+
})
|
|
228
|
+
const TestLayer = QLive.pipe(
|
|
229
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
|
|
230
|
+
Layer.provideMerge(store.layer),
|
|
231
|
+
Layer.provideMerge(Engine),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return Effect.gen(function* () {
|
|
235
|
+
const view = yield* Q.store
|
|
236
|
+
yield* gate.awaitEntry(1)
|
|
237
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
238
|
+
|
|
239
|
+
yield* gate.release
|
|
240
|
+
const settled = yield* until(
|
|
241
|
+
() => view.get(),
|
|
242
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
243
|
+
)
|
|
244
|
+
expect(settled).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
245
|
+
yield* until(
|
|
246
|
+
() => store.map.get('P'),
|
|
247
|
+
(persisted) => persisted === 10,
|
|
248
|
+
)
|
|
249
|
+
expect(store.map.get('P')).toBe(10)
|
|
250
|
+
}).pipe(Effect.provide(TestLayer))
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
it('persist: a re-armed watcher does not duplicate the generation covering hydration', async () => {
|
|
255
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
256
|
+
const fresh = controlled<number>()
|
|
257
|
+
const persisted = promiseQueryStore(() => cached.promise)
|
|
258
|
+
const runs = { n: 0 }
|
|
259
|
+
class Q extends AsyncCalc.make('CoveredHydrationQ', {
|
|
260
|
+
inputs: [],
|
|
261
|
+
output: S.Number,
|
|
262
|
+
alwaysOn: true,
|
|
263
|
+
}) {}
|
|
264
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
265
|
+
query: () =>
|
|
266
|
+
Effect.sync(() => {
|
|
267
|
+
runs.n += 1
|
|
268
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
269
|
+
coalesce: 'trailing',
|
|
270
|
+
persist: true,
|
|
271
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
272
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
273
|
+
|
|
274
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
275
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
276
|
+
await runtime.runPromise(
|
|
277
|
+
until(
|
|
278
|
+
() => runs.n,
|
|
279
|
+
(n) => n > 0,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
expect(runs.n).toBe(1)
|
|
283
|
+
|
|
284
|
+
cached.resolve(Option.some(99))
|
|
285
|
+
await runtime.runPromise(
|
|
286
|
+
until(
|
|
287
|
+
() => view.get(),
|
|
288
|
+
(v) => v._tag === 'Success',
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
292
|
+
|
|
293
|
+
// A duplicated generation would leave a trailing request pending, so settling
|
|
294
|
+
// out of `refetching` is what proves the run was covered exactly once.
|
|
295
|
+
fresh.resolve(10)
|
|
296
|
+
await runtime.runPromise(
|
|
297
|
+
until(
|
|
298
|
+
() => view.get(),
|
|
299
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
expect(runs.n).toBe(1)
|
|
303
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
304
|
+
stop()
|
|
305
|
+
await runtime.dispose()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('persist: an explicit refetch still trails the generation covering hydration', async () => {
|
|
309
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
310
|
+
const fresh = controlled<number>()
|
|
311
|
+
const persisted = promiseQueryStore(() => cached.promise)
|
|
312
|
+
const runs = { n: 0 }
|
|
313
|
+
class CoveredHydrationRefetchQ extends AsyncCalc.make('CoveredHydrationRefetchQ', {
|
|
314
|
+
inputs: [],
|
|
315
|
+
output: S.Number,
|
|
316
|
+
alwaysOn: true,
|
|
317
|
+
}) {}
|
|
318
|
+
const TestLayer = AsyncCalc.live(CoveredHydrationRefetchQ, {
|
|
319
|
+
query: () =>
|
|
320
|
+
Effect.sync(() => {
|
|
321
|
+
runs.n += 1
|
|
322
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
323
|
+
coalesce: 'trailing',
|
|
324
|
+
persist: true,
|
|
325
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
326
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
327
|
+
|
|
328
|
+
const view = forceSync(() => runtime.runSync(CoveredHydrationRefetchQ.store))
|
|
329
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
330
|
+
await runtime.runPromise(
|
|
331
|
+
until(
|
|
332
|
+
() => runs.n,
|
|
333
|
+
(n) => n > 0,
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
cached.resolve(Option.some(99))
|
|
337
|
+
await runtime.runPromise(
|
|
338
|
+
until(
|
|
339
|
+
() => view.get(),
|
|
340
|
+
(v) => v._tag === 'Success',
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
await runtime.runPromise(AsyncCalc.refetch(CoveredHydrationRefetchQ))
|
|
345
|
+
fresh.resolve(10)
|
|
346
|
+
await runtime.runPromise(
|
|
347
|
+
until(
|
|
348
|
+
() => view.get(),
|
|
349
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
expect(runs.n).toBe(2)
|
|
353
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
354
|
+
stop()
|
|
355
|
+
await runtime.dispose()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('invalidate after a requested generation stays uncovered when hydration lands', async () => {
|
|
359
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
360
|
+
const fresh = controlled<number>()
|
|
361
|
+
const persisted = promiseQueryStore(() => cached.promise)
|
|
362
|
+
const runs = { n: 0 }
|
|
363
|
+
class InFlightInvalidateQ extends AsyncCalc.make('InFlightInvalidateQ', {
|
|
364
|
+
inputs: [],
|
|
365
|
+
output: S.Number,
|
|
366
|
+
alwaysOn: true,
|
|
367
|
+
}) {}
|
|
368
|
+
const TestLayer = AsyncCalc.live(InFlightInvalidateQ, {
|
|
369
|
+
query: () =>
|
|
370
|
+
Effect.sync(() => {
|
|
371
|
+
runs.n += 1
|
|
372
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
373
|
+
coalesce: 'trailing',
|
|
374
|
+
persist: true,
|
|
375
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
376
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
377
|
+
|
|
378
|
+
const view = forceSync(() => runtime.runSync(InFlightInvalidateQ.store))
|
|
379
|
+
await runtime.runPromise(
|
|
380
|
+
until(
|
|
381
|
+
() => runs.n,
|
|
382
|
+
(n) => n > 0,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
expect(runs.n).toBe(1)
|
|
386
|
+
|
|
387
|
+
await runtime.runPromise(AsyncCalc.invalidate(InFlightInvalidateQ))
|
|
388
|
+
expect(await runtime.runPromise(isStale('InFlightInvalidateQ'))).toBe(true)
|
|
389
|
+
cached.resolve(Option.some(99))
|
|
390
|
+
await runtime.runPromise(
|
|
391
|
+
until(
|
|
392
|
+
() => view.get(),
|
|
393
|
+
(v) => v._tag === 'Success',
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
397
|
+
|
|
398
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
399
|
+
fresh.resolve(7)
|
|
400
|
+
await runtime.runPromise(
|
|
401
|
+
until(
|
|
402
|
+
() => view.get(),
|
|
403
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
expect(runs.n).toBe(2)
|
|
407
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
408
|
+
stop()
|
|
409
|
+
await runtime.dispose()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('a failed refetch leaves its stale mark uncovered, so a fresh subscriber retries', async () => {
|
|
413
|
+
const runs = { n: 0 }
|
|
414
|
+
class FailedCoverQ extends AsyncCalc.make('FailedCoverQ', {
|
|
415
|
+
inputs: [],
|
|
416
|
+
output: S.Number,
|
|
417
|
+
error: S.String,
|
|
418
|
+
alwaysOn: true,
|
|
419
|
+
}) {}
|
|
420
|
+
const runtime = ManagedRuntime.make(
|
|
421
|
+
AsyncCalc.live(FailedCoverQ, {
|
|
422
|
+
query: () =>
|
|
423
|
+
Effect.suspend((): Effect.Effect<number, string> => {
|
|
424
|
+
runs.n += 1
|
|
425
|
+
return runs.n === 2 ? Effect.fail('boom') : Effect.succeed(runs.n)
|
|
426
|
+
}),
|
|
427
|
+
}).pipe(Layer.provideMerge(Engine)),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const view = forceSync(() => runtime.runSync(FailedCoverQ.store))
|
|
431
|
+
await runtime.runPromise(
|
|
432
|
+
until(
|
|
433
|
+
() => view.get(),
|
|
434
|
+
(v) => v._tag === 'Success',
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
expect(runs.n).toBe(1)
|
|
438
|
+
|
|
439
|
+
// Invalidate with no readers, then attach one: that reader's refetch covers the
|
|
440
|
+
// stale mark, and it fails.
|
|
441
|
+
await runtime.runPromise(AsyncCalc.invalidate(FailedCoverQ))
|
|
442
|
+
const first = view.subscribe(() => {})
|
|
443
|
+
await runtime.runPromise(
|
|
444
|
+
until(
|
|
445
|
+
() => view.get(),
|
|
446
|
+
(v) => v._tag === 'Error',
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
expect(runs.n).toBe(2)
|
|
450
|
+
expect(view.get()).toMatchObject({ _tag: 'Error', error: 'boom' })
|
|
451
|
+
expect(await runtime.runPromise(isStale('FailedCoverQ'))).toBe(true)
|
|
452
|
+
|
|
453
|
+
// Nothing successful has covered the mark, so the next subscriber still retries.
|
|
454
|
+
first()
|
|
455
|
+
view.subscribe(() => {})
|
|
456
|
+
await runtime.runPromise(
|
|
457
|
+
until(
|
|
458
|
+
() => view.get(),
|
|
459
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
expect(runs.n).toBe(3)
|
|
463
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 3, refetching: false })
|
|
464
|
+
await runtime.dispose()
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('persist: hydration before any request still gets one initial revalidation', async () => {
|
|
468
|
+
const fresh = controlled<number>()
|
|
469
|
+
const persisted = fakeQueryStore({ SyncHydrationQ: 99 })
|
|
470
|
+
const runs = { n: 0 }
|
|
471
|
+
class Q extends AsyncCalc.make('SyncHydrationQ', {
|
|
472
|
+
inputs: [],
|
|
473
|
+
output: S.Number,
|
|
474
|
+
alwaysOn: true,
|
|
475
|
+
}) {}
|
|
476
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
477
|
+
query: () =>
|
|
478
|
+
Effect.sync(() => {
|
|
479
|
+
runs.n += 1
|
|
480
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
481
|
+
coalesce: 'trailing',
|
|
482
|
+
persist: true,
|
|
483
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
484
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
485
|
+
|
|
486
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
487
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
488
|
+
await runtime.runPromise(
|
|
489
|
+
until(
|
|
490
|
+
() => runs.n,
|
|
491
|
+
(n) => n > 0,
|
|
492
|
+
),
|
|
493
|
+
)
|
|
494
|
+
expect(runs.n).toBe(1)
|
|
495
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
496
|
+
|
|
497
|
+
fresh.resolve(10)
|
|
498
|
+
await runtime.runPromise(
|
|
499
|
+
until(
|
|
500
|
+
() => view.get(),
|
|
501
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
expect(runs.n).toBe(1)
|
|
505
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
506
|
+
stop()
|
|
507
|
+
await runtime.dispose()
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('a genuine key change still enqueues a trailing generation', async () => {
|
|
511
|
+
const release = controlled<void>()
|
|
512
|
+
const runs = { n: 0 }
|
|
513
|
+
class Count extends State.make('key-change-count', S.Number) {}
|
|
129
514
|
class Inputs extends StateGroup.make(Count) {}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
inputs: [StateGroup.select(Inputs, 'count')],
|
|
515
|
+
class Q extends AsyncCalc.make('InFlightKeyChangeQ', {
|
|
516
|
+
inputs: [StateGroup.select(Inputs, 'key-change-count')],
|
|
133
517
|
output: S.Number,
|
|
134
518
|
alwaysOn: true,
|
|
135
519
|
}) {}
|
|
136
|
-
const
|
|
137
|
-
query: ({ count }) =>
|
|
520
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
521
|
+
query: ({ 'key-change-count': count }) =>
|
|
522
|
+
Effect.sync(() => {
|
|
523
|
+
runs.n += 1
|
|
524
|
+
}).pipe(Effect.zipRight(Effect.promise(() => release.promise)), Effect.as(count)),
|
|
525
|
+
coalesce: 'trailing',
|
|
526
|
+
}).pipe(
|
|
527
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'key-change-count': 1 })),
|
|
528
|
+
Layer.provideMerge(Engine),
|
|
529
|
+
)
|
|
530
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
531
|
+
|
|
532
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
533
|
+
const count = runtime.runSync(Count.store)
|
|
534
|
+
await runtime.runPromise(
|
|
535
|
+
until(
|
|
536
|
+
() => runs.n,
|
|
537
|
+
(n) => n > 0,
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
expect(runs.n).toBe(1)
|
|
541
|
+
|
|
542
|
+
// The change is enqueued, not run: the first generation still owns the slot.
|
|
543
|
+
await runtime.runPromise(afterNotify(count, () => count.set(2)))
|
|
544
|
+
expect(runs.n).toBe(1)
|
|
545
|
+
release.resolve()
|
|
546
|
+
await runtime.runPromise(
|
|
547
|
+
until(
|
|
548
|
+
() => view.get(),
|
|
549
|
+
(v) => v._tag === 'Success' && v.value === 2 && v.refetching === false,
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
expect(runs.n).toBe(2)
|
|
553
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 2, refetching: false })
|
|
554
|
+
await runtime.dispose()
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('persist: promise-backed hydration keeps construction synchronous and preserves SWR ordering', async () => {
|
|
558
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
559
|
+
const fresh = controlled<number>()
|
|
560
|
+
const store = promiseQueryStore(() => cached.promise)
|
|
561
|
+
const runs = { n: 0 }
|
|
562
|
+
class Q extends AsyncCalc.make('AsyncPersistQ', {
|
|
563
|
+
inputs: [],
|
|
564
|
+
output: S.Number,
|
|
565
|
+
alwaysOn: true,
|
|
566
|
+
}) {}
|
|
567
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
568
|
+
query: () => {
|
|
569
|
+
runs.n += 1
|
|
570
|
+
return Effect.promise(() => fresh.promise)
|
|
571
|
+
},
|
|
138
572
|
persist: { key: 'P' },
|
|
573
|
+
}).pipe(Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
|
|
574
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
575
|
+
|
|
576
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
577
|
+
expect(view.get()).toMatchObject({ _tag: 'Loading' })
|
|
578
|
+
await runtime.runPromise(
|
|
579
|
+
until(
|
|
580
|
+
() => runs.n,
|
|
581
|
+
(n) => n > 0,
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
expect(runs.n).toBe(1)
|
|
585
|
+
|
|
586
|
+
cached.resolve(Option.some(99))
|
|
587
|
+
await runtime.runPromise(
|
|
588
|
+
until(
|
|
589
|
+
() => view.get(),
|
|
590
|
+
(v) => v._tag === 'Success',
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
594
|
+
|
|
595
|
+
fresh.resolve(10)
|
|
596
|
+
await runtime.runPromise(
|
|
597
|
+
until(
|
|
598
|
+
() => view.get(),
|
|
599
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
603
|
+
await runtime.runPromise(
|
|
604
|
+
until(
|
|
605
|
+
() => store.map.get('P'),
|
|
606
|
+
(persisted) => persisted === 10,
|
|
607
|
+
),
|
|
608
|
+
)
|
|
609
|
+
expect(store.map.get('P')).toBe(10)
|
|
610
|
+
await runtime.dispose()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('persist: late or corrupt hydration cannot overwrite fresh truth or a changed source key', async () => {
|
|
614
|
+
const cacheByKey = new Map<string, ReturnType<typeof controlled<Option.Option<unknown>>>>()
|
|
615
|
+
const store = promiseQueryStore((key) => {
|
|
616
|
+
const pending = controlled<Option.Option<unknown>>()
|
|
617
|
+
cacheByKey.set(key, pending)
|
|
618
|
+
return pending.promise
|
|
139
619
|
})
|
|
140
|
-
|
|
141
|
-
|
|
620
|
+
class Count extends State.make('count', S.Number) {}
|
|
621
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
622
|
+
class Q extends AsyncCalc.make('AsyncPersistKeyed', {
|
|
623
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
624
|
+
output: S.Number,
|
|
625
|
+
alwaysOn: true,
|
|
626
|
+
}) {}
|
|
627
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
628
|
+
query: ({ count }) => Effect.succeed(count * 10),
|
|
629
|
+
persist: { key: ({ count }) => `P:${count}` },
|
|
630
|
+
}).pipe(
|
|
631
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
142
632
|
Layer.provideMerge(store.layer),
|
|
143
633
|
Layer.provideMerge(Engine),
|
|
144
634
|
)
|
|
635
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
636
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
637
|
+
const count = runtime.runSync(Count.store)
|
|
145
638
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
639
|
+
count.set(2)
|
|
640
|
+
await runtime.runPromise(
|
|
641
|
+
until(
|
|
642
|
+
() => view.get(),
|
|
643
|
+
(v) => v._tag === 'Success' && v.value === 20 && v.refetching === false,
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 20, refetching: false })
|
|
647
|
+
// Awaiting the same promise, then a scheduler round, lands after every
|
|
648
|
+
// continuation the superseded hydration could still have been holding.
|
|
649
|
+
cacheByKey.get('P:1')?.resolve(Option.some(99))
|
|
650
|
+
await cacheByKey.get('P:1')?.promise
|
|
651
|
+
await runtime.runPromise(scheduled)
|
|
652
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 20, refetching: false })
|
|
149
653
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
654
|
+
await runtime.dispose()
|
|
655
|
+
|
|
656
|
+
const churnedCache = controlled<Option.Option<unknown>>()
|
|
657
|
+
const churnedStore = promiseQueryStore(() => churnedCache.promise)
|
|
658
|
+
const churnedRuntime = ManagedRuntime.make(
|
|
659
|
+
AsyncCalc.live(Q, {
|
|
660
|
+
query: () => Effect.never,
|
|
661
|
+
persist: { key: ({ count }) => `churned:${count}` },
|
|
662
|
+
}).pipe(
|
|
663
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
664
|
+
Layer.provideMerge(churnedStore.layer),
|
|
665
|
+
Layer.provideMerge(Engine),
|
|
666
|
+
),
|
|
667
|
+
)
|
|
668
|
+
const churnedView = forceSync(() => churnedRuntime.runSync(Q.store))
|
|
669
|
+
const churnedCount = churnedRuntime.runSync(Count.store)
|
|
670
|
+
await churnedRuntime.runPromise(afterNotify(churnedCount, () => churnedCount.set(2)))
|
|
671
|
+
await churnedRuntime.runPromise(afterNotify(churnedCount, () => churnedCount.set(1)))
|
|
672
|
+
churnedCache.resolve(Option.some(99))
|
|
673
|
+
await churnedCache.promise
|
|
674
|
+
await churnedRuntime.runPromise(scheduled)
|
|
675
|
+
expect(churnedView.get()).toMatchObject({ _tag: 'Loading' })
|
|
676
|
+
await churnedRuntime.dispose()
|
|
677
|
+
|
|
678
|
+
const erroredCache = controlled<Option.Option<unknown>>()
|
|
679
|
+
const erroredStore = promiseQueryStore(() => erroredCache.promise)
|
|
680
|
+
class ErroredQ extends AsyncCalc.make('AsyncPersistErrored', {
|
|
681
|
+
inputs: [],
|
|
682
|
+
output: S.Number,
|
|
683
|
+
error: S.String,
|
|
684
|
+
alwaysOn: true,
|
|
685
|
+
}) {}
|
|
686
|
+
const erroredRuntime = ManagedRuntime.make(
|
|
687
|
+
AsyncCalc.live(ErroredQ, {
|
|
688
|
+
query: () => Effect.fail('fresh-error'),
|
|
689
|
+
persist: true,
|
|
690
|
+
}).pipe(Layer.provideMerge(erroredStore.layer), Layer.provideMerge(Engine)),
|
|
691
|
+
)
|
|
692
|
+
const erroredView = forceSync(() => erroredRuntime.runSync(ErroredQ.store))
|
|
693
|
+
const queries = erroredRuntime.runSync(Queries)
|
|
694
|
+
await erroredRuntime.runPromise(
|
|
695
|
+
until(
|
|
696
|
+
() => erroredView.get(),
|
|
697
|
+
(v) => v._tag === 'Error',
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
expect(erroredView.get()).toMatchObject({ _tag: 'Error', error: 'fresh-error' })
|
|
701
|
+
erroredCache.resolve(Option.some(99))
|
|
702
|
+
await erroredCache.promise
|
|
703
|
+
await erroredRuntime.runPromise(scheduled)
|
|
704
|
+
expect(erroredView.get()).toMatchObject({ _tag: 'Error', error: 'fresh-error' })
|
|
705
|
+
const erroredHandle = queries.byName.get('AsyncPersistErrored')
|
|
706
|
+
expect(erroredHandle).toBeDefined()
|
|
707
|
+
if (erroredHandle !== undefined) {
|
|
708
|
+
expect(Option.isNone(erroredHandle.snapshot().data)).toBe(true)
|
|
709
|
+
}
|
|
710
|
+
await erroredRuntime.dispose()
|
|
711
|
+
|
|
712
|
+
const corrupt = controlled<Option.Option<unknown>>()
|
|
713
|
+
const pendingFresh = controlled<number>()
|
|
714
|
+
const corruptStore = promiseQueryStore(() => corrupt.promise)
|
|
715
|
+
const corruptRuntime = ManagedRuntime.make(
|
|
716
|
+
AsyncCalc.live(Q, {
|
|
717
|
+
query: () => Effect.promise(() => pendingFresh.promise),
|
|
718
|
+
persist: { key: 'corrupt' },
|
|
719
|
+
}).pipe(
|
|
720
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
721
|
+
Layer.provideMerge(corruptStore.layer),
|
|
722
|
+
Layer.provideMerge(Engine),
|
|
723
|
+
),
|
|
724
|
+
)
|
|
725
|
+
const corruptView = forceSync(() => corruptRuntime.runSync(Q.store))
|
|
726
|
+
corrupt.resolve(Option.some('not-a-number'))
|
|
727
|
+
await corrupt.promise
|
|
728
|
+
await corruptRuntime.runPromise(scheduled)
|
|
729
|
+
expect(corruptView.get()).toMatchObject({ _tag: 'Loading' })
|
|
730
|
+
pendingFresh.resolve(7)
|
|
731
|
+
await corruptRuntime.runPromise(
|
|
732
|
+
until(
|
|
733
|
+
() => corruptView.get(),
|
|
734
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
735
|
+
),
|
|
736
|
+
)
|
|
737
|
+
expect(corruptView.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
738
|
+
await corruptRuntime.dispose()
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('persist: finalization and gating prevent late or unnecessary hydration', async () => {
|
|
742
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
743
|
+
const store = promiseQueryStore(() => cached.promise)
|
|
744
|
+
class Q extends AsyncCalc.make('FinalizedPersistQ', {
|
|
745
|
+
inputs: [],
|
|
746
|
+
output: S.Number,
|
|
747
|
+
alwaysOn: true,
|
|
748
|
+
}) {}
|
|
749
|
+
const runtime = ManagedRuntime.make(
|
|
750
|
+
AsyncCalc.live(Q, {
|
|
751
|
+
query: () => Effect.never,
|
|
752
|
+
persist: true,
|
|
753
|
+
}).pipe(Layer.provideMerge(store.layer), Layer.provideMerge(Engine)),
|
|
754
|
+
)
|
|
755
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
756
|
+
await runtime.dispose()
|
|
757
|
+
|
|
758
|
+
// The scope is closed, so awaiting the cache promise is enough: any surviving
|
|
759
|
+
// hydration would resume on that same continuation.
|
|
760
|
+
cached.resolve(Option.some(99))
|
|
761
|
+
await cached.promise
|
|
762
|
+
await Effect.runPromise(scheduled)
|
|
763
|
+
expect(view.get()).toMatchObject({ _tag: 'Loading' })
|
|
764
|
+
expect(store.map.size).toBe(0)
|
|
765
|
+
|
|
766
|
+
const disabledCalls = { gets: 0, runs: 0 }
|
|
767
|
+
const disabledStore = promiseQueryStore(() => {
|
|
768
|
+
disabledCalls.gets += 1
|
|
769
|
+
return Promise.resolve(Option.some(1))
|
|
770
|
+
})
|
|
771
|
+
class Enabled extends State.make('disabled-persist-enabled', S.Boolean) {}
|
|
772
|
+
class DisabledInputs extends StateGroup.make(Enabled) {}
|
|
773
|
+
class DisabledQ extends AsyncCalc.make('DisabledPersistQ', {
|
|
774
|
+
inputs: [StateGroup.select(DisabledInputs, 'disabled-persist-enabled')],
|
|
775
|
+
output: S.Number,
|
|
776
|
+
}) {}
|
|
777
|
+
const disabledRuntime = ManagedRuntime.make(
|
|
778
|
+
AsyncCalc.live(DisabledQ, {
|
|
779
|
+
query: () => Effect.sync(() => (disabledCalls.runs += 1)),
|
|
780
|
+
disabled: ({ 'disabled-persist-enabled': enabled }) => !enabled,
|
|
781
|
+
persist: true,
|
|
782
|
+
}).pipe(
|
|
783
|
+
Layer.provideMerge(StateGroup.live(DisabledInputs, { 'disabled-persist-enabled': false })),
|
|
784
|
+
Layer.provideMerge(disabledStore.layer),
|
|
785
|
+
Layer.provideMerge(Engine),
|
|
786
|
+
),
|
|
787
|
+
)
|
|
788
|
+
const disabledView = forceSync(() => disabledRuntime.runSync(DisabledQ.store))
|
|
789
|
+
const enabled = disabledRuntime.runSync(Enabled.store)
|
|
790
|
+
expect(disabledView.get()).toMatchObject({ _tag: 'Idle' })
|
|
791
|
+
expect(disabledCalls).toEqual({ gets: 0, runs: 0 })
|
|
792
|
+
|
|
793
|
+
// Enabling is the anchor: once its run has landed, a hydration read during the
|
|
794
|
+
// disabled window would already have shown up in the counts.
|
|
795
|
+
enabled.set(true)
|
|
796
|
+
await disabledRuntime.runPromise(
|
|
797
|
+
until(
|
|
798
|
+
() => disabledView.get(),
|
|
799
|
+
(v) => v._tag === 'Success',
|
|
800
|
+
),
|
|
801
|
+
)
|
|
802
|
+
expect(disabledView.get()).toMatchObject({ _tag: 'Success', value: 1 })
|
|
803
|
+
expect(disabledCalls).toEqual({ gets: 0, runs: 1 })
|
|
804
|
+
await disabledRuntime.dispose()
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('persist: promise hydration is shared by RemoteState and participates in reuse', async () => {
|
|
808
|
+
const Item = S.Struct({ id: S.String, name: S.String })
|
|
809
|
+
type Item = typeof Item.Type
|
|
810
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
811
|
+
const fresh = controlled<ReadonlyArray<Item>>()
|
|
812
|
+
const store = promiseQueryStore(() => cached.promise)
|
|
813
|
+
class Items extends RemoteState.make('AsyncPersistRemoteItems', {
|
|
814
|
+
inputs: [],
|
|
815
|
+
output: S.Array(Item),
|
|
816
|
+
intents: [],
|
|
817
|
+
alwaysOn: true,
|
|
818
|
+
}) {}
|
|
819
|
+
const runtime = ManagedRuntime.make(
|
|
820
|
+
RemoteState.live(Items, {
|
|
821
|
+
query: () => Effect.promise(() => fresh.promise),
|
|
822
|
+
send: () => Effect.void,
|
|
823
|
+
apply: (items) => items,
|
|
824
|
+
persist: true,
|
|
825
|
+
reuse: true,
|
|
826
|
+
}).pipe(Layer.provideMerge(store.layer), Layer.provideMerge(Engine)),
|
|
827
|
+
)
|
|
828
|
+
const view = forceSync(() => runtime.runSync(Items.store))
|
|
829
|
+
const cachedItem = { id: 'a', name: 'alpha' }
|
|
830
|
+
cached.resolve(Option.some([cachedItem]))
|
|
831
|
+
await runtime.runPromise(
|
|
832
|
+
until(
|
|
833
|
+
() => view.get(),
|
|
834
|
+
(v) => v._tag === 'Success',
|
|
835
|
+
),
|
|
836
|
+
)
|
|
837
|
+
const hydrated = view.get()
|
|
838
|
+
expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
|
|
839
|
+
if (hydrated._tag === 'Success') {
|
|
840
|
+
fresh.resolve([{ id: 'a', name: 'alpha' }])
|
|
841
|
+
await runtime.runPromise(
|
|
842
|
+
until(
|
|
843
|
+
() => view.get(),
|
|
844
|
+
(v) => v._tag === 'Success' && v.refetching === false,
|
|
845
|
+
),
|
|
846
|
+
)
|
|
847
|
+
const settled = view.get()
|
|
848
|
+
expect(settled).toMatchObject({ _tag: 'Success', refetching: false })
|
|
849
|
+
if (settled._tag === 'Success') {
|
|
850
|
+
expect(settled.value[0]).toBe(hydrated.value[0])
|
|
851
|
+
expect(store.map.get('AsyncPersistRemoteItems')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
await runtime.dispose()
|
|
154
855
|
})
|