@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.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. 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, Queries, QueryStore, State, StateGroup } from '../index'
4
-
5
- // The data-oriented half of AsyncCalc: `invalidate` flips `isStale` only (no
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(() => {}) // active reader
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('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
138
- 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) {}
139
380
  class Inputs extends StateGroup.make(Count) {}
140
- const store = fakeQueryStore({ P: 99 }) // a previously-persisted value
141
- class Q extends AsyncCalc.make('Q', {
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 QLive = AsyncCalc.live(Q, {
147
- 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
+ },
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
- const TestLayer = QLive.pipe(
151
- 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 })),
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
- return Effect.gen(function* () {
157
- const view = yield* Q.store
158
- // The persisted value shows instantly, marked refetching (stale-while-revalidate).
159
- expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
160
-
161
- // The fresh fetch lands and is written through to the store.
162
- yield* tick(45)
163
- expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
164
- expect(store.map.get('P')).toBe(10)
165
- }).pipe(Effect.provide(TestLayer))
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
  })