@playfast/reform 0.0.10 → 0.1.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.
- package/README.md +11 -11
- package/package.json +1 -1
- package/src/boundary/boundary.ts +16 -7
- package/src/calc/asyncCalc.ts +84 -37
- package/src/calc/asyncData.ts +8 -4
- package/src/calc/calc.ts +7 -3
- package/src/calc/calcFamily.ts +24 -10
- package/src/calc/compose.ts +1 -1
- package/src/calc/queryState.ts +8 -8
- package/src/channel/channel.ts +63 -37
- package/src/compose/composition.ts +20 -1
- package/src/compose/provide.ts +5 -1
- package/src/compose/slot.ts +4 -2
- package/src/compose/structure.ts +43 -19
- package/src/compose/ui.ts +21 -2
- package/src/compose/ui.typecheck.ts +4 -4
- package/src/definition/definition.ts +20 -7
- package/src/feature/feature.test.ts +4 -4
- package/src/feature/feature.ts +30 -16
- package/src/feature/feature.typecheck.ts +2 -2
- package/src/internal/capture.ts +1 -0
- package/src/internal/errors.ts +9 -4
- package/src/internal/inspect.ts +4 -4
- package/src/internal/queryDriver.ts +102 -53
- package/src/internal/reuse.ts +67 -30
- package/src/internal/scheduler.ts +35 -23
- package/src/internal/sources.ts +14 -8
- package/src/internal/stateRegistry.ts +3 -1
- package/src/internal/store.ts +10 -8
- package/src/internal/track.ts +3 -1
- package/src/procedure/procedure.ts +2 -2
- package/src/reducer/reducer.ts +17 -9
- package/src/remote/remoteState.test.ts +188 -1
- package/src/remote/remoteState.ts +112 -51
- package/src/remote/remoteState.typecheck.ts +4 -1
- package/src/runtime/bus.ts +3 -1
- package/src/runtime/hardening.test.ts +1 -1
- package/src/runtime/loop.ts +67 -46
- package/src/runtime/queries.ts +3 -1
- package/src/scene/scene.ts +16 -8
- package/src/state/state.ts +11 -10
- package/src/state/stateFamily.ts +27 -11
- package/src/state/stateGroup.ts +22 -9
- package/src/synced/syncedStore.ts +15 -9
- package/src/wire/tree.ts +66 -30
- package/src/wire/triggers.ts +9 -8
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ State, events, reducers, derived values, async & remote data, and compositions
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/react`](https://www.npmjs.com/package/@playfast/react), [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native)) turns a closed *scene* into a live tree, and [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
|
|
16
|
+
reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react), [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native)) turns a closed *scene* into a live tree, and [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -474,7 +474,7 @@ const AppScene = scene(AppRoot, {
|
|
|
474
474
|
})
|
|
475
475
|
```
|
|
476
476
|
|
|
477
|
-
`provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) to mount or to [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
|
|
477
|
+
`provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) to mount or to [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
|
|
478
478
|
|
|
479
479
|
---
|
|
480
480
|
|
|
@@ -495,7 +495,7 @@ const AppLayer = Layer.mergeAll(
|
|
|
495
495
|
)
|
|
496
496
|
```
|
|
497
497
|
|
|
498
|
-
Also exported for hosts and headless tests: `Bus` / `publish(priority, event)` / `Priority` (`'High' | 'Normal'`); the `Reducers` / `Channels` / `Procedures` registries and their `ReducerEntry` / `ProcedureEntry` shapes; `CaptureSink` (the capturing UI sink proofs assert against); the tagged errors (`
|
|
498
|
+
Also exported for hosts and headless tests: `Bus` / `publish(priority, event)` / `Priority` (`'High' | 'Normal'`); the `Reducers` / `Channels` / `Procedures` registries and their `ReducerEntry` / `ProcedureEntry` shapes; `CaptureSink` (the capturing UI sink proofs assert against); the tagged errors (`AsyncReducer`, `AsyncSceneLayer`, `DuplicateRegistration`, `FeatureLoadFailed`, `InvalidProvideTarget`, `UnknownGroupState`); and the **notification scheduler** — `Notifications` / `notificationsLayer` / `makeScheduler` / `defaultScheduler`, which hosts provide upstream for per-runtime isolation (concurrent SSR, multiple mounted roots). Everything else coalesces on the process-wide default scheduler.
|
|
499
499
|
|
|
500
500
|
---
|
|
501
501
|
|
|
@@ -533,7 +533,7 @@ const logic = Layer.mergeAll(
|
|
|
533
533
|
}),
|
|
534
534
|
)
|
|
535
535
|
|
|
536
|
-
// a DOM presentation (rendered by @playfast/react)
|
|
536
|
+
// a DOM presentation (rendered by @playfast/reform-react)
|
|
537
537
|
const view = provide(CounterUi, Ui.make(CounterUi, ({ count }, _s, { bump }) =>
|
|
538
538
|
<button onClick={() => bump({ by: 1 })}>count: {count}</button>
|
|
539
539
|
))
|
|
@@ -541,7 +541,7 @@ const view = provide(CounterUi, Ui.make(CounterUi, ({ count }, _s, { bump }) =>
|
|
|
541
541
|
export const CounterScene = scene(Counter, { provide: [Engine, logic, view] })
|
|
542
542
|
```
|
|
543
543
|
|
|
544
|
-
Mount it with [`@playfast/react`](https://www.npmjs.com/package/@playfast/react), or prove it headlessly with [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) — the same `CounterScene` value.
|
|
544
|
+
Mount it with [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react), or prove it headlessly with [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) — the same `CounterScene` value.
|
|
545
545
|
|
|
546
546
|
---
|
|
547
547
|
|
|
@@ -550,12 +550,12 @@ Mount it with [`@playfast/react`](https://www.npmjs.com/package/@playfast/react)
|
|
|
550
550
|
| Package | Role |
|
|
551
551
|
| --- | --- |
|
|
552
552
|
| **`@playfast/reform`** | Renderer-neutral core (this package) |
|
|
553
|
-
| [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) | React / DOM host |
|
|
554
|
-
| [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native) | React Native host |
|
|
555
|
-
| [`@playfast/forms`](https://www.npmjs.com/package/@playfast/forms) | Headless form state |
|
|
556
|
-
| [`@playfast/forms-react`](https://www.npmjs.com/package/@playfast/forms-react) | Typed JSX mapping for forms |
|
|
557
|
-
| [`@playfast/proof`](https://www.npmjs.com/package/@playfast/proof) | Headless testing toolkit |
|
|
558
|
-
| [`@playfast/eslint-plugin`](https://www.npmjs.com/package/@playfast/eslint-plugin) | Lint rules for reform conventions |
|
|
553
|
+
| [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) | React / DOM host |
|
|
554
|
+
| [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native) | React Native host |
|
|
555
|
+
| [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms) | Headless form state |
|
|
556
|
+
| [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) | Typed JSX mapping for forms |
|
|
557
|
+
| [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) | Headless testing toolkit |
|
|
558
|
+
| [`@playfast/reform-eslint-plugin`](https://www.npmjs.com/package/@playfast/reform-eslint-plugin) | Lint rules for reform conventions |
|
|
559
559
|
|
|
560
560
|
## License
|
|
561
561
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
|
|
7
7
|
"keywords": [
|
package/src/boundary/boundary.ts
CHANGED
|
@@ -102,6 +102,7 @@ export interface BoundaryOptions {
|
|
|
102
102
|
* loading state. Without the latch the boundary re-pends on any covered
|
|
103
103
|
* first load, which is what a per-screen boundary wants.
|
|
104
104
|
*/
|
|
105
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- public options-bag input; omission is the documented default (no latch) and external callers pass a plain `{ once: true }` literal
|
|
105
106
|
readonly once?: boolean
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -153,19 +154,23 @@ export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSo
|
|
|
153
154
|
{ readonly key: ReadonlyArray<unknown>; readonly value: BoundaryState } | undefined
|
|
154
155
|
>(undefined)
|
|
155
156
|
const recompute = (): BoundaryState => {
|
|
156
|
-
if (options.once === true && MutableRef.get(latched))
|
|
157
|
+
if (options.once === true && MutableRef.get(latched)) {
|
|
158
|
+
return readyArm
|
|
159
|
+
}
|
|
157
160
|
const arms = sources.keyOf(sources.snapshot())
|
|
158
161
|
const prev = MutableRef.get(memo)
|
|
159
|
-
if (prev !== undefined && sameKey(arms, prev.key))
|
|
162
|
+
if (prev !== undefined && sameKey(arms, prev.key)) {
|
|
163
|
+
return prev.value
|
|
164
|
+
}
|
|
160
165
|
const errors = arms.flatMap(errorOf)
|
|
161
|
-
const
|
|
166
|
+
const mergedArm =
|
|
162
167
|
errors.length > 0
|
|
163
168
|
? erroredArm(errors)
|
|
164
169
|
: arms.some((arm) => tagOf(arm) === 'Loading')
|
|
165
170
|
? pendingArm
|
|
166
171
|
: readyArm
|
|
167
|
-
MutableRef.set(memo, { key: arms, value })
|
|
168
|
-
return
|
|
172
|
+
MutableRef.set(memo, { key: arms, value: mergedArm })
|
|
173
|
+
return mergedArm
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
const derived = makeDerivedStore(recompute, sources.subscribe, scheduler)
|
|
@@ -181,9 +186,13 @@ export const live = <N extends string, Over extends ReadonlyArray<AnyLifecycleSo
|
|
|
181
186
|
// Ready self-corrects within the flush for readers, but a latch taken
|
|
182
187
|
// there would freeze it; a subscriber runs only after the fixpoint,
|
|
183
188
|
// where the value is converged.
|
|
184
|
-
if (recompute()._tag === 'Ready')
|
|
189
|
+
if (recompute()._tag === 'Ready') {
|
|
190
|
+
MutableRef.set(latched, true)
|
|
191
|
+
}
|
|
185
192
|
const offSelf = derived.store.subscribe(() => {
|
|
186
|
-
if (derived.store.get()._tag === 'Ready')
|
|
193
|
+
if (derived.store.get()._tag === 'Ready') {
|
|
194
|
+
MutableRef.set(latched, true)
|
|
195
|
+
}
|
|
187
196
|
})
|
|
188
197
|
yield* Effect.addFinalizer(() => Effect.sync(offSelf))
|
|
189
198
|
}
|
package/src/calc/asyncCalc.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Option, Schema } from 'effect'
|
|
1
|
+
import { Context, Effect, Layer, Match, Option, Schema } from 'effect'
|
|
2
2
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
3
|
import { type AnyEvent } from '../event/event'
|
|
4
4
|
import {
|
|
@@ -35,6 +35,7 @@ export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
|
|
|
35
35
|
readonly kind: 'AsyncCalc'
|
|
36
36
|
readonly name: N
|
|
37
37
|
readonly output: Schema.Schema<A, any>
|
|
38
|
+
// 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
|
|
38
39
|
readonly error?: Schema.Schema<E, any>
|
|
39
40
|
/** Whether the query can be disabled (drives the `Idle` arm). */
|
|
40
41
|
readonly gated: boolean
|
|
@@ -63,8 +64,10 @@ export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E,
|
|
|
63
64
|
/** Schema of the `Success` value. */
|
|
64
65
|
readonly output: Schema.Schema<A, any>
|
|
65
66
|
/** Schema of the failure. Omitted ⇒ the query is infallible and there is no `Error` arm. */
|
|
67
|
+
// 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
|
|
66
68
|
readonly error?: Schema.Schema<E, any>
|
|
67
69
|
/** `true` ⇒ the query is always on: no `Idle` arm and `disabled` is rejected on `.live`. */
|
|
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
|
|
68
71
|
readonly alwaysOn?: AlwaysOn
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -81,6 +84,7 @@ export type AsyncCalcLive<
|
|
|
81
84
|
R,
|
|
82
85
|
> = {
|
|
83
86
|
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
|
|
84
88
|
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
85
89
|
/**
|
|
86
90
|
* Refetch conciliation. `'switch'` (default): a new key cancels the in-flight
|
|
@@ -89,6 +93,7 @@ export type AsyncCalcLive<
|
|
|
89
93
|
* refetch runs after it settles — a burst of N invalidations during one
|
|
90
94
|
* flight costs 2 fetches, not N cancel-restarts.
|
|
91
95
|
*/
|
|
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
|
|
92
97
|
readonly coalesce?: 'switch' | 'trailing'
|
|
93
98
|
/**
|
|
94
99
|
* Structural sharing for `Success` values: reconcile each refetch result
|
|
@@ -97,19 +102,58 @@ export type AsyncCalcLive<
|
|
|
97
102
|
* subtree identities, so downstream memo boundaries skip them. Same pass as
|
|
98
103
|
* `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
|
|
99
104
|
*/
|
|
105
|
+
// 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
|
|
100
106
|
readonly reuse?: boolean
|
|
101
107
|
/**
|
|
102
108
|
* Persist the `Success` value through an optional host `QueryStore` (e.g.
|
|
103
109
|
* `@playfast/reform-query-browser`'s localStorage layer): hydrate it on build
|
|
104
110
|
* so the value shows instantly (marked stale, so it still refetches) and write
|
|
105
111
|
* through on each settle. `true` keys it by the calc name; pass `{ key }` to
|
|
106
|
-
* override
|
|
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
|
|
107
115
|
* `QueryStore` in context ⇒ inert (no hard requirement added).
|
|
108
116
|
*/
|
|
109
|
-
|
|
117
|
+
// 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
|
+
readonly persist?:
|
|
119
|
+
| 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
|
+
| { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
|
|
110
122
|
} & (Gated extends true
|
|
111
|
-
?
|
|
112
|
-
|
|
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 })
|
|
127
|
+
|
|
128
|
+
/** `.live` config plus the optional `invalidateOn` event list (impl signature). */
|
|
129
|
+
export type AsyncCalcLiveWithInvalidateOn<
|
|
130
|
+
Inputs extends ReadonlyArray<AnySource>,
|
|
131
|
+
A,
|
|
132
|
+
E,
|
|
133
|
+
Gated extends boolean,
|
|
134
|
+
R,
|
|
135
|
+
> = 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
|
+
readonly invalidateOn?: ReadonlyArray<AnyEvent>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Loose runtime view of `.live` config — `disabled` recovered past its type erasure. */
|
|
141
|
+
export interface AsyncCalcLiveView<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
142
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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
|
+
readonly persist?:
|
|
153
|
+
| 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
|
+
| { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
|
|
156
|
+
}
|
|
113
157
|
|
|
114
158
|
/**
|
|
115
159
|
* Define an async derived value. `output`/`error` schemas and `alwaysOn` shape
|
|
@@ -195,9 +239,7 @@ export function live<
|
|
|
195
239
|
R,
|
|
196
240
|
>(
|
|
197
241
|
calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
|
|
198
|
-
config:
|
|
199
|
-
readonly invalidateOn?: ReadonlyArray<AnyEvent>
|
|
200
|
-
},
|
|
242
|
+
config: AsyncCalcLiveWithInvalidateOn<Inputs, A, E, Gated, R>,
|
|
201
243
|
// The wide-R implementation signature behind precise overloads — the same
|
|
202
244
|
// seam `Reducer.live` uses; both overload returns are assignable (RIn is
|
|
203
245
|
// covariant), so no value is ever cast.
|
|
@@ -218,25 +260,22 @@ export function live<
|
|
|
218
260
|
Effect.gen(function* () {
|
|
219
261
|
// `disabled` is rejected at the type level for non-gated calcs (erased to
|
|
220
262
|
// `never` there); read it through a loose view for the runtime.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
224
|
-
readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
|
|
225
|
-
readonly coalesce?: 'switch' | 'trailing'
|
|
226
|
-
readonly reuse?: boolean
|
|
227
|
-
readonly persist?: boolean | { readonly key?: string }
|
|
228
|
-
}
|
|
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>
|
|
229
265
|
|
|
230
|
-
// `persist: true` keys by the calc name; `{ key }` overrides
|
|
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
|
|
231
269
|
// (de)serializes through the calc's own `output` schema.
|
|
270
|
+
const persistOption = cfg.persist
|
|
271
|
+
const persistKey =
|
|
272
|
+
persistOption === true || persistOption === undefined || persistOption === false
|
|
273
|
+
? calc.manifest.name
|
|
274
|
+
: persistOption.key ?? calc.manifest.name
|
|
232
275
|
const persist =
|
|
233
|
-
|
|
276
|
+
persistOption === undefined || persistOption === false
|
|
234
277
|
? undefined
|
|
235
|
-
: {
|
|
236
|
-
key:
|
|
237
|
-
cfg.persist === true ? calc.manifest.name : cfg.persist.key ?? calc.manifest.name,
|
|
238
|
-
schema: calc.manifest.output,
|
|
239
|
-
}
|
|
278
|
+
: { key: persistKey, schema: calc.manifest.output }
|
|
240
279
|
|
|
241
280
|
// The hidden revision store, read requirement-free (`serviceOption`): the
|
|
242
281
|
// assembly below always provides it alongside this driver, and feeding it
|
|
@@ -244,10 +283,17 @@ export function live<
|
|
|
244
283
|
// the user's `query` and `invalidateBy` receive exactly
|
|
245
284
|
// `InputsObject<Inputs>`, with no hidden property to leak into
|
|
246
285
|
// spread-into-RPC payloads.
|
|
247
|
-
const revision =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
286
|
+
const revision = yield* Match.value(revisionState).pipe(
|
|
287
|
+
Match.when(undefined, () => Effect.succeed(undefined)),
|
|
288
|
+
Match.orElse((state) =>
|
|
289
|
+
Effect.serviceOption(state.store).pipe(Effect.map(Option.getOrUndefined)),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
const extraKeyFor = (rev: NonNullable<typeof revision>) => ({
|
|
293
|
+
read: () => rev.getSnapshot(),
|
|
294
|
+
subscribe: (listener: () => void) => rev.subscribe(listener),
|
|
295
|
+
})
|
|
296
|
+
const extraKey = revision === undefined ? undefined : extraKeyFor(revision)
|
|
251
297
|
const driver = yield* makeQueryDriver({
|
|
252
298
|
name: calc.manifest.name,
|
|
253
299
|
label: 'AsyncCalc',
|
|
@@ -259,13 +305,7 @@ export function live<
|
|
|
259
305
|
coalesce: cfg.coalesce,
|
|
260
306
|
reuse: cfg.reuse,
|
|
261
307
|
persist,
|
|
262
|
-
extraKey
|
|
263
|
-
revision === undefined
|
|
264
|
-
? undefined
|
|
265
|
-
: {
|
|
266
|
-
read: () => revision.getSnapshot(),
|
|
267
|
-
subscribe: (listener) => revision.subscribe(listener),
|
|
268
|
-
},
|
|
308
|
+
extraKey,
|
|
269
309
|
})
|
|
270
310
|
|
|
271
311
|
// The internal store is the full union; narrow to the definition's arms.
|
|
@@ -273,7 +313,9 @@ export function live<
|
|
|
273
313
|
}),
|
|
274
314
|
)
|
|
275
315
|
|
|
276
|
-
if (config.invalidateOn === undefined || revisionState === undefined)
|
|
316
|
+
if (config.invalidateOn === undefined || revisionState === undefined) {
|
|
317
|
+
return driver
|
|
318
|
+
}
|
|
277
319
|
// The hidden reducer: an ordinary `Reducer` folding every listed event to
|
|
278
320
|
// n + 1 — the revision's sole writer, registered/unregistered with this
|
|
279
321
|
// layer's scope like any user reducer. `Layer.provide` builds the hidden
|
|
@@ -296,7 +338,12 @@ export function live<
|
|
|
296
338
|
* focus/online provider layer calls. A no-op if the calc isn't live; resolves the
|
|
297
339
|
* `Queries` registry (part of `Engine`).
|
|
298
340
|
*/
|
|
299
|
-
|
|
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> =>
|
|
300
347
|
Effect.flatMap(Queries, (queries) =>
|
|
301
348
|
Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
|
|
302
349
|
)
|
|
@@ -305,7 +352,7 @@ export const invalidate = (calc: { readonly name: string }): Effect.Effect<void,
|
|
|
305
352
|
* Force an immediate refetch of the current key, bypassing the no-op-key guard
|
|
306
353
|
* and independent of `isStale`. A no-op if the calc isn't live or is disabled.
|
|
307
354
|
*/
|
|
308
|
-
export const refetch = (calc:
|
|
355
|
+
export const refetch = (calc: NamedCalc): Effect.Effect<void, never, Queries> =>
|
|
309
356
|
Effect.flatMap(Queries, (queries) =>
|
|
310
357
|
Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
|
|
311
358
|
)
|
package/src/calc/asyncData.ts
CHANGED
|
@@ -49,9 +49,9 @@ export type AnyAsyncData<A, E> = AsyncIdle | AsyncLoading | AsyncSuccess<A> | As
|
|
|
49
49
|
|
|
50
50
|
const idle: AsyncIdle = { _tag: 'Idle' }
|
|
51
51
|
const loading: AsyncLoading = { _tag: 'Loading' }
|
|
52
|
-
const success = <A>(
|
|
52
|
+
const success = <A>(payload: A, refetching = false): AsyncSuccess<A> => ({
|
|
53
53
|
_tag: 'Success',
|
|
54
|
-
value,
|
|
54
|
+
value: payload,
|
|
55
55
|
refetching,
|
|
56
56
|
})
|
|
57
57
|
const error = <E>(err: E, refetching = false): AsyncError<E> => ({
|
|
@@ -82,7 +82,9 @@ export const AsyncData: AsyncDataConstructors = { idle, loading, success, error
|
|
|
82
82
|
*/
|
|
83
83
|
export const narrowStore = <A, E, Gated extends boolean>(
|
|
84
84
|
store: Store<AnyAsyncData<A, E>>,
|
|
85
|
-
): Store<AsyncData<A, E, Gated>> =>
|
|
85
|
+
): Store<AsyncData<A, E, Gated>> =>
|
|
86
|
+
// 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
|
+
store as unknown as Store<AsyncData<A, E, Gated>>
|
|
86
88
|
|
|
87
89
|
/**
|
|
88
90
|
* The read-only inverse of `narrowStore`: widen a definition-narrowed store back
|
|
@@ -93,4 +95,6 @@ export const narrowStore = <A, E, Gated extends boolean>(
|
|
|
93
95
|
*/
|
|
94
96
|
export const widenStore = <A, E, Gated extends boolean>(
|
|
95
97
|
store: Store<AsyncData<A, E, Gated>>,
|
|
96
|
-
): Store<AnyAsyncData<A, E>> =>
|
|
98
|
+
): Store<AnyAsyncData<A, E>> =>
|
|
99
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- read-only widen of the SAME runtime store back to the full union; sound for reads, documented inverse of narrowStore
|
|
100
|
+
store as unknown as Store<AnyAsyncData<A, E>>
|
package/src/calc/calc.ts
CHANGED
|
@@ -17,7 +17,9 @@ import { type AnySource } from '../state/token'
|
|
|
17
17
|
// even though the reactive plumbing now lives in `internal/sources`.
|
|
18
18
|
export { type InputsObject, type InputStores, type InvalidateBy } from '../internal/sources'
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Boundary config object: optional fields are the framework's public, JSON-like
|
|
21
|
+
// definition surface (`ExternalApi` postfix exempts them from `no-optional-fields`).
|
|
22
|
+
export interface CalcOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>> {
|
|
21
23
|
/**
|
|
22
24
|
* Custom invalidation: recompute only when the projected key changes by value
|
|
23
25
|
* equality (React-Query's `queryKey`). Omitted ⇒ recompute when any input
|
|
@@ -83,7 +85,7 @@ export const make = <const N extends string, const Inputs extends ReadonlyArray<
|
|
|
83
85
|
export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, Out>(
|
|
84
86
|
calc: CalcClass<N, Inputs, Out>,
|
|
85
87
|
compute: (inputs: InputsObject<Inputs>) => Out,
|
|
86
|
-
options:
|
|
88
|
+
options: CalcOptionsExternalApi<Inputs> = {},
|
|
87
89
|
): Layer.Layer<Store<Out>, never, InputStores<Inputs>> =>
|
|
88
90
|
// Scoped so the source subscriptions are released when the layer's scope
|
|
89
91
|
// closes (each proof/test builds and disposes its own runtime).
|
|
@@ -102,7 +104,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
102
104
|
const args = sources.snapshot()
|
|
103
105
|
const key = sources.keyOf(args)
|
|
104
106
|
const prev = MutableRef.get(memo)
|
|
105
|
-
if (prev !== undefined && sameKey(key, prev.key))
|
|
107
|
+
if (prev !== undefined && sameKey(key, prev.key)) {
|
|
108
|
+
return prev.output
|
|
109
|
+
}
|
|
106
110
|
const fresh = compute(args)
|
|
107
111
|
// With `reuse`, a recompute that lands value-equal to the previous
|
|
108
112
|
// output returns the previous reference — the derived store's Equal
|
package/src/calc/calcFamily.ts
CHANGED
|
@@ -84,9 +84,11 @@ export const read = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
84
84
|
family: CalcFamilyClass<N, Inputs, K, Out>,
|
|
85
85
|
key: K,
|
|
86
86
|
): Effect.Effect<Out, never, FamilyStore<K, Out>> =>
|
|
87
|
-
Effect.flatMap(family.store, (
|
|
87
|
+
Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// Boundary config object: optional fields are the framework's public, JSON-like
|
|
90
|
+
// definition surface (`ExternalApi` postfix exempts them from `no-optional-fields`).
|
|
91
|
+
export interface CalcFamilyLiveOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>>
|
|
90
92
|
extends FamilyOptions {
|
|
91
93
|
/**
|
|
92
94
|
* Family-wide invalidation key over the SHARED inputs (the member key plays
|
|
@@ -107,7 +109,7 @@ export interface CalcFamilyLiveOptions<Inputs extends ReadonlyArray<AnySource>>
|
|
|
107
109
|
export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
|
|
108
110
|
family: CalcFamilyClass<N, Inputs, K, Out>,
|
|
109
111
|
compute: (key: K) => (inputs: InputsObject<Inputs>) => Out,
|
|
110
|
-
options:
|
|
112
|
+
options: CalcFamilyLiveOptionsExternalApi<Inputs> = {},
|
|
111
113
|
): Layer.Layer<FamilyStore<K, Out>, never, InputStores<Inputs>> =>
|
|
112
114
|
Layer.scoped(
|
|
113
115
|
family.store,
|
|
@@ -122,7 +124,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
122
124
|
|
|
123
125
|
const dropEntry = (key: K): void => {
|
|
124
126
|
const entry = entries.get(key)
|
|
125
|
-
if (entry !== undefined)
|
|
127
|
+
if (entry !== undefined) {
|
|
128
|
+
entry.unsubscribe()
|
|
129
|
+
}
|
|
126
130
|
entries.delete(key)
|
|
127
131
|
subscribers.delete(key)
|
|
128
132
|
}
|
|
@@ -137,7 +141,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
137
141
|
const args = sources.snapshot()
|
|
138
142
|
const memoKey = sources.keyOf(args)
|
|
139
143
|
const prev = MutableRef.get(memo)
|
|
140
|
-
if (prev !== undefined && sameKey(memoKey, prev.key))
|
|
144
|
+
if (prev !== undefined && sameKey(memoKey, prev.key)) {
|
|
145
|
+
return prev.output
|
|
146
|
+
}
|
|
141
147
|
const output = body(args)
|
|
142
148
|
MutableRef.set(memo, { key: memoKey, output })
|
|
143
149
|
return output
|
|
@@ -151,11 +157,14 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
151
157
|
const refCounted = (key: K, store: Store<Out>): Store<Out> => ({
|
|
152
158
|
...store,
|
|
153
159
|
subscribe: (listener) => {
|
|
154
|
-
|
|
160
|
+
const priorCount = subscribers.get(key)
|
|
161
|
+
subscribers.set(key, (priorCount === undefined ? 0 : priorCount) + 1)
|
|
155
162
|
const off = store.subscribe(listener)
|
|
156
163
|
const released = { done: false }
|
|
157
164
|
return () => {
|
|
158
|
-
if (released.done)
|
|
165
|
+
if (released.done) {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
159
168
|
released.done = true
|
|
160
169
|
off()
|
|
161
170
|
const remaining = (subscribers.get(key) ?? 1) - 1
|
|
@@ -165,7 +174,10 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
165
174
|
}
|
|
166
175
|
subscribers.delete(key)
|
|
167
176
|
queueMicrotask(() => {
|
|
168
|
-
|
|
177
|
+
const liveCount = subscribers.get(key)
|
|
178
|
+
if (liveCount === undefined || liveCount === 0) {
|
|
179
|
+
dropEntry(key)
|
|
180
|
+
}
|
|
169
181
|
})
|
|
170
182
|
}
|
|
171
183
|
},
|
|
@@ -174,7 +186,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
174
186
|
const familyStore: FamilyStore<K, Out> = {
|
|
175
187
|
at: (key) => {
|
|
176
188
|
const existing = entries.get(key)
|
|
177
|
-
if (existing !== undefined)
|
|
189
|
+
if (existing !== undefined) {
|
|
190
|
+
return existing.store
|
|
191
|
+
}
|
|
178
192
|
const member = createMember(key)
|
|
179
193
|
const created = evictWhenUnused
|
|
180
194
|
? { store: refCounted(key, member.store), unsubscribe: member.unsubscribe }
|
|
@@ -184,7 +198,7 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
|
|
|
184
198
|
},
|
|
185
199
|
forget: dropEntry,
|
|
186
200
|
clear: () => {
|
|
187
|
-
|
|
201
|
+
;[...entries.keys()].forEach((key) => dropEntry(key))
|
|
188
202
|
},
|
|
189
203
|
size: () => entries.size,
|
|
190
204
|
}
|
package/src/calc/compose.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function composeCalcs(
|
|
|
63
63
|
// Fold leaf-first: each upstream is provided into the accumulated downstream,
|
|
64
64
|
// so dependency order is exactly the argument order.
|
|
65
65
|
return upstream.reduce<Layer.Layer<unknown, unknown, unknown>>(
|
|
66
|
-
(downstream,
|
|
66
|
+
(downstream, upstreamLayer) => Layer.provideMerge(downstream, upstreamLayer),
|
|
67
67
|
leaf,
|
|
68
68
|
)
|
|
69
69
|
}
|
package/src/calc/queryState.ts
CHANGED
|
@@ -37,16 +37,16 @@ export const empty = (fetching: boolean): QueryState<never, never> => ({
|
|
|
37
37
|
* non-gated query, which always fetches) and `Idle` only for a switched-off
|
|
38
38
|
* gateable query.
|
|
39
39
|
*/
|
|
40
|
-
export const toAsyncData = <A, E>(
|
|
41
|
-
Option.match(
|
|
42
|
-
onSome: (error) => AsyncData.error(error,
|
|
40
|
+
export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
|
|
41
|
+
Option.match(state.error, {
|
|
42
|
+
onSome: (error) => AsyncData.error(error, state.isFetching),
|
|
43
43
|
onNone: () =>
|
|
44
|
-
Option.match(
|
|
45
|
-
onSome: (
|
|
46
|
-
onNone: () => (
|
|
44
|
+
Option.match(state.data, {
|
|
45
|
+
onSome: (payload) => AsyncData.success(payload, state.isFetching),
|
|
46
|
+
onNone: () => (state.isFetching || !gated ? AsyncData.loading : AsyncData.idle),
|
|
47
47
|
}),
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
/** `Loading` in the React-Query sense: fetching with nothing to show yet. */
|
|
51
|
-
export const isLoading = <A, E>(
|
|
52
|
-
|
|
51
|
+
export const isLoading = <A, E>(state: QueryState<A, E>): boolean =>
|
|
52
|
+
state.isFetching && Option.isNone(state.data)
|