@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/LICENSE +21 -0
- package/README.md +3466 -0
- package/lib/index.d.mts +7 -0
- package/lib/index.mjs +137 -0
- package/package.json +33 -0
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
|
+
[](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/).
|