1pub mod files;
2#[cfg(feature = "postgres-live")]
3pub mod pg_datastore;
4#[cfg(feature = "postgres-live")]
5pub mod pg_exec;
6#[cfg(feature = "postgres-live")]
7pub mod pg_search;
8#[cfg(feature = "postgres-live")]
9pub mod pg_tx_store;
10pub mod pool;
11pub mod postgres;
12pub mod search;
13pub mod search_maintenance;
14pub mod search_query;
15pub mod sqlite;
16
17use std::fmt;
18
19use pylon_kernel::AppManifest;
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct StorageError {
28 pub code: String,
29 pub message: String,
30}
31
32impl StorageError {
33 pub fn new(code: &str, message: &str) -> Self {
34 Self {
35 code: code.to_string(),
36 message: message.to_string(),
37 }
38 }
39}
40
41impl fmt::Display for StorageError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 write!(f, "[{}] {}", self.code, self.message)
44 }
45}
46
47impl std::error::Error for StorageError {}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(tag = "op", rename_all = "snake_case")]
55pub enum SchemaOperation {
56 CreateEntity {
57 name: String,
58 fields: Vec<FieldSpec>,
59 },
60 AddField {
61 entity: String,
62 field: FieldSpec,
63 },
64 RemoveField {
65 entity: String,
66 field_name: String,
67 },
68 AlterField {
85 entity: String,
86 previous: FieldSpec,
87 target: FieldSpec,
88 },
89 RemoveEntity {
90 name: String,
91 },
92 AddIndex {
93 entity: String,
94 name: String,
95 fields: Vec<String>,
96 unique: bool,
97 },
98 RemoveIndex {
99 entity: String,
100 name: String,
101 },
102 CreateSearchIndex {
107 entity: String,
108 config: pylon_kernel::ManifestSearchConfig,
109 },
110 RemoveSearchIndex {
113 entity: String,
114 },
115 Noop,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct FieldSpec {
120 pub name: String,
121 pub field_type: String,
122 pub optional: bool,
123 pub unique: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct SchemaPlan {
132 pub operations: Vec<SchemaOperation>,
133}
134
135impl SchemaPlan {
136 pub fn is_empty(&self) -> bool {
137 self.operations.is_empty()
138 || self
139 .operations
140 .iter()
141 .all(|op| matches!(op, SchemaOperation::Noop))
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct SchemaSnapshot {
151 pub tables: Vec<TableSnapshot>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct TableSnapshot {
156 pub name: String,
157 pub columns: Vec<ColumnSnapshot>,
158 pub indexes: Vec<IndexSnapshot>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct ColumnSnapshot {
163 pub name: String,
164 pub column_type: String,
165 pub notnull: bool,
166 pub primary_key: bool,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct IndexSnapshot {
171 pub name: String,
172 pub columns: Vec<String>,
173 pub unique: bool,
174}
175
176pub fn plan_from_snapshot(snapshot: &SchemaSnapshot, target: &AppManifest) -> SchemaPlan {
180 use std::collections::{HashMap, HashSet};
181
182 let existing_tables: HashMap<&str, &TableSnapshot> = snapshot
183 .tables
184 .iter()
185 .map(|t| (t.name.as_str(), t))
186 .collect();
187
188 let mut operations = Vec::new();
189
190 for entity in &target.entities {
191 match existing_tables.get(entity.name.as_str()) {
192 None => {
193 let fields: Vec<FieldSpec> = entity
194 .fields
195 .iter()
196 .map(|f| FieldSpec {
197 name: f.name.clone(),
198 field_type: f.field_type.clone(),
199 optional: f.optional,
200 unique: f.unique,
201 })
202 .collect();
203 operations.push(SchemaOperation::CreateEntity {
204 name: entity.name.clone(),
205 fields,
206 });
207 for index in &entity.indexes {
208 operations.push(SchemaOperation::AddIndex {
209 entity: entity.name.clone(),
210 name: index.name.clone(),
211 fields: index.fields.clone(),
212 unique: index.unique,
213 });
214 }
215 if let Some(cfg) = &entity.search {
216 if !cfg.is_empty() {
217 operations.push(SchemaOperation::CreateSearchIndex {
218 entity: entity.name.clone(),
219 config: cfg.clone(),
220 });
221 }
222 }
223 }
224 Some(table) => {
225 let cols_by_name: HashMap<&str, &ColumnSnapshot> =
226 table.columns.iter().map(|c| (c.name.as_str(), c)).collect();
227 for field in &entity.fields {
228 match cols_by_name.get(field.name.as_str()) {
229 None => {
230 operations.push(SchemaOperation::AddField {
231 entity: entity.name.clone(),
232 field: FieldSpec {
233 name: field.name.clone(),
234 field_type: field.field_type.clone(),
235 optional: field.optional,
236 unique: field.unique,
237 },
238 });
239 }
240 Some(existing) => {
241 let existing_optional = !existing.notnull;
249 let target_optional = field.optional;
250 if existing_optional != target_optional {
251 let target_spec = FieldSpec {
252 name: field.name.clone(),
253 field_type: field.field_type.clone(),
254 optional: target_optional,
255 unique: field.unique,
256 };
257 let previous_spec = FieldSpec {
258 name: field.name.clone(),
259 field_type: existing.column_type.clone(),
260 optional: existing_optional,
261 unique: field.unique,
262 };
263 operations.push(SchemaOperation::AlterField {
264 entity: entity.name.clone(),
265 previous: previous_spec,
266 target: target_spec,
267 });
268 }
269 }
270 }
271 }
272 let existing_indexes: HashSet<&str> =
274 table.indexes.iter().map(|i| i.name.as_str()).collect();
275 for index in &entity.indexes {
276 let full_name = format!("{}_{}", entity.name, index.name);
277 if !existing_indexes.contains(full_name.as_str()) {
278 operations.push(SchemaOperation::AddIndex {
279 entity: entity.name.clone(),
280 name: index.name.clone(),
281 fields: index.fields.clone(),
282 unique: index.unique,
283 });
284 }
285 }
286 if let Some(cfg) = &entity.search {
295 if !cfg.is_empty() {
296 let fts_table = format!("_fts_{}", entity.name);
297 let facet_table = "_facet_bitmap";
298 let fts_exists = existing_tables.contains_key(fts_table.as_str());
299 let facet_exists = existing_tables.contains_key(facet_table);
300 if !fts_exists || !facet_exists {
301 operations.push(SchemaOperation::CreateSearchIndex {
302 entity: entity.name.clone(),
303 config: cfg.clone(),
304 });
305 }
306 }
307 }
308 }
309 }
310 }
311
312 if operations.is_empty() {
313 operations.push(SchemaOperation::Noop);
314 }
315
316 SchemaPlan { operations }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324pub struct PlanWarning {
325 pub code: String,
326 pub message: String,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
330pub struct PlanAnalysis {
331 pub destructive: bool,
332 pub has_unsupported: bool,
333 pub warnings: Vec<PlanWarning>,
334}
335
336pub fn analyze_plan(plan: &SchemaPlan) -> PlanAnalysis {
338 let mut destructive = false;
339 let mut has_unsupported = false;
340 let mut warnings = Vec::new();
341
342 for op in &plan.operations {
343 match op {
344 SchemaOperation::RemoveEntity { name } => {
345 destructive = true;
346 has_unsupported = true;
347 warnings.push(PlanWarning {
348 code: "DESTRUCTIVE_REMOVE_ENTITY".into(),
349 message: format!(
350 "Removing entity \"{}\" will drop the table and all its data",
351 name
352 ),
353 });
354 }
355 SchemaOperation::RemoveField { entity, field_name } => {
356 destructive = true;
357 has_unsupported = true;
358 warnings.push(PlanWarning {
359 code: "DESTRUCTIVE_REMOVE_FIELD".into(),
360 message: format!(
361 "Removing field \"{}.{}\" will drop the column and its data",
362 entity, field_name
363 ),
364 });
365 }
366 SchemaOperation::RemoveIndex { entity, name } => {
367 has_unsupported = true;
368 warnings.push(PlanWarning {
369 code: "UNSUPPORTED_REMOVE_INDEX".into(),
370 message: format!(
371 "Removing index \"{}.{}\" is not supported by the SQLite adapter",
372 entity, name
373 ),
374 });
375 }
376 _ => {}
377 }
378 }
379
380 PlanAnalysis {
381 destructive,
382 has_unsupported,
383 warnings,
384 }
385}
386
387pub trait StorageAdapter {
392 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError>;
394
395 fn apply_schema(&self, _plan: &SchemaPlan) -> Result<(), StorageError> {
397 Err(StorageError {
398 code: "APPLY_NOT_IMPLEMENTED".into(),
399 message: "This adapter does not support applying schemas".into(),
400 })
401 }
402}
403
404pub struct DryRunAdapter;
411
412impl StorageAdapter for DryRunAdapter {
413 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
414 let mut operations = Vec::new();
415
416 for entity in &target.entities {
417 let fields: Vec<FieldSpec> = entity
418 .fields
419 .iter()
420 .map(|f| FieldSpec {
421 name: f.name.clone(),
422 field_type: f.field_type.clone(),
423 optional: f.optional,
424 unique: f.unique,
425 })
426 .collect();
427
428 operations.push(SchemaOperation::CreateEntity {
429 name: entity.name.clone(),
430 fields,
431 });
432
433 for index in &entity.indexes {
434 operations.push(SchemaOperation::AddIndex {
435 entity: entity.name.clone(),
436 name: index.name.clone(),
437 fields: index.fields.clone(),
438 unique: index.unique,
439 });
440 }
441 }
442
443 if operations.is_empty() {
444 operations.push(SchemaOperation::Noop);
445 }
446
447 Ok(SchemaPlan { operations })
448 }
449}
450
451pub struct DiffAdapter {
457 pub from: AppManifest,
458}
459
460impl StorageAdapter for DiffAdapter {
461 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
462 let mut operations = Vec::new();
463
464 let old_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = self
465 .from
466 .entities
467 .iter()
468 .map(|e| (e.name.as_str(), e))
469 .collect();
470 let new_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = target
471 .entities
472 .iter()
473 .map(|e| (e.name.as_str(), e))
474 .collect();
475
476 for name in old_entities.keys() {
478 if !new_entities.contains_key(name) {
479 operations.push(SchemaOperation::RemoveEntity {
480 name: name.to_string(),
481 });
482 }
483 }
484
485 for (name, entity) in &new_entities {
487 if !old_entities.contains_key(name) {
488 let fields: Vec<FieldSpec> = entity
489 .fields
490 .iter()
491 .map(|f| FieldSpec {
492 name: f.name.clone(),
493 field_type: f.field_type.clone(),
494 optional: f.optional,
495 unique: f.unique,
496 })
497 .collect();
498 operations.push(SchemaOperation::CreateEntity {
499 name: name.to_string(),
500 fields,
501 });
502 for index in &entity.indexes {
503 operations.push(SchemaOperation::AddIndex {
504 entity: name.to_string(),
505 name: index.name.clone(),
506 fields: index.fields.clone(),
507 unique: index.unique,
508 });
509 }
510 }
511 }
512
513 for (name, new_entity) in &new_entities {
515 if let Some(old_entity) = old_entities.get(name) {
516 let old_fields: std::collections::HashSet<&str> =
517 old_entity.fields.iter().map(|f| f.name.as_str()).collect();
518 let new_fields: std::collections::HashSet<&str> =
519 new_entity.fields.iter().map(|f| f.name.as_str()).collect();
520
521 for field in &new_entity.fields {
522 if !old_fields.contains(field.name.as_str()) {
523 operations.push(SchemaOperation::AddField {
524 entity: name.to_string(),
525 field: FieldSpec {
526 name: field.name.clone(),
527 field_type: field.field_type.clone(),
528 optional: field.optional,
529 unique: field.unique,
530 },
531 });
532 }
533 }
534
535 for field in &old_entity.fields {
536 if !new_fields.contains(field.name.as_str()) {
537 operations.push(SchemaOperation::RemoveField {
538 entity: name.to_string(),
539 field_name: field.name.clone(),
540 });
541 }
542 }
543
544 let old_indexes: std::collections::HashSet<&str> =
546 old_entity.indexes.iter().map(|i| i.name.as_str()).collect();
547 let new_indexes: std::collections::HashSet<&str> =
548 new_entity.indexes.iter().map(|i| i.name.as_str()).collect();
549
550 for index in &new_entity.indexes {
551 if !old_indexes.contains(index.name.as_str()) {
552 operations.push(SchemaOperation::AddIndex {
553 entity: name.to_string(),
554 name: index.name.clone(),
555 fields: index.fields.clone(),
556 unique: index.unique,
557 });
558 }
559 }
560
561 for index in &old_entity.indexes {
562 if !new_indexes.contains(index.name.as_str()) {
563 operations.push(SchemaOperation::RemoveIndex {
564 entity: name.to_string(),
565 name: index.name.clone(),
566 });
567 }
568 }
569 }
570 }
571
572 if operations.is_empty() {
573 operations.push(SchemaOperation::Noop);
574 }
575
576 Ok(SchemaPlan { operations })
577 }
578}
579
580#[cfg(test)]
585mod tests {
586 use super::*;
587 use pylon_kernel::*;
588
589 fn minimal_manifest() -> AppManifest {
590 AppManifest {
591 manifest_version: MANIFEST_VERSION,
592 name: "test".into(),
593 version: "0.1.0".into(),
594 entities: vec![ManifestEntity {
595 name: "User".into(),
596 fields: vec![ManifestField {
597 name: "email".into(),
598 field_type: "string".into(),
599 optional: false,
600 unique: true,
601 crdt: None,
602 }],
603 indexes: vec![],
604 relations: vec![],
605 search: None,
606 crdt: true,
607 }],
608 routes: vec![],
609 queries: vec![],
610 actions: vec![],
611 policies: vec![],
612 auth: Default::default(),
613 }
614 }
615
616 #[test]
617 fn dry_run_creates_all_entities() {
618 let adapter = DryRunAdapter;
619 let manifest = minimal_manifest();
620 let plan = adapter.plan_schema(&manifest).unwrap();
621
622 assert_eq!(plan.operations.len(), 1);
623 match &plan.operations[0] {
624 SchemaOperation::CreateEntity { name, fields } => {
625 assert_eq!(name, "User");
626 assert_eq!(fields.len(), 1);
627 assert_eq!(fields[0].name, "email");
628 }
629 other => panic!("expected CreateEntity, got: {other:?}"),
630 }
631 }
632
633 #[test]
634 fn dry_run_includes_indexes() {
635 let adapter = DryRunAdapter;
636 let mut manifest = minimal_manifest();
637 manifest.entities[0].indexes.push(ManifestIndex {
638 name: "by_email".into(),
639 fields: vec!["email".into()],
640 unique: true,
641 });
642 let plan = adapter.plan_schema(&manifest).unwrap();
643
644 assert_eq!(plan.operations.len(), 2);
645 match &plan.operations[1] {
646 SchemaOperation::AddIndex {
647 entity,
648 name,
649 fields,
650 unique,
651 } => {
652 assert_eq!(entity, "User");
653 assert_eq!(name, "by_email");
654 assert_eq!(fields, &vec!["email".to_string()]);
655 assert!(unique);
656 }
657 other => panic!("expected AddIndex, got: {other:?}"),
658 }
659 }
660
661 #[test]
662 fn dry_run_empty_manifest_produces_noop() {
663 let adapter = DryRunAdapter;
664 let manifest = AppManifest {
665 manifest_version: MANIFEST_VERSION,
666 name: "empty".into(),
667 version: "0.1.0".into(),
668 entities: vec![],
669 routes: vec![],
670 queries: vec![],
671 actions: vec![],
672 policies: vec![],
673 auth: Default::default(),
674 };
675 let plan = adapter.plan_schema(&manifest).unwrap();
676 assert!(plan.is_empty());
677 }
678
679 #[test]
680 fn diff_adapter_detects_new_entity() {
681 let old = minimal_manifest();
682 let mut new = minimal_manifest();
683 new.entities.push(ManifestEntity {
684 name: "Post".into(),
685 fields: vec![ManifestField {
686 name: "title".into(),
687 field_type: "string".into(),
688 optional: false,
689 unique: false,
690 crdt: None,
691 }],
692 indexes: vec![],
693 relations: vec![],
694 search: None,
695 crdt: true,
696 });
697
698 let adapter = DiffAdapter { from: old };
699 let plan = adapter.plan_schema(&new).unwrap();
700
701 assert!(plan.operations.iter().any(|op| matches!(
702 op,
703 SchemaOperation::CreateEntity { name, .. } if name == "Post"
704 )));
705 }
706
707 #[test]
708 fn diff_adapter_detects_removed_entity() {
709 let old = minimal_manifest();
710 let mut new = minimal_manifest();
711 new.entities.clear();
712
713 let adapter = DiffAdapter { from: old };
714 let plan = adapter.plan_schema(&new).unwrap();
715
716 assert!(plan.operations.iter().any(|op| matches!(
717 op,
718 SchemaOperation::RemoveEntity { name } if name == "User"
719 )));
720 }
721
722 #[test]
723 fn diff_adapter_detects_added_field() {
724 let old = minimal_manifest();
725 let mut new = minimal_manifest();
726 new.entities[0].fields.push(ManifestField {
727 name: "name".into(),
728 field_type: "string".into(),
729 optional: false,
730 unique: false,
731 crdt: None,
732 });
733
734 let adapter = DiffAdapter { from: old };
735 let plan = adapter.plan_schema(&new).unwrap();
736
737 assert!(plan.operations.iter().any(|op| matches!(
738 op,
739 SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name"
740 )));
741 }
742
743 #[test]
744 fn diff_adapter_detects_removed_field() {
745 let old = minimal_manifest();
746 let mut new = minimal_manifest();
747 new.entities[0].fields.clear();
748
749 let adapter = DiffAdapter { from: old };
750 let plan = adapter.plan_schema(&new).unwrap();
751
752 assert!(plan.operations.iter().any(|op| matches!(
753 op,
754 SchemaOperation::RemoveField { entity, field_name } if entity == "User" && field_name == "email"
755 )));
756 }
757
758 #[test]
759 fn diff_adapter_no_changes_produces_noop() {
760 let m = minimal_manifest();
761 let adapter = DiffAdapter { from: m.clone() };
762 let plan = adapter.plan_schema(&m).unwrap();
763 assert!(plan.is_empty());
764 }
765
766 #[test]
767 fn apply_schema_not_implemented() {
768 let adapter = DryRunAdapter;
769 let plan = SchemaPlan { operations: vec![] };
770 let result = adapter.apply_schema(&plan);
771 assert!(result.is_err());
772 assert_eq!(result.unwrap_err().code, "APPLY_NOT_IMPLEMENTED");
773 }
774
775 #[test]
778 fn safe_plan_has_no_warnings() {
779 let plan = SchemaPlan {
780 operations: vec![
781 SchemaOperation::CreateEntity {
782 name: "User".into(),
783 fields: vec![],
784 },
785 SchemaOperation::AddIndex {
786 entity: "User".into(),
787 name: "idx".into(),
788 fields: vec!["email".into()],
789 unique: true,
790 },
791 SchemaOperation::Noop,
792 ],
793 };
794 let analysis = analyze_plan(&plan);
795 assert!(!analysis.destructive);
796 assert!(!analysis.has_unsupported);
797 assert!(analysis.warnings.is_empty());
798 }
799
800 #[test]
801 fn remove_entity_is_destructive() {
802 let plan = SchemaPlan {
803 operations: vec![SchemaOperation::RemoveEntity {
804 name: "User".into(),
805 }],
806 };
807 let analysis = analyze_plan(&plan);
808 assert!(analysis.destructive);
809 assert!(analysis.has_unsupported);
810 assert_eq!(analysis.warnings.len(), 1);
811 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_ENTITY");
812 }
813
814 #[test]
815 fn remove_field_is_destructive() {
816 let plan = SchemaPlan {
817 operations: vec![SchemaOperation::RemoveField {
818 entity: "User".into(),
819 field_name: "email".into(),
820 }],
821 };
822 let analysis = analyze_plan(&plan);
823 assert!(analysis.destructive);
824 assert!(analysis.has_unsupported);
825 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_FIELD");
826 }
827
828 #[test]
829 fn remove_index_is_unsupported_not_destructive() {
830 let plan = SchemaPlan {
831 operations: vec![SchemaOperation::RemoveIndex {
832 entity: "User".into(),
833 name: "idx".into(),
834 }],
835 };
836 let analysis = analyze_plan(&plan);
837 assert!(!analysis.destructive);
838 assert!(analysis.has_unsupported);
839 assert_eq!(analysis.warnings[0].code, "UNSUPPORTED_REMOVE_INDEX");
840 }
841
842 #[test]
843 fn mixed_plan_flags_both() {
844 let plan = SchemaPlan {
845 operations: vec![
846 SchemaOperation::CreateEntity {
847 name: "Post".into(),
848 fields: vec![],
849 },
850 SchemaOperation::RemoveEntity {
851 name: "User".into(),
852 },
853 SchemaOperation::RemoveIndex {
854 entity: "Post".into(),
855 name: "idx".into(),
856 },
857 ],
858 };
859 let analysis = analyze_plan(&plan);
860 assert!(analysis.destructive);
861 assert!(analysis.has_unsupported);
862 assert_eq!(analysis.warnings.len(), 2);
863 }
864
865 #[test]
866 fn noop_plan_is_safe() {
867 let plan = SchemaPlan {
868 operations: vec![SchemaOperation::Noop],
869 };
870 let analysis = analyze_plan(&plan);
871 assert!(!analysis.destructive);
872 assert!(!analysis.has_unsupported);
873 assert!(analysis.warnings.is_empty());
874 }
875
876 #[test]
879 fn storage_error_display() {
880 let err = StorageError {
881 code: "TEST".into(),
882 message: "msg".into(),
883 };
884 assert_eq!(format!("{err}"), "[TEST] msg");
885 }
886
887 #[test]
890 fn plan_from_snapshot_empty_both() {
891 let snapshot = SchemaSnapshot { tables: vec![] };
892 let manifest = AppManifest {
893 manifest_version: MANIFEST_VERSION,
894 name: "test".into(),
895 version: "0.1.0".into(),
896 entities: vec![],
897 routes: vec![],
898 queries: vec![],
899 actions: vec![],
900 policies: vec![],
901 auth: Default::default(),
902 };
903 let plan = plan_from_snapshot(&snapshot, &manifest);
904 assert!(plan.is_empty());
905 }
906
907 #[test]
908 fn plan_from_snapshot_add_field_to_existing() {
909 let snapshot = SchemaSnapshot {
910 tables: vec![TableSnapshot {
911 name: "User".into(),
912 columns: vec![
913 ColumnSnapshot {
914 name: "id".into(),
915 column_type: "TEXT".into(),
916 notnull: true,
917 primary_key: true,
918 },
919 ColumnSnapshot {
920 name: "email".into(),
921 column_type: "TEXT".into(),
922 notnull: true,
923 primary_key: false,
924 },
925 ],
926 indexes: vec![],
927 }],
928 };
929 let manifest = AppManifest {
930 manifest_version: MANIFEST_VERSION,
931 name: "test".into(),
932 version: "0.1.0".into(),
933 entities: vec![ManifestEntity {
934 name: "User".into(),
935 fields: vec![
936 ManifestField {
937 name: "email".into(),
938 field_type: "string".into(),
939 optional: false,
940 unique: true,
941 crdt: None,
942 },
943 ManifestField {
944 name: "name".into(),
945 field_type: "string".into(),
946 optional: false,
947 unique: false,
948 crdt: None,
949 },
950 ],
951 indexes: vec![],
952 relations: vec![],
953 search: None,
954 crdt: true,
955 }],
956 routes: vec![],
957 queries: vec![],
958 actions: vec![],
959 policies: vec![],
960 auth: Default::default(),
961 };
962 let plan = plan_from_snapshot(&snapshot, &manifest);
963 assert!(plan.operations.iter().any(|op| matches!(op, SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name")));
964 }
965
966 #[test]
967 fn plan_from_snapshot_add_index() {
968 let snapshot = SchemaSnapshot {
969 tables: vec![TableSnapshot {
970 name: "User".into(),
971 columns: vec![
972 ColumnSnapshot {
973 name: "id".into(),
974 column_type: "TEXT".into(),
975 notnull: true,
976 primary_key: true,
977 },
978 ColumnSnapshot {
979 name: "email".into(),
980 column_type: "TEXT".into(),
981 notnull: true,
982 primary_key: false,
983 },
984 ],
985 indexes: vec![], }],
987 };
988 let manifest = AppManifest {
989 manifest_version: MANIFEST_VERSION,
990 name: "test".into(),
991 version: "0.1.0".into(),
992 entities: vec![ManifestEntity {
993 name: "User".into(),
994 fields: vec![ManifestField {
995 name: "email".into(),
996 field_type: "string".into(),
997 optional: false,
998 unique: true,
999 crdt: None,
1000 }],
1001 indexes: vec![ManifestIndex {
1002 name: "by_email".into(),
1003 fields: vec!["email".into()],
1004 unique: true,
1005 }],
1006 relations: vec![],
1007 search: None,
1008 crdt: true,
1009 }],
1010 routes: vec![],
1011 queries: vec![],
1012 actions: vec![],
1013 policies: vec![],
1014 auth: Default::default(),
1015 };
1016 let plan = plan_from_snapshot(&snapshot, &manifest);
1017 assert!(plan
1018 .operations
1019 .iter()
1020 .any(|op| matches!(op, SchemaOperation::AddIndex { name, .. } if name == "by_email")));
1021 }
1022
1023 #[test]
1026 fn plan_empty_vec_is_empty() {
1027 let plan = SchemaPlan { operations: vec![] };
1028 assert!(plan.is_empty());
1029 }
1030
1031 #[test]
1032 fn plan_with_real_ops_not_empty() {
1033 let plan = SchemaPlan {
1034 operations: vec![SchemaOperation::CreateEntity {
1035 name: "X".into(),
1036 fields: vec![],
1037 }],
1038 };
1039 assert!(!plan.is_empty());
1040 }
1041
1042 #[test]
1045 fn plan_analysis_serializable() {
1046 let analysis = analyze_plan(&SchemaPlan {
1047 operations: vec![SchemaOperation::RemoveEntity { name: "X".into() }],
1048 });
1049 let json = serde_json::to_string(&analysis).unwrap();
1050 assert!(json.contains("DESTRUCTIVE_REMOVE_ENTITY"));
1051 }
1052}