@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.
@@ -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
+ }