@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
@@ -1,5 +1,6 @@
1
- import { Context, Effect, Layer, Match, Option, Schema } from 'effect'
2
- import { type Manifest, yieldableClass } from '../definition/definition'
1
+ import { Context, Effect, Layer, Match, Option } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
3
+ import { type Manifest } from '../definition/definition'
3
4
  import { type AnyEvent } from '../event/event'
4
5
  import {
5
6
  type InputsObject,
@@ -8,36 +9,25 @@ import {
8
9
  } from '../internal/sources'
9
10
  import {
10
11
  bumpRevision,
11
- type GatedOf,
12
- gatedFlag,
13
12
  makeQueryDriver,
14
13
  RevisionSchema,
15
14
  revisionZero,
16
15
  } from '../internal/queryDriver'
17
16
  import { type Store } from '../internal/store'
18
- import { readTracked } from '../internal/track'
19
17
  import * as Reducer from '../reducer/reducer'
20
18
  import { Reducers } from '../runtime/loop'
21
- import { Queries } from '../runtime/queries'
22
19
  import * as State from '../state/state'
23
20
  import { type AnySource } from '../state/token'
24
21
  import { type AsyncData, narrowStore } from './asyncData'
25
-
26
- // `AsyncCalc` is `Calc`'s async sibling: a derived store whose value is the
27
- // lifecycle of an Effect (a query), recomputed reactively from its inputs — the
28
- // React-Query model, but inside the reform graph and without dispatching events.
29
- // Definition (`make`) is reflectable data; implementation (`live`) is the effect.
30
- // The reactive machinery lives in `internal/queryDriver` (shared with
31
- // `RemoteState`); this module owns the definition shape and the `invalidateOn`
32
- // sugar.
22
+ import type { AnySchema, AnyValue } from '../internal/variance'
23
+ export { invalidate, make, type NamedCalc, refetch } from './asyncCalcDefinitions'
33
24
 
34
25
  export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
35
26
  readonly kind: 'AsyncCalc'
36
27
  readonly name: N
37
- readonly output: Schema.Schema<A, any>
28
+ readonly output: AnySchema<A>
38
29
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
39
- readonly error?: Schema.Schema<E, any>
40
- /** Whether the query can be disabled (drives the `Idle` arm). */
30
+ readonly error?: AnySchema<E>
41
31
  readonly gated: boolean
42
32
  }
43
33
 
@@ -47,35 +37,24 @@ export interface AsyncCalcClass<
47
37
  in out A,
48
38
  in out E,
49
39
  in out Gated extends boolean,
50
- > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
40
+ > extends EffectType<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
51
41
  new (): {}
52
42
  readonly manifest: AsyncCalcManifest<N, A, E>
53
43
  readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
54
- /** The calc's name, so it doubles as a `Source` input to another (async) calc. */
55
44
  readonly name: N
56
- /** Marker data read by `AsyncCalc.live`. */
57
45
  readonly inputs: Inputs
58
- /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
59
46
  readonly gated: Gated
60
47
  }
61
48
 
