@effector-tanstack-query/core 0.1.0

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 (65) hide show
  1. package/README.md +40 -0
  2. package/dist/createBaseQuery.cjs +208 -0
  3. package/dist/createBaseQuery.cjs.map +1 -0
  4. package/dist/createBaseQuery.d.cts +114 -0
  5. package/dist/createBaseQuery.d.ts +114 -0
  6. package/dist/createBaseQuery.js +204 -0
  7. package/dist/createBaseQuery.js.map +1 -0
  8. package/dist/createInfiniteQuery.cjs +193 -0
  9. package/dist/createInfiniteQuery.cjs.map +1 -0
  10. package/dist/createInfiniteQuery.d.cts +8 -0
  11. package/dist/createInfiniteQuery.d.ts +8 -0
  12. package/dist/createInfiniteQuery.js +191 -0
  13. package/dist/createInfiniteQuery.js.map +1 -0
  14. package/dist/createInvalidate.cjs +37 -0
  15. package/dist/createInvalidate.cjs.map +1 -0
  16. package/dist/createInvalidate.d.cts +50 -0
  17. package/dist/createInvalidate.d.ts +50 -0
  18. package/dist/createInvalidate.js +35 -0
  19. package/dist/createInvalidate.js.map +1 -0
  20. package/dist/createMutation.cjs +177 -0
  21. package/dist/createMutation.cjs.map +1 -0
  22. package/dist/createMutation.d.cts +7 -0
  23. package/dist/createMutation.d.ts +7 -0
  24. package/dist/createMutation.js +175 -0
  25. package/dist/createMutation.js.map +1 -0
  26. package/dist/createQuery.cjs +98 -0
  27. package/dist/createQuery.cjs.map +1 -0
  28. package/dist/createQuery.d.cts +8 -0
  29. package/dist/createQuery.d.ts +8 -0
  30. package/dist/createQuery.js +96 -0
  31. package/dist/createQuery.js.map +1 -0
  32. package/dist/index.cjs +36 -0
  33. package/dist/index.cjs.map +1 -0
  34. package/dist/index.d.cts +8 -0
  35. package/dist/index.d.ts +8 -0
  36. package/dist/index.js +7 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/queryClient.cjs +20 -0
  39. package/dist/queryClient.cjs.map +1 -0
  40. package/dist/queryClient.d.cts +15 -0
  41. package/dist/queryClient.d.ts +15 -0
  42. package/dist/queryClient.js +17 -0
  43. package/dist/queryClient.js.map +1 -0
  44. package/dist/resolve.cjs +37 -0
  45. package/dist/resolve.cjs.map +1 -0
  46. package/dist/resolve.d.cts +17 -0
  47. package/dist/resolve.d.ts +17 -0
  48. package/dist/resolve.js +33 -0
  49. package/dist/resolve.js.map +1 -0
  50. package/dist/types.cjs +4 -0
  51. package/dist/types.cjs.map +1 -0
  52. package/dist/types.d.cts +209 -0
  53. package/dist/types.d.ts +209 -0
  54. package/dist/types.js +3 -0
  55. package/dist/types.js.map +1 -0
  56. package/package.json +60 -0
  57. package/src/createBaseQuery.ts +428 -0
  58. package/src/createInfiniteQuery.ts +291 -0
  59. package/src/createInvalidate.ts +104 -0
  60. package/src/createMutation.ts +271 -0
  61. package/src/createQuery.ts +155 -0
  62. package/src/index.ts +17 -0
  63. package/src/queryClient.ts +23 -0
  64. package/src/resolve.ts +50 -0
  65. package/src/types.ts +270 -0
