@playfast/reform 0.0.8 → 0.0.10
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/calc/asyncCalc.invalidate.test.ts +166 -0
- package/src/calc/asyncCalc.ts +44 -0
- package/src/calc/queryState.ts +52 -0
- package/src/compose/composition.ts +57 -23
- package/src/compose/host.ts +7 -13
- package/src/compose/structure.test.ts +90 -0
- package/src/compose/structure.ts +145 -0
- package/src/compose/ui.test.ts +2 -30
- package/src/compose/ui.ts +21 -43
- package/src/index.ts +39 -3
- package/src/internal/capture.ts +8 -0
- package/src/internal/errors.ts +0 -12
- package/src/internal/queryDriver.ts +209 -71
- package/src/internal/queryEvents.ts +34 -0
- package/src/internal/queryStore.ts +36 -0
- package/src/runtime/loop.ts +14 -7
- package/src/runtime/queries.ts +53 -0
- package/src/scene/scene.ts +26 -14
- package/src/scene/seedScene.test.ts +37 -30
- package/src/state/stateGroup.ts +20 -0
|
@@ -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,14 +23,17 @@ 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
|
|
@@ -52,7 +58,7 @@ export const revisionZero: Revision = Revision(0)
|
|
|
52
58
|
export const bumpRevision = (r: Revision): Revision => Revision(r + 1)
|
|
53
59
|
|
|
54
60
|
export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
55
|
-
/** The owning definition's name —
|
|
61
|
+
/** The owning definition's name — the defect log line and the `Queries` key. */
|
|
56
62
|
readonly name: string
|
|
57
63
|
/** The owning primitive's kind label — only for the defect log line. */
|
|
58
64
|
readonly label: string
|
|
@@ -75,6 +81,13 @@ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A,
|
|
|
75
81
|
readonly subscribe: (listener: () => void) => () => void
|
|
76
82
|
}
|
|
77
83
|
| undefined
|
|
84
|
+
/**
|
|
85
|
+
* Persist the `Success` value through the optional `QueryStore`: hydrate the
|
|
86
|
+
* cell from `key` before the first run (seeded `isStale: true`, so it shows
|
|
87
|
+
* instantly and still refetches) and write through on each settle. Values
|
|
88
|
+
* (de)serialize through `schema`, never trusted structurally.
|
|
89
|
+
*/
|
|
90
|
+
readonly persist?: { readonly key: string; readonly schema: Schema.Schema<A, any> } | undefined
|
|
78
91
|
/**
|
|
79
92
|
* Fired when a run's `Success` value has landed in the store (same scheduler
|
|
80
93
|
* flush — never before the converged value is readable), with the run's
|
|
@@ -85,7 +98,7 @@ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A,
|
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
export interface QueryDriver<A, E> {
|
|
88
|
-
/** The lifecycle store (the
|
|
101
|
+
/** The lifecycle store (the `AnyAsyncData` projection — callers narrow to their arms). */
|
|
89
102
|
readonly store: Store<AnyAsyncData<A, E>>
|
|
90
103
|
/**
|
|
91
104
|
* The highest generation requested so far (0 before the first kick).
|
|
@@ -97,12 +110,13 @@ export interface QueryDriver<A, E> {
|
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
/**
|
|
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
|
-
* `
|
|
113
|
+
* Build the driver: state cell + projection + subscription + request queue + run
|
|
114
|
+
* fiber, all owned by the ambient scope (callers run this under `Layer.scoped`). A
|
|
115
|
+
* change to an input re-runs the query latest-wins (a new run cancels the in-flight
|
|
116
|
+
* one; or, with `coalesce: 'trailing'`, lets it finish and runs one trailing
|
|
117
|
+
* refetch); while a re-run is in flight the last `data`/`error` is kept with
|
|
118
|
+
* `isFetching: true`. `invalidate` flips `isStale`; a stale query with active
|
|
119
|
+
* readers auto-refetches (the same rule that drives refetch-on-resubscribe).
|
|
106
120
|
*/
|
|
107
121
|
export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
|
|
108
122
|
options: QueryDriverOptions<Inputs, A, E, R>,
|
|
@@ -111,6 +125,8 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
111
125
|
const scheduler = yield* resolveScheduler
|
|
112
126
|
const sources = yield* wireSources(options.inputs, options.invalidateBy)
|
|
113
127
|
const extraKey = options.extraKey
|
|
128
|
+
const persist = options.persist
|
|
129
|
+
const queryStore = yield* resolveQueryStore
|
|
114
130
|
|
|
115
131
|
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
116
132
|
extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
|
|
@@ -125,74 +141,128 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
125
141
|
const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
|
|
126
142
|
options.gated && options.disabled !== undefined ? options.disabled(args) : false
|
|
127
143
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
//
|
|
144
|
+
// The flat source-of-truth cell. `idle` is a shared reference so re-disabling
|
|
145
|
+
// an already-idle query is a no-op write (the store's Equal gate suppresses it).
|
|
146
|
+
const idle: QueryState<A, E> = empty(false)
|
|
147
|
+
const stateStore = makeStore<QueryState<A, E>>(disabledNow() ? idle : empty(true), scheduler)
|
|
148
|
+
|
|
149
|
+
// Active-reader count of the PROJECTION (React components, the RemoteState
|
|
150
|
+
// overlay). Drives the "stale + active ⇒ refetch" policy and refetch-on-
|
|
151
|
+
// resubscribe (a 0→1 transition).
|
|
152
|
+
const readers = MutableRef.make(0)
|
|
153
|
+
|
|
154
|
+
// The last requested key, so a change that doesn't move it (or only churns an
|
|
155
|
+
// input `invalidateBy` ignores) doesn't re-fetch.
|
|
134
156
|
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
157
|
const generation = MutableRef.make(0)
|
|
138
158
|
const nextGeneration = (): number => {
|
|
139
159
|
MutableRef.update(generation, (n) => n + 1)
|
|
140
160
|
return MutableRef.get(generation)
|
|
141
161
|
}
|
|
142
162
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
// ── State writers, each touching only the axes it owns. The untouched axes
|
|
164
|
+
// keep their field references, so a pure `isStale` flip leaves the projection
|
|
165
|
+
// reference unmoved (see `project`). ──────────────────────────────────────
|
|
166
|
+
const markFetching = () => {
|
|
167
|
+
const s = stateStore.get()
|
|
168
|
+
if (!s.isFetching) stateStore.set({ ...s, isFetching: true })
|
|
169
|
+
}
|
|
170
|
+
const settleSuccess = (value: A): A => {
|
|
171
|
+
const prev = stateStore.get()
|
|
172
|
+
const shared =
|
|
173
|
+
options.reuse === true && Option.isSome(prev.data) ? reuse(prev.data.value, value) : value
|
|
174
|
+
stateStore.set({
|
|
175
|
+
data: Option.some(shared),
|
|
176
|
+
error: Option.none(),
|
|
177
|
+
isFetching: false,
|
|
178
|
+
isStale: false,
|
|
179
|
+
})
|
|
180
|
+
return shared
|
|
181
|
+
}
|
|
182
|
+
const settleError = (error: E) => {
|
|
183
|
+
const prev = stateStore.get()
|
|
184
|
+
// Keep `data` underneath: `error` wins in the projection (the `Error` arm),
|
|
185
|
+
// but a consumer reading `QueryState` still has the last good value.
|
|
186
|
+
stateStore.set({ ...prev, error: Option.some(error), isFetching: false })
|
|
149
187
|
}
|
|
150
188
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
189
|
+
const persistWrite = (value: A): Effect.Effect<void> =>
|
|
190
|
+
persist === undefined
|
|
191
|
+
? Effect.void
|
|
192
|
+
: Schema.encode(persist.schema)(value).pipe(
|
|
193
|
+
Effect.flatMap((encoded) => queryStore.set(persist.key, encoded)),
|
|
194
|
+
// Persistence is best-effort: a serialization or storage failure must
|
|
195
|
+
// never fail the run nor block the visible write.
|
|
196
|
+
Effect.ignore,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// The projection the public store exposes: `AnyAsyncData`, memoized so a pure
|
|
200
|
+
// `isStale` change (which the projection ignores) returns the SAME reference
|
|
201
|
+
// and wakes no `AnyAsyncData` subscriber.
|
|
202
|
+
const projection = MutableRef.make<
|
|
203
|
+
{ readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
|
|
204
|
+
>(undefined)
|
|
205
|
+
const project = (): AnyAsyncData<A, E> => {
|
|
206
|
+
const state = stateStore.get()
|
|
207
|
+
const prev = MutableRef.get(projection)
|
|
208
|
+
// The projection depends only on data/error/isFetching; writers preserve the
|
|
209
|
+
// refs of untouched axes, so reference identity on those three is exact.
|
|
210
|
+
if (
|
|
211
|
+
prev !== undefined &&
|
|
212
|
+
prev.state.data === state.data &&
|
|
213
|
+
prev.state.error === state.error &&
|
|
214
|
+
prev.state.isFetching === state.isFetching
|
|
215
|
+
)
|
|
216
|
+
return prev.view
|
|
217
|
+
const view = toAsyncData(state, options.gated)
|
|
218
|
+
MutableRef.set(projection, { state, view })
|
|
219
|
+
return view
|
|
220
|
+
}
|
|
221
|
+
const derived = makeDerivedStore(project, stateStore.subscribe, scheduler)
|
|
222
|
+
yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
|
|
223
|
+
|
|
224
|
+
interface Request {
|
|
225
|
+
readonly args: InputsObject<Inputs>
|
|
226
|
+
readonly generation: number
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// One run of the query, folded into the cell. A failure becomes `Error`; an
|
|
230
|
+
// interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug in
|
|
231
|
+
// the body) is logged and isolated. A `Success` reports its generation through
|
|
232
|
+
// `onSettled` after the write, then persists (best-effort, trailing).
|
|
156
233
|
const runQuery = (request: Request): Effect.Effect<void, never, R> =>
|
|
157
234
|
options.query(request.args).pipe(
|
|
158
235
|
Effect.tapDefect((defect) =>
|
|
159
236
|
Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
|
|
160
237
|
),
|
|
161
238
|
Effect.matchCause({
|
|
162
|
-
onSuccess: (value) =>
|
|
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) => {
|
|
239
|
+
onSuccess: (value): Option.Option<A> => Option.some(value),
|
|
240
|
+
onFailure: (cause): Option.Option<A> => {
|
|
176
241
|
const failure = Cause.failureOption(cause)
|
|
177
|
-
if (Option.isSome(failure) && !disabledNow())
|
|
178
|
-
|
|
179
|
-
}
|
|
242
|
+
if (Option.isSome(failure) && !disabledNow()) settleError(failure.value)
|
|
243
|
+
return Option.none()
|
|
180
244
|
},
|
|
181
245
|
}),
|
|
246
|
+
Effect.flatMap((ok) =>
|
|
247
|
+
Option.match(ok, {
|
|
248
|
+
onNone: () => Effect.void,
|
|
249
|
+
onSome: (value) =>
|
|
250
|
+
disabledNow()
|
|
251
|
+
? Effect.void
|
|
252
|
+
: Effect.suspend(() => {
|
|
253
|
+
const shared = settleSuccess(value)
|
|
254
|
+
options.onSettled?.(request.generation)
|
|
255
|
+
return persistWrite(shared)
|
|
256
|
+
}),
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
182
259
|
)
|
|
183
260
|
|
|
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.
|
|
261
|
+
// The driver. `'switch'` (default): each new request cancels the in-flight run
|
|
262
|
+
// — the same latest-wins semantics a `latest` channel gives procedures.
|
|
191
263
|
// `'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.
|
|
264
|
+
// request that piled up during the flight and runs the LATEST one — "exactly
|
|
265
|
+
// one trailing run after settle" by construction.
|
|
196
266
|
const trailing = options.coalesce === 'trailing'
|
|
197
267
|
const requests = yield* Queue.unbounded<Request>()
|
|
198
268
|
yield* Effect.forkScoped(
|
|
@@ -202,14 +272,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
202
272
|
const first = yield* Queue.take(requests)
|
|
203
273
|
const queued = yield* Queue.takeAll(requests)
|
|
204
274
|
const request = Option.getOrElse(Chunk.last(queued), () => first)
|
|
205
|
-
// A disable that landed while the request waited: skip the run.
|
|
206
275
|
if (!disabledNow()) yield* runQuery(request)
|
|
207
|
-
// The settle may have written a stale-key result with
|
|
208
|
-
// `refetching: false`; if a newer request is already waiting,
|
|
209
|
-
// restore the syncing flag before the next take — both writes
|
|
210
|
-
// coalesce into one scheduler flush for subscribers.
|
|
211
276
|
const pending = yield* Queue.size(requests)
|
|
212
|
-
if (pending > 0 && !disabledNow()) yield* Effect.sync(
|
|
277
|
+
if (pending > 0 && !disabledNow()) yield* Effect.sync(markFetching)
|
|
213
278
|
}),
|
|
214
279
|
)
|
|
215
280
|
: Stream.fromQueue(requests).pipe(
|
|
@@ -218,30 +283,103 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
|
|
|
218
283
|
),
|
|
219
284
|
)
|
|
220
285
|
|
|
221
|
-
|
|
286
|
+
// Enqueue a run of the current key, marking the flight. `force` skips the
|
|
287
|
+
// no-op-key guard (the refetch path); otherwise an unchanged key is ignored.
|
|
288
|
+
const enqueue = (force: boolean) => {
|
|
222
289
|
const args = sources.snapshot()
|
|
223
290
|
if (disabledNow(args)) {
|
|
224
291
|
// Switched off: show Idle and forget the key so re-enabling always re-runs.
|
|
225
292
|
MutableRef.set(lastKey, undefined)
|
|
226
|
-
|
|
293
|
+
stateStore.set(idle)
|
|
227
294
|
return
|
|
228
295
|
}
|
|
229
296
|
const key = keyOf(args)
|
|
230
297
|
const previous = MutableRef.get(lastKey)
|
|
231
|
-
if (previous !== undefined && sameKey(key, previous)) return
|
|
298
|
+
if (!force && previous !== undefined && sameKey(key, previous)) return
|
|
232
299
|
MutableRef.set(lastKey, key)
|
|
233
|
-
|
|
300
|
+
markFetching()
|
|
234
301
|
Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
|
|
235
302
|
}
|
|
303
|
+
const trigger = () => enqueue(false)
|
|
304
|
+
const refetch = () => enqueue(true)
|
|
305
|
+
|
|
306
|
+
// `invalidate`: flip `isStale` only. A stale query with active readers then
|
|
307
|
+
// auto-refetches — the one place invalidation indirectly fetches.
|
|
308
|
+
const maybeAutoRefetch = () => {
|
|
309
|
+
if (stateStore.get().isStale && MutableRef.get(readers) > 0) refetch()
|
|
310
|
+
}
|
|
311
|
+
const invalidate = () => {
|
|
312
|
+
const s = stateStore.get()
|
|
313
|
+
if (s.isStale) return
|
|
314
|
+
stateStore.set({ ...s, isStale: true })
|
|
315
|
+
maybeAutoRefetch()
|
|
316
|
+
}
|
|
317
|
+
|
|
236
318
|
const unsubscribe = subscribeAll(trigger)
|
|
237
319
|
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
238
320
|
|
|
321
|
+
// Hydrate from the persisted value before the first kick: show it instantly,
|
|
322
|
+
// marked stale, so the kick refetches over it (stale-while-revalidate).
|
|
323
|
+
if (persist !== undefined && !disabledNow()) {
|
|
324
|
+
const cached = yield* queryStore.get(persist.key)
|
|
325
|
+
yield* Option.match(cached, {
|
|
326
|
+
onNone: () => Effect.void,
|
|
327
|
+
onSome: (raw) =>
|
|
328
|
+
Schema.decodeUnknown(persist.schema)(raw).pipe(
|
|
329
|
+
Effect.match({
|
|
330
|
+
// Corrupt/incompatible cache: ignore and fetch fresh.
|
|
331
|
+
onFailure: () => undefined,
|
|
332
|
+
onSuccess: (value) =>
|
|
333
|
+
stateStore.set({
|
|
334
|
+
data: Option.some(value),
|
|
335
|
+
error: Option.none(),
|
|
336
|
+
isFetching: false,
|
|
337
|
+
isStale: true,
|
|
338
|
+
}),
|
|
339
|
+
}),
|
|
340
|
+
),
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
239
344
|
// Kick off the first fetch unless the query starts disabled.
|
|
240
345
|
const initial = sources.snapshot()
|
|
241
346
|
if (!disabledNow(initial)) {
|
|
242
347
|
MutableRef.set(lastKey, keyOf(initial))
|
|
348
|
+
markFetching()
|
|
243
349
|
Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
|
|
244
350
|
}
|
|
245
351
|
|
|
352
|
+
// The public store: the projection, with reader-count tracking wrapped around
|
|
353
|
+
// `subscribe`, so a 0→1 transition can auto-refetch a stale query.
|
|
354
|
+
const store: Store<AnyAsyncData<A, E>> = {
|
|
355
|
+
...derived.store,
|
|
356
|
+
subscribe: (listener) => {
|
|
357
|
+
const before = MutableRef.get(readers)
|
|
358
|
+
MutableRef.set(readers, before + 1)
|
|
359
|
+
const off = derived.store.subscribe(listener)
|
|
360
|
+
if (before === 0) maybeAutoRefetch()
|
|
361
|
+
return () => {
|
|
362
|
+
off()
|
|
363
|
+
MutableRef.update(readers, (n) => Math.max(0, n - 1))
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Register the handle so `AsyncCalc.invalidate`/`.refetch` and provider layers
|
|
369
|
+
// can act on this query by name. Optional (`serviceOption`): an absent registry
|
|
370
|
+
// (a calc wired with no `Engine` in context) just means no imperative control.
|
|
371
|
+
const handle: QueryHandle = {
|
|
372
|
+
name: options.name,
|
|
373
|
+
invalidate,
|
|
374
|
+
refetch,
|
|
375
|
+
snapshot: () => stateStore.getSnapshot(),
|
|
376
|
+
subscribe: (listener) => stateStore.subscribe(listener),
|
|
377
|
+
}
|
|
378
|
+
const queries = Option.getOrUndefined(yield* Effect.serviceOption(Queries))
|
|
379
|
+
if (queries !== undefined) {
|
|
380
|
+
queries.register(handle)
|
|
381
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => queries.unregister(handle)))
|
|
382
|
+
}
|
|
383
|
+
|
|
246
384
|
return { store, requested: () => MutableRef.get(generation) }
|
|
247
385
|
})
|
|
@@ -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
|
+
)
|
package/src/runtime/loop.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '../channel/channel'
|
|
8
8
|
import { notificationsLayer } from '../internal/scheduler'
|
|
9
9
|
import { Bus, busLayer, type Envelope, type Tagged } from './bus'
|
|
10
|
+
import { Queries, queriesLayer } from './queries'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* A registered reducer, with its target store(s) already captured at
|
|
@@ -162,10 +163,16 @@ const drain = Effect.gen(function* () {
|
|
|
162
163
|
* drain loop. Registration mutates the live collections, so reducers/procedures/
|
|
163
164
|
* channels merged alongside are picked up before the first dispatch (boot).
|
|
164
165
|
*/
|
|
165
|
-
export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures> =
|
|
166
|
-
drain
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
export const Engine: Layer.Layer<Bus | Reducers | Channels | Procedures | Queries> =
|
|
167
|
+
Layer.scopedDiscard(drain).pipe(
|
|
168
|
+
Layer.provideMerge(
|
|
169
|
+
Layer.mergeAll(
|
|
170
|
+
busLayer,
|
|
171
|
+
reducersLayer,
|
|
172
|
+
channelsLayer,
|
|
173
|
+
proceduresLayer,
|
|
174
|
+
notificationsLayer,
|
|
175
|
+
queriesLayer,
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Context, Layer } from 'effect'
|
|
2
|
+
import { type QueryState } from '../calc/queryState'
|
|
3
|
+
|
|
4
|
+
// The `Queries` registry: the runtime-wide index of live query handles, the same
|
|
5
|
+
// shape as `Reducers`/`Channels`/`Procedures` (`loop.ts`, `channel.ts`). Each
|
|
6
|
+
// `AsyncCalc.live` driver registers its handle here scoped — so `AsyncCalc.invalidate`
|
|
7
|
+
// / `AsyncCalc.refetch` (and any provider layer) can act on a calc by name without
|
|
8
|
+
// holding its store tag. A base in-memory registry ships in `Engine`; provider
|
|
9
|
+
// layers (persistence, focus/online managers) build on top by calling the handles.
|
|
10
|
+
//
|
|
11
|
+
// The per-calc generics are erased through function signatures (reads are
|
|
12
|
+
// covariant; `subscribe` is uniform), so the heterogeneous registry needs no cast.
|
|
13
|
+
|
|
14
|
+
export interface QueryHandle {
|
|
15
|
+
readonly name: string
|
|
16
|
+
/** Mark the query's value stale (`isStale := true`). Does not fetch. */
|
|
17
|
+
readonly invalidate: () => void
|
|
18
|
+
/** Force a run of the current key, bypassing the no-op-key guard. */
|
|
19
|
+
readonly refetch: () => void
|
|
20
|
+
/** Read the current state (erased to the open value type). */
|
|
21
|
+
readonly snapshot: () => QueryState<unknown, unknown>
|
|
22
|
+
/** Subscribe to state changes; returns an unsubscribe thunk. */
|
|
23
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QueryRegistry {
|
|
27
|
+
readonly byName: Map<string, QueryHandle>
|
|
28
|
+
readonly register: (handle: QueryHandle) => void
|
|
29
|
+
readonly unregister: (handle: QueryHandle) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const QueriesBase: Context.TagClass<Queries, 'reform/Queries', QueryRegistry> = Context.Tag(
|
|
33
|
+
'reform/Queries',
|
|
34
|
+
)<Queries, QueryRegistry>()
|
|
35
|
+
export class Queries extends QueriesBase {}
|
|
36
|
+
|
|
37
|
+
const makeQueryRegistry = (): QueryRegistry => {
|
|
38
|
+
const byName = new Map<string, QueryHandle>()
|
|
39
|
+
return {
|
|
40
|
+
byName,
|
|
41
|
+
register: (handle) => {
|
|
42
|
+
byName.set(handle.name, handle)
|
|
43
|
+
},
|
|
44
|
+
// Only drop the entry if it is still the one we registered: a same-named
|
|
45
|
+
// re-registration (re-mount before the old scope's finalizer runs) must not
|
|
46
|
+
// be clobbered by the stale handle's unregister.
|
|
47
|
+
unregister: (handle) => {
|
|
48
|
+
if (byName.get(handle.name) === handle) byName.delete(handle.name)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const queriesLayer: Layer.Layer<Queries> = Layer.sync(Queries, makeQueryRegistry)
|
package/src/scene/scene.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Layer } from 'effect'
|
|
2
2
|
import type { CompositionClass, CompositionService } from '../compose/composition'
|
|
3
3
|
import type { UiContract } from '../compose/ui'
|
|
4
|
+
import type { EventOf } from '../event/event'
|
|
4
5
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
5
|
-
import type {
|
|
6
|
+
import type { SeedsOf } from '../state/stateGroup'
|
|
7
|
+
import type { Bus } from '../runtime/bus'
|
|
6
8
|
|
|
7
9
|
// A scene is a VALUE — a composition plus the closed wiring that runs it: the
|
|
8
10
|
// layers that supply its logic/views (each seeding its own live state) and the
|
|
@@ -23,27 +25,37 @@ import type { Bus, Tagged } from '../runtime/bus'
|
|
|
23
25
|
*/
|
|
24
26
|
export type MountedServices = CompositionService | Bus
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
// A scene's boot list holds CONSTRUCTED events (`Event.construct(E, payload)`),
|
|
29
|
+
// not bare tagged objects — a lightweight nudge toward the real builder. It is
|
|
30
|
+
// not a guarantee the event is handled by the wiring (reform carries no
|
|
31
|
+
// type-level union of handled tags), only that it came from an event definition.
|
|
32
|
+
type BootEvent = EventOf<string, unknown>
|
|
33
|
+
|
|
34
|
+
export interface Scene<
|
|
35
|
+
C extends UiContract = UiContract,
|
|
36
|
+
S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
|
|
37
|
+
> {
|
|
27
38
|
readonly kind: 'Scene'
|
|
28
|
-
/** The composition to run, with its contract preserved for typed consumers. */
|
|
29
|
-
readonly composition: CompositionClass<unknown, C>
|
|
39
|
+
/** The composition to run, with its contract + states preserved for typed consumers. */
|
|
40
|
+
readonly composition: CompositionClass<unknown, C, S>
|
|
30
41
|
/** Closed wiring (logic + views), each layer seeding its own live state. */
|
|
31
42
|
readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
|
|
32
43
|
/** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
|
|
33
|
-
readonly boot?: ReadonlyArray<
|
|
44
|
+
readonly boot?: ReadonlyArray<BootEvent>
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
48
|
* Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
|
|
38
|
-
* The composition's contract
|
|
49
|
+
* The composition's contract `C` and state tuple `S` flow through, so consumers
|
|
50
|
+
* (the facade, `seedScene`) stay typed.
|
|
39
51
|
*/
|
|
40
|
-
export const scene = <C extends UiContract
|
|
41
|
-
composition: CompositionClass<unknown, C>,
|
|
52
|
+
export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
53
|
+
composition: CompositionClass<unknown, C, S>,
|
|
42
54
|
config: {
|
|
43
55
|
readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
|
|
44
|
-
readonly boot?: ReadonlyArray<
|
|
56
|
+
readonly boot?: ReadonlyArray<BootEvent>
|
|
45
57
|
},
|
|
46
|
-
): Scene<C> => ({
|
|
58
|
+
): Scene<C, S> => ({
|
|
47
59
|
kind: 'Scene',
|
|
48
60
|
composition,
|
|
49
61
|
provide: config.provide,
|
|
@@ -63,10 +75,10 @@ export const scene = <C extends UiContract>(
|
|
|
63
75
|
* an invalid value silently falls back to the authored seed. `StateFamily`
|
|
64
76
|
* entries are not covered.
|
|
65
77
|
*/
|
|
66
|
-
export const seedScene = <C extends UiContract
|
|
67
|
-
base: Scene<C>,
|
|
68
|
-
seeds:
|
|
69
|
-
): Scene<C> =>
|
|
78
|
+
export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
79
|
+
base: Scene<C, S>,
|
|
80
|
+
seeds: SeedsOf<S>,
|
|
81
|
+
): Scene<C, S> =>
|
|
70
82
|
Object.keys(seeds).length === 0
|
|
71
83
|
? base
|
|
72
84
|
: { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
|