62
49
  export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E, AlwaysOn extends boolean> {
63
50
  readonly inputs: Inputs
64
- /** Schema of the `Success` value. */
65
- readonly output: Schema.Schema<A, any>
66
- /** Schema of the failure. Omitted ⇒ the query is infallible and there is no `Error` arm. */
51
+ readonly output: AnySchema<A>
67
52
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
68
- readonly error?: Schema.Schema<E, any>
69
- /** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
53
+ readonly error?: AnySchema<E>
70
54
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
71
55
  readonly alwaysOn?: AlwaysOn
72
56
  }
73
57
 
74
- /**
75
- * The `.live` config: the effect plus the reactive logic. `disabled` is only
76
- * accepted for a gateable query (no `alwaysOn`); `invalidateBy` is the same
77
- * `queryKey` projection `Calc` uses.
78
- */
79
58
  export type AsyncCalcLive<
80
59
  Inputs extends ReadonlyArray<AnySource>,
81
60
  A,
@@ -86,34 +65,10 @@ export type AsyncCalcLive<
86
65
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
87
66
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
88
67
  readonly invalidateBy?: InvalidateBy<Inputs>
89
- /**
90
- * Refetch conciliation. `'switch'` (default): a new key cancels the in-flight
91
- * run (latest-wins). `'trailing'`: the in-flight run completes; key changes
92
- * that arrive meanwhile conflate to the latest, and exactly ONE trailing
93
- * refetch runs after it settles — a burst of N invalidations during one
94
- * flight costs 2 fetches, not N cancel-restarts.
95
- */
96
68
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
97
69
  readonly coalesce?: 'switch' | 'trailing'
98
- /**
99
- * Structural sharing for `Success` values: reconcile each refetch result
100
- * against the previous one, substituting previous nodes wherever value
101
- * equality holds — refetches returning mostly-identical data keep unchanged
102
- * subtree identities, so downstream memo boundaries skip them. Same pass as
103
- * `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
104
- */
105
70
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
106
71
  readonly reuse?: boolean
107
- /**
108
- * Persist the `Success` value through an optional host `QueryStore` (e.g.
109
- * `@playfast/reform-query-browser`'s localStorage layer): hydrate it on build
110
- * so the value shows instantly (marked stale, so it still refetches) and write
111
- * through on each settle. `true` keys it by the calc name; pass `{ key }` to
112
- * override — a static string for a singleton, or a function of the inputs to
113
- * key a family per-entity (so reactive-input keyed reads never collide on one
114
- * slot). Values (de)serialize through the calc's `output` schema. No host
115
- * `QueryStore` in context ⇒ inert (no hard requirement added).
116
- */
117
72
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
118
73
  readonly persist?:
119
74
  | boolean
@@ -125,7 +80,6 @@ export type AsyncCalcLive<
125
80
  : // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
126
81
  { readonly disabled?: never })
127
82
 
128
- /** `.live` config plus the optional `invalidateOn` event list (impl signature). */
129
83
  export type AsyncCalcLiveWithInvalidateOn<
130
84
  Inputs extends ReadonlyArray<AnySource>,
131
85
  A,
@@ -137,7 +91,6 @@ export type AsyncCalcLiveWithInvalidateOn<
137
91
  readonly invalidateOn?: ReadonlyArray<AnyEvent>
138
92
  }
139
93
 
140
- /** Loose runtime view of `.live` config — `disabled` recovered past its type erasure. */
141
94
  export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
142
95
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
143
96
  // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
@@ -155,57 +108,6 @@ export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E
155
108
  | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
156
109
  }
157
110
 
158
- /**
159
- * Define an async derived value. `output`/`error` schemas and `alwaysOn` shape
160
- * the value type, so `yield* MyQuery` is typed to exactly the arms that can
161
- * occur. The query effect itself is supplied by `AsyncCalc.live`.
162
- */
163
- export const make = <
164
- const N extends string,
165
- const Inputs extends ReadonlyArray<AnySource>,
166
- A,
167
- E = never,
168
- const AlwaysOn extends boolean = false,
169
- >(
170
- name: N,
171
- config: AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
172
- ): AsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> => {
173
- const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
174
- `reform/asyncCalc/${name}`,
175
- )
176
- // Runtime mirror of the type-level `Gated`, typed as its literal in one place.
177
- const gated = gatedFlag(config.alwaysOn)
178
- const manifest: AsyncCalcManifest<N, A, E> = {
179
- kind: 'AsyncCalc',
180
- name,
181
- output: config.output,
182
- gated,
183
- ...(config.error !== undefined ? { error: config.error } : {}),
184
- }
185
- const read = Effect.flatMap(store, readTracked)
186
- return yieldableClass(read, {
187
- manifest,
188
- store,
189
- name,
190
- inputs: config.inputs,
191
- gated,
192
- })
193
- }
194
-
195
- /**
196
- * Wire the query and its reactive logic. The query runs on the runtime (its `R`
197
- * — RPC clients, etc. — is captured from the layer context). A change to an
198
- * input re-runs it latest-wins (a new run cancels the in-flight one; or, with
199
- * `coalesce: 'trailing'`, lets it finish and runs one trailing refetch); while a
200
- * re-run is in flight the last `Success`/`Error` is kept with `refetching: true`.
201
- * Re-runs are driven by inputs / `invalidateBy` / `disabled` — and, with
202
- * `invalidateOn`, by listed events: pure sugar that generates a hidden revision
203
- * state + reducer (the sole writer) and appends the revision to the calc's key,
204
- * so an event-driven refetch is still just key movement under `Equal.equals`.
205
- * The hidden pair stays internal — the user's `query`/`invalidateBy` see exactly
206
- * the declared inputs — and costs the layer one extra requirement: the
207
- * `Reducers` registry (the `Engine`).
208
- */
209
111
  export function live<
