@gaganref/confect-react 0.0.0-stage → 10.3.1-fork.10

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/src/index.ts ADDED
@@ -0,0 +1,737 @@
1
+ import { Ref } from "@confect/core";
2
+ import { version as convexVersion } from "convex";
3
+ import type { OptimisticUpdate as ConvexOptimisticUpdate } from "convex/browser";
4
+ import * as ConvexReact from "convex/react";
5
+ import type { usePaginatedQuery as useConvexPaginatedQuery } from "convex/react";
6
+ import {
7
+ useAction as useConvexAction,
8
+ useMutation as useConvexMutation,
9
+ useQueries as useConvexQueries,
10
+ useQuery as useConvexQuery,
11
+ type ReactMutation as ConvexReactMutation,
12
+ } from "convex/react";
13
+ import { getFunctionName, type FunctionReference } from "convex/server";
14
+ import { ConvexError, convexToJson, type Value } from "convex/values";
15
+ import * as Cause from "effect/Cause";
16
+ import * as Effect from "effect/Effect";
17
+ import * as Result from "effect/Result";
18
+ import * as Exit from "effect/Exit";
19
+ import * as Match from "effect/Match";
20
+ import * as Option from "effect/Option";
21
+ import { useCallback, useMemo, useState } from "react";
22
+
23
+ import * as OptimisticLocalStore from "./OptimisticLocalStore.js";
24
+ import * as PaginatedQueryResult from "./PaginatedQueryResult.js";
25
+ import * as QueryResult from "./QueryResult.js";
26
+ import * as StreamPagination from "./StreamPagination.js";
27
+
28
+ export {
29
+ OptimisticLocalStore,
30
+ PaginatedQueryResult,
31
+ QueryResult,
32
+ StreamPagination,
33
+ };
34
+
35
+ export type InvokeReturn<Ref_ extends Ref.Any> = [Ref.Error<Ref_>] extends [
36
+ never,
37
+ ]
38
+ ? Promise<Ref.Returns<Ref_>>
39
+ : Promise<Result.Result<Ref.Returns<Ref_>, Ref.Error<Ref_>>>;
40
+
41
+ type UseQueryArgs<Query extends Ref.AnyPublicQuery> =
42
+ keyof Ref.Args<Query> extends never
43
+ ? [args?: Ref.Args<Query> | "skip"]
44
+ : [args: Ref.Args<Query> | "skip"];
45
+
46
+ export const useQuery = <Query extends Ref.AnyPublicQuery>(
47
+ ref: Query,
48
+ ...rest: UseQueryArgs<Query>
49
+ ): QueryResult.QueryResult<Ref.Returns<Query>, Ref.Error<Query>> => {
50
+ const functionReference = Ref.getFunctionReference(ref);
51
+ const args = rest[0];
52
+ const skipped = args === "skip";
53
+ const encodedArgs = skipped
54
+ ? "skip"
55
+ : Ref.encodeArgsSync(ref, (args ?? {}) as Ref.Args<Query>);
56
+
57
+ // `useConvexQuery` returns a referentially stable value while the underlying
58
+ // Convex result is unchanged, and throws a stable error when the query
59
+ // fails. We capture either outcome as a `Result` and decode/wrap it inside
60
+ // `useMemo` so that the returned `QueryResult` keeps a stable identity across
61
+ // renders when nothing has actually changed. Decoding on every render would
62
+ // hand consumers a fresh object each time, breaking effects and memoization
63
+ // that depend on the result's identity.
64
+ const encodedReturnsOrError: Result.Result<unknown, unknown> = Result.try(
65
+ () => useConvexQuery(functionReference, encodedArgs),
66
+ );
67
+
68
+ return useMemo(
69
+ () =>
70
+ Result.match(encodedReturnsOrError, {
71
+ onSuccess: (encodedReturnsOrUndefined) =>
72
+ encodedReturnsOrUndefined === undefined
73
+ ? QueryResult.load(skipped)
74
+ : QueryResult.succeed(
75
+ Ref.decodeReturnsSync(ref, encodedReturnsOrUndefined),
76
+ ),
77
+ onFailure: (error) => {
78
+ if (Ref.isConvexError(error)) {
79
+ const decoded = Ref.decodeErrorOption(ref, error.data);
80
+ if (Option.isSome(decoded)) {
81
+ return QueryResult.fail(decoded.value);
82
+ }
83
+ }
84
+ throw error;
85
+ },
86
+ }),
87
+ // `Result.try` allocates a fresh wrapper each render, so we key the memo on
88
+ // the stable value it carries (the Convex result or thrown error) rather
89
+ // than the wrapper itself; the decoded result is a function of that value,
90
+ // `ref`, and `skipped`.
91
+ [ref, skipped, Result.merge(encodedReturnsOrError)],
92
+ );
93
+ };
94
+
95
+ /**
96
+ * Like `Omit`, but implemented with key remapping so that optional modifiers
97
+ * are preserved and index signatures don't reintroduce the omitted key's
98
+ * literal type. Mirrors `BetterOmit` from `convex/server`.
99
+ */
100
+ type OmitKey<T, K extends PropertyKey> = {
101
+ [P in keyof T as P extends K ? never : P]: T[P];
102
+ };
103
+
104
+ /**
105
+ * The args accepted by {@link usePaginatedQuery}: the ref's args without
106
+ * `paginationOpts`, which the underlying Convex hook manages itself. When no
107
+ * args remain, resolves to `Record<string, never>` (only the empty object is
108
+ * accepted) rather than `{}`, which would accept any non-nullish value.
109
+ */
110
+ export type PaginatedQueryArgs<Query extends Ref.AnyPublicPaginatedQuery> =
111
+ OmitKey<Ref.Args<Query>, "paginationOpts"> extends infer Args_
112
+ ? keyof Args_ extends never
113
+ ? Record<string, never>
114
+ : Args_
115
+ : never;
116
+
117
+ /**
118
+ * The item type for a paginated query: the element type of the `page` field of
119
+ * the ref's returns.
120
+ */
121
+ export type PaginatedQueryItem<Query extends Ref.AnyPublicPaginatedQuery> =
122
+ Ref.Returns<Query>["page"][number];
123
+
124
+ /**
125
+ * The args parameter of {@link usePaginatedQuery}: the ref's args without
126
+ * `paginationOpts`, or `"skip"` to skip the query.
127
+ */
128
+ export type UsePaginatedQueryArgs<Query extends Ref.AnyPublicPaginatedQuery> =
129
+ | PaginatedQueryArgs<Query>
130
+ | "skip";
131
+
132
+ export type PaginatedQueryOptions = Parameters<
133
+ typeof useConvexPaginatedQuery
134
+ >[2];
135
+
136
+ /**
137
+ * The `user`-facing result of `usePaginatedQueryInternal` from `convex/react`,
138
+ * including the `"Error"` status its non-throwing mode can return.
139
+ */
140
+ interface ConvexPaginatedQueryInternalUser {
141
+ readonly results: ReadonlyArray<unknown>;
142
+ readonly status:
143
+ | "LoadingFirstPage"
144
+ | "LoadingMore"
145
+ | "CanLoadMore"
146
+ | "Exhausted"
147
+ | "Error";
148
+ readonly isLoading: boolean;
149
+ readonly loadMore: (numItems: number) => void;
150
+ readonly error?: unknown;
151
+ }
152
+
153
+ type UseConvexPaginatedQueryInternal = (
154
+ query: FunctionReference<"query">,
155
+ args: Record<string, Value> | "skip",
156
+ options: PaginatedQueryOptions,
157
+ throwOnError: boolean,
158
+ ) => { user: ConvexPaginatedQueryInternalUser };
159
+
160
+ // `usePaginatedQueryInternal` is runtime-exported by convex/react but absent
161
+ // from its public type declarations. It is the only way to observe query
162
+ // errors as values: the public positional `usePaginatedQuery` hard-codes
163
+ // throwOnError and throws mid-hook-sequence, which cannot be caught without
164
+ // breaking React's hook-order invariant.
165
+ const useConvexPaginatedQueryInternal = (
166
+ ConvexReact as unknown as Record<string, unknown>
167
+ ).usePaginatedQueryInternal as UseConvexPaginatedQueryInternal | undefined;
168
+
169
+ const MINIMUM_CONVEX_VERSION = "1.36.0";
170
+
171
+ /**
172
+ * The non-throwing mode of `usePaginatedQueryInternal`—its fourth,
173
+ * `throwOnError` parameter—arrived in convex 1.36.0. Convex 1.32 through 1.35
174
+ * export the same symbol taking only three parameters, where a fourth argument
175
+ * is silently ignored and errors are always thrown.
176
+ *
177
+ * The function itself cannot be interrogated for this: `Function.length` counts
178
+ * parameters up to the first one with a default, and 1.36.0 declares
179
+ * `throwOnError = true`, so both shapes report an arity of 3. The package
180
+ * version is the only reliable signal.
181
+ */
182
+ const supportsErrorsAsValues = ((): boolean => {
183
+ const [major, minor] = convexVersion.split(".").map(Number);
184
+ if (
185
+ major === undefined ||
186
+ minor === undefined ||
187
+ Number.isNaN(major) ||
188
+ Number.isNaN(minor)
189
+ ) {
190
+ return false;
191
+ }
192
+ const [minimumMajor, minimumMinor] = MINIMUM_CONVEX_VERSION.split(".").map(
193
+ Number,
194
+ ) as [number, number];
195
+ return (
196
+ major > minimumMajor || (major === minimumMajor && minor >= minimumMinor)
197
+ );
198
+ })();
199
+
200
+ /**
201
+ * Load data reactively from a paginated query defined with
202
+ * `FunctionSpec.publicPaginatedQuery`, mirroring the ergonomics of
203
+ * `usePaginatedQuery` from `convex/react`.
204
+ *
205
+ * Args are encoded via the ref's user-args schema (`paginationOpts` is managed
206
+ * by the Convex hook, not the caller), and each loaded page is decoded via the
207
+ * ref's item schema.
208
+ *
209
+ * Returns a {@link PaginatedQueryResult.PaginatedQueryResult}: the loaded
210
+ * variants carry `results` and `isLoading`, and `CanLoadMore` additionally
211
+ * carries `loadMore`; if the `Ref` declares an `error` schema and the query
212
+ * fails with that typed error, the decoded error is returned as the `Failure`
213
+ * variant, which also carries the pages loaded before the failure. Unknown
214
+ * errors are thrown, to be caught by an error boundary.
215
+ */
216
+ export const usePaginatedQuery = <Query extends Ref.AnyPublicPaginatedQuery>(
217
+ ref: Query,
218
+ args: UsePaginatedQueryArgs<Query>,
219
+ options: PaginatedQueryOptions,
220
+ ): PaginatedQueryResult.PaginatedQueryResult<
221
+ PaginatedQueryItem<Query>,
222
+ Ref.Error<Query>
223
+ > => {
224
+ if (
225
+ useConvexPaginatedQueryInternal === undefined ||
226
+ !supportsErrorsAsValues
227
+ ) {
228
+ throw new Error(
229
+ `usePaginatedQuery requires convex >= ${MINIMUM_CONVEX_VERSION}, but found ` +
230
+ `${convexVersion}. Earlier versions always throw paginated query errors ` +
231
+ "instead of returning them, so a declared `error` schema cannot be " +
232
+ "surfaced as a `Failure`—upgrade the `convex` package.",
233
+ );
234
+ }
235
+
236
+ const functionReference = Ref.getFunctionReference(ref);
237
+ const skipped = args === "skip";
238
+
239
+ // The encoded output is the wire form Convex's hook forwards verbatim, not
240
+ // the ref's decoded args type.
241
+ const encodedArgs = useMemo(
242
+ () =>
243
+ args === "skip"
244
+ ? ("skip" as const)
245
+ : (Ref.encodePaginatedQueryArgsSync(
246
+ ref,
247
+ args as unknown as Omit<Ref.Args<Query>, "paginationOpts">,
248
+ ) as Record<string, Value>),
249
+ [ref, args],
250
+ );
251
+
252
+ const { user: convexResult } = useConvexPaginatedQueryInternal(
253
+ functionReference,
254
+ encodedArgs,
255
+ options,
256
+ false,
257
+ );
258
+
259
+ // The Convex hook carries the pages loaded so far on every status,
260
+ // failures included, so results are decoded unconditionally.
261
+ //
262
+ // Decoding allocates fresh items, so key on the referentially stable
263
+ // `results` the Convex hook provides—the same identity-preservation
264
+ // rationale as in `useQuery` above.
265
+ const { results: encodedResults } = convexResult;
266
+ const decodedResults = useMemo(
267
+ () => Ref.decodePaginationPageSync(ref, encodedResults),
268
+ [ref, encodedResults],
269
+ );
270
+
271
+ const { status, loadMore } = convexResult;
272
+ const error = status === "Error" ? convexResult.error : undefined;
273
+
274
+ return useMemo(
275
+ (): PaginatedQueryResult.Variants<
276
+ PaginatedQueryItem<Query>,
277
+ Ref.Error<Query>
278
+ > =>
279
+ // `status` is a bare string union rather than a tagged one, so the arms
280
+ // match on literals.
281
+ Match.value(status).pipe(
282
+ Match.when("Error", () => {
283
+ if (Ref.isConvexError(error)) {
284
+ const decoded = Ref.decodeErrorOption(ref, error.data);
285
+ if (Option.isSome(decoded)) {
286
+ return PaginatedQueryResult.failure({
287
+ error: decoded.value,
288
+ results: decodedResults,
289
+ });
290
+ }
291
+ }
292
+ // Unknown errors still throw. All hooks have run by this point, so
293
+ // an aborted render here is hook-order-safe.
294
+ throw error;
295
+ }),
296
+ Match.when("LoadingFirstPage", () =>
297
+ PaginatedQueryResult.loadingFirstPage({ skipped }),
298
+ ),
299
+ Match.when("LoadingMore", () =>
300
+ PaginatedQueryResult.loadingMore({ results: decodedResults }),
301
+ ),
302
+ // `CanLoadMore` is the only status whose `loadMore` does anything—the Convex hook returns an intentional no-op for all the others, so
303
+ // they carry no callback at all.
304
+ Match.when("CanLoadMore", () =>
305
+ PaginatedQueryResult.canLoadMore({
306
+ results: decodedResults,
307
+ loadMore,
308
+ }),
309
+ ),
310
+ Match.when("Exhausted", () =>
311
+ PaginatedQueryResult.exhausted({ results: decodedResults }),
312
+ ),
313
+ Match.exhaustive,
314
+ ),
315
+ // The memo produces `Variants`, the conditional return type's superset—when `E` is `never` the `Failure` arm is unreachable at runtime, which
316
+ // is exactly what the conditional encodes.
317
+ [ref, skipped, decodedResults, status, loadMore, error],
318
+ ) as PaginatedQueryResult.PaginatedQueryResult<
319
+ PaginatedQueryItem<Query>,
320
+ Ref.Error<Query>
321
+ >;
322
+ };
323
+
324
+ /**
325
+ * Whether a paginated query error means the stored cursors no longer match the
326
+ * query (a data-dependent query changed underneath us), calling for a full
327
+ * pagination reset rather than a failure.
328
+ */
329
+ const isInvalidCursorError = (error: Error): boolean =>
330
+ error.message.includes("InvalidCursor") ||
331
+ (error instanceof ConvexError &&
332
+ typeof error.data === "object" &&
333
+ error.data !== null &&
334
+ // Both Convex's built-in pagination (which also sets
335
+ // `isConvexSystemError`) and `QueryStream.paginate` signal an invalid
336
+ // cursor with this data shape—the same check `convex/react` performs.
337
+ (error.data as { paginationError?: unknown }).paginationError ===
338
+ "InvalidCursor");
339
+
340
+ const NO_ITEMS: ReadonlyArray<unknown> = [];
341
+
342
+ /**
343
+ * EXPERIMENTAL—endCursor-pinned reactive pagination (see
344
+ * `notes/stream-based-querying.md`). Use it with paginated queries whose
345
+ * handlers paginate via `QueryStream.paginate`: those don't write the query
346
+ * journal that {@link usePaginatedQuery}'s built-in reactivity relies on, so
347
+ * gap-free pages must be maintained by the client instead. (It works with any
348
+ * paginated query honoring the `endCursor` protocol field, including the
349
+ * built-in `paginate`.)
350
+ *
351
+ * Each loaded page is pinned to a fixed index range by re-subscribing it with
352
+ * its `continueCursor` echoed back as `endCursor`—pages then grow and shrink
353
+ * reactively but always meet exactly, and a page that outgrows
354
+ * `initialNumItems` is split in two. This is the mechanism of
355
+ * `convex-helpers/react`'s `usePaginatedQuery`, re-expressed over the pure
356
+ * {@link StreamPagination} state machine, with args/items/errors codec'd through
357
+ * the ref's schemas exactly like {@link usePaginatedQuery}.
358
+ */
359
+ export const useStreamPaginatedQuery = <
360
+ Query extends Ref.AnyPublicPaginatedQuery,
361
+ >(
362
+ ref: Query,
363
+ args: UsePaginatedQueryArgs<Query>,
364
+ options: {
365
+ readonly initialNumItems: number;
366
+ /**
367
+ * Per-page read budgets forwarded as `paginationOpts.maximumRowsRead` and
368
+ * `paginationOpts.maximumBytesRead`: a page that would scan more rows, or
369
+ * read more bytes, than these returns truncated with `SplitRequired` (and
370
+ * the hook splits it) instead of exceeding Convex's query limits on a
371
+ * filter-heavy stream.
372
+ */
373
+ readonly maximumRowsRead?: number;
374
+ readonly maximumBytesRead?: number;
375
+ },
376
+ ): PaginatedQueryResult.PaginatedQueryResult<
377
+ PaginatedQueryItem<Query>,
378
+ Ref.Error<Query>
379
+ > => {
380
+ // `useQueries` requires a *referentially stable* queries object while
381
+ // nothing has changed: its subscription memo keys on identity, and its
382
+ // `useSubscription` re-runs a render-phase state update whenever the
383
+ // subscription changes—an unstable input loops the render. Callers
384
+ // typically pass a fresh `args` literal each render, so everything that
385
+ // feeds the queries object is stabilized by *value* here: the function
386
+ // reference by `ref`, and the encoded args by their serialized form (as
387
+ // `convex/react`'s own paginated hook does).
388
+ const functionReference = useMemo(() => Ref.getFunctionReference(ref), [ref]);
389
+ const skipped = args === "skip";
390
+
391
+ const rawEncodedArgs =
392
+ args === "skip"
393
+ ? undefined
394
+ : (Ref.encodePaginatedQueryArgsSync(
395
+ ref,
396
+ args as unknown as Omit<Ref.Args<Query>, "paginationOpts">,
397
+ ) as Record<string, Value>);
398
+ const encodedArgsKey =
399
+ rawEncodedArgs === undefined
400
+ ? "skip"
401
+ : JSON.stringify(convexToJson(rawEncodedArgs));
402
+ const encodedArgs = useMemo(
403
+ () => rawEncodedArgs,
404
+ // The serialized form stands in for the freshly allocated object.
405
+ [ref, encodedArgsKey],
406
+ );
407
+
408
+ // A change of query, args, skippedness, or page size restarts pagination
409
+ // from scratch.
410
+ const resetKey = useMemo(
411
+ () =>
412
+ JSON.stringify([
413
+ getFunctionName(functionReference),
414
+ encodedArgsKey,
415
+ options.initialNumItems,
416
+ options.maximumRowsRead ?? null,
417
+ options.maximumBytesRead ?? null,
418
+ ]),
419
+ [
420
+ functionReference,
421
+ encodedArgsKey,
422
+ options.initialNumItems,
423
+ options.maximumRowsRead,
424
+ options.maximumBytesRead,
425
+ ],
426
+ );
427
+
428
+ const freshState = () =>
429
+ skipped
430
+ ? StreamPagination.empty
431
+ : StreamPagination.initial(options.initialNumItems, options);
432
+
433
+ const [tracked, setTracked] = useState(() => ({
434
+ resetKey,
435
+ state: freshState(),
436
+ }));
437
+ // Render-phase reset—the React-sanctioned derived-state-from-props
438
+ // pattern, also how `convex/react`'s own paginated hook resets.
439
+ let current = tracked;
440
+ if (current.resetKey !== resetKey) {
441
+ current = { resetKey, state: freshState() };
442
+ setTracked(current);
443
+ }
444
+
445
+ const applyTransitions = useCallback(
446
+ (
447
+ transitions: ReadonlyArray<
448
+ (state: StreamPagination.State) => StreamPagination.State
449
+ >,
450
+ ) =>
451
+ setTracked((previous) => ({
452
+ resetKey: previous.resetKey,
453
+ state: transitions.reduce(
454
+ (state, transition) => transition(state),
455
+ previous.state,
456
+ ),
457
+ })),
458
+ [],
459
+ );
460
+
461
+ const queries = useMemo(
462
+ () =>
463
+ StreamPagination.pageRequests(current.state, (page) => ({
464
+ query: functionReference,
465
+ args: { ...encodedArgs, paginationOpts: page },
466
+ })),
467
+ [current.state, functionReference, encodedArgs],
468
+ );
469
+
470
+ const resultsObject = useConvexQueries(
471
+ queries as unknown as Parameters<typeof useConvexQueries>[0],
472
+ );
473
+
474
+ const interpretation = useMemo(
475
+ () =>
476
+ StreamPagination.interpret(
477
+ current.state,
478
+ resultsObject as StreamPagination.Results,
479
+ { initialNumItems: options.initialNumItems, isInvalidCursorError },
480
+ ),
481
+ [current.state, resultsObject, options.initialNumItems],
482
+ );
483
+
484
+ // Apply the transitions this render discovered (completed split swaps,
485
+ // new splits of overgrown pages) or restart after an invalid cursor—render-phase state updates, as in `convex-helpers`' hook. Both settle:
486
+ // the updated state no longer produces the same discovery.
487
+ if (interpretation._tag === "ResetRequired") {
488
+ setTracked((previous) => ({
489
+ resetKey: previous.resetKey,
490
+ state: freshState(),
491
+ }));
492
+ } else if (
493
+ interpretation._tag === "Interpreted" &&
494
+ interpretation.transitions.length > 0
495
+ ) {
496
+ applyTransitions(interpretation.transitions);
497
+ }
498
+
499
+ const encodedItems =
500
+ interpretation._tag === "ResetRequired" ? NO_ITEMS : interpretation.items;
501
+ // Page results keep their identity while unchanged (only the page that
502
+ // actually updated is a fresh object), so caching decodes per encoded
503
+ // item makes a single-page update decode only that page's items rather
504
+ // than every loaded page's.
505
+ const decodeCache = useMemo(
506
+ () => new WeakMap<object, PaginatedQueryItem<Query>>(),
507
+ [ref],
508
+ );
509
+ const decodedResults = useMemo(
510
+ () =>
511
+ encodedItems.map((item): PaginatedQueryItem<Query> => {
512
+ if (typeof item !== "object" || item === null) {
513
+ return Ref.decodePaginationPageSync(ref, [
514
+ item,
515
+ ])[0] as PaginatedQueryItem<Query>;
516
+ }
517
+ const cached = decodeCache.get(item);
518
+ if (cached !== undefined) {
519
+ return cached;
520
+ }
521
+ const decoded = Ref.decodePaginationPageSync(ref, [
522
+ item,
523
+ ])[0] as PaginatedQueryItem<Query>;
524
+ decodeCache.set(item, decoded);
525
+ return decoded;
526
+ }),
527
+ [ref, encodedItems, decodeCache],
528
+ );
529
+
530
+ return useMemo((): PaginatedQueryResult.Variants<
531
+ PaginatedQueryItem<Query>,
532
+ Ref.Error<Query>
533
+ > => {
534
+ if (interpretation._tag === "ResetRequired") {
535
+ return PaginatedQueryResult.loadingFirstPage({ skipped });
536
+ }
537
+ if (interpretation._tag === "Failed") {
538
+ const error = interpretation.error;
539
+ if (Ref.isConvexError(error)) {
540
+ const decoded = Ref.decodeErrorOption(ref, error.data);
541
+ if (Option.isSome(decoded)) {
542
+ return PaginatedQueryResult.failure({
543
+ error: decoded.value,
544
+ results: decodedResults,
545
+ });
546
+ }
547
+ }
548
+ // Unknown errors still throw, to be caught by an error boundary.
549
+ throw error;
550
+ }
551
+
552
+ const { lastResult } = interpretation;
553
+ if (Option.isNone(lastResult) && current.state.pageKeys.length <= 1) {
554
+ return PaginatedQueryResult.loadingFirstPage({ skipped });
555
+ }
556
+ if (
557
+ Option.isNone(lastResult) ||
558
+ StreamPagination.isLastPageSplitting(current.state)
559
+ ) {
560
+ return PaginatedQueryResult.loadingMore({ results: decodedResults });
561
+ }
562
+ if (lastResult.value.isDone) {
563
+ return PaginatedQueryResult.exhausted({ results: decodedResults });
564
+ }
565
+
566
+ const continueCursor = lastResult.value.continueCursor;
567
+ // A per-result guard, as in `convex-helpers`: calling `loadMore`
568
+ // repeatedly before the next render is a single load.
569
+ let alreadyLoadingMore = false;
570
+ return PaginatedQueryResult.canLoadMore({
571
+ results: decodedResults,
572
+ loadMore: (numItems: number) => {
573
+ if (!alreadyLoadingMore) {
574
+ alreadyLoadingMore = true;
575
+ applyTransitions([
576
+ StreamPagination.loadMore(continueCursor, numItems, options),
577
+ ]);
578
+ }
579
+ },
580
+ });
581
+ }, [
582
+ interpretation,
583
+ decodedResults,
584
+ current.state,
585
+ skipped,
586
+ ref,
587
+ applyTransitions,
588
+ ]) as PaginatedQueryResult.PaginatedQueryResult<
589
+ PaginatedQueryItem<Query>,
590
+ Ref.Error<Query>
591
+ >;
592
+ };
593
+
594
+ /**
595
+ * An optimistic update for a Confect mutation. Mirrors Convex's
596
+ * `OptimisticUpdate`, but receives a Confect {@link OptimisticLocalStore} and
597
+ * the decoded mutation `args`.
598
+ */
599
+ export type OptimisticUpdate<Mutation extends Ref.AnyPublicMutation> = (
600
+ localStore: OptimisticLocalStore.OptimisticLocalStore,
601
+ args: Ref.Args<Mutation>,
602
+ ) => void;
603
+
604
+ /**
605
+ * The handle returned by {@link useMutation}. It is callable like the function
606
+ * returned by Convex's `useMutation`, and additionally exposes
607
+ * `withOptimisticUpdate` for attaching an optimistic update. Mirrors the
608
+ * `ReactMutation` type from `convex/react`.
609
+ */
610
+ export interface ReactMutation<Mutation extends Ref.AnyPublicMutation> {
611
+ (...args: Ref.OptionalArgs<Mutation>): InvokeReturn<Mutation>;
612
+ withOptimisticUpdate(
613
+ optimisticUpdate: OptimisticUpdate<Mutation>,
614
+ ): ReactMutation<Mutation>;
615
+ }
616
+
617
+ const makeReactMutation = <Mutation extends Ref.AnyPublicMutation>(
618
+ ref: Mutation,
619
+ convexReactMutation: ConvexReactMutation<
620
+ Ref.FunctionReference<Mutation> & FunctionReference<"mutation">
621
+ >,
622
+ ): ReactMutation<Mutation> => {
623
+ const callable = ((...args: Ref.OptionalArgs<Mutation>) =>
624
+ invokeAsResult(
625
+ ref,
626
+ (_, encodedArgs) => convexReactMutation(encodedArgs as never),
627
+ args,
628
+ ).then((result) =>
629
+ Ref.hasErrorSchema(ref) ? result : Result.getOrThrow(result),
630
+ )) as (...args: Ref.OptionalArgs<Mutation>) => InvokeReturn<Mutation>;
631
+
632
+ const withOptimisticUpdate = (
633
+ optimisticUpdate: OptimisticUpdate<Mutation>,
634
+ ): ReactMutation<Mutation> => {
635
+ const wrappedUpdate: ConvexOptimisticUpdate<Record<string, Value>> = (
636
+ convexLocalStore,
637
+ encodedArgs,
638
+ ) => {
639
+ const decodedArgs = Ref.decodeArgsSync(ref, encodedArgs);
640
+ optimisticUpdate(
641
+ OptimisticLocalStore.make(convexLocalStore),
642
+ decodedArgs,
643
+ );
644
+ };
645
+ const nextConvexReactMutation =
646
+ convexReactMutation.withOptimisticUpdate(wrappedUpdate);
647
+ return makeReactMutation(ref, nextConvexReactMutation);
648
+ };
649
+
650
+ return Object.assign(callable, { withOptimisticUpdate });
651
+ };
652
+
653
+ /**
654
+ * Returns a {@link ReactMutation} handle for the provided `Ref`'s mutation. The
655
+ * handle is callable to invoke the mutation, and exposes `withOptimisticUpdate`
656
+ * for attaching an optimistic update, mirroring `useMutation` from
657
+ * `convex/react`.
658
+ *
659
+ * If the `Ref` declares an `error` schema, the returned promise resolves to a
660
+ * `Result` with the decoded `returns` value in the `Success` and the decoded
661
+ * error in the `Failure`.
662
+ *
663
+ * If the `Ref` does not declare an `error` schema, the promise resolves
664
+ * directly to the decoded `returns` value, matching the behavior of
665
+ * `useMutation` from `convex/react`.
666
+ *
667
+ * Any other failure rejects the promise.
668
+ */
669
+ export const useMutation = <Mutation extends Ref.AnyPublicMutation>(
670
+ ref: Mutation,
671
+ ): ReactMutation<Mutation> => {
672
+ const functionReference = Ref.getFunctionReference(ref);
673
+ const convexReactMutation = useConvexMutation(functionReference);
674
+
675
+ return useMemo(
676
+ () =>
677
+ makeReactMutation(
678
+ ref,
679
+ convexReactMutation as ConvexReactMutation<
680
+ Ref.FunctionReference<Mutation> & FunctionReference<"mutation">
681
+ >,
682
+ ),
683
+ [ref, convexReactMutation],
684
+ );
685
+ };
686
+
687
+ /**
688
+ * Returns a function that invokes the provided `Ref`'s action.
689
+ *
690
+ * If the `Ref` declares an `error` schema, the returned promise resolves to a
691
+ * `Result` with the decoded `returns` value in the `Success` and the decoded
692
+ * error in the `Failure`.
693
+ *
694
+ * If the `Ref` does not declare an `error` schema, the promise resolves
695
+ * directly to the decoded `returns` value, matching the behavior of
696
+ * `useMutation` from `convex/react`.
697
+ *
698
+ * Any other failure rejects the promise.
699
+ */
700
+ export const useAction = <Action extends Ref.AnyPublicAction>(
701
+ ref: Action,
702
+ ): ((...args: Ref.OptionalArgs<Action>) => InvokeReturn<Action>) => {
703
+ const functionReference = Ref.getFunctionReference(ref);
704
+ const actualAction = useConvexAction(functionReference);
705
+
706
+ return useCallback(
707
+ ((...args: Ref.OptionalArgs<Action>) =>
708
+ invokeAsResult(
709
+ ref,
710
+ (_, encodedArgs) => actualAction(encodedArgs),
711
+ args,
712
+ ).then((result) =>
713
+ Ref.hasErrorSchema(ref) ? result : Result.getOrThrow(result),
714
+ )) as (...args: Ref.OptionalArgs<Action>) => InvokeReturn<Action>,
715
+ [ref, actualAction],
716
+ );
717
+ };
718
+
719
+ const invokeAsResult = <Ref_ extends Ref.Any>(
720
+ ref: Ref_,
721
+ invoke: (
722
+ fnRef: Ref.FunctionReference<Ref_>,
723
+ encodedArgs: unknown,
724
+ ) => PromiseLike<unknown>,
725
+ args: Ref.OptionalArgs<Ref_>,
726
+ ): Promise<Result.Result<Ref.Returns<Ref_>, Ref.Error<Ref_>>> => {
727
+ const exitPromise = Effect.runPromiseExit(
728
+ Ref.runWithCodec(ref, (args[0] ?? {}) as Ref.Args<Ref_>, invoke).pipe(
729
+ Effect.catchTag("SchemaError", Effect.die),
730
+ Effect.result,
731
+ ),
732
+ );
733
+ return exitPromise.then((exit) => {
734
+ if (Exit.isSuccess(exit)) return exit.value;
735
+ throw Cause.squash(exit.cause);
736
+ });
737
+ };