@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
|
@@ -10,8 +10,11 @@ import {
|
|
|
10
10
|
type Scope,
|
|
11
11
|
Stream,
|
|
12
12
|
} from 'effect'
|
|
13
|
-
import { type AnyAsyncData
|
|
13
|
+
import { type AnyAsyncData } from '../calc/asyncData'
|
|
14
|
+
import { empty, type QueryState, toAsyncData } from '../calc/queryState'
|
|
15
|
+
import { Queries, type QueryHandle } from '../runtime/queries'
|
|
14
16
|
import { reuse } from './reuse'
|
|
17
|
+
import { resolveQueryStore } from './queryStore'
|
|
15
18
|
import { resolveScheduler } from './scheduler'
|
|
16
19
|
import {
|
|
17
20
|
type InputsObject,
|
|
@@ -20,20 +23,24 @@ import {
|
|
|
20
23
|
sameKey,
|
|
21
24
|
wireSources,
|
|
22
25
|
} from './sources'
|
|
23
|
-
import { makeStore, type Store } from './store'
|
|
26
|
+
import { makeDerivedStore, makeStore, type Store } from './store'
|
|
24
27
|
import { type AnySource } from '../state/token'
|
|
25
28
|
|
|
26
29
|
// The query-lifecycle driver shared by `AsyncCalc.live` and `RemoteState.live`:
|
|
27
|
-
// sense the inputs, run the query latest-wins (or trailing-conflated), fold
|
|
28
|
-
// result into
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// sense the inputs, run the query latest-wins (or trailing-conflated), and fold
|
|
31
|
+
// the result into a flat `QueryState` cell (data/error/isFetching/isStale). The
|
|
32
|
+
// public store is the `AnyAsyncData` *projection* of that cell, so existing
|
|
33
|
+
// arm-matching consumers and `RemoteState` are unchanged. `invalidate` (flip
|
|
34
|
+
// `isStale`) and `refetch` (force a run) are exposed as separate operations and
|
|
35
|
+
// registered into the `Queries` registry by name. The asyncCalc test suite is the
|
|
36
|
+
// driver's regression suite.
|
|
31
37
|
|
|
32
38
|
/** `Gated` (whether the `Idle` arm exists) is the inverse of `alwaysOn`. */
|
|
33
39
|
export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
|
|
34
40
|
|
|
35
41
|
/** The runtime gated flag for an `alwaysOn` config, typed as its `Gated` literal. */
|
|
36
42
|
export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
|
|
43
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- restate the runtime boolean as its conditional `GatedOf` literal; no guard maps a boolean to a conditional type
|
|
37
44
|
(alwaysOn !== true) as GatedOf<AlwaysOn>
|
|
38
45
|
|
|
39
46
|
// The hidden revision behind `invalidateOn` — a module-local *branded* number.
|
|
@@ -49,10 +56,10 @@ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pip
|
|
|
49
56
|
Schema.brand('reform/Revision'),
|
|
50
57
|
)
|
|
51
58
|
export const revisionZero: Revision = Revision(0)
|
|
52
|
-
export const bumpRevision = (
|
|
59
|
+
export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
|
|
53
60
|
|
|
54
61
|
export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
55
|
-
/** The owning definition's name —
|
|
62
|
+
/** The owning definition's name — the defect log line and the `Queries` key. */
|
|
56
63
|
readonly name: string
|
|
57
64
|
/** The owning primitive's kind label — only for the defect log line. */
|
|
58
65
|
readonly label: string
|
|
@@ -60,32 +67,53 @@ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A,
|
|
|
60
67
|
readonly gated: boolean
|
|
61
68
|
readonly inputs: Inputs
|
|
62
69
|
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
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
|
|
63
71
|
readonly invalidateBy?: InvalidateBy<Inputs> | undefined
|
|
72
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
|
|
64
73
|
readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
|
|
74
|
+
// 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
75
|
readonly coalesce?: 'switch' | 'trailing' | undefined
|
|
76
|
+
// 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
77
|
readonly reuse?: boolean | undefined
|
|
67
78
|
/**
|
|
68
79
|
* An extra key segment + wake source beyond the declared inputs — the hidden
|
|
69
80
|
* `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
|
|
70
81
|
* and `invalidateBy` see exactly the declared inputs.
|
|
71
82
|
*/
|
|
83
|
+
// 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
84
|
readonly extraKey?:
|
|
73
85
|
| {
|
|
74
86
|
readonly read: () => unknown
|
|
75
87
|
readonly subscribe: (listener: () => void) => () => void
|
|
76
88
|
}
|
|
77
89
|
| undefined
|
|
90
|
+
/**
|
|
91
|
+
* Persist the `Success` value through the optional `QueryStore`: hydrate the
|
|
92
|
+
* cell from `key` before the first run (seeded `isStale: true`, so it shows
|
|
93
|
+
* instantly and still refetches) and write through on each settle. Values
|
|
94
|
+
* (de)serialize through `schema`, never trusted structurally.
|
|
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
|
|
97
|
+
readonly persist?:
|
|
98
|
+
| {
|
|
99
|
+
// A static string keys a singleton read; a function of the inputs keys a
|
|
100
|
+
// family per-entity, so reactive-input keyed reads never collide on one slot.
|
|
101
|
+
readonly key: string | ((inputs: InputsObject<Inputs>) => string)
|
|
102
|
+
readonly schema: Schema.Schema<A, any>
|
|
103
|
+
}
|
|
104
|
+
| undefined
|
|
78
105
|
/**
|
|
79
106
|
* Fired when a run's `Success` value has landed in the store (same scheduler
|
|
80
107
|
* flush — never before the converged value is readable), with the run's
|
|
81
108
|
* generation. Not fired on `Error`, interrupt, or while disabled. The seam
|
|
82
109
|
* `RemoteState` settles pending intents through.
|
|
83
110
|
*/
|
|
111
|
+
// 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
112
|
readonly onSettled?: ((generation: number) => void) | undefined
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
export interface QueryDriver<A, E> {
|
|
88
|
-
/** The lifecycle store (the
|
|
116
|
+
/** The lifecycle store (the `AnyAsyncData` projection — callers narrow to their arms). */
|
|
89
117
|
readonly store: Store<AnyAsyncData<A, E>>
|
|
90
118
|
/**
|
|
91
119
|
* The highest generation requested so far (0 before the first kick).
|
|
@@ -97,13 +125,15 @@ export interface QueryDriver<A, E> {
|
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/**
|
|
100
|
-
* Build the driver:
|
|
101
|
-
* by the ambient scope (callers run this under `Layer.scoped`). A
|
|
102
|
-
* input re-runs the query latest-wins (a new run cancels the in-flight
|
|
103
|
-
* with `coalesce: 'trailing'`, lets it finish and runs one trailing
|
|
104
|
-
* while a re-run is in flight the last `
|
|
105
|
-
* `
|
|
128
|
+
* Build the driver: state cell + projection + subscription + request queue + run
|
|
129
|
+
* fiber, all owned by the ambient scope (callers run this under `Layer.scoped`). A
|
|
130
|
+
* change to an input re-runs the query latest-wins (a new run cancels the in-flight
|
|
131
|
+
* one; or, with `coalesce: 'trailing'`, lets it finish and runs one trailing
|
|
132
|
+
* refetch); while a re-run is in flight the last `data`/`error` is kept with
|
|
133
|
+
* `isFetching: true`. `invalidate` flips `isStale`; a stale query with active
|
|
134
|
+
* readers auto-refetches (the same rule that drives refetch-on-resubscribe).
|
|
106
135
|
*/
|
|
136
|
+
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- generic export: Effect.fn's inferred type isn't portable under isolatedDeclarations
|
|
107
137
|
export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
|
|
108
138
|
options: QueryDriverOptions<Inputs, A, E, R>,
|
|
109
139
|
): Effect.Effect<QueryDriver<A, E>, never, InputStores<Inputs> | R | Scope.Scope> =>
|
|
@@ -111,9 +141,16 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
111
141
|
const scheduler = yield* resolveScheduler
|
|
112
142
|
const sources = yield* wireSources(options.inputs, options.invalidateBy)
|
|
113
143
|
const extraKey = options.extraKey
|
|
144
|
+
const persist = options.persist
|
|
145
|
+
const queryStore = yield* resolveQueryStore
|
|
114
146
|
|
|
115
147
|
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
116
148
|
extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
|
|
149
|
+
// The persist storage key at the current inputs: a static string for a
|
|
150
|
+
// singleton, or the per-input function for a keyed family (no DOM/extraKey
|
|
151
|
+
// churn — purely the user-declared key, so it stays stable across refetches).
|
|
152
|
+
const persistKey = (key: string | ((inputs: InputsObject<Inputs>) => string), args: InputsObject<Inputs>): string =>
|
|
153
|
+
typeof key === 'function' ? key(args) : key
|
|
117
154
|
const subscribeAll = (listener: () => void): (() => void) => {
|
|
118
155
|
const offSources = sources.subscribe(listener)
|
|
119
156
|
const offExtra = extraKey?.subscribe(listener)
|
|
@@ -125,123 +162,273 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
125
162
|
const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
|
|
126
163
|
options.gated && options.disabled !== undefined ? options.disabled(args) : false
|
|
127
164
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
//
|
|
165
|
+
// The flat source-of-truth cell. `idle` is a shared reference so re-disabling
|
|
166
|
+
// an already-idle query is a no-op write (the store's Equal gate suppresses it).
|
|
167
|
+
const idle: QueryState<A, E> = empty(false)
|
|
168
|
+
const stateStore = makeStore<QueryState<A, E>>(disabledNow() ? idle : empty(true), scheduler)
|
|
169
|
+
|
|
170
|
+
// Active-reader count of the PROJECTION (React components, the RemoteState
|
|
171
|
+
// overlay). Drives the "stale + active ⇒ refetch" policy and refetch-on-
|
|
172
|
+
// resubscribe (a 0→1 transition).
|
|
173
|
+
const readers = MutableRef.make(0)
|
|
174
|
+
|
|
175
|
+
// The last requested key, so a change that doesn't move it (or only churns an
|
|
176
|
+
// input `invalidateBy` ignores) doesn't re-fetch.
|
|
134
177
|
const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
|
|
135
|
-
// The run-generation counter: bumped when a run is REQUESTED (enqueued), so
|
|
136
|
-
// `requested()` names the newest run that could possibly be in flight.
|
|
137
178
|
const generation = MutableRef.make(0)
|
|
138
179
|
const nextGeneration = (): number => {
|
|
139
|
-
MutableRef.update(generation, (
|
|
180
|
+
MutableRef.update(generation, (current) => current + 1)
|
|
140
181
|
return MutableRef.get(generation)
|
|
141
182
|
}
|
|
142
183
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
184
|
+
// ── State writers, each touching only the axes it owns. The untouched axes
|
|
185
|
+
// keep their field references, so a pure `isStale` flip leaves the projection
|
|
186
|
+
// reference unmoved (see `project`). ──────────────────────────────────────
|
|
187
|
+
const markFetching = () => {
|
|
188
|
+
const state = stateStore.get()
|
|
189
|
+
if (!state.isFetching) {
|
|
190
|
+
stateStore.set({ ...state, isFetching: true })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const settleSuccess = (produced: A): A => {
|
|
194
|
+
const prev = stateStore.get()
|
|
195
|
+
const shared =
|
|
196
|
+
options.reuse === true && Option.isSome(prev.data)
|
|
197
|
+
? reuse(prev.data.value, produced)
|
|
198
|
+
: produced
|
|
199
|
+
stateStore.set({
|
|
200
|
+
data: Option.some(shared),
|
|
201
|
+
error: Option.none(),
|
|
202
|
+
isFetching: false,
|
|
203
|
+
isStale: false,
|
|
204
|
+
})
|
|
205
|
+
return shared
|
|
206
|
+
}
|
|
207
|
+
const settleError = (error: E) => {
|
|
208
|
+
const prev = stateStore.get()
|
|
209
|
+
// Keep `data` underneath: `error` wins in the projection (the `Error` arm),
|
|
210
|
+
// but a consumer reading `QueryState` still has the last good value.
|
|
211
|
+
stateStore.set({ ...prev, error: Option.some(error), isFetching: false })
|
|
149
212
|
}
|
|
150
213
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
214
|
+
const persistWriteTo = (
|
|
215
|
+
target: NonNullable<typeof persist>,
|
|
216
|
+
produced: A,
|
|
217
|
+
args: InputsObject<Inputs>,
|
|
218
|
+
): Effect.Effect<void> =>
|
|
219
|
+
Schema.encode(target.schema)(produced).pipe(
|
|
220
|
+
Effect.flatMap((encoded) => queryStore.set(persistKey(target.key, args), encoded)),
|
|
221
|
+
// Persistence is best-effort: a serialization or storage failure must
|
|
222
|
+
// never fail the run nor block the visible write.
|
|
223
|
+
Effect.ignore,
|
|
224
|
+
)
|
|
225
|
+
const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
|
|
226
|
+
persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
|
|
227
|
+
|
|
228
|
+
// The projection the public store exposes: `AnyAsyncData`, memoized so a pure
|
|
229
|
+
// `isStale` change (which the projection ignores) returns the SAME reference
|
|
230
|
+
// and wakes no `AnyAsyncData` subscriber.
|
|
231
|
+
const projection = MutableRef.make<
|
|
232
|
+
{ readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
|
|
233
|
+
>(undefined)
|
|
234
|
+
const project = (): AnyAsyncData<A, E> => {
|
|
235
|
+
const state = stateStore.get()
|
|
236
|
+
const prev = MutableRef.get(projection)
|
|
237
|
+
// The projection depends only on data/error/isFetching; writers preserve the
|
|
238
|
+
// refs of untouched axes, so reference identity on those three is exact.
|
|
239
|
+
if (
|
|
240
|
+
prev !== undefined &&
|
|
241
|
+
prev.state.data === state.data &&
|
|
242
|
+
prev.state.error === state.error &&
|
|
243
|
+
prev.state.isFetching === state.isFetching
|
|
244
|
+
) {
|
|
245
|
+
return prev.view
|
|
246
|
+
}
|
|
247
|
+
const view = toAsyncData(state, options.gated)
|
|
248
|
+
MutableRef.set(projection, { state, view })
|
|
249
|
+
return view
|
|
250
|
+
}
|
|
251
|
+
const derived = makeDerivedStore(project, stateStore.subscribe, scheduler)
|
|
252
|
+
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
253
|
+
|
|
254
|
+
interface Request {
|
|
255
|
+
readonly args: InputsObject<Inputs>
|
|
256
|
+
readonly generation: number
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Commit a `Success` value: write the cell, report the generation, then persist
|
|
260
|
+
// (best-effort, trailing). A no-op when the query was disabled meanwhile.
|
|
261
|
+
const commitSuccess = (produced: A, request: Request): Effect.Effect<void> => {
|
|
262
|
+
if (disabledNow()) {
|
|
263
|
+
return Effect.void
|
|
264
|
+
}
|
|
265
|
+
return Effect.suspend(() => {
|
|
266
|
+
const shared = settleSuccess(produced)
|
|
267
|
+
options.onSettled?.(request.generation)
|
|
268
|
+
return persistWrite(shared, request.args)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// One run of the query, folded into the cell. A failure becomes `Error`; an
|
|
273
|
+
// interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
|
|
274
|
+
// the body) is logged and isolated. A `Success` reports its generation through
|
|
275
|
+
// `onSettled` after the write, then persists (best-effort, trailing).
|
|
156
276
|
const runQuery = (request: Request): Effect.Effect<void, never, R> =>
|
|
157
277
|
options.query(request.args).pipe(
|
|
158
278
|
Effect.tapDefect((defect) =>
|
|
159
279
|
Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
|
|
160
280
|
),
|
|
161
281
|
Effect.matchCause({
|
|
162
|
-
onSuccess: (
|
|
163
|
-
|
|
164
|
-
// `reuse`: share unchanged subtrees with the previous Success
|
|
165
|
-
// value, so a refetch that barely moved keeps identities stable.
|
|
166
|
-
const prev = store.get()
|
|
167
|
-
const shared =
|
|
168
|
-
options.reuse === true && prev._tag === 'Success'
|
|
169
|
-
? reuse(prev.value, value)
|
|
170
|
-
: value
|
|
171
|
-
store.set(AsyncData.success(shared, false))
|
|
172
|
-
options.onSettled?.(request.generation)
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
onFailure: (cause) => {
|
|
282
|
+
onSuccess: (produced): Option.Option<A> => Option.some(produced),
|
|
283
|
+
onFailure: (cause): Option.Option<A> => {
|
|
176
284
|
const failure = Cause.failureOption(cause)
|
|
177
285
|
if (Option.isSome(failure) && !disabledNow()) {
|
|
178
|
-
|
|
286
|
+
settleError(failure.value)
|
|
179
287
|
}
|
|
288
|
+
return Option.none()
|
|
180
289
|
},
|
|
181
290
|
}),
|
|
291
|
+
Effect.flatMap((ok) =>
|
|
292
|
+
Option.match(ok, {
|
|
293
|
+
onNone: () => Effect.void,
|
|
294
|
+
onSome: (produced) => commitSuccess(produced, request),
|
|
295
|
+
}),
|
|
296
|
+
),
|
|
182
297
|
)
|
|
183
298
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
readonly generation: number
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// The driver. `'switch'` (default): each new request cancels the in-flight
|
|
190
|
-
// run — the same latest-wins semantics a `latest` channel gives procedures.
|
|
299
|
+
// The driver. `'switch'` (default): each new request cancels the in-flight run
|
|
300
|
+
// — the same latest-wins semantics a `latest` channel gives procedures.
|
|
191
301
|
// `'trailing'`: a strictly sequential consumer that, on wake, drains every
|
|
192
|
-
// request that piled up during the flight and runs the LATEST one —
|
|
193
|
-
//
|
|
194
|
-
// run's generation is the max drained (the latest request's), so it
|
|
195
|
-
// vouches for every request it conflated.
|
|
302
|
+
// request that piled up during the flight and runs the LATEST one — "exactly
|
|
303
|
+
// one trailing run after settle" by construction.
|
|
196
304
|
const trailing = options.coalesce === 'trailing'
|
|
197
305
|
const requests = yield* Queue.unbounded<Request>()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const pending = yield* Queue.size(requests)
|
|
212
|
-
if (pending > 0 && !disabledNow()) yield* Effect.sync(markRefetching)
|
|
213
|
-
}),
|
|
214
|
-
)
|
|
215
|
-
: Stream.fromQueue(requests).pipe(
|
|
216
|
-
Stream.flatMap((request) => Stream.fromEffect(runQuery(request)), { switch: true }),
|
|
217
|
-
Stream.runDrain,
|
|
218
|
-
),
|
|
306
|
+
const trailingConsumer = Effect.forever(
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
const first = yield* Queue.take(requests)
|
|
309
|
+
const queued = yield* Queue.takeAll(requests)
|
|
310
|
+
const request = Option.getOrElse(Chunk.last(queued), () => first)
|
|
311
|
+
if (!disabledNow()) {
|
|
312
|
+
yield* runQuery(request)
|
|
313
|
+
}
|
|
314
|
+
const pending = yield* Queue.size(requests)
|
|
315
|
+
if (pending > 0 && !disabledNow()) {
|
|
316
|
+
yield* Effect.sync(markFetching)
|
|
317
|
+
}
|
|
318
|
+
}),
|
|
219
319
|
)
|
|
320
|
+
const switchConsumer = Stream.fromQueue(requests).pipe(
|
|
321
|
+
Stream.flatMap((request) => Stream.fromEffect(runQuery(request)), { switch: true }),
|
|
322
|
+
Stream.runDrain,
|
|
323
|
+
)
|
|
324
|
+
yield* Effect.forkScoped(trailing ? trailingConsumer : switchConsumer)
|
|
220
325
|
|
|
221
|
-
|
|
326
|
+
// Enqueue a run of the current key, marking the flight. `force` skips the
|
|
327
|
+
// no-op-key guard (the refetch path); otherwise an unchanged key is ignored.
|
|
328
|
+
const enqueue = (force: boolean) => {
|
|
222
329
|
const args = sources.snapshot()
|
|
223
330
|
if (disabledNow(args)) {
|
|
224
331
|
// Switched off: show Idle and forget the key so re-enabling always re-runs.
|
|
225
332
|
MutableRef.set(lastKey, undefined)
|
|
226
|
-
|
|
333
|
+
stateStore.set(idle)
|
|
227
334
|
return
|
|
228
335
|
}
|
|
229
336
|
const key = keyOf(args)
|
|
230
337
|
const previous = MutableRef.get(lastKey)
|
|
231
|
-
if (previous !== undefined && sameKey(key, previous))
|
|
338
|
+
if (!force && previous !== undefined && sameKey(key, previous)) {
|
|
339
|
+
return
|
|
340
|
+
}
|
|
232
341
|
MutableRef.set(lastKey, key)
|
|
233
|
-
|
|
342
|
+
markFetching()
|
|
234
343
|
Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
|
|
235
344
|
}
|
|
345
|
+
const trigger = () => enqueue(false)
|
|
346
|
+
const refetch = () => enqueue(true)
|
|
347
|
+
|
|
348
|
+
// `invalidate`: flip `isStale` only. A stale query with active readers then
|
|
349
|
+
// auto-refetches — the one place invalidation indirectly fetches.
|
|
350
|
+
const maybeAutoRefetch = () => {
|
|
351
|
+
if (stateStore.get().isStale && MutableRef.get(readers) > 0) {
|
|
352
|
+
refetch()
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const invalidate = () => {
|
|
356
|
+
const state = stateStore.get()
|
|
357
|
+
if (state.isStale) {
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
stateStore.set({ ...state, isStale: true })
|
|
361
|
+
maybeAutoRefetch()
|
|
362
|
+
}
|
|
363
|
+
|
|
236
364
|
const unsubscribe = subscribeAll(trigger)
|
|
237
365
|
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
238
366
|
|
|
367
|
+
// Hydrate from the persisted value before the first kick: show it instantly,
|
|
368
|
+
// marked stale, so the kick refetches over it (stale-while-revalidate).
|
|
369
|
+
if (persist !== undefined && !disabledNow()) {
|
|
370
|
+
const hydrateArgs = sources.snapshot()
|
|
371
|
+
const cached = yield* queryStore.get(persistKey(persist.key, hydrateArgs))
|
|
372
|
+
yield* Option.match(cached, {
|
|
373
|
+
onNone: () => Effect.void,
|
|
374
|
+
onSome: (raw) =>
|
|
375
|
+
Schema.decodeUnknown(persist.schema)(raw).pipe(
|
|
376
|
+
Effect.match({
|
|
377
|
+
// Corrupt/incompatible cache: ignore and fetch fresh.
|
|
378
|
+
onFailure: () => undefined,
|
|
379
|
+
onSuccess: (decoded) =>
|
|
380
|
+
stateStore.set({
|
|
381
|
+
data: Option.some(decoded),
|
|
382
|
+
error: Option.none(),
|
|
383
|
+
isFetching: false,
|
|
384
|
+
isStale: true,
|
|
385
|
+
}),
|
|
386
|
+
}),
|
|
387
|
+
),
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
239
391
|
// Kick off the first fetch unless the query starts disabled.
|
|
240
392
|
const initial = sources.snapshot()
|
|
241
393
|
if (!disabledNow(initial)) {
|
|
242
394
|
MutableRef.set(lastKey, keyOf(initial))
|
|
395
|
+
markFetching()
|
|
243
396
|
Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
|
|
244
397
|
}
|
|
245
398
|
|
|
399
|
+
// The public store: the projection, with reader-count tracking wrapped around
|
|
400
|
+
// `subscribe`, so a 0→1 transition can auto-refetch a stale query.
|
|
401
|
+
const store: Store<AnyAsyncData<A, E>> = {
|
|
402
|
+
...derived.store,
|
|
403
|
+
subscribe: (listener) => {
|
|
404
|
+
const before = MutableRef.get(readers)
|
|
405
|
+
MutableRef.set(readers, before + 1)
|
|
406
|
+
const off = derived.store.subscribe(listener)
|
|
407
|
+
if (before === 0) {
|
|
408
|
+
maybeAutoRefetch()
|
|
409
|
+
}
|
|
410
|
+
return () => {
|
|
411
|
+
off()
|
|
412
|
+
MutableRef.update(readers, (count) => Math.max(0, count - 1))
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Register the handle so `AsyncCalc.invalidate`/`.refetch` and provider layers
|
|
418
|
+
// can act on this query by name. Optional (`serviceOption`): an absent registry
|
|
419
|
+
// (a calc wired with no `Engine` in context) just means no imperative control.
|
|
420
|
+
const handle: QueryHandle = {
|
|
421
|
+
name: options.name,
|
|
422
|
+
invalidate,
|
|
423
|
+
refetch,
|
|
424
|
+
snapshot: () => stateStore.getSnapshot(),
|
|
425
|
+
subscribe: (listener) => stateStore.subscribe(listener),
|
|
426
|
+
}
|
|
427
|
+
const queries = Option.getOrUndefined(yield* Effect.serviceOption(Queries))
|
|
428
|
+
if (queries !== undefined) {
|
|
429
|
+
queries.register(handle)
|
|
430
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => queries.unregister(handle)))
|
|
431
|
+
}
|
|
432
|
+
|
|
246
433
|
return { store, requested: () => MutableRef.get(generation) }
|
|
247
434
|
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Context, Effect, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
// `QueryEvents` is the optional host-signal seam invalidation *sources* subscribe
|
|
4
|
+
// to — window focus/visibility and network reconnect. It carries no DOM itself:
|
|
5
|
+
// the concrete `window.addEventListener` implementation lives in an edge package
|
|
6
|
+
// (`@playfast/reform-query-browser`); core only declares the shape. Resolved via
|
|
7
|
+
// `serviceOption` with a no-op fallback, so its absence (SSR, native, proofs) is
|
|
8
|
+
// inert. The subscriptions return an unsubscribe thunk — the synchronous,
|
|
9
|
+
// React-facing shape the rest of reform uses (see `Store.subscribe`).
|
|
10
|
+
|
|
11
|
+
export interface QueryEventsApi {
|
|
12
|
+
/** Fires when the window/tab regains focus or visibility. */
|
|
13
|
+
readonly subscribeFocus: (listener: () => void) => () => void
|
|
14
|
+
/** Fires when the network comes back online. */
|
|
15
|
+
readonly subscribeOnline: (listener: () => void) => () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const QueryEventsBase: Context.TagClass<QueryEvents, 'reform/QueryEvents', QueryEventsApi> =
|
|
19
|
+
Context.Tag('reform/QueryEvents')<QueryEvents, QueryEventsApi>()
|
|
20
|
+
export class QueryEvents extends QueryEventsBase {}
|
|
21
|
+
|
|
22
|
+
const noop = (): (() => void) => () => {}
|
|
23
|
+
|
|
24
|
+
/** A source that never fires — the fallback when no host provides `QueryEvents`. */
|
|
25
|
+
export const noopQueryEvents: QueryEventsApi = {
|
|
26
|
+
subscribeFocus: noop,
|
|
27
|
+
subscribeOnline: noop,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve the host's `QueryEvents`, or the no-op source when none is in context. */
|
|
31
|
+
export const resolveQueryEvents: Effect.Effect<QueryEventsApi> = Effect.map(
|
|
32
|
+
Effect.serviceOption(QueryEvents),
|
|
33
|
+
Option.getOrElse(() => noopQueryEvents),
|
|
34
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Context, Effect, Option } from 'effect'
|
|
2
|
+
|
|
3
|
+
// `QueryStore` is the optional persistence seam an `AsyncCalc` writes through and
|
|
4
|
+
// hydrates from — a key→value cache provided by the host (localStorage,
|
|
5
|
+
// IndexedDB, AsyncStorage, …). Like `Notifications`, it is resolved through
|
|
6
|
+
// `serviceOption`, so a calc that opts into `persist` imposes NO hard requirement:
|
|
7
|
+
// absent host ⇒ the no-op store ⇒ the feature is simply inert (SSR, native,
|
|
8
|
+
// proofs). Values cross the boundary as `unknown` and are (de)serialized through
|
|
9
|
+
// the calc's own `output` schema by the driver, never trusted structurally.
|
|
10
|
+
|
|
11
|
+
export interface QueryStoreApi {
|
|
12
|
+
readonly get: (key: string) => Effect.Effect<Option.Option<unknown>>
|
|
13
|
+
readonly set: (key: string, value: unknown) => Effect.Effect<void>
|
|
14
|
+
readonly remove: (key: string) => Effect.Effect<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const QueryStoreBase: Context.TagClass<QueryStore, 'reform/QueryStore', QueryStoreApi> =
|
|
18
|
+
Context.Tag('reform/QueryStore')<QueryStore, QueryStoreApi>()
|
|
19
|
+
export class QueryStore extends QueryStoreBase {}
|
|
20
|
+
|
|
21
|
+
/** A store that persists nothing — the fallback when no host provides `QueryStore`. */
|
|
22
|
+
export const noopQueryStore: QueryStoreApi = {
|
|
23
|
+
get: () => Effect.succeedNone,
|
|
24
|
+
set: () => Effect.void,
|
|
25
|
+
remove: () => Effect.void,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the host's `QueryStore`, or the no-op store when none is in context.
|
|
30
|
+
* `serviceOption` keeps it requirement-free — a calc wired as a sibling of the
|
|
31
|
+
* runtime that provides no persistence simply gets the inert store.
|
|
32
|
+
*/
|
|
33
|
+
export const resolveQueryStore: Effect.Effect<QueryStoreApi> = Effect.map(
|
|
34
|
+
Effect.serviceOption(QueryStore),
|
|
35
|
+
Option.getOrElse(() => noopQueryStore),
|
|
36
|
+
)
|