How-to guide

Fetch only what a component needs

Source Clients/, ClientDocuments.cs

A frontend reaches a train through a document it writes. The host generates none for a train, because only the component knows what it needs from an answer: an entity whose navigations lead to other entities has no finite "everything" worth fetching. Write a .graphql document selecting what the component needs: the host resolves only that, and the generated function's type is exactly what comes back. These steps assume a host with persisted operations and a client generated with trax client.

1. Write the document

Put a .graphql file anywhere in the frontend's source. It holds one named query or mutation, rooted where the train sits: discover for a query train or a query model, dispatch for a mutation train, and the namespace if it has one.

# src/trax-documents/orderCard.graphql
query OrderCard($input: FindOrderInput!) {
  discover {
    findOrder(input: $input) {
      title
      customer {
        ...CustomerBadge
      }
    }
  }
}

A list of a query model is paged, filtered and sorted in the document; only the values vary:

# src/trax-documents/openOrders.graphql
query OpenOrders($customer: String, $after: String) {
  discover {
    orders(
      first: 20
      after: $after
      where: { state: { eq: "open" }, customerName: { eq: $customer } }
      order: { placedAt: DESC }
    ) {
      totalCount
      nodes {
        title
        placedAt
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
}

The query model is projected, so the host reads only title and placedAt from the database.

A shape several components share goes in a named fragment, in a file of its own or beside the operation:

# src/trax-documents/customerBadge.graphql
fragment CustomerBadge on Customer {
  name
}

What a document may hold:

  • Trains and query models. Under discover or dispatch, only the trains and the query models the host registered, and their namespaces. A query may hold several, trains and query models together; a mutation runs exactly one train. A field someone added to discover by hand is refused. The operations surface, machines and runProgress keep their own generated documents.
  • Filters and sorts in the document. Write a query model's where and order in the document and pass only their values as variables (where: { state: { eq: $state } }, order: { title: $direction }). A variable whose type is a filter or sort input ($where: OrderFilterInput) is refused, so the id fixes which filter runs.
  • No directives. @include, @skip, @defer and @stream are refused, so every field in the type is always there.
  • Any depth the host allows. A document may follow any field, an entity's reference back to its parent included; the host's MaxExecutionDepth, cost limit and operation cap still apply.

2. Generate its function

Run trax client with --documents, from the frontend's root:

trax client --manifest src/trax/manifest.json --out src/trax --documents 'src/**/*.graphql'

It checks every document against the host's types, as the host will, and refuses with the file, line and column of each problem: an unknown field or argument, an inline filter or sort naming a field the model does not have or a value of the wrong type, a query model in a mutation, and a filter or sort passed as a variable. For each operation it writes documents/<operation>.g.ts, exported from index.g.ts:

export type OrderCardResult = {
  title: string;
  customer: {
    name: string;
  };
};
 
export function orderCard(input: FindOrderInput): Promise<OrderCardResult>;

Each named fragment becomes a type in fragments.g.ts. A component typed with it takes any answer that has those fields, from any document:

import { orderCard, type CustomerBadge } from "./trax/index.g";
 
function Badge({ customer }: { customer: CustomerBadge }) {
  return <b>{customer.name}</b>;
}
 
const order = await orderCard({ id: 7 });
<Badge customer={order.customer} />;

It also writes trax.documents.json, the documents as the host registers them, and schema.graphql with graphql.config.yml, so the GraphQL editor extension completes and checks the .graphql files. Commit all of them. trax client --check with the same --documents fails when any is stale.

3. Register the documents on the host

Name the file in ClientDocuments, relative to the host's content root:

using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
 
builder.Services.AddTraxGraphQL(graphql =>
    graphql.UsePersistedOperations(opts =>
        opts.RequirePersisted(true)
            .SingleNode()
            .ClientDocuments("../web/src/trax/trax.documents.json")
    )
);

The host checks every document again when it starts and runs it through its own validation. A document that reaches anything but its trains and query models, passes a filter or sort as a variable, names a field the host no longer has, or is deeper than its MaxExecutionDepth refuses the start, naming the document and its file. Each train's and query model's own authorization still applies to every call.

4. Deploy the host first

The host must hold a document before a page sends it, so deploy the host, then the frontend. A page built before the deploy keeps working: a version of a document the frontend no longer holds keeps running, on every node, while it still validates, until no node has registered it for the grace period (KeepOlderMachineDocumentsFor, seven days by default). After that, or once a schema change breaks it, the host answers CLIENT_OUTDATED and the page reloads.

SDK Reference

ClientDocuments | UsePersistedOperations | Client manifest | trax client