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 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// ---------------------------------------------------------------------------
17// Errors
18// ---------------------------------------------------------------------------
19
20#[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// ---------------------------------------------------------------------------
44// Schema operations — what a plan is made of
45// ---------------------------------------------------------------------------
46
47#[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    /// Change column shape on an existing field. Only nullable transitions
63    /// today (`required → optional`, `optional → required`); type changes
64    /// stay manual because they can fail on existing rows in ways the
65    /// planner can't reason about (e.g. `string → int` on a column with
66    /// non-numeric values).
67    ///
68    /// Real-world driver: pylon-cloud's User entity made `avatarColor`
69    /// optional after the framework's OAuth handler started failing
70    /// USER_CREATE_FAILED on a NOT NULL violation. Without AlterField
71    /// the manifest change was a no-op against the live PG schema
72    /// — operators had to drop NOT NULL by hand. Now the planner emits
73    /// the ALTER and `pylon dev` auto-applies it on the next reload.
74    ///
75    /// `previous` carries the old column shape so the SQL emitter can
76    /// decide whether to emit DROP NOT NULL, SET NOT NULL, both, or
77    /// neither. `target` is the desired final shape.
78    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    /// Materialize the FTS5 + facet-bitmap shadow tables for a
97    /// searchable entity. Emitted alongside CreateEntity when the
98    /// manifest declares a `search:` config; idempotent so repeated
99    /// pushes against a live DB are safe.
100    CreateSearchIndex {
101        entity: String,
102        config: pylon_kernel::ManifestSearchConfig,
103    },
104    /// Drop an entity's search shadow tables. Emitted when the entity
105    /// is removed from the manifest or its `search:` block is cleared.
106    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// ---------------------------------------------------------------------------
121// Schema plan — the output of planning
122// ---------------------------------------------------------------------------
123
124#[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// ---------------------------------------------------------------------------
140// Schema snapshot — shared introspection types
141// ---------------------------------------------------------------------------
142
143#[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
170/// Plan additive schema changes from a snapshot to a target manifest.
171/// Shared by both SQLite and Postgres adapters.
172/// Only produces CreateEntity, AddField, AddIndex, and Noop.
173pub 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                            // Detect nullable-shape drift between the live
236                            // column and the manifest. Existing column's
237                            // `notnull = true` ≡ manifest's `optional = false`.
238                            // We DON'T re-derive type/unique here — type
239                            // changes need a destructive plan we don't
240                            // attempt automatically, and unique-add lives in
241                            // the AddIndex path.
242                            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                // Index names in DB are prefixed: {entity}_{index_name}.
267                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                // Search-index creation on an already-existing table:
281                // emit CreateSearchIndex when the manifest adds a
282                // `search:` block to an entity that was previously
283                // non-searchable. Apply is idempotent (IF NOT EXISTS
284                // on the FTS + facet tables) so repeated pushes don't
285                // fail; the one-way gap this closes is going from
286                // "no search" → "search" on a table that already has
287                // rows.
288                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// ---------------------------------------------------------------------------
314// Plan analysis — safety classification
315// ---------------------------------------------------------------------------
316
317#[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
330/// Analyze a schema plan for destructive or unsupported operations.
331pub 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
381// ---------------------------------------------------------------------------
382// Storage adapter trait
383// ---------------------------------------------------------------------------
384
385pub trait StorageAdapter {
386    /// Produce a plan that would bring storage in line with the target manifest.
387    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError>;
388
389    /// Apply a schema plan. Not implemented by dry-run adapters.
390    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
398// ---------------------------------------------------------------------------
399// Dry-run adapter — plans against an empty baseline
400// ---------------------------------------------------------------------------
401
402/// A storage adapter that assumes no existing schema.
403/// It produces a plan to create everything from scratch.
404pub 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
445// ---------------------------------------------------------------------------
446// Diff-based adapter — plans from one manifest to another
447// ---------------------------------------------------------------------------
448
449/// A storage adapter that plans the transition from an old manifest to a new one.
450pub 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        // Removed entities
471        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        // Added entities
480        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        // Field changes in shared entities
508        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                // Index changes
539                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// ---------------------------------------------------------------------------
575// Tests
576// ---------------------------------------------------------------------------
577
578#[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    // -- Plan analysis tests --
768
769    #[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    // -- StorageError --
869
870    #[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    // -- plan_from_snapshot edge cases --
880
881    #[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![], // no indexes
976            }],
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    // -- SchemaPlan::is_empty --
1013
1014    #[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    // -- PlanWarning serialization --
1032
1033    #[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}