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}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum HandlerType {
208 DeleteCascade,
209 UpdateField,
210 Webhook,
211 Wasm,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CustomRouteDefinition {
216 pub method: String,
217 pub path: String,
218 pub handler: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub job: Option<JobConfig>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
228pub struct JobConfig {
229 pub timeout_secs: u32,
231 pub max_attempts: u32,
234}
235
236impl JobConfig {
237 pub const DEFAULT_TIMEOUT_SECS: u32 = 600;
238 pub const DEFAULT_MAX_ATTEMPTS: u32 = 3;
239}
240
241impl Default for JobConfig {
242 fn default() -> Self {
243 Self {
244 timeout_secs: Self::DEFAULT_TIMEOUT_SECS,
245 max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, Default)]
252pub struct TableHooks {
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub before_create: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub after_create: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub before_update: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub after_update: Option<String>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub before_delete: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub after_delete: Option<String>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct AuthorizationConfig {
270 #[serde(default)]
272 pub areas: Vec<PermissionAreaDef>,
273 #[serde(default)]
275 pub default_roles: Vec<DefaultRoleDef>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct PermissionAreaDef {
281 pub name: String,
282 #[serde(default)]
283 pub operations: Vec<String>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct DefaultRoleDef {
289 pub name: String,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub description: Option<String>,
292 #[serde(default)]
293 pub permissions: Vec<String>,
294}
295
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct SchemaDiff {
301 #[serde(default)]
302 pub added_tables: Vec<String>,
303 #[serde(default)]
304 pub dropped_tables: Vec<String>,
305 #[serde(default)]
309 pub added_columns: Vec<(String, String)>,
310 #[serde(default)]
314 pub dropped_columns: Vec<(String, String)>,
315 #[serde(default)]
317 pub type_changes: Vec<(String, String, String, String)>,
318 #[serde(default)]
320 pub nullability_changes: Vec<(String, String, bool)>,
321}
322
323impl SchemaDiff {
324 pub fn added_column(&self, table: &str, col: &str) -> bool {
325 self.added_columns
326 .iter()
327 .any(|(t, c)| t == table && c == col)
328 }
329
330 pub fn dropped_column(&self, table: &str, col: &str) -> bool {
331 self.dropped_columns
332 .iter()
333 .any(|(t, c)| t == table && c == col)
334 }
335
336 pub fn added_table(&self, table: &str) -> bool {
337 self.added_tables.iter().any(|t| t == table)
338 }
339
340 pub fn dropped_table(&self, table: &str) -> bool {
341 self.dropped_tables.iter().any(|t| t == table)
342 }
343
344 pub fn is_empty(&self) -> bool {
345 self.added_tables.is_empty()
346 && self.dropped_tables.is_empty()
347 && self.added_columns.is_empty()
348 && self.dropped_columns.is_empty()
349 && self.type_changes.is_empty()
350 && self.nullability_changes.is_empty()
351 }
352}
353
354impl ServiceManifest {
355 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
357 self.tables.iter().find(|t| t.name == name)
358 }
359
360 pub fn requires_wasm(&self) -> bool {
368 if self.mode == ServiceMode::Wasm {
369 return true;
370 }
371 if self.on_migrate.is_some() {
372 return true;
373 }
374 if self.tables.iter().any(|t| t.hooks.is_some()) {
375 return true;
376 }
377 if !self.custom_routes.is_empty() {
378 return true;
379 }
380 if self
381 .subscriptions
382 .iter()
383 .any(|s| matches!(s.handler_type, HandlerType::Wasm))
384 {
385 return true;
386 }
387 false
388 }
389
390 pub fn declared_wasm_handlers(&self) -> Vec<&str> {
394 let mut handlers: Vec<&str> = Vec::new();
395 if let Some(ref h) = self.on_migrate {
396 handlers.push(h.as_str());
397 }
398 for route in &self.custom_routes {
399 handlers.push(route.handler.as_str());
400 }
401 for sub in &self.subscriptions {
402 if matches!(sub.handler_type, HandlerType::Wasm) {
403 handlers.push(sub.handler.as_str());
404 }
405 }
406 for table in &self.tables {
407 if let Some(ref hooks) = table.hooks {
408 for h in [
409 hooks.before_create.as_deref(),
410 hooks.after_create.as_deref(),
411 hooks.before_update.as_deref(),
412 hooks.after_update.as_deref(),
413 hooks.before_delete.as_deref(),
414 hooks.after_delete.as_deref(),
415 ]
416 .iter()
417 .flatten()
418 {
419 handlers.push(h);
420 }
421 }
422 }
423 handlers.sort();
424 handlers.dedup();
425 handlers
426 }
427
428 pub fn schema_hash(&self) -> String {
430 use sha2::{Digest, Sha256};
431 let mut hasher = Sha256::new();
432 hasher.update(
433 serde_json::to_string(&self.tables)
434 .unwrap_or_default()
435 .as_bytes(),
436 );
437 hasher.update(
438 serde_json::to_string(&self.custom_routes)
439 .unwrap_or_default()
440 .as_bytes(),
441 );
442 hasher.update(
443 serde_json::to_string(&self.subscriptions)
444 .unwrap_or_default()
445 .as_bytes(),
446 );
447 hasher.update(
448 serde_json::to_string(&self.authorization)
449 .unwrap_or_default()
450 .as_bytes(),
451 );
452 hasher.update(
453 serde_json::to_string(&self.on_migrate)
454 .unwrap_or_default()
455 .as_bytes(),
456 );
457 hex::encode(hasher.finalize())
458 }
459}
460
461impl TableDefinition {
462 pub fn primary_key(&self) -> Option<&ColumnDefinition> {
464 self.columns.iter().find(|c| c.primary_key)
465 }
466
467 pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
469 self.columns
470 .iter()
471 .filter(|c| !c.auto_generate || !c.primary_key)
472 .collect()
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 fn sample_manifest() -> ServiceManifest {
481 ServiceManifest {
482 name: "test-service".into(),
483 version: Some("1.0.0".into()),
484 tables: vec![TableDefinition {
485 name: "todos".into(),
486 columns: vec![
487 ColumnDefinition {
488 name: "id".into(),
489 column_type: ColumnType::Uuid,
490 primary_key: true,
491 nullable: false,
492 auto_generate: true,
493 default_value: None,
494 references: None,
495 on_delete: None,
496 unique: false,
497 validations: vec![],
498 },
499 ColumnDefinition {
500 name: "title".into(),
501 column_type: ColumnType::Text,
502 primary_key: false,
503 nullable: false,
504 auto_generate: false,
505 default_value: None,
506 references: None,
507 on_delete: None,
508 unique: false,
509 validations: vec![],
510 },
511 ColumnDefinition {
512 name: "user_id".into(),
513 column_type: ColumnType::Uuid,
514 primary_key: false,
515 nullable: false,
516 auto_generate: false,
517 default_value: None,
518 references: Some("users.id".into()),
519 on_delete: Some(ForeignKeyAction::Cascade),
520 unique: false,
521 validations: vec![],
522 },
523 ],
524 indexes: vec![],
525 soft_delete: false,
526 owner_field: None,
527 auth_required: false,
528 permission_area: None,
529 hooks: None,
530 }],
531 cells: vec![],
532 events: vec![],
533 subscriptions: vec![],
534 custom_routes: vec![],
535 mode: ServiceMode::Crud,
536 authorization: None,
537 on_migrate: None,
538 }
539 }
540
541 #[test]
542 fn test_manifest_serialization_roundtrip() {
543 let manifest = sample_manifest();
544 let json = serde_json::to_string(&manifest).unwrap();
545 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
546 assert_eq!(parsed.name, "test-service");
547 assert_eq!(parsed.tables.len(), 1);
548 assert_eq!(parsed.tables[0].columns.len(), 3);
549 }
550
551 #[test]
552 fn test_get_table() {
553 let manifest = sample_manifest();
554 assert!(manifest.get_table("todos").is_some());
555 assert!(manifest.get_table("nonexistent").is_none());
556 }
557
558 #[test]
559 fn test_primary_key() {
560 let manifest = sample_manifest();
561 let table = manifest.get_table("todos").unwrap();
562 let pk = table.primary_key().unwrap();
563 assert_eq!(pk.name, "id");
564 assert!(pk.auto_generate);
565 }
566
567 #[test]
568 fn test_schema_hash_deterministic() {
569 let m1 = sample_manifest();
570 let m2 = sample_manifest();
571 assert_eq!(m1.schema_hash(), m2.schema_hash());
572 }
573
574 #[test]
575 fn test_schema_hash_changes() {
576 let mut m1 = sample_manifest();
577 let m2 = sample_manifest();
578 m1.tables[0].columns.push(ColumnDefinition {
579 name: "extra".into(),
580 column_type: ColumnType::Text,
581 primary_key: false,
582 nullable: true,
583 auto_generate: false,
584 default_value: None,
585 references: None,
586 on_delete: None,
587 unique: false,
588 validations: vec![],
589 });
590 assert_ne!(m1.schema_hash(), m2.schema_hash());
591 }
592
593 #[test]
594 fn test_column_type_to_sql() {
595 assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
596 assert_eq!(ColumnType::Text.to_sql(), "TEXT");
597 assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
598 assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
599 assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
600 assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
601 assert_eq!(ColumnType::Date.to_sql(), "DATE");
602 assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
603 }
604
605 #[test]
606 fn test_fk_serialization() {
607 let manifest = sample_manifest();
608 let json = serde_json::to_string(&manifest).unwrap();
609 assert!(json.contains("\"references\":\"users.id\""));
610 assert!(json.contains("\"on_delete\":\"cascade\""));
611
612 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
613 let user_id_col = &parsed.tables[0].columns[2];
614 assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
615 assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
616 }
617
618 #[test]
619 fn test_fk_not_serialized_when_none() {
620 let col = ColumnDefinition {
621 name: "title".into(),
622 column_type: ColumnType::Text,
623 primary_key: false,
624 nullable: false,
625 auto_generate: false,
626 default_value: None,
627 references: None,
628 on_delete: None,
629 unique: false,
630 validations: vec![],
631 };
632 let json = serde_json::to_string(&col).unwrap();
633 assert!(!json.contains("references"));
634 assert!(!json.contains("on_delete"));
635 }
636
637 #[test]
638 fn test_unique_column_serialization() {
639 let col = ColumnDefinition {
640 name: "email".into(),
641 column_type: ColumnType::Text,
642 primary_key: false,
643 nullable: false,
644 auto_generate: false,
645 default_value: None,
646 references: None,
647 on_delete: None,
648 unique: true,
649 validations: vec![],
650 };
651 let json = serde_json::to_string(&col).unwrap();
652 assert!(json.contains("\"unique\":true"));
653 let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
654 assert!(parsed.unique);
655 }
656
657 #[test]
658 fn test_index_serialization() {
659 let table = TableDefinition {
660 name: "users".into(),
661 columns: vec![],
662 indexes: vec![IndexDefinition {
663 name: "idx_users_email".into(),
664 columns: vec!["email".into()],
665 unique: true,
666 }],
667 soft_delete: false,
668 owner_field: None,
669 auth_required: false,
670 permission_area: None,
671 hooks: None,
672 };
673 let json = serde_json::to_string(&table).unwrap();
674 assert!(json.contains("idx_users_email"));
675 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
676 assert_eq!(parsed.indexes.len(), 1);
677 assert!(parsed.indexes[0].unique);
678 }
679
680 #[test]
681 fn test_indexes_not_serialized_when_empty() {
682 let table = TableDefinition {
683 name: "users".into(),
684 columns: vec![],
685 indexes: vec![],
686 soft_delete: false,
687 owner_field: None,
688 auth_required: false,
689 permission_area: None,
690 hooks: None,
691 };
692 let json = serde_json::to_string(&table).unwrap();
693 assert!(!json.contains("indexes"));
694 }
695
696 #[test]
697 fn test_service_mode_default() {
698 let json = r#"{"name":"svc","tables":[]}"#;
699 let m: ServiceManifest = serde_json::from_str(json).unwrap();
700 assert_eq!(m.mode, ServiceMode::Crud);
701 }
702
703 #[test]
704 fn test_owner_field_serialization() {
705 let table = TableDefinition {
706 name: "notes".into(),
707 columns: vec![],
708 indexes: vec![],
709 soft_delete: false,
710 owner_field: Some("user_id".into()),
711 auth_required: false,
712 permission_area: None,
713 hooks: None,
714 };
715 let json = serde_json::to_string(&table).unwrap();
716 assert!(json.contains("\"owner_field\":\"user_id\""));
717 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
718 assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
719 }
720
721 #[test]
722 fn test_owner_field_not_serialized_when_none() {
723 let table = TableDefinition {
724 name: "notes".into(),
725 columns: vec![],
726 indexes: vec![],
727 soft_delete: false,
728 owner_field: None,
729 auth_required: false,
730 permission_area: None,
731 hooks: None,
732 };
733 let json = serde_json::to_string(&table).unwrap();
734 assert!(!json.contains("owner_field"));
735 }
736
737 #[test]
738 fn test_owner_field_defaults_to_none() {
739 let json = r#"{"name":"notes","columns":[]}"#;
740 let table: TableDefinition = serde_json::from_str(json).unwrap();
741 assert!(table.owner_field.is_none());
742 }
743
744 #[test]
745 fn test_auth_required_serialization() {
746 let table = TableDefinition {
747 name: "orders".into(),
748 columns: vec![],
749 indexes: vec![],
750 soft_delete: false,
751 owner_field: None,
752 auth_required: true,
753 permission_area: None,
754 hooks: None,
755 };
756 let json = serde_json::to_string(&table).unwrap();
757 assert!(json.contains("\"auth_required\":true"));
758 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
759 assert!(parsed.auth_required);
760 }
761
762 #[test]
763 fn test_auth_required_defaults_to_false() {
764 let json = r#"{"name":"orders","columns":[]}"#;
765 let table: TableDefinition = serde_json::from_str(json).unwrap();
766 assert!(!table.auth_required);
767 }
768
769 #[test]
770 fn test_on_migrate_defaults_to_none() {
771 let json = r#"{"name":"svc","tables":[]}"#;
772 let m: ServiceManifest = serde_json::from_str(json).unwrap();
773 assert!(m.on_migrate.is_none());
774 }
775
776 #[test]
777 fn test_on_migrate_serialization_roundtrip() {
778 let mut m = sample_manifest();
779 m.on_migrate = Some("handle_on_migrate".into());
780 let json = serde_json::to_string(&m).unwrap();
781 assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
782 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
783 assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
784 }
785
786 #[test]
787 fn test_on_migrate_skipped_when_none() {
788 let m = sample_manifest();
789 let json = serde_json::to_string(&m).unwrap();
790 assert!(!json.contains("on_migrate"));
791 }
792
793 #[test]
794 fn test_on_migrate_changes_schema_hash() {
795 let m1 = sample_manifest();
796 let mut m2 = sample_manifest();
797 m2.on_migrate = Some("handle_on_migrate".into());
798 assert_ne!(m1.schema_hash(), m2.schema_hash());
799 }
800
801 #[test]
802 fn test_schema_diff_helpers() {
803 let diff = SchemaDiff {
804 added_tables: vec!["batches".into()],
805 dropped_tables: vec!["legacy_batches".into()],
806 added_columns: vec![("pickups".into(), "min".into())],
807 dropped_columns: vec![("pickups".into(), "midpoint".into())],
808 type_changes: vec![],
809 nullability_changes: vec![],
810 };
811 assert!(!diff.is_empty());
812 assert!(diff.added_table("batches"));
813 assert!(!diff.added_table("nope"));
814 assert!(diff.dropped_table("legacy_batches"));
815 assert!(diff.added_column("pickups", "min"));
816 assert!(!diff.added_column("pickups", "max"));
817 assert!(diff.dropped_column("pickups", "midpoint"));
818 }
819
820 #[test]
821 fn test_schema_diff_is_empty_default() {
822 assert!(SchemaDiff::default().is_empty());
823 }
824
825 #[test]
826 fn test_schema_diff_serialization_roundtrip() {
827 let diff = SchemaDiff {
828 added_tables: vec![],
829 dropped_tables: vec![],
830 added_columns: vec![("t".into(), "c".into())],
831 dropped_columns: vec![],
832 type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
833 nullability_changes: vec![("t".into(), "c".into(), false)],
834 };
835 let json = serde_json::to_string(&diff).unwrap();
836 let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
837 assert!(parsed.added_column("t", "c"));
838 assert_eq!(parsed.type_changes.len(), 1);
839 assert_eq!(parsed.nullability_changes.len(), 1);
840 }
841
842 #[test]
843 fn test_requires_wasm_false_for_plain_crud() {
844 let m = sample_manifest();
845 assert!(!m.requires_wasm());
846 }
847
848 #[test]
849 fn test_requires_wasm_true_for_wasm_mode() {
850 let mut m = sample_manifest();
851 m.mode = ServiceMode::Wasm;
852 assert!(m.requires_wasm());
853 }
854
855 #[test]
856 fn test_requires_wasm_true_for_on_migrate() {
857 let mut m = sample_manifest();
858 m.on_migrate = Some("handle_on_migrate".into());
859 assert!(m.requires_wasm());
860 }
861
862 #[test]
863 fn test_requires_wasm_true_for_table_hooks() {
864 let mut m = sample_manifest();
865 m.tables[0].hooks = Some(TableHooks {
866 before_create: Some("validate".into()),
867 ..Default::default()
868 });
869 assert!(m.requires_wasm());
870 }
871
872 #[test]
873 fn test_requires_wasm_true_for_custom_routes() {
874 let mut m = sample_manifest();
875 m.custom_routes.push(CustomRouteDefinition {
876 method: "GET".into(),
877 path: "/hello".into(),
878 handler: "hello".into(),
879 job: None,
880 });
881 assert!(m.requires_wasm());
882 }
883
884 #[test]
885 fn test_requires_wasm_true_for_wasm_subscription() {
886 let mut m = sample_manifest();
887 m.subscriptions.push(SubscriptionDefinition {
888 subject: "events.foo".into(),
889 handler: "on_foo".into(),
890 handler_type: HandlerType::Wasm,
891 config: HashMap::new(),
892 });
893 assert!(m.requires_wasm());
894 }
895
896 #[test]
897 fn test_requires_wasm_false_for_non_wasm_subscription() {
898 let mut m = sample_manifest();
899 m.subscriptions.push(SubscriptionDefinition {
900 subject: "events.foo".into(),
901 handler: "on_foo".into(),
902 handler_type: HandlerType::Webhook,
903 config: HashMap::new(),
904 });
905 assert!(!m.requires_wasm());
906 }
907
908 #[test]
909 fn test_declared_wasm_handlers_collects_all_sources() {
910 let mut m = sample_manifest();
911 m.on_migrate = Some("on_mig".into());
912 m.custom_routes.push(CustomRouteDefinition {
913 method: "GET".into(),
914 path: "/h".into(),
915 handler: "route_h".into(),
916 job: None,
917 });
918 m.subscriptions.push(SubscriptionDefinition {
919 subject: "x".into(),
920 handler: "sub_h".into(),
921 handler_type: HandlerType::Wasm,
922 config: HashMap::new(),
923 });
924 m.subscriptions.push(SubscriptionDefinition {
925 subject: "y".into(),
926 handler: "wh_ignored".into(),
927 handler_type: HandlerType::Webhook,
928 config: HashMap::new(),
929 });
930 m.tables[0].hooks = Some(TableHooks {
931 before_create: Some("bc".into()),
932 after_create: Some("ac".into()),
933 before_delete: Some("bc".into()), ..Default::default()
935 });
936 let handlers = m.declared_wasm_handlers();
937 assert!(handlers.contains(&"on_mig"));
938 assert!(handlers.contains(&"route_h"));
939 assert!(handlers.contains(&"sub_h"));
940 assert!(handlers.contains(&"bc"));
941 assert!(handlers.contains(&"ac"));
942 assert!(!handlers.contains(&"wh_ignored"));
943 assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
945 }
946
947 #[test]
948 fn job_config_defaults() {
949 let cfg = JobConfig::default();
950 assert_eq!(cfg.timeout_secs, JobConfig::DEFAULT_TIMEOUT_SECS);
951 assert_eq!(cfg.max_attempts, JobConfig::DEFAULT_MAX_ATTEMPTS);
952 }
953
954 #[test]
955 fn custom_route_serde_round_trip_with_job() {
956 let route = CustomRouteDefinition {
957 method: "POST".into(),
958 path: "/run".into(),
959 handler: "do_thing".into(),
960 job: Some(JobConfig {
961 timeout_secs: 900,
962 max_attempts: 2,
963 }),
964 };
965 let json = serde_json::to_string(&route).unwrap();
966 let round: CustomRouteDefinition = serde_json::from_str(&json).unwrap();
967 assert_eq!(round.method, "POST");
968 let job = round.job.unwrap();
969 assert_eq!(job.timeout_secs, 900);
970 assert_eq!(job.max_attempts, 2);
971 }
972
973 #[test]
974 fn custom_route_without_job_serializes_without_field() {
975 let route = CustomRouteDefinition {
976 method: "GET".into(),
977 path: "/x".into(),
978 handler: "x".into(),
979 job: None,
980 };
981 let json = serde_json::to_value(&route).unwrap();
982 assert!(json.get("job").is_none(), "absent job should be omitted");
983 }
984
985 #[test]
986 fn custom_route_legacy_json_without_job_deserializes() {
987 let json = r#"{"method":"GET","path":"/x","handler":"x"}"#;
988 let route: CustomRouteDefinition = serde_json::from_str(json).unwrap();
989 assert!(route.job.is_none());
990 }
991}