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