@pyreon/query 0.0.1

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,138 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, effect, batch } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import { InfiniteQueryObserver } from '@tanstack/query-core'
5
+ import type {
6
+ DefaultError,
7
+ InfiniteData,
8
+ InfiniteQueryObserverOptions,
9
+ InfiniteQueryObserverResult,
10
+ QueryKey,
11
+ QueryObserverResult,
12
+ } from '@tanstack/query-core'
13
+ import { useQueryClient } from './query-client'
14
+
15
+ export interface UseInfiniteQueryResult<TQueryFnData, TError = DefaultError> {
16
+ /** Raw signal — full observer result. */
17
+ result: Signal<
18
+ InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
19
+ >
20
+ data: Signal<InfiniteData<TQueryFnData> | undefined>
21
+ error: Signal<TError | null>
22
+ status: Signal<'pending' | 'error' | 'success'>
23
+ isPending: Signal<boolean>
24
+ isLoading: Signal<boolean>
25
+ isFetching: Signal<boolean>
26
+ isFetchingNextPage: Signal<boolean>
27
+ isFetchingPreviousPage: Signal<boolean>
28
+ isError: Signal<boolean>
29
+ isSuccess: Signal<boolean>
30
+ hasNextPage: Signal<boolean>
31
+ hasPreviousPage: Signal<boolean>
32
+ fetchNextPage: () => Promise<
33
+ InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
34
+ >
35
+ fetchPreviousPage: () => Promise<
36
+ InfiniteQueryObserverResult<InfiniteData<TQueryFnData>, TError>
37
+ >
38
+ refetch: () => Promise<
39
+ QueryObserverResult<InfiniteData<TQueryFnData>, TError>
40
+ >
41
+ }
42
+
43
+ /**
44
+ * Subscribe to a paginated / infinite-scroll query.
45
+ * Returns fine-grained reactive signals plus `fetchNextPage`, `fetchPreviousPage`,
46
+ * `hasNextPage` and `hasPreviousPage`.
47
+ *
48
+ * @example
49
+ * const query = useInfiniteQuery(() => ({
50
+ * queryKey: ['posts'],
51
+ * queryFn: ({ pageParam }) => fetchPosts(pageParam as number),
52
+ * initialPageParam: 0,
53
+ * getNextPageParam: (lastPage) => lastPage.nextCursor,
54
+ * }))
55
+ * // query.data()?.pages — array of pages
56
+ * // h('button', { onClick: () => query.fetchNextPage() }, 'Load more')
57
+ */
58
+ export function useInfiniteQuery<
59
+ TQueryFnData = unknown,
60
+ TError = DefaultError,
61
+ TQueryKey extends QueryKey = QueryKey,
62
+ TPageParam = unknown,
63
+ >(
64
+ options: () => InfiniteQueryObserverOptions<
65
+ TQueryFnData,
66
+ TError,
67
+ InfiniteData<TQueryFnData>,
68
+ TQueryKey,
69
+ TPageParam
70
+ >,
71
+ ): UseInfiniteQueryResult<TQueryFnData, TError> {
72
+ const client = useQueryClient()
73
+ const observer = new InfiniteQueryObserver<
74
+ TQueryFnData,
75
+ TError,
76
+ InfiniteData<TQueryFnData>,
77
+ TQueryKey,
78
+ TPageParam
79
+ >(client, options())
80
+ const initial = observer.getCurrentResult()
81
+
82
+ const resultSig = signal(initial)
83
+ const dataSig = signal<InfiniteData<TQueryFnData> | undefined>(initial.data)
84
+ const errorSig = signal<TError | null>(initial.error ?? null)
85
+ const statusSig = signal(initial.status)
86
+ const isPending = signal(initial.isPending)
87
+ const isLoading = signal(initial.isLoading)
88
+ const isFetching = signal(initial.isFetching)
89
+ const isFetchingNextPage = signal(initial.isFetchingNextPage)
90
+ const isFetchingPreviousPage = signal(initial.isFetchingPreviousPage)
91
+ const isError = signal(initial.isError)
92
+ const isSuccess = signal(initial.isSuccess)
93
+ const hasNextPage = signal(initial.hasNextPage)
94
+ const hasPreviousPage = signal(initial.hasPreviousPage)
95
+
96
+ const unsub = observer.subscribe((r) => {
97
+ batch(() => {
98
+ resultSig.set(r)
99
+ dataSig.set(r.data)
100
+ errorSig.set(r.error ?? null)
101
+ statusSig.set(r.status)
102
+ isPending.set(r.isPending)
103
+ isLoading.set(r.isLoading)
104
+ isFetching.set(r.isFetching)
105
+ isFetchingNextPage.set(r.isFetchingNextPage)
106
+ isFetchingPreviousPage.set(r.isFetchingPreviousPage)
107
+ isError.set(r.isError)
108
+ isSuccess.set(r.isSuccess)
109
+ hasNextPage.set(r.hasNextPage)
110
+ hasPreviousPage.set(r.hasPreviousPage)
111
+ })
112
+ })
113
+
114
+ effect(() => {
115
+ observer.setOptions(options())
116
+ })
117
+
118
+ onUnmount(() => unsub())
119
+
120
+ return {
121
+ result: resultSig,
122
+ data: dataSig,
123
+ error: errorSig,
124
+ status: statusSig,
125
+ isPending,
126
+ isLoading,
127
+ isFetching,
128
+ isFetchingNextPage,
129
+ isFetchingPreviousPage,
130
+ isError,
131
+ isSuccess,
132
+ hasNextPage,
133
+ hasPreviousPage,
134
+ fetchNextPage: () => observer.fetchNextPage(),
135
+ fetchPreviousPage: () => observer.fetchPreviousPage(),
136
+ refetch: () => observer.refetch(),
137
+ }
138
+ }
@@ -0,0 +1,44 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import type { MutationFilters, QueryFilters } from '@tanstack/query-core'
5
+ import { useQueryClient } from './query-client'
6
+
7
+ /**
8
+ * Returns a signal that tracks how many queries are currently in-flight.
9
+ * Useful for global loading indicators.
10
+ *
11
+ * @example
12
+ * const fetching = useIsFetching()
13
+ * // h('span', null, () => fetching() > 0 ? 'Loading…' : '')
14
+ */
15
+ export function useIsFetching(filters?: QueryFilters): Signal<number> {
16
+ const client = useQueryClient()
17
+ const count = signal(client.isFetching(filters))
18
+
19
+ const unsub = client.getQueryCache().subscribe(() => {
20
+ count.set(client.isFetching(filters))
21
+ })
22
+ onUnmount(() => unsub())
23
+
24
+ return count
25
+ }
26
+
27
+ /**
28
+ * Returns a signal that tracks how many mutations are currently in-flight.
29
+ *
30
+ * @example
31
+ * const mutating = useIsMutating()
32
+ * // h('span', null, () => mutating() > 0 ? 'Saving…' : '')
33
+ */
34
+ export function useIsMutating(filters?: MutationFilters): Signal<number> {
35
+ const client = useQueryClient()
36
+ const count = signal(client.isMutating(filters))
37
+
38
+ const unsub = client.getMutationCache().subscribe(() => {
39
+ count.set(client.isMutating(filters))
40
+ })
41
+ onUnmount(() => unsub())
42
+
43
+ return count
44
+ }
@@ -0,0 +1,117 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, batch } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import { MutationObserver } from '@tanstack/query-core'
5
+ import type {
6
+ DefaultError,
7
+ MutateFunction,
8
+ MutationObserverOptions,
9
+ MutationObserverResult,
10
+ } from '@tanstack/query-core'
11
+ import { useQueryClient } from './query-client'
12
+
13
+ export interface UseMutationResult<
14
+ TData,
15
+ TError = DefaultError,
16
+ TVariables = void,
17
+ TContext = unknown,
18
+ > {
19
+ /** Raw signal — full observer result. Fine-grained accessors below are preferred. */
20
+ result: Signal<MutationObserverResult<TData, TError, TVariables, TContext>>
21
+ data: Signal<TData | undefined>
22
+ error: Signal<TError | null>
23
+ status: Signal<'idle' | 'pending' | 'success' | 'error'>
24
+ isPending: Signal<boolean>
25
+ isSuccess: Signal<boolean>
26
+ isError: Signal<boolean>
27
+ isIdle: Signal<boolean>
28
+ /** Fire the mutation (fire-and-forget). Errors are captured in the error signal. */
29
+ mutate: (
30
+ variables: TVariables,
31
+ options?: Parameters<
32
+ MutateFunction<TData, TError, TVariables, TContext>
33
+ >[1],
34
+ ) => void
35
+ /** Like mutate but returns a promise — use for try/catch error handling. */
36
+ mutateAsync: MutateFunction<TData, TError, TVariables, TContext>
37
+ /** Reset the mutation state back to idle. */
38
+ reset: () => void
39
+ }
40
+
41
+ /**
42
+ * Run a mutation (create / update / delete). Returns reactive signals for
43
+ * pending / success / error state plus `mutate` and `mutateAsync` functions.
44
+ *
45
+ * @example
46
+ * const mutation = useMutation({
47
+ * mutationFn: (data: CreatePostInput) =>
48
+ * fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
49
+ * onSuccess: () => client.invalidateQueries({ queryKey: ['posts'] }),
50
+ * })
51
+ * // h('button', { onClick: () => mutation.mutate({ title: 'New' }) }, 'Create')
52
+ */
53
+ export function useMutation<
54
+ TData = unknown,
55
+ TError = DefaultError,
56
+ TVariables = void,
57
+ TContext = unknown,
58
+ >(
59
+ options: MutationObserverOptions<TData, TError, TVariables, TContext>,
60
+ ): UseMutationResult<TData, TError, TVariables, TContext> {
61
+ const client = useQueryClient()
62
+ const observer = new MutationObserver<TData, TError, TVariables, TContext>(
63
+ client,
64
+ options,
65
+ )
66
+ const initial = observer.getCurrentResult()
67
+
68
+ // Fine-grained signals: each field is independent so only effects that read
69
+ // e.g. `mutation.isPending()` re-run when isPending changes, not on every update.
70
+ const resultSig =
71
+ signal<MutationObserverResult<TData, TError, TVariables, TContext>>(initial)
72
+ const dataSig = signal<TData | undefined>(initial.data)
73
+ const errorSig = signal<TError | null>(initial.error ?? null)
74
+ const statusSig = signal<'idle' | 'pending' | 'success' | 'error'>(
75
+ initial.status,
76
+ )
77
+ const isPending = signal(initial.isPending)
78
+ const isSuccess = signal(initial.isSuccess)
79
+ const isError = signal(initial.isError)
80
+ const isIdle = signal(initial.isIdle)
81
+
82
+ // batch() coalesces all signal updates into one notification flush.
83
+ const unsub = observer.subscribe((r) => {
84
+ batch(() => {
85
+ resultSig.set(r)
86
+ dataSig.set(r.data)
87
+ errorSig.set(r.error ?? null)
88
+ statusSig.set(r.status)
89
+ isPending.set(r.isPending)
90
+ isSuccess.set(r.isSuccess)
91
+ isError.set(r.isError)
92
+ isIdle.set(r.isIdle)
93
+ })
94
+ })
95
+
96
+ onUnmount(() => unsub())
97
+
98
+ return {
99
+ result: resultSig,
100
+ data: dataSig,
101
+ error: errorSig,
102
+ status: statusSig,
103
+ isPending,
104
+ isSuccess,
105
+ isError,
106
+ isIdle,
107
+ mutate: (vars, callbackOptions) => {
108
+ observer.mutate(vars, callbackOptions).catch(() => {
109
+ // Error is already captured in the error signal via the observer subscription.
110
+ // This catch prevents an unhandled promise rejection for fire-and-forget callers.
111
+ })
112
+ },
113
+ mutateAsync: (vars, callbackOptions) =>
114
+ observer.mutate(vars, callbackOptions),
115
+ reset: () => observer.reset(),
116
+ }
117
+ }
@@ -0,0 +1,61 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, effect } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import { QueriesObserver } from '@tanstack/query-core'
5
+ import type {
6
+ DefaultError,
7
+ QueryKey,
8
+ QueryObserverOptions,
9
+ QueryObserverResult,
10
+ } from '@tanstack/query-core'
11
+ import { useQueryClient } from './query-client'
12
+
13
+ export type UseQueriesOptions<TQueryKey extends QueryKey = QueryKey> =
14
+ QueryObserverOptions<unknown, DefaultError, unknown, unknown, TQueryKey>
15
+
16
+ /**
17
+ * Subscribe to multiple queries in parallel. Returns a single signal containing
18
+ * the array of results — index-aligned with the `queries` array.
19
+ *
20
+ * `queries` is a reactive function so signal-based keys trigger re-evaluation
21
+ * automatically.
22
+ *
23
+ * @example
24
+ * const userIds = signal([1, 2, 3])
25
+ * const results = useQueries(() =>
26
+ * userIds().map(id => ({
27
+ * queryKey: ['user', id],
28
+ * queryFn: () => fetchUser(id),
29
+ * }))
30
+ * )
31
+ * // results() — QueryObserverResult[]
32
+ * // results()[0].data — first user
33
+ */
34
+ export function useQueries(
35
+ queries: () => UseQueriesOptions[],
36
+ ): Signal<QueryObserverResult[]> {
37
+ const client = useQueryClient()
38
+ const observer = new QueriesObserver(client, queries())
39
+
40
+ const resultSig = signal(
41
+ observer.getCurrentResult() as readonly QueryObserverResult[],
42
+ ) as Signal<QueryObserverResult[]>
43
+
44
+ const unsub = observer.subscribe(
45
+ (results: readonly QueryObserverResult[]) => {
46
+ resultSig.set(results as QueryObserverResult[])
47
+ },
48
+ )
49
+
50
+ // When signals inside queries() change, update the observer.
51
+ effect(() => {
52
+ observer.setQueries(queries())
53
+ })
54
+
55
+ onUnmount(() => {
56
+ unsub()
57
+ observer.destroy()
58
+ })
59
+
60
+ return resultSig
61
+ }
@@ -0,0 +1,95 @@
1
+ import {
2
+ createContext,
3
+ pushContext,
4
+ popContext,
5
+ onUnmount,
6
+ useContext,
7
+ } from '@pyreon/core'
8
+ import type { VNodeChild, VNode } from '@pyreon/core'
9
+ import type { Props } from '@pyreon/core'
10
+ import { useQueryClient } from './query-client'
11
+
12
+ // ─── Context ────────────────────────────────────────────────────────────────
13
+
14
+ interface ErrorResetBoundaryValue {
15
+ reset: () => void
16
+ }
17
+
18
+ const QueryErrorResetBoundaryContext =
19
+ createContext<ErrorResetBoundaryValue | null>(null)
20
+
21
+ // ─── QueryErrorResetBoundary ─────────────────────────────────────────────────
22
+
23
+ export interface QueryErrorResetBoundaryProps extends Props {
24
+ children?: VNodeChild
25
+ }
26
+
27
+ /**
28
+ * Wraps a subtree so that `useQueryErrorResetBoundary()` descendants can reset
29
+ * all errored queries within this boundary.
30
+ *
31
+ * Pair with Pyreon's `ErrorBoundary` to retry failed queries when the user
32
+ * dismisses the error fallback:
33
+ *
34
+ * @example
35
+ * h(QueryErrorResetBoundary, null,
36
+ * h(ErrorBoundary, {
37
+ * fallback: (err, boundaryReset) => {
38
+ * const { reset } = useQueryErrorResetBoundary()
39
+ * return h('button', {
40
+ * onClick: () => { reset(); boundaryReset() },
41
+ * }, 'Retry')
42
+ * },
43
+ * }, h(MyComponent, null)),
44
+ * )
45
+ */
46
+ export function QueryErrorResetBoundary(
47
+ props: QueryErrorResetBoundaryProps,
48
+ ): VNode {
49
+ const client = useQueryClient()
50
+
51
+ const value: ErrorResetBoundaryValue = {
52
+ reset: () => {
53
+ // Reset all active queries that are in error state so they refetch.
54
+ client.refetchQueries({
55
+ predicate: (query) => query.state.status === 'error',
56
+ })
57
+ },
58
+ }
59
+
60
+ const frame = new Map([[QueryErrorResetBoundaryContext.id, value]])
61
+ pushContext(frame)
62
+ onUnmount(() => popContext())
63
+
64
+ const ch = props.children
65
+ return (typeof ch === 'function' ? (ch as () => VNodeChild)() : ch) as VNode
66
+ }
67
+
68
+ // ─── useQueryErrorResetBoundary ──────────────────────────────────────────────
69
+
70
+ /**
71
+ * Returns the `reset` function provided by the nearest `QueryErrorResetBoundary`.
72
+ * If called outside a boundary, falls back to resetting all errored queries
73
+ * on the current `QueryClient`.
74
+ *
75
+ * @example
76
+ * // Inside an ErrorBoundary fallback:
77
+ * const { reset } = useQueryErrorResetBoundary()
78
+ * h('button', { onClick: () => { reset(); boundaryReset() } }, 'Retry')
79
+ */
80
+ export function useQueryErrorResetBoundary(): ErrorResetBoundaryValue {
81
+ const boundary = useContext(QueryErrorResetBoundaryContext)
82
+ // Always call useQueryClient to respect hook ordering rules
83
+ const client = useQueryClient()
84
+
85
+ if (boundary) return boundary
86
+
87
+ // Fallback: no explicit boundary — use the QueryClient directly.
88
+ return {
89
+ reset: () => {
90
+ client.refetchQueries({
91
+ predicate: (query) => query.state.status === 'error',
92
+ })
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,106 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, effect, batch } from '@pyreon/reactivity'
3
+ import type { Signal } from '@pyreon/reactivity'
4
+ import { QueryObserver } from '@tanstack/query-core'
5
+ import type {
6
+ DefaultError,
7
+ QueryKey,
8
+ QueryObserverOptions,
9
+ QueryObserverResult,
10
+ } from '@tanstack/query-core'
11
+ import { useQueryClient } from './query-client'
12
+
13
+ export interface UseQueryResult<TData, TError = DefaultError> {
14
+ /** Raw signal — the full observer result. Fine-grained accessors below are preferred. */
15
+ result: Signal<QueryObserverResult<TData, TError>>
16
+ data: Signal<TData | undefined>
17
+ error: Signal<TError | null>
18
+ status: Signal<'pending' | 'error' | 'success'>
19
+ isPending: Signal<boolean>
20
+ isLoading: Signal<boolean>
21
+ isFetching: Signal<boolean>
22
+ isError: Signal<boolean>
23
+ isSuccess: Signal<boolean>
24
+ /** Manually trigger a refetch. */
25
+ refetch: () => Promise<QueryObserverResult<TData, TError>>
26
+ }
27
+
28
+ /**
29
+ * Subscribe to a query. Returns fine-grained reactive signals for data,
30
+ * error and status — each signal only notifies effects that depend on it.
31
+ *
32
+ * `options` is a function so it can read Pyreon signals — when a signal changes
33
+ * (e.g. a reactive query key), the observer is updated and refetches automatically.
34
+ *
35
+ * @example
36
+ * const userId = signal(1)
37
+ * const query = useQuery(() => ({
38
+ * queryKey: ['user', userId()],
39
+ * queryFn: () => fetch(`/api/users/${userId()}`).then(r => r.json()),
40
+ * }))
41
+ * // In template: () => query.data()?.name
42
+ */
43
+ export function useQuery<
44
+ TData = unknown,
45
+ TError = DefaultError,
46
+ TKey extends QueryKey = QueryKey,
47
+ >(
48
+ options: () => QueryObserverOptions<TData, TError, TData, TData, TKey>,
49
+ ): UseQueryResult<TData, TError> {
50
+ const client = useQueryClient()
51
+ const observer = new QueryObserver<TData, TError, TData, TData, TKey>(
52
+ client,
53
+ options(),
54
+ )
55
+ const initial = observer.getCurrentResult()
56
+
57
+ // Fine-grained signals: each field is independent so only effects that read
58
+ // e.g. `query.data()` re-run when data changes, not when isFetching flips.
59
+ const resultSig = signal<QueryObserverResult<TData, TError>>(initial)
60
+ const dataSig = signal<TData | undefined>(initial.data)
61
+ const errorSig = signal<TError | null>(initial.error ?? null)
62
+ const statusSig = signal<'pending' | 'error' | 'success'>(initial.status)
63
+ const isPending = signal(initial.isPending)
64
+ const isLoading = signal(initial.isLoading)
65
+ const isFetching = signal(initial.isFetching)
66
+ const isError = signal(initial.isError)
67
+ const isSuccess = signal(initial.isSuccess)
68
+
69
+ // Subscribe synchronously — data flows before mount (correct for SSR pre-population).
70
+ // batch() coalesces all signal updates into one notification flush.
71
+ const unsub = observer.subscribe((r) => {
72
+ batch(() => {
73
+ resultSig.set(r)
74
+ dataSig.set(r.data)
75
+ errorSig.set(r.error ?? null)
76
+ statusSig.set(r.status)
77
+ isPending.set(r.isPending)
78
+ isLoading.set(r.isLoading)
79
+ isFetching.set(r.isFetching)
80
+ isError.set(r.isError)
81
+ isSuccess.set(r.isSuccess)
82
+ })
83
+ })
84
+
85
+ // Track reactive options: when signals inside options() change, update the observer.
86
+ // effect() is auto-registered in the component's EffectScope → auto-disposed on unmount.
87
+ effect(() => {
88
+ observer.setOptions(options())
89
+ })
90
+
91
+ // Unsubscribe the observer on unmount (effect disposal is handled by EffectScope).
92
+ onUnmount(() => unsub())
93
+
94
+ return {
95
+ result: resultSig,
96
+ data: dataSig,
97
+ error: errorSig,
98
+ status: statusSig,
99
+ isPending,
100
+ isLoading,
101
+ isFetching,
102
+ isError,
103
+ isSuccess,
104
+ refetch: () => observer.refetch(),
105
+ }
106
+ }