@robohall/react-query-factory 2.0.0 → 2.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.
package/README.md CHANGED
@@ -5,105 +5,270 @@
5
5
  ![gzipped](https://img.shields.io/badge/gzipped-%3C_3_kB-blue)
6
6
  [![license](https://img.shields.io/npm/l/@robohall/react-query-factory)](./LICENSE)
7
7
 
8
- A factory function for TanStack Query configs. Instead of calling `useQuery` with ad-hoc options, you define a factory once and call it anywhere — getting consistent cache keys, automatic pagination crawling, and `useInfiniteQuery` support for free. TanStack's API stays fully exposed.
8
+ <p align="center">
9
+ <a href="https://roberth26.github.io/react-query-factory/"><strong>Visit the Sandbox</strong></a>
10
+ </p>
9
11
 
10
- Zero runtime dependenciesall TanStack imports are type-only and erased at compile time.
12
+ TanStack Query handles caching, syncing, and invalidation. What it doesn't do is crawl paginated APIs for you. This library adds that a factory function that wraps your `queryFn` with a configurable crawl loop so `useQuery` can return accumulated results instead of a single page. The same factory produces `useInfiniteQuery` options, composes into child factories that share the cache, and exposes scope-aware invalidation keys. TanStack's API stays fully exposed at every call site.
13
+
14
+ Zero runtime dependencies.
11
15
 
12
16
  ---
13
17
 
14
- ## Installation
18
+ ## The problem
15
19
 
16
- ```bash
17
- npm install @robohall/react-query-factory
18
- # peer dependency: @tanstack/react-query >= 5.0.0
20
+ ### Step 1 — wrap `useQuery` in a custom hook
21
+
22
+ The first instinct when a query is reused across components:
23
+
24
+ ```typescript
25
+ function useInstances(params: DescribeInstancesCommandInput) {
26
+ return useQuery({
27
+ queryKey: ['instances', params],
28
+ queryFn: () => fetchInstances(params),
29
+ });
30
+ }
19
31
  ```
20
32
 
21
- ---
33
+ Works, but the key only exists inside the hook. Prefetching in a route loader, invalidating after a mutation, or fetching imperatively all require knowing `['instances', params]` without calling the hook.
22
34
 
23
- ## Quick start
35
+ ### Step 2 — `queryOptions` for colocation
24
36
 
25
- Define a factory once, call it in any component:
37
+ TanStack's `queryOptions` helper moves the key and fn into a shared object:
26
38
 
27
39
  ```typescript
28
- import {
29
- EC2Client,
30
- DescribeInstancesCommand,
31
- type DescribeInstancesCommandInput,
32
- } from '@aws-sdk/client-ec2';
33
- import { queryFactory } from '@robohall/react-query-factory';
34
- import { useQuery } from '@tanstack/react-query';
40
+ const instancesOptions = (params: DescribeInstancesCommandInput) =>
41
+ queryOptions({
42
+ queryKey: ['instances', params],
43
+ queryFn: () => fetchInstances(params),
44
+ });
45
+
46
+ useQuery(instancesOptions(params));
47
+ queryClient.prefetchQuery(instancesOptions(params));
48
+ queryClient.invalidateQueries(instancesOptions(params));
49
+ ```
35
50
 
36
- const ec2 = new EC2Client({ region: 'us-east-1' });
51
+ This is genuinely good this library builds on the same pattern. But once you need multiple related queries, the cracks show.
37
52
 
38
- const describeInstances = queryFactory({
39
- queryKey: ['ec2:DescribeInstances'],
40
- queryFn: (params: DescribeInstancesCommandInput, ctx) =>
41
- ec2.send(new DescribeInstancesCommand(params), { abortSignal: ctx.signal }),
53
+ ### Step 3 — derived queries and key coordination
54
+
55
+ Say you want a running-instances view that shares the same cache entry as the full list. The natural move is to spread the base options and override `select`:
56
+
57
+ ```typescript
58
+ const { data: running } = useQuery({
59
+ ...instancesOptions(params),
60
+ select: data => data.filter(i => i.state === 'running'),
42
61
  });
62
+ ```
43
63
 
44
- function InstanceList() {
45
- const { data } = useQuery(
46
- describeInstances({ Filters: [{ Name: 'instance-state-name', Values: ['running'] }] })
47
- );
48
- // query key: ['ec2:DescribeInstances', { Filters: [...] }]
49
- }
64
+ No key or `queryFn` duplication — this is the right approach. But `select` can only be applied at the call site, not captured in `instancesOptions` itself. And after a mutation you still need a magic string to bust the cache:
65
+
66
+ ```typescript
67
+ // If the key structure ever changes, every site breaks.
68
+ queryClient.invalidateQueries({ queryKey: ['instances'] });
50
69
  ```
51
70
 
52
- `describeInstances({ ... })` returns a plain object `{ queryKey, queryFn, staleTime, … }` — that you spread or pass directly to `useQuery`. The factory does not touch your query client.
71
+ ### Step 4paginated APIs
53
72
 
54
- ---
73
+ `DescribeInstances` returns at most `MaxResults` instances per call. To get them all, you need to loop. The usual options:
55
74
 
56
- ## Crawling
75
+ **Put the loop in `queryFn`:**
76
+
77
+ ```typescript
78
+ const instancesOptions = params =>
79
+ queryOptions({
80
+ queryKey: ['instances', params],
81
+ queryFn: async () => {
82
+ let all: Instance[] = [];
83
+ let nextToken: string | undefined;
84
+ do {
85
+ const page = await ec2.send(
86
+ new DescribeInstancesCommand({ ...params, NextToken: nextToken }),
87
+ );
88
+ all = [
89
+ ...all,
90
+ ...(page.Reservations?.flatMap(r => r.Instances ?? []) ?? []),
91
+ ];
92
+ nextToken = page.NextToken;
93
+ } while (nextToken);
94
+ return all;
95
+ },
96
+ });
97
+ ```
57
98
 
58
- `DescribeInstances` is paginated. If you have more than 20 instances, one call won't get them all. The standard approach chaining `fetchNextPage` calls, accumulating results, checking `NextToken` is correct but tedious to repeat everywhere.
99
+ The crawl logic is now baked in. Every call site gets all pages you can't stop at 50 for a dropdown while fetching all for a table. The loop gets copy-pasted into every paginated query.
59
100
 
60
- Add `getNextPageParam` and `shouldFetchNextPage` to activate crawling — those two are the only required pieces. `initialPageParam` types `ctx.pageParam` in your `queryFn` (without it and without a `getNextPageParam` that provides inference, `ctx.pageParam` is `never`). `reduce` folds crawled pages into a single value; without it the result is an array of all fetched raw pages (`TData[]`). **`shouldFetchNextPage`** is called after each page — return `true` to keep fetching, `false` to stop. Use `() => true` to walk every page:
101
+ **Use `useInfiniteQuery`:**
61
102
 
62
103
  ```typescript
63
- import type { Instance, DescribeInstancesCommandInput } from '@aws-sdk/client-ec2';
104
+ const instancesInfiniteOptions = params =>
105
+ infiniteQueryOptions({
106
+ queryKey: ['instances', 'infinite', params],
107
+ queryFn: ({ pageParam }) =>
108
+ ec2.send(
109
+ new DescribeInstancesCommand({ ...params, NextToken: pageParam }),
110
+ ),
111
+ getNextPageParam: r => r.NextToken,
112
+ initialPageParam: undefined,
113
+ });
114
+
115
+ // Caller still has to flatten, auto-advance, manage hasNextPage...
116
+ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
117
+ instancesInfiniteOptions(params),
118
+ );
119
+ const allInstances = data?.pages.flatMap(
120
+ page => page.Reservations?.flatMap(r => r.Instances ?? []) ?? [],
121
+ );
122
+ ```
123
+
124
+ Now you have two separate factories that duplicate the key and queryFn and need to stay in sync. `useQuery` and `useInfiniteQuery` are separate cache entries. Derived queries, invalidation, and prefetching all have to be wired up independently for each.
125
+
126
+ ### What's missing
127
+
128
+ - Define the query **once**: key, queryFn, pagination config
129
+ - Let each **call site** decide how much to crawl (e.g. 50 records or all of them)
130
+ - Optionally have `useQuery` crawl and return the **accumulated result** instead of a single page
131
+ - Use **async iterables** as `queryFn` — pass a paginator function directly, no cursor wiring required
132
+ - Have `.infinite()` available on the **same factory**, no duplication
133
+ - Have derived queries **share the cache entry** automatically
134
+ - Have **scoped invalidation** through key composition — bust the whole namespace or just one param set and its children
135
+
136
+ ---
137
+
138
+ ## The solution
139
+
140
+ ```typescript
141
+ import { queryFactory } from '@robohall/react-query-factory';
64
142
 
65
143
  const describeInstances = queryFactory({
66
144
  queryKey: ['ec2:DescribeInstances'],
67
145
  queryFn: (params: DescribeInstancesCommandInput, ctx) =>
68
146
  ec2.send(
69
147
  new DescribeInstancesCommand({ ...params, NextToken: ctx.pageParam }),
70
- { abortSignal: ctx.signal },
148
+ {
149
+ abortSignal: ctx.signal,
150
+ },
71
151
  ),
72
- getNextPageParam: response => response.NextToken,
152
+ getNextPageParam: r => r.NextToken,
73
153
  initialPageParam: undefined as string | undefined,
74
- shouldFetchNextPage: () => true,
75
154
  reduce: (acc, page): Instance[] => [
76
155
  ...(acc ?? []),
77
156
  ...(page.Reservations?.flatMap(r => r.Instances ?? []) ?? []),
78
157
  ],
158
+ shouldFetchNextPage: (instances, opts: { minResults?: number }) =>
159
+ opts.minResults == null || instances.length < opts.minResults,
79
160
  });
80
161
 
81
- function InstanceList() {
82
- // one useQuery call; data is Instance[], not DescribeInstancesResponse[]
83
- const { data } = useQuery(describeInstances({ MaxResults: 20 }));
84
- }
162
+ // useQuery — crawls all pages, data is Instance[]
163
+ const { data } = useQuery(describeInstances({ MaxResults: 20 }));
164
+
165
+ // Stop at 50 — separate cache entry, independent crawl
166
+ const { data } = useQuery(
167
+ describeInstances({ MaxResults: 20 }, { minResults: 50 }),
168
+ );
169
+
170
+ // UI-driven pagination — same factory, no duplication
171
+ const { data, fetchNextPage } = useInfiniteQuery(
172
+ describeInstances.infinite({ MaxResults: 20 }, { minResults: 50 }),
173
+ );
174
+
175
+ // Derived view — shares the cache entry, no extra API call
176
+ const runningInstances = queryFactory(describeInstances, {
177
+ select: instances => instances.filter(i => i.State?.Name === 'running'),
178
+ });
179
+ const { data: running } = useQuery(runningInstances({ MaxResults: 20 }));
180
+
181
+ // Prefetch in a route loader
182
+ await queryClient.prefetchQuery(describeInstances({ MaxResults: 20 }));
183
+
184
+ // Bust everything in the namespace
185
+ queryClient.invalidateQueries(describeInstances());
186
+
187
+ // Bust only this param set — cascades to runningInstances and any other child
188
+ queryClient.invalidateQueries(describeInstances({ MaxResults: 20 }));
189
+ ```
190
+
191
+ `describeInstances({ ... })` returns a plain `{ queryKey, queryFn, ... }` object — pass it directly to `useQuery`, `useInfiniteQuery`, `prefetchQuery`, or `getQueryData`. The factory doesn't touch your query client.
192
+
193
+ ---
194
+
195
+ ## Installation
196
+
197
+ ```bash
198
+ npm install @robohall/react-query-factory
199
+ # peer dependency: @tanstack/react-query >= 5.0.0
85
200
  ```
86
201
 
87
- `shouldFetchNextPage` also accepts a `crawlOptions` object passed at call time, letting each call site control the crawl independently:
202
+ ---
203
+
204
+ ## Crawling
205
+
206
+ `shouldFetchNextPage` is called after each page — return `true` to keep fetching, `false` to stop. `getNextPageParam` and `initialPageParam` follow the exact TanStack API. `reduce` folds pages into a single accumulated value; without it the result is an array of raw pages (`TData[]`).
207
+
208
+ The `crawlOptions` argument passed at call time is forwarded to `shouldFetchNextPage` and appended to the query key, so different call sites crawl independently and never share a cache entry:
88
209
 
89
210
  ```typescript
90
211
  const describeInstances = queryFactory({
91
212
  // ...
92
- reduce: (acc, page): Instance[] => [...(acc ?? []), ...page.Reservations.flatMap(r => r.Instances)],
93
213
  shouldFetchNextPage: (instances, opts: { minResults?: number }) =>
94
214
  opts.minResults == null || instances.length < opts.minResults,
95
215
  });
96
216
 
97
- // fetch all pages
217
+ // two separate cache entries — crawl independently
98
218
  const { data: all } = useQuery(describeInstances({ MaxResults: 20 }));
99
-
100
- // stop after accumulating at least 50 instances (≥ 3 API calls)
101
219
  const { data: partial } = useQuery(
102
- describeInstances({ MaxResults: 20 }, { minResults: 50 })
220
+ describeInstances({ MaxResults: 20 }, { minResults: 50 }),
103
221
  );
104
222
  ```
105
223
 
106
- `crawlOptions` is appended to the query key, so `describeInstances({}, { minResults: 50 })` and `describeInstances({}, { minResults: 200 })` are separate cache entries — they crawl independently and never collide.
224
+ ---
225
+
226
+ ## Async iterator queryFns
227
+
228
+ When `queryFn` returns an `AsyncIterable`, the library detects it automatically and drives the crawl with `for await...of` instead of the cursor loop. This means `getNextPageParam` and `initialPageParam` are not required — the iterator manages its own cursor. AWS SDK v3 paginator functions (`paginateDescribeInstances`, etc.) are a common source of async iterables:
229
+
230
+ ```typescript
231
+ import { paginateDescribeInstances } from '@aws-sdk/client-ec2';
232
+
233
+ const describeInstances = queryFactory({
234
+ queryKey: ['ec2:DescribeInstances'],
235
+ queryFn: (params: DescribeInstancesCommandInput) =>
236
+ paginateDescribeInstances({ client: ec2 }, params),
237
+ shouldFetchNextPage: (instances, opts: { minResults?: number }) =>
238
+ opts.minResults == null || instances.length < opts.minResults,
239
+ reduce: (acc, page: DescribeInstancesResponse): Instance[] => [
240
+ ...(acc ?? []),
241
+ ...(page.Reservations?.flatMap(r => r.Instances ?? []) ?? []),
242
+ ],
243
+ });
244
+ ```
245
+
246
+ `shouldFetchNextPage` still controls early stopping — the library calls `next()` on the iterator only when it returns `true`. Any source of `AsyncIterable<TPage>` works, not just AWS SDK paginators.
247
+
248
+ For `.infinite()` mode, `getNextPageParam` is required to capture the next virtual page's starting cursor from the last yielded item. AWS SDK v3 paginators accept a `startingToken` to resume from a specific position — wire `ctx.pageParam` to it:
249
+
250
+ ```typescript
251
+ const describeInstances = queryFactory({
252
+ queryKey: ['ec2:DescribeInstances'],
253
+ queryFn: (params: DescribeInstancesCommandInput, ctx) =>
254
+ paginateDescribeInstances(
255
+ { client: ec2 },
256
+ { ...params, StartingToken: ctx.pageParam },
257
+ ),
258
+ getNextPageParam: page => page.NextToken,
259
+ initialPageParam: undefined as string | undefined,
260
+ shouldFetchNextPage: (instances, opts: { minResults?: number }) =>
261
+ opts.minResults == null || instances.length < opts.minResults,
262
+ reduce: (acc, page): Instance[] => [
263
+ ...(acc ?? []),
264
+ ...(page.Instances ?? []),
265
+ ],
266
+ });
267
+
268
+ const { data, fetchNextPage } = useInfiniteQuery(
269
+ describeInstances.infinite({ MaxResults: 20 }, { minResults: 50 }),
270
+ );
271
+ ```
107
272
 
108
273
  ---
109
274
 
@@ -139,7 +304,7 @@ const findInstance = queryFactory(describeInstances, {
139
304
  // query key: ['ec2:DescribeInstances', { MaxResults: 20 }, 'find', { instanceId: 'i-0abc123def456' }]
140
305
  // crawls pages until the target instance appears, then stops
141
306
  const { data } = useQuery(
142
- findInstance({ MaxResults: 20 }, { instanceId: 'i-0abc123def456' })
307
+ findInstance({ MaxResults: 20 }, { instanceId: 'i-0abc123def456' }),
143
308
  );
144
309
  ```
145
310
 
@@ -180,18 +345,17 @@ Every factory exposes a `.infinite()` method that returns `useInfiniteQuery`-com
180
345
  ```typescript
181
346
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
182
347
  // load 50 instances per UI page, each backed by up to 5 DescribeInstances calls
183
- describeInstances.infinite({ MaxResults: 20 }, { minResults: 50 })
348
+ describeInstances.infinite({ MaxResults: 20 }, { minResults: 50 }),
184
349
  );
185
350
 
186
351
  // data.pages is Instance[][], one array per virtual page
187
352
  ```
188
353
 
189
354
  The `.infinite()` key includes an `'infinite'` segment to keep it separate from the regular `useQuery` cache entry:
355
+
190
356
  - `describeInstances({ MaxResults: 20 })` → `['ec2:DescribeInstances', { MaxResults: 20 }]`
191
357
  - `describeInstances.infinite({ MaxResults: 20 })` → `['ec2:DescribeInstances', 'infinite', { MaxResults: 20 }]`
192
358
 
193
- Child factories place `params` before their own key segments so that the parent key is always a prefix of the child key for the same params — enabling per-call-site scoped invalidation.
194
-
195
359
  ---
196
360
 
197
361
  ## Public API
@@ -209,6 +373,7 @@ queryFactory<TParams, TData, TError, TSelected, TPageParam, TCrawlOptions>(
209
373
  ### `queryFactory(parent, config)`
210
374
 
211
375
  Creates a child factory. Two overloads:
376
+
212
377
  - **With a new `queryFn`** — inherits key namespace and standard options; crawling config must be re-declared if needed.
213
378
  - **Without a `queryFn`** — inherits everything; accepts `queryKey`, `select`, standard options, and any crawling fields (`shouldFetchNextPage`, `reduce`, `getNextPageParam`, `getPreviousPageParam`, `initialPageParam`) to override the parent's. `select` is composed with the parent's.
214
379
 
@@ -216,17 +381,17 @@ Creates a child factory. Two overloads:
216
381
 
217
382
  All fields except `reduce` and `shouldFetchNextPage` are the standard TanStack Query API — the same types and semantics you'd pass to `useQuery` or `useInfiniteQuery`. The factory doesn't reinvent them; it just requires certain combinations to be present in order to activate crawling.
218
383
 
219
- | Field | Type | Notes |
220
- |---|---|---|
221
- | `queryKey` | `QueryKey` | Namespace segments. Params are appended at call time. |
222
- | `queryFn` | `(params: TParams, ctx: QueryFunctionContext) => TData \| Promise<TData>` | Same as TanStack, with an extra leading `params` argument. |
223
- | `select` | `(data: TData) => TSelected` | Exact TanStack API. Composed automatically on child factories. |
224
- | `getNextPageParam` | `GetNextPageParamFunction<TPageParam, TData>` | Exact TanStack API. Required (with `shouldFetchNextPage`) to activate crawling. Required (with `initialPageParam`) for `.infinite()`. |
225
- | `initialPageParam` | `TPageParam` | Exact TanStack API. Drives `TPageParam` inference — without it and without a `getNextPageParam` that provides inference, `ctx.pageParam` is typed `never`. Required for `.infinite()` to work at runtime. |
226
- | `getPreviousPageParam` | `GetPreviousPageParamFunction<TPageParam, TData>` | Exact TanStack API. Passed through on `.infinite()`. |
227
- | `shouldFetchNextPage` | `(combined: TSelected \| undefined, crawlOptions: TCrawlOptions) => boolean` | Library addition. **Required (with `getNextPageParam`) to activate crawling.** Called after each page — return `true` to keep fetching, `false` to stop. |
228
- | `reduce` | `(acc: TSelected \| undefined, page: TData) => TSelected` | Library addition. Optional. Folds crawled pages into a single `TSelected` value; when omitted the result is an array of all fetched raw pages (`TData[]`). |
229
- | + all `StandardQueryOptions` fields | | All options accepted by TanStack's `useQuery` / `useInfiniteQuery` except `queryKey`, `queryFn`, and `select` (which the factory owns). Includes `staleTime`, `gcTime`, `retry`, `retryOnMount`, `enabled`, `refetchOnWindowFocus`, `refetchOnReconnect`, `refetchOnMount`, `refetchInterval`, `refetchIntervalInBackground`, `networkMode`, `notifyOnChangeProps`, `throwOnError`, `structuralSharing`, `initialData`, `initialDataUpdatedAt`, `placeholderData`, `queryKeyHashFn`, `persister`, `meta`, `maxPages`, `experimental_prefetchInRender`. Function-form callbacks (e.g. `enabled: (query) => boolean`) are supported wherever TanStack accepts them. |
384
+ | Field | Type | Notes |
385
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
386
+ | `queryKey` | `QueryKey` | Namespace segments. Params are appended at call time. |
387
+ | `queryFn` | `(params: TParams, ctx: QueryFunctionContext) => TData \| Promise<TData> \| AsyncIterable<TData>` | Same as TanStack, with an extra leading `params` argument. Returns an `AsyncIterable` to use iterator-based crawling. |
388
+ | `select` | `(data: TData) => TSelected` | Exact TanStack API. Composed automatically on child factories. |
389
+ | `getNextPageParam` | `GetNextPageParamFunction<TPageParam, TData>` | Exact TanStack API. Required (with `shouldFetchNextPage`) to activate cursor-based crawling. Required (with `initialPageParam`) for `.infinite()`. |
390
+ | `initialPageParam` | `TPageParam` | Exact TanStack API. Drives `TPageParam` inference. Required for `.infinite()` to work at runtime. |
391
+ | `getPreviousPageParam` | `GetPreviousPageParamFunction<TPageParam, TData>` | Exact TanStack API. Passed through on `.infinite()`. |
392
+ | `shouldFetchNextPage` | `(combined: TSelected \| undefined, crawlOptions: TCrawlOptions) => boolean` | Library addition. **Required to activate crawling.** Called after each page — return `true` to keep fetching, `false` to stop. |
393
+ | `reduce` | `(acc: TSelected \| undefined, page: TData) => TSelected` | Library addition. Optional. Folds crawled pages into a single `TSelected` value; when omitted the result is an array of all fetched raw pages (`TData[]`). |
394
+ | + all `StandardQueryOptions` fields | | `staleTime`, `gcTime`, `retry`, `enabled`, `refetchOnWindowFocus`, `placeholderData`, `initialData`, `meta`, etc. Function-form callbacks are supported wherever TanStack accepts them. |
230
395
 
231
396
  ### `QueryFactory<TParams, TData, TError, TSelected, TPageParam, TCrawlOptions>`
232
397
 
@@ -249,10 +414,8 @@ Return type of `factory.infinite(params)`. Pass directly to `useInfiniteQuery()`
249
414
 
250
415
  ## Running the sandbox
251
416
 
252
- The sandbox contains six interactive demos using a mock paginated API: basic single-page fetch, full crawl, factory composition, infinite query with per-page crawling, early-stop target search, and namespace-based cache invalidation.
253
-
254
417
  ```bash
255
418
  npm run sandbox
256
419
  ```
257
420
 
258
- This starts a Vite dev server. Navigate to the URL it prints (typically `http://localhost:5173`).
421
+ Starts a Vite dev server with interactive demos covering every pattern: basic single-page fetch, async iterator queryFns, crawl-then-render, render-while-crawling, on-demand infinite pagination, client-side search with early stopping, factory composition, and scoped cache invalidation.
package/dist/index.d.mts CHANGED
@@ -156,5 +156,39 @@ declare function queryFactory<TChildParams extends TParentParams, TData = unknow
156
156
  reduce?: (accumulator: TChildSelected | undefined, page: TData) => TChildSelected;
157
157
  shouldFetchNextPage?: (combined: TParentHasReduce extends true ? TChildSelected : TChildSelected | undefined, crawlOptions: TChildCrawlOptions) => boolean;
158
158
  }): QueryFactory<TChildParams, TData, TError, TChildSelected, TPageParam, TChildCrawlOptions, TParentHasReduce>;
159
+ /**
160
+ * Creates a standalone factory whose queryFn returns an AsyncIterable (e.g. an AWS SDK v3
161
+ * paginator). The library drives the crawl with `for await...of`; `getNextPageParam` and
162
+ * `initialPageParam` are not required for `useQuery` mode. When `reduce` is present,
163
+ * `shouldFetchNextPage` receives `TSelected` (never undefined).
164
+ */
165
+ declare function queryFactory<TParams = void, TData = unknown, TError = Error, TSelected = TData, TPageParam = unknown, TCrawlOptions extends Record<string, unknown> = Record<string, unknown>>(config: StandardQueryOptions<TError, TData> & {
166
+ queryKey: QueryKey;
167
+ queryFn: (params: TParams, context: QueryFunctionContext<QueryKey, [
168
+ unknown
169
+ ] extends [TPageParam] ? never : TPageParam>) => AsyncIterable<TData>;
170
+ select?: (data: TData) => TSelected;
171
+ getNextPageParam?: GetNextPageParamFunction<TPageParam, TData>;
172
+ initialPageParam?: TPageParam;
173
+ getPreviousPageParam?: GetPreviousPageParamFunction<TPageParam, TData>;
174
+ reduce: (accumulator: TSelected | undefined, page: TData) => TSelected;
175
+ shouldFetchNextPage?: (combined: TSelected, crawlOptions: TCrawlOptions) => boolean;
176
+ }): QueryFactory<TParams, TData, TError, TSelected, TPageParam, TCrawlOptions, true>;
177
+ /**
178
+ * Creates a standalone factory whose queryFn returns an AsyncIterable, without a `reduce`
179
+ * function. Result is `TData[]` (one element per yielded page).
180
+ */
181
+ declare function queryFactory<TParams = void, TData = unknown, TError = Error, TSelected = TData, TPageParam = unknown, TCrawlOptions extends Record<string, unknown> = Record<string, unknown>>(config: StandardQueryOptions<TError, TData> & {
182
+ queryKey: QueryKey;
183
+ queryFn: (params: TParams, context: QueryFunctionContext<QueryKey, [
184
+ unknown
185
+ ] extends [TPageParam] ? never : TPageParam>) => AsyncIterable<TData>;
186
+ select?: (data: TData) => TSelected;
187
+ getNextPageParam?: GetNextPageParamFunction<TPageParam, TData>;
188
+ initialPageParam?: TPageParam;
189
+ getPreviousPageParam?: GetPreviousPageParamFunction<TPageParam, TData>;
190
+ reduce?: never;
191
+ shouldFetchNextPage: (combined: TSelected | undefined, crawlOptions: TCrawlOptions) => boolean;
192
+ }): QueryFactory<TParams, TData, TError, TSelected, TPageParam, TCrawlOptions, false>;
159
193
 
160
194
  export { type QueryFactory, type QueryFactoryConfig, type ResolvedInfiniteOptions, type ResolvedQueryOptions, type StandardQueryOptions, queryFactory };
package/dist/index.d.ts CHANGED
@@ -156,5 +156,39 @@ declare function queryFactory<TChildParams extends TParentParams, TData = unknow
156
156
  reduce?: (accumulator: TChildSelected | undefined, page: TData) => TChildSelected;
157
157
  shouldFetchNextPage?: (combined: TParentHasReduce extends true ? TChildSelected : TChildSelected | undefined, crawlOptions: TChildCrawlOptions) => boolean;
158
158
  }): QueryFactory<TChildParams, TData, TError, TChildSelected, TPageParam, TChildCrawlOptions, TParentHasReduce>;
