You are reading the docs for rws 18.33 The latest release is 18.34. Read this page for the latest release.

Keys, timestamps, soft delete and locking

String and UUID primary keys, created_at/updated_at, soft delete with restore, and optimistic locking with

Since 18.30.0 #[derive(Model)] and the repository handle four things apps otherwise write by hand: keys that aren't auto-increment integers, created_at / updated_at, soft delete, and optimistic locking. Each is opt-in per model, and none adds a callback or a lazy load: the repository writes the columns in the same statements it already runs.

use rust_web_server::model::{Timestamp, Uuid};
use rust_web_server::Model;

#[derive(Model, Debug, Clone)]
#[table(name = "articles")]
#[timestamps]   // created_at, updated_at
#[soft_delete]  // deleted_at
pub struct Article {
    #[primary_key]
    pub id: Uuid,                       // Uuid::NIL → save picks a fresh v7
    pub title: String,
    #[version]
    pub lock_version: i64,              // optimistic locking
    pub created_at: Timestamp,
    pub updated_at: Timestamp,
    pub deleted_at: Option<Timestamp>,
}
-- migrations/0001_create_articles.sql (PostgreSQL)
CREATE TABLE articles (
    id           TEXT PRIMARY KEY,
    title        TEXT NOT NULL,
    lock_version BIGINT NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL,
    updated_at   TIMESTAMPTZ NOT NULL,
    deleted_at   TIMESTAMPTZ
);

The fields keep their names; #[column(name = "…")] on them still renames the column. A missing field is a compile error (#[timestamps] needs a mapped field named updated_at), and so is a deleted_at that isn't an Option.

Primary keys

ModelRepository<T, ID> implements Repository<T, ID> for ID = i64, i32, String and Uuid. T::repository(&pool) is keyed by i64 for an integer key of any width (as before 18.30), and by the field's type otherwise; for an i32 key, ModelRepository::<T, i32>::new(&pool) takes i32 ids.

What save does:

Key save
#[primary_key(auto_increment)], value 0 INSERT; the database picks the key
#[primary_key(auto_increment)], any other value UPDATE that row
#[primary_key] (any type) looks the key up (soft-deleted rows included): UPDATE if the row is there, INSERT otherwise
Uuid::NIL (Uuid::default()) INSERT with a new Uuid::now_v7()
empty String an error

The look-up and the write are two statements, so two processes saving the same new key at the same moment can both insert, and the second gets the database's unique-key error. repo.insert(&entity) always inserts.

let tags = Tag::repository(&pool);           // ModelRepository<Tag, String>
tags.save(&Tag { slug: "rust".into(), label: "Rust".into() }).await?;      // INSERT
tags.save(&Tag { slug: "rust".into(), label: "Rust lang".into() }).await?; // UPDATE
let rust = tags.find_by_id("rust".to_string()).await?;

Uuid is rws's own 16-byte type (not the uuid crate): Uuid::now_v7() (time-ordered, so new rows go to the end of the index), Uuid::new_v4(), Uuid::parse(text), Display as the hyphenated form, serde as a string. It is stored as text: use a TEXT, CHAR(36) or VARCHAR(36) column. PostgreSQL UUID columns are read, but rws binds text, which PostgreSQL won't compare with a UUID column without a cast.

Timestamps

With #[timestamps], save sets created_at and updated_at to the current UTC time on insert, and updated_at on every update; created_at is never written again, whatever the entity holds. QueryBuilder::update sets updated_at too. The fields are Timestamp (microseconds since the Unix epoch; Timestamp::now(), parse, to_rfc3339, serde as RFC 3339) — or String, which gets the stored text.

Backend Column type What rws writes
SQLite TEXT 2026-11-04T12:34:56.123456Z (RFC 3339, sorts as text)
PostgreSQL TIMESTAMPTZ (or TIMESTAMP) the same text, CAST(… AS TIMESTAMPTZ)
MySQL DATETIME(6) 2026-11-04 12:34:56.123456 (UTC, no zone)

Reading gives the same RFC 3339 text on every backend (rws decodes PostgreSQL TIMESTAMPTZ/TIMESTAMP/DATE and MySQL DATETIME/TIMESTAMP/DATE itself), so Timestamp fields round-trip. MySQL TIMESTAMP columns convert to the session time zone; use DATETIME(6), which stores what it is given.

In your own queries, bind a Timestamp with ts.to_value(pool.backend()) — the MySQL form differs.

Soft delete

With #[soft_delete] and a deleted_at: Option<Timestamp> field:

  • repo.delete_by_id(id) and QueryBuilder::delete() set deleted_at instead of deleting (a row deleted already keeps its time);
  • find_by_id, find_all, count, exists_by_id, every QueryBuilder operation and HasMany / HasOne / BelongsTo leave out rows with deleted_at set;
  • QueryBuilder::with_deleted() includes them, only_deleted() lists only them;
  • repo.restore(id) (true when the row was deleted) and QueryBuilder::restore() clear deleted_at;
  • repo.force_delete_by_id(id) and QueryBuilder::force_delete() delete for real;
  • repo.find_by_id_with_deleted(id) reads a row either way.
let repo = Article::repository(&pool);
repo.delete_by_id(article.id).await?;                     // soft
assert!(repo.find_by_id(article.id).await?.is_none());
let trash = Article::query(&pool).only_deleted().fetch_all().await?;
repo.restore(article.id).await?;
Article::query(&pool).only_deleted().force_delete().await?; // empty the trash

Raw SQL (pool.query, pool.execute) sees every row; add WHERE deleted_at IS NULL yourself.

Optimistic locking

A #[version] integer field is 1 after the insert and one more after each update. save updates only while the row still has the version the entity was read with (UPDATE … SET …, lock_version = lock_version + 1 WHERE id = ? AND lock_version = ?); otherwise nothing is written and it fails with DbErrorKind::StaleVersion:

use rust_web_server::model::DbErrorKind;

match repo.save(&edited).await {
    Ok(saved) => saved,
    Err(e) if e.kind() == DbErrorKind::StaleVersion => {
        // Someone saved it since `edited` was read: reload and apply the change again,
        // or answer 409 Conflict.
        return conflict(&e.to_string());
    }
    Err(e) => return Err(e.into()),
}

A row that is gone gives DbErrorKind::NotFound instead. QueryBuilder::update increments the version as well (it doesn't check it), so copies read before a bulk update are stale afterwards.

Tested on every backend

The model tests run on SQLite, PostgreSQL 16 and MySQL 8.4 in CI (the model-databases job). To run them against your own servers, set RWS_TEST_POSTGRES_URL / RWS_TEST_MYSQL_URL — each test makes a schema (PostgreSQL) or database (MySQL) of its own and drops it, so the MySQL user needs CREATE and DROP:

RWS_TEST_POSTGRES_URL=postgres://rws:rws@127.0.0.1/rws_test \
RWS_TEST_MYSQL_URL=mysql://root:rws@127.0.0.1/rws_test \
cargo test -p rws-server --features "http2 test-support macros model-sqlite model-postgres model-mysql" --lib -- model

Edit this page on GitHub