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