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}
220
221#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223pub struct TableHooks {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub before_create: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub after_create: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub before_update: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub after_update: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub before_delete: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub after_delete: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct AuthorizationConfig {
241 #[serde(default)]
243 pub areas: Vec<PermissionAreaDef>,
244 #[serde(default)]
246 pub default_roles: Vec<DefaultRoleDef>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PermissionAreaDef {
252 pub name: String,
253 #[serde(default)]
254 pub operations: Vec<String>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct DefaultRoleDef {
260 pub name: String,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub description: Option<String>,
263 #[serde(default)]
264 pub permissions: Vec<String>,
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct SchemaDiff {
272 #[serde(default)]
273 pub added_tables: Vec<String>,
274 #[serde(default)]
275 pub dropped_tables: Vec<String>,
276 #[serde(default)]
280 pub added_columns: Vec<(String, String)>,
281 #[serde(default)]
285 pub dropped_columns: Vec<(String, String)>,
286 #[serde(default)]
288 pub type_changes: Vec<(String, String, String, String)>,
289 #[serde(default)]
291 pub nullability_changes: Vec<(String, String, bool)>,
292}
293
294impl SchemaDiff {
295 pub fn added_column(&self, table: &str, col: &str) -> bool {
296 self.added_columns
297 .iter()
298 .any(|(t, c)| t == table && c == col)
299 }
300
301 pub fn dropped_column(&self, table: &str, col: &str) -> bool {
302 self.dropped_columns
303 .iter()
304 .any(|(t, c)| t == table && c == col)
305 }
306
307 pub fn added_table(&self, table: &str) -> bool {
308 self.added_tables.iter().any(|t| t == table)
309 }
310
311 pub fn dropped_table(&self, table: &str) -> bool {
312 self.dropped_tables.iter().any(|t| t == table)
313 }
314
315 pub fn is_empty(&self) -> bool {
316 self.added_tables.is_empty()
317 && self.dropped_tables.is_empty()
318 && self.added_columns.is_empty()
319 && self.dropped_columns.is_empty()
320 && self.type_changes.is_empty()
321 && self.nullability_changes.is_empty()
322 }
323}
324
325impl ServiceManifest {
326 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
328 self.tables.iter().find(|t| t.name == name)
329 }
330
331 pub fn schema_hash(&self) -> String {
333 use sha2::{Digest, Sha256};
334 let mut hasher = Sha256::new();
335 hasher.update(
336 serde_json::to_string(&self.tables)
337 .unwrap_or_default()
338 .as_bytes(),
339 );
340 hasher.update(
341 serde_json::to_string(&self.custom_routes)
342 .unwrap_or_default()
343 .as_bytes(),
344 );
345 hasher.update(
346 serde_json::to_string(&self.subscriptions)
347 .unwrap_or_default()
348 .as_bytes(),
349 );
350 hasher.update(
351 serde_json::to_string(&self.authorization)
352 .unwrap_or_default()
353 .as_bytes(),
354 );
355 hasher.update(
356 serde_json::to_string(&self.on_migrate)
357 .unwrap_or_default()
358 .as_bytes(),
359 );
360 hex::encode(hasher.finalize())
361 }
362}
363
364impl TableDefinition {
365 pub fn primary_key(&self) -> Option<&ColumnDefinition> {
367 self.columns.iter().find(|c| c.primary_key)
368 }
369
370 pub fn writable_columns(&self) -> Vec<&ColumnDefinition> {
372 self.columns
373 .iter()
374 .filter(|c| !c.auto_generate || !c.primary_key)
375 .collect()
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn sample_manifest() -> ServiceManifest {
384 ServiceManifest {
385 name: "test-service".into(),
386 version: Some("1.0.0".into()),
387 tables: vec![TableDefinition {
388 name: "todos".into(),
389 columns: vec![
390 ColumnDefinition {
391 name: "id".into(),
392 column_type: ColumnType::Uuid,
393 primary_key: true,
394 nullable: false,
395 auto_generate: true,
396 default_value: None,
397 references: None,
398 on_delete: None,
399 unique: false,
400 validations: vec![],
401 },
402 ColumnDefinition {
403 name: "title".into(),
404 column_type: ColumnType::Text,
405 primary_key: false,
406 nullable: false,
407 auto_generate: false,
408 default_value: None,
409 references: None,
410 on_delete: None,
411 unique: false,
412 validations: vec![],
413 },
414 ColumnDefinition {
415 name: "user_id".into(),
416 column_type: ColumnType::Uuid,
417 primary_key: false,
418 nullable: false,
419 auto_generate: false,
420 default_value: None,
421 references: Some("users.id".into()),
422 on_delete: Some(ForeignKeyAction::Cascade),
423 unique: false,
424 validations: vec![],
425 },
426 ],
427 indexes: vec![],
428 soft_delete: false,
429 owner_field: None,
430 auth_required: false,
431 permission_area: None,
432 hooks: None,
433 }],
434 cells: vec![],
435 events: vec![],
436 subscriptions: vec![],
437 custom_routes: vec![],
438 mode: ServiceMode::Crud,
439 authorization: None,
440 on_migrate: None,
441 }
442 }
443
444 #[test]
445 fn test_manifest_serialization_roundtrip() {
446 let manifest = sample_manifest();
447 let json = serde_json::to_string(&manifest).unwrap();
448 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
449 assert_eq!(parsed.name, "test-service");
450 assert_eq!(parsed.tables.len(), 1);
451 assert_eq!(parsed.tables[0].columns.len(), 3);
452 }
453
454 #[test]
455 fn test_get_table() {
456 let manifest = sample_manifest();
457 assert!(manifest.get_table("todos").is_some());
458 assert!(manifest.get_table("nonexistent").is_none());
459 }
460
461 #[test]
462 fn test_primary_key() {
463 let manifest = sample_manifest();
464 let table = manifest.get_table("todos").unwrap();
465 let pk = table.primary_key().unwrap();
466 assert_eq!(pk.name, "id");
467 assert!(pk.auto_generate);
468 }
469
470 #[test]
471 fn test_schema_hash_deterministic() {
472 let m1 = sample_manifest();
473 let m2 = sample_manifest();
474 assert_eq!(m1.schema_hash(), m2.schema_hash());
475 }
476
477 #[test]
478 fn test_schema_hash_changes() {
479 let mut m1 = sample_manifest();
480 let m2 = sample_manifest();
481 m1.tables[0].columns.push(ColumnDefinition {
482 name: "extra".into(),
483 column_type: ColumnType::Text,
484 primary_key: false,
485 nullable: true,
486 auto_generate: false,
487 default_value: None,
488 references: None,
489 on_delete: None,
490 unique: false,
491 validations: vec![],
492 });
493 assert_ne!(m1.schema_hash(), m2.schema_hash());
494 }
495
496 #[test]
497 fn test_column_type_to_sql() {
498 assert_eq!(ColumnType::Uuid.to_sql(), "UUID");
499 assert_eq!(ColumnType::Text.to_sql(), "TEXT");
500 assert_eq!(ColumnType::Integer.to_sql(), "INTEGER");
501 assert_eq!(ColumnType::BigInteger.to_sql(), "BIGINT");
502 assert_eq!(ColumnType::Boolean.to_sql(), "BOOLEAN");
503 assert_eq!(ColumnType::Timestamp.to_sql(), "TIMESTAMPTZ");
504 assert_eq!(ColumnType::Date.to_sql(), "DATE");
505 assert_eq!(ColumnType::Jsonb.to_sql(), "JSONB");
506 }
507
508 #[test]
509 fn test_fk_serialization() {
510 let manifest = sample_manifest();
511 let json = serde_json::to_string(&manifest).unwrap();
512 assert!(json.contains("\"references\":\"users.id\""));
513 assert!(json.contains("\"on_delete\":\"cascade\""));
514
515 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
516 let user_id_col = &parsed.tables[0].columns[2];
517 assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
518 assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
519 }
520
521 #[test]
522 fn test_fk_not_serialized_when_none() {
523 let col = ColumnDefinition {
524 name: "title".into(),
525 column_type: ColumnType::Text,
526 primary_key: false,
527 nullable: false,
528 auto_generate: false,
529 default_value: None,
530 references: None,
531 on_delete: None,
532 unique: false,
533 validations: vec![],
534 };
535 let json = serde_json::to_string(&col).unwrap();
536 assert!(!json.contains("references"));
537 assert!(!json.contains("on_delete"));
538 }
539
540 #[test]
541 fn test_unique_column_serialization() {
542 let col = ColumnDefinition {
543 name: "email".into(),
544 column_type: ColumnType::Text,
545 primary_key: false,
546 nullable: false,
547 auto_generate: false,
548 default_value: None,
549 references: None,
550 on_delete: None,
551 unique: true,
552 validations: vec![],
553 };
554 let json = serde_json::to_string(&col).unwrap();
555 assert!(json.contains("\"unique\":true"));
556 let parsed: ColumnDefinition = serde_json::from_str(&json).unwrap();
557 assert!(parsed.unique);
558 }
559
560 #[test]
561 fn test_index_serialization() {
562 let table = TableDefinition {
563 name: "users".into(),
564 columns: vec![],
565 indexes: vec![IndexDefinition {
566 name: "idx_users_email".into(),
567 columns: vec!["email".into()],
568 unique: true,
569 }],
570 soft_delete: false,
571 owner_field: None,
572 auth_required: false,
573 permission_area: None,
574 hooks: None,
575 };
576 let json = serde_json::to_string(&table).unwrap();
577 assert!(json.contains("idx_users_email"));
578 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
579 assert_eq!(parsed.indexes.len(), 1);
580 assert!(parsed.indexes[0].unique);
581 }
582
583 #[test]
584 fn test_indexes_not_serialized_when_empty() {
585 let table = TableDefinition {
586 name: "users".into(),
587 columns: vec![],
588 indexes: vec![],
589 soft_delete: false,
590 owner_field: None,
591 auth_required: false,
592 permission_area: None,
593 hooks: None,
594 };
595 let json = serde_json::to_string(&table).unwrap();
596 assert!(!json.contains("indexes"));
597 }
598
599 #[test]
600 fn test_service_mode_default() {
601 let json = r#"{"name":"svc","tables":[]}"#;
602 let m: ServiceManifest = serde_json::from_str(json).unwrap();
603 assert_eq!(m.mode, ServiceMode::Crud);
604 }
605
606 #[test]
607 fn test_owner_field_serialization() {
608 let table = TableDefinition {
609 name: "notes".into(),
610 columns: vec![],
611 indexes: vec![],
612 soft_delete: false,
613 owner_field: Some("user_id".into()),
614 auth_required: false,
615 permission_area: None,
616 hooks: None,
617 };
618 let json = serde_json::to_string(&table).unwrap();
619 assert!(json.contains("\"owner_field\":\"user_id\""));
620 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
621 assert_eq!(parsed.owner_field.as_deref(), Some("user_id"));
622 }
623
624 #[test]
625 fn test_owner_field_not_serialized_when_none() {
626 let table = TableDefinition {
627 name: "notes".into(),
628 columns: vec![],
629 indexes: vec![],
630 soft_delete: false,
631 owner_field: None,
632 auth_required: false,
633 permission_area: None,
634 hooks: None,
635 };
636 let json = serde_json::to_string(&table).unwrap();
637 assert!(!json.contains("owner_field"));
638 }
639
640 #[test]
641 fn test_owner_field_defaults_to_none() {
642 let json = r#"{"name":"notes","columns":[]}"#;
643 let table: TableDefinition = serde_json::from_str(json).unwrap();
644 assert!(table.owner_field.is_none());
645 }
646
647 #[test]
648 fn test_auth_required_serialization() {
649 let table = TableDefinition {
650 name: "orders".into(),
651 columns: vec![],
652 indexes: vec![],
653 soft_delete: false,
654 owner_field: None,
655 auth_required: true,
656 permission_area: None,
657 hooks: None,
658 };
659 let json = serde_json::to_string(&table).unwrap();
660 assert!(json.contains("\"auth_required\":true"));
661 let parsed: TableDefinition = serde_json::from_str(&json).unwrap();
662 assert!(parsed.auth_required);
663 }
664
665 #[test]
666 fn test_auth_required_defaults_to_false() {
667 let json = r#"{"name":"orders","columns":[]}"#;
668 let table: TableDefinition = serde_json::from_str(json).unwrap();
669 assert!(!table.auth_required);
670 }
671
672 #[test]
673 fn test_on_migrate_defaults_to_none() {
674 let json = r#"{"name":"svc","tables":[]}"#;
675 let m: ServiceManifest = serde_json::from_str(json).unwrap();
676 assert!(m.on_migrate.is_none());
677 }
678
679 #[test]
680 fn test_on_migrate_serialization_roundtrip() {
681 let mut m = sample_manifest();
682 m.on_migrate = Some("handle_on_migrate".into());
683 let json = serde_json::to_string(&m).unwrap();
684 assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
685 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
686 assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
687 }
688
689 #[test]
690 fn test_on_migrate_skipped_when_none() {
691 let m = sample_manifest();
692 let json = serde_json::to_string(&m).unwrap();
693 assert!(!json.contains("on_migrate"));
694 }
695
696 #[test]
697 fn test_on_migrate_changes_schema_hash() {
698 let m1 = sample_manifest();
699 let mut m2 = sample_manifest();
700 m2.on_migrate = Some("handle_on_migrate".into());
701 assert_ne!(m1.schema_hash(), m2.schema_hash());
702 }
703
704 #[test]
705 fn test_schema_diff_helpers() {
706 let diff = SchemaDiff {
707 added_tables: vec!["batches".into()],
708 dropped_tables: vec!["legacy_batches".into()],
709 added_columns: vec![("pickups".into(), "min".into())],
710 dropped_columns: vec![("pickups".into(), "midpoint".into())],
711 type_changes: vec![],
712 nullability_changes: vec![],
713 };
714 assert!(!diff.is_empty());
715 assert!(diff.added_table("batches"));
716 assert!(!diff.added_table("nope"));
717 assert!(diff.dropped_table("legacy_batches"));
718 assert!(diff.added_column("pickups", "min"));
719 assert!(!diff.added_column("pickups", "max"));
720 assert!(diff.dropped_column("pickups", "midpoint"));
721 }
722
723 #[test]
724 fn test_schema_diff_is_empty_default() {
725 assert!(SchemaDiff::default().is_empty());
726 }
727
728 #[test]
729 fn test_schema_diff_serialization_roundtrip() {
730 let diff = SchemaDiff {
731 added_tables: vec![],
732 dropped_tables: vec![],
733 added_columns: vec![("t".into(), "c".into())],
734 dropped_columns: vec![],
735 type_changes: vec![("t".into(), "c".into(), "TEXT".into(), "INTEGER".into())],
736 nullability_changes: vec![("t".into(), "c".into(), false)],
737 };
738 let json = serde_json::to_string(&diff).unwrap();
739 let parsed: SchemaDiff = serde_json::from_str(&json).unwrap();
740 assert!(parsed.added_column("t", "c"));
741 assert_eq!(parsed.type_changes.len(), 1);
742 assert_eq!(parsed.nullability_changes.len(), 1);
743 }
744}