surformSurrealDB schemas, declared

Getting started

This page takes a small schema from an empty directory to a compiled, apply-ready script.

Install

Surform is a single Rust program with no runtime dependencies. You need the Rust toolchain (rustup installs it in one command), then clone this repository and build the compiler:

sh
cargo install --path .

That puts the surform binary on your PATH. Check it:

textcaptured output — the real thing, not prose
Declarative SurrealDB schemas — SDL to SurrealQL DDL and back

Usage: surform [OPTIONS] <COMMAND>

Commands:
  check      Parse an SDL file and report diagnostics
  build      Compile an SDL file to SurrealQL DDL
  fmt        Write an SDL file's canonical form
  normalize  Merge rendered INFO interchange into an SDL file and re-emit the canonical SDL
  migrate    Schema migrations — generate, commit, push, status, revert
  lint       Lint an SDL file — advisory findings, exit 1 when any exist
  help       Print this message or the help of the given subcommand(s)

Options:
      --json     Render diagnostics as JSON (one object per line) instead of the graphical format
  -h, --help     Print help
  -V, --version  Print version

The schema

Here is the quick-start schema — a shop with users and posts. This is examples/shop.surdl in the docs sources; every panel on this page is produced from this file. The examples on these pages are editable: the verdict and the DDL recompute as you type, and Reset brings the verified text back.

applied to SurrealDB 3.3.0
schema.surdl
sdl
// The quick-start schema. `//` lines are ordinary comments — they
// document the file and never reach the database.

namespace acme;
database shop;

/// People who buy things.
table user {
  schemafull;

  // The engine rejects anything that does not look like an email.
  field email: string { assert: string::is_email($value); };
  field name: string;

  // No two accounts share an email — a plain comment, since doc
  // comments only attach to top-level definitions.
  index unique email_unique {
    fields: email;
  };
};

/// Anything a user publishes.
table post {
  schemafull;

  field title: string { assert: string::len($value) > 0; };
  field body: string;

  // Who wrote this post.
  field author: record<user>;

  index author_idx {
    fields: author;
  };
};
surform build
sql
DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE `user` TYPE NORMAL SCHEMAFULL COMMENT 'People who buy things.';
DEFINE FIELD OVERWRITE email ON `user` TYPE string ASSERT string::is_email($value);
DEFINE FIELD OVERWRITE name ON `user` TYPE string;
DEFINE INDEX OVERWRITE email_unique ON `user` FIELDS email UNIQUE;
DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL COMMENT 'Anything a user publishes.';
DEFINE FIELD OVERWRITE title ON `post` TYPE string ASSERT string::len($value) > 0;
DEFINE FIELD OVERWRITE body ON `post` TYPE string;
DEFINE FIELD OVERWRITE author ON `post` TYPE record<user>;
DEFINE INDEX OVERWRITE author_idx ON `post` FIELDS author;
  • /// doc comments become the definition's COMMENT in SurrealDB — your documentation travels with the schema.
  • field email: string { assert: … }; — anything after : is SurrealQL, checked by SurrealDB itself. Surform does not invent a second expression language.
  • index unique email_unique is nested inside its table, the way you think about it; the compiler emits the standalone DEFINE INDEX SurrealDB expects.
  • field author: record<user> is a graph-style reference — types pass through verbatim.

Check it

check parses the file and reports problems. On a clean file it says nothing at all — silence is success:

captured from the real binary
$ surform check shop.surdl
text

The same check on a file with a misspelled permissions clause:

real Surform diagnostics
this schema is wrong on purpose
sdl
namespace acme;
database shop;

table person {
  schemafull;
  field name: string;
  permisions: full;
};
what Surform reports
text
SDL-E1003 (spec/README.md#design-rules-settled)

  x unknown clause `permisions` in table definition
   ,-[7:3]
 6 |   field name: string;
 7 |   permisions: full;
   :   ^^^^^|^^^^
   :        `-- unknown clause key
 8 | };
   `----
  help: did you mean `permissions`?

The diagnostic points at the offending line, names the code, and suggests a fix. The CLI reference has more on reading diagnostics.

Compile it

build prints the SurrealQL DDL to stdout — pipe it to a file, to surreal sql, or to your deployment pipeline:

captured from the real binary
$ surform build shop.surdl
text
DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE `user` TYPE NORMAL SCHEMAFULL COMMENT 'People who buy things.';
DEFINE FIELD OVERWRITE email ON `user` TYPE string ASSERT string::is_email($value);
DEFINE FIELD OVERWRITE name ON `user` TYPE string;
DEFINE INDEX OVERWRITE email_unique ON `user` FIELDS email UNIQUE;
DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL COMMENT 'Anything a user publishes.';
DEFINE FIELD OVERWRITE title ON `post` TYPE string ASSERT string::len($value) > 0;
DEFINE FIELD OVERWRITE body ON `post` TYPE string;
DEFINE FIELD OVERWRITE author ON `post` TYPE record<user>;
DEFINE INDEX OVERWRITE author_idx ON `post` FIELDS author;

Two conventions the output relies on:

  • Every emitted DEFINE carries OVERWRITE (one exception: a user with no credential defines once — Users & roles). Applying the script twice — or applying it over a database that already has some of the definitions — redefines cleanly instead of erroring. Your schema file is the desired state; applying it converges the database to it.
  • After each DEFINE NAMESPACE / DEFINE DATABASE the compiler emits the matching USE line, so one script crosses namespace and database boundaries safely (a database with no preceding namespace omits the USE).

Apply it

The build output is ordinary SurrealQL — run it anywhere SurrealDB 3.3 runs. For example, with the SurrealDB CLI installed:

sh
surform build schema.surdl > schema.surql
surreal sql --conn ws://localhost:8000 --user root --pass … --ns acme --db shop < schema.surql

Surform itself never touches your database connection — it produces the script, your existing tooling applies it. (Adopting a database — the reverse trip — is one command.)

Keep it tidy

fmt prints the canonical form of a file: one clause style, one clause order, shortest spellings. Team members stop arguing about layout — the compiler owns it:

canonical form verified (re-parses, stable)
as you wrote it
sdl
namespace acme; database shop;
table `user` { schemafull; field name:string; field email: string { readonly; }; }
surform fmt
sdl
namespace acme;

database shop;

table `user` {
  schemafull;
  field name: string;
  field email: string {
    readonly;
  };
};

Let the linter catch the traps

Some mistakes are legal but dangerous. This table compiles, applies, and silently grants everyone everything — permissions: full; lets anyone, even unauthenticated users, read and write every record:

real Surform lint findings
this file lints
sdl
namespace acme;
database shop;

table secrets {
  schemafull;
  field payload: string;
  permissions: full;
};
what Surform suggests
text
SDL-L0001 (spec/table.md)

  ! table `secrets` in database `acme/shop`: permissions are `full` — anyone, even unauthenticated users, can access every record
   ,-[4:1]
 3 |     
 4 | ,-> table secrets {
 5 | |     schemafull;
 6 | |     field payload: string;
 7 | |     permissions: full;
 8 | |-> };
   : `---- `permissions: full` on this table
   `----
  help: `full` grants everyone — including unauthenticated connections — access to every record; omit the clause for the secure default (implicit `none`), or scope operations in a `permissions`
        block

surform lint explains why and links to the fix. Omitting the clause keeps the database's secure default — every operation denied — and never warns. The full catalog of warnings lives on the linter page.

Where next