#GraphQL: the entity graph API
POST /entity/graphql serves nested entity reads as a Relay-style GraphQL
surface. Every read is authorized exactly like the REST query API
(POST /entity/query): same cross-tenant shares, same attribute filters, same
policy mode. If your principal can read a row through REST, it can read it
through GraphQL — and not otherwise.
Most integrations don't need GraphQL for this. For a nested, graph-shaped read,
POST /entity/query-graph(REST — see Entities & Data Model) is the more commonly used surface: pass a nestedincludetree and get back one deduplicated graph (roots/nodes/edges), no query language required. Reach for GraphQL below when you specifically want field-level selection, Relay Connections, or codegen.
Use GraphQL when you want:
- One round-trip for nested relationships. The include tree is derived from
your selection set — no
include_specsarray to assemble by hand. - Selective fields. Only the fields you select cross the wire.
- A familiar shape. Relay Connections (
edges/nodes/pageInfo/totalCount) plus a single-row${TypeName}ByIdroot field, both codegen-friendly.
REST POST /entity/query is still the right call for one-off lookups, bulk
admin reads, and CSV exports; POST /entity/query-graph for nested reads
without GraphQL.
#Calling the endpoint
Send a standard GraphQL request body — query plus optional variables — to
POST /entity/graphql. Authenticate with HTTP Basic using your PAT.
POST/entity/graphqlRun a GraphQL queryAPI docs ↗Try it
#Schema generation rules
For each entity type the schema generator emits:
Two root fields (root fields are PascalCase, named after the type):
${TypeName}(where, orderBy, first, after, last, before, countStrategy): ${TypeName}Connection— a Relay Connection over rows.${TypeName}ById(id: ID!): ${TypeName}— single-row fetch; returns the entity ornullwhen the row doesn't exist or the caller can't see it.
One object type
${TypeName}with:- System fields
id: ID!,name,identifierKey,createdAt,updatedAt— all camelCase. attributes: ${TypeName}Attributes— a nested object holding every user-defined attribute (see below).- One field per relationship rule that touches this type, named after the related entity type (PascalCase, not the rule name). Singular when the parent-side cardinality is 1, otherwise a Connection.
- System fields
Filter/sort input types:
${TypeName}BoolExp,${TypeName}AttributesBoolExp,${TypeName}OrderBy,${TypeName}AttributesOrderBy.
#Attributes are nested
Every user-defined attribute lives under a top-level attributes: field — not
as a sibling of id / name:
query OneCert {
VesselCertificateById(id: "…") {
id
name
attributes { type expiry_date issued_date issuer }
}
}
Namespacing attributes this way lets a catalogue name an attribute name, id,
or created_at without shadowing the system fields. Types with zero mappable
attributes have no attributes: field at all — query only their system and
relationship fields.
#Response envelope
The response is Relay-compliant and stable:
{
"data": {
"<TypeName>": {
"edges": [
{
"node": {
"id": "...",
"name": "...",
"identifierKey": "...",
"createdAt": "...",
"updatedAt": "...",
"attributes": {
"expiry_date": "2027-01-15T00:00:00Z",
"type": "ISSC"
},
// single-cardinality relationship → object
"Vessel": {
"id": "...", "name": "MV …",
"attributes": { "imo_number": "9123456" }
},
// list relationship → Connection
"BridgeDocument": {
"nodes": [ { "id": "...", "attributes": { "document_name": "…" } } ],
"edges": [ { "node": {...}, "cursor": "…" } ],
"pageInfo": { "hasNextPage": false, "hasPreviousPage": false },
"totalCount": 1
}
},
"cursor": "eyJpZCI6…"
}
],
"nodes": [ /* same node objects as edges[i].node, flat */ ],
"pageInfo": {
"startCursor": "eyJpZCI6…",
"endCursor": "eyJpZCI6…",
"hasNextPage": true,
"hasPreviousPage": false
},
"totalCount": 42 // only present when selected
}
}
}
nodes is a convenience flat list — the same objects as edges[*].node. Use
nodes when you don't need cursors; use edges when you do.
#Cardinality cheat sheet
Whether a relationship surfaces as a Connection or a single object depends on the rule's cardinality from the parent's perspective:
| Parent side | Rule shape | GraphQL field type |
|---|---|---|
from side |
max_to_cardinality = 1 |
single ${Type} object |
from side |
max_to_cardinality = null |
${Type}Connection |
to side |
max_from_cardinality = 1 |
single ${Type} object |
to side |
max_from_cardinality = null |
${Type}Connection |
| self-referential | (always emits both directions) | ${Type}Connection |
Selecting { nodes { id } } on a single-object field — or { id } on a
Connection — is rejected at validation. Match the shape.
#Filters, sort, pagination
Filter through where (a typed BoolExp); sort through orderBy:
query CertsExpiringSoon($cursor: String) {
VesselCertificate(
where: {
_and: [
{ attributes: { type: { _eq: "SMC" } } },
{ attributes: { expiry_date: { _lt: "2026-12-31T00:00:00Z" } } }
]
}
orderBy: [{ attributes: { expiry_date: ASC } }]
first: 50
after: $cursor
) {
nodes { id name attributes { type expiry_date } }
pageInfo { hasNextPage endCursor }
totalCount
}
}
SortDirection is an uppercase enum literal — ASC / DESC (also
ASC_NULLS_FIRST, DESC_NULLS_LAST, etc.), never the quoted string "ASC".
Lowercase asc/desc is rejected with 400 GRAPHQL_VALIDATION_FAILED.
#where operators (BoolExp)
Operators are underscore-prefixed. Each attribute or system-field input is typed by its value:
| Comparison input | Applies to | Operators |
|---|---|---|
StringComparison |
strings, including timestamps as ISO | _eq, _neq, _in, _nin, _gt, _gte, _lt, _lte, _like, _ilike |
NumericComparison |
numeric (int / float) | _eq, _neq, _in, _nin, _gt, _gte, _lt, _lte |
TimestampComparison |
timestamps as ISO strings | _eq, _neq, _gt, _gte, _lt, _lte |
UUIDComparison |
uuid (id columns) | _eq, _neq, _in, _nin |
BooleanComparison |
bool | _eq |
ArrayComparison |
text_array attributes | element-set ops |
_like is case-sensitive and accent-insensitive; _ilike is case-insensitive
and accent-insensitive. Compose with _and, _or, _not:
where: {
_or: [
{ name: { _ilike: "atlantic%" } },
{ attributes: { flag: { _in: ["Liberia", "Panama"] } } }
]
}
#Relationship existence in where
The BoolExp for a type carries a field named after every related type. A non-null BoolExp on that field means "at least one related row matches":
query VesselsWithExpiringISSC {
Vessel(
where: {
VesselCertificate: {
_and: [
{ attributes: { type: { _eq: "ISSC" } } },
{ attributes: { expiry_date: { _lt: "2026-12-31T00:00:00Z" } } }
]
}
}
) {
nodes { id name }
}
}
#Filterable / sortable attributes
Filter and sort paths must reference an indexed attribute on the type. A path
the engine can't resolve returns 400 INVALID_FILTER, not a silent no-op — the
same rule as REST, and it applies at every nesting level.
#Cursor pagination
Forward with first / after; backward with last / before:
query Page1 {
VesselCertificate(first: 50) {
nodes { id name }
pageInfo { endCursor hasNextPage }
}
}
query PageN($cursor: String!) {
VesselCertificate(first: 50, after: $cursor) {
nodes { id name }
pageInfo { endCursor hasNextPage }
}
}
Rules:
afteris mutually exclusive withbefore;firstwithlast.- The cursor is opaque — built from the row's
orderBykeys. Feed it back unmodified. pageInfo.startCursor/endCursorare computed only when one of them (oredges.cursor) is selected — request them explicitly when paginating.
#Count strategy
totalCount is capped at 10001 by default — page renders shouldn't block on
a full count. Override with countStrategy (an enum literal, not a string):
query {
VesselCertificate(countStrategy: ESTIMATE) {
nodes { id }
totalCount
}
}
countStrategy is an uppercase enum literal, like SortDirection — never a
quoted string. Options: CAPPED (default, fast), ESTIMATE (sub-ms, can be
stale), EXACT (linear scan — only for small result sets).
#Nested filter / sort / limit (depth-1)
Connection relationship fields accept their own where, orderBy, and first,
applied to the related entity at that level:
query {
Vessel(first: 20) {
nodes {
id name
VesselCertificate(
where: { attributes: { type: { _eq: "SMC" } } }
orderBy: [{ attributes: { expiry_date: ASC } }]
first: 5
) {
nodes { name attributes { expiry_date } }
totalCount
}
}
}
}
Nested pagination cursors (after / before / last) are not plumbed
through on relationship fields — only where / orderBy / first are honoured
there. Top-level cursor pagination works as normal.
#Single-row fetch — ${TypeName}ById
For "load this one row," use the dedicated by-id field. It returns null when
the row doesn't exist or isn't visible to the caller:
query OneVessel($id: ID!) {
VesselById(id: $id) {
id name
attributes { imo_number flag }
VesselCertificate {
nodes { id name attributes { type expiry_date } }
}
}
}
Prefer this over Vessel(where: { id: { _eq: $id } }) { nodes }[0] — it's
clearer at the call site and skips the Connection wrapper.
#Depth cap
POST /entity/graphql hydrates up to 6 levels of nested relationships. A
relationship selection deeper than that is rejected at query-build time with
BAD_USER_INPUT ("exceeds the maximum nested include depth") rather than
resolving to empty data — shorten the path or split it into separate root
queries. Scalar-only selections past the cap are fine.
#Authorization & access control
You don't have to reason about cross-tenant rows, shares, attribute filters, or
policy mode — they're enforced uniformly with the REST query API. If your
principal can't read a row through POST /entity/query, you can't read it
through GraphQL. There is no second access path.
In practice:
- Cross-tenant shares surface shared rows under every relationship that points at the shared type.
- Attribute filters apply at every depth — a row hidden at the root is also hidden as a nested neighbour.
- Policy mode reads behave identically to REST reads in policy mode.
#"The include is empty but I know rows exist"
When a relationship resolves to null (single) or { nodes: [] } (Connection)
and you can confirm the rows exist in the owner tenant, the cause is almost
always a missing access leg, not a bug. A cross-tenant include needs both:
- The share. The owner tenant has an active share on the included type to your tenant.
- The grant. Your policy carries a
readgrant on entities of the included type. Policy-mode tokens (the default for app-minted tokens) see only what their policy grants.
Missing either leg returns silently — the parent row is delivered and the
included field is empty — because both legs go through the same engine path as
REST POST /entity/query. See Apps (Bridge) for
how to set up the share and the consumer-side grant.
#Schema-build grants
The per-tenant schema is built from the types your principal can read. A
spec policy needs only entity_type:read + entity:read on each type the app
queries — the shape that describes a readable type (its attribute schema and
relationship rules) is implied by entity_type:read. Minimum surface for a
Vessel grid:
{ "resource_type": "entity_type", "resource_id": "<Vessel-type-id>", "actions": ["read"] },
{ "resource_type": "entity", "resource_id": "<Vessel-type-id>", "actions": ["read"] }
If a principal can read no types, the schema is empty and the endpoint returns
422 "No entity types are readable for this principal — check your entity_type:read grants".
#Playground & workflow converter
GET /entity/graphql/schema returns the tenant-scoped schema — standard
introspection JSON by default, or SDL with ?sdl=1. GraphQL clients (the
console playground, codegen, ad-hoc tooling) read it for docs and autocomplete,
so you get a typed editor against your own catalogue. The playground also
includes a converter that turns a GraphQL query into a workflow queryEntity
step, so a read you prototype interactively drops straight into a workflow
definition.
Introspection is served only by this schema endpoint; the POST /entity/graphql
query endpoint rejects __schema / __type selections.