@effect-app/vue 4.0.0-beta.31 → 4.0.0-beta.311

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +2330 -0
  2. package/dist/atomQuery.d.ts +119 -0
  3. package/dist/atomQuery.d.ts.map +1 -0
  4. package/dist/atomQuery.js +426 -0
  5. package/dist/commander.d.ts +634 -0
  6. package/dist/commander.d.ts.map +1 -0
  7. package/dist/commander.js +1070 -0
  8. package/dist/{experimental/confirm.d.ts → confirm.d.ts} +6 -4
  9. package/dist/confirm.d.ts.map +1 -0
  10. package/dist/confirm.js +26 -0
  11. package/dist/dependencyMetadata.d.ts +18 -0
  12. package/dist/dependencyMetadata.d.ts.map +1 -0
  13. package/dist/dependencyMetadata.js +67 -0
  14. package/dist/errorReporter.d.ts +6 -4
  15. package/dist/errorReporter.d.ts.map +1 -1
  16. package/dist/errorReporter.js +14 -19
  17. package/dist/form.d.ts +14 -5
  18. package/dist/form.d.ts.map +1 -1
  19. package/dist/form.js +51 -16
  20. package/dist/internal/tanstackQuery.d.ts +9 -0
  21. package/dist/internal/tanstackQuery.d.ts.map +1 -0
  22. package/dist/internal/tanstackQuery.js +204 -0
  23. package/dist/intl.d.ts +15 -0
  24. package/dist/intl.d.ts.map +1 -0
  25. package/dist/intl.js +9 -0
  26. package/dist/lib.d.ts +7 -9
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +35 -10
  29. package/dist/liveQueryInvalidation.d.ts +16 -0
  30. package/dist/liveQueryInvalidation.d.ts.map +1 -0
  31. package/dist/liveQueryInvalidation.js +115 -0
  32. package/dist/makeClient.d.ts +236 -336
  33. package/dist/makeClient.d.ts.map +1 -1
  34. package/dist/makeClient.js +417 -408
  35. package/dist/makeContext.d.ts.map +1 -1
  36. package/dist/makeIntl.d.ts.map +1 -1
  37. package/dist/{experimental/makeUseCommand.d.ts → makeUseCommand.d.ts} +4 -3
  38. package/dist/makeUseCommand.d.ts.map +1 -0
  39. package/dist/makeUseCommand.js +13 -0
  40. package/dist/mutate.d.ts +109 -40
  41. package/dist/mutate.d.ts.map +1 -1
  42. package/dist/mutate.js +224 -58
  43. package/dist/query.d.ts +158 -45
  44. package/dist/query.d.ts.map +1 -1
  45. package/dist/query.js +380 -122
  46. package/dist/routeParams.d.ts +4 -4
  47. package/dist/routeParams.d.ts.map +1 -1
  48. package/dist/routeParams.js +4 -3
  49. package/dist/runtime.d.ts +1 -14
  50. package/dist/runtime.d.ts.map +1 -1
  51. package/dist/runtime.js +2 -26
  52. package/dist/suspense.d.ts +19 -0
  53. package/dist/suspense.d.ts.map +1 -0
  54. package/dist/suspense.js +38 -0
  55. package/dist/toast.d.ts +2 -0
  56. package/dist/toast.d.ts.map +1 -0
  57. package/dist/toast.js +2 -0
  58. package/dist/withToast.d.ts +2 -0
  59. package/dist/withToast.d.ts.map +1 -0
  60. package/dist/withToast.js +2 -0
  61. package/docs/atom-query-api-redesign.md +207 -0
  62. package/docs/query-key-invalidation.md +106 -0
  63. package/examples/streamMutation.ts +72 -0
  64. package/package.json +36 -95
  65. package/src/atomQuery.ts +592 -0
  66. package/src/commander.ts +3413 -0
  67. package/src/{experimental/confirm.ts → confirm.ts} +13 -15
  68. package/src/dependencyMetadata.ts +84 -0
  69. package/src/errorReporter.ts +65 -75
  70. package/src/form.ts +66 -21
  71. package/src/index.ts +7 -7
  72. package/src/internal/tanstackQuery.ts +308 -0
  73. package/src/intl.ts +12 -0
  74. package/src/lib.ts +49 -21
  75. package/src/liveQueryInvalidation.ts +138 -0
  76. package/src/makeClient.ts +971 -1159
  77. package/src/makeIntl.ts +2 -2
  78. package/src/{experimental/makeUseCommand.ts → makeUseCommand.ts} +9 -6
  79. package/src/mutate.ts +396 -153
  80. package/src/query.ts +763 -260
  81. package/src/routeParams.ts +7 -7
  82. package/src/runtime.ts +1 -31
  83. package/src/suspense.ts +43 -0
  84. package/src/toast.ts +1 -0
  85. package/src/withToast.ts +1 -0
  86. package/test/Mutation.test.ts +181 -24
  87. package/test/dependencyInvalidation.test.ts +397 -0
  88. package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
  89. package/test/dist/form.test.d.ts.map +1 -1
  90. package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
  91. package/test/dist/lib.test.d.ts.map +1 -0
  92. package/test/dist/liveQueryInvalidation.test.d.ts.map +1 -0
  93. package/test/dist/query-span.test.d.ts.map +1 -0
  94. package/test/dist/queryOptions.test.d.ts.map +1 -0
  95. package/test/dist/streamFinal.test.d.ts.map +1 -0
  96. package/test/dist/streamFn.test.d.ts.map +1 -0
  97. package/test/dist/stubs.d.ts +3753 -122
  98. package/test/dist/stubs.d.ts.map +1 -1
  99. package/test/dist/stubs.js +192 -32
  100. package/test/dist/suspense-regression.test.d.ts.map +1 -0
  101. package/test/dist/suspense.test.d.ts.map +1 -0
  102. package/test/form-validation-errors.test.ts +25 -20
  103. package/test/form.test.ts +22 -3
  104. package/test/interrupt-refetch-repro.test.ts +623 -0
  105. package/test/lib.test.ts +240 -0
  106. package/test/liveQueryInvalidation.test.ts +108 -0
  107. package/test/makeClient.test.ts +437 -38
  108. package/test/query-span.test.ts +92 -0
  109. package/test/queryOptions.test.ts +88 -0
  110. package/test/streamFinal.test.ts +64 -0
  111. package/test/streamFn.test.ts +457 -0
  112. package/test/stubs.ts +228 -43
  113. package/test/suspense-regression.test.ts +168 -0
  114. package/test/suspense.test.ts +160 -0
  115. package/tsconfig.examples.json +20 -0
  116. package/tsconfig.json +9 -2
  117. package/tsconfig.src.json +34 -34
  118. package/tsconfig.test.json +2 -2
  119. package/vitest.config.ts +5 -5
  120. package/dist/experimental/commander.d.ts +0 -359
  121. package/dist/experimental/commander.d.ts.map +0 -1
  122. package/dist/experimental/commander.js +0 -557
  123. package/dist/experimental/confirm.d.ts.map +0 -1
  124. package/dist/experimental/confirm.js +0 -28
  125. package/dist/experimental/intl.d.ts +0 -16
  126. package/dist/experimental/intl.d.ts.map +0 -1
  127. package/dist/experimental/intl.js +0 -5
  128. package/dist/experimental/makeUseCommand.d.ts.map +0 -1
  129. package/dist/experimental/makeUseCommand.js +0 -13
  130. package/dist/experimental/toast.d.ts +0 -47
  131. package/dist/experimental/toast.d.ts.map +0 -1
  132. package/dist/experimental/toast.js +0 -41
  133. package/dist/experimental/withToast.d.ts +0 -25
  134. package/dist/experimental/withToast.d.ts.map +0 -1
  135. package/dist/experimental/withToast.js +0 -45
  136. package/eslint.config.mjs +0 -24
  137. package/src/experimental/commander.ts +0 -1835
  138. package/src/experimental/intl.ts +0 -9
  139. package/src/experimental/toast.ts +0 -66
  140. package/src/experimental/withToast.ts +0 -99
  141. package/tsconfig.json.bak +0 -12