159
+ /**
160
+ * Creates a standalone factory whose queryFn returns an AsyncIterable (e.g. an AWS SDK v3
161
+ * paginator). The library drives the crawl with `for await...of`; `getNextPageParam` and
162
+ * `initialPageParam` are not required for `useQuery` mode. When `reduce` is present,
163
+ * `shouldFetchNextPage` receives `TSelected` (never undefined).
164
+ */
165
+ declare function queryFactory<TParams = void, TData = unknown, TError = Error, TSelected = TData, TPageParam = unknown, TCrawlOptions extends Record<string, unknown> = Record<string, unknown>>(config: StandardQueryOptions<TError, TData> & {
166
+ queryKey: QueryKey;
167
+ queryFn: (params: TParams, context: QueryFunctionContext<QueryKey, [
168
+ unknown
169
+ ] extends [TPageParam] ? never : TPageParam>) => AsyncIterable<TData>;
170
+ select?: (data: TData) => TSelected;
171
+ getNextPageParam?: GetNextPageParamFunction<TPageParam, TData>;
172
+ initialPageParam?: TPageParam;
173
+ getPreviousPageParam?: GetPreviousPageParamFunction<TPageParam, TData>;
174
+ reduce: (accumulator: TSelected | undefined, page: TData) => TSelected;
175
+ shouldFetchNextPage?: (combined: TSelected, crawlOptions: TCrawlOptions) => boolean;
176
+ }): QueryFactory<TParams, TData, TError, TSelected, TPageParam, TCrawlOptions, true>;
177
+ /**
178
+ * Creates a standalone factory whose queryFn returns an AsyncIterable, without a `reduce`
179
+ * function. Result is `TData[]` (one element per yielded page).
180
+ */
181
+ declare function queryFactory<TParams = void, TData = unknown, TError = Error, TSelected = TData, TPageParam = unknown, TCrawlOptions extends Record<string, unknown> = Record<string, unknown>>(config: StandardQueryOptions<TError, TData> & {
182
+ queryKey: QueryKey;
183
+ queryFn: (params: TParams, context: QueryFunctionContext<QueryKey, [
184
+ unknown
185
+ ] extends [TPageParam] ? never : TPageParam>) => AsyncIterable<TData>;
186
+ select?: (data: TData) => TSelected;
187
+ getNextPageParam?: GetNextPageParamFunction<TPageParam, TData>;
188
+ initialPageParam?: TPageParam;
189
+ getPreviousPageParam?: GetPreviousPageParamFunction<TPageParam, TData>;
190
+ reduce?: never;
191
+ shouldFetchNextPage: (combined: TSelected | undefined, crawlOptions: TCrawlOptions) => boolean;
192
+ }): QueryFactory<TParams, TData, TError, TSelected, TPageParam, TCrawlOptions, false>;
159
193
 
