Skip to main content

cufflink_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// A service manifest describes the complete definition of a cufflink service.
5/// This is generated by the SDK macros from the user's Rust struct definitions.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ServiceManifest {
8    pub name: String,
9    pub version: Option<String>,
10    pub tables: Vec<TableDefinition>,
11    #[serde(default)]
12    pub cells: Vec<CellDefinition>,
13    #[serde(default)]
14    pub events: Vec<EventDefinition>,
15    #[serde(default)]
16    pub subscriptions: Vec<SubscriptionDefinition>,
17    #[serde(default)]
18    pub custom_routes: Vec<CustomRouteDefinition>,
19    #[serde(default)]
20    pub mode: ServiceMode,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
24#[serde(rename_all = "lowercase")]
25pub enum ServiceMode {
26    #[default]
27    Crud,
28    Wasm,
29    Container,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TableDefinition {
34    pub name: String,
35    pub columns: Vec<ColumnDefinition>,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub indexes: Vec<IndexDefinition>,
38    #[serde(default)]
39    pub soft_delete: bool,
40    /// Column name that stores the owner's user ID for row-level security.
41    /// When set, CRUD operations are scoped to the authenticated user's records.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub owner_field: Option<String>,
44    /// When true, all CRUD operations on this table require authentication.
45    /// Unauthenticated requests receive a 401 Unauthorized response.
46    #[serde(default)]
47    pub auth_required: bool,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ColumnDefinition {
52    pub name: String,
53    pub column_type: ColumnType,
54    #[serde(default)]
55    pub primary_key: bool,
56    #[serde(default)]
57    pub nullable: bool,
58    #[serde(default)]
59    pub auto_generate: bool,
60    pub default_value: Option<String>,
61    /// Foreign key reference in "table.column" format
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub references: Option<String>,
64    /// Whether to cascade deletes on the FK
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub on_delete: Option<ForeignKeyAction>,
67    /// Whether this column has a unique constraint
68    #[serde(default)]
69    pub unique: bool,
70    /// Validation rules for this column
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub validations: Vec<ValidationRule>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76#[serde(tag = "rule", rename_all = "snake_case")]
77pub enum ValidationRule {
78    /// Regex pattern match
79    Regex { pattern: String },
80    /// Minimum numeric value
81    Min { value: f64 },
82    /// Maximum numeric value
83    Max { value: f64 },
84    /// Minimum string length
85    MinLength { value: usize },
86    /// Maximum string length
87    MaxLength { value: usize },
88    /// Must be one of the listed values
89    OneOf { values: Vec<String> },
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct IndexDefinition {
94    pub name: String,
95    pub columns: Vec<String>,
96    #[serde(default)]
97    pub unique: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101#[serde(rename_all = "snake_case")]
102pub enum ForeignKeyAction {
103    Cascade,
104    SetNull,
105    Restrict,
106    NoAction,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110#[serde(rename_all = "lowercase")]
111pub enum ColumnType {
112    Uuid,
113    Text,
114    Integer,
115    BigInteger,
116    Float,
117    Double,
118    Boolean,
119    Timestamp,
120    Date,
121    Jsonb,
122}
123
124impl ColumnType {
125    pub fn to_sql(&self) -> &str {
126        match self {
127            ColumnType::Uuid => "UUID",
128            ColumnType::Text => "TEXT",
129            ColumnType::Integer => "INTEGER",
130            ColumnType::BigInteger => "BIGINT",
131            ColumnType::Float => "REAL",
132            ColumnType::Double => "DOUBLE PRECISION",
133            ColumnType::Boolean => "BOOLEAN",
134            ColumnType::Timestamp => "TIMESTAMPTZ",
135            ColumnType::Date => "DATE",
136            ColumnType::Jsonb => "JSONB",
137        }
138    }
139
140    /// Map to the filter column type for query param parsing
141    pub fn filter_type(&self) -> &str {
142        match self {
143            ColumnType::Uuid => "uuid",
144            ColumnType::Text => "text",
145            ColumnType::Integer | ColumnType::BigInteger => "integer",
146            ColumnType::Float | ColumnType::Double => "double",
147            ColumnType::Boolean => "boolean",
148            ColumnType::Timestamp => "timestamp",
149            ColumnType::Date => "date",
150            ColumnType::Jsonb => "jsonb",
151        }
152    }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CellDefinition {
157    pub name: String,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct EventDefinition {
162    pub name: String,
163    pub table: String,
164    pub action: EventAction,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum EventAction {
170    Create,
171    Update,
172    Delete,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SubscriptionDefinition {
177    pub subject: String,
178    pub handler: String,
179    pub handler_type: HandlerType,
180    #[serde(default)]
181    pub config: HashMap<String, String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum HandlerType {
187    DeleteCascade,
188    UpdateField,
189    Webhook,
190    Wasm,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CustomRouteDefinition {
195    pub method: String,
196    pub path: String,
197    pub handler: String,
198}
199
200impl ServiceManifest {
201    /// Get table definition by name
202    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
203        self.tables.iter().find(|t| t.name == name)
204    }
205
206    /// Compute a SHA-256 hash of the manifest for change detection
207    pub fn schema_hash(&self) -> String {
208        use sha2::{Digest, Sha256};
209        let json = serde_json::to_string(&self.tables).unwrap_or_default();
210        let mut hasher = Sha256::new();
211        hasher.update(json.as_bytes());
212        hex::encode(hasher.finalize())
213    }
214}
215
216impl TableDefinition {
217    /// Get the primary key column
218    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
219        self.columns.iter().find(|c| c.primary_key)
220    }
221
222    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
223    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
224        self.columns
225            .iter()
226            .filter(|c| !c.auto_generate || !c.primary_key)
227            .collect()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn sample_manifest() -> ServiceManifest {
236        ServiceManifest {
237            name: "test-service".into(),
238            version: Some("1.0.0".into()),
239            tables: vec![TableDefinition {
240                name: "todos".into(),
241                columns: vec![
242                    ColumnDefinition {
243                        name: "id".into(),
244                        column_type: ColumnType::Uuid,
245                        primary_key: true,
246                        nullable: false,
247                        auto_generate: true,
248                        default_value: None,
249                        references: None,
250                        on_delete: None,
251                        unique: false,
252                        validations: vec![],
253                    },
254                    ColumnDefinition {
255                        name: "title".into(),
256                        column_type: ColumnType::Text,
257                        primary_key: false,
258                        nullable: false,
259                        auto_generate: false,
260                        default_value: None,
261                        references: None,
262                        on_delete: None,
263                        unique: false,
264                        validations: vec![],
265                    },
266                    ColumnDefinition {
267                        name: "user_id".into(),
268                        column_type: ColumnType::Uuid,
269                        primary_key: false,
270                        nullable: false,
271                        auto_generate: false,
272                        default_value: None,
273                        references: Some("users.id".into()),
274                        on_delete: Some(ForeignKeyAction::Cascade),
275                        unique: false,
276                        validations: vec![],
277                    },
278                ],
279                indexes: vec![],
280                soft_delete: false,
281                owner_field: None,
282                auth_required: false,
283            }],
284            cells: vec![],
285            events: vec![],
286            subscriptions: vec![],
287            custom_routes: vec![],
288            mode: ServiceMode::Crud,
289        }
290    }
291
292    #[test]
293    fn test_manifest_serialization_roundtrip() {
294        let manifest = sample_manifest();
295        let json = serde_json::to_string(&manifest).unwrap();
296        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
297        assert_eq!(parsed.name, "test-service");
298        assert_eq!(parsed.tables.len(), 1);
299        assert_eq!(parsed.tables[0].columns.len(), 3);
300    }
301
302    #[test]
303    fn test_get_table() {
304        let manifest = sample_manifest();
305        assert!(manifest.get_table("todos").is_some());
306        assert!(manifest.get_table("nonexistent").is_none());
307    }
308
309    #[test]
310    fn test_primary_key() {
311        let manifest = sample_manifest();
312        let table = manifest.get_table("todos").unwrap();
313        let pk = table.primary_key().unwrap();
314        assert_eq!(pk.name, "id");
315        assert!(pk.auto_generate);
316    }
317
318    #[test]
319    fn test_schema_hash_deterministic() {
320        let m1 = sample_manifest();
321        let m2 = sample_manifest();
322        assert_eq!(m1.schema_hash(), m2.schema_hash());
323    }
324
325    #[test]
326    fn test_schema_hash_changes() {
327        let mut m1 = sample_manifest();
328        let m2 = sample_manifest();
329        m1.tables[0].columns.push(ColumnDefinition {
330            name: "extra".into(),
331            column_type: ColumnType::Text,
332            primary_key: false,
333            nullable: true,
334            auto_generate: false,
335            default_value: None,
336            references: None,
337            on_delete: None,
338            unique: false,
339            validations: vec![],
340        });
341        assert_ne!(m1.schema_hash(), m2.schema_hash());
342    }
343
344    #[test]
345    fn test_column_type_to_sql() {
346        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
347        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
348        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
349        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
350        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
351        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
352        assert_eq!(ColumnType::Date.to_sql(), "DATE");
353        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
354    }
355
356    #[test]
357    fn test_fk_serialization() {
358        let manifest = sample_manifest();
359        let json = serde_json::to_string(&manifest).unwrap();
360        assert!(json.contains("\"references\":\"users.id\""));
361        assert!(json.contains("\"on_delete\":\"cascade\""));
362
363        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
364        let user_id_col = &parsed.tables[0].columns[2];
365        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
366        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
367    }
368
369    #[test]
370    fn test_fk_not_serialized_when_none() {
371        let col = ColumnDefinition {
372            name: "title".into(),
373            column_type: ColumnType::Text,
374            primary_key: false,
375            nullable: false,
376            auto_generate: false,
377            default_value: None,
378            references: None,
379            on_delete: None,
380            unique: false,
381            validations: vec![],
382        };
383        let json = serde_json::to_string(&col).unwrap();
384        assert!(!json.contains("references"));
385        assert!(!json.contains("on_delete"));
386    }
387
388    #[test]
389    fn test_unique_column_serialization() {
390        let col = ColumnDefinition {
391            name: "email".into(),
392            column_type: ColumnType::Text,
393            primary_key: false,
394            nullable: false,
395            auto_generate: false,
396            default_value: None,
397            references: None,
398            on_delete: None,
399            unique: true,
400            validations: vec![],
401        };
402        let json = serde_json::to_string(&col).unwrap();
403        assert!(json.contains("\"unique\":true"));
404        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
405        assert!(parsed.unique);
406    }
407
408    #[test]
409    fn test_index_serialization() {
410        let table = TableDefinition {
411            name: "users".into(),
412            columns: vec![],
413            indexes: vec![IndexDefinition {
414                name: "idx_users_email".into(),
415                columns: vec!["email".into()],
416                unique: true,
417            }],
418            soft_delete: false,
419            owner_field: None,
420            auth_required: false,
421        };
422        let json = serde_json::to_string(&table).unwrap();
423        assert!(json.contains("idx_users_email"));
424        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
425        assert_eq!(parsed.indexes.len(), 1);
426        assert!(parsed.indexes[0].unique);
427    }
428
429    #[test]
430    fn test_indexes_not_serialized_when_empty() {
431        let table = TableDefinition {
432            name: "users".into(),
433            columns: vec![],
434            indexes: vec![],
435            soft_delete: false,
436            owner_field: None,
437            auth_required: false,
438        };
439        let json = serde_json::to_string(&table).unwrap();
440        assert!(!json.contains("indexes"));
441    }
442
443    #[test]
444    fn test_service_mode_default() {
445        let json = r#"{"name":"svc","tables":[]}"#;
446        let m: ServiceManifest = serde_json::from_str(json).unwrap();
447        assert_eq!(m.mode, ServiceMode::Crud);
448    }
449
450    #[test]
451    fn test_owner_field_serialization() {
452        let table = TableDefinition {
453            name: "notes".into(),
454            columns: vec![],
455            indexes: vec![],
456            soft_delete: false,
457            owner_field: Some("user_id".into()),
458            auth_required: false,
459        };
460        let json = serde_json::to_string(&table).unwrap();
461        assert!(json.contains("\"owner_field\":\"user_id\""));
462        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
463        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
464    }
465
466    #[test]
467    fn test_owner_field_not_serialized_when_none() {
468        let table = TableDefinition {
469            name: "notes".into(),
470            columns: vec![],
471            indexes: vec![],
472            soft_delete: false,
473            owner_field: None,
474            auth_required: false,
475        };
476        let json = serde_json::to_string(&table).unwrap();
477        assert!(!json.contains("owner_field"));
478    }
479
480    #[test]
481    fn test_owner_field_defaults_to_none() {
482        let json = r#"{"name":"notes","columns":[]}"#;
483        let table: TableDefinition = serde_json::from_str(json).unwrap();
484        assert!(table.owner_field.is_none());
485    }
486
487    #[test]
488    fn test_auth_required_serialization() {
489        let table = TableDefinition {
490            name: "orders".into(),
491            columns: vec![],
492            indexes: vec![],
493            soft_delete: false,
494            owner_field: None,
495            auth_required: true,
496        };
497        let json = serde_json::to_string(&table).unwrap();
498        assert!(json.contains("\"auth_required\":true"));
499        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
500        assert!(parsed.auth_required);
501    }
502
503    #[test]
504    fn test_auth_required_defaults_to_false() {
505        let json = r#"{"name":"orders","columns":[]}"#;
506        let table: TableDefinition = serde_json::from_str(json).unwrap();
507        assert!(!table.auth_required);
508    }
509}