@nkzw/fate-indexeddb 1.9.0 → 1.10.1

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