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