210
112
  N extends string,
211
113
  Inputs extends ReadonlyArray<AnySource>,
@@ -240,33 +142,18 @@ export function live<
240
142
  >(
241
143
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
242
144
  config: AsyncCalcLiveWithInvalidateOn<Inputs, A, E, Gated, R>,
243
- // The wide-R implementation signature behind precise overloads — the same
244
- // seam `Reducer.live` uses; both overload returns are assignable (RIn is
245
- // covariant), so no value is ever cast.
246
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, any> {
247
- // The hidden revision pair, built once per `live` call. Namespacing the state
248
- // by the calc's name (`reform/state/${calcName}/invalidateOn`) keeps the tag
249
- // out of any plausible user namespace; the duplicate-reducer check is
250
- // warning-only and calc names are unique by convention.
145
+ ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, AnyValue> {
251
146
  const revisionState =
252
147
  config.invalidateOn === undefined
253
148
  ? undefined
254
149
  : State.make(`${calc.manifest.name}/invalidateOn`, RevisionSchema)
255
150
 
256
- // Scoped so the source subscriptions and the driver fiber are released when
257
- // the layer's scope closes (each proof/test builds and disposes its runtime).
258
151
  const driver = Layer.scoped(
259
152
  calc.store,
260
153
  Effect.gen(function* () {
261
- // `disabled` is rejected at the type level for non-gated calcs (erased to
262
- // `never` there); read it through a loose view for the runtime.
263
154
  // oxlint-disable-next-line reform-rules/no-type-assertion -- loose runtime view: `disabled` is type-erased to `never` for non-gated calcs, no guard recovers it
264
155
  const cfg = config as AsyncCalcLiveView<Inputs, A, E, R>
265
156
 
266
- // `persist: true` keys by the calc name; `{ key }` overrides — a static
267
- // string for a singleton, or a function of the inputs for a keyed family
268
- // (the driver resolves the function per-input at hydrate/write). The driver
269
- // (de)serializes through the calc's own `output` schema.
270
157
  const persistOption = cfg.persist
271
158
  const persistKey =
272
159
  persistOption === true || persistOption === undefined || persistOption === false
@@ -277,12 +164,6 @@ export function live<
277
164
  ? undefined
278
165
  : { key: persistKey, schema: calc.manifest.output }
279
166
 
280
- // The hidden revision store, read requirement-free (`serviceOption`): the
281
- // assembly below always provides it alongside this driver, and feeding it
282
- // through `extraKey` (not `wireSources`) keeps it out of the snapshot —
283
- // the user's `query` and `invalidateBy` receive exactly
284
- // `InputsObject<Inputs>`, with no hidden property to leak into
285
- // spread-into-RPC payloads.
286
167
  const revision = yield* Match.value(revisionState).pipe(
287
168
  Match.when(undefined, () => Effect.succeed(undefined)),
288
169
  Match.orElse((state) =>
@@ -308,7 +189,6 @@ export function live<
308
189
  extraKey,
309
190
  })
310
191
 
311
- // The internal store is the full union; narrow to the definition's arms.
312
192
  return narrowStore<A, E, Gated>(driver.store)
313
193
  }),
314
194
  )
@@ -316,12 +196,6 @@ export function live<
316
196
  if (config.invalidateOn === undefined || revisionState === undefined) {
317
197
  return driver
318
198
  }
319
- // The hidden reducer: an ordinary `Reducer` folding every listed event to
320
- // n + 1 — the revision's sole writer, registered/unregistered with this
321
- // layer's scope like any user reducer. `Layer.provide` builds the hidden
322
- // state once and feeds the same store to both the driver and the reducer,
323
- // while the outer context (user inputs, the `Reducers` registry) passes
324
- // through untouched.
325
199
  const revisionReducer = Reducer.make(`${calc.manifest.name}/invalidateOn`, {
326
200
  states: [revisionState],
327
201
  events: config.invalidateOn,
@@ -330,29 +204,3 @@ export function live<
330
204
  Layer.provide(State.live(revisionState, revisionZero)),
331
205
  )
332
206
  }
333
-
334
- /**
335
- * Mark a query's value stale (`isStale := true`) — without fetching. A stale
336
- * query with active readers (a mounted view) refetches; an unread one refetches
337
- * when next subscribed. The imperative counterpart of `invalidateOn`, and what a
338
- * focus/online provider layer calls. A no-op if the calc isn't live; resolves the
339
- * `Queries` registry (part of `Engine`).
340
- */
341
- /** The minimal calc shape `invalidate`/`refetch` need: just its registry name. */
342
- export interface NamedCalc {
343
- readonly name: string
344
- }
345
-
346
- export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
347
- Effect.flatMap(Queries, (queries) =>
348
- Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
349
- )
350
-
351
- /**
352
- * Force an immediate refetch of the current key, bypassing the no-op-key guard
353
- * and independent of `isStale`. A no-op if the calc isn't live or is disabled.
354
- */
355
- export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
356
- Effect.flatMap(Queries, (queries) =>
357
- Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
358
- )
@@ -0,0 +1,48 @@
1
+ import { Context, Effect } from 'effect'
2
+ import { yieldableClass } from '../definition/definition'
3
+ import { type GatedOf, gatedFlag } from '../internal/queryDriver'
4
+ import type { Store } from '../internal/store'
5
+ import { readTracked } from '../internal/track'
6
+ import { Queries } from '../runtime/queries'
7
+ import type { AnySource } from '../state/token'
8
+ import type { AsyncData } from './asyncData'
9
+ import type { AsyncCalcClass, AsyncCalcConfig, AsyncCalcManifest } from './asyncCalc'
10
+
11
+ export const make = <
12
+ const N extends string,
13
+ const Inputs extends ReadonlyArray<AnySource>,
14
+ A,
15
+ E = never,
16
+ const AlwaysOn extends boolean = false,
17
+ >(
18
+ name: N,
19
+ config: AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
20
+ ): AsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> => {
21
+ const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
22
+ `reform/asyncCalc/${name}`,
23
+ )
24
+ const gated = gatedFlag(config.alwaysOn)
25
+ const manifest: AsyncCalcManifest<N, A, E> = {
26
+ kind: 'AsyncCalc',
27
+ name,
28
+ output: config.output,
29
+ gated,
30
+ ...(config.error !== undefined ? { error: config.error } : {}),
31
+ }
32
+ const read = Effect.flatMap(store, readTracked)
33
+ return yieldableClass(read, { manifest, store, name, inputs: config.inputs, gated })
34
+ }
35
+
36
+ export interface NamedCalc {
37
+ readonly name: string
38
+ }
39
+
40
+ export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
41
+ Effect.flatMap(Queries, (queries) =>
42
+ Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
43
+ )
44
+
45
+ export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
46
+ Effect.flatMap(Queries, (queries) =>
47
+ Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
48
+ )
@@ -1,50 +1,31 @@
1
1
  import { type Store } from '../internal/store'
2
2
 
3
- // The value an `AsyncCalc` holds: a tagged union of the query's lifecycle,
4
- // consumed with `effect`'s `Match` (`Match.value(data).pipe(Match.tag('Success', …))`).
5
- //
6
- // The shape is narrowed by the definition, so you only handle arms that can
7
- // occur: `Idle` exists only for a gateable query (no `alwaysOn`), and `Error`
8
- // exists only when the query declares an `error` schema. There is no separate
9
- // "refreshing" arm — a re-fetch keeps the last `Success`/`Error` and flips its
10
- // `refetching` flag (stale-while-revalidate), so the UI never flashes empty.
11
-
12
- /** A gated query that is currently switched off (only when not `alwaysOn`). */
13
3
  export interface AsyncIdle {
14
4
  readonly _tag: 'Idle'
15
5
  }
16
6
 
17
- /** The first fetch, before any value exists. */
18
7
  export interface AsyncLoading {
19
8
  readonly _tag: 'Loading'
20
9
  }
21
10
 
22
- /** A resolved value; `refetching` is true while a background re-fetch runs. */
23
11
  export interface AsyncSuccess<A> {
24
12
  readonly _tag: 'Success'
25
13
  readonly value: A
26
14
  readonly refetching: boolean
27
15
  }
28
16
 
29
- /** A failed query; `refetching` is true while a background re-fetch runs. */
30
17
  export interface AsyncError<E> {
31
18
  readonly _tag: 'Error'
32
19
  readonly error: E
33
20
  readonly refetching: boolean
34
21
  }
35
22
 
36
- /**
37
- * The query lifecycle. `Gated` adds the `Idle` arm (a disable-able query); a
38
- * non-`never` `E` adds the `Error` arm. Both are decided at `AsyncCalc.make`, so
39
- * `yield* MyQuery` is typed to exactly the arms that can occur.
40
- */
41
23
  export type AsyncData<A, E = never, Gated extends boolean = false> =
42
24
  | (Gated extends true ? AsyncIdle : never)
43
25
  | AsyncLoading
44
26
  | AsyncSuccess<A>
45
27
  | ([E] extends [never] ? never : AsyncError<E>)
46
28
 
47
- /** The full (un-narrowed) union — used internally by the live store. */
48
29
  export type AnyAsyncData<A, E> = AsyncIdle | AsyncLoading | AsyncSuccess<A> | AsyncError<E>
49
30
 
50
31
  const idle: AsyncIdle = { _tag: 'Idle' }
@@ -60,7 +41,6 @@ const error = <E>(err: E, refetching = false): AsyncError<E> => ({
60
41
  refetching,
61
42
  })
62
43
 
63
- /** The arm-constructor namespace exposed as `AsyncData`. */
64
44
  export interface AsyncDataConstructors {
65
45
  readonly idle: AsyncIdle
66
46
  readonly loading: AsyncLoading
@@ -68,31 +48,14 @@ export interface AsyncDataConstructors {
68
48
  readonly error: <E>(err: E, refetching?: boolean) => AsyncError<E>
69
49
  }
70
50
 
71
- /**
72
- * Constructors for the arms, namespaced under the same name as the type so call
73
- * sites read `AsyncData.success(v)` / `AsyncData.error(e)` — no `error` import
74
- * shadowing the keyword and one obvious home for every arm.
75
- */
76
51
  export const AsyncData: AsyncDataConstructors = { idle, loading, success, error }
77
52
 
78
- /**
79
- * Narrow the live store (which holds the full `AnyAsyncData` union) to the arms
80
- * the definition actually permits (`Gated`/`E`). The single documented home for
81
- * that narrowing, so `AsyncCalc.live` returns it without an inline cast.
82
- */
83
53
  export const narrowStore = <A, E, Gated extends boolean>(
84
54
  store: Store<AnyAsyncData<A, E>>,
85
55
  ): Store<AsyncData<A, E, Gated>> =>
86
56
  // oxlint-disable-next-line reform-rules/no-type-assertion -- type-level narrow of the SAME runtime store to the definition's permitted arms; the single documented home for this seam
87
57
  store as unknown as Store<AsyncData<A, E, Gated>>
88
58
 
89
- /**
90
- * The read-only inverse of `narrowStore`: widen a definition-narrowed store back
91
- * to the full union, so a consumer (the `RemoteState` overlay) can dispatch on the
92
- * lifecycle arms without carrying the definition's `Gated`/`E` conditionals.
93
- * Sound for reads only — every narrowed value IS an `AnyAsyncData` — so callers
94
- * must never `set` through the widened view (derived stores ignore `set` anyway).
95
- */
96
59
  export const widenStore = <A, E, Gated extends boolean>(
97
60
  store: Store<AsyncData<A, E, Gated>>,
98
61
  ): Store<AnyAsyncData<A, E>> =>
@@ -2,15 +2,11 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Duration, Effect, Layer, Schema as S } from 'effect'
3
3
  import { Calc, State, StateGroup } from '../index'
4
4
 
5
- // The calc store memoizes on input identity and — the behaviour under test —
6
- // only wakes its subscribers when a source change actually moves the output.
7
-
8
5
  const tick = Effect.sleep(Duration.millis(1))
9
6
 
10
7
  it.live('a source change that does not move the output notifies no subscriber', () => {
11
8
  class Count extends State.make('count', S.Number) {}
12
9
  class Inputs extends StateGroup.make(Count) {}
13
- // `isPositive` is stable across 1 -> 2 (still true) but flips on 2 -> -1.
14
10
  class IsPositive extends Calc.make('IsPositive', {
15
11
  inputs: [StateGroup.select(Inputs, 'count')],
16
12
  output: S.Boolean,
@@ -33,13 +29,11 @@ it.live('a source change that does not move the output notifies no subscriber',
33
29
 
34
30
  expect(derived.get()).toBe(true)
35
31
 
36
- // 1 -> 2: inputs changed, recompute runs, but the output (true) is unchanged.
37
32
  source.set(2)
38
33
  yield* tick
39
34
  expect(notifications.n).toBe(0)
40
35
  expect(derived.get()).toBe(true)
41
36
 
42
- // 2 -> -1: output flips to false — exactly one notification.
43
37
  source.set(-1)
44
38
  yield* tick
45
39
  expect(notifications.n).toBe(1)
@@ -65,7 +59,6 @@ it.live('reading does not recompute while inputs are unchanged (memoized)', () =
65
59
  return Effect.gen(function* () {
66
60
  const derived = yield* Doubled.store
67
61
  const before = runs.n
68
- // Repeated reads with no input change hit the memo — no extra compute.
69
62
  expect(derived.get()).toBe(10)
70
63
  expect(derived.get()).toBe(10)
71
64
  expect(runs.n).toBe(before)
@@ -80,7 +73,6 @@ it.live('a calc can depend on another calc as an input source', () => {
80
73
  output: S.Number,
81
74
  }) {}
82
75
  const DoubledLive = Calc.live(Doubled, ({ count }) => count * 2)
83
- // `Doubled` (a Calc) is used directly as an input source; its name is the key.
84
76
  class PlusDoubled extends Calc.make('PlusDoubled', {
85
77
  inputs: [StateGroup.select(Inputs, 'count'), Doubled],
86
78
  output: S.Number,
@@ -95,17 +87,14 @@ it.live('a calc can depend on another calc as an input source', () => {
95
87
  return Effect.gen(function* () {
96
88
  const source = yield* StateGroup.select(Inputs, 'count').store
97
89
  const derived = yield* PlusDoubled.store
98
- expect(derived.get()).toBe(15) // 5 + (5*2)
90
+ expect(derived.get()).toBe(15)
99
91
  source.set(3)
100
92
  yield* tick
101
- expect(derived.get()).toBe(9) // 3 + (3*2)
93
+ expect(derived.get()).toBe(9)
102
94
  }).pipe(Effect.provide(TestLayer))
103
95
  })
104
96
 
105
97
  it.live('a diamond dependency resolves in one flush with a single leaf notification', () => {
106
- // count ─┬─► A (count+1) ─┐
107
- // └─► B (count+10)─┴─► C (A+B). A change to `count` moves both A and B;
108
- // C must converge to the new value and wake its subscriber exactly once.
109
98
  class Count extends State.make('count', S.Number) {}
110
99
  class Inputs extends StateGroup.make(Count) {}
111
100
  class A extends Calc.make('A', {
@@ -133,11 +122,11 @@ it.live('a diamond dependency resolves in one flush with a single leaf notificat
133
122
  c.subscribe(() => {
134
123
  wakes.n += 1
135
124
  })
136
- expect(c.get()).toBe(11) // (0+1) + (0+10)
125
+ expect(c.get()).toBe(11)
137
126
 
138
127
  source.set(5)
139
128
  yield* tick
140
- expect(c.get()).toBe(21) // (5+1) + (5+10)
129
+ expect(c.get()).toBe(21)
141
130
  expect(wakes.n).toBe(1)
142
131
  }).pipe(Effect.provide(TestLayer))
143
132
  })
@@ -168,13 +157,11 @@ it.live('invalidateBy: a calc recomputes only when the projected key moves', ()
168
157
  const base = runs.n
169
158
  expect(sum.get()).toBe(2)
170
159
 
171
- // `b` moves but the key (just `a`) is unchanged — no recompute, value retained.
172
160
  sb.set(100)
173
161
  yield* tick
174
162
  expect(runs.n).toBe(base)
175
163
  expect(sum.get()).toBe(2)
176
164
 
177
- // `a` moves — recompute, now seeing the current `b`.
178
165
  sa.set(5)
179
166
  yield* tick
180
167
  expect(sum.get()).toBe(105)
@@ -184,11 +171,6 @@ it.live('invalidateBy: a calc recomputes only when the projected key moves', ()
184
171
  it.live('inputs are keyed by the make() name even when the subclass binding differs', () => {
185
172
  class Count extends State.make('count', S.Number) {}
186
173
  class Inputs extends StateGroup.make(Count) {}
187
- // The binding name ('Renamed…') deliberately differs from the make() name
188
- // ('feed') — a class declaration defines its OWN static `name` from the
189
- // binding, shadowing the explicit one. Minifiers rename bindings, so keying
190
- // the snapshot off the static would make this input undefined in production
191
- // builds; the inputs object must key by the manifest's make() name.
192
174
  class RenamedByTheMinifier extends Calc.make('feed', {
193
175
  inputs: [StateGroup.select(Inputs, 'count')],
194
176
  output: S.Number,
@@ -210,9 +192,7 @@ it.live('inputs are keyed by the make() name even when the subclass binding diff
210
192
  )
211
193
 
212
194
  return Effect.gen(function* () {
213
- // The subclass's own static name is the (would-be-minified) binding name…
214
195
  expect(RenamedByTheMinifier.name).toBe('RenamedByTheMinifier')
215
- // …but the snapshot keys by the make() name, so `feed` is defined.
216
196
  const downstream = yield* Downstream.store
217
197
  expect(downstream.get()).toBe(7)
218
198
  expect(seen.every((v) => v !== undefined)).toBe(true)
@@ -243,7 +223,6 @@ it.live('reuse: an unchanged subtree keeps its identity across recompute', () =>
243
223
  const derived = yield* Split.store
244
224
  const before = derived.get()
245
225
 
246
- // Only `b` moves: the root and `right` are fresh, `left` keeps its identity.
247
226
  b.set(2)
248
227
  yield* tick
249
228
  const after = derived.get()
@@ -262,9 +241,6 @@ it.live('reuse: a value-equal recompute keeps the previous reference and wakes n
262
241
  inputs: [StateGroup.select(Inputs, 'count')],
263
242
  output: Box,
264
243
  }) {}
265
- // A plain-object output: WITHOUT reuse every recompute is a fresh identity
266
- // (and would notify); with it, a value-equal recompute returns the previous
267
- // reference and the Equal gate stays silent.
268
244
  const IsPositiveLive = Calc.live(IsPositive, ({ count }) => ({ positive: count > 0 }), {
269
245
  reuse: true,
270
246
  })