Tina4

Chapter 9: GraphQL#

One Method, Two Worlds#

REST gives you URLs. GraphQL gives you a query language. Different philosophies, different trade-offs -- but your frontend shouldn't care. The api module you met in Chapter 6 already speaks GraphQL. Same auth. Same token rotation. Same error handling. One extra method and you're talking to a GraphQL endpoint.

No Apollo. No urql. No code generation. You write a query string, pass it to api.graphql(), and get back { data, errors }. The framework handles the plumbing.


1. Your First Query#

api.graphql() sends a POST with { query, variables } and returns a typed response.

javascript
import { api } from "tina4js";โ€‹api.configure({ baseUrl: "/api", auth: true });โ€‹const { data, errors } = await api.graphql("/graphql",    "{ products { id name price } }");โ€‹if (errors) {    console.error("GraphQL errors:", errors);} else {    console.log(data.products);}

The response shape never changes:

typescript
{    data: T | null;          // Your result, or null on failure    errors?: Array<{ message: string }>;  // Present only when something went wrong}

Query succeeds -- data holds your result, errors is undefined. Query fails -- data is null, errors tells you why. No status codes to decode. No response body formats to guess at.


2. Variables#

Hardcoded values in query strings invite injection and kill reusability. Variables fix both problems. Pass them as the third argument.

javascript
const { data } = await api.graphql("/graphql",    `query ($limit: Int!, $offset: Int!) {        products(limit: $limit, offset: $offset) {            id            name            price            stock        }    }`,    { limit: 10, offset: 0 });

A search query with user input:

javascript
const { data } = await api.graphql("/graphql",    `query ($term: String!) {        search_products(term: $term) {            id            name            price        }    }`,    { term: "widget" });

The variable goes into the JSON body, not the query string. The server handles escaping. Your frontend stays clean.


3. Mutations#

Queries read. Mutations write. Same method, different keyword.

Create#

javascript
const { data } = await api.graphql("/graphql",    `mutation ($input: ProductInput!) {        create_product(input: $input) {            id            name            price        }    }`,    {        input: {            name: "New Widget",            price: 29.99,            category_id: 1,            stock: 100        }    });โ€‹console.log("Created:", data.create_product.id);

Update#

javascript
const { data } = await api.graphql("/graphql",    `mutation ($id: Int!, $input: ProductInput!) {        update_product(id: $id, input: $input) {            id            name            price        }    }`,    { id: 42, input: { price: 24.99 } });

Delete#

javascript
const { data } = await api.graphql("/graphql",    `mutation ($id: Int!) {        delete_product(id: $id) {            success        }    }`,    { id: 42 });

Three operations. One method. The server reads the query or mutation keyword and knows what to do.


4. Authentication#

api.graphql() runs through the same auth pipeline as every other api method. Configure auth once and forget about it.

javascript
api.configure({    baseUrl: "/api",    auth: true,});โ€‹// The Bearer token rides alongconst { data } = await api.graphql("/graphql",    "{ me { id name email role } }");

Token rotation works too. The server sends a FreshToken header. The client stores it. The next request uses the fresh token. You never touch any of this.


5. Error Handling#

GraphQL responses carry two kinds of failure. The HTTP request itself can fail -- network down, server crashed, 500 response. Or the request succeeds but the query contains errors -- bad field name, permission denied, validation failure.

GraphQL Errors (query-level)#

The server returns 200 but errors is populated. data might still hold partial results.

javascript
const { data, errors } = await api.graphql("/graphql",    `{        products { id name }        categories { id name }    }`);โ€‹if (errors) {    for (const err of errors) {        console.warn("GraphQL error:", err.message);    }}โ€‹// Products might have loaded even if categories failedif (data?.products) {    renderProducts(data.products);}

Network Errors (transport-level)#

The HTTP request never completed. This throws.

javascript
try {    const { data } = await api.graphql("/graphql", "{ products { id } }");} catch (err) {    console.error("Network error:", err);}

Check errors for query problems. Catch exceptions for network problems. Handle both and your UI stays resilient.


6. Custom Headers#

Per-request headers and query params go in the fourth argument. Same options object as api.get() and api.post().

javascript
const { data } = await api.graphql("/graphql",    "{ products { id name } }",    {},    {        headers: { "X-Request-Id": "abc123" },        params: { debug: "true" }    });

The empty object in position three means "no variables." The options follow.


7. Tina4 Backend Integration#

Every Tina4 backend -- Python, PHP, Ruby, Node.js -- generates a GraphQL endpoint from your ORM models. Register a model. The backend builds the schema. The frontend queries it. No SDL files. No resolvers to wire by hand.

The default endpoint is /api/graphql.

javascript
api.configure({ baseUrl: "" });โ€‹// The backend generated this query from the User modelconst { data } = await api.graphql("/api/graphql",    "{ users(limit: 10) { id name email } }");โ€‹// Mutations are generated tooconst { data: created } = await api.graphql("/api/graphql",    `mutation {        create_user(name: "Alice", email: "alice@example.com") {            id name email        }    }`);

Your ORM models define the schema. The GraphQL layer reads it. The frontend consumes it. Three layers, zero duplication.


8. TypeScript#

api.graphql() accepts a type parameter. Pass an interface that describes the response shape and TypeScript narrows the return type.

typescript
interface ProductsResponse {    products: Array<{        id: number;        name: string;        price: number;    }>;}โ€‹const { data } = await api.graphql<ProductsResponse>("/graphql",    "{ products { id name price } }");โ€‹// data is ProductsResponse | nulldata?.products.forEach(p => console.log(p.name));

The type flows through destructuring. Your editor autocompletes data.products[0].name. No type assertions needed.


9. Reactive Queries with Signals#

GraphQL and signals fit together. A signal changes. An effect fires the query. The result lands in another signal. The UI updates.

javascript
import { signal, effect, html } from "tina4js";import { api } from "tina4js/api";โ€‹const searchTerm = signal("");const products = signal([]);const loading = signal(false);โ€‹effect(async () => {    const term = searchTerm.value;    if (term.length < 2) return;โ€‹    loading.value = true;    const { data } = await api.graphql("/api/graphql",        `query ($term: String!) {            search_products(term: $term) { id name price }        }`,        { term }    );    products.value = data?.search_products || [];    loading.value = false;});โ€‹const view = html`    <input @input=${(e) => { searchTerm.value = e.target.value; }}           placeholder="Search products...">    ${() => loading.value        ? html`<p>Loading...</p>`        : html`<ul>${() => products.value.map(p =>            html`<li>${p.name} - $${p.price}</li>`          )}</ul>`    }`;

The user types. The signal updates. The effect queries. The list renders. No state management library. No cache normalisation layer. Signals and GraphQL, working together.


Summary#

CallWhat it does
api.graphql(path, query)Run a query
api.graphql(path, query, variables)Run a query with variables
api.graphql(path, query, variables, options)Run a query with custom headers or params

One method. It sends { query, variables } as JSON. It returns { data, errors }. Auth, interceptors, and token rotation carry over from your api.configure() call. Everything you learned in Chapter 6 applies here -- GraphQL rides on the same transport.