@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/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gleanql/client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Glean's runtime: normalized cache, fine-grained reactivity, mutations, subscriptions and React hooks",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Alexander Liljengard",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/gleanql/gleanql.git",
|
|
10
|
+
"directory": "packages/client"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.mjs",
|
|
14
|
+
"types": "./dist/index.d.mts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.mts",
|
|
18
|
+
"default": "./dist/index.mjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@gleanql/core": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "19.2.14",
|
|
36
|
+
"@types/react-dom": "19.2.3",
|
|
37
|
+
"react": "19.2.6",
|
|
38
|
+
"react-dom": "19.2.6"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://gleanql.com",
|
|
41
|
+
"bugs": "https://github.com/gleanql/gleanql/issues",
|
|
42
|
+
"keywords": [
|
|
43
|
+
"graphql",
|
|
44
|
+
"graphql-client",
|
|
45
|
+
"react",
|
|
46
|
+
"normalized-cache",
|
|
47
|
+
"suspense",
|
|
48
|
+
"optimistic-updates"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20"
|
|
52
|
+
},
|
|
53
|
+
"sideEffects": false,
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsdown src/index.ts --format esm --dts.eager"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { GraphResult } from "./adapter.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared helpers for client adapters.
|
|
5
|
+
*
|
|
6
|
+
* Every adapter implements the same `GraphClientAdapter.execute` contract; the
|
|
7
|
+
* runtime owns cache identity and Suspense, so an adapter is *only* transport.
|
|
8
|
+
* These helpers keep error/response mapping consistent across clients.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface GraphQLError {
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Build a `GraphResult`, omitting empty `data`/`errors` keys. */
|
|
16
|
+
export function result<TData>(data: TData | null | undefined, errors?: readonly GraphQLError[]): GraphResult<TData> {
|
|
17
|
+
return {
|
|
18
|
+
...(data != null ? { data } : {}),
|
|
19
|
+
...(errors && errors.length > 0 ? { errors } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A push→pull async iterator: the transport pushes, the consumer pulls. */
|
|
24
|
+
export interface PushPullIterator<T> extends AsyncIterator<T> {
|
|
25
|
+
/** Deliver the next value to the consumer (ignored once finished). */
|
|
26
|
+
push(value: T): void;
|
|
27
|
+
/** End the stream; the consumer's next pull resolves `{ done: true }`. */
|
|
28
|
+
finish(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bridge a push-based transport (SSE `onmessage`, a graphql-ws sink) into the
|
|
33
|
+
* pull-based `AsyncIterator` the runtime consumes. The transport wires its callbacks
|
|
34
|
+
* to `push`/`finish`; the consumer's `return()` (cleanup) calls `onReturn` to tear the
|
|
35
|
+
* transport down. Values pushed with no pending pull queue up and drain in order.
|
|
36
|
+
*/
|
|
37
|
+
export function pushPullIterator<T>(onReturn?: () => void): PushPullIterator<T> {
|
|
38
|
+
const queue: T[] = [];
|
|
39
|
+
let waiting: ((r: IteratorResult<T>) => void) | null = null;
|
|
40
|
+
let done = false;
|
|
41
|
+
const ended = (): IteratorResult<T> => ({ value: undefined, done: true });
|
|
42
|
+
|
|
43
|
+
const finish = (): void => {
|
|
44
|
+
if (done) return;
|
|
45
|
+
done = true;
|
|
46
|
+
if (waiting) {
|
|
47
|
+
waiting(ended());
|
|
48
|
+
waiting = null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
push(value: T): void {
|
|
54
|
+
if (done) return;
|
|
55
|
+
if (waiting) {
|
|
56
|
+
waiting({ value, done: false });
|
|
57
|
+
waiting = null;
|
|
58
|
+
} else {
|
|
59
|
+
queue.push(value);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
finish,
|
|
63
|
+
next(): Promise<IteratorResult<T>> {
|
|
64
|
+
if (queue.length > 0) return Promise.resolve({ value: queue.shift()!, done: false });
|
|
65
|
+
if (done) return Promise.resolve(ended());
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
waiting = resolve;
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
return(): Promise<IteratorResult<T>> {
|
|
71
|
+
onReturn?.();
|
|
72
|
+
finish();
|
|
73
|
+
return Promise.resolve(ended());
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GraphClientAdapter,
|
|
3
|
+
GraphOperation,
|
|
4
|
+
GraphRequestContext,
|
|
5
|
+
GraphResult,
|
|
6
|
+
} from "./adapter.js";
|
|
7
|
+
import { pushPullIterator } from "./adapter-shared.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `graphql-ws` transport for the same `GraphClientAdapter` seam the fetch
|
|
11
|
+
* adapter implements. WebSockets carry every operation kind — a query/mutation is
|
|
12
|
+
* a single-result stream that completes, a subscription is a long-lived one — so
|
|
13
|
+
* this adapter drives both `execute` and `subscribe` off one `graphql-ws` client.
|
|
14
|
+
*
|
|
15
|
+
* We do NOT bundle `graphql-ws`: the app installs it, calls its `createClient({ url })`,
|
|
16
|
+
* and passes the result here. The client is typed structurally (the subset we use)
|
|
17
|
+
* so the dependency stays optional and the adapter is trivially testable with a fake.
|
|
18
|
+
*
|
|
19
|
+
* import { createClient } from "graphql-ws";
|
|
20
|
+
* const adapter = createGraphWsAdapter({ client: createClient({ url: "wss://…/graphql" }) });
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Sink graphql-ws pushes results into — mirrors its `Sink`. */
|
|
24
|
+
export interface GraphWsSink<T = unknown> {
|
|
25
|
+
next: (value: T) => void;
|
|
26
|
+
error: (error: unknown) => void;
|
|
27
|
+
complete: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Operation payload graphql-ws subscribes with — mirrors its `SubscribePayload`. */
|
|
31
|
+
export interface GraphWsPayload {
|
|
32
|
+
readonly query: string;
|
|
33
|
+
readonly variables?: Record<string, unknown> | null;
|
|
34
|
+
readonly operationName?: string | null;
|
|
35
|
+
readonly extensions?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Structural subset of graphql-ws's `Client` we depend on. */
|
|
39
|
+
export interface GraphWsClient {
|
|
40
|
+
subscribe<T = GraphResult<unknown>>(payload: GraphWsPayload, sink: GraphWsSink<T>): () => void;
|
|
41
|
+
dispose?: () => void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GraphWsAdapterOptions {
|
|
45
|
+
/** A graphql-ws client, e.g. `createClient({ url })`. */
|
|
46
|
+
readonly client: GraphWsClient;
|
|
47
|
+
/**
|
|
48
|
+
* Per-request `extensions` from context (auth token, shop domain, locale). The
|
|
49
|
+
* connection-level params belong on the client; this rides each operation.
|
|
50
|
+
*/
|
|
51
|
+
readonly extensions?: (context: GraphRequestContext) => Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toErrors(error: unknown): ReadonlyArray<{ message: string }> {
|
|
55
|
+
if (Array.isArray(error)) {
|
|
56
|
+
return error.map((e) => ({ message: String((e as { message?: unknown })?.message ?? e) }));
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof Error) return [{ message: error.message }];
|
|
59
|
+
if (error && typeof error === "object" && "reason" in error) {
|
|
60
|
+
// A WebSocket CloseEvent.
|
|
61
|
+
return [{ message: String((error as { reason?: unknown }).reason) || "subscription socket closed" }];
|
|
62
|
+
}
|
|
63
|
+
return [{ message: String(error) }];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createGraphWsAdapter(options: GraphWsAdapterOptions): GraphClientAdapter {
|
|
67
|
+
const { client } = options;
|
|
68
|
+
|
|
69
|
+
const payloadFor = <TVariables>(
|
|
70
|
+
operation: GraphOperation<unknown, TVariables>,
|
|
71
|
+
variables: TVariables,
|
|
72
|
+
context: GraphRequestContext,
|
|
73
|
+
): GraphWsPayload => {
|
|
74
|
+
const extensions = options.extensions?.(context);
|
|
75
|
+
return {
|
|
76
|
+
query: operation.document,
|
|
77
|
+
variables: (variables ?? {}) as Record<string, unknown>,
|
|
78
|
+
operationName: operation.name,
|
|
79
|
+
...(extensions && Object.keys(extensions).length > 0 ? { extensions } : {}),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
// Query / mutation: take the single result, settle on the first `next` (or on
|
|
85
|
+
// `complete`/`error` if the server completes empty). Dispose immediately after.
|
|
86
|
+
execute<TData, TVariables>(
|
|
87
|
+
operation: GraphOperation<TData, TVariables>,
|
|
88
|
+
variables: TVariables,
|
|
89
|
+
context: GraphRequestContext,
|
|
90
|
+
): Promise<GraphResult<TData>> {
|
|
91
|
+
return new Promise<GraphResult<TData>>((resolve) => {
|
|
92
|
+
let settled = false;
|
|
93
|
+
let dispose: (() => void) | undefined;
|
|
94
|
+
const settle = (r: GraphResult<TData>): void => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
resolve(r);
|
|
98
|
+
dispose?.();
|
|
99
|
+
};
|
|
100
|
+
dispose = client.subscribe(payloadFor(operation, variables, context), {
|
|
101
|
+
next: (value) => settle(value as GraphResult<TData>),
|
|
102
|
+
error: (error) => settle({ errors: toErrors(error) }),
|
|
103
|
+
complete: () => settle({}),
|
|
104
|
+
});
|
|
105
|
+
// `subscribe` may have settled synchronously before `dispose` was assigned.
|
|
106
|
+
if (settled) dispose?.();
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Subscription: bridge the push-based sink into a pull-based AsyncIterable, the
|
|
111
|
+
// shape the runtime consumes. `return()` disposes the graphql-ws subscription.
|
|
112
|
+
subscribe<TData, TVariables>(
|
|
113
|
+
operation: GraphOperation<TData, TVariables>,
|
|
114
|
+
variables: TVariables,
|
|
115
|
+
context: GraphRequestContext,
|
|
116
|
+
): AsyncIterable<GraphResult<TData>> {
|
|
117
|
+
return {
|
|
118
|
+
[Symbol.asyncIterator](): AsyncIterator<GraphResult<TData>> {
|
|
119
|
+
let dispose: (() => void) | undefined;
|
|
120
|
+
const it = pushPullIterator<GraphResult<TData>>(() => dispose?.());
|
|
121
|
+
dispose = client.subscribe(payloadFor(operation, variables, context), {
|
|
122
|
+
next: (value) => it.push(value as GraphResult<TData>),
|
|
123
|
+
error: (error) => {
|
|
124
|
+
it.push({ errors: toErrors(error) });
|
|
125
|
+
it.finish();
|
|
126
|
+
},
|
|
127
|
+
complete: () => it.finish(),
|
|
128
|
+
});
|
|
129
|
+
return it;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client adapter interface. The graph runtime owns cache identity, Suspense,
|
|
3
|
+
* batching and normalization; an adapter owns transport (HTTP, auth, retries).
|
|
4
|
+
* Ships a plain fetch adapter; any transport (graphql-ws for subscriptions, or a
|
|
5
|
+
* urql/Apollo client if an app already runs one) slots in behind this interface.
|
|
6
|
+
*/
|
|
7
|
+
import { pushPullIterator } from "./adapter-shared.js";
|
|
8
|
+
|
|
9
|
+
export interface GraphOperation<_TData = unknown, _TVariables = unknown> {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly kind: "query" | "mutation" | "subscription";
|
|
12
|
+
readonly document: string;
|
|
13
|
+
/** SHA-256 hex of `document` — the persisted-operation ID (present on compiled operations). */
|
|
14
|
+
readonly hash?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GraphRequestContext {
|
|
18
|
+
readonly [key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GraphResult<TData> {
|
|
22
|
+
readonly data?: TData;
|
|
23
|
+
readonly errors?: ReadonlyArray<{ message: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GraphClientAdapter {
|
|
27
|
+
execute<TData, TVariables>(
|
|
28
|
+
operation: GraphOperation<TData, TVariables>,
|
|
29
|
+
variables: TVariables,
|
|
30
|
+
context: GraphRequestContext,
|
|
31
|
+
): Promise<GraphResult<TData>>;
|
|
32
|
+
|
|
33
|
+
subscribe?<TData, TVariables>(
|
|
34
|
+
operation: GraphOperation<TData, TVariables>,
|
|
35
|
+
variables: TVariables,
|
|
36
|
+
context: GraphRequestContext,
|
|
37
|
+
): AsyncIterable<GraphResult<TData>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FetchAdapterOptions {
|
|
41
|
+
readonly endpoint: string;
|
|
42
|
+
readonly fetch?: typeof fetch;
|
|
43
|
+
/** Build request headers from context (auth, shop domain, locale, ...). */
|
|
44
|
+
readonly headers?: (context: GraphRequestContext) => Record<string, string>;
|
|
45
|
+
/**
|
|
46
|
+
* Endpoint for subscriptions, consumed as a Server-Sent Events stream via the
|
|
47
|
+
* browser's `EventSource`. Defaults to `${endpoint}/stream`. A production app
|
|
48
|
+
* that prefers WebSockets can drop a `graphql-ws` adapter into the same
|
|
49
|
+
* `subscribe` seam instead.
|
|
50
|
+
*/
|
|
51
|
+
readonly subscriptionEndpoint?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Send operations BY HASH instead of by document (`extensions.persistedQuery.
|
|
54
|
+
* sha256Hash` — the Apollo APQ wire shape, which `createPersistedResolver`
|
|
55
|
+
* understands server-side). The document never rides the request, so the
|
|
56
|
+
* server can enforce a build-produced allowlist. If the server answers
|
|
57
|
+
* `PersistedQueryNotFound` (e.g. an APQ cache that hasn't seen the hash), the
|
|
58
|
+
* request retries ONCE with the full document so the server can register it.
|
|
59
|
+
* Operations without a `hash` fall back to sending the document.
|
|
60
|
+
*/
|
|
61
|
+
readonly persisted?: boolean;
|
|
62
|
+
/** Observability hook: a persisted hash was unknown to the server and the document was re-sent (APQ register). */
|
|
63
|
+
readonly onPersistedRetry?: (operationName: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Minimal fetch transport. Context is used only to build headers; it is never serialized into the body. */
|
|
67
|
+
export function createFetchAdapter(options: FetchAdapterOptions): GraphClientAdapter {
|
|
68
|
+
const doFetch = options.fetch ?? fetch;
|
|
69
|
+
return {
|
|
70
|
+
async execute<TData, TVariables>(
|
|
71
|
+
operation: GraphOperation<TData, TVariables>,
|
|
72
|
+
variables: TVariables,
|
|
73
|
+
context: GraphRequestContext,
|
|
74
|
+
): Promise<GraphResult<TData>> {
|
|
75
|
+
const post = async (body: Record<string, unknown>): Promise<GraphResult<TData>> => {
|
|
76
|
+
const res = await doFetch(options.endpoint, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"content-type": "application/json",
|
|
80
|
+
...(options.headers ? options.headers(context) : {}),
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
});
|
|
84
|
+
// A GraphQL response (even an error one) is JSON; anything else (proxy 502
|
|
85
|
+
// HTML, empty body) becomes a clear transport error instead of a parse error.
|
|
86
|
+
const parsed = (await res.json().catch(() => undefined)) as GraphResult<TData> | undefined;
|
|
87
|
+
if (parsed === undefined) {
|
|
88
|
+
throw new Error(`graph fetch: non-JSON response (${res.status} ${res.statusText}) from ${options.endpoint}`);
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (options.persisted && operation.hash) {
|
|
94
|
+
const extensions = { persistedQuery: { version: 1, sha256Hash: operation.hash } };
|
|
95
|
+
const first = await post({ operationName: operation.name, variables, extensions });
|
|
96
|
+
// APQ negotiation: an allowlist server never answers this (the build seeded
|
|
97
|
+
// it); a cache-style server asks for the document once.
|
|
98
|
+
if (first.errors?.some((e) => e.message === "PersistedQueryNotFound")) {
|
|
99
|
+
options.onPersistedRetry?.(operation.name);
|
|
100
|
+
return post({ query: operation.document, operationName: operation.name, variables, extensions });
|
|
101
|
+
}
|
|
102
|
+
return first;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return post({ query: operation.document, variables, operationName: operation.name });
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Subscriptions ride an SSE stream (GET + EventSource) — client-only. The
|
|
109
|
+
// operation rides the query string; each `data:` frame is a GraphResult JSON.
|
|
110
|
+
subscribe<TData, TVariables>(
|
|
111
|
+
operation: GraphOperation<TData, TVariables>,
|
|
112
|
+
variables: TVariables,
|
|
113
|
+
): AsyncIterable<GraphResult<TData>> {
|
|
114
|
+
const base = options.subscriptionEndpoint ?? `${options.endpoint}/stream`;
|
|
115
|
+
return sseIterable(base, operation.document, variables, operation.name) as AsyncIterable<GraphResult<TData>>;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Bridge a Server-Sent Events stream into an `AsyncIterable<GraphResult>`. */
|
|
121
|
+
function sseIterable(
|
|
122
|
+
url: string,
|
|
123
|
+
document: string,
|
|
124
|
+
variables: unknown,
|
|
125
|
+
operationName: string,
|
|
126
|
+
): AsyncIterable<GraphResult<unknown>> {
|
|
127
|
+
return {
|
|
128
|
+
[Symbol.asyncIterator](): AsyncIterator<GraphResult<unknown>> {
|
|
129
|
+
// No EventSource (server / non-browser): an empty, immediately-done stream.
|
|
130
|
+
if (typeof EventSource === "undefined") {
|
|
131
|
+
return { next: () => Promise.resolve({ value: undefined, done: true }) };
|
|
132
|
+
}
|
|
133
|
+
const qs =
|
|
134
|
+
`query=${encodeURIComponent(document)}` +
|
|
135
|
+
`&operationName=${encodeURIComponent(operationName)}` +
|
|
136
|
+
`&variables=${encodeURIComponent(JSON.stringify(variables ?? {}))}`;
|
|
137
|
+
const es = new EventSource(`${url}?${qs}`);
|
|
138
|
+
const it = pushPullIterator<GraphResult<unknown>>(() => es.close());
|
|
139
|
+
|
|
140
|
+
es.onmessage = (e: MessageEvent) => {
|
|
141
|
+
try {
|
|
142
|
+
it.push(JSON.parse(e.data) as GraphResult<unknown>);
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore malformed frame */
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
// SSE auto-reconnects, so surface the error as a frame but keep the stream open.
|
|
148
|
+
es.onerror = () => it.push({ errors: [{ message: "subscription stream error" }] });
|
|
149
|
+
return it;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { canonicalArgs, responseKey, type ArgMap, type ArgValue, type SelectionSet, type FieldSelection } from "@gleanql/core";
|
|
2
|
+
import type { GraphCache, GraphRef, FieldValue } from "./cache.js";
|
|
3
|
+
import { isGraphRef } from "./proxy.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cache-first resolution.
|
|
7
|
+
*
|
|
8
|
+
* Before executing an operation over the network, check whether the normalized
|
|
9
|
+
* cache already satisfies its entire selection. The link from a root call to its
|
|
10
|
+
* entity (`product(handle:"x") -> Product:123`) is persisted under the root
|
|
11
|
+
* record, so a re-run, a back-navigation, or data another writer already filled
|
|
12
|
+
* (mutation, subscription, sibling query) resolves with zero requests. Coverage
|
|
13
|
+
* is all-or-nothing here; partial gaps fall back to a full fetch (a future
|
|
14
|
+
* `node(id:)` patch could fetch only the missing fields).
|
|
15
|
+
*/
|
|
16
|
+
const ROOT = "Query";
|
|
17
|
+
|
|
18
|
+
function resolveArg(v: ArgValue, vars: Record<string, unknown>): ArgValue {
|
|
19
|
+
// Substitute the operation variable; canonicalArgValue JSON-stringifies the value.
|
|
20
|
+
if (v.kind === "var") return { kind: "literal", value: (vars[v.name] ?? null) as string | number | boolean | null };
|
|
21
|
+
if (v.kind === "list") return { kind: "list", items: v.items.map((i) => resolveArg(i, vars)) };
|
|
22
|
+
if (v.kind === "object") return { kind: "object", fields: v.fields.map(([k, fv]) => [k, resolveArg(fv, vars)] as const) };
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Stable per-root-call key (`product(handle:"x")`), with operation variables substituted. */
|
|
27
|
+
function rootLinkKey(field: FieldSelection, vars: Record<string, unknown>): string {
|
|
28
|
+
const resolved: ArgMap = (field.args ?? []).map(([k, v]) => [k, resolveArg(v, vars)] as const);
|
|
29
|
+
return `${field.name}(${canonicalArgs(resolved)})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Record each root field's resolved ref so a later run finds the entity without a fetch. */
|
|
33
|
+
export function persistRootLinks(
|
|
34
|
+
cache: GraphCache,
|
|
35
|
+
selection: SelectionSet,
|
|
36
|
+
vars: Record<string, unknown>,
|
|
37
|
+
roots: Record<string, FieldValue>,
|
|
38
|
+
rootPath: string = ROOT,
|
|
39
|
+
): void {
|
|
40
|
+
const rec: GraphRef = { path: rootPath };
|
|
41
|
+
for (const field of selection.fields) {
|
|
42
|
+
const key = responseKey(field);
|
|
43
|
+
if (key in roots) cache.setField(rec, rootLinkKey(field, vars), roots[key] as FieldValue);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Does `ref`'s record cover every field in `selection` (recursively)? */
|
|
48
|
+
function covers(cache: GraphCache, ref: GraphRef, selection: SelectionSet): boolean {
|
|
49
|
+
for (const field of selection.fields) {
|
|
50
|
+
const got = cache.getField(ref, responseKey(field));
|
|
51
|
+
if (got.status !== "ready") return false;
|
|
52
|
+
if (field.selection && !coversValue(cache, got.value, field.selection)) return false;
|
|
53
|
+
}
|
|
54
|
+
for (const frag of selection.inlineFragments ?? []) {
|
|
55
|
+
if (ref.__typename === frag.onType && !covers(cache, ref, frag.selection)) return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function coversValue(cache: GraphCache, value: FieldValue, selection: SelectionSet): boolean {
|
|
61
|
+
if (value == null) return true; // nullable object — nothing deeper to require
|
|
62
|
+
if (Array.isArray(value)) return value.every((v) => coversValue(cache, v, selection));
|
|
63
|
+
if (isGraphRef(value)) return covers(cache, value, selection);
|
|
64
|
+
return true; // scalar where an object was expected — tolerate, don't block
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CacheResolution {
|
|
68
|
+
readonly covered: boolean;
|
|
69
|
+
readonly roots: Record<string, FieldValue>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Try to satisfy an operation entirely from cache. `covered` is true only when
|
|
74
|
+
* every root link exists and every selected field beneath it is present.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveFromCache(
|
|
77
|
+
cache: GraphCache,
|
|
78
|
+
selection: SelectionSet,
|
|
79
|
+
vars: Record<string, unknown>,
|
|
80
|
+
rootPath: string = ROOT,
|
|
81
|
+
): CacheResolution {
|
|
82
|
+
const rec: GraphRef = { path: rootPath };
|
|
83
|
+
const roots: Record<string, FieldValue> = {};
|
|
84
|
+
for (const field of selection.fields) {
|
|
85
|
+
const link = cache.getField(rec, rootLinkKey(field, vars));
|
|
86
|
+
if (link.status !== "ready") return { covered: false, roots: {} };
|
|
87
|
+
const ref = link.value;
|
|
88
|
+
if (ref == null) {
|
|
89
|
+
roots[responseKey(field)] = null;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (field.selection && (!isGraphRef(ref) || !covers(cache, ref, field.selection))) {
|
|
93
|
+
return { covered: false, roots: {} };
|
|
94
|
+
}
|
|
95
|
+
roots[responseKey(field)] = ref;
|
|
96
|
+
}
|
|
97
|
+
return { covered: true, roots };
|
|
98
|
+
}
|