Docs / Protocols
GraphQL surface
Generate a GraphQL API from your Postgres schema — queries, mutations, subscriptions, aggregates, batch ops and nested writes, governed by the shared permission model.
gqlith generates a complete GraphQL API from your Postgres schema: Relay-style pagination, nested writes, subscriptions, aggregates and filtering, all governed by the same permission model as the REST and MCP surfaces.
findMany — Relay-style pagination
query {
recipes(
first: 10
after: "cursor=="
where: { status: { eq: "published" }, servings: { gte: 4 } }
orderBy: [createdAt_DESC]
) {
totalCount
pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
edges {
cursor
node { id title cuisine { name } }
}
}
}
orderBy takes generated enum values — camelCase field name + direction, with optional nulls placement: createdAt_DESC, rating_DESC_NULLS_LAST. Collections also accept distinctOn: [column] (aligned with the first orderBy key) and a filterql argument carrying the same FiltrQL string the REST and MCP surfaces use.
Switch the project to offset pagination (defaults.pagination: offset in gqlith.yaml) and recipes takes (offset, limit) returning { totalCount, nodes } directly.
findOne by primary key or unique
query {
recipe(where: { id: 42 }) {
id title
authorUser { id name }
recipeSteps(first: 10) { edges { node { sortOrder instruction } } }
}
# compound unique keys also accept a direct value
recipe(where: { tenantIdSlug: { tenantId: "weeknight", slug: "pasta" } }) { id title }
}
Aggregates
Every entity gets <plural>_aggregate and <plural>_groupedAggregate — an instant analytics endpoint under the same RBAC, row filters and tenancy as the rows themselves:
query {
recipes_aggregate(where: { status: { eq: "published" } }) {
count
avg { servings }
max { totalCookMinutes }
}
recipes_groupedAggregate(
groupBy: [cuisineId]
having: { min: { servings: { gte: 2 } } }
) {
key { cuisineId }
aggregate { count avg { servings } }
}
}
Functions: count, sum, avg, min, max, distinctCount — with groupBy, where and having on the grouped form.
Mutations
mutation {
createRecipe(data: { title: "Carbonara", slug: "carbonara", status: published, servings: 4 }) {
id slug createdAt
}
updateRecipe(
where: { id: 42 }
data: { title: "Updated" }
expectedVersion: 3 # OCC — mismatch returns STALE_OBJECT
) { id title revision }
deleteRecipe(where: { id: 42 }) { id deletedAt } # soft delete if configured
restoreRecipe(where: { id: 42 }) { id deletedAt } # generated for @gqlith.softDelete tables
upsertRecipe(data: { title: "Carbonara", slug: "carbonara", … }) { id }
}
Batch mutations
createMany returns the created rows; updateMany / deleteMany return an affected-row count. Each takes an atMost cap — exceed it and the whole operation fails with ATMOST_EXCEEDED before touching data:
mutation {
createManyRecipes(data: [{ … }, { … }]) { id }
updateManyRecipes(where: { status: { eq: "draft" } }, data: { status: archived }, atMost: 50)
}
Cross-mutation transactions
Multiple root mutations in one request run in one transaction. Any error rolls back all of them.
mutation {
m1: createRecipe(data: { … }) { id }
m2: createRecipe(data: { … }) { id }
}
Nested mutations
Configure per-FK or per-table via @gqlith.nestedMutation. Defaults: create, connect.
mutation {
createRecipe(data: {
title: "Pasta", slug: "pasta", status: published, servings: 4
recipeSteps: { create: [
{ sortOrder: 1, instruction: "Boil water." }
{ sortOrder: 2, instruction: "Cook pasta 8 minutes." }
] }
cuisine: { connect: { id: { eq: 3 } } }
recipeTags: { set: [{ dietaryTagId: 2 }, { dietaryTagId: 5 }] } # atomic M2M replace
}) {
id
recipeSteps { id sortOrder }
recipeTags { dietaryTag { slug } }
}
}
Operators: create, connect, connectOrCreate, update, upsert, disconnect, delete, replace (M2O swap), set (atomic M2M replace).
Soft-delete selectors
Every collection field — root lists, relation lists, aggregates — accepts withDeleted: true (include tombstones) or onlyDeleted: true (tombstones only), AND-composed with where.
Views, computed fields, your own SQL
- Postgres views are exposed read-only, with
@gqlith.rbacon the view governing access. @gqlith.computedon a SQL function adds it as a field on its first argument’s type — multi-argument functions take GraphQL args:user { searchRecipes(q: "thai", limit: 5) { … } }.@gqlith.rootKind(query | mutation)exposes a SQL function as a root field —STABLEfunctions become queries,VOLATILEones mutations. Untagged functions are never exposed.
Subscriptions
Enabled by setting targets.subscriptionTransport: sse and a multi-process eventBus (Redis or Durable Objects on Cloudflare). Each entity gets three events — recipeCreated, recipeUpdated, recipeDeleted:
subscription {
recipeUpdated { id slug status }
}
Transport is graphql-sse distinct-connections mode at /graphql/stream. RBAC, row filters and tenancy are re-checked at event emission time — a subscriber only receives events for rows they can currently see.
Errors you can program against
Every typed error carries a closed-union extensions.code — FORBIDDEN, VALIDATION_FAILED, STALE_OBJECT, NOT_FOUND, RATE_LIMITED, BAD_USER_INPUT, ATMOST_EXCEEDED, CONFLICT and friends. Constraint violations add extensions.subcode with the class (UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION, CHECK_VIOLATION). Depth, complexity, input-depth and body-size limits are enforced and configurable (graphql.maxDepth, graphql.maxComplexity, graphql.maxInputDepth, http.maxBodyBytes).
Authentication
Every request carries Authorization: Bearer <jwt>. The configured identity provider — jwt-jose (HS256 / RS256 / ES256), supabase, clerk or auth0 — extracts $userId, $role, $tenantId and $claims.* for use in row filters. Nonstandard token shapes map in declaratively via jwt.claimsMap (per-claim dotted paths).
Full-schema introspection (__schema) is off by default and gated separately from the permission-projected schema guide (schemaDiscovery { raw, projected }).
Opt in to database-layer security and the same claims propagate to Postgres GUCs per transaction, with RLS policies generated from the same annotations.