@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/paginate.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
printOperation,
|
|
3
|
+
type ArgMap,
|
|
4
|
+
type ArgValue,
|
|
5
|
+
type FieldSelection,
|
|
6
|
+
type SelectionSet,
|
|
7
|
+
type SchemaModel,
|
|
8
|
+
type VariableDef,
|
|
9
|
+
} from "@gleanql/core";
|
|
10
|
+
import {
|
|
11
|
+
selectionOf,
|
|
12
|
+
trailOf,
|
|
13
|
+
responseKeyCandidates,
|
|
14
|
+
toArgMap,
|
|
15
|
+
createGraphProxy,
|
|
16
|
+
type GraphClientAdapter,
|
|
17
|
+
type GraphPagePointer,
|
|
18
|
+
type GraphRef,
|
|
19
|
+
type GraphRequestContext,
|
|
20
|
+
type PathStep,
|
|
21
|
+
errorMessage,
|
|
22
|
+
} from "./index.js";
|
|
23
|
+
import type { GraphRuntime } from "./runtime.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `usePaginated` query-building + merge — the pure core of connection pagination,
|
|
27
|
+
* plus the component-slice query builder `refresh({ component })` uses. No React
|
|
28
|
+
* here; the hooks in `glue-client.ts` are thin wrappers. Glean bakes in NO pagination
|
|
29
|
+
* convention: you read whatever `pageInfo`/cursor fields you want and the compiler
|
|
30
|
+
* includes exactly those, so the page query is rebuilt from the connection's own
|
|
31
|
+
* compiled selection.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Helpers handed to a `merge` callback for combining a connection's node lists. */
|
|
35
|
+
export interface MergeHelpers {
|
|
36
|
+
/** Existing node values (graph proxies) already in the list. */
|
|
37
|
+
readonly existing: readonly unknown[];
|
|
38
|
+
/** Newly-fetched node values for this page. */
|
|
39
|
+
readonly incoming: readonly unknown[];
|
|
40
|
+
/** Stable de-dupe keeping first occurrence (e.g. `uniqBy(all, n => n.id)`). */
|
|
41
|
+
uniqBy<T>(items: readonly T[], key: (item: T) => unknown): T[];
|
|
42
|
+
/** Stable sort by a derived key. */
|
|
43
|
+
sortBy<T>(items: readonly T[], key: (item: T) => number | string): T[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UsePaginatedOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Combine the prior nodes with the freshly-fetched page into the new node list.
|
|
49
|
+
* Defaults to plain concatenation (`[...existing, ...incoming]`). Use this for
|
|
50
|
+
* de-dupe/sort, or any non-`nodes` connection shape.
|
|
51
|
+
*/
|
|
52
|
+
readonly merge?: (helpers: MergeHelpers) => readonly unknown[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UsePaginatedResult {
|
|
56
|
+
/** Fetch the next page; `args` override the connection field's arguments (e.g. `{ after }`). */
|
|
57
|
+
fetchMore(args: Record<string, unknown>): Promise<boolean>;
|
|
58
|
+
/** True while a `fetchMore` is in flight. */
|
|
59
|
+
readonly isLoading: boolean;
|
|
60
|
+
/** The last transport/error message from a failed `fetchMore`, if any. */
|
|
61
|
+
readonly error?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a minimal operation that fetches only what `componentName` reads — its
|
|
66
|
+
* compiled read-map (entity-rooted field paths like `"Product.views"`) pruned out
|
|
67
|
+
* of `op`'s selection, keeping identity at each retained level and only the
|
|
68
|
+
* variables the slice still uses. So an island refetches its own fields without
|
|
69
|
+
* ever naming them. Returns `undefined` if the component isn't in the read-map.
|
|
70
|
+
* Pure — exported for testing.
|
|
71
|
+
*/
|
|
72
|
+
export function buildComponentOperation(
|
|
73
|
+
op: { name: string; document: string; selection?: SelectionSet; readMap?: Record<string, readonly string[]> },
|
|
74
|
+
componentName: string,
|
|
75
|
+
): { name: string; kind: "query"; document: string } | undefined {
|
|
76
|
+
const paths = op.readMap?.[componentName];
|
|
77
|
+
if (!op.selection || !paths?.length) return undefined;
|
|
78
|
+
const pruned = pruneByReadPaths(op.selection, paths);
|
|
79
|
+
if (pruned.fields.length === 0) return undefined;
|
|
80
|
+
const used = collectVarNames(pruned);
|
|
81
|
+
const variables = parseVariableDefs(op.document).filter((v) => used.has(v.name));
|
|
82
|
+
const name = `${op.name}_${componentName}`;
|
|
83
|
+
return { name, kind: "query", document: printOperation({ kind: "query", name, variables, selection: pruned }) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The minimal compiled-operation shape paginate needs. */
|
|
87
|
+
interface PageableOp {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
readonly document: string;
|
|
90
|
+
readonly selection?: SelectionSet;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface PaginateConnectionParams {
|
|
94
|
+
readonly connection: unknown;
|
|
95
|
+
readonly args: Record<string, unknown>;
|
|
96
|
+
readonly merge?: UsePaginatedOptions["merge"];
|
|
97
|
+
readonly schema: SchemaModel;
|
|
98
|
+
readonly operations: Record<string, PageableOp>;
|
|
99
|
+
readonly adapter: GraphClientAdapter;
|
|
100
|
+
readonly runtime: GraphRuntime;
|
|
101
|
+
readonly page: GraphPagePointer;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The non-hook core of `usePaginated`: rebuild the connection's query with the
|
|
106
|
+
* caller's `args`, fetch it, and merge the page into the cache. Exported so it can
|
|
107
|
+
* be tested without a React renderer (the hook is a thin wrapper that adds loading
|
|
108
|
+
* state + a cache subscription).
|
|
109
|
+
*/
|
|
110
|
+
export async function paginateConnection(params: PaginateConnectionParams): Promise<{ ok: boolean; error?: string }> {
|
|
111
|
+
const { connection, args, merge, schema, operations, adapter, runtime, page } = params;
|
|
112
|
+
const sel = selectionOf(connection);
|
|
113
|
+
const trail = trailOf(connection);
|
|
114
|
+
if (!sel || !trail || trail.length === 0) return { ok: false };
|
|
115
|
+
|
|
116
|
+
const op = operations[page.operationName];
|
|
117
|
+
const built = op && buildPageOperation(op, trail, args, schema);
|
|
118
|
+
if (!built) return { ok: false };
|
|
119
|
+
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
result = await adapter.execute(
|
|
123
|
+
{ name: built.name, kind: "query", document: built.document },
|
|
124
|
+
{ ...page.variables, ...args },
|
|
125
|
+
page.context as GraphRequestContext,
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { ok: false, error: errorMessage(err) };
|
|
129
|
+
}
|
|
130
|
+
if (result?.errors?.length) return { ok: false, error: result.errors[0]!.message };
|
|
131
|
+
|
|
132
|
+
const pageData = navigatePage(result?.data as Record<string, unknown> | undefined, trail);
|
|
133
|
+
if (!pageData) return { ok: false };
|
|
134
|
+
runtime.appendConnection(sel.ref, pageData, refMergeFor(merge, schema, sel.type, runtime));
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The op field whose response key matches a runtime path step (handles arg-aliasing). */
|
|
139
|
+
function pickStepField(fields: readonly FieldSelection[], step: PathStep): FieldSelection | undefined {
|
|
140
|
+
const named = fields.filter((f) => f.name === step.name);
|
|
141
|
+
if (named.length <= 1) return named[0];
|
|
142
|
+
const keys = responseKeyCandidates(step.name, toArgMap(step.args));
|
|
143
|
+
return named.find((f) => keys.includes(f.alias ?? f.name)) ?? named[0];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Replace/add args on the connection field, turning each caller arg into a `$var`. */
|
|
147
|
+
function withUserArgs(existing: ArgMap | undefined, args: Record<string, unknown>): ArgMap {
|
|
148
|
+
const map = new Map<string, ArgValue>(existing ?? []);
|
|
149
|
+
for (const name of Object.keys(args)) map.set(name, { kind: "var", name });
|
|
150
|
+
return [...map.entries()];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Clone the single root→connection path out of `op`, overriding the connection's args. */
|
|
154
|
+
function clonePathField(
|
|
155
|
+
parent: SelectionSet,
|
|
156
|
+
trail: readonly PathStep[],
|
|
157
|
+
depth: number,
|
|
158
|
+
args: Record<string, unknown>,
|
|
159
|
+
): FieldSelection | undefined {
|
|
160
|
+
const field = pickStepField(parent.fields, trail[depth]!);
|
|
161
|
+
if (!field) return undefined;
|
|
162
|
+
if (depth === trail.length - 1) return { ...field, args: withUserArgs(field.args, args) };
|
|
163
|
+
if (!field.selection) return undefined;
|
|
164
|
+
const child = clonePathField(field.selection, trail, depth + 1, args);
|
|
165
|
+
if (!child) return undefined;
|
|
166
|
+
const identity = field.selection.fields.filter((f) => !f.selection && (f.name === "__typename" || f.name === "id"));
|
|
167
|
+
return { ...field, selection: { typeName: field.selection.typeName, fields: [...identity, child] } };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Walk the schema along `trail` to the connection field's declared arg types. */
|
|
171
|
+
function connectionArgTypes(trail: readonly PathStep[], schema: SchemaModel): Record<string, string> {
|
|
172
|
+
let parentType: string | undefined = schema.queryType;
|
|
173
|
+
let fieldDef;
|
|
174
|
+
for (const step of trail) {
|
|
175
|
+
if (!parentType) return {};
|
|
176
|
+
fieldDef = schema.getField(parentType, step.name);
|
|
177
|
+
parentType = fieldDef?.type;
|
|
178
|
+
}
|
|
179
|
+
const out: Record<string, string> = {};
|
|
180
|
+
for (const a of fieldDef?.args ?? []) out[a.name] = a.type;
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build a query for the NEXT page of the connection at `trail`: the single path from
|
|
186
|
+
* a Query root to it (with its full node/pageInfo selection), with the caller's
|
|
187
|
+
* `args` overriding the connection field's arguments as `$vars`. Returns `undefined`
|
|
188
|
+
* if the path isn't in the op. Pure — exported for testing.
|
|
189
|
+
*/
|
|
190
|
+
export function buildPageOperation(
|
|
191
|
+
op: { name: string; document: string; selection?: SelectionSet },
|
|
192
|
+
trail: readonly PathStep[],
|
|
193
|
+
args: Record<string, unknown>,
|
|
194
|
+
schema: SchemaModel,
|
|
195
|
+
): { name: string; kind: "query"; document: string } | undefined {
|
|
196
|
+
if (!op.selection || trail.length === 0) return undefined;
|
|
197
|
+
const pathField = clonePathField(op.selection, trail, 0, args);
|
|
198
|
+
if (!pathField) return undefined;
|
|
199
|
+
const selection: SelectionSet = { typeName: op.selection.typeName, fields: [pathField] };
|
|
200
|
+
const used = collectVarNames(selection);
|
|
201
|
+
const argTypes = connectionArgTypes(trail, schema);
|
|
202
|
+
const headerVars = parseVariableDefs(op.document).filter((v) => used.has(v.name));
|
|
203
|
+
const argVars: VariableDef[] = Object.keys(args).map((name) => ({ name, type: argTypes[name] ?? "String" }));
|
|
204
|
+
const variables = dedupeVarsByName([...headerVars, ...argVars]);
|
|
205
|
+
const name = `${op.name}_page`;
|
|
206
|
+
return { name, kind: "query", document: printOperation({ kind: "query", name, variables, selection }) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function dedupeVarsByName(vars: readonly VariableDef[]): VariableDef[] {
|
|
210
|
+
const seen = new Map<string, VariableDef>();
|
|
211
|
+
for (const v of vars) if (!seen.has(v.name)) seen.set(v.name, v);
|
|
212
|
+
return [...seen.values()];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Walk a result's data along the runtime path to the connection object. */
|
|
216
|
+
function navigatePage(
|
|
217
|
+
data: Record<string, unknown> | undefined,
|
|
218
|
+
trail: readonly PathStep[],
|
|
219
|
+
): Record<string, unknown> | undefined {
|
|
220
|
+
let cur: unknown = data;
|
|
221
|
+
for (const step of trail) {
|
|
222
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
223
|
+
const obj = cur as Record<string, unknown>;
|
|
224
|
+
const keys = responseKeyCandidates(step.name, toArgMap(step.args));
|
|
225
|
+
const key = keys.find((k) => k in obj) ?? step.name;
|
|
226
|
+
cur = obj[key];
|
|
227
|
+
}
|
|
228
|
+
return cur && typeof cur === "object" ? (cur as Record<string, unknown>) : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Adapt a user `merge` (which works on node *values* — graph proxies, so `n => n.id`
|
|
233
|
+
* etc. read naturally) into the ref-level merge the cache stores. Wraps existing +
|
|
234
|
+
* incoming node refs as proxies, runs the user merge, and maps the result back to
|
|
235
|
+
* refs. Returns `undefined` (plain concat) when no merge was supplied.
|
|
236
|
+
*/
|
|
237
|
+
function refMergeFor(
|
|
238
|
+
merge: UsePaginatedOptions["merge"],
|
|
239
|
+
schema: SchemaModel,
|
|
240
|
+
connectionType: string,
|
|
241
|
+
runtime: GraphRuntime,
|
|
242
|
+
): ((existing: readonly unknown[], incoming: readonly unknown[]) => readonly unknown[]) | undefined {
|
|
243
|
+
if (!merge) return undefined;
|
|
244
|
+
const nodeType = schema.getField(connectionType, "nodes")?.type ?? "Unknown";
|
|
245
|
+
const binding = { schema, getRuntime: () => runtime };
|
|
246
|
+
const wrap = (ref: unknown): unknown => {
|
|
247
|
+
if (ref && typeof ref === "object" && ("path" in ref || ("__typename" in ref && "id" in ref))) {
|
|
248
|
+
const r = ref as GraphRef;
|
|
249
|
+
return createGraphProxy(binding, r, (r.__typename as string | undefined) ?? nodeType);
|
|
250
|
+
}
|
|
251
|
+
return ref;
|
|
252
|
+
};
|
|
253
|
+
const unwrap = (value: unknown): unknown => selectionOf(value)?.ref ?? value;
|
|
254
|
+
return (existing, incoming) => {
|
|
255
|
+
const merged = merge({
|
|
256
|
+
existing: existing.map(wrap),
|
|
257
|
+
incoming: incoming.map(wrap),
|
|
258
|
+
uniqBy: stableUniqBy,
|
|
259
|
+
sortBy: stableSortBy,
|
|
260
|
+
});
|
|
261
|
+
return merged.map(unwrap);
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stableUniqBy<T>(items: readonly T[], key: (item: T) => unknown): T[] {
|
|
266
|
+
const seen = new Set<unknown>();
|
|
267
|
+
const out: T[] = [];
|
|
268
|
+
for (const item of items) {
|
|
269
|
+
const k = key(item);
|
|
270
|
+
if (!seen.has(k)) {
|
|
271
|
+
seen.add(k);
|
|
272
|
+
out.push(item);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stableSortBy<T>(items: readonly T[], key: (item: T) => number | string): T[] {
|
|
279
|
+
return [...items]
|
|
280
|
+
.map((item, i) => [item, i] as const)
|
|
281
|
+
.sort((a, b) => {
|
|
282
|
+
const ka = key(a[0]);
|
|
283
|
+
const kb = key(b[0]);
|
|
284
|
+
return ka < kb ? -1 : ka > kb ? 1 : a[1] - b[1];
|
|
285
|
+
})
|
|
286
|
+
.map(([item]) => item);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Prune a root selection to the entity-rooted read-map `paths` (e.g.
|
|
291
|
+
* `"Product.featuredImage.url"`). Root fields are kept when their entity type is
|
|
292
|
+
* referenced; below the root, fields are kept when they're on a path (or an
|
|
293
|
+
* ancestor of one), plus identity for normalization.
|
|
294
|
+
*/
|
|
295
|
+
function pruneByReadPaths(rootSel: SelectionSet, paths: readonly string[]): SelectionSet {
|
|
296
|
+
const rootTypes = new Set(paths.map((p) => p.split(".")[0]));
|
|
297
|
+
const fields: FieldSelection[] = [];
|
|
298
|
+
for (const f of rootSel.fields) {
|
|
299
|
+
if (f.selection && rootTypes.has(f.selection.typeName)) {
|
|
300
|
+
fields.push({ ...f, selection: pruneEntity(f.selection, f.selection.typeName, paths) });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { typeName: rootSel.typeName, fields };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Prune an entity selection; `prefix` is the entity-rooted field path so far (e.g. `"Product"`). */
|
|
307
|
+
function pruneEntity(sel: SelectionSet, prefix: string, paths: readonly string[]): SelectionSet {
|
|
308
|
+
const fields: FieldSelection[] = [];
|
|
309
|
+
const seen = new Set<string>();
|
|
310
|
+
const keep = (f: FieldSelection) => {
|
|
311
|
+
const key = f.alias ?? f.name;
|
|
312
|
+
if (!seen.has(key)) {
|
|
313
|
+
seen.add(key);
|
|
314
|
+
fields.push(f);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
for (const f of sel.fields) {
|
|
318
|
+
if (!f.selection && (f.name === "__typename" || f.name === "id")) {
|
|
319
|
+
keep(f); // identity, so the result normalizes back onto the entity
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const tp = `${prefix}.${f.name}`;
|
|
323
|
+
if (paths.includes(tp)) keep(f); // exact read (scalar, or a whole object subtree)
|
|
324
|
+
else if (f.selection && paths.some((p) => p.startsWith(`${tp}.`))) keep({ ...f, selection: pruneEntity(f.selection, tp, paths) });
|
|
325
|
+
}
|
|
326
|
+
return { typeName: sel.typeName, fields };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Names of operation variables referenced anywhere in a selection's arguments. */
|
|
330
|
+
function collectVarNames(sel: SelectionSet): Set<string> {
|
|
331
|
+
const out = new Set<string>();
|
|
332
|
+
const fromValue = (v: ArgValue) => {
|
|
333
|
+
if (v.kind === "var") out.add(v.name);
|
|
334
|
+
else if (v.kind === "list") v.items.forEach(fromValue);
|
|
335
|
+
else if (v.kind === "object") v.fields.forEach(([, vv]) => fromValue(vv));
|
|
336
|
+
};
|
|
337
|
+
const fromArgs = (args?: ArgMap) => (args ?? []).forEach(([, v]) => fromValue(v));
|
|
338
|
+
const walk = (s: SelectionSet) => {
|
|
339
|
+
for (const f of s.fields) {
|
|
340
|
+
fromArgs(f.args);
|
|
341
|
+
if (f.selection) walk(f.selection);
|
|
342
|
+
}
|
|
343
|
+
for (const fr of s.inlineFragments ?? []) walk(fr.selection);
|
|
344
|
+
};
|
|
345
|
+
walk(sel);
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Parse `query Name($a: T!, $b: [U!])` → variable defs (operation header only). */
|
|
350
|
+
function parseVariableDefs(document: string): VariableDef[] {
|
|
351
|
+
const m = /^\s*(?:query|mutation|subscription)\s+\w+\s*\(([^)]*)\)\s*\{/.exec(document);
|
|
352
|
+
if (!m?.[1]) return [];
|
|
353
|
+
return m[1]
|
|
354
|
+
.split(",")
|
|
355
|
+
.map((s) => s.trim())
|
|
356
|
+
.filter(Boolean)
|
|
357
|
+
.map((s) => {
|
|
358
|
+
const colon = s.indexOf(":");
|
|
359
|
+
return { name: s.slice(0, colon).trim().replace(/^\$/, ""), type: s.slice(colon + 1).trim() };
|
|
360
|
+
});
|
|
361
|
+
}
|
package/src/persisted.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side persisted-operation allowlist.
|
|
3
|
+
*
|
|
4
|
+
* The build already knows every operation the app can send (the compiled
|
|
5
|
+
* `operations` map carries each document + its SHA-256 hash), so the server can
|
|
6
|
+
* refuse anything else. `createPersistedResolver(operations)` turns an incoming
|
|
7
|
+
* request body into the document to execute — by hash (`extensions.
|
|
8
|
+
* persistedQuery.sha256Hash`, the Apollo APQ wire shape the fetch adapter's
|
|
9
|
+
* `persisted: true` mode sends) or by exact-document match — and rejects
|
|
10
|
+
* free-form queries unless explicitly allowed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The slice of a compiled operation the resolver needs (the generated `operations` map satisfies it). */
|
|
14
|
+
export interface PersistedLookupOperation {
|
|
15
|
+
readonly document: string;
|
|
16
|
+
readonly hash?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A GraphQL-over-HTTP request body, as parsed from JSON. */
|
|
20
|
+
export interface PersistedRequestBody {
|
|
21
|
+
readonly query?: string;
|
|
22
|
+
readonly operationName?: string;
|
|
23
|
+
readonly variables?: unknown;
|
|
24
|
+
readonly extensions?: {
|
|
25
|
+
readonly persistedQuery?: { readonly version?: number; readonly sha256Hash?: string };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PersistedResolution =
|
|
30
|
+
/** Execute this document. */
|
|
31
|
+
| { readonly kind: "ok"; readonly document: string }
|
|
32
|
+
/** Unknown hash and no usable document — reply `PersistedQueryNotFound` so an APQ client retries with the query. */
|
|
33
|
+
| { readonly kind: "not-found" }
|
|
34
|
+
/** Free-form (or mismatched) query outside the allowlist — reply 4xx. */
|
|
35
|
+
| { readonly kind: "rejected" };
|
|
36
|
+
|
|
37
|
+
export interface PersistedResolverOptions {
|
|
38
|
+
/** Execute documents that aren't in the allowlist (turns the allowlist into hash-only transport compression). */
|
|
39
|
+
readonly allowUnpersisted?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createPersistedResolver(
|
|
43
|
+
operations: Readonly<Record<string, PersistedLookupOperation>>,
|
|
44
|
+
options: PersistedResolverOptions = {},
|
|
45
|
+
): (body: PersistedRequestBody) => PersistedResolution {
|
|
46
|
+
const byHash = new Map<string, string>();
|
|
47
|
+
const documents = new Set<string>();
|
|
48
|
+
for (const op of Object.values(operations)) {
|
|
49
|
+
documents.add(op.document);
|
|
50
|
+
if (op.hash) byHash.set(op.hash, op.document);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (body) => {
|
|
54
|
+
const hash = body.extensions?.persistedQuery?.sha256Hash;
|
|
55
|
+
if (hash) {
|
|
56
|
+
const document = byHash.get(hash);
|
|
57
|
+
if (document) return { kind: "ok", document };
|
|
58
|
+
// Unknown hash + an allowlisted document (APQ register retry): execute it.
|
|
59
|
+
if (body.query) {
|
|
60
|
+
return documents.has(body.query) || options.allowUnpersisted
|
|
61
|
+
? { kind: "ok", document: body.query }
|
|
62
|
+
: { kind: "rejected" };
|
|
63
|
+
}
|
|
64
|
+
return { kind: "not-found" };
|
|
65
|
+
}
|
|
66
|
+
if (body.query) {
|
|
67
|
+
return documents.has(body.query) || options.allowUnpersisted
|
|
68
|
+
? { kind: "ok", document: body.query }
|
|
69
|
+
: { kind: "rejected" };
|
|
70
|
+
}
|
|
71
|
+
return { kind: "rejected" };
|
|
72
|
+
};
|
|
73
|
+
}
|