eventdbx 3.18.4

Immutable, event-sourced, nosql, write-side database system.
Documentation
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::Value;

use crate::{
    error::{EventError, Result},
    schema::SchemaManager,
};

pub const MAX_AGGREGATE_ID_LENGTH: usize = 128;
pub const MAX_EVENT_PAYLOAD_BYTES: usize = 256 * 1024;
pub const MAX_EVENT_METADATA_BYTES: usize = 64 * 1024;

static SNAKE_CASE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("valid snake_case regex"));
static AGGREGATE_ID_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,127})?$").expect("valid aggregate_id regex")
});
static METADATA_KEY_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"^@[A-Za-z0-9][A-Za-z0-9_-]*$").expect("valid metadata key regex"));

pub fn ensure_snake_case(label: &str, value: &str) -> Result<()> {
    if SNAKE_CASE_RE.is_match(value) {
        Ok(())
    } else {
        Err(EventError::InvalidSchema(format!(
            "{label} must be lowercase snake_case"
        )))
    }
}

pub fn ensure_aggregate_id(value: &str) -> Result<()> {
    if value.trim() != value {
        return Err(EventError::InvalidSchema(
            "aggregate_id cannot include leading or trailing whitespace".into(),
        ));
    }
    if value.is_empty() {
        return Err(EventError::InvalidSchema(
            "aggregate_id must not be empty".into(),
        ));
    }
    if value.len() > MAX_AGGREGATE_ID_LENGTH {
        return Err(EventError::InvalidSchema(format!(
            "aggregate_id cannot exceed {} characters",
            MAX_AGGREGATE_ID_LENGTH
        )));
    }
    if !AGGREGATE_ID_RE.is_match(value) {
        return Err(EventError::InvalidSchema(
            "aggregate_id may only contain letters, numbers, underscores, or hyphens".into(),
        ));
    }
    Ok(())
}

pub fn ensure_payload_size(payload: &Value) -> Result<()> {
    let size = serde_json::to_vec(payload)
        .map_err(|err| EventError::Serialization(err.to_string()))?
        .len();
    if size > MAX_EVENT_PAYLOAD_BYTES {
        return Err(EventError::InvalidSchema(format!(
            "payload_json exceeds maximum size of {} bytes",
            MAX_EVENT_PAYLOAD_BYTES
        )));
    }
    Ok(())
}

pub fn ensure_first_event_rule(_is_new_aggregate: bool, _event_type: &str) -> Result<()> {
    Ok(())
}

pub fn ensure_schema_declared(schemas: &SchemaManager, aggregate_type: &str) -> Result<()> {
    match schemas.get(aggregate_type) {
        Ok(_) => Ok(()),
        Err(EventError::SchemaNotFound) => Err(EventError::SchemaViolation(
            missing_schema_message(aggregate_type),
        )),
        Err(err) => Err(err),
    }
}

pub fn missing_schema_message(aggregate: &str) -> String {
    format!(
        "aggregate {} requires a schema before events can be appended",
        aggregate
    )
}

pub fn ensure_metadata_extensions(metadata: &Value) -> Result<()> {
    let object = metadata
        .as_object()
        .ok_or_else(|| EventError::InvalidSchema("metadata must be a JSON object".into()))?;

    for key in object.keys() {
        if !METADATA_KEY_RE.is_match(key) {
            return Err(EventError::InvalidSchema(format!(
                "metadata key '{}' must start with '@' followed by letters, numbers, underscores, or hyphens",
                key
            )));
        }
    }

    let size = serde_json::to_vec(metadata)
        .map_err(|err| EventError::Serialization(err.to_string()))?
        .len();
    if size > MAX_EVENT_METADATA_BYTES {
        return Err(EventError::InvalidSchema(format!(
            "metadata exceeds maximum size of {} bytes",
            MAX_EVENT_METADATA_BYTES
        )));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn snake_case_validation_allows_valid_names() {
        ensure_snake_case("aggregate_type", "order_created").expect("valid snake case");
    }

    #[test]
    fn snake_case_validation_rejects_invalid_names() {
        let err = ensure_snake_case("event_type", "OrderCreated").unwrap_err();
        assert!(matches!(err, EventError::InvalidSchema(_)));
    }

    #[test]
    fn aggregate_id_validation_rejects_whitespace() {
        let err = ensure_aggregate_id(" bad").unwrap_err();
        assert!(matches!(err, EventError::InvalidSchema(_)));
    }

    #[test]
    fn payload_size_enforces_limit() {
        let oversized = "x".repeat(MAX_EVENT_PAYLOAD_BYTES + 1);
        let err = ensure_payload_size(&json!(oversized)).unwrap_err();
        assert!(matches!(err, EventError::InvalidSchema(_)));
    }

    #[test]
    fn metadata_extensions_validate_keys_and_size() {
        ensure_metadata_extensions(&json!({"@plugin": {"enabled": true}})).expect("valid metadata");

        let err = ensure_metadata_extensions(&json!({"plugin": {"enabled": true}})).unwrap_err();
        assert!(matches!(err, EventError::InvalidSchema(_)));

        let huge_value = "x".repeat(MAX_EVENT_METADATA_BYTES + 1);
        let err = ensure_metadata_extensions(&json!({"@plugin": huge_value})).unwrap_err();
        assert!(matches!(err, EventError::InvalidSchema(_)));
    }
}