~/gqlith — docs/graphql.md
Early access gqlith is in private early access — the code is not publicly available yet. These docs preview the surface so you know what to expect. Join the list →

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.rbac on the view governing access.
  • @gqlith.computed on 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 — STABLE functions become queries, VOLATILE ones 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.codeFORBIDDEN, 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.