@playfast/reform 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
-
import { AsyncCalc, Engine,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// fetch), `refetch` forces a run, a stale query with active readers (or a fresh
|
|
7
|
-
// subscriber) refetches, and `persist` hydrates + writes through an optional
|
|
8
|
-
// QueryStore. `isStale` is read off the `Queries` registry handle.
|
|
2
|
+
import { Duration, 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'
|
|
9
6
|
|
|
10
7
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
11
8
|
|
|
12
9
|
const isStale = (name: string) =>
|
|
13
10
|
Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
|
|
14
11
|
|
|
15
|
-
// An in-memory QueryStore, so persistence is exercised with no DOM.
|
|
16
12
|
const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
17
13
|
const map = new Map<string, unknown>(Object.entries(seed))
|
|
18
14
|
const layer = Layer.succeed(QueryStore, {
|
|
@@ -23,6 +19,37 @@ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
|
23
19
|
return { map, layer }
|
|
24
20
|
}
|
|
25
21
|
|
|
22
|
+
const controlled = <A>() => {
|
|
23
|
+
const resolver = MutableRef.make<(value: A) => void>(() => {})
|
|
24
|
+
const promise = new Promise<A>((resume) => {
|
|
25
|
+
MutableRef.set(resolver, resume)
|
|
26
|
+
})
|
|
27
|
+
return { promise, resolve: (value: A) => MutableRef.get(resolver)(value) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const promiseQueryStore = (get: (key: string) => Promise<Option.Option<unknown>>) => {
|
|
31
|
+
const map = new Map<string, unknown>()
|
|
32
|
+
const layer = Layer.succeed(QueryStore, {
|
|
33
|
+
get: (key) => Effect.promise(() => get(key)),
|
|
34
|
+
set: (key, value) => Effect.sync(() => void map.set(key, value)),
|
|
35
|
+
remove: (key) => Effect.sync(() => void map.delete(key)),
|
|
36
|
+
})
|
|
37
|
+
return { map, layer }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rearmOnEveryChange = (subscribe: (listener: () => void) => () => void): (() => void) => {
|
|
41
|
+
const current = MutableRef.make<() => void>(() => {})
|
|
42
|
+
const arm = () => {
|
|
43
|
+
const unsubscribe = subscribe(() => {
|
|
44
|
+
MutableRef.get(current)()
|
|
45
|
+
arm()
|
|
46
|
+
})
|
|
47
|
+
MutableRef.set(current, unsubscribe)
|
|
48
|
+
}
|
|
49
|
+
arm()
|
|
50
|
+
return () => MutableRef.get(current)()
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
|
|
27
54
|
class Count extends State.make('count', S.Number) {}
|
|
28
55
|
class Inputs extends StateGroup.make(Count) {}
|
|
@@ -49,13 +76,11 @@ it.live('invalidate marks stale without fetching; a fresh subscriber then refetc
|
|
|
49
76
|
yield* tick()
|
|
50
77
|
expect(runs.n).toBe(1)
|
|
51
78
|
|
|
52
|
-
// No active readers: invalidate flips the flag but fetches nothing.
|
|
53
79
|
yield* AsyncCalc.invalidate(Q)
|
|
54
80
|
yield* tick()
|
|
55
81
|
expect(yield* isStale('Q')).toBe(true)
|
|
56
82
|
expect(runs.n).toBe(1)
|
|
57
83
|
|
|
58
|
-
// A reader arriving over a stale query (the resubscribe/mount analog) refetches.
|
|
59
84
|
store.subscribe(() => {})
|
|
60
85
|
yield* tick()
|
|
61
86
|
expect(runs.n).toBe(2)
|
|
@@ -86,12 +111,11 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
|
|
|
86
111
|
|
|
87
112
|
return Effect.gen(function* () {
|
|
88
113
|
const store = yield* Q.store
|
|
89
|
-
store.subscribe(() => {})
|
|
114
|
+
store.subscribe(() => {})
|
|
90
115
|
yield* tick(40)
|
|
91
116
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
92
117
|
expect(runs.n).toBe(1)
|
|
93
118
|
|
|
94
|
-
// Stale + active ⇒ refetch now; the last value stays visible (SWR).
|
|
95
119
|
yield* AsyncCalc.invalidate(Q)
|
|
96
120
|
yield* tick(5)
|
|
97
121
|
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
|
|
@@ -127,40 +151,502 @@ it.live('refetch forces a run regardless of staleness', () => {
|
|
|
127
151
|
expect(runs.n).toBe(1)
|
|
128
152
|
expect(yield* isStale('Q')).toBe(false)
|
|
129
153
|
|
|
130
|
-
// Not stale, no key movement — refetch still runs.
|
|
131
154
|
yield* AsyncCalc.refetch(Q)
|
|
132
155
|
yield* tick()
|
|
133
156
|
expect(runs.n).toBe(2)
|
|
134
157
|
}).pipe(Effect.provide(TestLayer))
|
|
135
158
|
})
|
|
136
159
|
|
|
137
|
-
it.live(
|
|
138
|
-
|
|
160
|
+
it.live(
|
|
161
|
+
'persist: hydrates from the QueryStore (stale) then writes the settled value through',
|
|
162
|
+
() => {
|
|
163
|
+
class Count extends State.make('count', S.Number) {}
|
|
164
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
165
|
+
const store = fakeQueryStore({ P: 99 })
|
|
166
|
+
class Q extends AsyncCalc.make('Q', {
|
|
167
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
168
|
+
output: S.Number,
|
|
169
|
+
alwaysOn: true,
|
|
170
|
+
}) {}
|
|
171
|
+
const QLive = AsyncCalc.live(Q, {
|
|
172
|
+
query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
|
|
173
|
+
persist: { key: 'P' },
|
|
174
|
+
})
|
|
175
|
+
const TestLayer = QLive.pipe(
|
|
176
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
|
|
177
|
+
Layer.provideMerge(store.layer),
|
|
178
|
+
Layer.provideMerge(Engine),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return Effect.gen(function* () {
|
|
182
|
+
const view = yield* Q.store
|
|
183
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
184
|
+
|
|
185
|
+
yield* tick(45)
|
|
186
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
187
|
+
expect(store.map.get('P')).toBe(10)
|
|
188
|
+
}).pipe(Effect.provide(TestLayer))
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
it('persist: a re-armed watcher does not duplicate the generation covering hydration', async () => {
|
|
193
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
194
|
+
const fresh = controlled<number>()
|
|
195
|
+
const persisted = promiseQueryStore(() => cached.promise)
|
|
196
|
+
const runs = { n: 0 }
|
|
197
|
+
class Q extends AsyncCalc.make('CoveredHydrationQ', {
|
|
198
|
+
inputs: [],
|
|
199
|
+
output: S.Number,
|
|
200
|
+
alwaysOn: true,
|
|
201
|
+
}) {}
|
|
202
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
203
|
+
query: () =>
|
|
204
|
+
Effect.sync(() => {
|
|
205
|
+
runs.n += 1
|
|
206
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
207
|
+
coalesce: 'trailing',
|
|
208
|
+
persist: true,
|
|
209
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
210
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
211
|
+
|
|
212
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
213
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
214
|
+
await runtime.runPromise(tick())
|
|
215
|
+
expect(runs.n).toBe(1)
|
|
216
|
+
|
|
217
|
+
cached.resolve(Option.some(99))
|
|
218
|
+
await runtime.runPromise(tick())
|
|
219
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
220
|
+
|
|
221
|
+
fresh.resolve(10)
|
|
222
|
+
await runtime.runPromise(tick())
|
|
223
|
+
expect(runs.n).toBe(1)
|
|
224
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
225
|
+
stop()
|
|
226
|
+
await runtime.dispose()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('persist: an explicit refetch still trails the generation covering hydration', async () => {
|
|
230
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
231
|
+
const fresh = controlled<number>()
|
|
232
|
+
const persisted = promiseQueryStore(() => cached.promise)
|
|
233
|
+
const runs = { n: 0 }
|
|
234
|
+
class CoveredHydrationRefetchQ extends AsyncCalc.make('CoveredHydrationRefetchQ', {
|
|
235
|
+
inputs: [],
|
|
236
|
+
output: S.Number,
|
|
237
|
+
alwaysOn: true,
|
|
238
|
+
}) {}
|
|
239
|
+
const TestLayer = AsyncCalc.live(CoveredHydrationRefetchQ, {
|
|
240
|
+
query: () =>
|
|
241
|
+
Effect.sync(() => {
|
|
242
|
+
runs.n += 1
|
|
243
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
244
|
+
coalesce: 'trailing',
|
|
245
|
+
persist: true,
|
|
246
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
247
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
248
|
+
|
|
249
|
+
const view = forceSync(() => runtime.runSync(CoveredHydrationRefetchQ.store))
|
|
250
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
251
|
+
await runtime.runPromise(tick())
|
|
252
|
+
cached.resolve(Option.some(99))
|
|
253
|
+
await runtime.runPromise(tick())
|
|
254
|
+
|
|
255
|
+
await runtime.runPromise(AsyncCalc.refetch(CoveredHydrationRefetchQ))
|
|
256
|
+
fresh.resolve(10)
|
|
257
|
+
await runtime.runPromise(tick())
|
|
258
|
+
expect(runs.n).toBe(2)
|
|
259
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
260
|
+
stop()
|
|
261
|
+
await runtime.dispose()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('invalidate after a requested generation stays uncovered when hydration lands', async () => {
|
|
265
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
266
|
+
const fresh = controlled<number>()
|
|
267
|
+
const persisted = promiseQueryStore(() => cached.promise)
|
|
268
|
+
const runs = { n: 0 }
|
|
269
|
+
class InFlightInvalidateQ extends AsyncCalc.make('InFlightInvalidateQ', {
|
|
270
|
+
inputs: [],
|
|
271
|
+
output: S.Number,
|
|
272
|
+
alwaysOn: true,
|
|
273
|
+
}) {}
|
|
274
|
+
const TestLayer = AsyncCalc.live(InFlightInvalidateQ, {
|
|
275
|
+
query: () =>
|
|
276
|
+
Effect.sync(() => {
|
|
277
|
+
runs.n += 1
|
|
278
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
279
|
+
coalesce: 'trailing',
|
|
280
|
+
persist: true,
|
|
281
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
282
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
283
|
+
|
|
284
|
+
const view = forceSync(() => runtime.runSync(InFlightInvalidateQ.store))
|
|
285
|
+
await runtime.runPromise(tick())
|
|
286
|
+
expect(runs.n).toBe(1)
|
|
287
|
+
|
|
288
|
+
await runtime.runPromise(AsyncCalc.invalidate(InFlightInvalidateQ))
|
|
289
|
+
expect(await runtime.runPromise(isStale('InFlightInvalidateQ'))).toBe(true)
|
|
290
|
+
cached.resolve(Option.some(99))
|
|
291
|
+
await runtime.runPromise(tick())
|
|
292
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
293
|
+
|
|
294
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
295
|
+
fresh.resolve(7)
|
|
296
|
+
await runtime.runPromise(tick())
|
|
297
|
+
expect(runs.n).toBe(2)
|
|
298
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
299
|
+
stop()
|
|
300
|
+
await runtime.dispose()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('a failed refetch leaves its stale mark uncovered, so a fresh subscriber retries', async () => {
|
|
304
|
+
const runs = { n: 0 }
|
|
305
|
+
class FailedCoverQ extends AsyncCalc.make('FailedCoverQ', {
|
|
306
|
+
inputs: [],
|
|
307
|
+
output: S.Number,
|
|
308
|
+
error: S.String,
|
|
309
|
+
alwaysOn: true,
|
|
310
|
+
}) {}
|
|
311
|
+
const runtime = ManagedRuntime.make(
|
|
312
|
+
AsyncCalc.live(FailedCoverQ, {
|
|
313
|
+
query: () =>
|
|
314
|
+
Effect.suspend((): Effect.Effect<number, string> => {
|
|
315
|
+
runs.n += 1
|
|
316
|
+
return runs.n === 2 ? Effect.fail('boom') : Effect.succeed(runs.n)
|
|
317
|
+
}),
|
|
318
|
+
}).pipe(Layer.provideMerge(Engine)),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const view = forceSync(() => runtime.runSync(FailedCoverQ.store))
|
|
322
|
+
await runtime.runPromise(tick())
|
|
323
|
+
expect(runs.n).toBe(1)
|
|
324
|
+
|
|
325
|
+
// Invalidate with no readers, then attach one: that reader's refetch covers the
|
|
326
|
+
// stale mark, and it fails.
|
|
327
|
+
await runtime.runPromise(AsyncCalc.invalidate(FailedCoverQ))
|
|
328
|
+
const first = view.subscribe(() => {})
|
|
329
|
+
await runtime.runPromise(tick())
|
|
330
|
+
expect(runs.n).toBe(2)
|
|
331
|
+
expect(view.get()).toMatchObject({ _tag: 'Error', error: 'boom' })
|
|
332
|
+
expect(await runtime.runPromise(isStale('FailedCoverQ'))).toBe(true)
|
|
333
|
+
|
|
334
|
+
// Nothing successful has covered the mark, so the next subscriber still retries.
|
|
335
|
+
first()
|
|
336
|
+
view.subscribe(() => {})
|
|
337
|
+
await runtime.runPromise(tick())
|
|
338
|
+
expect(runs.n).toBe(3)
|
|
339
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 3, refetching: false })
|
|
340
|
+
await runtime.dispose()
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('persist: hydration before any request still gets one initial revalidation', async () => {
|
|
344
|
+
const fresh = controlled<number>()
|
|
345
|
+
const persisted = fakeQueryStore({ SyncHydrationQ: 99 })
|
|
346
|
+
const runs = { n: 0 }
|
|
347
|
+
class Q extends AsyncCalc.make('SyncHydrationQ', {
|
|
348
|
+
inputs: [],
|
|
349
|
+
output: S.Number,
|
|
350
|
+
alwaysOn: true,
|
|
351
|
+
}) {}
|
|
352
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
353
|
+
query: () =>
|
|
354
|
+
Effect.sync(() => {
|
|
355
|
+
runs.n += 1
|
|
356
|
+
}).pipe(Effect.zipRight(Effect.promise(() => fresh.promise))),
|
|
357
|
+
coalesce: 'trailing',
|
|
358
|
+
persist: true,
|
|
359
|
+
}).pipe(Layer.provideMerge(persisted.layer), Layer.provideMerge(Engine))
|
|
360
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
361
|
+
|
|
362
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
363
|
+
const stop = rearmOnEveryChange(view.subscribe)
|
|
364
|
+
await runtime.runPromise(tick())
|
|
365
|
+
expect(runs.n).toBe(1)
|
|
366
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
367
|
+
|
|
368
|
+
fresh.resolve(10)
|
|
369
|
+
await runtime.runPromise(tick())
|
|
370
|
+
expect(runs.n).toBe(1)
|
|
371
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
372
|
+
stop()
|
|
373
|
+
await runtime.dispose()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('a genuine key change still enqueues a trailing generation', async () => {
|
|
377
|
+
const release = controlled<void>()
|
|
378
|
+
const runs = { n: 0 }
|
|
379
|
+
class Count extends State.make('key-change-count', S.Number) {}
|
|
139
380
|
class Inputs extends StateGroup.make(Count) {}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
inputs: [StateGroup.select(Inputs, 'count')],
|
|
381
|
+
class Q extends AsyncCalc.make('InFlightKeyChangeQ', {
|
|
382
|
+
inputs: [StateGroup.select(Inputs, 'key-change-count')],
|
|
143
383
|
output: S.Number,
|
|
144
384
|
alwaysOn: true,
|
|
145
385
|
}) {}
|
|
146
|
-
const
|
|
147
|
-
query: ({ count }) =>
|
|
386
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
387
|
+
query: ({ 'key-change-count': count }) =>
|
|
388
|
+
Effect.sync(() => {
|
|
389
|
+
runs.n += 1
|
|
390
|
+
}).pipe(Effect.zipRight(Effect.promise(() => release.promise)), Effect.as(count)),
|
|
391
|
+
coalesce: 'trailing',
|
|
392
|
+
}).pipe(
|
|
393
|
+
Layer.provideMerge(StateGroup.live(Inputs, { 'key-change-count': 1 })),
|
|
394
|
+
Layer.provideMerge(Engine),
|
|
395
|
+
)
|
|
396
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
397
|
+
|
|
398
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
399
|
+
const count = runtime.runSync(Count.store)
|
|
400
|
+
await runtime.runPromise(tick())
|
|
401
|
+
expect(runs.n).toBe(1)
|
|
402
|
+
|
|
403
|
+
count.set(2)
|
|
404
|
+
await runtime.runPromise(tick())
|
|
405
|
+
expect(runs.n).toBe(1)
|
|
406
|
+
release.resolve()
|
|
407
|
+
await runtime.runPromise(tick())
|
|
408
|
+
expect(runs.n).toBe(2)
|
|
409
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 2, refetching: false })
|
|
410
|
+
await runtime.dispose()
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('persist: promise-backed hydration keeps construction synchronous and preserves SWR ordering', async () => {
|
|
414
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
415
|
+
const fresh = controlled<number>()
|
|
416
|
+
const store = promiseQueryStore(() => cached.promise)
|
|
417
|
+
const runs = { n: 0 }
|
|
418
|
+
class Q extends AsyncCalc.make('AsyncPersistQ', {
|
|
419
|
+
inputs: [],
|
|
420
|
+
output: S.Number,
|
|
421
|
+
alwaysOn: true,
|
|
422
|
+
}) {}
|
|
423
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
424
|
+
query: () => {
|
|
425
|
+
runs.n += 1
|
|
426
|
+
return Effect.promise(() => fresh.promise)
|
|
427
|
+
},
|
|
148
428
|
persist: { key: 'P' },
|
|
429
|
+
}).pipe(Layer.provideMerge(store.layer), Layer.provideMerge(Engine))
|
|
430
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
431
|
+
|
|
432
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
433
|
+
expect(view.get()).toMatchObject({ _tag: 'Loading' })
|
|
434
|
+
await runtime.runPromise(tick())
|
|
435
|
+
expect(runs.n).toBe(1)
|
|
436
|
+
|
|
437
|
+
cached.resolve(Option.some(99))
|
|
438
|
+
await runtime.runPromise(tick())
|
|
439
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
440
|
+
|
|
441
|
+
fresh.resolve(10)
|
|
442
|
+
await runtime.runPromise(tick())
|
|
443
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
444
|
+
expect(store.map.get('P')).toBe(10)
|
|
445
|
+
await runtime.dispose()
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('persist: late or corrupt hydration cannot overwrite fresh truth or a changed source key', async () => {
|
|
449
|
+
const cacheByKey = new Map<string, ReturnType<typeof controlled<Option.Option<unknown>>>>()
|
|
450
|
+
const store = promiseQueryStore((key) => {
|
|
451
|
+
const pending = controlled<Option.Option<unknown>>()
|
|
452
|
+
cacheByKey.set(key, pending)
|
|
453
|
+
return pending.promise
|
|
149
454
|
})
|
|
150
|
-
|
|
151
|
-
|
|
455
|
+
class Count extends State.make('count', S.Number) {}
|
|
456
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
457
|
+
class Q extends AsyncCalc.make('AsyncPersistKeyed', {
|
|
458
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
459
|
+
output: S.Number,
|
|
460
|
+
alwaysOn: true,
|
|
461
|
+
}) {}
|
|
462
|
+
const TestLayer = AsyncCalc.live(Q, {
|
|
463
|
+
query: ({ count }) => Effect.succeed(count * 10),
|
|
464
|
+
persist: { key: ({ count }) => `P:${count}` },
|
|
465
|
+
}).pipe(
|
|
466
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
152
467
|
Layer.provideMerge(store.layer),
|
|
153
468
|
Layer.provideMerge(Engine),
|
|
154
469
|
)
|
|
470
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
471
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
472
|
+
const count = runtime.runSync(Count.store)
|
|
155
473
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
474
|
+
count.set(2)
|
|
475
|
+
await runtime.runPromise(tick())
|
|
476
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 20, refetching: false })
|
|
477
|
+
cacheByKey.get('P:1')?.resolve(Option.some(99))
|
|
478
|
+
await runtime.runPromise(tick())
|
|
479
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 20, refetching: false })
|
|
480
|
+
|
|
481
|
+
await runtime.dispose()
|
|
482
|
+
|
|
483
|
+
const churnedCache = controlled<Option.Option<unknown>>()
|
|
484
|
+
const churnedStore = promiseQueryStore(() => churnedCache.promise)
|
|
485
|
+
const churnedRuntime = ManagedRuntime.make(
|
|
486
|
+
AsyncCalc.live(Q, {
|
|
487
|
+
query: () => Effect.never,
|
|
488
|
+
persist: { key: ({ count }) => `churned:${count}` },
|
|
489
|
+
}).pipe(
|
|
490
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
491
|
+
Layer.provideMerge(churnedStore.layer),
|
|
492
|
+
Layer.provideMerge(Engine),
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
const churnedView = forceSync(() => churnedRuntime.runSync(Q.store))
|
|
496
|
+
const churnedCount = churnedRuntime.runSync(Count.store)
|
|
497
|
+
churnedCount.set(2)
|
|
498
|
+
await churnedRuntime.runPromise(tick())
|
|
499
|
+
churnedCount.set(1)
|
|
500
|
+
await churnedRuntime.runPromise(tick())
|
|
501
|
+
churnedCache.resolve(Option.some(99))
|
|
502
|
+
await churnedRuntime.runPromise(tick())
|
|
503
|
+
expect(churnedView.get()).toMatchObject({ _tag: 'Loading' })
|
|
504
|
+
await churnedRuntime.dispose()
|
|
505
|
+
|
|
506
|
+
const erroredCache = controlled<Option.Option<unknown>>()
|
|
507
|
+
const erroredStore = promiseQueryStore(() => erroredCache.promise)
|
|
508
|
+
class ErroredQ extends AsyncCalc.make('AsyncPersistErrored', {
|
|
509
|
+
inputs: [],
|
|
510
|
+
output: S.Number,
|
|
511
|
+
error: S.String,
|
|
512
|
+
alwaysOn: true,
|
|
513
|
+
}) {}
|
|
514
|
+
const erroredRuntime = ManagedRuntime.make(
|
|
515
|
+
AsyncCalc.live(ErroredQ, {
|
|
516
|
+
query: () => Effect.fail('fresh-error'),
|
|
517
|
+
persist: true,
|
|
518
|
+
}).pipe(Layer.provideMerge(erroredStore.layer), Layer.provideMerge(Engine)),
|
|
519
|
+
)
|
|
520
|
+
const erroredView = forceSync(() => erroredRuntime.runSync(ErroredQ.store))
|
|
521
|
+
const queries = erroredRuntime.runSync(Queries)
|
|
522
|
+
await erroredRuntime.runPromise(tick())
|
|
523
|
+
expect(erroredView.get()).toMatchObject({ _tag: 'Error', error: 'fresh-error' })
|
|
524
|
+
erroredCache.resolve(Option.some(99))
|
|
525
|
+
await erroredRuntime.runPromise(tick())
|
|
526
|
+
expect(erroredView.get()).toMatchObject({ _tag: 'Error', error: 'fresh-error' })
|
|
527
|
+
const erroredHandle = queries.byName.get('AsyncPersistErrored')
|
|
528
|
+
expect(erroredHandle).toBeDefined()
|
|
529
|
+
if (erroredHandle !== undefined) {
|
|
530
|
+
expect(Option.isNone(erroredHandle.snapshot().data)).toBe(true)
|
|
531
|
+
}
|
|
532
|
+
await erroredRuntime.dispose()
|
|
533
|
+
|
|
534
|
+
const corrupt = controlled<Option.Option<unknown>>()
|
|
535
|
+
const pendingFresh = controlled<number>()
|
|
536
|
+
const corruptStore = promiseQueryStore(() => corrupt.promise)
|
|
537
|
+
const corruptRuntime = ManagedRuntime.make(
|
|
538
|
+
AsyncCalc.live(Q, {
|
|
539
|
+
query: () => Effect.promise(() => pendingFresh.promise),
|
|
540
|
+
persist: { key: 'corrupt' },
|
|
541
|
+
}).pipe(
|
|
542
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
543
|
+
Layer.provideMerge(corruptStore.layer),
|
|
544
|
+
Layer.provideMerge(Engine),
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
const corruptView = forceSync(() => corruptRuntime.runSync(Q.store))
|
|
548
|
+
corrupt.resolve(Option.some('not-a-number'))
|
|
549
|
+
await corruptRuntime.runPromise(tick())
|
|
550
|
+
expect(corruptView.get()).toMatchObject({ _tag: 'Loading' })
|
|
551
|
+
pendingFresh.resolve(7)
|
|
552
|
+
await corruptRuntime.runPromise(tick())
|
|
553
|
+
expect(corruptView.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
554
|
+
await corruptRuntime.dispose()
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('persist: finalization and gating prevent late or unnecessary hydration', async () => {
|
|
558
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
559
|
+
const store = promiseQueryStore(() => cached.promise)
|
|
560
|
+
class Q extends AsyncCalc.make('FinalizedPersistQ', {
|
|
561
|
+
inputs: [],
|
|
562
|
+
output: S.Number,
|
|
563
|
+
alwaysOn: true,
|
|
564
|
+
}) {}
|
|
565
|
+
const runtime = ManagedRuntime.make(
|
|
566
|
+
AsyncCalc.live(Q, {
|
|
567
|
+
query: () => Effect.never,
|
|
568
|
+
persist: true,
|
|
569
|
+
}).pipe(Layer.provideMerge(store.layer), Layer.provideMerge(Engine)),
|
|
570
|
+
)
|
|
571
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
572
|
+
await runtime.dispose()
|
|
573
|
+
|
|
574
|
+
cached.resolve(Option.some(99))
|
|
575
|
+
await Effect.runPromise(tick())
|
|
576
|
+
expect(view.get()).toMatchObject({ _tag: 'Loading' })
|
|
577
|
+
expect(store.map.size).toBe(0)
|
|
578
|
+
|
|
579
|
+
const disabledCalls = { gets: 0, runs: 0 }
|
|
580
|
+
const disabledStore = promiseQueryStore(() => {
|
|
581
|
+
disabledCalls.gets += 1
|
|
582
|
+
return Promise.resolve(Option.some(1))
|
|
583
|
+
})
|
|
584
|
+
class Enabled extends State.make('disabled-persist-enabled', S.Boolean) {}
|
|
585
|
+
class DisabledInputs extends StateGroup.make(Enabled) {}
|
|
586
|
+
class DisabledQ extends AsyncCalc.make('DisabledPersistQ', {
|
|
587
|
+
inputs: [StateGroup.select(DisabledInputs, 'disabled-persist-enabled')],
|
|
588
|
+
output: S.Number,
|
|
589
|
+
}) {}
|
|
590
|
+
const disabledRuntime = ManagedRuntime.make(
|
|
591
|
+
AsyncCalc.live(DisabledQ, {
|
|
592
|
+
query: () => Effect.sync(() => (disabledCalls.runs += 1)),
|
|
593
|
+
disabled: ({ 'disabled-persist-enabled': enabled }) => !enabled,
|
|
594
|
+
persist: true,
|
|
595
|
+
}).pipe(
|
|
596
|
+
Layer.provideMerge(StateGroup.live(DisabledInputs, { 'disabled-persist-enabled': false })),
|
|
597
|
+
Layer.provideMerge(disabledStore.layer),
|
|
598
|
+
Layer.provideMerge(Engine),
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
const disabledView = forceSync(() => disabledRuntime.runSync(DisabledQ.store))
|
|
602
|
+
const enabled = disabledRuntime.runSync(Enabled.store)
|
|
603
|
+
await disabledRuntime.runPromise(tick())
|
|
604
|
+
expect(disabledView.get()).toMatchObject({ _tag: 'Idle' })
|
|
605
|
+
expect(disabledCalls).toEqual({ gets: 0, runs: 0 })
|
|
606
|
+
|
|
607
|
+
enabled.set(true)
|
|
608
|
+
await disabledRuntime.runPromise(tick())
|
|
609
|
+
expect(disabledView.get()).toMatchObject({ _tag: 'Success', value: 1 })
|
|
610
|
+
expect(disabledCalls).toEqual({ gets: 0, runs: 1 })
|
|
611
|
+
await disabledRuntime.dispose()
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('persist: promise hydration is shared by RemoteState and participates in reuse', async () => {
|
|
615
|
+
const Item = S.Struct({ id: S.String, name: S.String })
|
|
616
|
+
type Item = typeof Item.Type
|
|
617
|
+
const cached = controlled<Option.Option<unknown>>()
|
|
618
|
+
const fresh = controlled<ReadonlyArray<Item>>()
|
|
619
|
+
const store = promiseQueryStore(() => cached.promise)
|
|
620
|
+
class Items extends RemoteState.make('AsyncPersistRemoteItems', {
|
|
621
|
+
inputs: [],
|
|
622
|
+
output: S.Array(Item),
|
|
623
|
+
intents: [],
|
|
624
|
+
alwaysOn: true,
|
|
625
|
+
}) {}
|
|
626
|
+
const runtime = ManagedRuntime.make(
|
|
627
|
+
RemoteState.live(Items, {
|
|
628
|
+
query: () => Effect.promise(() => fresh.promise),
|
|
629
|
+
send: () => Effect.void,
|
|
630
|
+
apply: (items) => items,
|
|
631
|
+
persist: true,
|
|
632
|
+
reuse: true,
|
|
633
|
+
}).pipe(Layer.provideMerge(store.layer), Layer.provideMerge(Engine)),
|
|
634
|
+
)
|
|
635
|
+
const view = forceSync(() => runtime.runSync(Items.store))
|
|
636
|
+
const cachedItem = { id: 'a', name: 'alpha' }
|
|
637
|
+
cached.resolve(Option.some([cachedItem]))
|
|
638
|
+
await runtime.runPromise(tick())
|
|
639
|
+
const hydrated = view.get()
|
|
640
|
+
expect(hydrated).toMatchObject({ _tag: 'Success', refetching: true })
|
|
641
|
+
if (hydrated._tag === 'Success') {
|
|
642
|
+
fresh.resolve([{ id: 'a', name: 'alpha' }])
|
|
643
|
+
await runtime.runPromise(tick())
|
|
644
|
+
const settled = view.get()
|
|
645
|
+
expect(settled).toMatchObject({ _tag: 'Success', refetching: false })
|
|
646
|
+
if (settled._tag === 'Success') {
|
|
647
|
+
expect(settled.value[0]).toBe(hydrated.value[0])
|
|
648
|
+
expect(store.map.get('AsyncPersistRemoteItems')).toEqual([{ id: 'a', name: 'alpha' }])
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
await runtime.dispose()
|
|
166
652
|
})
|