@nkzw/fate-indexeddb 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,3466 @@
1
+ <!-- auto-generated from docs/guide/*.md and docs/integrations/*.md. Do not edit directly. -->
2
+
3
+ <p align="center">
4
+ <picture>
5
+ <source media="(prefers-color-scheme: dark)" srcset="/public/fate-logo-dark.svg">
6
+ <source media="(prefers-color-scheme: light)" srcset="/public/fate-logo.svg">
7
+ <img alt="Logo" src="/public/fate-logo.svg" width="50%">
8
+ </picture>
9
+ </p>
10
+
11
+ **_fate_** is a modern data client for React inspired by [Relay](https://relay.dev/) and [GraphQL](https://graphql.org/). It combines view composition, normalized caching, data masking, Async React features, and type-safe data fetching.
12
+
13
+ ### Features
14
+
15
+ - **View Composition:** Components declare their data requirements using co-located "views". Views are composed into a single request per screen, minimizing network requests and eliminating waterfalls.
16
+ - **Normalized Cache:** fate maintains a normalized cache for all fetched data. This enables efficient data updates through actions and mutations and avoids stale or duplicated data.
17
+ - **Data Masking & Strict Selection:** fate enforces strict data selection for each view, and masks (hides) data that components did not request. This prevents accidental coupling between components and reduces overfetching.
18
+ - **Async React:** fate uses modern Async React features like Actions, Suspense, and `use` to support concurrent rendering and enable a seamless user experience.
19
+ - **Lists & Pagination:** fate provides built-in support for connection-style lists with cursor-based pagination, making it easy to implement infinite scrolling and "load-more" functionality.
20
+ - **Optimistic Updates:** fate supports declarative optimistic updates for mutations, allowing the UI to update immediately while the server request is in-flight. If the request fails, the cache and its associated views are rolled back to their previous state.
21
+ - **Live Views:** fate can keep individual view refs up to date through a single native Server-Sent Events stream, merging updates into the normalized cache.
22
+ - **AI-Ready:** fate's minimal, predictable API and explicit data selection enable local reasoning, enabling humans and AI tools to generate stable, type-safe data-fetching code.
23
+
24
+ ### A modern data client for React
25
+
26
+ **_fate_** is designed to make data fetching and state management in React applications more composable, declarative, and predictable. The framework has a minimal API, no DSL, and no magic—_it's just JavaScript_.
27
+
28
+ GraphQL and Relay introduced several novel ideas: fragments co‑located with components, [a normalized cache](https://relay.dev/docs/principles-and-architecture/thinking-in-graphql/#caching-a-graph) keyed by global identifiers, and a compiler that hoists fragments into a single network request. These innovations made it possible to build large applications where data requirements are modular and self‑contained.
29
+
30
+ [Nakazawa Tech](https://nakazawa.tech) builds apps and [games](https://athenacrisis.com) primarily with GraphQL and Relay. We advocate for these technologies in [talks](https://www.youtube.com/watch?v=rxPTEko8J7c&t=36s) and provide templates ([server](https://github.com/nkzw-tech/server-template), [client](https://github.com/nkzw-tech/web-app-template/tree/with-relay)) to help developers get started quickly.
31
+
32
+ However, GraphQL comes with its own type system and query language. If you are already using tRPC or another type‑safe RPC framework, it's a significant investment to adopt and implement GraphQL on the backend. This investment often prevents teams from adopting Relay on the frontend.
33
+
34
+ Many React data frameworks lack Relay's ergonomics, especially fragment composition, co-located data requirements, predictable caching, and deep integration with modern React features. Optimistic updates usually require manually managing keys and imperative data updates, which is error-prone and tedious.
35
+
36
+ _fate_ takes the great ideas from Relay and applies them to plain TypeScript data fetching. You get type safety between the client and server, a native protocol with optional adapters such as tRPC, and GraphQL-like ergonomics for data fetching. Using _fate_ usually looks like this:
37
+
38
+ ```tsx
39
+ export const PostView = view<Post>()({
40
+ author: UserView,
41
+ content: true,
42
+ id: true,
43
+ title: true,
44
+ });
45
+
46
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
47
+ const post = useView(PostView, postRef);
48
+
49
+ return (
50
+ <Card>
51
+ <h2>{post.title}</h2>
52
+ <p>{post.content}</p>
53
+ <UserCard user={post.author} />
54
+ </Card>
55
+ );
56
+ };
57
+ ```
58
+
59
+ _[Learn more](/docs/guide/getting-started.md) about fate's core concepts or create an app from one of the templates._
60
+
61
+ ## Getting Started
62
+
63
+ ### Template
64
+
65
+ Create a new fate app with Vite+:
66
+
67
+ ```bash
68
+ vp create fate my-app
69
+ ```
70
+
71
+ Explore the [fate stack](https://stack.fate.technology) to see the tools included in your new project.
72
+
73
+ 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.
74
+
75
+ ### Manual Installation
76
+
77
+ For a React client, install `react-fate`. It requires React 19.2+:
78
+
79
+ ::: code-group
80
+
81
+ ```bash [npm]
82
+ npm add react-fate
83
+ ```
84
+
85
+ ```bash [pnpm]
86
+ pnpm add react-fate
87
+ ```
88
+
89
+ ```bash [yarn]
90
+ yarn add react-fate
91
+ ```
92
+
93
+ :::
94
+
95
+ For a Vue client, install `vue-fate`:
96
+
97
+ ::: code-group
98
+
99
+ ```bash [npm]
100
+ npm add vue-fate
101
+ ```
102
+
103
+ ```bash [pnpm]
104
+ pnpm add vue-fate
105
+ ```
106
+
107
+ ```bash [yarn]
108
+ yarn add vue-fate
109
+ ```
110
+
111
+ :::
112
+
113
+ If your server is a separate package, install `@nkzw/fate` there as a runtime dependency too. Install `@nkzw/fate` on the client only for a barebones integration without a framework adapter:
114
+
115
+ ::: code-group
116
+
117
+ ```bash [npm]
118
+ npm add @nkzw/fate
119
+ ```
120
+
121
+ ```bash [pnpm]
122
+ pnpm add @nkzw/fate
123
+ ```
124
+
125
+ ```bash [yarn]
126
+ yarn add @nkzw/fate
127
+ ```
128
+
129
+ :::
130
+
131
+ > [!WARNING]
132
+ >
133
+ > **_fate_** is currently in alpha and not production ready. If something doesn't work for you, please open a pull request.
134
+
135
+ If you'd like to try the example app in GitHub Codespaces, click the button below:
136
+
137
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?repo=nkzw-tech/fate)
138
+
139
+ ## Core Concepts
140
+
141
+ **_fate_** has a minimal API surface and is aimed at reducing data fetching complexity.
142
+
143
+ ### Thinking in Views
144
+
145
+ In fate, each component declares the data it needs using views. Views are composed upward through the component tree until they reach a root, where the actual request is made. fate fetches all required data in a single request. React Suspense manages loading states, and any data-fetching errors naturally bubble up to React error boundaries. This eliminates the need for imperative loading logic or manual error handling.
146
+
147
+ Traditionally, React apps are built with components and hooks. fate introduces a third primitive: views – a declarative way for components to express their data requirements. An app built with fate looks more like this:
148
+
149
+ <p align="center">
150
+ <picture class="fate-tree">
151
+ <source media="(prefers-color-scheme: dark)" srcset="/public/fate-tree-dark.svg">
152
+ <source media="(prefers-color-scheme: light)" srcset="/public/fate-tree.svg">
153
+ <img alt="Tree" src="/public/fate-tree.svg" width="90%">
154
+ </picture>
155
+ </p>
156
+
157
+ With fate, you no longer worry about _when_ to fetch data, how to coordinate loading states, or how to handle errors imperatively. You avoid overfetching, stop passing unnecessary data down the tree, and eliminate boilerplate types created solely for passing server data to child components.
158
+
159
+ > [!NOTE]
160
+ > Views in _fate_ are what fragments are in GraphQL.
161
+
162
+ ## Views
163
+
164
+ ### Defining Views
165
+
166
+ 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:
167
+
168
+ ```tsx
169
+ import { view } from 'react-fate';
170
+
171
+ type Post = {
172
+ content: string;
173
+ id: string;
174
+ title: string;
175
+ };
176
+
177
+ export const PostView = view<Post>()({
178
+ content: true,
179
+ id: true,
180
+ title: true,
181
+ });
182
+ ```
183
+
184
+ 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.
185
+
186
+ > [!NOTE]
187
+ > The `Post` type above is an example. In a real application, this type is defined on the server and imported into your client code.
188
+
189
+ ### Resolving a View with `useView`
190
+
191
+ 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`:
192
+
193
+ ```tsx
194
+ import { useView, ViewRef } from 'react-fate';
195
+
196
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
197
+ const post = useView(PostView, postRef);
198
+
199
+ return (
200
+ <Card>
201
+ <h2>{post.title}</h2>
202
+ <p>{post.content}</p>
203
+ </Card>
204
+ );
205
+ };
206
+ ```
207
+
208
+ 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.
209
+
210
+ 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.
211
+
212
+ ### Fetching Data with `useRequest`
213
+
214
+ Now that we defined our view and component, we fetch the data from the server using the `useRequest` hook from fate. This hook allows us to declare what data we need for a specific screen or component tree. At the root of our app, we can request a list of posts like this:
215
+
216
+ ```tsx
217
+ import { useRequest } from 'react-fate';
218
+ import { PostCard, PostView } from './PostCard.tsx';
219
+
220
+ export function App() {
221
+ const { posts } = useRequest({ posts: { list: PostView } });
222
+
223
+ return posts.map((post) => <PostCard key={post.id} post={post} />);
224
+ }
225
+ ```
226
+
227
+ _Learn more about `useRequest` in the [Requests Guide](/docs/guide/requests.md)._
228
+
229
+ ### Composing Views
230
+
231
+ In the above example we are defining a single view for a `Post`. One of fate's core strengths is view composition. Let's say we want to show the author's name along with the post. A simple way to do this is by adding an `author` field to the `PostView` with a concrete selection:
232
+
233
+ ```tsx
234
+ import { Suspense } from 'react';
235
+ import { useView, ViewRef } from 'react-fate';
236
+
237
+ export const PostView = view<Post>()({
238
+ author: {
239
+ id: true,
240
+ name: true,
241
+ },
242
+ content: true,
243
+ id: true,
244
+ title: true,
245
+ });
246
+
247
+ const PostCard = ({ postRef }: { postRef: ViewRef<'Post'> }) => {
248
+ const post = useView(PostView, postRef);
249
+ return (
250
+ <Card>
251
+ <h2>{post.title}</h2>
252
+ <p>by {post.author.name}</p>
253
+ <p>{post.content}</p>
254
+ </Card>
255
+ );
256
+ };
257
+ ```
258
+
259
+ This code fetches the author associated with the Post and makes it available to the `PostCard` component. However, this approach has some downsides:
260
+
261
+ 1. The `author` selection is tightly coupled to the `PostView`. If we want to use the author's data in another component, we would need to duplicate the field selection.
262
+ 1. If the `author` has more fields that we want to use in other components, we would need to add them to the `PostView`, leading to overfetching.
263
+ 1. We cannot reuse the `author` field selection in other views or components.
264
+
265
+ In fate, views are composable and reusable. Instead of inlining the selection, we can define a `UserView` and compose it into the `PostView` like this:
266
+
267
+ ```tsx
268
+ import type { Post, User } from '@your-org/server/views';
269
+ import { view } from 'react-fate';
270
+
271
+ export const UserView = view<User>()({
272
+ id: true,
273
+ name: true,
274
+ profilePicture: true,
275
+ });
276
+
277
+ export const PostView = view<Post>()({
278
+ author: UserView,
279
+ content: true,
280
+ id: true,
281
+ title: true,
282
+ });
283
+ ```
284
+
285
+ Now we can create a separate `UserCard` component that uses our `UserView`:
286
+
287
+ ```tsx
288
+ import { useView, ViewRef } from 'react-fate';
289
+
290
+ export const UserCard = ({ user: userRef }: { user: ViewRef<'User'> }) => {
291
+ const user = useView(UserView, userRef);
292
+
293
+ return (
294
+ <div>
295
+ <img src={user.profilePicture} alt={user.name} />
296
+ <p>{user.name}</p>
297
+ </div>
298
+ );
299
+ };
300
+ ```
301
+
302
+ And update `PostCard` to use our `UserCard` component:
303
+
304
+ ```tsx
305
+ import { UserCard } from './UserCard.tsx';
306
+
307
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
308
+ const post = useView(PostView, postRef);
309
+
310
+ return (
311
+ <Card>
312
+ <h2>{post.title}</h2>
313
+ <UserCard user={post.author} />
314
+ <p>{post.content}</p>
315
+ </Card>
316
+ );
317
+ };
318
+ ```
319
+
320
+ ### View Spreads
321
+
322
+ When building complex UIs, you will often build multiple components that share the same data requirements. In fate, you can use view spreads to compose such views together. This is similar to GraphQL fragment spreads, but works with plain JavaScript objects.
323
+
324
+ Let's assume we want to fetch and display additional information about the author in the `PostCard`, such as their bio. Instead of directly assigning our `UserView` to the `author` field, we can instead spread it and add the `bio` field:
325
+
326
+ ```tsx
327
+ export const PostView = view<Post>()({
328
+ author: {
329
+ ...UserView,
330
+ bio: true,
331
+ },
332
+ content: true,
333
+ id: true,
334
+ title: true,
335
+ });
336
+ ```
337
+
338
+ Now the `PostCard` component can access the `bio` field of the author:
339
+
340
+ ```tsx
341
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
342
+ const post = useView(PostView, postRef);
343
+
344
+ return (
345
+ <Card>
346
+ <h2>{post.title}</h2>
347
+ <UserCard author={post.author} />
348
+ {/* Accessing the bio field */}
349
+ <p>{post.author.bio}</p>
350
+ <p>{post.content}</p>
351
+ </Card>
352
+ );
353
+ };
354
+ ```
355
+
356
+ We can also spread multiple views together. For example, if we have another view called `UserStatsView` that selects some statistics about the user, we can include it in the `PostView` like this:
357
+
358
+ ```tsx
359
+ export const UserStatsView = view<User>()({
360
+ followerCount: true,
361
+ postCount: true,
362
+ });
363
+
364
+ export const PostView = view<Post>()({
365
+ author: {
366
+ ...UserView,
367
+ ...UserStatsView,
368
+ bio: true,
369
+ },
370
+ content: true,
371
+ id: true,
372
+ title: true,
373
+ });
374
+ ```
375
+
376
+ 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.
377
+
378
+ ### `useView` and Suspense
379
+
380
+ 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.
381
+
382
+ _Tip: You can test this behavior in development mode with Fast Refresh (HMR) enabled in your bundler. When you edit the selection of a view, components using that view will suspend, fetch the missing data, and then resume rendering._
383
+
384
+ ### Type Safety and Data Masking
385
+
386
+ fate provides guarantees through TypeScript and during runtime that prevent you from accessing data that wasn't selected in a component. This ensures that you declare all the data dependencies at the right level in your component tree, and prevents accidental coupling between components.
387
+
388
+ In the below example, we forgot to select the `content` of a `Post`. As a result, type-checks fail and the `content` field is undefined during runtime:
389
+
390
+ ```tsx
391
+ const PostView = view<Post>()({
392
+ id: true,
393
+ title: true,
394
+ // `content: true` is omitted.
395
+ });
396
+
397
+ const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
398
+ const post = useView(PostView, postRef);
399
+
400
+ return (
401
+ <Card>
402
+ <h2>{post.title}</h2>
403
+ {/* TypeScript errors here, and `post.content` is undefined during runtime */}
404
+ <p>{post.content}</p>
405
+ </Card>
406
+ );
407
+ };
408
+ ```
409
+
410
+ Views can only be resolved against refs that include that view directly or via view spreads. If a component tries to resolve a view against a ref that isn't linked, it will throw an error during runtime:
411
+
412
+ ```tsx
413
+ const PostDetailView = view<Post>()({
414
+ content: true,
415
+ });
416
+
417
+ const AnotherPostView = view<Post>()({
418
+ content: true,
419
+ });
420
+
421
+ const PostView = view<Post>()({
422
+ id: true,
423
+ title: true,
424
+ ...AnotherPostView,
425
+ });
426
+
427
+ const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
428
+ const post = useView(PostView, postRef);
429
+ return <PostDetail post={post} />;
430
+ };
431
+
432
+ const PostDetail = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
433
+ // This throws because the post reference passed into this component
434
+ // is of type `AnotherPostView`, not `PostDetailView`.
435
+ const post = useView(PostDetailView, postRef);
436
+ };
437
+ ```
438
+
439
+ ViewRefs carry a set of view names they can resolve. `useView` throws if a ref does not include the required view.
440
+
441
+ ## Requests
442
+
443
+ ### Requesting Lists
444
+
445
+ The `useRequest` hook can be used to declare our data needs for a specific screen or component tree. At the root of our app, we can request a list of posts like this:
446
+
447
+ ```tsx
448
+ import { useRequest } from 'react-fate';
449
+ import { PostCard, PostView } from './PostCard.tsx';
450
+
451
+ export function App() {
452
+ const { posts } = useRequest({ posts: { list: PostView } });
453
+ return posts.map((post) => <PostCard key={post.id} post={post} />);
454
+ }
455
+ ```
456
+
457
+ This component suspends or throws errors, which bubble up to the nearest error boundary. Wrap your component tree with `ErrorBoundary` and `Suspense` components to show error and loading states:
458
+
459
+ ```tsx
460
+ <ErrorBoundary FallbackComponent={ErrorComponent}>
461
+ <Suspense fallback={<div>Loading…</div>}>
462
+ <App />
463
+ </Suspense>
464
+ </ErrorBoundary>
465
+ ```
466
+
467
+ > [!NOTE]
468
+ >
469
+ > `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).
470
+
471
+ ### Requesting Objects by ID
472
+
473
+ If you want to fetch data for a single object instead of a list, you can specify the `id` and the associated `view` like this:
474
+
475
+ ```tsx
476
+ const { post } = useRequest({
477
+ post: { id: '12', view: PostView },
478
+ });
479
+ ```
480
+
481
+ If you want to fetch multiple objects by their IDs, you can use the `ids` field:
482
+
483
+ ```tsx
484
+ const { posts } = useRequest({
485
+ posts: { ids: ['6', '7'], view: PostView },
486
+ });
487
+ ```
488
+
489
+ ### Other Types of Requests
490
+
491
+ For any other queries, pass only the `type` and `view`:
492
+
493
+ ```tsx
494
+ const { viewer } = useRequest({
495
+ viewer: { view: UserView },
496
+ });
497
+ ```
498
+
499
+ ### Request Arguments
500
+
501
+ You can pass arguments to `useRequest` calls. This is useful for pagination, filtering, or sorting. For example, to fetch the first 10 posts, you can do the following:
502
+
503
+ ```tsx
504
+ const { posts } = useRequest({
505
+ posts: {
506
+ args: { first: 10 },
507
+ list: PostView,
508
+ },
509
+ });
510
+ ```
511
+
512
+ 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.
513
+
514
+ ### Request Modes
515
+
516
+ `useRequest` supports different request modes to control caching and data freshness. The available modes are:
517
+
518
+ - `cache-first` (_default_): Returns data from the cache if available, otherwise fetches from the network.
519
+ - `stale-while-revalidate`: Returns data from the cache and simultaneously fetches fresh data from the network.
520
+ - `network-only`: Always fetches data from the network, bypassing the cache.
521
+
522
+ You can pass the request mode as an option to `useRequest`:
523
+
524
+ ```tsx
525
+ const { posts } = useRequest(
526
+ {
527
+ posts: { list: PostView },
528
+ },
529
+ {
530
+ mode: 'stale-while-revalidate',
531
+ },
532
+ );
533
+ ```
534
+
535
+ ### Cache Lifetime
536
+
537
+ 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.
538
+
539
+ 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.
540
+
541
+ You can tune the buffer when creating the client:
542
+
543
+ ```tsx
544
+ const fate = createClient({
545
+ gcReleaseBufferSize: 20,
546
+ roots,
547
+ transport,
548
+ types,
549
+ });
550
+ ```
551
+
552
+ Set `gcReleaseBufferSize` to `0` in tests or very memory-sensitive environments when released screens should be collected immediately.
553
+
554
+ `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.
555
+
556
+ 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:
557
+
558
+ ```tsx
559
+ const request = { posts: { list: PostView } };
560
+ const retained = fate.retain(request);
561
+
562
+ try {
563
+ const { posts } = await fate.request(request);
564
+ // Use posts while this request is retained.
565
+ } finally {
566
+ retained.dispose();
567
+ }
568
+ ```
569
+
570
+ 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.
571
+
572
+ ### SSR and Hydration
573
+
574
+ Create a request-scoped fate client on the server, preload the route data, and dehydrate its normalized cache:
575
+
576
+ ```tsx
577
+ const fate = createFateClient();
578
+ await fate.request({ post: { id: '12', view: PostView } });
579
+
580
+ return {
581
+ fate: fate.dehydrate(),
582
+ };
583
+ ```
584
+
585
+ 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.
586
+
587
+ On the browser, hydrate the new client before rendering components that call `useRequest`:
588
+
589
+ ```tsx
590
+ const fate = createFateClient();
591
+ fate.hydrate(loaderData.fate);
592
+
593
+ hydrateRoot(
594
+ document,
595
+ <FateClient client={fate}>
596
+ <App />
597
+ </FateClient>,
598
+ );
599
+ ```
600
+
601
+ 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.
602
+
603
+ 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:
604
+
605
+ ```tsx
606
+ const fate = createClient({
607
+ hydrationScope: 'storefront-v2',
608
+ // ...
609
+ });
610
+ ```
611
+
612
+ Use `hydrationLimits` when an application needs stricter bootstrap payload limits. fate applies conservative defaults for total encoded values, collection sizes, and string lengths.
613
+
614
+ 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:
615
+
616
+ ```tsx
617
+ fate.hydrate(loaderData.fate, { merge: 'replace' });
618
+ ```
619
+
620
+ `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.
621
+
622
+ 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.
623
+
624
+ ## Deferred Views
625
+
626
+ 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.
627
+
628
+ ```tsx
629
+ import { Suspense } from 'react';
630
+ import { defer, useListView, useView, view, Deferred, ViewRef } from 'react-fate';
631
+
632
+ const CommentView = view<Comment>()({
633
+ content: true,
634
+ id: true,
635
+ });
636
+
637
+ const CommentConnectionView = {
638
+ args: { first: 3 },
639
+ items: { node: CommentView },
640
+ };
641
+
642
+ const PostView = view<Post>()({
643
+ comments: defer(CommentConnectionView),
644
+ content: true,
645
+ id: true,
646
+ title: true,
647
+ });
648
+
649
+ function PostCard({ post: postRef }: { post: ViewRef<'Post'> }) {
650
+ const post = useView(PostView, postRef);
651
+
652
+ return (
653
+ <article>
654
+ <h2>{post.title}</h2>
655
+ <p>{post.content}</p>
656
+ <Suspense fallback={<CommentsSkeleton />}>
657
+ <PostComments comments={post.comments} />
658
+ </Suspense>
659
+ </article>
660
+ );
661
+ }
662
+
663
+ function PostComments({
664
+ comments,
665
+ }: {
666
+ comments: Deferred<{ items: ReadonlyArray<{ node: ViewRef<'Comment'> }> }>;
667
+ }) {
668
+ const [items, loadNext] = useListView(CommentConnectionView, comments);
669
+
670
+ return (
671
+ <section>
672
+ {items.map(({ node }) => (
673
+ <CommentCard comment={node} key={node.id} />
674
+ ))}
675
+ {loadNext ? <button onClick={loadNext}>Load more</button> : null}
676
+ </section>
677
+ );
678
+ }
679
+ ```
680
+
681
+ 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.
682
+
683
+ 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.
684
+
685
+ 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.
686
+
687
+ ## List Views
688
+
689
+ ### Pagination with `useListView`
690
+
691
+ You can wrap a list of references using `useListView` to enable connection-style lists with pagination support.
692
+
693
+ For example, you can define a `CommentView` and reuse it inside of a `CommentConnectionView`:
694
+
695
+ ```tsx
696
+ import { useListView, ViewRef } from 'react-fate';
697
+
698
+ const CommentView = view<Comment>()({
699
+ content: true,
700
+ id: true,
701
+ });
702
+
703
+ const CommentConnectionView = {
704
+ args: { first: 10 },
705
+ items: {
706
+ node: CommentView,
707
+ },
708
+ };
709
+
710
+ const PostView = view<Post>()({
711
+ comments: CommentConnectionView,
712
+ });
713
+ ```
714
+
715
+ Now you can apply the `useListView` hook inside of your `PostCard` component to read the list of comments and load more comments when needed:
716
+
717
+ ```tsx
718
+ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
719
+ const post = useView(PostView, postRef);
720
+ const [comments, loadNext] = useListView(CommentConnectionView, post.comments);
721
+
722
+ return (
723
+ <div>
724
+ {comments.map(({ node }) => (
725
+ <CommentCard comment={node} key={node.id} post={post} />
726
+ ))}
727
+ {loadNext ? (
728
+ <Button onClick={loadNext} variant="ghost">
729
+ Load more comments
730
+ </Button>
731
+ ) : null}
732
+ </div>
733
+ );
734
+ }
735
+ ```
736
+
737
+ 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.
738
+
739
+ ### Pagination Arguments
740
+
741
+ Connection views can define default arguments, and `useListView` carries those arguments forward when loading more pages:
742
+
743
+ ```tsx
744
+ const CommentConnectionView = {
745
+ args: { first: 10 },
746
+ items: {
747
+ cursor: true,
748
+ node: CommentView,
749
+ },
750
+ pagination: {
751
+ hasNext: true,
752
+ hasPrevious: true,
753
+ nextCursor: true,
754
+ previousCursor: true,
755
+ },
756
+ };
757
+ ```
758
+
759
+ 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.
760
+
761
+ Additional arguments on a root request are scoped to that root list:
762
+
763
+ ```tsx
764
+ const { posts } = useRequest({
765
+ posts: {
766
+ args: { categoryId: category.id, first: 20 },
767
+ list: PostConnectionView,
768
+ },
769
+ });
770
+ ```
771
+
772
+ 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.
773
+
774
+ ## Live Views
775
+
776
+ `useLiveView` resolves a `ViewRef` just like `useView`, but also keeps the selected object up to date through the native live SSE transport.
777
+
778
+ ```tsx
779
+ import { useLiveView, ViewRef } from 'react-fate';
780
+
781
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
782
+ const post = useLiveView(PostView, postRef);
783
+
784
+ return (
785
+ <Card>
786
+ <h2>{post.title}</h2>
787
+ {/* Updates automatically! */}
788
+ <p>{post.likes} likes</p>
789
+ </Card>
790
+ );
791
+ };
792
+ ```
793
+
794
+ 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.
795
+
796
+ ### How Live Updates Work
797
+
798
+ 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.
799
+
800
+ 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.
801
+
802
+ 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.
803
+
804
+ 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.
805
+
806
+ ### Client Setup
807
+
808
+ Configure the native transport and point the client at your fate endpoint:
809
+
810
+ ```tsx
811
+ import { FateClient } from 'react-fate';
812
+ import { createFateClient } from 'react-fate/client';
813
+
814
+ export function App() {
815
+ const fate = useMemo(
816
+ () =>
817
+ createFateClient({
818
+ fetch: (input, init) =>
819
+ fetch(input, {
820
+ ...init,
821
+ credentials: 'include',
822
+ }),
823
+ url: `${env('SERVER_URL')}/fate`,
824
+ }),
825
+ [],
826
+ );
827
+
828
+ return <FateClient client={fate}>{/* Components go here */}</FateClient>;
829
+ }
830
+ ```
831
+
832
+ > [!NOTE]
833
+ >
834
+ > Live views use `GET /fate/live` for the single SSE stream and `POST /fate/live` for subscribe/unsubscribe control messages.
835
+
836
+ ### Server Setup
837
+
838
+ 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.
839
+
840
+ Pass a live event bus to `createFateServer` and expose the native handler:
841
+
842
+ ```tsx
843
+ import { createFateServer, createHonoFateHandler, createLiveEventBus } from '@nkzw/fate/server';
844
+ import type { AppContext } from './context.ts';
845
+ import { sources } from './sources.ts';
846
+ import { Root } from './views.ts';
847
+
848
+ export const live = createLiveEventBus();
849
+
850
+ export const fate = createFateServer<AppContext>({
851
+ live,
852
+ roots: Root,
853
+ sources,
854
+ });
855
+
856
+ app.all('/fate/*', createHonoFateHandler(fate));
857
+ ```
858
+
859
+ 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:
860
+
861
+ ```tsx
862
+ export const fate = createFateServer<AppContext>({
863
+ live: {
864
+ bus: live,
865
+ maxQueueSize: 500,
866
+ },
867
+ roots: Root,
868
+ sources,
869
+ });
870
+ ```
871
+
872
+ Once this is in place, components can switch from `useView` to `useLiveView` without changing their view definitions or return types.
873
+
874
+ ### Live List Views
875
+
876
+ `useLiveListView` mirrors `useListView`, but subscribes to live connection events for the connection it receives:
877
+
878
+ ```tsx
879
+ import { useLiveListView, useLiveView, ViewRef } from 'react-fate';
880
+
881
+ export function PostCard({ post: postRef }: { post: ViewRef<'Post'> }) {
882
+ const post = useLiveView(PostView, postRef);
883
+ const [comments, loadNext] = useLiveListView(CommentConnectionView, post.comments);
884
+
885
+ return (
886
+ <>
887
+ {comments.map(({ node }) => (
888
+ <CommentCard comment={node} key={node.id} />
889
+ ))}
890
+ {loadNext ? <button onClick={loadNext}>Load more</button> : null}
891
+ </>
892
+ );
893
+ }
894
+ ```
895
+
896
+ 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.
897
+
898
+ 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:
899
+
900
+ ```tsx
901
+ const MessageConnectionView = {
902
+ args: { first: 30 },
903
+ items: {
904
+ node: MessageView,
905
+ },
906
+ live: {
907
+ append: 'visible',
908
+ },
909
+ };
910
+ ```
911
+
912
+ Emit connection events on the server when list membership changes:
913
+
914
+ ```tsx
915
+ live.connection('Post.comments', { id: postId }).prependNode('Comment', comment.id);
916
+ live.connection('Post.comments', { id: postId }).deleteEdge('Comment', comment.id);
917
+ ```
918
+
919
+ For root lists, use the generated root procedure name:
920
+
921
+ ```tsx
922
+ live.connection('posts', { categoryId }).prependNode('Post', post.id);
923
+ ```
924
+
925
+ If the changed list cannot be described precisely, invalidate the active connection and fate will refetch it:
926
+
927
+ ```tsx
928
+ live.connection('posts', { categoryId }).invalidate();
929
+ ```
930
+
931
+ 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.
932
+
933
+ ### Emitting Events
934
+
935
+ After a mutation changes an object, emit an update event for that object:
936
+
937
+ ```tsx
938
+ export const postRouter = router({
939
+ ...fate.procedures({
940
+ view: postDataView,
941
+ }),
942
+ like: procedure.input(likeInput).mutation(async ({ ctx, input }) => {
943
+ const post = await ctx.prisma.post.update({
944
+ data: {
945
+ likes: {
946
+ increment: 1,
947
+ },
948
+ },
949
+ where: { id: input.id },
950
+ });
951
+
952
+ live.update('Post', input.id);
953
+
954
+ return post;
955
+ }),
956
+ });
957
+ ```
958
+
959
+ This tells fate that the `Post` changed. Every active live view for that post refreshes using the selection it subscribed with.
960
+
961
+ If you know which fields changed, pass them with `changed` to reduce the amount of data sent to each subscriber:
962
+
963
+ ```tsx
964
+ live.update('Post', input.id, { changed: ['likes'] });
965
+ ```
966
+
967
+ With this version, a live view that selected `likes` refreshes only `likes`, while a live view that only selected unrelated fields is skipped entirely.
968
+
969
+ If a mutation changes a related object, emit for the object whose live view should refresh. For example, adding a comment usually changes the post's `commentCount` and `comments` list, so emit for the `Post`:
970
+
971
+ ```tsx
972
+ export const commentRouter = router({
973
+ add: procedure.input(addCommentInput).mutation(async ({ ctx, input }) => {
974
+ const comment = await ctx.prisma.comment.create({
975
+ data: {
976
+ content: input.content,
977
+ postId: input.postId,
978
+ },
979
+ });
980
+
981
+ live.update('Post', input.postId, { changed: ['commentCount', 'comments'] });
982
+
983
+ return comment;
984
+ }),
985
+ });
986
+ ```
987
+
988
+ For deletions, emit a delete event for the deleted object if clients may be subscribed to it:
989
+
990
+ ```tsx
991
+ live.delete('Comment', input.id);
992
+ ```
993
+
994
+ If deleting the object also changes another object, emit an update for that object too:
995
+
996
+ ```tsx
997
+ live.update('Post', postId, { changed: ['commentCount', 'comments'] });
998
+ ```
999
+
1000
+ You can pass an `eventId` when emitting. fate sends it on the native SSE event and includes the last received event ID when it resubscribes after a reconnect:
1001
+
1002
+ ```tsx
1003
+ live.update('Post', input.id, {
1004
+ changed: ['likes'],
1005
+ eventId: `post:${input.id}:${Date.now()}`,
1006
+ });
1007
+ ```
1008
+
1009
+ 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.
1010
+
1011
+ ### Error Handling
1012
+
1013
+ Live subscription errors are reported out of band. They do not replace the last cached data or throw through the component that called `useLiveView`.
1014
+
1015
+ Pass `onLiveError` when creating the client to send those failures to your logger or monitoring system:
1016
+
1017
+ ```tsx
1018
+ const fate = createFateClient({
1019
+ fetch: (input, init) =>
1020
+ fetch(input, {
1021
+ ...init,
1022
+ credentials: 'include',
1023
+ }),
1024
+ onLiveError(error) {
1025
+ captureException(error);
1026
+ },
1027
+ url: `${env('SERVER_URL')}/fate`,
1028
+ });
1029
+ ```
1030
+
1031
+ 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.
1032
+
1033
+ ## Actions
1034
+
1035
+ fate does not provide hooks for mutations like traditional data fetching libraries do. Instead, mutations are exposed in two ways:
1036
+
1037
+ - `fate.actions` for use with [`useActionState`](https://react.dev/reference/react/useActionState) and React Actions.
1038
+ - `fate.mutations` for traditional imperative mutation calls.
1039
+
1040
+ Server mutations are exposed automatically as actions and mutations by fate's Vite plugin. The transport determines where those mutations are declared:
1041
+
1042
+ - With the [native HTTP transport](/docs/integrations/server.md#native-fate-protocol), mutations come from the `mutations` object passed to `createFateServer`.
1043
+ - With the [tRPC adapter](/docs/integrations/server.md#trpc-fate-setup), mutations come from tRPC mutation procedures exposed through your fate-enabled router.
1044
+ - With [Void](/docs/integrations/void.md), mutations use the same native fate server shape and are exposed through the Void route helpers.
1045
+
1046
+ If you have a mutation named `post.like`, a `LikeButton` component using fate Actions and an async component library could look like this:
1047
+
1048
+ ```tsx
1049
+ import { useActionState } from 'react';
1050
+ import { useFateClient } from 'react-fate';
1051
+
1052
+ const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
1053
+ const fate = useFateClient();
1054
+ const [result, like] = useActionState(fate.actions.post.like, null);
1055
+
1056
+ return (
1057
+ <Button action={() => like({ input: { id: post.id } })}>
1058
+ {result?.error ? 'Oops!' : 'Like'}
1059
+ </Button>
1060
+ );
1061
+ };
1062
+ ```
1063
+
1064
+ If you are not using an async component library, you can use React's `useTransition` to start the action in a transition:
1065
+
1066
+ ```tsx
1067
+ const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
1068
+ const fate = useFateClient();
1069
+ const [, startTransition] = useTransition();
1070
+ const [result, like, isPending] = useActionState(fate.actions.post.like, null);
1071
+
1072
+ return (
1073
+ <button
1074
+ disabled={isPending}
1075
+ onClick={() => {
1076
+ startTransition(() =>
1077
+ like({
1078
+ input: { id: post.id },
1079
+ }),
1080
+ );
1081
+ }}
1082
+ >
1083
+ {result?.error ? 'Oops!' : 'Like'}
1084
+ </button>
1085
+ );
1086
+ };
1087
+ ```
1088
+
1089
+ By using `useActionState`, fate Actions integrate with Suspense and concurrent rendering.
1090
+
1091
+ ### Optimistic Updates
1092
+
1093
+ 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:
1094
+
1095
+ ```tsx
1096
+ like({
1097
+ input: { id: post.id },
1098
+ optimistic: { likes: post.likes + 1 },
1099
+ });
1100
+ ```
1101
+
1102
+ 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.
1103
+
1104
+ 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.
1105
+
1106
+ ### Inserting New Objects
1107
+
1108
+ 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:
1109
+
1110
+ ```tsx
1111
+ const content = 'New Comment text';
1112
+ addComment({
1113
+ input: { content, postId: post.id },
1114
+ optimistic: {
1115
+ author: { id: user.id, name: user.name },
1116
+ content,
1117
+ id: `optimistic:${Date.now().toString(36)}`,
1118
+ post: { commentCount: post.commentCount + 1, id: post.id },
1119
+ },
1120
+ });
1121
+ ```
1122
+
1123
+ 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:
1124
+
1125
+ ```tsx
1126
+ addComment({
1127
+ input: { content, postId: post.id },
1128
+ insert: 'before',
1129
+ optimistic: {
1130
+ content,
1131
+ id: `optimistic:${Date.now().toString(36)}`,
1132
+ post: { id: post.id },
1133
+ },
1134
+ });
1135
+ ```
1136
+
1137
+ 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.
1138
+
1139
+ 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.
1140
+
1141
+ ### Selecting a View with Actions
1142
+
1143
+ Mutations may change data that is not directly specified in the mutation result. For example, adding a comment increases the post's comment count. For such cases, you can provide a `view` to an action that specifies which fields to fetch as part of the mutation:
1144
+
1145
+ ```tsx
1146
+ addComment({
1147
+ input: { content: 'New Comment text', postId: post.id },
1148
+ view: view<Comment>()({
1149
+ ...CommentView,
1150
+ post: { commentCount: true },
1151
+ }),
1152
+ });
1153
+ ```
1154
+
1155
+ 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:
1156
+
1157
+ ```tsx
1158
+ const [result, addComment] = useActionState(fate.actions.comment.add, null);
1159
+
1160
+ const newComment = result?.result;
1161
+ if (newComment) {
1162
+ // All the fields selected in the view are available on `newComment`:
1163
+ console.log(newComment.post.commentCount);
1164
+ }
1165
+ ```
1166
+
1167
+ ### Mutations
1168
+
1169
+ 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:
1170
+
1171
+ ```tsx
1172
+ const result = await fate.mutations.comment.add({
1173
+ input: { content, postId: post.id },
1174
+ });
1175
+ ```
1176
+
1177
+ 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.
1178
+
1179
+ ### Mutation Server Implementation
1180
+
1181
+ 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:
1182
+
1183
+ - [Native HTTP custom mutations](/docs/integrations/server.md#custom-mutations) use `createFateServer({ mutations })`.
1184
+ - [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.
1185
+ - [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`.
1186
+
1187
+ Here is a native HTTP mutation for `post.like`:
1188
+
1189
+ ```tsx
1190
+ export const fate = createFateServer({
1191
+ mutations: {
1192
+ 'post.like': {
1193
+ input: likeInput,
1194
+ resolve: async ({ ctx, input, select }) => {
1195
+ await ctx.prisma.post.update({
1196
+ data: {
1197
+ likes: {
1198
+ increment: 1,
1199
+ },
1200
+ },
1201
+ where: { id: input.id },
1202
+ });
1203
+
1204
+ return sources.resolveById({
1205
+ ctx,
1206
+ id: input.id,
1207
+ input: { select },
1208
+ view: postDataView,
1209
+ });
1210
+ },
1211
+ type: 'Post',
1212
+ },
1213
+ },
1214
+ roots: Root,
1215
+ sources,
1216
+ });
1217
+ ```
1218
+
1219
+ The equivalent tRPC mutation lives in your router and returns the selected shape that the client asked for:
1220
+
1221
+ ```tsx
1222
+ import { z } from 'zod';
1223
+ import { connectionArgs, createResolver } from '@nkzw/fate/server';
1224
+ import { procedure, router } from '../init.ts';
1225
+ import { postDataView, PostItem } from '../views.ts';
1226
+
1227
+ export const postRouter = router({
1228
+ like: procedure
1229
+ .input(
1230
+ z.object({
1231
+ args: connectionArgs,
1232
+ id: z.string().min(1, 'Post id is required.'),
1233
+ select: z.array(z.string()),
1234
+ }),
1235
+ )
1236
+ .mutation(async ({ ctx, input }) => {
1237
+ const { resolve, select } = createResolver({
1238
+ ...input,
1239
+ ctx,
1240
+ view: postDataView,
1241
+ });
1242
+
1243
+ return resolve(
1244
+ await ctx.prisma.post.update({
1245
+ data: {
1246
+ likes: {
1247
+ increment: 1,
1248
+ },
1249
+ },
1250
+ select,
1251
+ where: { id: input.id },
1252
+ } as PostUpdateArgs),
1253
+ );
1254
+ }),
1255
+ });
1256
+ ```
1257
+
1258
+ 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.
1259
+
1260
+ ### Action & Mutation Error Handling
1261
+
1262
+ 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.
1263
+
1264
+ 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:
1265
+
1266
+ ```tsx
1267
+ const [result] = useActionState(fate.actions.post.delete, null);
1268
+
1269
+ if (result?.error) {
1270
+ if (result.error.code === 'NOT_FOUND') {
1271
+ // Handle not found error at call site.
1272
+ } else {
1273
+ // Handle other *expected* errors.
1274
+ }
1275
+ }
1276
+ ```
1277
+
1278
+ 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:
1279
+
1280
+ ```tsx
1281
+ <ErrorBoundary FallbackComponent={ErrorComponent}>
1282
+ <Suspense fallback={<div>Loading…</div>}>
1283
+ <PostPage postId={postId} />
1284
+ </Suspense>
1285
+ </ErrorBoundary>
1286
+ ```
1287
+
1288
+ 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).
1289
+
1290
+ ### Deleting Records
1291
+
1292
+ 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:
1293
+
1294
+ ```tsx
1295
+ const [result, deleteAction] = useActionState(fate.actions.post.delete, null);
1296
+
1297
+ deleteAction({
1298
+ input: { id: post.id },
1299
+ delete: true,
1300
+ });
1301
+ ```
1302
+
1303
+ ### Resetting Action State
1304
+
1305
+ 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:
1306
+
1307
+ ```tsx
1308
+ const [result, like] = useActionState(fate.actions.post.like, null);
1309
+
1310
+ useEffect(() => {
1311
+ if (result?.error) {
1312
+ // Reset the action state after 3 seconds.
1313
+ const timeout = setTimeout(() => startTransition(() => like('reset')), 3000);
1314
+ return () => clearTimeout(timeout);
1315
+ }
1316
+ }, [like, result]);
1317
+ ```
1318
+
1319
+ ### Controlling List Insertion Behavior
1320
+
1321
+ 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:
1322
+
1323
+ ```tsx
1324
+ addComment({
1325
+ input: { content: 'New Comment text', postId: post.id },
1326
+ insert: 'before', // Insert the new comment at the beginning of the list.
1327
+ });
1328
+ ```
1329
+
1330
+ Or, use the `none` option if you want to ignore inserting the new object into any lists:
1331
+
1332
+ ```tsx
1333
+ addComment({
1334
+ input: { content: 'New Comment text', postId: post.id },
1335
+ insert: 'none', // Do not insert the new comment into any lists.
1336
+ });
1337
+ ```
1338
+
1339
+ ## Persistence
1340
+
1341
+ 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.
1342
+
1343
+ 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.
1344
+
1345
+ ### Client Setup
1346
+
1347
+ The persistence layer is part of `@nkzw/fate`, with storage adapters installed separately. For a browser app using IndexedDB, add the adapter:
1348
+
1349
+ ```sh
1350
+ vp add @nkzw/fate-indexeddb
1351
+ ```
1352
+
1353
+ Then pass `persistence` when creating the client:
1354
+
1355
+ ```tsx
1356
+ import { createPersistence } from '@nkzw/fate/persistence';
1357
+ import { indexedDB } from '@nkzw/fate-indexeddb';
1358
+ import { createFateClient } from 'react-fate/client';
1359
+
1360
+ const fate = createFateClient({
1361
+ persistence: createPersistence({
1362
+ key: `workspace:${workspaceId}:user:${userId}`,
1363
+ storage: indexedDB(),
1364
+ }),
1365
+ url: '/api/fate',
1366
+ });
1367
+ ```
1368
+
1369
+ By default, fate keeps loaded data for **one day**, with a storage budget of **25 MiB**. You can change both values:
1370
+
1371
+ ```tsx
1372
+ persistence: createPersistence({
1373
+ key: `workspace:${workspaceId}:user:${userId}`,
1374
+ maxAge: 24 * 60 * 60 * 1000,
1375
+ maxBytes: 25 * 1024 * 1024,
1376
+ storage: indexedDB(),
1377
+ }),
1378
+ ```
1379
+
1380
+ 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.
1381
+
1382
+ > [!NOTE]
1383
+ >
1384
+ > 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.
1385
+
1386
+ ### Cache Lifetime
1387
+
1388
+ 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:
1389
+
1390
+ ```tsx
1391
+ const { posts } = useRequest(
1392
+ {
1393
+ posts: { list: PostView },
1394
+ },
1395
+ {
1396
+ persist: { maxAge: 3 * 24 * 60 * 60 * 1000 },
1397
+ },
1398
+ );
1399
+ ```
1400
+
1401
+ 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.
1402
+
1403
+ 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.
1404
+
1405
+ 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.
1406
+
1407
+ `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.
1408
+
1409
+ When callers share a pending request, the latest explicit `persist.maxAge` also applies to that request's eventual cache write.
1410
+
1411
+ 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.
1412
+
1413
+ 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.
1414
+
1415
+ ### Refreshing Data
1416
+
1417
+ 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:
1418
+
1419
+ - `cache-first` (_default_): Uses available saved data and fetches missing data from the network.
1420
+ - `stale-while-revalidate`: Shows saved data and refreshes it in the background. If the refresh fails, the saved data stays visible.
1421
+ - `network-only`: Requires a network response, even if saved data is available.
1422
+
1423
+ For example, to show the previous session's posts immediately and update them on reload:
1424
+
1425
+ ```tsx
1426
+ const { posts } = useRequest(
1427
+ {
1428
+ posts: { list: PostView },
1429
+ },
1430
+ {
1431
+ mode: 'stale-while-revalidate',
1432
+ persist: { maxAge: 3 * 24 * 60 * 60 * 1000 },
1433
+ },
1434
+ );
1435
+ ```
1436
+
1437
+ 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).
1438
+
1439
+ ### Cache Size
1440
+
1441
+ 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.
1442
+
1443
+ 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.
1444
+
1445
+ 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.
1446
+
1447
+ 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.
1448
+
1449
+ > [!NOTE]
1450
+ >
1451
+ > 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.
1452
+
1453
+ ### Actions & Mutations
1454
+
1455
+ 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):
1456
+
1457
+ ```tsx
1458
+ const [result, like] = useActionState(fate.actions.post.like, null);
1459
+
1460
+ like({
1461
+ input: { id: post.id },
1462
+ optimistic: { likes: post.likes + 1 },
1463
+ });
1464
+ ```
1465
+
1466
+ 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.
1467
+
1468
+ 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).
1469
+
1470
+ 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.
1471
+
1472
+ #### Skipping Persistence
1473
+
1474
+ For a call that should run immediately without being saved or retried, pass `persist: false`:
1475
+
1476
+ ```tsx
1477
+ await fate.mutations.analytics.record({
1478
+ input: { event: 'opened-settings' },
1479
+ persist: false,
1480
+ });
1481
+ ```
1482
+
1483
+ 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.
1484
+
1485
+ #### Creating Objects Offline
1486
+
1487
+ 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.
1488
+
1489
+ 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.
1490
+
1491
+ 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.
1492
+
1493
+ ### Server Deduplication
1494
+
1495
+ 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.
1496
+
1497
+ 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.
1498
+
1499
+ 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:
1500
+
1501
+ ```ts
1502
+ import { createMutationIdempotency } from '@nkzw/fate/persistence/server';
1503
+ import { createFateServer } from '@nkzw/fate/server';
1504
+
1505
+ const server = createFateServer({
1506
+ // ...roots, sources, mutations, context...
1507
+ idempotency: createMutationIdempotency({
1508
+ scope: (ctx) => `workspace:${ctx.workspace.id}:user:${ctx.user.id}`,
1509
+ store: {
1510
+ transaction: (ctx, scope, id, run) =>
1511
+ database.transaction(async (tx) => {
1512
+ await lockMutationIdentity(tx, scope, id);
1513
+ return run({
1514
+ context: { ...ctx, db: tx },
1515
+ read: () => readReceipt(tx, scope, id),
1516
+ write: (receipt) => insertReceipt(tx, scope, id, receipt),
1517
+ });
1518
+ }),
1519
+ },
1520
+ }),
1521
+ });
1522
+ ```
1523
+
1524
+ 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.
1525
+
1526
+ 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.
1527
+
1528
+ 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.
1529
+
1530
+ 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.
1531
+
1532
+ 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.
1533
+
1534
+ ### tRPC, GraphQL, and Custom Transports
1535
+
1536
+ 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)`.
1537
+
1538
+ 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:
1539
+
1540
+ ```tsx
1541
+ const durableHTTP = createHTTPTransport<MyAPI>({
1542
+ url: '/api/fate',
1543
+ // Use the same authenticated headers as your other transport.
1544
+ });
1545
+
1546
+ const fate = createFateClient({
1547
+ // ...your generated tRPC or GraphQL client options...
1548
+ persistence: createPersistence({ key: accountKey, storage: indexedDB() }),
1549
+ mutateDurably: durableHTTP.mutateDurably,
1550
+ });
1551
+ ```
1552
+
1553
+ 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.
1554
+
1555
+ 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`:
1556
+
1557
+ ```ts
1558
+ return idempotency.execute({
1559
+ ctx,
1560
+ identity: input.identity,
1561
+ input: input.update,
1562
+ name: 'post.update',
1563
+ select: input.select,
1564
+ resolve: (transactionContext) => updatePost(transactionContext, input.update),
1565
+ });
1566
+ ```
1567
+
1568
+ 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.
1569
+
1570
+ 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.
1571
+
1572
+ ### Persistence State
1573
+
1574
+ Use `fate.persistence` to observe pending changes and storage errors:
1575
+
1576
+ ```tsx
1577
+ const session = fate.persistence!;
1578
+ await session.ready;
1579
+
1580
+ const unsubscribe = session.subscribe(() => {
1581
+ const { status, error, mutations } = session.getSnapshot();
1582
+ // Show pending changes or report a storage error.
1583
+ });
1584
+ ```
1585
+
1586
+ `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.
1587
+
1588
+ 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.
1589
+
1590
+ Cache writes are batched. To save the latest read cache before a reload you control, call `flush()`:
1591
+
1592
+ ```tsx
1593
+ await fate.persistence!.flush();
1594
+ window.location.reload();
1595
+ ```
1596
+
1597
+ 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.
1598
+
1599
+ You can also retry delivery or discard a mutation:
1600
+
1601
+ ```tsx
1602
+ session.retry();
1603
+ await session.discard(mutationId);
1604
+ ```
1605
+
1606
+ `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.
1607
+
1608
+ When leaving an account, dispose its session and unsubscribe:
1609
+
1610
+ ```tsx
1611
+ session.dispose();
1612
+ unsubscribe();
1613
+ ```
1614
+
1615
+ 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.
1616
+
1617
+ ### Clearing Saved Data
1618
+
1619
+ To remove the saved read cache, call `clearCache()`:
1620
+
1621
+ ```tsx
1622
+ await fate.persistence!.clearCache();
1623
+ ```
1624
+
1625
+ 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.
1626
+
1627
+ 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.
1628
+
1629
+ 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.
1630
+
1631
+ ### Other Storage Backends
1632
+
1633
+ The core persistence layer has no IndexedDB dependency. You can use another backend by implementing `PersistenceStorage`:
1634
+
1635
+ ```ts
1636
+ interface PersistenceStorage {
1637
+ read(key: string): Promise<unknown>;
1638
+ write(key: string, value: unknown): Promise<void>;
1639
+ scan(
1640
+ prefix: string,
1641
+ after?: string,
1642
+ limit?: number,
1643
+ ): Promise<Array<{ key: string; value: unknown }>>;
1644
+ writeBatch(entries: ReadonlyArray<readonly [string, unknown]>): Promise<void>;
1645
+ exclusive<T>(key: string, run: () => Promise<T>): Promise<T>;
1646
+ subscribe?(key: string, listener: () => void): () => void;
1647
+ }
1648
+ ```
1649
+
1650
+ Values use fate's hydration codec and can be stored as JSON. The adapter handles storage and coordination:
1651
+
1652
+ - `read` returns the saved value for a key.
1653
+ - `write` replaces one value atomically and resolves after it has committed.
1654
+ - `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.
1655
+ - `writeBatch` commits all entries atomically. An `undefined` value deletes the key.
1656
+ - `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.
1657
+ - `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.
1658
+
1659
+ 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.
1660
+
1661
+ 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.
1662
+
1663
+ ## Vue
1664
+
1665
+ _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.
1666
+
1667
+ 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.
1668
+
1669
+ ### Installation
1670
+
1671
+ Install `vue-fate` in your Vue client:
1672
+
1673
+ ::: code-group
1674
+
1675
+ ```bash [npm]
1676
+ npm add vue-fate
1677
+ ```
1678
+
1679
+ ```bash [pnpm]
1680
+ pnpm add vue-fate
1681
+ ```
1682
+
1683
+ ```bash [yarn]
1684
+ yarn add vue-fate
1685
+ ```
1686
+
1687
+ :::
1688
+
1689
+ If your server lives in a separate package, install `@nkzw/fate` there as a runtime dependency too.
1690
+
1691
+ ### Vite Plugin
1692
+
1693
+ Use the Vue adapter's Vite plugin in the client app:
1694
+
1695
+ ```ts
1696
+ import { fate } from 'vue-fate/vite';
1697
+ import { defineConfig } from 'vite';
1698
+ import vue from '@vitejs/plugin-vue';
1699
+
1700
+ export default defineConfig({
1701
+ plugins: [
1702
+ vue(),
1703
+ fate({
1704
+ module: '@your-org/server/fate.ts',
1705
+ transport: 'native',
1706
+ }),
1707
+ ],
1708
+ });
1709
+ ```
1710
+
1711
+ The plugin generates `vue-fate/client`, which contains the typed `createFateClient` helper for your app. The `module` and `transport` options are the same options used by the React adapter.
1712
+
1713
+ ### Providing the Client
1714
+
1715
+ Create a client with `createFateClient` and provide it with `FateClient`:
1716
+
1717
+ ```vue
1718
+ <script setup lang="ts">
1719
+ import { computed, ref } from 'vue';
1720
+ import { FateClient } from 'vue-fate';
1721
+ import { createFateClient } from 'vue-fate/client';
1722
+ import AppRoutes from './AppRoutes.vue';
1723
+
1724
+ const token = ref<string | null>(null);
1725
+
1726
+ const fate = computed(() =>
1727
+ createFateClient({
1728
+ headers: () => ({
1729
+ authorization: token.value ? `Bearer ${token.value}` : '',
1730
+ }),
1731
+ url: '/fate',
1732
+ }),
1733
+ );
1734
+ </script>
1735
+
1736
+ <template>
1737
+ <FateClient :client="fate">
1738
+ <Suspense>
1739
+ <AppRoutes />
1740
+ </Suspense>
1741
+ </FateClient>
1742
+ </template>
1743
+ ```
1744
+
1745
+ The `client` prop accepts a plain client, a ref, a computed value, or a getter. Descendants always read the current client, so switching credentials, endpoints, or transports does not require remounting the provider.
1746
+
1747
+ You can also install the client as a Vue plugin:
1748
+
1749
+ ```ts
1750
+ import { createApp } from 'vue';
1751
+ import { createFatePlugin } from 'vue-fate';
1752
+ import { createFateClient } from 'vue-fate/client';
1753
+ import App from './App.vue';
1754
+
1755
+ const fate = createFateClient({ url: '/fate' });
1756
+
1757
+ createApp(App).use(createFatePlugin(fate)).mount('#app');
1758
+ ```
1759
+
1760
+ ### Defining Views
1761
+
1762
+ Views are plain TypeScript values and can live anywhere. In Vue apps, it is usually best to define shared views in `.ts` modules and import them from single-file components:
1763
+
1764
+ ```ts
1765
+ import type { Post, User } from '@your-org/server/views';
1766
+ import { view } from 'vue-fate';
1767
+
1768
+ export const UserView = view<User>()({
1769
+ id: true,
1770
+ name: true,
1771
+ username: true,
1772
+ });
1773
+
1774
+ export const PostView = view<Post>()({
1775
+ author: UserView,
1776
+ id: true,
1777
+ title: true,
1778
+ });
1779
+ ```
1780
+
1781
+ 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.
1782
+
1783
+ ### Requests
1784
+
1785
+ `useRequest` declares the data a route, page, or component tree needs. It returns a resource with `data`, `pending`, `error`, `ready`, `refresh`, and `dispose`:
1786
+
1787
+ ```vue
1788
+ <script setup lang="ts">
1789
+ import { useListView, useRequest } from 'vue-fate';
1790
+ import { PostCardView } from '../fateViews';
1791
+ import PostCard from '../ui/PostCard.vue';
1792
+
1793
+ const request = useRequest({
1794
+ posts: {
1795
+ args: { first: 20 },
1796
+ list: PostCardView,
1797
+ },
1798
+ });
1799
+
1800
+ const { posts } = await request.ready();
1801
+ const [postItems, loadNext] = useListView(PostCardView, posts);
1802
+ </script>
1803
+
1804
+ <template>
1805
+ <PostCard v-for="{ node } in postItems" :key="node.id" :post="node" />
1806
+ <button v-if="loadNext" @click="loadNext()">Load more</button>
1807
+ </template>
1808
+ ```
1809
+
1810
+ 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.
1811
+
1812
+ ### Views in Components
1813
+
1814
+ Use `useView` to read a `ViewRef` from the normalized cache and subscribe to updates for the selected fields:
1815
+
1816
+ ```vue
1817
+ <script setup lang="ts">
1818
+ import type { ViewRef } from 'vue-fate';
1819
+ import { useView } from 'vue-fate';
1820
+ import { PostCardView, UserView } from '../fateViews';
1821
+ import UserCard from './UserCard.vue';
1822
+
1823
+ const props = defineProps<{
1824
+ post: ViewRef<'Post'>;
1825
+ }>();
1826
+
1827
+ const post = useView(PostCardView, () => props.post);
1828
+ const author = useView(UserView, () => post.value?.author ?? null);
1829
+ </script>
1830
+
1831
+ <template>
1832
+ <article v-if="post">
1833
+ <h2>{{ post.title }}</h2>
1834
+ <UserCard v-if="author" :user="author" />
1835
+ </article>
1836
+ </template>
1837
+ ```
1838
+
1839
+ 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.
1840
+
1841
+ ### Lists and Live Views
1842
+
1843
+ `useListView` subscribes to a connection returned from `useRequest` or from a nested view field:
1844
+
1845
+ ```ts
1846
+ const [comments, loadNextCommentPage] = useListView(CommentView, () => post.value?.comments);
1847
+ ```
1848
+
1849
+ `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:
1850
+
1851
+ ```ts
1852
+ const post = useLiveView(PostCardView, () => props.post);
1853
+ const [comments] = useLiveListView(CommentView, () => post.value?.comments);
1854
+ ```
1855
+
1856
+ Manual cleanup works through `dispose()`:
1857
+
1858
+ ```ts
1859
+ const post = useLiveView(PostCardView, () => props.post);
1860
+
1861
+ onBeforeUnmount(() => {
1862
+ post.dispose();
1863
+ });
1864
+ ```
1865
+
1866
+ Vue scope disposal also cleans up resources automatically.
1867
+
1868
+ ### Mutations
1869
+
1870
+ Use `useFateClient` to access generated mutations:
1871
+
1872
+ ```vue
1873
+ <script setup lang="ts">
1874
+ import { ref } from 'vue';
1875
+ import { useFateClient } from 'vue-fate';
1876
+
1877
+ const props = defineProps<{
1878
+ post: { id: string; likes: number };
1879
+ }>();
1880
+
1881
+ const fate = useFateClient();
1882
+ const pending = ref(false);
1883
+ const error = ref<unknown>(null);
1884
+
1885
+ const like = async () => {
1886
+ pending.value = true;
1887
+ error.value = null;
1888
+
1889
+ try {
1890
+ await fate.mutations.post.like({
1891
+ input: { id: props.post.id },
1892
+ optimistic: { likes: props.post.likes + 1 },
1893
+ });
1894
+ } catch (caughtError) {
1895
+ error.value = caughtError;
1896
+ } finally {
1897
+ pending.value = false;
1898
+ }
1899
+ };
1900
+ </script>
1901
+
1902
+ <template>
1903
+ <button :disabled="pending" @click="like">Like</button>
1904
+ </template>
1905
+ ```
1906
+
1907
+ 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.
1908
+
1909
+ ### API Differences from React
1910
+
1911
+ The names intentionally mirror `react-fate` where Vue has an equivalent API. The main differences are Vue framework differences:
1912
+
1913
+ - `useRequest`, `useView`, and list hooks return Vue resources instead of throwing promises from render.
1914
+ - Async setup and `<Suspense>` replace React's async component model.
1915
+ - Mutations use `fate.mutations` directly; React-only `fate.actions` and `useActionState` patterns do not apply.
1916
+ - Shared views are best kept in `.ts` modules instead of exporting named views from component files.
1917
+
1918
+ The generated client, server integrations, cache behavior, masking, pagination, optimistic updates, and live transport behavior are shared across adapters.
1919
+
1920
+ ## GraphQL Integration
1921
+
1922
+ _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.
1923
+
1924
+ Use the GraphQL transport when your backend already exposes GraphQL and you want fate's client model without adding fate's native server protocol.
1925
+
1926
+ ### Template
1927
+
1928
+ Create a client for an existing GraphQL server with:
1929
+
1930
+ ```bash
1931
+ vp create fate my-app --template graphql-client
1932
+ ```
1933
+
1934
+ Create a full GraphQL + Prisma example app with:
1935
+
1936
+ ```bash
1937
+ vp create fate my-app --template graphql
1938
+ ```
1939
+
1940
+ 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.
1941
+
1942
+ ### GraphQL Schema Shape
1943
+
1944
+ The GraphQL transport expects a schema with Relay-style object identity and pagination:
1945
+
1946
+ - Entity objects include `id` and `__typename`.
1947
+ - Object fetches go through a `nodes(ids:)` field.
1948
+ - List fields return Relay connections with `edges`, `cursor`, `node`, and `pageInfo`.
1949
+ - Root queries and mutations return the entity type selected by the fate view.
1950
+
1951
+ For example, a `Post` list can be exposed as a normal GraphQL connection:
1952
+
1953
+ ```graphql
1954
+ type Query {
1955
+ posts(first: Int, after: String): PostConnection!
1956
+ viewer: User
1957
+ nodes(ids: [ID!]!): [Node]!
1958
+ }
1959
+
1960
+ type PostConnection {
1961
+ edges: [PostEdge!]!
1962
+ pageInfo: PageInfo!
1963
+ }
1964
+ ```
1965
+
1966
+ If your schema uses different root field names, keep the fate names you want on the client and map them with `fateGraphQL.roots`.
1967
+
1968
+ ### Mapping Your Schema
1969
+
1970
+ 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.
1971
+
1972
+ ```tsx
1973
+ import { graphqlMutation } from '@nkzw/fate';
1974
+ import { dataView, list, type Entity } from '@nkzw/fate/server';
1975
+
1976
+ type GraphQLUser = {
1977
+ id: string;
1978
+ name?: string | null;
1979
+ username?: string | null;
1980
+ };
1981
+
1982
+ type GraphQLPost = {
1983
+ author?: GraphQLUser | null;
1984
+ id: string;
1985
+ title: string;
1986
+ };
1987
+
1988
+ export const userDataView = dataView<GraphQLUser>('User')({
1989
+ id: true,
1990
+ name: true,
1991
+ username: true,
1992
+ });
1993
+
1994
+ export const postDataView = dataView<GraphQLPost>('Post')({
1995
+ author: userDataView,
1996
+ id: true,
1997
+ title: true,
1998
+ });
1999
+
2000
+ export type User = Entity<typeof userDataView, 'User'>;
2001
+ export type Post = Entity<
2002
+ typeof postDataView,
2003
+ 'Post',
2004
+ {
2005
+ author: User | null;
2006
+ }
2007
+ >;
2008
+
2009
+ export const Root = {
2010
+ posts: list(postDataView),
2011
+ viewer: userDataView,
2012
+ };
2013
+
2014
+ export const fateGraphQL = {
2015
+ roots: {
2016
+ posts: { field: 'posts' },
2017
+ viewer: { field: 'viewer' },
2018
+ },
2019
+ } as const;
2020
+ ```
2021
+
2022
+ 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.
2023
+
2024
+ ### Vite Plugin
2025
+
2026
+ Configure the fate Vite plugin with the GraphQL transport and point it at the mapping module:
2027
+
2028
+ ::: code-group
2029
+
2030
+ ```tsx [React]
2031
+ import { fate } from 'react-fate/vite';
2032
+ import { defineConfig } from 'vite';
2033
+
2034
+ export default defineConfig({
2035
+ plugins: [
2036
+ fate({
2037
+ module: './src/fate/graphql.ts',
2038
+ transport: 'graphql',
2039
+ }),
2040
+ ],
2041
+ });
2042
+ ```
2043
+
2044
+ ```ts [Vue]
2045
+ import vue from '@vitejs/plugin-vue';
2046
+ import { fate } from 'vue-fate/vite';
2047
+ import { defineConfig } from 'vite';
2048
+
2049
+ export default defineConfig({
2050
+ plugins: [
2051
+ vue(),
2052
+ fate({
2053
+ module: './src/fate/graphql.ts',
2054
+ transport: 'graphql',
2055
+ }),
2056
+ ],
2057
+ });
2058
+ ```
2059
+
2060
+ :::
2061
+
2062
+ 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.
2063
+
2064
+ ### Creating a Client
2065
+
2066
+ Create the client with your GraphQL endpoint and provide it through the `FateClient` provider:
2067
+
2068
+ ::: code-group
2069
+
2070
+ ```tsx [React]
2071
+ import { FateClient } from 'react-fate';
2072
+ import { createFateClient } from 'react-fate/client';
2073
+
2074
+ const fate = createFateClient({
2075
+ headers: () => ({
2076
+ authorization: `Bearer ${token}`,
2077
+ }),
2078
+ url: 'https://api.example.com/graphql',
2079
+ });
2080
+
2081
+ export function App() {
2082
+ return <FateClient client={fate}>{/* Components go here */}</FateClient>;
2083
+ }
2084
+ ```
2085
+
2086
+ ```vue [Vue]
2087
+ <script setup lang="ts">
2088
+ import { FateClient } from 'vue-fate';
2089
+ import { createFateClient } from 'vue-fate/client';
2090
+ import AppRoutes from './AppRoutes.vue';
2091
+
2092
+ const fate = createFateClient({
2093
+ headers: () => ({
2094
+ authorization: `Bearer ${token}`,
2095
+ }),
2096
+ url: 'https://api.example.com/graphql',
2097
+ });
2098
+ </script>
2099
+
2100
+ <template>
2101
+ <FateClient :client="fate">
2102
+ <AppRoutes />
2103
+ </FateClient>
2104
+ </template>
2105
+ ```
2106
+
2107
+ :::
2108
+
2109
+ Use `fetch` when you need to customize credentials or reuse an application fetch wrapper:
2110
+
2111
+ ```tsx
2112
+ const fate = createFateClient({
2113
+ fetch: (input, init) =>
2114
+ fetch(input, {
2115
+ ...init,
2116
+ credentials: 'include',
2117
+ }),
2118
+ url: `${env('SERVER_URL')}/graphql`,
2119
+ });
2120
+ ```
2121
+
2122
+ GraphQL operations issued in the same microtask are batched into a single GraphQL query or mutation document with aliased fields.
2123
+
2124
+ 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.
2125
+
2126
+ ### Object IDs
2127
+
2128
+ The transport converts between fate entity IDs and GraphQL node IDs. By default, it sends IDs as `${type}-${id}` and strips that prefix from returned IDs. Override this if your schema uses Relay global IDs, raw database IDs, or another encoding:
2129
+
2130
+ ```tsx
2131
+ const fate = createFateClient({
2132
+ decodeNodeId: (type, id) => {
2133
+ const [nodeType, nodeId] = atob(String(id)).split(':');
2134
+ if (nodeType !== type) {
2135
+ throw new Error(`Expected a ${type} node id.`);
2136
+ }
2137
+ return nodeId;
2138
+ },
2139
+ encodeNodeId: (type, id) => btoa(`${type}:${id}`),
2140
+ url: '/graphql',
2141
+ });
2142
+ ```
2143
+
2144
+ If your GraphQL API already accepts and returns the same IDs you use in the app, return `id` from both functions.
2145
+
2146
+ ### Requests and Arguments
2147
+
2148
+ Client code keeps using `useRequest` with the same shape as the other transports:
2149
+
2150
+ ```tsx
2151
+ const { posts, viewer } = useRequest({
2152
+ posts: {
2153
+ args: { first: 10 },
2154
+ list: PostView,
2155
+ },
2156
+ viewer: { view: UserView },
2157
+ });
2158
+ ```
2159
+
2160
+ Root arguments are sent to the root GraphQL field. Nested relation arguments are scoped by relation name:
2161
+
2162
+ ```tsx
2163
+ const { posts } = useRequest({
2164
+ posts: {
2165
+ args: {
2166
+ comments: { first: 3 },
2167
+ first: 10,
2168
+ },
2169
+ list: PostWithCommentsView,
2170
+ },
2171
+ });
2172
+ ```
2173
+
2174
+ This produces a root `posts(first: 10)` field and a nested `comments(first: 3)` field in the generated GraphQL selection.
2175
+
2176
+ ### Mutations
2177
+
2178
+ Map fate mutation names to GraphQL mutation fields with `graphqlMutation`:
2179
+
2180
+ ```tsx
2181
+ export const fateGraphQL = {
2182
+ mutations: {
2183
+ 'post.like': graphqlMutation<Post, { id: string }, Post>('Post', {
2184
+ field: 'postLike',
2185
+ }),
2186
+ },
2187
+ roots: {
2188
+ posts: { field: 'posts' },
2189
+ },
2190
+ } as const;
2191
+ ```
2192
+
2193
+ By default, the input is sent as an `input` argument:
2194
+
2195
+ ```graphql
2196
+ mutation {
2197
+ postLike(input: { id: "12" }) {
2198
+ id
2199
+ likes
2200
+ }
2201
+ }
2202
+ ```
2203
+
2204
+ Use `inputArg` when your schema uses a different argument name, or `inputArg: false` when the input object should be spread into field arguments:
2205
+
2206
+ ```tsx
2207
+ export const fateGraphQL = {
2208
+ mutations: {
2209
+ 'post.like': graphqlMutation<Post, { id: string }, Post>('Post', {
2210
+ field: 'likePost',
2211
+ inputArg: 'payload',
2212
+ }),
2213
+ 'user.follow': graphqlMutation<User, { id: string }, User>('User', {
2214
+ field: 'followUser',
2215
+ inputArg: false,
2216
+ }),
2217
+ },
2218
+ } as const;
2219
+ ```
2220
+
2221
+ 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`.
2222
+
2223
+ ### Live Views
2224
+
2225
+ GraphQL live views use [GraphQL SSE](https://github.com/enisdenjo/graphql-sse). Install `graphql-sse` in the client package and leave `live` enabled, or pass `live: false` when your schema does not support subscriptions.
2226
+
2227
+ ```tsx
2228
+ const fate = createFateClient({
2229
+ live: {
2230
+ url: 'https://api.example.com/graphql/stream',
2231
+ },
2232
+ url: 'https://api.example.com/graphql',
2233
+ });
2234
+ ```
2235
+
2236
+ The default subscription fields are `fateLiveNode` for `useLiveView` and `fateLiveConnection` for `useLiveListView`. Rename them with `entityField` and `connectionField`:
2237
+
2238
+ ```tsx
2239
+ const fate = createFateClient({
2240
+ live: {
2241
+ connectionField: 'liveConnection',
2242
+ entityField: 'liveNode',
2243
+ url: '/graphql/stream',
2244
+ },
2245
+ url: '/graphql',
2246
+ });
2247
+ ```
2248
+
2249
+ 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.
2250
+
2251
+ If you do not need live views, disable them explicitly:
2252
+
2253
+ ```tsx
2254
+ const fate = createFateClient({
2255
+ live: false,
2256
+ url: '/graphql',
2257
+ });
2258
+ ```
2259
+
2260
+ ### Existing Servers
2261
+
2262
+ 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.
2263
+
2264
+ 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.
2265
+
2266
+ ## Server Integration
2267
+
2268
+ 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:
2269
+
2270
+ - The native fate protocol, which is transport-agnostic and can be hosted by any Fetch-compatible server.
2271
+ - The tRPC adapter, which keeps compatibility with existing tRPC backends.
2272
+ - The [GraphQL transport](/docs/integrations/graphql.md), which maps fate views and roots to an existing GraphQL schema.
2273
+
2274
+ _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.
2275
+
2276
+ ### Conventions & Object Identity
2277
+
2278
+ fate expects that data is served by a backend that follows these conventions:
2279
+
2280
+ - A `byId` query for each data type to fetch individual objects by their unique identifier (`id`).
2281
+ - A `list` query for fetching lists of objects with support for pagination.
2282
+
2283
+ 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.
2284
+
2285
+ 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.
2286
+
2287
+ > [!NOTE]
2288
+ > You can adopt _fate_ incrementally in an existing tRPC codebase without changing your existing schema by adding these queries alongside your existing procedures.
2289
+
2290
+ ### Data Views
2291
+
2292
+ Since clients can send arbitrary selection objects to the server, we need to implement a way to translate these selection objects into database queries without exposing raw database queries and private data to the client. On the client, we define views to select fields on each type. We can do the same on the server using fate data views and the `dataView` function from `@nkzw/fate/server`.
2293
+
2294
+ Create a `views.ts` file next to your server entry that exports the data views for each type. The same data view shape works with both Prisma model types and Drizzle row types:
2295
+
2296
+ ::: code-group
2297
+
2298
+ ```tsx [Prisma]
2299
+ import { dataView, type Entity } from '@nkzw/fate/server';
2300
+ import type { User as PrismaUser } from '../prisma/prisma-client/client.ts';
2301
+
2302
+ export const userDataView = dataView<PrismaUser>('User')({
2303
+ id: true,
2304
+ name: true,
2305
+ username: true,
2306
+ });
2307
+
2308
+ export type User = Entity<typeof userDataView, 'User'>;
2309
+ ```
2310
+
2311
+ ```tsx [Drizzle]
2312
+ import { dataView, type Entity } from '@nkzw/fate/server';
2313
+ import type { UserRow } from '../drizzle/schema.ts';
2314
+
2315
+ export const userDataView = dataView<UserRow>('User')({
2316
+ id: true,
2317
+ name: true,
2318
+ username: true,
2319
+ });
2320
+
2321
+ export type User = Entity<typeof userDataView, 'User'>;
2322
+ ```
2323
+
2324
+ :::
2325
+
2326
+ Now that we apply `userDataView` to the `byId` query, the server limits the selection to the fields defined in the data view, keeping private fields hidden from the client, and providing type safety for client views:
2327
+
2328
+ ```tsx
2329
+ const UserData = view<User>()({
2330
+ // Type-error + ignored during runtime.
2331
+ password: true,
2332
+ });
2333
+ ```
2334
+
2335
+ ### Data View Composition
2336
+
2337
+ Similar to client-side views, data views can be composed of other data views:
2338
+
2339
+ ```tsx
2340
+ export const postDataView = dataView<PostItem>('Post')({
2341
+ author: userDataView,
2342
+ content: true,
2343
+ id: true,
2344
+ title: true,
2345
+ });
2346
+ ```
2347
+
2348
+ ### Data View Lists
2349
+
2350
+ Use the `list` helper to define list fields:
2351
+
2352
+ ```tsx
2353
+ import { list } from '@nkzw/fate/server';
2354
+
2355
+ export const commentDataView = dataView<CommentItem>('Comment')({
2356
+ content: true,
2357
+ id: true,
2358
+ });
2359
+
2360
+ export const postDataView = dataView<PostItem>('Post')({
2361
+ author: userDataView,
2362
+ comments: list(commentDataView, { orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }),
2363
+ });
2364
+ ```
2365
+
2366
+ We can define extra root-level lists and queries by exporting a `Root` object from our `views.ts` file using the same view syntax as everywhere else:
2367
+
2368
+ ```tsx
2369
+ export const Root = {
2370
+ categories: list(categoryDataView, { orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }),
2371
+ commentSearch: {
2372
+ procedure: 'search',
2373
+ view: list(commentDataView, { orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] }),
2374
+ },
2375
+ events: list(eventDataView, { orderBy: [{ startAt: 'asc' }, { id: 'asc' }] }),
2376
+ posts: list(postDataView, { orderBy: { createdAt: 'desc', id: 'desc' } }),
2377
+ viewer: userDataView,
2378
+ };
2379
+ ```
2380
+
2381
+ 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.
2382
+
2383
+ 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.
2384
+
2385
+ For the above `Root` definitions, you can make the following requests using `useRequest`:
2386
+
2387
+ ```tsx
2388
+ const query = 'Apple';
2389
+
2390
+ const { posts, categories, viewer } = useRequest({
2391
+ // Explicit Root queries:
2392
+ categories: { list: categoryView },
2393
+ commentSearch: { args: { query }, list: commentView },
2394
+ events: { list: eventView },
2395
+ posts: { list: postView },
2396
+ viewer: { view: userView },
2397
+
2398
+ // Queries by id, if those entities have a `byId` query defined:
2399
+ post: { id: '12', view: postView },
2400
+ comment: { ids: ['6', '7'], view: commentView },
2401
+ });
2402
+ ```
2403
+
2404
+ ### Native fate protocol
2405
+
2406
+ 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.
2407
+
2408
+ ```tsx
2409
+ import { createFateServer, createHonoFateHandler } from '@nkzw/fate/server';
2410
+ import { createPrismaSourceAdapter } from '@nkzw/fate/server/prisma';
2411
+ import { Hono } from 'hono';
2412
+ import type { AppContext } from './context.ts';
2413
+ import { prisma } from './prisma.ts';
2414
+ import { Root, userDataView } from './views.ts';
2415
+
2416
+ export { Root } from './views.ts';
2417
+
2418
+ const sources = createPrismaSourceAdapter<AppContext>({
2419
+ prisma: (ctx) => ctx.prisma,
2420
+ views: Root,
2421
+ });
2422
+
2423
+ export const fate = createFateServer({
2424
+ context: async ({ adapterContext }) => ({
2425
+ prisma,
2426
+ request: adapterContext.req.raw,
2427
+ sessionUser: await getSessionUser(adapterContext.req.raw),
2428
+ }),
2429
+ queries: {
2430
+ viewer: {
2431
+ resolve: ({ ctx, select }) =>
2432
+ sources.resolveById({
2433
+ ctx,
2434
+ id: ctx.sessionUser.id,
2435
+ input: { select },
2436
+ view: userDataView,
2437
+ }),
2438
+ },
2439
+ },
2440
+ roots: Root,
2441
+ sources,
2442
+ });
2443
+
2444
+ const app = new Hono();
2445
+ const handler = createHonoFateHandler(fate);
2446
+
2447
+ app.post('/fate', handler);
2448
+ app.post('/fate/live', handler);
2449
+ ```
2450
+
2451
+ Configure the Vite plugin with the native transport:
2452
+
2453
+ ::: code-group
2454
+
2455
+ ```tsx [React]
2456
+ import { fate } from 'react-fate/vite';
2457
+ import { defineConfig } from 'vite';
2458
+
2459
+ export default defineConfig({
2460
+ plugins: [
2461
+ fate({
2462
+ module: '@your-org/server/fate.ts',
2463
+ transport: 'native',
2464
+ }),
2465
+ ],
2466
+ });
2467
+ ```
2468
+
2469
+ ```ts [Vue]
2470
+ import vue from '@vitejs/plugin-vue';
2471
+ import { fate } from 'vue-fate/vite';
2472
+ import { defineConfig } from 'vite';
2473
+
2474
+ export default defineConfig({
2475
+ plugins: [
2476
+ vue(),
2477
+ fate({
2478
+ module: '@your-org/server/fate.ts',
2479
+ transport: 'native',
2480
+ }),
2481
+ ],
2482
+ });
2483
+ ```
2484
+
2485
+ :::
2486
+
2487
+ With the native transport, the Vite plugin handles the HTTP transport setup. If you need to create a client manually, use `createFateClient` with the same route:
2488
+
2489
+ ::: code-group
2490
+
2491
+ ```tsx [React]
2492
+ import { createFateClient } from 'react-fate/client';
2493
+
2494
+ const client = createFateClient({
2495
+ url: '/fate',
2496
+ });
2497
+ ```
2498
+
2499
+ ```ts [Vue]
2500
+ import { createFateClient } from 'vue-fate/client';
2501
+
2502
+ const client = createFateClient({
2503
+ url: '/fate',
2504
+ });
2505
+ ```
2506
+
2507
+ :::
2508
+
2509
+ 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.
2510
+
2511
+ #### Custom Queries
2512
+
2513
+ Root query entries such as `viewer` need an explicit resolver because fate cannot infer application-specific behavior like "current user" from a data view:
2514
+
2515
+ ```tsx
2516
+ export const fate = createFateServer({
2517
+ context,
2518
+ queries: {
2519
+ viewer: {
2520
+ resolve: ({ ctx, select }) =>
2521
+ sources.resolveById({
2522
+ ctx,
2523
+ id: ctx.sessionUser.id,
2524
+ input: { select },
2525
+ view: userDataView,
2526
+ }),
2527
+ },
2528
+ },
2529
+ roots: Root,
2530
+ sources,
2531
+ });
2532
+ ```
2533
+
2534
+ #### Custom Mutations
2535
+
2536
+ Mutations declare the entity type they return and receive the selected fields requested by the client. Resolve the updated record through the source adapter so the response has the same masking and relation behavior as regular view requests:
2537
+
2538
+ ```tsx
2539
+ export const fate = createFateServer({
2540
+ mutations: {
2541
+ 'post.like': {
2542
+ input: likeInput,
2543
+ resolve: async ({ ctx, input, select }) => {
2544
+ await ctx.prisma.post.update({
2545
+ data: { likes: { increment: 1 } },
2546
+ where: { id: input.id },
2547
+ });
2548
+
2549
+ return sources.resolveById({
2550
+ ctx,
2551
+ id: input.id,
2552
+ input: { select },
2553
+ view: postDataView,
2554
+ });
2555
+ },
2556
+ type: 'Post',
2557
+ },
2558
+ },
2559
+ roots: Root,
2560
+ sources,
2561
+ });
2562
+ ```
2563
+
2564
+ #### Live Views
2565
+
2566
+ Pass a live event bus to enable `useLiveView` over the native SSE endpoint:
2567
+
2568
+ ```tsx
2569
+ import { createLiveEventBus } from '@nkzw/fate/server';
2570
+
2571
+ export const live = createLiveEventBus();
2572
+
2573
+ export const fate = createFateServer({
2574
+ live,
2575
+ queries: {
2576
+ viewer: {
2577
+ resolve: ({ ctx, select }) =>
2578
+ sources.resolveById({
2579
+ ctx,
2580
+ id: ctx.sessionUser.id,
2581
+ input: { select },
2582
+ view: userDataView,
2583
+ }),
2584
+ },
2585
+ },
2586
+ roots: Root,
2587
+ sources,
2588
+ });
2589
+
2590
+ live.update('Post', post.id, {
2591
+ changed: ['likes'],
2592
+ eventId: `post:${post.id}:${Date.now()}`,
2593
+ });
2594
+ ```
2595
+
2596
+ `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`.
2597
+
2598
+ 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 }`.
2599
+
2600
+ ### tRPC fate setup
2601
+
2602
+ 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.
2603
+
2604
+ 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.
2605
+
2606
+ #### Prisma
2607
+
2608
+ Use `createPrismaFate` from `@nkzw/fate/server/prisma` next to your tRPC helpers. By default, fate reads Prisma delegates from `ctx.prisma` using each data view's type name:
2609
+
2610
+ ```tsx
2611
+ import { initTRPC } from '@trpc/server';
2612
+ import { createPrismaFate } from '@nkzw/fate/server/prisma';
2613
+ import type { AppContext } from './context.ts';
2614
+ import { Root } from './views.ts';
2615
+
2616
+ const t = initTRPC.context<AppContext>().create();
2617
+
2618
+ export const router = t.router;
2619
+ export const procedure = t.procedure;
2620
+
2621
+ export const fate = createPrismaFate<AppContext, typeof procedure>({
2622
+ procedure,
2623
+ views: Root,
2624
+ });
2625
+ ```
2626
+
2627
+ If your Prisma client is not stored at `ctx.prisma`, pass `prisma: (ctx) => ctx.db`.
2628
+
2629
+ 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.
2630
+
2631
+ For custom Prisma queries and mutations, use `fate.createPlan` with `toPrismaSelect`:
2632
+
2633
+ ```tsx
2634
+ import { toPrismaSelect } from '@nkzw/fate/server';
2635
+
2636
+ const plan = fate.createPlan({
2637
+ ...input,
2638
+ ctx,
2639
+ view: postDataView,
2640
+ });
2641
+
2642
+ const post = await ctx.prisma.post.update({
2643
+ data: {
2644
+ likes: {
2645
+ increment: 1,
2646
+ },
2647
+ },
2648
+ select: toPrismaSelect(plan),
2649
+ where: { id: input.id },
2650
+ });
2651
+
2652
+ return plan.resolve(post);
2653
+ ```
2654
+
2655
+ #### Drizzle
2656
+
2657
+ Use `createDrizzleFate` from `@nkzw/fate/server/drizzle`. fate matches data view type names to Drizzle tables from your schema. The `db` option can be a Drizzle database object or a function that receives your tRPC context and returns a request-scoped database object:
2658
+
2659
+ ```tsx
2660
+ import { initTRPC } from '@trpc/server';
2661
+ import { createDrizzleFate } from '@nkzw/fate/server/drizzle';
2662
+ import db from '../drizzle/db.ts';
2663
+ import schema from '../drizzle/schema.ts';
2664
+ import type { AppContext } from './context.ts';
2665
+ import { Root } from './views.ts';
2666
+
2667
+ const t = initTRPC.context<AppContext>().create();
2668
+
2669
+ export const router = t.router;
2670
+ export const procedure = t.procedure;
2671
+
2672
+ export const fate = createDrizzleFate<AppContext, typeof procedure>({
2673
+ db,
2674
+ procedure,
2675
+ schema,
2676
+ views: Root,
2677
+ });
2678
+ ```
2679
+
2680
+ If your database lives on the request context, pass a function instead:
2681
+
2682
+ ```tsx
2683
+ import schema from '../drizzle/schema.ts';
2684
+ import { Root } from './views.ts';
2685
+
2686
+ export const fate = createDrizzleFate<AppContext, typeof procedure>({
2687
+ db: (ctx) => ctx.db,
2688
+ procedure,
2689
+ schema,
2690
+ views: Root,
2691
+ });
2692
+ ```
2693
+
2694
+ 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')`.
2695
+
2696
+ 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:
2697
+
2698
+ ```tsx
2699
+ export const fate = createDrizzleFate<AppContext, typeof procedure>({
2700
+ db,
2701
+ nestedPaginationConcurrency: 5,
2702
+ procedure,
2703
+ schema,
2704
+ views: Root,
2705
+ });
2706
+ ```
2707
+
2708
+ For request-specific sorting, prefer a custom root query that validates and translates explicit sort args.
2709
+
2710
+ 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:
2711
+
2712
+ ```tsx
2713
+ export const fate = createDrizzleFate<AppContext, typeof procedure>({
2714
+ db,
2715
+ procedure,
2716
+ schema,
2717
+ views: Root,
2718
+ });
2719
+ ```
2720
+
2721
+ You can still provide explicit join metadata when the schema is ambiguous:
2722
+
2723
+ ```tsx
2724
+ {
2725
+ manyToMany: {
2726
+ tags: {
2727
+ foreignColumn: postToTag.tagId,
2728
+ localColumn: postToTag.postId,
2729
+ table: postToTag,
2730
+ },
2731
+ },
2732
+ relations: {
2733
+ tags: {
2734
+ foreignKey: 'id',
2735
+ localKey: 'id',
2736
+ through: {
2737
+ foreignKey: 'tagId',
2738
+ localKey: 'postId',
2739
+ },
2740
+ },
2741
+ },
2742
+ table: post,
2743
+ view: postDataView,
2744
+ }
2745
+ ```
2746
+
2747
+ Drizzle writes should stay ordinary Drizzle code. After creating or updating a row, use `fate.resolveById` to return the selected shape that the client asked for:
2748
+
2749
+ ```tsx
2750
+ const postId = await createPostRecord({
2751
+ authorId: ctx.sessionUser.id,
2752
+ content: input.content,
2753
+ title: input.title,
2754
+ });
2755
+
2756
+ const post = await fate.resolveById({
2757
+ ctx,
2758
+ id: postId,
2759
+ input,
2760
+ view: postDataView,
2761
+ });
2762
+
2763
+ return post;
2764
+ ```
2765
+
2766
+ ### tRPC Procedures
2767
+
2768
+ Use `fate.procedures` to build the standard `byId` and `list` procedures expected by fate's request APIs:
2769
+
2770
+ ```tsx
2771
+ import { fate, router } from '../init.ts';
2772
+ import { postDataView } from '../views.ts';
2773
+
2774
+ export const postRouter = router({
2775
+ ...fate.procedures(postDataView),
2776
+ });
2777
+ ```
2778
+
2779
+ You can disable the generated `list` procedure if a view should only be fetched by id:
2780
+
2781
+ ```tsx
2782
+ export const commentRouter = router({
2783
+ ...fate.procedures({
2784
+ list: false,
2785
+ view: commentDataView,
2786
+ }),
2787
+ });
2788
+ ```
2789
+
2790
+ ### Custom Queries
2791
+
2792
+ You can add custom root queries next to generated procedures. Define the root in `Root`, implement a matching tRPC procedure, and call `fate.resolveConnection`:
2793
+
2794
+ ```tsx
2795
+ export const Root = {
2796
+ commentSearch: { procedure: 'search', view: list(commentDataView) },
2797
+ };
2798
+ ```
2799
+
2800
+ ```tsx
2801
+ import { ilike } from 'drizzle-orm';
2802
+ import { fate } from '../init.ts';
2803
+
2804
+ export const commentRouter = router({
2805
+ ...fate.procedures({
2806
+ list: false,
2807
+ view: commentDataView,
2808
+ }),
2809
+ search: fate.connection({
2810
+ input: z.object({
2811
+ query: z.string().min(1, 'Search query is required'),
2812
+ }),
2813
+ query: ({ ctx, cursor, direction, input, take }) =>
2814
+ fate.resolveConnection({
2815
+ ctx,
2816
+ cursor,
2817
+ direction,
2818
+ extra: {
2819
+ where: ilike(comment.content, `%${input.args.query}%`),
2820
+ },
2821
+ input,
2822
+ take,
2823
+ view: commentDataView,
2824
+ }),
2825
+ }),
2826
+ });
2827
+ ```
2828
+
2829
+ For Prisma, pass Prisma query options such as `{ where: { ... } }` in `extra` instead of a Drizzle SQL expression.
2830
+
2831
+ ### Data View Resolvers
2832
+
2833
+ fate data views support computed fields. Use `computed`, `field`, and `count` to describe the hidden data needed to resolve a public field:
2834
+
2835
+ ```tsx
2836
+ import { computed, count, field } from '@nkzw/fate/server';
2837
+
2838
+ export const userDataView = dataView<UserItem>('User')({
2839
+ email: computed<UserItem, string | null, AppContext>({
2840
+ authorize: ({ id }, context) => context?.sessionUser?.id === id,
2841
+ select: {
2842
+ email: field('email'),
2843
+ },
2844
+ resolve: (_item, deps) => (deps.email as string | null) ?? null,
2845
+ }),
2846
+ id: true,
2847
+ });
2848
+
2849
+ export const postDataView = dataView<PostItem>('Post')({
2850
+ commentCount: computed<PostItem, number>({
2851
+ select: {
2852
+ count: count('comments'),
2853
+ },
2854
+ resolve: (_item, deps) => (deps.count as number) ?? 0,
2855
+ }),
2856
+ id: true,
2857
+ });
2858
+ ```
2859
+
2860
+ The adapters fetch the hidden `field(...)` and `count(...)` dependencies for you. This keeps private fields like `email` available to the resolver without exposing them to the client selection.
2861
+
2862
+ ### Connecting the Client
2863
+
2864
+ Now that we have defined our client views and our server module, add fate's Vite plugin to the client app. The plugin reads your server exports and wires the typed fate APIs into your app.
2865
+
2866
+ For tRPC, make sure the `router.ts` file exports the `appRouter` object, `AppRouter` type and all the views we have defined:
2867
+
2868
+ ```tsx
2869
+ import { router } from './init.ts';
2870
+ import { postRouter } from './routers/post.ts';
2871
+ import { userRouter } from './routers/user.ts';
2872
+
2873
+ export const appRouter = router({
2874
+ post: postRouter,
2875
+ user: userRouter,
2876
+ });
2877
+
2878
+ export type AppRouter = typeof appRouter;
2879
+
2880
+ export * from './views.ts';
2881
+ ```
2882
+
2883
+ Configure the fate Vite plugin with your server module:
2884
+
2885
+ ::: code-group
2886
+
2887
+ ```tsx [React]
2888
+ import { fate } from 'react-fate/vite';
2889
+ import { defineConfig } from 'vite';
2890
+
2891
+ export default defineConfig({
2892
+ plugins: [
2893
+ fate({
2894
+ module: '@your-org/server/trpc/router.ts',
2895
+ }),
2896
+ ],
2897
+ });
2898
+ ```
2899
+
2900
+ ```ts [Vue]
2901
+ import vue from '@vitejs/plugin-vue';
2902
+ import { fate } from 'vue-fate/vite';
2903
+ import { defineConfig } from 'vite';
2904
+
2905
+ export default defineConfig({
2906
+ plugins: [
2907
+ vue(),
2908
+ fate({
2909
+ module: '@your-org/server/trpc/router.ts',
2910
+ }),
2911
+ ],
2912
+ });
2913
+ ```
2914
+
2915
+ :::
2916
+
2917
+ _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._
2918
+
2919
+ 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.
2920
+
2921
+ 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.
2922
+
2923
+ The plugin writes project-local types under `.fate/`. If your TypeScript config does not already include dot-directories, extend the generated config:
2924
+
2925
+ ```json
2926
+ {
2927
+ "extends": "./.fate/tsconfig.json"
2928
+ }
2929
+ ```
2930
+
2931
+ ### Creating a _fate_ Client
2932
+
2933
+ Now that the Vite plugin has connected the types, create a fate client instance and provide it to your app with the `FateClient` provider:
2934
+
2935
+ ::: code-group
2936
+
2937
+ ```tsx [React]
2938
+ import { httpBatchLink } from '@trpc/client';
2939
+ import { FateClient } from 'react-fate';
2940
+ import { createFateClient } from 'react-fate/client';
2941
+
2942
+ export function App() {
2943
+ const fate = useMemo(
2944
+ () =>
2945
+ createFateClient({
2946
+ links: [
2947
+ httpBatchLink({
2948
+ fetch: (input, init) =>
2949
+ fetch(input, {
2950
+ ...init,
2951
+ credentials: 'include',
2952
+ }),
2953
+ url: `${env('SERVER_URL')}/trpc`,
2954
+ }),
2955
+ ],
2956
+ }),
2957
+ [],
2958
+ );
2959
+ return <FateClient client={fate}>{/* Components go here */}</FateClient>;
2960
+ }
2961
+ ```
2962
+
2963
+ ```vue [Vue]
2964
+ <script setup lang="ts">
2965
+ import { httpBatchLink } from '@trpc/client';
2966
+ import { computed } from 'vue';
2967
+ import { FateClient } from 'vue-fate';
2968
+ import { createFateClient } from 'vue-fate/client';
2969
+ import AppRoutes from './AppRoutes.vue';
2970
+
2971
+ const fate = computed(() =>
2972
+ createFateClient({
2973
+ links: [
2974
+ httpBatchLink({
2975
+ fetch: (input, init) =>
2976
+ fetch(input, {
2977
+ ...init,
2978
+ credentials: 'include',
2979
+ }),
2980
+ url: `${env('SERVER_URL')}/trpc`,
2981
+ }),
2982
+ ],
2983
+ }),
2984
+ );
2985
+ </script>
2986
+
2987
+ <template>
2988
+ <FateClient :client="fate">
2989
+ <AppRoutes />
2990
+ </FateClient>
2991
+ </template>
2992
+ ```
2993
+
2994
+ :::
2995
+
2996
+ _And you are all set. Happy building!_
2997
+
2998
+ ## Void Integration
2999
+
3000
+ `void-fate` is the first-class [Void](https://void.cloud) adapter for fate to ease integration with the Void SDK and for deploying to the Void platform.
3001
+
3002
+ Use this integration when your app runs on Void and you want the example app's
3003
+ setup without copying its adapter glue.
3004
+
3005
+ ### New Project
3006
+
3007
+ 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.
3008
+
3009
+ ```sh
3010
+ vp create fate my-app --template void
3011
+ ```
3012
+
3013
+ Use Vue instead of React with:
3014
+
3015
+ ```sh
3016
+ vp create fate my-app --template void --framework vue
3017
+ ```
3018
+
3019
+ ### Existing Project
3020
+
3021
+ For an existing Void project, add the packages directly:
3022
+
3023
+ ::: code-group
3024
+
3025
+ ```sh [React]
3026
+ pnpm add @nkzw/fate react-fate void-fate void @void/react
3027
+ ```
3028
+
3029
+ ```sh [Vue]
3030
+ pnpm add @nkzw/fate vue-fate void-fate void @void/vue
3031
+ ```
3032
+
3033
+ :::
3034
+
3035
+ ### Vite
3036
+
3037
+ Use the framework adapter's Vite plugin with the Void transport:
3038
+
3039
+ ::: code-group
3040
+
3041
+ ```tsx [React]
3042
+ import { voidReact } from '@void/react/plugin';
3043
+ import { fate } from 'react-fate/vite';
3044
+ import { defineConfig } from 'vite-plus';
3045
+ import { voidPlugin } from 'void';
3046
+
3047
+ export default defineConfig({
3048
+ plugins: [
3049
+ voidPlugin(),
3050
+ voidReact(),
3051
+ fate({
3052
+ module: './src/fate/server.ts',
3053
+ transport: 'void',
3054
+ }),
3055
+ ],
3056
+ });
3057
+ ```
3058
+
3059
+ ```ts [Vue]
3060
+ import { voidVue } from '@void/vue/plugin';
3061
+ import { fate } from 'vue-fate/vite';
3062
+ import { defineConfig } from 'vite-plus';
3063
+ import { voidPlugin } from 'void';
3064
+
3065
+ export default defineConfig({
3066
+ plugins: [
3067
+ voidPlugin(),
3068
+ voidVue(),
3069
+ fate({
3070
+ module: './src/fate/server.ts',
3071
+ transport: 'void',
3072
+ }),
3073
+ ],
3074
+ });
3075
+ ```
3076
+
3077
+ :::
3078
+
3079
+ The Void transport uses `/fate` for RPC requests and `/fate-live` for live
3080
+ updates by default. In SSR, it calls the exported fate server directly. In the
3081
+ browser, it uses fetch and the SSE live endpoint.
3082
+
3083
+ ### Server Setup
3084
+
3085
+ Create a Void live adapter with `createVoidFateLive`, pass its `live` event bus
3086
+ to `createFateServer`, and export the adapter next to your fate server.
3087
+
3088
+ ```tsx
3089
+ import { createFateServer } from '@nkzw/fate/server';
3090
+ import { createDrizzleSourceAdapter } from '@nkzw/fate/server/drizzle';
3091
+ import { createVoidFateLive } from 'void-fate/server';
3092
+ import { db } from 'void/db';
3093
+ import schema from '../db/schema.ts';
3094
+ import { createContext } from './context.ts';
3095
+ import { Root } from './views.ts';
3096
+
3097
+ const sources = createDrizzleSourceAdapter({
3098
+ db,
3099
+ schema,
3100
+ views: Root,
3101
+ });
3102
+
3103
+ export const fateLive = createVoidFateLive();
3104
+ export const { live } = fateLive;
3105
+
3106
+ export const fateServer = createFateServer({
3107
+ context: ({ request }) => createContext({ request }),
3108
+ live,
3109
+ roots: Root,
3110
+ sources,
3111
+ });
3112
+ ```
3113
+
3114
+ Your app can publish live updates through the normal fate live bus:
3115
+
3116
+ ```tsx
3117
+ live.update('Post', postId, { changed: ['likes'] });
3118
+ live.connection('Post.comments', { id: postId }).appendNode('Comment', commentId, {
3119
+ node: comment,
3120
+ });
3121
+ ```
3122
+
3123
+ `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.
3124
+
3125
+ ### Routes
3126
+
3127
+ Add one route for fate RPC requests:
3128
+
3129
+ ```tsx
3130
+ // routes/fate.ts
3131
+ import { defineVoidFateRoute } from 'void-fate/server';
3132
+ import { fateLive, fateServer } from '../src/fate/server.ts';
3133
+
3134
+ export const { GET, POST } = defineVoidFateRoute(fateServer, fateLive);
3135
+ ```
3136
+
3137
+ Add a second route for the live SSE transport:
3138
+
3139
+ ```tsx
3140
+ // routes/fate-live.ts
3141
+ import { defineVoidFateLiveRoute } from 'void-fate/server';
3142
+ import { fateLive, fateServer } from '../src/fate/server.ts';
3143
+
3144
+ export const { GET, POST } = defineVoidFateLiveRoute(fateServer, fateLive);
3145
+ ```
3146
+
3147
+ The live route handles `GET /fate-live` SSE connections and `POST /fate-live`
3148
+ control messages. `void-fate` does not use WebSockets.
3149
+
3150
+ ### Layout
3151
+
3152
+ Wrap your app with the Void fate client for your framework. It creates and
3153
+ provides the fate client through the matching adapter.
3154
+
3155
+ ::: code-group
3156
+
3157
+ ```tsx [React]
3158
+ import { useShared } from '@void/react';
3159
+ import type { ReactNode } from 'react';
3160
+ import { VoidFateClient } from 'void-fate/react';
3161
+ import type { SharedData } from '../src/lib/shared.ts';
3162
+
3163
+ export default function Layout({ children }: { children: ReactNode }) {
3164
+ const shared = useShared<SharedData>();
3165
+ const userId = shared.auth.user?.id;
3166
+ const origin = typeof window === 'undefined' ? shared.origin : window.location.origin;
3167
+
3168
+ return (
3169
+ <VoidFateClient origin={origin} userId={userId}>
3170
+ {children}
3171
+ </VoidFateClient>
3172
+ );
3173
+ }
3174
+ ```
3175
+
3176
+ ```vue [Vue]
3177
+ <script setup lang="ts">
3178
+ import { useShared } from '@void/vue';
3179
+ import { computed } from 'vue';
3180
+ import { FateClient } from 'vue-fate';
3181
+ import { createFateClient } from 'vue-fate/client';
3182
+ import type { SharedData } from '../src/lib/shared.ts';
3183
+
3184
+ const shared = useShared<SharedData>();
3185
+
3186
+ const fate = computed(() =>
3187
+ createFateClient({
3188
+ origin: typeof window === 'undefined' ? shared.origin : window.location.origin,
3189
+ userId: shared.auth.user?.id,
3190
+ }),
3191
+ );
3192
+ </script>
3193
+
3194
+ <template>
3195
+ <FateClient :client="fate">
3196
+ <slot />
3197
+ </FateClient>
3198
+ </template>
3199
+ ```
3200
+
3201
+ :::
3202
+
3203
+ `userId` is optional, but passing it lets the client be recreated when the
3204
+ signed-in user changes. Browser requests include credentials when a `userId` is
3205
+ present.
3206
+
3207
+ ### Custom Paths
3208
+
3209
+ The default route pair is `/fate` and `/fate-live`. If your Void app uses
3210
+ different paths, configure the same values on the live adapter and client.
3211
+
3212
+ ```tsx
3213
+ export const fateLive = createVoidFateLive({
3214
+ livePath: '/custom-fate-live',
3215
+ });
3216
+ ```
3217
+
3218
+ ::: code-group
3219
+
3220
+ ```tsx [React]
3221
+ <VoidFateClient livePath="/custom-fate-live" origin={origin} rpcPath="/custom-fate" userId={userId}>
3222
+ {children}
3223
+ </VoidFateClient>
3224
+ ```
3225
+
3226
+ ```vue [Vue]
3227
+ <script setup lang="ts">
3228
+ const fate = computed(() =>
3229
+ createFateClient({
3230
+ livePath: '/custom-fate-live',
3231
+ origin,
3232
+ rpcPath: '/custom-fate',
3233
+ userId,
3234
+ }),
3235
+ );
3236
+ </script>
3237
+
3238
+ <template>
3239
+ <FateClient :client="fate">
3240
+ <slot />
3241
+ </FateClient>
3242
+ </template>
3243
+ ```
3244
+
3245
+ :::
3246
+
3247
+ The route helper does not own the route path. Make sure your Void route filename
3248
+ or router configuration matches the paths you pass to the client.
3249
+
3250
+ ### Live Transport
3251
+
3252
+ Void can run separate request handlers for mutations and long-lived SSE
3253
+ connections. `createVoidFateLive` bridges those handlers by publishing live
3254
+ events from the request that changed data to the live route.
3255
+
3256
+ In local development, `void-fate` uses a development token for that internal
3257
+ publish request. Outside local development, Void must provide `__VOID_PROXY_TOKEN`
3258
+ in the route environment. If no internal publish token is available, the adapter
3259
+ falls back to the in-memory live bus for the current request context.
3260
+
3261
+ The live transport is best-effort and does not replay missed events after a
3262
+ client reconnects. This matches fate's default in-memory live event bus.
3263
+
3264
+ ## Cloudflare Integration
3265
+
3266
+ `cf-fate` is the first-class Cloudflare Workers adapter for Fate native HTTP transport and live views.
3267
+
3268
+ Use it when your backend runs directly on Cloudflare Workers and you want fate live views without adopting the Void platform.
3269
+
3270
+ ### New Project
3271
+
3272
+ For a new Cloudflare Workers app, start from the Cloudflare template. It includes the client, Worker server, D1 migrations, Wrangler config, Durable Object live transport, auth wiring, and generated fate client setup.
3273
+
3274
+ ```sh
3275
+ vp create fate my-app --template cloudflare
3276
+ ```
3277
+
3278
+ Use Vue instead of React with:
3279
+
3280
+ ```sh
3281
+ vp create fate my-app --template cloudflare --framework vue
3282
+ ```
3283
+
3284
+ ### Existing Project
3285
+
3286
+ For an existing Cloudflare Workers project, add the packages directly:
3287
+
3288
+ ```sh
3289
+ pnpm add @nkzw/fate react-fate cf-fate drizzle-orm
3290
+ pnpm add -D wrangler
3291
+ ```
3292
+
3293
+ For Vue clients, replace `react-fate` with `vue-fate`.
3294
+
3295
+ ### Server Setup
3296
+
3297
+ Create a Cloudflare live stream and pass its Fate live facade to `createFateServer`.
3298
+
3299
+ ```ts
3300
+ // src/fate/live.ts
3301
+ import { defineCloudflareFateLiveStream } from 'cf-fate/server';
3302
+
3303
+ export const fateStream = defineCloudflareFateLiveStream({
3304
+ allowAnonymousControl: true,
3305
+ binding: 'FATE_LIVE',
3306
+ id: 'fate',
3307
+ });
3308
+ ```
3309
+
3310
+ ```ts
3311
+ // src/fate/server.ts
3312
+ import { createFateServer } from '@nkzw/fate/server';
3313
+ import { createCloudflareFateLive } from 'cf-fate/server';
3314
+
3315
+ export const fateLive = createCloudflareFateLive();
3316
+ export const { live } = fateLive;
3317
+
3318
+ export const fateServer = createFateServer({
3319
+ live,
3320
+ // context,
3321
+ // roots,
3322
+ // sources,
3323
+ });
3324
+ ```
3325
+
3326
+ Publish from mutations through the normal Fate live bus:
3327
+
3328
+ ```ts
3329
+ live.update('Post', postId, { changed: ['likes'] });
3330
+ live.connection('Post.comments', { id: postId }).appendNode('Comment', commentId);
3331
+ ```
3332
+
3333
+ ### Worker Routes
3334
+
3335
+ Expose one route for Fate RPC and one route for the SSE live stream.
3336
+
3337
+ ```ts
3338
+ import {
3339
+ createCloudflareFateLiveDurableObject,
3340
+ defineCloudflareFateLiveRoute,
3341
+ defineCloudflareFateRoute,
3342
+ } from 'cf-fate/server';
3343
+ import { fateStream } from './fate/live';
3344
+ import { fateLive, fateServer } from './fate/server';
3345
+
3346
+ const fateRoute = defineCloudflareFateRoute(fateServer, fateLive, { stream: fateStream });
3347
+ const fateLiveRoute = defineCloudflareFateLiveRoute(fateStream);
3348
+
3349
+ export const FateLiveDurableObject = createCloudflareFateLiveDurableObject({
3350
+ binding: 'FATE_LIVE',
3351
+ });
3352
+
3353
+ export default {
3354
+ fetch(request, env, ctx) {
3355
+ const url = new URL(request.url);
3356
+ if (url.pathname === '/fate') {
3357
+ return fateRoute.fetch(request, env, ctx);
3358
+ }
3359
+ if (url.pathname === '/fate-live') {
3360
+ return fateLiveRoute.fetch(request, env, ctx);
3361
+ }
3362
+ return new Response('Not Found', { status: 404 });
3363
+ },
3364
+ };
3365
+ ```
3366
+
3367
+ ### Wrangler
3368
+
3369
+ Add a Durable Object binding and migration. `cf-fate` uses `node:async_hooks`, so the Worker must enable Node compatibility.
3370
+
3371
+ ```jsonc
3372
+ {
3373
+ "compatibility_flags": ["nodejs_compat"],
3374
+ "durable_objects": {
3375
+ "bindings": [
3376
+ {
3377
+ "name": "FATE_LIVE",
3378
+ "class_name": "FateLiveDurableObject",
3379
+ },
3380
+ ],
3381
+ },
3382
+ "migrations": [
3383
+ {
3384
+ "tag": "fate-live-v1",
3385
+ "new_sqlite_classes": ["FateLiveDurableObject"],
3386
+ },
3387
+ ],
3388
+ }
3389
+ ```
3390
+
3391
+ ### Client
3392
+
3393
+ Use the Cloudflare transport in the Fate Vite plugin:
3394
+
3395
+ ```ts
3396
+ import { fate } from 'react-fate/vite';
3397
+
3398
+ fate({
3399
+ module: './src/fate/server.ts',
3400
+ transport: 'cloudflare',
3401
+ });
3402
+ ```
3403
+
3404
+ Then point the generated client at the Worker endpoints:
3405
+
3406
+ ```tsx
3407
+ import { FateClient } from 'react-fate';
3408
+ import { createFateClient } from 'react-fate/client';
3409
+
3410
+ const fate = createFateClient({
3411
+ liveUrl: 'http://localhost:8787/fate-live',
3412
+ url: 'http://localhost:8787/fate',
3413
+ });
3414
+
3415
+ export function App({ children }) {
3416
+ return <FateClient client={fate}>{children}</FateClient>;
3417
+ }
3418
+ ```
3419
+
3420
+ ### Semantics
3421
+
3422
+ `cf-fate` uses one browser `EventSource` per Fate client and multiplexes entity and connection topics over that stream. Durable Objects keep connection and topic subscription state so later requests, mutations, scheduled handlers, and queue consumers can publish to already-connected clients.
3423
+
3424
+ Delivery is at-most-once. Events are ordered within one topic, but events are not durably replayed after a disconnect. Use authoritative refetching or application-owned replay storage if missed events must be recovered.
3425
+
3426
+ ## Frequently Asked Questions
3427
+
3428
+ ### Is this serious software?
3429
+
3430
+ [In an alternate reality](https://github.com/phacility/javelin), _fate_ can be described like this:
3431
+
3432
+ **_fate_** is an ambitious React data library that tries to blend Relay-style ideas with type-safe data fetching, held together by equal parts vision and vibes. It aims to fix problems you definitely wouldn't have if you enjoy writing the same fetch logic in three different places with imperative loading state and error handling. fate promises predictable data flow, minimal APIs, and "no magic", though you may occasionally suspect otherwise.
3433
+
3434
+ **_fate_** is almost certainly worse than actual sync engines, but will hopefully be better than existing React data-fetching libraries eventually. Use it if you have a high tolerance for pain and want to help shape the future of data fetching in React.
3435
+
3436
+ ### Is _fate_ better than Relay?
3437
+
3438
+ Absolutely not.
3439
+
3440
+ ### Is _fate_ better than using GraphQL?
3441
+
3442
+ Probably. One day. _Maybe._
3443
+
3444
+ ### How was fate built?
3445
+
3446
+ > [!NOTE]
3447
+ > 80% of _fate_'s code was written by OpenAI's Codex – four versions per task, carefully curated by a human. The remaining 20% was written by [@cnakazawa](https://x.com/cnakazawa). _You get to decide which parts are the good ones!_ The docs were 100% written by a human.
3448
+ >
3449
+ > If you contribute to _fate_, we [require you to disclose your use of AI tools](https://github.com/nkzw-tech/fate/blob/main/CONTRIBUTING.md#ai-assistance-notice).
3450
+
3451
+ ## Future
3452
+
3453
+ **_fate_** is not complete yet. The current implementation of _fate_ ships with tRPC, Prisma, and Drizzle support, but the core ideas are not tied to a particular transport or database. We welcome contributions and ideas to improve fate. Here are some features we'd like to add:
3454
+
3455
+ - Live views for pagination
3456
+ - Additional backend adapters
3457
+ - Persistent storage for offline support
3458
+ - Better code generation and less type repetition
3459
+
3460
+ ## Acknowledgements
3461
+
3462
+ - [Relay](https://relay.dev/), [Isograph](https://isograph.dev/) & [GraphQL](https://graphql.org/) for inspiration
3463
+ - [Ricky Hanlon](https://x.com/rickyfm) for guidance on Async React
3464
+ - [Anthony Powell](https://x.com/Cephalization) for testing fate and providing feedback
3465
+
3466
+ **_fate_** was created by [@cnakazawa](https://x.com/cnakazawa) and is maintained by [Nakazawa Tech](https://nakazawa.tech/).