Graven

The schema language (.graven)

Reference for the language accepted by gravenc (crate graven-compiler) and for the Rust code it generates. The binary format is specified normatively in the format specification; this document explains how to write a schema, how to evolve it, and what the generator produces.

namespace bank;

enum Currency : u16 { BRL = 0, USD = 1, EUR = 2 }

table Entry {
  account @0: u64;
  amount  @1: i64;
  memo    @2: string?;
}

table Transaction {
  id         @0: u64;
  ts         @1: u64;
  currency   @2: Currency;
  from_shard @3: u32;
  to_shard   @4: u32;
  entries    @5: [Entry];
  note       @6: string?;
}

1. Grammar

schema    = "namespace" path ";" { import } { item } ;
import    = "import" path [ "as" ident ] ";" ;
path      = ident { "." ident } ;
item      = enum | struct | table | union ;
enum      = "enum" ident ":" type "{" [ evalue { "," evalue } [","] ] "}" ;
evalue    = ident "=" int ;                    (* decimal or 0x…, unsigned *)
struct    = "struct" ident "{" [ sfield { "," sfield } [","] ] "}" ;
sfield    = ident ":" type ;
table     = { attrs } "table" ident "{" { tfield } "}" ;
tfield    = { attrs } ( "@" int ":" "reserved" [ type ] ";"
                      | ident "@" int ":" type [ "?" ] [ "=" literal ] ";" ) ;
attrs     = "#" "[" attr { "," attr } [","] "]" ;
attr      = ident [ "(" [ arg { "," arg } [","] ] ")" ] ;
arg       = ident [ "=" literal ] ;
union     = "union" ident "{" uvar { "," uvar } [","] "}" ;   (* at least one variant *)
uvar      = ident "@" int ":" type ;
type      = ident [ "." ident ] | "[" type "]" ;   (* `name.Type`: an imported type *)
literal   = [ "-" ] int | [ "-" ] float | "true" | "false" | ident ;
ident     = ( letter | "_" ) { letter | digit | "_" } ;   (* ASCII *)
int       = decimal digits | "0x" hex digits ;           (* "_" separates digits *)
float     = digits ( "." digits [ exponent ] | exponent ) ;   (* 1.5, 2e10, 1.0e-3 *)
exponent  = ( "e" | "E" ) [ "+" | "-" ] digits ;
digits    = digit { digit | "_" } ;

Comments: // … to end of line and /* … */. Whitespace is free. Documentation comments, /// …, document the declaration that follows them (see Documentation comments). The namespace is part of each table's schema_id ("<namespace>.<Table>", spec §8.2).

Documentation comments

A /// comment documents the type, field, enum variant, union variant or struct field right after it. The generated code carries it, so it shows up in the editor and in API documentation next to the generated reader, verified view, writer, accessors and setters:

/// A double-entry transaction between two shards.
///
/// The entries of a transaction always sum to zero.
table Transaction {
  /// Unix time, in seconds.
  ts @1: u64;
}

2. Types

Type Where Notes
u8 u16 u32 u64 u128 i8 i16 i32 i64 i128 field, struct, list little-endian
f32 f64 field, struct, list IEEE-754
bool field, struct, list in a table it takes only bits (spec §4.4); in a struct or list, 1 byte
string field, list UTF-8, validated by verify
bytes field, list
[T] field T = scalar, bool, enum, struct, string, bytes or table; no [[T]] and no [Union]
enum E : u8/u16/u32 field, struct, list an unknown value reads as Unknown(raw)
struct S field, struct, list frozen: packed fields, no padding, never evolves; only scalars, bool, enums and structs; no recursion
table T field, list, union variant evolves through IDs; recursion allowed
union U field variants are tables, unique tags > 0; an unknown tag reads as Unknown(tag)

The built-in names (u8…f64, bool, string, bytes) cannot be redeclared.

3. Table fields: IDs, presence and defaults

Every field has an ID @N. IDs are contiguous from @0, with no repeats; ID order defines the layout (spec §3.3), not the order in the text.

Form Presence Reading a message without the field
x @0: u32; required Missing error (on read for scalars; on access/verify for pointers)
x @0: u32?; optional None
x @0: u32 = 7; default the default

4. Reserved fields

A field that leaves the schema is not deleted: it becomes reserved, keeping its ID and slot.

table Root {
  a @0: u32;
  @1: reserved u32;   // was `old @1: u32?`
}

5. Attributes and validation rules

Evolution confirmation

#[allow_breaking_readers] confirms compatibility warnings:

table T {
  a @0: u32;
  #[allow_breaking_readers] @1: reserved;   // `b` used to be required
  #[allow_breaking_readers] c @2: u32?;     // `c` used to be required
}

The attribute may stay in the schema after update; it only has an effect in the step where the breaking change is introduced.

Validation rules

Tables and table fields can declare value validation rules without changing the wire format (see proposal 0002):

enum Status : u8 { Active = 0, Draft = 1 }

#[check(valid_period)]
table Promo {
  #[len(min = 1, max = 64), check(slug)]
  name @0: string;

  #[range(min = 1, max = 10_000_000)]
  price @1: u32;

  #[finite, range(min = 0.0, max = 100.0)]
  discount @2: f32?;

  #[known]
  status @3: Status;

  starts @4: u64;
  ends @5: u64;
}
Rule Applies to Meaning
range(min = a, max = b) integer and float fields a ≤ value ≤ b (inclusive; NaN is outside); either bound may be omitted
len(min = a, max = b) string, bytes, [T] byte length (string/bytes) or element count ([T]); either bound may be omitted
finite f32, f64 not NaN and not infinite
known enum fields and [Enum] value is a variant declared in the reader's schema
check(name) any field or table calls application-provided Checks::name(&self, v) -> bool

