@playfast/reform 0.0.9 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.9",
4
+ "version": "0.0.10",
5
5
  "type": "module",
6
6
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
7
7
  "keywords": [
@@ -0,0 +1,166 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Layer, Option, Schema as S } from 'effect'
3
+ import { AsyncCalc, Engine, Queries, QueryStore, State, StateGroup } from '../index'
4
+
5
+ // The data-oriented half of AsyncCalc: `invalidate` flips `isStale` only (no
6
+ // fetch), `refetch` forces a run, a stale query with active readers (or a fresh
7
+ // subscriber) refetches, and `persist` hydrates + writes through an optional
8
+ // QueryStore. `isStale` is read off the `Queries` registry handle.
9
+
10
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
11
+
12
+ const isStale = (name: string) =>
13
+ Effect.map(Queries, (q) => q.byName.get(name)?.snapshot().isStale ?? false)
14
+
15
+ // An in-memory QueryStore, so persistence is exercised with no DOM.
16
+ const fakeQueryStore = (seed: Record<string, unknown> = {}) => {
17
+ const map = new Map<string, unknown>(Object.entries(seed))
18
+ const layer = Layer.succeed(QueryStore, {
19
+ get: (key) => Effect.sync(() => Option.fromNullable(map.has(key) ? map.get(key) : undefined)),
20
+ set: (key, value) => Effect.sync(() => void map.set(key, value)),
21
+ remove: (key) => Effect.sync(() => void map.delete(key)),
22
+ })
23
+ return { map, layer }
24
+ }
25
+
26
+ it.live('invalidate marks stale without fetching; a fresh subscriber then refetches', () => {
27
+ class Count extends State.make('count', S.Number) {}
28
+ class Inputs extends StateGroup.make(Count) {}
29
+ const runs = { n: 0 }
30
+ class Q extends AsyncCalc.make('Q', {
31
+ inputs: [StateGroup.select(Inputs, 'count')],
32
+ output: S.Number,
33
+ alwaysOn: true,
34
+ }) {}
35
+ const QLive = AsyncCalc.live(Q, {
36
+ query: ({ count }) =>
37
+ Effect.sync(() => {
38
+ runs.n += 1
39
+ return count
40
+ }),
41
+ })
42
+ const TestLayer = QLive.pipe(
43
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
44
+ Layer.provideMerge(Engine),
45
+ )
46
+
47
+ return Effect.gen(function* () {
48
+ const store = yield* Q.store
49
+ yield* tick()
50
+ expect(runs.n).toBe(1)
51
+
52
+ // No active readers: invalidate flips the flag but fetches nothing.
53
+ yield* AsyncCalc.invalidate(Q)
54
+ yield* tick()
55
+ expect(yield* isStale('Q')).toBe(true)
56
+ expect(runs.n).toBe(1)
57
+
58
+ // A reader arriving over a stale query (the resubscribe/mount analog) refetches.
59
+ store.subscribe(() => {})
60
+ yield* tick()
61
+ expect(runs.n).toBe(2)
62
+ expect(yield* isStale('Q')).toBe(false)
63
+ }).pipe(Effect.provide(TestLayer))
64
+ })
65
+
66
+ it.live('invalidate with an active reader auto-refetches immediately, keeping the value', () => {
67
+ class Count extends State.make('count', S.Number) {}
68
+ class Inputs extends StateGroup.make(Count) {}
69
+ const runs = { n: 0 }
70
+ class Q extends AsyncCalc.make('Q', {
71
+ inputs: [StateGroup.select(Inputs, 'count')],
72
+ output: S.Number,
73
+ alwaysOn: true,
74
+ }) {}
75
+ const QLive = AsyncCalc.live(Q, {
76
+ query: ({ count }) =>
77
+ Effect.sync(() => {
78
+ runs.n += 1
79
+ return count
80
+ }).pipe(Effect.delay(Duration.millis(20))),
81
+ })
82
+ const TestLayer = QLive.pipe(
83
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 7 })),
84
+ Layer.provideMerge(Engine),
85
+ )
86
+
87
+ return Effect.gen(function* () {
88
+ const store = yield* Q.store
89
+ store.subscribe(() => {}) // active reader
90
+ yield* tick(40)
91
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
92
+ expect(runs.n).toBe(1)
93
+
94
+ // Stale + active ⇒ refetch now; the last value stays visible (SWR).
95
+ yield* AsyncCalc.invalidate(Q)
96
+ yield* tick(5)
97
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: true })
98
+ yield* tick(40)
99
+ expect(store.get()).toMatchObject({ _tag: 'Success', value: 7, refetching: false })
100
+ expect(runs.n).toBe(2)
101
+ }).pipe(Effect.provide(TestLayer))
102
+ })
103
+
104
+ it.live('refetch forces a run regardless of staleness', () => {
105
+ class Count extends State.make('count', S.Number) {}
106
+ class Inputs extends StateGroup.make(Count) {}
107
+ const runs = { n: 0 }
108
+ class Q extends AsyncCalc.make('Q', {
109
+ inputs: [StateGroup.select(Inputs, 'count')],
110
+ output: S.Number,
111
+ alwaysOn: true,
112
+ }) {}
113
+ const QLive = AsyncCalc.live(Q, {
114
+ query: ({ count }) =>
115
+ Effect.sync(() => {
116
+ runs.n += 1
117
+ return count
118
+ }),
119
+ })
120
+ const TestLayer = QLive.pipe(
121
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 3 })),
122
+ Layer.provideMerge(Engine),
123
+ )
124
+
125
+ return Effect.gen(function* () {
126
+ yield* tick()
127
+ expect(runs.n).toBe(1)
128
+ expect(yield* isStale('Q')).toBe(false)
129
+
130
+ // Not stale, no key movement — refetch still runs.
131
+ yield* AsyncCalc.refetch(Q)
132
+ yield* tick()
133
+ expect(runs.n).toBe(2)
134
+ }).pipe(Effect.provide(TestLayer))
135
+ })
136
+
137
+ it.live('persist: hydrates from the QueryStore (stale) then writes the settled value through', () => {
138
+ class Count extends State.make('count', S.Number) {}
139
+ class Inputs extends StateGroup.make(Count) {}
140
+ const store = fakeQueryStore({ P: 99 }) // a previously-persisted value
141
+ class Q extends AsyncCalc.make('Q', {
142
+ inputs: [StateGroup.select(Inputs, 'count')],
143
+ output: S.Number,
144
+ alwaysOn: true,
145
+ }) {}
146
+ const QLive = AsyncCalc.live(Q, {
147
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(25))),
148
+ persist: { key: 'P' },
149
+ })
150
+ const TestLayer = QLive.pipe(
151
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
152
+ Layer.provideMerge(store.layer),
153
+ Layer.provideMerge(Engine),
154
+ )
155
+
156
+ return Effect.gen(function* () {
157
+ const view = yield* Q.store
158
+ // The persisted value shows instantly, marked refetching (stale-while-revalidate).
159
+ expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
160
+
161
+ // The fresh fetch lands and is written through to the store.
162
+ yield* tick(45)
163
+ expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
164
+ expect(store.map.get('P')).toBe(10)
165
+ }).pipe(Effect.provide(TestLayer))
166
+ })
@@ -18,6 +18,7 @@ import { type Store } from '../internal/store'
18
18
  import { readTracked } from '../internal/track'
