1import * as Effect from "effect/Effect";
2
3/** GraphQL variables by name, each with the type it is declared as and the value it carries. */
4export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>;
5
6export interface GraphQlDocument {
7 readonly query: string;
8 readonly variables: Readonly<Record<string, unknown>>;
9}
10
11/**
12 * One document asking the same field for many items, each under its own alias: the prefix and
13 * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub
14 * has no bulk form of most lookups, so aliases are how a batch travels in one request.
15 *
16 * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those
17 * placeholders rather than the values: nothing an item holds is written into the document.
18 * `shared` variables are declared once and referenced by their own name; `within` nests the aliased
19 * fields inside one parent selection, such as a single repository.
20 *
21 * Null for no items, since a document with no selection is not a document.
22 */
23export function aliasedGraphQlDocument<Item, Variables extends GraphQlVariables>(input: {
24 readonly operation: "query" | "mutation";
25 /** The operation's name, which tells one batch from another in logs and recorded traffic. */
26 readonly name?: string;
27 readonly alias: string;
28 readonly key?: (item: Item, index: number) => number;
29 readonly items: ReadonlyArray<Item>;
30 readonly variables: (item: Item) => Variables;
31 readonly field: (
32 placeholders: { readonly [Name in keyof Variables]: string },
33 item: Item,
34 ) => string;
35 readonly shared?: GraphQlVariables;
36 readonly within?: (fields: string) => string;
37}): GraphQlDocument | null {
38 if (input.items.length === 0) return null;
39 const declarations: string[] = [];
40 const variables: Record<string, unknown> = {};
41 for (const [name, [type, value]] of Object.entries(input.shared ?? {})) {
42 declarations.push(`$${name}: ${type}`);
43 variables[name] = value;
44 }
45 const fields = input.items.map((item, index) => {
46 const alias = `${input.alias}${input.key?.(item, index) ?? index}`;
47 const placeholders: Record<string, string> = {};
48 for (const [name, [type, value]] of Object.entries(input.variables(item))) {
49 const variable = `${alias}_${name}`;
50 declarations.push(`$${variable}: ${type}`);
51 variables[variable] = value;
52 placeholders[name] = `$${variable}`;
53 }
54 return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`;
55 });
56 const selection = fields.join("\n");
57 const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`;
58 return {
59 query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`,
60 variables,
61 };
62}
63
64/**
65 * The pages of a GraphQL connection, read one after another from `from` (the first page by
66 * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is
67 * handed the pages so far, for reads that size or check the next page by what has already arrived.
68 *
69 * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time
70 * would page forever, so it stops the read as truncated too.
71 */
72export const readGraphQlPages = <Page, E, R>(
73 read: (after: string | null, pages: ReadonlyArray<Page>) => Effect.Effect<Page, E, R>,
74 options: {
75 readonly nextCursor: (page: NoInfer<Page>) => string | null;
76 readonly from?: string | null;
77 readonly maxPages?: number;
78 readonly until?: (pages: ReadonlyArray<NoInfer<Page>>) => boolean;
79 },
80): Effect.Effect<{ readonly pages: ReadonlyArray<Page>; readonly truncated: boolean }, E, R> =>
81 Effect.gen(function* () {
82 const pages: Page[] = [];
83 const seen = new Set<string>();
84 let after = options.from ?? null;
85 while (true) {
86 const page: Page = yield* read(after, pages);
87 pages.push(page);
88 const next = options.nextCursor(page);
89 if (next === null) return { pages, truncated: false };
90 if (
91 seen.has(next) ||
92 pages.length >= (options.maxPages ?? Infinity) ||
93 options.until?.(pages) === true
94 ) {
95 return { pages, truncated: true };
96 }
97 seen.add(next);
98 after = next;
99 }
100 });