Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Evenframe
Evenframe makes your Rust types the single source of truth. From the structs and enums you already write, it generates TypeScript types and validators, synchronizes a SurrealDB schema (SQL planned for a future release), fills the database with mock data, and gives your program the same type information at runtime. You can configure the features to include the full feature set, or something as simple as just using the type metadata with no syncing or mock data at all.
The goal is one dependency for every utility that needs to know your types. Runtime metadata, type synchronization, schema synchronization and mock data all start from the same type information, so evenframe reads it once and serves all of them from one crate, instead of each tool keeping its own copy of your types.
Ways to use it
| You want | Use | Needs |
|---|---|---|
| Type metadata and validated deserialization at runtime | the derive | evenframe with metadata |
TypeScript and the schema's SurrealQL written on every cargo build |
the derive and a build script | evenframe with build-typesync, build-schemadump or both (build-fullstack) |
| Schema synchronization, mock data and plugins as well | the CLI | the evenframe binary |
These combine. A project can use the derive for runtime access, a build script for its TypeScript, and the CLI for its database.
1. The derive: runtime type access
With the metadata feature, deriving Evenframe on a struct or enum
registers its full description at compile time: fields, types, validators,
and, for tables, permissions, indexes, events and mock data settings. A
struct with an id field is a table; one without is an embedded object.
Metadata needs no database dependency.
[]
= { = "0.6", = false, = ["metadata"] }
= { = "1", = ["derive"] }
use Evenframe;
use Serialize;
At runtime, look a type up by name in the registry, or ask it directly:
use ;
use EvenframePersistableStruct;
let user = get_table_config.expect;
for field in &user.struct_config.fields
println!;
assert_eq!;
Each validator is named in snake case: a check such as email alone, one
with a value as max_length = 80 or between = (0.0, 100.0). A name two kinds
of validator share takes the kind, as in string_validator(min_length = 3),
and the path form, StringValidator::MaxLength(80), reads the same.
#[morphs(...)] rewrites a value into canonical form before its validators
check it, keeping its type:
| Morph | Applies to | Does |
|---|---|---|
trim |
String |
removes leading and trailing whitespace |
collapse_whitespace |
String |
turns each run of whitespace into one space |
lower, upper |
String |
changes case |
capitalize |
String |
upper-cases the first character |
normalize, normalize_nfc, normalize_nfd, normalize_nfkc, normalize_nfkd |
String |
Unicode normalization (normalize is NFC) |
round = 2 |
f64 |
rounds to that many decimal places, halves up, as Math.round does |
clamp = ("0", "100") |
integers, f64, decimal text in a String |
pulls the value into the range |
sort |
Vec of strings, characters, integers or booleans |
orders the elements, strings by code point |
unique |
Vec of strings, characters, integers or booleans |
drops elements equal to an earlier one |
Morphs run in the order written, all before the validators, in the Rust read
and in every TypeScript output (Macroforge's @endec({ normalize: [...] })).
The schema stores the rewritten value and asserts only the validators. A morph
on a type it does not apply to is a compile error, and one written inside
#[validators(...)] is refused with the place it belongs.
A value written as text is a type, so it reads and writes the same way:
| Type | Written as | Holds |
|---|---|---|
FromText<T> |
"42", "0.5", "https://…" |
an integer within JavaScript's safe range, an f32/f64, or a url::Url |
IsoDate |
"2024-02-29T10:15:00.000Z" |
a DateTime<Utc> |
EpochMillis |
"1709201700000" |
a DateTime<Utc> |
JsonText<T> |
"[1,2]" |
any T, read from and written as JSON |
Each parses exactly what ArkType's and Effect's parses accept (FromText<u8>
refuses "300" and "007"), and serializes back to the same text. Validators
and morphs on the field reach the parsed value, the database stores it as its
own type (int, float, datetime, or T's), and the TypeScript outputs
decode the text: ArkType's string.integer.parse and the like, Effect's
NumberFromString and parseJson, and Macroforge's @endec({ as: ... })
codecs (DisplayFromStr, DateFromString, JsonString, ...).
A struct whose fields carry #[validators(...)] or #[morphs(...)] also gets
a generated serde::Deserialize: serde reads the input under the struct's own
#[serde(...)] attributes, then every field's morphs and validators run, and
every field that fails is reported in one error. Do not derive Deserialize
on such a struct yourself. Every derived type also implements
evenframe::validator::validate::Validate, whose validate() checks a value
built in code, and every value nested in it, the same way. Neither needs
metadata: without it the derive emits only these and the EvenframeTable
marker that RecordLink accepts, which is all a project using only a build
script or the CLI needs.
A single-field tuple struct, or a #[serde(transparent)] struct, is a
newtype: serde writes it as its one field's value, and so does every output.
#[validators(...)] on the struct checks that value wherever the newtype is
read, and a validator on a field holding a newtype checks the value inside it:
;
A newtype with validators is also built from a value the program holds:
NonEmptyString::try_from(text) runs its validators, rewriting the value as
reading it does, and fails with ValidationErrors. One holding a String
takes a &str too and reads back with as_str(). A newtype whose value is a
type parameter gets neither, since core's blanket TryFrom already covers it,
and needs a build without the metadata feature, whose registry names one
concrete type.
The TypeScript outputs brand a newtype (ArkType and Effect brands,
Macroforge's $Newtype<T>), so a plain string is not one. The schema stores
it as its inner type and asserts its validators wherever the value holds it:
the field itself, an Option, each element of tags above, a map's keys and
values, a tuple's items, an embedded struct's fields, a tagged enum's payload
under its tag and an untagged one's where serde would read the value as that
variant. A struct holding itself, and an untagged variant after one whose
shape SurrealQL cannot state (such as Maybe(Option<T>)), cannot be followed
or told apart, so validators there are checked when read but not asserted,
and schemasync warns about each one. The fallback DEFAULT of a field whose
zero value fails a check is left out, so the field is required. A
tuple struct of several fields is written as an array and a unit struct as
null, as serde writes them.
A validator runs in three places: the Rust read, the schema's ASSERT, and
every TypeScript output. A custom pattern,
regex_literal = format(custom = "..."), therefore takes only the syntax
Rust's regex and JavaScript's RegExp read the same way: no \d, \w,
\s, \b, \p{...}, ., inline flags or class set operations, and a
negated class only directly under * or +. Anything else is a compile error
naming the portable form, such as [0-9] for \d.
starts_with, ends_with and includes take text in quotes or a format,
so a password reads includes = format(uppercase) rather than a hand-written
pattern. The validator anchors the format's pattern where it looks: a custom
pattern there may not anchor itself with ^ or $. Besides the mock-data
formats, uppercase, lowercase, digit and symbol (ASCII punctuation)
match one character, and relative_path and slug match a URL path such as
/a/b and a slug such as my-post-1.
Where one pipeline needs something the others cannot read, an override
replaces a field's, tuple element's or newtype's #[validators(...)] there:
#[typesync(validators(...))] applies to the TypeScript outputs, and its
patterns are JavaScript regex literals with their flags (i, m, s, and u
or v). #[schemasync(validators(...))] applies to the schema and mock data,
and its patterns are Rust regexes, which SurrealDB's string::matches runs.
Either replaces the list outright for its pipeline. The Rust read runs only
#[validators(...)], so a value with overrides alone is not validated in Rust.
Mock data meets the schema's list and, where it differs, the TypeScript one.
Every other shape serde writes is described as serde writes it:
- A variant's own
#[serde(untagged)]writes it bare, after the tagged variants, which are read first. #[serde(rename(serialize = "...", deserialize = "..."))]is described by the name serde writes, and read by both.- A key serde skips in one direction only is optional in the TypeScript, and the database holds only what serde writes.
#[serde(flatten)]on a struct, or anOptionof one, puts its fields beside the struct's own. A flattened map or enum adds keys known only from a value: the TypeScript gains an index signature or an intersection, a table holding them is defined SCHEMALESS, and an object holding them is a FLEXIBLEobjectfield.[schemasync] warn_schemaless = truewarns about each such table.#[serde(into = "X")]is described asXand stored as the struct's own fields. Withfromortry_from, the read converts first, then the fields' validators check the result.- With
#[serde(remote = "...")], the definition's validators check the remote value it hands serde. - Validators on a tuple variant's or tuple struct's elements check each
element at its position, such as
Circle.0.
With the surrealdb-types feature, every derived type also implements the
SurrealDB SDK's SurrealValue in the shape evenframe's schema defines: fields
under their database names (#[surreal(rename...)]), enums in serde's
representation, a missing field taking serde's default, and a read that runs
the field's morphs before validating, as serde's read does. Rows read with response.take::<Vec<T>>(n) and bound with
into_value() need no JSON round trip. A field whose type has no
SurrealValue converts through serde. A Rust Option is stored as
option<T> with NONE by default. Set [schemasync] option_none = "null" to
generate null | T, NULL defaults, and NULL mock values. For typed SDK writes,
config.option_none.into_value(record) applies that configuration without
changing the SDK's canonical into_value(). Use
config.option_none.read_value::<Record>(value) to read configured NULL
absence through the SDK's declared type shape, including nested optional arrays.
A query's row, which no output describes, takes the same impl from
#[derive(evenframe::SurrealValue)]. Its keys are its serde names unless
#[surreal] names them, as serde read it from the database's JSON: a
String or Vec<String> field reads a record id or datetime as that JSON's
text, and a #[serde(flatten)] field reads the keys its siblings leave.
Untagged object variants are supported when required keys distinguish their
serialized representations. Optional and defaulted fields are not discriminators.
Field aliases and deny_unknown_fields are accounted for; indistinguishable
shapes are rejected at compile time.
Typesync and Schemasync are the same derive limited to one pipeline, and
EvenframeUnion describes an enum whose variants are each a table.
2. The derive and a build script: generated files on every build
Add evenframe with the build-typesync feature as a build dependency, and describe
your outputs in evenframe.toml at the project root:
[]
= { = "0.6", = false, = ["build-typesync", "arktype", "effect"] }
# evenframe.toml
[]
= [
{ = "arktype", = "./web/src/generated" },
{ = "effect", = "./web/src/generated/effect", = "per_file" },
]
// build.rs
typesync() scans the workspace for derived types and writes every
configured output, leaving unchanged files untouched. To configure it in
code instead of the file, build a ScanConfig and call
evenframe::build::typesync_with.
The outputs are ArkType, Effect, Macroforge, Protocol Buffers and
FlatBuffers. Select generators with arktype, effect, macroforge,
protobuf and flatbuffers (or typesync-all for all of them). A validator
macroforge does not provide is written as a function in a helpers module beside
the output, which the generated types name in custom({ function, source }).
Macroforge output gives a struct or enum Decode by default and a newtype no
derive. A macroforge output's default_derives = ["Default", "Encode", "Decode"]
replaces that default for every type with no derives of its own, whether from
the source or an output rule plugin. #[typesync(...)] adds to what the outputs
write for a type, struct variant or field:
macroforge(derives = [...]) adds to the type's @derive(...) and applies to
a struct, enum, newtype or struct variant. macroforge(attributes = [...]) writes each
entry as the JSDoc annotation Macroforge reads: hidden as /** @hidden */,
endec(rename = "joined_at") as /** @endec({ rename: "joined_at" }) */, with
each key in camelCase, a bare key true and a nested list an object. Values
are string, number and boolean literals or arrays of them. annotation("...")
writes its text as /** ... */ unchanged. Validation, enum tagging, and
foreign-type format annotations use @endec; a foreign type's endec_format
option supplies its format annotation. Rust field names and enum representations
still follow their Rust serde attributes.
The scan reads source text and finds types by their derive, so the types
still derive Evenframe (or Typesync, or an apply_aliases attribute that
includes it), and the crate depends on evenframe for the derive as well. The
scan uses none of the derive's output, so that dependency needs no features.
Why a build script does not sync the database
A build script generates files and stops there. Applying schema changes to a
database stays a deliberate step, through the CLI or the schemasync
library API, because a build script is the wrong place for it:
- Build scripts run far more often than the builds you mean to make:
rust-analyzer runs them in the background as you edit, and so do
cargo check, clippy and CI. Each run would change the live database. - Every build would need the database reachable and its credentials in the build environment, so an offline machine or a CI job without the database would fail to compile.
- Schemasync is async and needs the full SurrealDB client, which would compile into the build dependencies.
- A build script cannot ask before a destructive change, as
evenframe schemasync applydoes.
What a build script can do safely is write the schema down. With the
build-schemadump feature, evenframe::build::schemadump() writes the
SurrealQL schemasync would apply to .evenframe/surql/schema.surql, the file
evenframe schemasync dump writes, without connecting to a database or
pulling in its client. It needs the [schemasync] section of
evenframe.toml, whose connection settings may reference variables a build
does not set. build-fullstack enables both build-script features:
// build.rs
3. The CLI: schemasync, typesync, or both
The CLI drives the whole pipeline from evenframe.toml: generating types,
synchronizing the database schema, writing mock data, and running plugins.
| Command | What it does |
|---|---|
evenframe typesync |
Writes every configured TypeScript and schema output (--formats and --skip pick kinds). |
evenframe schemasync |
Brings the database schema in line with the Rust types, then writes mock data. |
evenframe schemasync diff |
Shows the schema changes without applying them. |
evenframe schemasync dump |
Writes the resolved SurrealQL to a file, with no database connection. |
evenframe generate |
Runs typesync and schemasync together (--skip-typesync, --skip-schemasync, --no-mocks). |
evenframe mockmake --count 500 |
Inserts mock records from the scan cache, without rescanning the sources. |
evenframe validate |
Checks the configuration and the detected types. |
evenframe check |
Exits non-zero when the scan cache no longer matches the sources, for CI. |
evenframe info |
Lists the detected types and configuration. |
evenframe test-plugin |
Runs an output rule plugin against the project and prints what it produces. |
evenframe expand |
Manages the macro expansion cache used for types that macros generate. |
A project that only syncs its database needs only a [schemasync] section,
and one that only generates types needs only [typesync]:
[]
= true
[]
= "surrealdb"
= "${SURREALDB_URL:-http://localhost:8000}"
= "${SURREALDB_NS}"
= "${SURREALDB_DB}"
= 60
[]
= 100
[]
= [{ = "arktype", = "./web/src/generated" }]
The configuration is every .evenframe/config.toml or evenframe.toml from
the current directory upward, merged with the nearer file winning: tables
merge key by key, and any other value, a list included, replaces the outer
one. A bare config in a subdirectory therefore inherits everything it leaves
out. Each file resolves on its own before the merge: its relative paths are
relative to its own project root, and its values can reference environment
variables as ${VAR} or ${VAR:-default}, read from its own .env
(general.env_path, by default .env in its project root). A variable a
nearer file's .env sets wins over an outer one's.
Workspaces
A config that lists projects makes its directory a workspace. Each project
keeps its own config (its sources, plugins and database) and inherits the
workspace's, so shared settings such as foreign_types and [typesync] are
written once:
# evenframe.toml at the workspace root
[]
= ["app/backend", "auth/backend"]
[]
= { = "macroforge", = "./web/src/generated" }
# auth/backend/evenframe.toml: the app's address type is read, not emitted
[]
= [{ = "../../app/backend/src/address.rs", = true }]
[]
= "${SURREALDB_URL}"
= "${SURREALDB_NS}"
= "auth"
Run from the workspace root, a command covers every project; run from inside
a project, schemasync and mockmake cover that project alone. Typesync always
scans every project and writes one output, so a type two projects share is
generated once: a project that only resolves it (resolve_only) defers to
the one that emits it, and two projects that both emit it must define it
alike. A file a project's crate holds but another project owns is left out of
the project's scan with exclude_files. projects is never inherited, and a
workspace cannot contain another.
Schemasync compares the types with the live database and applies only what changed, one transaction per table. Existing records are kept, trimmed or regenerated to each table's mock count, and links between records stay valid.
A field's #[format(...)] shapes its mock values: a Format such as
#[format(Email)] or #[format(Url("example.com"))] generates values its
pattern matches, and a duration field takes a range in steps,
#[format(duration_ns(min = "PT1H", max = "PT5H", step = "PT15M"))]. The
bounds are ISO 8601 durations of a fixed length, so years and months are
refused, and the step must divide the range.
Plugins
Plugins are WebAssembly modules, written in Rust with the evenframe_plugin
crate:
- Output rule plugins (
[general.output_rule_plugins]) see every type and change how it is generated: annotations, derives, permissions, events and field rules. - Synthetic item plugins (
[general.synthetic_item_plugins]) add new structs, enums and tables derived from the scanned types. - Mock data plugins (
[schemasync.plugins]) generate values for a table's fields; a table opts in with#[mock_data(plugin = "name")].
[]
= { = "plugins/conventions.wasm" }
[]
= { = "plugins/people.wasm", = { = "en" } }
Field names
TypeScript fields default to camelCase. Explicit naming takes precedence:
field #[evenframe(ts_name = "snake_case")], then container
#[evenframe(all_ts_names = "snake_case")], then serde's rename, rename_all
or rename_all_fields. Evenframe casing rules convert the Rust field name.
Supported styles are lowercase, UPPERCASE, PascalCase, camelCase,
snake_case, SCREAMING_SNAKE_CASE, kebab-case and SCREAMING-KEBAB-CASE.
An all_ts_names on an enum or a named variant applies to its payload fields,
never its variant names.
Set ts_names = "respect_serde" under [typesync] to use serde's exact names
for fields without an Evenframe override, including unchanged Rust names.
The other accepted value is "default", which is selected when omitted.
Enum variant names and tag/content keys always follow serde. Serde skip
omits fields and variants, and skip_serializing_if makes a key optional.
TypeScript naming does not change Rust serialization or database naming.
Database storage
The database stores the shape serde writes unless a #[surreal(...)] key says
otherwise. Key names are the exception: a stored key is the Rust name unless
#[surreal(rename)] or #[surreal(rename_all)] changes it, whatever serde
renames it to. Every key the SurrealDB SDK's SurrealValue derive defines is
accepted where it applies, and the schema, mock data and the derived
SurrealValue all follow it:
- On a field:
rename,default,wrap(stored as serde writes it, typedany),flatten, and evenframe'sskip, which leaves a field serde writes out of the database. - On a container:
rename,rename_all,default;tupleon a tuple struct stores it as an array;valueon a unit struct stores that literal. - On an enum:
untagged,tag,content,rename_all(or the legacyuppercaseandlowercase),skip_contentandskip_content_if. - On a variant:
rename,rename_all,default,tuple,valueon an untagged unit variant,otherfor the unit variant that reads any tag no variant names, andskip_contentorskip_content_ifon an adjacently tagged one.
A key outside these, or one used where it does not apply, is a compile error, as are the combinations the SDK refuses.
rename_all names keys as the SDK does, with one difference: the SDK cases
names with heck 0.4, which splits words at every non-ASCII character and drops
it, while evenframe keeps letters of any script. Under every casing but
lowercase and UPPERCASE, a field größe is stored as größe where the
SDK writes gr_e, and a variant ÜberCool as über_cool where it writes
ber_cool. ASCII names are stored identically
(evenframe_derive/tests/surreal_casing.rs).
A field serde skips is not stored, and a variant serde skips is stored as
NONE, unless the field or variant has a #[surreal(...)] attribute or
#[schemasync(retain)], which on a container keeps every field and variant
serde skips. serde writes a unit struct and an untagged unit variant as null,
which is how they are stored. A field with #[serde(with)],
serialize_with or deserialize_with is stored as that module writes it and
typed any. An internally tagged newtype variant stores its tag beside the
keys of the struct it holds; a payload that is not a struct of named fields is
a compile error, where serde would fail when writing it.
Foreign types
A type from another crate, such as chrono::DateTime or
rust_decimal::Decimal, is described once under [general.foreign_types]:
its database type, its type in each output with the import it needs, its
schema default, and how mock data generates it.
[]
= ["DateTime", "chrono::DateTime"]
= true
= "datetime"
= { = "'string.date.iso'" }
= { = "Schema.DateTimeUtc", = "string" }
= "time::now()"
= "datetime"
std::time::Duration needs no entry: it is a SurrealDB duration in the
schema and serde's { secs, nanos } in every TypeScript output.
Record links
A RecordLink<T> field holds the linked record's id, the SurrealDB SDK's
RecordId, or the record itself where a query fetched it. It needs the
surrealdb-types feature. Each TypeScript output writes the id half as the
project's RecordId foreign type says, so a project with record links maps
it, for example to a codec for the JavaScript SDK's RecordId (the
testground's tooling/testground/src/record-id.ts is one):
[]
= ["RecordId"]
= "record"
= { = "RecordIdCodec.ark", = { = "../record-id.ts", = "RecordIdCodec", = false } }
= { = "RecordIdCodec.schema", = "Schema.Schema.Encoded<typeof RecordIdCodec.schema>", = { = "../record-id.ts", = "RecordIdCodec", = false } }
= { = "RecordIdEncoded", = { = "../record-id.ts", = "RecordIdEncoded" } }
A RecordLink entry instead replaces the whole link in the outputs it maps,
with {0} for the linked type.
Cargo features
evenframe with no features builds only what the derive needs.
| Feature | Adds |
|---|---|
metadata |
Each derived type's config functions and the registry that finds them by name. |
surrealdb-types |
RecordLink, holding the SurrealDB SDK's RecordId, without the SDK's client. |
typesync |
Shared type-generation infrastructure, with no output generator selected. |
arktype |
The ArkType generator. Enables typesync. |
effect |
The Effect generator. Enables typesync. |
macroforge, protobuf, flatbuffers |
Those generators. typesync-all enables every generator. |
build-typesync |
Workspace scanning and build::typesync() for build scripts. Implies typesync. |
build-schemadump |
Workspace scanning and build::schemadump() for build scripts, with no database client. |
build-fullstack |
Both build-script features. |
schemasync |
Schema comparison and synchronization against SurrealDB. |
mockmake |
Mock data generation. Implies schemasync. |
wasm-plugins |
The plugin runtime. |
cli (default) |
Everything the evenframe binary needs. |
full |
Every feature. |
Upgrading to 0.6
- Transforms are morphs:
trim,lower,upper,capitalizeand thenormalizeforms move from#[validators(...)]to#[morphs(...)], which run before every validator rather than in the order written among them. TheStringValidator::Trim-style path forms are gone. - The parse validators are types:
integer_parseandnumeric_parsebecome aFromText<T>field,url_parseaFromText<url::Url>,date_iso_parseanIsoDate,date_epoch_parseanEpochMillis, andjson_parseaJsonText<T>. The loosedate_parseis gone, sinceDate.parse's formats do not round-trip. A text form's parse error is serde's, reported as the read stops, rather than collected with the validators' failures. #[validators(...)]on a struct of named fields, an enum or a tuple struct of several fields is refused: put it on the field, tuple element or newtype it checks.starts_with,ends_withandincludestake aTextPattern, text or a format, so a path form such asStringValidator::Includes("x".to_owned())becomesStringValidator::Includes("x".into()). An invalid format is described by name in error messages rather than by its Rust debug form.- The ArkType output no longer writes a
default{Type}object beside each struct, and a foreign type'sdefault_value_tsis gone: remove it from[general.foreign_types], which refuses unknown keys. #[format(AppointmentDurationNs)]is#[format(duration_ns(min = "PT1H", max = "PT5H", step = "PT15M"))], and a field'sformatis aMockFormat, which wraps aFormatinMockFormat::Format.StringValidator::NormalizeNFCPreformattedand its NFD, NFKC and NFKD twins areNormalizeNfcPreformattedand so on, andNumberValidator::NonNaNisNonNan, whose attribute name isnon_nanrather thannon_na_n.
License
MIT
Documentation website
The SvelteKit documentation site lives in website/. It uses Deno,
Biome, and shadcn-svelte and deploys to Cloudflare Workers Static Assets. See
website/README.md for development and deployment commands.