@@ -0,0 +1,592 @@
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 { DataDependencies, 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 Ref from "effect/Ref"
29
+ import * as Stream from "effect/Stream"
30
+ import type * as Tracer from "effect/Tracer"
31
+ import { isHttpClientError } from "effect/unstable/http/HttpClientError"
32
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
33
+ import * as Atom from "effect/unstable/reactivity/Atom"
34
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
35
+ import { clearQueryReadDependencies, getQueryReadDependencies, type QueryInvalidationMode, registerQueryInvalidationMode, setQueryReadDependencies } from "./dependencyMetadata.ts"
36
+ import { reportRuntimeError } from "./lib.ts"
37
+ import { beginLiveQueryFetch, endLiveQueryFetch, type LiveQueryOptions, registerLiveQuery } from "./liveQueryInvalidation.ts"
38
+
39
+ /** All non-empty prefixes of a key, longest last. `[a,b,c]` -> `[[a],[a,b],[a,b,c]]`. */
40
+ const prefixesOf = (key: ReadonlyArray<unknown>): ReadonlyArray<ReadonlyArray<unknown>> =>
41
+ key.map((_, i) => key.slice(0, i + 1))
42
+
43
+ const uniqueKeys = (keys: ReadonlyArray<ReadonlyArray<unknown>>): ReadonlyArray<ReadonlyArray<unknown>> => {
44
+ const out: Array<ReadonlyArray<unknown>> = []
45
+ const seen = new Set<number>()
46
+ for (const key of keys) {
47
+ const hash = Hash.hash(key)
48
+ if (seen.has(hash)) continue
49
+ seen.add(hash)
50
+ out.push(key)
51
+ }
52
+ return out
53
+ }
54
+
55
+ // --- awaitable invalidation -------------------------------------------------------------------
56
+ // keyHash -> live query atoms registered under that key. A query atom is tracked while it is alive
57
+ // in the registry (mounted OR cached within idle-ttl) and removed on GC, so invalidation reaches
58
+ // cached-but-unmounted queries too (e.g. a list you navigated away from).
59
+ const keyAtoms = new Map<number, Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>>()
60
+
61
+ const trackByKeys =
62
+ (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
63
+ <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
64
+ Atom.transform(atom, (get) => {
65
+ for (const key of keys) {
66
+ const h = Hash.hash(key)
67
+ let set = keyAtoms.get(h)
68
+ if (!set) keyAtoms.set(h, set = new Set())
69
+ set.add(atom)
70
+ get.addFinalizer(() => {
71
+ set.delete(atom)
72
+ if (set.size === 0) keyAtoms.delete(h)
73
+ })
74
+ }
75
+ return get(atom)
76
+ }, { initialValueTarget: atom })
77
+
78
+ const trackWritableByKeys =
79
+ (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
80
+ <A, E, W>(atom: Atom.Writable<AsyncResult.AsyncResult<A, E>, W>): Atom.Writable<AsyncResult.AsyncResult<A, E>, W> => {
81
+ const tracked = trackByKeys(keys)(atom)
82
+ return Atom.writable(
83
+ (get) => get(tracked),
84
+ (ctx, value) => ctx.set(atom, value),
85
+ (refresh) => refresh(tracked)
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Keep a query's recorded read-dependencies registered for as long as it is alive, and drop them when
91
+ * its atom is GC'd (mirrors `trackByKeys`).
92
+ *
93
+ * `recordReads` only stores the deps on an actual fetch. A query that unmounts and later remounts from
94
+ * cache (within idle-ttl) does NOT re-run the handler, so its registry entry — cleared by the previous
95
+ * teardown's finalizer — would never be restored, and a subsequent mutation could not derive it as an
96
+ * invalidation target. Re-asserting the last-known reads on every (re)subscribe closes that gap, so the
97
+ * registry mirrors the live query even across cache-hit remounts.
98
+ */
99
+ const trackReadDependencies =
100
+ (key: ReadonlyArray<unknown>, getReads: () => DataDependencies.DataDependencies) =>
101
+ <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
102
+ Atom.transform(atom, (get) => {
103
+ const reads = getReads()
104
+ if (DataDependencies.isNonEmpty(reads)) setQueryReadDependencies(key, reads)
105
+ get.addFinalizer(() => clearQueryReadDependencies(key))
106
+ return get(atom)
107
+ }, { initialValueTarget: atom })
108
+
109
+ const atomsForKeys = (keys: ReadonlyArray<unknown>): ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<any, any>>> => {
110
+ const atoms = new Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>()
111
+ for (const key of keys) {
112
+ const set = keyAtoms.get(Hash.hash(key))
113
+ if (set) { for (const a of set) atoms.add(a) }
114
+ }
115
+ return [...atoms]
116
+ }
117
+
118
+ /** Refresh registered query atoms without making the triggering mutation await them. */
119
+ export const invalidateSoft = (keys: ReadonlyArray<unknown>): Effect.Effect<void> =>
120
+ Effect
121
+ .gen(function*() {
122
+ const atoms = atomsForKeys(keys)
123
+ yield* Effect.forEach(atoms, captureAtomQueryParentSpan, { discard: true, concurrency: "unbounded" })
124
+ if (atoms.length === 0) return
125
+ yield* Effect.forEach(atoms, (atom) => Effect.sync(() => defaultRegistry.refresh(atom)), {
126
+ discard: true
127
+ })
128
+ })
129
+ .pipe(Effect.orDie)
130
+
131
+ /**
132
+ * Invalidate the given keys and AWAIT the result. `keyAtoms` resolves all matching hierarchical
133
+ * keys to a deduplicated set of query atoms. Refresh that set directly: sending the whole key set
134
+ * through `Reactivity.invalidate` invokes one atom's registered callback once per matching key,
135
+ * repeatedly superseding the same fetch when a mutation carries many row/prefix keys.
136
+ *
137
+ * Resolves once the affected queries have settled, so a mutation can `yield*` this and know the
138
+ * affected queries are fresh. (The await reads via the module-global default registry — the one the
139
+ * vue composables resolve via `injectRegistry`'s fallback.)
140
+ */
141
+ export const invalidateAndAwait = (keys: ReadonlyArray<unknown>): Effect.Effect<void> =>
142
+ Effect
143
+ .gen(function*() {
144
+ const atoms = atomsForKeys(keys)
145
+ yield* Effect.forEach(atoms, captureAtomQueryParentSpan, { discard: true, concurrency: "unbounded" })
146
+ if (atoms.length === 0) return
147
+ yield* Effect.forEach(atoms, (atom) => Effect.sync(() => defaultRegistry.refresh(atom)), {
148
+ discard: true
149
+ })
150
+ yield* Effect.forEach(atoms, (a) => awaitAtomResult(defaultRegistry, a).pipe(Effect.exit))
151
+ })
152
+ .pipe(Effect.orDie)
153
+
154
+ const isPlainObject = (o: unknown): o is Record<string, unknown> => {
155
+ if (typeof o !== "object" || o === null) return false
156
+ const proto = Object.getPrototypeOf(o)
157
+ return proto === Object.prototype || proto === null
158
+ }
159
+
160
+ /**
161
+ * Structural sharing (tanstack `replaceEqualDeep`): walk `next` against `prev` and reuse `prev`'s
162
+ * reference for any unchanged array/object sub-tree, so unchanged data keeps referential identity
163
+ * (Vue skips re-rendering it). Leaves — including decoded Schema CLASS INSTANCES — are compared with
164
+ * Effect `Equal.equals` (structural), which reuses equal instances that tanstack's `===` could not.
165
+ */
166
+ export const replaceEqualDeep = (prev: any, next: any): any => {
167
+ if (prev === next) return prev
168
+ const bothArrays = Array.isArray(prev) && Array.isArray(next)
169
+ if (bothArrays || (isPlainObject(prev) && isPlainObject(next))) {
170
+ const a: Record<PropertyKey, any> = prev
171
+ const b: Record<PropertyKey, any> = next
172
+ const copy: Record<PropertyKey, any> = bothArrays ? [] : {}
173
+ const nextKeys: Array<PropertyKey> = bothArrays ? (b as Array<any>).map((_, i) => i) : Object.keys(b)
174
+ const nextSize = nextKeys.length
175
+ const prevSize = bothArrays ? (a as Array<any>).length : Object.keys(a).length
176
+ let equalItems = 0
177
+ for (let i = 0; i < nextSize; i++) {
178
+ const key = nextKeys[i]!
179
+ copy[key] = replaceEqualDeep(a[key], b[key])
180
+ if (copy[key] === a[key] && a[key] !== undefined) equalItems++
181
+ }
182
+ return prevSize === nextSize && equalItems === prevSize ? prev : copy
183
+ }
184
+ return Equal.equals(prev, next) ? prev : next
185
+ }
186
+
187
+ /** Atom combinator: share each new `Success` value structurally against the previous one. */
188
+ const structuralShare = <A, E>(
189
+ self: Atom.Atom<AsyncResult.AsyncResult<A, E>>
190
+ ): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
191
+ Atom.transform(self, (get) => {
192
+ const next = get(self)
193
+ if (next._tag !== "Success") return next
194
+ const prev = Option.flatMap(get.self<AsyncResult.AsyncResult<A, E>>(), AsyncResult.value)
195
+ if (Option.isNone(prev)) return next
196
+ const shared = replaceEqualDeep(prev.value, next.value)
197
+ return shared === next.value
198
+ ? next
199
+ : AsyncResult.success(shared, { waiting: next.waiting, timestamp: next.timestamp })
200
+ }, { initialValueTarget: self })
201
+
202
+ export interface AtomClientRuntime {
203
+ readonly runtime: Atom.AtomRuntime<any, never>
204
+ readonly factory: Atom.RuntimeFactory
205
+ }
206
+
207
+ /**
208
+ * Build one AtomRuntime (and its factory) from an already-built app context.
209
+ * Shares the ManagedRuntime's `memoMap` so layers are not built twice, and so
210
+ * query-registration and mutation-invalidation resolve the SAME `Reactivity`.
211
+ */
212
+ export const makeAtomClientRuntime = <R>(
213
+ getContext: () => Layer.Layer<R, never, never>,
214
+ memoMap: Layer.MemoMap
215
+ ): AtomClientRuntime => {
216
+ const factory = Atom.context({ memoMap })
217
+ const runtime = factory((_get) => getContext())
218
+ return { runtime, factory }
219
+ }
220
+
221
+ const isRetryable = (e: unknown): boolean =>
222
+ isHttpClientError(e)
223
+ || S.is(ServiceUnavailableError)(e)
224
+ || (typeof e === "object" && e !== null && (e as any)._tag === "RpcClientError")
225
+
226
+ export interface AtomQueryOptions {
227
+ /** background-refresh threshold (TanStack staleTime; default 5s) */
228
+ readonly staleTime?: Duration.Input
229
+ /** dispose-when-idle (TanStack gcTime; default 5min). "infinity" => keepAlive */
230
+ readonly gcTime?: Duration.Input | "infinity"
231
+ readonly invalidation?: QueryInvalidationMode
232
+ /**
233
+ * Revalidate a stale query on window focus AND on network reconnect (default on, matching
234
+ * tanstack refetchOnWindowFocus + refetchOnReconnect).
235
+ */
236
+ readonly revalidateOnFocus?: boolean
237
+ /**
238
+ * Reuse references of unchanged sub-trees across refetches (default on, matching tanstack
239
+ * structuralSharing). Uses Effect `Equal` so decoded Schema instances share too — more effective
240
+ * than tanstack's `===`, but a deep compare per refetch (O(rows·fields)). Set `false` for very
241
+ * large or mostly-changing result sets where the compare costs more than the saved re-renders.
242
+ */
243
+ readonly structuralSharing?: boolean
244
+ /** poll: re-fetch every N ms (tanstack refetchInterval). */
245
+ readonly refetchInterval?: number
246
+ /** Refresh when writes intersect the dependencies recorded by this query. */
247
+ readonly live?: boolean | LiveQueryOptions
248
+ }
249
+
250
+ const defaults = { staleTime: Duration.seconds(5), gcTime: Duration.minutes(5) }
251
+
252
+ export interface AtomQueryMetadata {
253
+ readonly staleTimeMs: number
254
+ }
255
+
256
+ const atomQueryMetadata = new WeakMap<Atom.Atom<AsyncResult.AsyncResult<any, any>>, AtomQueryMetadata>()
257
+ const atomQueryParentSpans = new WeakMap<Atom.Atom<AsyncResult.AsyncResult<any, any>>, Tracer.AnySpan>()
258
+ const atomQueryKeys = new WeakMap<Atom.Atom<AsyncResult.AsyncResult<any, any>>, ReadonlyArray<unknown>>()
259
+
260
+ export const queryKeyForAtom = <A, E>(
261
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
262
+ ): ReadonlyArray<unknown> | undefined => atomQueryKeys.get(atom)
263
+
264
+ const atomSpanTarget = <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) => {
265
+ let target = atom
266
+ while (target.initialValueTarget !== undefined) {
267
+ target = target.initialValueTarget
268
+ }
269
+ return target
270
+ }
271
+
272
+ const takeAtomQueryParentSpan = <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) => {
273
+ const target = atomSpanTarget(atom)
274
+ const span = atomQueryParentSpans.get(target)
275
+ if (span !== undefined) atomQueryParentSpans.delete(target)
276
+ return span
277
+ }
278
+
279
+ const setAtomQueryParentSpan = <A, E>(
280
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
281
+ span: Tracer.AnySpan
282
+ ) => {
283
+ atomQueryParentSpans.set(atomSpanTarget(atom), span)
284
+ }
285
+
286
+ export const captureAtomQueryParentSpan = <A, E>(
287
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
288
+ ): Effect.Effect<void> =>
289
+ Effect.currentParentSpan.pipe(
290
+ Effect.tap((span) => Effect.sync(() => setAtomQueryParentSpan(atom, span))),
291
+ Effect.ignore
292
+ )
293
+
294
+ export const refreshAtomWithCurrentSpan = <A, E>(
295
+ registry: AtomRegistry.AtomRegistry,
296
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
297
+ ): Effect.Effect<void> =>
298
+ captureAtomQueryParentSpan(atom).pipe(
299
+ Effect.andThen(Effect.sync(() => registry.refresh(atom)))
300
+ )
301
+
302
+ const setAtomQueryMetadata = <A, E>(
303
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
304
+ opts: AtomQueryOptions = {}
305
+ ) => {
306
+ const staleTimeMs = staleTimeMsOf(opts)
307
+ const previous = atomQueryMetadata.get(atom)
308
+ atomQueryMetadata.set(atom, {
309
+ staleTimeMs: previous === undefined ? staleTimeMs : Math.min(previous.staleTimeMs, staleTimeMs)
310
+ })
311
+ return atom
312
+ }
313
+
314
+ export const getAtomQueryMetadata = <A, E>(
315
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
316
+ ): AtomQueryMetadata | undefined => atomQueryMetadata.get(atom)
317
+
318
+ /** Exported so the vue hook can do refetch-on-mount-per-observer with the same rule as swr. */
319
+ export const isStaleResult = (r: AsyncResult.AsyncResult<any, any>, staleTimeMs: number): boolean => {
320
+ if (r.waiting) return false
321
+ const ts = r._tag === "Success"
322
+ ? r.timestamp
323
+ : r._tag === "Failure"
324
+ ? Option.getOrUndefined(Option.map(r.previousSuccess, (s) => s.timestamp))
325
+ : undefined
326
+ if (ts === undefined) return r._tag !== "Initial"
327
+ return Date.now() - ts >= staleTimeMs
328
+ }
329
+
330
+ export const staleTimeMsOf = (opts: AtomQueryOptions): number =>
331
+ Duration.toMillis(Duration.fromInputUnsafe(opts.staleTime ?? defaults.staleTime))
332
+
333
+ // A query fetch that is interrupted (a subscriber lost interest / a refresh superseded it / the
334
+ // component navigated) leaves the family atom `waiting=true` with NO fiber running — effect-core
335
+ // `makeEffect` removes its result-observer before interrupting, so the interrupt is never written
336
+ // back; it stays `waitingFrom(previous)`. `swr`/`isStaleResult` both short-circuit
337
+ // `if (waiting) return false`, so that stuck `waiting` is neither completing nor considered stale —
338
+ // mount/focus/staleness all skip it. Left alone the interrupt is TERMINAL: any current or future
339
+ // subscriber inherits the `waiting` forever (cold → `latestDefined` throws → white screen; warm →
340
+ // the Mako Bauhaus PickList stale wedge).
341
+ //
342
+ // The invariant we restore in this layer (no effect-core change): an interrupt must never be a
343
+ // terminal state. We do NOT auto-retry the interrupted fetch — an interrupt is intentional (that
344
+ // caller lost interest), and the first observer recovers through its normal refresh/staleness rules.
345
+ // We only guarantee that a *genuine* new/parallel/future observer is never stuck on a departed
346
+ // observer's interrupt: per family atom we count live computes (`inFlight`), so "stuck" = a `waiting`
347
+ // result with `inFlight === 0` (nothing is actually fetching). On mount a stuck atom triggers one
348
+ // fresh fetch instead of adopting the dangling `waiting`; a genuinely in-flight fetch (`inFlight > 0`)
349
+ // is joined, not superseded (dedup / re-entrancy preserved). `recovering` dedupes concurrent mounts
350
+ // so exactly one recovery fetch is issued until a fetch is running again.
351
+ export interface QueryFetchState {
352
+ inFlight: number
353
+ recovering: boolean
354
+ }
355
+ export const queryFetchStates = new WeakMap<Atom.Atom<any>, QueryFetchState>()
356
+
357
+ const recoverStuckWaitingOnMount =
358
+ <A, E>(familyAtom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) =>
359
+ (wrapped: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
360
+ Atom.transform(wrapped, (get) => {
361
+ const current = get.once(wrapped)
362
+ get.subscribe(wrapped, (value) => get.setSelf(value))
363
+ const state = queryFetchStates.get(familyAtom)
364
+ const waiting = current?.waiting === true
365
+ // Stuck: the result says `waiting` yet nothing is in-flight — an interrupt was hidden and left
366
+ // `waiting` dangling. Treat it as not-yet-fetched and refetch; a live fetch (`inFlight > 0`) is
367
+ // joined, not superseded. `recovering` prevents concurrent mounts from issuing more than one
368
+ // recovery fetch (cleared once a fetch is actually running again).
369
+ if (state && waiting && state.inFlight === 0 && !state.recovering) {
370
+ state.recovering = true
371
+ get.refresh(familyAtom)
372
+ }
373
+ return current
374
+ }, { initialValueTarget: wrapped })
375
+
376
+ export const withQueryOptions = <A, E>(
377
+ self: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
378
+ opts: AtomQueryOptions = {},
379
+ liveKey?: ReadonlyArray<unknown>
380
+ ): Atom.Atom<AsyncResult.AsyncResult<A, E>> => {
381
+ setAtomQueryMetadata(self, opts)
382
+ const staleTime: Duration.Input = opts.staleTime ?? defaults.staleTime
383
+ let atom = self
384
+ if (liveKey !== undefined) {
385
+ atom = Atom.transform(atom, (get) => {
386
+ const unregister = registerQueryInvalidationMode(liveKey, opts.invalidation ?? "await")
387
+ get.addFinalizer(unregister)
388
+ return get(self)
389
+ }, { initialValueTarget: self })
390
+ }
391
+ if (opts.live && liveKey !== undefined) {
392
+ const liveOptions = opts.live === true ? {} : opts.live
393
+ atom = Atom.transform(atom, (get) => {
394
+ const unregister = registerLiveQuery(
395
+ liveKey,
396
+ () => getQueryReadDependencies(liveKey),
397
+ liveOptions
398
+ )
399
+ get.addFinalizer(unregister)
400
+ return get(self)
401
+ }, { initialValueTarget: self })
402
+ }
403
+ const revalidateOnFocus = opts.revalidateOnFocus ?? true
404
+ atom = Atom.swr({
405
+ staleTime,
406
+ revalidateOnFocus,
407
+ focusSignal: revalidateOnFocus ? focusOrReconnectSignal : undefined
408
+ })(atom)
409
+ atom = recoverStuckWaitingOnMount(self)(atom)
410
+ if (opts.refetchInterval) atom = Atom.withRefresh(Duration.millis(opts.refetchInterval))(atom)
411
+ if (opts.structuralSharing ?? true) atom = structuralShare(atom)
412
+ return atom
413
+ }
414
+
415
+ /** Constant atom for disabled / `mode:"optional"`-None queries: stays Initial, never fetches. */
416
+ export const disabledQueryAtom: Atom.Atom<AsyncResult.AsyncResult<any, any>> = Atom.readable(() =>
417
+ AsyncResult.initial(false)
418
+ )
419
+
420
+ /**
421
+ * Bumps when the browser regains connectivity (the `online` event) — the tanstack
422
+ * `refetchOnReconnect` trigger. One shared listener (module-level). SSR-guarded.
423
+ */
424
+ const onlineSignal: Atom.Atom<number> = Atom.readable((get) => {
425
+ let count = 0
426
+ if (typeof window === "undefined") return count
427
+ const update = () => {
428
+ if (navigator.onLine) get.setSelf(++count)
429
+ }
430
+ window.addEventListener("online", update)
431
+ get.addFinalizer(() => window.removeEventListener("online", update))
432
+ return count
433
+ })
434
+
435
+ /**
436
+ * Focus OR reconnect, as a single signal for `swr` — both should stale-revalidate a query.
437
+ * swr takes one `focusSignal`, so we fold window-focus + reconnect into one derived atom;
438
+ * a bump from either triggers swr's stale check.
439
+ */
440
+ const focusOrReconnectSignal: Atom.Atom<number> = Atom.make((get) => get(Atom.windowFocusSignal) + get(onlineSignal))
441
+
442
+ /**
443
+ * Build the per-input atom family for a request handler — the query CACHE IDENTITY.
444
+ *
445
+ * This is the TanStack `queryKey = [handler, input]` equivalent and the piece that makes
446
+ * caching cross-component: `Atom.family` memoizes one atom per structurally-distinct input
447
+ * (v4 hashes the input via Hash/Equal), so every component querying the same handler+input
448
+ * reads the SAME atom instance => one fetch, one shared result in the global registry,
449
+ * ref-counted and GC'd on idle ttl. (The registry + ttl give lifetime; reactivity keys give
450
+ * invalidation; the family gives identity/sharing — all three are needed.)
451
+ *
452
+ * The family is created once per handler (see query.ts's per-handler cache), so it is shared
453
+ * process-wide via the registry.
454
+ *
455
+ * Invalidation is hierarchical: each atom registers under EVERY prefix of its full key
456
+ * `[...makeQueryKey(self), input]`. Since reactivity matches keys by exact hash, registering
457
+ * all prefixes means `invalidate(P)` refreshes every atom whose key starts with `P` — e.g.
458
+ * `["$X"]` refreshes all inputs, `["$X","$List",input]` only that input. (`makeQueryKey`'s
459
+ * collapsed form `getQueryKey` — what mutations invalidate by default — is one of the prefixes.)
460
+ */
461
+ export const buildQueryFamily = <I, A, E>(
462
+ rt: AtomClientRuntime,
463
+ self: {
464
+ readonly id: string
465
+ readonly handler: (i: I) => Effect.Effect<A, E, any>
466
+ readonly options?: ClientForOptions
467
+ readonly queryKeyProjectionHash?: string
468
+ }
469
+ ) => {
470
+ const baseKey = makeQueryKey(self) // hierarchical, input-independent
471
+
472
+ return Atom.family((input: I) => {
473
+ const fullKey = [...baseKey, input]
474
+ // Record the repository/server read-dependencies seen while fetching, keyed by `fullKey`, so a
475
+ // later mutation whose writes intersect them can derive this query as an invalidation target.
476
+ // The last recorded reads are retained on the (memoized) family atom so `trackReadDependencies`
477
+ // can re-assert them on a cache-hit remount that never re-runs the handler.
478
+ let lastReads: DataDependencies.DataDependencies = DataDependencies.empty()
479
+ // Fetch bookkeeping read by `recoverStuckWaitingOnMount`. `inFlight` counts live computes, so a
480
+ // `waiting` result with `inFlight === 0` is a stuck/hidden interrupt. `recovering` guards the
481
+ // recovery refresh against concurrent mounts; a running fetch clears it.
482
+ const fetchState: QueryFetchState = { inFlight: 0, recovering: false }
483
+ let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
484
+ atom = rt.runtime.atom(
485
+ Effect.suspend(() => {
486
+ // A fetch is now running: the atom is not stuck, and any pending recovery is fulfilled.
487
+ fetchState.recovering = false
488
+ fetchState.inFlight++
489
+ const recordReads = Effect
490
+ .gen(function*() {
491
+ const readsRef = yield* Ref.make(DataDependencies.empty())
492
+ const writesRef = yield* Ref.make(DataDependencies.empty())
493
+ const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
494
+ const result = yield* self
495
+ .handler(input)
496
+ .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder))
497
+ lastReads = yield* Ref.get(readsRef)
498
+ setQueryReadDependencies(fullKey, lastReads)
499
+ return result
500
+ })
501
+ let liveFetch = false
502
+ const effect = Effect
503
+ .gen(function*() {
504
+ liveFetch = yield* Effect.promise(() => beginLiveQueryFetch(fullKey))
505
+ return yield* recordReads.pipe(Effect.retry({ times: 5, while: isRetryable }))
506
+ })
507
+ .pipe(
508
+ Effect.ensuring(Effect.sync(() => endLiveQueryFetch(liveFetch))),
509
+ Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
510
+ // On exit, the compute is no longer in-flight. An interrupt (subscriber lost interest / a
511
+ // superseding refresh) may leave the result at `waiting`; with `inFlight` back at 0 that
512
+ // reads as "stuck", so the next mount recovers it (`recoverStuckWaitingOnMount`). We do not
513
+ // re-fire here — the interrupt was intentional; recovery is driven by a genuine (re)mount.
514
+ Effect.onExit(() =>
515
+ Effect.sync(() => {
516
+ fetchState.inFlight = Math.max(0, fetchState.inFlight - 1)
517
+ })
518
+ ),
519
+ Effect.withSpan(`query ${self.id}`, {}, { captureStackTrace: false })
520
+ )
521
+ const parentSpan = takeAtomQueryParentSpan(atom)
522
+ return parentSpan === undefined
523
+ ? effect
524
+ : effect.pipe(Effect.withParentSpan(parentSpan, { captureStackTrace: false }))
525
+ })
526
+ )
527
+ // Register under every prefix of the full key => hierarchical (prefix) invalidation. Two roles:
528
+ // - withReactivity: `Reactivity.invalidate(key)` refreshes this atom (the actual refetch).
529
+ // - trackByKeys: records the atom in `keyAtoms` so the mutation can AWAIT its settle.
530
+ const projectedFullKey = self.queryKeyProjectionHash === undefined
531
+ ? fullKey
532
+ : [...baseKey, self.queryKeyProjectionHash, input]
533
+ const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
534
+ atom = rt.factory.withReactivity(reactivityKeys)(atom)
535
+ atom = trackByKeys(reactivityKeys)(atom)
536
+ atom = trackReadDependencies(fullKey, () => lastReads)(atom)
537
+ // gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
538
+ // idle window, letting invalidation reach a cached-but-unmounted query.
539
+ atom = Atom.setIdleTTL(atom, defaults.gcTime)
540
+ const registered = setAtomQueryMetadata(Atom.withLabel(`query:${self.id}`)(atom))
541
+ const writable = Atom.writable(
542
+ (get) => {
543
+ const current = get.once(registered)
544
+ get.subscribe(registered, (value) => get.setSelf(value))
545
+ return current
546
+ },
547
+ (ctx, value: AsyncResult.AsyncResult<A, E>) => ctx.setSelf(value),
548
+ (refresh) => refresh(registered)
549
+ )
550
+ const writableWithTarget = Object.assign(writable, { initialValueTarget: registered })
551
+ const result = setAtomQueryMetadata(Atom.withLabel(`query-cache:${self.id}`)(writableWithTarget))
552
+ // Key the fetch state by the atom `withQueryOptions` receives, so its mount hook can find it.
553
+ queryFetchStates.set(result, fetchState)
554
+ atomQueryKeys.set(result, fullKey)
555
+ return result
556
+ })
557
+ }
558
+
559
+ export const buildStreamQueryFamily = <I, A, E>(
560
+ rt: AtomClientRuntime,
561
+ self: {
562
+ readonly id: string
563
+ readonly handler: (i: I) => Stream.Stream<A, E, any>
564
+ readonly options?: ClientForOptions
565
+ readonly queryKeyProjectionHash?: string
566
+ }
567
+ ) => {
568
+ const baseKey = makeQueryKey(self)
569
+
570
+ return Atom.family((input: I) => {
571
+ let atom = rt.runtime.pull(
572
+ self.handler(input).pipe(
573
+ Stream.tapCause((cause) => Cause.hasDies(cause) ? reportRuntimeError(cause) : Effect.void)
574
+ )
575
+ )
576
+ const fullKey = [...baseKey, input]
577
+ const projectedFullKey = self.queryKeyProjectionHash === undefined
578
+ ? fullKey
579
+ : [...baseKey, self.queryKeyProjectionHash, input]
580
+ const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
581
+ atom = rt.factory.withReactivity(reactivityKeys)(atom)
582
+ atom = trackWritableByKeys(reactivityKeys)(atom)
583
+ atom = Atom.setIdleTTL(atom, defaults.gcTime)
584
+ return setAtomQueryMetadata(Atom.withLabel(`stream-query:${self.id}`)(atom))
585
+ })
586
+ }
587
+
588
+ /** Await the first resolved (non-Waiting) result of an atom. Failing query results fail the Effect. */
589
+ export const awaitAtomResult = <A, E>(
590
+ registry: AtomRegistry.AtomRegistry,
591
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
592
+ ) => AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })