@effect-app/vue 4.0.0-beta.272 → 4.0.0-beta.274

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.
@@ -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.272",
3
+ "version": "4.0.0-beta.274",
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.272"
14
+ "effect-app": "4.0.0-beta.274"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@effect/atom-vue": "^4.0.0-beta.84",
@@ -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 })