varyk GitHub

Varyk 0.8.0: time, ids, and bytes

· by Vlad Mickevic

Varyk 0.8.0 is on crates.io. It is milestone 5c of the roadmap, the last part of the batteries for services. Milestone 5b4 made the golden path: a users API on a database is varyk init, varyk add http sql, one file, and varyk run away. What that API could not say well was when a user was made: a created_at had to be a string. Milestone 5c adds three built-in types, Time, Uuid, and Bytes, known without a declaration, as Error is.

The two official packages follow it. varyk-sql 0.3.0 drops sqlx's Any driver for the concrete SQLite, Postgres, and MySQL pools, so the three types are native columns. varyk-http 0.2.0 reads a Time or a Uuid from a route, and sends and receives bytes: request and response bodies, uploads, binary WebSocket messages, and the client's bodies. Both need Varyk 0.8, and Varyk 0.8 needs them, since a program and its packages share one varyk-std. With them, milestones 5b4 and 5c are done, and milestone 5's bar is met.

What is in it

Time. One point in time in UTC, to the microsecond, from the year 0000 to the year 9999. Time::now() reads the clock. Time::from_iso(text) and text.parse() read RFC 3339 text, 2026-10-07T12:00:00Z, or one with an offset, which is taken to UTC; Time::from_unix(seconds) and Time::from_unix_micros(n) read a Unix time. to_iso, to_unix, and to_unix_micros go the other way, and {} prints what to_iso writes. t.add_seconds(n) and t.seconds_since(u) do the arithmetic, in whole seconds; + on a Time is an error. Times compare with ==, <, and the rest, a later time being the greater, and a Vec<Time> sorts, earliest first. Microseconds are what a Postgres timestamptz and a MySQL datetime(6) hold, so a time read back from the database equals the one written. No call of Time stops the program: each one that can leave the range, add_seconds among them, gives a Result.

Uuid. A 128-bit id. Uuid::new() makes a version 7, time-ordered, which keeps a primary key index compact; Uuid::v7() is the same, and Uuid::v4() makes a random one. A version 7 id holds the millisecond it was made in, readable by anyone who has the id, so a service that publishes ids and does not want their creation times known makes them with Uuid::v4(). text.parse() reads the 36-character form with hyphens, in either case, and {} writes it in lower case. Ids compare with == and !=, and a Uuid is a HashMap key; ids have no order.

Bytes. An immutable run of bytes: like a struct, a parameter borrows it, a field owns it, and let c = b moves it. Bytes::from_text and Bytes::from_base64 make one, to_text and to_base64 read it, and len and is_empty measure it. .clone() gives a second Bytes that shares the first one's buffer, with no byte copied. Bytes have no one printed form, so {} on a Bytes is an error whose note names to_text and to_base64, and the Err of from_base64 or to_text says what is wrong without repeating the bytes, which can be a whole request body.

Where they go. All three go through JSON, where a Time is RFC 3339 text, a Uuid its text with hyphens, and a Bytes standard base64 with padding; through assert_eq, and == and .clone() on a struct that holds them; as the values after a query; and in the signatures of a .rs facade. Time and Uuid also go through env::parse, parse, {}, and a route's path and query parameters, where text that does not read as the type is a 400 and the handler is not called. The compiler's new example, records, puts the three in one struct:

struct Upload {
    id: Uuid,
    at: Time,
    data: Bytes,
}

fn first_upload() -> Result<Upload, Error> {
    let id: Uuid = "0192F0C4-7A3E-7B5C-9D1E-2F3A4B5C6D7E".parse()?;
    let at = Time::from_iso("2026-10-07T12:00:00+02:00")?;
    Ok(Upload { id: id, at: at, data: Bytes::from_text("hello, world") })
}

// Writes `upload` as JSON, reads it back, and compares the two.
fn report(upload: Upload) -> Result<bool, Error> {
    println!("{} at {}", upload.id, upload.at);
    let expires = upload.at.add_seconds(3600)?;
    println!("expires at {}, {} seconds later", expires, expires.seconds_since(upload.at));
    let text = json::stringify(upload);
    println!("{}", text);
    let back: Upload = json::parse(text)?;
    Ok(back == upload)
}

The whole program, on the examples page, prints:

0192f0c4-7a3e-7b5c-9d1e-2f3a4b5c6d7e at 2026-10-07T10:00:00Z
expires at 2026-10-07T11:00:00Z, 3600 seconds later
{"id":"0192f0c4-7a3e-7b5c-9d1e-2f3a4b5c6d7e","at":"2026-10-07T10:00:00Z","data":"aGVsbG8sIHdvcmxk"}
read back the same: true
hello, world
aGVsbG8sIHdvcmxk
a new upload has its own id: true
`42` is not a Uuid like 01890a5d-ac96-774b-bcce-b302099a8057
`2026-10-07 12:00` is not a time like 2026-10-07T12:00:00Z

