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