Validation never runs implicitly on from_bytes or to_bytes (so old data stays readable). When a schema declares rules, generated code provides pub trait Checks, XVerified::validate(&checks) (stops at the first failure, no allocation), XVerified::validate_all(&checks) (collects Vec<Invalid>), and XW::to_bytes_validated(&mut b, &checks, |w| ...). Schemas without rules generate the exact same code as before.

Any other attribute is an error.

6. Names

Besides being unique in their scope, names must produce valid, collision-free Rust. check rejects (with file:line:column):

Option, String, Result, FnOnce and the like are accepted: the generated code only uses absolute paths (::core::…, ::graven::…). So is a field named schema_id (the method schema_id() does not collide with the const SCHEMA_ID). Style recommendation: types and variants in CamelCase, fields in snake_case — the generator does not rename, and other styles produce lint warnings in the crate that includes the code.

7. Imports

A schema can use the types declared in other schemas by importing their namespace:

// common.graven
namespace common;

struct Money { cents: i64, currency: u16 }
table Address { street @0: string; city @1: string; }
// orders.graven
namespace orders;

import common;

table Order {
  total   @0: common.Money;
  ship_to @1: common.Address?;
}

8. Compatibility (lockfile)

The lock (<schema>.lock, deterministic TOML, committed next to the schema) stores the state that must survive across versions: each table's schema_id, the layout (ID, type, size, slot, bit, presence) and the types of reserved fields, enums, structs and unions. gravenc update compares the new schema against the lock (normative rules in spec §9) before rewriting it.

Errors (refused):

Rule When
type-changed a field's type or size changed (or a type changed kind: enum ↔ struct ↔ table ↔ union)
id-reused a reserved ID became a field again
ids-reordered fields swapped IDs
field-removed a field disappeared without becoming reserved
enum-base-changed an enum's base type changed
struct-changed a struct's fields or size changed
new-required a new required field without a default
optional-to-required an optional/defaulted field became required
table-removed a table in the lock disappeared
default-changed a field's default value changed (old messages without the field would change meaning)
default-added an optional field gained a default (old messages without the field would start reading the default; this also closes = 5 → ? → = 6)
enum-variant-changed an enum variant changed value or was removed (renaming while keeping the value is allowed)
union-variant-changed a union variant was removed, or its tag now points to another table
namespace-changed the namespace changed, which changes every schema id; if that is intended, start a new lockfile (delete the lock and run gravenc update --init)
layout-changed defensive: a different slot/bit with the same types (the sequential layout never produces this)

Warnings (an error until confirmed with #[allow_breaking_readers] on the field or table): required-reserved (required → reserved), required-relaxed (required → optional/default), rule-tightened (a range/len bound tightened or a new built-in rule added to an existing field), and check-added (a new check(name) added to an existing field or table). Since required-relaxed also requires confirmation, reserving in two steps (required → optional → reserved) does not escape the warning.

Allowed: a new optional or defaulted field (new-optional, new-default); reserving an optional or defaulted field; default → optional; renaming a field; a new enum or union variant; a new table.

9. Workflow: gravenc update vs build.rs

The lock only changes through an explicit action; the build never writes it.

gravenc check  [--locked] <schema>...      validate schema(s) and compatibility with <schema>.lock
gravenc update <schema>... [--from <lock> | --init]
                                         apply the compatibility check and write <schema>.lock
gravenc gen    <schema> -o <dir>           generate <dir>/<file without .graven>.rs (creates <dir>)

In the crate that uses the schema:

// build.rs
fn main() {
    graven_compiler::build::Build::new()
        .schema("schemas/bank.graven")     // reads schemas/bank.graven.lock
        .compile()                       // writes $OUT_DIR/bank.rs
        .unwrap_or_else(|e| panic!("{e}"));
}

compile() emits cargo:rerun-if-changed for the schema and the lock, and fails if: the lock does not exist (the message points to gravenc update --init or --from); regenerating the lock would change something (the schema was edited without gravenc update); compatibility has an error or an unconfirmed warning; the schema does not pass parse/check. Confirmed warnings become cargo:warning.

Typical cycle: create the .graven → gravenc update --init once; afterwards, edit the .graven → gravenc update (review the findings) → commit schema and lock → cargo build.

10. Safety: generated code lives alone in its module

Include each generated file alone in its own module:

#![deny(unsafe_code)]   // in the crate (generated code carries #[allow(unsafe_code)] where needed)

pub mod bank {
    include!(concat!(env!("OUT_DIR"), "/bank.rs"));
    // nothing else here
}

The view XVerified<'a> reads strings without revalidating UTF-8 (str_unchecked). That is sound because it is only constructed after verify() has passed. Its tuple field is private — but privacy in Rust is per module: safe code written in the same module as the include! could write XVerified(X::read(buf)?) without verifying and, given an invalid string, cause undefined behavior. Outside the module, that construction does not compile. The header of every generated file repeats this warning.

When schemas import each other, include each generated file in a module named after the last segment of its namespace, side by side: the generated orders code refers to super::common::Money.

pub mod common { include!(concat!(env!("OUT_DIR"), "/common.rs")); }
pub mod orders { include!(concat!(env!("OUT_DIR"), "/orders.rs")); }

11. Generated API (summary)

For each table X (with a field field):

Enum E: enum E { …, Unknown(base) } with from_raw/to_raw, Default (raw value 0) and graven::Le. Struct S: struct S { pub fields } (Copy, Default, PartialEq, and Eq when there are no floats) with graven::Le. Union U: enum U<'a> { Variant(T<'a>), …, Unknown(u16) } for the reader and enum UVerified<'a> for the view.