@playfast/reform 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,16 +1,15 @@
1
1
  import { Context, Effect, Layer, Match, Option, Schema } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
2
3
  import { type Manifest, yieldableClass } from '../definition/definition'
3
4
  import { type AnyEvent } from '../event/event'
4
- import {
5
- type InputsObject,
6
- type InputStores,
7
- type InvalidateBy,
8
- } from '../internal/sources'
5
+ import { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
6
+ import type { AnySchema } from '../internal/variance'
9
7
  import {
10
8
  bumpRevision,
11
9
  type GatedOf,
12
10
  gatedFlag,
13
11
  makeQueryDriver,
12
+ type QueryCodec,
14
13
  RevisionSchema,
15
14
  revisionZero,
16
15
  } from '../internal/queryDriver'
@@ -20,62 +19,104 @@ import * as Reducer from '../reducer/reducer'
20
19
  import { Reducers } from '../runtime/loop'
21
20
  import { Queries } from '../runtime/queries'
22
21
  import * as State from '../state/state'
23
- import { type AnySource } from '../state/token'
22
+ import { type AnySource, type Source, type SourceCapture } from '../state/token'
24
23
  import { type AsyncData, narrowStore } from './asyncData'
25
24
 
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.
25
+ export interface AsyncCalcSchemaReflection {
26
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
27
+ }
33
28
 
34
- export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
29
+ export interface AsyncCalcManifest<N extends string, A = unknown, E = never> extends Manifest {
35
30
  readonly kind: 'AsyncCalc'
36
31
  readonly name: N
37
- readonly output: Schema.Schema<A, any>
32
+ readonly output: AnySchema<A>
38
33
  // 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). */
34
+ readonly error?: AnySchema<E>
41
35
  readonly gated: boolean
42
36
  }
43
37
 
44
- export interface AsyncCalcClass<
45
- out N extends string,
46
- out Inputs extends ReadonlyArray<AnySource>,
38
+ export const AsyncCalcStoreTypeId: unique symbol = Symbol.for('reform/AsyncCalcStore')
39
+ export type AsyncCalcStoreTypeId = typeof AsyncCalcStoreTypeId
40
+
41
+ interface AsyncCalcStoreCompatibilityExternalApi<
42
+ N extends string,
47
43
  in out A,
48
44
  in out E,
49
45
  in out Gated extends boolean,
50
- > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
46
+ > extends Store<AsyncData<A, E, Gated>> {
47
+ readonly [AsyncCalcStoreTypeId]?: N
48
+ }
49
+
50
+ export type AsyncCalcStore<
51
+ N extends string,
52
+ A,
53
+ E,
54
+ Gated extends boolean,
55
+ > = AsyncCalcStoreCompatibilityExternalApi<N, A, E, Gated>
56
+
57
+ export interface AsyncCalcClass<
58
+ N extends string,
59
+ Inputs extends ReadonlyArray<AnySource>,
60
+ A,
61
+ E,
62
+ Gated extends boolean,
63
+ >
64
+ extends
65
+ EffectType<AsyncData<A, E, Gated>, never, AsyncCalcStore<N, A, E, Gated>>,
66
+ Source<N, AsyncData<A, E, Gated>, AsyncCalcStore<N, A, E, Gated>> {
51
67
  new (): {}
52
68
  readonly manifest: AsyncCalcManifest<N, A, E>
53
- 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. */
69
+ readonly store: Context.Tag<AsyncCalcStore<N, A, E, Gated>, Store<AsyncData<A, E, Gated>>>
55
70
  readonly name: N
56
- /** Marker data read by `AsyncCalc.live`. */
57
71
  readonly inputs: Inputs
58
- /** Runtime mirror of `Gated`: whether `disabled` is honored / `Idle` can occur. */
59
72
  readonly gated: Gated
60
73
  }
61
74
 
62
- export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E, AlwaysOn extends boolean> {
75
+ export interface DefinedAsyncCalcClass<
76
+ N extends string,
77
+ Inputs extends ReadonlyArray<AnySource>,
78
+ A,
79
+ E,
80
+ Gated extends boolean,
81
+ > extends AsyncCalcClass<N, Inputs, A, E, Gated> {
82
+ readonly codec: QueryCodec<A>
83
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
84
+ }
85
+
86
+ type ConfigSchema<ValueOrSchema> = [ValueOrSchema] extends [Schema.Schema.AnyNoContext]
87
+ ? ValueOrSchema
88
+ : AnySchema<ValueOrSchema>
89
+
90
+ type ConfigErrorSchema<ValueOrSchema> = [ValueOrSchema] extends [undefined]
91
+ ? ValueOrSchema
92
+ : ConfigSchema<ValueOrSchema>
93
+
94
+ export interface AsyncCalcConfig<
95
+ Inputs extends ReadonlyArray<AnySource>,
96
+ OutputSchema,
97
+ ErrorSchema,
98
+ AlwaysOn extends boolean,
99
+ > {
63
100
  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. */
101
+ readonly output: ConfigSchema<OutputSchema>
67
102
  // 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`. */
103
+ readonly error?: ConfigErrorSchema<ErrorSchema>
70
104
  // 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
105
  readonly alwaysOn?: AlwaysOn
72
106
  }
73
107
 
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
- */
108
+ interface AsyncCalcMakeConfigExternalApi<
109
+ Inputs extends ReadonlyArray<AnySource>,
110
+ OutputSchema extends Schema.Schema.AnyNoContext,
111
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
112
+ AlwaysOn extends boolean,
113
+ > {
114
+ readonly inputs: Inputs
115
+ readonly output: OutputSchema
116
+ readonly error?: ErrorSchema
117
+ readonly alwaysOn?: AlwaysOn
118
+ }
119
+
79
120
  export type AsyncCalcLive<
80
121
  Inputs extends ReadonlyArray<AnySource>,
81
122
  A,
@@ -84,48 +125,19 @@ export type AsyncCalcLive<
84
125
  R,
85
126
  > = {
86
127
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
87
- // 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
128
  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
129
  // 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
130
  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
131
  // 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
132
  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
133
  // 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
134
  readonly persist?:
119
135
  | boolean
120
- // 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
121
136
  | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
122
137
  } & (Gated extends true
123
- ? // 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
124
- { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
125
- : // 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
- { readonly disabled?: never })
138
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
139
+ : { readonly disabled?: never })
127
140
 
128
- /** `.live` config plus the optional `invalidateOn` event list (impl signature). */
129
141
  export type AsyncCalcLiveWithInvalidateOn<
130
142
  Inputs extends ReadonlyArray<AnySource>,
131
143
  A,
@@ -133,47 +145,58 @@ export type AsyncCalcLiveWithInvalidateOn<
133
145
  Gated extends boolean,
134
146
  R,
135
147
  > = AsyncCalcLive<Inputs, A, E, Gated, R> & {
136
- // 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
137
148
  readonly invalidateOn?: ReadonlyArray<AnyEvent>
138
149
  }
139
150
 
140
- /** Loose runtime view of `.live` config — `disabled` recovered past its type erasure. */
141
151
  export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
142
152
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
143
- // 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
144
153
  readonly invalidateBy?: InvalidateBy<Inputs>
145
- // 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
146
154
  readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
147
- // 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
148
155
  readonly coalesce?: 'switch' | 'trailing'
149
- // 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
150
156
  readonly reuse?: boolean
151
- // 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
152
157
  readonly persist?:
153
158
  | boolean
154
- // 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
159
  | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
156
160
  }
157
161
 
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 = <
162
+ export function make<
164
163
  const N extends string,
165
164
  const Inputs extends ReadonlyArray<AnySource>,
166
- A,
167
- E = never,
165
+ OutputSchema extends Schema.Schema.AnyNoContext,
166
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
168
167
  const AlwaysOn extends boolean = false,
169
168
  >(
170
169
  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.
170
+ config: AsyncCalcConfig<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
171
+ ): DefinedAsyncCalcClass<
172
+ N,
173
+ Inputs,
174
+ Schema.Schema.Type<OutputSchema>,
175
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
176
+ GatedOf<AlwaysOn>
177
+ >
178
+ export function make<
179
+ const N extends string,
180
+ const Inputs extends ReadonlyArray<AnySource>,
181
+ OutputSchema extends Schema.Schema.AnyNoContext,
182
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
183
+ const AlwaysOn extends boolean = false,
184
+ >(
185
+ name: N,
186
+ config: AsyncCalcMakeConfigExternalApi<Inputs, OutputSchema, ErrorSchema, AlwaysOn>,
187
+ ): DefinedAsyncCalcClass<
188
+ N,
189
+ Inputs,
190
+ Schema.Schema.Type<OutputSchema>,
191
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
192
+ GatedOf<AlwaysOn>
193
+ > {
194
+ type A = Schema.Schema.Type<OutputSchema>
195
+ type E = ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never
196
+ const store = Context.GenericTag<
197
+ AsyncCalcStore<N, A, E, GatedOf<AlwaysOn>>,
198
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
199
+ >(`reform/asyncCalc/${name}`)
177
200
  const gated = gatedFlag(config.alwaysOn)
178
201
  const manifest: AsyncCalcManifest<N, A, E> = {
179
202
  kind: 'AsyncCalc',
@@ -182,30 +205,23 @@ export const make = <
182
205
  gated,
183
206
  ...(config.error !== undefined ? { error: config.error } : {}),
184
207
  }
208
+ const codec: QueryCodec<A> = {
209
+ encode: Schema.encode(config.output),
210
+ decode: Schema.decodeUnknown(config.output),
211
+ }
185
212
  const read = Effect.flatMap(store, readTracked)
186
- return yieldableClass(read, {
213
+ const calc: DefinedAsyncCalcClass<N, Inputs, A, E, GatedOf<AlwaysOn>> = yieldableClass(read, {
187
214
  manifest,
215
+ codec,
188
216
  store,
189
217
  name,
190
218
  inputs: config.inputs,
191
219
  gated,
220
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(calc),
192
221
  })
222
+ return calc
193
223
  }
194
224
 
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
225
  export function live<
210
226
  N extends string,
211
227
  Inputs extends ReadonlyArray<AnySource>,
@@ -218,7 +234,7 @@ export function live<
218
234
  config: AsyncCalcLive<Inputs, A, E, Gated, R> & {
219
235
  readonly invalidateOn: ReadonlyArray<AnyEvent>
220
236
  },
221
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R | Reducers>
237
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R | Reducers>
222
238
  export function live<
223
239
  N extends string,
224
240
  Inputs extends ReadonlyArray<AnySource>,
@@ -229,7 +245,7 @@ export function live<
229
245
  >(
230
246
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
231
247
  config: AsyncCalcLive<Inputs, A, E, Gated, R>,
232
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, InputStores<Inputs> | R>
248
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R>
233
249
  export function live<
234
250
  N extends string,
235
251
  Inputs extends ReadonlyArray<AnySource>,
@@ -240,49 +256,31 @@ export function live<
240
256
  >(
241
257
  calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
242
258
  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.
259
+ ): Layer.Layer<AsyncCalcStore<N, A, E, Gated>, never, InputStores<Inputs> | R | Reducers> {
251
260
  const revisionState =
252
261
  config.invalidateOn === undefined
253
262
  ? undefined
254
263
  : State.make(`${calc.manifest.name}/invalidateOn`, RevisionSchema)
255
264
 
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
265
  const driver = Layer.scoped(
259
266
  calc.store,
260
267
  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
- // 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
- const cfg = config as AsyncCalcLiveView<Inputs, A, E, R>
268
+ const cfg: AsyncCalcLiveView<Inputs, A, E, R> = config
265
269
 
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
270
  const persistOption = cfg.persist
271
271
  const persistKey =
272
272
  persistOption === true || persistOption === undefined || persistOption === false
273
273
  ? calc.manifest.name
274
- : persistOption.key ?? calc.manifest.name
274
+ : (persistOption.key ?? calc.manifest.name)
275
+ const persistCodec: QueryCodec<A> = {
276
+ encode: Schema.encode(calc.manifest.output),
277
+ decode: Schema.decodeUnknown(calc.manifest.output),
278
+ }
275
279
  const persist =
276
280
  persistOption === undefined || persistOption === false
277
281
  ? undefined
278
- : { key: persistKey, schema: calc.manifest.output }
282
+ : { key: persistKey, codec: persistCodec }
279
283
 
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
284
  const revision = yield* Match.value(revisionState).pipe(
287
285
  Match.when(undefined, () => Effect.succeed(undefined)),
288
286
  Match.orElse((state) =>
@@ -308,7 +306,6 @@ export function live<
308
306
  extraKey,
309
307
  })
310
308
 
311
- // The internal store is the full union; narrow to the definition's arms.
312
309
  return narrowStore<A, E, Gated>(driver.store)
313
310
  }),
314
311
  )
@@ -316,12 +313,6 @@ export function live<
316
313
  if (config.invalidateOn === undefined || revisionState === undefined) {
317
314
  return driver
318
315
  }
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
316
  const revisionReducer = Reducer.make(`${calc.manifest.name}/invalidateOn`, {
326
317
  states: [revisionState],
327
318
  events: config.invalidateOn,
@@ -331,14 +322,6 @@ export function live<
331
322
  )
332
323
  }
333
324
 
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
325
  export interface NamedCalc {
343
326
  readonly name: string
344
327
  }
@@ -348,11 +331,5 @@ export const invalidate = (calc: NamedCalc): Effect.Effect<void, never, Queries>
348
331
  Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
349
332
  )
350
333
 
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
334
  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
- )
335
+ Effect.flatMap(Queries, (queries) => Effect.sync(() => queries.byName.get(calc.name)?.refetch()))
@@ -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>> =>