@playfast/reform 0.1.0 → 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 +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  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 +78 -173
  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 -202
  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 +48 -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 +10 -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 +131 -0
  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 +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  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/src/calc/calc.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
1
+ import { Context, Effect, Layer, MutableRef } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
2
3
  import { type Manifest, yieldableClass } from '../definition/definition'
3
4
  import {
4
5
  type InputsObject,
@@ -8,63 +9,40 @@ import {
8
9
  wireSources,
9
10
  } from '../internal/sources'
10
11
  import { resolveScheduler } from '../internal/scheduler'
12
+ import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
11
13
  import { reuse } from '../internal/reuse'
12
14
  import { makeDerivedStore, type Store } from '../internal/store'
13
15
  import { readTracked } from '../internal/track'
14
16
  import { type AnySource } from '../state/token'
17
+ import type { AnySchema } from '../internal/variance'
15
18
 
16
- // Re-exported so `Calc.InputsObject` / `Calc.InvalidateBy` keep their home here
17
- // even though the reactive plumbing now lives in `internal/sources`.
18
19
  export { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
19
20
 
20
- // Boundary config object: optional fields are the framework's public, JSON-like
21
- // definition surface (`ExternalApi` postfix exempts them from `no-optional-fields`).
22
21
  export interface CalcOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>> {
23
- /**
24
- * Custom invalidation: recompute only when the projected key changes by value
25
- * equality (React-Query's `queryKey`). Omitted ⇒ recompute when any input
26
- * value changes. Shared with `AsyncCalc`.
27
- */
28
22
  readonly invalidateBy?: InvalidateBy<Inputs>
29
- /**
30
- * Structural sharing: reconcile each recomputed output against the previous
31
- * one, substituting previous nodes wherever value equality holds — so
32
- * identities only move where values moved and downstream memo boundaries
33
- * (React subtrees) skip unchanged regions. Referentially transparent (the
34
- * result is value-equal to the fresh output); costs one O(output) walk per
35
- * recompute, so opt in for large projections.
36
- */
37
23
  readonly reuse?: boolean
38
24
  }
39
25
 
40
26
  export interface CalcManifest<N extends string, Out> extends Manifest {
41
27
  readonly kind: 'Calc'
42
28
  readonly name: N
43
- readonly output: Schema.Schema<Out, any>
29
+ readonly output: AnySchema<Out>
44
30
  }
45
31
 
46
32
  export interface CalcClass<out N extends string, out Inputs extends ReadonlyArray<AnySource>, in out Out>
47
- extends Effect.Effect<Out, never, Store<Out>> {
33
+ extends EffectType<Out, never, Store<Out>> {
48
34
  new (): {}
49
35
  readonly manifest: CalcManifest<N, Out>
50
36
  readonly store: Context.Tag<Store<Out>, Store<Out>>
51
- /** The calc's name, so it doubles as a `Source` input to another calc. */
52
37
  readonly name: N
53
- /** Marker data read by `Calc.live`. */
54
38
  readonly inputs: Inputs
55
39
  }
56
40
 
57
41
  export interface CalcConfig<Inputs extends ReadonlyArray<AnySource>, Out> {
58
42
  readonly inputs: Inputs
59
- readonly output: Schema.Schema<Out, any>
43
+ readonly output: AnySchema<Out>
60
44
  }
61
45
 
62
- /**
63
- * A derived, memoized value over explicit inputs (state members, other calcs, or
64
- * async calcs). The *definition* fixes the inputs and the output schema; the
65
- * *implementation* is the pure projection. Recomputes only when an input changes;
66
- * `yield* Calc` reads the memoized value.
67
- */
68
46
  export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, Out>(
69
47
  name: N,
70
48
  config: CalcConfig<Inputs, Out>,
@@ -75,28 +53,18 @@ export const make = <const N extends string, const Inputs extends ReadonlyArray<
75
53
  return yieldableClass(read, { manifest, store, name, inputs: config.inputs })
76
54
  }
77
55
 
78
- /**
79
- * Wire the pure projection for a calc. Recomputes only when its invalidation key
80
- * changes (by default every input value; override with `invalidateBy`), memoized
81
- * on that key; `yield* Calc` reads the memoized value. The derived store reuses
82
- * the shared coalescing scheduler, so a recompute that yields an equal value
83
- * wakes no subscriber.
84
- */
85
56
  export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, Out>(
86
57
  calc: CalcClass<N, Inputs, Out>,
87
58
  compute: (inputs: InputsObject<Inputs>) => Out,
88
59
  options: CalcOptionsExternalApi<Inputs> = {},
89
60
  ): Layer.Layer<Store<Out>, never, InputStores<Inputs>> =>
90
- // Scoped so the source subscriptions are released when the layer's scope
91
- // closes (each proof/test builds and disposes its own runtime).
92
61
  Layer.scoped(
93
62
  calc.store,
94
63
  Effect.gen(function* () {
95
64
  const scheduler = yield* resolveScheduler
65
+ const instrumentation = yield* resolveInstrumentation
96
66
  const sources = yield* wireSources(calc.inputs, options.invalidateBy)
97
67
 
98
- // Memo cell: key and output captured together so reads are always
99
- // consistent (no separate output that could lag the key it was computed for).
100
68
  const memo = MutableRef.make<
101
69
  { readonly key: ReadonlyArray<unknown>; readonly output: Out } | undefined
102
70
  >(undefined)
@@ -107,17 +75,21 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
107
75
  if (prev !== undefined && sameKey(key, prev.key)) {
108
76
  return prev.output
109
77
  }
78
+ const end = instrumentation.calcRecomputed(calc.manifest.name)
110
79
  const fresh = compute(args)
111
- // With `reuse`, a recompute that lands value-equal to the previous
112
- // output returns the previous reference — the derived store's Equal
113
- // gate then wakes no subscriber at all.
114
80
  const output =
115
81
  options.reuse === true && prev !== undefined ? reuse(prev.output, fresh) : fresh
82
+ end()
116
83
  MutableRef.set(memo, { key, output })
117
84
  return output
118
85
  }
119
86
 
120
- const derived = makeDerivedStore(recompute, sources.subscribe, scheduler)
87
+ const derived = makeDerivedStore(
88
+ recompute,
89
+ sources.subscribe,
90
+ scheduler,
91
+ stateUpdateHook(instrumentation, calc.manifest.name),
92
+ )
121
93
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
122
94
  return derived.store
123
95
  }),
@@ -2,14 +2,8 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Context, Duration, Effect, Exit, Layer, Schema as S, Scope } from 'effect'
3
3
  import { CalcFamily, State, StateGroup } from '../index'
4
4
 
5
- // CalcFamily: per-key derived stores over shared inputs. The behaviours under
6
- // test: members derive + memoize independently, a member only notifies when ITS
7
- // output moves, and every removal path releases the member's upstream
8
- // subscription (a derived member holds one; a StateFamily entry doesn't).
9
-
10
5
  const tick = Effect.sleep(Duration.millis(1))
11
6
 
12
- // Shared fixture shape: a record of named counters; each member projects one.
13
7
  const Board = S.Record({ key: S.String, value: S.Number })
14
8
 
15
9
  const fixture = () => {
@@ -60,7 +54,6 @@ it.live('a member only notifies when its own slice of the projection moves', ()
60
54
  wakes.b += 1
61
55
  })
62
56
 
63
- // Only `a` moves: b's member recomputes but its output is Equal — silent.
64
57
  feed.set({ a: 2, b: 10 })
65
58
  yield* tick
66
59
  expect(wakes).toEqual({ a: 1, b: 0 })
@@ -130,13 +123,11 @@ it.live('forget releases the member and its upstream subscription', () => {
130
123
 
131
124
  family.forget('a')
132
125
  expect(family.size()).toBe(0)
133
- // The dropped member no longer recomputes on upstream churn.
134
126
  const before = runs.get('a') ?? 0
135
127
  feed.set({ a: 2 })
136
128
  yield* tick
137
129
  expect(runs.get('a') ?? 0).toBe(before)
138
130
 
139
- // Re-`at`-ing allocates a fresh member over the current snapshot.
140
131
  expect(family.at('a').get()).toBe(2)
141
132
  }).pipe(Effect.provide(TestLayer))
142
133
  })
