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 RemoveEntity {
63 name: String,
64 },
65 AddIndex {
66 entity: String,
67 name: String,
68 fields: Vec<String>,
69 unique: bool,
70 },
71 RemoveIndex {
72 entity: String,
73 name: String,
74 },
75 CreateSearchIndex {
80 entity: String,
81 config: pylon_kernel::ManifestSearchConfig,
82 },
83 RemoveSearchIndex {
86 entity: String,
87 },
88 Noop,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct FieldSpec {
93 pub name: String,
94 pub field_type: String,
95 pub optional: bool,
96 pub unique: bool,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct SchemaPlan {
105 pub operations: Vec<SchemaOperation>,
106}
107
108impl SchemaPlan {
109 pub fn is_empty(&self) -> bool {
110 self.operations.is_empty()
111 || self
112 .operations
113 .iter()
114 .all(|op| matches!(op, SchemaOperation::Noop))
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct SchemaSnapshot {
124 pub tables: Vec<TableSnapshot>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct TableSnapshot {
129 pub name: String,
130 pub columns: Vec<ColumnSnapshot>,
131 pub indexes: Vec<IndexSnapshot>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct ColumnSnapshot {
136 pub name: String,
137 pub column_type: String,
138 pub notnull: bool,
139 pub primary_key: bool,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct IndexSnapshot {
144 pub name: String,
145 pub columns: Vec<String>,
146 pub unique: bool,
147}
148
149pub fn plan_from_snapshot(snapshot: &SchemaSnapshot, target: &AppManifest) -> SchemaPlan {
153 use std::collections::{HashMap, HashSet};
154
155 let existing_tables: HashMap<&str, &TableSnapshot> = snapshot
156 .tables
157 .iter()
158 .map(|t| (t.name.as_str(), t))
159 .collect();
160
161 let mut operations = Vec::new();
162
163 for entity in &target.entities {
164 match existing_tables.get(entity.name.as_str()) {
165 None => {
166 let fields: Vec<FieldSpec> = entity
167 .fields
168 .iter()
169 .map(|f| FieldSpec {
170 name: f.name.clone(),
171 field_type: f.field_type.clone(),
172 optional: f.optional,
173 unique: f.unique,
174 })
175 .collect();
176 operations.push(SchemaOperation::CreateEntity {
177 name: entity.name.clone(),
178 fields,
179 });
180 for index in &entity.indexes {
181 operations.push(SchemaOperation::AddIndex {
182 entity: entity.name.clone(),
183 name: index.name.clone(),
184 fields: index.fields.clone(),
185 unique: index.unique,
186 });
187 }
188 if let Some(cfg) = &entity.search {
189 if !cfg.is_empty() {
190 operations.push(SchemaOperation::CreateSearchIndex {
191 entity: entity.name.clone(),
192 config: cfg.clone(),
193 });
194 }
195 }
196 }
197 Some(table) => {
198 let existing_cols: HashSet<&str> =
199 table.columns.iter().map(|c| c.name.as_str()).collect();
200 for field in &entity.fields {
201 if !existing_cols.contains(field.name.as_str()) {
202 operations.push(SchemaOperation::AddField {
203 entity: entity.name.clone(),
204 field: FieldSpec {
205 name: field.name.clone(),
206 field_type: field.field_type.clone(),
207 optional: field.optional,
208 unique: field.unique,
209 },
210 });
211 }
212 }
213 let existing_indexes: HashSet<&str> =
215 table.indexes.iter().map(|i| i.name.as_str()).collect();
216 for index in &entity.indexes {
217 let full_name = format!("{}_{}", entity.name, index.name);
218 if !existing_indexes.contains(full_name.as_str()) {
219 operations.push(SchemaOperation::AddIndex {
220 entity: entity.name.clone(),
221 name: index.name.clone(),
222 fields: index.fields.clone(),
223 unique: index.unique,
224 });
225 }
226 }
227 if let Some(cfg) = &entity.search {
236 if !cfg.is_empty() {
237 let fts_table = format!("_fts_{}", entity.name);
238 let facet_table = "_facet_bitmap";
239 let fts_exists = existing_tables.contains_key(fts_table.as_str());
240 let facet_exists = existing_tables.contains_key(facet_table);
241 if !fts_exists || !facet_exists {
242 operations.push(SchemaOperation::CreateSearchIndex {
243 entity: entity.name.clone(),
244 config: cfg.clone(),
245 });
246 }
247 }
248 }
249 }
250 }
251 }
252
253 if operations.is_empty() {
254 operations.push(SchemaOperation::Noop);
255 }
256
257 SchemaPlan { operations }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265pub struct PlanWarning {
266 pub code: String,
267 pub message: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271pub struct PlanAnalysis {
272 pub destructive: bool,
273 pub has_unsupported: bool,
274 pub warnings: Vec<PlanWarning>,
275}
276
277pub fn analyze_plan(plan: &SchemaPlan) -> PlanAnalysis {
279 let mut destructive = false;
280 let mut has_unsupported = false;
281 let mut warnings = Vec::new();
282
283 for op in &plan.operations {
284 match op {
285 SchemaOperation::RemoveEntity { name } => {
286 destructive = true;
287 has_unsupported = true;
288 warnings.push(PlanWarning {
289 code: "DESTRUCTIVE_REMOVE_ENTITY".into(),
290 message: format!(
291 "Removing entity \"{}\" will drop the table and all its data",
292 name
293 ),
294 });
295 }
296 SchemaOperation::RemoveField { entity, field_name } => {
297 destructive = true;
298 has_unsupported = true;
299 warnings.push(PlanWarning {
300 code: "DESTRUCTIVE_REMOVE_FIELD".into(),
301 message: format!(
302 "Removing field \"{}.{}\" will drop the column and its data",
303 entity, field_name
304 ),
305 });
306 }
307 SchemaOperation::RemoveIndex { entity, name } => {
308 has_unsupported = true;
309 warnings.push(PlanWarning {
310 code: "UNSUPPORTED_REMOVE_INDEX".into(),
311 message: format!(
312 "Removing index \"{}.{}\" is not supported by the SQLite adapter",
313 entity, name
314 ),
315 });
316 }
317 _ => {}
318 }
319 }
320
321 PlanAnalysis {
322 destructive,
323 has_unsupported,
324 warnings,
325 }
326}
327
328pub trait StorageAdapter {
333 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError>;
335
336 fn apply_schema(&self, _plan: &SchemaPlan) -> Result<(), StorageError> {
338 Err(StorageError {
339 code: "APPLY_NOT_IMPLEMENTED".into(),
340 message: "This adapter does not support applying schemas".into(),
341 })
342 }
343}
344
345pub struct DryRunAdapter;
352
353impl StorageAdapter for DryRunAdapter {
354 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
355 let mut operations = Vec::new();
356
357 for entity in &target.entities {
358 let fields: Vec<FieldSpec> = entity
359 .fields
360 .iter()
361 .map(|f| FieldSpec {
362 name: f.name.clone(),
363 field_type: f.field_type.clone(),
364 optional: f.optional,
365 unique: f.unique,
366 })
367 .collect();
368
369 operations.push(SchemaOperation::CreateEntity {
370 name: entity.name.clone(),
371 fields,
372 });
373
374 for index in &entity.indexes {
375 operations.push(SchemaOperation::AddIndex {
376 entity: entity.name.clone(),
377 name: index.name.clone(),
378 fields: index.fields.clone(),
379 unique: index.unique,
380 });
381 }
382 }
383
384 if operations.is_empty() {
385 operations.push(SchemaOperation::Noop);
386 }
387
388 Ok(SchemaPlan { operations })
389 }
390}
391
392pub struct DiffAdapter {
398 pub from: AppManifest,
399}
400
401impl StorageAdapter for DiffAdapter {
402 fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
403 let mut operations = Vec::new();
404
405 let old_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = self
406 .from
407 .entities
408 .iter()
409 .map(|e| (e.name.as_str(), e))
410 .collect();
411 let new_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = target
412 .entities
413 .iter()
414 .map(|e| (e.name.as_str(), e))
415 .collect();
416
417 for name in old_entities.keys() {
419 if !new_entities.contains_key(name) {
420 operations.push(SchemaOperation::RemoveEntity {
421 name: name.to_string(),
422 });
423 }
424 }
425
426 for (name, entity) in &new_entities {
428 if !old_entities.contains_key(name) {
429 let fields: Vec<FieldSpec> = entity
430 .fields
431 .iter()
432 .map(|f| FieldSpec {
433 name: f.name.clone(),
434 field_type: f.field_type.clone(),
435 optional: f.optional,
436 unique: f.unique,
437 })
438 .collect();
439 operations.push(SchemaOperation::CreateEntity {
440 name: name.to_string(),
441 fields,
442 });
443 for index in &entity.indexes {
444 operations.push(SchemaOperation::AddIndex {
445 entity: name.to_string(),
446 name: index.name.clone(),
447 fields: index.fields.clone(),
448 unique: index.unique,
449 });
450 }
451 }
452 }
453
454 for (name, new_entity) in &new_entities {
456 if let Some(old_entity) = old_entities.get(name) {
457 let old_fields: std::collections::HashSet<&str> =
458 old_entity.fields.iter().map(|f| f.name.as_str()).collect();
459 let new_fields: std::collections::HashSet<&str> =
460 new_entity.fields.iter().map(|f| f.name.as_str()).collect();
461
462 for field in &new_entity.fields {
463 if !old_fields.contains(field.name.as_str()) {
464 operations.push(SchemaOperation::AddField {
465 entity: name.to_string(),
466 field: FieldSpec {
467 name: field.name.clone(),
468 field_type: field.field_type.clone(),
469 optional: field.optional,
470 unique: field.unique,
471 },
472 });
473 }
474 }
475
476 for field in &old_entity.fields {
477 if !new_fields.contains(field.name.as_str()) {
478 operations.push(SchemaOperation::RemoveField {
479 entity: name.to_string(),
480 field_name: field.name.clone(),
481 });
482 }
483 }
484
485 let old_indexes: std::collections::HashSet<&str> =
487 old_entity.indexes.iter().map(|i| i.name.as_str()).collect();
488 let new_indexes: std::collections::HashSet<&str> =
489 new_entity.indexes.iter().map(|i| i.name.as_str()).collect();
490
491 for index in &new_entity.indexes {
492 if !old_indexes.contains(index.name.as_str()) {
493 operations.push(SchemaOperation::AddIndex {
494 entity: name.to_string(),
495 name: index.name.clone(),
496 fields: index.fields.clone(),
497 unique: index.unique,
498 });
499 }
500 }
501
502 for index in &old_entity.indexes {
503 if !new_indexes.contains(index.name.as_str()) {
504 operations.push(SchemaOperation::RemoveIndex {
505 entity: name.to_string(),
506 name: index.name.clone(),
507 });
508 }
509 }
510 }
511 }
512
513 if operations.is_empty() {
514 operations.push(SchemaOperation::Noop);
515 }
516
517 Ok(SchemaPlan { operations })
518 }
519}
520
521#[cfg(test)]
526mod tests {
527 use super::*;
528 use pylon_kernel::*;
529
530 fn minimal_manifest() -> AppManifest {
531 AppManifest {
532 manifest_version: MANIFEST_VERSION,
533 name: "test".into(),
534 version: "0.1.0".into(),
535 entities: vec![ManifestEntity {
536 name: "User".into(),
537 fields: vec![ManifestField {
538 name: "email".into(),
539 field_type: "string".into(),
540 optional: false,
541 unique: true,
542 crdt: None,
543 }],
544 indexes: vec![],
545 relations: vec![],
546 search: None,
547 crdt: true,
548 }],
549 routes: vec![],
550 queries: vec![],
551 actions: vec![],
552 policies: vec![],
553 }
554 }
555
556 #[test]
557 fn dry_run_creates_all_entities() {
558 let adapter = DryRunAdapter;
559 let manifest = minimal_manifest();
560 let plan = adapter.plan_schema(&manifest).unwrap();
561
562 assert_eq!(plan.operations.len(), 1);
563 match &plan.operations[0] {
564 SchemaOperation::CreateEntity { name, fields } => {
565 assert_eq!(name, "User");
566 assert_eq!(fields.len(), 1);
567 assert_eq!(fields[0].name, "email");
568 }
569 other => panic!("expected CreateEntity, got: {other:?}"),
570 }
571 }
572
573 #[test]
574 fn dry_run_includes_indexes() {
575 let adapter = DryRunAdapter;
576 let mut manifest = minimal_manifest();
577 manifest.entities[0].indexes.push(ManifestIndex {
578 name: "by_email".into(),
579 fields: vec!["email".into()],
580 unique: true,
581 });
582 let plan = adapter.plan_schema(&manifest).unwrap();
583
584 assert_eq!(plan.operations.len(), 2);
585 match &plan.operations[1] {
586 SchemaOperation::AddIndex {
587 entity,
588 name,
589 fields,
590 unique,
591 } => {
592 assert_eq!(entity, "User");
593 assert_eq!(name, "by_email");
594 assert_eq!(fields, &vec!["email".to_string()]);
595 assert!(unique);
596 }
597 other => panic!("expected AddIndex, got: {other:?}"),
598 }
599 }
600
601 #[test]
602 fn dry_run_empty_manifest_produces_noop() {
603 let adapter = DryRunAdapter;
604 let manifest = AppManifest {
605 manifest_version: MANIFEST_VERSION,
606 name: "empty".into(),
607 version: "0.1.0".into(),
608 entities: vec![],
609 routes: vec![],
610 queries: vec![],
611 actions: vec![],
612 policies: vec![],
613 };
614 let plan = adapter.plan_schema(&manifest).unwrap();
615 assert!(plan.is_empty());
616 }
617
618 #[test]
619 fn diff_adapter_detects_new_entity() {
620 let old = minimal_manifest();
621 let mut new = minimal_manifest();
622 new.entities.push(ManifestEntity {
623 name: "Post".into(),
624 fields: vec![ManifestField {
625 name: "title".into(),
626 field_type: "string".into(),
627 optional: false,
628 unique: false,
629 crdt: None,
630 }],
631 indexes: vec![],
632 relations: vec![],
633 search: None,
634 crdt: true,
635 });
636
637 let adapter = DiffAdapter { from: old };
638 let plan = adapter.plan_schema(&new).unwrap();
639
640 assert!(plan.operations.iter().any(|op| matches!(
641 op,
642 SchemaOperation::CreateEntity { name, .. } if name == "Post"
643 )));
644 }
645
646 #[test]
647 fn diff_adapter_detects_removed_entity() {
648 let old = minimal_manifest();
649 let mut new = minimal_manifest();
650 new.entities.clear();
651
652 let adapter = DiffAdapter { from: old };
653 let plan = adapter.plan_schema(&new).unwrap();
654
655 assert!(plan.operations.iter().any(|op| matches!(
656 op,
657 SchemaOperation::RemoveEntity { name } if name == "User"
658 )));
659 }
660
661 #[test]
662 fn diff_adapter_detects_added_field() {
663 let old = minimal_manifest();
664 let mut new = minimal_manifest();
665 new.entities[0].fields.push(ManifestField {
666 name: "name".into(),
667 field_type: "string".into(),
668 optional: false,
669 unique: false,
670 crdt: None,
671 });
672
673 let adapter = DiffAdapter { from: old };
674 let plan = adapter.plan_schema(&new).unwrap();
675
676 assert!(plan.operations.iter().any(|op| matches!(
677 op,
678 SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name"
679 )));
680 }
681
682 #[test]
683 fn diff_adapter_detects_removed_field() {
684 let old = minimal_manifest();
685 let mut new = minimal_manifest();
686 new.entities[0].fields.clear();
687
688 let adapter = DiffAdapter { from: old };
689 let plan = adapter.plan_schema(&new).unwrap();
690
691 assert!(plan.operations.iter().any(|op| matches!(
692 op,
693 SchemaOperation::RemoveField { entity, field_name } if entity == "User" && field_name == "email"
694 )));
695 }
696
697 #[test]
698 fn diff_adapter_no_changes_produces_noop() {
699 let m = minimal_manifest();
700 let adapter = DiffAdapter { from: m.clone() };
701 let plan = adapter.plan_schema(&m).unwrap();
702 assert!(plan.is_empty());
703 }
704
705 #[test]
706 fn apply_schema_not_implemented() {
707 let adapter = DryRunAdapter;
708 let plan = SchemaPlan { operations: vec![] };
709 let result = adapter.apply_schema(&plan);
710 assert!(result.is_err());
711 assert_eq!(result.unwrap_err().code, "APPLY_NOT_IMPLEMENTED");
712 }
713
714 #[test]
717 fn safe_plan_has_no_warnings() {
718 let plan = SchemaPlan {
719 operations: vec![
720 SchemaOperation::CreateEntity {
721 name: "User".into(),
722 fields: vec![],
723 },
724 SchemaOperation::AddIndex {
725 entity: "User".into(),
726 name: "idx".into(),
727 fields: vec!["email".into()],
728 unique: true,
729 },
730 SchemaOperation::Noop,
731 ],
732 };
733 let analysis = analyze_plan(&plan);
734 assert!(!analysis.destructive);
735 assert!(!analysis.has_unsupported);
736 assert!(analysis.warnings.is_empty());
737 }
738
739 #[test]
740 fn remove_entity_is_destructive() {
741 let plan = SchemaPlan {
742 operations: vec![SchemaOperation::RemoveEntity {
743 name: "User".into(),
744 }],
745 };
746 let analysis = analyze_plan(&plan);
747 assert!(analysis.destructive);
748 assert!(analysis.has_unsupported);
749 assert_eq!(analysis.warnings.len(), 1);
750 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_ENTITY");
751 }
752
753 #[test]
754 fn remove_field_is_destructive() {
755 let plan = SchemaPlan {
756 operations: vec![SchemaOperation::RemoveField {
757 entity: "User".into(),
758 field_name: "email".into(),
759 }],
760 };
761 let analysis = analyze_plan(&plan);
762 assert!(analysis.destructive);
763 assert!(analysis.has_unsupported);
764 assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_FIELD");
765 }
766
767 #[test]
768 fn remove_index_is_unsupported_not_destructive() {
769 let plan = SchemaPlan {
770 operations: vec![SchemaOperation::RemoveIndex {
771 entity: "User".into(),
772 name: "idx".into(),
773 }],
774 };
775 let analysis = analyze_plan(&plan);
776 assert!(!analysis.destructive);
777 assert!(analysis.has_unsupported);
778 assert_eq!(analysis.warnings[0].code, "UNSUPPORTED_REMOVE_INDEX");
779 }
780
781 #[test]
782 fn mixed_plan_flags_both() {
783 let plan = SchemaPlan {
784 operations: vec![
785 SchemaOperation::CreateEntity {
786 name: "Post".into(),
787 fields: vec![],
788 },
789 SchemaOperation::RemoveEntity {
790 name: "User".into(),
791 },
792 SchemaOperation::RemoveIndex {
793 entity: "Post".into(),
794 name: "idx".into(),
795 },
796 ],
797 };
798 let analysis = analyze_plan(&plan);
799 assert!(analysis.destructive);
800 assert!(analysis.has_unsupported);
801 assert_eq!(analysis.warnings.len(), 2);
802 }
803
804 #[test]
805 fn noop_plan_is_safe() {
806 let plan = SchemaPlan {
807 operations: vec![SchemaOperation::Noop],
808 };
809 let analysis = analyze_plan(&plan);
810 assert!(!analysis.destructive);
811 assert!(!analysis.has_unsupported);
812 assert!(analysis.warnings.is_empty());
813 }
814
815 #[test]
818 fn storage_error_display() {
819 let err = StorageError {
820 code: "TEST".into(),
821 message: "msg".into(),
822 };
823 assert_eq!(format!("{err}"), "[TEST] msg");
824 }
825
826 #[test]
829 fn plan_from_snapshot_empty_both() {
830 let snapshot = SchemaSnapshot { tables: vec![] };
831 let manifest = AppManifest {
832 manifest_version: MANIFEST_VERSION,
833 name: "test".into(),
834 version: "0.1.0".into(),
835 entities: vec![],
836 routes: vec![],
837 queries: vec![],
838 actions: vec![],
839 policies: vec![],
840 };
841 let plan = plan_from_snapshot(&snapshot, &manifest);
842 assert!(plan.is_empty());
843 }
844
845 #[test]
846 fn plan_from_snapshot_add_field_to_existing() {
847 let snapshot = SchemaSnapshot {
848 tables: vec![TableSnapshot {
849 name: "User".into(),
850 columns: vec![
851 ColumnSnapshot {
852 name: "id".into(),
853 column_type: "TEXT".into(),
854 notnull: true,
855 primary_key: true,
856 },
857 ColumnSnapshot {
858 name: "email".into(),
859 column_type: "TEXT".into(),
860 notnull: true,
861 primary_key: false,
862 },
863 ],
864 indexes: vec![],
865 }],
866 };
867 let manifest = AppManifest {
868 manifest_version: MANIFEST_VERSION,
869 name: "test".into(),
870 version: "0.1.0".into(),
871 entities: vec![ManifestEntity {
872 name: "User".into(),
873 fields: vec![
874 ManifestField {
875 name: "email".into(),
876 field_type: "string".into(),
877 optional: false,
878 unique: true,
879 crdt: None,
880 },
881 ManifestField {
882 name: "name".into(),
883 field_type: "string".into(),
884 optional: false,
885 unique: false,
886 crdt: None,
887 },
888 ],
889 indexes: vec![],
890 relations: vec![],
891 search: None,
892 crdt: true,
893 }],
894 routes: vec![],
895 queries: vec![],
896 actions: vec![],
897 policies: vec![],
898 };
899 let plan = plan_from_snapshot(&snapshot, &manifest);
900 assert!(plan.operations.iter().any(|op| matches!(op, SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name")));
901 }
902
903 #[test]
904 fn plan_from_snapshot_add_index() {
905 let snapshot = SchemaSnapshot {
906 tables: vec![TableSnapshot {
907 name: "User".into(),
908 columns: vec![
909 ColumnSnapshot {
910 name: "id".into(),
911 column_type: "TEXT".into(),
912 notnull: true,
913 primary_key: true,
914 },
915 ColumnSnapshot {
916 name: "email".into(),
917 column_type: "TEXT".into(),
918 notnull: true,
919 primary_key: false,
920 },
921 ],
922 indexes: vec![], }],
924 };
925 let manifest = AppManifest {
926 manifest_version: MANIFEST_VERSION,
927 name: "test".into(),
928 version: "0.1.0".into(),
929 entities: vec![ManifestEntity {
930 name: "User".into(),
931 fields: vec![ManifestField {
932 name: "email".into(),
933 field_type: "string".into(),
934 optional: false,
935 unique: true,
936 crdt: None,
937 }],
938 indexes: vec![ManifestIndex {
939 name: "by_email".into(),
940 fields: vec!["email".into()],
941 unique: true,
942 }],
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
954 .operations
955 .iter()
956 .any(|op| matches!(op, SchemaOperation::AddIndex { name, .. } if name == "by_email")));
957 }
958
959 #[test]
962 fn plan_empty_vec_is_empty() {
963 let plan = SchemaPlan { operations: vec![] };
964 assert!(plan.is_empty());
965 }
966
967 #[test]
968 fn plan_with_real_ops_not_empty() {
969 let plan = SchemaPlan {
970 operations: vec![SchemaOperation::CreateEntity {
971 name: "X".into(),
972 fields: vec![],
973 }],
974 };
975 assert!(!plan.is_empty());
976 }
977
978 #[test]
981 fn plan_analysis_serializable() {
982 let analysis = analyze_plan(&SchemaPlan {
983 operations: vec![SchemaOperation::RemoveEntity { name: "X".into() }],
984 });
985 let json = serde_json::to_string(&analysis).unwrap();
986 assert!(json.contains("DESTRUCTIVE_REMOVE_ENTITY"));
987 }
988}