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    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    /// Materialize the FTS5 + facet-bitmap shadow tables for a
76    /// searchable entity. Emitted alongside CreateEntity when the
77    /// manifest declares a `search:` config; idempotent so repeated
78    /// pushes against a live DB are safe.
79    CreateSearchIndex {
80        entity: String,
81        config: pylon_kernel::ManifestSearchConfig,
82    },
83    /// Drop an entity's search shadow tables. Emitted when the entity
84    /// is removed from the manifest or its `search:` block is cleared.
85    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// ---------------------------------------------------------------------------
100// Schema plan — the output of planning
101// ---------------------------------------------------------------------------
102
103#[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// ---------------------------------------------------------------------------
119// Schema snapshot — shared introspection types
120// ---------------------------------------------------------------------------
121
122#[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
149/// Plan additive schema changes from a snapshot to a target manifest.
150/// Shared by both SQLite and Postgres adapters.
151/// Only produces CreateEntity, AddField, AddIndex, and Noop.
152pub 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                // Index names in DB are prefixed: {entity}_{index_name}.
214                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                // Search-index creation on an already-existing table:
228                // emit CreateSearchIndex when the manifest adds a
229                // `search:` block to an entity that was previously
230                // non-searchable. Apply is idempotent (IF NOT EXISTS
231                // on the FTS + facet tables) so repeated pushes don't
232                // fail; the one-way gap this closes is going from
233                // "no search" → "search" on a table that already has
234                // rows.
235                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// ---------------------------------------------------------------------------
261// Plan analysis — safety classification
262// ---------------------------------------------------------------------------
263
264#[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
277/// Analyze a schema plan for destructive or unsupported operations.
278pub 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
328// ---------------------------------------------------------------------------
329// Storage adapter trait
330// ---------------------------------------------------------------------------
331
332pub trait StorageAdapter {
333    /// Produce a plan that would bring storage in line with the target manifest.
334    fn plan_schema(&self, target: &AppManifest) -> Result<SchemaPlan, StorageError>;
335
336    /// Apply a schema plan. Not implemented by dry-run adapters.
337    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
345// ---------------------------------------------------------------------------
346// Dry-run adapter — plans against an empty baseline
347// ---------------------------------------------------------------------------
348
349/// A storage adapter that assumes no existing schema.
350/// It produces a plan to create everything from scratch.
351pub 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
392// ---------------------------------------------------------------------------
393// Diff-based adapter — plans from one manifest to another
394// ---------------------------------------------------------------------------
395
396/// A storage adapter that plans the transition from an old manifest to a new one.
397pub 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        // Removed entities
418        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        // Added entities
427        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        // Field changes in shared entities
455        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                // Index changes
486                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// ---------------------------------------------------------------------------
522// Tests
523// ---------------------------------------------------------------------------
524
525#[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    // -- Plan analysis tests --
715
716    #[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    // -- StorageError --
816
817    #[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    // -- plan_from_snapshot edge cases --
827
828    #[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![], // no indexes
923            }],
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    // -- SchemaPlan::is_empty --
960
961    #[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    // -- PlanWarning serialization --
979
980    #[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}