19
19
  import * as Reducer from '../reducer/reducer'
20
20
  import { Reducers } from '../runtime/loop'
21
+ import { Queries } from '../runtime/queries'
21
22
  import * as State from '../state/state'
22
23
  import { type AnySource } from '../state/token'
23
24
  import { type AsyncData, narrowStore } from './asyncData'
@@ -97,6 +98,15 @@ export type AsyncCalcLive<
97
98
  * `Calc`'s `reuse`; opt-in (one O(result) walk per settle).
98
99
  */
99
100
  readonly reuse?: boolean
101
+ /**
102
+ * Persist the `Success` value through an optional host `QueryStore` (e.g.
103
+ * `@playfast/reform-query-browser`'s localStorage layer): hydrate it on build
104
+ * so the value shows instantly (marked stale, so it still refetches) and write
105
+ * through on each settle. `true` keys it by the calc name; pass `{ key }` to
106
+ * override. Values (de)serialize through the calc's `output` schema. No host
107
+ * `QueryStore` in context ⇒ inert (no hard requirement added).
108
+ */
109
+ readonly persist?: boolean | { readonly key?: string }
100
110
  } & (Gated extends true
101
111
  ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
102
112
  : { readonly disabled?: never })
@@ -214,8 +224,20 @@ export function live<
214
224
  readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