160
194
  export { type QueryFactory, type QueryFactoryConfig, type ResolvedInfiniteOptions, type ResolvedQueryOptions, type StandardQueryOptions, queryFactory };
package/dist/index.js CHANGED
@@ -28,6 +28,9 @@ module.exports = __toCommonJS(index_exports);
28
28
  var FACTORY_CONFIG = /* @__PURE__ */ Symbol("factoryConfig");
29
29
  var getEnvelopeNextPageParam = (envelope) => envelope.nextPageParam;
30
30
  var noNextPage = () => void 0;
31
+ function isAsyncIterable(value) {
32
+ return value != null && typeof value[Symbol.asyncIterator] === "function";
33
+ }
31
34
  function resolveKey(namespace, params, crawlOptions) {
32
35
  const withParams = params === void 0 ? namespace : [...namespace, params];
33
36
  if (!crawlOptions) return withParams;
@@ -55,31 +58,53 @@ function buildChildKey(parentKey, ownSegments, params, crawlOptions, infinite) {
55
58
  function wrapGetNextPageParam(getNextPageParam, shouldFetchNextPage, crawlOptions, select) {
56
59
  return (lastPage, allPages, lastPageParam, allPageParams) => {
57
60
  const combined = select ? select(lastPage) : lastPage;
58
- if (!shouldFetchNextPage(combined, crawlOptions))
59
- return void 0;
61
+ if (!shouldFetchNextPage(combined, crawlOptions)) return void 0;
60
62
  return getNextPageParam(lastPage, allPages, lastPageParam, allPageParams);
61
63
  };
62
64
  }
63
65
  function buildCrawlingQueryFn(queryFn, getNextPageParam, initialPageParam, shouldFetchNextPage, reduce) {
64
66
  return async (params, crawlOptions, context) => {
65
- var _a, _b;
67
+ var _a, _b, _c, _d;
68
+ if ((_a = context.signal) == null ? void 0 : _a.aborted) {
69
+ if (reduce) throw new DOMException("Aborted", "AbortError");
70
+ return [];
71
+ }
72
+ const ctx = { ...context, pageParam: initialPageParam };
73
+ const initialResult = queryFn(params, ctx);
74
+ if (isAsyncIterable(initialResult)) {
75
+ const pages2 = [];
76
+ let acc2 = void 0;
77
+ for await (const page of initialResult) {
78
+ if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
79
+ pages2.push(page);
80
+ if (reduce) acc2 = reduce(acc2, page);
81
+ if (!shouldFetchNextPage(acc2, crawlOptions)) break;
82
+ }
83
+ if (reduce) {
84
+ if (acc2 === void 0) throw new DOMException("Aborted", "AbortError");
85
+ return acc2;
86
+ }
87
+ return pages2;
88
+ }
66
89
  const pages = [];
67
90
  const pageParams = [];
68
91
  let currentParam = initialPageParam;
69
92
  let acc = void 0;
70
- const ctx = { ...context, pageParam: currentParam };
93
+ let nextResult = initialResult;
71
94
  while (true) {
72
- if ((_a = context.signal) == null ? void 0 : _a.aborted) break;
73
- ctx.pageParam = currentParam;
74
- const page = await queryFn(params, ctx);
95
+ const page = await nextResult;
75
96
  pages.push(page);
76
97
  pageParams.push(currentParam);
77
98
  if (reduce) acc = reduce(acc, page);
78
- if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
99
+ if ((_c = context.signal) == null ? void 0 : _c.aborted) break;
79
100
  if (!shouldFetchNextPage(acc, crawlOptions)) break;
101
+ if (!getNextPageParam) break;
80
102
  const nextParam = getNextPageParam(page, pages, currentParam, pageParams);
81
103
  if (nextParam == null) break;
82
104
  currentParam = nextParam;
105
+ if ((_d = context.signal) == null ? void 0 : _d.aborted) break;
106
+ ctx.pageParam = currentParam;
107
+ nextResult = queryFn(params, ctx);
83
108
  }
84
109
  if (reduce) {
85
110
  if (acc === void 0) throw new DOMException("Aborted", "AbortError");
@@ -90,26 +115,47 @@ function buildCrawlingQueryFn(queryFn, getNextPageParam, initialPageParam, shoul
90
115
  }
91
116
  function buildInfiniteCrawlingQueryFn(queryFn, getNextPageParam, shouldFetchNextPage, reduce) {
92
117
  return async (params, crawlOptions, context) => {
93
- var _a, _b;
118
+ var _a, _b, _c, _d;
119
+ if ((_a = context.signal) == null ? void 0 : _a.aborted)
120
+ throw new DOMException("Aborted", "AbortError");
121
+ const ctx = { ...context, pageParam: context.pageParam };
122
+ const initialResult = queryFn(params, ctx);
123
+ if (isAsyncIterable(initialResult)) {
124
+ const pages2 = [];
125
+ let acc2 = void 0;
126
+ let nextBatchParam2 = null;
127
+ for await (const page of initialResult) {
128
+ if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
129
+ pages2.push(page);
130
+ acc2 = reduce(acc2, page);
131
+ const nextParam = getNextPageParam(page, pages2, void 0, []);
132
+ nextBatchParam2 = nextParam != null ? nextParam : null;
133
+ if (nextParam == null) break;
134
+ if (!shouldFetchNextPage(acc2, crawlOptions)) break;
135
+ }
136
+ if (acc2 === void 0) throw new DOMException("Aborted", "AbortError");
137
+ return { data: acc2, nextPageParam: nextBatchParam2 };
138
+ }
94
139
  const pages = [];
95
140
  const pageParams = [];
96
141
  let currentParam = context.pageParam;
97
142
  let acc = void 0;
98
143
  let nextBatchParam = null;
99
- const ctx = { ...context, pageParam: currentParam };
144
+ let nextResult = initialResult;
100
145
  while (true) {
101
- if ((_a = context.signal) == null ? void 0 : _a.aborted) break;
102
- ctx.pageParam = currentParam;
103
- const page = await queryFn(params, ctx);
146
+ const page = await nextResult;
104
147
  pages.push(page);
105
148
  pageParams.push(currentParam);
106
149
  acc = reduce(acc, page);
107
- if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
150
+ if ((_c = context.signal) == null ? void 0 : _c.aborted) break;
108
151
  const nextParam = getNextPageParam(page, pages, currentParam, pageParams);
109
152
  nextBatchParam = nextParam != null ? nextParam : null;
110
153
  if (nextParam == null) break;
111
154
  if (!shouldFetchNextPage(acc, crawlOptions)) break;
112
155
  currentParam = nextParam;
156
+ if ((_d = context.signal) == null ? void 0 : _d.aborted) break;
157
+ ctx.pageParam = currentParam;
158
+ nextResult = queryFn(params, ctx);
113
159
  }
114
160
  if (acc === void 0) throw new DOMException("Aborted", "AbortError");
115
161
  return { data: acc, nextPageParam: nextBatchParam };
@@ -130,8 +176,8 @@ function buildFactory(cfg) {
130
176
  } = cfg;
131
177
  const ownSegments = parentKey !== void 0 ? namespace.slice(parentKey.length) : namespace;
132
178
  const infiniteNamespace = [...namespace, "infinite"];
133
- const hasCrawling = rawQueryFn !== void 0 && getNextPageParam !== void 0 && shouldFetchNextPage !== void 0;
134
- const hasInfiniteCrawling = hasCrawling && reduce !== void 0;
179
+ const hasCrawling = rawQueryFn !== void 0 && shouldFetchNextPage !== void 0;
180
+ const hasInfiniteCrawling = hasCrawling && reduce !== void 0 && getNextPageParam !== void 0;
135
181
  const crawlingFn = hasCrawling ? buildCrawlingQueryFn(
136
182
  rawQueryFn,
137
183
  getNextPageParam,
package/dist/index.mjs CHANGED
@@ -2,6 +2,9 @@
2
2
  var FACTORY_CONFIG = /* @__PURE__ */ Symbol("factoryConfig");
3
3
  var getEnvelopeNextPageParam = (envelope) => envelope.nextPageParam;
4
4
  var noNextPage = () => void 0;
5
+ function isAsyncIterable(value) {
6
+ return value != null && typeof value[Symbol.asyncIterator] === "function";
7
+ }
5
8
  function resolveKey(namespace, params, crawlOptions) {
6
9
  const withParams = params === void 0 ? namespace : [...namespace, params];
7
10
  if (!crawlOptions) return withParams;
@@ -29,31 +32,53 @@ function buildChildKey(parentKey, ownSegments, params, crawlOptions, infinite) {
29
32
  function wrapGetNextPageParam(getNextPageParam, shouldFetchNextPage, crawlOptions, select) {
30
33
  return (lastPage, allPages, lastPageParam, allPageParams) => {
31
34
  const combined = select ? select(lastPage) : lastPage;
32
- if (!shouldFetchNextPage(combined, crawlOptions))
33
- return void 0;
35
+ if (!shouldFetchNextPage(combined, crawlOptions)) return void 0;
34
36
  return getNextPageParam(lastPage, allPages, lastPageParam, allPageParams);
35
37
  };
36
38
  }
37
39
  function buildCrawlingQueryFn(queryFn, getNextPageParam, initialPageParam, shouldFetchNextPage, reduce) {
38
40
  return async (params, crawlOptions, context) => {
39
- var _a, _b;
41
+ var _a, _b, _c, _d;
42
+ if ((_a = context.signal) == null ? void 0 : _a.aborted) {
43
+ if (reduce) throw new DOMException("Aborted", "AbortError");
44
+ return [];
45
+ }
46
+ const ctx = { ...context, pageParam: initialPageParam };
47
+ const initialResult = queryFn(params, ctx);
48
+ if (isAsyncIterable(initialResult)) {
49
+ const pages2 = [];
50
+ let acc2 = void 0;
51
+ for await (const page of initialResult) {
52
+ if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
53
+ pages2.push(page);
54
+ if (reduce) acc2 = reduce(acc2, page);
55
+ if (!shouldFetchNextPage(acc2, crawlOptions)) break;
56
+ }
57
+ if (reduce) {
58
+ if (acc2 === void 0) throw new DOMException("Aborted", "AbortError");
59
+ return acc2;
60
+ }
61
+ return pages2;
62
+ }
40
63
  const pages = [];
41
64
  const pageParams = [];
42
65
  let currentParam = initialPageParam;
43
66
  let acc = void 0;
44
- const ctx = { ...context, pageParam: currentParam };
67
+ let nextResult = initialResult;
45
68
  while (true) {
46
- if ((_a = context.signal) == null ? void 0 : _a.aborted) break;
47
- ctx.pageParam = currentParam;
48
- const page = await queryFn(params, ctx);
69
+ const page = await nextResult;
49
70
  pages.push(page);
50
71
  pageParams.push(currentParam);
51
72
  if (reduce) acc = reduce(acc, page);
52
- if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
73
+ if ((_c = context.signal) == null ? void 0 : _c.aborted) break;
53
74
  if (!shouldFetchNextPage(acc, crawlOptions)) break;
75
+ if (!getNextPageParam) break;
54
76
  const nextParam = getNextPageParam(page, pages, currentParam, pageParams);
55
77
  if (nextParam == null) break;
56
78
  currentParam = nextParam;
79
+ if ((_d = context.signal) == null ? void 0 : _d.aborted) break;
80
+ ctx.pageParam = currentParam;
81
+ nextResult = queryFn(params, ctx);
57
82
  }
58
83
  if (reduce) {
59
84
  if (acc === void 0) throw new DOMException("Aborted", "AbortError");
@@ -64,26 +89,47 @@ function buildCrawlingQueryFn(queryFn, getNextPageParam, initialPageParam, shoul
64
89
  }
65
90
  function buildInfiniteCrawlingQueryFn(queryFn, getNextPageParam, shouldFetchNextPage, reduce) {
66
91
  return async (params, crawlOptions, context) => {
67
- var _a, _b;
92
+ var _a, _b, _c, _d;
93
+ if ((_a = context.signal) == null ? void 0 : _a.aborted)
94
+ throw new DOMException("Aborted", "AbortError");
95
+ const ctx = { ...context, pageParam: context.pageParam };
96
+ const initialResult = queryFn(params, ctx);
97
+ if (isAsyncIterable(initialResult)) {
98
+ const pages2 = [];
99
+ let acc2 = void 0;
100
+ let nextBatchParam2 = null;
101
+ for await (const page of initialResult) {
102
+ if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
103
+ pages2.push(page);
104
+ acc2 = reduce(acc2, page);
105
+ const nextParam = getNextPageParam(page, pages2, void 0, []);
106
+ nextBatchParam2 = nextParam != null ? nextParam : null;
107
+ if (nextParam == null) break;
108
+ if (!shouldFetchNextPage(acc2, crawlOptions)) break;
109
+ }
110
+ if (acc2 === void 0) throw new DOMException("Aborted", "AbortError");
111
+ return { data: acc2, nextPageParam: nextBatchParam2 };
112
+ }
68
113
  const pages = [];
69
114
  const pageParams = [];
70
115
  let currentParam = context.pageParam;
71
116
  let acc = void 0;
72
117
  let nextBatchParam = null;
73
- const ctx = { ...context, pageParam: currentParam };
118
+ let nextResult = initialResult;
74
119
  while (true) {
75
- if ((_a = context.signal) == null ? void 0 : _a.aborted) break;
76
- ctx.pageParam = currentParam;
77
- const page = await queryFn(params, ctx);
120
+ const page = await nextResult;
78
121
  pages.push(page);
79
122
  pageParams.push(currentParam);
80
123
  acc = reduce(acc, page);
81
- if ((_b = context.signal) == null ? void 0 : _b.aborted) break;
124
+ if ((_c = context.signal) == null ? void 0 : _c.aborted) break;
82
125
  const nextParam = getNextPageParam(page, pages, currentParam, pageParams);
83
126
  nextBatchParam = nextParam != null ? nextParam : null;
84
127
  if (nextParam == null) break;
85
128
  if (!shouldFetchNextPage(acc, crawlOptions)) break;
86
129
  currentParam = nextParam;
130
+ if ((_d = context.signal) == null ? void 0 : _d.aborted) break;
131
+ ctx.pageParam = currentParam;
132
+ nextResult = queryFn(params, ctx);
87
133
  }
88
134
  if (acc === void 0) throw new DOMException("Aborted", "AbortError");
89
135
  return { data: acc, nextPageParam: nextBatchParam };
@@ -104,8 +150,8 @@ function buildFactory(cfg) {
104
150
  } = cfg;
105
151
  const ownSegments = parentKey !== void 0 ? namespace.slice(parentKey.length) : namespace;
106
152
  const infiniteNamespace = [...namespace, "infinite"];
107
- const hasCrawling = rawQueryFn !== void 0 && getNextPageParam !== void 0 && shouldFetchNextPage !== void 0;
108
- const hasInfiniteCrawling = hasCrawling && reduce !== void 0;
153
+ const hasCrawling = rawQueryFn !== void 0 && shouldFetchNextPage !== void 0;
154
+ const hasInfiniteCrawling = hasCrawling && reduce !== void 0 && getNextPageParam !== void 0;
109
155
  const crawlingFn = hasCrawling ? buildCrawlingQueryFn(
110
156
  rawQueryFn,
111
157
  getNextPageParam,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robohall/react-query-factory",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "A factory abstraction for TanStack Query (React Query) with composable keys, crawling support, and automatic infinite query generation",
5
5
  "author": "Robert Hall",
6
6
  "license": "MIT",
@@ -35,13 +35,15 @@
35
35
  "test": "vitest run",
36
36
  "test:watch": "vitest",
37
37
  "prepublishOnly": "npm run build && npm test",
38
- "sandbox": "cd sandbox && npm run dev"
38
+ "sandbox": "cd sandbox && npm run dev",
39
+ "format": "prettier --write ."
39
40
  },
40
41
  "peerDependencies": {
41
42
  "@tanstack/react-query": ">=5.0.0"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@tanstack/react-query": "^5.0.0",
46
+ "prettier": "^3.8.3",
45
47
  "tsup": "^8.0.0",
46
48
  "typescript": "^5.0.0",
47
49
  "vitest": "^2.0.0"