@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.
Files changed (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. 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, Queries, QueryStore, State, StateGroup } from '../index'
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('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
128
- class Count extends State.make('count', S.Number) {}
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
- const store = fakeQueryStore({ P: 99 })
131
- class Q extends AsyncCalc.make('Q', {
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 QLive = AsyncCalc.live(Q, {
137
- query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
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
- const TestLayer = QLive.pipe(
141
- Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
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
- return Effect.gen(function* () {
147
- const view = yield* Q.store
148
- expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
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
- yield* tick(45)
151
- expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
152
- expect(store.map.get('P')).toBe(10)
153
- }).pipe(Effect.provide(TestLayer))
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
  })