GraphQL

A Relay-style GraphQL API, generated from your database schema. It runs on the same connection, keys, and Row-Level Security as the REST API; a GraphQL reader sees exactly what the same principal's REST read would.

POST  /graphql/v1

One endpoint, POST only, with the usual GraphQL JSON body. Every request carries Authorization: Bearer <key-or-token>, the same credential you use for REST. A well-formed request always answers 200 with a { "data": …, "errors": [ … ] } body; only a body that is not valid JSON gets a 400.

curl -X POST "https://<ref>.kethosbase.com/graphql/v1" \
  -H "Authorization: Bearer <key>" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ todosCollection(first: 20) { edges { node { id task } } } }"}'

The schema

The schema is projected from your public tables, no schema file to write or keep in sync. Table and column names are used verbatim. Introspection (__schema, __type) is filtered by the caller's privileges, so it exposes only the tables and columns that principal may read.

Querying: collections and connections

Each table gets one query field named <table>Collection that returns a Relay connection. Select edges (each with a cursor and a node), pageInfo, and totalCount:

{
  todosCollection(
    filter: { done: { eq: false } }
    orderBy: [{ id: DescNullsLast }]
    first: 20
  ) {
    edges {
      cursor
      node { id task done }
    }
    pageInfo { hasNextPage endCursor }
    totalCount
  }
}

Arguments

ArgumentMeaning
filterA tree of column conditions: see operators below.
orderByList of { column: Direction }. Directions: Asc / AscNullsFirst / AscNullsLast and the Desc… equivalents.
first / afterForward pagination: the first n rows after a cursor.
last / beforeBackward pagination. first and last together is an error.
offsetSkip rows from the start.

A page is capped at 1000 rows and defaults to 100. Cursors are opaque; pass an endCursor straight back as after to page on.

Filter operators

The same operator set as REST: eq, neq, gt, gte, lt, lte, like, ilike, match, imatch, isdistinct, in (a list), and is (NULL / NOT_NULL). Combine conditions with and, or (lists), and not (an object), nested freely:

{
  todosCollection(filter: {
    or: [
      { done: { is: NOT_NULL } }
      { priority: { gte: 5 } }
    ]
  }) { edges { node { id } } }
}

Related tables

A foreign key becomes a nested field: a to-one relationship embeds an object, a to-many relationship embeds a nested connection. Nested reads run under the same RLS as the rest of the query.

{
  booksCollection(orderBy: [{ title: AscNullsLast }]) {
    edges {
      node {
        title
        author { name }              # to-one → object
        reviewsCollection {          # to-many → connection
          edges { node { rating } }
        }
      }
    }
  }
}
A nested …Collection is a full connection too: it takes the same filter, orderBy and pagination arguments as a top-level one, and answers edges { cursor node }, pageInfo and totalCount. A cursor is the row's position in that parent's ordered child set, so it pages each parent's children independently. A nested connection with no pagination argument returns the same default page a top-level one does; a to-many relationship is never unbounded. Arguments on a to-one relationship are rejected with a clear error: there is one row, so there is nothing to filter, order or page.

Mutations

Each table gets insert, update, and delete mutations. They return the affected records (scalar columns) and an affectedCount. Update and delete require a filter (there is no accidental full-table write) and accept an optional atMost that rolls the whole mutation back if more rows than that would change.

# insert
mutation {
  insertIntoTodosCollection(objects: [{ task: "ship it" }]) {
    records { id task }
    affectedCount
  }
}

# update (filter required; atMost guards the blast radius)
mutation {
  updateTodosCollection(
    set: { done: true }
    filter: { id: { eq: 1 } }
    atMost: 1
  ) { affectedCount }
}

# delete (filter required)
mutation {
  deleteFromTodosCollection(filter: { done: { is: NOT_NULL } }) {
    affectedCount
  }
}

Errors and safety

Field-level failures come back in errors with a path; a table the caller cannot read resolves to null with a field-scoped error rather than leaking its existence. As on REST, only the secret key (service_role) sees raw database messages; anonymous and authenticated callers get generic text. Every request runs in one transaction with a 10-second statement timeout.

Guard rails bound query cost: selection depth 16, filter nesting 12, up to 2000 fields and 50 related-table joins per query.

Not supported yet

See Limits & errors for status codes and quotas, and Row-Level Security for how access is decided.