Facades. A .rs function, method, or pub field names the three by their full paths: varyk_std::Time and varyk_std::Uuid, copied as numbers are, &varyk_std::Bytes for bytes it only reads, and varyk_std::Bytes for bytes it takes. An imported enum may hold them in its variants, which is how varyk-http's WebSocket message carries a binary message.

There is no new diagnostic code: the existing ones gain cases, such as V0113 for a type named Time, V0200 for < on a Uuid, V0203 for {} on a Bytes, and V0219 for a Bytes path parameter. The reference has every call, and the error codes the full table.

varyk-sql 0.3.0

varyk-sql 0.3.0 is on crates.io. sqlx's Any driver, which served the three databases through one pool, has no date or uuid kind, so 0.3 holds one of the concrete pools, chosen from the URL's scheme as before, each behind its cargo feature. connect, Pool, Tx, and their calls are unchanged. The README's first service now stores a time:

struct User {
    id: i64,
    name: string,
    created_at: Time,
}

async fn load() -> Result<Vec<User>, Error> {
    let config: Config = env::parse()?;
    let db = sql::connect(config.database_url).await?;
    db.migrate("migrations").await?;
    db.run("insert into users (name, created_at) values (?, ?)", "Ada", Time::now()).await?;
    db.all("select id, name, created_at from users").await
}

Config reads DATABASE_URL from the environment, and varyk run prints 1 Ada and the time the row was added. A Time, a Uuid, and a Bytes each go into a column of their own, and back, with no cast in the query:

DatabaseTimeUuidBytes
Postgrestimestamptzuuidbytea
MySQLdatetime(6)char(36)blob
SQLitetexttextblob

SQLite has no time type, so varyk-sql writes a Time there as text of a fixed width, always with six fraction digits, so that order by and < in SQL put times in the order Varyk does. A Uuid is its 36-character text on MySQL and SQLite, readable in a SQL shell as in JSON. A Postgres time is read from its raw microseconds, so infinity in a column is an Error, not a crash. On Postgres:

// Postgres; SQLite takes `$1` as well as `?`
let id: i64 = db.one("insert into users (name, created_at) values ($1, $2) returning id", name, Time::now()).await?;

Two things a program had to write go. A None on Postgres is now a NULL with no type, which takes its type from where the parameter is first used, so set done = $1 needs no cast; only a parameter first used where nothing gives it a type, as in $1 is null, still needs one. And MySQL's unsigned integers, tinyint, mediumint, and boolean columns read with no cast; under Any, an unsigned value at or above 2^31 in an int unsigned read back as a negative number. A field that refuses a column says so by the column's name, never by its value. The README's column types have the rest.

varyk-http 0.2.0

varyk-http 0.2.0 is on crates.io. Its first service, and its users demo, store created_at as a Time:

async fn create_user(user: NewUser, state: Shared<State>) -> Result<http::Response, Error> {
    if user.name.is_empty() {
        return Err(http::bad_request("a user needs a name"));
    }
    let created_at = Time::now();
    let id: i64 = state.db.one("insert into users (name, created_at) values (?, ?) returning id", user.name, created_at).await?;
    let mut r = http::Response::json(User { id: id, name: user.name.clone(), created_at: created_at });
    r.set_status(201);
    r.set_header("location", format!("/users/{}", id));
    Ok(r)
}

with created_at: Time on User and created_at text not null in the migration. Every read gives the time back:

curl -i -H 'x-api-key: dev-key' -d '{"name":"Ada"}' 127.0.0.1:3000/users
# 201, location: /users/1,
# {"id":1,"name":"Ada","created_at":"2026-10-07T12:00:00.123456Z"}
curl -H 'x-api-key: dev-key' 127.0.0.1:3000/users/1
# {"id":1,"name":"Ada","created_at":"2026-10-07T12:00:00.123456Z"}
curl -H 'x-api-key: dev-key' 127.0.0.1:3000/users/2   # 404 {"error":"not found"}
curl 127.0.0.1:3000/users                             # 401
curl 127.0.0.1:3000/health                            # "ok"

Times and ids in routes. A path or query parameter may be a Time or a Uuid, and a query parameter an Option of one. A Time reads as Time::from_iso reads it, and a Uuid in its 36-character form; anything else is a 400 naming the parameter. In a query string a + reads as a space, as forms encode it, so an offset is sent as %2B: ?since=2026-10-07T12:00:00%2B02:00.

