# GraphQL & tRPC

Federated operations and service procedures


Expose a federated GraphQL subgraph for UI reads and tRPC procedures for service-to-service calls.

For auth headers see [API Authentication](https://erxes.io/docs/api-authentication); for how the gateway composes subgraphs see [Backend & API Gateway](https://erxes.io/docs/architecture-backend-gateway).

## Federated subgraph conventions

`startPlugin` builds your `typeDefs`/`resolvers` into a subgraph (the plugin's part of the composed schema) with `buildSubgraphSchema` and serves it at `/graphql`. The gateway polls each service's subgraph endpoint and composes the supergraph with `rover supergraph compose`.

- Compose `typeDefs` from `apolloCommonTypes` (scalars `Date`/`JSON`, `CURSOR_DIRECTION`, `CURSOR_MODE`, `Attachment`, `Coordinate`, `PageInfo`) plus per-module schema strings under `extend type Query` / `extend type Mutation`.
- Operation names are unique repo-wide and prefixed with the plugin or module: `salesPipelines`, `frontlineInbox`, `cmsPageList`. Client-facing client-portal operations use the `cp` prefix (`cpSalesPipelines`).
- Keep schema and resolvers inside their module's `graphql/` directory.

## Pagination

Two parameter styles exist and are not interchangeable:

| Style | Schema fragment | Resolver helper |
| --- | --- | --- |
| Cursor | `GQL_CURSOR_PARAM_DEFS` (`limit`, `cursor`, `cursorMode`, `direction`, `orderBy`, `sortMode`, `aggregationPipeline`) | `cursorPaginate` / `cursorPaginateAggregation` |
| Offset | `GQL_OFFSET_PARAM_DEFS` (`page`, `perPage`, `sortField`, `sortDirection`) | `defaultPaginate` |

`cursorPaginate({ model, params, query, formatter })` ([mongoose-utils.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/erxes-api-shared/src/utils/mongo/mongoose-utils.ts)) enforces `limit` 1–100 (default 20), decodes the base64 cursor, applies `orderBy` with an `_id` tiebreaker, and returns `{ list, totalCount, pageInfo }`. Declare `formatter` for typed sort fields (`createdAt: 'date'`). A sales example:

```graphql
# modules/sales/graphql/schemas/pipeline.ts
salesPipelines(
  boardId: String,
  isAll: Boolean
  limit: Int
  cursor: String
  cursorMode: CURSOR_MODE
  direction: CURSOR_DIRECTION
  orderBy: JSON
  sortMode: String
  aggregationPipeline: [JSON]
): SalesPipelinesListResponse
```

```ts
const { list, totalCount, pageInfo } =
  await cursorPaginateAggregation<IPipelineDocument>({
    model: models.Pipelines,
    pipeline: [{ $match: query }, PIPELINE_STATUS_RANK_STAGE],
    params: { ...params, orderBy: PIPELINES_ORDER_BY, limit: params.limit || 20 },
    formatter: { createdAt: 'date' },
  });
```

## The resolver pipeline and `wrapperConfig`

`wrapApolloResolvers` wraps every Query/Mutation resolver: `beforeResolvers` hooks run first, then `wrapPermission` calls `checkLogin(user)`, mutations go through `withLogging`, and everything ends in `withSentryCapture`. Per-resolver overrides live on `resolver.wrapperConfig`:

```ts
// opt out of the login check for client-portal traffic (sales wishlist)
wishlistQueries.cpWishlist.wrapperConfig = { forClientPortal: true };

// or mark a whole resolver map (sales productReview mutations)
markResolvers(productReviewMutations, {
  wrapperConfig: { skipPermission: true },
});
```

- `forClientPortal` requires `context.clientPortal`; add `cpUserRequired: true` to also require `context.cpUser`.
- `skipPermission` makes the resolver public; use it sparingly.
- Inside resolvers, `context.checkPermission('<action>')` enforces a registered action and OAuth scope; `context` also carries `user`, `subdomain`, `models`, `eventHandlers`, `__(doc)`, and `processId`.

## tRPC routers

The generated `src/trpc/init-trpc.ts` creates `t = initTRPC.context<ITRPCContext>().create()` and an `appRouter`. Plugins type the context with their models and merge module routers; sales:

```ts
export type SalesTRPCContext = ITRPCContext<{ models: IModels }>;
const t = initTRPC.context<SalesTRPCContext>().create();

export const appRouter = t.mergeRouters(
  dealTrpcRouter,
  posTrpcRouter,
  documentTrpcRouter,
  // …
);
```

Procedures are addressed as `<module>.<action>`. From sales' `dealTrpcRouter`:

```ts
export const dealTrpcRouter = t.router({
  deal: {
    findOne: t.procedure
      .input(z.any())
      .query(async ({ ctx, input }) => {
        return await ctx.models.Deals.findOne(input).lean();
      }),
    find: t.procedure
      .input(z.any())
      .query(async ({ ctx, input }) => {
        const { query, skip, limit, sort = {} } = input;
        if (!query) return ctx.models.Deals.find(input).lean();
        return ctx.models.Deals.find(query).skip(skip || 0).limit(limit || 0).sort(sort).lean();
      }),
  },
});
```

## Calling other services

Use `sendTRPCMessage` ([trpc/index.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/erxes-api-shared/src/utils/trpc/index.ts)): it looks up the target's address from Redis service discovery, skips disabled plugins, encodes the tenant and caller context into the `x-trpc-context` header, and returns `defaultValue` on failure unless `throwOnError` is set:

```ts
const fields = await sendTRPCMessage({
  subdomain,
  pluginName: 'core',
  method: 'query',
  module: 'fields',
  action: 'find',
  input: { query: { contentType: 'sales:deal' } },
  defaultValue: [],
});
```

The callee sees `ctx.subdomain` plus optional `userId`/`processId` from the header; the `/trpc` mount rejects mutation calls that carry no context. For a raw client, the generated `src/trpc/trpcClients.ts` builds `createTRPCUntypedClient` with `httpBatchLink({ url: (await getPlugin('core')).address + '/trpc' })` after `isEnabled('core')`. On SaaS, `sendTRPCMessage` instead routes through `https://<subdomain>.next.erxes.io/gateway/pl:<plugin>/trpc`.

## Subscriptions over Redis

Pub/sub runs on `graphqlPubsub`, a `RedisPubSub` instance from `erxes-api-shared/utils`. A plugin declares subscriptions in a dedicated file (`src/apollo/subscription.ts`) exporting `{ name, typeDefs, generateResolvers(graphqlPubsub) }`:

```ts
export default {
  name: 'sales',
  typeDefs: `salesDealChanged(_id: String!): DealSubscription`,
  generateResolvers: (graphqlPubsub) => ({
    salesDealChanged: {
      resolve: (payload) => payload.salesDealChanged,
      subscribe: (_, { _id }) =>
        graphqlPubsub.asyncIterator(`salesDealChanged:${_id}`),
    },
  }),
};
```

Set `hasSubscriptions: true` and `subscriptionPluginPath` in `startPlugin`. The file is served at `/subscriptionPlugin.js`; the gateway's subscription service downloads each plugin's bundle ([downloadPlugins.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/gateway/src/subscription/plugins/downloadPlugins.ts)) to build the composite subscription resolver. Publish with `graphqlPubsub.publish('<channel>', payload)` from resolvers or `afterProcess` handlers.

