@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,46 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import type { ComponentType, ReactNode } from "react";
3
+ import { serializeGraph, type ActiveRequestGraph, type GraphHydrationPayload } from "./index.js";
4
+
5
+ /**
6
+ * The server-side RSC glue. The generated `@gleanql/client/server` entrypoint is a
7
+ * thin shim that passes its framework's active-graph resolver + the client
8
+ * `GraphHydrator`, and re-exports `GraphHydrate`/`withGraphHydration`. Authored
9
+ * here (typed) rather than as a template string.
10
+ *
11
+ * `GraphHydrate` serializes this request's cache and renders the client hydrator
12
+ * with it as a prop, so the snapshot rides the RSC flight stream. `withGraphHydration`
13
+ * is the HOC the build plugin uses to auto-wrap each route component.
14
+ */
15
+ export interface GraphServerOptions {
16
+ /** Resolve this request's active graph (null on non-graph routes). */
17
+ readonly getActive: () => ActiveRequestGraph | null;
18
+ /** The client hydrator component (from the generated client entrypoint). */
19
+ readonly GraphHydrator: ComponentType<{ payload: GraphHydrationPayload; children?: ReactNode }>;
20
+ }
21
+
22
+ export interface GraphServer {
23
+ GraphHydrate(props?: { clientSafeContext?: readonly string[]; children?: ReactNode }): ReactNode;
24
+ withGraphHydration<P extends object>(Page: ComponentType<P>): ComponentType<P>;
25
+ }
26
+
27
+ export function createGraphServer(opts: GraphServerOptions): GraphServer {
28
+ // The page renders INSIDE the hydrator: in the SSR pass the hydrator provides
29
+ // this request's graph through React context (request-isolated by construction),
30
+ // so `useGlean()` islands server-render warm. The payload prop still rides the
31
+ // flight stream for the browser's hydration.
32
+ function GraphHydrate(props?: { clientSafeContext?: readonly string[]; children?: ReactNode }): ReactNode {
33
+ const active = opts.getActive();
34
+ if (!active) return props?.children ?? null;
35
+ const payload = serializeGraph(active, { clientSafeContext: props?.clientSafeContext ?? [] });
36
+ return jsx(opts.GraphHydrator, { payload, children: props?.children });
37
+ }
38
+
39
+ function withGraphHydration<P extends object>(Page: ComponentType<P>): ComponentType<P> {
40
+ return function GraphHydratedPage(props: P) {
41
+ return jsx(GraphHydrate, { children: jsx(Page, props) });
42
+ };
43
+ }
44
+
45
+ return { GraphHydrate, withGraphHydration };
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@gleanql/client` — the runtime an app installs.
3
+ *
4
+ * Bundles everything the app/worker needs at runtime: the Suspense-aware cache,
5
+ * graph proxies, request scope, mutations, the RedwoodSDK integration
6
+ * (preload/seed, serialize/hydrate), and the fetch transport adapter. The build
7
+ * plugin (`@gleanql/vite`) generates the schema-specific `graph` accessor +
8
+ * `operations` into this package's `generated/` slot; the app then imports
9
+ * everything from `@gleanql/client`.
10
+ */
11
+ // Runtime core
12
+ export * from "./adapter.js";
13
+ export * from "./cache.js";
14
+ export * from "./normalize.js";
15
+ export * from "./runtime.js";
16
+ export * from "./route.js";
17
+ export * from "./cache-resolve.js";
18
+ export * from "./proxy.js";
19
+ export * from "./scope.js";
20
+ export * from "./mutation.js";
21
+ export * from "./mutator.js";
22
+ // RedwoodSDK integration
23
+ export * from "./context.js";
24
+ export * from "./integration.js";
25
+ export * from "./serialize.js";
26
+ // Transport adapter helpers
27
+ export * from "./adapter-shared.js";
28
+ export * from "./adapter-ws.js";
29
+ // Persisted-operation allowlist (server side)
30
+ export * from "./persisted.js";
@@ -0,0 +1,201 @@
1
+ import {
2
+ GraphRuntime,
3
+ bindGraph,
4
+ createMutator,
5
+ invalidateValue,
6
+ runRoute,
7
+ type BoundGraph,
8
+ type BoundMutations,
9
+ type CompiledOperation,
10
+ type FieldValue,
11
+ type GraphClientAdapter,
12
+ type GraphRef,
13
+ type GraphRequestContext,
14
+ type GraphScope,
15
+ type MissingFieldMode,
16
+ type MissingFieldRead,
17
+ type MissingFieldResult,
18
+ } from "./index.js";
19
+ import type { SchemaModel } from "@gleanql/core";
20
+ import { buildRouteContext, type BuildRouteContextOptions, type GraphRouteContext, type RequestInfo } from "./context.js";
21
+
22
+ /**
23
+ * RedwoodSDK integration.
24
+ *
25
+ * Answers the four questions the brief asks of a framework adapter:
26
+ * - Which operation drives this entrypoint? -> `resolveOperationName` / explicit name
27
+ * - How do we read params/search/request/env? -> `buildRouteContext`
28
+ * - How do we preload + seed? -> `runRoute` into a fresh per-request cache
29
+ * - How do we expose the graph to components? -> attach a bound graph to `ctx`
30
+ *
31
+ * Per request: pick the operation, compute variables, execute via the client
32
+ * adapter, seed a fresh cache, and attach `{ runtime, graph, ... }` to
33
+ * `requestInfo.ctx` so Pages/components read graph fields with cache hits. Unseeded
34
+ * (lazy) fields fall through to the Suspense runtime.
35
+ */
36
+
37
+ const CTX_KEY = "__graph" as const;
38
+
39
+ export interface ActiveRequestGraph extends GraphRequestContext {
40
+ readonly runtime: GraphRuntime;
41
+ readonly graph: BoundGraph;
42
+ /** `graph.mutate.*` — one callable per compiled mutation operation. */
43
+ readonly mutate: BoundMutations;
44
+ readonly roots: Record<string, FieldValue>;
45
+ readonly operation: CompiledOperation<GraphRouteContext>;
46
+ readonly variables: Record<string, unknown>;
47
+ /** The route/request context (used for transport + missing-field fetches). */
48
+ readonly requestContext: GraphRouteContext;
49
+ readonly errors?: ReadonlyArray<{ message: string }>;
50
+ }
51
+
52
+ export interface GraphIntegrationOptions<Ctx extends Record<string, unknown> = Record<string, unknown>>
53
+ extends BuildRouteContextOptions<Ctx> {
54
+ readonly schema: SchemaModel;
55
+ /** Compiled operations, keyed by name (e.g. from `virtual:graph/operations`). */
56
+ readonly operations: Record<string, CompiledOperation<GraphRouteContext>>;
57
+ /** Transport: a fetch/graphql-request adapter. */
58
+ readonly adapter: GraphClientAdapter;
59
+ /** Map a request to its operation name when `preload` is called without one. */
60
+ readonly resolveOperationName?: (requestInfo: RequestInfo<Ctx>) => string | undefined;
61
+ /**
62
+ * Fetch fields absent from the compiled operation (lazy boundaries, dynamic
63
+ * misses). Receives the batched misses + request context; returns resolved
64
+ * values. If omitted, misses are allowed/warned per `unexpectedMissingField`
65
+ * (hybrid mode) and resolve to `undefined`.
66
+ */
67
+ readonly fetchMissing?: (
68
+ misses: readonly MissingFieldRead[],
69
+ context: GraphRequestContext,
70
+ ) => Promise<readonly MissingFieldResult[]>;
71
+ readonly unexpectedMissingField?: MissingFieldMode;
72
+ readonly onWarn?: (message: string) => void;
73
+ /** Allow-list of `context` keys that are safe to serialize to the client. */
74
+ readonly clientSafeContext?: readonly string[];
75
+ /** Optional scope to make a module-level `graph` import resolve this runtime. */
76
+ readonly scope?: GraphScope;
77
+ }
78
+
79
+ export interface GraphIntegration<Ctx extends Record<string, unknown>> {
80
+ /** Preload + seed the operation for a request; attaches the graph to `ctx`. */
81
+ preload(requestInfo: RequestInfo<Ctx>, operationName?: string): Promise<ActiveRequestGraph | undefined>;
82
+ /** Read the active graph attached to a request (throws if not preloaded). */
83
+ getGraph(requestInfo: RequestInfo<Ctx>): BoundGraph;
84
+ /** The `graph.mutate.*` namespace for this request (throws if not preloaded). */
85
+ getMutator(requestInfo: RequestInfo<Ctx>): BoundMutations;
86
+ /** Invalidate a graph value / ref in this request's cache (refetch on next read). */
87
+ invalidate(requestInfo: RequestInfo<Ctx>, value: GraphRef | unknown): void;
88
+ /** Read the full active request state (runtime, roots, variables, ...). */
89
+ getActive(requestInfo: RequestInfo<Ctx>): ActiveRequestGraph | undefined;
90
+ /** Re-run the active (or named) operation, bypassing cache-first, into the same cache. */
91
+ refetch(requestInfo: RequestInfo<Ctx>, operationName?: string): Promise<void>;
92
+ /** Run `fn` with this request's runtime installed on the scope (server render). */
93
+ runInScope<R>(requestInfo: RequestInfo<Ctx>, fn: () => R): R;
94
+ }
95
+
96
+ export function createGraphIntegration<Ctx extends Record<string, unknown> = Record<string, unknown>>(
97
+ options: GraphIntegrationOptions<Ctx>,
98
+ ): GraphIntegration<Ctx> {
99
+ // Identify entities by the schema's key fields (default `id`, or a `keys`
100
+ // override), so types keyed by something other than `id` normalize correctly.
101
+ const keyOf = (typename: string, obj: Record<string, unknown>) => options.schema.identityOf(typename, obj);
102
+
103
+ function makeRuntime(requestContext: GraphRouteContext): GraphRuntime {
104
+ return new GraphRuntime({
105
+ keyOf,
106
+ unexpectedMissingField: options.unexpectedMissingField,
107
+ onWarn: options.onWarn,
108
+ fetchMissing: async (misses) =>
109
+ options.fetchMissing ? options.fetchMissing(misses, requestContext) : missesUnresolved(misses),
110
+ });
111
+ }
112
+
113
+ async function preload(
114
+ requestInfo: RequestInfo<Ctx>,
115
+ operationName?: string,
116
+ ): Promise<ActiveRequestGraph | undefined> {
117
+ const name = operationName ?? options.resolveOperationName?.(requestInfo);
118
+ const operation = name ? options.operations[name] : undefined;
119
+ if (!operation) return undefined; // not a graph-backed entrypoint
120
+
121
+ const requestContext = buildRouteContext(requestInfo, options);
122
+ const runtime = makeRuntime(requestContext);
123
+ const { variables, roots, errors } = await runRoute({
124
+ operation,
125
+ routeContext: requestContext,
126
+ adapter: options.adapter,
127
+ context: requestContext,
128
+ runtime,
129
+ });
130
+ const graph = bindGraph({ schema: options.schema, getRuntime: () => runtime, roots });
131
+ const mutate = createMutator({ operations: options.operations, adapter: options.adapter, runtime, context: requestContext });
132
+
133
+ const active: ActiveRequestGraph = {
134
+ runtime,
135
+ graph,
136
+ mutate,
137
+ roots,
138
+ operation,
139
+ variables,
140
+ requestContext,
141
+ ...(errors ? { errors } : {}),
142
+ };
143
+ (requestInfo.ctx as Record<string, unknown>)[CTX_KEY] = active;
144
+ return active;
145
+ }
146
+
147
+ function getActive(requestInfo: RequestInfo<Ctx>): ActiveRequestGraph | undefined {
148
+ return (requestInfo.ctx as Record<string, unknown>)[CTX_KEY] as ActiveRequestGraph | undefined;
149
+ }
150
+
151
+ async function refetch(requestInfo: RequestInfo<Ctx>, operationName?: string): Promise<void> {
152
+ const active = getActive(requestInfo);
153
+ if (!active) return;
154
+ const operation = options.operations[operationName ?? active.operation.name];
155
+ if (!operation) return;
156
+ // Re-run into the same cache, bypassing cache-first; the re-seed bumps the
157
+ // cache version so subscribers (useSyncExternalStore) re-render.
158
+ await runRoute({
159
+ operation,
160
+ routeContext: active.requestContext,
161
+ adapter: options.adapter,
162
+ context: active.requestContext,
163
+ runtime: active.runtime,
164
+ options: { cacheFirst: false },
165
+ });
166
+ }
167
+
168
+ function getGraph(requestInfo: RequestInfo<Ctx>): BoundGraph {
169
+ const active = getActive(requestInfo);
170
+ if (!active) {
171
+ throw new Error(
172
+ "No graph attached to this request. Call integration.preload(requestInfo) before rendering (e.g. in a middleware or at the top of the Page).",
173
+ );
174
+ }
175
+ return active.graph;
176
+ }
177
+
178
+ function getMutator(requestInfo: RequestInfo<Ctx>): BoundMutations {
179
+ const active = getActive(requestInfo);
180
+ if (!active) throw new Error("No graph attached to this request. Call integration.preload(requestInfo) first.");
181
+ return active.mutate;
182
+ }
183
+
184
+ function invalidate(requestInfo: RequestInfo<Ctx>, value: GraphRef | unknown): void {
185
+ const active = getActive(requestInfo);
186
+ if (active) invalidateValue(active.runtime, value);
187
+ }
188
+
189
+ function runInScope<R>(requestInfo: RequestInfo<Ctx>, fn: () => R): R {
190
+ const active = getActive(requestInfo);
191
+ if (!active || !options.scope) return fn();
192
+ return options.scope.run({ runtime: active.runtime, graph: active.graph }, fn);
193
+ }
194
+
195
+ return { preload, getGraph, getMutator, invalidate, getActive, refetch, runInScope };
196
+ }
197
+
198
+ /** Default missing-field resolution: leave each miss undefined (hybrid allow/warn). */
199
+ function missesUnresolved(misses: readonly MissingFieldRead[]): readonly MissingFieldResult[] {
200
+ return misses.map((m) => ({ ref: m.ref, fieldKey: m.fieldKey, value: undefined }));
201
+ }
@@ -0,0 +1,171 @@
1
+ import { GraphCache, type GraphRef, type FieldValue } from "./cache.js";
2
+ import type { GraphRuntime } from "./runtime.js";
3
+ import type { GraphClientAdapter, GraphRequestContext } from "./adapter.js";
4
+ import type { CompiledOperation } from "./route.js";
5
+ import { selectionOf } from "./proxy.js";
6
+
7
+ /**
8
+ * Mutations + invalidation.
9
+ *
10
+ * Reads were the first milestone; this is the write side. A mutation is run
11
+ * through the same client adapter as a query, and its result is normalized into
12
+ * the cache — so any entity it returns (`__typename + id`) updates *in place* and
13
+ * every read of that entity, through any path, reflects the change for free.
14
+ * That is the payoff of the normalized cache the brief asked for.
15
+ *
16
+ * On top of that the engine adds: GraphQL-style `userErrors`, optimistic writes
17
+ * with automatic rollback, and invalidation of affected graph values/roots.
18
+ */
19
+
20
+ /** A GraphQL `userErrors` entry (Shopify-style mutation payloads). */
21
+ export interface UserError {
22
+ readonly field?: readonly string[];
23
+ readonly message: string;
24
+ readonly code?: string;
25
+ }
26
+
27
+ export interface MutationResult<TData = unknown> {
28
+ readonly data?: TData;
29
+ /** Logical, per-mutation errors returned in the payload (not transport errors). */
30
+ readonly userErrors: readonly UserError[];
31
+ /** Transport/GraphQL execution errors. */
32
+ readonly errors?: ReadonlyArray<{ message: string }>;
33
+ /** True when there were no transport errors and no userErrors. */
34
+ readonly ok: boolean;
35
+ }
36
+
37
+ /**
38
+ * A reversible batch of cache writes. Optimistic updates record the prior value
39
+ * of every field they touch so the whole batch can be rolled back if the
40
+ * mutation fails (transport error or `userErrors`).
41
+ */
42
+ export class MutationTransaction {
43
+ private readonly undo: Array<() => void> = [];
44
+
45
+ constructor(private readonly cache: GraphCache) {}
46
+
47
+ /** Optimistically write a field, remembering how to undo it. */
48
+ set(ref: GraphRef, fieldKey: string, value: FieldValue): void {
49
+ const before = this.cache.getField(ref, fieldKey);
50
+ if (before.status === "ready") {
51
+ const prev = before.value;
52
+ this.undo.push(() => this.cache.setField(ref, fieldKey, prev));
53
+ } else {
54
+ this.undo.push(() => this.cache.invalidateField(ref, fieldKey));
55
+ }
56
+ this.cache.setField(ref, fieldKey, value);
57
+ }
58
+
59
+ /** Roll back every write in reverse order. */
60
+ rollback(): void {
61
+ for (let i = this.undo.length - 1; i >= 0; i--) this.undo[i]!();
62
+ this.undo.length = 0;
63
+ }
64
+ }
65
+
66
+ export interface RunMutationOptions<TData = unknown> {
67
+ readonly operation: CompiledOperation<unknown, Record<string, unknown>> | {
68
+ readonly name: string;
69
+ readonly kind: "mutation";
70
+ readonly document: string;
71
+ };
72
+ readonly variables: Record<string, unknown>;
73
+ readonly adapter: GraphClientAdapter;
74
+ readonly context: GraphRequestContext;
75
+ readonly runtime: GraphRuntime;
76
+ /** Optimistically patch the cache before the request; rolled back on failure. */
77
+ readonly optimistic?: (tx: MutationTransaction) => void;
78
+ /** Apply the server result (e.g. prepend to a connection) after normalization. */
79
+ readonly update?: (data: TData, tx: MutationTransaction) => void;
80
+ /** Graph values / refs to invalidate on success (refetch on next read). */
81
+ readonly invalidate?: (data: TData) => ReadonlyArray<GraphRef | unknown>;
82
+ }
83
+
84
+ /**
85
+ * Execute a mutation, normalize its result into the cache, surface userErrors,
86
+ * and apply optimistic/invalidation policy. The returned promise never rejects
87
+ * for logical failures — inspect `ok`/`userErrors`/`errors`.
88
+ */
89
+ export async function runMutation<TData = Record<string, unknown>>(
90
+ options: RunMutationOptions<TData>,
91
+ ): Promise<MutationResult<TData>> {
92
+ const { runtime, adapter, context, variables } = options;
93
+ const tx = new MutationTransaction(runtime.cache);
94
+ if (options.optimistic) options.optimistic(tx);
95
+
96
+ let result;
97
+ try {
98
+ result = await adapter.execute(
99
+ { name: options.operation.name, kind: "mutation", document: options.operation.document },
100
+ variables,
101
+ context,
102
+ );
103
+ } catch (error) {
104
+ tx.rollback();
105
+ return { userErrors: [], errors: [{ message: errorMessage(error) }], ok: false };
106
+ }
107
+
108
+ if (result.errors && result.errors.length > 0) {
109
+ tx.rollback();
110
+ return { userErrors: [], errors: result.errors, ok: false };
111
+ }
112
+
113
+ const data = result.data as TData | undefined;
114
+ const userErrors = data ? extractUserErrors(data as Record<string, unknown>) : [];
115
+
116
+ if (userErrors.length > 0) {
117
+ // The server rejected the change: undo the optimistic patch and report.
118
+ tx.rollback();
119
+ return { data, userErrors, ok: false };
120
+ }
121
+
122
+ // Success: fold the server result into the cache (entities update in place).
123
+ if (data) runtime.seedResult(data as Record<string, unknown>);
124
+ if (options.update && data) options.update(data, tx);
125
+
126
+ if (options.invalidate && data) {
127
+ for (const target of options.invalidate(data)) {
128
+ const ref = toRef(target);
129
+ if (ref) runtime.invalidate(ref);
130
+ }
131
+ }
132
+
133
+ return { data, userErrors: [], ok: true };
134
+ }
135
+
136
+ /** Invalidate a record by graph value (proxy) or raw ref — next read re-fetches. */
137
+ export function invalidateValue(runtime: GraphRuntime, value: GraphRef | unknown): void {
138
+ const ref = toRef(value);
139
+ if (ref) runtime.invalidate(ref);
140
+ }
141
+
142
+ /** Collect `userErrors` from each top-level mutation payload in the result. */
143
+ function extractUserErrors(data: Record<string, unknown>): UserError[] {
144
+ const out: UserError[] = [];
145
+ for (const value of Object.values(data)) {
146
+ if (value && typeof value === "object" && !Array.isArray(value)) {
147
+ const ue = (value as Record<string, unknown>).userErrors;
148
+ if (Array.isArray(ue)) {
149
+ for (const e of ue) {
150
+ if (e && typeof e === "object") out.push(e as UserError);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ function toRef(value: GraphRef | unknown): GraphRef | undefined {
159
+ const selection = selectionOf(value);
160
+ if (selection) return selection.ref;
161
+ if (value && typeof value === "object") {
162
+ const v = value as Record<string, unknown>;
163
+ if ((v.__typename != null && v.id != null) || typeof v.path === "string") return v as GraphRef;
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ /** One rule for stringifying unknown errors, shared across hooks and transports. */
169
+ export function errorMessage(error: unknown): string {
170
+ return error instanceof Error ? error.message : String(error);
171
+ }
package/src/mutator.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { GraphRuntime } from "./runtime.js";
2
+ import type { GraphClientAdapter, GraphRequestContext } from "./adapter.js";
3
+ import { runMutation, type MutationResult } from "./mutation.js";
4
+
5
+ /** The minimal operation shape the mutator needs (any CompiledOperation satisfies it). */
6
+ export interface MutationOperationLike {
7
+ readonly name: string;
8
+ readonly kind: "query" | "mutation" | "subscription";
9
+ readonly document: string;
10
+ }
11
+
12
+ /**
13
+ * The `graph.mutate.*` namespace.
14
+ *
15
+ * Mutations are compiled operations like queries; this binds one callable per
16
+ * compiled mutation operation so app code writes:
17
+ *
18
+ * await graph.mutate.cartLinesAdd({ cartId, lines }, {
19
+ * optimistic: (tx) => tx.set(cartRef, "totalQuantity", n + 1),
20
+ * invalidate: (data) => [cartRef],
21
+ * });
22
+ *
23
+ * Each call runs the mutation, folds the result into the cache, and applies the
24
+ * optimistic/invalidation policy (see runMutation).
25
+ */
26
+ export type MutateFn = (
27
+ variables: Record<string, unknown>,
28
+ options?: Omit<Parameters<typeof runMutation>[0], "operation" | "variables" | "adapter" | "context" | "runtime">,
29
+ ) => Promise<MutationResult>;
30
+
31
+ export type BoundMutations = Record<string, MutateFn>;
32
+
33
+ export interface CreateMutatorOptions {
34
+ /** Compiled operations; only `kind: "mutation"` entries are bound. */
35
+ readonly operations: Record<string, MutationOperationLike>;
36
+ readonly adapter: GraphClientAdapter;
37
+ readonly runtime: GraphRuntime;
38
+ /** The request context (auth/locale/env) passed to the transport. */
39
+ readonly context: GraphRequestContext;
40
+ }
41
+
42
+ export function createMutator(options: CreateMutatorOptions): BoundMutations {
43
+ const mutate: BoundMutations = {};
44
+ for (const [name, operation] of Object.entries(options.operations)) {
45
+ if (operation.kind !== "mutation") continue;
46
+ const mutationOp = { name: operation.name, kind: "mutation" as const, document: operation.document };
47
+ mutate[name] = (variables, opts) =>
48
+ runMutation({
49
+ operation: mutationOp,
50
+ variables,
51
+ adapter: options.adapter,
52
+ context: options.context,
53
+ runtime: options.runtime,
54
+ ...opts,
55
+ });
56
+ }
57
+ return mutate;
58
+ }
@@ -0,0 +1,101 @@
1
+ import type { GraphCache, GraphRef, FieldValue } from "./cache.js";
2
+
3
+ /**
4
+ * Normalize a GraphQL JSON result into the cache.
5
+ *
6
+ * Every object selection includes `__typename` (and `id` when the type has one),
7
+ * so the result alone carries enough to choose an identity:
8
+ * - `__typename + id` -> normalized entity record, deduped across queries.
9
+ * - id-less object -> embedded under its *owning entity* at the field path
10
+ * since that entity (`Product:123.priceRange`), NOT the query path. So the same
11
+ * nested object reached through two different queries resolves to one record —
12
+ * update it once and every reader sees it, and a second query needn't refetch.
13
+ * - id-less with no entity ancestor (root objects) -> anchored at the operation
14
+ * path (`Query.search(q)`), which is the only correct identity for them.
15
+ * Scalars store inline; object fields store a `GraphRef`; lists store an array.
16
+ *
17
+ * `anchor` is the nearest owning entity's record key (or the root path); `field`
18
+ * is the path from that anchor.
19
+ */
20
+ /**
21
+ * Resolve an object's identity value from its `__typename`, or undefined when
22
+ * the object is id-less (and must be embedded). Defaults to the `id` field;
23
+ * supply a schema-derived resolver to key types by another field (`sku`, `slug`)
24
+ * or a composite.
25
+ */
26
+ export type KeyOf = (typename: string, obj: Record<string, unknown>) => string | undefined;
27
+
28
+ const defaultKeyOf: KeyOf = (_t, obj) => (obj.id != null ? String(obj.id) : undefined);
29
+
30
+ export function normalizeValue(
31
+ cache: GraphCache,
32
+ value: unknown,
33
+ anchor: string,
34
+ field: string,
35
+ keyOf: KeyOf = defaultKeyOf,
36
+ seen: WeakSet<object> = new WeakSet(),
37
+ ): FieldValue {
38
+ if (value === null || typeof value !== "object") return value as FieldValue;
39
+ // GraphQL JSON can't be cyclic, but optimistic/user-built objects can — fail
40
+ // with a clear message instead of blowing the stack. `seen` tracks the CURRENT
41
+ // DESCENT PATH (entries are removed on the way back up), so a DAG — the same
42
+ // object referenced from two siblings — still normalizes fine.
43
+ if (seen.has(value)) {
44
+ throw new Error(`normalizeValue: circular reference at ${anchor}.${field} — cannot normalize cyclic data`);
45
+ }
46
+ seen.add(value);
47
+ try {
48
+ return normalizeNonCyclic(cache, value, anchor, field, keyOf, seen);
49
+ } finally {
50
+ seen.delete(value);
51
+ }
52
+ }
53
+
54
+ function normalizeNonCyclic(
55
+ cache: GraphCache,
56
+ value: object,
57
+ anchor: string,
58
+ field: string,
59
+ keyOf: KeyOf,
60
+ seen: WeakSet<object>,
61
+ ): FieldValue {
62
+ if (Array.isArray(value)) {
63
+ return value.map((item, i) => normalizeValue(cache, item, anchor, `${field}.${i}`, keyOf, seen));
64
+ }
65
+
66
+ const obj = value as Record<string, unknown>;
67
+ const typename = typeof obj.__typename === "string" ? obj.__typename : undefined;
68
+ const identity = typename != null ? keyOf(typename, obj) : undefined;
69
+
70
+ if (typename != null && identity != null) {
71
+ // Identified entity: a new normalization anchor; child paths reset under it.
72
+ const ref: GraphRef = { __typename: typename, id: identity };
73
+ const entityAnchor = cache.recordKey(ref);
74
+ for (const [key, v] of Object.entries(obj)) {
75
+ cache.setField(ref, key, normalizeValue(cache, v, entityAnchor, key, keyOf, seen));
76
+ }
77
+ return ref;
78
+ }
79
+
80
+ // Id-less: embed under the owning entity at `anchor.field` (stays anchored to
81
+ // the same entity as we descend, so it dedupes across queries).
82
+ const ref: GraphRef = { path: `${anchor}.${field}` };
83
+ for (const [key, v] of Object.entries(obj)) {
84
+ cache.setField(ref, key, normalizeValue(cache, v, anchor, `${field}.${key}`, keyOf, seen));
85
+ }
86
+ return ref;
87
+ }
88
+
89
+ /** Seed an operation result; returns each root field's ref for reading. */
90
+ export function seedResult(
91
+ cache: GraphCache,
92
+ data: Readonly<Record<string, unknown>>,
93
+ options: { rootPath?: string; keyOf?: KeyOf } = {},
94
+ ): Record<string, FieldValue> {
95
+ const rootPath = options.rootPath ?? "Query";
96
+ const roots: Record<string, FieldValue> = {};
97
+ for (const [field, value] of Object.entries(data)) {
98
+ roots[field] = normalizeValue(cache, value, rootPath, field, options.keyOf);
99
+ }
100
+ return roots;
101
+ }