@playfast/reform 1.0.1 → 1.1.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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  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 +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
package/README.md CHANGED
@@ -449,12 +449,24 @@ class Counter extends Feature.make('counter', {
449
449
  // lazy: defer the module behind an import
450
450
  class Reports extends Feature.make('reports', {
451
451
  loadingStrategy: 'lazy',
452
+ composition: ReportsComp,
452
453
  load: lazyImport(() => import('./reports.module')),
454
+ eager: featureModule([], PlaceholderLive),
453
455
  placeholder: { loading: SpinnerComp, failed: RetryComp },
454
456
  }) {}
455
457
  ```
456
458
 
457
- A feature shares the app's engine but gets its own scope — `mountFeature(binding, engineContext)` loads it, builds its layer, dispatches its boot events, and disposes everything when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`; the host paints the placeholders while it loads.
459
+ A feature's `requires` tuple accepts shared State/Calc carriers, StateGroups, and
460
+ ordinary Effect `Context.Tag`s. A lazy feature shares the app's engine but gets
461
+ its own scope — `mountFeature(binding, parentContext)` loads it, builds its layer
462
+ against the immediate parent, dispatches its boot events, and disposes everything
463
+ when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`;
464
+ `provide` merges its optional eager placeholder module automatically while the
465
+ host paints the placeholders and loads the main module.
466
+
467
+ Default Features retain their binding identity too. Their module is merged by
468
+ `provide`, their composition renders synchronously from the parent runtime, and
469
+ their boot events run once for each keyed activation.
458
470
 
459
471
  ---
460
472
 
@@ -465,6 +477,7 @@ A scene bundles a root composition with the closed wiring that runs it — the s
465
477
 
466
478
  ```ts
467
479
  scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
480
+ featureScene(eagerFeature, { provide: closedEnvironment }) // → Scene
468
481
  seedScene(base, seeds) // tooling: override seed values
469
482
  isScene(value) // reflection guard
470
483
 
@@ -476,6 +489,10 @@ const AppScene = scene(AppRoot, {
476
489
 
477
490
  `provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) to mount or to [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
478
491
 
492
+ `featureScene` is the typed root helper for a default/eager Feature. Its `provide`
493
+ layer closes the engine plus the Feature's declared requirements; lazy Features
494
+ are rejected because an application root must be immediately available.
495
+
479
496
  ---
480
497
 
481
498
  <a name="engine"></a>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
3
  "playbook": "./playbook",
4
- "version": "1.0.1",
4
+ "version": "1.1.1",
5
5
  "type": "module",
6
6
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
7
7
  "keywords": [
@@ -2,12 +2,6 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
4
4
 
5
- // Boundary merges several async lifecycles into one Pending/Errored/Ready value
6
- // — the Suspense-boundary analog, value-level. The behaviours under test: the
7
- // arm mapping (Loading pends, Idle and SWR-refetching count Ready, Error
8
- // surfaces), the chained-gate settle-gap pin, the `once` latch, and the
9
- // notification economy (arm-stable churn wakes nobody).
10
-
11
5
  const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
12
6
 
13
7
  const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
@@ -51,13 +45,10 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
51
45
  })
52
46
  expect(store.get()._tag).toBe('Pending')
53
47
 
54
- // The fast query settles first — still Pending (the slow one holds it).
55
48
  yield* tick(20)
56
49
  expect(store.get()._tag).toBe('Pending')
57
50
 
58
51
  yield* until(() => store.get()._tag, (t) => t === 'Ready')
59
- // Exactly ONE notification fired across the whole boot: Pending→Pending
60
- // churn (Fast settling) was absorbed by the stable arm reference.
61
52
  expect(observed).toEqual(['Ready'])
62
53
  }).pipe(Effect.provide(TestLayer))
63
54
  })
@@ -86,8 +77,6 @@ it.live('a gated query that is OFF counts Ready — the off arm must not hold th
86
77
  )
87
78
  return Effect.gen(function* () {
88
79
  const store = yield* Gate.store
89
- // The gated query is Idle (deliberately off): only the always-on first
90
- // load pends, and Ready arrives without ever waiting on the Idle arm.
91
80
  yield* until(() => store.get()._tag, (t) => t === 'Ready')
92
81
  expect(store.get()._tag).toBe('Ready')
93
82
  }).pipe(Effect.provide(TestLayer))
@@ -116,7 +105,6 @@ it.live('an SWR refetch never re-pends — Success with refetching: true is Read
116
105
  const store = yield* Gate.store
117
106
  yield* until(() => store.get()._tag, (t) => t === 'Ready')
118
107
 
119
- // Move the input: the query goes Success+refetching (SWR) — Ready holds.
120
108
  seed.set(2)
121
109
  yield* until(() => qStore.get(), (q) => q._tag === 'Success' && q.refetching)
122
110
  expect(store.get()._tag).toBe('Ready')
@@ -157,7 +145,6 @@ it.live('a first-load failure surfaces as Errored with the failure, and a retry
157
145
  expect(failure instanceof Boom && failure.message).toBe('server down')
158
146
  }
159
147
 
160
- // The retry path: the server heals, a listed fact refetches, Ready lands.
161
148
  healthy.now = true
162
149
  yield* Event.dispatch(Poke, {})
163
150
  yield* until(() => store.get()._tag, (t) => t === 'Ready')
@@ -166,12 +153,6 @@ it.live('a first-load failure surfaces as Errored with the failure, and a retry
166
153
  })
167
154
 
168
155
  it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
169
- // B enables only when A has succeeded — the app's Session → Bootstrap shape.
170
- // The dangerous instant: A flips Success while B is still Idle (Idle counts
171
- // Ready), so a naive merge could notify Ready before B's driver flips it to
172
- // Loading. Layer dependency order (B's driver subscribes to A's store before
173
- // the boundary does) plus the one-microtask fixpoint flush close the gap;
174
- // this records every notification to prove no intermediate Ready escapes.
175
156
  class Seed extends State.make('seed', S.Number) {}
176
157
  class Inputs extends StateGroup.make(Seed) {}
177
158
  class A extends AsyncCalc.make('A', {
@@ -205,19 +186,12 @@ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
205
186
  expect(store.get()._tag).toBe('Pending')
206
187
 
207
188
  yield* until(() => store.get()._tag, (t) => t === 'Ready')
208
- yield* tick(30) // let any straggler notification land before judging
209
- // ONE Ready, at the end — never a Ready while B's hop was still pending.
189
+ yield* tick(30)
210
190
  expect(observed).toEqual(['Ready'])
211
191
  }).pipe(Effect.provide(TestLayer))
212
192
  })
213
193
 
214
194
  it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Ready', () => {
215
- // The app's boot shape: AsyncCalc A → plain Calc projecting it → AsyncCalc B
216
- // gated on the projection. Propagation crosses TWO scheduler-flush rounds,
217
- // so when A settles there is a mid-flush frame where B has not yet flipped
218
- // to Loading — a recompute-time latch would freeze that intermediate Ready
219
- // and the boundary would abandon B's whole first load (caught live by the
220
- // boot-frames walk: nav in, boards card still spinning).
221
195
  class Seed extends State.make('seed', S.Number) {}
222
196
  class Inputs extends StateGroup.make(Seed) {}
223
197
  class A extends AsyncCalc.make('A', {
@@ -247,8 +221,6 @@ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Rea
247
221
  return Effect.gen(function* () {
248
222
  const bStore = yield* B.store
249
223
  const store = yield* Gate.store
250
- // While B's first load is in flight the boundary MUST still be Pending —
251
- // a poisoned latch would already report Ready here.
252
224
  yield* until(() => bStore.get()._tag, (t) => t === 'Loading')
253
225
  expect(store.get()._tag).toBe('Pending')
254
226
  yield* until(() => bStore.get()._tag, (t) => t === 'Success')
@@ -258,9 +230,6 @@ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Rea
258
230
  })
259
231
 
260
232
  it.live('once: a covered gate enabling AFTER the latch never re-pends the boundary', () => {
261
- // The boot-boundary semantics: a route-gated query (the board) first loads
262
- // on a later navigation — its own screen owns that loading state, the shell
263
- // splash must not come back.
264
233
  class Route extends State.make('routeFlag', S.Boolean) {}
265
234
  class Inputs extends StateGroup.make(Route) {}
266
235
  class Always extends AsyncCalc.make('Always', {
@@ -291,7 +260,6 @@ it.live('once: a covered gate enabling AFTER the latch never re-pends the bounda
291
260
  const store = yield* Gate.store
292
261
  yield* until(() => store.get()._tag, (t) => t === 'Ready')
293
262
 
294
- // "Navigate": the routed query enables and goes Loading — latched Ready holds.
295
263
  route.set(true)
296
264
  yield* until(() => routedStore.get()._tag, (t) => t === 'Loading')
297
265
  expect(store.get()._tag).toBe('Ready')
@@ -1,78 +1,34 @@
1
1
  import { Context, Data, Effect, Layer, MutableRef, Predicate } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
2
3
  import { type Manifest, yieldableClass } from '../definition/definition'
3
4
  import { resolveScheduler } from '../internal/scheduler'
4
5
  import { type InputStores, sameKey, wireSources } from '../internal/sources'
5
6
  import { makeDerivedStore, type Store } from '../internal/store'
6
7
  import { readTracked } from '../internal/track'
7
8
  import { type Source } from '../state/token'
9
+ import type { AnyValue } from '../internal/variance'
8
10
 
9
- // Boundary: the Suspense-boundary analog, value-level. One derived store that
10
- // merges the lifecycles of several async sources into a single
11
- // Pending/Errored/Ready value, so ONE surface (a splash, a skeleton) covers a
12
- // whole subtree's first load instead of every query consumer painting its own.
13
- //
14
- // Reform can do this without React's throw-a-promise machinery because
15
- // fetching is layer-driven, not render-driven: an `AsyncCalc` runs because its
16
- // layer is built, never because something rendered. A boundary therefore only
17
- // *aggregates* lifecycles — it cannot create render-waterfalls, and content it
18
- // reveals already has its data.
19
- //
20
- // The arm mapping IS the semantics, and most of it falls out of `AsyncData`:
21
- // Loading → Pending (the arm only exists before the first value,
22
- // so a boundary is first-load-only by construction)
23
- // Success → Ready (even `refetching: true` — SWR refetches never
24
- // re-show the fallback; no transition machinery)
25
- // Idle → Ready (a gated query that is off is DELIBERATELY off —
26
- // a signed-out viewer must not wait on a query
27
- // that only runs signed-in)
28
- // Error → Errored (the error-boundary half, also value-level)
29
- //
30
- // Chained gates (B enables when A succeeds) cannot leak a Ready between hops:
31
- // the boundary's layer requires every covered store, so each driver subscribes
32
- // upstream BEFORE the boundary does, the scheduler flush runs listeners in
33
- // subscription order to a fixpoint within one microtask, and reads are
34
- // pull-fresh — by the time any subscriber reads, the next hop is already
35
- // Loading. `boundary.test.ts` pins this with a recorded-notification proof.
36
-
37
- /** Some covered source is still on its FIRST load — show the fallback. */
38
11
  export interface BoundaryPending {
39
12
  readonly _tag: 'Pending'
40
13
  }
41
14
 
42
- /** Some covered source failed before its first value. */
43
15
  export interface BoundaryErrored {
44
16
  readonly _tag: 'Errored'
45
- /** The failures, in `over` order (heterogeneous — display via `String`). */
46
17
  readonly errors: ReadonlyArray<unknown>
47
18
  }
48
19
 
49
- /** Every covered source has settled (or is deliberately gated off). */
50
20
  export interface BoundaryReady {
51
21
  readonly _tag: 'Ready'
52
22
  }
53
23
 
54
24
  export type BoundaryState = BoundaryPending | BoundaryErrored | BoundaryReady
55
25
 
56
- // Stable arm references: repeated recomputes return the same object, so the
57
- // derived store's Equal gate (and `useSyncExternalStore`'s stable-snapshot
58
- // contract) hold without a wrapper. `Errored` carries data, so it is a `Data`
59
- // struct — value-equal failures coalesce the same way.
60
26
  const pendingArm: BoundaryState = { _tag: 'Pending' }
61
27
  const readyArm: BoundaryState = { _tag: 'Ready' }
62
28
  const erroredArm = (errors: ReadonlyArray<unknown>): BoundaryState =>
63
29
  Data.struct({ _tag: 'Errored' as const, errors: Data.array(errors) })
64
30
 
65
- /**
66
- * Anything a boundary can cover: a `Source` whose value is an async lifecycle —
67
- * an `AsyncCalc`, or anything lifecycle-valued built over one (e.g. a
68
- * `RemoteState` overlay). The constraint is the same erased `Source` every calc
69
- * input uses (store invariance rules out a fixed lifecycle value type — each
70
- * definition's union carries its own `Gated`/`E` arms); the captured `const`
71
- * tuple keeps each member's precise store requirement, and the runtime merge
72
- * reads arms through structural guards, so a non-lifecycle source simply
73
- * counts as settled.
74
- */
75
- export type AnyLifecycleSource = Source<string, any>
31
+ export type AnyLifecycleSource = Source<string, AnyValue>
76
32
 
77
33
  export interface BoundaryManifest<N extends string> extends Manifest {
78
34
  readonly kind: 'Boundary'
@@ -80,13 +36,11 @@ export interface BoundaryManifest<N extends string> extends Manifest {
80
36
  }
81
37
 
82
38
  export interface BoundaryClass<out N extends string, out Over extends ReadonlyArray<AnyLifecycleSource>>
83
- extends Effect.Effect<BoundaryState, never, Store<BoundaryState>> {
39
+ extends EffectType<BoundaryState, never, Store<BoundaryState>> {
84
40
  new (): {}
85
41
  readonly manifest: BoundaryManifest<N>
86
42
  readonly store: Context.Tag<Store<BoundaryState>, Store<BoundaryState>>
87
- /** The boundary's name, so it doubles as a `Source` input to a calc. */
88
43
  readonly name: N
89
- /** The covered lifecycle sources — read by `live` and the editor's graph. */
90
44
  readonly over: Over
91
45
  }
92
46
 
@@ -95,23 +49,10 @@ export interface BoundaryConfig<Over extends ReadonlyArray<AnyLifecycleSource>>
95
49
  }
96
50
 
97
51
  export interface BoundaryOptions {
98
- /**
99
- * Latch: once Ready, stay Ready. A BOOT boundary wants this — a covered
100
- * route-gated query that first enables on a later navigation (Idle →
101
- * Loading) must not re-splash the whole shell; its own screen owns that
102
- * loading state. Without the latch the boundary re-pends on any covered
103
- * first load, which is what a per-screen boundary wants.
104
- */
105
52
  // oxlint-disable-next-line reform-rules/no-optional-fields -- public options-bag input; omission is the documented default (no latch) and external callers pass a plain `{ once: true }` literal
106
53
  readonly once?: boolean
107
54
  }
108
55
 
109
- /**
110
- * Define a boundary over async sources. `yield* MyBoundary` reads the merged
111
- * `Pending | Errored | Ready` lifecycle; the consuming view renders ONE
112
- * fallback on Pending and its content slot on Ready — content reveals
113
- * together, with data already present (fetching never waited on rendering).
114
- */
115
56
  export const make = <const N extends string, const Over extends ReadonlyArray<AnyLifecycleSource>>(
116
57
  name: N,
117
58
  config: BoundaryConfig<Over>,
@@ -124,25 +65,15 @@ export const make = <const N extends string, const Over extends ReadonlyArray<An
124
65
  return yieldableClass(read, { manifest, store, name, over: config.over })
125
66
  }
126
67
 
127
- // The snapshot values arrive through `wireSources`' erased key projection
128
- // (`ReadonlyArray<unknown>`), so read the two facts the merge needs through
129
- // honest structural guards — no cast back to `AnyAsyncData`.
130
68
  const tagOf = (arm: unknown): string =>
131
69
  Predicate.hasProperty(arm, '_tag') && Predicate.isString(arm._tag) ? arm._tag : ''
132
70
  const errorOf = (arm: unknown): ReadonlyArray<unknown> =>
133
71
  Predicate.hasProperty(arm, 'error') && tagOf(arm) === 'Error' ? [arm.error] : []
134
72
 
135
- /**
136
- * Wire the merge. A derived store over the covered stores (sensed through the
137
- * same `wireSources` plumbing every calc uses): recomputes are memoized on the
138
- * input snapshots and the arms are stable references, so input churn that does
139
- * not move the merged arm wakes nobody.
140
- */
141
73
  export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSource>>(
142
74
  boundary: BoundaryClass<N, Over>,
143
75
  options: BoundaryOptions = {},
144
76
  ): Layer.Layer<Store<BoundaryState>, never, InputStores<Over>> =>
145
- // Scoped so the source subscriptions are released with the layer's scope.
146
77
  Layer.scoped(
147
78
  boundary.store,
148
79
  Effect.gen(function* () {
@@ -177,15 +108,6 @@ export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSo
177
108
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
178
109
 
179
110
  if (options.once === true) {
180
- // Latch on CONVERGED values only — at build and on (coalesced,
181
- // post-fixpoint) notifications — never inside `recompute`. A chained
182
- // gate with a plain Calc hop between the async stores (bootstrap →
183
- // current-workspace → boards) propagates over TWO flush rounds, so a
184
- // mid-flush recompute can observe the frame where the downstream
185
- // driver has not yet flipped its query to Loading. That intermediate
186
- // Ready self-corrects within the flush for readers, but a latch taken
187
- // there would freeze it; a subscriber runs only after the fixpoint,
188
- // where the value is converged.
189
111
  if (recompute()._tag === 'Ready') {
190
112
  MutableRef.set(latched, true)
191
113
  }
@@ -2,17 +2,11 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Engine, Queries, QueryStore, State, StateGroup } from '../index'
4
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.
9
-
10
5
  const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
11
6
 
12
7
  const isStale = (name: string) =>
13
8
  Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
14
9
 
15
- // An in-memory QueryStore, so persistence is exercised with no DOM.
16
10
  const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
17
11
  const map = new Map<string, unknown>(Object.entries(seed))
18
12
  const layer = Layer.succeed(QueryStore, {
@@ -49,13 +43,11 @@ it.live('invalidate marks stale without fetching; a fresh subscriber then refetc
49
43
  yield* tick()
50
44
  expect(runs.n).toBe(1)
51
45
 
52
- // No active readers: invalidate flips the flag but fetches nothing.
53
46
  yield* AsyncCalc.invalidate(Q)
54
47
  yield* tick()
55
48
  expect(yield* isStale('Q')).toBe(true)
56
49
  expect(runs.n).toBe(1)
57
50
 
58
- // A reader arriving over a stale query (the resubscribe/mount analog) refetches.
59
51
  store.subscribe(() => {})
60
52
  yield* tick()
61
53
  expect(runs.n).toBe(2)
@@ -86,12 +78,11 @@ it.live('invalidate with an active reader auto-refetches immediately, keeping th
86
78
 
87
79
  return Effect.gen(function* () {
88
80
  const store = yield* Q.store
89
- store.subscribe(() => {}) // active reader
81
+ store.subscribe(() => {})
90
82
  yield* tick(40)
91
83
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
92
84
  expect(runs.n).toBe(1)
93
85
 
94
- // Stale + active ⇒ refetch now; the last value stays visible (SWR).
95
86
  yield* AsyncCalc.invalidate(Q)
96
87
  yield* tick(5)
97
88
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
@@ -127,7 +118,6 @@ it.live('refetch forces a run regardless of staleness', () => {
127
118
  expect(runs.n).toBe(1)
128
119
  expect(yield* isStale('Q')).toBe(false)
129
120
 
130
- // Not stale, no key movement — refetch still runs.
131
121
  yield* AsyncCalc.refetch(Q)
132
122
  yield* tick()
133
123
  expect(runs.n).toBe(2)
@@ -137,7 +127,7 @@ it.live('refetch forces a run regardless of staleness', () => {
137
127
  it.live('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
138
128
  class Count extends State.make('count', S.Number) {}
139
129
  class Inputs extends StateGroup.make(Count) {}
140
- const store = fakeQueryStore({ P: 99 }) // a previously-persisted value
130
+ const store = fakeQueryStore({ P: 99 })
141
131
  class Q extends AsyncCalc.make('Q', {
142
132
  inputs: [StateGroup.select(Inputs, 'count')],
143
133
  output: S.Number,
@@ -155,10 +145,8 @@ it.live('persist: hydrates from the QueryStore (stale) then writes the settled v
155
145
 
156
146
  return Effect.gen(function* () {
157
147
  const view = yield* Q.store
158
- // The persisted value shows instantly, marked refetching (stale-while-revalidate).
159
148
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
160
149
 
161
- // The fresh fetch lands and is written through to the store.
162
150
  yield* tick(45)
163
151
  expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
164
152
  expect(store.map.get('P')).toBe(10)
@@ -2,14 +2,8 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Engine, Event, State, StateGroup } from '../index'
4
4
 
5
- // AsyncCalc is a derived store whose value is the lifecycle of an Effect. It runs
6
- // the query reactively from its inputs (latest-wins), keeps the last value while
7
- // re-fetching (stale-while-revalidate), and goes Idle when a gated query disables.
8
-
9
5
  const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
10
6
 
11
- // Poll until the predicate holds (bounded) — for assertions that race real
12
- // timers under parallel-suite host load, where a fixed `tick` budget flakes.
13
7
  const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
14
8
  Effect.suspend(() => {
15
9
  const a = read()
@@ -35,7 +29,6 @@ it.live('runs the query and resolves Loading -> Success', () => {
35
29
 
36
30
  return Effect.gen(function* () {
37
31
  const store = yield* Doubled.store
38
- // Before the forked query resolves, the value is Loading.
39
32
  expect(store.get()._tag).toBe('Loading')
40
33
  yield* tick(40)
41
34
  const value = store.get()
@@ -92,7 +85,6 @@ it.live('a dependency change re-fetches, keeping the last value with refetching:
92
85
  yield* tick(40)
93
86
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
94
87
 
95
- // Move the input: the prior value is kept while the new fetch is in flight.
96
88
  source.set(7)
97
89
  yield* tick(5)
98
90
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: true })
@@ -126,9 +118,6 @@ it.live('latest-wins: a rapid second change cancels the in-flight run', () => {
126
118
  yield* tick(40)
127
119
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 2 })
128
120
 
129
- // Two changes in quick succession: the first run is switched out before it
130
- // can write, so only the latest value lands (poll — fixed sleeps flake when
131
- // a loaded host starves the 25ms query timer).
132
121
  source.set(3)
133
122
  yield* tick(5)
134
123
  source.set(4)
@@ -181,7 +170,6 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
181
170
  runs.n += 1
182
171
  return page
183
172
  }),
184
- // Only `page` is part of the key; `status` churn must not re-fetch.
185
173
  invalidateBy: ({ page }) => [page],
186
174
  })
187
175
  const TestLayer = QLive.pipe(
@@ -195,12 +183,10 @@ it.live('invalidateBy: an input change outside the key does not re-fetch', () =>
195
183
  yield* tick()
196
184
  expect(runs.n).toBe(1)
197
185
 
198
- // status moves, key unchanged -> no re-fetch.
199
186
  status.set('b')
200
187
  yield* tick()
201
188
  expect(runs.n).toBe(1)
202
189
 
203
- // page moves, key changes -> one re-fetch.
204
190
  page.set(2)
205
191
  yield* tick()
206
192
  expect(runs.n).toBe(2)
@@ -218,7 +204,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
218
204
  alwaysOn: true,
219
205
  }) {}
220
206
  const RowsLive = AsyncCalc.live(Rows, {
221
- // Fresh objects every run — only the row matching `page` differs.
222
207
  query: ({ page }) =>
223
208
  Effect.succeed([
224
209
  { id: 1, name: 'one' },
@@ -240,7 +225,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
240
225
  (v) => v._tag === 'Success' && v.refetching === false && v.value !== first.value,
241
226
  )
242
227
  if (second._tag !== 'Success') return
243
- // Row 1 didn't move: same identity. Row 2 did: fresh.
244
228
  expect(second.value[0]).toBe(first.value[0])
245
229
  expect(second.value[1]).not.toBe(first.value[1])
246
230
  expect(second.value[1]).toEqual({ id: 2, name: 'two@2' })
@@ -250,8 +234,6 @@ it.live('reuse: a refetch returning mostly-equal data keeps unchanged element id
250
234
  it.live('coalesce trailing: a burst during one flight completes it and runs ONE trailing refetch', () => {
251
235
  class Count extends State.make('count', S.Number) {}
252
236
  class Inputs extends StateGroup.make(Count) {}
253
- // Completion log: the sync body runs AFTER the delay, so the order proves
254
- // which runs actually finished (and that the in-flight one was not cancelled).
255
237
  const completed: number[] = []
256
238
  class Doubled extends AsyncCalc.make('Doubled', {
257
239
  inputs: [StateGroup.select(Inputs, 'count')],
@@ -274,9 +256,6 @@ it.live('coalesce trailing: a burst during one flight completes it and runs ONE
274
256
  yield* until(() => store.get(), (v) => v._tag === 'Success')
275
257
  expect(completed).toEqual([1])
276
258
 
277
- // Three key changes during the first change's flight: the in-flight run (2)
278
- // completes and lands, the middle change (3) is conflated away, and exactly
279
- // one trailing run fetches the latest key (4).
280
259
  source.set(2)
281
260
  yield* tick(5)
282
261
  source.set(3)
@@ -310,8 +289,6 @@ it.live('coalesce trailing: the stale settle re-flags refetching until the trail
310
289
  const store = yield* Doubled.store
311
290
  yield* until(() => store.get(), (v) => v._tag === 'Success')
312
291
 
313
- // Two changes: when the in-flight result (4) lands, a trailing run is still
314
- // pending — the value must read as syncing, not settled.
315
292
  source.set(2)
316
293
  yield* tick(5)
317
294
  source.set(3)
@@ -353,8 +330,6 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
353
330
  const store = yield* Q.store
354
331
  yield* until(() => store.get(), (v) => v._tag === 'Success')
355
332
 
356
- // 2 starts a flight, 3 waits in the slot, then the gate closes: the flight's
357
- // stale result must not land (Idle), and the queued request must not run.
358
333
  source.set(2)
359
334
  yield* tick(5)
360
335
  source.set(3)
@@ -364,7 +339,6 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
364
339
  expect(store.get()._tag).toBe('Idle')
365
340
  expect(completed).toEqual([1, 2])
366
341
 
367
- // Re-enabling re-runs (the driver forgot its key when switched off).
368
342
  source.set(5)
369
343
  const settled = yield* until(() => store.get(), (v) => v._tag === 'Success')
370
344
  expect(settled).toMatchObject({ _tag: 'Success', value: 10 })
@@ -372,9 +346,6 @@ it.live('coalesce trailing: a disable while a request waits in the slot skips th
372
346
  }).pipe(Effect.provide(TestLayer))
373
347
  })
374
348
 
375
- // `invalidateOn` is sugar for a hidden revision state + reducer appended to the
376
- // key — these proofs need the Engine (the Reducers registry + bus + drain loop).
377
-
378
349
  it.live('invalidateOn: dispatching a listed event re-fetches with refetching: true', () => {
379
350
  class Count extends State.make('count', S.Number) {}
380
351
  class Inputs extends StateGroup.make(Count) {}
@@ -404,8 +375,6 @@ it.live('invalidateOn: dispatching a listed event re-fetches with refetching: tr
404
375
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: false })
405
376
  expect(runs.n).toBe(1)
406
377
 
407
- // The fact lands: the hidden revision bumps, the key moves, the value is
408
- // kept while the re-fetch is in flight (SWR), then settles.
409
378
  yield* Event.dispatch(Poke, {})
410
379
  yield* tick(5)
411
380
  expect(store.get()).toMatchObject({ _tag: 'Success', value: 4, refetching: true })
@@ -445,13 +414,10 @@ it.live('invalidateOn: composes with invalidateBy — non-key churn stays inert,
445
414
  yield* tick()
446
415
  expect(runs.n).toBe(1)
447
416
 
448
- // Outside the user key: still no re-fetch (the hidden revision rides along
449
- // WITHOUT widening the key to every input).
450
417
  status.set('b')
451
418
  yield* tick()
452
419
  expect(runs.n).toBe(1)
453
420
 
454
- // The listed event always re-fetches.
455
421
  yield* Event.dispatch(Poke, {})
456
422
  yield* tick()
457
423
  expect(runs.n).toBe(2)
@@ -487,8 +453,6 @@ it.live('invalidateOn: a listed event while disabled stays Idle, and enabling re
487
453
  yield* tick()
488
454
  expect(store.get()._tag).toBe('Idle')
489
455
 
490
- // A bump while gated off runs nothing — the driver shows Idle and forgets
491
- // its key, so the eventual enable always re-runs.
492
456
  yield* Event.dispatch(Poke, {})
493
457
  yield* tick()
494
458
  expect(store.get()._tag).toBe('Idle')
@@ -543,8 +507,6 @@ it.live('invalidateOn: two async calcs keep independent hidden revisions', () =>
543
507
  yield* tick()
544
508
  expect(runs).toEqual({ a: 1, b: 1 })
545
509
 
546
- // Each calc's hidden revision is namespaced by its own name: A's fact moves
547
- // only A.
548
510
  yield* Event.dispatch(PokeA, {})
549
511
  yield* tick()
550
512
  expect(runs).toEqual({ a: 2, b: 1 })