distributed 4.1.0

CQRS/ES framework for Rust using Plain Old Rust Structs — append-only events, replay, snapshots, outbox, service bus, and pluggable infrastructure
Documentation
use std::collections::HashMap;

use distributed::{
    ColumnType, ReadModel, RelationalReadModel, RelationshipKind, RowValue, TableStoreError,
    DEFAULT_TABLE_VERSION_COLUMN,
};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
#[table("account_summaries")]
struct AccountSummary {
    #[id("account_id")]
    account_id: String,
    #[index]
    owner: Option<String>,
    balance_cents: i64,
    #[readmodel(default = "0")]
    deposit_count: u32,
    #[readmodel(jsonb)]
    counters_by_game: HashMap<String, i64>,
    #[readmodel(skip_query)]
    projected_event_ids: Vec<String>,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)]
#[table("players")]
struct Player {
    #[id("player_id")]
    player_id: String,
    display_name: String,
    #[readmodel(has_many = "PlayerWeapon", foreign_key = "player_id")]
    weapons: Vec<PlayerWeapon>,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)]
#[table("player_weapons")]
#[readmodel(primary_key = ["player_id", "weapon_id"])]
struct PlayerWeapon {
    #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")]
    player_id: String,
    weapon_id: String,
    acquired_at: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
#[collection("direct_document_views")]
struct DirectDocumentView {
    #[id]
    id: String,
    value: i32,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
#[table("direct_table_views")]
#[index(
    name = "idx_direct_table_views_tenant_value",
    columns = ["tenant_id", "value"]
)]
#[unique(columns = ["tenant_id", "slug"])]
struct DirectTableView {
    #[id("direct_id")]
    id: String,
    tenant_id: String,
    slug: String,
    #[column("direct_value")]
    #[index("idx_direct_table_views_direct_value")]
    value: i32,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
#[table("binary_assets")]
struct BinaryAsset {
    #[id("asset_id")]
    id: String,
    payload: Vec<u8>,
    optional_payload: Option<Vec<u8>>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
struct Todos {
    #[id]
    todo_id: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
struct ChatMessages {
    #[id]
    message_id: String,
    #[index]
    posted_at: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
struct BlobGames {
    #[id]
    game_id: String,
    #[index]
    owner_id: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
struct CounterView {
    #[id]
    counter_id: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
#[collection("statuses")]
struct Status {
    #[id]
    status_id: String,
    #[index]
    label: String,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)]
#[table("buses")]
struct Bus {
    #[id]
    bus_id: String,
}

#[test]
fn derive_preserves_read_model_identity_for_table_models() {
    let summary = AccountSummary {
        account_id: "acct-1".into(),
        owner: Some("Ada".into()),
        balance_cents: 100,
        deposit_count: 1,
        counters_by_game: HashMap::new(),
        projected_event_ids: Vec::new(),
    };

    assert_eq!(AccountSummary::COLLECTION, "account_summaries");
    assert_eq!(summary.id(), "acct-1");
}

#[test]
fn derive_describes_columns_indexes_nullability_and_jsonb() {
    let schema = AccountSummary::schema();

    schema.validate().unwrap();
    assert_eq!(schema.table_name, "account_summaries");
    assert_eq!(schema.primary_key.columns, vec!["account_id"]);
    assert_eq!(
        schema.version_column.as_deref(),
        Some(DEFAULT_TABLE_VERSION_COLUMN)
    );

    let owner = schema
        .columns
        .iter()
        .find(|column| column.column_name == "owner")
        .unwrap();
    assert!(owner.nullable);

    let counters = schema
        .columns
        .iter()
        .find(|column| column.column_name == "counters_by_game")
        .unwrap();
    assert_eq!(counters.column_type, ColumnType::Json);
    assert!(counters.jsonb);

    assert_eq!(schema.indexes.len(), 1);
    assert_eq!(schema.indexes[0].columns, vec!["owner"]);

    let deposit_count = schema
        .columns
        .iter()
        .find(|column| column.column_name == "deposit_count")
        .unwrap();
    assert!(deposit_count.has_default);
    assert_eq!(deposit_count.default.as_deref(), Some("0"));
}

#[test]
fn row_conversion_round_trips_scalar_option_and_jsonb_fields() {
    let mut counters = HashMap::new();
    counters.insert("arena".to_string(), 3);
    let summary = AccountSummary {
        account_id: "acct-1".into(),
        owner: Some("Ada".into()),
        balance_cents: 2500,
        deposit_count: 2,
        counters_by_game: counters,
        projected_event_ids: Vec::new(),
    };

    let row = summary.to_row().unwrap();
    assert_eq!(
        row.get("account_id"),
        Some(&RowValue::String("acct-1".into()))
    );

    let round_trip = AccountSummary::from_row(row).unwrap();
    assert_eq!(round_trip, summary);
}

#[test]
fn row_conversion_keeps_binary_columns_as_bytes_values() {
    let asset = BinaryAsset {
        id: "asset-1".into(),
        payload: vec![0, 1, 255],
        optional_payload: Some(vec![2, 3, 5]),
    };

    let row = asset.to_row().unwrap();

    assert_eq!(row.get("payload"), Some(&RowValue::Bytes(vec![0, 1, 255])));
}

#[test]
fn row_conversion_keeps_optional_binary_columns_as_bytes_values() {
    let asset = BinaryAsset {
        id: "asset-1".into(),
        payload: vec![0, 1, 255],
        optional_payload: Some(vec![2, 3, 5]),
    };

    let row = asset.to_row().unwrap();

    assert_eq!(
        row.get("optional_payload"),
        Some(&RowValue::Bytes(vec![2, 3, 5]))
    );
}

#[test]
fn derive_represents_relationships_composite_keys_and_delegated_foreign_keys() {
    let player_schema = Player::schema();
    let weapon_schema = PlayerWeapon::schema();

    player_schema.validate().unwrap();
    weapon_schema.validate().unwrap();

    assert_eq!(player_schema.relationships.len(), 1);
    assert_eq!(
        player_schema.relationships[0].kind,
        RelationshipKind::HasMany
    );
    assert_eq!(player_schema.relationships[0].target_model, "PlayerWeapon");
    assert_eq!(
        player_schema.relationships[0].foreign_key.as_deref(),
        Some("player_id")
    );

    assert_eq!(
        weapon_schema.primary_key.columns,
        vec!["player_id", "weapon_id"]
    );
    let player_id = weapon_schema
        .columns
        .iter()
        .find(|column| column.column_name == "player_id")
        .unwrap();
    assert_eq!(
        player_id.delegated_from.as_deref(),
        Some("Player.player_id")
    );
    assert_eq!(player_id.foreign_key.as_ref().unwrap().table, "players");
}

#[test]
fn metadata_validation_reports_missing_keys_before_storage_writes() {
    #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)]
    #[table("missing_key_models")]
    struct MissingKeyModel {
        value: String,
    }

    let err = MissingKeyModel::schema().validate().unwrap_err();

    assert!(matches!(err, TableStoreError::Metadata(message) if message.contains("primary-key")));
}

#[test]
fn direct_collection_table_column_and_index_attributes_wrap_readmodel_metadata() {
    let direct = DirectDocumentView {
        id: "doc-1".into(),
        value: 7,
    };
    let schema = DirectTableView::schema();

    assert_eq!(DirectDocumentView::COLLECTION, "direct_document_views");
    assert_eq!(direct.id(), "doc-1");
    assert_eq!(DirectTableView::COLLECTION, "direct_table_views");
    assert_eq!(schema.table_name, "direct_table_views");
    assert_eq!(schema.primary_key.columns, vec!["direct_id"]);
    assert!(schema
        .columns
        .iter()
        .any(|column| column.field_name == "id" && column.column_name == "direct_id"));
    assert!(schema
        .columns
        .iter()
        .any(|column| column.field_name == "value" && column.column_name == "direct_value"));
    assert!(schema.indexes.iter().any(|index| {
        index.name.as_deref() == Some("idx_direct_table_views_direct_value")
            && index.columns == vec!["direct_value"]
            && !index.unique
    }));
    assert!(schema.indexes.iter().any(|index| {
        index.name.as_deref() == Some("idx_direct_table_views_tenant_value")
            && index.columns == vec!["tenant_id", "direct_value"]
            && !index.unique
    }));
    assert!(schema.indexes.iter().any(|index| {
        index.name.as_deref() == Some("uq_direct_table_views_tenant_id_slug")
            && index.columns == vec!["tenant_id", "slug"]
            && index.unique
    }));
}

#[test]
fn natural_plural_document_storage_name_is_inferred() {
    assert_eq!(Todos::COLLECTION, "todos");
}

#[test]
fn natural_plural_storage_name_is_shared_by_document_and_relational_metadata() {
    let schema = ChatMessages::schema();

    assert_eq!(
        (ChatMessages::COLLECTION, schema.table_name.as_str()),
        ("chat_messages", "chat_messages")
    );
}

#[test]
fn natural_plural_model_identity_is_not_tied_to_a_view_suffix() {
    let chat = ChatMessages::schema();
    let blob = BlobGames::schema();

    assert_eq!(
        (
            chat.model_name.as_str(),
            chat.table_name.as_str(),
            blob.model_name.as_str(),
            blob.table_name.as_str(),
        ),
        ("ChatMessages", "chat_messages", "BlobGames", "blob_games")
    );
}

#[test]
fn singular_model_keeps_the_existing_append_s_convention() {
    assert_eq!(CounterView::COLLECTION, "counter_views");
}

#[test]
fn explicit_overrides_disambiguate_singular_names_ending_in_s() {
    let status = Status::schema();
    let bus = Bus::schema();

    assert_eq!(
        (
            Status::COLLECTION,
            status.table_name.as_str(),
            Bus::COLLECTION,
            bus.table_name.as_str(),
        ),
        ("statuses", "statuses", "buses", "buses")
    );
}