@playfast/reform 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Brand,
|
|
3
2
|
Cause,
|
|
4
3
|
Chunk,
|
|
5
4
|
Effect,
|
|
6
5
|
MutableRef,
|
|
7
6
|
Option,
|
|
7
|
+
type ParseResult,
|
|
8
8
|
Queue,
|
|
9
|
-
|
|
9
|
+
Runtime,
|
|
10
10
|
type Scope,
|
|
11
11
|
Stream,
|
|
12
12
|
} from 'effect'
|
|
13
13
|
import { type AnyAsyncData } from '../calc/asyncData'
|
|
14
|
-
import { empty, type QueryState
|
|
14
|
+
import { empty, type QueryState } from '../calc/queryState'
|
|
15
15
|
import { Queries, type QueryHandle } from '../runtime/queries'
|
|
16
16
|
import { reuse } from './reuse'
|
|
17
17
|
import { resolveInstrumentation } from '../runtime/instrumentation'
|
|
18
18
|
import { resolveQueryStore } from './queryStore'
|
|
19
|
+
import {
|
|
20
|
+
incrementGeneration,
|
|
21
|
+
makeQueryProjection,
|
|
22
|
+
subscribeQuerySources,
|
|
23
|
+
settleQueryError,
|
|
24
|
+
withReaderTracking,
|
|
25
|
+
} from './queryDriverStore'
|
|
19
26
|
import { resolveScheduler } from './scheduler'
|
|
20
27
|
import {
|
|
21
28
|
type InputsObject,
|
|
@@ -24,116 +31,54 @@ import {
|
|
|
24
31
|
sameKey,
|
|
25
32
|
wireSources,
|
|
26
33
|
} from './sources'
|
|
27
|
-
import {
|
|
34
|
+
import { makeStore, type Store } from './store'
|
|
28
35
|
import { type AnySource } from '../state/token'
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/** `Gated` (whether the `Idle` arm exists) is the inverse of `alwaysOn`. */
|
|
40
|
-
export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
|
|
37
|
+
export {
|
|
38
|
+
bumpRevision,
|
|
39
|
+
gatedFlag,
|
|
40
|
+
type GatedOf,
|
|
41
|
+
Revision,
|
|
42
|
+
RevisionSchema,
|
|
43
|
+
revisionZero,
|
|
44
|
+
} from './queryDriverTypes'
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
(
|
|
46
|
-
|
|
47
|
-
// The hidden revision behind `invalidateOn` — a module-local *branded* number.
|
|
48
|
-
// Reform store services are type-identified (`Context.GenericTag<Store<A>>`), so
|
|
49
|
-
// the hidden state's value type must be un-collidable: `Layer.provide` erases
|
|
50
|
-
// exactly `Store<Revision>` from the returned layer and can never type-level
|
|
51
|
-
// erase a user input that happens to be a number state. Never exported from the
|
|
52
|
-
// package index, so the editor catalog (which only indexes module exports)
|
|
53
|
-
// never sees it.
|
|
54
|
-
export type Revision = number & Brand.Brand<'reform/Revision'>
|
|
55
|
-
export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
|
|
56
|
-
export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
|
|
57
|
-
Schema.brand('reform/Revision'),
|
|
58
|
-
)
|
|
59
|
-
export const revisionZero: Revision = Revision(0)
|
|
60
|
-
export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
|
|
46
|
+
// Keep schema invariance behind a covariant persistence boundary.
|
|
47
|
+
export interface QueryCodec<A> {
|
|
48
|
+
readonly encode: (value: A) => Effect.Effect<unknown, ParseResult.ParseError, never>
|
|
49
|
+
readonly decode: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
|
|
50
|
+
}
|
|
61
51
|
|
|
62
52
|
export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
63
|
-
/** The owning definition's name — the defect log line and the `Queries` key. */
|
|
64
53
|
readonly name: string
|
|
65
|
-
/** The owning primitive's kind label — only for the defect log line. */
|
|
66
54
|
readonly label: string
|
|
67
|
-
/** Whether `disabled` is honored / the `Idle` arm can occur. */
|
|
68
55
|
readonly gated: boolean
|
|
69
56
|
readonly inputs: Inputs
|
|
70
57
|
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
71
|
-
// 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
|
|
72
58
|
readonly invalidateBy?: InvalidateBy<Inputs> | undefined
|
|
73
|
-
// 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
|
|
74
59
|
readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
|
|
75
|
-
// 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
|
|
76
60
|
readonly coalesce?: 'switch' | 'trailing' | undefined
|
|
77
|
-
// 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
|
|
78
61
|
readonly reuse?: boolean | undefined
|
|
79
|
-
/**
|
|
80
|
-
* An extra key segment + wake source beyond the declared inputs — the hidden
|
|
81
|
-
* `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
|
|
82
|
-
* and `invalidateBy` see exactly the declared inputs.
|
|
83
|
-
*/
|
|
84
|
-
// 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
|
|
85
62
|
readonly extraKey?:
|
|
86
63
|
| {
|
|
87
64
|
readonly read: () => unknown
|
|
88
65
|
readonly subscribe: (listener: () => void) => () => void
|
|
89
66
|
}
|
|
90
67
|
| undefined
|
|
91
|
-
/**
|
|
92
|
-
* Persist the `Success` value through the optional `QueryStore`: hydrate the
|
|
93
|
-
* cell from `key` before the first run (seeded `isStale: true`, so it shows
|
|
94
|
-
* instantly and still refetches) and write through on each settle. Values
|
|
95
|
-
* (de)serialize through `schema`, never trusted structurally.
|
|
96
|
-
*/
|
|
97
|
-
// 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
|
|
98
68
|
readonly persist?:
|
|
99
69
|
| {
|
|
100
|
-
// A static string keys a singleton read; a function of the inputs keys a
|
|
101
|
-
// family per-entity, so reactive-input keyed reads never collide on one slot.
|
|
102
70
|
readonly key: string | ((inputs: InputsObject<Inputs>) => string)
|
|
103
|
-
readonly
|
|
71
|
+
readonly codec: QueryCodec<A>
|
|
104
72
|
}
|
|
105
73
|
| undefined
|
|
106
|
-
/**
|
|
107
|
-
* Fired when a run's `Success` value has landed in the store (same scheduler
|
|
108
|
-
* flush — never before the converged value is readable), with the run's
|
|
109
|
-
* generation. Not fired on `Error`, interrupt, or while disabled. The seam
|
|
110
|
-
* `RemoteState` settles pending intents through.
|
|
111
|
-
*/
|
|
112
|
-
// 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
|
|
113
74
|
readonly onSettled?: ((generation: number) => void) | undefined
|
|
114
75
|
}
|
|
115
76
|
|
|
116
77
|
export interface QueryDriver<A, E> {
|
|
117
|
-
/** The lifecycle store (the `AnyAsyncData` projection — callers narrow to their arms). */
|
|
118
78
|
readonly store: Store<AnyAsyncData<A, E>>
|
|
119
|
-
/**
|
|
120
|
-
* The highest generation requested so far (0 before the first kick).
|
|
121
|
-
* Generations are assigned when a run is REQUESTED (enqueued), so a waiter
|
|
122
|
-
* registered as `requested() + 1` is satisfied only by a run that began
|
|
123
|
-
* after registration — the settle-ordering rule `RemoteState` builds on.
|
|
124
|
-
*/
|
|
125
79
|
readonly requested: () => number
|
|
126
80
|
}
|
|
127
81
|
|
|
128
|
-
/**
|
|
129
|
-
* Build the driver: state cell + projection + subscription + request queue + run
|
|
130
|
-
* fiber, all owned by the ambient scope (callers run this under `Layer.scoped`). A
|
|
131
|
-
* change to an input re-runs the query latest-wins (a new run cancels the in-flight
|
|
132
|
-
* one; or, with `coalesce: 'trailing'`, lets it finish and runs one trailing
|
|
133
|
-
* refetch); while a re-run is in flight the last `data`/`error` is kept with
|
|
134
|
-
* `isFetching: true`. `invalidate` flips `isStale`; a stale query with active
|
|
135
|
-
* readers auto-refetches (the same rule that drives refetch-on-resubscribe).
|
|
136
|
-
*/
|
|
137
82
|
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
|
|
138
83
|
export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
|
|
139
84
|
options: QueryDriverOptions<Inputs, A, E, R>,
|
|
@@ -148,44 +93,30 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
148
93
|
|
|
149
94
|
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
150
95
|
extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
typeof key === 'function' ? key(args) : key
|
|
156
|
-
const subscribeAll = (listener: () => void): (() => void) => {
|
|
157
|
-
const offSources = sources.subscribe(listener)
|
|
158
|
-
const offExtra = extraKey?.subscribe(listener)
|
|
159
|
-
return () => {
|
|
160
|
-
offSources()
|
|
161
|
-
offExtra?.()
|
|
162
|
-
}
|
|
163
|
-
}
|
|
96
|
+
const persistKey = (
|
|
97
|
+
key: string | ((inputs: InputsObject<Inputs>) => string),
|
|
98
|
+
args: InputsObject<Inputs>,
|
|
99
|
+
): string => (typeof key === 'function' ? key(args) : key)
|
|
164
100
|
const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
|
|
165
101
|
options.gated && options.disabled !== undefined ? options.disabled(args) : false
|
|
166
102
|
|
|
167
|
-
// The flat source-of-truth cell. `idle` is a shared reference so re-disabling
|
|
168
|
-
// an already-idle query is a no-op write (the store's Equal gate suppresses it).
|
|
169
103
|
const idle: QueryState<A, E> = empty(false)
|
|
170
104
|
const stateStore = makeStore<QueryState<A, E>>(disabledNow() ? idle : empty(true), scheduler)
|
|
171
105
|
|
|
172
|
-
// Active-reader count of the PROJECTION (React components, the RemoteState
|
|
173
|
-
// overlay). Drives the "stale + active ⇒ refetch" policy and refetch-on-
|
|
174
|
-
// resubscribe (a 0→1 transition).
|
|
175
106
|
const readers = MutableRef.make(0)
|
|
176
107
|
|
|
177
|
-
// The last requested key, so a change that doesn't move it (or only churns an
|
|
178
|
-
// input `invalidateBy` ignores) doesn't re-fetch.
|
|
179
108
|
const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
|
|
109
|
+
// Monotonic source epoch for the one startup hydration attempt. Comparing
|
|
110
|
+
// only the current key is insufficient: A → B → A would otherwise let the
|
|
111
|
+
// original A cache land over the later A request. Same-key refetches do not
|
|
112
|
+
// move this epoch, so cache may still paint during an in-flight revalidation.
|
|
113
|
+
const sourceEpoch = MutableRef.make(0)
|
|
180
114
|
const generation = MutableRef.make(0)
|
|
181
|
-
const nextGeneration = (): number =>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// ── State writers, each touching only the axes it owns. The untouched axes
|
|
187
|
-
// keep their field references, so a pure `isStale` flip leaves the projection
|
|
188
|
-
// reference unmoved (see `project`). ──────────────────────────────────────
|
|
115
|
+
const nextGeneration = (): number => incrementGeneration(generation)
|
|
116
|
+
// A stale cause needs a strictly newer requested generation. Hydration
|
|
117
|
+
// captures this before the startup kick, while invalidation captures it when
|
|
118
|
+
// the value is marked stale.
|
|
119
|
+
const staleAtGeneration = MutableRef.make(0)
|
|
189
120
|
const markFetching = () => {
|
|
190
121
|
const state = stateStore.get()
|
|
191
122
|
if (!state.isFetching) {
|
|
@@ -206,11 +137,10 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
206
137
|
})
|
|
207
138
|
return shared
|
|
208
139
|
}
|
|
209
|
-
const settleError = (error: E) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
stateStore.set({ ...prev, error: Option.some(error), isFetching: false })
|
|
140
|
+
const settleError = (error: E, failedGeneration: number) => {
|
|
141
|
+
// Preserve the stale cause after failure without duplicating a newer request.
|
|
142
|
+
MutableRef.update(staleAtGeneration, (current) => Math.max(current, failedGeneration))
|
|
143
|
+
settleQueryError(stateStore, error)
|
|
214
144
|
}
|
|
215
145
|
|
|
216
146
|
const persistWriteTo = (
|
|
@@ -218,39 +148,15 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
218
148
|
produced: A,
|
|
219
149
|
args: InputsObject<Inputs>,
|
|
220
150
|
): Effect.Effect<void> =>
|
|
221
|
-
|
|
151
|
+
target.codec.encode(produced).pipe(
|
|
222
152
|
Effect.flatMap((encoded) => queryStore.set(persistKey(target.key, args), encoded)),
|
|
223
|
-
// Persistence is best-effort
|
|
224
|
-
// never fail the run nor block the visible write.
|
|
153
|
+
// Persistence is best-effort and must not block the visible write.
|
|
225
154
|
Effect.ignore,
|
|
226
155
|
)
|
|
227
156
|
const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
|
|
228
157
|
persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
|
|
229
158
|
|
|
230
|
-
|
|
231
|
-
// `isStale` change (which the projection ignores) returns the SAME reference
|
|
232
|
-
// and wakes no `AnyAsyncData` subscriber.
|
|
233
|
-
const projection = MutableRef.make<
|
|
234
|
-
{ readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
|
|
235
|
-
>(undefined)
|
|
236
|
-
const project = (): AnyAsyncData<A, E> => {
|
|
237
|
-
const state = stateStore.get()
|
|
238
|
-
const prev = MutableRef.get(projection)
|
|
239
|
-
// The projection depends only on data/error/isFetching; writers preserve the
|
|
240
|
-
// refs of untouched axes, so reference identity on those three is exact.
|
|
241
|
-
if (
|
|
242
|
-
prev !== undefined &&
|
|
243
|
-
prev.state.data === state.data &&
|
|
244
|
-
prev.state.error === state.error &&
|
|
245
|
-
prev.state.isFetching === state.isFetching
|
|
246
|
-
) {
|
|
247
|
-
return prev.view
|
|
248
|
-
}
|
|
249
|
-
const view = toAsyncData(state, options.gated)
|
|
250
|
-
MutableRef.set(projection, { state, view })
|
|
251
|
-
return view
|
|
252
|
-
}
|
|
253
|
-
const derived = makeDerivedStore(project, stateStore.subscribe, scheduler)
|
|
159
|
+
const derived = makeQueryProjection({ gated: options.gated, scheduler, stateStore })
|
|
254
160
|
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
255
161
|
|
|
256
162
|
interface Request {
|
|
@@ -258,8 +164,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
258
164
|
readonly generation: number
|
|
259
165
|
}
|
|
260
166
|
|
|
261
|
-
// Commit a `Success` value: write the cell, report the generation, then persist
|
|
262
|
-
// (best-effort, trailing). A no-op when the query was disabled meanwhile.
|
|
263
167
|
const commitSuccess = (produced: A, request: Request): Effect.Effect<void> => {
|
|
264
168
|
if (disabledNow()) {
|
|
265
169
|
return Effect.void
|
|
@@ -271,11 +175,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
271
175
|
})
|
|
272
176
|
}
|
|
273
177
|
|
|
274
|
-
// One run of the query, folded into the cell. A failure becomes `Error`; an
|
|
275
|
-
// interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
|
|
276
|
-
// the body) is logged and isolated. A `Success` reports its generation through
|
|
277
|
-
// `onSettled` after the write, then persists (best-effort, trailing). The
|
|
278
|
-
// `queryRun` span covers start → settle/cancel (`ensuring` fires on all three).
|
|
279
178
|
const runQuery = (request: Request): Effect.Effect<void, never, R> =>
|
|
280
179
|
Effect.sync(() => instrumentation.queryRun(options.name)).pipe(
|
|
281
180
|
Effect.flatMap((endSpan) =>
|
|
@@ -289,7 +188,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
289
188
|
onFailure: (cause): Option.Option<A> => {
|
|
290
189
|
const failure = Cause.failureOption(cause)
|
|
291
190
|
if (Option.isSome(failure) && !disabledNow()) {
|
|
292
|
-
settleError(failure.value)
|
|
191
|
+
settleError(failure.value, request.generation)
|
|
293
192
|
}
|
|
294
193
|
return Option.none()
|
|
295
194
|
},
|
|
@@ -302,11 +201,6 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
302
201
|
),
|
|
303
202
|
)
|
|
304
203
|
|
|
305
|
-
// The driver. `'switch'` (default): each new request cancels the in-flight run
|
|
306
|
-
// — the same latest-wins semantics a `latest` channel gives procedures.
|
|
307
|
-
// `'trailing'`: a strictly sequential consumer that, on wake, drains every
|
|
308
|
-
// request that piled up during the flight and runs the LATEST one — "exactly
|
|
309
|
-
// one trailing run after settle" by construction.
|
|
310
204
|
const trailing = options.coalesce === 'trailing'
|
|
311
205
|
const requests = yield* Queue.unbounded<Request>()
|
|
312
206
|
const trailingConsumer = Effect.forever(
|
|
@@ -329,12 +223,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
329
223
|
)
|
|
330
224
|
yield* Effect.forkScoped(trailing ? trailingConsumer : switchConsumer)
|
|
331
225
|
|
|
332
|
-
// Enqueue a run of the current key, marking the flight. `force` skips the
|
|
333
|
-
// no-op-key guard (the refetch path); otherwise an unchanged key is ignored.
|
|
334
226
|
const enqueue = (force: boolean) => {
|
|
335
227
|
const args = sources.snapshot()
|
|
336
228
|
if (disabledNow(args)) {
|
|
337
|
-
|
|
229
|
+
if (MutableRef.get(lastKey) !== undefined) {
|
|
230
|
+
MutableRef.update(sourceEpoch, (current) => current + 1)
|
|
231
|
+
}
|
|
338
232
|
MutableRef.set(lastKey, undefined)
|
|
339
233
|
stateStore.set(idle)
|
|
340
234
|
return
|
|
@@ -344,6 +238,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
344
238
|
if (!force && previous !== undefined && sameKey(key, previous)) {
|
|
345
239
|
return
|
|
346
240
|
}
|
|
241
|
+
if (previous !== undefined && !sameKey(key, previous)) {
|
|
242
|
+
MutableRef.update(sourceEpoch, (current) => current + 1)
|
|
243
|
+
}
|
|
347
244
|
MutableRef.set(lastKey, key)
|
|
348
245
|
markFetching()
|
|
349
246
|
Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
|
|
@@ -351,10 +248,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
351
248
|
const trigger = () => enqueue(false)
|
|
352
249
|
const refetch = () => enqueue(true)
|
|
353
250
|
|
|
354
|
-
// `invalidate`: flip `isStale` only. A stale query with active readers then
|
|
355
|
-
// auto-refetches — the one place invalidation indirectly fetches.
|
|
356
251
|
const maybeAutoRefetch = () => {
|
|
357
|
-
|
|
252
|
+
const hasCoveringRequest = MutableRef.get(generation) > MutableRef.get(staleAtGeneration)
|
|
253
|
+
if (stateStore.get().isStale && MutableRef.get(readers) > 0 && !hasCoveringRequest) {
|
|
358
254
|
refetch()
|
|
359
255
|
}
|
|
360
256
|
}
|
|
@@ -363,38 +259,56 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
363
259
|
if (state.isStale) {
|
|
364
260
|
return
|
|
365
261
|
}
|
|
262
|
+
MutableRef.set(staleAtGeneration, MutableRef.get(generation))
|
|
366
263
|
stateStore.set({ ...state, isStale: true })
|
|
367
264
|
maybeAutoRefetch()
|
|
368
265
|
}
|
|
369
266
|
|
|
370
|
-
const unsubscribe =
|
|
267
|
+
const unsubscribe = subscribeQuerySources(sources, extraKey, trigger)
|
|
371
268
|
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
372
269
|
|
|
373
|
-
//
|
|
374
|
-
//
|
|
270
|
+
// Fork hydration so an asynchronous store never blocks layer acquisition.
|
|
271
|
+
// Epoch, key, and settled-state guards prevent a late cache from replacing newer truth.
|
|
375
272
|
if (persist !== undefined && !disabledNow()) {
|
|
376
273
|
const hydrateArgs = sources.snapshot()
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
274
|
+
const hydrateSourceKey = keyOf(hydrateArgs)
|
|
275
|
+
const hydrateSourceEpoch = MutableRef.get(sourceEpoch)
|
|
276
|
+
const hydrateStaleAtGeneration = MutableRef.get(generation)
|
|
277
|
+
const hydrate = queryStore.get(persistKey(persist.key, hydrateArgs)).pipe(
|
|
278
|
+
Effect.flatMap((cached) =>
|
|
279
|
+
Option.match(cached, {
|
|
280
|
+
onNone: () => Effect.void,
|
|
281
|
+
onSome: (raw) =>
|
|
282
|
+
persist.codec.decode(raw).pipe(
|
|
283
|
+
Effect.tap((decoded) =>
|
|
284
|
+
Effect.sync(() => {
|
|
285
|
+
const state = stateStore.get()
|
|
286
|
+
if (
|
|
287
|
+
disabledNow() ||
|
|
288
|
+
hydrateSourceEpoch !== MutableRef.get(sourceEpoch) ||
|
|
289
|
+
!sameKey(hydrateSourceKey, keyOf(sources.snapshot())) ||
|
|
290
|
+
Option.isSome(state.data) ||
|
|
291
|
+
Option.isSome(state.error)
|
|
292
|
+
) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
MutableRef.update(staleAtGeneration, (current) =>
|
|
296
|
+
Math.max(current, hydrateStaleAtGeneration),
|
|
297
|
+
)
|
|
298
|
+
stateStore.set({ ...state, data: Option.some(decoded), isStale: true })
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
304
|
+
// Corrupt/incompatible cache: ignore and fetch fresh.
|
|
305
|
+
Effect.ignore,
|
|
306
|
+
)
|
|
307
|
+
const runtime = yield* Effect.runtime<never>()
|
|
308
|
+
const scope = yield* Effect.scope
|
|
309
|
+
Runtime.runFork(runtime)(hydrate, { immediate: true, scope })
|
|
395
310
|
}
|
|
396
311
|
|
|
397
|
-
// Kick off the first fetch unless the query starts disabled.
|
|
398
312
|
const initial = sources.snapshot()
|
|
399
313
|
if (!disabledNow(initial)) {
|
|
400
314
|
MutableRef.set(lastKey, keyOf(initial))
|
|
@@ -402,27 +316,12 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
402
316
|
Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
|
|
403
317
|
}
|
|
404
318
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const before = MutableRef.get(readers)
|
|
411
|
-
MutableRef.set(readers, before + 1)
|
|
412
|
-
const off = derived.store.subscribe(listener)
|
|
413
|
-
if (before === 0) {
|
|
414
|
-
maybeAutoRefetch()
|
|
415
|
-
}
|
|
416
|
-
return () => {
|
|
417
|
-
off()
|
|
418
|
-
MutableRef.update(readers, (count) => Math.max(0, count - 1))
|
|
419
|
-
}
|
|
420
|
-
},
|
|
421
|
-
}
|
|
319
|
+
const store = withReaderTracking({
|
|
320
|
+
store: derived.store,
|
|
321
|
+
readers,
|
|
322
|
+
onFirstReader: maybeAutoRefetch,
|
|
323
|
+
})
|
|
422
324
|
|
|
423
|
-
// Register the handle so `AsyncCalc.invalidate`/`.refetch` and provider layers
|
|
424
|
-
// can act on this query by name. Optional (`serviceOption`): an absent registry
|
|
425
|
-
// (a calc wired with no `Engine` in context) just means no imperative control.
|
|
426
325
|
const handle: QueryHandle = {
|
|
427
326
|
name: options.name,
|
|
428
327
|
invalidate,
|
|
@@ -432,6 +331,11 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
432
331
|
}
|
|
433
332
|
const queries = Option.getOrUndefined(yield* Effect.serviceOption(Queries))
|
|
434
333
|
if (queries !== undefined) {
|
|
334
|
+
if (queries.byName.has(handle.name)) {
|
|
335
|
+
yield* Effect.logWarning(
|
|
336
|
+
`reform: duplicate query name '${handle.name}' registered; previous registration is being replaced`,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
435
339
|
queries.register(handle)
|
|
436
340
|
yield* Effect.addFinalizer(() => Effect.sync(() => queries.unregister(handle)))
|
|
437
341
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Effect, MutableRef, Option, Schema } from 'effect'
|
|
2
|
+
import type { AnyAsyncData } from '../calc/asyncData'
|
|
3
|
+
import { type QueryState, toAsyncData } from '../calc/queryState'
|
|
4
|
+
import type { QueryStoreApi } from './queryStore'
|
|
5
|
+
import type { Scheduler } from './scheduler'
|
|
6
|
+
import { makeDerivedStore, type Store } from './store'
|
|
7
|
+
import type { AnySchema } from './variance'
|
|
8
|
+
import type { InputsObject } from './sources'
|
|
9
|
+
import type { AnySource } from '../state/token'
|
|
10
|
+
|
|
11
|
+
interface HydrateQueryOptions<A, E> {
|
|
12
|
+
readonly cacheKey: string
|
|
13
|
+
readonly queryStore: QueryStoreApi
|
|
14
|
+
readonly schema: AnySchema<A>
|
|
15
|
+
readonly stateStore: Store<QueryState<A, E>>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const hydrateQuery = <A, E>(options: HydrateQueryOptions<A, E>): Effect.Effect<void> =>
|
|
19
|
+
Effect.flatMap(options.queryStore.get(options.cacheKey), (cached) =>
|
|
20
|
+
Option.match(cached, {
|
|
21
|
+
onNone: () => Effect.void,
|
|
22
|
+
onSome: (raw) =>
|
|
23
|
+
Schema.decodeUnknown(options.schema)(raw).pipe(
|
|
24
|
+
Effect.match({
|
|
25
|
+
onFailure: () => undefined,
|
|
26
|
+
onSuccess: (decoded) =>
|
|
27
|
+
options.stateStore.set({
|
|
28
|
+
data: Option.some(decoded),
|
|
29
|
+
error: Option.none(),
|
|
30
|
+
isFetching: false,
|
|
31
|
+
isStale: true,
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
interface PersistQueryValueOptions<A> {
|
|
39
|
+
readonly cacheKey: string
|
|
40
|
+
readonly produced: A
|
|
41
|
+
readonly queryStore: QueryStoreApi
|
|
42
|
+
readonly schema: AnySchema<A>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const persistQueryValue = <A>(options: PersistQueryValueOptions<A>): Effect.Effect<void> =>
|
|
46
|
+
Schema.encode(options.schema)(options.produced).pipe(
|
|
47
|
+
Effect.flatMap((encoded) => options.queryStore.set(options.cacheKey, encoded)),
|
|
48
|
+
Effect.ignore,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
interface PersistWriterOptions<Inputs extends ReadonlyArray<AnySource>, A> {
|
|
52
|
+
readonly persist:
|
|
53
|
+
| {
|
|
54
|
+
readonly key: string | ((inputs: InputsObject<Inputs>) => string)
|
|
55
|
+
readonly schema: AnySchema<A>
|
|
56
|
+
}
|
|
57
|
+
| undefined
|
|
58
|
+
readonly queryStore: QueryStoreApi
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const makePersistWriter = <Inputs extends ReadonlyArray<AnySource>, A>(
|
|
62
|
+
options: PersistWriterOptions<Inputs, A>,
|
|
63
|
+
): ((produced: A, inputs: InputsObject<Inputs>) => Effect.Effect<void>) => {
|
|
64
|
+
if (options.persist === undefined) {
|
|
65
|
+
return () => Effect.void
|
|
66
|
+
}
|
|
67
|
+
const persist = options.persist
|
|
68
|
+
return (produced, inputs) =>
|
|
69
|
+
persistQueryValue({
|
|
70
|
+
cacheKey: typeof persist.key === 'function' ? persist.key(inputs) : persist.key,
|
|
71
|
+
produced,
|
|
72
|
+
queryStore: options.queryStore,
|
|
73
|
+
schema: persist.schema,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface SubscribableSource {
|
|
78
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const subscribeQuerySources = (
|
|
82
|
+
sources: SubscribableSource,
|
|
83
|
+
extraKey: SubscribableSource | undefined,
|
|
84
|
+
listener: () => void,
|
|
85
|
+
): (() => void) => {
|
|
86
|
+
const offSources = sources.subscribe(listener)
|
|
87
|
+
const offExtra = extraKey?.subscribe(listener)
|
|
88
|
+
return () => {
|
|
89
|
+
offSources()
|
|
90
|
+
offExtra?.()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const settleQueryError = <A, E>(stateStore: Store<QueryState<A, E>>, error: E): void => {
|
|
95
|
+
const previous = stateStore.get()
|
|
96
|
+
stateStore.set({ ...previous, error: Option.some(error), isFetching: false })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const incrementGeneration = (generation: MutableRef.MutableRef<number>): number => {
|
|
100
|
+
MutableRef.update(generation, (current) => current + 1)
|
|
101
|
+
return MutableRef.get(generation)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface QueryProjectionOptions<A, E> {
|
|
105
|
+
readonly gated: boolean
|
|
106
|
+
readonly scheduler: Scheduler
|
|
107
|
+
readonly stateStore: Store<QueryState<A, E>>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const makeQueryProjection = <A, E>(
|
|
111
|
+
options: QueryProjectionOptions<A, E>,
|
|
112
|
+
): { readonly store: Store<AnyAsyncData<A, E>>; readonly unsubscribe: () => void } => {
|
|
113
|
+
const projection = MutableRef.make<
|
|
114
|
+
{ readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
|
|
115
|
+
>(undefined)
|
|
116
|
+
const project = (): AnyAsyncData<A, E> => {
|
|
117
|
+
const state = options.stateStore.get()
|
|
118
|
+
const previous = MutableRef.get(projection)
|
|
119
|
+
if (
|
|
120
|
+
previous !== undefined &&
|
|
121
|
+
previous.state.data === state.data &&
|
|
122
|
+
previous.state.error === state.error &&
|
|
123
|
+
previous.state.isFetching === state.isFetching
|
|
124
|
+
) {
|
|
125
|
+
return previous.view
|
|
126
|
+
}
|
|
127
|
+
const view = toAsyncData(state, options.gated)
|
|
128
|
+
MutableRef.set(projection, { state, view })
|
|
129
|
+
return view
|
|
130
|
+
}
|
|
131
|
+
return makeDerivedStore(project, options.stateStore.subscribe, options.scheduler)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface ReaderStoreOptions<A, E> {
|
|
135
|
+
readonly store: Store<AnyAsyncData<A, E>>
|
|
136
|
+
readonly readers: MutableRef.MutableRef<number>
|
|
137
|
+
readonly onFirstReader: () => void
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const withReaderTracking = <A, E>(
|
|
141
|
+
options: ReaderStoreOptions<A, E>,
|
|
142
|
+
): Store<AnyAsyncData<A, E>> => ({
|
|
143
|
+
...options.store,
|
|
144
|
+
subscribe: (listener) => {
|
|
145
|
+
const before = MutableRef.get(options.readers)
|
|
146
|
+
MutableRef.set(options.readers, before + 1)
|
|
147
|
+
const off = options.store.subscribe(listener)
|
|
148
|
+
if (before === 0) {
|
|
149
|
+
options.onFirstReader()
|
|
150
|
+
}
|
|
151
|
+
return () => {
|
|
152
|
+
off()
|
|
153
|
+
MutableRef.update(options.readers, (count) => Math.max(0, count - 1))
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
})
|