Skip to content

Thinking in MeshQL

Before wiring adapters or writing SQL, it helps to internalize how MeshQL splits work between client and server. This page is the React-style “thinking in…” guide — mechanics live in Concepts.

The client declares the shape. The server declares the fetch.

Your UI (or API consumer) sends a selection — which fields, which nested relations. MeshQL turns that into a JoinPlan and hands it to one resolver per root entity. You write a single query (SQL or ORM), return flat rows, and MeshQL’s shaper builds nested JSON.

That’s the whole loop:

Client selection → JoinPlan → your resolver → flat rows → nested JSON

REST stays REST. URLs, methods, and caching work normally. Selection rides in headers or a POST body — not a separate GraphQL endpoint.

Don’t design “endpoints per screen.” Design entities and ask: what shape does this view need?

A profile screen might need:

{
"user": {
"id": true,
"name": true,
"tokens": { "accessToken": true }
}
}

That object is the contract. In React, your component’s client.query({ user: { … } }) call is the spec — same idea as props describing UI, but for data.

Step 2: Model entities and joins, not resolver trees

Section titled “Step 2: Model entities and joins, not resolver trees”

GraphQL encourages a resolver per field. MeshQL encourages a schema of entities and joins:

Concept Role
Entity A queryable root type (user, post)
Fields Allowlisted scalars on that entity
Join How entities relate (user.tokens → many tokens)

Joins are declared once in schema config. You don’t reimplement “fetch tokens for user” in three different resolvers — the join definition and the client’s selection drive the plan.

See Concepts — Schema.

Step 3: One resolver per entity (not per field)

Section titled “Step 3: One resolver per entity (not per field)”

When a request arrives, MeshQL parses the selection and calls one mesh.resolve("user", …) handler. That handler receives a JoinPlan with:

  • plan.fields — only scalars the client asked for
  • plan.joins — only relations the client requested
mesh.resolve("user", async (plan) => {
// plan tells you exactly what to SELECT and JOIN
const { sql, params } = buildSelectSql(plan, schema);
return db.query(sql, params);
});

Rule of thumb: if you’re tempted to call the database inside a nested field helper, stop — fold it into the entity resolver as a join.

ORM adapters follow the same rule: mesh.resolve("*", prismaResolver(prisma)) is one catch-all per entity, not per field.

Resolvers return rows with aliased columns, not hand-nested objects:

user_id, user_name, tokens_accessToken

MeshQL’s shaper groups by entity prefix and builds:

{
"id": 1,
"name": "Ada",
"tokens": [{ "accessToken": "tok_1" }]
}

If you return { id, name, tokens: [...] } yourself, you’re fighting the shaper. Return what SQL naturally gives you; let MeshQL nest.

See Concepts — Shaper.

  • GET /mesh/user/1 + X-Mesh-Query — cacheable reads
  • POST /mesh — ad-hoc list queries
  • PUT /mesh/user/1 — updates with selection in headers

The client SDK sets headers for you. See HTTP adapters.

For live UI, SSE reuses the same selection: GET /mesh/user/1/events streams update events whenever pub/sub notifies a change. See meshql-sse.

If you’re thinking… In MeshQL…
“One endpoint per page” One entity per resource; pages differ by selection
“Resolver for each nested field” One join + one entity resolver
“Dataloader to fix N+1” One SQL JOIN — N+1 is structurally avoided
“GraphQL schema + codegen” TypeScript types + schema config; optional client inference
“Build JSON in the resolver” Return flat rows; shaper builds the tree
“POST everything” Prefer GET for reads when caching matters

Resolver per relation.
resolve("user") and resolve("token") as separate entry points for one nested read adds round trips. Use joins and one root resolver.

Over-fetching in SQL.
Ignore plan.fields and plan.joins — you’ll SELECT columns nobody asked for. The plan exists so you can trim the query.

Nested objects from SQL.
JSON aggregation in SQL works, but MeshQL expects flat aliased rows unless you’ve opted into a custom path. Prefer buildSelectSql + shaper.

Selection in the URL path.
Field lists belong in X-Mesh-Query (or persisted query IDs), not /users/1,name,email. Keeps URLs stable and cache keys sane.