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    /// Optional WASM handler invoked between additive and destructive schema
27    /// phases of a deploy. Used to backfill or restructure data when columns
28    /// are split, renamed, or have their semantics changed. The handler must
29    /// be idempotent — it may be re-invoked on retried deploys.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub on_migrate: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
35#[serde(rename_all = "lowercase")]
36pub enum ServiceMode {
37    #[default]
38    Crud,
39    Wasm,
40    Container,
41    Web,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TableDefinition {
46    pub name: String,
47    pub columns: Vec<ColumnDefinition>,
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub indexes: Vec<IndexDefinition>,
50    #[serde(default)]
51    pub soft_delete: bool,
52    /// Column name that stores the owner's user ID for row-level security.
53    /// When set, CRUD operations are scoped to the authenticated user's records.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub owner_field: Option<String>,
56    /// When true, all CRUD operations on this table require authentication.
57    /// Unauthenticated requests receive a 401 Unauthorized response.
58    #[serde(default)]
59    pub auth_required: bool,
60    /// Permission area that controls access to this table's CRUD operations.
61    /// When set, platform checks "{area}:view" for GET, "{area}:create" for POST,
62    /// "{area}:edit" for PUT, "{area}:delete" for DELETE.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub permission_area: Option<String>,
65    /// CRUD lifecycle hooks — WASM handler names called before/after operations.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub hooks: Option<TableHooks>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ColumnDefinition {
72    pub name: String,
73    pub column_type: ColumnType,
74    #[serde(default)]
75    pub primary_key: bool,
76    #[serde(default)]
77    pub nullable: bool,
78    #[serde(default)]
79    pub auto_generate: bool,
80    pub default_value: Option<String>,
81    /// Foreign key reference in "table.column" format
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub references: Option<String>,
84    /// Whether to cascade deletes on the FK
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub on_delete: Option<ForeignKeyAction>,
87    /// Whether this column has a unique constraint
88    #[serde(default)]
89    pub unique: bool,
90    /// Validation rules for this column
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub validations: Vec<ValidationRule>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96#[serde(tag = "rule", rename_all = "snake_case")]
97// Note: ValidationRule contains f64 fields (Min/Max), so Eq cannot be derived
98pub enum ValidationRule {
99    /// Regex pattern match
100    Regex { pattern: String },
101    /// Minimum numeric value
102    Min { value: f64 },
103    /// Maximum numeric value
104    Max { value: f64 },
105    /// Minimum string length
106    MinLength { value: usize },
107    /// Maximum string length
108    MaxLength { value: usize },
109    /// Must be one of the listed values
110    OneOf { values: Vec<String> },
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct IndexDefinition {
115    pub name: String,
116    pub columns: Vec<String>,
117    #[serde(default)]
118    pub unique: bool,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum ForeignKeyAction {
124    Cascade,
125    SetNull,
126    Restrict,
127    NoAction,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "lowercase")]
132pub enum ColumnType {
133    Uuid,
134    Text,
135    Integer,
136    BigInteger,
137    Float,
138    Double,
139    Boolean,
140    Timestamp,
141    Date,
142    Jsonb,
143}
144
145impl ColumnType {
146    pub fn to_sql(&self) -> &str {
147        match self {
148            ColumnType::Uuid => "UUID",
149            ColumnType::Text => "TEXT",
150            ColumnType::Integer => "INTEGER",
151            ColumnType::BigInteger => "BIGINT",
152            ColumnType::Float => "REAL",
153            ColumnType::Double => "DOUBLE PRECISION",
154            ColumnType::Boolean => "BOOLEAN",
155            ColumnType::Timestamp => "TIMESTAMPTZ",
156            ColumnType::Date => "DATE",
157            ColumnType::Jsonb => "JSONB",
158        }
159    }
160
161    /// Map to the filter column type for query param parsing
162    pub fn filter_type(&self) -> &str {
163        match self {
164            ColumnType::Uuid => "uuid",
165            ColumnType::Text => "text",
166            ColumnType::Integer | ColumnType::BigInteger => "integer",
167            ColumnType::Float | ColumnType::Double => "double",
168            ColumnType::Boolean => "boolean",
169            ColumnType::Timestamp => "timestamp",
170            ColumnType::Date => "date",
171            ColumnType::Jsonb => "jsonb",
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct CellDefinition {
178    pub name: String,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct EventDefinition {
183    pub name: String,
184    pub table: String,
185    pub action: EventAction,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum EventAction {
191    Create,
192    Update,
193    Delete,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct SubscriptionDefinition {
198    pub subject: String,
199    pub handler: String,
200    pub handler_type: HandlerType,
201    #[serde(default)]
202    pub config: HashMap<String, String>,
203    /// Explicit JetStream stream to host this subscription's consumer. When
204    /// `None`, the platform uses the tenant stream (`DW_{tenant}`).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub stream: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum HandlerType {
212    DeleteCascade,
213    UpdateField,
214    Webhook,
215    Wasm,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct CustomRouteDefinition {
220    pub method: String,
221    pub path: String,
222    pub handler: String,
223    /// When present, the HTTP route accepts the request, persists a
224    /// `wasm_jobs` row, publishes to JetStream, and returns `202 Accepted`
225    /// with `{job_id}`. The handler executes asynchronously on a worker
226    /// with no inline 30s wall.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub job: Option<JobConfig>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232pub struct JobConfig {
233    /// Per-handler wall-clock ceiling enforced by the worker.
234    pub timeout_secs: u32,
235    /// Maximum delivery attempts before the job is permanently failed.
236    /// `1` disables retry-on-crash.
237    pub max_attempts: u32,
238}
239
240impl JobConfig {
241    pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
242    pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
243}
244
245impl Default for JobConfig {
246    fn default() -> Self {
247        Self {
248            timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
249            max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
250        }
251    }
252}
253
254/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
255#[derive(Debug, Clone, Serialize, Deserialize, Default)]
256pub struct TableHooks {
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub before_create: Option<String>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub after_create: Option<String>,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub before_update: Option<String>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub after_update: Option<String>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub before_delete: Option<String>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub after_delete: Option<String>,
269}
270
271/// Authorization configuration declared in the service manifest.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct AuthorizationConfig {
274    /// Permission areas declared by this service.
275    #[serde(default)]
276    pub areas: Vec<PermissionAreaDef>,
277    /// Default roles to seed on first deployment.
278    #[serde(default)]
279    pub default_roles: Vec<DefaultRoleDef>,
280}
281
282/// A permission area with its supported operations.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct PermissionAreaDef {
285    pub name: String,
286    #[serde(default)]
287    pub operations: Vec<String>,
288}
289
290/// A default role seeded on deployment.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct DefaultRoleDef {
293    pub name: String,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub description: Option<String>,
296    #[serde(default)]
297    pub permissions: Vec<String>,
298}
299
300/// Description of the changes a deploy is about to apply, passed as the
301/// payload to the `on_migrate` hook so user code can decide which backfill
302/// blocks should fire.
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct SchemaDiff {
305    #[serde(default)]
306    pub added_tables: Vec<String>,
307    #[serde(default)]
308    pub dropped_tables: Vec<String>,
309    /// (table, column) pairs for newly added columns. In phase 2 these
310    /// already exist in the database (added in phase 1) but may be NULL on
311    /// existing rows; the hook is the place to backfill them.
312    #[serde(default)]
313    pub added_columns: Vec<(String, String)>,
314    /// (table, column) pairs for columns that will be dropped in phase 3.
315    /// During phase 2 these are still present in the database, so the hook
316    /// can read them to populate new columns before they disappear.
317    #[serde(default)]
318    pub dropped_columns: Vec<(String, String)>,
319    /// (table, column, old_type, new_type)
320    #[serde(default)]
321    pub type_changes: Vec<(String, String, String, String)>,
322    /// (table, column, new_nullable)
323    #[serde(default)]
324    pub nullability_changes: Vec<(String, String, bool)>,
325}
326
327impl SchemaDiff {
328    pub fn added_column(&self, table: &str, col: &str) -> bool {
329        self.added_columns
330            .iter()
331            .any(|(t, c)| t == table && c == col)
332    }
333
334    pub fn dropped_column(&self, table: &str, col: &str) -> bool {
335        self.dropped_columns
336            .iter()
337            .any(|(t, c)| t == table && c == col)
338    }
339
340    pub fn added_table(&self, table: &str) -> bool {
341        self.added_tables.iter().any(|t| t == table)
342    }
343
344    pub fn dropped_table(&self, table: &str) -> bool {
345        self.dropped_tables.iter().any(|t| t == table)
346    }
347
348    pub fn is_empty(&self) -> bool {
349        self.added_tables.is_empty()
350            && self.dropped_tables.is_empty()
351            && self.added_columns.is_empty()
352            && self.dropped_columns.is_empty()
353            && self.type_changes.is_empty()
354            && self.nullability_changes.is_empty()
355    }
356}
357
358impl ServiceManifest {
359    /// Get table definition by name
360    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
361        self.tables.iter().find(|t| t.name == name)
362    }
363
364    /// Whether this manifest needs a WASM artifact bundled at deploy time.
365    ///
366    /// A WASM module is required when the service runs user code: explicit
367    /// `mode: wasm`, an `on_migrate` schema hook, table CRUD lifecycle hooks,
368    /// or any `Wasm` subscription/custom-route handler. The CLI uses this to
369    /// decide whether to compile a WASM artifact; the platform uses it to
370    /// reject deploys that arrive without one.
371    pub fn requires_wasm(&self) -> bool {
372        if self.mode == ServiceMode::Wasm {
373            return true;
374        }
375        if self.on_migrate.is_some() {
376            return true;
377        }
378        if self.tables.iter().any(|t| t.hooks.is_some()) {
379            return true;
380        }
381        if !self.custom_routes.is_empty() {
382            return true;
383        }
384        if self
385            .subscriptions
386            .iter()
387            .any(|s| matches!(s.handler_type, HandlerType::Wasm))
388        {
389            return true;
390        }
391        false
392    }
393
394    /// All handler names this manifest expects the WASM module to export.
395    /// Used at deploy time to reject manifests whose declared handlers are
396    /// missing from the bundled binary.
397    pub fn declared_wasm_handlers(&self) -> Vec<&str> {
398        let mut handlers: Vec<&str> = Vec::new();
399        if let Some(ref h) = self.on_migrate {
400            handlers.push(h.as_str());
401        }
402        for route in &self.custom_routes {
403            handlers.push(route.handler.as_str());
404        }
405        for sub in &self.subscriptions {
406            if matches!(sub.handler_type, HandlerType::Wasm) {
407                handlers.push(sub.handler.as_str());
408            }
409        }
410        for table in &self.tables {
411            if let Some(ref hooks) = table.hooks {
412                for h in [
413                    hooks.before_create.as_deref(),
414                    hooks.after_create.as_deref(),
415                    hooks.before_update.as_deref(),
416                    hooks.after_update.as_deref(),
417                    hooks.before_delete.as_deref(),
418                    hooks.after_delete.as_deref(),
419                ]
420                .iter()
421                .flatten()
422                {
423                    handlers.push(h);
424                }
425            }
426        }
427        handlers.sort();
428        handlers.dedup();
429        handlers
430    }
431
432    /// Compute a SHA-256 hash of the manifest for change detection
433    pub fn schema_hash(&self) -> String {
434        use sha2::{Digest, Sha256};
435        let mut hasher = Sha256::new();
436        hasher.update(
437            serde_json::to_string(&self.tables)
438                .unwrap_or_default()
439                .as_bytes(),
440        );
441        hasher.update(
442            serde_json::to_string(&self.custom_routes)
443                .unwrap_or_default()
444                .as_bytes(),
445        );
446        hasher.update(
447            serde_json::to_string(&self.subscriptions)
448                .unwrap_or_default()
449                .as_bytes(),
450        );
451        hasher.update(
452            serde_json::to_string(&self.authorization)
453                .unwrap_or_default()
454                .as_bytes(),
455        );
456        hasher.update(
457            serde_json::to_string(&self.on_migrate)
458                .unwrap_or_default()
459                .as_bytes(),
460        );
461        hex::encode(hasher.finalize())
462    }
463}
464
465impl TableDefinition {
466    /// Get the primary key column
467    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
468        self.columns.iter().find(|c| c.primary_key)
469    }
470
471    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
472    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
473        self.columns
474            .iter()
475            .filter(|c| !c.auto_generate || !c.primary_key)
476            .collect()
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn sample_manifest() -> ServiceManifest {
485        ServiceManifest {
486            name: "test-service".into(),
487            version: Some("1.0.0".into()),
488            tables: vec![TableDefinition {
489                name: "todos".into(),
490                columns: vec![
491                    ColumnDefinition {
492                        name: "id".into(),
493                        column_type: ColumnType::Uuid,
494                        primary_key: true,
495                        nullable: false,
496                        auto_generate: true,
497                        default_value: None,
498                        references: None,
499                        on_delete: None,
500                        unique: false,
501                        validations: vec![],
502                    },
503                    ColumnDefinition {
504                        name: "title".into(),
505                        column_type: ColumnType::Text,
506                        primary_key: false,
507                        nullable: false,
508                        auto_generate: false,
509                        default_value: None,
510                        references: None,
511                        on_delete: None,
512                        unique: false,
513                        validations: vec![],
514                    },
515                    ColumnDefinition {
516                        name: "user_id".into(),
517                        column_type: ColumnType::Uuid,
518                        primary_key: false,
519                        nullable: false,
520                        auto_generate: false,
521                        default_value: None,
522                        references: Some("users.id".into()),
523                        on_delete: Some(ForeignKeyAction::Cascade),
524                        unique: false,
525                        validations: vec![],
526                    },
527                ],
528                indexes: vec![],
529                soft_delete: false,
530                owner_field: None,
531                auth_required: false,
532                permission_area: None,
533                hooks: None,
534            }],
535            cells: vec![],
536            events: vec![],
537            subscriptions: vec![],
538            custom_routes: vec![],
539            mode: ServiceMode::Crud,
540            authorization: None,
541            on_migrate: None,
542        }
543    }
544
545    #[test]
546    fn test_manifest_serialization_roundtrip() {
547        let manifest = sample_manifest();
548        let json = serde_json::to_string(&manifest).unwrap();
549        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
550        assert_eq!(parsed.name, "test-service");
551        assert_eq!(parsed.tables.len(), 1);
552        assert_eq!(parsed.tables[0].columns.len(), 3);
553    }
554
555    #[test]
556    fn test_get_table() {
557        let manifest = sample_manifest();
558        assert!(manifest.get_table("todos").is_some());
559        assert!(manifest.get_table("nonexistent").is_none());
560    }
561
562    #[test]
563    fn test_primary_key() {
564        let manifest = sample_manifest();
565        let table = manifest.get_table("todos").unwrap();
566        let pk = table.primary_key().unwrap();
567        assert_eq!(pk.name, "id");
568        assert!(pk.auto_generate);
569    }
570
571    #[test]
572    fn test_schema_hash_deterministic() {
573        let m1 = sample_manifest();
574        let m2 = sample_manifest();
575        assert_eq!(m1.schema_hash(), m2.schema_hash());
576    }
577
578    #[test]
579    fn test_schema_hash_changes() {
580        let mut m1 = sample_manifest();
581        let m2 = sample_manifest();
582        m1.tables[0].columns.push(ColumnDefinition {
583            name: "extra".into(),
584            column_type: ColumnType::Text,
585            primary_key: false,
586            nullable: true,
587            auto_generate: false,
588            default_value: None,
589            references: None,
590            on_delete: None,
591            unique: false,
592            validations: vec![],
593        });
594        assert_ne!(m1.schema_hash(), m2.schema_hash());
595    }
596
597    #[test]
598    fn test_column_type_to_sql() {
599        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
600        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
601        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
602        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
603        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
604        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
605        assert_eq!(ColumnType::Date.to_sql(), "DATE");
606        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
607    }
608
609    #[test]
610    fn test_fk_serialization() {
611        let manifest = sample_manifest();
612        let json = serde_json::to_string(&manifest).unwrap();
613        assert!(json.contains("\"references\":\"users.id\""));
614        assert!(json.contains("\"on_delete\":\"cascade\""));
615
616        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
617        let user_id_col = &parsed.tables[0].columns[2];
618        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
619        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
620    }
621
622    #[test]
623    fn test_fk_not_serialized_when_none() {
624        let col = ColumnDefinition {
625            name: "title".into(),
626            column_type: ColumnType::Text,
627            primary_key: false,
628            nullable: false,
629            auto_generate: false,
630            default_value: None,
631            references: None,
632            on_delete: None,
633            unique: false,
634            validations: vec![],
635        };
636        let json = serde_json::to_string(&col).unwrap();
637        assert!(!json.contains("references"));
638        assert!(!json.contains("on_delete"));
639    }
640
641    #[test]
642    fn test_unique_column_serialization() {
643        let col = ColumnDefinition {
644            name: "email".into(),
645            column_type: ColumnType::Text,
646            primary_key: false,
647            nullable: false,
648            auto_generate: false,
649            default_value: None,
650            references: None,
651            on_delete: None,
652            unique: true,
653            validations: vec![],
654        };
655        let json = serde_json::to_string(&col).unwrap();
656        assert!(json.contains("\"unique\":true"));
657        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
658        assert!(parsed.unique);
659    }
660
661    #[test]
662    fn test_index_serialization() {
663        let table = TableDefinition {
664            name: "users".into(),
665            columns: vec![],
666            indexes: vec![IndexDefinition {
667                name: "idx_users_email".into(),
668                columns: vec!["email".into()],
669                unique: true,
670            }],
671            soft_delete: false,
672            owner_field: None,
673            auth_required: false,
674            permission_area: None,
675            hooks: None,
676        };
677        let json = serde_json::to_string(&table).unwrap();
678        assert!(json.contains("idx_users_email"));
679        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
680        assert_eq!(parsed.indexes.len(), 1);
681        assert!(parsed.indexes[0].unique);
682    }
683
684    #[test]
685    fn test_indexes_not_serialized_when_empty() {
686        let table = TableDefinition {
687            name: "users".into(),
688            columns: vec![],
689            indexes: vec![],
690            soft_delete: false,
691            owner_field: None,
692            auth_required: false,
693            permission_area: None,
694            hooks: None,
695        };
696        let json = serde_json::to_string(&table).unwrap();
697        assert!(!json.contains("indexes"));
698    }
699
700    #[test]
701    fn test_service_mode_default() {
702        let json = r#"{"name":"svc","tables":[]}"#;
703        let m: ServiceManifest = serde_json::from_str(json).unwrap();
704        assert_eq!(m.mode, ServiceMode::Crud);
705    }
706
707    #[test]
708    fn test_owner_field_serialization() {
709        let table = TableDefinition {
710            name: "notes".into(),
711            columns: vec![],
712            indexes: vec![],
713            soft_delete: false,
714            owner_field: Some("user_id".into()),
715            auth_required: false,
716            permission_area: None,
717            hooks: None,
718        };
719        let json = serde_json::to_string(&table).unwrap();
720        assert!(json.contains("\"owner_field\":\"user_id\""));
721        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
722        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
723    }
724
725    #[test]
726    fn test_owner_field_not_serialized_when_none() {
727        let table = TableDefinition {
728            name: "notes".into(),
729            columns: vec![],
730            indexes: vec![],
731            soft_delete: false,
732            owner_field: None,
733            auth_required: false,
734            permission_area: None,
735            hooks: None,
736        };
737        let json = serde_json::to_string(&table).unwrap();
738        assert!(!json.contains("owner_field"));
739    }
740
741    #[test]
742    fn test_owner_field_defaults_to_none() {
743        let json = r#"{"name":"notes","columns":[]}"#;
744        let table: TableDefinition = serde_json::from_str(json).unwrap();
745        assert!(table.owner_field.is_none());
746    }
747
748    #[test]
749    fn test_auth_required_serialization() {
750        let table = TableDefinition {
751            name: "orders".into(),
752            columns: vec![],
753            indexes: vec![],
754            soft_delete: false,
755            owner_field: None,
756            auth_required: true,
757            permission_area: None,
758            hooks: None,
759        };
760        let json = serde_json::to_string(&table).unwrap();
761        assert!(json.contains("\"auth_required\":true"));
762        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
763        assert!(parsed.auth_required);
764    }
765
766    #[test]
767    fn test_auth_required_defaults_to_false() {
768        let json = r#"{"name":"orders","columns":[]}"#;
769        let table: TableDefinition = serde_json::from_str(json).unwrap();
770        assert!(!table.auth_required);
771    }
772
773    #[test]
774    fn test_on_migrate_defaults_to_none() {
775        let json = r#"{"name":"svc","tables":[]}"#;
776        let m: ServiceManifest = serde_json::from_str(json).unwrap();
777        assert!(m.on_migrate.is_none());
778    }
779
780    #[test]
781    fn test_on_migrate_serialization_roundtrip() {
782        let mut m = sample_manifest();
783        m.on_migrate = Some("handle_on_migrate".into());
784        let json = serde_json::to_string(&m).unwrap();
785        assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
786        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
787        assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
788    }
789
790    #[test]
791    fn test_on_migrate_skipped_when_none() {
792        let m = sample_manifest();
793        let json = serde_json::to_string(&m).unwrap();
794        assert!(!json.contains("on_migrate"));
795    }
796
797    #[test]
798    fn test_on_migrate_changes_schema_hash() {
799        let m1 = sample_manifest();
800        let mut m2 = sample_manifest();
801        m2.on_migrate = Some("handle_on_migrate".into());
802        assert_ne!(m1.schema_hash(), m2.schema_hash());
803    }
804
805    #[test]
806    fn test_subscription_stream_defaults_to_none_in_legacy_json() {
807        let json = r#"{"subject":"x","handler":"h","handler_type":"wasm"}"#;
808        let sub: SubscriptionDefinition = serde_json::from_str(json).unwrap();
809        assert!(sub.stream.is_none());
810    }
811
812    #[test]
813    fn test_subscription_stream_roundtrip_when_set() {
814        let sub = SubscriptionDefinition {
815            subject: "cufflink.tick.5min".into(),
816            handler: "handle_tick_resync".into(),
817            handler_type: HandlerType::Wasm,
818            config: HashMap::new(),
819            stream: Some("CUFFLINK_TICKS".into()),
820        };
821        let json = serde_json::to_string(&sub).unwrap();
822        assert!(json.contains("\"stream\":\"CUFFLINK_TICKS\""));
823        let parsed: SubscriptionDefinition = serde_json::from_str(&json).unwrap();
824        assert_eq!(parsed.stream.as_deref(), Some("CUFFLINK_TICKS"));
825    }
826
827    #[test]
828    fn test_subscription_stream_skipped_when_none() {
829        let sub = SubscriptionDefinition {
830            subject: "x".into(),
831            handler: "h".into(),
832            handler_type: HandlerType::Wasm,
833            config: HashMap::new(),
834            stream: None,
835        };
836        let json = serde_json::to_string(&sub).unwrap();
837        assert!(!json.contains("stream"));
838    }
839
840    #[test]
841    fn test_schema_diff_helpers() {
842        let diff = SchemaDiff {
843            added_tables: vec!["batches".into()],
844            dropped_tables: vec!["legacy_batches".into()],
845            added_columns: vec![("pickups".into(), "min".into())],
846            dropped_columns: vec![("pickups".into(), "midpoint".into())],
847            type_changes: vec![],
848            nullability_changes: vec![],
849        };
850        assert!(!diff.is_empty());
851        assert!(diff.added_table("batches"));
852        assert!(!diff.added_table("nope"));
853        assert!(diff.dropped_table("legacy_batches"));
854        assert!(diff.added_column("pickups", "min"));
855        assert!(!diff.added_column("pickups", "max"));
856        assert!(diff.dropped_column("pickups", "midpoint"));
857    }
858
859    #[test]
860    fn test_schema_diff_is_empty_default() {
861        assert!(SchemaDiff::default().is_empty());
862    }
863
864    #[test]
865    fn test_schema_diff_serialization_roundtrip() {
866        let diff = SchemaDiff {
867            added_tables: vec![],
868            dropped_tables: vec![],
869            added_columns: vec![("t".into(), "c".into())],
870            dropped_columns: vec![],
871            type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
872            nullability_changes: vec![("t".into(), "c".into(), false)],
873        };
874        let json = serde_json::to_string(&diff).unwrap();
875        let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
876        assert!(parsed.added_column("t", "c"));
877        assert_eq!(parsed.type_changes.len(), 1);
878        assert_eq!(parsed.nullability_changes.len(), 1);
879    }
880
881    #[test]
882    fn test_requires_wasm_false_for_plain_crud() {
883        let m = sample_manifest();
884        assert!(!m.requires_wasm());
885    }
886
887    #[test]
888    fn test_requires_wasm_true_for_wasm_mode() {
889        let mut m = sample_manifest();
890        m.mode = ServiceMode::Wasm;
891        assert!(m.requires_wasm());
892    }
893
894    #[test]
895    fn test_requires_wasm_true_for_on_migrate() {
896        let mut m = sample_manifest();
897        m.on_migrate = Some("handle_on_migrate".into());
898        assert!(m.requires_wasm());
899    }
900
901    #[test]
902    fn test_requires_wasm_true_for_table_hooks() {
903        let mut m = sample_manifest();
904        m.tables[0].hooks = Some(TableHooks {
905            before_create: Some("validate".into()),
906            ..Default::default()
907        });
908        assert!(m.requires_wasm());
909    }
910
911    #[test]
912    fn test_requires_wasm_true_for_custom_routes() {
913        let mut m = sample_manifest();
914        m.custom_routes.push(CustomRouteDefinition {
915            method: "GET".into(),
916            path: "/hello".into(),
917            handler: "hello".into(),
918            job: None,
919        });
920        assert!(m.requires_wasm());
921    }
922
923    #[test]
924    fn test_requires_wasm_true_for_wasm_subscription() {
925        let mut m = sample_manifest();
926        m.subscriptions.push(SubscriptionDefinition {
927            subject: "events.foo".into(),
928            handler: "on_foo".into(),
929            handler_type: HandlerType::Wasm,
930            config: HashMap::new(),
931            stream: None,
932        });
933        assert!(m.requires_wasm());
934    }
935
936    #[test]
937    fn test_requires_wasm_false_for_non_wasm_subscription() {
938        let mut m = sample_manifest();
939        m.subscriptions.push(SubscriptionDefinition {
940            subject: "events.foo".into(),
941            handler: "on_foo".into(),
942            handler_type: HandlerType::Webhook,
943            config: HashMap::new(),
944            stream: None,
945        });
946        assert!(!m.requires_wasm());
947    }
948
949    #[test]
950    fn test_declared_wasm_handlers_collects_all_sources() {
951        let mut m = sample_manifest();
952        m.on_migrate = Some("on_mig".into());
953        m.custom_routes.push(CustomRouteDefinition {
954            method: "GET".into(),
955            path: "/h".into(),
956            handler: "route_h".into(),
957            job: None,
958        });
959        m.subscriptions.push(SubscriptionDefinition {
960            subject: "x".into(),
961            handler: "sub_h".into(),
962            handler_type: HandlerType::Wasm,
963            config: HashMap::new(),
964            stream: None,
965        });
966        m.subscriptions.push(SubscriptionDefinition {
967            subject: "y".into(),
968            handler: "wh_ignored".into(),
969            handler_type: HandlerType::Webhook,
970            config: HashMap::new(),
971            stream: None,
972        });
973        m.tables[0].hooks = Some(TableHooks {
974            before_create: Some("bc".into()),
975            after_create: Some("ac".into()),
976            before_delete: Some("bc".into()), // duplicate handler name
977            ..Default::default()
978        });
979        let handlers = m.declared_wasm_handlers();
980        assert!(handlers.contains(&"on_mig"));
981        assert!(handlers.contains(&"route_h"));
982        assert!(handlers.contains(&"sub_h"));
983        assert!(handlers.contains(&"bc"));
984        assert!(handlers.contains(&"ac"));
985        assert!(!handlers.contains(&"wh_ignored"));
986        // Deduplicated
987        assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
988    }
989
990    #[test]
991    fn job_config_defaults() {
992        let cfg = JobConfig::default();
993        assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
994        assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
995    }
996
997    #[test]
998    fn custom_route_serde_round_trip_with_job() {
999        let route = CustomRouteDefinition {
1000            method: "POST".into(),
1001            path: "/run".into(),
1002            handler: "do_thing".into(),
1003            job: Some(JobConfig {
1004                timeout_secs: 900,
1005                max_attempts: 2,
1006            }),
1007        };
1008        let json = serde_json::to_string(&route).unwrap();
1009        let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
1010        assert_eq!(round.method, "POST");
1011        let job = round.job.unwrap();
1012        assert_eq!(job.timeout_secs, 900);
1013        assert_eq!(job.max_attempts, 2);
1014    }
1015
1016    #[test]
1017    fn custom_route_without_job_serializes_without_field() {
1018        let route = CustomRouteDefinition {
1019            method: "GET".into(),
1020            path: "/x".into(),
1021            handler: "x".into(),
1022            job: None,
1023        };
1024        let json = serde_json::to_value(&route).unwrap();
1025        assert!(json.get("job").is_none(), "absent job should be omitted");
1026    }
1027
1028    #[test]
1029    fn custom_route_legacy_json_without_job_deserializes() {
1030        let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
1031        let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
1032        assert!(route.job.is_none());
1033    }
1034}