cufflink-types 0.11.0

Shared type definitions for the Cufflink CRUD microservice platform
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableDefinition {
    pub name: String,
    pub columns: Vec<ColumnDefinition>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub indexes: Vec<IndexDefinition>,
    #[serde(default)]
    pub soft_delete: bool,
    /// Column name that stores the owner's user ID for row-level security.
    /// When set, CRUD operations are scoped to the authenticated user's records.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner_field: Option<String>,
    /// When true, all CRUD operations on this table require authentication.
    /// Unauthenticated requests receive a 401 Unauthorized response.
    #[serde(default)]
    pub auth_required: bool,
    /// Permission area that controls access to this table's CRUD operations.
    /// When set, platform checks "{area}:view" for GET, "{area}:create" for POST,
    /// "{area}:edit" for PUT, "{area}:delete" for DELETE.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub permission_area: Option<String>,
    /// Self-service ownership: when true (requires `owner_field`), owning a row
    /// authorizes access to it — the row-level scoping by `owner_field` IS the
    /// authorization, so the `permission_area` grant is NOT required for these
    /// (inherently owner-scoped) CRUD operations. Use for user-owned data a user
    /// manages themselves (their items, addresses, orders). Defaults false, so
    /// existing tables keep requiring `permission_area`; cross-owner/staff access
    /// stays gated by RBAC (typically via dedicated handlers, not owner-scoped CRUD).
    #[serde(default)]
    pub owner_self_service: bool,
    /// CRUD lifecycle hooks — WASM handler names called before/after operations.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hooks: Option<TableHooks>,
    /// Scoped permissions: the permission area whose grants' scopes
    /// (`@own`, `@field(column=claim)`, `@all`) select the rows a caller
    /// reaches. Requires `owner_field` (the `@own` column); `permission_area`
    /// defaults to it. Without it the table behaves as before scopes existed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scoped_by: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnDefinition {
    pub name: String,
    pub column_type: ColumnType,
    #[serde(default)]
    pub primary_key: bool,
    #[serde(default)]
    pub nullable: bool,
    #[serde(default)]
    pub auto_generate: bool,
    pub default_value: Option<String>,
    /// Foreign key reference in "table.column" format
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub references: Option<String>,
    /// Whether to cascade deletes on the FK
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub on_delete: Option<ForeignKeyAction>,
    /// Whether this column has a unique constraint
    #[serde(default)]
    pub unique: bool,
    /// Validation rules for this column
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub validations: Vec<ValidationRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "rule", rename_all = "snake_case")]
// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
pub enum ValidationRule {
    /// Regex pattern match
    Regex { pattern: String },
    /// Minimum numeric value
    Min { value: f64 },
    /// Maximum numeric value
    Max { value: f64 },
    /// Minimum string length
    MinLength { value: usize },
    /// Maximum string length
    MaxLength { value: usize },
    /// Must be one of the listed values
    OneOf { values: Vec<String> },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IndexDefinition {
    pub name: String,
    pub columns: Vec<String>,
    #[serde(default)]
    pub unique: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ForeignKeyAction {
    Cascade,
    SetNull,
    Restrict,
    NoAction,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ColumnType {
    Uuid,
    Text,
    Integer,
    BigInteger,
    Float,
    Double,
    Boolean,
    Timestamp,
    Date,
    Jsonb,
}

impl ColumnType {
    pub fn to_sql(&self) -> &str {
        match self {
            ColumnType::Uuid => "UUID",
            ColumnType::Text => "TEXT",
            ColumnType::Integer => "INTEGER",
            ColumnType::BigInteger => "BIGINT",
            ColumnType::Float => "REAL",
            ColumnType::Double => "DOUBLE PRECISION",
            ColumnType::Boolean => "BOOLEAN",
            ColumnType::Timestamp => "TIMESTAMPTZ",
            ColumnType::Date => "DATE",
            ColumnType::Jsonb => "JSONB",
        }
    }

    /// Map to the filter column type for query param parsing
    pub fn filter_type(&self) -> &str {
        match self {
            ColumnType::Uuid => "uuid",
            ColumnType::Text => "text",
            ColumnType::Integer | ColumnType::BigInteger => "integer",
            ColumnType::Float | ColumnType::Double => "double",
            ColumnType::Boolean => "boolean",
            ColumnType::Timestamp => "timestamp",
            ColumnType::Date => "date",
            ColumnType::Jsonb => "jsonb",
        }
    }
}

/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TableHooks {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub before_create: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub after_create: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub before_update: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub after_update: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub before_delete: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub after_delete: Option<String>,
}

impl TableDefinition {
    /// Get the primary key column
    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
        self.columns.iter().find(|c| c.primary_key)
    }

    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
        self.columns
            .iter()
            .filter(|c| !c.auto_generate || !c.primary_key)
            .collect()
    }

    /// Platform features this table's declaration requires.
    pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
        self.scoped_by
            .is_some()
            .then_some(crate::features::SCOPED_PERMISSIONS)
            .into_iter()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::manifest::fixtures::sample_manifest;

    #[test]
    fn test_primary_key() {
        let manifest = sample_manifest();
        let table = manifest.get_table("todos").unwrap();
        let pk = table.primary_key().unwrap();
        assert_eq!(pk.name, "id");
        assert!(pk.auto_generate);
    }

    #[test]
    fn test_column_type_to_sql() {
        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
        assert_eq!(ColumnType::Date.to_sql(), "DATE");
        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
    }

    #[test]
    fn test_fk_not_serialized_when_none() {
        let col = ColumnDefinition {
            name: "title".into(),
            column_type: ColumnType::Text,
            primary_key: false,
            nullable: false,
            auto_generate: false,
            default_value: None,
            references: None,
            on_delete: None,
            unique: false,
            validations: vec![],
        };
        let json = serde_json::to_string(&col).unwrap();
        assert!(!json.contains("references"));
        assert!(!json.contains("on_delete"));
    }

    #[test]
    fn test_unique_column_serialization() {
        let col = ColumnDefinition {
            name: "email".into(),
            column_type: ColumnType::Text,
            primary_key: false,
            nullable: false,
            auto_generate: false,
            default_value: None,
            references: None,
            on_delete: None,
            unique: true,
            validations: vec![],
        };
        let json = serde_json::to_string(&col).unwrap();
        assert!(json.contains("\"unique\":true"));
        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
        assert!(parsed.unique);
    }

    #[test]
    fn test_index_serialization() {
        let table = TableDefinition {
            name: "users".into(),
            columns: vec![],
            indexes: vec![IndexDefinition {
                name: "idx_users_email".into(),
                columns: vec!["email".into()],
                unique: true,
            }],
            soft_delete: false,
            owner_field: None,
            auth_required: false,
            permission_area: None,
            owner_self_service: false,
            hooks: None,
            scoped_by: None,
        };
        let json = serde_json::to_string(&table).unwrap();
        assert!(json.contains("idx_users_email"));
        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.indexes.len(), 1);
        assert!(parsed.indexes[0].unique);
    }

