Skip to content
Talk to our solutions team

Schema export and discovery

data.svc renders eight external schema formats, the GraphQL SDL and a JSON discovery surface from the same entity definitions the engine executes. Nothing is hand-maintained: change a field, rebuild the engine, and every format changes with it.

Export renderers are pure functions over the resolved schema. No I/O, no state, and deterministic ordering (entities and enums sorted by name, fields by declaration position with a name tie-break). The JSON-valued formats are byte-identical across calls, so they diff and cache cleanly, the Zod and CUE files stamp the generation time into a header comment, so two fetches of an unchanged schema differ on that line, strip the header before diffing.

Paths are shown as an external client sees them through the gateway, which strips the /data/ prefix; the service itself serves them at /schema/.... Every route accepts an optional ?datastore=<name> and falls back to the schema’s default datastore.

FormatVersionBundle endpointPer-entity endpointContent type
OpenAPI3.1.0GET /data/schema/openapi.json—application/json
JSON Schema2020-12GET /data/schema/jsonschema.jsonGET /data/schema/jsonschema/{entity}application/schema+json
Zod (TypeScript)Zod 3.22+GET /data/schema/zod.tsGET /data/schema/zod/{entity}application/typescript
CUE—GET /data/schema/cueGET /data/schema/cue/{entity}text/x-cue
AsyncAPI2.6.0GET /data/schema/asyncapi.json—application/json
Avro1.11GET /data/schema/avro.avsc—application/json
OData CSDLV4GET /data/schema/odata.xml, also GET /data/schema/odata/$metadata—application/xml
Protocol Buffersproto3GET /data/schema/proto—text/x-proto

On the per-entity routes the file extension is optional, /data/schema/jsonschema/order and /data/schema/jsonschema/order.json return the same document, and the same holds for .ts on the Zod route and .cue on the CUE route.

All export and discovery routes require a bearer token. There is no anonymous mirror for schema discovery, unlike GraphQL, which serves an anonymous endpoint, a published anonymous integration has to be handed its schema out of band.

Two settings decide whether a format is served, and a format is served only when both allow it.

The product decides in its schema. A top-level schema: block names the export surface as a whole and the formats:

schema:
serve: true # the export surface for this product; default true
formats: [openapi, jsonschema] # which formats; omit to serve every format

The names are the format column of the table above in lower case (openapi, jsonschema, zod, cue, asyncapi, avro, odata, proto) plus graphql for the SDL at GET /data/schema/graphql. A name outside that set, an empty list, or a list under serve: false is refused at load with the spelling that means what was intended. A tenant overlay cannot carry the block: what the service serves is the product’s to say.

The operator decides in the configuration, with the same block under properties: — in the tenant’s configuration, or for every tenant in the data.svc boot document:

properties:
schema:
serve: true
formats: [jsonschema, avro, odata, proto, zod]

The tenant’s block wins when both are set; absent both, every format is allowed. data.svc reads it with the rest of the tenant’s parameters when the tenant’s engine is built and hands it to the API layer. A product’s own block narrows it further, and the same mistakes are refused the same way in both documents.

A format either setting excludes answers 404 with errors[0].code export_not_served and a message that names the document and the block — schema.formats in the schema lists openapi, jsonschema, properties.schema.formats in this tenant's configuration lists …, or … declares schema: { serve: false } — so whoever reads the refusal knows which switch to flip. The discovery routes (GET /data/schema, /data/schema/entities) are not formats and are always served.

FormatUse it for
OpenAPI 3.1Generating REST clients and SDKs; import into API consoles and contract-test tooling.
JSON Schema 2020-12Request/response validation with Ajv or any 2020-12 validator; the input to most codegen chains.
ZodFront-end TypeScript types and runtime validation compiled from the same definitions.
CUEConfig templating and policy validation pipelines that need a lattice, not a validator.
AsyncAPI 2.6The event surface: three channels per entity (entity.<name>.created, .updated, .deleted) with the payload each carries, which is what a /subscribe/{entity} client receives. Feed it to asyncapi-cli or @asyncapi/generator. The document carries no servers block: the service does not know the origin you reach it through, so the tool supplies it.
Avro 1.11Schemas for a Kafka or object-store pipeline: one record per variant, one enum per enum, under the namespace seshat.<schema>, with logical types for decimals and instants.
OData V4 CSDLLetting Excel Power Query, Tableau or any OData client introspect the entities. One EntityType and EntitySet per entity, one EnumType per enum; relations are not yet emitted as navigation properties.
Protocol Buffers 3gRPC or binary clients: three messages per entity and one enum per enum, field tags in declaration order — adding a field appends a tag, reordering breaks wire compatibility, exactly as with a hand-written .proto.

Each codegen format emits three shapes for each entity. AsyncAPI carries the Output shape in its event payloads and OData describes the stored entity only, so those two emit the Output shape only:

