@playfast/reform 1.1.1 → 1.2.1

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