| 1 | import * as Effect from "effect/Effect"; |
| 2 | |
| 3 | /** GraphQL variables by name, each with the type it is declared as and the value it carries. */ |
| 4 | export type GraphQlVariables = Readonly<Record<string, readonly [type: string, value: unknown]>>; |
| 5 | |
| 6 | export 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 | */ |
| 23 | export 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 | */ |
| 72 | export 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 | }); |