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