GraphQL
data.svc generates a GraphQL SDL from the same entity definitions that drive REST, and
resolves it through the same execution engine. There is no separate GraphQL schema file, no
resolver code, and no second execution path: every top-level field turns into one engine
Query, Create, Update or Delete call, so access rules,
row-level security, validations, compliance and audit behave exactly as they do on
REST.
What one entity generates
Section titled “What one entity generates”For an entity named order, the generated SDL carries three query fields, three mutation
fields, and three input types.
| SDL element | Name | Signature |
|---|---|---|
| Get by id | order | order(id: ID!): Order |
| List | orderList | orderList(filter: OrderFilter, limit: Int, offset: Int, orderBy: String): [Order!]! |
| Count | orderCount | orderCount(filter: OrderFilter): Int! |
| Create | createOrder | createOrder(input: CreateOrderInput!): Order! |
| Update | updateOrder | updateOrder(id: ID!, input: UpdateOrderInput!): Order! |
| Delete | deleteOrder | deleteOrder(id: ID!): Boolean! |
| Object type | Order | one field per declared field, plus one per relation |
| Create input | CreateOrderInput | writable fields, non-null preserved |
| Update input | UpdateOrderInput | writable fields, all optional |
| Filter input | OrderFilter | every field, all optional |
Type names are the PascalCase form of the entity name — the name is split on _ and each
part is capitalised. Query field names are not PascalCased: they use the entity name
verbatim.
Field type mapping
Section titled “Field type mapping”The SDL declares three custom scalars — DateTime, JSON and Decimal. Decimal is
declared but never assigned to a field.
| Field type | GraphQL type |
|---|---|
string, text, file | String |
int, bigint | Int |
float, decimal, money | Float |
bool | Boolean |
datetime, date, time | DateTime |
uuid, ulid | ID |
json, array, bytes | JSON |
enum, state | String |
| custom types, anything else | String |
A ! is appended only when the field is required: true and nullable is not set.
Setting nullable: true on a required field removes the non-null marker.
Relations
Section titled “Relations”Each entry under relations: emits one field on the parent type, typed by cardinality.
type: | Emitted field |
|---|---|
onetomany | <name>: [RelatedType!] |
manytomany | <name>: [RelatedType!] |
onetoone | <name>: RelatedType |
manytoone | <name>: RelatedType |
At execution, every sub-selection that has its own selection set is collected by name and passed to the engine as an eager-load include. The engine loads the relation, then the response is trimmed to exactly the fields you selected.
Only the top-level field’s immediate sub-selections become includes. Deeper nesting is projected out of whatever the engine already returned, so a third level resolves only when the engine’s include machinery already loaded it.
Create, update and filter inputs
Section titled “Create, update and filter inputs”These field names are excluded from both Create<T>Input and Update<T>Input:
id · created_at · updated_at · created_by · updated_by · deleted_at ·
deleted_by · version · tenant_id
Any field marked computed: is excluded as well. In Update<T>Input every remaining field
has its non-null marker stripped — all update fields are optional. <T>Filter is different:
it contains every field including the excluded ones, all optional.
A worked example
Section titled “A worked example”This definition:
entities: - name: widget fields: - name: id type: ulid defaultvalue: ulid() - name: title type: string - name: qty type: intgenerates this SDL:
scalar DateTimescalar JSONscalar Decimal
type Widget { id: ID title: String qty: Int createdby: String createdon: DateTime updatedby: String updatedon: DateTime deletedby: String deletedon: DateTime}
input CreateWidgetInput { title: String qty: Int createdby: String createdon: DateTime deletedby: String deletedon: DateTime}
input UpdateWidgetInput { title: String qty: Int createdby: String createdon: DateTime deletedby: String deletedon: DateTime}
input WidgetFilter { id: ID title: String qty: Int createdby: String createdon: DateTime updatedby: String updatedon: DateTime deletedby: String deletedon: DateTime}
type Query { widget(id: ID!): Widget widgetList(filter: WidgetFilter, limit: Int, offset: Int, orderBy: String): [Widget!]! widgetCount(filter: WidgetFilter): Int!}
type Mutation { createWidget(input: CreateWidgetInput!): Widget! updateWidget(id: ID!, input: UpdateWidgetInput!): Widget! deleteWidget(id: ID!): Boolean!}The six audit columns are not in the definition. They are added to every entity before the SDL is generated, which is why they appear on the type, in the filter, and — for the four that are not computed — in the create and update inputs.
Listing with projection and ordering:
query { widgetList(limit: 10, orderBy: "qty DESC") { id title }}Fetching one row:
{ widget(id: "w1") { id qty }}Creating a row — id is absent from the input because it is an auto field, and the engine’s
defaults fill it:
mutation { createWidget(input: { title: "Created", qty: 1 }) { id title }}The response keys results under the field name, or under the alias when one is given —
data.createWidget, not data.widget.
List arguments
Section titled “List arguments”| Argument | Type | Behaviour |
|---|---|---|
filter | <T>Filter | Equality only. Each supplied key becomes one field = value condition, ANDed. |
limit | Int | Maximum rows. |
offset | Int | Rows to skip. |
orderBy | String | A single string of the form "field ASC" or "field DESC". |
Execution behaviour
Section titled “Execution behaviour”| Operation | Engine call | Notes |
|---|---|---|
<entity> | Query, limit: 1 | Filters on the column literally named id. Returns null when no row matches. |
<entity>List | Query | Includes from sub-selections, then projection. |
<entity>Count | Query | Counts rows in memory — see below. |
create<Entity> | Create | RETURNING *, projected to the selection set. |
update<Entity> | Update | Filters on id, RETURNING *. |
delete<Entity> | Delete | Filters on id. |
Three behaviours differ from what the SDL suggests:
<entity>Countfetches rows and counts them in Go. There is noCOUNT(*)pushdown. The engine caps an uncapped query at 10 000 rows, so any count above 10 000 is reported as 10 000.delete<Entity>always returnstruewhen the engine returns no error, regardless of how many rows matched. Deleting a nonexistent id reports success.- Create and update echo your input when the mutation’s
RETURNINGproduced no rows. You can receive values that were never persisted, and server-generated defaults are absent in that case.
What is not supported
Section titled “What is not supported”| Feature | State |
|---|---|
Introspection (__schema, __type) | Validates, then fails to resolve. Use GET /data/schema/graphql. |
| Subscriptions | No type Subscription is generated and no transport serves one. |
| Fragments and inline fragments | Parse and validate, then contribute no data and raise no error. |
Directives (@skip, @include) | Never evaluated. |
| Interfaces, unions, custom directives | Never generated. |
| Multiple operations per request | Only the operation selected by operationName runs. |
| A hosted playground or GraphiQL page | Not served. |
GET /data/graphql | Rejected with 405; the endpoint is POST-only. |
Aliases and variables do work. Variables are merged with the operation’s declared defaults before arguments are resolved, and both top-level and nested fields honour an alias.
Subscriptions
Section titled “Subscriptions”There are no GraphQL subscriptions. Schema generation emits only type Query and
type Mutation; the resolver has no subscription operation kind; no WebSocket or SSE route
serves one. Design material and generated event contracts that mention subscribing to entity
events describe a transport that is not served. There is no push surface to fall back to
either: row history is stored, not streamed, and you read it by polling an entity’s
?asof= and ?version= reads.
Getting the schema
Section titled “Getting the schema”Introspection does not work, so the SDL is served as a plain document:
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/graphqlThat returns text/plain; charset=utf-8. GET /data/schema/entities lists entity names with
field and relation counts only; GET /data/schema/entities/<entity> is the field-by-field
JSON view, returning name, table_name, primary_key, materialization, fields[] and
relations[]. GET /data/schema/jsonschema.json and GET /data/schema/zod.ts return
generated bindings.
A schema change becomes visible to GraphQL only once the tenant’s engine is rebuilt; the
generated SDL is cached per tenant and datastore. DELETE /data/cache drops the tenant’s
cached schemas, pools and engines so the next request rebuilds.
Endpoints
Section titled “Endpoints”| Method | Path | Auth |
|---|---|---|
POST | /data/graphql | Bearer token |
POST | /data/anon/graphql | Anonymous; access rules and RLS decide what resolves |
Both serve the same generated SDL from the same resolvers — only the resolved principal
differs. Append ?datastore=<name> to address a datastore other than the default.
The request body is the standard envelope:
{ "query": "{ widgetList { id title } }", "operationName": "", "variables": {} }When operationName is empty the first operation in the document runs.
For the shared request conventions — tenant headers, bearer format, the error envelope, and the common query parameters — see the Data API HTTP reference.
Errors
Section titled “Errors”GraphQL errors do not change the HTTP status.
| Condition | Status | Body |
|---|---|---|
| Unknown-field or engine error | 200 | {"data": {...}, "errors": [...]} with the failing field null |
| Parse or validation error | 200 | {"errors": [...]} — no data key at all; nothing was dispatched |
| Non-POST method | 405 | method not allowed, text/plain |
| Body is not valid JSON | 400 | invalid JSON body, text/plain |
| No tenant on the request | 403 | Platform error envelope |
| Engine or SDL could not be built | 500 | Platform error envelope |
An access denial, a validation failure and a constraint violation all arrive as HTTP 200 with
an entry in errors[] scoped to that field’s path. Inspect the body, not the status.