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