@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.
@@ -10,8 +10,11 @@ import {
10
10
  type Scope,
11
11
  Stream,
12
12
  } from 'effect'
13
- import { type AnyAsyncData, AsyncData } from '../calc/asyncData'
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 the
28
- // result into an `AnyAsyncData` store with stale-while-revalidate semantics.
29
- // Extracted so both primitives run the exact same machinery the asyncCalc
30
- // test suite is the driver's regression suite.
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 — only for the defect log line. */
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 full union — callers narrow to their arms). */
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: store + subscription + request queue + run fiber, all owned
101
- * by the ambient scope (callers run this under `Layer.scoped`). A change to an
102
- * input re-runs the query latest-wins (a new run cancels the in-flight one; or,
103
- * with `coalesce: 'trailing'`, lets it finish and runs one trailing refetch);
104
- * while a re-run is in flight the last `Success`/`Error` is kept with
105
- * `refetching: true`.
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
- const store = makeStore<AnyAsyncData<A, E>>(
129
- disabledNow() ? AsyncData.idle : AsyncData.loading,
130
- scheduler,
131
- )
132
- // The last requested key, so a change that doesn't move it (or only churns
133
- // an input `invalidateBy` ignores) doesn't re-fetch.
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
- // Mark a re-fetch in flight without dropping the visible value (SWR).
144
- const markRefetching = () => {
145
- const prev = store.get()
146
- if (prev._tag === 'Success') store.set(AsyncData.success(prev.value, true))
147
- else if (prev._tag === 'Error') store.set(AsyncData.error(prev.error, true))
148
- else store.set(AsyncData.loading)
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
- // One run of the query, folded into the store. A failure becomes `Error`; an
152
- // interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug
153
- // in the body) is logged and isolated — the driver keeps running. A `Success`
154
- // additionally reports its generation through `onSettled`, after the write,
155
- // so settle-driven consequences observe the converged value.
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
- if (!disabledNow()) {
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
- store.set(AsyncData.error(failure.value, false))
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
- interface Request {
185
- readonly args: InputsObject<Inputs>
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
- // "exactly one trailing run after settle" by construction. The trailing
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(markRefetching)
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
- const trigger = () => {
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
- store.set(AsyncData.idle)
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
- markRefetching()
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
+ )
@@ -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> = Layer.scopedDiscard(
166
- drain,
167
- ).pipe(
168
- Layer.provideMerge(
169
- Layer.mergeAll(busLayer, reducersLayer, channelsLayer, proceduresLayer, notificationsLayer),
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)
@@ -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 { Bus, Tagged } from '../runtime/bus'
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
- export interface Scene<C extends UiContract = UiContract> {
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<Tagged>
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 flows through `C`, so consumers stay typed.
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<Tagged>
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: Readonly<Record<string, unknown>>,
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)) }