@eleven-am/golem-client 0.1.1 → 0.2.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 CHANGED
@@ -34,7 +34,7 @@ export const golem = new GolemClient({
34
34
  |---|---|
35
35
  | `queryClient` | Required. Your application's TanStack `QueryClient`. A second `GolemClient` on a `QueryClient` that already has one throws `GolemConfigurationError`. |
36
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.2 or later). |
37
+ | `manifest` | The generated `zz_golem_client.gen.ts` of your golem server (go/v0.8.3 or later). |
38
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
39
  | `createSocket` | Optional `(url, protocols, credentials) => socket` factory, for a WebSocket implementation of your own (for example in Node). |
40
40
  | `fetch` | Optional `fetch` implementation for HTTP requests. Defaults to `globalThis.fetch`. |
@@ -58,7 +58,7 @@ const unsubscribe = observer.subscribe((result) => render(result.data))
58
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
59
  - `golem.queryOptions(document, variables?)` returns TanStack options for a live query. It throws `GolemNotLiveError` for a document that cannot be live.
60
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 that cannot be live (aggregates, group-by, counts, update-many roots). It is fetched once and never patched.
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
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
63
 
64
64
  ```ts
@@ -151,18 +151,7 @@ The client adds `_version` and `__typename` to every selection of a model with e
151
151
  - `golem.transport.status`: `idle`, `connecting`, `open`, `reconnecting`, `unauthenticated`, `forbidden`, `invalid`, `protocol-mismatch`, `contract-mismatch` or `deadline-required`.
152
152
  - `golem.transport.onStatus(listener)`: calls you on every change and returns an unsubscribe function.
153
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
- ```
154
+ Wrap them in your framework's external-store primitive. In React, `useGolemConnection()` from `@eleven-am/golem-react` does it.
166
155
 
167
156
  #### Why there is a manifest
168
157
 
@@ -178,6 +167,18 @@ GraphQL does not say which subscription root carries a model's events, what its
178
167
 
179
168
  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
169
 
170
+ ## Mutations on page unload
171
+
172
+ 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 })`.
173
+
174
+ ```ts
175
+ addEventListener('pagehide', () => {
176
+ void golem.mutate(END_SESSION, { id: sessionId }, { keepalive: true }).catch(() => {})
177
+ })
178
+ ```
179
+
180
+ 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.
181
+
181
182
  ## Errors
182
183
 
183
184
  All server errors are `GolemError` (`code`, `path`, `extensions`, `retryable`) or a subclass:
@@ -191,7 +192,7 @@ Client-side failures: `GolemConfigurationError` (bad setup), `GolemDocumentError
191
192
 
192
193
  ## Requirements
193
194
 
194
- - golem **go/v0.8.2 or later** on the server, with `golem.Subscriptions` on every model a live query reaches.
195
+ - golem **go/v0.8.3 or later** on the server, with `golem.Subscriptions` on every model a live query reaches.
195
196
  - `@tanstack/query-core` 5.
196
197
  - 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).
197
198
 
@@ -7,6 +7,7 @@ export interface StreamPlan {
7
7
  readonly model: string;
8
8
  readonly eventsRoot: string;
9
9
  readonly patchable: boolean;
10
+ readonly derived: boolean;
10
11
  readonly scope: 'exact' | 'policy-only';
11
12
  readonly paths: ReadonlyArray<string>;
12
13
  readonly query: string;
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>>, signal?: AbortSignal): Promise<T>;
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, signal) {
49
- return this.mutations.execute(document, variables, signal);
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.d.ts CHANGED
@@ -77,6 +77,7 @@ export declare class LiveSync {
77
77
  };
78
78
  };
79
79
  statusOf(query: Query): LiveStatus | undefined;
80
+ private hasQueuedRefetch;
80
81
  subscribeStatus(listener: () => void): () => void;
81
82
  private onCacheEvent;
82
83
  private reconcileHold;
package/dist/live.js CHANGED
@@ -105,6 +105,10 @@ export class LiveSync {
105
105
  statusOf(query) {
106
106
  return this.statuses.get(query.queryHash);
107
107
  }
108
+ hasQueuedRefetch(query) {
109
+ const entry = this.entries.get(query.queryHash);
110
+ return entry !== undefined && (entry.coalescer.pending || entry.derivedStale);
111
+ }
108
112
  subscribeStatus(listener) {
109
113
  this.listeners.add(listener);
110
114
  return () => {
@@ -157,6 +161,7 @@ export class LiveSync {
157
161
  waiters: new Set(),
158
162
  merge: EMPTY_MERGE_STATE,
159
163
  inFlight: 0,
164
+ derivedStale: false,
160
165
  outage: query.state.dataUpdatedAt !== 0,
161
166
  bypass: false,
162
167
  everLive: false,
@@ -410,6 +415,11 @@ export class LiveSync {
410
415
  }
411
416
  }
412
417
  onEvent(entry, stream, frame) {
418
+ if (stream.plan.derived) {
419
+ entry.derivedStale = entry.inFlight > 0;
420
+ entry.coalescer.request();
421
+ return;
422
+ }
413
423
  const key = this.patcher.eventKey(stream.plan.model, frame.id, frame.entity);
414
424
  const version = parseVersion(frame.version);
415
425
  const receivedAt = this.clock();
@@ -516,6 +526,7 @@ export class LiveSync {
516
526
  const issuedAt = this.clock();
517
527
  if (live !== undefined) {
518
528
  live.inFlight++;
529
+ live.derivedStale = false;
519
530
  }
520
531
  try {
521
532
  const data = await this.transport.execute({ ...request, signal: context.signal });
@@ -535,6 +546,10 @@ export class LiveSync {
535
546
  live.inFlight--;
536
547
  if (live.inFlight === 0) {
537
548
  live.merge = EMPTY_MERGE_STATE;
549
+ if (live.derivedStale) {
550
+ live.derivedStale = false;
551
+ live.coalescer.request();
552
+ }
538
553
  }
539
554
  }
540
555
  }
@@ -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>>, signal?: AbortSignal): Promise<T>;
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, signal) {
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) {
@@ -13,6 +13,7 @@ export interface ExecuteRequest {
13
13
  readonly query: string;
14
14
  readonly variables?: Readonly<Record<string, unknown>>;
15
15
  readonly signal?: AbortSignal;
16
+ readonly keepalive?: boolean;
16
17
  }
17
18
  export declare class Transport {
18
19
  private readonly options;
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: JSON.stringify({
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.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Live GraphQL client for golem backends",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",