Bytes in and out. Each bytes call has a name of its own, beside the text call it matches, since Varyk has no overloading and the server's Response is also what the client gives back:

CallGives
req.body_bytes()the request body as it came, with no copy
http::Response::bytes(b, content_type)a 200 with the body b, that content-type, and x-content-type-options: nosniff
r.body_bytes()a response's body with no UTF-8 check, in a test or from the client
part.bytes().await, part.content_type()an uploaded part's content as it came, and the content type the client declared, not checked
ws.recv_message().await, ws.send_bytes(b).awaitWebSocket messages, text or binary
client.post_bytes(url, b, content_type), client.put_bytes(url, b, content_type)a request with b as its body
req.set_body_bytes(b)a body of bytes, for a request sent with app.request in a test

A content type is always written, so no binary body goes out without a declared type. A Bytes given to the JSON calls, or returned by a handler, is sent as a JSON string of base64, as any value is. ws.recv() stays text, and still closes the connection on a binary message; a handler that takes binary messages asks for them by name:

async fn echo(ws: http::WebSocket) -> Result<http::Response, Error> {
    while let Some(message) = ws.recv_message().await? {
        match message {
            http::live::Message::Text(text) => ws.send(text).await?,
            http::live::Message::Binary(b) => ws.send_bytes(b).await?,
        };
    }
    Ok(http::Response::empty())
}

The README's live connections and uploads section has the rest.

For Rust readers

In the generated Rust the three are ::varyk_std::Time, ::varyk_std::Uuid, and ::varyk_std::Bytes, held by varyk-std on the crates time, uuid, bytes, and base64. Time and Uuid are Copy and passed by value, as numbers are. varyk-std builds every Bytes in the bytes crate's shared form, so the compiler hands a Bytes over as a trailing value by adding one to a reference count, allocating nothing, and axum's request body reaches req.body_bytes() without a copy. The compiler still inserts exactly two allocations, a string literal placed into an owned slot and a string passed as a trailing value, and --emit-rust shows both.

What is not yet

From the language: a calendar date, a time of day, a duration type, time zones and local time, formatting a Time by a pattern, and a monotonic clock; ordering Uuids, the nil id, and other versions; indexing, slicing, or building Bytes piece by piece, and Bytes from a Vec<u8>; Bytes in env::parse; Time or Bytes as a map key; and a handler parameter bound to the raw body. From varyk-sql: numeric, json, date, time of day, interval, and array columns without a cast, a Uuid written as 16 bytes, and session time zones other than UTC. From varyk-http: a bytes body sent in pieces, a patch with a bytes body, and checking a part's declared content type against its content. The reference lists the language's cuts, and the open questions, such as whether Varyk should have a Date or a Duration, are in the compiler's open-questions.md.

Upgrading

The three crates, varyk, varyk-syntax, and varyk-std, are released together as 0.8.0, and two things in it are breaking. Time, Uuid, and Bytes are now reserved names, as Error is: a program that declares a type, an enum, or a module by one of them renames it, and varyk check reports V0113 until it does. And varyk_std::Value, the type of the values after a facade's other arguments, has three new variants, so a .rs facade that matches on a Value needs arms for them. After cargo install varyk --locked, run varyk check in a package, and it says what to change: the varyk-std line in Cargo.toml, or cargo update -p varyk-std.

A program that uses varyk-sql or varyk-http moves to their new releases at the same time: in Cargo.toml, change the version on the http line to "0.2.0" and on the sql line to "0.3.0". Running varyk add http sql again does not do it, since cargo add keeps the version of a dependency the manifest already lists. Until then, varyk check reports V0404, since varyk-sql 0.2 and varyk-http 0.1 need varyk-std 0.7. varyk-sql's calls are unchanged, and most of the casts 0.2 asked for are no longer needed.

varykvaryk-sqlvaryk-http
0.80.30.2
0.70.20.1

Try it

You need a stable Rust toolchain installed through rustup.

cargo install varyk --locked
varyk init hello
cd hello
varyk run

The getting started page goes from there to a first service on HTTP and a database, and the language reference describes everything the compiler accepts. The READMEs of varyk-http and varyk-sql are the full documentation of the packages.

What is next

Milestone 6 of the roadmap: tooling and beyond, varyk fmt, a language server, nested modules in .rs files, Rust tuple and unit structs imported from .rs files, Debug on imported Rust structs, and a decision on a native backend. Beside it, as their own work, varyk-mongo and varyk-redis, the next packages, and an agent evaluation: the examples written by a model from the language reference alone, with pass rates published, before any page claims that agents write Varyk well. Nothing there is a date.

Varyk is experimental and pre-1.0. Store a time, an id, or an upload with it, and tell us where it hurts: the issues are the place.