@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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.ts +16 -7
  3. package/src/calc/asyncCalc.invalidate.test.ts +166 -0
  4. package/src/calc/asyncCalc.ts +116 -25
  5. package/src/calc/asyncData.ts +8 -4
  6. package/src/calc/calc.ts +7 -3
  7. package/src/calc/calcFamily.ts +24 -10
  8. package/src/calc/compose.ts +1 -1
  9. package/src/calc/queryState.ts +52 -0
  10. package/src/channel/channel.ts +63 -37
  11. package/src/compose/composition.ts +20 -1
  12. package/src/compose/provide.ts +5 -1
  13. package/src/compose/slot.ts +4 -2
  14. package/src/compose/structure.ts +43 -19
  15. package/src/compose/ui.ts +21 -2
  16. package/src/compose/ui.typecheck.ts +4 -4
  17. package/src/definition/definition.ts +20 -7
  18. package/src/feature/feature.test.ts +4 -4
  19. package/src/feature/feature.ts +30 -16
  20. package/src/feature/feature.typecheck.ts +2 -2
  21. package/src/index.ts +21 -0
  22. package/src/internal/capture.ts +1 -0
  23. package/src/internal/errors.ts +9 -4
  24. package/src/internal/inspect.ts +4 -4
  25. package/src/internal/queryDriver.ts +273 -86
  26. package/src/internal/queryEvents.ts +34 -0
  27. package/src/internal/queryStore.ts +36 -0
  28. package/src/internal/reuse.ts +67 -30
  29. package/src/internal/scheduler.ts +35 -23
  30. package/src/internal/sources.ts +14 -8
  31. package/src/internal/stateRegistry.ts +3 -1
  32. package/src/internal/store.ts +10 -8
  33. package/src/internal/track.ts +3 -1
  34. package/src/procedure/procedure.ts +2 -2
  35. package/src/reducer/reducer.ts +17 -9
  36. package/src/remote/remoteState.test.ts +188 -1
  37. package/src/remote/remoteState.ts +112 -51
  38. package/src/remote/remoteState.typecheck.ts +4 -1
  39. package/src/runtime/bus.ts +3 -1
  40. package/src/runtime/hardening.test.ts +1 -1
  41. package/src/runtime/loop.ts +81 -53
  42. package/src/runtime/queries.ts +55 -0
  43. package/src/scene/scene.ts +16 -8
  44. package/src/state/state.ts +11 -10
  45. package/src/state/stateFamily.ts +27 -11
  46. package/src/state/stateGroup.ts +22 -9
  47. package/src/synced/syncedStore.ts +15 -9
  48. package/src/wire/tree.ts +66 -30
  49. 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, 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,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 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
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 = (r: Revision): Revision => Revision(r + 1)
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 — only for the defect log line. */
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 full union — callers narrow to their arms). */
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: 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`.
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
- 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.
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, (n) => n + 1)
180
+ MutableRef.update(generation, (current) => current + 1)
140
181
  return MutableRef.get(generation)
141
182
  }
142
183
 
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)
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
- // 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.
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: (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) => {
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
- store.set(AsyncData.error(failure.value, false))
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
- 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.
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
- // "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.
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
- yield* Effect.forkScoped(
199
- trailing
200
- ? Effect.forever(
201
- Effect.gen(function* () {
202
- const first = yield* Queue.take(requests)
203
- const queued = yield* Queue.takeAll(requests)
204
- const request = Option.getOrElse(Chunk.last(queued), () => first)
205
- // A disable that landed while the request waited: skip the run.
206
- 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
- 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
- const trigger = () => {
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
- store.set(AsyncData.idle)
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)) return
338
+ if (!force && previous !== undefined && sameKey(key, previous)) {
339
+ return
340
+ }
232
341
  MutableRef.set(lastKey, key)
233
- markRefetching()
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
+ )