@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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
package/src/calc/asyncCalc.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Match, Option
|
|
2
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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?:
|
|
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
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
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
|
+
)
|
package/src/calc/asyncData.ts
CHANGED
|
@@ -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>> =>
|
package/src/calc/calc.test.ts
CHANGED
|
@@ -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)
|
|
90
|
+
expect(derived.get()).toBe(15)
|
|
99
91
|
source.set(3)
|
|
100
92
|
yield* tick
|
|
101
|
-
expect(derived.get()).toBe(9)
|
|
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)
|
|
125
|
+
expect(c.get()).toBe(11)
|
|
137
126
|
|
|
138
127
|
source.set(5)
|
|
139
128
|
yield* tick
|
|
140
|
-
expect(c.get()).toBe(21)
|
|
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
|
})
|