@eleven-am/golem-react 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,11 +1,247 @@
|
|
|
1
1
|
# @eleven-am/golem-react
|
|
2
2
|
|
|
3
|
-
React
|
|
3
|
+
Live React queries for golem servers. You write an ordinary GraphQL query; the hooks open the matching subscriptions behind it, and creates, updates, deletes and rows leaving a result patch the TanStack Query cache by themselves. Your components never write to the cache.
|
|
4
|
+
|
|
5
|
+
Built on `@eleven-am/golem-client` and `@tanstack/react-query`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
4
8
|
|
|
5
9
|
```sh
|
|
6
|
-
npm install @eleven-am/golem-react @eleven-am/golem-client @tanstack/react-query react
|
|
10
|
+
npm install @eleven-am/golem-react @eleven-am/golem-client @tanstack/react-query react gql.tada graphql graphql-ws
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies: `@eleven-am/golem-client` ^0.1.0, `@tanstack/react-query` ^5, `react` >=18, `gql.tada` ^1.8. The client in turn needs `graphql` ^16 and `graphql-ws` ^6 (and `@tanstack/query-core` ^5, which `@tanstack/react-query` brings).
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
The client lives on your application's `QueryClient`. It does not make a second one.
|
|
18
|
+
|
|
19
|
+
The manifest is `zz_golem_client.gen.ts`, written by `golem generate` beside your GraphQL schema on the server (go/v0.8.2 or later). Copy or import it into your front end, and regenerate it whenever the schema changes. Never edit it.
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
23
|
+
import { GolemClient } from '@eleven-am/golem-client'
|
|
24
|
+
import { GolemProvider } from '@eleven-am/golem-react'
|
|
25
|
+
import { golemClientManifest } from './zz_golem_client.gen'
|
|
26
|
+
|
|
27
|
+
export const queryClient = new QueryClient()
|
|
28
|
+
|
|
29
|
+
export const golem = new GolemClient({
|
|
30
|
+
queryClient,
|
|
31
|
+
url: 'https://api.example.com/graphql',
|
|
32
|
+
manifest: golemClientManifest,
|
|
33
|
+
credentials: () => ({
|
|
34
|
+
headers: { authorization: `Bearer ${session.token()}` },
|
|
35
|
+
connectionParams: { token: session.token() },
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function Root() {
|
|
40
|
+
return (
|
|
41
|
+
<QueryClientProvider client={queryClient}>
|
|
42
|
+
<GolemProvider client={golem}>
|
|
43
|
+
<App />
|
|
44
|
+
</GolemProvider>
|
|
45
|
+
</QueryClientProvider>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `credentials` is called again whenever a request or a connection is made, including after the server closes a socket at its credential deadline, so return the current token each time.
|
|
51
|
+
- `GolemProvider` throws `GolemConfigurationError` when the surrounding `QueryClientProvider` does not hold `golem.queryClient`. It attaches live sync with `useSyncExternalStore` (no effects) and renders its children once live sync is running, so no query is fetched before its subscriptions are open. It renders nothing on the server.
|
|
52
|
+
- A second `GolemClient` on a `QueryClient` that already has one throws `GolemConfigurationError`. Call `golem.dispose()` to release it.
|
|
53
|
+
|
|
54
|
+
## Hooks
|
|
55
|
+
|
|
56
|
+
Every hook takes a GraphQL document, as a string or as a gql.tada typed document (the result and variable types come from the document; a plain string gives `unknown` data). The examples use the social example schema.
|
|
57
|
+
|
|
58
|
+
### useGolemQuery
|
|
59
|
+
|
|
60
|
+
`useQuery` over `client.queryOptions`. The query is live: its streams are open while the component is mounted.
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
const POSTS = `
|
|
64
|
+
query PublishedPosts {
|
|
65
|
+
posts(where: { published: { equals: true } }) { id title }
|
|
66
|
+
}
|
|
67
|
+
`
|
|
68
|
+
|
|
69
|
+
function Posts() {
|
|
70
|
+
const posts = useGolemQuery(POSTS)
|
|
71
|
+
const rows = (posts.data as { posts: Array<{ id: string; title: string }> } | undefined)?.posts ?? []
|
|
72
|
+
return <ul>{rows.map((post) => <li key={post.id}>{post.title}</li>)}</ul>
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It throws `GolemNotLiveError` (naming the reason and the root) for a document that cannot be live. Use `useGolemStaticQuery` for those.
|
|
77
|
+
|
|
78
|
+
### useGolemStatus
|
|
79
|
+
|
|
80
|
+
The live status of a query, or `undefined` before the query exists. It takes the same document and variables as the query; pass `{ infinite: true }` as the third argument for an infinite query. It is per query, not global.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const status = useGolemStatus(POSTS)
|
|
84
|
+
return <small>{status?.state}</small>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### useGolemMutation
|
|
88
|
+
|
|
89
|
+
`useMutation` over `client.mutate`. A committed mutation invalidates every cached query over the models it touches, so the live event or the refetch brings it up to date. The mutation result is never written into the cache. A document holds exactly one operation.
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
const RETITLE = `
|
|
93
|
+
mutation Retitle($id: UUID!, $title: String!) {
|
|
94
|
+
updatePost(where: { ID: $id }, data: { title: { set: $title } }) { id title }
|
|
95
|
+
}
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
const retitle = useGolemMutation(RETITLE)
|
|
99
|
+
retitle.mutate({ id, title: 'New title' })
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### useGolemInfiniteQuery
|
|
103
|
+
|
|
104
|
+
`useInfiniteQuery` over a list query with a `take`. Pages stay live. Give `take` a variable with a default, or a literal; never write `cursor` or `skip`, the client adds them from the manifest. `hasNextPage` is true while the last page is full.
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
const FEED = `
|
|
108
|
+
query Feed($take: Int = 20) {
|
|
109
|
+
posts(take: $take, orderBy: [{ createdAt: desc }]) { id title }
|
|
110
|
+
}
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
const feed = useGolemInfiniteQuery(FEED)
|
|
114
|
+
const pages = feed.data?.pages ?? []
|
|
115
|
+
// feed.hasNextPage, feed.fetchNextPage()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### useGolemStaticQuery
|
|
119
|
+
|
|
120
|
+
`useQuery` for a document that cannot be live: aggregates, group-by, counts and update-many roots. Fetched once, never patched.
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
const COUNT = `
|
|
124
|
+
query PublishedCount {
|
|
125
|
+
aggregatePosts(where: { published: { equals: true } }) { count }
|
|
126
|
+
}
|
|
127
|
+
`
|
|
128
|
+
|
|
129
|
+
const total = useGolemStaticQuery(COUNT)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Live status
|
|
133
|
+
|
|
134
|
+
`useGolemStatus` reads the state of one query:
|
|
135
|
+
|
|
136
|
+
| State | Meaning |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `CONNECTING` | Subscriptions are being opened; the first fetch waits for their acks. |
|
|
139
|
+
| `LIVE` | Every stream is acknowledged; the cache follows the server. |
|
|
140
|
+
| `RECOVERING` | A stream or the socket was lost; it is reopening and the query will refetch once after the acks. |
|
|
141
|
+
| `LIMIT` | The connection's subscription cap was reached. The data is still fetched, but not live until a slot frees. |
|
|
142
|
+
| `FORBIDDEN` | The server stopped a stream because the principal lost access. The query refetches once and does not resubscribe. |
|
|
143
|
+
| `NOT_FOUND` | A row held in a single result slot left the result; the query refetches. |
|
|
144
|
+
| `FAILED` | A stream failed for good (`error` holds the cause). |
|
|
145
|
+
|
|
146
|
+
TanStack's own `isFetching`, `isError` and `data` keep their usual meaning.
|
|
147
|
+
|
|
148
|
+
## How live queries work
|
|
149
|
+
|
|
150
|
+
#### Connections
|
|
151
|
+
|
|
152
|
+
A `GolemClient` opens one WebSocket (graphql-ws), and only when the first stream needs it. The socket is lazy and reconnects forever, with backoff, except when the server refuses the handshake for good (protocol or contract mismatch, a missing deadline). Queries, refetches and mutations are plain HTTP `fetch` requests.
|
|
153
|
+
|
|
154
|
+
#### Subscriptions
|
|
155
|
+
|
|
156
|
+
The client reads your query document and opens one subscription per (model, filter) pair in it.
|
|
157
|
+
|
|
158
|
+
- A literal or variable `where` gives an `exact`-scoped stream: the server sends only events for rows that match.
|
|
159
|
+
- No `where` gives a `policy-only` stream: the server sends every event the caller's policy allows for that model.
|
|
160
|
+
- A nested relation is a pair of its own. When the parent has a `where` and the manifest knows the relation's inverse, the child stream is scoped by that parent filter. Otherwise it is `policy-only` unless the relation has its own `where`.
|
|
161
|
+
- Two selections with the same model and filter share one stream. Different filters do not. (Two selections that select the same response key differently get separate streams.)
|
|
162
|
+
- Components that run the same query with the same variables share one TanStack query, and so one set of streams.
|
|
163
|
+
|
|
164
|
+
| Document | Streams |
|
|
165
|
+
|---|---|
|
|
166
|
+
| `posts(where: { published: { equals: true } }) { id title }` | 1 (`Post`, exact) |
|
|
167
|
+
| `posts { id comments { id } }` | 2 (`Post` and `Comment`, both policy-only) |
|
|
168
|
+
| Two lists, both `posts(where: { published: { equals: true } })` | 1 |
|
|
169
|
+
| `posts(where: { published: { equals: true } })` and `posts(where: { published: { equals: false } })` | 2 |
|
|
170
|
+
|
|
171
|
+
A model without events (no `golem.Subscriptions`) makes the document not live: `useGolemQuery` throws `GolemNotLiveError`.
|
|
172
|
+
|
|
173
|
+
#### Filtering is automatic
|
|
174
|
+
|
|
175
|
+
You never filter events in the client. The server judges each event against the caller's policy and the stream's filter, and sends one of four event types: `CREATED` and `UPDATED` for a row that matches, `EXITED` for a row that stops matching or that the caller may no longer read, and `DELETED` for a removed row. A row that starts to match arrives as `CREATED` or `UPDATED`. See [Rows that leave a list](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#rows-that-leave-a-list).
|
|
176
|
+
|
|
177
|
+
#### Patch or refetch
|
|
178
|
+
|
|
179
|
+
- `UPDATED` is patched into the cache in place only when the stream's selection is plain: a top-level list or single-row root, arguments limited to `where` (or the unique selector of a single-row root), no `orderBy`, `distinct`, `take`, `skip` or cursor, and scalar fields only. Every other `UPDATED` refetches the query.
|
|
180
|
+
- `CREATED` refetches.
|
|
181
|
+
- `DELETED` and `EXITED` remove the held row from the cache at once. The query then refetches, because a removal changes a list or empties a slot.
|
|
182
|
+
- A response that carries an older row version than the cache already holds is discarded, and the query refetches.
|
|
183
|
+
- A mutation result is never written into the cache. A committed mutation invalidates the cached queries over the models it touches (TanStack marks them stale and refetches the ones in use).
|
|
184
|
+
- Refetches that arrive close together are coalesced into one.
|
|
185
|
+
|
|
186
|
+
#### Row versions
|
|
187
|
+
|
|
188
|
+
The client adds `_version` and `__typename` to every selection of a model with events, and the primary key fields (`id` for most models) when you left them out. It compares `_version` to decide which copy of a row is newer. You can select them yourself, but not alias another field to those names. See [Row versions](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#row-versions).
|
|
189
|
+
|
|
190
|
+
#### Status is per query
|
|
191
|
+
|
|
192
|
+
`useGolemStatus` reports one query, keyed by its document and variables. It is not a global connection state. For that, read the transport:
|
|
193
|
+
|
|
194
|
+
- `golem.transport.status`: `idle`, `connecting`, `open`, `reconnecting`, `unauthenticated`, `forbidden`, `invalid`, `protocol-mismatch`, `contract-mismatch` or `deadline-required`.
|
|
195
|
+
- `golem.transport.onStatus(listener)`: calls you on every change and returns an unsubscribe function.
|
|
196
|
+
|
|
197
|
+
Wrap them in `useSyncExternalStore`:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import { useSyncExternalStore } from 'react'
|
|
201
|
+
|
|
202
|
+
function useConnection() {
|
|
203
|
+
return useSyncExternalStore(
|
|
204
|
+
(notify) => golem.transport.onStatus(notify),
|
|
205
|
+
() => golem.transport.status,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
7
208
|
```
|
|
8
209
|
|
|
9
|
-
|
|
210
|
+
#### Why there is a manifest
|
|
211
|
+
|
|
212
|
+
GraphQL does not say which subscription root carries a model's events, what its primary key is, whether a root returns rows or a count, whether a single-row root can be `null`, which unique input a cursor uses, or which contract the server was built from. The manifest carries those facts. It has one owner, the server's generator (`golem generate` writes `zz_golem_client.gen.ts`), and `golem check` fails when it falls behind the schema. The client reads it and never guesses. See [The client manifest](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-client-manifest).
|
|
213
|
+
|
|
214
|
+
#### Server concepts
|
|
215
|
+
|
|
216
|
+
- [Subscriptions and events](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#subscriptions-and-events)
|
|
217
|
+
- [Row versions](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#row-versions)
|
|
218
|
+
- [Rows that leave a list](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#rows-that-leave-a-list)
|
|
219
|
+
- [The live handshake and the subscription ack](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-live-handshake-and-the-subscription-ack)
|
|
220
|
+
- [The socket deadline](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-socket-deadline)
|
|
221
|
+
|
|
222
|
+
For a walkthrough from a model to a live component, see [Using golem from React](https://github.com/eleven-am/golem/blob/main/docs/golem-go/REACT.md).
|
|
223
|
+
|
|
224
|
+
## Errors
|
|
225
|
+
|
|
226
|
+
All server errors are `GolemError` (`code`, `path`, `extensions`, `retryable`) or a subclass:
|
|
227
|
+
|
|
228
|
+
- `GolemProtocolMismatchError`, `GolemContractMismatchError`: the client and server disagree on the live protocol or the contract. Regenerate the manifest.
|
|
229
|
+
- `GolemDeadlineRequiredError` (`GOLEM_PRINCIPAL_DEADLINE_REQUIRED`): the server refused a live socket because the principal has no deadline. This is a server fix.
|
|
230
|
+
- `GolemUnauthenticatedError`, `GolemHttpError`.
|
|
231
|
+
- `GolemPartialCommitError`, `GolemCommitUnknownError`: a mutation whose commit state is partial or unknown. Affected live queries are invalidated for you, as they are after every committed mutation.
|
|
232
|
+
|
|
233
|
+
Client-side failures: `GolemConfigurationError` (bad setup), `GolemDocumentError` (a document the client cannot run, for example one that sets `cursor` or `skip` itself) and its subclass `GolemMultipleOperationsError` (a document with more than one operation), `GolemNotLiveError`, `GolemNotFoundError`, `GolemProtocolError`.
|
|
234
|
+
|
|
235
|
+
## Requirements
|
|
236
|
+
|
|
237
|
+
- golem **go/v0.8.2 or later** on the server, with `golem.Subscriptions` on every model a live query reaches.
|
|
238
|
+
- `react` >=18, `@tanstack/react-query` 5.
|
|
239
|
+
- A socket deadline for every principal, and a manifest regenerated after every schema change. See [The socket deadline](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-socket-deadline) and [The client manifest](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-client-manifest).
|
|
240
|
+
|
|
241
|
+
## Links
|
|
242
|
+
|
|
243
|
+
- [Using golem from React](https://github.com/eleven-am/golem/blob/main/docs/golem-go/REACT.md): a walkthrough from a model to a live component.
|
|
244
|
+
- [golem Go guide](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md): models, policies, events, the manifest and the socket deadline.
|
|
245
|
+
- [Client workspace overview](https://github.com/eleven-am/golem/blob/main/client/README.md)
|
|
10
246
|
|
|
11
247
|
License: GPL-3.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eleven-am/golem-react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "React bindings for the golem live GraphQL client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "tsc --noEmit -p tsconfig.spec.json && vitest run"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@eleven-am/golem-client": "^0.1.
|
|
23
|
+
"@eleven-am/golem-client": "^0.1.1",
|
|
24
24
|
"@tanstack/react-query": "^5.0.0",
|
|
25
25
|
"gql.tada": "^1.8.0",
|
|
26
26
|
"react": ">=18"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"typescript": "^5.7.0",
|
|
42
42
|
"vitest": "^3.0.0"
|
|
43
43
|
},
|
|
44
|
+
"keywords": ["golem", "react", "hooks", "graphql", "live-query", "realtime", "subscriptions", "tanstack-query"],
|
|
44
45
|
"license": "GPL-3.0",
|
|
45
46
|
"author": "Roy OSSAI",
|
|
46
47
|
"engines": {
|