Esquema completo, resolvers con DataLoader, paginación por cursor, suscripciones WebSocket y cliente React para el dashboard de ventas.
# ── Escalares personalizados ─────────── scalar DateTime scalar Email # ── Entidades del dominio ────────────── type Contact { id: ID! fullName: String! email: Email! phone: String score: Int! # 0-100 lead score stage: PipelineStage! tags: [String!]! company: Company # cargado con DataLoader activities(limit: Int = 5): [Activity!]! deals: [Deal!]! createdAt: DateTime! } type Company { id: ID! name: String! sector: String! employees: Int website: String contacts: [Contact!]! } type Deal { id: ID! title: String! value: Float! # EUR probability: Int! # 0-100 stage: DealStage! contact: Contact! owner: User! closedAt: DateTime updatedAt: DateTime! }
enum PipelineStage { PROSPECT QUALIFIED PROPOSAL NEGOTIATION CLOSED } enum DealStage { OPEN WON LOST } # ── Paginación Relay-style ───────────── type ContactConnection { edges: [ContactEdge!]! pageInfo: PageInfo! totalCount: Int! } type ContactEdge { node: Contact!; cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! endCursor: String } # ── Operaciones ─────────────────────── type Query { contact(id: ID!): Contact contacts(filter: ContactFilter, first: Int = 20, after: String): ContactConnection! deals(stage: DealStage, ownerId: ID): [Deal!]! pipelineSummary: PipelineSummary! } type Mutation { createContact(input: CreateContactInput!): Contact! updateContact(id: ID!, input: UpdateContactInput!): Contact! moveDeal(dealId: ID!, newStage: DealStage!): Deal! logActivity(input: ActivityInput!): Activity! assignDeal(dealId: ID!, userId: ID!): Deal! } type Subscription { dealMoved(ownerId: ID): DealMovedEvent! contactScoreUpdated: Contact! } type DealMovedEvent { deal: Deal! fromStage: DealStage! toStage: DealStage! movedBy: User! movedAt: DateTime! }
import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@apollo/server/express4'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { WebSocketServer } from 'ws'; import { useServer } from 'graphql-ws/lib/use/ws'; import { PubSub } from 'graphql-subscriptions'; import depthLimit from 'graphql-depth-limit'; import express, { json } from 'express'; import cors from 'cors'; import http from 'node:http'; export const pubsub = new PubSub(); const schema = makeExecutableSchema({ typeDefs, resolvers }); const app = express(); const http = http.createServer(app); // WebSocket server (suscripciones) const wss = new WebSocketServer({ server: http, path: '/graphql' }); const serverCleanup = useServer({ schema, context: async (ctx) => ({ user: await getUserFromWsToken(ctx.connectionParams?.authToken), }), }, wss); // Apollo Server HTTP const apollo = new ApolloServer({ schema, validationRules: [depthLimit(7)], // evita queries anidadas abusivas plugins: [{ async serverWillStart() { return { async drainServer() { await serverCleanup.dispose(); } }; }, }], }); await apollo.start(); app.use('/graphql', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }), json(), expressMiddleware(apollo, { context: async ({ req }) => ({ user: await getUserFromToken(req.headers.authorization?.replace('Bearer ', '')), dataSources: createDataSources(), // DataLoaders por request pubsub, }), }) ); http.listen(4000, () => console.log('🚀 GraphQL: http://localhost:4000/graphql'));
// Al listar 20 contactos con su empresa: // 1 query → SELECT * FROM contacts LIMIT 20 // 20 queries → SELECT * FROM companies WHERE id = ? // (una por cada contacto) ❌ const resolvers = { Contact: { company: (parent, _, { db }) => db.companies.findById(parent.companyId), // ↑ se ejecuta 20 veces = 20 queries } };
import DataLoader from 'dataloader'; export function createDataSources() { // Loader: recibe [id1, id2, ...id20] en batch const companyLoader = new DataLoader(async (ids) => { const rows = await db.query( `SELECT * FROM companies WHERE id = ANY($1)`, [ids] // 1 sola query con IN clause ✅ ); const map = new Map(rows.map(r => [r.id, r])); return ids.map(id => map.get(id) ?? null); // ↑ orden preservado: req. de DataLoader }); return { companies: { getById: (id) => companyLoader.load(id), }, contacts: { getMany: ({ first, after, filter }) => paginateContacts({ first, after, filter }), getById: (id) => db.contacts.findById(id), }, }; } // Resolver limpio const resolvers = { Contact: { company: (parent, _, { dataSources }) => dataSources.companies.getById(parent.companyId), // ↑ 20 llamadas → 1 sola query a BD ✅ } };
import { GraphQLError } from 'graphql'; export const resolvers = { Query: { contacts: async (_, { filter, first = 20, after }, { dataSources }) => { // Decodificar cursor → offset/id const afterId = after ? Buffer.from(after, 'base64').toString('ascii') : null; const { items, hasMore, total } = await dataSources.contacts.getMany({ first: first + 1, afterId, filter }); const page = items.slice(0, first); return { edges: page.map(c => ({ node: c, cursor: Buffer.from(c.id).toString('base64'), })), pageInfo: { hasNextPage: items.length > first, hasPreviousPage: !!after, endCursor: page.at(-1) ? Buffer.from(page.at(-1).id).toString('base64') : null, }, totalCount: total, }; }, }, Mutation: { moveDeal: async (_, { dealId, newStage }, { dataSources, user, pubsub }) => { if (!user) throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED' } }); const deal = await dataSources.deals.getById(dealId); if (!deal) throw new GraphQLError('Deal not found', { extensions: { code: 'NOT_FOUND' } }); const updated = await dataSources.deals.updateStage(dealId, newStage); // Publicar evento para suscriptores WebSocket await pubsub.publish('DEAL_MOVED', { dealMoved: { deal: updated, fromStage: deal.stage, toStage: newStage, movedBy: user, movedAt: new Date() }, }); return updated; }, }, Subscription: { dealMoved: { subscribe: (_, { ownerId }, { pubsub }) => pubsub.asyncIterator(['DEAL_MOVED']), resolve: (payload) => payload.dealMoved, }, }, };
import { useQuery, useMutation, useSubscription, gql, ApolloClient, InMemoryCache, split, HttpLink, } from '@apollo/client'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; // Cliente con split HTTP / WebSocket const httpLink = new HttpLink({ uri: '/graphql' }); const wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/graphql', connectionParams: { authToken: localStorage.getItem('token') }, })); const link = split( ({ query }) => query.definitions.some(d => d.operation === 'subscription'), wsLink, httpLink ); const client = new ApolloClient({ link, cache: new InMemoryCache({ typePolicies: { Query: { fields: { contacts: { keyArgs: ['filter'], merge(existing, incoming) { /* cursor merge */ } } } } } }), }); // Queries / Subscriptions const PIPELINE_QUERY = gql` query PipelineData { deals(stage: OPEN) { id title value probability stage contact { fullName company { name } } owner { name avatarUrl } } pipelineSummary { totalValue wonValue openDeals avgProbability } } `; const DEAL_MOVED_SUB = gql` subscription OnDealMoved { dealMoved { fromStage toStage movedAt deal { id title value stage } movedBy { name } } } `; function PipelineDashboard() { const { data, loading } = useQuery(PIPELINE_QUERY); // Actualizar cache automáticamente al recibir evento useSubscription(DEAL_MOVED_SUB, { onData: ({ client, data: { data: ev } }) => { client.cache.modify({ id: client.cache.identify(ev.dealMoved.deal), fields: { stage: () => ev.dealMoved.toStage }, }); }, }); if (loading) return <Skeleton />; return <KanbanBoard deals={data.deals} summary={data.pipelineSummary} />; }
query ContactDetail($id: ID!) { contact(id: $id) { id fullName email phone score stage tags company { name sector employees website } activities(limit: 5) { type subject result date } deals { title value probability stage owner { name avatarUrl } } } }
{ "data": { "contact": {
"id": "c-7f3a", "fullName": "Laura Martínez",
"email": "l.martinez@growthlab.io",
"score": 82, "stage": "NEGOTIATION",
"tags": ["hot-lead", "q2-target"],
"company": {
"name": "GrowthHack Labs",
"sector": "Marketing Digital",
"employees": 48,
"website": "growthhacklabs.io"
},
"activities": [
{ "type": "CALL", "subject": "Demo producto",
"result": "POSITIVE", "date": "2026-06-12" },
{ "type": "EMAIL", "subject": "Propuesta enviada",
"result": "OPENED", "date": "2026-06-10" }
],
"deals": [{
"title": "LeadFlow Pro — Plan Anual",
"value": 31200, "probability": 75,
"stage": "OPEN",
"owner": { "name": "Carlos R." }
}]
}}}}