@effect-app/vue 4.0.0-beta.271 → 4.0.0-beta.273
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/CHANGELOG.md +16 -0
- package/dist/atomQuery.d.ts +90 -0
- package/dist/atomQuery.d.ts.map +1 -0
- package/dist/atomQuery.js +275 -0
- package/dist/internal/tanstackQuery.d.ts +8 -0
- package/dist/internal/tanstackQuery.d.ts.map +1 -0
- package/dist/internal/tanstackQuery.js +145 -0
- package/dist/makeClient.d.ts +75 -13
- package/dist/makeClient.d.ts.map +1 -1
- package/dist/makeClient.js +203 -78
- package/dist/mutate.d.ts +20 -12
- package/dist/mutate.d.ts.map +1 -1
- package/dist/mutate.js +65 -64
- package/dist/query.d.ts +114 -12
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +275 -179
- package/docs/atom-query-api-redesign.md +191 -0
- package/package.json +2 -2
- package/src/atomQuery.ts +361 -0
- package/src/internal/tanstackQuery.ts +221 -0
- package/src/makeClient.ts +382 -91
- package/src/mutate.ts +101 -110
- package/src/query.ts +564 -243
- package/test/dist/stubs.d.ts +169 -2
- package/test/dist/stubs.d.ts.map +1 -1
- package/test/dist/stubs.js +11 -6
- package/test/makeClient.test.ts +110 -0
- package/test/stubs.ts +10 -5
- package/tsconfig.json.bak +72 -14
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Atom query API redesign
|
|
2
|
+
|
|
3
|
+
This branch can stop treating Effect atoms as an implementation detail hidden behind a TanStack-shaped API. The useful durable primitive is the atom; Vue refs, suspense promises, refetch helpers, and future hydration should all be adapters around it.
|
|
4
|
+
|
|
5
|
+
## Source findings
|
|
6
|
+
|
|
7
|
+
Relevant upstream Effect v4 atom APIs checked locally:
|
|
8
|
+
|
|
9
|
+
- `repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`
|
|
10
|
+
- `AtomRuntime.atom` turns an `Effect` into `Atom<AsyncResult<A, E>>`.
|
|
11
|
+
- `Atom.family` gives structural cache identity by input.
|
|
12
|
+
- `Atom.swr`, `Atom.withRefresh`, `Atom.setIdleTTL`, and `Atom.keepAlive` already model stale reads, polling, idle lifetime, and keep-alive.
|
|
13
|
+
- `Atom.mapResult` and `Atom.transform` are the right composition layer for `select` and derived queries.
|
|
14
|
+
- `Atom.optimistic` / `Atom.optimisticFn` can replace our current no-op optimistic `useUpdateQuery` facade.
|
|
15
|
+
- `Atom.toStreamResult`, `Atom.getResult`, `Atom.refresh`, and `Atom.mount` provide Effect-native conversion points.
|
|
16
|
+
- `repos/effect/packages/effect/src/unstable/reactivity/AtomRegistry.ts`
|
|
17
|
+
- `getResult(registry, atom, { suspendOnWaiting: true })` is the precise operation we need for awaitable refetch and Vue suspense.
|
|
18
|
+
- Registries are independent; relying on the module-global `defaultRegistry` leaks a policy decision into the query engine.
|
|
19
|
+
- `repos/effect/packages/effect/src/unstable/reactivity/AtomRpc.ts`
|
|
20
|
+
- Upstream RPC integration exposes `query(tag, payload)` as an atom and `mutation(tag)` as an atom result function. This is the shape we should mirror for effect-app clients.
|
|
21
|
+
- `repos/effect/packages/effect/src/unstable/reactivity/Hydration.ts`
|
|
22
|
+
- Serializable atoms support `dehydrate` / `hydrate`; Nuxt SSR can use that later without inventing query-specific prefetch state.
|
|
23
|
+
- `repos/effect/packages/atom/vue/src/index.ts`
|
|
24
|
+
- Vue integration is intentionally thin: `useAtomValue`, `useAtom`, `injectRegistry`, `registryKey`.
|
|
25
|
+
- There is no special query abstraction upstream; the app layer should own the ergonomic client API.
|
|
26
|
+
|
|
27
|
+
## Current problems
|
|
28
|
+
|
|
29
|
+
- `query.ts` still exposes TanStack vocabulary and types: `UseQueryReturnType`, `QueryObserverResult`, `RefetchOptions`, `initialData`, `placeholderData`, `gcTime`, `refetchInterval`, and tuple returns.
|
|
30
|
+
- The raw query atom is hidden in the fourth tuple slot. That makes composition awkward and encourages helper APIs to tunnel through private handles.
|
|
31
|
+
- Query family identity must be stable by query key plus projection hash, while observer options stay outside the family. Otherwise projected and unprojected clients can accidentally share a base atom.
|
|
32
|
+
- `useUpdateQuery` accepts an updater but cannot apply it because query atoms are read-only derived atoms there; it currently refreshes and ignores the updater.
|
|
33
|
+
- Legacy stream query `.query()` is still collapsed into `Stream.runCollect`, so the compatibility API behaves like a slow normal query. Stream query clients now also expose atom-native `.atom()`, `.family()`, and `.queryNew()` helpers backed by `Atom.pull`, so new call sites can observe incremental pull state without waiting for stream completion.
|
|
34
|
+
- The default atom registry is used in invalidation-await logic. That works for today but blocks scoped registries, SSR hydration, and tests that provide a custom registry.
|
|
35
|
+
|
|
36
|
+
## Compatibility boundary
|
|
37
|
+
|
|
38
|
+
Keep the existing public APIs intact while the internals move to atom-native composition:
|
|
39
|
+
|
|
40
|
+
- `client.X.query(...)` keeps its current tuple return shape.
|
|
41
|
+
- `client.X.suspense(...)` keeps returning a Promise of that tuple shape.
|
|
42
|
+
- Existing options remain accepted on old APIs, but their behavior should be fixed internally. In particular, handler-global base atoms should be shared, while observer-specific behavior such as `select`, polling, SWR/focus policy, and structural sharing is layered on top.
|
|
43
|
+
|
|
44
|
+
Only expose breaking or meaningfully different APIs under new names:
|
|
45
|
+
|
|
46
|
+
- `client.X.atom(input, options?)`
|
|
47
|
+
- `client.X.queryNew(input, options?)`
|
|
48
|
+
- `client.X.suspenseNew(input, options?)`
|
|
49
|
+
|
|
50
|
+
This lets the app test the new shape in real call sites without forcing a broad migration, and lets old API users benefit from the internal option-sharing fix.
|
|
51
|
+
|
|
52
|
+
## Target client shape
|
|
53
|
+
|
|
54
|
+
Keep query helpers on the typed clients, like mutations:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const atom = client.Carts.List.atom(input, options)
|
|
58
|
+
const query = client.Carts.List.queryNew(input, options)
|
|
59
|
+
const suspense = await client.Carts.List.suspenseNew(input, options)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`suspenseNew` stays a `Promise`, because Vue setup / Suspense wants a Promise boundary.
|
|
63
|
+
|
|
64
|
+
The proposed breaking return shape is an object:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
interface QueryView<A, E> {
|
|
68
|
+
readonly result: ComputedRef<AsyncResult.AsyncResult<A, E>>
|
|
69
|
+
readonly data: ComputedRef<A | undefined>
|
|
70
|
+
readonly atom: ComputedRef<Atom.Atom<AsyncResult.AsyncResult<A, E>>>
|
|
71
|
+
readonly awaitResult: () => Effect.Effect<A, E, never>
|
|
72
|
+
readonly refetch: () => Effect.Effect<A, E, never>
|
|
73
|
+
readonly refresh: () => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface SuspenseQueryView<A, E> extends Omit<QueryView<A, E>, "data"> {
|
|
77
|
+
readonly data: ComputedRef<A>
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`queryNew()` returns `QueryView<A, E>`. `suspenseNew()` returns `Promise<SuspenseQueryView<A, E>>`.
|
|
82
|
+
|
|
83
|
+
This removes tuple slot meaning, makes `refetch` obviously awaitable, and exposes the atom for composition.
|
|
84
|
+
|
|
85
|
+
## Atom-first internals
|
|
86
|
+
|
|
87
|
+
Split the implementation into two layers:
|
|
88
|
+
|
|
89
|
+
- `atomQuery.ts`: build and compose atoms.
|
|
90
|
+
- `queryAtom(handler, input, options)` returns `Atom<AsyncResult<A, E>>`.
|
|
91
|
+
- `queryFamily(handler)` is stable by query key plus projection hash and does not capture observer options.
|
|
92
|
+
- `awaitQueryAtom(registry, atom)` delegates to `AtomRegistry.getResult`.
|
|
93
|
+
- `refreshQueryAtom(registry, atom)` delegates to `registry.refresh`.
|
|
94
|
+
- `query.ts`: Vue adapter only.
|
|
95
|
+
- Resolve refs/getters/options.
|
|
96
|
+
- Call `useAtomValue`.
|
|
97
|
+
- Return `QueryView` / `SuspenseQueryView`.
|
|
98
|
+
- Convert to Promise only inside `useSuspenseQuery`.
|
|
99
|
+
|
|
100
|
+
Options that affect a single observer should wrap the shared raw atom rather than mutate handler-family identity:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const raw = family(input)
|
|
104
|
+
const selected = select ? Atom.mapResult(raw, select) : raw
|
|
105
|
+
const refreshed = refreshEvery
|
|
106
|
+
? Atom.withRefresh(refreshEvery)(selected)
|
|
107
|
+
: selected
|
|
108
|
+
const viewed = Atom.swr({ staleTime, revalidateOnFocus, focusSignal })(
|
|
109
|
+
refreshed
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
TTL is the awkward option. If TTL is part of the base atom, it should be a client/default policy, not the first observer's option. For old APIs, keep accepting `gcTime`, but normalize it into a base-atom policy that cannot be captured accidentally by the first observer. For new APIs, prefer an atom-native `idleTTL` or `timeToLive` name.
|
|
114
|
+
|
|
115
|
+
## Option names
|
|
116
|
+
|
|
117
|
+
Use atom-native names on the new APIs. Keep old option names on old APIs:
|
|
118
|
+
|
|
119
|
+
- `gcTime` -> `idleTTL` or `timeToLive`
|
|
120
|
+
- `refetchInterval` -> `refreshEvery`
|
|
121
|
+
- `refetchOnWindowFocus` -> `revalidateOnFocus`
|
|
122
|
+
- `select` can stay; it is a familiar projection name and maps cleanly to `Atom.mapResult`.
|
|
123
|
+
- Drop `initialData` and `placeholderData` from the new core API. Keep them accepted by old APIs if compatibility requires it, but implement them as wrappers/fallbacks over atoms rather than query-engine state.
|
|
124
|
+
|
|
125
|
+
## Composition API
|
|
126
|
+
|
|
127
|
+
Expose enough atoms that app code can compose before Vue refs:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const cartsAtom = client.Carts.List.atom(undefined)
|
|
131
|
+
const spotsAtom = client.Spots.List.atom(undefined)
|
|
132
|
+
|
|
133
|
+
const pageAtom = Atom.make((get) => ({
|
|
134
|
+
carts: get.result(cartsAtom, { suspendOnWaiting: true }),
|
|
135
|
+
spots: get.result(spotsAtom, { suspendOnWaiting: true })
|
|
136
|
+
}))
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
For plain result composition, add an atom-native replacement for `composeQueries`:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const combined = composeQueryAtoms({
|
|
143
|
+
carts: client.Carts.List.atom(undefined),
|
|
144
|
+
spots: client.Spots.List.atom(undefined)
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The existing `composeQueries` can remain as a Vue-ref convenience wrapper during migration.
|
|
149
|
+
|
|
150
|
+
## Optimistic updates
|
|
151
|
+
|
|
152
|
+
Replace `useUpdateQuery(query, input, updater)` with atom-level helpers:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const carts = client.Carts.List.optimistic(input)
|
|
156
|
+
const updateCart = client.Carts.Update.optimistic(carts, reducer)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Internally this should use `Atom.optimistic` / `Atom.optimisticFn`, not a manual cache patch. The mutation can still invalidate reactivity keys after success; optimistic atoms handle temporary UI state and rollback.
|
|
160
|
+
|
|
161
|
+
## Registry and full-stack notes
|
|
162
|
+
|
|
163
|
+
- The frontend plugin should provide an app-level `AtomRegistry` via `registryKey` instead of relying on `@effect/atom-vue`'s fallback `defaultRegistry`.
|
|
164
|
+
- `invalidateAndAwait` should await against the active registry, not a module global. A registry-aware mutation layer is cleaner than hidden global state.
|
|
165
|
+
- Serializable query atoms can unlock Nuxt SSR hydration later:
|
|
166
|
+
- mark query atoms with `Atom.serializable` using request schema + stable input key,
|
|
167
|
+
- dehydrate after server setup,
|
|
168
|
+
- hydrate the client registry before mounting.
|
|
169
|
+
- Stream query clients expose pull atoms for real progress:
|
|
170
|
+
- `client.Progress.Stream.atom(input)` returns `Atom.Writable<Atom.PullResult<A, E>, void>`,
|
|
171
|
+
- `client.Progress.Stream.family()` returns the reusable atom family,
|
|
172
|
+
- `client.Progress.Stream.queryNew(input)` returns a Vue view with `result`, `items`, `latest`, `done`, `pull`, and `pullAndAwait`.
|
|
173
|
+
- Legacy `.query()` remains collect-to-array compatibility until call sites migrate.
|
|
174
|
+
|
|
175
|
+
## Migration plan
|
|
176
|
+
|
|
177
|
+
1. Refactor internals so a base query atom is shared per handler+input, then old and new APIs layer observer options on top. This fixes old API behavior without changing old API shape.
|
|
178
|
+
2. Add `client.X.atom(input, options?)`, `client.X.queryNew(input, options?)`, and `client.X.suspenseNew(input, options?)`. Update type tests to assert atom exposure and object-return typing.
|
|
179
|
+
3. Keep `.query()` and `.suspense()` as compatibility APIs. They can delegate to the new internal engine and adapt the result back to tuple shape.
|
|
180
|
+
4. Add one or two real frontend example conversions to `queryNew` / `suspenseNew`, preferably places that exercise:
|
|
181
|
+
- a suspense read with `data` and `result`,
|
|
182
|
+
- an awaitable `refetch`,
|
|
183
|
+
- optionally `atom` composition if a small call site exists.
|
|
184
|
+
5. Keep most frontend call sites on the old tuple APIs so both surfaces are exercised during the transition.
|
|
185
|
+
6. Replace `useUpdateQuery` call sites with atom refresh or optimistic helpers after the new query surface proves out.
|
|
186
|
+
7. Convert legacy stream query call sites from collect-to-array `.query()` to pull/accumulating `.queryNew()` / `.atom()` APIs.
|
|
187
|
+
8. Add registry provider + hydration experiments after the client API is stable.
|
|
188
|
+
|
|
189
|
+
## Recommendation
|
|
190
|
+
|
|
191
|
+
Do direct atom exposure and the breaking object API together, but under additive names first. Keep `suspense()` as the compatibility Promise tuple and add `suspenseNew()` as the Promise object API. The Promise should remain a framework boundary over `AtomRegistry.getResult`, not the internal shape. The API should make the atom visible, because that is where Effect v4 gives us composition, refresh, hydration, streams, and optimistic state.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-app/vue",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.273",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/effect-ts-app/libs/tree/main/packages/vue",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"@vueuse/core": "^14.3.0",
|
|
12
12
|
"change-case": "^5.4.4",
|
|
13
13
|
"query-string": "^9.4.0",
|
|
14
|
-
"effect-app": "4.0.0-beta.
|
|
14
|
+
"effect-app": "4.0.0-beta.273"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"@effect/atom-vue": "^4.0.0-beta.84",
|
package/src/atomQuery.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
/**
|
|
3
|
+
* Shared atom core for the query/mutation engine (used by query.ts + mutate.ts).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the @tanstack/vue-query engine with Effect `Atom`, keeping the public
|
|
6
|
+
* `.query()/.suspense()/.mutate()` contract unchanged:
|
|
7
|
+
* - cache identity = the atom reference (one per [handler, input], via Atom.family)
|
|
8
|
+
* - invalidation key = reactivity keys (= the app's existing namespace query keys)
|
|
9
|
+
* - SWR + focus = Atom.swr (+ windowFocusSignal)
|
|
10
|
+
* - gcTime = Atom.setIdleTTL / Atom.keepAlive
|
|
11
|
+
* - retry = Effect.retry inside the atom effect
|
|
12
|
+
*
|
|
13
|
+
* Built over the app's RPC client through the existing `RequestHandlerWithInput`
|
|
14
|
+
* abstraction (mirrors AtomRpc's recipe; see docs/atom-query-plan.md).
|
|
15
|
+
*/
|
|
16
|
+
import { defaultRegistry } from "@effect/atom-vue"
|
|
17
|
+
import { makeQueryKey } from "effect-app/client"
|
|
18
|
+
import type { ClientForOptions } from "effect-app/client/clientFor"
|
|
19
|
+
import { ServiceUnavailableError } from "effect-app/client/errors"
|
|
20
|
+
import * as Effect from "effect-app/Effect"
|
|
21
|
+
import * as Option from "effect-app/Option"
|
|
22
|
+
import * as S from "effect-app/Schema"
|
|
23
|
+
import * as Cause from "effect/Cause"
|
|
24
|
+
import * as Duration from "effect/Duration"
|
|
25
|
+
import * as Equal from "effect/Equal"
|
|
26
|
+
import * as Hash from "effect/Hash"
|
|
27
|
+
import type * as Layer from "effect/Layer"
|
|
28
|
+
import * as Stream from "effect/Stream"
|
|
29
|
+
import { isHttpClientError } from "effect/unstable/http/HttpClientError"
|
|
30
|
+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
31
|
+
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
32
|
+
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
|
|
33
|
+
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
|
|
34
|
+
import { reportRuntimeError } from "./lib.ts"
|
|
35
|
+
|
|
36
|
+
/** All non-empty prefixes of a key, longest last. `[a,b,c]` -> `[[a],[a,b],[a,b,c]]`. */
|
|
37
|
+
const prefixesOf = (key: ReadonlyArray<unknown>): ReadonlyArray<ReadonlyArray<unknown>> =>
|
|
38
|
+
key.map((_, i) => key.slice(0, i + 1))
|
|
39
|
+
|
|
40
|
+
const uniqueKeys = (keys: ReadonlyArray<ReadonlyArray<unknown>>): ReadonlyArray<ReadonlyArray<unknown>> => {
|
|
41
|
+
const out: Array<ReadonlyArray<unknown>> = []
|
|
42
|
+
const seen = new Set<number>()
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const hash = Hash.hash(key)
|
|
45
|
+
if (seen.has(hash)) continue
|
|
46
|
+
seen.add(hash)
|
|
47
|
+
out.push(key)
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- awaitable invalidation -------------------------------------------------------------------
|
|
53
|
+
// keyHash -> live query atoms registered under that key. A query atom is tracked while it is alive
|
|
54
|
+
// in the registry (mounted OR cached within idle-ttl) and removed on GC, so invalidation reaches
|
|
55
|
+
// cached-but-unmounted queries too (e.g. a list you navigated away from).
|
|
56
|
+
const keyAtoms = new Map<number, Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>>()
|
|
57
|
+
|
|
58
|
+
const trackByKeys =
|
|
59
|
+
(keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
|
|
60
|
+
<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
|
|
61
|
+
Atom.transform(atom, (get) => {
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
const h = Hash.hash(key)
|
|
64
|
+
let set = keyAtoms.get(h)
|
|
65
|
+
if (!set) keyAtoms.set(h, set = new Set())
|
|
66
|
+
set.add(atom)
|
|
67
|
+
get.addFinalizer(() => {
|
|
68
|
+
set.delete(atom)
|
|
69
|
+
if (set.size === 0) keyAtoms.delete(h)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return get(atom)
|
|
73
|
+
}, { initialValueTarget: atom })
|
|
74
|
+
|
|
75
|
+
const trackWritableByKeys =
|
|
76
|
+
(keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
|
|
77
|
+
<A, E, W>(atom: Atom.Writable<AsyncResult.AsyncResult<A, E>, W>): Atom.Writable<AsyncResult.AsyncResult<A, E>, W> => {
|
|
78
|
+
const tracked = trackByKeys(keys)(atom)
|
|
79
|
+
return Atom.writable(
|
|
80
|
+
(get) => get(tracked),
|
|
81
|
+
(ctx, value) => ctx.set(atom, value),
|
|
82
|
+
(refresh) => refresh(tracked)
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const atomsForKeys = (keys: ReadonlyArray<unknown>): ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<any, any>>> => {
|
|
87
|
+
const atoms = new Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>()
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const set = keyAtoms.get(Hash.hash(key))
|
|
90
|
+
if (set) { for (const a of set) atoms.add(a) }
|
|
91
|
+
}
|
|
92
|
+
return [...atoms]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Invalidate the given keys and AWAIT the result. The invalidation (refetch trigger) goes through
|
|
97
|
+
* the built-in `Reactivity` service — the same one query atoms register against via
|
|
98
|
+
* `factory.withReactivity`, shared via the runtime memoMap. The await uses our own `keyAtoms`
|
|
99
|
+
* tracking + `awaitAtomResult`, since `Reactivity.invalidate` returns void and can't be awaited.
|
|
100
|
+
*
|
|
101
|
+
* Resolves once the affected queries have settled, so a mutation can `yield*` this and know the
|
|
102
|
+
* affected queries are fresh. (The await reads via the module-global default registry — the one the
|
|
103
|
+
* vue composables resolve via `injectRegistry`'s fallback.)
|
|
104
|
+
*/
|
|
105
|
+
export const invalidateAndAwait = (keys: ReadonlyArray<unknown>): Effect.Effect<void, never, Reactivity.Reactivity> =>
|
|
106
|
+
Effect.gen(function*() {
|
|
107
|
+
yield* Reactivity.invalidate(keys) // invalidates everything but only refreshes what's mounted
|
|
108
|
+
const atoms = atomsForKeys(keys)
|
|
109
|
+
// for (const a of atoms) defaultRegistry.refresh(a) // refreshes everything even when not mounted
|
|
110
|
+
if (atoms.length === 0) return
|
|
111
|
+
yield* Effect.forEach(atoms, (a) => awaitAtomResult(defaultRegistry, a).pipe(Effect.exit))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const isPlainObject = (o: unknown): o is Record<string, unknown> => {
|
|
115
|
+
if (typeof o !== "object" || o === null) return false
|
|
116
|
+
const proto = Object.getPrototypeOf(o)
|
|
117
|
+
return proto === Object.prototype || proto === null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Structural sharing (tanstack `replaceEqualDeep`): walk `next` against `prev` and reuse `prev`'s
|
|
122
|
+
* reference for any unchanged array/object sub-tree, so unchanged data keeps referential identity
|
|
123
|
+
* (Vue skips re-rendering it). Leaves — including decoded Schema CLASS INSTANCES — are compared with
|
|
124
|
+
* Effect `Equal.equals` (structural), which reuses equal instances that tanstack's `===` could not.
|
|
125
|
+
*/
|
|
126
|
+
const replaceEqualDeep = (prev: any, next: any): any => {
|
|
127
|
+
if (prev === next) return prev
|
|
128
|
+
const bothArrays = Array.isArray(prev) && Array.isArray(next)
|
|
129
|
+
if (bothArrays || (isPlainObject(prev) && isPlainObject(next))) {
|
|
130
|
+
const a: Record<PropertyKey, any> = prev
|
|
131
|
+
const b: Record<PropertyKey, any> = next
|
|
132
|
+
const copy: Record<PropertyKey, any> = bothArrays ? [] : {}
|
|
133
|
+
const nextKeys: Array<PropertyKey> = bothArrays ? (b as Array<any>).map((_, i) => i) : Object.keys(b)
|
|
134
|
+
const nextSize = nextKeys.length
|
|
135
|
+
const prevSize = bothArrays ? (a as Array<any>).length : Object.keys(a).length
|
|
136
|
+
let equalItems = 0
|
|
137
|
+
for (let i = 0; i < nextSize; i++) {
|
|
138
|
+
const key = nextKeys[i]!
|
|
139
|
+
copy[key] = replaceEqualDeep(a[key], b[key])
|
|
140
|
+
if (copy[key] === a[key] && a[key] !== undefined) equalItems++
|
|
141
|
+
}
|
|
142
|
+
return prevSize === nextSize && equalItems === prevSize ? prev : copy
|
|
143
|
+
}
|
|
144
|
+
return Equal.equals(prev, next) ? prev : next
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Atom combinator: share each new `Success` value structurally against the previous one. */
|
|
148
|
+
const structuralShare = <A, E>(
|
|
149
|
+
self: Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
150
|
+
): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
|
|
151
|
+
Atom.transform(self, (get) => {
|
|
152
|
+
const next = get(self)
|
|
153
|
+
if (next._tag !== "Success") return next
|
|
154
|
+
const prev = Option.flatMap(get.self<AsyncResult.AsyncResult<A, E>>(), AsyncResult.value)
|
|
155
|
+
if (Option.isNone(prev)) return next
|
|
156
|
+
const shared = replaceEqualDeep(prev.value, next.value)
|
|
157
|
+
return shared === next.value
|
|
158
|
+
? next
|
|
159
|
+
: AsyncResult.success(shared, { waiting: next.waiting, timestamp: next.timestamp })
|
|
160
|
+
}, { initialValueTarget: self })
|
|
161
|
+
|
|
162
|
+
export interface AtomClientRuntime {
|
|
163
|
+
readonly runtime: Atom.AtomRuntime<any, never>
|
|
164
|
+
readonly factory: Atom.RuntimeFactory
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build one AtomRuntime (and its factory) from an already-built app context.
|
|
169
|
+
* Shares the ManagedRuntime's `memoMap` so layers are not built twice, and so
|
|
170
|
+
* query-registration and mutation-invalidation resolve the SAME `Reactivity`.
|
|
171
|
+
*/
|
|
172
|
+
export const makeAtomClientRuntime = (
|
|
173
|
+
getContext: () => Layer.Layer<any, never, never>,
|
|
174
|
+
memoMap: Layer.MemoMap
|
|
175
|
+
): AtomClientRuntime => {
|
|
176
|
+
const factory = Atom.context({ memoMap })
|
|
177
|
+
const runtime = factory((_get) => getContext())
|
|
178
|
+
return { runtime, factory }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const isRetryable = (e: unknown): boolean =>
|
|
182
|
+
isHttpClientError(e)
|
|
183
|
+
|| S.is(ServiceUnavailableError)(e)
|
|
184
|
+
|| (typeof e === "object" && e !== null && (e as any)._tag === "RpcClientError")
|
|
185
|
+
|
|
186
|
+
export interface AtomQueryOptions {
|
|
187
|
+
/** background-refresh threshold (TanStack staleTime; default 5s) */
|
|
188
|
+
readonly staleTime?: Duration.Input
|
|
189
|
+
/** dispose-when-idle (TanStack gcTime; default 5min). "infinity" => keepAlive */
|
|
190
|
+
readonly gcTime?: Duration.Input | "infinity"
|
|
191
|
+
/**
|
|
192
|
+
* Revalidate a stale query on window focus AND on network reconnect (default on, matching
|
|
193
|
+
* tanstack refetchOnWindowFocus + refetchOnReconnect).
|
|
194
|
+
*/
|
|
195
|
+
readonly revalidateOnFocus?: boolean
|
|
196
|
+
/**
|
|
197
|
+
* Reuse references of unchanged sub-trees across refetches (default on, matching tanstack
|
|
198
|
+
* structuralSharing). Uses Effect `Equal` so decoded Schema instances share too — more effective
|
|
199
|
+
* than tanstack's `===`, but a deep compare per refetch (O(rows·fields)). Set `false` for very
|
|
200
|
+
* large or mostly-changing result sets where the compare costs more than the saved re-renders.
|
|
201
|
+
*/
|
|
202
|
+
readonly structuralSharing?: boolean
|
|
203
|
+
/** poll: re-fetch every N ms (tanstack refetchInterval). */
|
|
204
|
+
readonly refetchInterval?: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const defaults = { staleTime: Duration.seconds(5), gcTime: Duration.minutes(5) }
|
|
208
|
+
|
|
209
|
+
/** Exported so the vue hook can do refetch-on-mount-per-observer with the same rule as swr. */
|
|
210
|
+
export const isStaleResult = (r: AsyncResult.AsyncResult<any, any>, staleTimeMs: number): boolean => {
|
|
211
|
+
if (r.waiting) return false
|
|
212
|
+
const ts = r._tag === "Success"
|
|
213
|
+
? r.timestamp
|
|
214
|
+
: r._tag === "Failure"
|
|
215
|
+
? Option.getOrUndefined(Option.map(r.previousSuccess, (s) => s.timestamp))
|
|
216
|
+
: undefined
|
|
217
|
+
if (ts === undefined) return r._tag !== "Initial"
|
|
218
|
+
return Date.now() - ts >= staleTimeMs
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const staleTimeMsOf = (opts: AtomQueryOptions): number =>
|
|
222
|
+
Duration.toMillis(Duration.fromInputUnsafe(opts.staleTime ?? defaults.staleTime))
|
|
223
|
+
|
|
224
|
+
export const withQueryOptions = <A, E>(
|
|
225
|
+
self: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
|
|
226
|
+
opts: AtomQueryOptions = {}
|
|
227
|
+
): Atom.Atom<AsyncResult.AsyncResult<A, E>> => {
|
|
228
|
+
const staleTime: Duration.Input = opts.staleTime ?? defaults.staleTime
|
|
229
|
+
const gcTime = opts.gcTime === "infinity"
|
|
230
|
+
? "infinity" as const
|
|
231
|
+
: Duration.fromInputUnsafe(opts.gcTime ?? defaults.gcTime)
|
|
232
|
+
let atom = self
|
|
233
|
+
const revalidateOnFocus = opts.revalidateOnFocus ?? true
|
|
234
|
+
atom = Atom.swr({
|
|
235
|
+
staleTime,
|
|
236
|
+
revalidateOnFocus,
|
|
237
|
+
focusSignal: revalidateOnFocus ? focusOrReconnectSignal : undefined
|
|
238
|
+
})(atom)
|
|
239
|
+
if (opts.refetchInterval) atom = Atom.withRefresh(Duration.millis(opts.refetchInterval))(atom)
|
|
240
|
+
if (opts.structuralSharing ?? true) atom = structuralShare(atom)
|
|
241
|
+
return gcTime === "infinity" ? Atom.keepAlive(atom) : Atom.setIdleTTL(atom, gcTime)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Constant atom for disabled / `mode:"optional"`-None queries: stays Initial, never fetches. */
|
|
245
|
+
export const disabledQueryAtom: Atom.Atom<AsyncResult.AsyncResult<any, any>> = Atom.readable(() =>
|
|
246
|
+
AsyncResult.initial(false)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Bumps when the browser regains connectivity (the `online` event) — the tanstack
|
|
251
|
+
* `refetchOnReconnect` trigger. One shared listener (module-level). SSR-guarded.
|
|
252
|
+
*/
|
|
253
|
+
const onlineSignal: Atom.Atom<number> = Atom.readable((get) => {
|
|
254
|
+
let count = 0
|
|
255
|
+
if (typeof window === "undefined") return count
|
|
256
|
+
const update = () => {
|
|
257
|
+
if (navigator.onLine) get.setSelf(++count)
|
|
258
|
+
}
|
|
259
|
+
window.addEventListener("online", update)
|
|
260
|
+
get.addFinalizer(() => window.removeEventListener("online", update))
|
|
261
|
+
return count
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Focus OR reconnect, as a single signal for `swr` — both should stale-revalidate a query.
|
|
266
|
+
* swr takes one `focusSignal`, so we fold window-focus + reconnect into one derived atom;
|
|
267
|
+
* a bump from either triggers swr's stale check.
|
|
268
|
+
*/
|
|
269
|
+
const focusOrReconnectSignal: Atom.Atom<number> = Atom.make((get) => get(Atom.windowFocusSignal) + get(onlineSignal))
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build the per-input atom family for a request handler — the query CACHE IDENTITY.
|
|
273
|
+
*
|
|
274
|
+
* This is the TanStack `queryKey = [handler, input]` equivalent and the piece that makes
|
|
275
|
+
* caching cross-component: `Atom.family` memoizes one atom per structurally-distinct input
|
|
276
|
+
* (v4 hashes the input via Hash/Equal), so every component querying the same handler+input
|
|
277
|
+
* reads the SAME atom instance => one fetch, one shared result in the global registry,
|
|
278
|
+
* ref-counted and GC'd on idle ttl. (The registry + ttl give lifetime; reactivity keys give
|
|
279
|
+
* invalidation; the family gives identity/sharing — all three are needed.)
|
|
280
|
+
*
|
|
281
|
+
* The family is created once per handler (see query.ts's per-handler cache), so it is shared
|
|
282
|
+
* process-wide via the registry.
|
|
283
|
+
*
|
|
284
|
+
* Invalidation is hierarchical: each atom registers under EVERY prefix of its full key
|
|
285
|
+
* `[...makeQueryKey(self), input]`. Since reactivity matches keys by exact hash, registering
|
|
286
|
+
* all prefixes means `invalidate(P)` refreshes every atom whose key starts with `P` — e.g.
|
|
287
|
+
* `["$X"]` refreshes all inputs, `["$X","$List",input]` only that input. (`makeQueryKey`'s
|
|
288
|
+
* collapsed form `getQueryKey` — what mutations invalidate by default — is one of the prefixes.)
|
|
289
|
+
*/
|
|
290
|
+
export const buildQueryFamily = <I, A, E>(
|
|
291
|
+
rt: AtomClientRuntime,
|
|
292
|
+
self: {
|
|
293
|
+
readonly id: string
|
|
294
|
+
readonly handler: (i: I) => Effect.Effect<A, E, any>
|
|
295
|
+
readonly options?: ClientForOptions
|
|
296
|
+
readonly queryKeyProjectionHash?: string
|
|
297
|
+
}
|
|
298
|
+
) => {
|
|
299
|
+
const baseKey = makeQueryKey(self) // hierarchical, input-independent
|
|
300
|
+
|
|
301
|
+
return Atom.family((input: I) => {
|
|
302
|
+
let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> = rt.runtime.atom(
|
|
303
|
+
self
|
|
304
|
+
.handler(input)
|
|
305
|
+
.pipe(
|
|
306
|
+
Effect.retry({ times: 5, while: isRetryable }),
|
|
307
|
+
Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
|
|
308
|
+
Effect.withSpan(`query ${self.id}`, {}, { captureStackTrace: false })
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
// Register under every prefix of the full key => hierarchical (prefix) invalidation. Two roles:
|
|
312
|
+
// - withReactivity: `Reactivity.invalidate(key)` refreshes this atom (the actual refetch).
|
|
313
|
+
// - trackByKeys: records the atom in `keyAtoms` so the mutation can AWAIT its settle.
|
|
314
|
+
const fullKey = [...baseKey, input]
|
|
315
|
+
const projectedFullKey = self.queryKeyProjectionHash === undefined
|
|
316
|
+
? fullKey
|
|
317
|
+
: [...baseKey, self.queryKeyProjectionHash, input]
|
|
318
|
+
const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
|
|
319
|
+
atom = rt.factory.withReactivity(reactivityKeys)(atom)
|
|
320
|
+
atom = trackByKeys(reactivityKeys)(atom)
|
|
321
|
+
// gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
|
|
322
|
+
// idle window, letting invalidation reach a cached-but-unmounted query.
|
|
323
|
+
atom = Atom.setIdleTTL(atom, defaults.gcTime)
|
|
324
|
+
return Atom.withLabel(`query:${self.id}`)(atom)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const buildStreamQueryFamily = <I, A, E>(
|
|
329
|
+
rt: AtomClientRuntime,
|
|
330
|
+
self: {
|
|
331
|
+
readonly id: string
|
|
332
|
+
readonly handler: (i: I) => Stream.Stream<A, E, any>
|
|
333
|
+
readonly options?: ClientForOptions
|
|
334
|
+
readonly queryKeyProjectionHash?: string
|
|
335
|
+
}
|
|
336
|
+
) => {
|
|
337
|
+
const baseKey = makeQueryKey(self)
|
|
338
|
+
|
|
339
|
+
return Atom.family((input: I) => {
|
|
340
|
+
let atom = rt.runtime.pull(
|
|
341
|
+
self.handler(input).pipe(
|
|
342
|
+
Stream.tapCause((cause) => Cause.hasDies(cause) ? reportRuntimeError(cause) : Effect.void)
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
const fullKey = [...baseKey, input]
|
|
346
|
+
const projectedFullKey = self.queryKeyProjectionHash === undefined
|
|
347
|
+
? fullKey
|
|
348
|
+
: [...baseKey, self.queryKeyProjectionHash, input]
|
|
349
|
+
const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
|
|
350
|
+
atom = rt.factory.withReactivity(reactivityKeys)(atom)
|
|
351
|
+
atom = trackWritableByKeys(reactivityKeys)(atom)
|
|
352
|
+
atom = Atom.setIdleTTL(atom, defaults.gcTime)
|
|
353
|
+
return Atom.withLabel(`stream-query:${self.id}`)(atom)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Await the first resolved (non-Waiting) result of an atom. Failing query results fail the Effect. */
|
|
358
|
+
export const awaitAtomResult = <A, E>(
|
|
359
|
+
registry: AtomRegistry.AtomRegistry,
|
|
360
|
+
atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
361
|
+
) => AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })
|