Schema and naming
Your workspace's GraphQL schema is generated from your custom object definitions. Nothing is hand-written, so every type, field and argument below follows mechanically from the definitions you created over REST. Change a definition and the schema changes with it.
Because it is per-workspace, no static schema document is published. Download the current one:
curl "https://api.tourfold.com/api/v2/graphql/schema" \
-H "Authorization: Bearer YOUR_TOKEN"
The response is text/plain SDL, suitable for a GraphQL IDE, Insomnia, GraphiQL, or a codegen tool
pointed at a file. Save it and re-download it after changing a definition.
Because the schema is per-workspace and generated at runtime, build-time code generation against a shared schema is not possible. Generate against your own downloaded SDL, and regenerate when your definitions change.
Root fieldsโ
Each custom object definition contributes one root query field, named after the definition's slug, returning a connection:
type Query {
equipment(where: equipmentFilter, order_by: [equipmentOrderBy!], first: Int, after: String, last: Int, before: String, offset: Int): equipmentConnection!
maintenance_request(where: maintenance_requestFilter, ...): maintenance_requestConnection!
}
The field name is the singular slug โ equipment, not equipments โ because it is the definition
slug verbatim, not a pluralisation.
A workspace with no custom object definitions gets a placeholder schema whose only field is
_empty: String. That is not an error; it means there is nothing to query yet.
Type and field namesโ
GraphQL names are restricted to [_A-Za-z][_0-9A-Za-z]*, while Tourfold property names are not. Names
are therefore sanitized: every character outside that set becomes _, and a leading digit gets a
_ prefix.
| Definition slug or property name | GraphQL name |
|---|---|
equipment | equipment |
asset tag | asset_tag |
Power (kW) | Power__kW_ |
2nd_reading | _2nd_reading |
:::warning Two property names can collide
Sanitization is not reversible, so two different property names can produce the same GraphQL field
name โ power kw and power-kw both become power_kw. When that happens only one of them appears in
the schema and the other is silently unreachable through GraphQL. No error is raised, at definition
time or at query time; the property is still there over REST.
Avoid property names that differ only in punctuation or spacing. If a field you expect is missing from the SDL, check for a near-duplicate name on the same definition. :::
The generated type names follow fixed patterns, so you can predict them:
| Kind | Pattern | Example |
|---|---|---|
| Object type | <slug> | equipment |
| Connection | <slug>Connection | equipmentConnection |
| Edge | <slug>Edge | equipmentEdge |
| Filter input | <slug>Filter | equipmentFilter |
| Order-by input | <slug>OrderBy | equipmentOrderBy |
| Nested-object filter | <slug>_<field>Filter | equipment_specsFilter |
| Nested-object order-by | <slug>_<field>OrderBy | equipment_specsOrderBy |
| Enum | <slug>_<field> | equipment_category |
Scalar mappingโ
A property's JSON Schema type and format decide its GraphQL type:
| JSON Schema | GraphQL | Notes |
|---|---|---|
string | String | |
string + format: uuid | ID | |
string + format: date | Date | 2026-03-14 |
string + format: time | Time | 09:30:00 |
string + format: date-time | DateTime | RFC 3339, e.g. 2026-03-14T09:30:00Z |
number | Float | Also used for integers โ there is no Int mapping. |
boolean | Boolean | |
array | [T] | T from the array's items.type; defaults to String. |
object | String | JSON-encoded, see below. |
totalCount is a Long, not an Int โ a collection can exceed 2ยณยน rows.
Nested objects come back as JSON stringsโ
A property of type object is returned as a String containing the JSON document, not as a GraphQL
object type:
{ "specs": "{\"manufacturer\":\"Kelvion\",\"power_kw\":42.5}" }
Parse it client-side. Note the asymmetry: you cannot select into a nested object, but you can filter and sort by its inner fields (see Filtering and sorting) โ the filter input types are generated from the nested schema even though the output type is a string.
Enumsโ
A string property with a non-empty enum list becomes a GraphQL enum named <slug>_<field>, with
each value sanitized the same way as a field name:
enum equipment_category {
hvac
elevator
lighting
}
When filtering, an enum-typed field accepts only _in, and its operand is a list of strings
(_in: ["hvac"]) rather than bare enum literals โ the enum type applies to the output field, not to the
filter input.
Required fieldsโ
A property marked readOnly in the definition is emitted as non-null (String!). Everything else is
nullable, including properties listed as required on the definition โ required constrains writes,
which GraphQL does not perform.
System fieldsโ
Every type carries four fields Tourfold maintains itself. You never declare them and cannot change them:
| Field | Type | |
|---|---|---|
id | ID! | The record's UUID. |
created_at | DateTime! | |
updated_at | DateTime! | |
lock_version | Float! | Optimistic-locking counter, used when writing over REST. |
lock_version is selectable but cannot be filtered or sorted on โ it is write-side bookkeeping with
no meaningful comparison semantics. The other three filter and sort like any field of their type.
There is no computed display-name field. If you need one, select the properties that make it up.
Relationsโ
Each relation you define contributes a field to both types involved, named after that side's slug towards the other side. The shape depends on the target's cardinality:
To-one โ a plain nullable object field, with no arguments:
type maintenance_request {
equipment: equipment
}
To-many โ a connection, with the same filtering, ordering and pagination arguments as a root field:
type equipment {
maintenance_requests(
where: maintenance_requestFilter
order_by: [maintenance_requestOrderBy!]
first: Int
after: String
last: Int
before: String
offset: Int
): maintenance_requestConnection
}
So one query can walk both directions:
query EquipmentWithOpenRequests {
equipment(first: 5, order_by: [{ name: asc }]) {
edges {
node {
name
maintenance_requests(where: { resolved: { _eq: false } }, first: 3, order_by: [{ reported_at: desc }]) {
totalCount
edges {
node {
title
priority
}
}
}
}
}
}
}
A few things follow from the generation rules:
- Self-referencing relations work. A definition related to itself gets both fields on the same type.
- Hidden relation sides are omitted. A relation side marked hidden contributes no field, in either the output type or the filter input.
- Nested connections have their own, smaller default page size โ 10 rather than 20 โ because they resolve once per parent row. See Pagination.
- Traversal depth is bounded. A connection costs several levels against the query-depth limit; see Errors and limits.
When the schema updatesโ
The compiled schema is cached per workspace, keyed by a token that changes whenever a definition or relation changes. A definition change is therefore visible to the next query, on every API instance โ you do not have to wait out a cache window, and you will not intermittently hit a stale schema depending on which instance served you.
Cached schemas are also discarded after ten minutes of no change, which has no effect you can observe beyond the first query afterwards being marginally slower.
Types you may not be able to seeโ
Custom object types can be access-restricted. When your role may not read a type, it still appears in
the schema, but querying it returns an empty connection rather than an error โ and a to-one
relation into it resolves to null. The rest of the query still executes and still returns data.
That is deliberate (a query is not failed by one inaccessible branch) but it does mean an empty result is not proof of an empty collection. If a type you expect to be populated is consistently empty, check the role's access to it before assuming there are no records. Types that exist only as children of another record are never listed at the root, only reached through their parent.