react-fate 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +810 -129
  2. package/docs/api/functions/FateClient.md +23 -0
  3. package/docs/api/functions/clientRoot.md +27 -0
  4. package/docs/api/functions/createClient.md +21 -0
  5. package/docs/api/functions/createHTTPTransport.md +51 -0
  6. package/docs/api/functions/createTRPCTransport.md +46 -0
  7. package/docs/api/functions/mutation.md +32 -0
  8. package/docs/api/functions/useFateClient.md +17 -0
  9. package/docs/api/functions/useListView.md +28 -0
  10. package/docs/api/functions/useLiveListView.md +28 -0
  11. package/docs/api/functions/useLiveView.md +38 -0
  12. package/docs/api/functions/useRequest.md +38 -0
  13. package/docs/api/functions/useView.md +37 -0
  14. package/docs/api/functions/view.md +26 -0
  15. package/docs/api/index.md +35 -0
  16. package/docs/api/type-aliases/ConnectionRef.md +13 -0
  17. package/docs/api/type-aliases/InferFateAPI.md +11 -0
  18. package/docs/api/type-aliases/ViewRef.md +13 -0
  19. package/docs/api/variables/toEntityId.md +21 -0
  20. package/docs/guide/actions.md +263 -0
  21. package/docs/guide/core-concepts.md +22 -0
  22. package/docs/guide/getting-started.md +57 -0
  23. package/docs/guide/list-views.md +86 -0
  24. package/docs/guide/live-views.md +245 -0
  25. package/docs/guide/requests.md +130 -0
  26. package/docs/guide/server-integration.md +630 -0
  27. package/docs/guide/views.md +278 -0
  28. package/docs/guide/void-integration.md +167 -0
  29. package/docs/index.md +4 -0
  30. package/lib/cli.d.mts +1 -1
  31. package/lib/cli.mjs +1 -2
  32. package/lib/client.d.mts +7 -0
  33. package/lib/client.mjs +2 -0
  34. package/lib/index.d.mts +41 -17
  35. package/lib/index.mjs +96 -46
  36. package/lib/vite.d.mts +12 -0
  37. package/lib/vite.mjs +8 -0
  38. package/package.json +17 -6