    #[test]
    fn test_indexes_not_serialized_when_empty() {
        let table = TableDefinition {
            name: "users".into(),
            columns: vec![],
            indexes: vec![],
            soft_delete: false,
            owner_field: None,
            auth_required: false,
            permission_area: None,
            owner_self_service: false,
            hooks: None,
            scoped_by: None,
        };
        let json = serde_json::to_string(&table).unwrap();
        assert!(!json.contains("indexes"));
    }

    #[test]
    fn test_owner_field_serialization() {
        let table = TableDefinition {
            name: "notes".into(),
            columns: vec![],
            indexes: vec![],
            soft_delete: false,
            owner_field: Some("user_id".into()),
            auth_required: false,
            permission_area: None,
            owner_self_service: false,
            hooks: None,
            scoped_by: None,
        };
        let json = serde_json::to_string(&table).unwrap();
        assert!(json.contains("\"owner_field\":\"user_id\""));
        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
    }

    #[test]
    fn test_owner_field_not_serialized_when_none() {
        let table = TableDefinition {
            name: "notes".into(),
            columns: vec![],
            indexes: vec![],
            soft_delete: false,
            owner_field: None,
            auth_required: false,
            permission_area: None,
            owner_self_service: false,
            hooks: None,
            scoped_by: None,
        };
        let json = serde_json::to_string(&table).unwrap();
        assert!(!json.contains("owner_field"));
    }

    #[test]
    fn test_owner_field_defaults_to_none() {
        let json = r#"{"name":"notes","columns":[]}"#;
        let table: TableDefinition = serde_json::from_str(json).unwrap();
        assert!(table.owner_field.is_none());
    }

    #[test]
    fn test_auth_required_serialization() {
        let table = TableDefinition {
            name: "orders".into(),
            columns: vec![],
            indexes: vec![],
            soft_delete: false,
            owner_field: None,
            auth_required: true,
            permission_area: None,
            owner_self_service: false,
            hooks: None,
            scoped_by: None,
        };
        let json = serde_json::to_string(&table).unwrap();
        assert!(json.contains("\"auth_required\":true"));
        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
        assert!(parsed.auth_required);
    }

    #[test]
    fn test_auth_required_defaults_to_false() {
        let json = r#"{"name":"orders","columns":[]}"#;
        let table: TableDefinition = serde_json::from_str(json).unwrap();
        assert!(!table.auth_required);
    }

    #[test]
    fn scoped_by_is_omitted_when_absent_and_derives_its_feature() {
        let table: TableDefinition =
            serde_json::from_str(r#"{"name":"leads","columns":[],"owner_field":"user_id"}"#)
                .unwrap();
        assert!(table.scoped_by.is_none());
        assert!(!serde_json::to_string(&table).unwrap().contains("scoped_by"));
        assert_eq!(table.derived_features().count(), 0);

        let scoped = TableDefinition {
            scoped_by: Some("leads".into()),
            ..table
        };
        let json = serde_json::to_string(&scoped).unwrap();
        assert!(json.contains(r#""scoped_by":"leads""#), "{json}");
        assert_eq!(
            scoped.derived_features().collect::<Vec<_>>(),
            [crate::features::SCOPED_PERMISSIONS]
        );
    }
}