@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
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { GraphCache, type GraphRef, type FieldValue } from "./cache.js";
|
|
2
|
+
import { normalizeValue, seedResult, type KeyOf } from "./normalize.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Suspense-aware graph runtime.
|
|
6
|
+
*
|
|
7
|
+
* A field read is synchronous on a cache hit. On a miss it enqueues the missing
|
|
8
|
+
* (ref, field), creates exactly one cached promise for it, and throws that
|
|
9
|
+
* promise (the Suspense contract). Multiple misses in the same tick batch into
|
|
10
|
+
* a single `fetchMissing` call. Re-reading a pending field throws the same
|
|
11
|
+
* promise — no duplicate request, stable across React render retries.
|
|
12
|
+
*/
|
|
13
|
+
export interface MissingFieldRead {
|
|
14
|
+
readonly ref: GraphRef;
|
|
15
|
+
readonly fieldKey: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MissingFieldResult {
|
|
19
|
+
readonly ref: GraphRef;
|
|
20
|
+
readonly fieldKey: string;
|
|
21
|
+
readonly value: FieldValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MissingFieldMode = "allow" | "warn" | "error";
|
|
25
|
+
|
|
26
|
+
export interface GraphRuntimeOptions {
|
|
27
|
+
/** Batched fetcher for fields not present in the seeded operation. */
|
|
28
|
+
readonly fetchMissing: (misses: readonly MissingFieldRead[]) => Promise<readonly MissingFieldResult[]>;
|
|
29
|
+
readonly cache?: GraphCache;
|
|
30
|
+
/** How to identify entities during normalization (defaults to the `id` field). */
|
|
31
|
+
readonly keyOf?: KeyOf;
|
|
32
|
+
/** Behavior when a field absent from the compiled operation is read. */
|
|
33
|
+
readonly unexpectedMissingField?: MissingFieldMode;
|
|
34
|
+
/** dev-only: warn with component/field context. */
|
|
35
|
+
readonly onWarn?: (message: string) => void;
|
|
36
|
+
/** Microtask scheduler (overridable in tests). */
|
|
37
|
+
readonly schedule?: (cb: () => void) => void;
|
|
38
|
+
/** Optional LRU cap for the cache (least-recently-used records evicted past it). */
|
|
39
|
+
readonly maxCacheRecords?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PendingEntry {
|
|
43
|
+
readonly promise: Promise<void>;
|
|
44
|
+
resolve(): void;
|
|
45
|
+
reject(error: unknown): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class GraphRuntime {
|
|
49
|
+
readonly cache: GraphCache;
|
|
50
|
+
private readonly pending = new Map<string, PendingEntry>();
|
|
51
|
+
private queue: MissingFieldRead[] = [];
|
|
52
|
+
private flushScheduled = false;
|
|
53
|
+
|
|
54
|
+
constructor(private readonly options: GraphRuntimeOptions) {
|
|
55
|
+
this.cache = options.cache ?? new GraphCache(options.maxCacheRecords);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Synchronous on hit; throws a (cached) promise on miss. */
|
|
59
|
+
readField(ref: GraphRef, fieldKey: string, debug?: { component?: string }): FieldValue {
|
|
60
|
+
const got = this.cache.getField(ref, fieldKey);
|
|
61
|
+
if (got.status === "ready") return got.value;
|
|
62
|
+
|
|
63
|
+
this.reportMiss(ref, fieldKey, debug);
|
|
64
|
+
|
|
65
|
+
const pkey = this.pendingKey(ref, fieldKey);
|
|
66
|
+
const existing = this.pending.get(pkey);
|
|
67
|
+
if (existing) throw existing.promise;
|
|
68
|
+
|
|
69
|
+
const entry = this.makeDeferred();
|
|
70
|
+
this.pending.set(pkey, entry);
|
|
71
|
+
this.queue.push({ ref, fieldKey });
|
|
72
|
+
this.scheduleFlush();
|
|
73
|
+
throw entry.promise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Seed a record's fields (e.g. from the compiled operation result). */
|
|
77
|
+
seed(ref: GraphRef, fields: Readonly<Record<string, FieldValue>>): void {
|
|
78
|
+
this.cache.merge(ref, fields);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Normalize a full operation result into the cache; returns root refs. */
|
|
82
|
+
seedResult(data: Readonly<Record<string, unknown>>, options?: { rootPath?: string }): Record<string, FieldValue> {
|
|
83
|
+
return seedResult(this.cache, data, { keyOf: this.options.keyOf, ...options });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Low-level pagination primitive: append a freshly-fetched page onto a cached
|
|
88
|
+
* connection. Normalizes the page's `nodes` and concats them after the existing
|
|
89
|
+
* ones, and (if present) replaces `pageInfo`. Every reader of the connection
|
|
90
|
+
* re-renders with the longer `nodes` array. This makes no assumptions about HOW
|
|
91
|
+
* the page was fetched or which cursor convention the schema uses — the app fetches
|
|
92
|
+
* the next page however it likes (the connection's ref is available via
|
|
93
|
+
* `selectionOf(value)`), then hands the page object here to merge it in.
|
|
94
|
+
*/
|
|
95
|
+
appendConnection(
|
|
96
|
+
connectionRef: GraphRef,
|
|
97
|
+
page: Record<string, unknown>,
|
|
98
|
+
mergeRefs?: (existing: readonly FieldValue[], incoming: readonly FieldValue[]) => readonly FieldValue[],
|
|
99
|
+
): void {
|
|
100
|
+
const keyOf = this.options.keyOf;
|
|
101
|
+
const anchor = this.cache.recordKey(connectionRef);
|
|
102
|
+
const existing = this.cache.getField(connectionRef, "nodes");
|
|
103
|
+
const prior = existing.status === "ready" && Array.isArray(existing.value) ? existing.value : [];
|
|
104
|
+
|
|
105
|
+
if (Array.isArray(page.nodes)) {
|
|
106
|
+
const fresh = page.nodes.map((n, i) =>
|
|
107
|
+
normalizeValue(this.cache, n, anchor, `nodes.${prior.length + i}`, keyOf),
|
|
108
|
+
);
|
|
109
|
+
const merged = mergeRefs ? mergeRefs(prior, fresh) : [...prior, ...fresh];
|
|
110
|
+
this.cache.setField(connectionRef, "nodes", [...merged]);
|
|
111
|
+
}
|
|
112
|
+
if (page.pageInfo != null) {
|
|
113
|
+
this.cache.setField(connectionRef, "pageInfo", normalizeValue(this.cache, page.pageInfo, anchor, "pageInfo", keyOf));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Invalidate a record (e.g. after a mutation) and clear its pending reads. */
|
|
118
|
+
invalidate(ref: GraphRef): void {
|
|
119
|
+
const prefix = `${this.cache.recordKey(ref)}.`;
|
|
120
|
+
this.cache.invalidate(ref);
|
|
121
|
+
for (const key of [...this.pending.keys()]) {
|
|
122
|
+
if (key.startsWith(prefix)) this.pending.delete(key);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Serialize the cache for hydration across the server/client boundary. */
|
|
127
|
+
snapshot(): Record<string, Record<string, FieldValue>> {
|
|
128
|
+
return this.cache.snapshot();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fold a snapshot into the live cache (write only, no notify); returns whether
|
|
133
|
+
* anything changed. Use for a per-navigation merge where the notify is deferred
|
|
134
|
+
* to a commit-phase effect (see `serialize.ts#absorbHydrationPayload`).
|
|
135
|
+
*/
|
|
136
|
+
absorbRecords(snapshot: Record<string, Record<string, FieldValue>>): boolean {
|
|
137
|
+
return this.cache.absorbRecords(snapshot);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Notify subscribers after one or more `absorbRecords` calls. */
|
|
141
|
+
notify(): void {
|
|
142
|
+
this.cache.notify();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Convenience: absorb a snapshot and notify if it changed (non-React callers). */
|
|
146
|
+
absorb(snapshot: Record<string, Record<string, FieldValue>>): boolean {
|
|
147
|
+
const changed = this.cache.absorbRecords(snapshot);
|
|
148
|
+
if (changed) this.cache.notify();
|
|
149
|
+
return changed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
static hydrate(
|
|
153
|
+
snapshot: Record<string, Record<string, FieldValue>>,
|
|
154
|
+
options: Omit<GraphRuntimeOptions, "cache">,
|
|
155
|
+
): GraphRuntime {
|
|
156
|
+
return new GraphRuntime({ ...options, cache: GraphCache.fromSnapshot(snapshot, options.maxCacheRecords) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private reportMiss(ref: GraphRef, fieldKey: string, debug?: { component?: string }): void {
|
|
160
|
+
const mode = this.options.unexpectedMissingField ?? "allow";
|
|
161
|
+
if (mode === "allow") return;
|
|
162
|
+
const where = debug?.component ? ` (read by ${debug.component})` : "";
|
|
163
|
+
const message = `Runtime graph field miss: ${this.cache.recordKey(ref)}.${fieldKey}${where} was not in the compiled operation.`;
|
|
164
|
+
if (mode === "error") throw new Error(message);
|
|
165
|
+
(this.options.onWarn ?? ((m) => console.warn(m)))(message);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private scheduleFlush(): void {
|
|
169
|
+
if (this.flushScheduled) return;
|
|
170
|
+
this.flushScheduled = true;
|
|
171
|
+
const schedule = this.options.schedule ?? queueMicrotask;
|
|
172
|
+
schedule(() => void this.flush());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async flush(): Promise<void> {
|
|
176
|
+
this.flushScheduled = false;
|
|
177
|
+
const misses = this.queue;
|
|
178
|
+
this.queue = [];
|
|
179
|
+
if (misses.length === 0) return;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const results = await this.options.fetchMissing(misses);
|
|
183
|
+
for (const r of results) this.cache.setField(r.ref, r.fieldKey, r.value);
|
|
184
|
+
for (const miss of misses) {
|
|
185
|
+
const pkey = this.pendingKey(miss.ref, miss.fieldKey);
|
|
186
|
+
this.pending.get(pkey)?.resolve();
|
|
187
|
+
this.pending.delete(pkey);
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
for (const miss of misses) {
|
|
191
|
+
const pkey = this.pendingKey(miss.ref, miss.fieldKey);
|
|
192
|
+
this.pending.get(pkey)?.reject(error);
|
|
193
|
+
this.pending.delete(pkey);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Stable key for a pending (ref, field) read — also the `invalidate` prefix base. */
|
|
199
|
+
private pendingKey(ref: GraphRef, fieldKey: string): string {
|
|
200
|
+
return `${this.cache.recordKey(ref)}.${fieldKey}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private makeDeferred(): PendingEntry {
|
|
204
|
+
let resolve!: () => void;
|
|
205
|
+
let reject!: (e: unknown) => void;
|
|
206
|
+
const promise = new Promise<void>((res, rej) => {
|
|
207
|
+
resolve = res;
|
|
208
|
+
reject = rej;
|
|
209
|
+
});
|
|
210
|
+
return { promise, resolve, reject };
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { GraphRuntime } from "./runtime.js";
|
|
2
|
+
import type { BoundGraph } from "./proxy.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request-scoped runtime resolution.
|
|
6
|
+
*
|
|
7
|
+
* A module-level `import { graph } from "~/graph"` must resolve to *the runtime
|
|
8
|
+
* for the current request* on the server (concurrent requests must not share a
|
|
9
|
+
* cache) and to a single client runtime in the browser. `GraphScope` is the tiny
|
|
10
|
+
* seam that makes that possible without threading a runtime through every prop.
|
|
11
|
+
*
|
|
12
|
+
* On the server, a framework adapter (RWSDK) wraps request handling in
|
|
13
|
+
* `scope.run(active, fn)`. If the host exposes AsyncLocalStorage, concurrent
|
|
14
|
+
* requests are isolated automatically; otherwise the adapter should resolve the
|
|
15
|
+
* runtime from its own per-request context (`getGraph(requestInfo)`), which never
|
|
16
|
+
* relies on a shared mutable global. On the client, `scope.set(active)` installs
|
|
17
|
+
* the singleton after hydration.
|
|
18
|
+
*/
|
|
19
|
+
export interface ActiveGraph {
|
|
20
|
+
readonly runtime: GraphRuntime;
|
|
21
|
+
readonly graph: BoundGraph;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AsyncLocalStorageLike<T> {
|
|
25
|
+
getStore(): T | undefined;
|
|
26
|
+
run<R>(store: T, fn: () => R): R;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class GraphScope {
|
|
30
|
+
private singleton: ActiveGraph | undefined;
|
|
31
|
+
private als: AsyncLocalStorageLike<ActiveGraph> | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(als?: AsyncLocalStorageLike<ActiveGraph>) {
|
|
34
|
+
this.als = als;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Attach an AsyncLocalStorage after construction — for isomorphic frameworks
|
|
39
|
+
* (e.g. React Router) where the *same* `graph` accessor module loads in both
|
|
40
|
+
* bundles: construct `new GraphScope()` in a universal, client-safe module (no
|
|
41
|
+
* `node:async_hooks`), then a server-only module calls `attachAls(...)` to
|
|
42
|
+
* upgrade it to per-request ALS isolation. `run`/`current` read `als`
|
|
43
|
+
* dynamically, so the upgrade takes effect immediately; the client keeps using
|
|
44
|
+
* the singleton set by `set()`.
|
|
45
|
+
*/
|
|
46
|
+
attachAls(als: AsyncLocalStorageLike<ActiveGraph>): void {
|
|
47
|
+
this.als = als;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The active graph, or throw a clear error if read outside any scope. */
|
|
51
|
+
current(): ActiveGraph {
|
|
52
|
+
const active = this.als?.getStore() ?? this.singleton;
|
|
53
|
+
if (!active) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"No active graph runtime. On the server wrap rendering in scope.run(active, fn); on the client call scope.set(active) after hydration.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return active;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run `fn` with `active` as the request-scoped runtime (server). */
|
|
62
|
+
run<R>(active: ActiveGraph, fn: () => R): R {
|
|
63
|
+
if (this.als) return this.als.run(active, fn);
|
|
64
|
+
const prev = this.singleton;
|
|
65
|
+
this.singleton = active;
|
|
66
|
+
try {
|
|
67
|
+
return fn();
|
|
68
|
+
} finally {
|
|
69
|
+
this.singleton = prev;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Install the active graph as a singleton (client, post-hydration). */
|
|
74
|
+
set(active: ActiveGraph): void {
|
|
75
|
+
this.singleton = active;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pair a {@link GraphScope} with a zero-arg resolver — the framework-agnostic
|
|
81
|
+
* binding for the generated accessor. An app exports `activeGraph` and points
|
|
82
|
+
* `@gleanql/vite`'s `requestScope: { import: "activeGraph", from: "..." }` at it,
|
|
83
|
+
* then wraps server rendering in `scope.run(active, fn)` (or
|
|
84
|
+
* `integration.runInScope`). Pass an `AsyncLocalStorage` to isolate concurrent
|
|
85
|
+
* server requests; omit it for the client singleton.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { AsyncLocalStorage } from "node:async_hooks";
|
|
89
|
+
* export const { scope, activeGraph } = bindScope(new AsyncLocalStorage());
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function bindScope(
|
|
93
|
+
als?: AsyncLocalStorageLike<ActiveGraph>,
|
|
94
|
+
): { scope: GraphScope; activeGraph: () => ActiveGraph } {
|
|
95
|
+
const scope = new GraphScope(als);
|
|
96
|
+
return { scope, activeGraph: () => scope.current() };
|
|
97
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GraphRuntime,
|
|
3
|
+
bindGraph,
|
|
4
|
+
type BoundGraph,
|
|
5
|
+
type FieldValue,
|
|
6
|
+
type GraphClientAdapter,
|
|
7
|
+
type GraphRequestContext,
|
|
8
|
+
type GraphScope,
|
|
9
|
+
type MissingFieldMode,
|
|
10
|
+
type MissingFieldRead,
|
|
11
|
+
type MissingFieldResult,
|
|
12
|
+
} from "./index.js";
|
|
13
|
+
import type { SchemaModel } from "@gleanql/core";
|
|
14
|
+
import type { ActiveRequestGraph } from "./integration.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Server -> client serialization.
|
|
18
|
+
*
|
|
19
|
+
* Graph values are proxies, not JSON, so we don't serialize them. We serialize
|
|
20
|
+
* the *cache* (normalized records + path records) plus the root refs and the
|
|
21
|
+
* operation identity. On the client we rebuild a runtime from that snapshot and
|
|
22
|
+
* re-bind the graph, so client components read the same fields with cache hits;
|
|
23
|
+
* fields absent from the snapshot fetch through the client adapter.
|
|
24
|
+
*
|
|
25
|
+
* Secrets stay server-side: only `clientSafeContext` keys are serialized.
|
|
26
|
+
*/
|
|
27
|
+
export interface GraphHydrationPayload {
|
|
28
|
+
readonly operationName: string;
|
|
29
|
+
readonly variables: Record<string, unknown>;
|
|
30
|
+
readonly snapshot: Record<string, Record<string, FieldValue>>;
|
|
31
|
+
readonly roots: Record<string, FieldValue>;
|
|
32
|
+
/** Allow-listed, client-safe slice of the request context. */
|
|
33
|
+
readonly context: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SerializeGraphOptions {
|
|
37
|
+
/** Keys of the request context that are safe to ship to the client. */
|
|
38
|
+
readonly clientSafeContext?: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build the JSON-safe hydration payload from an active request. */
|
|
42
|
+
export function serializeGraph(
|
|
43
|
+
active: ActiveRequestGraph,
|
|
44
|
+
options: SerializeGraphOptions = {},
|
|
45
|
+
): GraphHydrationPayload {
|
|
46
|
+
const allow = new Set(options.clientSafeContext ?? []);
|
|
47
|
+
const context: Record<string, unknown> = {};
|
|
48
|
+
for (const key of allow) {
|
|
49
|
+
if (key in active.requestContext) context[key] = active.requestContext[key];
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
operationName: active.operation.name,
|
|
53
|
+
variables: active.variables,
|
|
54
|
+
snapshot: active.runtime.snapshot(),
|
|
55
|
+
roots: active.roots,
|
|
56
|
+
context,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_GLOBAL = "__GRAPH_STATE__";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render a `<script>` that publishes the payload on `window[globalKey]` for
|
|
64
|
+
* client hydration. JSON is escaped so it cannot break out of the script element
|
|
65
|
+
* or be interpreted as HTML (`<`, `>`, `&`, U+2028/U+2029).
|
|
66
|
+
*/
|
|
67
|
+
export function renderGraphHydrationScript(
|
|
68
|
+
payload: GraphHydrationPayload,
|
|
69
|
+
options: { globalKey?: string; nonce?: string } = {},
|
|
70
|
+
): string {
|
|
71
|
+
const nonceAttr = options.nonce ? ` nonce="${options.nonce}"` : "";
|
|
72
|
+
return `<script${nonceAttr}>${graphHydrationScriptContent(payload, options.globalKey)}</script>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Just the inner JS of the hydration script (no `<script>` wrapper), for JSX
|
|
77
|
+
* hosts that inject via `dangerouslySetInnerHTML` and set the nonce themselves.
|
|
78
|
+
* JSON is escaped so it can't break out of the script element or be parsed as HTML.
|
|
79
|
+
*/
|
|
80
|
+
export function graphHydrationScriptContent(payload: GraphHydrationPayload, globalKey = DEFAULT_GLOBAL): string {
|
|
81
|
+
const json = JSON.stringify(payload)
|
|
82
|
+
.replace(/</g, "\\u003c")
|
|
83
|
+
.replace(/>/g, "\\u003e")
|
|
84
|
+
.replace(/&/g, "\\u0026")
|
|
85
|
+
.replace(/\u2028/g, "\\u2028")
|
|
86
|
+
.replace(/\u2029/g, "\\u2029");
|
|
87
|
+
return `window[${JSON.stringify(globalKey)}]=${json}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read the hydration payload published by `renderGraphHydrationScript` (client). */
|
|
91
|
+
export function readGraphHydrationPayload(globalKey = DEFAULT_GLOBAL): GraphHydrationPayload | undefined {
|
|
92
|
+
const g = globalThis as Record<string, unknown>;
|
|
93
|
+
return g[globalKey] as GraphHydrationPayload | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HydrateGraphOptions {
|
|
97
|
+
readonly schema: SchemaModel;
|
|
98
|
+
readonly adapter: GraphClientAdapter;
|
|
99
|
+
/** Fetch fields absent from the hydrated snapshot (lazy/dynamic reads). */
|
|
100
|
+
readonly fetchMissing?: (
|
|
101
|
+
misses: readonly MissingFieldRead[],
|
|
102
|
+
context: GraphRequestContext,
|
|
103
|
+
) => Promise<readonly MissingFieldResult[]>;
|
|
104
|
+
readonly unexpectedMissingField?: MissingFieldMode;
|
|
105
|
+
readonly onWarn?: (message: string) => void;
|
|
106
|
+
/** Install the hydrated runtime on this scope as the client singleton. */
|
|
107
|
+
readonly scope?: GraphScope;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface HydratedGraph {
|
|
111
|
+
readonly runtime: GraphRuntime;
|
|
112
|
+
readonly graph: BoundGraph;
|
|
113
|
+
readonly roots: Record<string, FieldValue>;
|
|
114
|
+
readonly context: Record<string, unknown>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Rebuild the runtime + bound graph on the client from a hydration payload.
|
|
119
|
+
* Reads present in the snapshot resolve synchronously; missing fields suspend and
|
|
120
|
+
* fetch through the client adapter.
|
|
121
|
+
*/
|
|
122
|
+
export function hydrateGraph(payload: GraphHydrationPayload, options: HydrateGraphOptions): HydratedGraph {
|
|
123
|
+
const runtime = GraphRuntime.hydrate(payload.snapshot, {
|
|
124
|
+
keyOf: (typename, obj) => options.schema.identityOf(typename, obj),
|
|
125
|
+
unexpectedMissingField: options.unexpectedMissingField,
|
|
126
|
+
onWarn: options.onWarn,
|
|
127
|
+
fetchMissing: async (misses) =>
|
|
128
|
+
options.fetchMissing ? options.fetchMissing(misses, payload.context) : misses.map((m) => ({ ref: m.ref, fieldKey: m.fieldKey, value: undefined })),
|
|
129
|
+
});
|
|
130
|
+
const graph = bindGraph({ schema: options.schema, getRuntime: () => runtime, roots: payload.roots });
|
|
131
|
+
const active: HydratedGraph = { runtime, graph, roots: payload.roots, context: payload.context };
|
|
132
|
+
options.scope?.set({ runtime, graph });
|
|
133
|
+
return active;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* RSC-native hydration (vs. the `<script>`/`window` model above).
|
|
138
|
+
*
|
|
139
|
+
* Under React Server Components the `Document` shell is rendered once, but each
|
|
140
|
+
* client navigation re-streams only the page subtree. So a one-shot global can't
|
|
141
|
+
* keep client islands warm across navigation. Instead the payload rides the RSC
|
|
142
|
+
* flight stream as a prop of a client component (it is plain JSON by
|
|
143
|
+
* construction), and on every (re)render that component folds it into a single
|
|
144
|
+
* long-lived client runtime — the cache accumulates across navigations.
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/** The page-current pointer islands read for `refresh()` (which operation + vars). */
|
|
148
|
+
export interface GraphPagePointer {
|
|
149
|
+
readonly operationName: string;
|
|
150
|
+
readonly variables: Record<string, unknown>;
|
|
151
|
+
readonly context: Record<string, unknown>;
|
|
152
|
+
readonly roots: Record<string, FieldValue>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render-phase merge: fold a payload's snapshot into a live runtime, write-only
|
|
157
|
+
* (no subscriber notify — the caller bumps in a commit-phase effect). Idempotent.
|
|
158
|
+
* Returns whether anything changed.
|
|
159
|
+
*/
|
|
160
|
+
export function absorbHydrationPayload(
|
|
161
|
+
runtime: GraphRuntime,
|
|
162
|
+
payload: GraphHydrationPayload,
|
|
163
|
+
): boolean {
|
|
164
|
+
return runtime.absorbRecords(payload.snapshot);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Derive the current-page pointer from a payload (drives `refresh()`). */
|
|
168
|
+
export function pagePointer(payload: GraphHydrationPayload): GraphPagePointer {
|
|
169
|
+
return {
|
|
170
|
+
operationName: payload.operationName,
|
|
171
|
+
variables: payload.variables,
|
|
172
|
+
context: payload.context,
|
|
173
|
+
roots: payload.roots,
|
|
174
|
+
};
|
|
175
|
+
}
|