UNPKG

@settlemint/sdk-thegraph

Version:

TheGraph integration module for SettleMint SDK, enabling querying and indexing of blockchain data through subgraphs

421 lines (418 loc) • 13.9 kB
/* SettleMint The Graph SDK - Indexing Protocol */ import { appendHeaders } from "@settlemint/sdk-utils/http"; import { ensureServer } from "@settlemint/sdk-utils/runtime"; import { ApplicationAccessTokenSchema, UrlOrPathSchema, validate } from "@settlemint/sdk-utils/validation"; import { initGraphQLTada, readFragment } from "gql.tada"; import { GraphQLClient } from "graphql-request"; import { z } from "zod"; import { sortBy } from "es-toolkit"; import { get, isArray, isEmpty, set } from "es-toolkit/compat"; import { Kind, parse, visit } from "graphql"; //#region src/utils/pagination.ts const THE_GRAPH_LIMIT = 500; const FIRST_ARG = "first"; const SKIP_ARG = "skip"; const FETCH_ALL_DIRECTIVE = "fetchAll"; /** * Detects and strips @fetchAll directives from a GraphQL document * * @param {DocumentNode} document - The GraphQL document to process * @returns {Object} Processed document and list of fields with @fetchAll * * @remarks * This function: * - Identifies fields decorated with @fetchAll directive * - Removes the directive from the AST (The Graph doesn't recognize it) * - Returns both the cleaned document and a list of fields to auto-paginate */ function stripFetchAllDirective(document) { const fetchAllFields = new Set(); const documentNode = typeof document === "string" ? parse(document) : document; const strippedDocument = visit(documentNode, { Field(node) { if (node.directives && node.directives.length > 0) { const hasFetchAll = node.directives.some((dir) => dir.name.value === FETCH_ALL_DIRECTIVE); if (hasFetchAll) { const fieldIdentifier = node.alias?.value || node.name.value; fetchAllFields.add(fieldIdentifier); return { ...node, directives: node.directives.filter((dir) => dir.name.value !== FETCH_ALL_DIRECTIVE) }; } } return node; } }); return { document: strippedDocument, fetchAllFields }; } /** * Custom merge function for deep object merging with special handling for lists * * @param {unknown} target - The target object or value to merge into * @param {unknown} source - The source object or value to merge from * @returns {unknown} Merged result with preservation of arrays and specific merge logic * * @remarks * Key behaviors: * - Preserves existing arrays without merging * - Handles null and undefined values * - Performs deep merge for nested objects * - Prioritizes source values for primitives * */ function customMerge(target, source) { if (source == null) return target; if (target == null) return source; if (isArray(source)) { return source; } if (typeof target !== "object" || typeof source !== "object") { return source; } const targetObj = target; const sourceObj = source; const result = { ...targetObj }; for (const key in sourceObj) { if (Object.hasOwn(sourceObj, key)) { result[key] = key in result ? customMerge(result[key], sourceObj[key]) : sourceObj[key]; } } return result; } function extractFetchAllFields(document, variables, fetchAllFields) { const fields = []; const pathStack = []; visit(document, { Field: { enter: (node) => { const fieldIdentifier = node.alias?.value || node.name.value; pathStack.push(fieldIdentifier); if (node.name.value.startsWith("__")) { return; } let firstValue; let skipValue; const otherArgs = []; if (node.arguments) { for (const arg of node.arguments) { if (arg.name.value === FIRST_ARG) { if (arg.value.kind === Kind.INT) { firstValue = Number.parseInt(arg.value.value, 10); } else if (arg.value.kind === Kind.VARIABLE && variables) { const varName = arg.value.name.value; const varValue = variables[varName]; firstValue = typeof varValue === "number" ? varValue : undefined; } } else if (arg.name.value === SKIP_ARG) { if (arg.value.kind === Kind.INT) { skipValue = Number.parseInt(arg.value.value, 10); } else if (arg.value.kind === Kind.VARIABLE && variables) { const varName = arg.value.name.value; const varValue = variables[varName]; skipValue = typeof varValue === "number" ? varValue : undefined; } } else { otherArgs.push(arg); } } } const fieldIdentifierForDirective = node.alias?.value || node.name.value; const hasFetchAllDirective = fetchAllFields?.has(fieldIdentifierForDirective); if (hasFetchAllDirective) { const parentFetchAllField = fields.find((field) => pathStack.join(",").startsWith(field.path.join(","))); if (parentFetchAllField) { throw new Error(`Nesting of @fetchAll directive is not supported: ${pathStack.join(".")} is a child of ${parentFetchAllField.path.join(".")}`); } fields.push({ path: [...pathStack], fieldName: node.name.value, firstValue: firstValue ?? THE_GRAPH_LIMIT, skipValue: skipValue ?? 0, otherArgs }); } }, leave: () => { pathStack.pop(); } } }); return fields; } function createSingleFieldQuery(document, targetField, skip, first) { const targetPath = [...targetField.path]; const pathStack = []; return visit(document, { Field: { enter: (node) => { const fieldIdentifier = node.alias?.value || node.name.value; pathStack.push(fieldIdentifier); const onPath = pathStack.every((segment, i) => i >= targetPath.length || segment === targetPath[i]); if (!onPath) { pathStack.pop(); return null; } const isTarget = pathStack.length === targetPath.length && pathStack.every((segment, i) => segment === targetPath[i]); if (isTarget) { const newArgs = [...targetField.otherArgs]; newArgs.push({ kind: Kind.ARGUMENT, name: { kind: Kind.NAME, value: FIRST_ARG }, value: { kind: Kind.INT, value: first.toString() } }, { kind: Kind.ARGUMENT, name: { kind: Kind.NAME, value: SKIP_ARG }, value: { kind: Kind.INT, value: skip.toString() } }); return { ...node, arguments: newArgs }; } return undefined; }, leave: () => { pathStack.pop(); } } }); } function createNonListQuery(document, listFields) { const pathStack = []; const filtered = visit(document, { Field: { enter: (node) => { const fieldIdentifier = node.alias?.value || node.name.value; pathStack.push(fieldIdentifier); const isList = listFields.some((field) => field.path.length === pathStack.length && field.path.every((segment, i) => segment === pathStack[i])); if (isList) { pathStack.pop(); return null; } return undefined; }, leave: (node) => { pathStack.pop(); if (node.selectionSet && node.selectionSet.selections.length === 0) { return null; } return undefined; } } }); return filtered; } function countExecutableFields(document) { let fieldCount = 0; visit(document, { Field: (node) => { if (!node.name.value.startsWith("__")) { if (!node.selectionSet || node.selectionSet.selections.length > 0) { fieldCount += 1; } } } }); return fieldCount; } function filterVariables(variables, document) { if (!variables) return undefined; const usedVariables = new Set(); visit(document, { Variable: (node) => { usedVariables.add(node.name.value); } }); const filtered = {}; const varsObj = variables; for (const key of usedVariables) { if (key in varsObj) { filtered[key] = varsObj[key]; } } return isEmpty(filtered) ? undefined : filtered; } /** * Creates a TheGraph client that supports pagination for list fields * * @param theGraphClient - The GraphQL client to use for requests * @returns A TheGraph client that supports pagination for list fields * @internal Used internally by createTheGraphClient */ function createTheGraphClientWithPagination(theGraphClient) { async function executeListFieldPagination(document, variables, field, requestHeaders) { const results = []; let currentSkip = field.skipValue || 0; let hasMore = true; const batchSize = Math.min(field.firstValue || THE_GRAPH_LIMIT, THE_GRAPH_LIMIT); while (hasMore) { const query = createSingleFieldQuery(document, field, currentSkip, batchSize); const existingVariables = filterVariables(variables, query) ?? {}; const response = await theGraphClient.request(query, { ...existingVariables, first: batchSize, skip: currentSkip }, requestHeaders); const data = get(response, field.path) ?? get(response, field.fieldName); const parentPath = field.path.slice(0, -1); const parentData = get(response, parentPath); if (isArray(parentData) && parentData.length > 0) { throw new Error(`Response is an array, but expected a single object for field ${parentPath.join(".")}. The @fetchAll directive is not supported inside a query that returns a list of items.`); } if (isArray(data) && data.length > 0) { results.push(...data); hasMore = data.length === batchSize; } else { hasMore = false; } currentSkip += batchSize; } return results; } return { async query(documentOrOptions, variablesRaw, requestHeadersRaw) { let document; let variables; let requestHeaders; if (isRequestOptions(documentOrOptions)) { document = documentOrOptions.document; variables = documentOrOptions.variables ?? {}; requestHeaders = documentOrOptions.requestHeaders; } else { document = documentOrOptions; variables = variablesRaw ?? {}; requestHeaders = requestHeadersRaw; } const { document: processedDocument, fetchAllFields } = stripFetchAllDirective(document); const listFields = extractFetchAllFields(processedDocument, variables, fetchAllFields); if (listFields.length === 0) { return theGraphClient.request(processedDocument, variables, requestHeaders); } const result = {}; const sortedFields = sortBy(listFields, [(field) => field.path.length]); const fieldDataPromises = sortedFields.map(async (field) => ({ field, data: await executeListFieldPagination(processedDocument, variables, field, requestHeaders) })); const fieldResults = await Promise.all(fieldDataPromises); for (const { field, data } of fieldResults) { set(result, field.path, data); } const nonListQuery = createNonListQuery(processedDocument, listFields); if (nonListQuery) { if (countExecutableFields(nonListQuery) === 0) { return result; } const nonListResult = await theGraphClient.request(nonListQuery, filterVariables(variables, nonListQuery) ?? {}, requestHeaders); const merged = customMerge(nonListResult, result); return merged; } return result; } }; } function isRequestOptions(args) { return typeof args === "object" && args !== null && "document" in args; } //#endregion //#region src/thegraph.ts /** * Schema for validating client options for the TheGraph client. */ const ClientOptionsSchema = z.object({ instances: z.array(UrlOrPathSchema), accessToken: ApplicationAccessTokenSchema.optional(), subgraphName: z.string(), cache: z.enum([ "default", "force-cache", "no-cache", "no-store", "only-if-cached", "reload" ]).optional() }); /** * Constructs the full URL for TheGraph GraphQL API based on the provided options * * @param options - The client options for configuring TheGraph client * @returns The complete GraphQL API URL as a string * @throws Will throw an error if no matching instance is found for the specified subgraph */ function getFullUrl(options) { const instance = options.instances.find((instance$1) => instance$1.endsWith(`/${options.subgraphName}`)); if (!instance) { throw new Error(`Instance for subgraph ${options.subgraphName} not found`); } return new URL(instance).toString(); } /** * Creates a TheGraph GraphQL client with proper type safety using gql.tada * * @param options - Configuration options for the client including instance URLs, * access token and subgraph name * @param clientOptions - Optional GraphQL client configuration options * @returns An object containing: * - client: The configured GraphQL client instance * - graphql: The initialized gql.tada function for type-safe queries * @throws Will throw an error if the options fail validation against ClientOptionsSchema * @example * import { createTheGraphClient } from '@settlemint/sdk-thegraph'; * import type { introspection } from '@schemas/the-graph-env-kits'; * import { createLogger, requestLogger } from '@settlemint/sdk-utils/logging'; * * const logger = createLogger(); * * const { client, graphql } = createTheGraphClient<{ * introspection: introspection; * disableMasking: true; * scalars: { * Bytes: string; * Int8: string; * BigInt: string; * BigDecimal: string; * Timestamp: string; * }; * }>({ * instances: JSON.parse(process.env.SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS || '[]'), * accessToken: process.env.SETTLEMINT_ACCESS_TOKEN, * subgraphName: 'kits' * }, { * fetch: requestLogger(logger, "the-graph-kits", fetch) as typeof fetch, * }); * * // Making GraphQL queries * const query = graphql(` * query SearchAssets { * assets @fetchAll { * id * name * symbol * } * } * `); * * const result = await client.request(query); */ function createTheGraphClient(options, clientOptions) { ensureServer(); const validatedOptions = validate(ClientOptionsSchema, options); const graphql = initGraphQLTada(); const fullUrl = getFullUrl(validatedOptions); const client = new GraphQLClient(fullUrl, { ...clientOptions, headers: appendHeaders(clientOptions?.headers, { "x-auth-token": validatedOptions.accessToken }) }); const originalRequest = client.request.bind(client); const paginatedClient = createTheGraphClientWithPagination({ request: originalRequest }); client.request = paginatedClient.query; return { client, graphql }; } //#endregion export { ClientOptionsSchema, createTheGraphClient, createTheGraphClientWithPagination, readFragment }; //# sourceMappingURL=thegraph.js.map