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}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum HandlerType {
208    DeleteCascade,
209    UpdateField,
210    Webhook,
211    Wasm,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CustomRouteDefinition {
216    pub method: String,
217    pub path: String,
218    pub handler: String,
219}
220
221/// CRUD lifecycle hooks for a table. Each field names a WASM handler function.
222#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223pub struct TableHooks {
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub before_create: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub after_create: Option<String>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub before_update: Option<String>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub after_update: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub before_delete: Option<String>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub after_delete: Option<String>,
236}
237
238/// Authorization configuration declared in the service manifest.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct AuthorizationConfig {
241    /// Permission areas declared by this service.
242    #[serde(default)]
243    pub areas: Vec<PermissionAreaDef>,
244    /// Default roles to seed on first deployment.
245    #[serde(default)]
246    pub default_roles: Vec<DefaultRoleDef>,
247}
248
249/// A permission area with its supported operations.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PermissionAreaDef {
252    pub name: String,
253    #[serde(default)]
254    pub operations: Vec<String>,
255}
256
257/// A default role seeded on deployment.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct DefaultRoleDef {
260    pub name: String,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub description: Option<String>,
263    #[serde(default)]
264    pub permissions: Vec<String>,
265}
266
267/// Description of the changes a deploy is about to apply, passed as the
268/// payload to the `on_migrate` hook so user code can decide which backfill
269/// blocks should fire.
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct SchemaDiff {
272    #[serde(default)]
273    pub added_tables: Vec<String>,
274    #[serde(default)]
275    pub dropped_tables: Vec<String>,
276    /// (table, column) pairs for newly added columns. In phase 2 these
277    /// already exist in the database (added in phase 1) but may be NULL on
278    /// existing rows; the hook is the place to backfill them.
279    #[serde(default)]
280    pub added_columns: Vec<(String, String)>,
281    /// (table, column) pairs for columns that will be dropped in phase 3.
282    /// During phase 2 these are still present in the database, so the hook
283    /// can read them to populate new columns before they disappear.
284    #[serde(default)]
285    pub dropped_columns: Vec<(String, String)>,
286    /// (table, column, old_type, new_type)
287    #[serde(default)]
288    pub type_changes: Vec<(String, String, String, String)>,
289    /// (table, column, new_nullable)
290    #[serde(default)]
291    pub nullability_changes: Vec<(String, String, bool)>,
292}
293
294impl SchemaDiff {
295    pub fn added_column(&self, table: &str, col: &str) -> bool {
296        self.added_columns
297            .iter()
298            .any(|(t, c)| t == table && c == col)
299    }
300
301    pub fn dropped_column(&self, table: &str, col: &str) -> bool {
302        self.dropped_columns
303            .iter()
304            .any(|(t, c)| t == table && c == col)
305    }
306
307    pub fn added_table(&self, table: &str) -> bool {
308        self.added_tables.iter().any(|t| t == table)
309    }
310
311    pub fn dropped_table(&self, table: &str) -> bool {
312        self.dropped_tables.iter().any(|t| t == table)
313    }
314
315    pub fn is_empty(&self) -> bool {
316        self.added_tables.is_empty()
317            && self.dropped_tables.is_empty()
318            && self.added_columns.is_empty()
319            && self.dropped_columns.is_empty()
320            && self.type_changes.is_empty()
321            && self.nullability_changes.is_empty()
322    }
323}
324
325impl ServiceManifest {
326    /// Get table definition by name
327    pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
328        self.tables.iter().find(|t| t.name == name)
329    }
330
331    /// Whether this manifest needs a WASM artifact bundled at deploy time.
332    ///
333    /// A WASM module is required when the service runs user code: explicit
334    /// `mode: wasm`, an `on_migrate` schema hook, table CRUD lifecycle hooks,
335    /// or any `Wasm` subscription/custom-route handler. The CLI uses this to
336    /// decide whether to compile a WASM artifact; the platform uses it to
337    /// reject deploys that arrive without one.
338    pub fn requires_wasm(&self) -> bool {
339        if self.mode == ServiceMode::Wasm {
340            return true;
341        }
342        if self.on_migrate.is_some() {
343            return true;
344        }
345        if self.tables.iter().any(|t| t.hooks.is_some()) {
346            return true;
347        }
348        if !self.custom_routes.is_empty() {
349            return true;
350        }
351        if self
352            .subscriptions
353            .iter()
354            .any(|s| matches!(s.handler_type, HandlerType::Wasm))
355        {
356            return true;
357        }
358        false
359    }
360
361    /// All handler names this manifest expects the WASM module to export.
362    /// Used at deploy time to reject manifests whose declared handlers are
363    /// missing from the bundled binary.
364    pub fn declared_wasm_handlers(&self) -> Vec<&str> {
365        let mut handlers: Vec<&str> = Vec::new();
366        if let Some(ref h) = self.on_migrate {
367            handlers.push(h.as_str());
368        }
369        for route in &self.custom_routes {
370            handlers.push(route.handler.as_str());
371        }
372        for sub in &self.subscriptions {
373            if matches!(sub.handler_type, HandlerType::Wasm) {
374                handlers.push(sub.handler.as_str());
375            }
376        }
377        for table in &self.tables {
378            if let Some(ref hooks) = table.hooks {
379                for h in [
380                    hooks.before_create.as_deref(),
381                    hooks.after_create.as_deref(),
382                    hooks.before_update.as_deref(),
383                    hooks.after_update.as_deref(),
384                    hooks.before_delete.as_deref(),
385                    hooks.after_delete.as_deref(),
386                ]
387                .iter()
388                .flatten()
389                {
390                    handlers.push(h);
391                }
392            }
393        }
394        handlers.sort();
395        handlers.dedup();
396        handlers
397    }
398
399    /// Compute a SHA-256 hash of the manifest for change detection
400    pub fn schema_hash(&self) -> String {
401        use sha2::{Digest, Sha256};
402        let mut hasher = Sha256::new();
403        hasher.update(
404            serde_json::to_string(&self.tables)
405                .unwrap_or_default()
406                .as_bytes(),
407        );
408        hasher.update(
409            serde_json::to_string(&self.custom_routes)
410                .unwrap_or_default()
411                .as_bytes(),
412        );
413        hasher.update(
414            serde_json::to_string(&self.subscriptions)
415                .unwrap_or_default()
416                .as_bytes(),
417        );
418        hasher.update(
419            serde_json::to_string(&self.authorization)
420                .unwrap_or_default()
421                .as_bytes(),
422        );
423        hasher.update(
424            serde_json::to_string(&self.on_migrate)
425                .unwrap_or_default()
426                .as_bytes(),
427        );
428        hex::encode(hasher.finalize())
429    }
430}
431
432impl TableDefinition {
433    /// Get the primary key column
434    pub fn primary_key(&self) -> Option<&ColumnDefinition> {
435        self.columns.iter().find(|c| c.primary_key)
436    }
437
438    /// Get all non-primary-key, non-auto columns (for INSERT/UPDATE)
439    pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
440        self.columns
441            .iter()
442            .filter(|c| !c.auto_generate || !c.primary_key)
443            .collect()
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn sample_manifest() -> ServiceManifest {
452        ServiceManifest {
453            name: "test-service".into(),
454            version: Some("1.0.0".into()),
455            tables: vec![TableDefinition {
456                name: "todos".into(),
457                columns: vec![
458                    ColumnDefinition {
459                        name: "id".into(),
460                        column_type: ColumnType::Uuid,
461                        primary_key: true,
462                        nullable: false,
463                        auto_generate: true,
464                        default_value: None,
465                        references: None,
466                        on_delete: None,
467                        unique: false,
468                        validations: vec![],
469                    },
470                    ColumnDefinition {
471                        name: "title".into(),
472                        column_type: ColumnType::Text,
473                        primary_key: false,
474                        nullable: false,
475                        auto_generate: false,
476                        default_value: None,
477                        references: None,
478                        on_delete: None,
479                        unique: false,
480                        validations: vec![],
481                    },
482                    ColumnDefinition {
483                        name: "user_id".into(),
484                        column_type: ColumnType::Uuid,
485                        primary_key: false,
486                        nullable: false,
487                        auto_generate: false,
488                        default_value: None,
489                        references: Some("users.id".into()),
490                        on_delete: Some(ForeignKeyAction::Cascade),
491                        unique: false,
492                        validations: vec![],
493                    },
494                ],
495                indexes: vec![],
496                soft_delete: false,
497                owner_field: None,
498                auth_required: false,
499                permission_area: None,
500                hooks: None,
501            }],
502            cells: vec![],
503            events: vec![],
504            subscriptions: vec![],
505            custom_routes: vec![],
506            mode: ServiceMode::Crud,
507            authorization: None,
508            on_migrate: None,
509        }
510    }
511
512    #[test]
513    fn test_manifest_serialization_roundtrip() {
514        let manifest = sample_manifest();
515        let json = serde_json::to_string(&manifest).unwrap();
516        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
517        assert_eq!(parsed.name, "test-service");
518        assert_eq!(parsed.tables.len(), 1);
519        assert_eq!(parsed.tables[0].columns.len(), 3);
520    }
521
522    #[test]
523    fn test_get_table() {
524        let manifest = sample_manifest();
525        assert!(manifest.get_table("todos").is_some());
526        assert!(manifest.get_table("nonexistent").is_none());
527    }
528
529    #[test]
530    fn test_primary_key() {
531        let manifest = sample_manifest();
532        let table = manifest.get_table("todos").unwrap();
533        let pk = table.primary_key().unwrap();
534        assert_eq!(pk.name, "id");
535        assert!(pk.auto_generate);
536    }
537
538    #[test]
539    fn test_schema_hash_deterministic() {
540        let m1 = sample_manifest();
541        let m2 = sample_manifest();
542        assert_eq!(m1.schema_hash(), m2.schema_hash());
543    }
544
545    #[test]
546    fn test_schema_hash_changes() {
547        let mut m1 = sample_manifest();
548        let m2 = sample_manifest();
549        m1.tables[0].columns.push(ColumnDefinition {
550            name: "extra".into(),
551            column_type: ColumnType::Text,
552            primary_key: false,
553            nullable: true,
554            auto_generate: false,
555            default_value: None,
556            references: None,
557            on_delete: None,
558            unique: false,
559            validations: vec![],
560        });
561        assert_ne!(m1.schema_hash(), m2.schema_hash());
562    }
563
564    #[test]
565    fn test_column_type_to_sql() {
566        assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
567        assert_eq!(ColumnType::Text.to_sql(), "TEXT");
568        assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
569        assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
570        assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
571        assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
572        assert_eq!(ColumnType::Date.to_sql(), "DATE");
573        assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
574    }
575
576    #[test]
577    fn test_fk_serialization() {
578        let manifest = sample_manifest();
579        let json = serde_json::to_string(&manifest).unwrap();
580        assert!(json.contains("\"references\":\"users.id\""));
581        assert!(json.contains("\"on_delete\":\"cascade\""));
582
583        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
584        let user_id_col = &parsed.tables[0].columns[2];
585        assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
586        assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
587    }
588
589    #[test]
590    fn test_fk_not_serialized_when_none() {
591        let col = ColumnDefinition {
592            name: "title".into(),
593            column_type: ColumnType::Text,
594            primary_key: false,
595            nullable: false,
596            auto_generate: false,
597            default_value: None,
598            references: None,
599            on_delete: None,
600            unique: false,
601            validations: vec![],
602        };
603        let json = serde_json::to_string(&col).unwrap();
604        assert!(!json.contains("references"));
605        assert!(!json.contains("on_delete"));
606    }
607
608    #[test]
609    fn test_unique_column_serialization() {
610        let col = ColumnDefinition {
611            name: "email".into(),
612            column_type: ColumnType::Text,
613            primary_key: false,
614            nullable: false,
615            auto_generate: false,
616            default_value: None,
617            references: None,
618            on_delete: None,
619            unique: true,
620            validations: vec![],
621        };
622        let json = serde_json::to_string(&col).unwrap();
623        assert!(json.contains("\"unique\":true"));
624        let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
625        assert!(parsed.unique);
626    }
627
628    #[test]
629    fn test_index_serialization() {
630        let table = TableDefinition {
631            name: "users".into(),
632            columns: vec![],
633            indexes: vec![IndexDefinition {
634                name: "idx_users_email".into(),
635                columns: vec!["email".into()],
636                unique: true,
637            }],
638            soft_delete: false,
639            owner_field: None,
640            auth_required: false,
641            permission_area: None,
642            hooks: None,
643        };
644        let json = serde_json::to_string(&table).unwrap();
645        assert!(json.contains("idx_users_email"));
646        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
647        assert_eq!(parsed.indexes.len(), 1);
648        assert!(parsed.indexes[0].unique);
649    }
650
651    #[test]
652    fn test_indexes_not_serialized_when_empty() {
653        let table = TableDefinition {
654            name: "users".into(),
655            columns: vec![],
656            indexes: vec![],
657            soft_delete: false,
658            owner_field: None,
659            auth_required: false,
660            permission_area: None,
661            hooks: None,
662        };
663        let json = serde_json::to_string(&table).unwrap();
664        assert!(!json.contains("indexes"));
665    }
666
667    #[test]
668    fn test_service_mode_default() {
669        let json = r#"{"name":"svc","tables":[]}"#;
670        let m: ServiceManifest = serde_json::from_str(json).unwrap();
671        assert_eq!(m.mode, ServiceMode::Crud);
672    }
673
674    #[test]
675    fn test_owner_field_serialization() {
676        let table = TableDefinition {
677            name: "notes".into(),
678            columns: vec![],
679            indexes: vec![],
680            soft_delete: false,
681            owner_field: Some("user_id".into()),
682            auth_required: false,
683            permission_area: None,
684            hooks: None,
685        };
686        let json = serde_json::to_string(&table).unwrap();
687        assert!(json.contains("\"owner_field\":\"user_id\""));
688        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
689        assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
690    }
691
692    #[test]
693    fn test_owner_field_not_serialized_when_none() {
694        let table = TableDefinition {
695            name: "notes".into(),
696            columns: vec![],
697            indexes: vec![],
698            soft_delete: false,
699            owner_field: None,
700            auth_required: false,
701            permission_area: None,
702            hooks: None,
703        };
704        let json = serde_json::to_string(&table).unwrap();
705        assert!(!json.contains("owner_field"));
706    }
707
708    #[test]
709    fn test_owner_field_defaults_to_none() {
710        let json = r#"{"name":"notes","columns":[]}"#;
711        let table: TableDefinition = serde_json::from_str(json).unwrap();
712        assert!(table.owner_field.is_none());
713    }
714
715    #[test]
716    fn test_auth_required_serialization() {
717        let table = TableDefinition {
718            name: "orders".into(),
719            columns: vec![],
720            indexes: vec![],
721            soft_delete: false,
722            owner_field: None,
723            auth_required: true,
724            permission_area: None,
725            hooks: None,
726        };
727        let json = serde_json::to_string(&table).unwrap();
728        assert!(json.contains("\"auth_required\":true"));
729        let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
730        assert!(parsed.auth_required);
731    }
732
733    #[test]
734    fn test_auth_required_defaults_to_false() {
735        let json = r#"{"name":"orders","columns":[]}"#;
736        let table: TableDefinition = serde_json::from_str(json).unwrap();
737        assert!(!table.auth_required);
738    }
739
740    #[test]
741    fn test_on_migrate_defaults_to_none() {
742        let json = r#"{"name":"svc","tables":[]}"#;
743        let m: ServiceManifest = serde_json::from_str(json).unwrap();
744        assert!(m.on_migrate.is_none());
745    }
746
747    #[test]
748    fn test_on_migrate_serialization_roundtrip() {
749        let mut m = sample_manifest();
750        m.on_migrate = Some("handle_on_migrate".into());
751        let json = serde_json::to_string(&m).unwrap();
752        assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
753        let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
754        assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
755    }
756
757    #[test]
758    fn test_on_migrate_skipped_when_none() {
759        let m = sample_manifest();
760        let json = serde_json::to_string(&m).unwrap();
761        assert!(!json.contains("on_migrate"));
762    }
763
764    #[test]
765    fn test_on_migrate_changes_schema_hash() {
766        let m1 = sample_manifest();
767        let mut m2 = sample_manifest();
768        m2.on_migrate = Some("handle_on_migrate".into());
769        assert_ne!(m1.schema_hash(), m2.schema_hash());
770    }
771
772    #[test]
773    fn test_schema_diff_helpers() {
774        let diff = SchemaDiff {
775            added_tables: vec!["batches".into()],
776            dropped_tables: vec!["legacy_batches".into()],
777            added_columns: vec![("pickups".into(), "min".into())],
778            dropped_columns: vec![("pickups".into(), "midpoint".into())],
779            type_changes: vec![],
780            nullability_changes: vec![],
781        };
782        assert!(!diff.is_empty());
783        assert!(diff.added_table("batches"));
784        assert!(!diff.added_table("nope"));
785        assert!(diff.dropped_table("legacy_batches"));
786        assert!(diff.added_column("pickups", "min"));
787        assert!(!diff.added_column("pickups", "max"));
788        assert!(diff.dropped_column("pickups", "midpoint"));
789    }
790
791    #[test]
792    fn test_schema_diff_is_empty_default() {
793        assert!(SchemaDiff::default().is_empty());
794    }
795
796    #[test]
797    fn test_schema_diff_serialization_roundtrip() {
798        let diff = SchemaDiff {
799            added_tables: vec![],
800            dropped_tables: vec![],
801            added_columns: vec![("t".into(), "c".into())],
802            dropped_columns: vec![],
803            type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
804            nullability_changes: vec![("t".into(), "c".into(), false)],
805        };
806        let json = serde_json::to_string(&diff).unwrap();
807        let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
808        assert!(parsed.added_column("t", "c"));
809        assert_eq!(parsed.type_changes.len(), 1);
810        assert_eq!(parsed.nullability_changes.len(), 1);
811    }
812
813    #[test]
814    fn test_requires_wasm_false_for_plain_crud() {
815        let m = sample_manifest();
816        assert!(!m.requires_wasm());
817    }
818
819    #[test]
820    fn test_requires_wasm_true_for_wasm_mode() {
821        let mut m = sample_manifest();
822        m.mode = ServiceMode::Wasm;
823        assert!(m.requires_wasm());
824    }
825
826    #[test]
827    fn test_requires_wasm_true_for_on_migrate() {
828        let mut m = sample_manifest();
829        m.on_migrate = Some("handle_on_migrate".into());
830        assert!(m.requires_wasm());
831    }
832
833    #[test]
834    fn test_requires_wasm_true_for_table_hooks() {
835        let mut m = sample_manifest();
836        m.tables[0].hooks = Some(TableHooks {
837            before_create: Some("validate".into()),
838            ..Default::default()
839        });
840        assert!(m.requires_wasm());
841    }
842
843    #[test]
844    fn test_requires_wasm_true_for_custom_routes() {
845        let mut m = sample_manifest();
846        m.custom_routes.push(CustomRouteDefinition {
847            method: "GET".into(),
848            path: "/hello".into(),
849            handler: "hello".into(),
850        });
851        assert!(m.requires_wasm());
852    }
853
854    #[test]
855    fn test_requires_wasm_true_for_wasm_subscription() {
856        let mut m = sample_manifest();
857        m.subscriptions.push(SubscriptionDefinition {
858            subject: "events.foo".into(),
859            handler: "on_foo".into(),
860            handler_type: HandlerType::Wasm,
861            config: HashMap::new(),
862        });
863        assert!(m.requires_wasm());
864    }
865
866    #[test]
867    fn test_requires_wasm_false_for_non_wasm_subscription() {
868        let mut m = sample_manifest();
869        m.subscriptions.push(SubscriptionDefinition {
870            subject: "events.foo".into(),
871            handler: "on_foo".into(),
872            handler_type: HandlerType::Webhook,
873            config: HashMap::new(),
874        });
875        assert!(!m.requires_wasm());
876    }
877
878    #[test]
879    fn test_declared_wasm_handlers_collects_all_sources() {
880        let mut m = sample_manifest();
881        m.on_migrate = Some("on_mig".into());
882        m.custom_routes.push(CustomRouteDefinition {
883            method: "GET".into(),
884            path: "/h".into(),
885            handler: "route_h".into(),
886        });
887        m.subscriptions.push(SubscriptionDefinition {
888            subject: "x".into(),
889            handler: "sub_h".into(),
890            handler_type: HandlerType::Wasm,
891            config: HashMap::new(),
892        });
893        m.subscriptions.push(SubscriptionDefinition {
894            subject: "y".into(),
895            handler: "wh_ignored".into(),
896            handler_type: HandlerType::Webhook,
897            config: HashMap::new(),
898        });
899        m.tables[0].hooks = Some(TableHooks {
900            before_create: Some("bc".into()),
901            after_create: Some("ac".into()),
902            before_delete: Some("bc".into()), // duplicate handler name
903            ..Default::default()
904        });
905        let handlers = m.declared_wasm_handlers();
906        assert!(handlers.contains(&"on_mig"));
907        assert!(handlers.contains(&"route_h"));
908        assert!(handlers.contains(&"sub_h"));
909        assert!(handlers.contains(&"bc"));
910        assert!(handlers.contains(&"ac"));
911        assert!(!handlers.contains(&"wh_ignored"));
912        // Deduplicated
913        assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
914    }
915}