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.
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
| Argument | Meaning |
|---|---|
filter | A tree of column conditions: see operators below. |
orderBy | List of { column: Direction }. Directions: Asc / AscNullsFirst / AscNullsLast and the Desc… equivalents. |
first / after | Forward pagination: the first n rows after a cursor. |
last / before | Backward pagination. first and last together is an error. |
offset | Skip 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 } }
}
}
}
}
}
…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
- Subscriptions: use Realtime for live updates.
- Fragments, inline fragments, and
@directives: rejected by the parser with a clear message. - Arguments on nested relationships: see the note above.
- Selecting related tables inside a mutation's
records: scalar columns only there.
See Limits & errors for status codes and quotas, and Row-Level Security for how access is decided.