215
225
  readonly coalesce?: 'switch' | 'trailing'
216
226
  readonly reuse?: boolean
227
+ readonly persist?: boolean | { readonly key?: string }
217
228
  }
218
229
 
230
+ // `persist: true` keys by the calc name; `{ key }` overrides. The driver
231
+ // (de)serializes through the calc's own `output` schema.
232
+ const persist =
233
+ cfg.persist === undefined || cfg.persist === false
234
+ ? undefined
235
+ : {
236
+ key:
237
+ cfg.persist === true ? calc.manifest.name : cfg.persist.key ?? calc.manifest.name,
238
+ schema: calc.manifest.output,
239
+ }
240
+
219
241
  // The hidden revision store, read requirement-free (`serviceOption`): the
220
242
  // assembly below always provides it alongside this driver, and feeding it
221
243
  // through `extraKey` (not `wireSources`) keeps it out of the snapshot —
@@ -236,6 +258,7 @@ export function live<
236
258
  disabled: cfg.disabled,
237
259
  coalesce: cfg.coalesce,
238
260
  reuse: cfg.reuse,
261
+ persist,
239
262
  extraKey:
240
263
  revision === undefined
241
264
  ? undefined
@@ -265,3 +288,24 @@ export function live<
265
288
  Layer.provide(State.live(revisionState, revisionZero)),
266
289
  )
267
290
  }