@@ -181,9 +172,6 @@ it.live('the layer finalizer clears every member (no live subscriptions leak)',
181
172
  const { Inputs, Slice, SliceLive, runs } = fixture()
182
173
  return Effect.scoped(
183
174
  Effect.gen(function* () {
184
- // Build the input stores once, then the family in its OWN scope over that
185
- // exact context — so the stores outlive the family and we can prove the
186
- // members' upstream subscriptions died with it.
187
175
  const storesCtx = yield* Layer.build(StateGroup.live(Inputs, { feed: { a: 1, b: 2 } }))
188
176
  const feed = Context.get(storesCtx, StateGroup.select(Inputs, 'feed').store)
189
177
  const scope = yield* Scope.make()
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
1
+ import { Context, Effect, Layer, MutableRef } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
3
  import {
4
4
  type InputsObject,
@@ -8,25 +8,18 @@ import {
8
8
  wireSources,
9
9
  } from '../internal/sources'
10
10
  import { resolveScheduler } from '../internal/scheduler'
11
+ import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
11
12
  import { makeDerivedStore, type Store } from '../internal/store'
12
13
  import { readTracked } from '../internal/track'
13
14
  import { type FamilyOptions, type FamilyStore } from '../state/stateFamily'
14
15
  import { type AnySource } from '../state/token'
15
-
16
- // `CalcFamily` is `Calc`'s keyed sibling, modeled on `StateFamily`: one derived,
17
- // memoized store per key over a SHARED set of inputs. Where a monolithic calc
18
- // rebuilds its whole output on any change (waking every consumer), a family
19
- // member recomputes lazily and notifies only when ITS slice of the projection
20
- // moves (the derived store's Equal gate) — per-row/per-group UI subscribes per
21
- // key and unrelated changes never wake it. Members are pure projections; a
22
- // Reducer cannot target a CalcFamily (its config requires a `StateFamily`
23
- // manifest), so calc purity holds structurally.
16
+ import type { AnySchema } from '../internal/variance'
24
17
 
25
18
  export interface CalcFamilyManifest<N extends string, K, Out> extends Manifest {
26
19
  readonly kind: 'CalcFamily'
27
20
  readonly name: N
28
- readonly key: Schema.Schema<K, any>
29
- readonly output: Schema.Schema<Out, any>
21
+ readonly key: AnySchema<K>
22
+ readonly output: AnySchema<Out>
30
23
  }
31
24
 
32
25
  export interface CalcFamilyClass<
@@ -35,25 +28,18 @@ export interface CalcFamilyClass<
35
28
  in out K,
36
29
  in out Out,
37
30
  > {
38
- /** Instance carries the key phantom so `Family['Key']` resolves in key types. */
39
31
  new (): { readonly Key: K }
40
32
  readonly manifest: CalcFamilyManifest<N, K, Out>
41
33
  readonly store: Context.Tag<FamilyStore<K, Out>, FamilyStore<K, Out>>
42
- /** Marker data read by `CalcFamily.live` and the editor's graph builder. */
43
34
  readonly inputs: Inputs
44
35
  }
45
36
 
46
37
  export interface CalcFamilyConfig<Inputs extends ReadonlyArray<AnySource>, K, Out> {
47
- readonly key: Schema.Schema<K, any>
38
+ readonly key: AnySchema<K>
48
39
  readonly inputs: Inputs
49
- readonly output: Schema.Schema<Out, any>
40
+ readonly output: AnySchema<Out>
50
41
  }
51
42
 
52
- /**
53
- * Define a keyed derived value: per-key memoized projections over shared
54
- * inputs. The *definition* fixes the key/output schemas and the inputs; the
55
- * *implementation* (the per-key projection) is supplied by `CalcFamily.live`.
56
- */
57
43
  export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, K, Out>(
58
44
  name: N,
59
45
  config: CalcFamilyConfig<Inputs, K, Out>,
@@ -74,38 +60,17 @@ export const make = <const N extends string, const Inputs extends ReadonlyArray<
74
60
  })
75
61
  }
76
62
 
77
- /**
78
- * Read one member by key — `CalcFamily.read(GroupProjection, id)`. Inside a
79
- * render it subscribes exactly that member's store (unrelated members never
80
- * wake the subtree); elsewhere it just snapshots — the same one-read-API
81
- * contract as `StateFamily.read`.
82
- */
83
63
  export const read = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
84
64
  family: CalcFamilyClass<N, Inputs, K, Out>,
85
65
  key: K,
86
66
  ): Effect.Effect<Out, never, FamilyStore<K, Out>> =>
87
67
  Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
88
68
 
89
- // Boundary config object: optional fields are the framework's public, JSON-like
90
- // definition surface (`ExternalApi` postfix exempts them from `no-optional-fields`).
91
69
  export interface CalcFamilyLiveOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>>
92
70
  extends FamilyOptions {
93
- /**
94
- * Family-wide invalidation key over the SHARED inputs (the member key plays
95
- * no part — it is fixed per member). Same contract as `Calc`'s.
96
- */
97
71
  readonly invalidateBy?: InvalidateBy<Inputs>
98
72
  }
99
73
 
100
- /**
101
- * Wire the per-key projection. `compute` is curried — `(key) => (inputs) =>
102
- * Out` — so per-key construction (closing over key-derived constants) runs once
103
- * per member while the inner projection runs per recompute. Each member is a
104
- * lazy derived store over the shared wired sources: memoized on the family's
105
- * invalidation key, notifying only when its own output moves. Unlike a
106
- * `StateFamily` entry, a member holds an upstream subscription, so every
107
- * removal path (forget / clear / eviction / the layer finalizer) releases it.
108
- */
109
74
  export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
110
75
  family: CalcFamilyClass<N, Inputs, K, Out>,
111
76
  compute: (key: K) => (inputs: InputsObject<Inputs>) => Out,
@@ -115,11 +80,11 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
115
80
  family.store,
116
81
  Effect.gen(function* () {
117
82
  const scheduler = yield* resolveScheduler
118
- // Wired ONCE for the whole family — members share the sensing surface.
83
+ const instrumentation = yield* resolveInstrumentation
84
+ const onOutputChange = stateUpdateHook(instrumentation, family.manifest.name)
119
85
  const sources = yield* wireSources(family.inputs, options.invalidateBy)
120
86
  const entries = new Map<K, { readonly store: Store<Out>; readonly unsubscribe: () => void }>()
121
87
  const evictWhenUnused = options.evictWhenUnused === true
122
- // Live subscriber count per key — maintained only when eviction is on.
123
88
  const subscribers = new Map<K, number>()
124
89
 
125
90
  const dropEntry = (key: K): void => {
@@ -131,7 +96,6 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
131
96
  subscribers.delete(key)
132
97
  }
133
98
 
134
- // One member: the same memo block as `Calc.live`, with the key closed over.
135
99
  const createMember = (key: K): { readonly store: Store<Out>; readonly unsubscribe: () => void } => {
136
100
  const body = compute(key)
137
101
  const memo = MutableRef.make<
@@ -144,16 +108,15 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
144
108
  if (prev !== undefined && sameKey(memoKey, prev.key)) {
145
109
  return prev.output
146
110
  }
111
+ const end = instrumentation.calcRecomputed(family.manifest.name)
147
112
  const output = body(args)
113
+ end()
148
114
  MutableRef.set(memo, { key: memoKey, output })
149
115
  return output
150
116
  }
151
- return makeDerivedStore(recompute, sources.subscribe, scheduler)
117
+ return makeDerivedStore(recompute, sources.subscribe, scheduler, onOutputChange)
152
118
  }
153
119
 
154
- // The `StateFamily` ref-count idiom: evict on the next microtask once a
155
- // key falls idle (a same-commit re-subscribe cancels it) — here eviction
156
- // also releases the member's upstream subscription via `dropEntry`.
157
120
  const refCounted = (key: K, store: Store<Out>): Store<Out> => ({
158
121
  ...store,
159
122
  subscribe: (listener) => {
@@ -2,10 +2,6 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Effect, Layer, Schema as S } from 'effect'
3
3
  import { Calc, composeCalcs, State, StateGroup } from '../index'
4
4
 
5
- // `composeCalcs` wires a calc-on-calc chain in dependency order. The shape under
6
- // test is the one that defeats a flat `Layer.mergeAll`: a linear chain where each
7
- // calc's only input is the previous calc — count → Doubled → PlusOne → Tripled.
8
-
9
5
  it.live('composeCalcs wires a linear calc chain; the result needs only the base state', () => {
10
6
  class Count extends State.make('count', S.Number) {}
11
7
  class Inputs extends StateGroup.make(Count) {}
@@ -22,23 +18,16 @@ it.live('composeCalcs wires a linear calc chain; the result needs only the base
22
18
  class Tripled extends Calc.make('Tripled', { inputs: [PlusOne], output: S.Number }) {}
23
19
  const TripledLive = Calc.live(Tripled, ({ PlusOne }) => PlusOne * 3)
24
20
 
25
- // Leaf-first, then upstreams in dependency order. The composed layer's only
26
- // remaining requirement is the base `count` store — which the type system
27
- // proves by letting us provide *only* the state group below.
28
- // The chain's only remaining requirement is the base `count` store — provide
29
- // (and expose, via provideMerge) just the state group and everything builds.
30
21
  const Chain = composeCalcs(TripledLive, PlusOneLive, DoubledLive)
31
22
  const TestLayer = Chain.pipe(Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })))
32
23
 
33
24
  return Effect.gen(function* () {
34
25
  const source = yield* StateGroup.select(Inputs, 'count').store
35
26
  const tripled = yield* Tripled.store
36
- // count 5 → Doubled 10 → PlusOne 11 → Tripled 33
37
27
  expect(tripled.get()).toBe(33)
38
28
 
39
29
  source.set(2)
40
30
  yield* Effect.yieldNow()
41
- // count 2 → Doubled 4 → PlusOne 5 → Tripled 15
42
31
  expect(tripled.get()).toBe(15)
43
32
  }).pipe(Effect.provide(TestLayer))
44
33
  })
@@ -63,6 +52,6 @@ it.live('composeCalcs(leaf, upstream) matches the hand-written provideMerge chai
63
52
 
64
53
  return Effect.gen(function* () {
65
54
  const derived = yield* PlusDoubled.store
66
- expect(derived.get()).toBe(15) // 5 + (5*2), same as the manual-pipe test
55
+ expect(derived.get()).toBe(15)
67
56
  }).pipe(Effect.provide(TestLayer))
68
57
  })
@@ -1,25 +1,5 @@
1
1
  import { Layer } from 'effect'
2
2
 
3
- // Wiring a calc-on-calc chain (`FilteredAgents` ← `AllAgents` ← `AgentsQuery`)
4
- // is the one calc-graph shape that trips people up: each upstream calc's store
5
- // must be *provided into* the downstream live, in dependency order. A flat
6
- // `Layer.mergeAll(FilteredLive, AllLive, QueryLive)` does NOT do this — `mergeAll`
7
- // merges outputs but never provides members to one another, so the upstream
8
- // calc stores stay as unmet `Store<…>` requirements (the TS2322 the migration
9
- // hit). The correct shape is `leaf.pipe(provideMerge(u1), provideMerge(u2), …)`;
10
- // `composeCalcs` is that fold under one name so the right thing is the easy thing.
11
-
12
- /**
13
- * Compose a calc-dependency chain: provide each upstream calc's `.live` layer
14
- * into the downstream one, nearest dependency first. The result exposes every
15
- * calc store in the chain and requires only the chain's *external* inputs (the
16
- * base state stores) — never the intermediate calc stores.
17
- *
18
- * Order is leaf-first, then its upstreams in dependency order:
19
- * `composeCalcs(FilteredLive, AllLive, QueryLive)` ≡
20
- * `FilteredLive.pipe(Layer.provideMerge(AllLive), Layer.provideMerge(QueryLive))`.
21
- * For chains deeper than three upstreams, nest `composeCalcs`.
22
- */
23
3
  export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn>(
24
4
  leaf: Layer.Layer<LOut, LErr, LIn>,
25
5
  upstream: Layer.Layer<AOut, AErr, AIn>,
@@ -60,8 +40,6 @@ export function composeCalcs(
60
40
  leaf: Layer.Layer<unknown, unknown, unknown>,
61
41
  ...upstream: ReadonlyArray<Layer.Layer<unknown, unknown, unknown>>
62
42
  ): Layer.Layer<unknown, unknown, unknown> {
63
- // Fold leaf-first: each upstream is provided into the accumulated downstream,
64
- // so dependency order is exactly the argument order.
65
43
  return upstream.reduce<Layer.Layer<unknown, unknown, unknown>>(
66
44
  (downstream, upstreamLayer) => Layer.provideMerge(downstream, upstreamLayer),
67
45
  leaf,
@@ -1,26 +1,13 @@
1
1
  import { Option } from 'effect'
2
2
  import { type AnyAsyncData, AsyncData } from './asyncData'
3
3
 
4
- // `QueryState` is the data-oriented value an `AsyncCalc` is driven by: the four
5
- // axes of a query, stored *flat and independent* so each can move on its own.
6
- // `data`/`error` survive a refetch (stale-while-revalidate for free); `isFetching`
7
- // (activity) and `isStale` (freshness) are orthogonal to both — which is exactly
8
- // what lets `invalidate` (flip `isStale`) and `refetch` (drive `isFetching`) be
9
- // separate operations. The matchable `AsyncData` union is *derived* from this
10
- // (`toAsyncData`), so view code and `RemoteState` keep their arm dispatch.
11
-
12
4
  export interface QueryState<A, E> {
13
- /** Last resolved value — kept across a refetch. */
14
5
  readonly data: Option.Option<A>
15
- /** Last failure — kept across a refetch. */
16
6
  readonly error: Option.Option<E>
17
- /** Activity axis: a run is in flight. */
18
7
  readonly isFetching: boolean
19
- /** Freshness axis: the value has been marked invalid (drives auto-refetch). */
20
8
  readonly isStale: boolean
21
9
  }
22
10
 
23
- /** The empty cell. `fetching` seeds the first-kick `Loading` for an enabled query. */
24
11
  export const empty = (fetching: boolean): QueryState<never, never> => ({
25
12
  data: Option.none(),
26
13
  error: Option.none(),
@@ -28,15 +15,6 @@ export const empty = (fetching: boolean): QueryState<never, never> => ({
28
15
  isStale: false,
29
16
  })
30
17
 
31
- /**
32
- * Project the flat state onto the public `AsyncData` union (the view contract) —
33
- * the single `Match`-able view of a query, so call sites keep `Match.tag`-ing the
34
- * arms. `error` wins over `data` so a failed refetch surfaces the `Error` arm,
35
- * matching the pre-`QueryState` driver; `isFetching` becomes the arm's
36
- * `refetching` flag. The data-less case is `Loading` while fetching (or for a
37
- * non-gated query, which always fetches) and `Idle` only for a switched-off
38
- * gateable query.
39
- */
40
18
  export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
41
19
  Option.match(state.error, {
42
20
  onSome: (error) => AsyncData.error(error, state.isFetching),
@@ -47,6 +25,5 @@ export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyA
47
25
  }),
48
26
  })
49
27
 
50
- /** `Loading` in the React-Query sense: fetching with nothing to show yet. */
51
28
  export const isLoading = <A, E>(state: QueryState<A, E>): boolean =>
52
29
  state.isFetching && Option.isNone(state.data)