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 auth: Default::default(),
607 }
608 }
609
610 #[test]
611 fn dry_run_creates_all_entities() {
612 let adapter = DryRunAdapter;
613 let manifest = minimal_manifest();
614 let plan = adapter.plan_schema(&manifest).unwrap();
615
616 assert_eq!(plan.operations.len(), 1);
617 match &plan.operations[0] {
618 SchemaOperation::CreateEntity { name, fields } => {
619 assert_eq!(name, "User");
620 assert_eq!(fields.len(), 1);
621 assert_eq!(fields[0].name, "email");
622 }
623 other => panic!("expected CreateEntity, got: {other:?}"),
624 }
625 }
626
627 #[test]
628 fn dry_run_includes_indexes() {
629 let adapter = DryRunAdapter;
630 let mut manifest = minimal_manifest();
631 manifest.entities[0].indexes.push(ManifestIndex {
632 name: "by_email".into(),
633 fields: vec!["email".into()],
634 unique: true,
635 });
636 let plan = adapter.plan_schema(&manifest).unwrap();
637
638 assert_eq!(plan.operations.len(), 2);
639 match &plan.operations[1] {
640 SchemaOperation::AddIndex {
641 entity,
642 name,
643 fields,
644 unique,
645 } => {
646 assert_eq!(entity, "User");
647 assert_eq!(name, "by_email");
648 assert_eq!(fields, &vec!["email".to_string()]);
649 assert!(unique);
650 }
651 other => panic!("expected AddIndex, got: {other:?}"),
652 }
653 }
654
655 #[test]
656 fn dry_run_empty_manifest_produces_noop() {
657 let adapter = DryRunAdapter;
658 let manifest = AppManifest {
659 manifest_version: MANIFEST_VERSION,
660 name: "empty".into(),
661 version: "0.1.0".into(),
662 entities: vec![],
663 routes: vec![],
664 queries: vec![],
665 actions: vec![],
666 policies: vec![],
667 auth: Default::default(),
668 };
669 let plan = adapter.plan_schema(&manifest).unwrap();
670 assert!(plan.is_empty());
671 }
672
673 #[test]
674 fn diff_adapter_detects_new_entity() {
675 let old = minimal_manifest();
676 let mut new = minimal_manifest();
677 new.entities.push(ManifestEntity {
678 name: "Post".into(),
679 fields: vec![ManifestField {
680 name: "title".into(),
681 field_type: "string".into(),
682 optional: false,
683 unique: false,
684 crdt: None,
685 }],
686 indexes: vec![],
687 relations: vec![],
688 search: None,
689 crdt: true,
690 });
691
692 let adapter = DiffAdapter { from: old };
693 let plan = adapter.plan_schema(&new).unwrap();
694
695 assert!(plan.operations.iter().any(|op| matches!(
696 op,
697 SchemaOperation::CreateEntity { name, .. } if name == "Post"
698 )));
699 }
700
701 #[test]
702 fn diff_adapter_detects_removed_entity() {
703 let old = minimal_manifest();
704 let mut new = minimal_manifest();
705 new.entities.clear();
706
707 let adapter = DiffAdapter { from: old };
708 let plan = adapter.plan_schema(&new).unwrap();
709
710 assert!(plan.operations.iter().any(|op| matches!(
711 op,
712 SchemaOperation::RemoveEntity { name } if name == "User"
713 )));
714 }
715
716 #[test]
717 fn diff_adapter_detects_added_field() {
718 let old = minimal_manifest();
719 let mut new = minimal_manifest();
720 new.entities[0].fields.push(ManifestField {
721 name: "name".into(),
722 field_type: "string".into(),
723 optional: false,
724 unique: false,
725 crdt: None,
726 });
727
728 let adapter = DiffAdapter { from: old };
729 let plan = adapter.plan_schema(&new).unwrap();
730
731 assert!(plan.operations.iter().any(|op| matches!(
732 op,
733 SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name"
734 )));
735 }
736
737 #[test]
738 fn diff_adapter_detects_removed_field() {
739 let old = minimal_manifest();
740 let mut new = minimal_manifest();
741 new.entities[0].fields.clear();
742
743 let adapter = DiffAdapter { from: old };
744 let plan = adapter.plan_schema(&new).unwrap();
745
746 assert!(plan.operations.iter().any(|op| matches!(
747 op,
748 SchemaOperation::RemoveField { entity, field_name } if entity == "User" && field_name == "email"
749 )));
750 }
751
752 #[test]
753 fn diff_adapter_no_changes_produces_noop() {
754 let m = minimal_manifest();
755 let adapter = DiffAdapter { from: m.clone() };
756 let plan = adapter.plan_schema(&m).unwrap();
757 assert!(plan.is_empty());
758 }
759
760 #[test]
761 fn apply_schema_not_implemented() {
762 let adapter = DryRunAdapter;
763 let plan = SchemaPlan { operations: vec![] };
764 let result = adapter.apply_schema(&plan);
765 assert!(result.is_err());
766 assert_eq!(result.unwrap_err().code, "APPLY_NOT_IMPLEMENTED");
767 }
768
769 #[test]
772 fn safe_plan_has_no_warnings() {
773 let plan = SchemaPlan {
774 operations: vec![
775 SchemaOperation::CreateEntity {
776 name: "User".into(),
777 fields: vec![],
778 },
779 SchemaOperation::AddIndex {
780 entity: "User".into(),
781 name: "idx".into(),
782 fields: vec!["email".into()],
783 unique: true,
784 },
785 SchemaOperation::Noop,
786 ],
787 };
788 let analysis = analyze_plan(&plan);
789 assert!(!analysis.destructive);
790 assert!(!analysis.has_unsupported);
791 assert!(analysis.warnings.is_empty());
792 }
793
794 #[test]
795 fn remove_entity_is_destructive() {
796 let plan = SchemaPlan {
797 operations: vec![SchemaOperation::RemoveEntity {
798 name: "User".into(),
799 }],
800 };
801 let analysis = analyze_plan(&plan);
802 assert!(analysis.destructive);
803 assert!(analysis.has_unsupported);
804 assert_eq!(analysis.warnings.len(), 1);
805 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_ENTITY");
806 }
807
808 #[test]
809 fn remove_field_is_destructive() {
810 let plan = SchemaPlan {
811 operations: vec![SchemaOperation::RemoveField {
812 entity: "User".into(),
813 field_name: "email".into(),
814 }],
815 };
816 let analysis = analyze_plan(&plan);
817 assert!(analysis.destructive);
818 assert!(analysis.has_unsupported);
819 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_FIELD");
820 }
821
822 #[test]
823 fn remove_index_is_unsupported_not_destructive() {
824 let plan = SchemaPlan {
825 operations: vec![SchemaOperation::RemoveIndex {
826 entity: "User".into(),
827 name: "idx".into(),
828 }],
829 };
830 let analysis = analyze_plan(&plan);
831 assert!(!analysis.destructive);
832 assert!(analysis.has_unsupported);
833 assert_eq!(analysis.warnings[0].code, "UNSUPPORTED_REMOVE_INDEX");
834 }
835
836 #[test]
837 fn mixed_plan_flags_both() {
838 let plan = SchemaPlan {
839 operations: vec![
840 SchemaOperation::CreateEntity {
841 name: "Post".into(),
842 fields: vec![],
843 },
844 SchemaOperation::RemoveEntity {
845 name: "User".into(),
846 },
847 SchemaOperation::RemoveIndex {
848 entity: "Post".into(),
849 name: "idx".into(),
850 },
851 ],
852 };
853 let analysis = analyze_plan(&plan);
854 assert!(analysis.destructive);
855 assert!(analysis.has_unsupported);
856 assert_eq!(analysis.warnings.len(), 2);
857 }
858
859 #[test]
860 fn noop_plan_is_safe() {
861 let plan = SchemaPlan {
862 operations: vec![SchemaOperation::Noop],
863 };
864 let analysis = analyze_plan(&plan);
865 assert!(!analysis.destructive);
866 assert!(!analysis.has_unsupported);
867 assert!(analysis.warnings.is_empty());
868 }
869
870 #[test]
873 fn storage_error_display() {
874 let err = StorageError {
875 code: "TEST".into(),
876 message: "msg".into(),
877 };
878 assert_eq!(format!("{err}"), "[TEST] msg");
879 }
880
881 #[test]
884 fn plan_from_snapshot_empty_both() {
885 let snapshot = SchemaSnapshot { tables: vec![] };
886 let manifest = AppManifest {
887 manifest_version: MANIFEST_VERSION,
888 name: "test".into(),
889 version: "0.1.0".into(),
890 entities: vec![],
891 routes: vec![],
892 queries: vec![],
893 actions: vec![],
894 policies: vec![],
895 auth: Default::default(),
896 };
897 let plan = plan_from_snapshot(&snapshot, &manifest);
898 assert!(plan.is_empty());
899 }
900
901 #[test]
902 fn plan_from_snapshot_add_field_to_existing() {
903 let snapshot = SchemaSnapshot {
904 tables: vec![TableSnapshot {
905 name: "User".into(),
906 columns: vec![
907 ColumnSnapshot {
908 name: "id".into(),
909 column_type: "TEXT".into(),
910 notnull: true,
911 primary_key: true,
912 },
913 ColumnSnapshot {
914 name: "email".into(),
915 column_type: "TEXT".into(),
916 notnull: true,
917 primary_key: false,
918 },
919 ],
920 indexes: vec![],
921 }],
922 };
923 let manifest = AppManifest {
924 manifest_version: MANIFEST_VERSION,
925 name: "test".into(),
926 version: "0.1.0".into(),
927 entities: vec![ManifestEntity {
928 name: "User".into(),
929 fields: vec![
930 ManifestField {
931 name: "email".into(),
932 field_type: "string".into(),
933 optional: false,
934 unique: true,
935 crdt: None,
936 },
937 ManifestField {
938 name: "name".into(),
939 field_type: "string".into(),
940 optional: false,
941 unique: false,
942 crdt: None,
943 },
944 ],
945 indexes: vec![],
946 relations: vec![],
947 search: None,
948 crdt: true,
949 }],
950 routes: vec![],
951 queries: vec![],
952 actions: vec![],
953 policies: vec![],
954 auth: Default::default(),
955 };
956 let plan = plan_from_snapshot(&snapshot, &manifest);
957 assert!(plan.operations.iter().any(|op| matches!(op, SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name")));
958 }
959
960 #[test]
961 fn plan_from_snapshot_add_index() {
962 let snapshot = SchemaSnapshot {
963 tables: vec![TableSnapshot {
964 name: "User".into(),
965 columns: vec![
966 ColumnSnapshot {
967 name: "id".into(),
968 column_type: "TEXT".into(),
969 notnull: true,
970 primary_key: true,
971 },
972 ColumnSnapshot {
973 name: "email".into(),
974 column_type: "TEXT".into(),
975 notnull: true,
976 primary_key: false,
977 },
978 ],
979 indexes: vec![], }],
981 };
982 let manifest = AppManifest {
983 manifest_version: MANIFEST_VERSION,
984 name: "test".into(),
985 version: "0.1.0".into(),
986 entities: vec![ManifestEntity {
987 name: "User".into(),
988 fields: vec![ManifestField {
989 name: "email".into(),
990 field_type: "string".into(),
991 optional: false,
992 unique: true,
993 crdt: None,
994 }],
995 indexes: vec![ManifestIndex {
996 name: "by_email".into(),
997 fields: vec!["email".into()],
998 unique: true,
999 }],
1000 relations: vec![],
1001 search: None,
1002 crdt: true,
1003 }],
1004 routes: vec![],
1005 queries: vec![],
1006 actions: vec![],
1007 policies: vec![],
1008 auth: Default::default(),
1009 };
1010 let plan = plan_from_snapshot(&snapshot, &manifest);
1011 assert!(plan
1012 .operations
1013 .iter()
1014 .any(|op| matches!(op, SchemaOperation::AddIndex { name, .. } if name == "by_email")));
1015 }
1016
1017 #[test]
1020 fn plan_empty_vec_is_empty() {
1021 let plan = SchemaPlan { operations: vec![] };
1022 assert!(plan.is_empty());
1023 }
1024
1025 #[test]
1026 fn plan_with_real_ops_not_empty() {
1027 let plan = SchemaPlan {
1028 operations: vec![SchemaOperation::CreateEntity {
1029 name: "X".into(),
1030 fields: vec![],
1031 }],
1032 };
1033 assert!(!plan.is_empty());
1034 }
1035
1036 #[test]
1039 fn plan_analysis_serializable() {
1040 let analysis = analyze_plan(&SchemaPlan {
1041 operations: vec![SchemaOperation::RemoveEntity { name: "X".into() }],
1042 });
1043 let json = serde_json::to_string(&analysis).unwrap();
1044 assert!(json.contains("DESTRUCTIVE_REMOVE_ENTITY"));
1045 }
1046}