Skip to main content

pylon_storage/
lib.rs

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