From GraphQL
If you know GraphQL, you already understand half of MeshQL: clients pick fields, servers shape responses. The other half is different on purpose — fewer moving parts, SQL-native fetching, REST transport.
For the underlying mindset, read Thinking in MeshQL first. This page is the translation guide.
What stays familiar
Section titled “What stays familiar”| GraphQL | MeshQL |
|---|---|
| Selection sets | JSON selection or { brace syntax } |
| Nested objects | Joins in schema + shaper |
| Typed client | @meshql/client / meshql-client |
| Schema as contract | MeshSchema entities + fields + joins |
| “Give me these fields” | Same — via X-Mesh-Query or persisted query ID |
Example — GraphQL:
query { user(id: 1) { id name tokens { accessToken } }}MeshQL JSON selection (equivalent shape):
{ "user": { "id": true, "name": true, "tokens": { "accessToken": true } } }QL syntax:
{ user { id name tokens { accessToken } } }Transport: GET /mesh/user/1 with base64-encoded query in X-Mesh-Query. See HTTP wire spec.
Concept mapping
Section titled “Concept mapping”| GraphQL | MeshQL | Notes |
|---|---|---|
type User |
entities.user |
Root queryable type |
| Object field | joins["user.tokens"] |
Declared once, not per-request resolver |
| Field resolver | (none) | Fetch happens at entity level |
| Root / parent resolver | mesh.resolve("user", fn) |
One function per entity |
Query type |
HTTP routes + selection | REST verbs, not a single POST endpoint |
| Fragment | Shared JSON selection object or persisted query | Reuse by ID in production |
@deprecated |
Remove from fields allowlist |
Schema is an allowlist |
| Dataloader | Not needed | JoinPlan → one JOIN |
DataLoader batching |
SQL JOIN or ORM nested select |
N+1 avoided by design |
| Mutation | PUT / POST / DELETE on /mesh/... |
Same integrity/access stack |
| Subscription | @meshql/sse + @meshql/pubsub |
SSE refresh, not GraphQL websocket protocol |
| Federation | @meshql/gateway V1 (static multi-service stitch) |
Dynamic discovery later |
| Introspection | No built-in | Document schema in OpenAPI or TS types |
| SDL | TypeScript schema config, ORM inference, or @meshql/codemods |
CLI converts SDL → schema + migration report |
The big mental shift
Section titled “The big mental shift”GraphQL: fields resolve independently
Section titled “GraphQL: fields resolve independently”User.tokens → resolver runs → DB query per user (N+1) → dataloader patches over itMeshQL: entities resolve once
Section titled “MeshQL: entities resolve once”Client asks for user + tokens → JoinPlan includes user fields + tokens join → one resolver → one SQL query with JOIN → shaper nests JSONYou stop thinking “how do I resolve this field?” and start thinking “what does this entity read need to fetch?”
Side-by-side: resolver
Section titled “Side-by-side: resolver”GraphQL
const resolvers = { Query: { user: (_, { id }) => db.user.findUnique({ where: { id } }), }, User: { tokens: (user) => db.token.findMany({ where: { userId: user.id } }), },};MeshQL
mesh.resolve("user", async (plan) => { const { sql, params } = buildSelectSql(plan, schema); return db.query(sql, params); // one query, includes tokens if client asked});With Prisma:
withPrisma(mesh, prisma, { schema });// plan → nested prisma.select from JoinPlanSide-by-side: list + filter
Section titled “Side-by-side: list + filter”GraphQL — often custom args + connection spec:
query { users(first: 10, role: ADMIN) { id name }}MeshQL — $list in selection (client SDK list option):
await client.query( { user: { id: true, name: true } }, { list: { limit: 10, filter: [{ field: "role", op: "eq", value: "admin" }], }, },);See List options spec.
Subscriptions
Section titled “Subscriptions”GraphQL subscriptions are a persistent websocket with field-level publish/subscribe semantics.
MeshQL v0.9.0 takes a different, REST-native path:
@meshql/pubsub— fan-out on entity/record channels (memory, Redis, Postgres).@meshql/sse—GET /mesh/:entity/:id/eventswith the same selection headers as a point read.- On mutation, call
notifyEntityUpdate(pubsub, "post", id). - Each subscriber gets an SSE
updateevent with freshly shaped JSON.
Good fit when: browser clients, cache-friendly infra, “refresh this record when it changes.”
Not a drop-in for: GraphQL subscription filters, arbitrary event streams, or third-party GraphQL clients.
What you give up
Section titled “What you give up”- Introspection — no
__schemaquery; use docs, TS types, or OpenAPI alongside MeshQL. - GraphQL ecosystem — Apollo, Relay, GraphiQL plugins don’t apply as-is.
- Public third-party API — GraphQL’s self-documenting schema shines for external developers you don’t control.
- Federation at basic level —
@meshql/gatewayV1 ships static multi-service stitch; dynamic discovery is later
What you gain
Section titled “What you gain”- No dataloaders — joins are the answer.
- HTTP caching — GET reads cache at CDN and browser.
- Smaller stack — no codegen loop for many teams.
- SQL/ORM-native — one query you can explain in EXPLAIN.
- Incremental adoption — run beside
/graphql.
Incremental migration
Section titled “Incremental migration”MeshQL does not require a big bang:
app.use("/graphql", graphqlHandler);app.use(meshExpressRouter(mesh, "/mesh"));app.use(meshSseExpressRouter(mesh, { pubsub }, "/mesh"));Suggested order:
- Week 1–2 — new features on
/meshonly. - Week 3–4 — migrate simple queries (single entity, no custom GraphQL middleware).
- Month 2 — migrate reads with N+1 pain (biggest win).
- Month 3+ — optional GraphQL retirement.
Automated migration
Section titled “Automated migration”@meshql/codemods reads GraphQL SDL and emits MeshQL schema config, resolver stubs, and a migration report:
npx meshql-codemod graphql-sdl ./schema.graphql --out ./meshql-migrationManual mapping for edge cases:
GraphQL type → MeshQL entityGraphQL object field → MeshQL joinGraphQL resolver → mesh.resolve()GraphQL dataloader → deleteGraphQL mutation → PUT/POST route + notifyEntityUpdate for SSEGraphQL subscription → SSE + pubsub