@gleanql/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/index.d.mts +954 -0
- package/dist/index.mjs +1412 -0
- package/package.json +57 -0
- package/src/adapter-shared.ts +76 -0
- package/src/adapter-ws.ts +134 -0
- package/src/adapter.ts +152 -0
- package/src/cache-resolve.ts +98 -0
- package/src/cache.ts +341 -0
- package/src/context.ts +67 -0
- package/src/glue-client.ts +1000 -0
- package/src/glue-server.ts +46 -0
- package/src/index.ts +30 -0
- package/src/integration.ts +201 -0
- package/src/mutation.ts +171 -0
- package/src/mutator.ts +58 -0
- package/src/normalize.ts +101 -0
- package/src/paginate.ts +361 -0
- package/src/persisted.ts +73 -0
- package/src/proxy.ts +288 -0
- package/src/reactivity.ts +149 -0
- package/src/route.ts +84 -0
- package/src/runtime.ts +212 -0
- package/src/scope.ts +97 -0
- package/src/serialize.ts +175 -0
- package/src/testing.ts +220 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
GraphRuntime,
|
|
5
|
+
GraphScope,
|
|
6
|
+
bindGraph,
|
|
7
|
+
createFetchAdapter,
|
|
8
|
+
refetch,
|
|
9
|
+
runMutation,
|
|
10
|
+
absorbHydrationPayload,
|
|
11
|
+
pagePointer,
|
|
12
|
+
selectionOf,
|
|
13
|
+
type BoundGraph,
|
|
14
|
+
type FieldValue,
|
|
15
|
+
type GraphRef,
|
|
16
|
+
type CompiledOperation,
|
|
17
|
+
type GraphClientAdapter,
|
|
18
|
+
type GraphHydrationPayload,
|
|
19
|
+
type GraphPagePointer,
|
|
20
|
+
type GraphRequestContext,
|
|
21
|
+
type ActiveGraph,
|
|
22
|
+
type MutationResult,
|
|
23
|
+
type RunMutationOptions,
|
|
24
|
+
type UserError,
|
|
25
|
+
errorMessage,
|
|
26
|
+
} from "./index.js";
|
|
27
|
+
import type { SchemaModel } from "@gleanql/core";
|
|
28
|
+
import { maskViolations, useTracked } from "./reactivity.js";
|
|
29
|
+
import {
|
|
30
|
+
paginateConnection,
|
|
31
|
+
buildComponentOperation,
|
|
32
|
+
type UsePaginatedOptions,
|
|
33
|
+
type UsePaginatedResult,
|
|
34
|
+
} from "./paginate.js";
|
|
35
|
+
|
|
36
|
+
// The reactivity substrate and the pagination query-building live in their own
|
|
37
|
+
// modules; re-export the public pieces so the generated glue + tests keep one entry.
|
|
38
|
+
export { affectedDigest } from "./reactivity.js";
|
|
39
|
+
export { buildPageOperation, type MergeHelpers } from "./paginate.js";
|
|
40
|
+
export { paginateConnection, buildComponentOperation };
|
|
41
|
+
export type { UsePaginatedOptions, UsePaginatedResult };
|
|
42
|
+
|
|
43
|
+
/** The latest render's value behind a stable ref — so a stable callback always sees fresh options. */
|
|
44
|
+
function useLatest<T>(value: T): { readonly current: T } {
|
|
45
|
+
const ref = useRef(value);
|
|
46
|
+
ref.current = value;
|
|
47
|
+
return ref;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An active graph plus the page's root pointers (how a server-side graph carries its roots). */
|
|
51
|
+
type SsrActive = ActiveGraph & { readonly roots?: Record<string, FieldValue> };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* SSR-pass carrier: `<GraphHydrator>` provides this request's graph to the island
|
|
55
|
+
* children it wraps, so `useGlean()` server-renders warm. React context is the one
|
|
56
|
+
* channel that is request-isolated by construction during streaming SSR — module
|
|
57
|
+
* state is shared across concurrent requests, and the framework's request scope
|
|
58
|
+
* (e.g. `rwsdk/worker`) is gated to the RSC environment.
|
|
59
|
+
*/
|
|
60
|
+
const SsrActiveContext = createContext<SsrActive | null>(null);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The client-side runtime glue, shared by both hydration models. The generated
|
|
64
|
+
* `@gleanql/client/client` entrypoint is a thin shim that calls this with its baked
|
|
65
|
+
* config and re-exports the pieces a host needs — so the real (typed, testable)
|
|
66
|
+
* logic lives here, not in template strings.
|
|
67
|
+
*
|
|
68
|
+
* - RSC (RedwoodSDK): omit `scope`. A private {@link GraphScope} singleton is used;
|
|
69
|
+
* the auto-injected `<GraphHydrator>` folds each page's snapshot in as it rides
|
|
70
|
+
* the flight stream.
|
|
71
|
+
* - Isomorphic SSR (React Router): pass the app's shared `scope`. The host calls
|
|
72
|
+
* `hydrate(payload)` from `entry.client`/root with loader data; the same scope
|
|
73
|
+
* backs the isomorphic `graph` accessor and `useGlean()`.
|
|
74
|
+
*/
|
|
75
|
+
export interface GraphClientOptions {
|
|
76
|
+
readonly schema: SchemaModel;
|
|
77
|
+
readonly operations: Record<string, CompiledOperation>;
|
|
78
|
+
readonly endpoint: string;
|
|
79
|
+
/** Shared scope (isomorphic hosts). Omit for an RSC-private singleton. */
|
|
80
|
+
readonly scope?: GraphScope;
|
|
81
|
+
/**
|
|
82
|
+
* Optional server-side resolver for this request's active graph. Most hosts don't
|
|
83
|
+
* need it: RSC islands get the request graph through `<GraphHydrator>`'s context,
|
|
84
|
+
* and isomorphic hosts through the shared scope. This is the escape hatch for a
|
|
85
|
+
* custom host whose SSR pass can reach its request scope directly.
|
|
86
|
+
*/
|
|
87
|
+
readonly serverActive?: () => SsrActive | undefined;
|
|
88
|
+
/** Optional LRU cap on the long-lived client cache (it accumulates across navigations). */
|
|
89
|
+
readonly maxCacheRecords?: number;
|
|
90
|
+
/** Send operations by sha-256 hash (persisted-operation mode) instead of by document. */
|
|
91
|
+
readonly persisted?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Staleness-aware GC (opt-in): on each navigation, collect records that are
|
|
94
|
+
* unretained AND untouched for this many page generations. Unset = no automatic
|
|
95
|
+
* collection ("unretained" alone is not a reason to drop valid data — the LRU
|
|
96
|
+
* cap bounds capacity; this bounds staleness). `gcKeepPages: 2` keeps roughly
|
|
97
|
+
* the last two pages' data warm for back-navigation.
|
|
98
|
+
*/
|
|
99
|
+
readonly gcKeepPages?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Central runtime-incident channel — the one place to wire an error tracker
|
|
102
|
+
* (Sentry, etc.). Every failure ALREADY surfaces locally (hook state, rejected
|
|
103
|
+
* promises, error boundaries); this mirrors them so production issues are
|
|
104
|
+
* observable without instrumenting each call site. A throwing listener is
|
|
105
|
+
* swallowed — observability must never break the app.
|
|
106
|
+
*/
|
|
107
|
+
readonly onEvent?: (event: GraphClientEvent) => void;
|
|
108
|
+
/**
|
|
109
|
+
* Dev read-masking (opt-in via the plugin's `masking` option): per-component
|
|
110
|
+
* sets of `Type.field` pairs the compiler proved each component reads. A
|
|
111
|
+
* component touching data OUTSIDE its set renders fields another component
|
|
112
|
+
* fetched — warned once per pair, never thrown.
|
|
113
|
+
*/
|
|
114
|
+
readonly readMask?: Record<string, readonly string[]>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A reportable runtime incident (see {@link GraphClientOptions.onEvent}). */
|
|
118
|
+
export type GraphClientEvent =
|
|
119
|
+
| { readonly type: "refresh-error"; readonly operation: string; readonly error: unknown }
|
|
120
|
+
| { readonly type: "operation-error"; readonly operation: string; readonly error: unknown }
|
|
121
|
+
| { readonly type: "mutation-error"; readonly operation: string; readonly error: string }
|
|
122
|
+
| { readonly type: "subscription-error"; readonly operation: string; readonly error: string }
|
|
123
|
+
/** The server didn't know a persisted hash; the document was re-sent once (APQ register). */
|
|
124
|
+
| { readonly type: "persisted-retry"; readonly operation: string }
|
|
125
|
+
/** Staleness-aware GC ran on navigation (only reported when something was collected). */
|
|
126
|
+
| { readonly type: "gc"; readonly dropped: number };
|
|
127
|
+
|
|
128
|
+
export interface GraphClient {
|
|
129
|
+
/**
|
|
130
|
+
* The active graph, re-rendering the caller when the cache changes.
|
|
131
|
+
* `component` is build-injected when read-masking is on — never hand-written.
|
|
132
|
+
*/
|
|
133
|
+
useGlean(component?: string): BoundGraph | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* Refetch over the wire; the cache notifies the UI.
|
|
136
|
+
* - `refresh()` — the whole current-page operation.
|
|
137
|
+
* - `refresh("OpName")` — a named operation, whole.
|
|
138
|
+
* - `refresh({ component })` — only what that component reads (its compiled
|
|
139
|
+
* read-map), pruned to a slice. The build injects `{ component }` into bare
|
|
140
|
+
* `refresh()` calls, so an island just writes `refresh()` to refetch its own
|
|
141
|
+
* fields — no hand-written selection.
|
|
142
|
+
*/
|
|
143
|
+
refresh(target?: string | { component: string }): Promise<void>;
|
|
144
|
+
/**
|
|
145
|
+
* Paginate a connection you already read in render. `connection` is the value
|
|
146
|
+
* (`glean.collection({handle}).products({first})`); `fetchMore(args)` re-runs that
|
|
147
|
+
* connection's selection with your `args` (whatever cursor/offset convention your
|
|
148
|
+
* schema uses) and merges the page in — by default concatenating `nodes`, or via
|
|
149
|
+
* the `merge` you supply. No schema convention is assumed and nothing is
|
|
150
|
+
* auto-selected: you read `pageInfo`/cursors yourself, so the compiler includes
|
|
151
|
+
* exactly what you use.
|
|
152
|
+
*/
|
|
153
|
+
usePaginated(connection: unknown, options?: UsePaginatedOptions): UsePaginatedResult;
|
|
154
|
+
/**
|
|
155
|
+
* The write side (gqty-style). `selector` is compile-time only — it drives the
|
|
156
|
+
* mutation operation and types `data`; the build injects the operation name as
|
|
157
|
+
* `opName` so the runtime runs the compiled `operations[opName]`. Returns
|
|
158
|
+
* `[mutate, state]`: `await mutate(vars)` runs the mutation, folds the result into
|
|
159
|
+
* the normalized cache (entities update in place), and surfaces
|
|
160
|
+
* `isLoading`/`data`/`error`/`userErrors`. `optimistic`/`update`/`invalidate` are
|
|
161
|
+
* passed through to the engine; `onCompleted`/`onError` fire after.
|
|
162
|
+
*/
|
|
163
|
+
useMutation<TData, TVars>(
|
|
164
|
+
selector: unknown,
|
|
165
|
+
options?: UseMutationOptions<TData, TVars>,
|
|
166
|
+
opName?: string,
|
|
167
|
+
): UseMutationResult<TData, TVars>;
|
|
168
|
+
/**
|
|
169
|
+
* Subscribe to a live operation (gqty-style). `selector` is compile-time only — it
|
|
170
|
+
* drives the `kind:"subscription"` operation and types `data`; the build injects the
|
|
171
|
+
* operation name. Each pushed payload normalizes into the cache (so any reader
|
|
172
|
+
* re-renders fine-grained), and the latest is returned as `data` alongside `error`.
|
|
173
|
+
* Pass operation variables via `options.variables`; the stream re-opens when they
|
|
174
|
+
* change and closes on unmount. Client-only (a no-op during SSR).
|
|
175
|
+
*/
|
|
176
|
+
useSubscription<TData, TVars>(
|
|
177
|
+
selector: unknown,
|
|
178
|
+
options?: UseSubscriptionOptions<TData, TVars>,
|
|
179
|
+
opName?: string,
|
|
180
|
+
): SubscriptionState<TData>;
|
|
181
|
+
/**
|
|
182
|
+
* Splice an entity into a LIST root's membership without a refetch. A list root's
|
|
183
|
+
* membership lives in the page pointer's `roots`, not in any normalized record — so
|
|
184
|
+
* after a mutation that adds an element you'd otherwise `refresh()` the whole list.
|
|
185
|
+
* Instead, `appendToRoot("todos", result.addTodo)` adds the entity's ref to the root
|
|
186
|
+
* array and re-renders its readers. Pass `{ prepend: true }` to add at the front;
|
|
187
|
+
* idempotent (an already-present entity isn't duplicated).
|
|
188
|
+
*
|
|
189
|
+
* For OPTIMISTIC UI, pass a client-built entity with its fields
|
|
190
|
+
* (`{ __typename: "Todo", id, title, completed: false }`) — those fields are seeded
|
|
191
|
+
* into the cache so the row renders immediately, before the server responds. Generate
|
|
192
|
+
* the `id` client-side and pass it to the mutation; roll back with `removeFromRoot` if
|
|
193
|
+
* the mutation fails. `{ at: index }` inserts at a position (clamped) rather than the
|
|
194
|
+
* end/front — e.g. to restore a row to its original spot when an optimistic remove fails.
|
|
195
|
+
*/
|
|
196
|
+
appendToRoot(rootField: string, entity: unknown, options?: { prepend?: boolean; at?: number }): void;
|
|
197
|
+
/** Remove an entity from a list root's membership without a refetch (the inverse of
|
|
198
|
+
* {@link appendToRoot}); pass the removed entity, a `{ __typename, id }`, or its ref. */
|
|
199
|
+
removeFromRoot(rootField: string, entity: unknown): void;
|
|
200
|
+
/**
|
|
201
|
+
* Execute a NAMED operation (compiled or registered) with explicit variables,
|
|
202
|
+
* outside any page flow — the runtime surface for `buildQuery`-registered
|
|
203
|
+
* operations (dashboards, reports). The result seeds the normalized cache
|
|
204
|
+
* (entities update in place for every reader) and is returned raw. In persisted
|
|
205
|
+
* mode the request rides the operation's sha-256 hash like any other.
|
|
206
|
+
*/
|
|
207
|
+
runOperation(
|
|
208
|
+
name: string,
|
|
209
|
+
variables?: Record<string, unknown>,
|
|
210
|
+
): Promise<{ data?: unknown; errors?: ReadonlyArray<{ message: string }> }>;
|
|
211
|
+
/**
|
|
212
|
+
* Subscribe to runtime incidents at RUNTIME (the generated glue bakes config as
|
|
213
|
+
* data, so a function can't ride the plugin options — register from app code
|
|
214
|
+
* instead). Returns the unsubscribe. See {@link GraphClientEvent}.
|
|
215
|
+
*/
|
|
216
|
+
onEvent(listener: (event: GraphClientEvent) => void): () => void;
|
|
217
|
+
/** Fold a hydration payload into the client runtime (host-driven; isomorphic SSR). */
|
|
218
|
+
hydrate(payload: GraphHydrationPayload | undefined): void;
|
|
219
|
+
/** Client island that folds its payload prop in as it crosses the RSC boundary. */
|
|
220
|
+
GraphHydrator(props: { payload: GraphHydrationPayload | undefined; children?: ReactNode }): ReactNode;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Splice a ref into (or out of) a list root's membership — pure, so it's unit-testable
|
|
225
|
+
* apart from the page-pointer plumbing. `keyOf` identifies refs (the cache's record key)
|
|
226
|
+
* so an append dedupes and a remove matches. Returns a NEW roots map (callers swap it in
|
|
227
|
+
* via the page pointer to trigger a re-render).
|
|
228
|
+
*/
|
|
229
|
+
export function spliceRootList(
|
|
230
|
+
roots: Record<string, FieldValue>,
|
|
231
|
+
field: string,
|
|
232
|
+
ref: GraphRef,
|
|
233
|
+
keyOf: (value: FieldValue) => string | undefined,
|
|
234
|
+
mode: { remove?: boolean; prepend?: boolean; at?: number },
|
|
235
|
+
): Record<string, FieldValue> {
|
|
236
|
+
const key = keyOf(ref);
|
|
237
|
+
const current = Array.isArray(roots[field]) ? (roots[field] as FieldValue[]) : [];
|
|
238
|
+
const without = key == null ? current : current.filter((r) => keyOf(r) !== key);
|
|
239
|
+
let next: FieldValue[];
|
|
240
|
+
if (mode.remove) next = without;
|
|
241
|
+
else if (mode.at != null) next = [...without.slice(0, mode.at), ref, ...without.slice(mode.at)]; // insert at index (clamped)
|
|
242
|
+
else if (mode.prepend) next = [ref, ...without];
|
|
243
|
+
else next = [...without, ref];
|
|
244
|
+
return { ...roots, [field]: next };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Resolve a `GraphRef` from a graph proxy/selection, a raw `{__typename,id}`/`{path}`, or a ref. */
|
|
248
|
+
export function refOf(entity: unknown): GraphRef | undefined {
|
|
249
|
+
if (entity == null || typeof entity !== "object") return undefined;
|
|
250
|
+
const sel = selectionOf(entity);
|
|
251
|
+
if (sel) return sel.ref;
|
|
252
|
+
const o = entity as Record<string, unknown>;
|
|
253
|
+
if (typeof o.__typename === "string" && o.id != null) return { __typename: o.__typename, id: String(o.id) };
|
|
254
|
+
if (typeof o.path === "string") return { path: o.path };
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The data fields (beyond identity) of a raw entity object, for OPTIMISTIC seeding —
|
|
260
|
+
* so `appendToRoot` can render a client-built entity before the server confirms it.
|
|
261
|
+
* Returns undefined for a graph proxy (already cached) or a bare `{__typename,id}` ref
|
|
262
|
+
* (nothing to render).
|
|
263
|
+
*/
|
|
264
|
+
export function seedableFields(entity: unknown): Record<string, FieldValue> | undefined {
|
|
265
|
+
if (entity == null || typeof entity !== "object" || selectionOf(entity)) return undefined;
|
|
266
|
+
const o = entity as Record<string, unknown>;
|
|
267
|
+
if (typeof o.__typename !== "string" || o.id == null) return undefined;
|
|
268
|
+
// Seed every non-__typename field, INCLUDING id — a proxy reads `.id` as a normal cache
|
|
269
|
+
// field (not off the ref), so the row needs it present (e.g. for its React key). Only
|
|
270
|
+
// worth seeding when there's data beyond the identity, though: a bare {__typename,id}
|
|
271
|
+
// ref (a membership-only op) seeds nothing.
|
|
272
|
+
const fields: Record<string, FieldValue> = {};
|
|
273
|
+
let hasData = false;
|
|
274
|
+
for (const [k, v] of Object.entries(o)) {
|
|
275
|
+
if (k === "__typename") continue;
|
|
276
|
+
fields[k] = v as FieldValue;
|
|
277
|
+
if (k !== "id") hasData = true;
|
|
278
|
+
}
|
|
279
|
+
return hasData ? fields : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Optimistic list-root membership ops handed to a `useMutation` `optimisticRoots` callback. */
|
|
283
|
+
export interface RootMembership {
|
|
284
|
+
/** Splice an entity into a list root now; undone (and the optimistic record evicted) on failure. */
|
|
285
|
+
append(field: string, entity: unknown, options?: { prepend?: boolean; at?: number }): void;
|
|
286
|
+
/** Splice an entity out now; re-inserted at its original index on failure. */
|
|
287
|
+
remove(field: string, entity: unknown): void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build a membership transaction over a set of splice ops — pure, so the
|
|
292
|
+
* apply/record/rollback logic is unit-testable apart from the page-pointer plumbing.
|
|
293
|
+
* `append` records an undo that removes (and evicts the optimistic record); `remove`
|
|
294
|
+
* captures the entity's index first and re-inserts there. `rollback` replays the undos
|
|
295
|
+
* in reverse. The hook wires `ops` to the real `appendToRoot`/`removeFromRoot`.
|
|
296
|
+
*/
|
|
297
|
+
export function createMembershipTx(ops: {
|
|
298
|
+
append: (field: string, entity: unknown, options?: { prepend?: boolean; at?: number }) => void;
|
|
299
|
+
remove: (field: string, entity: unknown) => void;
|
|
300
|
+
indexOf: (field: string, entity: unknown) => number | undefined;
|
|
301
|
+
evictOptimistic?: (entity: unknown) => void;
|
|
302
|
+
}): { membership: RootMembership; rollback: () => void } {
|
|
303
|
+
const undos: Array<() => void> = [];
|
|
304
|
+
const membership: RootMembership = {
|
|
305
|
+
append(field, entity, options) {
|
|
306
|
+
ops.append(field, entity, options);
|
|
307
|
+
undos.push(() => {
|
|
308
|
+
ops.remove(field, entity);
|
|
309
|
+
ops.evictOptimistic?.(entity);
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
remove(field, entity) {
|
|
313
|
+
const at = ops.indexOf(field, entity);
|
|
314
|
+
ops.remove(field, entity);
|
|
315
|
+
undos.push(() => ops.append(field, entity, at != null ? { at } : undefined));
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
return {
|
|
319
|
+
membership,
|
|
320
|
+
rollback: () => {
|
|
321
|
+
for (let i = undos.length - 1; i >= 0; i--) undos[i]!();
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface UseMutationOptions<TData = unknown, TVars = Record<string, unknown>> {
|
|
327
|
+
/** Called after a successful mutation with the normalized result data. */
|
|
328
|
+
readonly onCompleted?: (data: TData | undefined) => void;
|
|
329
|
+
/** Called after a failed mutation (transport error or `userErrors`). */
|
|
330
|
+
readonly onError?: (result: MutationResult<TData>) => void;
|
|
331
|
+
/** Optimistically patch the cache before the request; rolled back on failure. */
|
|
332
|
+
readonly optimistic?: RunMutationOptions<TData>["optimistic"];
|
|
333
|
+
/**
|
|
334
|
+
* Optimistically splice a LIST root before the request — applied immediately (the row
|
|
335
|
+
* appears/disappears now) and rolled back automatically if the mutation fails. The
|
|
336
|
+
* membership counterpart to `optimistic`'s field writes: `optimistic` rolls back cache
|
|
337
|
+
* fields, this rolls back `roots` membership (re-inserting a removed row at its index,
|
|
338
|
+
* evicting a failed-add's record). Generate ids client-side so the optimistic entity is
|
|
339
|
+
* the final one — the mutation normalizes over it with nothing to reconcile.
|
|
340
|
+
*/
|
|
341
|
+
readonly optimisticRoots?: (membership: RootMembership, vars: TVars) => void;
|
|
342
|
+
/** Apply the server result after normalization (e.g. prepend to a connection). */
|
|
343
|
+
readonly update?: RunMutationOptions<TData>["update"];
|
|
344
|
+
/** Graph values / refs to invalidate on success (refetch on next read). */
|
|
345
|
+
readonly invalidate?: RunMutationOptions<TData>["invalidate"];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface MutationState<TData = unknown> {
|
|
349
|
+
/** True while the mutation is in flight. */
|
|
350
|
+
readonly isLoading: boolean;
|
|
351
|
+
/** The normalized result data from the last successful mutation. */
|
|
352
|
+
readonly data?: TData;
|
|
353
|
+
/** The last transport/execution error message, if any. */
|
|
354
|
+
readonly error?: string;
|
|
355
|
+
/** Logical, per-mutation `userErrors` from the last run. */
|
|
356
|
+
readonly userErrors: readonly UserError[];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** `[mutate, state]` — call `mutate(vars)`, read loading/data/error off `state`. */
|
|
360
|
+
export type UseMutationResult<TData = unknown, TVars = Record<string, unknown>> = readonly [
|
|
361
|
+
(vars: TVars) => Promise<MutationResult<TData>>,
|
|
362
|
+
MutationState<TData>,
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
export interface UseSubscriptionOptions<TData = unknown, TVars = Record<string, unknown>> {
|
|
366
|
+
/** Operation variables (the selector's `vars`). The subscription re-opens when they change. */
|
|
367
|
+
readonly variables?: TVars;
|
|
368
|
+
/** Called with each pushed payload (after it normalizes into the cache). */
|
|
369
|
+
readonly onData?: (data: TData) => void;
|
|
370
|
+
/** Called when the stream surfaces a transport/execution error. */
|
|
371
|
+
readonly onError?: (message: string) => void;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface SubscriptionState<TData = unknown> {
|
|
375
|
+
/** The latest pushed payload (also folded into the normalized cache). */
|
|
376
|
+
readonly data?: TData;
|
|
377
|
+
/** The last transport/execution error from the stream, if any. */
|
|
378
|
+
readonly error?: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function createGraphClient(opts: GraphClientOptions): GraphClient {
|
|
382
|
+
const scope = opts.scope ?? new GraphScope();
|
|
383
|
+
let adapter: GraphClientAdapter | undefined;
|
|
384
|
+
let currentPage: GraphPagePointer | undefined;
|
|
385
|
+
|
|
386
|
+
// Incident listeners: the baked `opts.onEvent` plus anything registered at
|
|
387
|
+
// runtime via `onEvent(listener)` — the generated glue can't bake a function,
|
|
388
|
+
// so apps subscribe from app code instead.
|
|
389
|
+
const eventListeners = new Set<(event: GraphClientEvent) => void>();
|
|
390
|
+
if (opts.onEvent) eventListeners.add(opts.onEvent);
|
|
391
|
+
|
|
392
|
+
/** Report a runtime incident; a throwing listener must never break the app. */
|
|
393
|
+
const report = (event: GraphClientEvent): void => {
|
|
394
|
+
for (const listener of eventListeners) {
|
|
395
|
+
try {
|
|
396
|
+
listener(event);
|
|
397
|
+
} catch {
|
|
398
|
+
/* observability is best-effort */
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
function onEvent(listener: (event: GraphClientEvent) => void): () => void {
|
|
404
|
+
eventListeners.add(listener);
|
|
405
|
+
return () => eventListeners.delete(listener);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The page pointer changes on hydration and on every client navigation. That
|
|
409
|
+
// changes root resolution for EVERY reader (`glean.x()` resolves through the new
|
|
410
|
+
// page's `roots`), so a bump here re-renders all `useGlean` components — letting an
|
|
411
|
+
// island that first rendered before hydration re-resolve its roots and re-track the
|
|
412
|
+
// right keys, instead of staying bound to a stale pre-hydration path ref.
|
|
413
|
+
let pageEpoch = 0;
|
|
414
|
+
const pageListeners = new Set<() => void>();
|
|
415
|
+
const setCurrentPage = (page: GraphPagePointer | undefined): void => {
|
|
416
|
+
currentPage = page;
|
|
417
|
+
pageEpoch++;
|
|
418
|
+
for (const listener of pageListeners) listener();
|
|
419
|
+
// Staleness-aware GC (opt-in): a navigation advances the cache's generation
|
|
420
|
+
// clock and collects only records that are BOTH unretained and untouched for
|
|
421
|
+
// `gcKeepPages` generations — recently-left pages stay warm for back-nav.
|
|
422
|
+
if (opts.gcKeepPages != null) {
|
|
423
|
+
const cache = active()?.runtime.cache;
|
|
424
|
+
if (cache) {
|
|
425
|
+
// Collect BEFORE advancing the clock: the page just absorbed is stamped
|
|
426
|
+
// with the current epoch — judging it one generation later would collect
|
|
427
|
+
// a freshly-hydrated page at `gcKeepPages: 1`.
|
|
428
|
+
const dropped = cache.gc({ keepEpochs: opts.gcKeepPages });
|
|
429
|
+
cache.advanceEpoch();
|
|
430
|
+
if (dropped > 0) report({ type: "gc", dropped });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const subscribePage = (cb: () => void): (() => void) => {
|
|
435
|
+
pageListeners.add(cb);
|
|
436
|
+
return () => pageListeners.delete(cb);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const adapterFor = () =>
|
|
440
|
+
(adapter ??= createFetchAdapter({
|
|
441
|
+
endpoint: opts.endpoint,
|
|
442
|
+
persisted: opts.persisted,
|
|
443
|
+
onPersistedRetry: (operation) => report({ type: "persisted-retry", operation }),
|
|
444
|
+
}));
|
|
445
|
+
const active = () => {
|
|
446
|
+
try {
|
|
447
|
+
return scope.current();
|
|
448
|
+
} catch {
|
|
449
|
+
// RSC SSR pass: the private scope is never set server-side. Fall through to
|
|
450
|
+
// the host-provided per-request resolver (the same graph the route preloaded)
|
|
451
|
+
// so islands server-render warm instead of flashing their fallback.
|
|
452
|
+
return typeof window === "undefined" ? opts.serverActive?.() : undefined;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Resolve the client runtime, creating an empty one (installed on the scope) on
|
|
458
|
+
* first use so `useGlean()` always has something to subscribe to. No-op on the
|
|
459
|
+
* server: there the runtime is the request's (set by the host), never built here.
|
|
460
|
+
*/
|
|
461
|
+
function ensure() {
|
|
462
|
+
if (typeof window === "undefined") return active();
|
|
463
|
+
const existing = active();
|
|
464
|
+
if (existing) return existing;
|
|
465
|
+
const runtime = new GraphRuntime({
|
|
466
|
+
keyOf: (typename, obj) => opts.schema.identityOf(typename, obj),
|
|
467
|
+
fetchMissing: async (misses) => misses.map((m) => ({ ref: m.ref, fieldKey: m.fieldKey, value: undefined })),
|
|
468
|
+
maxCacheRecords: opts.maxCacheRecords,
|
|
469
|
+
});
|
|
470
|
+
const graph = bindGraph({ schema: opts.schema, getRuntime: () => runtime, roots: () => currentPage?.roots });
|
|
471
|
+
scope.set({ runtime, graph });
|
|
472
|
+
return active();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Fold a payload into the runtime (idempotent, write-only) and set the page pointer. */
|
|
476
|
+
function absorb(a: { runtime: GraphRuntime }, payload: GraphHydrationPayload, notify: boolean) {
|
|
477
|
+
const changed = absorbHydrationPayload(a.runtime, payload);
|
|
478
|
+
setCurrentPage(pagePointer(payload));
|
|
479
|
+
if (notify && changed) a.runtime.notify();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function hydrate(payload: GraphHydrationPayload | undefined) {
|
|
483
|
+
if (typeof window === "undefined" || !payload) return;
|
|
484
|
+
const a = ensure();
|
|
485
|
+
if (a) absorb(a, payload, true);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function GraphHydrator({
|
|
489
|
+
payload,
|
|
490
|
+
children,
|
|
491
|
+
}: {
|
|
492
|
+
payload: GraphHydrationPayload | undefined;
|
|
493
|
+
children?: ReactNode;
|
|
494
|
+
}): ReactNode {
|
|
495
|
+
const isServer = typeof window === "undefined";
|
|
496
|
+
const last = useRef<GraphHydrationPayload | null>(null);
|
|
497
|
+
const ssr = useRef<{ payload: GraphHydrationPayload; active: SsrActive } | null>(null);
|
|
498
|
+
const a = isServer ? undefined : ensure();
|
|
499
|
+
// Browser, render-phase: fold the snapshot in so the island children read warm
|
|
500
|
+
// this pass (write-only — the notify is deferred to the effect). The FIRST
|
|
501
|
+
// payload also installs the page pointer render-phase: the hydrator renders
|
|
502
|
+
// before its children, so their hydration render binds the same data the server
|
|
503
|
+
// rendered — symmetric with the SSR pass, no mismatch. (Later navigations go
|
|
504
|
+
// through the effect, which bumps the epoch and notifies.)
|
|
505
|
+
if (a && payload && payload !== last.current) {
|
|
506
|
+
absorbHydrationPayload(a.runtime, payload);
|
|
507
|
+
currentPage ??= pagePointer(payload);
|
|
508
|
+
}
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
if (a && payload && payload !== last.current) {
|
|
511
|
+
last.current = payload;
|
|
512
|
+
setCurrentPage(pagePointer(payload));
|
|
513
|
+
a.runtime.notify();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
if (isServer && payload) {
|
|
517
|
+
// SSR pass: there is no browser runtime and the private scope is unset.
|
|
518
|
+
// Rebuild this request's graph from the payload (the exact snapshot the
|
|
519
|
+
// browser will hydrate) and provide it through context — island children
|
|
520
|
+
// server-render warm, and the carrier is request-isolated by construction.
|
|
521
|
+
if (ssr.current?.payload !== payload) {
|
|
522
|
+
const runtime = GraphRuntime.hydrate(payload.snapshot, {
|
|
523
|
+
keyOf: (typename, obj) => opts.schema.identityOf(typename, obj),
|
|
524
|
+
fetchMissing: async (misses) => misses.map((m) => ({ ref: m.ref, fieldKey: m.fieldKey, value: undefined })),
|
|
525
|
+
});
|
|
526
|
+
const graph = bindGraph({ schema: opts.schema, getRuntime: () => runtime, roots: payload.roots });
|
|
527
|
+
ssr.current = { payload, active: { runtime, graph, roots: payload.roots } };
|
|
528
|
+
}
|
|
529
|
+
return createElement(SsrActiveContext.Provider, { value: ssr.current.active }, children);
|
|
530
|
+
}
|
|
531
|
+
return children ?? null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Masking: warned pairs are remembered per component+pair (one warning, not a
|
|
535
|
+
// console flood), and allowed-sets are materialized per component on demand.
|
|
536
|
+
const maskWarned = new Set<string>();
|
|
537
|
+
const maskSets = new Map<string, ReadonlySet<string>>();
|
|
538
|
+
const maskAllowedFor = (component: string): ReadonlySet<string> | undefined => {
|
|
539
|
+
const pairs = opts.readMask?.[component];
|
|
540
|
+
if (!pairs) return undefined;
|
|
541
|
+
let set = maskSets.get(component);
|
|
542
|
+
if (!set) maskSets.set(component, (set = new Set(pairs)));
|
|
543
|
+
return set;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
function useGlean(component?: string): BoundGraph | undefined {
|
|
547
|
+
// SSR pass: the wrapping <GraphHydrator> provides this request's graph. In the
|
|
548
|
+
// browser (and for scope-carrying hosts) `ensure()` wins and the context is null.
|
|
549
|
+
const ssrActive = useContext(SsrActiveContext);
|
|
550
|
+
const a = ensure() ?? ssrActive ?? undefined;
|
|
551
|
+
// Re-render on a page-pointer change (hydration / navigation) so roots re-resolve.
|
|
552
|
+
// Readiness is symmetric across the SSR/hydration boundary: on the server, `a`
|
|
553
|
+
// is the request's preloaded graph (via `serverActive`) and carries its own
|
|
554
|
+
// roots — the island server-renders warm. At hydration, `<GraphHydrator>` has
|
|
555
|
+
// already absorbed the payload and set the page pointer render-phase (it renders
|
|
556
|
+
// before its sibling islands), so the first client render binds the same data
|
|
557
|
+
// the server rendered — no mismatch. With no preload/payload, both sides render
|
|
558
|
+
// the fallback. The epoch subscription drives re-renders after navigations.
|
|
559
|
+
const epoch = useSyncExternalStore(subscribePage, () => pageEpoch, () => 0);
|
|
560
|
+
// Fine-grained: a fresh read tracker for THIS render. Reads in the component body
|
|
561
|
+
// record which records they touched, and the hook re-renders only when one of those
|
|
562
|
+
// changes — not on every write.
|
|
563
|
+
let tracker: Set<string> | undefined;
|
|
564
|
+
useTracked(
|
|
565
|
+
a?.runtime,
|
|
566
|
+
(affected) => {
|
|
567
|
+
tracker = affected;
|
|
568
|
+
},
|
|
569
|
+
// Read-masking (dev, opt-in): post-commit, flag field reads outside this
|
|
570
|
+
// component's COMPILED read-map — it's rendering data another component
|
|
571
|
+
// fetched, which goes stale/missing when that component's reads change.
|
|
572
|
+
component && opts.readMask
|
|
573
|
+
? (tracked) => {
|
|
574
|
+
const allowed = maskAllowedFor(component);
|
|
575
|
+
const cache = a?.runtime.cache;
|
|
576
|
+
if (!allowed || !cache) return;
|
|
577
|
+
for (const pair of maskViolations(cache, allowed, tracked)) {
|
|
578
|
+
const once = `${component}|${pair}`;
|
|
579
|
+
if (maskWarned.has(once)) continue;
|
|
580
|
+
maskWarned.add(once);
|
|
581
|
+
console.warn(
|
|
582
|
+
`[glean] <${component}> read ${pair} outside its compiled read-map. ` +
|
|
583
|
+
`It renders data another component fetched — read the field in <${component}> itself (or lift the read) so the compiler includes it.`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
: undefined,
|
|
588
|
+
);
|
|
589
|
+
const isServer = typeof window === "undefined";
|
|
590
|
+
if (!a) return undefined;
|
|
591
|
+
if (!isServer && epoch === 0 && !currentPage) return undefined; // no payload yet — pre-data fallback
|
|
592
|
+
void epoch; // subscription only; readiness is decided by `a` + the page pointer
|
|
593
|
+
// Bind the graph with this render's tracker so every read attributes to it directly
|
|
594
|
+
// — fiber-local, not an ambient global, so interleaved concurrent renders can't
|
|
595
|
+
// cross-attribute. (The isomorphic accessor / server `a.graph` carry no tracker.)
|
|
596
|
+
// The page pointer is browser state; a server-side active graph (request scope /
|
|
597
|
+
// `serverActive`) carries its own roots, so SSR reads resolve against those.
|
|
598
|
+
const activeRoots = (a as { roots?: Record<string, FieldValue> }).roots;
|
|
599
|
+
return bindGraph({
|
|
600
|
+
schema: opts.schema,
|
|
601
|
+
getRuntime: () => a.runtime,
|
|
602
|
+
roots: () => currentPage?.roots ?? activeRoots,
|
|
603
|
+
tracker,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function refresh(target?: string | { component: string }): Promise<void> {
|
|
608
|
+
const a = active();
|
|
609
|
+
if (!a || !currentPage) return;
|
|
610
|
+
const operationName = typeof target === "string" ? target : currentPage.operationName;
|
|
611
|
+
try {
|
|
612
|
+
await doRefresh(a, target);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
report({ type: "refresh-error", operation: operationName, error });
|
|
615
|
+
throw error; // callers still observe the failure where they awaited it
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function doRefresh(a: ActiveGraph, target?: string | { component: string }): Promise<void> {
|
|
620
|
+
if (!currentPage) return;
|
|
621
|
+
|
|
622
|
+
// Re-seeding bumps the cache records a refetch touched (entity field changes
|
|
623
|
+
// propagate through fine-grained tracking). But a LIST root's membership lives in
|
|
624
|
+
// `roots`, not in any tracked cache record — adding/removing an element changes the
|
|
625
|
+
// root array, which a reader only sees by re-resolving roots. So fold the fresh
|
|
626
|
+
// roots into the page pointer and bump the page epoch, re-resolving + re-rendering
|
|
627
|
+
// every root reader. (For object roots this is a harmless no-op: the ref is stable
|
|
628
|
+
// and the field-version bump already drove the re-render.)
|
|
629
|
+
const refreshRoots = (roots: Record<string, FieldValue>): void => {
|
|
630
|
+
if (!currentPage) return;
|
|
631
|
+
setCurrentPage({ ...currentPage, roots: { ...currentPage.roots, ...roots } });
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// Component-auto: refetch only what the calling component reads (a slice).
|
|
635
|
+
if (target && typeof target === "object") {
|
|
636
|
+
const op = opts.operations[currentPage.operationName];
|
|
637
|
+
const built = op && buildComponentOperation(op, target.component);
|
|
638
|
+
if (built) {
|
|
639
|
+
const result = await adapterFor().execute(built, currentPage.variables, currentPage.context as GraphRequestContext);
|
|
640
|
+
if (result?.data) refreshRoots(a.runtime.seedResult(result.data as Record<string, unknown>));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// Component not in this op's read-map → fall through to a whole-op refresh.
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const operation = opts.operations[typeof target === "string" ? target : currentPage.operationName];
|
|
647
|
+
if (!operation) return;
|
|
648
|
+
const result = await refetch({
|
|
649
|
+
operation,
|
|
650
|
+
routeContext: { params: currentPage.variables },
|
|
651
|
+
adapter: adapterFor(),
|
|
652
|
+
context: currentPage.context as GraphRequestContext,
|
|
653
|
+
runtime: a.runtime,
|
|
654
|
+
});
|
|
655
|
+
refreshRoots(result.roots);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function runOperation(
|
|
659
|
+
name: string,
|
|
660
|
+
variables: Record<string, unknown> = {},
|
|
661
|
+
): Promise<{ data?: unknown; errors?: ReadonlyArray<{ message: string }> }> {
|
|
662
|
+
const op = opts.operations[name];
|
|
663
|
+
if (!op) {
|
|
664
|
+
throw new Error(`runOperation: unknown operation "${name}" — it must be compiled from a route or registered via the plugin's \`operations\` module.`);
|
|
665
|
+
}
|
|
666
|
+
const a = ensure();
|
|
667
|
+
try {
|
|
668
|
+
const result = await adapterFor().execute(
|
|
669
|
+
{ name: op.name, kind: op.kind, document: op.document, hash: op.hash },
|
|
670
|
+
variables,
|
|
671
|
+
(currentPage?.context as GraphRequestContext | undefined) ?? {},
|
|
672
|
+
);
|
|
673
|
+
const failure = result.errors?.[0]?.message;
|
|
674
|
+
if (failure) report({ type: "operation-error", operation: name, error: failure });
|
|
675
|
+
// Seed even partial data: entity updates propagate to every reader fine-grained.
|
|
676
|
+
if (result.data && a) a.runtime.seedResult(result.data as Record<string, unknown>);
|
|
677
|
+
return result;
|
|
678
|
+
} catch (error) {
|
|
679
|
+
report({ type: "operation-error", operation: name, error });
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// A list-root membership array can hold non-ref values; resolving a record key
|
|
685
|
+
// tolerates that (a non-ref returns undefined and is left untouched). Shared by the
|
|
686
|
+
// splice (dedupe) and index-of (rollback) paths.
|
|
687
|
+
const recordKeyOf = (a: { runtime: GraphRuntime }, value: FieldValue): string | undefined => {
|
|
688
|
+
try {
|
|
689
|
+
return a.runtime.cache.recordKey(value as GraphRef);
|
|
690
|
+
} catch {
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/** Splice a list root's membership in place (no refetch) and re-render its readers. */
|
|
696
|
+
function spliceRoot(rootField: string, entity: unknown, mode: { remove?: boolean; prepend?: boolean; at?: number }): void {
|
|
697
|
+
const a = active();
|
|
698
|
+
if (!a || !currentPage) return;
|
|
699
|
+
const ref = refOf(entity);
|
|
700
|
+
if (!ref) return;
|
|
701
|
+
const keyOf = (value: FieldValue) => recordKeyOf(a, value);
|
|
702
|
+
setCurrentPage({ ...currentPage, roots: spliceRootList(currentPage.roots, rootField, ref, keyOf, mode) });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function appendToRoot(rootField: string, entity: unknown, options?: { prepend?: boolean; at?: number }): void {
|
|
706
|
+
// Optimistic: when handed a client-built entity (not a cached proxy/mutation result),
|
|
707
|
+
// seed its fields so the new row renders immediately, before any server round-trip.
|
|
708
|
+
const a = active();
|
|
709
|
+
const fields = seedableFields(entity);
|
|
710
|
+
if (a && fields) a.runtime.seed(refOf(entity)!, fields);
|
|
711
|
+
spliceRoot(rootField, entity, { prepend: options?.prepend, at: options?.at });
|
|
712
|
+
}
|
|
713
|
+
function removeFromRoot(rootField: string, entity: unknown): void {
|
|
714
|
+
spliceRoot(rootField, entity, { remove: true });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** The entity's current index in a list root (for restoring its place on a rollback). */
|
|
718
|
+
function rootIndexOf(a: { runtime: GraphRuntime }, rootField: string, entity: unknown): number | undefined {
|
|
719
|
+
const ref = refOf(entity);
|
|
720
|
+
const list = currentPage?.roots[rootField];
|
|
721
|
+
if (!ref || !Array.isArray(list)) return undefined;
|
|
722
|
+
const key = recordKeyOf(a, ref);
|
|
723
|
+
const i = list.findIndex((r) => recordKeyOf(a, r) === key);
|
|
724
|
+
return i >= 0 ? i : undefined;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function usePaginated(connection: unknown, options?: UsePaginatedOptions): UsePaginatedResult {
|
|
728
|
+
const a = ensure();
|
|
729
|
+
// Fine-grained: re-render when the paginated connection's own record changes
|
|
730
|
+
// (a fetched page lands via `appendConnection`), not on every cache write.
|
|
731
|
+
useTracked(a?.runtime, (affected) => {
|
|
732
|
+
const sel = selectionOf(connection);
|
|
733
|
+
if (a && sel) {
|
|
734
|
+
try {
|
|
735
|
+
affected.add(a.runtime.cache.recordKey(sel.ref));
|
|
736
|
+
} catch {
|
|
737
|
+
/* connection without identity/path — leave untracked */
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
const [isLoading, setLoading] = useState(false);
|
|
742
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
743
|
+
|
|
744
|
+
// The connection value is fresh each render; the stable `fetchMore` reads it here.
|
|
745
|
+
const latest = useLatest(connection);
|
|
746
|
+
const merge = options?.merge;
|
|
747
|
+
|
|
748
|
+
const fetchMore = useCallback(
|
|
749
|
+
async (args: Record<string, unknown>): Promise<boolean> => {
|
|
750
|
+
const act = active();
|
|
751
|
+
if (!act || !currentPage) return false;
|
|
752
|
+
setLoading(true);
|
|
753
|
+
setError(undefined);
|
|
754
|
+
try {
|
|
755
|
+
const res = await paginateConnection({
|
|
756
|
+
connection: latest.current,
|
|
757
|
+
args,
|
|
758
|
+
merge,
|
|
759
|
+
schema: opts.schema,
|
|
760
|
+
operations: opts.operations,
|
|
761
|
+
adapter: adapterFor(),
|
|
762
|
+
runtime: act.runtime,
|
|
763
|
+
page: currentPage,
|
|
764
|
+
});
|
|
765
|
+
if (res.error) setError(res.error);
|
|
766
|
+
return res.ok;
|
|
767
|
+
} finally {
|
|
768
|
+
setLoading(false);
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
[merge],
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
return { fetchMore, isLoading, error };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function useMutation<TData, TVars>(
|
|
778
|
+
_selector: unknown,
|
|
779
|
+
options?: UseMutationOptions<TData, TVars>,
|
|
780
|
+
opName?: string,
|
|
781
|
+
): UseMutationResult<TData, TVars> {
|
|
782
|
+
ensure();
|
|
783
|
+
// No cache subscription here: `setState` drives the hook's own loading/data/error,
|
|
784
|
+
// and any component displaying a mutated entity reads it through `useGlean`, which
|
|
785
|
+
// re-renders fine-grained when that entity's record changes.
|
|
786
|
+
const [state, setState] = useState<MutationState<TData>>({ isLoading: false, userErrors: [] });
|
|
787
|
+
|
|
788
|
+
// Options are fresh each render; the stable `mutate` reads them here.
|
|
789
|
+
const latestOptions = useLatest(options);
|
|
790
|
+
|
|
791
|
+
const mutate = useCallback(
|
|
792
|
+
async (vars: TVars): Promise<MutationResult<TData>> => {
|
|
793
|
+
const act = active();
|
|
794
|
+
if (!act || !opName) {
|
|
795
|
+
const message = opName ? "no active graph runtime" : "useMutation: missing operation binding";
|
|
796
|
+
const result = mutationFailure<TData>(message);
|
|
797
|
+
setState({ isLoading: false, userErrors: [], error: message });
|
|
798
|
+
latestOptions.current?.onError?.(result);
|
|
799
|
+
return result;
|
|
800
|
+
}
|
|
801
|
+
setState((s) => ({ ...s, isLoading: true, error: undefined }));
|
|
802
|
+
// Optimistic membership: splice list roots NOW (the row appears/disappears before
|
|
803
|
+
// the request) and record how to undo it; rolled back below if the mutation fails.
|
|
804
|
+
const tx = latestOptions.current?.optimisticRoots
|
|
805
|
+
? createMembershipTx({
|
|
806
|
+
append: appendToRoot,
|
|
807
|
+
remove: removeFromRoot,
|
|
808
|
+
indexOf: (field, entity) => rootIndexOf(act, field, entity),
|
|
809
|
+
evictOptimistic: (entity) => {
|
|
810
|
+
const ref = refOf(entity);
|
|
811
|
+
if (ref && seedableFields(entity)) act.runtime.invalidate(ref);
|
|
812
|
+
},
|
|
813
|
+
})
|
|
814
|
+
: undefined;
|
|
815
|
+
if (tx) latestOptions.current!.optimisticRoots!(tx.membership, vars);
|
|
816
|
+
const result = await runBoundMutation<TData, TVars>({
|
|
817
|
+
opName,
|
|
818
|
+
vars,
|
|
819
|
+
options: latestOptions.current,
|
|
820
|
+
operations: opts.operations,
|
|
821
|
+
adapter: adapterFor(),
|
|
822
|
+
runtime: act.runtime,
|
|
823
|
+
context: (currentPage?.context as GraphRequestContext | undefined) ?? {},
|
|
824
|
+
});
|
|
825
|
+
if (!result.ok) tx?.rollback();
|
|
826
|
+
// Transport/GraphQL failures are incidents; `userErrors` are expected
|
|
827
|
+
// domain outcomes and are NOT reported.
|
|
828
|
+
const failure = result.errors?.[0]?.message;
|
|
829
|
+
if (failure) report({ type: "mutation-error", operation: opName, error: failure });
|
|
830
|
+
setState({ isLoading: false, data: result.data, error: failure, userErrors: result.userErrors });
|
|
831
|
+
if (result.ok) latestOptions.current?.onCompleted?.(result.data);
|
|
832
|
+
else latestOptions.current?.onError?.(result);
|
|
833
|
+
return result;
|
|
834
|
+
},
|
|
835
|
+
[opName],
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
return [mutate, state];
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function useSubscription<TData, TVars>(
|
|
842
|
+
_selector: unknown,
|
|
843
|
+
options?: UseSubscriptionOptions<TData, TVars>,
|
|
844
|
+
opName?: string,
|
|
845
|
+
): SubscriptionState<TData> {
|
|
846
|
+
ensure();
|
|
847
|
+
const [state, setState] = useState<SubscriptionState<TData>>({});
|
|
848
|
+
// Options are fresh each render; the stream's callbacks read them here.
|
|
849
|
+
const latest = useLatest(options);
|
|
850
|
+
// Re-open the stream only when the operation or its variables change.
|
|
851
|
+
const varsKey = JSON.stringify(options?.variables ?? null);
|
|
852
|
+
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
const act = active();
|
|
855
|
+
if (!act || !opName || typeof window === "undefined") return;
|
|
856
|
+
return runBoundSubscription<TData, TVars>({
|
|
857
|
+
opName,
|
|
858
|
+
vars: (latest.current?.variables ?? {}) as TVars,
|
|
859
|
+
operations: opts.operations,
|
|
860
|
+
adapter: adapterFor(),
|
|
861
|
+
runtime: act.runtime,
|
|
862
|
+
context: (currentPage?.context as GraphRequestContext | undefined) ?? {},
|
|
863
|
+
onData: (data) => {
|
|
864
|
+
setState({ data, error: undefined });
|
|
865
|
+
latest.current?.onData?.(data);
|
|
866
|
+
},
|
|
867
|
+
onError: (message) => {
|
|
868
|
+
report({ type: "subscription-error", operation: opName, error: message });
|
|
869
|
+
setState((s) => ({ ...s, error: message }));
|
|
870
|
+
latest.current?.onError?.(message);
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
874
|
+
}, [opName, varsKey]);
|
|
875
|
+
|
|
876
|
+
return state;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return { useGlean, refresh, runOperation, onEvent, appendToRoot, removeFromRoot, usePaginated, useMutation, useSubscription, hydrate, GraphHydrator };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** A failed `MutationResult` carrying a single transport-level message. */
|
|
883
|
+
function mutationFailure<TData>(message: string): MutationResult<TData> {
|
|
884
|
+
return { userErrors: [], errors: [{ message }], ok: false };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Resolve a compiled op by name + kind and map its variables, or return an error message. */
|
|
888
|
+
function resolveBoundOp(
|
|
889
|
+
operations: Record<string, CompiledOperation>,
|
|
890
|
+
opName: string,
|
|
891
|
+
kind: "mutation" | "subscription",
|
|
892
|
+
vars: unknown,
|
|
893
|
+
): { op: CompiledOperation; variables: Record<string, unknown> } | { error: string } {
|
|
894
|
+
const op = operations[opName];
|
|
895
|
+
if (!op || op.kind !== kind) return { error: `unknown ${kind} operation: ${opName}` };
|
|
896
|
+
return { op, variables: (op.variables(vars) ?? {}) as Record<string, unknown> };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Params for {@link runBoundSubscription}. */
|
|
900
|
+
export interface RunBoundSubscriptionParams<TData, TVars> {
|
|
901
|
+
/** The compiled subscription operation's name (injected at build time). */
|
|
902
|
+
readonly opName: string;
|
|
903
|
+
/** The selector's `vars` — mapped to GraphQL variables by the op's factory. */
|
|
904
|
+
readonly vars: TVars;
|
|
905
|
+
readonly operations: Record<string, CompiledOperation>;
|
|
906
|
+
readonly adapter: GraphClientAdapter;
|
|
907
|
+
readonly runtime: GraphRuntime;
|
|
908
|
+
readonly context: GraphRequestContext;
|
|
909
|
+
readonly onData?: (data: TData) => void;
|
|
910
|
+
readonly onError?: (message: string) => void;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The non-hook core of `useSubscription`: resolve the compiled subscription, open the
|
|
915
|
+
* adapter's stream, and fold each pushed result into the cache (so readers re-render
|
|
916
|
+
* fine-grained). Returns an unsubscribe function. Exported so it can be tested without
|
|
917
|
+
* a React renderer — the hook is a thin wrapper that adds state + lifecycle.
|
|
918
|
+
*/
|
|
919
|
+
export function runBoundSubscription<TData = unknown, TVars = Record<string, unknown>>(
|
|
920
|
+
params: RunBoundSubscriptionParams<TData, TVars>,
|
|
921
|
+
): () => void {
|
|
922
|
+
const { opName, vars, operations, adapter, runtime, context, onData, onError } = params;
|
|
923
|
+
const resolved = resolveBoundOp(operations, opName, "subscription", vars);
|
|
924
|
+
if ("error" in resolved) {
|
|
925
|
+
onError?.(resolved.error);
|
|
926
|
+
return () => {};
|
|
927
|
+
}
|
|
928
|
+
if (!adapter.subscribe) {
|
|
929
|
+
onError?.("adapter does not support subscriptions");
|
|
930
|
+
return () => {};
|
|
931
|
+
}
|
|
932
|
+
const { op, variables } = resolved;
|
|
933
|
+
const iterator = adapter
|
|
934
|
+
.subscribe({ name: op.name, kind: "subscription", document: op.document }, variables, context)
|
|
935
|
+
[Symbol.asyncIterator]();
|
|
936
|
+
|
|
937
|
+
let active = true;
|
|
938
|
+
void (async () => {
|
|
939
|
+
try {
|
|
940
|
+
while (active) {
|
|
941
|
+
const { value, done } = await iterator.next();
|
|
942
|
+
if (done || !active) break;
|
|
943
|
+
if (value?.errors?.length) {
|
|
944
|
+
onError?.(value.errors[0]!.message);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (value?.data) {
|
|
948
|
+
runtime.seedResult(value.data as Record<string, unknown>);
|
|
949
|
+
onData?.(value.data as TData);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} catch (err) {
|
|
953
|
+
if (active) onError?.(errorMessage(err));
|
|
954
|
+
}
|
|
955
|
+
})();
|
|
956
|
+
|
|
957
|
+
return () => {
|
|
958
|
+
active = false;
|
|
959
|
+
void iterator.return?.();
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Params for {@link runBoundMutation}. */
|
|
964
|
+
export interface RunBoundMutationParams<TData, TVars> {
|
|
965
|
+
/** The compiled mutation operation's name (injected at build time). */
|
|
966
|
+
readonly opName: string;
|
|
967
|
+
/** The `mutate(vars)` argument — mapped to GraphQL variables by the op's factory. */
|
|
968
|
+
readonly vars: TVars;
|
|
969
|
+
readonly options?: UseMutationOptions<TData, TVars>;
|
|
970
|
+
readonly operations: Record<string, CompiledOperation>;
|
|
971
|
+
readonly adapter: GraphClientAdapter;
|
|
972
|
+
readonly runtime: GraphRuntime;
|
|
973
|
+
readonly context: GraphRequestContext;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* The non-hook core of `useMutation`: resolve the compiled mutation by name, map
|
|
978
|
+
* the caller's `vars` to GraphQL variables via the op's factory, and run it through
|
|
979
|
+
* the engine (normalize + optimistic + invalidate). Exported so it can be tested
|
|
980
|
+
* without a React renderer — the hook is a thin wrapper adding loading state and a
|
|
981
|
+
* cache subscription.
|
|
982
|
+
*/
|
|
983
|
+
export async function runBoundMutation<TData = unknown, TVars = Record<string, unknown>>(
|
|
984
|
+
params: RunBoundMutationParams<TData, TVars>,
|
|
985
|
+
): Promise<MutationResult<TData>> {
|
|
986
|
+
const { opName, vars, options, operations, adapter, runtime, context } = params;
|
|
987
|
+
const resolved = resolveBoundOp(operations, opName, "mutation", vars);
|
|
988
|
+
if ("error" in resolved) return mutationFailure<TData>(resolved.error);
|
|
989
|
+
const { op, variables } = resolved;
|
|
990
|
+
return runMutation<TData>({
|
|
991
|
+
operation: { name: op.name, kind: "mutation", document: op.document },
|
|
992
|
+
variables,
|
|
993
|
+
adapter,
|
|
994
|
+
context,
|
|
995
|
+
runtime,
|
|
996
|
+
...(options?.optimistic ? { optimistic: options.optimistic } : {}),
|
|
997
|
+
...(options?.update ? { update: options.update } : {}),
|
|
998
|
+
...(options?.invalidate ? { invalidate: options.invalidate } : {}),
|
|
999
|
+
});
|
|
1000
|
+
}
|