1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub mod openapi;
5pub mod policy;
6pub mod slug;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ServiceManifest {
12 pub name: String,
13 pub version: Option<String>,
14 pub tables: Vec<TableDefinition>,
15 #[serde(default)]
16 pub cells: Vec<CellDefinition>,
17 #[serde(default)]
18 pub events: Vec<EventDefinition>,
19 #[serde(default)]
20 pub subscriptions: Vec<SubscriptionDefinition>,
21 #[serde(default)]
22 pub custom_routes: Vec<CustomRouteDefinition>,
23 #[serde(default)]
24 pub mode: ServiceMode,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub authorization: Option<AuthorizationConfig>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub on_migrate: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub publishes: Option<Vec<String>>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub required_platform_features: Vec<String>,
48 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51 pub graphql: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum ServiceMode {
57 #[default]
58 Crud,
59 Wasm,
60 Container,
61 Web,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TableDefinition {
66 pub name: String,
67 pub columns: Vec<ColumnDefinition>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub indexes: Vec<IndexDefinition>,
70 #[serde(default)]
71 pub soft_delete: bool,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub owner_field: Option<String>,
76 #[serde(default)]
79 pub auth_required: bool,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub permission_area: Option<String>,
85 #[serde(default)]
93 pub owner_self_service: bool,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub hooks: Option<TableHooks>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ColumnDefinition {
101 pub name: String,
102 pub column_type: ColumnType,
103 #[serde(default)]
104 pub primary_key: bool,
105 #[serde(default)]
106 pub nullable: bool,
107 #[serde(default)]
108 pub auto_generate: bool,
109 pub default_value: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub references: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub on_delete: Option<ForeignKeyAction>,
116 #[serde(default)]
118 pub unique: bool,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub validations: Vec<ValidationRule>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125#[serde(tag = "rule", rename_all = "snake_case")]
126pub enum ValidationRule {
128 Regex { pattern: String },
130 Min { value: f64 },
132 Max { value: f64 },
134 MinLength { value: usize },
136 MaxLength { value: usize },
138 OneOf { values: Vec<String> },
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct IndexDefinition {
144 pub name: String,
145 pub columns: Vec<String>,
146 #[serde(default)]
147 pub unique: bool,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum ForeignKeyAction {
153 Cascade,
154 SetNull,
155 Restrict,
156 NoAction,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160#[serde(rename_all = "lowercase")]
161pub enum ColumnType {
162 Uuid,
163 Text,
164 Integer,
165 BigInteger,
166 Float,
167 Double,
168 Boolean,
169 Timestamp,
170 Date,
171 Jsonb,
172}
173
174impl ColumnType {
175 pub fn to_sql(&self) -> &str {
176 match self {
177 ColumnType::Uuid => "UUID",
178 ColumnType::Text => "TEXT",
179 ColumnType::Integer => "INTEGER",
180 ColumnType::BigInteger => "BIGINT",
181 ColumnType::Float => "REAL",
182 ColumnType::Double => "DOUBLE PRECISION",
183 ColumnType::Boolean => "BOOLEAN",
184 ColumnType::Timestamp => "TIMESTAMPTZ",
185 ColumnType::Date => "DATE",
186 ColumnType::Jsonb => "JSONB",
187 }
188 }
189
190 pub fn filter_type(&self) -> &str {
192 match self {
193 ColumnType::Uuid => "uuid",
194 ColumnType::Text => "text",
195 ColumnType::Integer | ColumnType::BigInteger => "integer",
196 ColumnType::Float | ColumnType::Double => "double",
197 ColumnType::Boolean => "boolean",
198 ColumnType::Timestamp => "timestamp",
199 ColumnType::Date => "date",
200 ColumnType::Jsonb => "jsonb",
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct CellDefinition {
207 pub name: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct EventDefinition {
212 pub name: String,
213 pub table: String,
214 pub action: EventAction,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum EventAction {
220 Create,
221 Update,
222 Delete,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct SubscriptionDefinition {
227 pub subject: String,
228 pub handler: String,
229 pub handler_type: HandlerType,
230 #[serde(default)]
231 pub config: HashMap<String, String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub stream: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum HandlerType {
241 DeleteCascade,
242 UpdateField,
243 Webhook,
244 Wasm,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct CustomRouteDefinition {
249 pub method: String,
250 pub path: String,
251 pub handler: String,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub job: Option<JobConfig>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub posture: Option<RoutePosture>,
264 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
268 pub forward_auth: bool,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(tag = "kind", rename_all = "snake_case")]
275pub enum RoutePosture {
276 Public { reason: String },
279 Authenticated,
281 Permission { area: String, operation: String },
283 Owner { field: String },
286}
287
288impl RoutePosture {
289 pub fn is_anonymous(&self) -> bool {
291 matches!(self, RoutePosture::Public { .. })
292 }
293
294 pub fn label(&self) -> &'static str {
296 match self {
297 RoutePosture::Public { .. } => "public",
298 RoutePosture::Authenticated => "authenticated",
299 RoutePosture::Permission { .. } => "permission",
300 RoutePosture::Owner { .. } => "owner",
301 }
302 }
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
306pub struct JobConfig {
307 pub timeout_secs: u32,
309 pub max_attempts: u32,
312}
313
314impl JobConfig {
315 pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
316 pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
317}
318
319impl Default for JobConfig {
320 fn default() -> Self {
321 Self {
322 timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
323 max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, Default)]
330pub struct TableHooks {
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub before_create: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub after_create: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub before_update: Option<String>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub after_update: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub before_delete: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub after_delete: Option<String>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct AuthorizationConfig {
348 #[serde(default)]
350 pub areas: Vec<PermissionAreaDef>,
351 #[serde(default)]
353 pub default_roles: Vec<DefaultRoleDef>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct PermissionAreaDef {
359 pub name: String,
360 #[serde(default)]
361 pub operations: Vec<String>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct DefaultRoleDef {
367 pub name: String,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub description: Option<String>,
370 #[serde(default)]
371 pub permissions: Vec<String>,
372}
373
374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378pub struct SchemaDiff {
379 #[serde(default)]
380 pub added_tables: Vec<String>,
381 #[serde(default)]
382 pub dropped_tables: Vec<String>,
383 #[serde(default)]
387 pub added_columns: Vec<(String, String)>,
388 #[serde(default)]
392 pub dropped_columns: Vec<(String, String)>,
393 #[serde(default)]
395 pub type_changes: Vec<(String, String, String, String)>,
396 #[serde(default)]
398 pub nullability_changes: Vec<(String, String, bool)>,
399}
400
401impl SchemaDiff {
402 pub fn added_column(&self, table: &str, col: &str) -> bool {
403 self.added_columns
404 .iter()
405 .any(|(t, c)| t == table && c == col)
406 }
407
408 pub fn dropped_column(&self, table: &str, col: &str) -> bool {
409 self.dropped_columns
410 .iter()
411 .any(|(t, c)| t == table && c == col)
412 }
413
414 pub fn added_table(&self, table: &str) -> bool {
415 self.added_tables.iter().any(|t| t == table)
416 }
417
418 pub fn dropped_table(&self, table: &str) -> bool {
419 self.dropped_tables.iter().any(|t| t == table)
420 }
421
422 pub fn is_empty(&self) -> bool {
423 self.added_tables.is_empty()
424 && self.dropped_tables.is_empty()
425 && self.added_columns.is_empty()
426 && self.dropped_columns.is_empty()
427 && self.type_changes.is_empty()
428 && self.nullability_changes.is_empty()
429 }
430}
431
432impl ServiceManifest {
433 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
435 self.tables.iter().find(|t| t.name == name)
436 }
437
438 pub fn requires_wasm(&self) -> bool {
446 if self.mode == ServiceMode::Wasm {
447 return true;
448 }
449 if self.on_migrate.is_some() {
450 return true;
451 }
452 if self.tables.iter().any(|t| t.hooks.is_some()) {
453 return true;
454 }
455 if !self.custom_routes.is_empty() {
456 return true;
457 }
458 if self
459 .subscriptions
460 .iter()
461 .any(|s| matches!(s.handler_type, HandlerType::Wasm))
462 {
463 return true;
464 }
465 false
466 }
467
468 pub fn declared_wasm_handlers(&self) -> Vec<&str> {
472 let mut handlers: Vec<&str> = Vec::new();
473 if let Some(ref h) = self.on_migrate {
474 handlers.push(h.as_str());
475 }
476 for route in &self.custom_routes {
477 handlers.push(route.handler.as_str());
478 }
479 for sub in &self.subscriptions {
480 if matches!(sub.handler_type, HandlerType::Wasm) {
481 handlers.push(sub.handler.as_str());
482 }
483 }
484 for table in &self.tables {
485 if let Some(ref hooks) = table.hooks {
486 for h in [
487 hooks.before_create.as_deref(),
488 hooks.after_create.as_deref(),
489 hooks.before_update.as_deref(),
490 hooks.after_update.as_deref(),
491 hooks.before_delete.as_deref(),
492 hooks.after_delete.as_deref(),
493 ]
494 .iter()
495 .flatten()
496 {
497 handlers.push(h);
498 }
499 }
500 }
501 handlers.sort();
502 handlers.dedup();
503 handlers
504 }
505
506 pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
514 self.custom_routes.iter().find(|r| r.handler == handler)
515 }
516
517 pub fn is_posture_aware(&self) -> bool {
522 self.custom_routes.iter().any(|r| r.posture.is_some())
523 }
524
525 pub fn deploy_hash(&self) -> String {
528 use sha2::{Digest, Sha256};
529 hex::encode(Sha256::digest(serde_json::to_vec(self).unwrap_or_default()))
530 }
531
532 pub fn schema_hash(&self) -> String {
534 use sha2::{Digest, Sha256};
535 let mut hasher = Sha256::new();
536 hasher.update(
537 serde_json::to_string(&self.tables)
538 .unwrap_or_default()
539 .as_bytes(),
540 );
541 hasher.update(
542 serde_json::to_string(&self.custom_routes)
543 .unwrap_or_default()
544 .as_bytes(),
545 );
546 hasher.update(
547 serde_json::to_string(&self.subscriptions)
548 .unwrap_or_default()
549 .as_bytes(),
550 );
551 hasher.update(
552 serde_json::to_string(&self.authorization)
553 .unwrap_or_default()
554 .as_bytes(),
555 );
556 hasher.update(
557 serde_json::to_string(&self.on_migrate)
558 .unwrap_or_default()
559 .as_bytes(),
560 );
561 hex::encode(hasher.finalize())
562 }
563}
564
565impl TableDefinition {
566 pub fn primary_key(&self) -> Option<&ColumnDefinition> {
568 self.columns.iter().find(|c| c.primary_key)
569 }
570
571 pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
573 self.columns
574 .iter()
575 .filter(|c| !c.auto_generate || !c.primary_key)
576 .collect()
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn sample_manifest() -> ServiceManifest {
585 ServiceManifest {
586 name: "test-service".into(),
587 version: Some("1.0.0".into()),
588 tables: vec![TableDefinition {
589 name: "todos".into(),
590 columns: vec![
591 ColumnDefinition {
592 name: "id".into(),
593 column_type: ColumnType::Uuid,
594 primary_key: true,
595 nullable: false,
596 auto_generate: true,
597 default_value: None,
598 references: None,
599 on_delete: None,
600 unique: false,
601 validations: vec![],
602 },
603 ColumnDefinition {
604 name: "title".into(),
605 column_type: ColumnType::Text,
606 primary_key: false,
607 nullable: false,
608 auto_generate: false,
609 default_value: None,
610 references: None,
611 on_delete: None,
612 unique: false,
613 validations: vec![],
614 },
615 ColumnDefinition {
616 name: "user_id".into(),
617 column_type: ColumnType::Uuid,
618 primary_key: false,
619 nullable: false,
620 auto_generate: false,
621 default_value: None,
622 references: Some("users.id".into()),
623 on_delete: Some(ForeignKeyAction::Cascade),
624 unique: false,
625 validations: vec![],
626 },
627 ],
628 indexes: vec![],
629 soft_delete: false,
630 owner_field: None,
631 auth_required: false,
632 permission_area: None,
633 owner_self_service: false,
634 hooks: None,
635 }],
636 cells: vec![],
637 events: vec![],
638 subscriptions: vec![],
639 custom_routes: vec![],
640 mode: ServiceMode::Crud,
641 authorization: None,
642 on_migrate: None,
643 publishes: None,
644 required_platform_features: vec![],
645 graphql: false,
646 }
647 }
648
649 #[test]
650 fn test_manifest_serialization_roundtrip() {
651 let manifest = sample_manifest();
652 let json = serde_json::to_string(&manifest).unwrap();
653 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
654 assert_eq!(parsed.name, "test-service");
655 assert_eq!(parsed.tables.len(), 1);
656 assert_eq!(parsed.tables[0].columns.len(), 3);
657 }
658
659 #[test]
660 fn test_get_table() {
661 let manifest = sample_manifest();
662 assert!(manifest.get_table("todos").is_some());
663 assert!(manifest.get_table("nonexistent").is_none());
664 }
665
666 #[test]
667 fn test_primary_key() {
668 let manifest = sample_manifest();
669 let table = manifest.get_table("todos").unwrap();
670 let pk = table.primary_key().unwrap();
671 assert_eq!(pk.name, "id");
672 assert!(pk.auto_generate);
673 }
674
675 #[test]
676 fn test_schema_hash_deterministic() {
677 let m1 = sample_manifest();
678 let m2 = sample_manifest();
679 assert_eq!(m1.schema_hash(), m2.schema_hash());
680 }
681
682 #[test]
683 fn test_schema_hash_changes() {
684 let mut m1 = sample_manifest();
685 let m2 = sample_manifest();
686 m1.tables[0].columns.push(ColumnDefinition {
687 name: "extra".into(),
688 column_type: ColumnType::Text,
689 primary_key: false,
690 nullable: true,
691 auto_generate: false,
692 default_value: None,
693 references: None,
694 on_delete: None,
695 unique: false,
696 validations: vec![],
697 });
698 assert_ne!(m1.schema_hash(), m2.schema_hash());
699 }
700
701 #[test]
702 fn test_column_type_to_sql() {
703 assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
704 assert_eq!(ColumnType::Text.to_sql(), "TEXT");
705 assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
706 assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
707 assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
708 assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
709 assert_eq!(ColumnType::Date.to_sql(), "DATE");
710 assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
711 }
712
713 #[test]
714 fn test_fk_serialization() {
715 let manifest = sample_manifest();
716 let json = serde_json::to_string(&manifest).unwrap();
717 assert!(json.contains("\"references\":\"users.id\""));
718 assert!(json.contains("\"on_delete\":\"cascade\""));
719
720 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
721 let user_id_col = &parsed.tables[0].columns[2];
722 assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
723 assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
724 }
725
726 #[test]
727 fn test_fk_not_serialized_when_none() {
728 let col = ColumnDefinition {
729 name: "title".into(),
730 column_type: ColumnType::Text,
731 primary_key: false,
732 nullable: false,
733 auto_generate: false,
734 default_value: None,
735 references: None,
736 on_delete: None,
737 unique: false,
738 validations: vec![],
739 };
740 let json = serde_json::to_string(&col).unwrap();
741 assert!(!json.contains("references"));
742 assert!(!json.contains("on_delete"));
743 }
744
745 #[test]
746 fn test_unique_column_serialization() {
747 let col = ColumnDefinition {
748 name: "email".into(),
749 column_type: ColumnType::Text,
750 primary_key: false,
751 nullable: false,
752 auto_generate: false,
753 default_value: None,
754 references: None,
755 on_delete: None,
756 unique: true,
757 validations: vec![],
758 };
759 let json = serde_json::to_string(&col).unwrap();
760 assert!(json.contains("\"unique\":true"));
761 let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
762 assert!(parsed.unique);
763 }
764
765 #[test]
766 fn test_index_serialization() {
767 let table = TableDefinition {
768 name: "users".into(),
769 columns: vec![],
770 indexes: vec![IndexDefinition {
771 name: "idx_users_email".into(),
772 columns: vec!["email".into()],
773 unique: true,
774 }],
775 soft_delete: false,
776 owner_field: None,
777 auth_required: false,
778 permission_area: None,
779 owner_self_service: false,
780 hooks: None,
781 };
782 let json = serde_json::to_string(&table).unwrap();
783 assert!(json.contains("idx_users_email"));
784 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
785 assert_eq!(parsed.indexes.len(), 1);
786 assert!(parsed.indexes[0].unique);
787 }
788
789 #[test]
790 fn test_indexes_not_serialized_when_empty() {
791 let table = TableDefinition {
792 name: "users".into(),
793 columns: vec![],
794 indexes: vec![],
795 soft_delete: false,
796 owner_field: None,
797 auth_required: false,
798 permission_area: None,
799 owner_self_service: false,
800 hooks: None,
801 };
802 let json = serde_json::to_string(&table).unwrap();
803 assert!(!json.contains("indexes"));
804 }
805
806 #[test]
807 fn test_service_mode_default() {
808 let json = r#"{"name":"svc","tables":[]}"#;
809 let m: ServiceManifest = serde_json::from_str(json).unwrap();
810 assert_eq!(m.mode, ServiceMode::Crud);
811 }
812
813 #[test]
814 fn test_owner_field_serialization() {
815 let table = TableDefinition {
816 name: "notes".into(),
817 columns: vec![],
818 indexes: vec![],
819 soft_delete: false,
820 owner_field: Some("user_id".into()),
821 auth_required: false,
822 permission_area: None,
823 owner_self_service: false,
824 hooks: None,
825 };
826 let json = serde_json::to_string(&table).unwrap();
827 assert!(json.contains("\"owner_field\":\"user_id\""));
828 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
829 assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
830 }
831
832 #[test]
833 fn test_owner_field_not_serialized_when_none() {
834 let table = TableDefinition {
835 name: "notes".into(),
836 columns: vec![],
837 indexes: vec![],
838 soft_delete: false,
839 owner_field: None,
840 auth_required: false,
841 permission_area: None,
842 owner_self_service: false,
843 hooks: None,
844 };
845 let json = serde_json::to_string(&table).unwrap();
846 assert!(!json.contains("owner_field"));
847 }
848
849 #[test]
850 fn test_owner_field_defaults_to_none() {
851 let json = r#"{"name":"notes","columns":[]}"#;
852 let table: TableDefinition = serde_json::from_str(json).unwrap();
853 assert!(table.owner_field.is_none());
854 }
855
856 #[test]
857 fn test_auth_required_serialization() {
858 let table = TableDefinition {
859 name: "orders".into(),
860 columns: vec![],
861 indexes: vec![],
862 soft_delete: false,
863 owner_field: None,
864 auth_required: true,
865 permission_area: None,
866 owner_self_service: false,
867 hooks: None,
868 };
869 let json = serde_json::to_string(&table).unwrap();
870 assert!(json.contains("\"auth_required\":true"));
871 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
872 assert!(parsed.auth_required);
873 }
874
875 #[test]
876 fn test_auth_required_defaults_to_false() {
877 let json = r#"{"name":"orders","columns":[]}"#;
878 let table: TableDefinition = serde_json::from_str(json).unwrap();
879 assert!(!table.auth_required);
880 }
881
882 #[test]
883 fn test_on_migrate_defaults_to_none() {
884 let json = r#"{"name":"svc","tables":[]}"#;
885 let m: ServiceManifest = serde_json::from_str(json).unwrap();
886 assert!(m.on_migrate.is_none());
887 }
888
889 #[test]
890 fn test_on_migrate_serialization_roundtrip() {
891 let mut m = sample_manifest();
892 m.on_migrate = Some("handle_on_migrate".into());
893 let json = serde_json::to_string(&m).unwrap();
894 assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
895 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
896 assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
897 }
898
899 #[test]
900 fn test_on_migrate_skipped_when_none() {
901 let m = sample_manifest();
902 let json = serde_json::to_string(&m).unwrap();
903 assert!(!json.contains("on_migrate"));
904 }
905
906 #[test]
907 fn deploy_hash_covers_fields_schema_hash_ignores() {
908 let m1 = sample_manifest();
909 let mut m2 = sample_manifest();
910 assert_eq!(m1.deploy_hash(), m2.deploy_hash());
911 m2.publishes = Some(vec!["acme.orders.>".into()]);
912 assert_ne!(m1.deploy_hash(), m2.deploy_hash());
913 assert_eq!(m1.schema_hash(), m2.schema_hash());
914 }
915
916 #[test]
917 fn test_on_migrate_changes_schema_hash() {
918 let m1 = sample_manifest();
919 let mut m2 = sample_manifest();
920 m2.on_migrate = Some("handle_on_migrate".into());
921 assert_ne!(m1.schema_hash(), m2.schema_hash());
922 }
923
924 #[test]
925 fn test_subscription_stream_defaults_to_none_in_legacy_json() {
926 let json = r#"{"subject":"x","handler":"h","handler_type":"wasm"}"#;
927 let sub: SubscriptionDefinition = serde_json::from_str(json).unwrap();
928 assert!(sub.stream.is_none());
929 }
930
931 #[test]
932 fn test_subscription_stream_roundtrip_when_set() {
933 let sub = SubscriptionDefinition {
934 subject: "cufflink.tick.5min".into(),
935 handler: "handle_tick_resync".into(),
936 handler_type: HandlerType::Wasm,
937 config: HashMap::new(),
938 stream: Some("CUFFLINK_TICKS".into()),
939 };
940 let json = serde_json::to_string(&sub).unwrap();
941 assert!(json.contains("\"stream\":\"CUFFLINK_TICKS\""));
942 let parsed: SubscriptionDefinition = serde_json::from_str(&json).unwrap();
943 assert_eq!(parsed.stream.as_deref(), Some("CUFFLINK_TICKS"));
944 }
945
946 #[test]
947 fn test_subscription_stream_skipped_when_none() {
948 let sub = SubscriptionDefinition {
949 subject: "x".into(),
950 handler: "h".into(),
951 handler_type: HandlerType::Wasm,
952 config: HashMap::new(),
953 stream: None,
954 };
955 let json = serde_json::to_string(&sub).unwrap();
956 assert!(!json.contains("stream"));
957 }
958
959 #[test]
960 fn test_schema_diff_helpers() {
961 let diff = SchemaDiff {
962 added_tables: vec!["batches".into()],
963 dropped_tables: vec!["legacy_batches".into()],
964 added_columns: vec![("pickups".into(), "min".into())],
965 dropped_columns: vec![("pickups".into(), "midpoint".into())],
966 type_changes: vec![],
967 nullability_changes: vec![],
968 };
969 assert!(!diff.is_empty());
970 assert!(diff.added_table("batches"));
971 assert!(!diff.added_table("nope"));
972 assert!(diff.dropped_table("legacy_batches"));
973 assert!(diff.added_column("pickups", "min"));
974 assert!(!diff.added_column("pickups", "max"));
975 assert!(diff.dropped_column("pickups", "midpoint"));
976 }
977
978 #[test]
979 fn test_schema_diff_is_empty_default() {
980 assert!(SchemaDiff::default().is_empty());
981 }
982
983 #[test]
984 fn test_schema_diff_serialization_roundtrip() {
985 let diff = SchemaDiff {
986 added_tables: vec![],
987 dropped_tables: vec![],
988 added_columns: vec![("t".into(), "c".into())],
989 dropped_columns: vec![],
990 type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
991 nullability_changes: vec![("t".into(), "c".into(), false)],
992 };
993 let json = serde_json::to_string(&diff).unwrap();
994 let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
995 assert!(parsed.added_column("t", "c"));
996 assert_eq!(parsed.type_changes.len(), 1);
997 assert_eq!(parsed.nullability_changes.len(), 1);
998 }
999
1000 #[test]
1001 fn test_requires_wasm_false_for_plain_crud() {
1002 let m = sample_manifest();
1003 assert!(!m.requires_wasm());
1004 }
1005
1006 #[test]
1007 fn test_requires_wasm_true_for_wasm_mode() {
1008 let mut m = sample_manifest();
1009 m.mode = ServiceMode::Wasm;
1010 assert!(m.requires_wasm());
1011 }
1012
1013 #[test]
1014 fn test_requires_wasm_true_for_on_migrate() {
1015 let mut m = sample_manifest();
1016 m.on_migrate = Some("handle_on_migrate".into());
1017 assert!(m.requires_wasm());
1018 }
1019
1020 #[test]
1021 fn test_requires_wasm_true_for_table_hooks() {
1022 let mut m = sample_manifest();
1023 m.tables[0].hooks = Some(TableHooks {
1024 before_create: Some("validate".into()),
1025 ..Default::default()
1026 });
1027 assert!(m.requires_wasm());
1028 }
1029
1030 #[test]
1031 fn test_requires_wasm_true_for_custom_routes() {
1032 let mut m = sample_manifest();
1033 m.custom_routes.push(CustomRouteDefinition {
1034 method: "GET".into(),
1035 path: "/hello".into(),
1036 handler: "hello".into(),
1037 job: None,
1038 posture: None,
1039 forward_auth: false,
1040 });
1041 assert!(m.requires_wasm());
1042 }
1043
1044 #[test]
1045 fn test_requires_wasm_true_for_wasm_subscription() {
1046 let mut m = sample_manifest();
1047 m.subscriptions.push(SubscriptionDefinition {
1048 subject: "events.foo".into(),
1049 handler: "on_foo".into(),
1050 handler_type: HandlerType::Wasm,
1051 config: HashMap::new(),
1052 stream: None,
1053 });
1054 assert!(m.requires_wasm());
1055 }
1056
1057 #[test]
1058 fn test_requires_wasm_false_for_non_wasm_subscription() {
1059 let mut m = sample_manifest();
1060 m.subscriptions.push(SubscriptionDefinition {
1061 subject: "events.foo".into(),
1062 handler: "on_foo".into(),
1063 handler_type: HandlerType::Webhook,
1064 config: HashMap::new(),
1065 stream: None,
1066 });
1067 assert!(!m.requires_wasm());
1068 }
1069
1070 #[test]
1071 fn test_declared_wasm_handlers_collects_all_sources() {
1072 let mut m = sample_manifest();
1073 m.on_migrate = Some("on_mig".into());
1074 m.custom_routes.push(CustomRouteDefinition {
1075 method: "GET".into(),
1076 path: "/h".into(),
1077 handler: "route_h".into(),
1078 job: None,
1079 posture: None,
1080 forward_auth: false,
1081 });
1082 m.subscriptions.push(SubscriptionDefinition {
1083 subject: "x".into(),
1084 handler: "sub_h".into(),
1085 handler_type: HandlerType::Wasm,
1086 config: HashMap::new(),
1087 stream: None,
1088 });
1089 m.subscriptions.push(SubscriptionDefinition {
1090 subject: "y".into(),
1091 handler: "wh_ignored".into(),
1092 handler_type: HandlerType::Webhook,
1093 config: HashMap::new(),
1094 stream: None,
1095 });
1096 m.tables[0].hooks = Some(TableHooks {
1097 before_create: Some("bc".into()),
1098 after_create: Some("ac".into()),
1099 before_delete: Some("bc".into()), ..Default::default()
1101 });
1102 let handlers = m.declared_wasm_handlers();
1103 assert!(handlers.contains(&"on_mig"));
1104 assert!(handlers.contains(&"route_h"));
1105 assert!(handlers.contains(&"sub_h"));
1106 assert!(handlers.contains(&"bc"));
1107 assert!(handlers.contains(&"ac"));
1108 assert!(!handlers.contains(&"wh_ignored"));
1109 assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
1111 }
1112
1113 #[test]
1114 fn http_route_exposes_only_custom_routes() {
1115 let mut m = sample_manifest();
1116 m.on_migrate = Some("on_mig".into());
1117 m.custom_routes.push(CustomRouteDefinition {
1118 method: "GET".into(),
1119 path: "/h".into(),
1120 handler: "route_h".into(),
1121 job: None,
1122 posture: None,
1123 forward_auth: false,
1124 });
1125 m.subscriptions.push(SubscriptionDefinition {
1126 subject: "evt.sub".into(),
1127 handler: "sub_h".into(),
1128 handler_type: HandlerType::Wasm,
1129 config: HashMap::new(),
1130 stream: None,
1131 });
1132 m.tables[0].hooks = Some(TableHooks {
1133 after_create: Some("hook_h".into()),
1134 ..Default::default()
1135 });
1136
1137 assert_eq!(
1138 m.http_route("route_h").map(|r| r.handler.as_str()),
1139 Some("route_h")
1140 );
1141 assert!(m.http_route("sub_h").is_none());
1142 assert!(m.http_route("on_mig").is_none());
1143 assert!(m.http_route("hook_h").is_none());
1144 assert!(m.http_route("unknown").is_none());
1145 }
1146
1147 #[test]
1148 fn http_route_allows_handler_declared_as_both_route_and_subscription() {
1149 let mut m = sample_manifest();
1150 m.custom_routes.push(CustomRouteDefinition {
1151 method: "POST".into(),
1152 path: "/dual".into(),
1153 handler: "dual".into(),
1154 job: Some(JobConfig::default()),
1155 posture: None,
1156 forward_auth: false,
1157 });
1158 m.subscriptions.push(SubscriptionDefinition {
1159 subject: "evt.dual".into(),
1160 handler: "dual".into(),
1161 handler_type: HandlerType::Wasm,
1162 config: HashMap::new(),
1163 stream: None,
1164 });
1165
1166 let route = m
1167 .http_route("dual")
1168 .expect("dual-purpose handler stays HTTP-invocable");
1169 assert!(route.job.is_some());
1170 }
1171
1172 #[test]
1173 fn job_config_defaults() {
1174 let cfg = JobConfig::default();
1175 assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
1176 assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
1177 }
1178
1179 #[test]
1180 fn custom_route_serde_round_trip_with_job() {
1181 let route = CustomRouteDefinition {
1182 method: "POST".into(),
1183 path: "/run".into(),
1184 handler: "do_thing".into(),
1185 job: Some(JobConfig {
1186 timeout_secs: 900,
1187 max_attempts: 2,
1188 }),
1189 posture: None,
1190 forward_auth: false,
1191 };
1192 let json = serde_json::to_string(&route).unwrap();
1193 let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
1194 assert_eq!(round.method, "POST");
1195 let job = round.job.unwrap();
1196 assert_eq!(job.timeout_secs, 900);
1197 assert_eq!(job.max_attempts, 2);
1198 }
1199
1200 #[test]
1201 fn forward_auth_defaults_off_and_is_only_serialized_when_set() {
1202 let stored: CustomRouteDefinition =
1203 serde_json::from_str(r#"{"method":"GET","path":"/x","handler":"x"}"#).unwrap();
1204 assert!(!stored.forward_auth);
1205 let json = serde_json::to_string(&stored).unwrap();
1206 assert!(!json.contains("forward_auth"), "{json}");
1207 let forwarding = CustomRouteDefinition {
1208 forward_auth: true,
1209 ..stored
1210 };
1211 let json = serde_json::to_value(&forwarding).unwrap();
1212 assert_eq!(json["forward_auth"], true);
1213 }
1214
1215 #[test]
1216 fn custom_route_without_job_serializes_without_field() {
1217 let route = CustomRouteDefinition {
1218 method: "GET".into(),
1219 path: "/x".into(),
1220 handler: "x".into(),
1221 job: None,
1222 posture: None,
1223 forward_auth: false,
1224 };
1225 let json = serde_json::to_value(&route).unwrap();
1226 assert!(json.get("job").is_none(), "absent job should be omitted");
1227 assert!(
1228 json.get("posture").is_none(),
1229 "absent posture should be omitted"
1230 );
1231 }
1232
1233 #[test]
1234 fn custom_route_legacy_json_without_job_deserializes() {
1235 let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
1236 let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
1237 assert!(route.job.is_none());
1238 assert!(route.posture.is_none());
1239 }
1240
1241 #[test]
1242 fn route_posture_serde_tagged_round_trip() {
1243 let cases = [
1244 RoutePosture::Public {
1245 reason: "stripe webhook".into(),
1246 },
1247 RoutePosture::Authenticated,
1248 RoutePosture::Permission {
1249 area: "items".into(),
1250 operation: "edit".into(),
1251 },
1252 RoutePosture::Owner {
1253 field: "customer_id".into(),
1254 },
1255 ];
1256 for p in cases {
1257 let json = serde_json::to_string(&p).unwrap();
1258 let round: RoutePosture = serde_json::from_str(&json).unwrap();
1259 assert_eq!(round, p);
1260 }
1261 let public = serde_json::to_value(RoutePosture::Authenticated).unwrap();
1262 assert_eq!(public["kind"], "authenticated");
1263 }
1264
1265 #[test]
1266 fn posture_aware_detects_any_declared_route() {
1267 let mut m = sample_manifest();
1268 assert!(!m.is_posture_aware());
1269 m.custom_routes.push(CustomRouteDefinition {
1270 method: "GET".into(),
1271 path: "/a".into(),
1272 handler: "a".into(),
1273 job: None,
1274 posture: None,
1275 forward_auth: false,
1276 });
1277 assert!(!m.is_posture_aware());
1278 m.custom_routes.push(CustomRouteDefinition {
1279 method: "GET".into(),
1280 path: "/b".into(),
1281 handler: "b".into(),
1282 job: None,
1283 posture: Some(RoutePosture::Authenticated),
1284 forward_auth: false,
1285 });
1286 assert!(m.is_posture_aware());
1287 }
1288
1289 #[test]
1290 fn manifest_publishes_and_features_default_absent() {
1291 let json = serde_json::to_value(sample_manifest()).unwrap();
1292 assert!(json.get("publishes").is_none());
1293 assert!(json.get("required_platform_features").is_none());
1294 let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
1295 let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
1296 assert!(parsed.publishes.is_none());
1297 assert!(parsed.required_platform_features.is_empty());
1298 }
1299}