convex-helpers 0.1.92 → 0.1.93
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 +28 -13
- package/package.json +1 -1
- package/react/cache/hooks.d.ts +8 -8
- package/react/cache/hooks.d.ts.map +1 -1
- package/react/cache/hooks.js +17 -14
- package/react/cache/hooks.ts +20 -14
- package/react.d.ts +26 -1
- package/react.d.ts.map +1 -1
- package/react.js +298 -2
- package/react.ts +390 -4
- package/server/migrations.d.ts +4 -4
- package/validators.d.ts +1 -1
package/README.md
CHANGED
|
@@ -662,10 +662,15 @@ In addition to `getPage`, convex-helpers provides a function
|
|
|
662
662
|
but does not subscribe the query to the end cursor automatically.
|
|
663
663
|
|
|
664
664
|
The syntax and interface for `paginator` is so similar to `.paginate` that it is
|
|
665
|
-
nearly a drop-in replacement and can even be used with `usePaginatedQuery
|
|
665
|
+
nearly a drop-in replacement and can even be used with `usePaginatedQuery`[^1].
|
|
666
666
|
This makes it more suitable for non-reactive pagination usecases,
|
|
667
667
|
such as iterating data in a mutation. Note: it supports `withIndex` but not `filter`.
|
|
668
668
|
|
|
669
|
+
[^1]:
|
|
670
|
+
Note: if you want gapless pagination, use the `usePaginatedQuery` hook in
|
|
671
|
+
`"convex-helpers/react"`, or if you're also using the cached query helpers, pass
|
|
672
|
+
`customPagination: true` for that version.
|
|
673
|
+
|
|
669
674
|
For more information on reactive pagination and end cursors, see
|
|
670
675
|
https://stack.convex.dev/fully-reactive-pagination
|
|
671
676
|
and
|
|
@@ -722,11 +727,12 @@ and paginate the result.
|
|
|
722
727
|
of documents, ordered by indexed fields.
|
|
723
728
|
|
|
724
729
|
The cool thing about QueryStreams is you can make more QueryStreams from them,
|
|
725
|
-
with operations equivalent to SQL's `UNION ALL`, `WHERE`, and
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
+
with operations equivalent to SQL's `UNION ALL`, `WHERE`, and `JOIN`.
|
|
731
|
+
These operations preserve order, so the result is still a valid QueryStream.
|
|
732
|
+
You can combine streams as much as you want, and finally treat it like a
|
|
733
|
+
Convex query to get documents with `.first()`, `.collect()`, or `.paginate()`.
|
|
734
|
+
See [this Stack post](https://stack.convex.dev/translate-sql-into-convex-queries)
|
|
735
|
+
for examples of translating SQL queries into Convex queries.
|
|
730
736
|
|
|
731
737
|
For example, if you have a stream of "messages created by user1" and a stream
|
|
732
738
|
of "messages created by user2", you can get a stream of
|
|
@@ -735,6 +741,9 @@ by creation time (or whatever the order is of the index you're using). You can
|
|
|
735
741
|
then filter the merged stream to get a stream of "messages created by user1 or user2 that are unread". Then you
|
|
736
742
|
can paginate the result.
|
|
737
743
|
|
|
744
|
+
See [this Stack post](https://stack.convex.dev/merging-streams-of-convex-data)
|
|
745
|
+
for more information.
|
|
746
|
+
|
|
738
747
|
Concrete functions you can use:
|
|
739
748
|
|
|
740
749
|
- `stream` constructs a stream using the same syntax as `DatabaseReader`.
|
|
@@ -746,9 +755,11 @@ Concrete functions you can use:
|
|
|
746
755
|
- Once your stream is set up, you can get documents from it with the normal
|
|
747
756
|
Convex query methods: `.first()`, `.collect()`, `.paginate()`, etc.
|
|
748
757
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
758
|
+
Note: if using `.paginate()` with streams in reactive queries, use the
|
|
759
|
+
`usePaginatedQuery` hook from `"convex-helpers/react"`, or if you're also using
|
|
760
|
+
the cached query helpers, pass `customPagination: true` for that version.
|
|
761
|
+
It has the same behavior as [`paginator` and `getPage`](#manual-pagination) in
|
|
762
|
+
that you need to pass in `endCursor` to prevent holes or overlaps between pages.
|
|
752
763
|
|
|
753
764
|
### Example 1: Paginate all messages by a fixed set of authors
|
|
754
765
|
|
|
@@ -920,19 +931,23 @@ server for some expiration period even after app `useQuery` hooks have all
|
|
|
920
931
|
unmounted. This allows very fast reloading of unevicted values during
|
|
921
932
|
navigation changes, view changes, etc.
|
|
922
933
|
|
|
934
|
+
Note: unlike other forms of caching, subscription caching will mean strictly
|
|
935
|
+
more bandwidth usage, because it will keep the subscription open even after
|
|
936
|
+
the component unmounts. This is for optimizing the user experience, not database
|
|
937
|
+
bandwidth.
|
|
938
|
+
|
|
923
939
|
Related files:
|
|
924
940
|
|
|
925
941
|
- [cache.ts](./react/cache.ts) re-exports things so you can import from a single convenient location.
|
|
926
942
|
- [provider.tsx](./react/cache/provider.tsx) contains `ConvexQueryCacheProvider`,
|
|
927
943
|
a configurable cache provider you put in your react app's root.
|
|
928
944
|
- [hooks.ts](./react/cache/hooks.ts) contains cache-enabled drop-in
|
|
929
|
-
replacements for
|
|
945
|
+
replacements for `useQuery`, `usePaginatedQuery`, and `useQueries`.
|
|
930
946
|
|
|
931
947
|
To use the cache, first make sure to put a `<ConvexQueryCacheProvider>`
|
|
932
948
|
inside `<ConvexProvider>` in your react component tree:
|
|
933
949
|
|
|
934
|
-
```
|
|
935
|
-
|
|
950
|
+
```tsx
|
|
936
951
|
import { ConvexQueryCacheProvider } from "convex-helpers/react/cache";
|
|
937
952
|
// For Next.js, import from "convex-helpers/react/cache/provider"; instead
|
|
938
953
|
|
|
@@ -966,7 +981,7 @@ This provider takes three optional props:
|
|
|
966
981
|
Finally, you can utilize `useQuery` (and `useQueries`) just the same as
|
|
967
982
|
their `convex/react` equivalents.
|
|
968
983
|
|
|
969
|
-
```
|
|
984
|
+
```tsx
|
|
970
985
|
import { useQuery } from "convex-helpers/react/cache";
|
|
971
986
|
// For Next.js, import from "convex-helpers/react/cache/hooks"; instead
|
|
972
987
|
|
package/package.json
CHANGED
package/react/cache/hooks.d.ts
CHANGED
|
@@ -105,13 +105,10 @@ export declare function useQuery<Query extends FunctionReference<"query">>(query
|
|
|
105
105
|
* @param args - The arguments object for the query function, excluding
|
|
106
106
|
* the `paginationOpts` property. That property is injected by this hook.
|
|
107
107
|
* @param options - An object specifying the `initialNumItems` to be loaded in
|
|
108
|
-
* the first page, and the `
|
|
109
|
-
* @param options.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* the page size will grow as new items are added within the range of the initial
|
|
113
|
-
* page. Once multiple pages are loaded, all but the last page will grow, in
|
|
114
|
-
* order to provide seamless pagination. See the docs for more details.
|
|
108
|
+
* the first page, and the `customPagination` to use.
|
|
109
|
+
* @param options.customPagination - Set this to true when you are using
|
|
110
|
+
* `stream` or `paginator` helpers on the server. This enables gapless
|
|
111
|
+
* pagination by connecting the pages explicitly when calling `loadMore`.
|
|
115
112
|
* @returns A {@link UsePaginatedQueryResult} that includes the currently loaded
|
|
116
113
|
* items, the status of the pagination, and a `loadMore` function.
|
|
117
114
|
*
|
|
@@ -119,6 +116,9 @@ export declare function useQuery<Query extends FunctionReference<"query">>(query
|
|
|
119
116
|
*/
|
|
120
117
|
export declare function usePaginatedQuery<Query extends PaginatedQueryReference>(query: Query, args: PaginatedQueryArgs<Query> | "skip", options: {
|
|
121
118
|
initialNumItems: number;
|
|
122
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Set this to true if you are using the `stream` or `paginator` helpers.
|
|
121
|
+
*/
|
|
122
|
+
customPagination?: boolean;
|
|
123
123
|
}): UsePaginatedQueryReturnType<Query>;
|
|
124
124
|
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,kBAAkB,EAClB,uBAAuB,EACvB,iBAAiB,EACjB,2BAA2B,EAC5B,MAAM,cAAc,CAAC;AAMtB,OAAO,KAAK,EAEV,iBAAiB,EACjB,kBAAkB,EAInB,MAAM,eAAe,CAAC;AAkBvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,GACzB,MAAM,CAAC,MAAM,EAAE,GAAG,GAAG,SAAS,GAAG,KAAK,CAAC,CAiCzC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,QAAQ,CAAC,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,EAC/D,KAAK,EAAE,KAAK,EACZ,GAAG,SAAS,EAAE,sBAAsB,CAAC,KAAK,CAAC,GAC1C,kBAAkB,CAAC,KAAK,CAAC,GAAG,SAAS,CAoBvC;AAoHD
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,kBAAkB,EAClB,uBAAuB,EACvB,iBAAiB,EACjB,2BAA2B,EAC5B,MAAM,cAAc,CAAC;AAMtB,OAAO,KAAK,EAEV,iBAAiB,EACjB,kBAAkB,EAInB,MAAM,eAAe,CAAC;AAkBvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,GACzB,MAAM,CAAC,MAAM,EAAE,GAAG,GAAG,SAAS,GAAG,KAAK,CAAC,CAiCzC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,QAAQ,CAAC,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,EAC/D,KAAK,EAAE,KAAK,EACZ,GAAG,SAAS,EAAE,sBAAsB,CAAC,KAAK,CAAC,GAC1C,kBAAkB,CAAC,KAAK,CAAC,GAAG,SAAS,CAoBvC;AAoHD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,SAAS,uBAAuB,EACrE,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAAG,MAAM,EACxC,OAAO,EAAE;IACP,eAAe,EAAE,MAAM,CAAC;IACxB;;OAEG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,GACA,2BAA2B,CAAC,KAAK,CAAC,CA2PpC"}
|
package/react/cache/hooks.js
CHANGED
|
@@ -234,13 +234,10 @@ const completeSplitQuery = (key) => (prevState) => {
|
|
|
234
234
|
* @param args - The arguments object for the query function, excluding
|
|
235
235
|
* the `paginationOpts` property. That property is injected by this hook.
|
|
236
236
|
* @param options - An object specifying the `initialNumItems` to be loaded in
|
|
237
|
-
* the first page, and the `
|
|
238
|
-
* @param options.
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* the page size will grow as new items are added within the range of the initial
|
|
242
|
-
* page. Once multiple pages are loaded, all but the last page will grow, in
|
|
243
|
-
* order to provide seamless pagination. See the docs for more details.
|
|
237
|
+
* the first page, and the `customPagination` to use.
|
|
238
|
+
* @param options.customPagination - Set this to true when you are using
|
|
239
|
+
* `stream` or `paginator` helpers on the server. This enables gapless
|
|
240
|
+
* pagination by connecting the pages explicitly when calling `loadMore`.
|
|
244
241
|
* @returns A {@link UsePaginatedQueryResult} that includes the currently loaded
|
|
245
242
|
* items, the status of the pagination, and a `loadMore` function.
|
|
246
243
|
*
|
|
@@ -296,10 +293,10 @@ export function usePaginatedQuery(query, args, options) {
|
|
|
296
293
|
const [state, setState] = useState(createInitialState);
|
|
297
294
|
// `currState` is the state that we'll render based on.
|
|
298
295
|
let currState = state;
|
|
299
|
-
if (
|
|
296
|
+
if (skip !== state.skip ||
|
|
297
|
+
getFunctionName(query) !== getFunctionName(state.query) ||
|
|
300
298
|
JSON.stringify(convexToJson(argsObject)) !==
|
|
301
|
-
JSON.stringify(convexToJson(state.args))
|
|
302
|
-
skip !== state.skip) {
|
|
299
|
+
JSON.stringify(convexToJson(state.args))) {
|
|
303
300
|
currState = createInitialState();
|
|
304
301
|
setState(currState);
|
|
305
302
|
}
|
|
@@ -346,8 +343,11 @@ export function usePaginatedQuery(query, args, options) {
|
|
|
346
343
|
else if (currResult.splitCursor &&
|
|
347
344
|
(currResult.pageStatus === "SplitRecommended" ||
|
|
348
345
|
currResult.pageStatus === "SplitRequired" ||
|
|
349
|
-
|
|
350
|
-
|
|
346
|
+
(options.customPagination
|
|
347
|
+
? // For custom pagination, we eagerly split the page when it grows.
|
|
348
|
+
currResult.page.length > options.initialNumItems
|
|
349
|
+
: currResult.page.length > options.initialNumItems * 2))) {
|
|
350
|
+
// If a single page has more than 1.5x the expected number of items,
|
|
351
351
|
// or if the server requests a split, split the page into two.
|
|
352
352
|
setState(splitQuery(pageKey, currResult.splitCursor, currResult.continueCursor));
|
|
353
353
|
}
|
|
@@ -411,7 +411,10 @@ export function usePaginatedQuery(query, args, options) {
|
|
|
411
411
|
const queries = { ...prevState.queries };
|
|
412
412
|
let ongoingSplits = prevState.ongoingSplits;
|
|
413
413
|
let pageKeys = prevState.pageKeys;
|
|
414
|
-
if (options.
|
|
414
|
+
if (options.customPagination) {
|
|
415
|
+
// Connect the current last page to the next page
|
|
416
|
+
// by setting the endCursor of the last page to the continueCursor
|
|
417
|
+
// of the next page.
|
|
415
418
|
const lastPageKey = prevState.pageKeys.at(-1);
|
|
416
419
|
const boundLastPageKey = nextPageKey;
|
|
417
420
|
queries[boundLastPageKey] = {
|
|
@@ -457,7 +460,7 @@ export function usePaginatedQuery(query, args, options) {
|
|
|
457
460
|
}
|
|
458
461
|
},
|
|
459
462
|
};
|
|
460
|
-
}, [maybeLastResult, currState.nextPageKey, options.
|
|
463
|
+
}, [maybeLastResult, currState.nextPageKey, options.customPagination]);
|
|
461
464
|
return {
|
|
462
465
|
results,
|
|
463
466
|
...statusObject,
|
package/react/cache/hooks.ts
CHANGED
|
@@ -314,13 +314,10 @@ const completeSplitQuery =
|
|
|
314
314
|
* @param args - The arguments object for the query function, excluding
|
|
315
315
|
* the `paginationOpts` property. That property is injected by this hook.
|
|
316
316
|
* @param options - An object specifying the `initialNumItems` to be loaded in
|
|
317
|
-
* the first page, and the `
|
|
318
|
-
* @param options.
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* the page size will grow as new items are added within the range of the initial
|
|
322
|
-
* page. Once multiple pages are loaded, all but the last page will grow, in
|
|
323
|
-
* order to provide seamless pagination. See the docs for more details.
|
|
317
|
+
* the first page, and the `customPagination` to use.
|
|
318
|
+
* @param options.customPagination - Set this to true when you are using
|
|
319
|
+
* `stream` or `paginator` helpers on the server. This enables gapless
|
|
320
|
+
* pagination by connecting the pages explicitly when calling `loadMore`.
|
|
324
321
|
* @returns A {@link UsePaginatedQueryResult} that includes the currently loaded
|
|
325
322
|
* items, the status of the pagination, and a `loadMore` function.
|
|
326
323
|
*
|
|
@@ -331,7 +328,10 @@ export function usePaginatedQuery<Query extends PaginatedQueryReference>(
|
|
|
331
328
|
args: PaginatedQueryArgs<Query> | "skip",
|
|
332
329
|
options: {
|
|
333
330
|
initialNumItems: number;
|
|
334
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Set this to true if you are using the `stream` or `paginator` helpers.
|
|
333
|
+
*/
|
|
334
|
+
customPagination?: boolean;
|
|
335
335
|
},
|
|
336
336
|
): UsePaginatedQueryReturnType<Query> {
|
|
337
337
|
if (
|
|
@@ -391,10 +391,10 @@ export function usePaginatedQuery<Query extends PaginatedQueryReference>(
|
|
|
391
391
|
// `currState` is the state that we'll render based on.
|
|
392
392
|
let currState = state;
|
|
393
393
|
if (
|
|
394
|
+
skip !== state.skip ||
|
|
394
395
|
getFunctionName(query) !== getFunctionName(state.query) ||
|
|
395
396
|
JSON.stringify(convexToJson(argsObject as Value)) !==
|
|
396
|
-
JSON.stringify(convexToJson(state.args))
|
|
397
|
-
skip !== state.skip
|
|
397
|
+
JSON.stringify(convexToJson(state.args))
|
|
398
398
|
) {
|
|
399
399
|
currState = createInitialState();
|
|
400
400
|
setState(currState);
|
|
@@ -455,9 +455,12 @@ export function usePaginatedQuery<Query extends PaginatedQueryReference>(
|
|
|
455
455
|
currResult.splitCursor &&
|
|
456
456
|
(currResult.pageStatus === "SplitRecommended" ||
|
|
457
457
|
currResult.pageStatus === "SplitRequired" ||
|
|
458
|
-
|
|
458
|
+
(options.customPagination
|
|
459
|
+
? // For custom pagination, we eagerly split the page when it grows.
|
|
460
|
+
currResult.page.length > options.initialNumItems
|
|
461
|
+
: currResult.page.length > options.initialNumItems * 2))
|
|
459
462
|
) {
|
|
460
|
-
// If a single page has more than
|
|
463
|
+
// If a single page has more than 1.5x the expected number of items,
|
|
461
464
|
// or if the server requests a split, split the page into two.
|
|
462
465
|
setState(
|
|
463
466
|
splitQuery(
|
|
@@ -527,7 +530,10 @@ export function usePaginatedQuery<Query extends PaginatedQueryReference>(
|
|
|
527
530
|
const queries = { ...prevState.queries };
|
|
528
531
|
let ongoingSplits = prevState.ongoingSplits;
|
|
529
532
|
let pageKeys = prevState.pageKeys;
|
|
530
|
-
if (options.
|
|
533
|
+
if (options.customPagination) {
|
|
534
|
+
// Connect the current last page to the next page
|
|
535
|
+
// by setting the endCursor of the last page to the continueCursor
|
|
536
|
+
// of the next page.
|
|
531
537
|
const lastPageKey = prevState.pageKeys.at(-1)!;
|
|
532
538
|
const boundLastPageKey = nextPageKey;
|
|
533
539
|
queries[boundLastPageKey] = {
|
|
@@ -572,7 +578,7 @@ export function usePaginatedQuery<Query extends PaginatedQueryReference>(
|
|
|
572
578
|
}
|
|
573
579
|
},
|
|
574
580
|
} as const;
|
|
575
|
-
}, [maybeLastResult, currState.nextPageKey, options.
|
|
581
|
+
}, [maybeLastResult, currState.nextPageKey, options.customPagination]);
|
|
576
582
|
|
|
577
583
|
return {
|
|
578
584
|
results,
|
package/react.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OptionalRestArgsOrSkip } from "convex/react";
|
|
1
|
+
import type { OptionalRestArgsOrSkip, PaginatedQueryArgs, PaginatedQueryReference, UsePaginatedQueryReturnType } from "convex/react";
|
|
2
2
|
import { useQueries } from "convex/react";
|
|
3
3
|
import type { FunctionReference, FunctionReturnType } from "convex/server";
|
|
4
4
|
/**
|
|
@@ -117,4 +117,29 @@ export declare function makeUseQueryWithStatus(useQueriesHook: typeof useQueries
|
|
|
117
117
|
isPending: false;
|
|
118
118
|
isError: true;
|
|
119
119
|
};
|
|
120
|
+
/**
|
|
121
|
+
* This is a clone of the `usePaginatedQuery` hook from `convex/react` made for
|
|
122
|
+
* use with the `stream` and `paginator` helpers, which don't automatically
|
|
123
|
+
* "grow" until you explicitly pass the `endCursor` arg.
|
|
124
|
+
*
|
|
125
|
+
* For these, we wait to set the end cursor until `loadMore` is called.
|
|
126
|
+
* So the first page will be a fixed size until the first call to `loadMore`,
|
|
127
|
+
* at which point the second page will start where the first page ended, and the
|
|
128
|
+
* first page will explicitly "pin" that end cursor. From then on, the last page
|
|
129
|
+
* will also be a fixed size until the next call to `loadMore`. This is less
|
|
130
|
+
* noticeable because typically the first page is the only page that grows.
|
|
131
|
+
*
|
|
132
|
+
* To use the cached query helpers, you can use those directly and pass
|
|
133
|
+
* `customPagination: true` in the options.
|
|
134
|
+
*
|
|
135
|
+
* Docs copied from {@link usePaginatedQueryOriginal} until `returns` block:
|
|
136
|
+
*
|
|
137
|
+
* @param query - a {@link server.FunctionReference} for the public query to run
|
|
138
|
+
* like `api.dir1.dir2.filename.func`.
|
|
139
|
+
* @param args - The arguments to the query function or the string "skip" if the
|
|
140
|
+
* query should not be loaded.
|
|
141
|
+
*/
|
|
142
|
+
export declare function usePaginatedQuery<Query extends PaginatedQueryReference>(query: Query, args: PaginatedQueryArgs<Query> | "skip", options: {
|
|
143
|
+
initialNumItems: number;
|
|
144
|
+
}): UsePaginatedQueryReturnType<Query>;
|
|
120
145
|
//# sourceMappingURL=react.d.ts.map
|
package/react.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["react.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["react.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,kBAAkB,EAClB,uBAAuB,EACvB,2BAA2B,EAC5B,MAAM,cAAc,CAAC;AAGtB,OAAO,EAEL,UAAU,EAEX,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAInB,MAAM,eAAe,CAAC;AAOvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,QAAQ,GA0CM,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,SACxD,KAAK,gBACE,sBAAsB,CAAC,KAAK,CAAC,KAEzC;IACE,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAChC,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,KAAK,CAAC;IACjB,OAAO,EAAE,KAAK,CAAC;CAChB,GACD;IACE,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,KAAK,CAAC;IACjB,SAAS,EAAE,IAAI,CAAC;IAChB,OAAO,EAAE,KAAK,CAAC;CAChB,GACD;IACE,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,KAAK,CAAC;IACjB,SAAS,EAAE,KAAK,CAAC;IACjB,OAAO,EAAE,IAAI,CAAC;CACf,AArEmD,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,sBAAsB,CAAC,cAAc,EAAE,OAAO,UAAU,IAC7C,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,EAC/D,OAAO,KAAK,EACZ,GAAG,WAAW,sBAAsB,CAAC,KAAK,CAAC,KAEzC;IACE,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAChC,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,KAAK,CAAC;IACjB,OAAO,EAAE,KAAK,CAAC;CAChB,GACD;IACE,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,KAAK,CAAC;IACjB,SAAS,EAAE,IAAI,CAAC;IAChB,OAAO,EAAE,KAAK,CAAC;CAChB,GACD;IACE,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,KAAK,CAAC;IACjB,SAAS,EAAE,KAAK,CAAC;IACjB,OAAO,EAAE,IAAI,CAAC;CACf,CAuDN;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,SAAS,uBAAuB,EACrE,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAAG,MAAM,EACxC,OAAO,EAAE;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,GACnC,2BAA2B,CAAC,KAAK,CAAC,CA6OpC"}
|
package/react.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ConvexError } from "convex/values";
|
|
2
|
+
import { convexToJson } from "convex/values";
|
|
3
|
+
import { useConvex, useQueries, useQuery as useQueryOriginal, } from "convex/react";
|
|
2
4
|
import { getFunctionName } from "convex/server";
|
|
3
|
-
import { useMemo } from "react";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
4
6
|
/**
|
|
5
7
|
* Use in place of `useQuery` from "convex/react" to fetch data from a query
|
|
6
8
|
* function but instead returns `{ status, data, error, isSuccess, isPending, isError}`.
|
|
@@ -131,3 +133,297 @@ export function makeUseQueryWithStatus(useQueriesHook) {
|
|
|
131
133
|
};
|
|
132
134
|
};
|
|
133
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* This is a clone of the `usePaginatedQuery` hook from `convex/react` made for
|
|
138
|
+
* use with the `stream` and `paginator` helpers, which don't automatically
|
|
139
|
+
* "grow" until you explicitly pass the `endCursor` arg.
|
|
140
|
+
*
|
|
141
|
+
* For these, we wait to set the end cursor until `loadMore` is called.
|
|
142
|
+
* So the first page will be a fixed size until the first call to `loadMore`,
|
|
143
|
+
* at which point the second page will start where the first page ended, and the
|
|
144
|
+
* first page will explicitly "pin" that end cursor. From then on, the last page
|
|
145
|
+
* will also be a fixed size until the next call to `loadMore`. This is less
|
|
146
|
+
* noticeable because typically the first page is the only page that grows.
|
|
147
|
+
*
|
|
148
|
+
* To use the cached query helpers, you can use those directly and pass
|
|
149
|
+
* `customPagination: true` in the options.
|
|
150
|
+
*
|
|
151
|
+
* Docs copied from {@link usePaginatedQueryOriginal} until `returns` block:
|
|
152
|
+
*
|
|
153
|
+
* @param query - a {@link server.FunctionReference} for the public query to run
|
|
154
|
+
* like `api.dir1.dir2.filename.func`.
|
|
155
|
+
* @param args - The arguments to the query function or the string "skip" if the
|
|
156
|
+
* query should not be loaded.
|
|
157
|
+
*/
|
|
158
|
+
export function usePaginatedQuery(query, args, options) {
|
|
159
|
+
if (typeof options?.initialNumItems !== "number" ||
|
|
160
|
+
options.initialNumItems <= 0) {
|
|
161
|
+
throw new Error(`\`options.initialNumItems\` must be a positive number. Received \`${options?.initialNumItems}\`.`);
|
|
162
|
+
}
|
|
163
|
+
const skip = args === "skip";
|
|
164
|
+
const argsObject = skip ? {} : args;
|
|
165
|
+
const queryName = getFunctionName(query);
|
|
166
|
+
const createInitialState = useMemo(() => {
|
|
167
|
+
return () => {
|
|
168
|
+
return {
|
|
169
|
+
query,
|
|
170
|
+
args: argsObject,
|
|
171
|
+
nextPageKey: 1,
|
|
172
|
+
pageKeys: skip ? [] : [0],
|
|
173
|
+
queries: skip
|
|
174
|
+
? {}
|
|
175
|
+
: {
|
|
176
|
+
0: {
|
|
177
|
+
query,
|
|
178
|
+
args: {
|
|
179
|
+
...argsObject,
|
|
180
|
+
paginationOpts: {
|
|
181
|
+
numItems: options.initialNumItems,
|
|
182
|
+
cursor: null,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
ongoingSplits: {},
|
|
188
|
+
skip,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
// ESLint doesn't like that we're stringifying the args. We do this because
|
|
192
|
+
// we want to avoid rerendering if the args are a different
|
|
193
|
+
// object that serializes to the same result.
|
|
194
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
+
}, [
|
|
196
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
197
|
+
JSON.stringify(convexToJson(argsObject)),
|
|
198
|
+
queryName,
|
|
199
|
+
options.initialNumItems,
|
|
200
|
+
skip,
|
|
201
|
+
]);
|
|
202
|
+
const [state, setState] = useState(createInitialState);
|
|
203
|
+
// `currState` is the state that we'll render based on.
|
|
204
|
+
let currState = state;
|
|
205
|
+
if (skip !== state.skip ||
|
|
206
|
+
getFunctionName(query) !== getFunctionName(state.query) ||
|
|
207
|
+
JSON.stringify(convexToJson(argsObject)) !==
|
|
208
|
+
JSON.stringify(convexToJson(state.args))) {
|
|
209
|
+
currState = createInitialState();
|
|
210
|
+
setState(currState);
|
|
211
|
+
}
|
|
212
|
+
const convexClient = useConvex();
|
|
213
|
+
const logger = convexClient.logger;
|
|
214
|
+
const resultsObject = useQueries(currState.queries);
|
|
215
|
+
const [results, maybeLastResult] = useMemo(() => {
|
|
216
|
+
let currResult = undefined;
|
|
217
|
+
const allItems = [];
|
|
218
|
+
for (const pageKey of currState.pageKeys) {
|
|
219
|
+
currResult = resultsObject[pageKey];
|
|
220
|
+
if (currResult === undefined) {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if (currResult instanceof Error) {
|
|
224
|
+
if (currResult.message.includes("InvalidCursor") ||
|
|
225
|
+
(currResult instanceof ConvexError &&
|
|
226
|
+
typeof currResult.data === "object" &&
|
|
227
|
+
currResult.data?.isConvexSystemError === true &&
|
|
228
|
+
currResult.data?.paginationError === "InvalidCursor")) {
|
|
229
|
+
// - InvalidCursor: If the cursor is invalid, probably the paginated
|
|
230
|
+
// database query was data-dependent and changed underneath us. The
|
|
231
|
+
// cursor in the params or journal no longer matches the current
|
|
232
|
+
// database query.
|
|
233
|
+
// In all cases, we want to restart pagination to throw away all our
|
|
234
|
+
// existing cursors.
|
|
235
|
+
logger.warn("usePaginatedQuery hit error, resetting pagination state: " +
|
|
236
|
+
currResult.message);
|
|
237
|
+
setState(createInitialState);
|
|
238
|
+
return [[], undefined];
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
throw currResult;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const ongoingSplit = currState.ongoingSplits[pageKey];
|
|
245
|
+
if (ongoingSplit !== undefined) {
|
|
246
|
+
if (resultsObject[ongoingSplit[0]] !== undefined &&
|
|
247
|
+
resultsObject[ongoingSplit[1]] !== undefined) {
|
|
248
|
+
// Both pages of the split have results now. Swap them in.
|
|
249
|
+
setState(completeSplitQuery(pageKey));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (currResult.splitCursor &&
|
|
253
|
+
(currResult.pageStatus === "SplitRecommended" ||
|
|
254
|
+
currResult.pageStatus === "SplitRequired" ||
|
|
255
|
+
// For custom pagination, we eagerly split the page when it grows.
|
|
256
|
+
currResult.page.length > options.initialNumItems)) {
|
|
257
|
+
setState(splitQuery(pageKey, currResult.splitCursor, currResult.continueCursor));
|
|
258
|
+
}
|
|
259
|
+
if (currResult.pageStatus === "SplitRequired") {
|
|
260
|
+
// If pageStatus is 'SplitRequired', it means the server was not able to
|
|
261
|
+
// fetch the full page. So we stop results before the incomplete
|
|
262
|
+
// page and return 'LoadingMore' while the page is splitting.
|
|
263
|
+
return [allItems, undefined];
|
|
264
|
+
}
|
|
265
|
+
allItems.push(...currResult.page);
|
|
266
|
+
}
|
|
267
|
+
return [allItems, currResult];
|
|
268
|
+
}, [
|
|
269
|
+
resultsObject,
|
|
270
|
+
currState.pageKeys,
|
|
271
|
+
currState.ongoingSplits,
|
|
272
|
+
options.initialNumItems,
|
|
273
|
+
createInitialState,
|
|
274
|
+
logger,
|
|
275
|
+
]);
|
|
276
|
+
const statusObject = useMemo(() => {
|
|
277
|
+
if (maybeLastResult === undefined) {
|
|
278
|
+
if (currState.nextPageKey === 1) {
|
|
279
|
+
return {
|
|
280
|
+
status: "LoadingFirstPage",
|
|
281
|
+
isLoading: true,
|
|
282
|
+
loadMore: (_numItems) => {
|
|
283
|
+
// Intentional noop.
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
return {
|
|
289
|
+
status: "LoadingMore",
|
|
290
|
+
isLoading: true,
|
|
291
|
+
loadMore: (_numItems) => {
|
|
292
|
+
// Intentional noop.
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (maybeLastResult.isDone) {
|
|
298
|
+
return {
|
|
299
|
+
status: "Exhausted",
|
|
300
|
+
isLoading: false,
|
|
301
|
+
loadMore: (_numItems) => {
|
|
302
|
+
// Intentional noop.
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const continueCursor = maybeLastResult.continueCursor;
|
|
307
|
+
let alreadyLoadingMore = false;
|
|
308
|
+
return {
|
|
309
|
+
status: "CanLoadMore",
|
|
310
|
+
isLoading: false,
|
|
311
|
+
loadMore: (numItems) => {
|
|
312
|
+
if (!alreadyLoadingMore) {
|
|
313
|
+
alreadyLoadingMore = true;
|
|
314
|
+
setState((prevState) => {
|
|
315
|
+
let nextPageKey = prevState.nextPageKey;
|
|
316
|
+
const queries = { ...prevState.queries };
|
|
317
|
+
let ongoingSplits = prevState.ongoingSplits;
|
|
318
|
+
// Connect the current last page to the next page
|
|
319
|
+
// by setting the endCursor of the last page to the continueCursor
|
|
320
|
+
// of the next page.
|
|
321
|
+
const lastPageKey = prevState.pageKeys.at(-1);
|
|
322
|
+
const boundLastPageKey = nextPageKey;
|
|
323
|
+
queries[boundLastPageKey] = {
|
|
324
|
+
query: prevState.query,
|
|
325
|
+
args: {
|
|
326
|
+
...prevState.args,
|
|
327
|
+
paginationOpts: {
|
|
328
|
+
...queries[lastPageKey].args
|
|
329
|
+
.paginationOpts,
|
|
330
|
+
endCursor: continueCursor,
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
nextPageKey++;
|
|
335
|
+
ongoingSplits = {
|
|
336
|
+
...ongoingSplits,
|
|
337
|
+
[lastPageKey]: [boundLastPageKey, nextPageKey],
|
|
338
|
+
};
|
|
339
|
+
queries[nextPageKey] = {
|
|
340
|
+
query: prevState.query,
|
|
341
|
+
args: {
|
|
342
|
+
...prevState.args,
|
|
343
|
+
paginationOpts: {
|
|
344
|
+
numItems,
|
|
345
|
+
cursor: continueCursor,
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
nextPageKey++;
|
|
350
|
+
return {
|
|
351
|
+
...prevState,
|
|
352
|
+
nextPageKey,
|
|
353
|
+
queries,
|
|
354
|
+
ongoingSplits,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}, [maybeLastResult, currState.nextPageKey]);
|
|
361
|
+
return {
|
|
362
|
+
results,
|
|
363
|
+
...statusObject,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function splitQuery(key, splitCursor, continueCursor) {
|
|
367
|
+
return (prevState) => {
|
|
368
|
+
const queries = { ...prevState.queries };
|
|
369
|
+
const splitKey1 = prevState.nextPageKey;
|
|
370
|
+
const splitKey2 = prevState.nextPageKey + 1;
|
|
371
|
+
const nextPageKey = prevState.nextPageKey + 2;
|
|
372
|
+
queries[splitKey1] = {
|
|
373
|
+
query: prevState.query,
|
|
374
|
+
args: {
|
|
375
|
+
...prevState.args,
|
|
376
|
+
paginationOpts: {
|
|
377
|
+
...prevState.queries[key].args.paginationOpts,
|
|
378
|
+
endCursor: splitCursor,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
queries[splitKey2] = {
|
|
383
|
+
query: prevState.query,
|
|
384
|
+
args: {
|
|
385
|
+
...prevState.args,
|
|
386
|
+
paginationOpts: {
|
|
387
|
+
...prevState.queries[key].args.paginationOpts,
|
|
388
|
+
cursor: splitCursor,
|
|
389
|
+
endCursor: continueCursor,
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
const ongoingSplits = { ...prevState.ongoingSplits };
|
|
394
|
+
ongoingSplits[key] = [splitKey1, splitKey2];
|
|
395
|
+
return {
|
|
396
|
+
...prevState,
|
|
397
|
+
nextPageKey,
|
|
398
|
+
queries,
|
|
399
|
+
ongoingSplits,
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function completeSplitQuery(key) {
|
|
404
|
+
return (prevState) => {
|
|
405
|
+
const completedSplit = prevState.ongoingSplits[key];
|
|
406
|
+
if (completedSplit === undefined) {
|
|
407
|
+
return prevState;
|
|
408
|
+
}
|
|
409
|
+
const queries = { ...prevState.queries };
|
|
410
|
+
delete queries[key];
|
|
411
|
+
const ongoingSplits = { ...prevState.ongoingSplits };
|
|
412
|
+
delete ongoingSplits[key];
|
|
413
|
+
let pageKeys = prevState.pageKeys.slice();
|
|
414
|
+
const pageIndex = prevState.pageKeys.findIndex((v) => v === key);
|
|
415
|
+
if (pageIndex >= 0) {
|
|
416
|
+
pageKeys = [
|
|
417
|
+
...prevState.pageKeys.slice(0, pageIndex),
|
|
418
|
+
...completedSplit,
|
|
419
|
+
...prevState.pageKeys.slice(pageIndex + 1),
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
...prevState,
|
|
424
|
+
queries,
|
|
425
|
+
pageKeys,
|
|
426
|
+
ongoingSplits,
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
}
|
package/react.ts
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type {
|
|
2
|
+
OptionalRestArgsOrSkip,
|
|
3
|
+
PaginatedQueryArgs,
|
|
4
|
+
PaginatedQueryReference,
|
|
5
|
+
UsePaginatedQueryReturnType,
|
|
6
|
+
} from "convex/react";
|
|
7
|
+
import { ConvexError } from "convex/values";
|
|
8
|
+
import { convexToJson } from "convex/values";
|
|
9
|
+
import {
|
|
10
|
+
useConvex,
|
|
11
|
+
useQueries,
|
|
12
|
+
useQuery as useQueryOriginal,
|
|
13
|
+
} from "convex/react";
|
|
14
|
+
import type {
|
|
15
|
+
FunctionReference,
|
|
16
|
+
FunctionReturnType,
|
|
17
|
+
PaginationOptions,
|
|
18
|
+
paginationOptsValidator,
|
|
19
|
+
PaginationResult,
|
|
20
|
+
} from "convex/server";
|
|
21
|
+
import type { Infer } from "convex/values";
|
|
4
22
|
import { getFunctionName } from "convex/server";
|
|
5
|
-
import { useMemo } from "react";
|
|
23
|
+
import { useMemo, useState } from "react";
|
|
6
24
|
import type { EmptyObject } from "./index.js";
|
|
25
|
+
import type { Value } from "convex/values";
|
|
7
26
|
|
|
8
27
|
/**
|
|
9
28
|
* Use in place of `useQuery` from "convex/react" to fetch data from a query
|
|
@@ -163,3 +182,370 @@ export function makeUseQueryWithStatus(useQueriesHook: typeof useQueries) {
|
|
|
163
182
|
};
|
|
164
183
|
};
|
|
165
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* This is a clone of the `usePaginatedQuery` hook from `convex/react` made for
|
|
188
|
+
* use with the `stream` and `paginator` helpers, which don't automatically
|
|
189
|
+
* "grow" until you explicitly pass the `endCursor` arg.
|
|
190
|
+
*
|
|
191
|
+
* For these, we wait to set the end cursor until `loadMore` is called.
|
|
192
|
+
* So the first page will be a fixed size until the first call to `loadMore`,
|
|
193
|
+
* at which point the second page will start where the first page ended, and the
|
|
194
|
+
* first page will explicitly "pin" that end cursor. From then on, the last page
|
|
195
|
+
* will also be a fixed size until the next call to `loadMore`. This is less
|
|
196
|
+
* noticeable because typically the first page is the only page that grows.
|
|
197
|
+
*
|
|
198
|
+
* To use the cached query helpers, you can use those directly and pass
|
|
199
|
+
* `customPagination: true` in the options.
|
|
200
|
+
*
|
|
201
|
+
* Docs copied from {@link usePaginatedQueryOriginal} until `returns` block:
|
|
202
|
+
*
|
|
203
|
+
* @param query - a {@link server.FunctionReference} for the public query to run
|
|
204
|
+
* like `api.dir1.dir2.filename.func`.
|
|
205
|
+
* @param args - The arguments to the query function or the string "skip" if the
|
|
206
|
+
* query should not be loaded.
|
|
207
|
+
*/
|
|
208
|
+
export function usePaginatedQuery<Query extends PaginatedQueryReference>(
|
|
209
|
+
query: Query,
|
|
210
|
+
args: PaginatedQueryArgs<Query> | "skip",
|
|
211
|
+
options: { initialNumItems: number },
|
|
212
|
+
): UsePaginatedQueryReturnType<Query> {
|
|
213
|
+
if (
|
|
214
|
+
typeof options?.initialNumItems !== "number" ||
|
|
215
|
+
options.initialNumItems <= 0
|
|
216
|
+
) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`\`options.initialNumItems\` must be a positive number. Received \`${options?.initialNumItems}\`.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const skip = args === "skip";
|
|
222
|
+
const argsObject = skip ? {} : args;
|
|
223
|
+
const queryName = getFunctionName(query);
|
|
224
|
+
const createInitialState = useMemo(() => {
|
|
225
|
+
return () => {
|
|
226
|
+
return {
|
|
227
|
+
query,
|
|
228
|
+
args: argsObject as Record<string, Value>,
|
|
229
|
+
nextPageKey: 1,
|
|
230
|
+
pageKeys: skip ? [] : [0],
|
|
231
|
+
queries: skip
|
|
232
|
+
? ({} as UsePaginatedQueryState["queries"])
|
|
233
|
+
: {
|
|
234
|
+
0: {
|
|
235
|
+
query,
|
|
236
|
+
args: {
|
|
237
|
+
...argsObject,
|
|
238
|
+
paginationOpts: {
|
|
239
|
+
numItems: options.initialNumItems,
|
|
240
|
+
cursor: null,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
ongoingSplits: {},
|
|
246
|
+
skip,
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
// ESLint doesn't like that we're stringifying the args. We do this because
|
|
250
|
+
// we want to avoid rerendering if the args are a different
|
|
251
|
+
// object that serializes to the same result.
|
|
252
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
253
|
+
}, [
|
|
254
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
255
|
+
JSON.stringify(convexToJson(argsObject as Value)),
|
|
256
|
+
queryName,
|
|
257
|
+
options.initialNumItems,
|
|
258
|
+
skip,
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
const [state, setState] =
|
|
262
|
+
useState<UsePaginatedQueryState>(createInitialState);
|
|
263
|
+
|
|
264
|
+
// `currState` is the state that we'll render based on.
|
|
265
|
+
let currState = state;
|
|
266
|
+
if (
|
|
267
|
+
skip !== state.skip ||
|
|
268
|
+
getFunctionName(query) !== getFunctionName(state.query) ||
|
|
269
|
+
JSON.stringify(convexToJson(argsObject as Value)) !==
|
|
270
|
+
JSON.stringify(convexToJson(state.args))
|
|
271
|
+
) {
|
|
272
|
+
currState = createInitialState();
|
|
273
|
+
setState(currState);
|
|
274
|
+
}
|
|
275
|
+
const convexClient = useConvex();
|
|
276
|
+
const logger = convexClient.logger;
|
|
277
|
+
|
|
278
|
+
const resultsObject = useQueries(currState.queries);
|
|
279
|
+
|
|
280
|
+
const [results, maybeLastResult]: [
|
|
281
|
+
Value[],
|
|
282
|
+
undefined | PaginationResult<Value>,
|
|
283
|
+
] = useMemo(() => {
|
|
284
|
+
let currResult: PaginationResult<Value> | undefined = undefined;
|
|
285
|
+
|
|
286
|
+
const allItems: Value[] = [];
|
|
287
|
+
for (const pageKey of currState.pageKeys) {
|
|
288
|
+
currResult = resultsObject[pageKey];
|
|
289
|
+
if (currResult === undefined) {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (currResult instanceof Error) {
|
|
294
|
+
if (
|
|
295
|
+
currResult.message.includes("InvalidCursor") ||
|
|
296
|
+
(currResult instanceof ConvexError &&
|
|
297
|
+
typeof currResult.data === "object" &&
|
|
298
|
+
currResult.data?.isConvexSystemError === true &&
|
|
299
|
+
currResult.data?.paginationError === "InvalidCursor")
|
|
300
|
+
) {
|
|
301
|
+
// - InvalidCursor: If the cursor is invalid, probably the paginated
|
|
302
|
+
// database query was data-dependent and changed underneath us. The
|
|
303
|
+
// cursor in the params or journal no longer matches the current
|
|
304
|
+
// database query.
|
|
305
|
+
|
|
306
|
+
// In all cases, we want to restart pagination to throw away all our
|
|
307
|
+
// existing cursors.
|
|
308
|
+
logger.warn(
|
|
309
|
+
"usePaginatedQuery hit error, resetting pagination state: " +
|
|
310
|
+
currResult.message,
|
|
311
|
+
);
|
|
312
|
+
setState(createInitialState);
|
|
313
|
+
return [[], undefined];
|
|
314
|
+
} else {
|
|
315
|
+
throw currResult;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const ongoingSplit = currState.ongoingSplits[pageKey];
|
|
319
|
+
if (ongoingSplit !== undefined) {
|
|
320
|
+
if (
|
|
321
|
+
resultsObject[ongoingSplit[0]] !== undefined &&
|
|
322
|
+
resultsObject[ongoingSplit[1]] !== undefined
|
|
323
|
+
) {
|
|
324
|
+
// Both pages of the split have results now. Swap them in.
|
|
325
|
+
setState(completeSplitQuery(pageKey));
|
|
326
|
+
}
|
|
327
|
+
} else if (
|
|
328
|
+
currResult.splitCursor &&
|
|
329
|
+
(currResult.pageStatus === "SplitRecommended" ||
|
|
330
|
+
currResult.pageStatus === "SplitRequired" ||
|
|
331
|
+
// For custom pagination, we eagerly split the page when it grows.
|
|
332
|
+
currResult.page.length > options.initialNumItems)
|
|
333
|
+
) {
|
|
334
|
+
setState(
|
|
335
|
+
splitQuery(
|
|
336
|
+
pageKey,
|
|
337
|
+
currResult.splitCursor,
|
|
338
|
+
currResult.continueCursor,
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (currResult.pageStatus === "SplitRequired") {
|
|
343
|
+
// If pageStatus is 'SplitRequired', it means the server was not able to
|
|
344
|
+
// fetch the full page. So we stop results before the incomplete
|
|
345
|
+
// page and return 'LoadingMore' while the page is splitting.
|
|
346
|
+
return [allItems, undefined];
|
|
347
|
+
}
|
|
348
|
+
allItems.push(...currResult.page);
|
|
349
|
+
}
|
|
350
|
+
return [allItems, currResult];
|
|
351
|
+
}, [
|
|
352
|
+
resultsObject,
|
|
353
|
+
currState.pageKeys,
|
|
354
|
+
currState.ongoingSplits,
|
|
355
|
+
options.initialNumItems,
|
|
356
|
+
createInitialState,
|
|
357
|
+
logger,
|
|
358
|
+
]);
|
|
359
|
+
|
|
360
|
+
const statusObject = useMemo(() => {
|
|
361
|
+
if (maybeLastResult === undefined) {
|
|
362
|
+
if (currState.nextPageKey === 1) {
|
|
363
|
+
return {
|
|
364
|
+
status: "LoadingFirstPage",
|
|
365
|
+
isLoading: true,
|
|
366
|
+
loadMore: (_numItems: number) => {
|
|
367
|
+
// Intentional noop.
|
|
368
|
+
},
|
|
369
|
+
} as const;
|
|
370
|
+
} else {
|
|
371
|
+
return {
|
|
372
|
+
status: "LoadingMore",
|
|
373
|
+
isLoading: true,
|
|
374
|
+
loadMore: (_numItems: number) => {
|
|
375
|
+
// Intentional noop.
|
|
376
|
+
},
|
|
377
|
+
} as const;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (maybeLastResult.isDone) {
|
|
381
|
+
return {
|
|
382
|
+
status: "Exhausted",
|
|
383
|
+
isLoading: false,
|
|
384
|
+
loadMore: (_numItems: number) => {
|
|
385
|
+
// Intentional noop.
|
|
386
|
+
},
|
|
387
|
+
} as const;
|
|
388
|
+
}
|
|
389
|
+
const continueCursor = maybeLastResult.continueCursor;
|
|
390
|
+
let alreadyLoadingMore = false;
|
|
391
|
+
return {
|
|
392
|
+
status: "CanLoadMore",
|
|
393
|
+
isLoading: false,
|
|
394
|
+
loadMore: (numItems: number) => {
|
|
395
|
+
if (!alreadyLoadingMore) {
|
|
396
|
+
alreadyLoadingMore = true;
|
|
397
|
+
setState((prevState) => {
|
|
398
|
+
let nextPageKey = prevState.nextPageKey;
|
|
399
|
+
const queries = { ...prevState.queries };
|
|
400
|
+
let ongoingSplits = prevState.ongoingSplits;
|
|
401
|
+
// Connect the current last page to the next page
|
|
402
|
+
// by setting the endCursor of the last page to the continueCursor
|
|
403
|
+
// of the next page.
|
|
404
|
+
const lastPageKey = prevState.pageKeys.at(-1)!;
|
|
405
|
+
const boundLastPageKey = nextPageKey;
|
|
406
|
+
queries[boundLastPageKey] = {
|
|
407
|
+
query: prevState.query,
|
|
408
|
+
args: {
|
|
409
|
+
...prevState.args,
|
|
410
|
+
paginationOpts: {
|
|
411
|
+
...(queries[lastPageKey]!.args
|
|
412
|
+
.paginationOpts as unknown as PaginationOptions),
|
|
413
|
+
endCursor: continueCursor,
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
nextPageKey++;
|
|
418
|
+
ongoingSplits = {
|
|
419
|
+
...ongoingSplits,
|
|
420
|
+
[lastPageKey]: [boundLastPageKey, nextPageKey],
|
|
421
|
+
};
|
|
422
|
+
queries[nextPageKey] = {
|
|
423
|
+
query: prevState.query,
|
|
424
|
+
args: {
|
|
425
|
+
...prevState.args,
|
|
426
|
+
paginationOpts: {
|
|
427
|
+
numItems,
|
|
428
|
+
cursor: continueCursor,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
nextPageKey++;
|
|
433
|
+
return {
|
|
434
|
+
...prevState,
|
|
435
|
+
nextPageKey,
|
|
436
|
+
queries,
|
|
437
|
+
ongoingSplits,
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
} as const;
|
|
443
|
+
}, [maybeLastResult, currState.nextPageKey]);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
results,
|
|
447
|
+
...statusObject,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* A {@link server.FunctionReference} that is usable with {@link usePaginatedQuery}.
|
|
453
|
+
*
|
|
454
|
+
* This function reference must:
|
|
455
|
+
* - Refer to a public query
|
|
456
|
+
* - Have an argument named "paginationOpts" of type {@link server.PaginationOptions}
|
|
457
|
+
* - Have a return type of {@link server.PaginationResult}.
|
|
458
|
+
*
|
|
459
|
+
* @public
|
|
460
|
+
*/
|
|
461
|
+
|
|
462
|
+
// Incrementing integer for each page queried in the usePaginatedQuery hook.
|
|
463
|
+
type QueryPageKey = number;
|
|
464
|
+
|
|
465
|
+
type UsePaginatedQueryState = {
|
|
466
|
+
query: FunctionReference<"query">;
|
|
467
|
+
args: Record<string, Value>;
|
|
468
|
+
nextPageKey: QueryPageKey;
|
|
469
|
+
pageKeys: QueryPageKey[];
|
|
470
|
+
queries: Record<
|
|
471
|
+
QueryPageKey,
|
|
472
|
+
{
|
|
473
|
+
query: FunctionReference<"query">;
|
|
474
|
+
// Use the validator type as a test that it matches the args
|
|
475
|
+
// we generate.
|
|
476
|
+
args: { paginationOpts: Infer<typeof paginationOptsValidator> };
|
|
477
|
+
}
|
|
478
|
+
>;
|
|
479
|
+
ongoingSplits: Record<QueryPageKey, [QueryPageKey, QueryPageKey]>;
|
|
480
|
+
skip: boolean;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
function splitQuery(
|
|
484
|
+
key: QueryPageKey,
|
|
485
|
+
splitCursor: string,
|
|
486
|
+
continueCursor: string,
|
|
487
|
+
) {
|
|
488
|
+
return (prevState: UsePaginatedQueryState) => {
|
|
489
|
+
const queries = { ...prevState.queries };
|
|
490
|
+
const splitKey1 = prevState.nextPageKey;
|
|
491
|
+
const splitKey2 = prevState.nextPageKey + 1;
|
|
492
|
+
const nextPageKey = prevState.nextPageKey + 2;
|
|
493
|
+
queries[splitKey1] = {
|
|
494
|
+
query: prevState.query,
|
|
495
|
+
args: {
|
|
496
|
+
...prevState.args,
|
|
497
|
+
paginationOpts: {
|
|
498
|
+
...prevState.queries[key]!.args.paginationOpts,
|
|
499
|
+
endCursor: splitCursor,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
queries[splitKey2] = {
|
|
504
|
+
query: prevState.query,
|
|
505
|
+
args: {
|
|
506
|
+
...prevState.args,
|
|
507
|
+
paginationOpts: {
|
|
508
|
+
...prevState.queries[key]!.args.paginationOpts,
|
|
509
|
+
cursor: splitCursor,
|
|
510
|
+
endCursor: continueCursor,
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
const ongoingSplits = { ...prevState.ongoingSplits };
|
|
515
|
+
ongoingSplits[key] = [splitKey1, splitKey2];
|
|
516
|
+
return {
|
|
517
|
+
...prevState,
|
|
518
|
+
nextPageKey,
|
|
519
|
+
queries,
|
|
520
|
+
ongoingSplits,
|
|
521
|
+
};
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function completeSplitQuery(key: QueryPageKey) {
|
|
526
|
+
return (prevState: UsePaginatedQueryState) => {
|
|
527
|
+
const completedSplit = prevState.ongoingSplits[key];
|
|
528
|
+
if (completedSplit === undefined) {
|
|
529
|
+
return prevState;
|
|
530
|
+
}
|
|
531
|
+
const queries = { ...prevState.queries };
|
|
532
|
+
delete queries[key];
|
|
533
|
+
const ongoingSplits = { ...prevState.ongoingSplits };
|
|
534
|
+
delete ongoingSplits[key];
|
|
535
|
+
let pageKeys = prevState.pageKeys.slice();
|
|
536
|
+
const pageIndex = prevState.pageKeys.findIndex((v) => v === key);
|
|
537
|
+
if (pageIndex >= 0) {
|
|
538
|
+
pageKeys = [
|
|
539
|
+
...prevState.pageKeys.slice(0, pageIndex),
|
|
540
|
+
...completedSplit,
|
|
541
|
+
...prevState.pageKeys.slice(pageIndex + 1),
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
...prevState,
|
|
546
|
+
queries,
|
|
547
|
+
pageKeys,
|
|
548
|
+
ongoingSplits,
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
}
|
package/server/migrations.d.ts
CHANGED
|
@@ -115,8 +115,8 @@ export declare const migrationsTable: import("convex/server").TableDefinition<im
|
|
|
115
115
|
workerId?: GenericId<"_scheduled_functions"> | undefined;
|
|
116
116
|
latestEnd?: number | undefined;
|
|
117
117
|
cursor: string | null;
|
|
118
|
-
name: string;
|
|
119
118
|
isDone: boolean;
|
|
119
|
+
name: string;
|
|
120
120
|
table: string;
|
|
121
121
|
processed: number;
|
|
122
122
|
latestStart: number;
|
|
@@ -129,7 +129,7 @@ export declare const migrationsTable: import("convex/server").TableDefinition<im
|
|
|
129
129
|
processed: import("convex/values").VFloat64<number, "required">;
|
|
130
130
|
latestStart: import("convex/values").VFloat64<number, "required">;
|
|
131
131
|
latestEnd: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
132
|
-
}, "required", "cursor" | "
|
|
132
|
+
}, "required", "cursor" | "isDone" | "name" | "table" | "workerId" | "processed" | "latestStart" | "latestEnd">, {
|
|
133
133
|
name: ["name", "_creationTime"];
|
|
134
134
|
}, {}, {}>;
|
|
135
135
|
declare const migrationArgs: {
|
|
@@ -283,8 +283,8 @@ export declare function cancelMigration<DataModel extends GenericDataModel>(ctx:
|
|
|
283
283
|
workerId?: GenericId<"_scheduled_functions"> | undefined;
|
|
284
284
|
latestEnd?: number | undefined;
|
|
285
285
|
cursor: string | null;
|
|
286
|
-
name: string;
|
|
287
286
|
isDone: boolean;
|
|
287
|
+
name: string;
|
|
288
288
|
table: string;
|
|
289
289
|
processed: number;
|
|
290
290
|
latestStart: number;
|
|
@@ -293,8 +293,8 @@ export declare function cancelMigration<DataModel extends GenericDataModel>(ctx:
|
|
|
293
293
|
workerId?: GenericId<"_scheduled_functions"> | undefined;
|
|
294
294
|
latestEnd?: number | undefined;
|
|
295
295
|
cursor: string | null;
|
|
296
|
-
name: string;
|
|
297
296
|
isDone: boolean;
|
|
297
|
+
name: string;
|
|
298
298
|
table: string;
|
|
299
299
|
processed: number;
|
|
300
300
|
latestStart: number;
|
package/validators.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export declare const literals: <V extends string | number | boolean | bigint, T
|
|
|
25
25
|
* @param x The validator to make nullable. As in, it can be the value or null.
|
|
26
26
|
* @returns A new validator that can be the value or null.
|
|
27
27
|
*/
|
|
28
|
-
export declare const nullable: <V extends Validator<any, "required", any>>(x: V) => VUnion<(
|
|
28
|
+
export declare const nullable: <V extends Validator<any, "required", any>>(x: V) => VUnion<(import("convex/values").VNull<null, "required"> | V)["type"], [import("convex/values").VNull<null, "required">, V], "required", (import("convex/values").VNull<null, "required"> | V)["fieldPaths"]>;
|
|
29
29
|
/**
|
|
30
30
|
* partial helps you define an object of optional validators more concisely.
|
|
31
31
|
*
|