@playfast/reform 0.0.9 → 0.0.11
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/package.json +1 -1
- package/src/boundary/boundary.ts +16 -7
- package/src/calc/asyncCalc.invalidate.test.ts +166 -0
- package/src/calc/asyncCalc.ts +116 -25
- 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 +52 -0
- 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/index.ts +21 -0
- 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 +273 -86
- package/src/internal/queryEvents.ts +34 -0
- package/src/internal/queryStore.ts +36 -0
- 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 +81 -53
- package/src/runtime/queries.ts +55 -0
- 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/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.0.11",
|
|
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
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
+
import { AsyncCalc, Engine, Queries, QueryStore, State, StateGroup } from '../index'
|
|
4
|
+
|
|
5
|
+
// The data-oriented half of AsyncCalc: `invalidate` flips `isStale` only (no
|
|
6
|
+
// fetch), `refetch` forces a run, a stale query with active readers (or a fresh
|
|
7
|
+
// subscriber) refetches, and `persist` hydrates + writes through an optional
|
|
8
|
+
// QueryStore. `isStale` is read off the `Queries` registry handle.
|
|
9
|
+
|
|
10
|
+
const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
|
|
11
|
+
|
|
12
|
+
const isStale = (name: string) =>
|
|
13
|
+
Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
|
|
14
|
+
|
|
15
|
+
// An in-memory QueryStore, so persistence is exercised with no DOM.
|
|
16
|
+
const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
|
|
17
|
+
const map = new Map<string, unknown>(Object.entries(seed))
|
|
18
|
+
const layer = Layer.succeed(QueryStore, {
|
|
19
|
+
get: (key) => Effect.sync(() => Option.fromNullable(map.has(key) ? map.get(key) : undefined)),
|
|
20
|
+
set: (key, value) => Effect.sync(() => void map.set(key, value)),
|
|
21
|
+
remove: (key) => Effect.sync(() => void map.delete(key)),
|
|
22
|
+
})
|
|
23
|
+
return { map, layer }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
|
|
27
|
+
class Count extends State.make('count', S.Number) {}
|
|
28
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
29
|
+
const runs = { n: 0 }
|
|
30
|
+
class Q extends AsyncCalc.make('Q', {
|
|
31
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
32
|
+
output: S.Number,
|
|
33
|
+
alwaysOn: true,
|
|
34
|
+
}) {}
|
|
35
|
+
const QLive = AsyncCalc.live(Q, {
|
|
36
|
+
query: ({ count }) =>
|
|
37
|
+
Effect.sync(() => {
|
|
38
|
+
runs.n += 1
|
|
39
|
+
return count
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
const TestLayer = QLive.pipe(
|
|
43
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
|
|
44
|
+
Layer.provideMerge(Engine),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return Effect.gen(function* () {
|
|
48
|
+
const store = yield* Q.store
|
|
49
|
+
yield* tick()
|
|
50
|
+
expect(runs.n).toBe(1)
|
|
51
|
+
|
|
52
|
+
// No active readers: invalidate flips the flag but fetches nothing.
|
|
53
|
+
yield* AsyncCalc.invalidate(Q)
|
|
54
|
+
yield* tick()
|
|
55
|
+
expect(yield* isStale('Q')).toBe(true)
|
|
56
|
+
expect(runs.n).toBe(1)
|
|
57
|
+
|
|
58
|
+
// A reader arriving over a stale query (the resubscribe/mount analog) refetches.
|
|
59
|
+
store.subscribe(() => {})
|
|
60
|
+
yield* tick()
|
|
61
|
+
expect(runs.n).toBe(2)
|
|
62
|
+
expect(yield* isStale('Q')).toBe(false)
|
|
63
|
+
}).pipe(Effect.provide(TestLayer))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it.live('invalidate with an active reader auto-refetches immediately, keeping the value', () => {
|
|
67
|
+
class Count extends State.make('count', S.Number) {}
|
|
68
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
69
|
+
const runs = { n: 0 }
|
|
70
|
+
class Q extends AsyncCalc.make('Q', {
|
|
71
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
72
|
+
output: S.Number,
|
|
73
|
+
alwaysOn: true,
|
|
74
|
+
}) {}
|
|
75
|
+
const QLive = AsyncCalc.live(Q, {
|
|
76
|
+
query: ({ count }) =>
|
|
77
|
+
Effect.sync(() => {
|
|
78
|
+
runs.n += 1
|
|
79
|
+
return count
|
|
80
|
+
}).pipe(Effect.delay(Duration.millis(20))),
|
|
81
|
+
})
|
|
82
|
+
const TestLayer = QLive.pipe(
|
|
83
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 7 })),
|
|
84
|
+
Layer.provideMerge(Engine),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return Effect.gen(function* () {
|
|
88
|
+
const store = yield* Q.store
|
|
89
|
+
store.subscribe(() => {}) // active reader
|
|
90
|
+
yield* tick(40)
|
|
91
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
92
|
+
expect(runs.n).toBe(1)
|
|
93
|
+
|
|
94
|
+
// Stale + active ⇒ refetch now; the last value stays visible (SWR).
|
|
95
|
+
yield* AsyncCalc.invalidate(Q)
|
|
96
|
+
yield* tick(5)
|
|
97
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
|
|
98
|
+
yield* tick(40)
|
|
99
|
+
expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
|
|
100
|
+
expect(runs.n).toBe(2)
|
|
101
|
+
}).pipe(Effect.provide(TestLayer))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it.live('refetch forces a run regardless of staleness', () => {
|
|
105
|
+
class Count extends State.make('count', S.Number) {}
|
|
106
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
107
|
+
const runs = { n: 0 }
|
|
108
|
+
class Q extends AsyncCalc.make('Q', {
|
|
109
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
110
|
+
output: S.Number,
|
|
111
|
+
alwaysOn: true,
|
|
112
|
+
}) {}
|
|
113
|
+
const QLive = AsyncCalc.live(Q, {
|
|
114
|
+
query: ({ count }) =>
|
|
115
|
+
Effect.sync(() => {
|
|
116
|
+
runs.n += 1
|
|
117
|
+
return count
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
const TestLayer = QLive.pipe(
|
|
121
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
|
|
122
|
+
Layer.provideMerge(Engine),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return Effect.gen(function* () {
|
|
126
|
+
yield* tick()
|
|
127
|
+
expect(runs.n).toBe(1)
|
|
128
|
+
expect(yield* isStale('Q')).toBe(false)
|
|
129
|
+
|
|
130
|
+
// Not stale, no key movement — refetch still runs.
|
|
131
|
+
yield* AsyncCalc.refetch(Q)
|
|
132
|
+
yield* tick()
|
|
133
|
+
expect(runs.n).toBe(2)
|
|
134
|
+
}).pipe(Effect.provide(TestLayer))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it.live('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
|
|
138
|
+
class Count extends State.make('count', S.Number) {}
|
|
139
|
+
class Inputs extends StateGroup.make(Count) {}
|
|
140
|
+
const store = fakeQueryStore({ P: 99 }) // a previously-persisted value
|
|
141
|
+
class Q extends AsyncCalc.make('Q', {
|
|
142
|
+
inputs: [StateGroup.select(Inputs, 'count')],
|
|
143
|
+
output: S.Number,
|
|
144
|
+
alwaysOn: true,
|
|
145
|
+
}) {}
|
|
146
|
+
const QLive = AsyncCalc.live(Q, {
|
|
147
|
+
query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
|
|
148
|
+
persist: { key: 'P' },
|
|
149
|
+
})
|
|
150
|
+
const TestLayer = QLive.pipe(
|
|
151
|
+
Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
|
|
152
|
+
Layer.provideMerge(store.layer),
|
|
153
|
+
Layer.provideMerge(Engine),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return Effect.gen(function* () {
|
|
157
|
+
const view = yield* Q.store
|
|
158
|
+
// The persisted value shows instantly, marked refetching (stale-while-revalidate).
|
|
159
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
|
|
160
|
+
|
|
161
|
+
// The fresh fetch lands and is written through to the store.
|
|
162
|
+
yield* tick(45)
|
|
163
|
+
expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
|
|
164
|
+
expect(store.map.get('P')).toBe(10)
|
|
165
|
+
}).pipe(Effect.provide(TestLayer))
|
|
166
|
+
})
|
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 {
|
|
@@ -18,6 +18,7 @@ import { type Store } from '../internal/store'
|
|
|
18
18
|
import { readTracked } from '../internal/track'
|
|
19
19
|
import * as Reducer from '../reducer/reducer'
|
|
20
20
|
import { Reducers } from '../runtime/loop'
|
|
21
|
+
import { Queries } from '../runtime/queries'
|
|
21
22
|
import * as State from '../state/state'
|
|
22
23
|
import { type AnySource } from '../state/token'
|
|
23
24
|
import { type AsyncData, narrowStore } from './asyncData'
|
|
@@ -34,6 +35,7 @@ export interface AsyncCalcManifest<N extends string, A, E> extends Manifest {
|
|
|
34
35
|
readonly kind: 'AsyncCalc'
|
|
35
36
|
readonly name: N
|
|
36
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
|
|
37
39
|
readonly error?: Schema.Schema<E, any>
|
|
38
40
|
/** Whether the query can be disabled (drives the `Idle` arm). */
|
|
39
41
|
readonly gated: boolean
|
|
@@ -62,8 +64,10 @@ export interface AsyncCalcConfig<Inputs extends ReadonlyArray<AnySource>, A, E,
|
|
|
62
64
|
/** Schema of the `Success` value. */
|
|
63
65
|
readonly output: Schema.Schema<A, any>
|
|
64
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
|
|
65
68
|
readonly error?: Schema.Schema<E, any>
|
|
66
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
|
|
67
71
|
readonly alwaysOn?: AlwaysOn
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -80,6 +84,7 @@ export type AsyncCalcLive<
|
|
|
80
84
|
R,
|
|
81
85
|
> = {
|
|
82
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
|
|
83
88
|
readonly invalidateBy?: InvalidateBy<Inputs>
|
|
84
89
|
/**
|
|
85
90
|
* Refetch conciliation. `'switch'` (default): a new key cancels the in-flight
|
|
@@ -88,6 +93,7 @@ export type AsyncCalcLive<
|
|
|
88
93
|
* refetch runs after it settles — a burst of N invalidations during one
|
|
89
94
|
* flight costs 2 fetches, not N cancel-restarts.
|
|
90
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
|
|
91
97
|
readonly coalesce?: 'switch' | 'trailing'
|
|
92
98
|
/**
|
|
93
99
|
* Structural sharing for `Success` values: reconcile each refetch result
|
|
@@ -96,10 +102,58 @@ export type AsyncCalcLive<
|
|
|
96
102
|
* subtree identities, so downstream memo boundaries skip them. Same pass as
|
|
97
103
|
* `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
|
|
98
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
|
|
99
106
|
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
|
+
// 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) }
|
|
100
122
|
} & (Gated extends true
|
|
101
|
-
?
|
|
102
|
-
|
|
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
|
+
}
|
|
103
157
|
|
|
104
158
|
/**
|
|
105
159
|
* Define an async derived value. `output`/`error` schemas and `alwaysOn` shape
|
|
@@ -185,9 +239,7 @@ export function live<
|
|
|
185
239
|
R,
|
|
186
240
|
>(
|
|
187
241
|
calc: AsyncCalcClass<N, Inputs, A, E, Gated>,
|
|
188
|
-
config:
|
|
189
|
-
readonly invalidateOn?: ReadonlyArray<AnyEvent>
|
|
190
|
-
},
|
|
242
|
+
config: AsyncCalcLiveWithInvalidateOn<Inputs, A, E, Gated, R>,
|
|
191
243
|
// The wide-R implementation signature behind precise overloads — the same
|
|
192
244
|
// seam `Reducer.live` uses; both overload returns are assignable (RIn is
|
|
193
245
|
// covariant), so no value is ever cast.
|
|
@@ -208,13 +260,22 @@ export function live<
|
|
|
208
260
|
Effect.gen(function* () {
|
|
209
261
|
// `disabled` is rejected at the type level for non-gated calcs (erased to
|
|
210
262
|
// `never` there); read it through a loose view for the runtime.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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>
|
|
265
|
+
|
|
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
|
+
const persistOption = cfg.persist
|
|
271
|
+
const persistKey =
|
|
272
|
+
persistOption === true || persistOption === undefined || persistOption === false
|
|
273
|
+
? calc.manifest.name
|
|
274
|
+
: persistOption.key ?? calc.manifest.name
|
|
275
|
+
const persist =
|
|
276
|
+
persistOption === undefined || persistOption === false
|
|
277
|
+
? undefined
|
|
278
|
+
: { key: persistKey, schema: calc.manifest.output }
|
|
218
279
|
|
|
219
280
|
// The hidden revision store, read requirement-free (`serviceOption`): the
|
|
220
281
|
// assembly below always provides it alongside this driver, and feeding it
|
|
@@ -222,10 +283,17 @@ export function live<
|
|
|
222
283
|
// the user's `query` and `invalidateBy` receive exactly
|
|
223
284
|
// `InputsObject<Inputs>`, with no hidden property to leak into
|
|
224
285
|
// spread-into-RPC payloads.
|
|
225
|
-
const revision =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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)
|
|
229
297
|
const driver = yield* makeQueryDriver({
|
|
230
298
|
name: calc.manifest.name,
|
|
231
299
|
label: 'AsyncCalc',
|
|
@@ -236,13 +304,8 @@ export function live<
|
|
|
236
304
|
disabled: cfg.disabled,
|
|
237
305
|
coalesce: cfg.coalesce,
|
|
238
306
|
reuse: cfg.reuse,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
? undefined
|
|
242
|
-
: {
|
|
243
|
-
read: () => revision.getSnapshot(),
|
|
244
|
-
subscribe: (listener) => revision.subscribe(listener),
|
|
245
|
-
},
|
|
307
|
+
persist,
|
|
308
|
+
extraKey,
|
|
246
309
|
})
|
|
247
310
|
|
|
248
311
|
// The internal store is the full union; narrow to the definition's arms.
|
|
@@ -250,7 +313,9 @@ export function live<
|
|
|
250
313
|
}),
|
|
251
314
|
)
|
|
252
315
|
|
|
253
|
-
if (config.invalidateOn === undefined || revisionState === undefined)
|
|
316
|
+
if (config.invalidateOn === undefined || revisionState === undefined) {
|
|
317
|
+
return driver
|
|
318
|
+
}
|
|
254
319
|
// The hidden reducer: an ordinary `Reducer` folding every listed event to
|
|
255
320
|
// n + 1 — the revision's sole writer, registered/unregistered with this
|
|
256
321
|
// layer's scope like any user reducer. `Layer.provide` builds the hidden
|
|
@@ -265,3 +330,29 @@ export function live<
|
|
|
265
330
|
Layer.provide(State.live(revisionState, revisionZero)),
|
|
266
331
|
)
|
|
267
332
|
}
|
|
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
|
+
)
|
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
|
}
|