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}
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 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub owner_field: Option<String>,
56 #[serde(default)]
59 pub auth_required: bool,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub permission_area: Option<String>,
65 #[serde(default)]
73 pub owner_self_service: bool,
74 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub references: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub on_delete: Option<ForeignKeyAction>,
96 #[serde(default)]
98 pub unique: bool,
99 #[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")]
106pub enum ValidationRule {
108 Regex { pattern: String },
110 Min { value: f64 },
112 Max { value: f64 },
114 MinLength { value: usize },
116 MaxLength { value: usize },
118 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 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 #[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 #[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 pub timeout_secs: u32,
244 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct AuthorizationConfig {
283 #[serde(default)]
285 pub areas: Vec<PermissionAreaDef>,
286 #[serde(default)]
288 pub default_roles: Vec<DefaultRoleDef>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct PermissionAreaDef {
294 pub name: String,
295 #[serde(default)]
296 pub operations: Vec<String>,
297}
298
299#[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#[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 #[serde(default)]
322 pub added_columns: Vec<(String, String)>,
323 #[serde(default)]
327 pub dropped_columns: Vec<(String, String)>,
328 #[serde(default)]
330 pub type_changes: Vec<(String, String, String, String)>,
331 #[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 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
370 self.tables.iter().find(|t| t.name == name)
371 }
372
373 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 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 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 pub fn primary_key(&self) -> Option<&ColumnDefinition> {
477 self.columns.iter().find(|c| c.primary_key)
478 }
479
480 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()), ..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 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}