@nkzw/fate-indexeddb 1.8.0 → 1.10.0

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