Skip to main content

cufflink_types/
lib.rs

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