@playfast/reform 1.1.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 +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +62 -16
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +516 -18
- package/src/calc/asyncCalc.test.ts +207 -175
- package/src/calc/asyncCalc.ts +168 -39
- package/src/calc/calc.test.ts +3 -5
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +80 -22
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +128 -11
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +346 -62
- 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 +1317 -197
- package/src/feature/feature.typecheck.ts +253 -61
- package/src/graph/closure.ts +403 -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 +120 -15
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +3 -1
- 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/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/remoteState.test.ts +515 -339
- package/src/remote/remoteState.ts +572 -107
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +157 -105
- package/src/runtime/appRuntime.ts +394 -33
- package/src/runtime/eventBudget.test.ts +1 -4
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/hardening.test.ts +4 -9
- package/src/runtime/instrumentation.test.ts +174 -192
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +36 -0
- package/src/runtime/loop.ts +69 -40
- 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 +14 -0
- 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 +75 -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/channel/procedureRegistry.ts +0 -90
- package/src/feature/featureBinding.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
- package/src/remote/remoteStateSend.ts +0 -64
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
|
-
import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
-
import { AsyncCalc, Engine,
|
|
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'
|
|
4
6
|
|
|
5
7
|
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
6
8
|
|
|
@@ -17,6 +19,37 @@ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
|
17
19
|
return { map, layer }
|
|
18
20
|
}
|
|
19
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
|
+
|
|
20
53
|
it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
|
|
21
54
|
class Count extends State.make('count', S.Number) {}
|
|
22
55
|
class Inputs extends StateGroup.make(Count) {}
|
|
@@ -124,31 +157,496 @@ it.live('refetch forces a run regardless of staleness', () => {
|
|
|
124
157
|
}).pipe(Effect.provide(TestLayer))
|
|
125
158
|
})
|
|
126
159
|
|
|
127
|
-
it.live(
|
|
128
|
-
|
|
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) {}
|
|
129
380
|
class Inputs extends StateGroup.make(Count) {}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
inputs: [StateGroup.select(Inputs, 'count')],
|
|
381
|
+
class Q extends AsyncCalc.make('InFlightKeyChangeQ', {
|
|
382
|
+
inputs: [StateGroup.select(Inputs, 'key-change-count')],
|
|
133
383
|
output: S.Number,
|
|
134
384
|
alwaysOn: true,
|
|
135
385
|
}) {}
|
|
136
|
-
const
|
|
137
|
-
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
|
+
},
|
|
138
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
|
|
139
454
|
})
|
|
140
|
-
|
|
141
|
-
|
|
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 })),
|
|
142
467
|
Layer.provideMerge(store.layer),
|
|
143
468
|
Layer.provideMerge(Engine),
|
|
144
469
|
)
|
|
470
|
+
const runtime = ManagedRuntime.make(TestLayer)
|
|
471
|
+
const view = forceSync(() => runtime.runSync(Q.store))
|
|
472
|
+
const count = runtime.runSync(Count.store)
|
|
145
473
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 })
|
|
149
480
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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()
|
|
154
652
|
})
|