@nkzw/fate-indexeddb 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +442 -770
- package/package.json +2 -2
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
|
-
|
|
509
|
-
|
|
510
|
-
Declare view definitions at module scope. Binding an equivalent parameter object again preserves the logical request and ref identity; no `useMemo` is needed. Parameter objects use the same serializable values as field arguments, and fate snapshots them when binding. Keep selection functions pure, with all varying inputs passed as parameters.
|
|
517
|
+
Here `name` accepts a `locale` argument on the server. Both refs point to the same user, but each keeps its own parameters.
|
|
511
518
|
|
|
512
|
-
|
|
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,50 +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
|
+
```
|
|
640
|
+
|
|
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).
|
|
642
|
+
|
|
643
|
+
### Timeouts and Cancellation
|
|
620
644
|
|
|
621
|
-
|
|
645
|
+
You can limit how long a request waits by passing a `timeout` in milliseconds:
|
|
622
646
|
|
|
623
647
|
```tsx
|
|
624
|
-
const
|
|
625
|
-
|
|
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.
|
|
626
652
|
|
|
653
|
+
To cancel a request, pass an `AbortSignal` and call `abort()` when the result is no longer needed:
|
|
654
|
+
|
|
655
|
+
```tsx
|
|
627
656
|
const controller = new AbortController();
|
|
628
|
-
|
|
629
|
-
// From another event handler: controller.abort();
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
The default has no deadline. A timeout rejects with an error named `TimeoutError`;
|
|
633
|
-
external cancellation rejects with `signal.reason` (normally an `AbortError`).
|
|
634
|
-
The deadline starts when the read starts, includes persistence initialization,
|
|
635
|
-
and stays fixed across equivalent Suspense rerenders. Use a stable signal when
|
|
636
|
-
passing one to a hook. An already-aborted signal prevents a new fetch.
|
|
637
|
-
|
|
638
|
-
Each imperative caller has its own deadline. Identical pending reads can share
|
|
639
|
-
work, but cancelling one caller keeps that work alive for the others. Batched HTTP
|
|
640
|
-
and GraphQL requests abort their shared fetch only when every operation cancels.
|
|
641
|
-
Cancelled operations cannot write late network results to the cache. Custom
|
|
642
|
-
transports receive the signal in their optional read context and should forward
|
|
643
|
-
it to their network implementation; fate still rejects and ignores their late
|
|
644
|
-
results if they do not support cancellation. The tRPC adapter passes the signal
|
|
645
|
-
in the resolver's second argument, matching tRPC query options.
|
|
646
|
-
|
|
647
|
-
`useRequest` sends initial failures to your error boundary. Handle errors from
|
|
648
|
-
`useRefetch` or `client.request` in the calling event handler; these failures
|
|
649
|
-
preserve existing data. An explicit retry starts a new deadline. The same options
|
|
650
|
-
work with `useRequestState`, `client.observeRequest`, and Vue `useRequest`.
|
|
651
|
-
For `stale-while-revalidate`, cached data returns immediately while the deadline
|
|
652
|
-
bounds the background refresh. These controls apply to the request's initial
|
|
653
|
-
reads; later view reads, deferred fields, pagination, live streams, and mutations
|
|
654
|
-
have separate lifetimes and do not inherit them.
|
|
657
|
+
const request = client.request({ posts: { list: PostView } }, { signal: controller.signal });
|
|
655
658
|
|
|
656
|
-
|
|
659
|
+
controller.abort();
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
await request;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
// Handle cancellation or a failed request.
|
|
665
|
+
}
|
|
666
|
+
```
|
|
657
667
|
|
|
658
|
-
|
|
668
|
+
Cancellation rejects with `signal.reason`, usually an `AbortError`. If callers share a request, cancelling one does not cancel the others.
|
|
659
669
|
|
|
660
|
-
|
|
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
|
|
661
673
|
|
|
662
|
-
|
|
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:
|
|
663
677
|
|
|
664
678
|
```tsx
|
|
665
679
|
const fate = createClient({
|
|
@@ -670,11 +684,9 @@ const fate = createClient({
|
|
|
670
684
|
});
|
|
671
685
|
```
|
|
672
686
|
|
|
673
|
-
Set `gcReleaseBufferSize` to `0`
|
|
674
|
-
|
|
675
|
-
`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.
|
|
676
688
|
|
|
677
|
-
|
|
689
|
+
Outside a component, use `retain` when you need to keep a request's data in memory:
|
|
678
690
|
|
|
679
691
|
```tsx
|
|
680
692
|
const request = { posts: { list: PostView } };
|
|
@@ -688,11 +700,9 @@ try {
|
|
|
688
700
|
}
|
|
689
701
|
```
|
|
690
702
|
|
|
691
|
-
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.
|
|
692
|
-
|
|
693
703
|
### SSR and Hydration
|
|
694
704
|
|
|
695
|
-
Create a
|
|
705
|
+
Create a separate fate client for each server request and load the route's data before rendering:
|
|
696
706
|
|
|
697
707
|
```tsx
|
|
698
708
|
const fate = createFateClient();
|
|
@@ -703,9 +713,7 @@ return {
|
|
|
703
713
|
};
|
|
704
714
|
```
|
|
705
715
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
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:
|
|
709
717
|
|
|
710
718
|
```tsx
|
|
711
719
|
const fate = createFateClient();
|
|
@@ -719,9 +727,9 @@ hydrateRoot(
|
|
|
719
727
|
);
|
|
720
728
|
```
|
|
721
729
|
|
|
722
|
-
|
|
730
|
+
`cache-first` requests can now use the server's data without refetching. Await preloading before calling `dehydrate`, and hydrate before starting browser requests.
|
|
723
731
|
|
|
724
|
-
|
|
732
|
+
Generated clients set a hydration scope automatically. When creating a client manually, set `hydrationScope` and change it when deploying incompatible data models:
|
|
725
733
|
|
|
726
734
|
```tsx
|
|
727
735
|
const fate = createClient({
|
|
@@ -730,63 +738,40 @@ const fate = createClient({
|
|
|
730
738
|
});
|
|
731
739
|
```
|
|
732
740
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
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'`:
|
|
736
742
|
|
|
737
743
|
```tsx
|
|
738
744
|
fate.hydrate(loaderData.fate, { merge: 'replace' });
|
|
739
745
|
```
|
|
740
746
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
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.
|
|
744
748
|
|
|
745
749
|
### Observing Request State
|
|
746
750
|
|
|
747
|
-
|
|
748
|
-
It returns `status`, `data`, `error`, and `isFetching`. Status is `pending`, `ready`,
|
|
749
|
-
or `error` for an enabled network request. An initial failure exposes the original
|
|
750
|
-
error rather than throwing during render. Request data contains the same refs as
|
|
751
|
-
`useRequest`; resolve them with `useView`.
|
|
752
|
-
|
|
753
|
-
Use `{ enabled: false }` for an empty search or an inactive screen. The status is
|
|
754
|
-
`disabled`, data is undefined, and no request starts. Changing `enabled` to true
|
|
755
|
-
starts the request. Equivalent inline request objects do not cause repeat fetches.
|
|
756
|
-
|
|
757
|
-
Use `{ mode: 'cache-only' }` to observe already-cached data. A complete selection
|
|
758
|
-
has status `ready`; an incomplete selection has status `missing` and undefined
|
|
759
|
-
data. A successful nullable root remains `ready` with a null value. Cache-only
|
|
760
|
-
requests never fetch, and their view refs cannot fetch missing fields either.
|
|
761
|
-
Mounted observers retain their request's records until unmount.
|
|
762
|
-
|
|
763
|
-
Outside React, `client.observeRequest(request, options)` exposes `getSnapshot()`
|
|
764
|
-
and `subscribe(listener)`. The first subscriber starts an enabled request;
|
|
765
|
-
unsubscribing releases its retention. Reading a snapshot alone never starts work.
|
|
766
|
-
|
|
767
|
-
Every request state includes a stable `refetch()` function. It forces a network
|
|
768
|
-
request, returns a promise for the completed result, and rejects with the original
|
|
769
|
-
error on failure. Concurrent calls share pending work. During refresh, complete
|
|
770
|
-
cached data stays available with `status: 'ready'` and `isFetching: true`. A failed
|
|
771
|
-
refresh keeps that data and exposes `error`; the next attempt clears the error.
|
|
772
|
-
`stale-while-revalidate` observers track the entire background request the same
|
|
773
|
-
way, including failures. Disabled and cache-only observers reject explicit
|
|
774
|
-
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:
|
|
775
752
|
|
|
776
|
-
```
|
|
753
|
+
```tsx
|
|
777
754
|
const state = useRequestState(request, { mode: 'stale-while-revalidate' });
|
|
778
|
-
|
|
779
|
-
|
|
755
|
+
|
|
756
|
+
// In a button's event handler:
|
|
757
|
+
const refresh = () => state.refetch().catch(reportError);
|
|
780
758
|
```
|
|
781
759
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
replacements also retain the newer result. Optimistic layers remain visible over
|
|
786
|
-
incoming confirmed data; rolling them back reveals that data, while committing a
|
|
787
|
-
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.
|
|
761
|
+
|
|
762
|
+
Use `enabled: false` to wait for input before fetching, or `mode: 'cache-only'` to read only cached data. The possible statuses are:
|
|
788
763
|
|
|
789
|
-
|
|
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
|
|
790
775
|
|
|
791
776
|
Use `alias(sourceField, selection)` to select one schema field under multiple
|
|
792
777
|
result names, with independent arguments:
|
|
@@ -808,55 +793,13 @@ const GamesView = view<User>()({
|
|
|
808
793
|
const request = { currentUser: alias('viewer', { view: GamesView }) };
|
|
809
794
|
```
|
|
810
795
|
|
|
811
|
-
|
|
812
|
-
contracts remain checked. Alias names affect the returned shape; normalized
|
|
813
|
-
identity uses the source field and its arguments. An ordinary selection with the
|
|
814
|
-
same arguments shares the cached data. Connection filters identify separate
|
|
815
|
-
cache slots and pagination state. Conflicting response names or arguments in a
|
|
816
|
-
composed view fail during planning. `id` and `__typename` are reserved result
|
|
817
|
-
names because fate needs them for entity identity.
|
|
818
|
-
|
|
819
|
-
GraphQL emits native aliases, including inside mutation selections, so multiple
|
|
820
|
-
variants execute in one operation. Native HTTP, tRPC, and custom transports
|
|
821
|
-
receive compatible reads with ordinary schema field names; conflicting variants
|
|
822
|
-
may require multiple reads or subscriptions. These fallback reads are independent
|
|
823
|
-
server operations. A mutation that needs multiple variants requires native alias
|
|
824
|
-
support and otherwise rejects before executing. A simple rename is lowered for
|
|
825
|
-
native mutations without repeating the action.
|
|
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.
|
|
826
797
|
|
|
827
|
-
|
|
828
|
-
one operation. Selection path segments and corresponding argument keys then use
|
|
829
|
-
`resultName:schemaField`, and returned records must use those same encoded keys.
|
|
830
|
-
GraphQL performs that conversion automatically. Applications consume the declared
|
|
831
|
-
result names, never the encoded transport names.
|
|
832
|
-
|
|
833
|
-
### Imperative refresh with Suspense
|
|
834
|
-
|
|
835
|
-
In React, use `useRefetch` alongside `useRequest` to refresh the same request from
|
|
836
|
-
an event handler without changing the Suspense hook's return value:
|
|
837
|
-
|
|
838
|
-
```tsx
|
|
839
|
-
import { useRefetch, useRequest } from 'react-fate';
|
|
840
|
-
|
|
841
|
-
const request = { viewer: { view: UserView } };
|
|
842
|
-
const { viewer } = useRequest(request);
|
|
843
|
-
const refetch = useRefetch(request);
|
|
844
|
-
|
|
845
|
-
// In an event handler or React Action:
|
|
846
|
-
await refetch();
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
Calling the hook does not fetch or subscribe. Its callback forces a network
|
|
850
|
-
request and returns its typed result, rejecting on failure so the caller can
|
|
851
|
-
handle the error. Existing cached data stays available while refreshing. Cache
|
|
852
|
-
updates notify subscribed views and requests, including changed root results.
|
|
853
|
-
Equivalent inline requests preserve the callback's identity. The optional second
|
|
854
|
-
argument accepts `persist` options; the request mode is always `network-only`.
|
|
855
|
-
Entity refreshes use the configured refetch query when one exists, across transports.
|
|
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.
|
|
856
799
|
|
|
857
800
|
## Deferred Views
|
|
858
801
|
|
|
859
|
-
|
|
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:
|
|
860
803
|
|
|
861
804
|
```tsx
|
|
862
805
|
import { Suspense } from 'react';
|
|
@@ -911,11 +854,9 @@ function PostComments({
|
|
|
911
854
|
}
|
|
912
855
|
```
|
|
913
856
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
This keeps parent components simple: eager fields like `title` and `content` are available when `useView(PostView, postRef)` returns, while slower or secondary fields such as `comments` can load under their own boundary.
|
|
857
|
+
`title` and `content` are available when `useView(PostView, postRef)` returns. Reading the comments with `useListView` loads any missing data and suspends only `PostComments`.
|
|
917
858
|
|
|
918
|
-
|
|
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.
|
|
919
860
|
|
|
920
861
|
## List Views
|
|
921
862
|
|
|
@@ -967,7 +908,15 @@ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: Vi
|
|
|
967
908
|
}
|
|
968
909
|
```
|
|
969
910
|
|
|
970
|
-
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.
|
|
971
920
|
|
|
972
921
|
### Pagination Arguments
|
|
973
922
|
|
|
@@ -991,7 +940,7 @@ const CommentConnectionView = {
|
|
|
991
940
|
|
|
992
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.
|
|
993
942
|
|
|
994
|
-
|
|
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.
|
|
995
944
|
|
|
996
945
|
Additional arguments on a root request are scoped to that root list:
|
|
997
946
|
|
|
@@ -1006,9 +955,13 @@ const { posts } = useRequest({
|
|
|
1006
955
|
|
|
1007
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.
|
|
1008
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
|
+
|
|
1009
962
|
## Live Views
|
|
1010
963
|
|
|
1011
|
-
`useLiveView`
|
|
964
|
+
Use `useLiveView` to keep a view up to date when its data changes on the server:
|
|
1012
965
|
|
|
1013
966
|
```tsx
|
|
1014
967
|
import { useLiveView, ViewRef } from 'react-fate';
|
|
@@ -1026,17 +979,13 @@ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
|
|
|
1026
979
|
};
|
|
1027
980
|
```
|
|
1028
981
|
|
|
1029
|
-
|
|
982
|
+
It takes the same view and ref as `useView`. Only the selected fields are available, and components update when those fields change.
|
|
1030
983
|
|
|
1031
984
|
### How Live Updates Work
|
|
1032
985
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
When the server sends an update, fate normalizes the selected record into the same cache used by requests, actions, and mutations. Components that read affected fields re-render automatically.
|
|
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.
|
|
1036
987
|
|
|
1037
|
-
For example,
|
|
1038
|
-
|
|
1039
|
-
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.
|
|
1040
989
|
|
|
1041
990
|
### Client Setup
|
|
1042
991
|
|
|
@@ -1064,15 +1013,11 @@ export function App() {
|
|
|
1064
1013
|
}
|
|
1065
1014
|
```
|
|
1066
1015
|
|
|
1067
|
-
|
|
1068
|
-
>
|
|
1069
|
-
> 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).
|
|
1070
1017
|
|
|
1071
1018
|
### Server Setup
|
|
1072
1019
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
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:
|
|
1076
1021
|
|
|
1077
1022
|
```tsx
|
|
1078
1023
|
import { createFateServer, createHonoFateHandler, createLiveEventBus } from '@nkzw/fate/server';
|
|
@@ -1091,7 +1036,7 @@ export const fate = createFateServer<AppContext>({
|
|
|
1091
1036
|
app.all('/fate/*', createHonoFateHandler(fate));
|
|
1092
1037
|
```
|
|
1093
1038
|
|
|
1094
|
-
|
|
1039
|
+
If a client falls behind, fate closes its connection after 1,000 queued events. You can change the limit with `maxQueueSize`:
|
|
1095
1040
|
|
|
1096
1041
|
```tsx
|
|
1097
1042
|
export const fate = createFateServer<AppContext>({
|
|
@@ -1130,7 +1075,7 @@ export function PostCard({ post: postRef }: { post: ViewRef<'Post'> }) {
|
|
|
1130
1075
|
|
|
1131
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.
|
|
1132
1077
|
|
|
1133
|
-
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:
|
|
1134
1079
|
|
|
1135
1080
|
```tsx
|
|
1136
1081
|
const MessageConnectionView = {
|
|
@@ -1163,7 +1108,7 @@ If the changed list cannot be described precisely, invalidate the active connect
|
|
|
1163
1108
|
live.connection('posts', { categoryId }).invalidate();
|
|
1164
1109
|
```
|
|
1165
1110
|
|
|
1166
|
-
|
|
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.
|
|
1167
1112
|
|
|
1168
1113
|
### Emitting Events
|
|
1169
1114
|
|
|
@@ -1241,13 +1186,11 @@ live.update('Post', input.id, {
|
|
|
1241
1186
|
});
|
|
1242
1187
|
```
|
|
1243
1188
|
|
|
1244
|
-
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`.
|
|
1245
1190
|
|
|
1246
1191
|
### Error Handling
|
|
1247
1192
|
|
|
1248
|
-
Live
|
|
1249
|
-
|
|
1250
|
-
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:
|
|
1251
1194
|
|
|
1252
1195
|
```tsx
|
|
1253
1196
|
const fate = createFateClient({
|
|
@@ -1263,7 +1206,7 @@ const fate = createFateClient({
|
|
|
1263
1206
|
});
|
|
1264
1207
|
```
|
|
1265
1208
|
|
|
1266
|
-
|
|
1209
|
+
These errors don't go to the component's error boundary.
|
|
1267
1210
|
|
|
1268
1211
|
## Actions
|
|
1269
1212
|
|
|
@@ -1272,11 +1215,7 @@ fate does not provide hooks for mutations like traditional data fetching librari
|
|
|
1272
1215
|
- `fate.actions` for use with [`useActionState`](https://react.dev/reference/react/useActionState) and React Actions.
|
|
1273
1216
|
- `fate.mutations` for traditional imperative mutation calls.
|
|
1274
1217
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
- With the [native HTTP transport](/docs/integrations/server.md#native-fate-protocol), mutations come from the `mutations` object passed to `createFateServer`.
|
|
1278
|
-
- With the [tRPC adapter](/docs/integrations/server.md#trpc-fate-setup), mutations come from tRPC mutation procedures exposed through your fate-enabled router.
|
|
1279
|
-
- 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.
|
|
1280
1219
|
|
|
1281
1220
|
If you have a mutation named `post.like`, a `LikeButton` component using fate Actions and an async component library could look like this:
|
|
1282
1221
|
|
|
@@ -1321,11 +1260,9 @@ const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
|
|
|
1321
1260
|
};
|
|
1322
1261
|
```
|
|
1323
1262
|
|
|
1324
|
-
By using `useActionState`, fate Actions integrate with Suspense and concurrent rendering.
|
|
1325
|
-
|
|
1326
1263
|
### Optimistic Updates
|
|
1327
1264
|
|
|
1328
|
-
|
|
1265
|
+
To update the like count before the server responds, pass an `optimistic` object:
|
|
1329
1266
|
|
|
1330
1267
|
```tsx
|
|
1331
1268
|
like({
|
|
@@ -1334,18 +1271,17 @@ like({
|
|
|
1334
1271
|
});
|
|
1335
1272
|
```
|
|
1336
1273
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
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.
|
|
1340
1275
|
|
|
1341
1276
|
### Inserting New Objects
|
|
1342
1277
|
|
|
1343
|
-
|
|
1278
|
+
Let's add a comment to a post. Pass the comments list as `insert.into` and give the optimistic comment a temporary ID:
|
|
1344
1279
|
|
|
1345
1280
|
```tsx
|
|
1346
1281
|
const content = 'New Comment text';
|
|
1347
1282
|
addComment({
|
|
1348
1283
|
input: { content, postId: post.id },
|
|
1284
|
+
insert: { into: post.comments, position: 'after' },
|
|
1349
1285
|
optimistic: {
|
|
1350
1286
|
author: { id: user.id, name: user.name },
|
|
1351
1287
|
content,
|
|
@@ -1355,23 +1291,9 @@ addComment({
|
|
|
1355
1291
|
});
|
|
1356
1292
|
```
|
|
1357
1293
|
|
|
1358
|
-
|
|
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.
|
|
1359
1295
|
|
|
1360
|
-
|
|
1361
|
-
addComment({
|
|
1362
|
-
input: { content, postId: post.id },
|
|
1363
|
-
insert: 'before',
|
|
1364
|
-
optimistic: {
|
|
1365
|
-
content,
|
|
1366
|
-
id: `optimistic:${Date.now().toString(36)}`,
|
|
1367
|
-
post: { id: post.id },
|
|
1368
|
-
},
|
|
1369
|
-
});
|
|
1370
|
-
```
|
|
1371
|
-
|
|
1372
|
-
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.
|
|
1373
|
-
|
|
1374
|
-
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.
|
|
1375
1297
|
|
|
1376
1298
|
### Selecting a View with Actions
|
|
1377
1299
|
|
|
@@ -1387,21 +1309,11 @@ addComment({
|
|
|
1387
1309
|
});
|
|
1388
1310
|
```
|
|
1389
1311
|
|
|
1390
|
-
The server
|
|
1391
|
-
|
|
1392
|
-
```tsx
|
|
1393
|
-
const [result, addComment] = useActionState(fate.actions.comment.add, null);
|
|
1394
|
-
|
|
1395
|
-
const newComment = result?.result;
|
|
1396
|
-
if (newComment) {
|
|
1397
|
-
// All the fields selected in the view are available on `newComment`:
|
|
1398
|
-
console.log(newComment.post.commentCount);
|
|
1399
|
-
}
|
|
1400
|
-
```
|
|
1312
|
+
The server returns the selected fields, and views that display the post's comment count update automatically.
|
|
1401
1313
|
|
|
1402
1314
|
### Mutations
|
|
1403
1315
|
|
|
1404
|
-
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:
|
|
1405
1317
|
|
|
1406
1318
|
```tsx
|
|
1407
1319
|
const result = await fate.mutations.comment.add({
|
|
@@ -1409,108 +1321,25 @@ const result = await fate.mutations.comment.add({
|
|
|
1409
1321
|
});
|
|
1410
1322
|
```
|
|
1411
1323
|
|
|
1412
|
-
|
|
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.
|
|
1413
1325
|
|
|
1414
1326
|
### Mutation Server Implementation
|
|
1415
1327
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
- [Native HTTP custom mutations](/docs/integrations/server.md#custom-mutations) use `createFateServer({ mutations })`.
|
|
1419
|
-
- [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.
|
|
1420
|
-
- [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`.
|
|
1421
|
-
|
|
1422
|
-
Here is a native HTTP mutation for `post.like`:
|
|
1423
|
-
|
|
1424
|
-
```tsx
|
|
1425
|
-
export const fate = createFateServer({
|
|
1426
|
-
mutations: {
|
|
1427
|
-
'post.like': {
|
|
1428
|
-
input: likeInput,
|
|
1429
|
-
resolve: async ({ ctx, input, select }) => {
|
|
1430
|
-
await ctx.prisma.post.update({
|
|
1431
|
-
data: {
|
|
1432
|
-
likes: {
|
|
1433
|
-
increment: 1,
|
|
1434
|
-
},
|
|
1435
|
-
},
|
|
1436
|
-
where: { id: input.id },
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
return sources.resolveById({
|
|
1440
|
-
ctx,
|
|
1441
|
-
id: input.id,
|
|
1442
|
-
input: { select },
|
|
1443
|
-
view: postDataView,
|
|
1444
|
-
});
|
|
1445
|
-
},
|
|
1446
|
-
type: 'Post',
|
|
1447
|
-
},
|
|
1448
|
-
},
|
|
1449
|
-
roots: Root,
|
|
1450
|
-
sources,
|
|
1451
|
-
});
|
|
1452
|
-
```
|
|
1453
|
-
|
|
1454
|
-
The equivalent tRPC mutation lives in your router and returns the selected shape that the client asked for:
|
|
1455
|
-
|
|
1456
|
-
```tsx
|
|
1457
|
-
import { connectionArgs, createResolver } from '@nkzw/fate/server';
|
|
1458
|
-
import { z } from 'zod';
|
|
1459
|
-
import { procedure, router } from '../init.ts';
|
|
1460
|
-
import { postDataView, PostItem } from '../views.ts';
|
|
1461
|
-
|
|
1462
|
-
export const postRouter = router({
|
|
1463
|
-
like: procedure
|
|
1464
|
-
.input(
|
|
1465
|
-
z.object({
|
|
1466
|
-
args: connectionArgs,
|
|
1467
|
-
id: z.string().min(1, 'Post id is required.'),
|
|
1468
|
-
select: z.array(z.string()),
|
|
1469
|
-
}),
|
|
1470
|
-
)
|
|
1471
|
-
.mutation(async ({ ctx, input }) => {
|
|
1472
|
-
const { resolve, select } = createResolver({
|
|
1473
|
-
...input,
|
|
1474
|
-
ctx,
|
|
1475
|
-
view: postDataView,
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
|
-
return resolve(
|
|
1479
|
-
await ctx.prisma.post.update({
|
|
1480
|
-
data: {
|
|
1481
|
-
likes: {
|
|
1482
|
-
increment: 1,
|
|
1483
|
-
},
|
|
1484
|
-
},
|
|
1485
|
-
select,
|
|
1486
|
-
where: { id: input.id },
|
|
1487
|
-
} as PostUpdateArgs),
|
|
1488
|
-
);
|
|
1489
|
-
}),
|
|
1490
|
-
});
|
|
1491
|
-
```
|
|
1492
|
-
|
|
1493
|
-
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.
|
|
1494
1329
|
|
|
1495
1330
|
### Action & Mutation Error Handling
|
|
1496
1331
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
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:
|
|
1500
1333
|
|
|
1501
1334
|
```tsx
|
|
1502
|
-
const [result] = useActionState(fate.actions.post.delete, null);
|
|
1335
|
+
const [result, deletePost] = useActionState(fate.actions.post.delete, null);
|
|
1503
1336
|
|
|
1504
1337
|
if (result?.error) {
|
|
1505
|
-
|
|
1506
|
-
// Handle not found error at call site.
|
|
1507
|
-
} else {
|
|
1508
|
-
// Handle other *expected* errors.
|
|
1509
|
-
}
|
|
1338
|
+
return <p>Could not delete this post. Please try again.</p>;
|
|
1510
1339
|
}
|
|
1511
1340
|
```
|
|
1512
1341
|
|
|
1513
|
-
|
|
1342
|
+
Network failures, server errors, and authentication or authorization errors are thrown. React Actions send them to the nearest error boundary:
|
|
1514
1343
|
|
|
1515
1344
|
```tsx
|
|
1516
1345
|
<ErrorBoundary FallbackComponent={ErrorComponent}>
|
|
@@ -1520,11 +1349,11 @@ However, if an `INTERNAL_SERVER_ERROR` error with code `500` occurs, it will be
|
|
|
1520
1349
|
</ErrorBoundary>
|
|
1521
1350
|
```
|
|
1522
1351
|
|
|
1523
|
-
|
|
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).
|
|
1524
1353
|
|
|
1525
1354
|
### Deleting Records
|
|
1526
1355
|
|
|
1527
|
-
|
|
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:
|
|
1528
1357
|
|
|
1529
1358
|
```tsx
|
|
1530
1359
|
const [result, deleteAction] = useActionState(fate.actions.post.delete, null);
|
|
@@ -1537,49 +1366,74 @@ deleteAction({
|
|
|
1537
1366
|
|
|
1538
1367
|
### Resetting Action State
|
|
1539
1368
|
|
|
1540
|
-
|
|
1369
|
+
To clear an action's result without sending another mutation, dispatch `'reset'` inside a transition:
|
|
1541
1370
|
|
|
1542
1371
|
```tsx
|
|
1372
|
+
import { startTransition, useActionState } from 'react';
|
|
1373
|
+
|
|
1543
1374
|
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
1544
1375
|
|
|
1545
|
-
|
|
1546
|
-
if (result?.error) {
|
|
1547
|
-
// Reset the action state after 3 seconds.
|
|
1548
|
-
const timeout = setTimeout(() => startTransition(() => like('reset')), 3000);
|
|
1549
|
-
return () => clearTimeout(timeout);
|
|
1550
|
-
}
|
|
1551
|
-
}, [like, result]);
|
|
1376
|
+
const dismiss = () => startTransition(() => like('reset'));
|
|
1552
1377
|
```
|
|
1553
1378
|
|
|
1554
1379
|
### Controlling List Insertion Behavior
|
|
1555
1380
|
|
|
1556
|
-
|
|
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:
|
|
1557
1382
|
|
|
1558
1383
|
```tsx
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1384
|
+
const PostCommentsView = view<Post>()({
|
|
1385
|
+
comments: defer({
|
|
1386
|
+
args: { first: 20 },
|
|
1387
|
+
items: { node: CommentView },
|
|
1388
|
+
}),
|
|
1389
|
+
id: true,
|
|
1562
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
|
+
});
|
|
1563
1405
|
```
|
|
1564
1406
|
|
|
1565
|
-
|
|
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:
|
|
1566
1410
|
|
|
1567
1411
|
```tsx
|
|
1568
|
-
|
|
1569
|
-
input: { content
|
|
1570
|
-
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,
|
|
1571
1419
|
});
|
|
1572
1420
|
```
|
|
1573
1421
|
|
|
1574
|
-
|
|
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.
|
|
1575
1423
|
|
|
1576
|
-
|
|
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.
|
|
1577
1425
|
|
|
1578
|
-
|
|
1426
|
+
#### Migrating from automatic insertion
|
|
1427
|
+
|
|
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.
|
|
1429
|
+
|
|
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.
|
|
1579
1433
|
|
|
1580
1434
|
### Client Setup
|
|
1581
1435
|
|
|
1582
|
-
|
|
1436
|
+
For a browser app, install the IndexedDB adapter:
|
|
1583
1437
|
|
|
1584
1438
|
```sh
|
|
1585
1439
|
vp add @nkzw/fate-indexeddb
|
|
@@ -1601,26 +1455,26 @@ const fate = createFateClient({
|
|
|
1601
1455
|
});
|
|
1602
1456
|
```
|
|
1603
1457
|
|
|
1604
|
-
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:
|
|
1605
1459
|
|
|
1606
1460
|
```tsx
|
|
1607
1461
|
persistence: createPersistence({
|
|
1608
1462
|
key: `workspace:${workspaceId}:user:${userId}`,
|
|
1609
|
-
maxAge: 24 * 60 * 60 * 1000,
|
|
1610
|
-
maxBytes:
|
|
1463
|
+
maxAge: 3 * 24 * 60 * 60 * 1000,
|
|
1464
|
+
maxBytes: 50 * 1024 * 1024,
|
|
1611
1465
|
storage: createIndexedDBStorage(),
|
|
1612
1466
|
}),
|
|
1613
1467
|
```
|
|
1614
1468
|
|
|
1615
|
-
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.
|
|
1616
1470
|
|
|
1617
1471
|
> [!NOTE]
|
|
1618
1472
|
>
|
|
1619
|
-
> 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.
|
|
1620
1474
|
|
|
1621
1475
|
### Cache Lifetime
|
|
1622
1476
|
|
|
1623
|
-
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:
|
|
1624
1478
|
|
|
1625
1479
|
```tsx
|
|
1626
1480
|
const { posts } = useRequest(
|
|
@@ -1633,29 +1487,15 @@ const { posts } = useRequest(
|
|
|
1633
1487
|
);
|
|
1634
1488
|
```
|
|
1635
1489
|
|
|
1636
|
-
The same option works with `fate.request(...)` and Vue's `useRequest`.
|
|
1637
|
-
|
|
1638
|
-
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.
|
|
1639
|
-
|
|
1640
|
-
For example, your feed might keep posts for one day, while the detail screen keeps them for three days. After one day, the fields needed by the detail screen remain available. Fields selected only by the feed can be removed. You don't need to coordinate separate copies of the same post or manually patch each request's cache.
|
|
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.
|
|
1641
1491
|
|
|
1642
|
-
`maxAge`
|
|
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.
|
|
1643
1493
|
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
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.
|
|
1647
|
-
|
|
1648
|
-
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.
|
|
1649
1495
|
|
|
1650
1496
|
### Refreshing Data
|
|
1651
1497
|
|
|
1652
|
-
Keeping data for three days doesn't mean waiting three days for updates.
|
|
1653
|
-
|
|
1654
|
-
- `cache-first` (_default_): Uses available saved data and fetches missing data from the network.
|
|
1655
|
-
- `stale-while-revalidate`: Shows saved data and refreshes it in the background. If the refresh fails, the saved data stays visible.
|
|
1656
|
-
- `network-only`: Requires a network response, even if saved data is available.
|
|
1657
|
-
|
|
1658
|
-
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:
|
|
1659
1499
|
|
|
1660
1500
|
```tsx
|
|
1661
1501
|
const { posts } = useRequest(
|
|
@@ -1669,25 +1509,17 @@ const { posts } = useRequest(
|
|
|
1669
1509
|
);
|
|
1670
1510
|
```
|
|
1671
1511
|
|
|
1672
|
-
|
|
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).
|
|
1673
1513
|
|
|
1674
1514
|
### Cache Size
|
|
1675
1515
|
|
|
1676
|
-
When
|
|
1677
|
-
|
|
1678
|
-
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.
|
|
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.
|
|
1679
1517
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
Pending mutations are never removed to make room for cached data. If a new mutation cannot fit, it fails before fate applies its optimistic update or sends it to the server.
|
|
1683
|
-
|
|
1684
|
-
> [!NOTE]
|
|
1685
|
-
>
|
|
1686
|
-
> 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.
|
|
1687
1519
|
|
|
1688
1520
|
### Actions & Mutations
|
|
1689
1521
|
|
|
1690
|
-
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):
|
|
1691
1523
|
|
|
1692
1524
|
```tsx
|
|
1693
1525
|
const [result, like] = useActionState(fate.actions.post.like, null);
|
|
@@ -1698,15 +1530,15 @@ like({
|
|
|
1698
1530
|
});
|
|
1699
1531
|
```
|
|
1700
1532
|
|
|
1701
|
-
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.
|
|
1702
1534
|
|
|
1703
|
-
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.
|
|
1704
1536
|
|
|
1705
|
-
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.
|
|
1706
1538
|
|
|
1707
1539
|
#### Skipping Persistence
|
|
1708
1540
|
|
|
1709
|
-
For a call that should
|
|
1541
|
+
For a call that should not be saved or retried, pass `persist: false`:
|
|
1710
1542
|
|
|
1711
1543
|
```tsx
|
|
1712
1544
|
await fate.mutations.analytics.record({
|
|
@@ -1715,23 +1547,23 @@ await fate.mutations.analytics.record({
|
|
|
1715
1547
|
});
|
|
1716
1548
|
```
|
|
1717
1549
|
|
|
1718
|
-
The same option works with `fate.actions`.
|
|
1550
|
+
The same option works with `fate.actions`.
|
|
1719
1551
|
|
|
1720
1552
|
#### Creating Objects Offline
|
|
1721
1553
|
|
|
1722
|
-
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.
|
|
1723
1555
|
|
|
1724
|
-
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.
|
|
1725
1557
|
|
|
1726
|
-
|
|
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`.
|
|
1727
1559
|
|
|
1728
1560
|
### Server Deduplication
|
|
1729
1561
|
|
|
1730
|
-
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.
|
|
1731
1563
|
|
|
1732
|
-
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.
|
|
1733
1565
|
|
|
1734
|
-
For the native HTTP transport,
|
|
1566
|
+
For the native HTTP transport, use `createMutationIdempotency`:
|
|
1735
1567
|
|
|
1736
1568
|
```ts
|
|
1737
1569
|
import { createMutationIdempotency } from '@nkzw/fate/persistence/server';
|
|
@@ -1756,26 +1588,17 @@ const server = createFateServer({
|
|
|
1756
1588
|
});
|
|
1757
1589
|
```
|
|
1758
1590
|
|
|
1759
|
-
Your app supplies
|
|
1760
|
-
|
|
1761
|
-
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.
|
|
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).
|
|
1762
1592
|
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
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.
|
|
1766
|
-
|
|
1767
|
-
Native HTTP sends durable mutations and receipt-only recovery using protocol version 2, so older servers reject unsupported requests before executing them. Ordinary requests and live subscriptions continue to use version 1. A current server without the idempotency integration also rejects durable mutations before running their resolvers.
|
|
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.
|
|
1768
1594
|
|
|
1769
1595
|
### tRPC, GraphQL, and Custom Transports
|
|
1770
1596
|
|
|
1771
|
-
For
|
|
1772
|
-
|
|
1773
|
-
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:
|
|
1774
1598
|
|
|
1775
1599
|
```tsx
|
|
1776
1600
|
const durableHTTP = createHTTPTransport<MyAPI>({
|
|
1777
1601
|
url: '/api/fate',
|
|
1778
|
-
// Use the same authenticated headers as your other transport.
|
|
1779
1602
|
});
|
|
1780
1603
|
|
|
1781
1604
|
const fate = createFateClient({
|
|
@@ -1785,9 +1608,9 @@ const fate = createFateClient({
|
|
|
1785
1608
|
});
|
|
1786
1609
|
```
|
|
1787
1610
|
|
|
1788
|
-
|
|
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.
|
|
1789
1612
|
|
|
1790
|
-
You can
|
|
1613
|
+
You can use the idempotency helper inside an existing resolver too:
|
|
1791
1614
|
|
|
1792
1615
|
```ts
|
|
1793
1616
|
return idempotency.execute({
|
|
@@ -1800,9 +1623,11 @@ return idempotency.execute({
|
|
|
1800
1623
|
});
|
|
1801
1624
|
```
|
|
1802
1625
|
|
|
1803
|
-
|
|
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.
|
|
1804
1629
|
|
|
1805
|
-
|
|
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.
|
|
1806
1631
|
|
|
1807
1632
|
### Persistence State
|
|
1808
1633
|
|
|
@@ -1818,27 +1643,25 @@ const unsubscribe = session.subscribe(() => {
|
|
|
1818
1643
|
});
|
|
1819
1644
|
```
|
|
1820
1645
|
|
|
1821
|
-
|
|
1646
|
+
Each mutation exposes its ID, name, input, status (`queued`, `sending`, or `failed`), and last error. `getSnapshot()` also works with React's `useSyncExternalStore`.
|
|
1822
1647
|
|
|
1823
|
-
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.
|
|
1824
1649
|
|
|
1825
|
-
|
|
1650
|
+
Read-cache writes are batched. Before a reload you control, call `flush()` to save the latest data:
|
|
1826
1651
|
|
|
1827
1652
|
```tsx
|
|
1828
|
-
await
|
|
1653
|
+
await session.flush();
|
|
1829
1654
|
window.location.reload();
|
|
1830
1655
|
```
|
|
1831
1656
|
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
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:
|
|
1835
1658
|
|
|
1836
1659
|
```tsx
|
|
1837
1660
|
session.retry();
|
|
1838
1661
|
await session.discard(mutationId);
|
|
1839
1662
|
```
|
|
1840
1663
|
|
|
1841
|
-
`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.
|
|
1842
1665
|
|
|
1843
1666
|
When leaving an account, dispose its session and unsubscribe:
|
|
1844
1667
|
|
|
@@ -1847,7 +1670,7 @@ session.dispose();
|
|
|
1847
1670
|
unsubscribe();
|
|
1848
1671
|
```
|
|
1849
1672
|
|
|
1850
|
-
|
|
1673
|
+
Disposal stops delivery and removes optimistic updates from memory. Saved mutations remain available to the next client using the same key.
|
|
1851
1674
|
|
|
1852
1675
|
### Clearing Saved Data
|
|
1853
1676
|
|
|
@@ -1857,15 +1680,13 @@ To remove the saved read cache, call `clearCache()`:
|
|
|
1857
1680
|
await fate.persistence!.clearCache();
|
|
1858
1681
|
```
|
|
1859
1682
|
|
|
1860
|
-
This leaves
|
|
1861
|
-
|
|
1862
|
-
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.
|
|
1863
1684
|
|
|
1864
|
-
|
|
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.
|
|
1865
1686
|
|
|
1866
1687
|
### Other Storage Backends
|
|
1867
1688
|
|
|
1868
|
-
|
|
1689
|
+
To use storage other than IndexedDB, implement `PersistenceStorage`:
|
|
1869
1690
|
|
|
1870
1691
|
```ts
|
|
1871
1692
|
interface PersistenceStorage {
|
|
@@ -1881,23 +1702,17 @@ interface PersistenceStorage {
|
|
|
1881
1702
|
}
|
|
1882
1703
|
```
|
|
1883
1704
|
|
|
1884
|
-
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.
|
|
1885
1706
|
|
|
1886
|
-
|
|
1887
|
-
- `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.
|
|
1888
|
-
- `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.
|
|
1889
|
-
- `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.
|
|
1890
|
-
- `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.
|
|
1891
1708
|
|
|
1892
|
-
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.
|
|
1893
1710
|
|
|
1894
|
-
Persistence coordinates
|
|
1711
|
+
Persistence coordinates delivery across tabs. Use refetching or [Live Views](/docs/guide/live-views.md) to receive fresh server data.
|
|
1895
1712
|
|
|
1896
1713
|
## Vue
|
|
1897
1714
|
|
|
1898
|
-
_fate_
|
|
1899
|
-
|
|
1900
|
-
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.
|
|
1901
1716
|
|
|
1902
1717
|
### Installation
|
|
1903
1718
|
|
|
@@ -2011,8 +1826,6 @@ export const PostView = view<Post>()({
|
|
|
2011
1826
|
});
|
|
2012
1827
|
```
|
|
2013
1828
|
|
|
2014
|
-
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.
|
|
2015
|
-
|
|
2016
1829
|
### Requests
|
|
2017
1830
|
|
|
2018
1831
|
`useRequest` declares the data a route, page, or component tree needs. It returns a resource with `data`, `pending`, `error`, `ready`, `refresh`, and `dispose`:
|
|
@@ -2030,8 +1843,8 @@ const request = useRequest({
|
|
|
2030
1843
|
},
|
|
2031
1844
|
});
|
|
2032
1845
|
|
|
2033
|
-
|
|
2034
|
-
const [postItems, loadNext] = useListView(PostCardView, posts);
|
|
1846
|
+
await request.ready();
|
|
1847
|
+
const [postItems, loadNext] = useListView(PostCardView, () => request.data.value?.posts);
|
|
2035
1848
|
</script>
|
|
2036
1849
|
|
|
2037
1850
|
<template>
|
|
@@ -2040,7 +1853,54 @@ const [postItems, loadNext] = useListView(PostCardView, posts);
|
|
|
2040
1853
|
</template>
|
|
2041
1854
|
```
|
|
2042
1855
|
|
|
2043
|
-
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).
|
|
2044
1904
|
|
|
2045
1905
|
### Views in Components
|
|
2046
1906
|
|
|
@@ -2071,6 +1931,32 @@ const author = useView(UserView, () => post.value?.author ?? null);
|
|
|
2071
1931
|
|
|
2072
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.
|
|
2073
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
|
+
|
|
2074
1960
|
### Lists and Live Views
|
|
2075
1961
|
|
|
2076
1962
|
`useListView` subscribes to a connection returned from `useRequest` or from a nested view field:
|
|
@@ -2079,6 +1965,8 @@ Pass reactive props through a getter so fate tracks prop changes. In script, res
|
|
|
2079
1965
|
const [comments, loadNextCommentPage] = useListView(CommentView, () => post.value?.comments);
|
|
2080
1966
|
```
|
|
2081
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
|
+
|
|
2082
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:
|
|
2083
1971
|
|
|
2084
1972
|
```ts
|
|
@@ -2086,17 +1974,7 @@ const post = useLiveView(PostCardView, () => props.post);
|
|
|
2086
1974
|
const [comments] = useLiveListView(CommentView, () => post.value?.comments);
|
|
2087
1975
|
```
|
|
2088
1976
|
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
```ts
|
|
2092
|
-
const post = useLiveView(PostCardView, () => props.post);
|
|
2093
|
-
|
|
2094
|
-
onBeforeUnmount(() => {
|
|
2095
|
-
post.dispose();
|
|
2096
|
-
});
|
|
2097
|
-
```
|
|
2098
|
-
|
|
2099
|
-
Vue scope disposal also cleans up resources automatically.
|
|
1977
|
+
Vue cleans up resources when the component unmounts. Call `dispose()` to stop a resource earlier.
|
|
2100
1978
|
|
|
2101
1979
|
### Mutations
|
|
2102
1980
|
|
|
@@ -2120,10 +1998,11 @@ const like = async () => {
|
|
|
2120
1998
|
error.value = null;
|
|
2121
1999
|
|
|
2122
2000
|
try {
|
|
2123
|
-
await fate.mutations.post.like({
|
|
2001
|
+
const result = await fate.mutations.post.like({
|
|
2124
2002
|
input: { id: props.post.id },
|
|
2125
2003
|
optimistic: { likes: props.post.likes + 1 },
|
|
2126
2004
|
});
|
|
2005
|
+
error.value = result.error ?? null;
|
|
2127
2006
|
} catch (caughtError) {
|
|
2128
2007
|
error.value = caughtError;
|
|
2129
2008
|
} finally {
|
|
@@ -2134,27 +2013,21 @@ const like = async () => {
|
|
|
2134
2013
|
|
|
2135
2014
|
<template>
|
|
2136
2015
|
<button :disabled="pending" @click="like">Like</button>
|
|
2016
|
+
<p v-if="error">Could not like this post.</p>
|
|
2137
2017
|
</template>
|
|
2138
2018
|
```
|
|
2139
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
|
+
|
|
2140
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.
|
|
2141
2023
|
|
|
2142
2024
|
### API Differences from React
|
|
2143
2025
|
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
- `useRequest`, `useView`, and list hooks return Vue resources instead of throwing promises from render.
|
|
2147
|
-
- Async setup and `<Suspense>` replace React's async component model.
|
|
2148
|
-
- Mutations use `fate.mutations` directly; React-only `fate.actions` and `useActionState` patterns do not apply.
|
|
2149
|
-
- Shared views are best kept in `.ts` modules instead of exporting named views from component files.
|
|
2150
|
-
|
|
2151
|
-
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.
|
|
2152
2027
|
|
|
2153
2028
|
## GraphQL Integration
|
|
2154
2029
|
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
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.
|
|
2158
2031
|
|
|
2159
2032
|
### Template
|
|
2160
2033
|
|
|
@@ -2170,7 +2043,7 @@ Create a full GraphQL + Prisma example app with:
|
|
|
2170
2043
|
vp create fate -- --template graphql
|
|
2171
2044
|
```
|
|
2172
2045
|
|
|
2173
|
-
|
|
2046
|
+
Both templates include `src/fate/graphql.ts`, where you map your schema's queries and mutations to fate.
|
|
2174
2047
|
|
|
2175
2048
|
### GraphQL Schema Shape
|
|
2176
2049
|
|
|
@@ -2200,59 +2073,25 @@ If your schema uses different root field names, keep the fate names you want on
|
|
|
2200
2073
|
|
|
2201
2074
|
### Mapping Your Schema
|
|
2202
2075
|
|
|
2203
|
-
Create
|
|
2076
|
+
Create `src/fate/graphql.ts` with your server's schema and the queries and mutations you want to use:
|
|
2204
2077
|
|
|
2205
2078
|
```tsx
|
|
2206
|
-
import {
|
|
2207
|
-
import { dataView, list, type Entity } from '@nkzw/fate/server';
|
|
2208
|
-
|
|
2209
|
-
type GraphQLUser = {
|
|
2210
|
-
id: string;
|
|
2211
|
-
name?: string | null;
|
|
2212
|
-
username?: string | null;
|
|
2213
|
-
};
|
|
2214
|
-
|
|
2215
|
-
type GraphQLPost = {
|
|
2216
|
-
author?: GraphQLUser | null;
|
|
2217
|
-
id: string;
|
|
2218
|
-
title: string;
|
|
2219
|
-
};
|
|
2220
|
-
|
|
2221
|
-
export const userDataView = dataView<GraphQLUser>('User')({
|
|
2222
|
-
id: true,
|
|
2223
|
-
name: true,
|
|
2224
|
-
username: true,
|
|
2225
|
-
});
|
|
2226
|
-
|
|
2227
|
-
export const postDataView = dataView<GraphQLPost>('Post')({
|
|
2228
|
-
author: userDataView,
|
|
2229
|
-
id: true,
|
|
2230
|
-
title: true,
|
|
2231
|
-
});
|
|
2232
|
-
|
|
2233
|
-
export type User = Entity<typeof userDataView, 'User'>;
|
|
2234
|
-
export type Post = Entity<
|
|
2235
|
-
typeof postDataView,
|
|
2236
|
-
'Post',
|
|
2237
|
-
{
|
|
2238
|
-
author: User | null;
|
|
2239
|
-
}
|
|
2240
|
-
>;
|
|
2241
|
-
|
|
2242
|
-
export const Root = {
|
|
2243
|
-
posts: list(postDataView),
|
|
2244
|
-
viewer: userDataView,
|
|
2245
|
-
};
|
|
2079
|
+
import { readFileSync } from 'node:fs';
|
|
2246
2080
|
|
|
2247
2081
|
export const fateGraphQL = {
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2082
|
+
schema: readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'),
|
|
2083
|
+
roots: { posts: {}, viewer: {} },
|
|
2084
|
+
mutations: {
|
|
2085
|
+
'post.like': { entity: 'Post', field: 'postLike' },
|
|
2251
2086
|
},
|
|
2252
2087
|
} as const;
|
|
2253
2088
|
```
|
|
2254
2089
|
|
|
2255
|
-
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.
|
|
2256
2095
|
|
|
2257
2096
|
### Vite Plugin
|
|
2258
2097
|
|
|
@@ -2292,7 +2131,29 @@ export default defineConfig({
|
|
|
2292
2131
|
|
|
2293
2132
|
:::
|
|
2294
2133
|
|
|
2295
|
-
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.
|
|
2296
2157
|
|
|
2297
2158
|
### Creating a Client
|
|
2298
2159
|
|
|
@@ -2352,9 +2213,9 @@ const fate = createFateClient({
|
|
|
2352
2213
|
});
|
|
2353
2214
|
```
|
|
2354
2215
|
|
|
2355
|
-
|
|
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).
|
|
2356
2217
|
|
|
2357
|
-
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.
|
|
2358
2219
|
|
|
2359
2220
|
### Refetch Fields
|
|
2360
2221
|
|
|
@@ -2371,27 +2232,20 @@ export const fateGraphQL = {
|
|
|
2371
2232
|
} as const;
|
|
2372
2233
|
```
|
|
2373
2234
|
|
|
2374
|
-
`idArg` defaults to `id`. Each
|
|
2375
|
-
|
|
2376
|
-
Mappings take precedence over the default `nodes(ids:)` fallback. Set `nodes: false` to disable that fallback; fetching an unmapped type then reports a configuration error without issuing a request. Existing clients need no configuration changes. Direct users of `createGraphQLTransport` can pass the same `byId` and `nodes` options.
|
|
2377
|
-
|
|
2378
|
-
When `schema` is supplied, codegen and transport construction validate the mapping: the field must exist, its ID argument must accept a single `ID`, `String`, or `Int`, its result must match the concrete entity type, and any other arguments must be optional or have defaults. Schema metadata also supplies the wire variable types. Without a schema, identifiers are validated and arguments use GraphQL literals.
|
|
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.
|
|
2379
2236
|
|
|
2380
|
-
|
|
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.
|
|
2381
2238
|
|
|
2382
|
-
|
|
2239
|
+
#### Refetching Through Existing Queries
|
|
2383
2240
|
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
An entity's cache identity does not need to be its lookup argument. With a schema, configure ordinary entity query roots directly and assign a refetch recipe:
|
|
2241
|
+
If your API fetches users by username instead of ID, configure fate to use that query when refreshing a user:
|
|
2387
2242
|
|
|
2388
2243
|
```tsx
|
|
2389
2244
|
export const fateGraphQL = {
|
|
2390
2245
|
schema,
|
|
2391
2246
|
nodes: false,
|
|
2392
2247
|
roots: {
|
|
2393
|
-
user: {
|
|
2394
|
-
viewer: { type: 'User' },
|
|
2248
|
+
user: {},
|
|
2395
2249
|
},
|
|
2396
2250
|
refetch: {
|
|
2397
2251
|
User: {
|
|
@@ -2399,16 +2253,14 @@ export const fateGraphQL = {
|
|
|
2399
2253
|
args: { username: { field: 'username' } },
|
|
2400
2254
|
},
|
|
2401
2255
|
},
|
|
2402
|
-
};
|
|
2256
|
+
} as const;
|
|
2403
2257
|
```
|
|
2404
2258
|
|
|
2405
|
-
Here `user` maps to `Query.user(username:)`.
|
|
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.
|
|
2406
2260
|
|
|
2407
|
-
|
|
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.
|
|
2408
2262
|
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
Arguments use `{ field: 'username' }` for scalar field projections, `{ value: ... }` for JSON constants, and `{ object: { ... } }` for nested input objects. The explicit object wrapper also supports input fields literally named `field`, `value`, or `object`. For example:
|
|
2263
|
+
Use `{ field: 'username' }` to read a scalar field, `{ value: 'public' }` for a constant, or `{ object: { ... } }` for a nested input object:
|
|
2412
2264
|
|
|
2413
2265
|
```tsx
|
|
2414
2266
|
args: {
|
|
@@ -2421,11 +2273,7 @@ args: {
|
|
|
2421
2273
|
}
|
|
2422
2274
|
```
|
|
2423
2275
|
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
Implicit locators are retained and persisted with their entities. Adding or changing a recipe does not change mutation-journal compatibility; restored cache records missing required locator coverage are refreshed through their requested root. Existing server refetch endpoints can remain available to older deployed clients.
|
|
2427
|
-
|
|
2428
|
-
For other transports, put the same recipe in `TypeConfig.refetch`. Implement `fetchQuery`; Fate adds implicit scalar selections to entity reads and entity mutation calls. Query transports receive an optional fourth `{ refetch: typeName }` context on automatic entity lookups. GraphQL also adds locator selections inside embedded results and concrete interface results.
|
|
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.
|
|
2429
2277
|
|
|
2430
2278
|
### Object IDs
|
|
2431
2279
|
|
|
@@ -2479,14 +2327,13 @@ This produces a root `posts(first: 10)` field and a nested `comments(first: 3)`
|
|
|
2479
2327
|
|
|
2480
2328
|
### Mutations
|
|
2481
2329
|
|
|
2482
|
-
Map fate mutation names to GraphQL mutation fields
|
|
2330
|
+
Map fate mutation names to GraphQL mutation fields:
|
|
2483
2331
|
|
|
2484
2332
|
```tsx
|
|
2485
2333
|
export const fateGraphQL = {
|
|
2334
|
+
schema,
|
|
2486
2335
|
mutations: {
|
|
2487
|
-
'post.like':
|
|
2488
|
-
field: 'postLike',
|
|
2489
|
-
}),
|
|
2336
|
+
'post.like': { entity: 'Post', field: 'postLike' },
|
|
2490
2337
|
},
|
|
2491
2338
|
roots: {
|
|
2492
2339
|
posts: { field: 'posts' },
|
|
@@ -2509,20 +2356,30 @@ Use `inputArg` when your schema uses a different argument name, or `inputArg: fa
|
|
|
2509
2356
|
|
|
2510
2357
|
```tsx
|
|
2511
2358
|
export const fateGraphQL = {
|
|
2359
|
+
schema,
|
|
2512
2360
|
mutations: {
|
|
2513
|
-
'post.like':
|
|
2514
|
-
|
|
2515
|
-
inputArg: 'payload',
|
|
2516
|
-
}),
|
|
2517
|
-
'user.follow': graphqlMutation<User, { id: string }, User>('User', {
|
|
2518
|
-
field: 'followUser',
|
|
2519
|
-
inputArg: false,
|
|
2520
|
-
}),
|
|
2361
|
+
'post.like': { entity: 'Post', field: 'likePost', inputArg: 'payload' },
|
|
2362
|
+
'user.follow': { entity: 'User', field: 'followUser', inputArg: false },
|
|
2521
2363
|
},
|
|
2522
2364
|
} as const;
|
|
2523
2365
|
```
|
|
2524
2366
|
|
|
2525
|
-
|
|
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.
|
|
2526
2383
|
|
|
2527
2384
|
### Live Views
|
|
2528
2385
|
|
|
@@ -2550,100 +2407,19 @@ const fate = createFateClient({
|
|
|
2550
2407
|
});
|
|
2551
2408
|
```
|
|
2552
2409
|
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
If you do not need live views, disable them explicitly:
|
|
2556
|
-
|
|
2557
|
-
```tsx
|
|
2558
|
-
const fate = createFateClient({
|
|
2559
|
-
live: false,
|
|
2560
|
-
url: '/graphql',
|
|
2561
|
-
});
|
|
2562
|
-
```
|
|
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`.
|
|
2563
2411
|
|
|
2564
2412
|
### Existing Servers
|
|
2565
2413
|
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
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.
|
|
2569
2415
|
|
|
2570
2416
|
### Schema-Aware Arguments
|
|
2571
2417
|
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
```tsx
|
|
2575
|
-
import { readFileSync } from 'node:fs';
|
|
2576
|
-
|
|
2577
|
-
export const fateGraphQL = {
|
|
2578
|
-
schema: readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'),
|
|
2579
|
-
roots: {
|
|
2580
|
-
maps: { field: 'maps' },
|
|
2581
|
-
},
|
|
2582
|
-
mutations: {
|
|
2583
|
-
'map.update': graphqlMutation<Map, UpdateMapInput, Map>('Map', {
|
|
2584
|
-
field: 'updateMap',
|
|
2585
|
-
inputArg: false,
|
|
2586
|
-
}),
|
|
2587
|
-
},
|
|
2588
|
-
};
|
|
2589
|
-
```
|
|
2590
|
-
|
|
2591
|
-
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.
|
|
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`.
|
|
2592
2419
|
|
|
2593
|
-
|
|
2420
|
+
Use valid SDL, including definitions for any custom directives. You can also pass a `GraphQLSchema` object.
|
|
2594
2421
|
|
|
2595
|
-
|
|
2596
|
-
import { readFileSync } from 'node:fs';
|
|
2597
|
-
import { buildSchema } from 'graphql';
|
|
2598
|
-
|
|
2599
|
-
const sdl = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
|
2600
|
-
export const fateGraphQL = {
|
|
2601
|
-
schema: buildSchema(sdl, { assumeValidSDL: true }),
|
|
2602
|
-
// ...roots and mutations...
|
|
2603
|
-
};
|
|
2604
|
-
```
|
|
2605
|
-
|
|
2606
|
-
`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.
|
|
2607
|
-
|
|
2608
|
-
For example, given `maps(biome: Biome)`, an argument `{ biome: 'Grassland' }` is sent as a variable declared with type `Biome`, rather than an invalid quoted enum literal. Variables also work for nested field arguments, input objects, lists, node IDs, and mutations. Batched operations use distinct variable names and separate query and mutation payloads. Mutation selection arguments are kept out of mutation inputs. A GraphQL mutation argument named `args` belongs in `input` like any other wire argument; use the mutation's `view` or its separate `args` option for selected field arguments. When calling `transport.mutate` directly, pass selected field arguments as its optional fourth parameter.
|
|
2609
|
-
|
|
2610
|
-
The generated client enforces root arguments in `client.request` and the React/Vue `useRequest` adapters. Mutation inputs are derived from the schema using the configured `inputArg`, replacing the manually declared input type for generated clients. Non-null arguments are required unless the schema supplies a default. Omitting an optional/defaulted value preserves its server default; explicit `null` remains distinct.
|
|
2611
|
-
|
|
2612
|
-
Schema-aware generation also writes `.fate/schema.generated.ts`. Import its types directly in the mapping module or application code; these imports are erased, so generation works before that file exists on a clean checkout:
|
|
2613
|
-
|
|
2614
|
-
```tsx
|
|
2615
|
-
import type { GraphQLEntities } from '../.fate/schema.generated.ts';
|
|
2616
|
-
|
|
2617
|
-
type User = GraphQLEntities['User'];
|
|
2618
|
-
|
|
2619
|
-
const UserCardView = view<User>()({
|
|
2620
|
-
displayName: true,
|
|
2621
|
-
username: true,
|
|
2622
|
-
});
|
|
2623
|
-
```
|
|
2624
|
-
|
|
2625
|
-
`GraphQLTypes` describes the GraphQL schema's output shapes, including discriminated interfaces and unions. `GraphQLEntities` describes selectable Fate shapes: connection fields expose their node arrays for view composition. `GraphQLResponses` describes transport normalization, where connections become `{ items, pagination }`. These are available schema fields, not promises that every response contains every field. Generating a union type does not add new runtime selection capabilities to the transport.
|
|
2626
|
-
|
|
2627
|
-
Schema-backed mappings can expose entity queries and Relay-style connection roots
|
|
2628
|
-
without client `dataView` declarations. Types and mutation arguments come from the schema:
|
|
2629
|
-
|
|
2630
|
-
```tsx
|
|
2631
|
-
export const fateGraphQL = {
|
|
2632
|
-
schema,
|
|
2633
|
-
roots: { posts: {}, viewer: {} },
|
|
2634
|
-
mutations: { 'post.like': { entity: 'Post', field: 'postLike' } },
|
|
2635
|
-
} as const;
|
|
2636
|
-
```
|
|
2637
|
-
|
|
2638
|
-
Use `field` when an application root name differs from the schema query field.
|
|
2639
|
-
Existing server data views may still implement resolvers and live updates; they
|
|
2640
|
-
are independent of these generated GraphQL contracts.
|
|
2641
|
-
|
|
2642
|
-
With a schema, generated clients derive root and mutation output contracts from that schema rather than handwritten mapping generics. Direct mutation calls infer their unmasked results from the supplied `view` or `select`, including nested selections and nullable results. Without a selection, entity results promise only automatic identity fields. Mutation responses are not masked `ViewData` and do not carry view tags. React action state has one result contract across dispatches with different selections, so entity action results promise identity rather than dispatch-specific fields.
|
|
2643
|
-
|
|
2644
|
-
Custom scalars default to `unknown`. Configure their TypeScript wire representations with `fateGraphQL.scalars`, for example `DateTime: 'string'`, or `{ input: 'string', output: 'string' }` when specifying both sides. A mapping changes types only; it does not decode values into JavaScript classes. For independent build tooling, `createGraphQLSchemaSource(schema, scalars)` from `@nkzw/fate/vite` generates the same contracts without loading a client mapping.
|
|
2645
|
-
|
|
2646
|
-
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:
|
|
2647
2423
|
|
|
2648
2424
|
```tsx
|
|
2649
2425
|
import type { GraphQLFieldArguments } from '../.fate/client.generated.ts';
|
|
@@ -2658,53 +2434,35 @@ const MapView = view<Map>()({
|
|
|
2658
2434
|
});
|
|
2659
2435
|
```
|
|
2660
2436
|
|
|
2661
|
-
|
|
2437
|
+
### Errors and Partial Results
|
|
2662
2438
|
|
|
2663
|
-
|
|
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.
|
|
2664
2440
|
|
|
2665
|
-
|
|
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:
|
|
2442
|
+
|
|
2443
|
+
```tsx
|
|
2444
|
+
import { GraphQLRequestError } from 'react-fate';
|
|
2445
|
+
|
|
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.
|
|
2666
2454
|
|
|
2667
|
-
|
|
2668
|
-
GraphQL `errors`, the first error's `path` and `extensions`, and the HTTP `status`
|
|
2669
|
-
when available. HTTP errors use the same error type; network failures retain the
|
|
2670
|
-
original fetch error.
|
|
2671
|
-
|
|
2672
|
-
Partial response data is available as `error.data` for explicit application
|
|
2673
|
-
handling. It is unnormalized diagnostic data, not a successful typed result, and
|
|
2674
|
-
fate does not write it to the cache. Successful sibling operations in the same
|
|
2675
|
-
batch still resolve normally, including legitimate `null` results. Mutation
|
|
2676
|
-
functions retain their `{ result, error }` result contract.
|
|
2677
|
-
|
|
2678
|
-
For actions and mutations without durable delivery, GraphQL execution errors are
|
|
2679
|
-
returned as `{ error, result: undefined }` so the caller can handle domain errors
|
|
2680
|
-
using `error.errors` and `error.extensions`. Optimistic changes roll back, and
|
|
2681
|
-
partial data never enters the cache. Query errors still reject normally.
|
|
2682
|
-
|
|
2683
|
-
Fate recognizes standard `extensions.code` values such as `BAD_USER_INPUT`,
|
|
2684
|
-
`CONFLICT`, and `INTERNAL_SERVER_ERROR`, as well as numeric
|
|
2685
|
-
`extensions.http.status` values. HTTP failures and these extension statuses follow
|
|
2686
|
-
the existing action error policy: validation and conflict errors are handled at
|
|
2687
|
-
the call site; authentication, authorization, and server failures throw. If any
|
|
2688
|
-
error in a response requires a boundary, the entire mutation throws its original
|
|
2689
|
-
`GraphQLRequestError`. The error's `status` remains the original HTTP status,
|
|
2690
|
-
including `200` for execution errors.
|
|
2691
|
-
|
|
2692
|
-
Malformed successful responses have `error.kind === 'protocol'` and throw, as do network
|
|
2693
|
-
failures. For durable delivery, an execution error without a recognized status
|
|
2694
|
-
remains retryable; fate does not infer terminal rejection from an error message or
|
|
2695
|
-
a custom code. Return a standard code or `extensions.http.status` to identify a
|
|
2696
|
-
terminal client rejection. This preserves pending writes when a server failure's
|
|
2697
|
-
outcome is unknown.
|
|
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).
|
|
2698
2456
|
|
|
2699
2457
|
## Server Integration
|
|
2700
2458
|
|
|
2701
|
-
|
|
2459
|
+
Now that we have defined our client views, let's connect them to a backend. fate supports three approaches:
|
|
2702
2460
|
|
|
2703
|
-
-
|
|
2704
|
-
-
|
|
2705
|
-
-
|
|
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.
|
|
2706
2464
|
|
|
2707
|
-
|
|
2465
|
+
The Prisma and Drizzle adapters turn your server data views into database queries. Both work with native HTTP and tRPC.
|
|
2708
2466
|
|
|
2709
2467
|
### Conventions & Object Identity
|
|
2710
2468
|
|
|
@@ -2713,9 +2471,7 @@ fate expects that data is served by a backend that follows these conventions:
|
|
|
2713
2471
|
- A `byId` query for each data type to fetch individual objects by their unique identifier (`id`).
|
|
2714
2472
|
- A `list` query for fetching lists of objects with support for pagination.
|
|
2715
2473
|
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
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.
|
|
2719
2475
|
|
|
2720
2476
|
> [!NOTE]
|
|
2721
2477
|
> You can adopt _fate_ incrementally in an existing tRPC codebase without changing your existing schema by adding these queries alongside your existing procedures.
|
|
@@ -2813,7 +2569,7 @@ export const Root = {
|
|
|
2813
2569
|
|
|
2814
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.
|
|
2815
2571
|
|
|
2816
|
-
|
|
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.
|
|
2817
2573
|
|
|
2818
2574
|
For the above `Root` definitions, you can make the following requests using `useRequest`:
|
|
2819
2575
|
|
|
@@ -2836,7 +2592,7 @@ const { posts, categories, viewer } = useRequest({
|
|
|
2836
2592
|
|
|
2837
2593
|
### Native fate protocol
|
|
2838
2594
|
|
|
2839
|
-
|
|
2595
|
+
Create a source adapter for your ORM, pass it to `createFateServer`, and add a route for the HTTP handler:
|
|
2840
2596
|
|
|
2841
2597
|
```tsx
|
|
2842
2598
|
import { createFateServer, createHonoFateHandler } from '@nkzw/fate/server';
|
|
@@ -2878,7 +2634,7 @@ const app = new Hono();
|
|
|
2878
2634
|
const handler = createHonoFateHandler(fate);
|
|
2879
2635
|
|
|
2880
2636
|
app.post('/fate', handler);
|
|
2881
|
-
app.
|
|
2637
|
+
app.all('/fate/live', handler);
|
|
2882
2638
|
```
|
|
2883
2639
|
|
|
2884
2640
|
Configure the Vite plugin with the native transport:
|
|
@@ -2939,30 +2695,11 @@ const client = createFateClient({
|
|
|
2939
2695
|
|
|
2940
2696
|
:::
|
|
2941
2697
|
|
|
2942
|
-
|
|
2698
|
+
Calls made together are batched into one HTTP request. The `/fate/live` route is used for [Live Views](/docs/guide/live-views.md).
|
|
2943
2699
|
|
|
2944
2700
|
#### Custom Queries
|
|
2945
2701
|
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
```tsx
|
|
2949
|
-
export const fate = createFateServer({
|
|
2950
|
-
context,
|
|
2951
|
-
queries: {
|
|
2952
|
-
viewer: {
|
|
2953
|
-
resolve: ({ ctx, select }) =>
|
|
2954
|
-
sources.resolveById({
|
|
2955
|
-
ctx,
|
|
2956
|
-
id: ctx.sessionUser.id,
|
|
2957
|
-
input: { select },
|
|
2958
|
-
view: userDataView,
|
|
2959
|
-
}),
|
|
2960
|
-
},
|
|
2961
|
-
},
|
|
2962
|
-
roots: Root,
|
|
2963
|
-
sources,
|
|
2964
|
-
});
|
|
2965
|
-
```
|
|
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.
|
|
2966
2703
|
|
|
2967
2704
|
#### Custom Mutations
|
|
2968
2705
|
|
|
@@ -2996,45 +2733,19 @@ export const fate = createFateServer({
|
|
|
2996
2733
|
|
|
2997
2734
|
#### Live Views
|
|
2998
2735
|
|
|
2999
|
-
|
|
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:
|
|
3000
2737
|
|
|
3001
2738
|
```tsx
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
export const live = createLiveEventBus();
|
|
3005
|
-
|
|
3006
|
-
export const fate = createFateServer({
|
|
3007
|
-
live,
|
|
3008
|
-
queries: {
|
|
3009
|
-
viewer: {
|
|
3010
|
-
resolve: ({ ctx, select }) =>
|
|
3011
|
-
sources.resolveById({
|
|
3012
|
-
ctx,
|
|
3013
|
-
id: ctx.sessionUser.id,
|
|
3014
|
-
input: { select },
|
|
3015
|
-
view: userDataView,
|
|
3016
|
-
}),
|
|
3017
|
-
},
|
|
3018
|
-
},
|
|
3019
|
-
roots: Root,
|
|
3020
|
-
sources,
|
|
3021
|
-
});
|
|
3022
|
-
|
|
3023
|
-
live.update('Post', post.id, {
|
|
3024
|
-
changed: ['likes'],
|
|
3025
|
-
eventId: `post:${post.id}:${Date.now()}`,
|
|
3026
|
-
});
|
|
2739
|
+
live.update('Post', post.id, { changed: ['likes'] });
|
|
3027
2740
|
```
|
|
3028
2741
|
|
|
3029
|
-
`changed`
|
|
3030
|
-
|
|
3031
|
-
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.
|
|
3032
2743
|
|
|
3033
2744
|
### tRPC fate setup
|
|
3034
2745
|
|
|
3035
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.
|
|
3036
2747
|
|
|
3037
|
-
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:
|
|
3038
2749
|
|
|
3039
2750
|
#### Prisma
|
|
3040
2751
|
|
|
@@ -3059,8 +2770,6 @@ export const fate = createPrismaFate<AppContext, typeof procedure>({
|
|
|
3059
2770
|
|
|
3060
2771
|
If your Prisma client is not stored at `ctx.prisma`, pass `prisma: (ctx) => ctx.db`.
|
|
3061
2772
|
|
|
3062
|
-
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.
|
|
3063
|
-
|
|
3064
2773
|
For custom Prisma queries and mutations, use `fate.createPlan` with `toPrismaSelect`:
|
|
3065
2774
|
|
|
3066
2775
|
```tsx
|
|
@@ -3124,34 +2833,13 @@ export const fate = createDrizzleFate<AppContext, typeof procedure>({
|
|
|
3124
2833
|
});
|
|
3125
2834
|
```
|
|
3126
2835
|
|
|
3127
|
-
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.
|
|
3128
2837
|
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
```tsx
|
|
3132
|
-
export const fate = createDrizzleFate<AppContext, typeof procedure>({
|
|
3133
|
-
db,
|
|
3134
|
-
nestedPaginationConcurrency: 5,
|
|
3135
|
-
procedure,
|
|
3136
|
-
schema,
|
|
3137
|
-
views: Root,
|
|
3138
|
-
});
|
|
3139
|
-
```
|
|
2838
|
+
Count filters can use equality objects or Drizzle predicates such as `(columns) => eq(columns.status, 'GOING')`.
|
|
3140
2839
|
|
|
3141
2840
|
For request-specific sorting, prefer a custom root query that validates and translates explicit sort args.
|
|
3142
2841
|
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
```tsx
|
|
3146
|
-
export const fate = createDrizzleFate<AppContext, typeof procedure>({
|
|
3147
|
-
db,
|
|
3148
|
-
procedure,
|
|
3149
|
-
schema,
|
|
3150
|
-
views: Root,
|
|
3151
|
-
});
|
|
3152
|
-
```
|
|
3153
|
-
|
|
3154
|
-
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:
|
|
3155
2843
|
|
|
3156
2844
|
```tsx
|
|
3157
2845
|
{
|
|
@@ -3349,9 +3037,7 @@ export default defineConfig({
|
|
|
3349
3037
|
|
|
3350
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._
|
|
3351
3039
|
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
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.
|
|
3355
3041
|
|
|
3356
3042
|
The plugin writes project-local types under `.fate/`. If your TypeScript config does not already include dot-directories, extend the generated config:
|
|
3357
3043
|
|
|
@@ -3432,9 +3118,6 @@ _And you are all set. Happy building!_
|
|
|
3432
3118
|
|
|
3433
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.
|
|
3434
3120
|
|
|
3435
|
-
Use this integration when your app runs on Void and you want the example app's
|
|
3436
|
-
setup without copying its adapter glue.
|
|
3437
|
-
|
|
3438
3121
|
### New Project
|
|
3439
3122
|
|
|
3440
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.
|
|
@@ -3509,9 +3192,7 @@ export default defineConfig({
|
|
|
3509
3192
|
|
|
3510
3193
|
:::
|
|
3511
3194
|
|
|
3512
|
-
The
|
|
3513
|
-
updates by default. In SSR, it calls the exported fate server directly. In the
|
|
3514
|
-
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.
|
|
3515
3196
|
|
|
3516
3197
|
### Server Setup
|
|
3517
3198
|
|
|
@@ -3553,7 +3234,7 @@ live.connection('Post.comments', { id: postId }).appendNode('Comment', commentId
|
|
|
3553
3234
|
});
|
|
3554
3235
|
```
|
|
3555
3236
|
|
|
3556
|
-
`changed`
|
|
3237
|
+
Pass `changed` to refresh only those fields, or omit it to refresh the whole selected view.
|
|
3557
3238
|
|
|
3558
3239
|
### Routes
|
|
3559
3240
|
|
|
@@ -3592,9 +3273,6 @@ import { fateStream } from '../src/fate/live.ts';
|
|
|
3592
3273
|
export const { GET, POST } = defineVoidFateLiveRoute(fateStream);
|
|
3593
3274
|
```
|
|
3594
3275
|
|
|
3595
|
-
The live route handles `GET /fate-live` SSE connections and `POST /fate-live`
|
|
3596
|
-
control messages. `void-fate` does not use WebSockets.
|
|
3597
|
-
|
|
3598
3276
|
### Layout
|
|
3599
3277
|
|
|
3600
3278
|
Wrap your app with the Void fate client for your framework. It creates and
|
|
@@ -3686,17 +3364,11 @@ const fate = computed(() =>
|
|
|
3686
3364
|
|
|
3687
3365
|
:::
|
|
3688
3366
|
|
|
3689
|
-
|
|
3690
|
-
or router configuration matches the paths you pass to the client.
|
|
3367
|
+
Make sure your route filenames match the paths passed to the client.
|
|
3691
3368
|
|
|
3692
3369
|
### Live Transport
|
|
3693
3370
|
|
|
3694
|
-
|
|
3695
|
-
stream associated with the current request. Void manages the Durable Object
|
|
3696
|
-
transport that distributes events to connected clients across requests.
|
|
3697
|
-
|
|
3698
|
-
The live transport is best-effort and does not replay missed events after a
|
|
3699
|
-
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.
|
|
3700
3372
|
|
|
3701
3373
|
### Cloudflare Deployment
|
|
3702
3374
|
|