VariantContainsMeaning
OutputEvery readable field, including server-managed onesWhat a response body looks like
InputFields a client may send on createWhat a POST body looks like
PatchInput with every field optionalWhat a PATCH body looks like

Entity names are PascalCased for type names, order_item becomes OrderItem, and the variants are named per format:

FormatOutputInputPatch
OpenAPIcomponents.schemas.OrderOrderInputOrderPatch
JSON Schema$defs/Order$defs/OrderInput$defs/OrderPatch
ZodOrderSchemaOrderInputSchemaOrderPatchSchema
CUE#Order#OrderInput#OrderPatch

Zod additionally exports the three inferred TypeScript types Order, OrderInput and OrderPatch, and derives OrderPatchSchema as OrderInputSchema.partial().

Variant membership is driven by field modifiers and the computed flag:

Field declarationOutputInputPatch
no modifieryesyesyes
readonlyyesnono
writeonlynoyesyes
finalyesyesno
computed: (virtual or materialized)yesnono
writeonceyesyesyes

writeonce is enforced at write time by the engine but has no effect on export shape.

Requiredness is per-variant: Output treats every emitted field as present, Input honours the field’s required flag, and Patch requires nothing.

A schema export is a data-egress surface, so one gate runs in front of every renderer’s field loop. A field is dropped from all three variants in all eight formats when its effective compliance posture marks it non-exportable, either explicitly, or implicitly because it carries a data classification.

DeclarationEffect on exports
compliances: [{type: noexport}]Field removed from every format and variant
compliances: [{type: pii}]Implies noexport (and nolog); field removed
compliances: [{type: phi}]Implies noexport; field removed
compliances: [{type: pci}]Implies noexport; field removed
compliances: [{type: gdpr}]Implies noexport; field removed
any other marker (mask, redact, hidden, tokenize, encrypted, hash, nolog, noindex, retention, searchable)Field exported; marker surfaces as x-compliance in the OpenAPI-flavoured schemas
entities:
- name: customer
fields:
- name: email
type: string
- name: internal_score
type: string
compliances:
- type: noexport
- name: ssn
type: string
compliances:
- type: pii

Only email appears in the rendered documents. internal_score is dropped by the explicit marker and ssn by the classification.

Field typeJSON Schema / OpenAPIZodCUE
string, textstringz.string()string
int, bigintintegerz.number().int()int
floatnumberz.number()number
decimal, moneynumberz.number()number
boolbooleanz.boolean()bool
datetimestring, format: date-timez.string().datetime()time.Time
datestring, format: datez.string().regex(/^\d{4}-\d{2}-\d{2}$/)string & =~ "^\\d{4}-\\d{2}-\\d{2}$"
timestring, format: timez.string().regex(/^\d{2}:\d{2}(:\d{2})?$/)string & =~ "^\\d{2}:\\d{2}(:\\d{2})?$"
uuidstring, format: uuid + patternz.string().uuid()string & =~ "<uuid pattern>"
ulidstring + ^[0-9A-HJKMNP-TV-Z]{26}$z.string().ulid()string & =~ "^[0-9A-HJKMNP-TV-Z]{26}$"
json{} (unconstrained)z.unknown()_
enum, state$ref: #/$defs/<Enum><Enum>Enum#<Enum>
array(<inner>)array with itemsz.array(<inner>)[...<inner>]
fileobject {id, url, mime_type, size}z.object({ id, url, mime_type, size })struct with the same four keys
bytesstring, contentEncoding: base64z.string()bytes

Zod’s native string formats require Zod 3.22 or later. The renderer has a non-native fallback, but it is not equivalent: uuid and ulid degrade to z.string().regex(…) while datetime degrades to a bare z.string() and the .email(), .url() and .ip() validations are dropped entirely. The shipped HTTP route emits the native form; the fallback is not reachable over HTTP. date and time are always regexes, Zod has no native helper for either.

FormatNullable field renders as
JSON Schema / OpenAPItype: [X, "null"], or anyOf: [<orig>, {type: "null"}] when the field is a $ref
Zod.nullable() (and .optional() when not required)
CUE(<expr>) | null, with a ? suffix on the key when optional

The document describes the REST surface. It emits openapi: "3.1.0", an info block (title from the schema name, version from the schema version, falling back to 0.0.0), a top-level security: [{JWT: []}], and components.securitySchemes.JWT as HTTP bearer with bearerFormat: JWT.

PathOperations
/restpost, put, patch, delete. The bulk envelope
/rest/<entity>get (list), post (create), put, patch
/rest/<entity>/id/{id}get, put, patch, delete
/rest/<entity>/id/{id}/restorepost: soft-delete reversal
/x<endpoint path>one per scripted endpoint declared in the definitions

<entity> is the literal entity name: the document emits a concrete path set per entity (/rest/order, /rest/order/id/{id}, …), not one templated {entity} path. Scripted endpoints keep their declared path under the /x prefix, so path: /reports/sales emits /x/reports/sales.