@@ -0,0 +1,263 @@
1
+ # Actions
2
+
3
+ fate does not provide hooks for mutations like traditional data fetching libraries do. Instead, mutations are exposed in two ways:
4
+
5
+ - `fate.actions` for use with [`useActionState`](https://react.dev/reference/react/useActionState) and React Actions.
6
+ - `fate.mutations` for traditional imperative mutation calls.
7
+
8
+ Mutations in your tRPC backend are made available as actions and mutations by fate's generated client.
9
+
10
+ Let's assume that our `Post` entity has a tRPC mutation for liking a post called `post.like`. A `LikeButton` component using fate Actions and an async component library could then look like this:
11
+
12
+ ```tsx
13
+ import { useActionState } from 'react';
14
+ import { useFateClient } from 'react-fate';
15
+
16
+ const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
17
+ const fate = useFateClient();
18
+ const [result, like] = useActionState(fate.actions.post.like, null);
19
+
20
+ return (
21
+ <Button action={() => like({ input: { id: post.id } })}>
22
+ {result?.error ? 'Oops!' : 'Like'}
23
+ </Button>
24
+ );
25
+ };
26
+ ```
27
+
28
+ If you are not using an async component library, you can use React's `useTransition` to start the action in a transition:
29
+
30
+ ```tsx
31
+ const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
32
+ const fate = useFateClient();
33
+ const [, startTransition] = useTransition();
34
+ const [result, like, isPending] = useActionState(fate.actions.post.like, null);
35
+
36
+ return (
37
+ <button
38
+ disabled={isPending}
39
+ onClick={() => {
40
+ startTransition(() =>
41
+ like({
42
+ input: { id: post.id },
43
+ }),
44
+ );
45
+ }}
46
+ >
47
+ {result?.error ? 'Oops!' : 'Like'}
48
+ </button>
49
+ );
50
+ };
51
+ ```
52
+
53
+ By using `useActionState`, fate Actions integrate with Suspense and concurrent rendering.
54
+
55
+ ## Optimistic Updates
56
+
57
+ 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:
58
+
59
+ ```tsx
60
+ like({
61
+ input: { id: post.id },
62
+ optimistic: { likes: post.likes + 1 },
63
+ });
64
+ ```
65
+
66
+ 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.
67
+
68
+ 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.
69
+
70
+ ## Inserting New Objects
71
+
72
+ 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:
73
+
74
+ ```tsx
75
+ const content = 'New Comment text';
76
+ addComment({
77
+ input: { content, postId: post.id },
78
+ optimistic: {
79
+ author: { id: user.id, name: user.name },
80
+ content,
81
+ id: `optimistic:${Date.now().toString(36)}`,
82
+ post: { commentCount: post.commentCount + 1, id: post.id },
83
+ },
84
+ });
85
+ ```
86
+
87
+ 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:
88
+
89
+ ```tsx
90
+ addComment({
91
+ input: { content, postId: post.id },
92
+ insert: 'before',
93
+ optimistic: {
94
+ content,
95
+ id: `optimistic:${Date.now().toString(36)}`,
96
+ post: { id: post.id },
97
+ },
98
+ });
99
+ ```
100
+
101
+ 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.
102
+
103
+ 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.
104
+
105
+ ## Selecting a View with Actions
106
+
107
+ 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:
108
+
109
+ ```tsx
110
+ addComment({
111
+ input: { content: 'New Comment text', postId: post.id },
112
+ view: view<Comment>()({
113
+ ...CommentView,
114
+ post: { commentCount: true },
115
+ }),
116
+ });
117
+ ```
118
+
119
+ 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:
120
+
121
+ ```tsx
122
+ const [result, addComment] = useActionState(fate.actions.comment.add, null);
123
+
124
+ const newComment = result?.result;
125
+ if (newComment) {
126
+ // All the fields selected in the view are available on `newComment`:
127
+ console.log(newComment.post.commentCount);
128
+ }
129
+ ```
130
+
131
+ ## Mutations
132
+
133
+ 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:
134
+
135
+ ```tsx
136
+ const result = await fate.mutations.comment.add({
137
+ input: { content, postId: post.id },
138
+ });
139
+ ```
140
+
141
+ 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.
142
+
143
+ ## Mutation Server Implementation
144
+
145
+ fate Actions & Mutations are backed by regular tRPC mutations on the server. Here is an example implementation of the `like` mutation in the `postRouter`.
146
+
147
+ ```tsx
148
+ import { z } from 'zod';
149
+ import { connectionArgs, createResolver } from '@nkzw/fate/server';
150
+ import { procedure, router } from '../init.ts';
151
+ import { postDataView, PostItem } from '../views.ts';
152
+
153
+ export const postRouter = router({
154
+ like: procedure
155
+ .input(
156
+ z.object({
157
+ args: connectionArgs,
158
+ id: z.string().min(1, 'Post id is required.'),
159
+ select: z.array(z.string()),
160
+ }),
161
+ )
162
+ .mutation(async ({ ctx, input }) => {
163
+ const { resolve, select } = createResolver({
164
+ ...input,
165
+ ctx,
166
+ view: postDataView,
167
+ });
168
+
169
+ return resolve(
170
+ await ctx.prisma.post.update({
171
+ data: {
172
+ likes: {
173
+ increment: 1,
174
+ },
175
+ },
176
+ select,
177
+ where: { id: input.id },
178
+ } as PostUpdateArgs),
179
+ );
180
+ }),
181
+ });
182
+ ```
183
+
184
+ See the [Server Integration](#server-integration) section for more details on how to integrate tRPC routers with fate.
185
+
186
+ ## Action & Mutation Error Handling
187
+
188
+ 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.
189
+
190
+ 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:
191
+
192
+ ```tsx
193
+ const [result] = useActionState(fate.actions.post.delete, null);
194
+
195
+ if (result?.error) {
196
+ if (result.error.code === 'NOT_FOUND') {
197
+ // Handle not found error at call site.
198
+ } else {
199
+ // Handle other *expected* errors.
200
+ }
201
+ }
202
+ ```
203
+
204
+ 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:
205
+
206
+ ```tsx
207
+ <ErrorBoundary FallbackComponent={ErrorComponent}>
208
+ <Suspense fallback={<div>Loading…</div>}>
209
+ <PostPage postId={postId} />
210
+ </Suspense>
211
+ </ErrorBoundary>
212
+ ```
213
+
214
+ 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).
215
+
216
+ ## Deleting Records
217
+
218
+ 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:
219
+
220
+ ```tsx
221
+ const [result, deleteAction] = useActionState(fate.actions.post.delete, null);
222
+
223
+ deleteAction({
224
+ input: { id: post.id },
225
+ delete: true,
226
+ });
227
+ ```
228
+
229
+ ## Resetting Action State
230
+
231
+ 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:
232
+
233
+ ```tsx
234
+ const [result, like] = useActionState(fate.actions.post.like, null);
235
+
236
+ useEffect(() => {
237
+ if (result?.error) {
238
+ // Reset the action state after 3 seconds.
239
+ const timeout = setTimeout(() => startTransition(() => like('reset')), 3000);
240
+ return () => clearTimeout(timeout);
241
+ }
242
+ }, [like, result]);
243
+ ```
244
+
245
+ ## Controlling List Insertion Behavior
246
+
247
+ 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:
248
+
249
+ ```tsx
250
+ addComment({
251
+ input: { content: 'New Comment text', postId: post.id },
252
+ insert: 'before', // Insert the new comment at the beginning of the list.
253
+ });
254
+ ```
255
+
256
+ Or, use the `none` option if you want to ignore inserting the new object into any lists:
257
+
258
+ ```tsx
259
+ addComment({
260
+ input: { content: 'New Comment text', postId: post.id },
261
+ insert: 'none', // Do not insert the new comment into any lists.
262
+ });
263
+ ```
@@ -0,0 +1,22 @@
1
+ # Core Concepts
2
+
3
+ **_fate_** has a minimal API surface and is aimed at reducing data fetching complexity.
4
+
5
+ ## Thinking in Views
6
+
7
+ 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.
8
+
9
+ 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:
10
+
11
+ <p align="center">
12
+ <picture class="fate-tree">
13
+ <source media="(prefers-color-scheme: dark)" srcset="/public/fate-tree-dark.svg">
14
+ <source media="(prefers-color-scheme: light)" srcset="/public/fate-tree.svg">
15
+ <img alt="Tree" src="/public/fate-tree.svg" width="90%">
16
+ </picture>
17
+ </p>
18
+
19
+ 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.
20
+
21
+ > [!NOTE]
22
+ > Views in _fate_ are what fragments are in GraphQL.
@@ -0,0 +1,57 @@
1
+ # Getting Started
2
+
3
+ ## Template
4
+
5
+ Create a new fate app with Vite+:
6
+
7
+ ```bash
8
+ vp create fate my-app
9
+ ```
10
+
11
+ The template selector can create a Void app with Drizzle, a tRPC app with Drizzle, or a tRPC app with Prisma. 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.
12
+
13
+ ## Manual Installation
14
+
15
+ **_fate_** requires React 19.2+. For a React client, install `react-fate`:
16
+
17
+ ::: code-group
18
+
19
+ ```bash [npm]
20
+ npm add react-fate
21
+ ```
22
+
23
+ ```bash [pnpm]
24
+ pnpm add react-fate
25
+ ```
26
+
27
+ ```bash [yarn]
28
+ yarn add react-fate
29
+ ```
30
+
31
+ :::
32
+
33
+ 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 React:
34
+
35
+ ::: code-group
36
+
37
+ ```bash [npm]
38
+ npm add @nkzw/fate
39
+ ```
40
+
41
+ ```bash [pnpm]
42
+ pnpm add @nkzw/fate
43
+ ```
44
+
45
+ ```bash [yarn]
46
+ yarn add @nkzw/fate
47
+ ```
48
+
49
+ :::
50
+
51
+ > [!WARNING]
52
+ >
53
+ > **_fate_** is currently in alpha and not production ready. If something doesn't work for you, please open a pull request.
54
+
55
+ If you'd like to try the example app in GitHub Codespaces, click the button below:
56
+
57
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?repo=nkzw-tech/fate)
@@ -0,0 +1,86 @@
1
+ # List Views
2
+
3
+ ## Pagination with `useListView`
4
+
5
+ You can wrap a list of references using `useListView` to enable connection-style lists with pagination support.
6
+
7
+ For example, you can define a `CommentView` and reuse it inside of a `CommentConnectionView`:
8
+
9
+ ```tsx
10
+ import { useListView, ViewRef } from 'react-fate';
11
+
12
+ const CommentView = view<Comment>()({
13
+ content: true,
14
+ id: true,
15
+ });
16
+
17
+ const CommentConnectionView = {
18
+ args: { first: 10 },
19
+ items: {
20
+ node: CommentView,
21
+ },
22
+ };
23
+
24
+ const PostView = view<Post>()({
25
+ comments: CommentConnectionView,
26
+ });
27
+ ```
28
+
29
+ Now you can apply the `useListView` hook inside of your `PostCard` component to read the list of comments and load more comments when needed:
30
+
31
+ ```tsx
32
+ export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
33
+ const post = useView(PostView, postRef);
34
+ const [comments, loadNext] = useListView(CommentConnectionView, post.comments);
35
+
36
+ return (
37
+ <div>
38
+ {comments.map(({ node }) => (
39
+ <CommentCard comment={node} key={node.id} post={post} />
40
+ ))}
41
+ {loadNext ? (
42
+ <Button onClick={loadNext} variant="ghost">
43
+ Load more comments
44
+ </Button>
45
+ ) : null}
46
+ </div>
47
+ );
48
+ }
49
+ ```
50
+
51
+ 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.
52
+
53
+ ## Pagination Arguments
54
+
55
+ Connection views can define default arguments, and `useListView` carries those arguments forward when loading more pages:
56
+
57
+ ```tsx
58
+ const CommentConnectionView = {
59
+ args: { first: 10 },
60
+ items: {
61
+ cursor: true,
62
+ node: CommentView,
63
+ },
64
+ pagination: {
65
+ hasNext: true,
66
+ hasPrevious: true,
67
+ nextCursor: true,
68
+ previousCursor: true,
69
+ },
70
+ };
71
+ ```
72
+
73
+ 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.
74
+
75
+ Additional arguments on a root request are scoped to that root list:
76
+
77
+ ```tsx
78
+ const { posts } = useRequest({
79
+ posts: {
80
+ args: { categoryId: category.id, first: 20 },
81
+ list: PostConnectionView,
82
+ },
83
+ });
84
+ ```
85
+
86
+ 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.
@@ -0,0 +1,245 @@
1
+ # Live Views
2
+
3
+ `useLiveView` resolves a `ViewRef` just like `useView`, but also keeps the selected object up to date through the native live SSE transport.
4
+
5
+ ```tsx
6
+ import { useLiveView, ViewRef } from 'react-fate';
7
+
8
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
9
+ const post = useLiveView(PostView, postRef);
10
+
11
+ return (
12
+ <Card>
13
+ <h2>{post.title}</h2>
14
+ {/* Updates automatically! */}
15
+ <p>{post.likes} likes</p>
16
+ </Card>
17
+ );
18
+ };
19
+ ```
20
+
21
+ 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.
22
+
23
+ ## How Live Updates Work
24
+
25
+ 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.
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ 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.
32
+
33
+ ## Client Setup
34
+
35
+ Generate the client with the native transport and point it at your Fate endpoint:
36
+
37
+ ```tsx
38
+ import { FateClient } from 'react-fate';
39
+ import { createFateClient } from 'react-fate/client';
40
+
41
+ export function App() {
42
+ const fate = useMemo(
43
+ () =>
44
+ createFateClient({
45
+ fetch: (input, init) =>
46
+ fetch(input, {
47
+ ...init,
48
+ credentials: 'include',
49
+ }),
50
+ url: `${env('SERVER_URL')}/fate`,
51
+ }),
52
+ [],
53
+ );
54
+
55
+ return <FateClient client={fate}>{/* Components go here */}</FateClient>;
56
+ }
57
+ ```
58
+
59
+ > [!NOTE]
60
+ >
61
+ > Live views use `GET /fate/live` for the single SSE stream and `POST /fate/live` for subscribe/unsubscribe control messages.
62
+
63
+ ## Server Setup
64
+
65
+ 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.
66
+
67
+ Pass a live event bus to `createFateServer` and expose the native handler:
68
+
69
+ ```tsx
70
+ import { createFateServer, createHonoFateHandler, createLiveEventBus } from '@nkzw/fate/server';
71
+ import type { AppContext } from './context.ts';
72
+ import { sources } from './sources.ts';
73
+ import { Root } from './views.ts';
74
+
75
+ export const live = createLiveEventBus();
76
+
77
+ export const fate = createFateServer<AppContext>({
78
+ live,
79
+ roots: Root,
80
+ sources,
81
+ });
82
+
83
+ app.all('/fate/*', createHonoFateHandler(fate));
84
+ ```
85
+
86
+ Once this is in place, components can switch from `useView` to `useLiveView` without changing their view definitions or return types.
87
+
88
+ ## Live List Views
89
+
90
+ `useLiveListView` mirrors `useListView`, but subscribes to live connection events for the connection it receives:
91
+
92
+ ```tsx
93
+ import { useLiveListView, useLiveView, ViewRef } from 'react-fate';
94
+
95
+ export function PostCard({ post: postRef }: { post: ViewRef<'Post'> }) {
96
+ const post = useLiveView(PostView, postRef);
97
+ const [comments, loadNext] = useLiveListView(CommentConnectionView, post.comments);
98
+
99
+ return (
100
+ <>
101
+ {comments.map(({ node }) => (
102
+ <CommentCard comment={node} key={node.id} />
103
+ ))}
104
+ {loadNext ? <button onClick={loadNext}>Load more</button> : null}
105
+ </>
106
+ );
107
+ }
108
+ ```
109
+
110
+ 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.
111
+
112
+ 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:
113
+
114
+ ```tsx
115
+ const MessageConnectionView = {
116
+ args: { first: 30 },
117
+ items: {
118
+ node: MessageView,
119
+ },
120
+ live: {
121
+ append: 'visible',
122
+ },
123
+ };
124
+ ```
125
+
126
+ Emit connection events on the server when list membership changes:
127
+
128
+ ```tsx
129
+ live.connection('Post.comments', { id: postId }).prependNode('Comment', comment.id);
130
+ live.connection('Post.comments', { id: postId }).deleteEdge('Comment', comment.id);
131
+ ```
132
+
133
+ For root lists, use the generated root procedure name:
134
+
135
+ ```tsx
136
+ live.connection('posts', { categoryId }).prependNode('Post', post.id);
137
+ ```
138
+
139
+ If the changed list cannot be described precisely, invalidate the active connection and fate will refetch it:
140
+
141
+ ```tsx
142
+ live.connection('posts', { categoryId }).invalidate();
143
+ ```
144
+
145
+ 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.
146
+
147
+ ## Emitting Events
148
+
149
+ After a mutation changes an object, emit an update event for that object:
150
+
151
+ ```tsx
152
+ export const postRouter = router({
153
+ ...fate.procedures({
154
+ view: postDataView,
155
+ }),
156
+ like: procedure.input(likeInput).mutation(async ({ ctx, input }) => {
157
+ const post = await ctx.prisma.post.update({
158
+ data: {
159
+ likes: {
160
+ increment: 1,
161
+ },
162
+ },
163
+ where: { id: input.id },
164
+ });
165
+
166
+ live.update('Post', input.id);
167
+
168
+ return post;
169
+ }),
170
+ });
171
+ ```
172
+
173
+ This tells fate that the `Post` changed. Every active live view for that post refreshes using the selection it subscribed with.
174
+
175
+ If you know which fields changed, pass them with `changed` to reduce the amount of data sent to each subscriber:
176
+
177
+ ```tsx
178
+ live.update('Post', input.id, { changed: ['likes'] });
179
+ ```
180
+
181
+ With this version, a live view that selected `likes` refreshes only `likes`, while a live view that only selected unrelated fields is skipped entirely.
182
+
183
+ 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`:
184
+
185
+ ```tsx
186
+ export const commentRouter = router({
187
+ add: procedure.input(addCommentInput).mutation(async ({ ctx, input }) => {
188
+ const comment = await ctx.prisma.comment.create({
189
+ data: {
190
+ content: input.content,
191
+ postId: input.postId,
192
+ },
193
+ });
194
+
195
+ live.update('Post', input.postId, { changed: ['commentCount', 'comments'] });
196
+
197
+ return comment;
198
+ }),
199
+ });
200
+ ```
201
+
202
+ For deletions, emit a delete event for the deleted object if clients may be subscribed to it:
203
+
204
+ ```tsx
205
+ live.delete('Comment', input.id);
206
+ ```
207
+
208
+ If deleting the object also changes another object, emit an update for that object too:
209
+
210
+ ```tsx
211
+ live.update('Post', postId, { changed: ['commentCount', 'comments'] });
212
+ ```
213
+
214
+ 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:
215
+
216
+ ```tsx
217
+ live.update('Post', input.id, {
218
+ changed: ['likes'],
219
+ eventId: `post:${input.id}:${Date.now()}`,
220
+ });
221
+ ```
222
+
223
+ 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.
224
+
225
+ ## Error Handling
226
+
227
+ Live subscription errors are reported out of band. They do not replace the last cached data or throw through the component that called `useLiveView`.
228
+
229
+ Pass `onLiveError` when creating the client to send those failures to your logger or monitoring system:
230
+
231
+ ```tsx
232
+ const fate = createFateClient({
233
+ fetch: (input, init) =>
234
+ fetch(input, {
235
+ ...init,
236
+ credentials: 'include',
237
+ }),
238
+ onLiveError(error) {
239
+ captureException(error);
240
+ },
241
+ url: `${env('SERVER_URL')}/fate`,
242
+ });
243
+ ```
244
+
245
+ 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.