Schema export and discovery
data.svc renders eight external schema formats 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.
Formats and the endpoint that serves each
Section titled “Formats and the endpoint that serves each”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.
| Format | Version | Bundle endpoint | Per-entity endpoint | Content type |
|---|---|---|---|---|
| OpenAPI | 3.1.0 | GET /data/schema/openapi.json | — | application/json |
| JSON Schema | 2020-12 | GET /data/schema/jsonschema.json | GET /data/schema/jsonschema/{entity} | application/schema+json |
| Zod (TypeScript) | Zod 3.22+ | GET /data/schema/zod.ts | GET /data/schema/zod/{entity} | application/typescript |
| CUE | — | GET /data/schema/cue | GET /data/schema/cue/{entity} | text/x-cue |
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.
What each format is good for
Section titled “What each format is good for”| Format | Use it for |
|---|---|
| OpenAPI 3.1 | Generating REST clients and SDKs; import into API consoles and contract-test tooling. |
| JSON Schema 2020-12 | Request/response validation with Ajv or any 2020-12 validator; the input to most codegen chains. |
| Zod | Front-end TypeScript types and runtime validation compiled from the same definitions. |
| CUE | Config templating and policy validation pipelines that need a lattice, not a validator. |
Three variants per entity
Section titled “Three variants per entity”Each format emits three shapes for each entity emit the Output shape only:
| Variant | Contains | Meaning |
|---|---|---|
| Output | Every readable field, including server-managed ones | What a response body looks like |
| Input | Fields a client may send on create | What a POST body looks like |
| Patch | Input with every field optional | What a PATCH body looks like |
Entity names are PascalCased for type names — order_item becomes OrderItem — and the
variants are named per format:
| Format | Output | Input | Patch |
|---|---|---|---|
| OpenAPI | components.schemas.Order | OrderInput | OrderPatch |
| JSON Schema | $defs/Order | $defs/OrderInput | $defs/OrderPatch |
| Zod | OrderSchema | OrderInputSchema | OrderPatchSchema |
| CUE | #Order | #OrderInput | #OrderPatch |
Zod additionally exports the three inferred TypeScript types Order, OrderInput and
OrderPatch, and derives OrderPatchSchema as OrderInputSchema.partial().
Which variants a field appears in
Section titled “Which variants a field appears in”Variant membership is driven by field modifiers and the computed flag:
| Field declaration | Output | Input | Patch |
|---|---|---|---|
| no modifier | yes | yes | yes |
readonly | yes | no | no |
writeonly | no | yes | yes |
final | yes | yes | no |
computed: (virtual or materialized) | yes | no | no |
writeonce | yes | yes | yes |
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.
Fields that never reach an export
Section titled “Fields that never reach an export”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.
| Declaration | Effect 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: piiOnly email appears in the rendered documents. internal_score is dropped by the
explicit marker and ssn by the classification.
Field-type mapping
Section titled “Field-type mapping”| Field type | JSON Schema / OpenAPI | Zod | CUE |
|---|---|---|---|
string, text | string | z.string() | string |
int, bigint | integer | z.number().int() | int |
float | number | z.number() | number |
decimal, money | number | z.number() | number |
bool | boolean | z.boolean() | bool |
datetime | string, format: date-time | z.string().datetime() | time.Time |
date | string, format: date | z.string().regex(/^\d{4}-\d{2}-\d{2}$/) | string & =~ "^\\d{4}-\\d{2}-\\d{2}$" |
time | string, format: time | z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/) | string & =~ "^\\d{2}:\\d{2}(:\\d{2})?$" |
uuid | string, format: uuid + pattern | z.string().uuid() | string & =~ "<uuid pattern>" |
ulid | string + ^[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 items | z.array(<inner>) | [...<inner>] |
file | object {id, url, mime_type, size} | z.object({ id, url, mime_type, size }) | struct with the same four keys |
bytes | string, contentEncoding: base64 | z.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.
Nullability
Section titled “Nullability”| Format | Nullable field renders as |
|---|---|
| JSON Schema / OpenAPI | type: [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 |
Per-format detail
Section titled “Per-format detail”OpenAPI 3.1
Section titled “OpenAPI 3.1”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.
| Path | Operations |
|---|---|
/rest | post, 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}/restore | post — 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).
JSON Schema 2020-12
Section titled “JSON Schema 2020-12”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:
| Validation | JSON Schema | Zod |
|---|---|---|
email | format: email | .email() |
url | format: 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) | enum | z.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.
Runtime discovery
Section titled “Runtime discovery”Discovery is how a client learns entity shape at runtime. Every route is GET, requires
a bearer token, and accepts ?datastore=<name>.
| Route | Returns |
|---|---|
/data/schema | Schema summary — name, version, default_datastore, and sorted name lists for datastores, entities, relations, types, enums, traits, embeddables, stateflows, views, functions |
/data/schema/graphql | The 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/stateflows | The declared state machines — name, field, initial_state, states |
/data/stateflow/{name} | One state machine |
/data/admin/data/actions | Catalogue 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/hashes | The 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" } ]}curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/graphqlcurl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/jsonschema/order.jsonMaking a definition change visible
Section titled “Making a definition change visible”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.
curl -X DELETE -H "Authorization: Bearer $TOKEN" https://api.example.com/data/cacheThe 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.
Errors
Section titled “Errors”| Code | HTTP | Meaning |
|---|---|---|
v2.no_tenant | 403 | No tenant key on the request. The gateway did not inject the tenant headers, or the service was called directly without them. |
v2.engine_unavailable | 500 | The tenant’s engine could not be resolved or built — datastore unreachable, or the definitions failed to load. |
v2.missing_entity | 400 | The entity path segment was empty, e.g. a request to /data/schema/jsonschema/. |
v2.entity_not_found | 404 | The named entity is not in the tenant’s resolved schema. Check the spelling and the ?datastore=. |
v2.export_render_failed | 500 | A renderer returned an error while producing the document. |
v2.cache_clear_failed | 500 | Cache 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/api/.