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