291
+
292
+ /**
293
+ * Mark a query's value stale (`isStale := true`) — without fetching. A stale
294
+ * query with active readers (a mounted view) refetches; an unread one refetches
295
+ * when next subscribed. The imperative counterpart of `invalidateOn`, and what a
296
+ * focus/online provider layer calls. A no-op if the calc isn't live; resolves the
297
+ * `Queries` registry (part of `Engine`).
298
+ */
299
+ export const invalidate = (calc: { readonly name: string }): Effect.Effect<void, never, Queries> =>
300
+ Effect.flatMap(Queries, (queries) =>
301
+ Effect.sync(() => queries.byName.get(calc.name)?.invalidate()),
302
+ )
303
+
304
+ /**
305
+ * Force an immediate refetch of the current key, bypassing the no-op-key guard
306
+ * and independent of `isStale`. A no-op if the calc isn't live or is disabled.
307
+ */
308
+ export const refetch = (calc: { readonly name: string }): Effect.Effect<void, never, Queries> =>
309
+ Effect.flatMap(Queries, (queries) =>
310
+ Effect.sync(() => queries.byName.get(calc.name)?.refetch()),
311
+ )
@@ -0,0 +1,52 @@
1
+ import { Option } from 'effect'
2
+ import { type AnyAsyncData, AsyncData } from './asyncData'
3
+
4
+ // `QueryState` is the data-oriented value an `AsyncCalc` is driven by: the four
5
+ // axes of a query, stored *flat and independent* so each can move on its own.
6
+ // `data`/`error` survive a refetch (stale-while-revalidate for free); `isFetching`
7
+ // (activity) and `isStale` (freshness) are orthogonal to both — which is exactly
8
+ // what lets `invalidate` (flip `isStale`) and `refetch` (drive `isFetching`) be
9
+ // separate operations. The matchable `AsyncData` union is *derived* from this
10
+ // (`toAsyncData`), so view code and `RemoteState` keep their arm dispatch.
11
+
12
+ export interface QueryState<A, E> {
13
+ /** Last resolved value — kept across a refetch. */
14
+ readonly data: Option.Option<A>
15
+ /** Last failure — kept across a refetch. */
16
+ readonly error: Option.Option<E>
17
+ /** Activity axis: a run is in flight. */
18
+ readonly isFetching: boolean
19
+ /** Freshness axis: the value has been marked invalid (drives auto-refetch). */
20
+ readonly isStale: boolean
21
+ }
22
+
23
+ /** The empty cell. `fetching` seeds the first-kick `Loading` for an enabled query. */
24
+ export const empty = (fetching: boolean): QueryState<never, never> => ({
25
+ data: Option.none(),
26
+ error: Option.none(),
27
+ isFetching: fetching,
28
+ isStale: false,
29
+ })
30
+
31
+ /**
32
+ * Project the flat state onto the public `AsyncData` union (the view contract) —
33
+ * the single `Match`-able view of a query, so call sites keep `Match.tag`-ing the
34
+ * arms. `error` wins over `data` so a failed refetch surfaces the `Error` arm,
35
+ * matching the pre-`QueryState` driver; `isFetching` becomes the arm's
36
+ * `refetching` flag. The data-less case is `Loading` while fetching (or for a
37
+ * non-gated query, which always fetches) and `Idle` only for a switched-off
38
+ * gateable query.
39
+ */
40
+ export const toAsyncData = <A, E>(s: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
41
+ Option.match(s.error, {
42
+ onSome: (error) => AsyncData.error(error, s.isFetching),
43
+ onNone: () =>
44
+ Option.match(s.data, {
45
+ onSome: (value) => AsyncData.success(value, s.isFetching),
46
+ onNone: () => (s.isFetching || !gated ? AsyncData.loading : AsyncData.idle),
47
+ }),
48
+ })
49
+
50
+ /** `Loading` in the React-Query sense: fetching with nothing to show yet. */
51
+ export const isLoading = <A, E>(s: QueryState<A, E>): boolean =>
52
+ s.isFetching && Option.isNone(s.data)
package/src/index.ts CHANGED
@@ -31,6 +31,23 @@ export {
31
31
  type AsyncLoading,
32
32
  type AsyncSuccess,
33
33
  } from './calc/asyncData'
34
+ // The flat, data-oriented query value (`data`/`error`/`isFetching`/`isStale`) an
35
+ // `AsyncCalc` is driven by; `toAsyncData` projects it onto the matchable arms.
36
+ export { isLoading, type QueryState, toAsyncData } from './calc/queryState'
37
+ // Optional host seams an `AsyncCalc` consults — persistence and lifecycle signals.
38
+ // Concrete implementations live in edge packages (e.g. `@playfast/reform-query-browser`).
39
+ export {
40
+ noopQueryStore,
41
+ QueryStore,
42
+ type QueryStoreApi,
43
+ resolveQueryStore,
44
+ } from './internal/queryStore'
45
+ export {
46
+ noopQueryEvents,
47
+ QueryEvents,
48
+ type QueryEventsApi,
49
+ resolveQueryEvents,
50
+ } from './internal/queryEvents'
34
51
  export * as RemoteState from './remote/remoteState'
35
52
  // Direct type re-exports alongside the namespace (TS2742 nameability): an app
36
53
  // layer's inferred type references these (`Store<ReadonlyArray<PendingIntent<…>>>`),
@@ -146,10 +163,14 @@ export { type SlotChild } from './compose/slot'
146
163
  // A scene bundles a composition with the closed wiring that runs it — the single
147
164
  // handle the react host, proofs, and the dev tool all consume.
148
165
  export { isScene, type MountedServices, type Scene, scene, seedScene } from './scene/scene'
166
+ export type { SeedsOf } from './state/stateGroup'
149
167
 
150
168
  // Runtime surface for hosts (`@reform/react`) and headless tests.
151
169
  export { Bus, type Envelope, type Priority, publish, type Tagged } from './runtime/bus'
152
170
  export { Engine, type ReducerEntry, Reducers } from './runtime/loop'
171
+ // The runtime-wide registry of live query handles. `AsyncCalc.invalidate` /
172
+ // `AsyncCalc.refetch` and provider layers act on a query by name through it.
173
+ export { Queries, type QueryHandle, type QueryRegistry } from './runtime/queries'
153
174
  export {
154
175
  type ChannelClass,
155
176
  type ChannelPolicy,
@@ -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)