List operations declare the query parameters limit, offset, cursor, expression (jq response shaping) and fields (comma-separated projection). Delete declares purge. Component schemas are the JSON Schema $defs with $refs rewritten into #/components/schemas/, plus two synthesised schemas: PageBlock (has_next_page, end_cursor) and ErrorResponse (error, code, details, with error and code required).

Emits $schema: https://json-schema.org/draft/2020-12/schema, a title (the schema name, or the entity name on the per-entity route), type: object, $defs, and a top-level version when the schema declares one. Every entity object sets additionalProperties: false. Named enums emit {type: string, enum: [...]} under their canonical PascalCase name; a field carrying an inline enum is referenced as <Entity><Field>, so status on order becomes OrderStatus.

The per-entity route emits only that entity’s three variants plus the enums its fields actually reference, so file-per-entity codegen does not pull in the whole document.

Field validations fold into schema keywords where an equivalent exists:

ValidationJSON SchemaZod
emailformat: email.email()
urlformat: uri.url()
ip (param version: v4 / v6)format: ipv4 / ipv6.ip()
regex (param expression)pattern.regex(/…/)
minmax (params min, max)minimum / maximum.min() / .max()
length (params min, max)minLength / maxLength.min() / .max()
enum, in (param values)enumz.enum([...])
phone—.regex(…)
creditcard—.refine(…)

Every other validation type is enforced by the engine but does not appear in any export. CUE expresses the same rules as lattice intersections (int & >=1 & <=1000, string & =~ "…"); it has no length keyword, so length becomes a ^.{min,max}$ regex.

Emits a package clause derived from the schema name (lowercased, with _, . and - folded to _), an import "time" when any datetime field exists, #<Enum> disjunctions, and the three structs per entity. Structs are open by default, they end with ..., so unknown fields validate; the renderer can close them for strict validation.

Discovery is how a client learns entity shape at runtime. Every route is GET, requires a bearer token, and accepts ?datastore=<name>.

RouteReturns
/data/schemaSchema summary: name, version, default_datastore, and sorted name lists for datastores, entities, relations, types, enums, traits, embeddables, stateflows, views, functions
/data/schema/graphqlThe generated GraphQL SDL as text/plain
/data/schema/entities{entities: [{name, fields, relations}], count}, sorted by name: fields and relations are counts
/data/schema/entities/{entity}Full detail for one entity
/data/stateflowsThe declared state machines: name, field, initial_state, states
/data/stateflow/{name}One state machine
/data/admin/data/actionsCatalogue of declared custom actions (admin policy gate)
/data/admin/data/references/{entity}Incoming and outgoing references for one entity (admin policy gate)
/data/admin/data/hashesThe named-query catalogue (admin policy gate)

The stateflow responses deliberately omit guards, actions, entry/exit hooks and conditions, because those reference scripts a caller should not see.

GET /data/schema/entities/{entity} is the shape call. Fields come back in declaration order:

{
"name": "order",
"table_name": "orders",
"primary_key": "id",
"materialization": "",
"fields": [
{
"name": "id",
"type": "",
"required": true,
"nullable": false,
"unique": true,
"primary_key": true,
"computed": false,
"default": "ulid()"
},
{
"name": "risk_band",
"type": "credit_score",
"required": false,
"nullable": true,
"unique": false,
"primary_key": false,
"computed": false,
"default": null
}
],
"relations": [
{
"name": "items",
"type": 2,
"to_entity": "order_item",
"from_field": "id",
"to_field": "order_id"
}
]
}
Terminal window
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/graphql
Terminal window
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/jsonschema/order.json

Exports and the SDL are rendered from the tenant’s resolved schema, which is cached per tenant and datastore. Loading new definitions does not by itself change what these routes return. The cached engine has to be dropped first.

Terminal window
curl -X DELETE -H "Authorization: Bearer $TOKEN" https://api.example.com/data/cache

The response is {"cleared": true, "tenant": "<tenant key>"}. Every cached resource for the calling tenant, schemas, pools, engines, action registries, is dropped and the next request rebuilds them.

CodeHTTPMeaning
no_tenant403No tenant key on the request. The gateway did not inject the tenant headers, or the service was called directly without them.
engine_unavailable500The tenant’s engine could not be resolved or built: datastore unreachable, or the definitions failed to load.
missing_entity400The entity path segment was empty, e.g. a request to /data/schema/jsonschema/.
entity_not_found404The named entity is not in the tenant’s resolved schema. Check the spelling and the ?datastore=.
export_render_failed500A renderer returned an error while producing the document.
cache_clear_failed500Cache invalidation failed while draining the tenant’s scopes.

The full external code table is on the block’s error reference, and the endpoint-level HTTP reference is at /blocks/baas/data/api/.