@@ -0,0 +1,428 @@
1
+ import {
2
+ attach,
3
+ combine,
4
+ createEvent,
5
+ createStore,
6
+ sample,
7
+ scopeBind,
8
+ } from 'effector'
9
+ import type { EventCallable, Store } from 'effector'
10
+ import type {
11
+ FetchStatus,
12
+ QueryClient,
13
+ QueryKey,
14
+ QueryStatus,
15
+ } from '@tanstack/query-core'
16
+ import { $queryClient } from './queryClient'
17
+ import { resolveEnabled, resolveKey } from './resolve'
18
+ import type { EffectorQueryKey, StoreOrValue } from './types'
19
+
20
+ /**
21
+ * The minimal shape of an observer that createBaseQuery knows how to drive.
22
+ * Both QueryObserver and InfiniteQueryObserver satisfy this.
23
+ */
24
+ export interface BaseObserverLike<TResult> {
25
+ options: { queryKey: QueryKey; _defaulted?: boolean; queryHash?: string }
26
+ setOptions(options: any): void
27
+ subscribe(listener: (result: TResult) => void): () => void
28
+ getCurrentResult(): TResult
29
+ destroy(): void
30
+ }
31
+
32
+ /**
33
+ * The subset of observer result fields that createBaseQuery wires up
34
+ * into stores common to all query flavors.
35
+ */
36
+ export interface BaseObserverResult<TData, TError> {
37
+ data: TData | undefined
38
+ error: TError | null
39
+ status: QueryStatus
40
+ isFetching: boolean
41
+ fetchStatus: FetchStatus
42
+ isPlaceholderData: boolean
43
+ }
44
+
45
+ export interface BaseQueryStores<TData, TError, TObserver> {
46
+ $data: Store<TData | undefined>
47
+ $error: Store<TError | null>
48
+ $status: Store<QueryStatus>
49
+ $isPending: Store<boolean>
50
+ $isFetching: Store<boolean>
51
+ $isSuccess: Store<boolean>
52
+ $isError: Store<boolean>
53
+ $isPlaceholderData: Store<boolean>
54
+ $fetchStatus: Store<FetchStatus>
55
+ /**
56
+ * Per-scope observer. Populated on first `mounted()` via attach over
57
+ * `$queryClient` — every fork scope has its own Observer instance bound
58
+ * to the scope's QueryClient. Read scope-aware via `useUnit($observer)`.
59
+ */
60
+ $observer: Store<TObserver | null>
61
+ /**
62
+ * Resolved QueryClient store. If the factory was called with an explicit
63
+ * client, this is a frozen store of that client. Otherwise it's the global
64
+ * `$queryClient`, which honors `fork({ values })` overrides.
65
+ */
66
+ $queryClient: Store<QueryClient | null>
67
+ /** Internal — used by the React suspense hooks. */
68
+ $resolvedKey: Store<QueryKey>
69
+ /** Internal — used by the React suspense hooks. */
70
+ $enabled: Store<boolean>
71
+ refresh: EventCallable<void>
72
+ mounted: EventCallable<void>
73
+ unmounted: EventCallable<void>
74
+ }
75
+
76
+ export interface BaseQueryOptions {
77
+ queryKey: EffectorQueryKey
78
+ enabled?: StoreOrValue<boolean>
79
+ /**
80
+ * Pre-resolved reactive `refetchInterval`. The per-flavor factory extracts
81
+ * the original option, and — if it's a Store — passes the Store here while
82
+ * stripping the value from the observer constructor options. Static values
83
+ * and function forms continue to flow through `restOptions` to the
84
+ * observer.
85
+ */
86
+ reactiveRefetchInterval?: Store<number | false | undefined>
87
+ name?: string
88
+ }
89
+
90
+ const SID_PREFIX = '@tanstack/query-effector'
91
+
92
+ const warnedNames = new Set<string>()
93
+
94
+ export function warnMissingName(role: string): void {
95
+ if (typeof process === 'undefined' || process.env.NODE_ENV === 'production') {
96
+ return
97
+ }
98
+ if (warnedNames.has(role)) return
99
+ warnedNames.add(role)
100
+ // eslint-disable-next-line no-console
101
+ console.warn(
102
+ `[@tanstack/query-effector] ${role} created without a "name" — internal stores will be excluded from serialize(scope). ` +
103
+ `Pass a unique "name" to enable SSR via fork({ values: serialize(scope) }).`,
104
+ )
105
+ }
106
+
107
+ export function sidConfig(
108
+ name: string | undefined,
109
+ role: string,
110
+ ): { sid: string; name: string } | {} {
111
+ if (!name) return {}
112
+ return {
113
+ sid: `${SID_PREFIX}.${name}.${role}`,
114
+ name: `${name}.${role}`,
115
+ }
116
+ }
117
+
118
+ export interface ExtrasSetup<TResult, TObserver, TExtraStores> {
119
+ /** Extra stores/events merged into the final result object. */
120
+ stores: TExtraStores
121
+ /**
122
+ * Invoked inside the mount effect. Must scope-bind any extra events
123
+ * and return a function that dispatches extra fields from the observer
124
+ * result. The returned dispatcher is called on every subscription
125
+ * notification alongside the base dispatcher.
126
+ */
127
+ bindDispatcher: () => (result: TResult) => void
128
+ /**
129
+ * Lets a flavor wire its own per-observer effects (e.g.
130
+ * fetchNextPage / fetchPreviousPage for infinite queries). Receives the
131
+ * per-scope `$observer` store so the flavor can build attach-based
132
+ * effects that resolve the observer from the current scope.
133
+ */
134
+ setupEffects?: (params: { $observer: Store<TObserver | null> }) => void
135
+ }
136
+
137
+ export interface CreateBaseQueryConfig<
138
+ TData,
139
+ TError,
140
+ TResult extends BaseObserverResult<TData, TError>,
141
+ TObserver extends BaseObserverLike<TResult>,
142
+ TExtraStores,
143
+ > {
144
+ /** Build the observer for the current scope. Receives the resolved client. */
145
+ createObserver: (
146
+ queryClient: QueryClient,
147
+ initial: { queryKey: QueryKey; enabled: boolean },
148
+ ) => TObserver
149
+ /**
150
+ * Hook for query flavors that need additional stores/events (e.g. infinite
151
+ * query's hasNextPage, fetchNextPage). Called once at factory time.
152
+ */
153
+ setupExtras?: () => ExtrasSetup<TResult, TObserver, TExtraStores>
154
+ }
155
+
156
+ export function createBaseQuery<
157
+ TData,
158
+ TError,
159
+ TResult extends BaseObserverResult<TData, TError>,
160
+ TObserver extends BaseObserverLike<TResult>,
161
+ TExtraStores = {},
162
+ >(
163
+ explicitClient: QueryClient | null,
164
+ options: BaseQueryOptions,
165
+ config: CreateBaseQueryConfig<TData, TError, TResult, TObserver, TExtraStores>,
166
+ ): BaseQueryStores<TData, TError, TObserver> & TExtraStores {
167
+ const { name, reactiveRefetchInterval: $reactiveRefetchInterval } = options
168
+ const $resolvedKey = resolveKey(options.queryKey)
169
+ const $enabled = resolveEnabled(options.enabled)
170
+
171
+ // If an explicit client is passed, the factory is locked to it. fork()
172
+ // values cannot override the captured value because $effectiveClient is a
173
+ // brand-new store (not the global one). When no explicit client is passed,
174
+ // we route through $queryClient — which respects fork({ values }) for
175
+ // per-scope isolation.
176
+ const $effectiveClient: Store<QueryClient | null> = explicitClient
177
+ ? createStore(explicitClient as QueryClient | null, {
178
+ serialize: 'ignore',
179
+ })
180
+ : $queryClient
181
+
182
+ const dataUpdated = createEvent<TData | undefined>()
183
+ const errorUpdated = createEvent<TError | null>()
184
+ const statusUpdated = createEvent<QueryStatus>()
185
+ const isFetchingUpdated = createEvent<boolean>()
186
+ const fetchStatusUpdated = createEvent<FetchStatus>()
187
+ const isPlaceholderDataUpdated = createEvent<boolean>()
188
+
189
+ const $data = createStore<TData | undefined>(undefined, {
190
+ skipVoid: false,
191
+ ...sidConfig(name, '$data'),
192
+ }).on(dataUpdated, (_, v) => v)
193
+ const $error = createStore<TError | null>(null, {
194
+ skipVoid: false,
195
+ ...sidConfig(name, '$error'),
196
+ }).on(errorUpdated, (_, v) => v)
197
+ const $status = createStore<QueryStatus>('pending', {
198
+ ...sidConfig(name, '$status'),
199
+ }).on(statusUpdated, (_, v) => v)
200
+ const $isFetching = createStore(false, {
201
+ ...sidConfig(name, '$isFetching'),
202
+ }).on(isFetchingUpdated, (_, v) => v)
203
+ const $fetchStatus = createStore<FetchStatus>('idle', {
204
+ ...sidConfig(name, '$fetchStatus'),
205
+ }).on(fetchStatusUpdated, (_, v) => v)
206
+ const $isPlaceholderData = createStore(false, {
207
+ ...sidConfig(name, '$isPlaceholderData'),
208
+ }).on(isPlaceholderDataUpdated, (_, v) => v)
209
+
210
+ // Derived stores via .map don't accept sid in their config — effector's
211
+ // serialize() captures source-store values, and derived stores recompute
212
+ // automatically on the client after fork({ values }).
213
+ const $isPending = $status.map((s) => s === 'pending')
214
+ const $isSuccess = $status.map((s) => s === 'success')
215
+ const $isError = $status.map((s) => s === 'error')
216
+
217
+ // Per-scope observer storage. Carries runtime-only references
218
+ // (subscriptions, callbacks) — must never participate in serialization.
219
+ const $observer = createStore<TObserver | null>(null, {
220
+ serialize: 'ignore',
221
+ })
222
+ const observerCreated = createEvent<TObserver>()
223
+ $observer.on(observerCreated, (_, obs) => obs)
224
+
225
+ // Per-observer unsubscribe handles. WeakMap so abandoned observers (e.g.
226
+ // a scope that was discarded without unmount) are GC'able.
227
+ const observerSubscriptions = new WeakMap<TObserver, () => void>()
228
+
229
+ const extras = config.setupExtras?.()
230
+ extras?.setupEffects?.({ $observer })
231
+
232
+ // Runs once per mount. Creates the observer for the current scope (if not
233
+ // yet created) and attaches the subscription. scopeBind({ safe: true })
234
+ // reliably captures the fork scope here because this effect is triggered
235
+ // directly from allSettled(mounted). Bound dispatchers are captured in the
236
+ // observer callback's closure and reused for all subsequent notifications
237
+ // (including after key/enabled changes).
238
+ const mountFx = attach({
239
+ source: { qc: $effectiveClient, observer: $observer },
240
+ effect: (
241
+ { qc, observer: existingObserver },
242
+ {
243
+ key,
244
+ enabled,
245
+ refetchInterval,
246
+ }: {
247
+ key: QueryKey
248
+ enabled: boolean
249
+ refetchInterval: number | false | undefined
250
+ },
251
+ ) => {
252
+ if (!qc) {
253
+ throw new Error(
254
+ '[@tanstack/query-effector] No QueryClient is set. Call setQueryClient(qc) before mounting, ' +
255
+ 'pass it to fork({ values: [[$queryClient, qc]] }), or pass it explicitly to the factory.',
256
+ )
257
+ }
258
+
259
+ const observer =
260
+ existingObserver ??
261
+ config.createObserver(qc, { queryKey: key, enabled })
262
+
263
+ const dispatchData = scopeBind(dataUpdated, { safe: true })
264
+ const dispatchError = scopeBind(errorUpdated, { safe: true })
265
+ const dispatchStatus = scopeBind(statusUpdated, { safe: true })
266
+ const dispatchIsFetching = scopeBind(isFetchingUpdated, { safe: true })
267
+ const dispatchFetchStatus = scopeBind(fetchStatusUpdated, { safe: true })
268
+ const dispatchIsPlaceholderData = scopeBind(isPlaceholderDataUpdated, {
269
+ safe: true,
270
+ })
271
+ const dispatchExtras = extras?.bindDispatcher()
272
+
273
+ observerSubscriptions.get(observer)?.()
274
+ observer.setOptions({
275
+ ...observer.options,
276
+ queryKey: key,
277
+ enabled,
278
+ // Only override refetchInterval when the user provided a reactive
279
+ // Store — otherwise the static value (or function) from the observer
280
+ // constructor wins.
281
+ ...($reactiveRefetchInterval ? { refetchInterval } : {}),
282
+ })
283
+
284
+ const dispatch = (result: TResult) => {
285
+ dispatchData(result.data)
286
+ dispatchError(result.error)
287
+ dispatchStatus(result.status)
288
+ dispatchIsFetching(result.isFetching)
289
+ dispatchFetchStatus(result.fetchStatus)
290
+ dispatchIsPlaceholderData(result.isPlaceholderData)
291
+ dispatchExtras?.(result)
292
+ }
293
+
294
+ const unsubscribe = observer.subscribe(dispatch)
295
+ observerSubscriptions.set(observer, unsubscribe)
296
+
297
+ // Emit the current state immediately — observer.subscribe() may not
298
+ // fire the callback synchronously when cached data already matches
299
+ // the observer's initial result (e.g. staleTime + setQueryData).
300
+ // This mirrors react-query's getOptimisticResult() on mount.
301
+ dispatch(observer.getCurrentResult())
302
+
303
+ return observer
304
+ },
305
+ })
306
+
307
+ sample({ clock: mountFx.doneData, target: observerCreated })
308
+
309
+ // Runs when key / enabled / reactive refetchInterval change after mount.
310
+ // Only updates observer options — subscription + dispatchers were already
311
+ // wired in mountFx.
312
+ const updateObserverFx = attach({
313
+ source: $observer,
314
+ effect: (
315
+ observer,
316
+ {
317
+ key,
318
+ enabled,
319
+ refetchInterval,
320
+ }: {
321
+ key: QueryKey
322
+ enabled: boolean
323
+ refetchInterval: number | false | undefined
324
+ },
325
+ ) => {
326
+ if (!observer) return
327
+ // Strip _defaulted and queryHash so defaultQueryOptions() recomputes
328
+ // the hash for the new key. Without this, the old hash is preserved and
329
+ // QueryObserver#updateQuery() finds the old query — no key switch, no fetch.
330
+ const {
331
+ _defaulted: _d,
332
+ queryHash: _h,
333
+ ...baseOptions
334
+ } = observer.options as typeof observer.options & {
335
+ _defaulted?: boolean
336
+ queryHash?: string
337
+ }
338
+ observer.setOptions({
339
+ ...baseOptions,
340
+ queryKey: key,
341
+ enabled,
342
+ ...($reactiveRefetchInterval ? { refetchInterval } : {}),
343
+ })
344
+ },
345
+ })
346
+
347
+ const mounted = createEvent<void>()
348
+ const unmounted = createEvent<void>()
349
+ const $isMounted = createStore(false, {
350
+ ...sidConfig(name, '$isMounted'),
351
+ })
352
+ .on(mounted, () => true)
353
+ .on(unmounted, () => false)
354
+
355
+ // Combine of all reactive options that drive observer.setOptions. Built once
356
+ // so mountFx and updateObserverFx see the same shape. When the user didn't
357
+ // pass a reactive `refetchInterval`, we fall back to a static-`false` store
358
+ // (its emitted value is never read — the spread is guarded by the original
359
+ // `$reactiveRefetchInterval` reference).
360
+ const $observerOptions = combine({
361
+ key: $resolvedKey,
362
+ enabled: $enabled,
363
+ refetchInterval:
364
+ $reactiveRefetchInterval ??
365
+ createStore<number | false | undefined>(false),
366
+ })
367
+
368
+ sample({
369
+ clock: mounted,
370
+ source: $observerOptions,
371
+ target: mountFx,
372
+ })
373
+
374
+ sample({
375
+ clock: $observerOptions,
376
+ source: $observerOptions,
377
+ filter: $isMounted,
378
+ target: updateObserverFx,
379
+ })
380
+
381
+ // Single effect: tear down subscription + destroy + clear $observer.
382
+ // Doing all three in one effect avoids ordering ambiguity vs. separate
383
+ // events that all sample from `unmounted`.
384
+ const observerDestroyed = createEvent<void>()
385
+ $observer.on(observerDestroyed, () => null)
386
+
387
+ const unmountFx = attach({
388
+ source: $observer,
389
+ effect: (observer) => {
390
+ if (!observer) return
391
+ observerSubscriptions.get(observer)?.()
392
+ observerSubscriptions.delete(observer)
393
+ observer.destroy()
394
+ },
395
+ })
396
+ sample({ clock: unmounted, target: unmountFx })
397
+ sample({ clock: unmountFx.finally, target: observerDestroyed })
398
+
399
+ const refresh = createEvent<void>()
400
+ const refreshFx = attach({
401
+ source: { qc: $effectiveClient, key: $resolvedKey },
402
+ effect: ({ qc, key }) => {
403
+ if (!qc) return
404
+ return qc.invalidateQueries({ queryKey: key })
405
+ },
406
+ })
407
+ sample({ clock: refresh, target: refreshFx })
408
+
409
+ return {
410
+ $data,
411
+ $error,
412
+ $status,
413
+ $isPending,
414
+ $isFetching,
415
+ $isSuccess,
416
+ $isError,
417
+ $isPlaceholderData,
418
+ $fetchStatus,
419
+ $observer,
420
+ $queryClient: $effectiveClient,
421
+ $resolvedKey,
422
+ $enabled,
423
+ refresh,
424
+ mounted,
425
+ unmounted,
426
+ ...(extras?.stores ?? ({} as TExtraStores)),
427
+ }
428
+ }