@nkzw/fate-indexeddb 1.8.0 → 1.10.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 +477 -641
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ vp create fate
|
|
|
72
72
|
|
|
73
73
|
Explore the [fate stack](https://fate.technology/#stack) to see the tools included in your new project.
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
Choose a backend in the template selector: Void, tRPC with Drizzle or Prisma, GraphQL with Prisma, or a client for an existing GraphQL server. React is the default; choose Vue in the selector or pass `--framework vue`.
|
|
76
76
|
|
|
77
77
|
### Manual Installation
|
|
78
78
|
|
|
@@ -160,14 +160,9 @@ With fate, you no longer worry about _when_ to fetch data, how to coordinate loa
|
|
|
160
160
|
Let's start by defining a simple view for a blog's `Post` component. fate requires you to explicitly "select" each field that you plan to use in your components. Here is how you can define a view for a `Post` entity that has `title` and `content` fields:
|
|
161
161
|
|
|
162
162
|
```tsx
|
|
163
|
+
import type { Post } from '@your-org/server/views';
|
|
163
164
|
import { view } from 'react-fate';
|
|
164
165
|
|
|
165
|
-
type Post = {
|
|
166
|
-
content: string;
|
|
167
|
-
id: string;
|
|
168
|
-
title: string;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
166
|
export const PostView = view<Post>()({
|
|
172
167
|
content: true,
|
|
173
168
|
id: true,
|
|
@@ -177,9 +172,6 @@ export const PostView = view<Post>()({
|
|
|
177
172
|
|
|
178
173
|
Fields are selected by setting them to `true` in the view definition. This tells **_fate_** that these fields should be fetched from the server and made available to components that use this view.
|
|
179
174
|
|
|
180
|
-
> [!NOTE]
|
|
181
|
-
> The `Post` type above is an example. In a real application, this type is defined on the server and imported into your client code.
|
|
182
|
-
|
|
183
175
|
### Resolving a View with `useView`
|
|
184
176
|
|
|
185
177
|
Now we can use the view that we defined in a `PostCard` React component to resolve the data against a reference of an individual `Post`:
|
|
@@ -199,7 +191,7 @@ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
|
199
191
|
};
|
|
200
192
|
```
|
|
201
193
|
|
|
202
|
-
A `ViewRef`
|
|
194
|
+
A `ViewRef` refers to a specific object, for example a `Post` with ID `7`. fate creates these references for you so you can pass them to child components.
|
|
203
195
|
|
|
204
196
|
Components using `useView` listen to changes for all selected fields. When data changes, fate re-renders all of the fields that depend on that data. For example, if the `title` of the `Post` changes, the `PostCard` component re-renders with new data. However, if a different field such as `likes` that isn't selected in `PostView` changes, the `PostCard` component will not re-render.
|
|
205
197
|
|
|
@@ -369,6 +361,27 @@ export const PostView = view<Post>()({
|
|
|
369
361
|
|
|
370
362
|
Views are opaque objects. Even if you select the same field multiple times through different views, the composed object won't have conflicting fields or result in TypeScript errors. fate automatically deduplicates fields during runtime and ensures that each field is only fetched once.
|
|
371
363
|
|
|
364
|
+
### Resolving Multiple Refs with `useViews`
|
|
365
|
+
|
|
366
|
+
Use `useViews` when a component needs selected data from an array of refs, for example to filter or sort a list before rendering it:
|
|
367
|
+
|
|
368
|
+
```tsx
|
|
369
|
+
import { useViews, type ViewRef } from 'react-fate';
|
|
370
|
+
|
|
371
|
+
function PostTitles({ posts: postRefs }: { posts: ReadonlyArray<ViewRef<'Post'>> }) {
|
|
372
|
+
const posts = useViews(PostView, postRefs);
|
|
373
|
+
const sorted = [...posts].sort((a, b) => a.title.localeCompare(b.title));
|
|
374
|
+
|
|
375
|
+
return sorted.map((post) => <h2 key={post.id}>{post.title}</h2>);
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Call `useViews` once with the whole array instead of calling `useView` inside a loop. Results follow the input order and update when selected fields change. Like `useView`, the hook suspends while data is loading and sends read errors to the nearest error boundary.
|
|
380
|
+
|
|
381
|
+
The array can change between renders. Deferred refs are supported, and `null` or `undefined` entries keep their values in the result.
|
|
382
|
+
|
|
383
|
+
Vue provides the same API as a reactive resource. See [Resolving Arrays of Refs in Vue](/docs/guide/vue.md#resolving-arrays-of-refs).
|
|
384
|
+
|
|
372
385
|
### `useView` and Suspense
|
|
373
386
|
|
|
374
387
|
We learned that `useRequest` is responsible for fetching data from the server and `useView` is used for reading data from the cache. In some situations data may not be available in the cache and `useView` might need to suspend the component to fetch only the missing data. Once that data is fetched and written to the cache, the component resumes rendering.
|
|
@@ -430,8 +443,6 @@ const PostDetail = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
|
430
443
|
};
|
|
431
444
|
```
|
|
432
445
|
|
|
433
|
-
ViewRefs carry a set of view names they can resolve. `useView` throws if a ref does not include the required view.
|
|
434
|
-
|
|
435
446
|
### Parameters and Conditional Selections
|
|
436
447
|
|
|
437
448
|
A view can take parameters. Define the selection with a function, then bind its parameters where you request or compose the view:
|
|
@@ -463,7 +474,7 @@ function Profile({ userId, isViewer }: { userId: string; isViewer: boolean }) {
|
|
|
463
474
|
}
|
|
464
475
|
```
|
|
465
476
|
|
|
466
|
-
`alias(AccountView)` gives
|
|
477
|
+
`alias(AccountView)` gives us an `account` ref to the same user. It doesn't require an `account` field on the server. Read it with `useView`, just like any other ref.
|
|
467
478
|
|
|
468
479
|
`when(condition, selection)` works with named fragments and ordinary fields:
|
|
469
480
|
|
|
@@ -474,9 +485,7 @@ const ProfileView = view<User>()(({ isViewer }: { isViewer: boolean }) => ({
|
|
|
474
485
|
}));
|
|
475
486
|
```
|
|
476
487
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
Both `useView` and `useLiveView` accept `undefined` refs and return `undefined` without fetching or subscribing. Call the hooks unconditionally, as in the example above.
|
|
488
|
+
When the condition is false, the field is `undefined` and isn't fetched. `useView` and `useLiveView` accept that undefined ref, so you can call them unconditionally, as above.
|
|
480
489
|
|
|
481
490
|
#### Carrying Parameters with Refs
|
|
482
491
|
|
|
@@ -505,11 +514,9 @@ function Names({ user }: { user: ViewRef<'User'> }) {
|
|
|
505
514
|
}
|
|
506
515
|
```
|
|
507
516
|
|
|
508
|
-
|
|
517
|
+
Here `name` accepts a `locale` argument on the server. Both refs point to the same user, but each keeps its own parameters.
|
|
509
518
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
Bind a parameterized definition before using it in a request, nested selection, alias, or spread. If a single ref contains multiple bindings of one definition, use named aliases to give each binding its own ref. Conditional anonymous spreads are not supported; use `when(condition, alias(View))` instead.
|
|
519
|
+
Define views outside components, then pass parameters when composing or requesting them. No `useMemo` is needed. Use named aliases when you need several versions of the same view, or `when(condition, alias(View))` to include one conditionally.
|
|
513
520
|
|
|
514
521
|
#### Vue
|
|
515
522
|
|
|
@@ -520,7 +527,7 @@ const profile = useView(ProfileView, () => request.value?.user);
|
|
|
520
527
|
const account = useView(AccountView, () => profile.value?.account);
|
|
521
528
|
```
|
|
522
529
|
|
|
523
|
-
|
|
530
|
+
An undefined ref produces an undefined value without fetching.
|
|
524
531
|
|
|
525
532
|
## Requests
|
|
526
533
|
|
|
@@ -550,7 +557,7 @@ This component suspends or throws errors, which bubble up to the nearest error b
|
|
|
550
557
|
|
|
551
558
|
> [!NOTE]
|
|
552
559
|
>
|
|
553
|
-
>
|
|
560
|
+
> fate batches requests made together. With tRPC, configure [HTTP Batch Link](https://trpc.io/docs/client/links/httpBatchLink) to send them in one network request.
|
|
554
561
|
|
|
555
562
|
### Requesting Objects by ID
|
|
556
563
|
|
|
@@ -572,7 +579,7 @@ const { posts } = useRequest({
|
|
|
572
579
|
|
|
573
580
|
### Other Types of Requests
|
|
574
581
|
|
|
575
|
-
For
|
|
582
|
+
For queries such as the current user, use the root name and a view:
|
|
576
583
|
|
|
577
584
|
```tsx
|
|
578
585
|
const { viewer } = useRequest({
|
|
@@ -593,7 +600,7 @@ const { posts } = useRequest({
|
|
|
593
600
|
});
|
|
594
601
|
```
|
|
595
602
|
|
|
596
|
-
|
|
603
|
+
Lists with different filters or sorting arguments stay separate. Loading another page extends the same list. Views that select the same objects share their cached data, and fate fetches any missing fields.
|
|
597
604
|
|
|
598
605
|
### Request Modes
|
|
599
606
|
|
|
@@ -616,13 +623,57 @@ const { posts } = useRequest(
|
|
|
616
623
|
);
|
|
617
624
|
```
|
|
618
625
|
|
|
619
|
-
###
|
|
626
|
+
### Refreshing Requests
|
|
627
|
+
|
|
628
|
+
To refresh posts already displayed by `useRequest`, call `useRefetch` with the same request:
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
import { useRefetch } from 'react-fate';
|
|
632
|
+
import { PostView } from './PostCard.tsx';
|
|
633
|
+
|
|
634
|
+
export function RefreshButton() {
|
|
635
|
+
const refetch = useRefetch({ posts: { list: PostView } });
|
|
636
|
+
|
|
637
|
+
return <button onClick={() => refetch().catch(reportError)}>Refresh posts</button>;
|
|
638
|
+
}
|
|
639
|
+
```
|
|
620
640
|
|
|
621
|
-
|
|
641
|
+
`refetch()` fetches fresh data while the current posts stay visible. Its promise resolves with the new result or rejects on failure. Replace `reportError` with your app's error handler. Vue has the same [refresh API](/docs/guide/vue.md#refreshing-requests).
|
|
622
642
|
|
|
623
|
-
|
|
643
|
+
### Timeouts and Cancellation
|
|
624
644
|
|
|
625
|
-
You can
|
|
645
|
+
You can limit how long a request waits by passing a `timeout` in milliseconds:
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
const { posts } = useRequest({ posts: { list: PostView } }, { timeout: 10_000 });
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
Requests have no timeout by default. A timeout raises a `TimeoutError`, which `useRequest` sends to your error boundary. Retrying starts a new timeout.
|
|
652
|
+
|
|
653
|
+
To cancel a request, pass an `AbortSignal` and call `abort()` when the result is no longer needed:
|
|
654
|
+
|
|
655
|
+
```tsx
|
|
656
|
+
const controller = new AbortController();
|
|
657
|
+
const request = client.request({ posts: { list: PostView } }, { signal: controller.signal });
|
|
658
|
+
|
|
659
|
+
controller.abort();
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
await request;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
// Handle cancellation or a failed request.
|
|
665
|
+
}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Cancellation rejects with `signal.reason`, usually an `AbortError`. If callers share a request, cancelling one does not cancel the others.
|
|
669
|
+
|
|
670
|
+
You can also use `timeout` and `signal` with `useRefetch`, `useRequestState`, and Vue requests. Keep the signal stable when passing it to a hook. These options apply to the request, including its background refresh, rather than later pagination or mutation calls.
|
|
671
|
+
|
|
672
|
+
### Cache Lifetime
|
|
673
|
+
|
|
674
|
+
fate keeps a screen's data in memory while its `useRequest` is mounted. After you leave, it keeps the 10 most recently released requests so navigating back can reuse their data.
|
|
675
|
+
|
|
676
|
+
You can change that number when creating the client:
|
|
626
677
|
|
|
627
678
|
```tsx
|
|
628
679
|
const fate = createClient({
|
|
@@ -633,11 +684,9 @@ const fate = createClient({
|
|
|
633
684
|
});
|
|
634
685
|
```
|
|
635
686
|
|
|
636
|
-
Set `gcReleaseBufferSize` to `0`
|
|
637
|
-
|
|
638
|
-
`cache-first` request handles are stable while their request is cached. If garbage collection later removes the data for a fulfilled request, the next `cache-first` request automatically fetches it again rather than returning stale references.
|
|
687
|
+
Set `gcReleaseBufferSize` to `0` to release data as soon as a screen unmounts. If data has been collected, the next request fetches it again.
|
|
639
688
|
|
|
640
|
-
|
|
689
|
+
Outside a component, use `retain` when you need to keep a request's data in memory:
|
|
641
690
|
|
|
642
691
|
```tsx
|
|
643
692
|
const request = { posts: { list: PostView } };
|
|
@@ -651,11 +700,9 @@ try {
|
|
|
651
700
|
}
|
|
652
701
|
```
|
|
653
702
|
|
|
654
|
-
Garbage collection waits for active optimistic updates to settle before sweeping records. This keeps temporary optimistic records and their list positions stable while mutations are still pending.
|
|
655
|
-
|
|
656
703
|
### SSR and Hydration
|
|
657
704
|
|
|
658
|
-
Create a
|
|
705
|
+
Create a separate fate client for each server request and load the route's data before rendering:
|
|
659
706
|
|
|
660
707
|
```tsx
|
|
661
708
|
const fate = createFateClient();
|
|
@@ -666,9 +713,7 @@ return {
|
|
|
666
713
|
};
|
|
667
714
|
```
|
|
668
715
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
On the browser, hydrate the new client before rendering components that call `useRequest`:
|
|
716
|
+
Pass the snapshot through your framework's loader or server component props. In the browser, hydrate the client before rendering:
|
|
672
717
|
|
|
673
718
|
```tsx
|
|
674
719
|
const fate = createFateClient();
|
|
@@ -682,9 +727,9 @@ hydrateRoot(
|
|
|
682
727
|
);
|
|
683
728
|
```
|
|
684
729
|
|
|
685
|
-
|
|
730
|
+
`cache-first` requests can now use the server's data without refetching. Await preloading before calling `dehydrate`, and hydrate before starting browser requests.
|
|
686
731
|
|
|
687
|
-
|
|
732
|
+
Generated clients set a hydration scope automatically. When creating a client manually, set `hydrationScope` and change it when deploying incompatible data models:
|
|
688
733
|
|
|
689
734
|
```tsx
|
|
690
735
|
const fate = createClient({
|
|
@@ -693,63 +738,40 @@ const fate = createClient({
|
|
|
693
738
|
});
|
|
694
739
|
```
|
|
695
740
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
By default, hydration preserves values already present in the browser cache while adding missing server data. Pass `{ merge: 'replace' }` only when the snapshot should authoritatively reset the durable cache:
|
|
741
|
+
By default, hydration adds missing data and preserves values already in the browser cache. To replace existing values with the snapshot instead, pass `merge: 'replace'`:
|
|
699
742
|
|
|
700
743
|
```tsx
|
|
701
744
|
fate.hydrate(loaderData.fate, { merge: 'replace' });
|
|
702
745
|
```
|
|
703
746
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
Do not reuse request-scoped snapshots across users. Dehydrate after awaited route preloading: snapshots are point-in-time values and do not stream cache patches for data that resolves later. Hydration and dehydration reject clients with in-flight requests, so hydrate the initial snapshot before rendering.
|
|
747
|
+
Snapshots from different hydration scopes cannot be combined. Keep each user's server client and snapshot separate.
|
|
707
748
|
|
|
708
749
|
### Observing Request State
|
|
709
750
|
|
|
710
|
-
|
|
711
|
-
It returns `status`, `data`, `error`, and `isFetching`. Status is `pending`, `ready`,
|
|
712
|
-
or `error` for an enabled network request. An initial failure exposes the original
|
|
713
|
-
error rather than throwing during render. Request data contains the same refs as
|
|
714
|
-
`useRequest`; resolve them with `useView`.
|
|
715
|
-
|
|
716
|
-
Use `{ enabled: false }` for an empty search or an inactive screen. The status is
|
|
717
|
-
`disabled`, data is undefined, and no request starts. Changing `enabled` to true
|
|
718
|
-
starts the request. Equivalent inline request objects do not cause repeat fetches.
|
|
719
|
-
|
|
720
|
-
Use `{ mode: 'cache-only' }` to observe already-cached data. A complete selection
|
|
721
|
-
has status `ready`; an incomplete selection has status `missing` and undefined
|
|
722
|
-
data. A successful nullable root remains `ready` with a null value. Cache-only
|
|
723
|
-
requests never fetch, and their view refs cannot fetch missing fields either.
|
|
724
|
-
Mounted observers retain their request's records until unmount.
|
|
725
|
-
|
|
726
|
-
Outside React, `client.observeRequest(request, options)` exposes `getSnapshot()`
|
|
727
|
-
and `subscribe(listener)`. The first subscriber starts an enabled request;
|
|
728
|
-
unsubscribing releases its retention. Reading a snapshot alone never starts work.
|
|
729
|
-
|
|
730
|
-
Every request state includes a stable `refetch()` function. It forces a network
|
|
731
|
-
request, returns a promise for the completed result, and rejects with the original
|
|
732
|
-
error on failure. Concurrent calls share pending work. During refresh, complete
|
|
733
|
-
cached data stays available with `status: 'ready'` and `isFetching: true`. A failed
|
|
734
|
-
refresh keeps that data and exposes `error`; the next attempt clears the error.
|
|
735
|
-
`stale-while-revalidate` observers track the entire background request the same
|
|
736
|
-
way, including failures. Disabled and cache-only observers reject explicit
|
|
737
|
-
refetches without fetching; enable the request or change its mode first.
|
|
751
|
+
For a search box or a screen that handles loading and errors itself, use `useRequestState` instead of Suspense:
|
|
738
752
|
|
|
739
|
-
```
|
|
753
|
+
```tsx
|
|
740
754
|
const state = useRequestState(request, { mode: 'stale-while-revalidate' });
|
|
741
|
-
|
|
742
|
-
|
|
755
|
+
|
|
756
|
+
// In a button's event handler:
|
|
757
|
+
const refresh = () => state.refetch().catch(reportError);
|
|
743
758
|
```
|
|
744
759
|
|
|
745
|
-
|
|
746
|
-
older response cannot overwrite a field already supplied by a newer response or
|
|
747
|
-
a later confirmed write. It may still fill other fields. Root results and list
|
|
748
|
-
replacements also retain the newer result. Optimistic layers remain visible over
|
|
749
|
-
incoming confirmed data; rolling them back reveals that data, while committing a
|
|
750
|
-
mutation protects its confirmed fields from older requests.
|
|
760
|
+
The result contains `data`, `error`, `isFetching`, `status`, and `refetch`. Data contains the same refs as `useRequest`, so child components can read them with `useView`. During a refresh, cached data stays visible and `isFetching` is true. A failed refresh sets `error` and keeps the previous data.
|
|
751
761
|
|
|
752
|
-
|
|
762
|
+
Use `enabled: false` to wait for input before fetching, or `mode: 'cache-only'` to read only cached data. The possible statuses are:
|
|
763
|
+
|
|
764
|
+
| Status | Meaning |
|
|
765
|
+
| ---------- | -------------------------------------------------- |
|
|
766
|
+
| `disabled` | The request is not enabled. |
|
|
767
|
+
| `missing` | A cache-only request needs data that isn't cached. |
|
|
768
|
+
| `pending` | The initial request is loading. |
|
|
769
|
+
| `ready` | Data is available. |
|
|
770
|
+
| `error` | The initial request failed. |
|
|
771
|
+
|
|
772
|
+
`refetch()` returns a promise and requires an enabled request that isn't cache-only. For Vue, see [Request State](/docs/guide/vue.md#request-state). Outside a component, `fate.observeRequest(request, options)` provides the same state through `getSnapshot()` and `subscribe(listener)`. Subscribing starts the request; unsubscribing releases it.
|
|
773
|
+
|
|
774
|
+
### Aliases
|
|
753
775
|
|
|
754
776
|
Use `alias(sourceField, selection)` to select one schema field under multiple
|
|
755
777
|
result names, with independent arguments:
|
|
@@ -771,31 +793,13 @@ const GamesView = view<User>()({
|
|
|
771
793
|
const request = { currentUser: alias('viewer', { view: GamesView }) };
|
|
772
794
|
```
|
|
773
795
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
same arguments shares the cached data. Connection filters identify separate
|
|
778
|
-
cache slots and pagination state. Conflicting response names or arguments in a
|
|
779
|
-
composed view fail during planning. `id` and `__typename` are reserved result
|
|
780
|
-
names because fate needs them for entity identity.
|
|
781
|
-
|
|
782
|
-
GraphQL emits native aliases, including inside mutation selections, so multiple
|
|
783
|
-
variants execute in one operation. Native HTTP, tRPC, and custom transports
|
|
784
|
-
receive compatible reads with ordinary schema field names; conflicting variants
|
|
785
|
-
may require multiple reads or subscriptions. These fallback reads are independent
|
|
786
|
-
server operations. A mutation that needs multiple variants requires native alias
|
|
787
|
-
support and otherwise rejects before executing. A simple rename is lowered for
|
|
788
|
-
native mutations without repeating the action.
|
|
789
|
-
|
|
790
|
-
Custom transports can set `supportsAliases: true` to handle the full selection in
|
|
791
|
-
one operation. Selection path segments and corresponding argument keys then use
|
|
792
|
-
`resultName:schemaField`, and returned records must use those same encoded keys.
|
|
793
|
-
GraphQL performs that conversion automatically. Applications consume the declared
|
|
794
|
-
result names, never the encoded transport names.
|
|
796
|
+
The result uses the names you choose, while fate keeps data with the same source field and arguments shared in the cache. Different filters keep separate lists and pagination. `id` and `__typename` cannot be used as alias names.
|
|
797
|
+
|
|
798
|
+
GraphQL can request these variants in one operation. Other transports may make separate reads. Selecting multiple argument variants in a single mutation currently requires a transport with native alias support, such as GraphQL.
|
|
795
799
|
|
|
796
800
|
## Deferred Views
|
|
797
801
|
|
|
798
|
-
|
|
802
|
+
Let's say a post's comments take longer to load than its title and content. Wrap `comments` in `defer` so the post can render first, and give the comments their own `Suspense` boundary:
|
|
799
803
|
|
|
800
804
|
```tsx
|
|
801
805
|
import { Suspense } from 'react';
|
|
@@ -850,11 +854,9 @@ function PostComments({
|
|
|
850
854
|
}
|
|
851
855
|
```
|
|
852
856
|
|
|
853
|
-
|
|
857
|
+
`title` and `content` are available when `useView(PostView, postRef)` returns. Reading the comments with `useListView` loads any missing data and suspends only `PostComments`.
|
|
854
858
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
GraphQL transports use the same client semantics today. The deferred field is omitted from the eager request and fetched when the deferred handle is resolved. GraphQL `@defer` is the natural transport representation for this feature, but consuming incremental multipart patches requires additional transport support before fate can safely normalize streamed patches from a single GraphQL response.
|
|
859
|
+
`useView` and `useLiveListView` can read deferred values too. This works with every transport. GraphQL currently fetches deferred fields with separate queries, rather than streaming `@defer` responses.
|
|
858
860
|
|
|
859
861
|
## List Views
|
|
860
862
|
|
|
@@ -906,7 +908,15 @@ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: Vi
|
|
|
906
908
|
}
|
|
907
909
|
```
|
|
908
910
|
|
|
909
|
-
If `loadNext` is
|
|
911
|
+
If `loadNext` is `null`, it means there are no more comments to load. If you want to instead load previous comments, you can use the third argument returned by `useListView`, which is `loadPrevious`. Similarly, if there are no previous comments to load, `loadPrevious` will be `null`.
|
|
912
|
+
|
|
913
|
+
When the connection is `null` or `undefined`, the returned items are `undefined` and both pagination functions are `null`. Default the items when the UI should render an empty list:
|
|
914
|
+
|
|
915
|
+
```tsx
|
|
916
|
+
const [comments = [], loadNext] = useListView(CommentConnectionView, post?.comments);
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
`useLiveListView` follows the same rules.
|
|
910
920
|
|
|
911
921
|
### Pagination Arguments
|
|
912
922
|
|
|
@@ -930,6 +940,8 @@ const CommentConnectionView = {
|
|
|
930
940
|
|
|
931
941
|
When `loadNext` runs, fate sends the next cursor as `after` and keeps the page size in `first`. When `loadPrevious` runs, fate sends the previous cursor as `before` and uses `last` for the page size. This lets the server distinguish forward and backward pagination while keeping the component API small.
|
|
932
942
|
|
|
943
|
+
Loading another page adds items to the list. Refreshing the query without `after` or `before` replaces the loaded list with the latest result, including removed items or an empty list. This applies to nested connections as well as root lists. A failed page load can be retried.
|
|
944
|
+
|
|
933
945
|
Additional arguments on a root request are scoped to that root list:
|
|
934
946
|
|
|
935
947
|
```tsx
|
|
@@ -943,9 +955,13 @@ const { posts } = useRequest({
|
|
|
943
955
|
|
|
944
956
|
The `categoryId` list above has its own cache entry and pagination state. Loading another page for that list does not update a different `posts` request with another category or search query.
|
|
945
957
|
|
|
958
|
+
### Updating Lists with Mutations
|
|
959
|
+
|
|
960
|
+
When a mutation returns a complete list, fate replaces that list's items. For example, if a mutation returns all of a post's comments without a removed comment, that comment disappears from the list. If the mutation returns only one page, fate keeps previously loaded items outside that page. Removing a comment from a list does not delete its record from the cache.
|
|
961
|
+
|
|
946
962
|
## Live Views
|
|
947
963
|
|
|
948
|
-
`useLiveView`
|
|
964
|
+
Use `useLiveView` to keep a view up to date when its data changes on the server:
|
|
949
965
|
|
|
950
966
|
```tsx
|
|
951
967
|
import { useLiveView, ViewRef } from 'react-fate';
|
|
@@ -963,17 +979,13 @@ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
|
963
979
|
};
|
|
964
980
|
```
|
|
965
981
|
|
|
966
|
-
|
|
982
|
+
It takes the same view and ref as `useView`. Only the selected fields are available, and components update when those fields change.
|
|
967
983
|
|
|
968
984
|
### How Live Updates Work
|
|
969
985
|
|
|
970
|
-
|
|
986
|
+
fate uses one live connection per client, shared by its mounted live views. When the server sends an update, fate writes it to the cache and updates components that select the changed fields.
|
|
971
987
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
For example, if `PostView` selects `likes`, a live update that changes `likes` re-renders the `PostCard`. If another component only selected `title`, it does not re-render for a `likes` change.
|
|
975
|
-
|
|
976
|
-
Live deletion events remove the record from the normalized cache in the same way as mutations, and any lists or object fields that reference it are pruned.
|
|
988
|
+
For example, a change to `likes` updates `PostCard` without re-rendering a component that only selects `title`. Deleting an object also removes references to it from lists and other objects.
|
|
977
989
|
|
|
978
990
|
### Client Setup
|
|
979
991
|
|
|
@@ -1001,15 +1013,11 @@ export function App() {
|
|
|
1001
1013
|
}
|
|
1002
1014
|
```
|
|
1003
1015
|
|
|
1004
|
-
|
|
1005
|
-
>
|
|
1006
|
-
> Live views use `GET /fate/live` for the single SSE stream and `POST /fate/live` for subscribe/unsubscribe control messages.
|
|
1016
|
+
This example uses native HTTP. For a GraphQL server, see [GraphQL Live Views](/docs/integrations/graphql.md#live-views).
|
|
1007
1017
|
|
|
1008
1018
|
### Server Setup
|
|
1009
1019
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
Pass a live event bus to `createFateServer` and expose the native handler:
|
|
1020
|
+
Pass an event bus to `createFateServer` so your mutations can tell fate which objects changed:
|
|
1013
1021
|
|
|
1014
1022
|
```tsx
|
|
1015
1023
|
import { createFateServer, createHonoFateHandler, createLiveEventBus } from '@nkzw/fate/server';
|
|
@@ -1028,7 +1036,7 @@ export const fate = createFateServer<AppContext>({
|
|
|
1028
1036
|
app.all('/fate/*', createHonoFateHandler(fate));
|
|
1029
1037
|
```
|
|
1030
1038
|
|
|
1031
|
-
|
|
1039
|
+
If a client falls behind, fate closes its connection after 1,000 queued events. You can change the limit with `maxQueueSize`:
|
|
1032
1040
|
|
|
1033
1041
|
```tsx
|
|
1034
1042
|
export const fate = createFateServer<AppContext>({
|
|
@@ -1067,7 +1075,7 @@ export function PostCard({ post: postRef }: { post: ViewRef<'Post'> }) {
|
|
|
1067
1075
|
|
|
1068
1076
|
The hook returns the same tuple as `useListView`: items, `loadNext`, and `loadPrevious`. Live events append, prepend, insert, or delete edges from one connection without deleting the underlying records.
|
|
1069
1077
|
|
|
1070
|
-
By default,
|
|
1078
|
+
By default, new items at an unloaded end of a list wait for pagination. For a chat or activity feed, use `append: 'visible'` to show new messages immediately:
|
|
1071
1079
|
|
|
1072
1080
|
```tsx
|
|
1073
1081
|
const MessageConnectionView = {
|
|
@@ -1100,7 +1108,7 @@ If the changed list cannot be described precisely, invalidate the active connect
|
|
|
1100
1108
|
live.connection('posts', { categoryId }).invalidate();
|
|
1101
1109
|
```
|
|
1102
1110
|
|
|
1103
|
-
|
|
1111
|
+
Pass the list's filters, such as `categoryId`, when emitting events. Pagination arguments like `first` and `after` don't affect which list receives an event.
|
|
1104
1112
|
|
|
1105
1113
|
### Emitting Events
|
|
1106
1114
|
|
|
@@ -1178,13 +1186,11 @@ live.update('Post', input.id, {
|
|
|
1178
1186
|
});
|
|
1179
1187
|
```
|
|
1180
1188
|
|
|
1181
|
-
The default
|
|
1189
|
+
The default event bus doesn't replay missed events after a disconnect. For that, provide a custom live bus that resumes from `lastEventId`.
|
|
1182
1190
|
|
|
1183
1191
|
### Error Handling
|
|
1184
1192
|
|
|
1185
|
-
Live
|
|
1186
|
-
|
|
1187
|
-
Pass `onLiveError` when creating the client to send those failures to your logger or monitoring system:
|
|
1193
|
+
Live errors leave the last cached data visible. Pass `onLiveError` to report them to your logger or monitoring system:
|
|
1188
1194
|
|
|
1189
1195
|
```tsx
|
|
1190
1196
|
const fate = createFateClient({
|
|
@@ -1200,7 +1206,7 @@ const fate = createFateClient({
|
|
|
1200
1206
|
});
|
|
1201
1207
|
```
|
|
1202
1208
|
|
|
1203
|
-
|
|
1209
|
+
These errors don't go to the component's error boundary.
|
|
1204
1210
|
|
|
1205
1211
|
## Actions
|
|
1206
1212
|
|
|
@@ -1209,11 +1215,7 @@ fate does not provide hooks for mutations like traditional data fetching librari
|
|
|
1209
1215
|
- `fate.actions` for use with [`useActionState`](https://react.dev/reference/react/useActionState) and React Actions.
|
|
1210
1216
|
- `fate.mutations` for traditional imperative mutation calls.
|
|
1211
1217
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
- With the [native HTTP transport](/docs/integrations/server.md#native-fate-protocol), mutations come from the `mutations` object passed to `createFateServer`.
|
|
1215
|
-
- With the [tRPC adapter](/docs/integrations/server.md#trpc-fate-setup), mutations come from tRPC mutation procedures exposed through your fate-enabled router.
|
|
1216
|
-
- With [Void](/docs/integrations/void.md), mutations use the same native fate server shape and are exposed through the Void route helpers.
|
|
1218
|
+
fate's Vite plugin makes your server mutations available on the client. The API works the same way with native HTTP, tRPC, GraphQL, and Void.
|
|
1217
1219
|
|
|
1218
1220
|
If you have a mutation named `post.like`, a `LikeButton` component using fate Actions and an async component library could look like this:
|
|
1219
1221
|
|
|
@@ -1258,11 +1260,9 @@ const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
|
|
|
1258
1260
|
};
|
|
1259
1261
|
```
|
|
1260
1262
|
|
|
1261
|
-
By using `useActionState`, fate Actions integrate with Suspense and concurrent rendering.
|
|
1262
|
-
|
|
1263
1263
|
### Optimistic Updates
|
|
1264
1264
|
|
|
1265
|
-
|
|
1265
|
+
To update the like count before the server responds, pass an `optimistic` object:
|
|
1266
1266
|
|
|
1267
1267
|
```tsx
|
|
1268
1268
|
like({
|
|
@@ -1271,18 +1271,17 @@ like({
|
|
|
1271
1271
|
});
|
|
1272
1272
|
```
|
|
1273
1273
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
If a mutation fails, the cache will be rolled back to its previous state and any views depending on the mutated data will be updated.
|
|
1274
|
+
Views that select `likes` update immediately. Views that only select other fields, such as `title`, do not re-render. If the mutation fails, fate rolls back the optimistic update.
|
|
1277
1275
|
|
|
1278
1276
|
### Inserting New Objects
|
|
1279
1277
|
|
|
1280
|
-
|
|
1278
|
+
Let's add a comment to a post. Pass the comments list as `insert.into` and give the optimistic comment a temporary ID:
|
|
1281
1279
|
|
|
1282
1280
|
```tsx
|
|
1283
1281
|
const content = 'New Comment text';
|
|
1284
1282
|
addComment({
|
|
1285
1283
|
input: { content, postId: post.id },
|
|
1284
|
+
insert: { into: post.comments, position: 'after' },
|
|
1286
1285
|
optimistic: {
|
|
1287
1286
|
author: { id: user.id, name: user.name },
|
|
1288
1287
|
content,
|
|
@@ -1292,23 +1291,9 @@ addComment({
|
|
|
1292
1291
|
});
|
|
1293
1292
|
```
|
|
1294
1293
|
|
|
1295
|
-
|
|
1294
|
+
The comment appears immediately. When the server responds, fate replaces the temporary ID with the real one. If the mutation fails, the comment disappears and the count is restored.
|
|
1296
1295
|
|
|
1297
|
-
|
|
1298
|
-
addComment({
|
|
1299
|
-
input: { content, postId: post.id },
|
|
1300
|
-
insert: 'before',
|
|
1301
|
-
optimistic: {
|
|
1302
|
-
content,
|
|
1303
|
-
id: `optimistic:${Date.now().toString(36)}`,
|
|
1304
|
-
post: { id: post.id },
|
|
1305
|
-
},
|
|
1306
|
-
});
|
|
1307
|
-
```
|
|
1308
|
-
|
|
1309
|
-
Insertion respects pagination boundaries. If you append to a list that still has a next page, fate keeps the new record attached to the unresolved trailing edge instead of mixing it into the loaded page. As you load more pages, the inserted record stays at the end until the server returns the canonical item or the list reaches the edge. The same behavior applies to prepends while `hasPrevious` is true.
|
|
1310
|
-
|
|
1311
|
-
Multiple pending optimistic inserts keep their visible order. For example, two `insert: 'before'` calls on a newest-first feed show the second optimistic item before the first, matching what users expect from newly created content.
|
|
1296
|
+
Use `position: 'before'` for a newest-first list. Without `insert`, fate updates existing records but does not add them to lists. See [Controlling List Insertion Behavior](#controlling-list-insertion-behavior) for deferred lists and inserting into more than one list.
|
|
1312
1297
|
|
|
1313
1298
|
### Selecting a View with Actions
|
|
1314
1299
|
|
|
@@ -1324,21 +1309,11 @@ addComment({
|
|
|
1324
1309
|
});
|
|
1325
1310
|
```
|
|
1326
1311
|
|
|
1327
|
-
The server
|
|
1328
|
-
|
|
1329
|
-
```tsx
|
|
1330
|
-
const [result, addComment] = useActionState(fate.actions.comment.add, null);
|
|
1331
|
-
|
|
1332
|
-
const newComment = result?.result;
|
|
1333
|
-
if (newComment) {
|
|
1334
|
-
// All the fields selected in the view are available on `newComment`:
|
|
1335
|
-
console.log(newComment.post.commentCount);
|
|
1336
|
-
}
|
|
1337
|
-
```
|
|
1312
|
+
The server returns the selected fields, and views that display the post's comment count update automatically.
|
|
1338
1313
|
|
|
1339
1314
|
### Mutations
|
|
1340
1315
|
|
|
1341
|
-
fate
|
|
1316
|
+
Use `fate.mutations` to call a mutation outside a React Action, or when calls should run without waiting for earlier actions to finish:
|
|
1342
1317
|
|
|
1343
1318
|
```tsx
|
|
1344
1319
|
const result = await fate.mutations.comment.add({
|
|
@@ -1346,108 +1321,25 @@ const result = await fate.mutations.comment.add({
|
|
|
1346
1321
|
});
|
|
1347
1322
|
```
|
|
1348
1323
|
|
|
1349
|
-
|
|
1324
|
+
The options are the same as for Actions, including `optimistic`, `insert`, and `view`. The call returns a promise; handle pending state and errors in the calling code.
|
|
1350
1325
|
|
|
1351
1326
|
### Mutation Server Implementation
|
|
1352
1327
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
- [Native HTTP custom mutations](/docs/integrations/server.md#custom-mutations) use `createFateServer({ mutations })`.
|
|
1356
|
-
- [tRPC fate setup](/docs/integrations/server.md#trpc-fate-setup) wires fate into your tRPC router; custom writes can use the same `fate.createPlan` and `fate.resolveById` helpers shown there.
|
|
1357
|
-
- [Void integration](/docs/integrations/void.md) exposes a native fate server from Void routes; define mutations with the native `createFateServer({ mutations })` API and serve them through `defineVoidFateRoute`.
|
|
1358
|
-
|
|
1359
|
-
Here is a native HTTP mutation for `post.like`:
|
|
1360
|
-
|
|
1361
|
-
```tsx
|
|
1362
|
-
export const fate = createFateServer({
|
|
1363
|
-
mutations: {
|
|
1364
|
-
'post.like': {
|
|
1365
|
-
input: likeInput,
|
|
1366
|
-
resolve: async ({ ctx, input, select }) => {
|
|
1367
|
-
await ctx.prisma.post.update({
|
|
1368
|
-
data: {
|
|
1369
|
-
likes: {
|
|
1370
|
-
increment: 1,
|
|
1371
|
-
},
|
|
1372
|
-
},
|
|
1373
|
-
where: { id: input.id },
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
return sources.resolveById({
|
|
1377
|
-
ctx,
|
|
1378
|
-
id: input.id,
|
|
1379
|
-
input: { select },
|
|
1380
|
-
view: postDataView,
|
|
1381
|
-
});
|
|
1382
|
-
},
|
|
1383
|
-
type: 'Post',
|
|
1384
|
-
},
|
|
1385
|
-
},
|
|
1386
|
-
roots: Root,
|
|
1387
|
-
sources,
|
|
1388
|
-
});
|
|
1389
|
-
```
|
|
1390
|
-
|
|
1391
|
-
The equivalent tRPC mutation lives in your router and returns the selected shape that the client asked for:
|
|
1392
|
-
|
|
1393
|
-
```tsx
|
|
1394
|
-
import { connectionArgs, createResolver } from '@nkzw/fate/server';
|
|
1395
|
-
import { z } from 'zod';
|
|
1396
|
-
import { procedure, router } from '../init.ts';
|
|
1397
|
-
import { postDataView, PostItem } from '../views.ts';
|
|
1398
|
-
|
|
1399
|
-
export const postRouter = router({
|
|
1400
|
-
like: procedure
|
|
1401
|
-
.input(
|
|
1402
|
-
z.object({
|
|
1403
|
-
args: connectionArgs,
|
|
1404
|
-
id: z.string().min(1, 'Post id is required.'),
|
|
1405
|
-
select: z.array(z.string()),
|
|
1406
|
-
}),
|
|
1407
|
-
)
|
|
1408
|
-
.mutation(async ({ ctx, input }) => {
|
|
1409
|
-
const { resolve, select } = createResolver({
|
|
1410
|
-
...input,
|
|
1411
|
-
ctx,
|
|
1412
|
-
view: postDataView,
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
return resolve(
|
|
1416
|
-
await ctx.prisma.post.update({
|
|
1417
|
-
data: {
|
|
1418
|
-
likes: {
|
|
1419
|
-
increment: 1,
|
|
1420
|
-
},
|
|
1421
|
-
},
|
|
1422
|
-
select,
|
|
1423
|
-
where: { id: input.id },
|
|
1424
|
-
} as PostUpdateArgs),
|
|
1425
|
-
);
|
|
1426
|
-
}),
|
|
1427
|
-
});
|
|
1428
|
-
```
|
|
1429
|
-
|
|
1430
|
-
See [Server Integration](/docs/integrations/server.md) for complete native HTTP and tRPC setup examples, and [Void Integration](/docs/integrations/void.md) for route helpers when your app runs on Void.
|
|
1328
|
+
Define mutations in your backend and return the fields requested by the client. See [Server Integration](/docs/integrations/server.md#custom-mutations) for native HTTP and tRPC, [GraphQL Integration](/docs/integrations/graphql.md#mutations) for an existing GraphQL API, or [Void Integration](/docs/integrations/void.md) for a Void app.
|
|
1431
1329
|
|
|
1432
1330
|
### Action & Mutation Error Handling
|
|
1433
1331
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
If your server returns a `NOT_FOUND` error with code `404`, the result of an Action or Mutation will contain an error object that you can handle at the call site:
|
|
1332
|
+
Expected errors, such as a validation failure or a `404`, are returned in `result.error`. You can show a message next to the button or form:
|
|
1437
1333
|
|
|
1438
1334
|
```tsx
|
|
1439
|
-
const [result] = useActionState(fate.actions.post.delete, null);
|
|
1335
|
+
const [result, deletePost] = useActionState(fate.actions.post.delete, null);
|
|
1440
1336
|
|
|
1441
1337
|
if (result?.error) {
|
|
1442
|
-
|
|
1443
|
-
// Handle not found error at call site.
|
|
1444
|
-
} else {
|
|
1445
|
-
// Handle other *expected* errors.
|
|
1446
|
-
}
|
|
1338
|
+
return <p>Could not delete this post. Please try again.</p>;
|
|
1447
1339
|
}
|
|
1448
1340
|
```
|
|
1449
1341
|
|
|
1450
|
-
|
|
1342
|
+
Network failures, server errors, and authentication or authorization errors are thrown. React Actions send them to the nearest error boundary:
|
|
1451
1343
|
|
|
1452
1344
|
```tsx
|
|
1453
1345
|
<ErrorBoundary FallbackComponent={ErrorComponent}>
|
|
@@ -1457,11 +1349,11 @@ However, if an `INTERNAL_SERVER_ERROR` error with code `500` occurs, it will be
|
|
|
1457
1349
|
</ErrorBoundary>
|
|
1458
1350
|
```
|
|
1459
1351
|
|
|
1460
|
-
|
|
1352
|
+
When calling `fate.mutations` directly, use `try`/`catch` to handle thrown errors. Both kinds of failure roll back optimistic updates. GraphQL error codes are described in [Errors and Partial Results](/docs/integrations/graphql.md#errors-and-partial-results).
|
|
1461
1353
|
|
|
1462
1354
|
### Deleting Records
|
|
1463
1355
|
|
|
1464
|
-
|
|
1356
|
+
Pass `delete: true` to remove an object immediately. fate also removes it from lists that contain it and restores it if the mutation fails:
|
|
1465
1357
|
|
|
1466
1358
|
```tsx
|
|
1467
1359
|
const [result, deleteAction] = useActionState(fate.actions.post.delete, null);
|
|
@@ -1474,49 +1366,74 @@ deleteAction({
|
|
|
1474
1366
|
|
|
1475
1367
|
### Resetting Action State
|
|
1476
1368
|
|
|
1477
|
-
|
|
1369
|
+
To clear an action's result without sending another mutation, dispatch `'reset'` inside a transition:
|
|
1478
1370
|
|
|
1479
1371
|
```tsx
|
|
1372
|
+
import { startTransition, useActionState } from 'react';
|
|
1373
|
+
|
|
1480
1374
|
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
1481
1375
|
|
|
1482
|
-
|
|
1483
|
-
if (result?.error) {
|
|
1484
|
-
// Reset the action state after 3 seconds.
|
|
1485
|
-
const timeout = setTimeout(() => startTransition(() => like('reset')), 3000);
|
|
1486
|
-
return () => clearTimeout(timeout);
|
|
1487
|
-
}
|
|
1488
|
-
}, [like, result]);
|
|
1376
|
+
const dismiss = () => startTransition(() => like('reset'));
|
|
1489
1377
|
```
|
|
1490
1378
|
|
|
1491
1379
|
### Controlling List Insertion Behavior
|
|
1492
1380
|
|
|
1493
|
-
|
|
1381
|
+
Choose which list should receive a new object with `insert`. For example, comments can be deferred and still used directly as an insertion target:
|
|
1494
1382
|
|
|
1495
1383
|
```tsx
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1384
|
+
const PostCommentsView = view<Post>()({
|
|
1385
|
+
comments: defer({
|
|
1386
|
+
args: { first: 20 },
|
|
1387
|
+
items: { node: CommentView },
|
|
1388
|
+
}),
|
|
1389
|
+
id: true,
|
|
1499
1390
|
});
|
|
1391
|
+
|
|
1392
|
+
// Inside a component that receives postRef:
|
|
1393
|
+
const post = useView(PostCommentsView, postRef);
|
|
1394
|
+
|
|
1395
|
+
const addComment = () =>
|
|
1396
|
+
fate.mutations.comment.add({
|
|
1397
|
+
input: { content: 'New Comment text', postId: post.id },
|
|
1398
|
+
insert: { into: post.comments, position: 'after' },
|
|
1399
|
+
optimistic: {
|
|
1400
|
+
content: 'New Comment text',
|
|
1401
|
+
id: 'optimistic:new-comment',
|
|
1402
|
+
},
|
|
1403
|
+
view: CommentView,
|
|
1404
|
+
});
|
|
1500
1405
|
```
|
|
1501
1406
|
|
|
1502
|
-
|
|
1407
|
+
Passing `post.comments` does not fetch the comments. If they haven't loaded yet, fate keeps the insertion until they do. You can also pass a resolved array or connection from a request or view. Use the original value from the same client, rather than a copied or filtered array.
|
|
1408
|
+
|
|
1409
|
+
Use an array of insertion objects to update multiple lists, with an independent position for each:
|
|
1503
1410
|
|
|
1504
1411
|
```tsx
|
|
1505
|
-
|
|
1506
|
-
input: { content
|
|
1507
|
-
insert:
|
|
1412
|
+
await fate.mutations.comment.add({
|
|
1413
|
+
input: { content, postId: post.id },
|
|
1414
|
+
insert: [
|
|
1415
|
+
{ into: post.comments, position: 'after' },
|
|
1416
|
+
{ into: recentComments, position: 'before' },
|
|
1417
|
+
],
|
|
1418
|
+
view: CommentView,
|
|
1508
1419
|
});
|
|
1509
1420
|
```
|
|
1510
1421
|
|
|
1511
|
-
|
|
1422
|
+
Only the named lists receive the new comment. Filters and sorting are still your app's choice: use `before` or `after` to match how each list is ordered. An object already in a list is updated without being duplicated or moved.
|
|
1423
|
+
|
|
1424
|
+
Insertions work with pagination, optimistic updates, and [offline mutations](/docs/guide/persistence.md). Appended comments stay after later pages as they load; prepended comments stay before earlier pages.
|
|
1425
|
+
|
|
1426
|
+
#### Migrating from automatic insertion
|
|
1512
1427
|
|
|
1513
|
-
|
|
1428
|
+
Earlier versions appended mutation results automatically. Add an `insert` option to calls that relied on that behavior. The strings `'before'` and `'after'` still enable automatic list matching, but prefer an explicit target. `'none'` is now the default.
|
|
1514
1429
|
|
|
1515
|
-
Persistence
|
|
1430
|
+
## Persistence
|
|
1431
|
+
|
|
1432
|
+
fate can keep data across page reloads and save mutations while a user is offline. When they come back, their views can show previously loaded data and their pending changes continue where they left off.
|
|
1516
1433
|
|
|
1517
1434
|
### Client Setup
|
|
1518
1435
|
|
|
1519
|
-
|
|
1436
|
+
For a browser app, install the IndexedDB adapter:
|
|
1520
1437
|
|
|
1521
1438
|
```sh
|
|
1522
1439
|
vp add @nkzw/fate-indexeddb
|
|
@@ -1538,26 +1455,26 @@ const fate = createFateClient({
|
|
|
1538
1455
|
});
|
|
1539
1456
|
```
|
|
1540
1457
|
|
|
1541
|
-
By default, fate
|
|
1458
|
+
By default, fate saves loaded data for **one day**, with a storage budget of **25 MiB**. Use `maxAge` (milliseconds) and `maxBytes` to change those defaults:
|
|
1542
1459
|
|
|
1543
1460
|
```tsx
|
|
1544
1461
|
persistence: createPersistence({
|
|
1545
1462
|
key: `workspace:${workspaceId}:user:${userId}`,
|
|
1546
|
-
maxAge: 24 * 60 * 60 * 1000,
|
|
1547
|
-
maxBytes:
|
|
1463
|
+
maxAge: 3 * 24 * 60 * 60 * 1000,
|
|
1464
|
+
maxBytes: 50 * 1024 * 1024,
|
|
1548
1465
|
storage: createIndexedDBStorage(),
|
|
1549
1466
|
}),
|
|
1550
1467
|
```
|
|
1551
1468
|
|
|
1552
|
-
Create the
|
|
1469
|
+
Create the client after you know which user is signed in. Use a different `key` for each account and workspace, matching the authenticated scope in your [server setup](#server-deduplication). When switching accounts, dispose the old persistence session and create a new client.
|
|
1553
1470
|
|
|
1554
1471
|
> [!NOTE]
|
|
1555
1472
|
>
|
|
1556
|
-
> Persistence saves
|
|
1473
|
+
> Persistence saves data. To load the app itself while offline, you'll also need a service worker for its HTML, JavaScript, and other assets.
|
|
1557
1474
|
|
|
1558
1475
|
### Cache Lifetime
|
|
1559
1476
|
|
|
1560
|
-
You can keep
|
|
1477
|
+
You can keep a screen's data longer by passing `persist` to its request. For example, to save posts for three days:
|
|
1561
1478
|
|
|
1562
1479
|
```tsx
|
|
1563
1480
|
const { posts } = useRequest(
|
|
@@ -1570,29 +1487,15 @@ const { posts } = useRequest(
|
|
|
1570
1487
|
);
|
|
1571
1488
|
```
|
|
1572
1489
|
|
|
1573
|
-
The same option works with `fate.request(...)` and Vue's `useRequest`.
|
|
1574
|
-
|
|
1575
|
-
fate stores objects by their type and ID, just like the in-memory cache. If a post appears in both your feed and a detail screen, both requests share the same saved post. Each request describes which fields and related objects it needs, including list membership and pagination state.
|
|
1490
|
+
The same option works with `fate.request(...)` and Vue's `useRequest`. Objects shared by several requests are saved once, and remain available while a saved request needs them.
|
|
1576
1491
|
|
|
1577
|
-
|
|
1492
|
+
`maxAge` starts when data is fetched successfully. Reading saved data does not extend it. Pass `persist: { maxAge: 0 }` to skip saving a request; other requests may still save data they share with it.
|
|
1578
1493
|
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
When callers share a pending request, the latest explicit `persist.maxAge` also applies to that request's eventual cache write.
|
|
1582
|
-
|
|
1583
|
-
Pass `persist: { maxAge: 0 }` to skip saving data for a request. Shared objects may still be saved for other requests, so this option does not delete all copies of an object.
|
|
1584
|
-
|
|
1585
|
-
The [in-memory garbage collector](/docs/guide/requests.md#cache-lifetime) continues to work independently. A post can be removed from memory while its saved copy remains available for a later visit. On startup, fate restores pending mutations, then loads saved data as requests need it. It does not load the entire saved cache into memory.
|
|
1494
|
+
Saved data can outlive the [in-memory cache](/docs/guide/requests.md#cache-lifetime). fate loads it when a request needs it, rather than restoring the whole cache at startup.
|
|
1586
1495
|
|
|
1587
1496
|
### Refreshing Data
|
|
1588
1497
|
|
|
1589
|
-
Keeping data for three days doesn't mean waiting three days for updates.
|
|
1590
|
-
|
|
1591
|
-
- `cache-first` (_default_): Uses available saved data and fetches missing data from the network.
|
|
1592
|
-
- `stale-while-revalidate`: Shows saved data and refreshes it in the background. If the refresh fails, the saved data stays visible.
|
|
1593
|
-
- `network-only`: Requires a network response, even if saved data is available.
|
|
1594
|
-
|
|
1595
|
-
For example, to show the previous session's posts immediately and update them on reload:
|
|
1498
|
+
Keeping data for three days doesn't mean waiting three days for updates. Request modes still control when to fetch fresh data. For example, to show saved posts immediately and refresh them in the background:
|
|
1596
1499
|
|
|
1597
1500
|
```tsx
|
|
1598
1501
|
const { posts } = useRequest(
|
|
@@ -1606,25 +1509,17 @@ const { posts } = useRequest(
|
|
|
1606
1509
|
);
|
|
1607
1510
|
```
|
|
1608
1511
|
|
|
1609
|
-
|
|
1512
|
+
Use `cache-first` to fetch only missing data, or `network-only` to require a fresh response. Expiration affects saved data, not data already displayed by a mounted view. See [Request Modes](/docs/guide/requests.md#request-modes) and [Live Views](/docs/guide/live-views.md).
|
|
1610
1513
|
|
|
1611
1514
|
### Cache Size
|
|
1612
1515
|
|
|
1613
|
-
When
|
|
1614
|
-
|
|
1615
|
-
The budget includes encoded data, keys, cache metadata, and the saved mutation queue. Your storage backend may use additional space for its own bookkeeping. `maxBytes` controls saved data; the existing garbage collector controls the lifetime of data in memory.
|
|
1616
|
-
|
|
1617
|
-
fate batches cache writes and yields during large traversals and writes so other work on the page can continue. The mutation journal stores each entry separately and writes only changed entries, so confirming a mutation does not rewrite the entire queue. Changes to scalar fields write the affected record, while changes to relationships also update the saved data that depends on them.
|
|
1516
|
+
When storage reaches `maxBytes`, fate removes expired data and the least recently used saved requests. Shared objects remain available while another saved request needs them.
|
|
1618
1517
|
|
|
1619
|
-
Pending mutations are never
|
|
1620
|
-
|
|
1621
|
-
> [!NOTE]
|
|
1622
|
-
>
|
|
1623
|
-
> Already accepted mutations must still be able to finish. Their recovery data and saved results can exceed the budget if they grow or you lower `maxBytes`. In that case, fate releases the read cache and rejects new durable mutations until capacity is available. Local confirmation receipts count toward the budget until their cache changes have been saved. fate then removes them automatically, making room for new mutations.
|
|
1518
|
+
Pending mutations are never evicted to make room for cached data. If a new mutation cannot fit, it fails before being applied optimistically or sent to the server. Already accepted mutations can still finish, even if their saved results temporarily exceed the budget.
|
|
1624
1519
|
|
|
1625
1520
|
### Actions & Mutations
|
|
1626
1521
|
|
|
1627
|
-
With persistence configured, fate saves
|
|
1522
|
+
With persistence configured, fate saves mutations before applying their optimistic updates or sending them to the server. The API is the same as for regular [Actions](/docs/guide/actions.md):
|
|
1628
1523
|
|
|
1629
1524
|
```tsx
|
|
1630
1525
|
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
@@ -1635,15 +1530,15 @@ like({
|
|
|
1635
1530
|
});
|
|
1636
1531
|
```
|
|
1637
1532
|
|
|
1638
|
-
If the user likes a post while offline, the
|
|
1533
|
+
If the user likes a post while offline, the count updates immediately. Reloading restores the pending action and its optimistic update. When the connection returns and the app is running, fate sends it to the server.
|
|
1639
1534
|
|
|
1640
|
-
Mutations
|
|
1535
|
+
Mutations are delivered in the order they were saved, one at a time per persistence key. Tabs sharing a key coordinate delivery. Temporary failures are retried. Authentication failures wait for the user to sign in again. A terminal client error rolls back the optimistic update.
|
|
1641
1536
|
|
|
1642
|
-
The mutation promise
|
|
1537
|
+
The mutation promise stays pending until confirmation, which can take until the user is back online. After a reload, use [Persistence State](#persistence-state) to show pending or failed changes.
|
|
1643
1538
|
|
|
1644
1539
|
#### Skipping Persistence
|
|
1645
1540
|
|
|
1646
|
-
For a call that should
|
|
1541
|
+
For a call that should not be saved or retried, pass `persist: false`:
|
|
1647
1542
|
|
|
1648
1543
|
```tsx
|
|
1649
1544
|
await fate.mutations.analytics.record({
|
|
@@ -1652,23 +1547,23 @@ await fate.mutations.analytics.record({
|
|
|
1652
1547
|
});
|
|
1653
1548
|
```
|
|
1654
1549
|
|
|
1655
|
-
The same option works with `fate.actions`.
|
|
1550
|
+
The same option works with `fate.actions`.
|
|
1656
1551
|
|
|
1657
1552
|
#### Creating Objects Offline
|
|
1658
1553
|
|
|
1659
|
-
Use
|
|
1554
|
+
Use client-generated IDs when creating objects that later offline mutations depend on. For example, creating a comment and then editing it can use the same ID for both calls.
|
|
1660
1555
|
|
|
1661
|
-
If
|
|
1556
|
+
If the server assigns IDs, wait for the creation result before sending a dependent mutation. fate does not rewrite IDs inside mutation inputs or cancel later mutations when a creation fails.
|
|
1662
1557
|
|
|
1663
|
-
|
|
1558
|
+
Inputs and optimistic updates must be serializable. Functions, streams, and `File` objects cannot be queued. For uploads, save the content first and queue a reference to it, or use `persist: false`.
|
|
1664
1559
|
|
|
1665
1560
|
### Server Deduplication
|
|
1666
1561
|
|
|
1667
|
-
A connection can fail after the server has
|
|
1562
|
+
A connection can fail after the server has applied a mutation. Retrying a lost response must not increment a like count twice.
|
|
1668
1563
|
|
|
1669
|
-
fate
|
|
1564
|
+
fate gives each saved mutation an identity and reuses it on retries. Your server must save the result and the database changes in **the same transaction**, then return that result if the mutation arrives again.
|
|
1670
1565
|
|
|
1671
|
-
For the native HTTP transport,
|
|
1566
|
+
For the native HTTP transport, use `createMutationIdempotency`:
|
|
1672
1567
|
|
|
1673
1568
|
```ts
|
|
1674
1569
|
import { createMutationIdempotency } from '@nkzw/fate/persistence/server';
|
|
@@ -1693,26 +1588,17 @@ const server = createFateServer({
|
|
|
1693
1588
|
});
|
|
1694
1589
|
```
|
|
1695
1590
|
|
|
1696
|
-
Your app supplies
|
|
1697
|
-
|
|
1698
|
-
See [`example/persistence/server.ts`](https://github.com/nkzw-tech/fate/blob/main/example/persistence/server.ts) for a complete SQLite implementation, including the receipt table and transaction handling.
|
|
1699
|
-
|
|
1700
|
-
The helper checks the identity against the authenticated scope and rejects reuse with different mutation names, inputs, or selections. Server receipts do not expire: a user might reconnect much later with a mutation whose response was lost. Keep receipts for as long as an old mutation could still arrive.
|
|
1701
|
-
|
|
1702
|
-
A database transaction cannot roll back an email, payment, or webhook sent to another service. For those effects, use a transactional outbox and the destination's idempotency support. The transaction integration guarantees one committed database effect; network requests and resolver attempts can still happen more than once.
|
|
1591
|
+
Your app supplies the database and receipt helpers. Lock each `(scope, id)` across server processes, and use `ctx.db` for mutation writes so they share the receipt's transaction. See the [complete SQLite example](https://github.com/nkzw-tech/fate/blob/main/example/persistence/server.ts).
|
|
1703
1592
|
|
|
1704
|
-
|
|
1593
|
+
Keep receipts for as long as an offline client might retry. For effects outside the database, such as payments or emails, use the destination's idempotency support and a transactional outbox.
|
|
1705
1594
|
|
|
1706
1595
|
### tRPC, GraphQL, and Custom Transports
|
|
1707
1596
|
|
|
1708
|
-
For
|
|
1709
|
-
|
|
1710
|
-
Both `createTRPCTransport` and `createGraphQLTransport`, including their generated clients, accept `mutateDurably`. For example, you can use a native fate endpoint for durable mutations alongside your existing transport:
|
|
1597
|
+
For other transports, provide `mutateDurably(name, input, select, identity)` with the same server deduplication. One option is to use a native fate endpoint alongside your existing backend:
|
|
1711
1598
|
|
|
1712
1599
|
```tsx
|
|
1713
1600
|
const durableHTTP = createHTTPTransport<MyAPI>({
|
|
1714
1601
|
url: '/api/fate',
|
|
1715
|
-
// Use the same authenticated headers as your other transport.
|
|
1716
1602
|
});
|
|
1717
1603
|
|
|
1718
1604
|
const fate = createFateClient({
|
|
@@ -1722,9 +1608,9 @@ const fate = createFateClient({
|
|
|
1722
1608
|
});
|
|
1723
1609
|
```
|
|
1724
1610
|
|
|
1725
|
-
|
|
1611
|
+
Use the same authentication, mutation names, inputs, results, and IDs on both endpoints. A GraphQL adapter must decode global IDs and return the unwrapped result. It also receives selected field arguments as a fifth parameter; forward them when needed.
|
|
1726
1612
|
|
|
1727
|
-
You can
|
|
1613
|
+
You can use the idempotency helper inside an existing resolver too:
|
|
1728
1614
|
|
|
1729
1615
|
```ts
|
|
1730
1616
|
return idempotency.execute({
|
|
@@ -1737,9 +1623,11 @@ return idempotency.execute({
|
|
|
1737
1623
|
});
|
|
1738
1624
|
```
|
|
1739
1625
|
|
|
1740
|
-
|
|
1626
|
+
Forward the entire identity on every attempt. When `identity.replayOnly` is true, the endpoint must return the saved result or a 404 without running the mutation. `createMutationIdempotency` handles this for you.
|
|
1627
|
+
|
|
1628
|
+
Without a durable adapter, saved reads and `persist: false` calls still work, but new durable mutations fail before being queued.
|
|
1741
1629
|
|
|
1742
|
-
|
|
1630
|
+
For GraphQL, return a standard `extensions.code` or numeric `extensions.http.status` for terminal client errors. Unrecognized execution errors remain retryable for saved mutations.
|
|
1743
1631
|
|
|
1744
1632
|
### Persistence State
|
|
1745
1633
|
|
|
@@ -1755,27 +1643,25 @@ const unsubscribe = session.subscribe(() => {
|
|
|
1755
1643
|
});
|
|
1756
1644
|
```
|
|
1757
1645
|
|
|
1758
|
-
|
|
1646
|
+
Each mutation exposes its ID, name, input, status (`queued`, `sending`, or `failed`), and last error. `getSnapshot()` also works with React's `useSyncExternalStore`.
|
|
1759
1647
|
|
|
1760
|
-
A failed initial
|
|
1648
|
+
A failed initial restore rejects `ready`. Storage errors appear in the snapshot. A cache read failure can fall back to the network, but a mutation that cannot be saved is not sent.
|
|
1761
1649
|
|
|
1762
|
-
|
|
1650
|
+
Read-cache writes are batched. Before a reload you control, call `flush()` to save the latest data:
|
|
1763
1651
|
|
|
1764
1652
|
```tsx
|
|
1765
|
-
await
|
|
1653
|
+
await session.flush();
|
|
1766
1654
|
window.location.reload();
|
|
1767
1655
|
```
|
|
1768
1656
|
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
You can also retry delivery or discard a mutation:
|
|
1657
|
+
Mutations are saved automatically before delivery. You can retry delivery or discard an unsent or failed mutation:
|
|
1772
1658
|
|
|
1773
1659
|
```tsx
|
|
1774
1660
|
session.retry();
|
|
1775
1661
|
await session.discard(mutationId);
|
|
1776
1662
|
```
|
|
1777
1663
|
|
|
1778
|
-
`retry()` respects the
|
|
1664
|
+
`retry()` respects the retry delay. Once a mutation has been attempted, it cannot be discarded until its outcome is known: it may have committed on the server.
|
|
1779
1665
|
|
|
1780
1666
|
When leaving an account, dispose its session and unsubscribe:
|
|
1781
1667
|
|
|
@@ -1784,7 +1670,7 @@ session.dispose();
|
|
|
1784
1670
|
unsubscribe();
|
|
1785
1671
|
```
|
|
1786
1672
|
|
|
1787
|
-
|
|
1673
|
+
Disposal stops delivery and removes optimistic updates from memory. Saved mutations remain available to the next client using the same key.
|
|
1788
1674
|
|
|
1789
1675
|
### Clearing Saved Data
|
|
1790
1676
|
|
|
@@ -1794,15 +1680,13 @@ To remove the saved read cache, call `clearCache()`:
|
|
|
1794
1680
|
await fate.persistence!.clearCache();
|
|
1795
1681
|
```
|
|
1796
1682
|
|
|
1797
|
-
This leaves
|
|
1798
|
-
|
|
1799
|
-
For a complete local account reset, stop every client using the key before removing its journal header, individual mutation entries, and namespaced cache entries from storage. This also removes unsent changes. It cannot undo mutations that already ran on the server.
|
|
1683
|
+
This leaves in-memory data and queued mutations intact.
|
|
1800
1684
|
|
|
1801
|
-
|
|
1685
|
+
Change `hydrationScope` when deploying incompatible data models or mutation inputs. Incompatible saved reads are discarded; incompatible queued mutations remain visible as failures so the app can handle them.
|
|
1802
1686
|
|
|
1803
1687
|
### Other Storage Backends
|
|
1804
1688
|
|
|
1805
|
-
|
|
1689
|
+
To use storage other than IndexedDB, implement `PersistenceStorage`:
|
|
1806
1690
|
|
|
1807
1691
|
```ts
|
|
1808
1692
|
interface PersistenceStorage {
|
|
@@ -1818,23 +1702,17 @@ interface PersistenceStorage {
|
|
|
1818
1702
|
}
|
|
1819
1703
|
```
|
|
1820
1704
|
|
|
1821
|
-
Values
|
|
1705
|
+
Values can be stored as JSON. `scan` returns keys in ascending order after the optional cursor, up to the limit (64 by default). `writeBatch` must commit all entries atomically; `undefined` deletes a key.
|
|
1822
1706
|
|
|
1823
|
-
|
|
1824
|
-
- `scan` returns keys under a prefix in ascending order, strictly after the optional cursor, up to the limit (64 by default). Use your backend's ordered index to keep each scan small.
|
|
1825
|
-
- `writeBatch` commits all entries atomically and resolves after they have committed. An `undefined` value deletes the key. Use a one-entry batch for a single write.
|
|
1826
|
-
- `exclusive` coordinates every tab or process sharing the backend. Different lock names are independent: fate holds a delivery lock during network work and acquires a separate write lock when updating storage. Your adapter must allow that nesting.
|
|
1827
|
-
- `subscribe` notifies other clients after a change commits, including keys changed by `writeBatch`. Without notifications, clients check saved mutations during delivery attempts and explicit `retry()` calls.
|
|
1707
|
+
`exclusive` coordinates tabs or processes sharing storage. Different lock names must be independent and allow nesting. `subscribe` notifies other clients after a write commits; without it, clients check during delivery and explicit retries.
|
|
1828
1708
|
|
|
1829
|
-
The IndexedDB adapter
|
|
1709
|
+
The IndexedDB adapter requires a secure browser context with Web Locks. Browser storage can be cleared or evicted, so choose another backend if your app needs stronger retention.
|
|
1830
1710
|
|
|
1831
|
-
Persistence coordinates
|
|
1711
|
+
Persistence coordinates delivery across tabs. Use refetching or [Live Views](/docs/guide/live-views.md) to receive fresh server data.
|
|
1832
1712
|
|
|
1833
1713
|
## Vue
|
|
1834
1714
|
|
|
1835
|
-
_fate_
|
|
1836
|
-
|
|
1837
|
-
Vue components use fate through Vue resources built from refs, computed values, watchers, and `<Suspense>`. The view model, generated client, normalized cache, masking, request shapes, list views, live views, and mutations are shared with the React adapter.
|
|
1715
|
+
_fate_ supports Vue through `vue-fate`, with the same views, requests, cache, and mutations as the React adapter. Request and view composables expose reactive Vue resources, with `<Suspense>` support for loading states.
|
|
1838
1716
|
|
|
1839
1717
|
### Installation
|
|
1840
1718
|
|
|
@@ -1948,8 +1826,6 @@ export const PostView = view<Post>()({
|
|
|
1948
1826
|
});
|
|
1949
1827
|
```
|
|
1950
1828
|
|
|
1951
|
-
Vue can import values across components, but single-file components have one default component export. Keeping reusable views in `.ts` files avoids coupling your data model to component files and makes view composition straightforward.
|
|
1952
|
-
|
|
1953
1829
|
### Requests
|
|
1954
1830
|
|
|
1955
1831
|
`useRequest` declares the data a route, page, or component tree needs. It returns a resource with `data`, `pending`, `error`, `ready`, `refresh`, and `dispose`:
|
|
@@ -1967,8 +1843,8 @@ const request = useRequest({
|
|
|
1967
1843
|
},
|
|
1968
1844
|
});
|
|
1969
1845
|
|
|
1970
|
-
|
|
1971
|
-
const [postItems, loadNext] = useListView(PostCardView, posts);
|
|
1846
|
+
await request.ready();
|
|
1847
|
+
const [postItems, loadNext] = useListView(PostCardView, () => request.data.value?.posts);
|
|
1972
1848
|
</script>
|
|
1973
1849
|
|
|
1974
1850
|
<template>
|
|
@@ -1977,7 +1853,54 @@ const [postItems, loadNext] = useListView(PostCardView, posts);
|
|
|
1977
1853
|
</template>
|
|
1978
1854
|
```
|
|
1979
1855
|
|
|
1980
|
-
Awaiting `ready()` in `<script setup>` participates in Vue Suspense.
|
|
1856
|
+
Awaiting `ready()` in `<script setup>` participates in Vue Suspense. The getter passed to `useListView` keeps the list in sync when the request result changes. To render loading and error states yourself, use `useRequestState` instead.
|
|
1857
|
+
|
|
1858
|
+
### Request State
|
|
1859
|
+
|
|
1860
|
+
Use `useRequestState` to render loading and error states without Suspense:
|
|
1861
|
+
|
|
1862
|
+
```vue
|
|
1863
|
+
<script setup lang="ts">
|
|
1864
|
+
import { useRequestState } from 'vue-fate';
|
|
1865
|
+
import { UserView } from '../fateViews';
|
|
1866
|
+
import UserCard from '../ui/UserCard.vue';
|
|
1867
|
+
|
|
1868
|
+
const state = useRequestState({ viewer: { view: UserView } }, { mode: 'stale-while-revalidate' });
|
|
1869
|
+
</script>
|
|
1870
|
+
|
|
1871
|
+
<template>
|
|
1872
|
+
<p v-if="state.isFetching">Loading…</p>
|
|
1873
|
+
<p v-if="state.error">Could not load your profile.</p>
|
|
1874
|
+
<UserCard v-if="state.data?.viewer" :user="state.data.viewer" />
|
|
1875
|
+
</template>
|
|
1876
|
+
```
|
|
1877
|
+
|
|
1878
|
+
The result is a ref: use `state.value` in script, while templates unwrap it automatically. Its data contains view refs, which components such as `UserCard` resolve with `useView`. Cached data stays visible during a refresh, and `isFetching` and `error` report its progress and failures.
|
|
1879
|
+
|
|
1880
|
+
The request and options accept plain values, refs, or getters. Pass `{ enabled: false }` to disable fetching, or `{ mode: 'cache-only' }` to observe cached data without fetching missing fields. The `status` is `disabled`, `missing`, `pending`, `ready`, or `error`, as described in [Observing Request State](/docs/guide/requests.md#observing-request-state).
|
|
1881
|
+
|
|
1882
|
+
Call `state.value.refetch()` to fetch fresh data. Handle its rejected promise in the calling code. Disabled and cache-only requests reject refetches. Vue cleans up the observer when the component unmounts; call `state.dispose()` to stop it earlier.
|
|
1883
|
+
|
|
1884
|
+
### Refreshing Requests
|
|
1885
|
+
|
|
1886
|
+
Use `useRefetch` to refresh data from a button or event handler:
|
|
1887
|
+
|
|
1888
|
+
```vue
|
|
1889
|
+
<script setup lang="ts">
|
|
1890
|
+
import { useRefetch } from 'vue-fate';
|
|
1891
|
+
import { UserView } from '../fateViews';
|
|
1892
|
+
|
|
1893
|
+
const refresh = useRefetch({ viewer: { view: UserView } });
|
|
1894
|
+
</script>
|
|
1895
|
+
|
|
1896
|
+
<template>
|
|
1897
|
+
<button @click="refresh().catch(reportError)">Refresh profile</button>
|
|
1898
|
+
</template>
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
The composable waits until you call `refresh()` to fetch from the network. Existing data stays visible, and request and view resources update when the result arrives. Handle failures in the event handler; `reportError` above represents your application's error handler.
|
|
1902
|
+
|
|
1903
|
+
Requests and options can be refs or getters. Each call uses their current values. The optional second argument accepts `persist`, `timeout`, and `signal`; see [Timeouts and Cancellation](/docs/guide/requests.md#timeouts-and-cancellation).
|
|
1981
1904
|
|
|
1982
1905
|
### Views in Components
|
|
1983
1906
|
|
|
@@ -2008,6 +1931,32 @@ const author = useView(UserView, () => post.value?.author ?? null);
|
|
|
2008
1931
|
|
|
2009
1932
|
Pass reactive props through a getter so fate tracks prop changes. In script, resources are refs and need `.value`. In templates, Vue unwraps them automatically.
|
|
2010
1933
|
|
|
1934
|
+
### Resolving Arrays of Refs
|
|
1935
|
+
|
|
1936
|
+
Use `useViews` when one component needs selected data from an array of refs, for example to filter or sort before rendering:
|
|
1937
|
+
|
|
1938
|
+
```vue
|
|
1939
|
+
<script setup lang="ts">
|
|
1940
|
+
import { computed } from 'vue';
|
|
1941
|
+
import { useViews, type ViewRef } from 'vue-fate';
|
|
1942
|
+
import { PostCardView } from '../fateViews';
|
|
1943
|
+
|
|
1944
|
+
const props = defineProps<{ posts: ReadonlyArray<ViewRef<'Post'>> }>();
|
|
1945
|
+
const posts = useViews(PostCardView, () => props.posts);
|
|
1946
|
+
const sorted = computed(() => [...posts.value].sort((a, b) => a.title.localeCompare(b.title)));
|
|
1947
|
+
|
|
1948
|
+
await posts.ready();
|
|
1949
|
+
</script>
|
|
1950
|
+
|
|
1951
|
+
<template>
|
|
1952
|
+
<h2 v-for="post in sorted" :key="post.id">{{ post.title }}</h2>
|
|
1953
|
+
</template>
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
Pass a plain array, ref, or getter, as you would with `useView`. The result follows the input order and updates when the refs or their selected fields change. Deferred refs are supported, and `null` or `undefined` entries keep their values in the result.
|
|
1957
|
+
|
|
1958
|
+
Await `ready()` to use Vue Suspense, or render the resource's pending and error states yourself.
|
|
1959
|
+
|
|
2011
1960
|
### Lists and Live Views
|
|
2012
1961
|
|
|
2013
1962
|
`useListView` subscribes to a connection returned from `useRequest` or from a nested view field:
|
|
@@ -2016,6 +1965,8 @@ Pass reactive props through a getter so fate tracks prop changes. In script, res
|
|
|
2016
1965
|
const [comments, loadNextCommentPage] = useListView(CommentView, () => post.value?.comments);
|
|
2017
1966
|
```
|
|
2018
1967
|
|
|
1968
|
+
Items and pagination helpers are refs. A missing connection returns undefined items and null pagination helpers. Use `comments.value ?? []` to treat missing comments as an empty list.
|
|
1969
|
+
|
|
2019
1970
|
`useLiveView` and `useLiveListView` have the same resource shape as `useView` and `useListView`, but they also subscribe to server-pushed updates when the selected transport supports live views:
|
|
2020
1971
|
|
|
2021
1972
|
```ts
|
|
@@ -2023,17 +1974,7 @@ const post = useLiveView(PostCardView, () => props.post);
|
|
|
2023
1974
|
const [comments] = useLiveListView(CommentView, () => post.value?.comments);
|
|
2024
1975
|
```
|
|
2025
1976
|
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
```ts
|
|
2029
|
-
const post = useLiveView(PostCardView, () => props.post);
|
|
2030
|
-
|
|
2031
|
-
onBeforeUnmount(() => {
|
|
2032
|
-
post.dispose();
|
|
2033
|
-
});
|
|
2034
|
-
```
|
|
2035
|
-
|
|
2036
|
-
Vue scope disposal also cleans up resources automatically.
|
|
1977
|
+
Vue cleans up resources when the component unmounts. Call `dispose()` to stop a resource earlier.
|
|
2037
1978
|
|
|
2038
1979
|
### Mutations
|
|
2039
1980
|
|
|
@@ -2057,10 +1998,11 @@ const like = async () => {
|
|
|
2057
1998
|
error.value = null;
|
|
2058
1999
|
|
|
2059
2000
|
try {
|
|
2060
|
-
await fate.mutations.post.like({
|
|
2001
|
+
const result = await fate.mutations.post.like({
|
|
2061
2002
|
input: { id: props.post.id },
|
|
2062
2003
|
optimistic: { likes: props.post.likes + 1 },
|
|
2063
2004
|
});
|
|
2005
|
+
error.value = result.error ?? null;
|
|
2064
2006
|
} catch (caughtError) {
|
|
2065
2007
|
error.value = caughtError;
|
|
2066
2008
|
} finally {
|
|
@@ -2071,27 +2013,21 @@ const like = async () => {
|
|
|
2071
2013
|
|
|
2072
2014
|
<template>
|
|
2073
2015
|
<button :disabled="pending" @click="like">Like</button>
|
|
2016
|
+
<p v-if="error">Could not like this post.</p>
|
|
2074
2017
|
</template>
|
|
2075
2018
|
```
|
|
2076
2019
|
|
|
2020
|
+
Expected mutation errors are returned in `result.error`; unexpected failures reject the promise. See [Action & Mutation Error Handling](/docs/guide/actions.md#action--mutation-error-handling).
|
|
2021
|
+
|
|
2077
2022
|
The mutation call shape is the same as `react-fate`: `input`, `optimistic`, `insert`, and `view` work the same way. Vue does not have React Actions or `useActionState`, so Vue components should model pending and error state with Vue refs or your application state library.
|
|
2078
2023
|
|
|
2079
2024
|
### API Differences from React
|
|
2080
2025
|
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
- `useRequest`, `useView`, and list hooks return Vue resources instead of throwing promises from render.
|
|
2084
|
-
- Async setup and `<Suspense>` replace React's async component model.
|
|
2085
|
-
- Mutations use `fate.mutations` directly; React-only `fate.actions` and `useActionState` patterns do not apply.
|
|
2086
|
-
- Shared views are best kept in `.ts` modules instead of exporting named views from component files.
|
|
2087
|
-
|
|
2088
|
-
The generated client, server integrations, cache behavior, masking, pagination, optimistic updates, and live transport behavior are shared across adapters.
|
|
2026
|
+
Views, requests, caching, and mutations work the same way in both adapters. Vue composables return reactive resources; await `ready()` to use Suspense, or read their pending and error state. For mutations, use `fate.mutations` and Vue refs for local state instead of React Actions.
|
|
2089
2027
|
|
|
2090
2028
|
## GraphQL Integration
|
|
2091
2029
|
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
Use the GraphQL transport when your backend already exposes GraphQL and you want fate's client model without adding fate's native server protocol.
|
|
2030
|
+
If your backend already exposes GraphQL, you can use it with fate. Define views and call requests and mutations as usual; fate builds the GraphQL operations for you.
|
|
2095
2031
|
|
|
2096
2032
|
### Template
|
|
2097
2033
|
|
|
@@ -2107,7 +2043,7 @@ Create a full GraphQL + Prisma example app with:
|
|
|
2107
2043
|
vp create fate -- --template graphql
|
|
2108
2044
|
```
|
|
2109
2045
|
|
|
2110
|
-
|
|
2046
|
+
Both templates include `src/fate/graphql.ts`, where you map your schema's queries and mutations to fate.
|
|
2111
2047
|
|
|
2112
2048
|
### GraphQL Schema Shape
|
|
2113
2049
|
|
|
@@ -2137,59 +2073,25 @@ If your schema uses different root field names, keep the fate names you want on
|
|
|
2137
2073
|
|
|
2138
2074
|
### Mapping Your Schema
|
|
2139
2075
|
|
|
2140
|
-
Create
|
|
2076
|
+
Create `src/fate/graphql.ts` with your server's schema and the queries and mutations you want to use:
|
|
2141
2077
|
|
|
2142
2078
|
```tsx
|
|
2143
|
-
import {
|
|
2144
|
-
import { dataView, list, type Entity } from '@nkzw/fate/server';
|
|
2145
|
-
|
|
2146
|
-
type GraphQLUser = {
|
|
2147
|
-
id: string;
|
|
2148
|
-
name?: string | null;
|
|
2149
|
-
username?: string | null;
|
|
2150
|
-
};
|
|
2151
|
-
|
|
2152
|
-
type GraphQLPost = {
|
|
2153
|
-
author?: GraphQLUser | null;
|
|
2154
|
-
id: string;
|
|
2155
|
-
title: string;
|
|
2156
|
-
};
|
|
2157
|
-
|
|
2158
|
-
export const userDataView = dataView<GraphQLUser>('User')({
|
|
2159
|
-
id: true,
|
|
2160
|
-
name: true,
|
|
2161
|
-
username: true,
|
|
2162
|
-
});
|
|
2163
|
-
|
|
2164
|
-
export const postDataView = dataView<GraphQLPost>('Post')({
|
|
2165
|
-
author: userDataView,
|
|
2166
|
-
id: true,
|
|
2167
|
-
title: true,
|
|
2168
|
-
});
|
|
2169
|
-
|
|
2170
|
-
export type User = Entity<typeof userDataView, 'User'>;
|
|
2171
|
-
export type Post = Entity<
|
|
2172
|
-
typeof postDataView,
|
|
2173
|
-
'Post',
|
|
2174
|
-
{
|
|
2175
|
-
author: User | null;
|
|
2176
|
-
}
|
|
2177
|
-
>;
|
|
2178
|
-
|
|
2179
|
-
export const Root = {
|
|
2180
|
-
posts: list(postDataView),
|
|
2181
|
-
viewer: userDataView,
|
|
2182
|
-
};
|
|
2079
|
+
import { readFileSync } from 'node:fs';
|
|
2183
2080
|
|
|
2184
2081
|
export const fateGraphQL = {
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2082
|
+
schema: readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'),
|
|
2083
|
+
roots: { posts: {}, viewer: {} },
|
|
2084
|
+
mutations: {
|
|
2085
|
+
'post.like': { entity: 'Post', field: 'postLike' },
|
|
2188
2086
|
},
|
|
2189
2087
|
} as const;
|
|
2190
2088
|
```
|
|
2191
2089
|
|
|
2192
|
-
The
|
|
2090
|
+
The `schema` option accepts schema definition language (SDL) text or a `GraphQLSchema` object. Install `graphql` in the package that generates the client when using SDL.
|
|
2091
|
+
|
|
2092
|
+
fate derives entity types, query arguments, and mutation inputs and results from the schema. Use `field` when a fate root name differs from its GraphQL query field, for example `viewer: { field: 'currentUser' }`. The schema is used during generation and is excluded from the browser bundle.
|
|
2093
|
+
|
|
2094
|
+
You can also define a mapping manually with [data views](/docs/integrations/server.md#data-views) and a `Root` export.
|
|
2193
2095
|
|
|
2194
2096
|
### Vite Plugin
|
|
2195
2097
|
|
|
@@ -2229,7 +2131,29 @@ export default defineConfig({
|
|
|
2229
2131
|
|
|
2230
2132
|
:::
|
|
2231
2133
|
|
|
2232
|
-
The plugin generates a typed `createFateClient` helper from your
|
|
2134
|
+
The plugin generates a typed `createFateClient` helper from your schema and GraphQL mapping. It also watches the mapping module and the files it imports during development.
|
|
2135
|
+
|
|
2136
|
+
### Generated Types
|
|
2137
|
+
|
|
2138
|
+
With a schema configured, fate generates `.fate/schema.generated.ts`. Use its `GraphQLEntities` types when defining client views:
|
|
2139
|
+
|
|
2140
|
+
```tsx
|
|
2141
|
+
import { view } from 'react-fate';
|
|
2142
|
+
import type { GraphQLEntities } from '../../.fate/schema.generated.ts';
|
|
2143
|
+
|
|
2144
|
+
type User = GraphQLEntities['User'];
|
|
2145
|
+
|
|
2146
|
+
const UserCardView = view<User>()({
|
|
2147
|
+
name: true,
|
|
2148
|
+
username: true,
|
|
2149
|
+
});
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
These types describe the fields you can select; the view exposes only the fields it selects. Connections appear as node arrays for composing views. Use `GraphQLTypes` for the original schema shapes or `GraphQLResponses` for response types with fate's `{ items, pagination }` connections.
|
|
2153
|
+
|
|
2154
|
+
Custom scalars default to `unknown`. Set `fateGraphQL.scalars` to describe their TypeScript types, for example `{ DateTime: 'string' }`. Use `{ input: 'string', output: 'string' }` when specifying each side separately. These mappings change types; they do not convert returned values.
|
|
2155
|
+
|
|
2156
|
+
Use `import type` for generated types. After updating the schema, run `fate generate` to refresh them.
|
|
2233
2157
|
|
|
2234
2158
|
### Creating a Client
|
|
2235
2159
|
|
|
@@ -2289,9 +2213,9 @@ const fate = createFateClient({
|
|
|
2289
2213
|
});
|
|
2290
2214
|
```
|
|
2291
2215
|
|
|
2292
|
-
|
|
2216
|
+
Queries made together are batched into one GraphQL operation. Mutations run separately and are not retried unless you configure [Persistence](/docs/guide/persistence.md).
|
|
2293
2217
|
|
|
2294
|
-
Deferred
|
|
2218
|
+
[Deferred fields](/docs/guide/deferred-views.md) load when a component reads them. This uses ordinary GraphQL queries; incremental `@defer` responses are not supported yet.
|
|
2295
2219
|
|
|
2296
2220
|
### Refetch Fields
|
|
2297
2221
|
|
|
@@ -2308,15 +2232,48 @@ export const fateGraphQL = {
|
|
|
2308
2232
|
} as const;
|
|
2309
2233
|
```
|
|
2310
2234
|
|
|
2311
|
-
`idArg` defaults to `id`. Each
|
|
2235
|
+
`idArg` defaults to `id`. Each query must return one entity of the configured type and accept its ID. Other required arguments need defaults. With `nodes: false`, every type fetched by ID needs a mapping.
|
|
2236
|
+
|
|
2237
|
+
Choose lookup fields with the authorization rules your app needs. These fields are also used to load deferred fields and additional pages of nested lists.
|
|
2312
2238
|
|
|
2313
|
-
|
|
2239
|
+
#### Refetching Through Existing Queries
|
|
2314
2240
|
|
|
2315
|
-
|
|
2241
|
+
If your API fetches users by username instead of ID, configure fate to use that query when refreshing a user:
|
|
2242
|
+
|
|
2243
|
+
```tsx
|
|
2244
|
+
export const fateGraphQL = {
|
|
2245
|
+
schema,
|
|
2246
|
+
nodes: false,
|
|
2247
|
+
roots: {
|
|
2248
|
+
user: {},
|
|
2249
|
+
},
|
|
2250
|
+
refetch: {
|
|
2251
|
+
User: {
|
|
2252
|
+
root: 'user',
|
|
2253
|
+
args: { username: { field: 'username' } },
|
|
2254
|
+
},
|
|
2255
|
+
},
|
|
2256
|
+
} as const;
|
|
2257
|
+
```
|
|
2316
2258
|
|
|
2317
|
-
|
|
2259
|
+
Here `user` maps to `Query.user(username:)`. fate selects `username` automatically and uses its confirmed value for later reads, deferred fields, and pagination. Components only see it if their view selects it.
|
|
2318
2260
|
|
|
2319
|
-
|
|
2261
|
+
The user must have been fetched before fate can look it up by username. If the username changes elsewhere, refresh a parent or viewer query to get the new value.
|
|
2262
|
+
|
|
2263
|
+
Use `{ field: 'username' }` to read a scalar field, `{ value: 'public' }` for a constant, or `{ object: { ... } }` for a nested input object:
|
|
2264
|
+
|
|
2265
|
+
```tsx
|
|
2266
|
+
args: {
|
|
2267
|
+
input: {
|
|
2268
|
+
object: {
|
|
2269
|
+
tenant: { value: 'public' },
|
|
2270
|
+
username: { field: 'username' },
|
|
2271
|
+
},
|
|
2272
|
+
},
|
|
2273
|
+
}
|
|
2274
|
+
```
|
|
2275
|
+
|
|
2276
|
+
You can check these arguments with `RefetchArguments<User, GraphQLFieldArguments['Query']['user']>` from `@nkzw/fate`. The query must return a single entity of the configured type.
|
|
2320
2277
|
|
|
2321
2278
|
### Object IDs
|
|
2322
2279
|
|
|
@@ -2370,14 +2327,13 @@ This produces a root `posts(first: 10)` field and a nested `comments(first: 3)`
|
|
|
2370
2327
|
|
|
2371
2328
|
### Mutations
|
|
2372
2329
|
|
|
2373
|
-
Map fate mutation names to GraphQL mutation fields
|
|
2330
|
+
Map fate mutation names to GraphQL mutation fields:
|
|
2374
2331
|
|
|
2375
2332
|
```tsx
|
|
2376
2333
|
export const fateGraphQL = {
|
|
2334
|
+
schema,
|
|
2377
2335
|
mutations: {
|
|
2378
|
-
'post.like':
|
|
2379
|
-
field: 'postLike',
|
|
2380
|
-
}),
|
|
2336
|
+
'post.like': { entity: 'Post', field: 'postLike' },
|
|
2381
2337
|
},
|
|
2382
2338
|
roots: {
|
|
2383
2339
|
posts: { field: 'posts' },
|
|
@@ -2400,20 +2356,30 @@ Use `inputArg` when your schema uses a different argument name, or `inputArg: fa
|
|
|
2400
2356
|
|
|
2401
2357
|
```tsx
|
|
2402
2358
|
export const fateGraphQL = {
|
|
2359
|
+
schema,
|
|
2403
2360
|
mutations: {
|
|
2404
|
-
'post.like':
|
|
2405
|
-
|
|
2406
|
-
inputArg: 'payload',
|
|
2407
|
-
}),
|
|
2408
|
-
'user.follow': graphqlMutation<User, { id: string }, User>('User', {
|
|
2409
|
-
field: 'followUser',
|
|
2410
|
-
inputArg: false,
|
|
2411
|
-
}),
|
|
2361
|
+
'post.like': { entity: 'Post', field: 'likePost', inputArg: 'payload' },
|
|
2362
|
+
'user.follow': { entity: 'User', field: 'followUser', inputArg: false },
|
|
2412
2363
|
},
|
|
2413
2364
|
} as const;
|
|
2414
2365
|
```
|
|
2415
2366
|
|
|
2416
|
-
|
|
2367
|
+
Call mutations through `fate.mutations`, as described in the [Actions Guide](/docs/guide/actions.md). Pass a view to select fields from the result:
|
|
2368
|
+
|
|
2369
|
+
```tsx
|
|
2370
|
+
const { result } = await fate.mutations.post.like({
|
|
2371
|
+
input: { id: post.id },
|
|
2372
|
+
view: PostView,
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
if (result) {
|
|
2376
|
+
console.log(result.title);
|
|
2377
|
+
}
|
|
2378
|
+
```
|
|
2379
|
+
|
|
2380
|
+
Without a selection, entity results expose only identity fields. React clients can also use `fate.actions` with `useActionState`; those action results expose identity fields, while selected fields update the cache for views to read.
|
|
2381
|
+
|
|
2382
|
+
For mappings without a schema, use `graphqlMutation` from `@nkzw/fate` to declare mutation types manually.
|
|
2417
2383
|
|
|
2418
2384
|
### Live Views
|
|
2419
2385
|
|
|
@@ -2441,66 +2407,19 @@ const fate = createFateClient({
|
|
|
2441
2407
|
});
|
|
2442
2408
|
```
|
|
2443
2409
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
If you do not need live views, disable them explicitly:
|
|
2447
|
-
|
|
2448
|
-
```tsx
|
|
2449
|
-
const fate = createFateClient({
|
|
2450
|
-
live: false,
|
|
2451
|
-
url: '/graphql',
|
|
2452
|
-
});
|
|
2453
|
-
```
|
|
2410
|
+
Your server must implement these subscription fields. See the [GraphQL subscription example](https://github.com/nkzw-tech/fate/blob/main/example/server-graphql/src/graphql/live.tsx) for their event shapes and resolvers. For servers without these subscriptions, set `live: false`.
|
|
2454
2411
|
|
|
2455
2412
|
### Existing Servers
|
|
2456
2413
|
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
Use data views to expose only the fields the client should be able to select, keep GraphQL schema authorization in your server, and treat `src/fate/graphql.ts` as the contract between your GraphQL API and fate's client.
|
|
2414
|
+
Your GraphQL server continues to handle authorization, validation, pagination, and mutation side effects. You don't need fate's server or ORM adapters.
|
|
2460
2415
|
|
|
2461
2416
|
### Schema-Aware Arguments
|
|
2462
2417
|
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
```tsx
|
|
2466
|
-
import { readFileSync } from 'node:fs';
|
|
2467
|
-
|
|
2468
|
-
export const fateGraphQL = {
|
|
2469
|
-
schema: readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'),
|
|
2470
|
-
roots: {
|
|
2471
|
-
maps: { field: 'maps' },
|
|
2472
|
-
},
|
|
2473
|
-
mutations: {
|
|
2474
|
-
'map.update': graphqlMutation<Map, UpdateMapInput, Map>('Map', {
|
|
2475
|
-
field: 'updateMap',
|
|
2476
|
-
inputArg: false,
|
|
2477
|
-
}),
|
|
2478
|
-
},
|
|
2479
|
-
};
|
|
2480
|
-
```
|
|
2481
|
-
|
|
2482
|
-
Install `graphql` in the package performing generation when passing SDL. Run `fate generate` again after updating the schema. Generated browser code contains argument metadata, not the SDL or GraphQL parser. Existing clients without a schema retain literal argument serialization; use schema-aware generation for enums.
|
|
2483
|
-
|
|
2484
|
-
SDL text must include definitions for every directive it uses. Some server-generated or Relay-oriented schema files contain annotations such as `@auth` or `@fetchable` without declaring them; strict SDL parsing rejects those files. Prefer adding the directive definitions to the exported SDL. If the omissions are intentional, pass a schema object instead:
|
|
2485
|
-
|
|
2486
|
-
```tsx
|
|
2487
|
-
import { readFileSync } from 'node:fs';
|
|
2488
|
-
import { buildSchema } from 'graphql';
|
|
2489
|
-
|
|
2490
|
-
const sdl = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
|
2491
|
-
export const fateGraphQL = {
|
|
2492
|
-
schema: buildSchema(sdl, { assumeValidSDL: true }),
|
|
2493
|
-
// ...roots and mutations...
|
|
2494
|
-
};
|
|
2495
|
-
```
|
|
2496
|
-
|
|
2497
|
-
`assumeValidSDL` skips SDL validation, so use it only when the annotations are known and the schema is validated elsewhere. Fate still extracts argument metadata at generation time; the browser bundle does not include the schema object.
|
|
2418
|
+
With a schema configured, fate checks arguments and sends them as typed GraphQL variables, including enum values. Required arguments and server defaults follow the schema; omitting a value is different from passing `null`.
|
|
2498
2419
|
|
|
2499
|
-
|
|
2420
|
+
Use valid SDL, including definitions for any custom directives. You can also pass a `GraphQLSchema` object.
|
|
2500
2421
|
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
Generation also exports `GraphQLInputs`, `GraphQLFieldArguments`, and `GraphQLSelectionArguments` from `.fate/client.generated.ts`. Use field contracts to check arguments inside reusable views:
|
|
2422
|
+
Generation also exports `GraphQLInputs`, `GraphQLFieldArguments`, and `GraphQLSelectionArguments` from `.fate/client.generated.ts`. Use these types to check arguments inside reusable views:
|
|
2504
2423
|
|
|
2505
2424
|
```tsx
|
|
2506
2425
|
import type { GraphQLFieldArguments } from '../.fate/client.generated.ts';
|
|
@@ -2515,32 +2434,35 @@ const MapView = view<Map>()({
|
|
|
2515
2434
|
});
|
|
2516
2435
|
```
|
|
2517
2436
|
|
|
2518
|
-
|
|
2437
|
+
### Errors and Partial Results
|
|
2519
2438
|
|
|
2520
|
-
|
|
2439
|
+
Query failures throw `GraphQLRequestError`, available from `react-fate` and `vue-fate`. You can inspect its `errors`, `path`, `extensions`, and HTTP `status`. Partial data is available as `error.data`, but isn't written to the cache.
|
|
2521
2440
|
|
|
2522
|
-
|
|
2441
|
+
Actions and mutations follow the [action error policy](/docs/guide/actions.md#action--mutation-error-handling). Expected failures such as validation errors return `{ error, result: undefined }`, and optimistic changes roll back:
|
|
2523
2442
|
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
when available. HTTP errors use the same error type; network failures retain the
|
|
2527
|
-
original fetch error.
|
|
2443
|
+
```tsx
|
|
2444
|
+
import { GraphQLRequestError } from 'react-fate';
|
|
2528
2445
|
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2446
|
+
const { error } = await fate.mutations.post.like({ input: { id: post.id } });
|
|
2447
|
+
|
|
2448
|
+
if (error instanceof GraphQLRequestError && error.extensions?.code === 'BAD_USER_INPUT') {
|
|
2449
|
+
// Show a validation message next to the form.
|
|
2450
|
+
}
|
|
2451
|
+
```
|
|
2452
|
+
|
|
2453
|
+
Use standard `extensions.code` values such as `BAD_USER_INPUT` and `CONFLICT`, or numeric `extensions.http.status` values, to identify expected errors. Authentication, authorization, server, and network failures throw.
|
|
2454
|
+
|
|
2455
|
+
The HTTP status may still be `200` when GraphQL reports an error. For retry behavior with saved mutations, see [Persistence](/docs/guide/persistence.md#trpc-graphql-and-custom-transports).
|
|
2534
2456
|
|
|
2535
2457
|
## Server Integration
|
|
2536
2458
|
|
|
2537
|
-
|
|
2459
|
+
Now that we have defined our client views, let's connect them to a backend. fate supports three approaches:
|
|
2538
2460
|
|
|
2539
|
-
-
|
|
2540
|
-
-
|
|
2541
|
-
-
|
|
2461
|
+
- Native HTTP with any Fetch-compatible server.
|
|
2462
|
+
- tRPC for an existing tRPC backend.
|
|
2463
|
+
- [GraphQL](/docs/integrations/graphql.md) for an existing GraphQL API.
|
|
2542
2464
|
|
|
2543
|
-
|
|
2465
|
+
The Prisma and Drizzle adapters turn your server data views into database queries. Both work with native HTTP and tRPC.
|
|
2544
2466
|
|
|
2545
2467
|
### Conventions & Object Identity
|
|
2546
2468
|
|
|
@@ -2549,9 +2471,7 @@ fate expects that data is served by a backend that follows these conventions:
|
|
|
2549
2471
|
- A `byId` query for each data type to fetch individual objects by their unique identifier (`id`).
|
|
2550
2472
|
- A `list` query for fetching lists of objects with support for pagination.
|
|
2551
2473
|
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
fate's type definitions might seem verbose at first glance. However, with fate's minimal API surface, AI tools can easily write this code for you, and the Vite plugin takes care of connecting it to your app.
|
|
2474
|
+
Each object needs an ID and a type name (`__typename`, such as `Post` or `User`). fate uses them to share the object between views in the client cache.
|
|
2555
2475
|
|
|
2556
2476
|
> [!NOTE]
|
|
2557
2477
|
> You can adopt _fate_ incrementally in an existing tRPC codebase without changing your existing schema by adding these queries alongside your existing procedures.
|
|
@@ -2649,7 +2569,7 @@ export const Root = {
|
|
|
2649
2569
|
|
|
2650
2570
|
Entries that wrap their view in `list(...)` are treated as list resolvers. In the native protocol, the root key is the operation name used by the client. In the tRPC adapter, `procedure` can point that root at a specific router procedure. If you omit `list(...)`, fate treats the entry as a standard query.
|
|
2651
2571
|
|
|
2652
|
-
|
|
2572
|
+
Each `list(...)` can have its own ordering. For example, the feed can show newest posts first while comments use oldest first. Without `orderBy`, fate sorts by `id asc`. It also adds `id asc` as a tie-breaker when your ordering doesn't include `id`. Use an array when ordering by several fields.
|
|
2653
2573
|
|
|
2654
2574
|
For the above `Root` definitions, you can make the following requests using `useRequest`:
|
|
2655
2575
|
|
|
@@ -2672,7 +2592,7 @@ const { posts, categories, viewer } = useRequest({
|
|
|
2672
2592
|
|
|
2673
2593
|
### Native fate protocol
|
|
2674
2594
|
|
|
2675
|
-
|
|
2595
|
+
Create a source adapter for your ORM, pass it to `createFateServer`, and add a route for the HTTP handler:
|
|
2676
2596
|
|
|
2677
2597
|
```tsx
|
|
2678
2598
|
import { createFateServer, createHonoFateHandler } from '@nkzw/fate/server';
|
|
@@ -2714,7 +2634,7 @@ const app = new Hono();
|
|
|
2714
2634
|
const handler = createHonoFateHandler(fate);
|
|
2715
2635
|
|
|
2716
2636
|
app.post('/fate', handler);
|
|
2717
|
-
app.
|
|
2637
|
+
app.all('/fate/live', handler);
|
|
2718
2638
|
```
|
|
2719
2639
|
|
|
2720
2640
|
Configure the Vite plugin with the native transport:
|
|
@@ -2775,30 +2695,11 @@ const client = createFateClient({
|
|
|
2775
2695
|
|
|
2776
2696
|
:::
|
|
2777
2697
|
|
|
2778
|
-
|
|
2698
|
+
Calls made together are batched into one HTTP request. The `/fate/live` route is used for [Live Views](/docs/guide/live-views.md).
|
|
2779
2699
|
|
|
2780
2700
|
#### Custom Queries
|
|
2781
2701
|
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
```tsx
|
|
2785
|
-
export const fate = createFateServer({
|
|
2786
|
-
context,
|
|
2787
|
-
queries: {
|
|
2788
|
-
viewer: {
|
|
2789
|
-
resolve: ({ ctx, select }) =>
|
|
2790
|
-
sources.resolveById({
|
|
2791
|
-
ctx,
|
|
2792
|
-
id: ctx.sessionUser.id,
|
|
2793
|
-
input: { select },
|
|
2794
|
-
view: userDataView,
|
|
2795
|
-
}),
|
|
2796
|
-
},
|
|
2797
|
-
},
|
|
2798
|
-
roots: Root,
|
|
2799
|
-
sources,
|
|
2800
|
-
});
|
|
2801
|
-
```
|
|
2702
|
+
The `viewer` query above needs a resolver because only your app knows which user is signed in. Use `sources.resolveById` to return that user's selected fields.
|
|
2802
2703
|
|
|
2803
2704
|
#### Custom Mutations
|
|
2804
2705
|
|
|
@@ -2832,45 +2733,19 @@ export const fate = createFateServer({
|
|
|
2832
2733
|
|
|
2833
2734
|
#### Live Views
|
|
2834
2735
|
|
|
2835
|
-
|
|
2736
|
+
Follow the [Live Views server setup](/docs/guide/live-views.md#server-setup) to add an event bus. After a mutation changes a post, emit an update:
|
|
2836
2737
|
|
|
2837
2738
|
```tsx
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
export const live = createLiveEventBus();
|
|
2841
|
-
|
|
2842
|
-
export const fate = createFateServer({
|
|
2843
|
-
live,
|
|
2844
|
-
queries: {
|
|
2845
|
-
viewer: {
|
|
2846
|
-
resolve: ({ ctx, select }) =>
|
|
2847
|
-
sources.resolveById({
|
|
2848
|
-
ctx,
|
|
2849
|
-
id: ctx.sessionUser.id,
|
|
2850
|
-
input: { select },
|
|
2851
|
-
view: userDataView,
|
|
2852
|
-
}),
|
|
2853
|
-
},
|
|
2854
|
-
},
|
|
2855
|
-
roots: Root,
|
|
2856
|
-
sources,
|
|
2857
|
-
});
|
|
2858
|
-
|
|
2859
|
-
live.update('Post', post.id, {
|
|
2860
|
-
changed: ['likes'],
|
|
2861
|
-
eventId: `post:${post.id}:${Date.now()}`,
|
|
2862
|
-
});
|
|
2739
|
+
live.update('Post', post.id, { changed: ['likes'] });
|
|
2863
2740
|
```
|
|
2864
2741
|
|
|
2865
|
-
`changed`
|
|
2866
|
-
|
|
2867
|
-
Native SSE connections keep a bounded in-memory queue while events are waiting to be resolved and sent. The default is `1000` queued events per connection. If a client falls behind and exceeds that limit, fate closes the live connection instead of buffering indefinitely. Configure it with `live: { bus: live, maxQueueSize: 500 }`.
|
|
2742
|
+
The optional `changed` list limits the update to those fields.
|
|
2868
2743
|
|
|
2869
2744
|
### tRPC fate setup
|
|
2870
2745
|
|
|
2871
2746
|
The Prisma and Drizzle tRPC integrations connect your data views to your database, bind fate's standard tRPC procedures, and expose helpers for custom queries and mutations.
|
|
2872
2747
|
|
|
2873
|
-
Pass the `Root` export from `views.ts` to
|
|
2748
|
+
Pass the `Root` export from `views.ts` to the ORM helper in your tRPC `init.ts` file:
|
|
2874
2749
|
|
|
2875
2750
|
#### Prisma
|
|
2876
2751
|
|
|
@@ -2895,8 +2770,6 @@ export const fate = createPrismaFate<AppContext, typeof procedure>({
|
|
|
2895
2770
|
|
|
2896
2771
|
If your Prisma client is not stored at `ctx.prisma`, pass `prisma: (ctx) => ctx.db`.
|
|
2897
2772
|
|
|
2898
|
-
The Prisma integration translates view requests into Prisma `select`, `where`, `cursor`, `skip`, and `take` options. It also hydrates computed `count(...)` dependencies using Prisma `groupBy` when needed.
|
|
2899
|
-
|
|
2900
2773
|
For custom Prisma queries and mutations, use `fate.createPlan` with `toPrismaSelect`:
|
|
2901
2774
|
|
|
2902
2775
|
```tsx
|
|
@@ -2960,34 +2833,13 @@ export const fate = createDrizzleFate<AppContext, typeof procedure>({
|
|
|
2960
2833
|
});
|
|
2961
2834
|
```
|
|
2962
2835
|
|
|
2963
|
-
The Drizzle adapter
|
|
2836
|
+
The Drizzle adapter loads the selected fields and relations for you. Nested pagination runs up to 10 child queries at a time; set `nestedPaginationConcurrency` to adjust that limit for your database pool.
|
|
2964
2837
|
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
```tsx
|
|
2968
|
-
export const fate = createDrizzleFate<AppContext, typeof procedure>({
|
|
2969
|
-
db,
|
|
2970
|
-
nestedPaginationConcurrency: 5,
|
|
2971
|
-
procedure,
|
|
2972
|
-
schema,
|
|
2973
|
-
views: Root,
|
|
2974
|
-
});
|
|
2975
|
-
```
|
|
2838
|
+
Count filters can use equality objects or Drizzle predicates such as `(columns) => eq(columns.status, 'GOING')`.
|
|
2976
2839
|
|
|
2977
2840
|
For request-specific sorting, prefer a custom root query that validates and translates explicit sort args.
|
|
2978
2841
|
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
```tsx
|
|
2982
|
-
export const fate = createDrizzleFate<AppContext, typeof procedure>({
|
|
2983
|
-
db,
|
|
2984
|
-
procedure,
|
|
2985
|
-
schema,
|
|
2986
|
-
views: Root,
|
|
2987
|
-
});
|
|
2988
|
-
```
|
|
2989
|
-
|
|
2990
|
-
You can still provide explicit join metadata when the schema is ambiguous:
|
|
2842
|
+
Define many-to-many joins in your Drizzle schema so fate can discover them. If a join is ambiguous, provide its metadata explicitly:
|
|
2991
2843
|
|
|
2992
2844
|
```tsx
|
|
2993
2845
|
{
|
|
@@ -3185,9 +3037,7 @@ export default defineConfig({
|
|
|
3185
3037
|
|
|
3186
3038
|
_Note: fate uses the specified server module name to find the server types it needs. Make sure that the module is available to the client package's Vite config._
|
|
3187
3039
|
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
For a barebones client without a framework adapter, import the plugin from `@nkzw/fate/vite` and the client APIs from `@nkzw/fate/client`. The plugin wires the same server types for the selected import path.
|
|
3040
|
+
The plugin updates the client when your server files change. For an app without React or Vue, use `@nkzw/fate/vite` and `@nkzw/fate/client` instead.
|
|
3191
3041
|
|
|
3192
3042
|
The plugin writes project-local types under `.fate/`. If your TypeScript config does not already include dot-directories, extend the generated config:
|
|
3193
3043
|
|
|
@@ -3268,9 +3118,6 @@ _And you are all set. Happy building!_
|
|
|
3268
3118
|
|
|
3269
3119
|
`void-fate` is the [Void](https://void.cloud) adapter for fate. Deploy your app directly to your own Cloudflare account or to the Void platform with the same integration.
|
|
3270
3120
|
|
|
3271
|
-
Use this integration when your app runs on Void and you want the example app's
|
|
3272
|
-
setup without copying its adapter glue.
|
|
3273
|
-
|
|
3274
3121
|
### New Project
|
|
3275
3122
|
|
|
3276
3123
|
For a new Void app, start from the Void template. It includes the client, Void routes, Drizzle setup, live transport, auth wiring, and generated fate client setup.
|
|
@@ -3345,9 +3192,7 @@ export default defineConfig({
|
|
|
3345
3192
|
|
|
3346
3193
|
:::
|
|
3347
3194
|
|
|
3348
|
-
The
|
|
3349
|
-
updates by default. In SSR, it calls the exported fate server directly. In the
|
|
3350
|
-
browser, it uses fetch and the SSE live endpoint.
|
|
3195
|
+
The default routes are `/fate` for requests and `/fate-live` for live updates. Server rendering calls your fate server directly.
|
|
3351
3196
|
|
|
3352
3197
|
### Server Setup
|
|
3353
3198
|
|
|
@@ -3389,7 +3234,7 @@ live.connection('Post.comments', { id: postId }).appendNode('Comment', commentId
|
|
|
3389
3234
|
});
|
|
3390
3235
|
```
|
|
3391
3236
|
|
|
3392
|
-
`changed`
|
|
3237
|
+
Pass `changed` to refresh only those fields, or omit it to refresh the whole selected view.
|
|
3393
3238
|
|
|
3394
3239
|
### Routes
|
|
3395
3240
|
|
|
@@ -3428,9 +3273,6 @@ import { fateStream } from '../src/fate/live.ts';
|
|
|
3428
3273
|
export const { GET, POST } = defineVoidFateLiveRoute(fateStream);
|
|
3429
3274
|
```
|
|
3430
3275
|
|
|
3431
|
-
The live route handles `GET /fate-live` SSE connections and `POST /fate-live`
|
|
3432
|
-
control messages. `void-fate` does not use WebSockets.
|
|
3433
|
-
|
|
3434
3276
|
### Layout
|
|
3435
3277
|
|
|
3436
3278
|
Wrap your app with the Void fate client for your framework. It creates and
|
|
@@ -3522,17 +3364,11 @@ const fate = computed(() =>
|
|
|
3522
3364
|
|
|
3523
3365
|
:::
|
|
3524
3366
|
|
|
3525
|
-
|
|
3526
|
-
or router configuration matches the paths you pass to the client.
|
|
3367
|
+
Make sure your route filenames match the paths passed to the client.
|
|
3527
3368
|
|
|
3528
3369
|
### Live Transport
|
|
3529
3370
|
|
|
3530
|
-
|
|
3531
|
-
stream associated with the current request. Void manages the Durable Object
|
|
3532
|
-
transport that distributes events to connected clients across requests.
|
|
3533
|
-
|
|
3534
|
-
The live transport is best-effort and does not replay missed events after a
|
|
3535
|
-
client reconnects. This matches fate's default in-memory live event bus.
|
|
3371
|
+
Void distributes live updates across requests and connected clients. Missed events are not replayed after a reconnect.
|
|
3536
3372
|
|
|
3537
3373
|
### Cloudflare Deployment
|
|
3538
3374
|
|