@eleven-am/golem-client 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -2
- package/dist/analysis.d.ts +1 -0
- package/dist/analysis.js +23 -9
- package/dist/client.d.ts +2 -2
- package/dist/client.js +2 -2
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +11 -0
- package/dist/live.js +11 -0
- package/dist/mutations.d.ts +5 -1
- package/dist/mutations.js +6 -2
- package/dist/transport.d.ts +1 -0
- package/dist/transport.js +14 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,11 +1,220 @@
|
|
|
1
1
|
# @eleven-am/golem-client
|
|
2
2
|
|
|
3
|
-
Live GraphQL
|
|
3
|
+
Live GraphQL for golem servers, without a UI framework: `GolemClient`, the live protocol, the HTTP and WebSocket transport, the TanStack Query cache patcher and typed errors. Works with TanStack Query core, so any framework adapter can sit on top. For React use [`@eleven-am/golem-react`](https://www.npmjs.com/package/@eleven-am/golem-react), which wraps this package in hooks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
4
6
|
|
|
5
7
|
```sh
|
|
6
8
|
npm install @eleven-am/golem-client @tanstack/query-core graphql graphql-ws
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Peer dependencies: `@tanstack/query-core` ^5, `graphql` ^16, `graphql-ws` ^6.
|
|
12
|
+
|
|
13
|
+
## GolemClient
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { QueryClient } from '@tanstack/query-core'
|
|
17
|
+
import { GolemClient } from '@eleven-am/golem-client'
|
|
18
|
+
import { golemClientManifest } from './zz_golem_client.gen'
|
|
19
|
+
|
|
20
|
+
export const queryClient = new QueryClient()
|
|
21
|
+
|
|
22
|
+
export const golem = new GolemClient({
|
|
23
|
+
queryClient,
|
|
24
|
+
url: 'https://api.example.com/graphql',
|
|
25
|
+
manifest: golemClientManifest,
|
|
26
|
+
credentials: () => ({
|
|
27
|
+
headers: { authorization: `Bearer ${session.token()}` },
|
|
28
|
+
connectionParams: { token: session.token() },
|
|
29
|
+
}),
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Option | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `queryClient` | Required. Your application's TanStack `QueryClient`. A second `GolemClient` on a `QueryClient` that already has one throws `GolemConfigurationError`. |
|
|
36
|
+
| `url` | The GraphQL endpoint. HTTP requests go to it; the WebSocket connects to the same URL. |
|
|
37
|
+
| `manifest` | The generated `zz_golem_client.gen.ts` of your golem server (go/v0.8.3 or later). |
|
|
38
|
+
| `credentials` | Optional function returning `{ headers, connectionParams }`. Called again for every request and every connection, including after the server closes a socket at its credential deadline, so return the current token each time. |
|
|
39
|
+
| `createSocket` | Optional `(url, protocols, credentials) => socket` factory, for a WebSocket implementation of your own (for example in Node). |
|
|
40
|
+
| `fetch` | Optional `fetch` implementation for HTTP requests. Defaults to `globalThis.fetch`. |
|
|
41
|
+
|
|
42
|
+
## Without React
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { QueryObserver } from '@tanstack/query-core'
|
|
46
|
+
|
|
47
|
+
const POSTS = `
|
|
48
|
+
query PublishedPosts {
|
|
49
|
+
posts(where: { published: { equals: true } }) { id title }
|
|
50
|
+
}
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
const stop = golem.live.start()
|
|
54
|
+
const observer = new QueryObserver(queryClient, golem.queryOptions(POSTS))
|
|
55
|
+
const unsubscribe = observer.subscribe((result) => render(result.data))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `golem.live.start()` starts live sync and returns a function that stops it. Start it before you observe a query, so no query is fetched before its subscriptions are open.
|
|
59
|
+
- `golem.queryOptions(document, variables?)` returns TanStack options for a live query. It throws `GolemNotLiveError` for a document that cannot be live.
|
|
60
|
+
- `golem.infiniteQueryOptions(document, variables?)` returns options for `InfiniteQueryObserver` over a list query with a `take`. Give `take` a variable with a default, or a literal; never write `cursor` or `skip`.
|
|
61
|
+
- `golem.staticQueryOptions(document, variables?)` returns options for a document fetched once and never refreshed, including one that cannot be live (a model without events, a custom root with no model rows). Counts, aggregates, group-by and custom roots with model rows are live through `golem.queryOptions`: any event on a model they come from refetches the document through the coalescer, and they are never patched.
|
|
62
|
+
- `golem.mutate(document, variables?)` runs a mutation over HTTP and returns its data. A document holds exactly one operation; one with several is refused with `GolemMultipleOperationsError`.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const data = await golem.mutate(
|
|
66
|
+
`mutation Retitle($id: UUID!, $title: String!) {
|
|
67
|
+
updatePost(where: { ID: $id }, data: { title: { set: $title } }) { id title }
|
|
68
|
+
}`,
|
|
69
|
+
{ id, title: 'New title' },
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Transport status and dispose
|
|
74
|
+
|
|
75
|
+
`golem.transport.status` is the state of the WebSocket: `idle`, `connecting`, `open`, `reconnecting`, `unauthenticated`, `forbidden`, `invalid`, `protocol-mismatch`, `contract-mismatch` or `deadline-required`. `golem.transport.onStatus(listener)` calls you on every change and returns an unsubscribe function.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const off = golem.transport.onStatus((status) => console.log(status))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The status of one query (below) is separate from this connection state.
|
|
82
|
+
|
|
83
|
+
`golem.dispose()` stops live sync, closes the transport and releases the `QueryClient` for another `GolemClient`.
|
|
84
|
+
|
|
85
|
+
## Live status
|
|
86
|
+
|
|
87
|
+
`golem.live.statusOf(query)` returns `{ state, error }` for a TanStack `Query`, or `undefined` before live sync tracks it. `golem.live.subscribeStatus(listener)` calls you on every change.
|
|
88
|
+
|
|
89
|
+
| State | Meaning |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `CONNECTING` | Subscriptions are being opened; the first fetch waits for their acks. |
|
|
92
|
+
| `LIVE` | Every stream is acknowledged; the cache follows the server. |
|
|
93
|
+
| `RECOVERING` | A stream or the socket was lost; it is reopening and the query will refetch once after the acks. |
|
|
94
|
+
| `LIMIT` | The connection's subscription cap was reached. The data is still fetched, but not live until a slot frees. |
|
|
95
|
+
| `FORBIDDEN` | The server stopped a stream because the principal lost access. The query refetches once and does not resubscribe. |
|
|
96
|
+
| `NOT_FOUND` | A row held in a single result slot left the result; the query refetches. |
|
|
97
|
+
| `FAILED` | A stream failed for good (`error` holds the cause). |
|
|
98
|
+
|
|
99
|
+
TanStack's own `isFetching`, `isError` and `data` keep their usual meaning.
|
|
100
|
+
|
|
101
|
+
## The manifest
|
|
102
|
+
|
|
103
|
+
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` beside your schema, and `golem check` fails when it falls behind. The client reads it and never guesses. Regenerate it after every schema change; never edit it. See [The client manifest](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-client-manifest).
|
|
104
|
+
|
|
105
|
+
## How live queries work
|
|
106
|
+
|
|
107
|
+
#### Connections
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
#### Subscriptions
|
|
112
|
+
|
|
113
|
+
The client reads your query document and opens one subscription per (model, filter) pair in it.
|
|
114
|
+
|
|
115
|
+
- A literal or variable `where` gives an `exact`-scoped stream: the server sends only events for rows that match.
|
|
116
|
+
- No `where` gives a `policy-only` stream: the server sends every event the caller's policy allows for that model.
|
|
117
|
+
- 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`.
|
|
118
|
+
- 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.)
|
|
119
|
+
- Components that run the same query with the same variables share one TanStack query, and so one set of streams.
|
|
120
|
+
|
|
121
|
+
| Document | Streams |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `posts(where: { published: { equals: true } }) { id title }` | 1 (`Post`, exact) |
|
|
124
|
+
| `posts { id comments { id } }` | 2 (`Post` and `Comment`, both policy-only) |
|
|
125
|
+
| Two lists, both `posts(where: { published: { equals: true } })` | 1 |
|
|
126
|
+
| `posts(where: { published: { equals: true } })` and `posts(where: { published: { equals: false } })` | 2 |
|
|
127
|
+
|
|
128
|
+
A model without events (no `golem.Subscriptions`) makes the document not live: `useGolemQuery` throws `GolemNotLiveError`.
|
|
129
|
+
|
|
130
|
+
#### Filtering is automatic
|
|
131
|
+
|
|
132
|
+
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).
|
|
133
|
+
|
|
134
|
+
#### Patch or refetch
|
|
135
|
+
|
|
136
|
+
- `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.
|
|
137
|
+
- `CREATED` refetches.
|
|
138
|
+
- `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.
|
|
139
|
+
- A response that carries an older row version than the cache already holds is discarded, and the query refetches.
|
|
140
|
+
- 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).
|
|
141
|
+
- Refetches that arrive close together are coalesced into one.
|
|
142
|
+
|
|
143
|
+
#### Row versions
|
|
144
|
+
|
|
145
|
+
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).
|
|
146
|
+
|
|
147
|
+
#### Status is per query
|
|
148
|
+
|
|
149
|
+
`golem.live.statusOf` reports one query, keyed by its document and variables. It is not a global connection state. For that, read the transport:
|
|
150
|
+
|
|
151
|
+
- `golem.transport.status`: `idle`, `connecting`, `open`, `reconnecting`, `unauthenticated`, `forbidden`, `invalid`, `protocol-mismatch`, `contract-mismatch` or `deadline-required`.
|
|
152
|
+
- `golem.transport.onStatus(listener)`: calls you on every change and returns an unsubscribe function.
|
|
153
|
+
|
|
154
|
+
Wrap them in your framework's external-store primitive. In React that is `useSyncExternalStore`:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { useSyncExternalStore } from 'react'
|
|
158
|
+
|
|
159
|
+
function useConnection() {
|
|
160
|
+
return useSyncExternalStore(
|
|
161
|
+
(notify) => golem.transport.onStatus(notify),
|
|
162
|
+
() => golem.transport.status,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### Why there is a manifest
|
|
168
|
+
|
|
169
|
+
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).
|
|
170
|
+
|
|
171
|
+
#### Server concepts
|
|
172
|
+
|
|
173
|
+
- [Subscriptions and events](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#subscriptions-and-events)
|
|
174
|
+
- [Row versions](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#row-versions)
|
|
175
|
+
- [Rows that leave a list](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#rows-that-leave-a-list)
|
|
176
|
+
- [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)
|
|
177
|
+
- [The socket deadline](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-socket-deadline)
|
|
178
|
+
|
|
179
|
+
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).
|
|
180
|
+
|
|
181
|
+
## Mutations on page unload
|
|
182
|
+
|
|
183
|
+
Browsers cancel ordinary fetches while a page unloads. To send a last mutation on `pagehide`, pass `keepalive`; only that request is sent with `fetch(..., { keepalive: true })`.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
addEventListener('pagehide', () => {
|
|
187
|
+
void golem.mutate(END_SESSION, { id: sessionId }, { keepalive: true }).catch(() => {})
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The third argument is either an `AbortSignal` or `{ signal?, keepalive? }`. The response may never be processed, so treat the mutation as possibly committed; the next page load refetches the authoritative state. Browsers cap keepalive request bodies at 64 KiB in total, so a larger body throws `GolemKeepaliveTooLargeError` before anything is sent.
|
|
192
|
+
|
|
193
|
+
## Errors
|
|
194
|
+
|
|
195
|
+
All server errors are `GolemError` (`code`, `path`, `extensions`, `retryable`) or a subclass:
|
|
196
|
+
|
|
197
|
+
- `GolemProtocolMismatchError`, `GolemContractMismatchError`: the client and server disagree on the live protocol or the contract. Regenerate the manifest.
|
|
198
|
+
- `GolemDeadlineRequiredError` (`GOLEM_PRINCIPAL_DEADLINE_REQUIRED`): the server refused a live socket because the principal has no deadline. This is a server fix.
|
|
199
|
+
- `GolemUnauthenticatedError`, `GolemHttpError`.
|
|
200
|
+
- `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.
|
|
201
|
+
|
|
202
|
+
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`.
|
|
203
|
+
|
|
204
|
+
## Requirements
|
|
205
|
+
|
|
206
|
+
- golem **go/v0.8.3 or later** on the server, with `golem.Subscriptions` on every model a live query reaches.
|
|
207
|
+
- `@tanstack/query-core` 5.
|
|
208
|
+
- A socket deadline for every principal. See [The socket deadline](https://github.com/eleven-am/golem/blob/main/docs/golem-go/GUIDE.md#the-socket-deadline).
|
|
209
|
+
|
|
210
|
+
## React
|
|
211
|
+
|
|
212
|
+
Use [`@eleven-am/golem-react`](https://www.npmjs.com/package/@eleven-am/golem-react) for hooks and a provider on top of this client.
|
|
213
|
+
|
|
214
|
+
## Links
|
|
215
|
+
|
|
216
|
+
- [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.
|
|
217
|
+
- [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.
|
|
218
|
+
- [Client workspace overview](https://github.com/eleven-am/golem/blob/main/client/README.md)
|
|
10
219
|
|
|
11
220
|
License: GPL-3.0.
|
package/dist/analysis.d.ts
CHANGED
package/dist/analysis.js
CHANGED
|
@@ -164,7 +164,7 @@ function visitField(field, models, scope, path, state, walk) {
|
|
|
164
164
|
return field;
|
|
165
165
|
}
|
|
166
166
|
state.nested = true;
|
|
167
|
-
return { ...field, selectionSet: rows(field.selectionSet, computed, null, false, childPath(path, field), walk) };
|
|
167
|
+
return { ...field, selectionSet: rows(field.selectionSet, computed, null, false, false, childPath(path, field), walk) };
|
|
168
168
|
}
|
|
169
169
|
fields.set(responseKey(field), field);
|
|
170
170
|
return field;
|
|
@@ -187,9 +187,9 @@ function visitRelation(field, owner, relation, scope, path, walk) {
|
|
|
187
187
|
inverseFilter = objectValue({ [relation.inverse]: objectValue({ [inverse.list ? 'some' : 'is']: scope }) });
|
|
188
188
|
}
|
|
189
189
|
const childScope = conjunction(filterScope(argument(field, 'where')?.value), inverseFilter);
|
|
190
|
-
return { ...field, selectionSet: rows(field.selectionSet, [relation.target], childScope, false, childPath(path, field), walk) };
|
|
190
|
+
return { ...field, selectionSet: rows(field.selectionSet, [relation.target], childScope, false, false, childPath(path, field), walk) };
|
|
191
191
|
}
|
|
192
|
-
function rows(set, models, scope, plain, path, walk) {
|
|
192
|
+
function rows(set, models, scope, plain, derived, path, walk) {
|
|
193
193
|
const order = walk.sink === null ? 0 : walk.sink.sequence++;
|
|
194
194
|
const state = { fields: new Map(models.map((model) => [model, new Map()])), nested: false };
|
|
195
195
|
let selections = visitSet(set, models, scope, path, state, walk);
|
|
@@ -200,7 +200,7 @@ function rows(set, models, scope, plain, path, walk) {
|
|
|
200
200
|
else {
|
|
201
201
|
selections = ensure(selections, [TYPENAME_FIELD]);
|
|
202
202
|
}
|
|
203
|
-
register(models, scope, plain && !state.nested, order, path, state, walk);
|
|
203
|
+
register(models, scope, plain && !state.nested, derived, order, path, state, walk);
|
|
204
204
|
return { ...set, selections };
|
|
205
205
|
}
|
|
206
206
|
function disagrees(draft, fields) {
|
|
@@ -212,7 +212,7 @@ function disagrees(draft, fields) {
|
|
|
212
212
|
}
|
|
213
213
|
return false;
|
|
214
214
|
}
|
|
215
|
-
function register(models, scope, patchable, order, path, state, walk) {
|
|
215
|
+
function register(models, scope, patchable, derived, order, path, state, walk) {
|
|
216
216
|
const sink = walk.sink;
|
|
217
217
|
if (sink === null) {
|
|
218
218
|
return;
|
|
@@ -235,11 +235,12 @@ function register(models, scope, patchable, order, path, state, walk) {
|
|
|
235
235
|
draft = sink.streams.get(key);
|
|
236
236
|
}
|
|
237
237
|
if (draft === undefined) {
|
|
238
|
-
draft = { key, model: name, eventsRoot: model.events, patchable, scope, order, fields: new Map(), paths: new Set() };
|
|
238
|
+
draft = { key, model: name, eventsRoot: model.events, patchable, derived, scope, order, fields: new Map(), paths: new Set() };
|
|
239
239
|
sink.streams.set(key, draft);
|
|
240
240
|
}
|
|
241
241
|
else {
|
|
242
242
|
draft.patchable &&= patchable;
|
|
243
|
+
draft.derived ||= derived;
|
|
243
244
|
}
|
|
244
245
|
draft.paths.add(path);
|
|
245
246
|
for (const [responseName, field] of fields) {
|
|
@@ -294,6 +295,7 @@ function finish(draft, manifest, operation) {
|
|
|
294
295
|
model: draft.model,
|
|
295
296
|
eventsRoot: draft.eventsRoot,
|
|
296
297
|
patchable: draft.patchable,
|
|
298
|
+
derived: draft.derived,
|
|
297
299
|
scope: draft.scope === null ? 'policy-only' : 'exact',
|
|
298
300
|
paths: [...draft.paths].sort(),
|
|
299
301
|
query: print(document),
|
|
@@ -337,6 +339,9 @@ function visitRoot(field, walk) {
|
|
|
337
339
|
if (modelName === undefined || model === undefined) {
|
|
338
340
|
throw new GolemDocumentError(`the manifest root ${name} names no model`);
|
|
339
341
|
}
|
|
342
|
+
if (root.shape === 'aggregate' || root.shape === 'groupBy') {
|
|
343
|
+
return derivedRoot(field, root, walk);
|
|
344
|
+
}
|
|
340
345
|
if (root.shape !== 'rows' && root.shape !== 'row' && root.shape !== 'deletedRow') {
|
|
341
346
|
record(walk.sink, new GolemNotLiveError('non-row-root', name, `${name} is not live: its result shape is ${root.shape}, not model rows`), `non-row-root|${name}`);
|
|
342
347
|
return field;
|
|
@@ -348,14 +353,23 @@ function visitRoot(field, walk) {
|
|
|
348
353
|
const scope = root.shape === 'rows' ? filterScope(where) : uniqueScope(model, where);
|
|
349
354
|
markNullable(field, root, walk);
|
|
350
355
|
const plain = (field.arguments ?? []).every((candidate) => candidate.name.value === 'where');
|
|
351
|
-
return { ...field, selectionSet: rows(field.selectionSet, [modelName], scope, plain, responseKey(field), walk) };
|
|
356
|
+
return { ...field, selectionSet: rows(field.selectionSet, [modelName], scope, plain, false, responseKey(field), walk) };
|
|
352
357
|
}
|
|
353
358
|
if (root.models.length === 0 || field.selectionSet === undefined) {
|
|
354
359
|
record(walk.sink, new GolemNotLiveError('opaque-root-without-models', name, `${name} is not live: the custom root's result holds no model rows`), `opaque-root-without-models|${name}`);
|
|
355
360
|
return field;
|
|
356
361
|
}
|
|
357
362
|
markNullable(field, root, walk);
|
|
358
|
-
return { ...field, selectionSet: rows(field.selectionSet, root.models, null, false, responseKey(field), walk) };
|
|
363
|
+
return { ...field, selectionSet: rows(field.selectionSet, root.models, null, false, true, responseKey(field), walk) };
|
|
364
|
+
}
|
|
365
|
+
function derivedRoot(field, root, walk) {
|
|
366
|
+
const order = walk.sink === null ? 0 : walk.sink.sequence++;
|
|
367
|
+
const scope = filterScope(argument(field, 'where')?.value);
|
|
368
|
+
root.models.forEach((model, index) => {
|
|
369
|
+
const state = { fields: new Map([[model, new Map()]]), nested: false };
|
|
370
|
+
register([model], index === 0 ? scope : null, false, true, order, responseKey(field), state, walk);
|
|
371
|
+
});
|
|
372
|
+
return field;
|
|
359
373
|
}
|
|
360
374
|
function markNullable(field, root, walk) {
|
|
361
375
|
if ((root.shape === 'row' || root.shape === 'deletedRow') && root.nullable && walk.sink !== null) {
|
|
@@ -370,7 +384,7 @@ function rewriteFragment(definition, walk) {
|
|
|
370
384
|
if (walk.manifest.models[condition] === undefined) {
|
|
371
385
|
return definition;
|
|
372
386
|
}
|
|
373
|
-
return { ...definition, selectionSet: rows(definition.selectionSet, [condition], null, false, '', walk) };
|
|
387
|
+
return { ...definition, selectionSet: rows(definition.selectionSet, [condition], null, false, false, '', walk) };
|
|
374
388
|
}
|
|
375
389
|
export function operationOf(document, kind) {
|
|
376
390
|
const operations = document.definitions.filter((definition) => definition.kind === Kind.OPERATION_DEFINITION);
|
package/dist/client.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { QueryClient, QueryFunctionContext } from '@tanstack/query-core';
|
|
|
3
3
|
import { DocumentAnalyzer } from './analysis.js';
|
|
4
4
|
import { LiveSync } from './live.js';
|
|
5
5
|
import type { ClientManifest } from './manifest.js';
|
|
6
|
-
import { Mutations } from './mutations.js';
|
|
6
|
+
import { Mutations, type MutateOptions } from './mutations.js';
|
|
7
7
|
import type { CredentialsGetter, SocketFactory } from './session.js';
|
|
8
8
|
import { Transport } from './transport.js';
|
|
9
9
|
export interface GolemClientOptions {
|
|
@@ -43,7 +43,7 @@ export declare class GolemClient {
|
|
|
43
43
|
queryKey: readonly ["golem", "static", string, Readonly<Record<string, unknown>>];
|
|
44
44
|
queryFn: (context: Pick<QueryFunctionContext, "signal">) => Promise<unknown>;
|
|
45
45
|
};
|
|
46
|
-
mutate<T = unknown>(document: DocumentNode | string, variables?: Readonly<Record<string, unknown>>,
|
|
46
|
+
mutate<T = unknown>(document: DocumentNode | string, variables?: Readonly<Record<string, unknown>>, options?: AbortSignal | MutateOptions): Promise<T>;
|
|
47
47
|
attach(): () => void;
|
|
48
48
|
dispose(): void;
|
|
49
49
|
}
|
package/dist/client.js
CHANGED
|
@@ -45,8 +45,8 @@ export class GolemClient {
|
|
|
45
45
|
queryFn: (context) => this.transport.execute({ query: analysis.query, variables, signal: context.signal }),
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
mutate(document, variables,
|
|
49
|
-
return this.mutations.execute(document, variables,
|
|
48
|
+
mutate(document, variables, options) {
|
|
49
|
+
return this.mutations.execute(document, variables, options);
|
|
50
50
|
}
|
|
51
51
|
attach() {
|
|
52
52
|
this.holds++;
|
package/dist/errors.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export declare class GolemProtocolError extends Error {
|
|
|
51
51
|
export declare class GolemConfigurationError extends Error {
|
|
52
52
|
constructor(message: string);
|
|
53
53
|
}
|
|
54
|
+
export declare const KEEPALIVE_BODY_LIMIT = 65536;
|
|
55
|
+
export declare class GolemKeepaliveTooLargeError extends Error {
|
|
56
|
+
readonly bytes: number;
|
|
57
|
+
readonly limit: number;
|
|
58
|
+
constructor(bytes: number);
|
|
59
|
+
}
|
|
54
60
|
export type NotLiveReason = 'model-without-events' | 'opaque-root-without-models' | 'hidden-inverse' | 'non-row-root';
|
|
55
61
|
export declare class GolemNotLiveError extends Error {
|
|
56
62
|
readonly reason: NotLiveReason;
|
package/dist/errors.js
CHANGED
|
@@ -69,6 +69,17 @@ export class GolemConfigurationError extends Error {
|
|
|
69
69
|
this.name = 'GolemConfigurationError';
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
export const KEEPALIVE_BODY_LIMIT = 65536;
|
|
73
|
+
export class GolemKeepaliveTooLargeError extends Error {
|
|
74
|
+
bytes;
|
|
75
|
+
limit;
|
|
76
|
+
constructor(bytes) {
|
|
77
|
+
super(`a keepalive request body is ${bytes} bytes; browsers allow at most ${KEEPALIVE_BODY_LIMIT}`);
|
|
78
|
+
this.name = 'GolemKeepaliveTooLargeError';
|
|
79
|
+
this.bytes = bytes;
|
|
80
|
+
this.limit = KEEPALIVE_BODY_LIMIT;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
72
83
|
export class GolemNotLiveError extends Error {
|
|
73
84
|
reason;
|
|
74
85
|
subject;
|
package/dist/live.js
CHANGED
|
@@ -157,6 +157,7 @@ export class LiveSync {
|
|
|
157
157
|
waiters: new Set(),
|
|
158
158
|
merge: EMPTY_MERGE_STATE,
|
|
159
159
|
inFlight: 0,
|
|
160
|
+
derivedStale: false,
|
|
160
161
|
outage: query.state.dataUpdatedAt !== 0,
|
|
161
162
|
bypass: false,
|
|
162
163
|
everLive: false,
|
|
@@ -410,6 +411,11 @@ export class LiveSync {
|
|
|
410
411
|
}
|
|
411
412
|
}
|
|
412
413
|
onEvent(entry, stream, frame) {
|
|
414
|
+
if (stream.plan.derived) {
|
|
415
|
+
entry.derivedStale = entry.inFlight > 0;
|
|
416
|
+
entry.coalescer.request();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
413
419
|
const key = this.patcher.eventKey(stream.plan.model, frame.id, frame.entity);
|
|
414
420
|
const version = parseVersion(frame.version);
|
|
415
421
|
const receivedAt = this.clock();
|
|
@@ -516,6 +522,7 @@ export class LiveSync {
|
|
|
516
522
|
const issuedAt = this.clock();
|
|
517
523
|
if (live !== undefined) {
|
|
518
524
|
live.inFlight++;
|
|
525
|
+
live.derivedStale = false;
|
|
519
526
|
}
|
|
520
527
|
try {
|
|
521
528
|
const data = await this.transport.execute({ ...request, signal: context.signal });
|
|
@@ -535,6 +542,10 @@ export class LiveSync {
|
|
|
535
542
|
live.inFlight--;
|
|
536
543
|
if (live.inFlight === 0) {
|
|
537
544
|
live.merge = EMPTY_MERGE_STATE;
|
|
545
|
+
if (live.derivedStale) {
|
|
546
|
+
live.derivedStale = false;
|
|
547
|
+
live.coalescer.request();
|
|
548
|
+
}
|
|
538
549
|
}
|
|
539
550
|
}
|
|
540
551
|
}
|
package/dist/mutations.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { DocumentNode } from 'graphql';
|
|
|
2
2
|
import type { QueryClient } from '@tanstack/query-core';
|
|
3
3
|
import type { ClientManifest } from './manifest.js';
|
|
4
4
|
import type { ExecuteRequest } from './transport.js';
|
|
5
|
+
export interface MutateOptions {
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
readonly keepalive?: boolean;
|
|
8
|
+
}
|
|
5
9
|
export interface MutationTransport {
|
|
6
10
|
execute<T = unknown>(request: ExecuteRequest): Promise<T>;
|
|
7
11
|
}
|
|
@@ -15,6 +19,6 @@ export declare class Mutations {
|
|
|
15
19
|
private readonly transport;
|
|
16
20
|
private readonly manifest;
|
|
17
21
|
constructor(options: MutationsOptions);
|
|
18
|
-
execute<T = unknown>(document: DocumentNode | string, variables?: Readonly<Record<string, unknown>>,
|
|
22
|
+
execute<T = unknown>(document: DocumentNode | string, variables?: Readonly<Record<string, unknown>>, options?: AbortSignal | MutateOptions): Promise<T>;
|
|
19
23
|
private invalidateCommitted;
|
|
20
24
|
}
|
package/dist/mutations.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { analyzeMutation } from './analysis.js';
|
|
2
2
|
import { GolemCommitUnknownError, GolemPartialCommitError } from './errors.js';
|
|
3
3
|
import { readMeta } from './live.js';
|
|
4
|
+
function isAbortSignal(value) {
|
|
5
|
+
return value !== undefined && typeof value.aborted === 'boolean' && typeof value.addEventListener === 'function';
|
|
6
|
+
}
|
|
4
7
|
export class Mutations {
|
|
5
8
|
client;
|
|
6
9
|
transport;
|
|
@@ -10,11 +13,12 @@ export class Mutations {
|
|
|
10
13
|
this.transport = options.transport;
|
|
11
14
|
this.manifest = options.manifest;
|
|
12
15
|
}
|
|
13
|
-
async execute(document, variables,
|
|
16
|
+
async execute(document, variables, options) {
|
|
17
|
+
const { signal, keepalive } = isAbortSignal(options) ? { signal: options, keepalive: undefined } : (options ?? {});
|
|
14
18
|
const analysis = analyzeMutation(document, this.manifest);
|
|
15
19
|
let data;
|
|
16
20
|
try {
|
|
17
|
-
data = await this.transport.execute({ query: analysis.query, variables, signal });
|
|
21
|
+
data = await this.transport.execute({ query: analysis.query, variables, signal, keepalive });
|
|
18
22
|
}
|
|
19
23
|
catch (error) {
|
|
20
24
|
if (error instanceof GolemPartialCommitError || error instanceof GolemCommitUnknownError) {
|
package/dist/transport.d.ts
CHANGED
package/dist/transport.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GolemConfigurationError, GolemHttpError, GolemProtocolError, failureOf } from './errors.js';
|
|
1
|
+
import { GolemConfigurationError, GolemHttpError, GolemKeepaliveTooLargeError, KEEPALIVE_BODY_LIMIT, GolemProtocolError, failureOf } from './errors.js';
|
|
2
2
|
import { golemBlock } from './protocol.js';
|
|
3
3
|
import { Session } from './session.js';
|
|
4
4
|
export class Transport {
|
|
@@ -29,15 +29,23 @@ export class Transport {
|
|
|
29
29
|
async execute(request) {
|
|
30
30
|
const credentials = (await this.options.credentials?.()) ?? {};
|
|
31
31
|
const send = this.options.fetch ?? globalThis.fetch;
|
|
32
|
+
const payload = JSON.stringify({
|
|
33
|
+
query: request.query,
|
|
34
|
+
variables: request.variables,
|
|
35
|
+
extensions: { golem: golemBlock(this.options.manifest) },
|
|
36
|
+
});
|
|
37
|
+
if (request.keepalive === true) {
|
|
38
|
+
const bytes = new TextEncoder().encode(payload).length;
|
|
39
|
+
if (bytes > KEEPALIVE_BODY_LIMIT) {
|
|
40
|
+
throw new GolemKeepaliveTooLargeError(bytes);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
32
43
|
const response = await send(this.options.url, {
|
|
33
44
|
method: 'POST',
|
|
34
45
|
headers: { 'content-type': 'application/json', accept: 'application/json', ...credentials.headers },
|
|
35
|
-
body:
|
|
36
|
-
query: request.query,
|
|
37
|
-
variables: request.variables,
|
|
38
|
-
extensions: { golem: golemBlock(this.options.manifest) },
|
|
39
|
-
}),
|
|
46
|
+
body: payload,
|
|
40
47
|
signal: request.signal,
|
|
48
|
+
...(request.keepalive === true ? { keepalive: true } : {}),
|
|
41
49
|
});
|
|
42
50
|
const text = await response.text();
|
|
43
51
|
let body;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eleven-am/golem-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Live GraphQL client for golem backends",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"typescript": "^5.7.0",
|
|
34
34
|
"vitest": "^3.0.0"
|
|
35
35
|
},
|
|
36
|
+
"keywords": ["golem", "graphql", "live-query", "realtime", "subscriptions", "tanstack-query", "graphql-ws", "typescript"],
|
|
36
37
|
"license": "GPL-3.0",
|
|
37
38
|
"author": "Roy OSSAI",
|
|
38
39
|
"engines": {
|