Skip to main content

pylon_storage/
lib.rs

1pub mod files;
2#[cfg(feature = "postgres-live")]
3pub mod pg_datastore;
4pub mod pool;
5pub mod postgres;
6pub mod sqlite;
7
8use std::fmt;
9
10use pylon_kernel::AppManifest;
11use serde::{Deserialize, Serialize};
12
13// ---------------------------------------------------------------------------
14// Errors
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct StorageError {
19    pub code: String,
20    pub message: String,
21}
22
23impl fmt::Display for StorageError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        write!(f, "[{}] {}", self.code, self.message)
26    }
27}
28
29impl std::error::Error for StorageError {}
30
31// ---------------------------------------------------------------------------
32// Schema operations — what a plan is made of
33// ---------------------------------------------------------------------------
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(tag = "op", rename_all = "snake_case")]
37pub enum SchemaOperation {
38    CreateEntity {
39        name: String,
40        fields: Vec<FieldSpec>,
41    },
42    AddField {
43        entity: String,
44        field: FieldSpec,
45    },
46    RemoveField {
47        entity: String,
48        field_name: String,
49    },
50    RemoveEntity {
51        name: String,
52    },
53    AddIndex {
54        entity: String,
55        name: String,
56        fields: Vec<String>,
57        unique: bool,
58    },
59    RemoveIndex {
60        entity: String,
61        name: String,
62    },
63    Noop,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct FieldSpec {
68    pub name: String,
69    pub field_type: String,
70    pub optional: bool,
71    pub unique: bool,
72}
73
74// ---------------------------------------------------------------------------
75// Schema plan — the output of planning
76// ---------------------------------------------------------------------------
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct SchemaPlan {
80    pub operations: Vec<SchemaOperation>,
81}
82
83impl SchemaPlan {
84    pub fn is_empty(&self) -> bool {
85        self.operations.is_empty()
86            || self
87                .operations
88                .iter()
89                .all(|op| matches!(op, SchemaOperation::Noop))
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Schema snapshot — shared introspection types
95// ---------------------------------------------------------------------------
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98pub struct SchemaSnapshot {
99    pub tables: Vec<TableSnapshot>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct TableSnapshot {
104    pub name: String,
105    pub columns: Vec<ColumnSnapshot>,
106    pub indexes: Vec<IndexSnapshot>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct ColumnSnapshot {
111    pub name: String,
112    pub column_type: String,
113    pub notnull: bool,
114    pub primary_key: bool,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct IndexSnapshot {
119    pub name: String,
120    pub columns: Vec<String>,
121    pub unique: bool,
122}
123
124/// Plan additive schema changes from a snapshot to a target manifest.
125/// Shared by both SQLite and Postgres adapters.
126/// Only produces CreateEntity, AddField, AddIndex, and Noop.
127pub fn plan_from_snapshot(snapshot: &SchemaSnapshot, target: &AppManifest) -> SchemaPlan {
128    use std::collections::{HashMap, HashSet};
129
130    let existing_tables: HashMap<&str, &TableSnapshot> = snapshot
131        .tables
132        .iter()
133        .map(|t| (t.name.as_str(), t))
134        .collect();
135
136    let mut operations = Vec::new();
137
138    for entity in &target.entities {
139        match existing_tables.get(entity.name.as_str()) {
140            None => {
141                let fields: Vec<FieldSpec> = entity
142                    .fields
143                    .iter()
144                    .map(|f| FieldSpec {
145                        name: f.name.clone(),
146                        field_type: f.field_type.clone(),
147                        optional: f.optional,
148                        unique: f.unique,
149                    })
150                    .collect();
151                operations.push(SchemaOperation::CreateEntity {
152                    name: entity.name.clone(),
153                    fields,
154                });
155                for index in &entity.indexes {
156                    operations.push(SchemaOperation::AddIndex {
157                        entity: entity.name.clone(),
158                        name: index.name.clone(),
159                        fields: index.fields.clone(),
160                        unique: index.unique,
161                    });
162                }
163            }
164            Some(table) => {
165                let existing_cols: HashSet<&str> =
166                    table.columns.iter().map(|c| c.name.as_str()).collect();
167                for field in &entity.fields {
168                    if !existing_cols.contains(field.name.as_str()) {
169                        operations.push(SchemaOperation::AddField {
170                            entity: entity.name.clone(),
171                            field: FieldSpec {
172                                name: field.name.clone(),
173                                field_type: field.field_type.clone(),
174                                optional: field.optional,
175                                unique: field.unique,
176                            },
177                        });
178                    }
179                }
180                // Index names in DB are prefixed: {entity}_{index_name}.
181                let existing_indexes: HashSet<&str> =
182                    table.indexes.iter().map(|i| i.name.as_str()).collect();
183                for index in &entity.indexes {
184                    let full_name = format!("{}_{}", entity.name, index.name);
185                    if !existing_indexes.contains(full_name.as_str()) {
186                        operations.push(SchemaOperation::AddIndex {
187                            entity: entity.name.clone(),
188                            name: index.name.clone(),
189                            fields: index.fields.clone(),
190                            unique: index.unique,
191                        });
192                    }
193                }
194            }
195        }
196    }
197
198    if operations.is_empty() {
199        operations.push(SchemaOperation::Noop);
200    }
201
202    SchemaPlan { operations }
203}
204
205// ---------------------------------------------------------------------------
206// Plan analysis — safety classification
207// ---------------------------------------------------------------------------
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210pub struct PlanWarning {
211    pub code: String,
212    pub message: String,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct PlanAnalysis {
217    pub destructive: bool,
218    pub has_unsupported: bool,
219    pub warnings: Vec<PlanWarning>,
220}
221
222/// Analyze a schema plan for destructive or unsupported operations.
223pub fn analyze_plan(plan: &SchemaPlan) -> PlanAnalysis {
224    let mut destructive = false;
225    let mut has_unsupported = false;
226    let mut warnings = Vec::new();
227
228    for op in &plan.operations {
229        match op {
230            SchemaOperation::RemoveEntity { name } => {
231                destructive = true;
232                has_unsupported = true;
233                warnings.push(PlanWarning {
234                    code: "DESTRUCTIVE_REMOVE_ENTITY".into(),
235                    message: format!(
236                        "Removing entity \"{}\" will drop the table and all its data",
237                        name
238                    ),
239                });
240            }
241            SchemaOperation::RemoveField { entity, field_name } => {
242                destructive = true;
243                has_unsupported = true;
244                warnings.push(PlanWarning {
245                    code: "DESTRUCTIVE_REMOVE_FIELD".into(),
246                    message: format!(
247                        "Removing field \"{}.{}\" will drop the column and its data",
248                        entity, field_name
249                    ),
250                });
251            }
252            SchemaOperation::RemoveIndex { entity, name } => {
253                has_unsupported = true;
254                warnings.push(PlanWarning {
255                    code: "UNSUPPORTED_REMOVE_INDEX".into(),
256                    message: format!(
257                        "Removing index \"{}.{}\" is not supported by the SQLite adapter",
258                        entity, name
259                    ),
260                });
261            }
262            _ => {}
263        }
264    }
265
266    PlanAnalysis {
267        destructive,
268        has_unsupported,
269        warnings,
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Storage adapter trait
275// ---------------------------------------------------------------------------
276
277pub trait StorageAdapter {
278    /// Produce a plan that would bring storage in line with the target manifest.
279    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError>;
280
281    /// Apply a schema plan. Not implemented by dry-run adapters.
282    fn apply_schema(&self, _plan: &SchemaPlan) -> Result<(), StorageError> {
283        Err(StorageError {
284            code: "APPLY_NOT_IMPLEMENTED".into(),
285            message: "This adapter does not support applying schemas".into(),
286        })
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Dry-run adapter — plans against an empty baseline
292// ---------------------------------------------------------------------------
293
294/// A storage adapter that assumes no existing schema.
295/// It produces a plan to create everything from scratch.
296pub struct DryRunAdapter;
297
298impl StorageAdapter for DryRunAdapter {
299    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
300        let mut operations = Vec::new();
301
302        for entity in &target.entities {
303            let fields: Vec<FieldSpec> = entity
304                .fields
305                .iter()
306                .map(|f| FieldSpec {
307                    name: f.name.clone(),
308                    field_type: f.field_type.clone(),
309                    optional: f.optional,
310                    unique: f.unique,
311                })
312                .collect();
313
314            operations.push(SchemaOperation::CreateEntity {
315                name: entity.name.clone(),
316                fields,
317            });
318
319            for index in &entity.indexes {
320                operations.push(SchemaOperation::AddIndex {
321                    entity: entity.name.clone(),
322                    name: index.name.clone(),
323                    fields: index.fields.clone(),
324                    unique: index.unique,
325                });
326            }
327        }
328
329        if operations.is_empty() {
330            operations.push(SchemaOperation::Noop);
331        }
332
333        Ok(SchemaPlan { operations })
334    }
335}
336
337// ---------------------------------------------------------------------------
338// Diff-based adapter — plans from one manifest to another
339// ---------------------------------------------------------------------------
340
341/// A storage adapter that plans the transition from an old manifest to a new one.
342pub struct DiffAdapter {
343    pub from: AppManifest,
344}
345
346impl StorageAdapter for DiffAdapter {
347    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError> {
348        let mut operations = Vec::new();
349
350        let old_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = self
351            .from
352            .entities
353            .iter()
354            .map(|e| (e.name.as_str(), e))
355            .collect();
356        let new_entities: std::collections::HashMap<&str, &pylon_kernel::ManifestEntity> = target
357            .entities
358            .iter()
359            .map(|e| (e.name.as_str(), e))
360            .collect();
361
362        // Removed entities
363        for name in old_entities.keys() {
364            if !new_entities.contains_key(name) {
365                operations.push(SchemaOperation::RemoveEntity {
366                    name: name.to_string(),
367                });
368            }
369        }
370
371        // Added entities
372        for (name, entity) in &new_entities {
373            if !old_entities.contains_key(name) {
374                let fields: Vec<FieldSpec> = entity
375                    .fields
376                    .iter()
377                    .map(|f| FieldSpec {
378                        name: f.name.clone(),
379                        field_type: f.field_type.clone(),
380                        optional: f.optional,
381                        unique: f.unique,
382                    })
383                    .collect();
384                operations.push(SchemaOperation::CreateEntity {
385                    name: name.to_string(),
386                    fields,
387                });
388                for index in &entity.indexes {
389                    operations.push(SchemaOperation::AddIndex {
390                        entity: name.to_string(),
391                        name: index.name.clone(),
392                        fields: index.fields.clone(),
393                        unique: index.unique,
394                    });
395                }
396            }
397        }
398
399        // Field changes in shared entities
400        for (name, new_entity) in &new_entities {
401            if let Some(old_entity) = old_entities.get(name) {
402                let old_fields: std::collections::HashSet<&str> =
403                    old_entity.fields.iter().map(|f| f.name.as_str()).collect();
404                let new_fields: std::collections::HashSet<&str> =
405                    new_entity.fields.iter().map(|f| f.name.as_str()).collect();
406
407                for field in &new_entity.fields {
408                    if !old_fields.contains(field.name.as_str()) {
409                        operations.push(SchemaOperation::AddField {
410                            entity: name.to_string(),
411                            field: FieldSpec {
412                                name: field.name.clone(),
413                                field_type: field.field_type.clone(),
414                                optional: field.optional,
415                                unique: field.unique,
416                            },
417                        });
418                    }
419                }
420
421                for field in &old_entity.fields {
422                    if !new_fields.contains(field.name.as_str()) {
423                        operations.push(SchemaOperation::RemoveField {
424                            entity: name.to_string(),
425                            field_name: field.name.clone(),
426                        });
427                    }
428                }
429
430                // Index changes
431                let old_indexes: std::collections::HashSet<&str> =
432                    old_entity.indexes.iter().map(|i| i.name.as_str()).collect();
433                let new_indexes: std::collections::HashSet<&str> =
434                    new_entity.indexes.iter().map(|i| i.name.as_str()).collect();
435
436                for index in &new_entity.indexes {
437                    if !old_indexes.contains(index.name.as_str()) {
438                        operations.push(SchemaOperation::AddIndex {
439                            entity: name.to_string(),
440                            name: index.name.clone(),
441                            fields: index.fields.clone(),
442                            unique: index.unique,
443                        });
444                    }
445                }
446
447                for index in &old_entity.indexes {
448                    if !new_indexes.contains(index.name.as_str()) {
449                        operations.push(SchemaOperation::RemoveIndex {
450                            entity: name.to_string(),
451                            name: index.name.clone(),
452                        });
453                    }
454                }
455            }
456        }
457
458        if operations.is_empty() {
459            operations.push(SchemaOperation::Noop);
460        }
461
462        Ok(SchemaPlan { operations })
463    }
464}
465
466// ---------------------------------------------------------------------------
467// Tests
468// ---------------------------------------------------------------------------
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use pylon_kernel::*;
474
475    fn minimal_manifest() -> AppManifest {
476        AppManifest {
477            manifest_version: MANIFEST_VERSION,
478            name: "test".into(),
479            version: "0.1.0".into(),
480            entities: vec![ManifestEntity {
481                name: "User".into(),
482                fields: vec![ManifestField {
483                    name: "email".into(),
484                    field_type: "string".into(),
485                    optional: false,
486                    unique: true,
487                }],
488                indexes: vec![],
489                relations: vec![],
490            }],
491            routes: vec![],
492            queries: vec![],
493            actions: vec![],
494            policies: vec![],
495        }
496    }
497
498    #[test]
499    fn dry_run_creates_all_entities() {
500        let adapter = DryRunAdapter;
501        let manifest = minimal_manifest();
502        let plan = adapter.plan_schema(&manifest).unwrap();
503
504        assert_eq!(plan.operations.len(), 1);
505        match &plan.operations[0] {
506            SchemaOperation::CreateEntity { name, fields } => {
507                assert_eq!(name, "User");
508                assert_eq!(fields.len(), 1);
509                assert_eq!(fields[0].name, "email");
510            }
511            other => panic!("expected CreateEntity, got: {other:?}"),
512        }
513    }
514
515    #[test]
516    fn dry_run_includes_indexes() {
517        let adapter = DryRunAdapter;
518        let mut manifest = minimal_manifest();
519        manifest.entities[0].indexes.push(ManifestIndex {
520            name: "by_email".into(),
521            fields: vec!["email".into()],
522            unique: true,
523        });
524        let plan = adapter.plan_schema(&manifest).unwrap();
525
526        assert_eq!(plan.operations.len(), 2);
527        match &plan.operations[1] {
528            SchemaOperation::AddIndex {
529                entity,
530                name,
531                fields,
532                unique,
533            } => {
534                assert_eq!(entity, "User");
535                assert_eq!(name, "by_email");
536                assert_eq!(fields, &vec!["email".to_string()]);
537                assert!(unique);
538            }
539            other => panic!("expected AddIndex, got: {other:?}"),
540        }
541    }
542
543    #[test]
544    fn dry_run_empty_manifest_produces_noop() {
545        let adapter = DryRunAdapter;
546        let manifest = AppManifest {
547            manifest_version: MANIFEST_VERSION,
548            name: "empty".into(),
549            version: "0.1.0".into(),
550            entities: vec![],
551            routes: vec![],
552            queries: vec![],
553            actions: vec![],
554            policies: vec![],
555        };
556        let plan = adapter.plan_schema(&manifest).unwrap();
557        assert!(plan.is_empty());
558    }
559
560    #[test]
561    fn diff_adapter_detects_new_entity() {
562        let old = minimal_manifest();
563        let mut new = minimal_manifest();
564        new.entities.push(ManifestEntity {
565            name: "Post".into(),
566            fields: vec![ManifestField {
567                name: "title".into(),
568                field_type: "string".into(),
569                optional: false,
570                unique: false,
571            }],
572            indexes: vec![],
573            relations: vec![],
574        });
575
576        let adapter = DiffAdapter { from: old };
577        let plan = adapter.plan_schema(&new).unwrap();
578
579        assert!(plan.operations.iter().any(|op| matches!(
580            op,
581            SchemaOperation::CreateEntity { name, .. } if name == "Post"
582        )));
583    }
584
585    #[test]
586    fn diff_adapter_detects_removed_entity() {
587        let old = minimal_manifest();
588        let mut new = minimal_manifest();
589        new.entities.clear();
590
591        let adapter = DiffAdapter { from: old };
592        let plan = adapter.plan_schema(&new).unwrap();
593
594        assert!(plan.operations.iter().any(|op| matches!(
595            op,
596            SchemaOperation::RemoveEntity { name } if name == "User"
597        )));
598    }
599
600    #[test]
601    fn diff_adapter_detects_added_field() {
602        let old = minimal_manifest();
603        let mut new = minimal_manifest();
604        new.entities[0].fields.push(ManifestField {
605            name: "name".into(),
606            field_type: "string".into(),
607            optional: false,
608            unique: false,
609        });
610
611        let adapter = DiffAdapter { from: old };
612        let plan = adapter.plan_schema(&new).unwrap();
613
614        assert!(plan.operations.iter().any(|op| matches!(
615            op,
616            SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name"
617        )));
618    }
619
620    #[test]
621    fn diff_adapter_detects_removed_field() {
622        let old = minimal_manifest();
623        let mut new = minimal_manifest();
624        new.entities[0].fields.clear();
625
626        let adapter = DiffAdapter { from: old };
627        let plan = adapter.plan_schema(&new).unwrap();
628
629        assert!(plan.operations.iter().any(|op| matches!(
630            op,
631            SchemaOperation::RemoveField { entity, field_name } if entity == "User" && field_name == "email"
632        )));
633    }
634
635    #[test]
636    fn diff_adapter_no_changes_produces_noop() {
637        let m = minimal_manifest();
638        let adapter = DiffAdapter { from: m.clone() };
639        let plan = adapter.plan_schema(&m).unwrap();
640        assert!(plan.is_empty());
641    }
642
643    #[test]
644    fn apply_schema_not_implemented() {
645        let adapter = DryRunAdapter;
646        let plan = SchemaPlan { operations: vec![] };
647        let result = adapter.apply_schema(&plan);
648        assert!(result.is_err());
649        assert_eq!(result.unwrap_err().code, "APPLY_NOT_IMPLEMENTED");
650    }
651
652    // -- Plan analysis tests --
653
654    #[test]
655    fn safe_plan_has_no_warnings() {
656        let plan = SchemaPlan {
657            operations: vec![
658                SchemaOperation::CreateEntity {
659                    name: "User".into(),
660                    fields: vec![],
661                },
662                SchemaOperation::AddIndex {
663                    entity: "User".into(),
664                    name: "idx".into(),
665                    fields: vec!["email".into()],
666                    unique: true,
667                },
668                SchemaOperation::Noop,
669            ],
670        };
671        let analysis = analyze_plan(&plan);
672        assert!(!analysis.destructive);
673        assert!(!analysis.has_unsupported);
674        assert!(analysis.warnings.is_empty());
675    }
676
677    #[test]
678    fn remove_entity_is_destructive() {
679        let plan = SchemaPlan {
680            operations: vec![SchemaOperation::RemoveEntity {
681                name: "User".into(),
682            }],
683        };
684        let analysis = analyze_plan(&plan);
685        assert!(analysis.destructive);
686        assert!(analysis.has_unsupported);
687        assert_eq!(analysis.warnings.len(), 1);
688        assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_ENTITY");
689    }
690
691    #[test]
692    fn remove_field_is_destructive() {
693        let plan = SchemaPlan {
694            operations: vec![SchemaOperation::RemoveField {
695                entity: "User".into(),
696                field_name: "email".into(),
697            }],
698        };
699        let analysis = analyze_plan(&plan);
700        assert!(analysis.destructive);
701        assert!(analysis.has_unsupported);
702        assert_eq!(analysis.warnings[0].code, "DESTRUCTIVE_REMOVE_FIELD");
703    }
704
705    #[test]
706    fn remove_index_is_unsupported_not_destructive() {
707        let plan = SchemaPlan {
708            operations: vec![SchemaOperation::RemoveIndex {
709                entity: "User".into(),
710                name: "idx".into(),
711            }],
712        };
713        let analysis = analyze_plan(&plan);
714        assert!(!analysis.destructive);
715        assert!(analysis.has_unsupported);
716        assert_eq!(analysis.warnings[0].code, "UNSUPPORTED_REMOVE_INDEX");
717    }
718
719    #[test]
720    fn mixed_plan_flags_both() {
721        let plan = SchemaPlan {
722            operations: vec![
723                SchemaOperation::CreateEntity {
724                    name: "Post".into(),
725                    fields: vec![],
726                },
727                SchemaOperation::RemoveEntity {
728                    name: "User".into(),
729                },
730                SchemaOperation::RemoveIndex {
731                    entity: "Post".into(),
732                    name: "idx".into(),
733                },
734            ],
735        };
736        let analysis = analyze_plan(&plan);
737        assert!(analysis.destructive);
738        assert!(analysis.has_unsupported);
739        assert_eq!(analysis.warnings.len(), 2);
740    }
741
742    #[test]
743    fn noop_plan_is_safe() {
744        let plan = SchemaPlan {
745            operations: vec![SchemaOperation::Noop],
746        };
747        let analysis = analyze_plan(&plan);
748        assert!(!analysis.destructive);
749        assert!(!analysis.has_unsupported);
750        assert!(analysis.warnings.is_empty());
751    }
752
753    // -- StorageError --
754
755    #[test]
756    fn storage_error_display() {
757        let err = StorageError {
758            code: "TEST".into(),
759            message: "msg".into(),
760        };
761        assert_eq!(format!("{err}"), "[TEST] msg");
762    }
763
764    // -- plan_from_snapshot edge cases --
765
766    #[test]
767    fn plan_from_snapshot_empty_both() {
768        let snapshot = SchemaSnapshot { tables: vec![] };
769        let manifest = AppManifest {
770            manifest_version: MANIFEST_VERSION,
771            name: "test".into(),
772            version: "0.1.0".into(),
773            entities: vec![],
774            routes: vec![],
775            queries: vec![],
776            actions: vec![],
777            policies: vec![],
778        };
779        let plan = plan_from_snapshot(&snapshot, &manifest);
780        assert!(plan.is_empty());
781    }
782
783    #[test]
784    fn plan_from_snapshot_add_field_to_existing() {
785        let snapshot = SchemaSnapshot {
786            tables: vec![TableSnapshot {
787                name: "User".into(),
788                columns: vec![
789                    ColumnSnapshot {
790                        name: "id".into(),
791                        column_type: "TEXT".into(),
792                        notnull: true,
793                        primary_key: true,
794                    },
795                    ColumnSnapshot {
796                        name: "email".into(),
797                        column_type: "TEXT".into(),
798                        notnull: true,
799                        primary_key: false,
800                    },
801                ],
802                indexes: vec![],
803            }],
804        };
805        let manifest = AppManifest {
806            manifest_version: MANIFEST_VERSION,
807            name: "test".into(),
808            version: "0.1.0".into(),
809            entities: vec![ManifestEntity {
810                name: "User".into(),
811                fields: vec![
812                    ManifestField {
813                        name: "email".into(),
814                        field_type: "string".into(),
815                        optional: false,
816                        unique: true,
817                    },
818                    ManifestField {
819                        name: "name".into(),
820                        field_type: "string".into(),
821                        optional: false,
822                        unique: false,
823                    },
824                ],
825                indexes: vec![],
826                relations: vec![],
827            }],
828            routes: vec![],
829            queries: vec![],
830            actions: vec![],
831            policies: vec![],
832        };
833        let plan = plan_from_snapshot(&snapshot, &manifest);
834        assert!(plan.operations.iter().any(|op| matches!(op, SchemaOperation::AddField { entity, field } if entity == "User" && field.name == "name")));
835    }
836
837    #[test]
838    fn plan_from_snapshot_add_index() {
839        let snapshot = SchemaSnapshot {
840            tables: vec![TableSnapshot {
841                name: "User".into(),
842                columns: vec![
843                    ColumnSnapshot {
844                        name: "id".into(),
845                        column_type: "TEXT".into(),
846                        notnull: true,
847                        primary_key: true,
848                    },
849                    ColumnSnapshot {
850                        name: "email".into(),
851                        column_type: "TEXT".into(),
852                        notnull: true,
853                        primary_key: false,
854                    },
855                ],
856                indexes: vec![], // no indexes
857            }],
858        };
859        let manifest = AppManifest {
860            manifest_version: MANIFEST_VERSION,
861            name: "test".into(),
862            version: "0.1.0".into(),
863            entities: vec![ManifestEntity {
864                name: "User".into(),
865                fields: vec![ManifestField {
866                    name: "email".into(),
867                    field_type: "string".into(),
868                    optional: false,
869                    unique: true,
870                }],
871                indexes: vec![ManifestIndex {
872                    name: "by_email".into(),
873                    fields: vec!["email".into()],
874                    unique: true,
875                }],
876                relations: vec![],
877            }],
878            routes: vec![],
879            queries: vec![],
880            actions: vec![],
881            policies: vec![],
882        };
883        let plan = plan_from_snapshot(&snapshot, &manifest);
884        assert!(plan
885            .operations
886            .iter()
887            .any(|op| matches!(op, SchemaOperation::AddIndex { name, .. } if name == "by_email")));
888    }
889
890    // -- SchemaPlan::is_empty --
891
892    #[test]
893    fn plan_empty_vec_is_empty() {
894        let plan = SchemaPlan { operations: vec![] };
895        assert!(plan.is_empty());
896    }
897
898    #[test]
899    fn plan_with_real_ops_not_empty() {
900        let plan = SchemaPlan {
901            operations: vec![SchemaOperation::CreateEntity {
902                name: "X".into(),
903                fields: vec![],
904            }],
905        };
906        assert!(!plan.is_empty());
907    }
908
909    // -- PlanWarning serialization --
910
911    #[test]
912    fn plan_analysis_serializable() {
913        let analysis = analyze_plan(&SchemaPlan {
914            operations: vec![SchemaOperation::RemoveEntity { name: "X".into() }],
915        });
916        let json = serde_json::to_string(&analysis).unwrap();
917        assert!(json.contains("DESTRUCTIVE_REMOVE_ENTITY"));
918    }
919}