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 } }
        }
      }
    }
  }
}
Arguments on a nested relationship (filter, orderBy, pagination) are not supported yet and are rejected with a clear error — nested connections accept only edges { node { … } }. Filter, order, and pagination on the top-level …Collection field work as documented above.

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.