Skip to main content

type_bridge_migration/
plan.rs

1//! Pure migration planner.
2//!
3//! Lowers a validated [`MigrationGraph`] and applied-state into an ordered
4//! [`ExecutionPlan`] of [`ExecutionStep`]s, each carrying its [`TxType`] and
5//! the executable TypeQL to run.  No database connection, no async, no TypeDB
6//! driver is touched here.
7
8use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11use type_bridge_orm::_schema::annotations::{
12    AnnotationToken, AnnotationTokenDiff, diff_annotation_tokens, split_annotation_tokens,
13};
14use type_bridge_orm::_schema::info::{
15    AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry, RoleEntry,
16    SchemaInfo,
17};
18use type_bridge_orm::TxType;
19
20use crate::checksum::check_checksum_drift;
21use crate::error::MigrationError;
22use crate::graph::{AppliedMigrationRecord, validate_graph};
23use crate::spec::{MigrationGraph, OperationSpec, copy_attribute_typeql};
24
25/// The kind of execution step, controlling how the executor dispatches it.
26///
27/// `Schema` and `Write` run the carried TypeQL directly.  `Backfill` is a
28/// write-typed step that additionally derives matched/inserted/skipped counts
29/// via bracketing `reduce $c = count;` read queries.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum StepKind {
33    /// Schema DDL step — opened under a schema transaction.
34    #[default]
35    Schema,
36    /// Data-write step — opened under a write transaction.
37    Write,
38    /// Backfill step — write transaction + bracketing count derivation.
39    Backfill,
40}
41
42/// Authored operation kind from which an execution step was lowered.
43///
44/// This discriminant is carried separately from [`StepKind`]: `StepKind`
45/// controls transaction dispatch, while `OperationKind` preserves the stable
46/// artifact-level operation identity used by per-step recovery.
47#[derive(
48    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
49)]
50#[serde(rename_all = "snake_case")]
51pub enum OperationKind {
52    /// Arbitrary authored TypeQL.
53    #[default]
54    RunTypeql,
55    /// Define a complete initial schema.
56    DefineSchema,
57    /// Add an attribute type.
58    AddAttribute,
59    /// Remove an attribute type.
60    RemoveAttribute,
61    /// Add an entity type.
62    AddEntity,
63    /// Remove an entity type.
64    RemoveEntity,
65    /// Add a relation type.
66    AddRelation,
67    /// Remove a relation type.
68    RemoveRelation,
69    /// Add an ownership capability.
70    AddOwnership,
71    /// Remove an ownership capability.
72    RemoveOwnership,
73    /// Modify ownership annotations.
74    ModifyOwnership,
75    /// Modify type annotations.
76    ModifyTypeAnnotations,
77    /// Modify role annotations.
78    ModifyRoleAnnotations,
79    /// Add a relation role.
80    AddRole,
81    /// Remove a relation role.
82    RemoveRole,
83    /// Add a role player capability.
84    AddRolePlayer,
85    /// Remove a role player capability.
86    RemoveRolePlayer,
87    /// Rename an attribute type.
88    RenameAttribute,
89    /// Copy attribute values as a backfill.
90    CopyAttribute,
91}
92
93impl OperationKind {
94    /// Stable snake-case token used in deterministic step identities.
95    pub const fn as_str(self) -> &'static str {
96        match self {
97            Self::RunTypeql => "run_typeql",
98            Self::DefineSchema => "define_schema",
99            Self::AddAttribute => "add_attribute",
100            Self::RemoveAttribute => "remove_attribute",
101            Self::AddEntity => "add_entity",
102            Self::RemoveEntity => "remove_entity",
103            Self::AddRelation => "add_relation",
104            Self::RemoveRelation => "remove_relation",
105            Self::AddOwnership => "add_ownership",
106            Self::RemoveOwnership => "remove_ownership",
107            Self::ModifyOwnership => "modify_ownership",
108            Self::ModifyTypeAnnotations => "modify_type_annotations",
109            Self::ModifyRoleAnnotations => "modify_role_annotations",
110            Self::AddRole => "add_role",
111            Self::RemoveRole => "remove_role",
112            Self::AddRolePlayer => "add_role_player",
113            Self::RemoveRolePlayer => "remove_role_player",
114            Self::RenameAttribute => "rename_attribute",
115            Self::CopyAttribute => "copy_attribute",
116        }
117    }
118
119    fn from_spec(operation: &OperationSpec) -> Self {
120        match operation {
121            OperationSpec::RunTypeql { .. } => Self::RunTypeql,
122            OperationSpec::DefineSchema { .. } => Self::DefineSchema,
123            OperationSpec::AddAttribute { .. } => Self::AddAttribute,
124            OperationSpec::RemoveAttribute { .. } => Self::RemoveAttribute,
125            OperationSpec::AddEntity { .. } => Self::AddEntity,
126            OperationSpec::RemoveEntity { .. } => Self::RemoveEntity,
127            OperationSpec::AddRelation { .. } => Self::AddRelation,
128            OperationSpec::RemoveRelation { .. } => Self::RemoveRelation,
129            OperationSpec::AddOwnership { .. } => Self::AddOwnership,
130            OperationSpec::RemoveOwnership { .. } => Self::RemoveOwnership,
131            OperationSpec::ModifyOwnership { .. } => Self::ModifyOwnership,
132            OperationSpec::ModifyTypeAnnotations { .. } => Self::ModifyTypeAnnotations,
133            OperationSpec::ModifyRoleAnnotations { .. } => Self::ModifyRoleAnnotations,
134            OperationSpec::AddRole { .. } => Self::AddRole,
135            OperationSpec::RemoveRole { .. } => Self::RemoveRole,
136            OperationSpec::AddRolePlayer { .. } => Self::AddRolePlayer,
137            OperationSpec::RemoveRolePlayer { .. } => Self::RemoveRolePlayer,
138            OperationSpec::RenameAttribute { .. } => Self::RenameAttribute,
139            OperationSpec::CopyAttribute { .. } => Self::CopyAttribute,
140        }
141    }
142}
143
144/// One executable step within a migration.
145///
146/// Carries the transaction type and the forward (and optional reverse) TypeQL.
147/// Every step maps to exactly one TypeDB transaction.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct ExecutionStep {
150    /// Transaction type to open for this step.
151    pub tx_type: TxType,
152    /// Discriminant controlling executor dispatch.
153    ///
154    /// Defaults to [`StepKind::Schema`] for backwards-compatible deserialization
155    /// of steps persisted before this field was introduced.
156    #[serde(default)]
157    pub kind: StepKind,
158    /// Artifact operation kind that produced this step.
159    ///
160    /// Defaults to [`OperationKind::RunTypeql`] when deserializing legacy
161    /// persisted plans that predate per-step recovery metadata.
162    #[serde(default)]
163    pub operation_kind: OperationKind,
164    /// Forward (apply) TypeQL text.
165    pub forward: String,
166    /// Reverse (rollback) TypeQL text, or `None` when the step is
167    /// non-reversible.
168    pub reverse: Option<String>,
169}
170
171/// Whether a migration execution is an apply or a rollback.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum MigrationAction {
175    /// Apply the migration forward.
176    Apply,
177    /// Roll the migration back.
178    Rollback,
179}
180
181/// One migration scheduled for execution, together with its assembled steps.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct MigrationExecution {
184    /// Application or migration package label.
185    pub app_label: String,
186    /// Migration file stem, such as `0001_initial`.
187    pub name: String,
188    /// Apply or rollback.
189    pub action: MigrationAction,
190    /// Ordered execution steps.
191    pub steps: Vec<ExecutionStep>,
192    /// Whether every step in this migration has a reverse.
193    ///
194    /// `false` when the migration contains a `DefineSchema` op (model-initial
195    /// schema; no reverse) or any op whose reverse is `None`.
196    pub reversible: bool,
197}
198
199/// Ordered execution plan produced by [`plan`].
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct ExecutionPlan {
202    /// Migrations to apply, in graph (dependency) order.
203    pub to_apply: Vec<MigrationExecution>,
204    /// Migrations to roll back, in reverse discovery order.
205    pub to_rollback: Vec<MigrationExecution>,
206}
207
208/// Produce an ordered [`ExecutionPlan`] from a validated graph and applied state.
209///
210/// # Errors
211///
212/// - [`MigrationError::Planning`] – graph validation found one or more errors.
213/// - [`MigrationError::ChecksumDrift`] – an applied migration's checksum has
214///   drifted (hard gate; no plan is produced).
215/// - [`MigrationError::UnloweredOperation`] – the graph contains an
216///   [`OperationSpec`] variant that is intentionally unsupported by the Rust
217///   planner.
218pub fn plan(
219    graph: &MigrationGraph,
220    applied: &[AppliedMigrationRecord],
221    target: Option<&str>,
222) -> crate::Result<ExecutionPlan> {
223    // Gate 1: structural graph validation (04).
224    let errors = validate_graph(graph, applied);
225    if !errors.is_empty() {
226        return Err(MigrationError::Planning { errors });
227    }
228
229    // Gate 2: checksum drift (04 gate, hard stop before any step assembly).
230    check_checksum_drift(graph, applied)?;
231
232    // Build an applied-key set for O(1) membership checks.
233    let applied_keys: std::collections::BTreeSet<(&str, &str)> = applied
234        .iter()
235        .map(|r| (r.app_label.as_str(), r.name.as_str()))
236        .collect();
237
238    let (to_apply, to_rollback) = if let Some(target_name) = target {
239        // Find the target index by name (or app_label::name).
240        let target_idx = graph
241            .migrations
242            .iter()
243            .position(|m| {
244                m.name == target_name || format!("{}::{}", m.app_label, m.name) == target_name
245            })
246            .ok_or_else(|| MigrationError::TargetNotFound {
247                target: target_name.to_string(),
248            })?;
249
250        let mut apply = Vec::new();
251        let mut rollback = Vec::new();
252
253        for (i, migration) in graph.migrations.iter().enumerate() {
254            let is_applied =
255                applied_keys.contains(&(migration.app_label.as_str(), migration.name.as_str()));
256            if i <= target_idx && !is_applied {
257                apply.push(migration);
258            } else if i > target_idx && is_applied {
259                rollback.push(migration);
260            }
261        }
262
263        // Rollbacks go in reverse order (matches Python _create_plan).
264        rollback.reverse();
265        (apply, rollback)
266    } else {
267        // Apply all pending migrations.
268        let apply: Vec<_> = graph
269            .migrations
270            .iter()
271            .filter(|m| !applied_keys.contains(&(m.app_label.as_str(), m.name.as_str())))
272            .collect();
273        (apply, Vec::new())
274    };
275
276    // Assemble MigrationExecution objects for the apply list.
277    let mut apply_executions = Vec::with_capacity(to_apply.len());
278    for migration in to_apply {
279        let steps = assemble_steps(&migration.operations, migration.reversible)?;
280        let reversible = steps.iter().all(|s| s.reverse.is_some());
281        apply_executions.push(MigrationExecution {
282            app_label: migration.app_label.clone(),
283            name: migration.name.clone(),
284            action: MigrationAction::Apply,
285            steps,
286            reversible,
287        });
288    }
289
290    // Assemble MigrationExecution objects for the rollback list.
291    let mut rollback_executions = Vec::with_capacity(to_rollback.len());
292    for migration in to_rollback {
293        let steps = assemble_steps(&migration.operations, migration.reversible)?;
294        let reversible = steps.iter().all(|s| s.reverse.is_some());
295        rollback_executions.push(MigrationExecution {
296            app_label: migration.app_label.clone(),
297            name: migration.name.clone(),
298            action: MigrationAction::Rollback,
299            steps,
300            reversible,
301        });
302    }
303
304    Ok(ExecutionPlan {
305        to_apply: apply_executions,
306        to_rollback: rollback_executions,
307    })
308}
309
310/// Relation labels deleted wholesale by a `RemoveRelation` in this migration.
311fn removed_relation_labels(operations: &[OperationSpec]) -> BTreeSet<&str> {
312    operations
313        .iter()
314        .filter_map(|op| match op {
315            OperationSpec::RemoveRelation { type_name } => Some(type_name.as_str()),
316            _ => None,
317        })
318        .collect()
319}
320
321/// True when `op` is a relation-scoped granular removal shadowed by a
322/// `RemoveRelation` of the same relation in the same migration.
323///
324/// Legacy v1.5.x artifacts decomposed whole-relation deletion into
325/// `RemoveRolePlayer`/`RemoveRole`/`RemoveOwnership` before the final
326/// `RemoveRelation`. Executed step-per-transaction, committing the last
327/// role's removal violates TypeDB's commit-time rule that a concrete
328/// relation must relate at least one role, stranding the migration after
329/// partial schema changes (#168). `undefine <relation>` already cascades
330/// roles, player capabilities, and ownerships in one schema transaction, so
331/// the shadowed steps are dropped at plan time — the artifact bytes and
332/// checksum are never touched.
333fn shadowed_by_remove_relation(op: &OperationSpec, removed: &BTreeSet<&str>) -> bool {
334    match op {
335        OperationSpec::RemoveRole { relation_type, .. }
336        | OperationSpec::RemoveRolePlayer { relation_type, .. } => {
337            removed.contains(relation_type.as_str())
338        }
339        OperationSpec::RemoveOwnership { owner_type, .. } => removed.contains(owner_type.as_str()),
340        _ => false,
341    }
342}
343
344/// Lower a slice of [`OperationSpec`] into [`ExecutionStep`]s.
345fn assemble_steps(
346    operations: &[OperationSpec],
347    migration_reversible: bool,
348) -> crate::Result<Vec<ExecutionStep>> {
349    let removed_relations = removed_relation_labels(operations);
350    let mut steps = Vec::with_capacity(operations.len());
351    for op in operations {
352        if shadowed_by_remove_relation(op, &removed_relations) {
353            continue;
354        }
355        let mut op_steps: Vec<ExecutionStep> = match op {
356            OperationSpec::RunTypeql { forward, reverse } => {
357                let tx_type = run_typeql_tx_type(forward);
358                vec![ExecutionStep {
359                    tx_type,
360                    kind: if tx_type == TxType::Write {
361                        StepKind::Write
362                    } else {
363                        StepKind::Schema
364                    },
365                    operation_kind: OperationKind::RunTypeql,
366                    forward: forward.clone(),
367                    reverse: reverse.clone(),
368                }]
369            }
370            OperationSpec::DefineSchema { schema } => {
371                // Route through the existing canonical Rust generator
372                // (SchemaInfo::to_typeql → generator::generate_define_block).
373                // This is the only TypeQL generation in plan.rs — no per-variant
374                // re-derivation for any other OperationSpec (invariant 2).
375                let forward = schema
376                    .to_typeql()
377                    .map_err(|e| MigrationError::SchemaGeneration {
378                        message: e.to_string(),
379                    })?;
380                vec![ExecutionStep {
381                    tx_type: TxType::Schema,
382                    kind: StepKind::Schema,
383                    operation_kind: OperationKind::DefineSchema,
384                    forward,
385                    // Model-initial migrations are non-reversible.
386                    reverse: None,
387                }]
388            }
389            OperationSpec::AddAttribute { attribute } => vec![schema_step(
390                define_attribute(attribute)?,
391                Some(undefine_attribute(&attribute.attr_name)),
392            )],
393            OperationSpec::RemoveAttribute { attr_name } => {
394                vec![schema_step(undefine_attribute(attr_name), None)]
395            }
396            OperationSpec::AddEntity { entity } => vec![schema_step(
397                define_entity(entity)?,
398                Some(undefine_entity(&entity.type_name)),
399            )],
400            OperationSpec::RemoveEntity { type_name } => {
401                vec![schema_step(undefine_entity(type_name), None)]
402            }
403            OperationSpec::AddRelation { relation } => vec![schema_step(
404                define_relation(relation)?,
405                Some(undefine_relation_with_players(relation)),
406            )],
407            OperationSpec::RemoveRelation { type_name } => {
408                vec![schema_step(undefine_relation(type_name), None)]
409            }
410            OperationSpec::AddOwnership {
411                owner_type,
412                attribute,
413            } => vec![schema_step(
414                define_ownership(owner_type, attribute),
415                Some(undefine_ownership(
416                    owner_type,
417                    &owned_attribute_type_ref(attribute),
418                )),
419            )],
420            OperationSpec::RemoveOwnership {
421                owner_type,
422                attr_name,
423            } => vec![schema_step(undefine_ownership(owner_type, attr_name), None)],
424            OperationSpec::ModifyOwnership {
425                owner_type,
426                attr_name,
427                old_annotations,
428                new_annotations,
429            } => annotation_token_steps(
430                &format!("{owner_type} owns {attr_name}"),
431                &diff_annotation_tokens(
432                    &split_annotation_tokens(old_annotations),
433                    &split_annotation_tokens(new_annotations),
434                ),
435            ),
436            OperationSpec::ModifyTypeAnnotations {
437                type_name,
438                old_doc,
439                new_doc,
440                old_meta,
441                new_meta,
442            } => annotation_token_steps(
443                type_name,
444                &diff_annotation_tokens(
445                    &doc_meta_tokens(old_doc.as_deref(), old_meta),
446                    &doc_meta_tokens(new_doc.as_deref(), new_meta),
447                ),
448            ),
449            OperationSpec::ModifyRoleAnnotations {
450                relation_type,
451                role_name,
452                old_doc,
453                new_doc,
454                old_meta,
455                new_meta,
456            } => annotation_token_steps(
457                &format!("{relation_type} relates {role_name}"),
458                &diff_annotation_tokens(
459                    &doc_meta_tokens(old_doc.as_deref(), old_meta),
460                    &doc_meta_tokens(new_doc.as_deref(), new_meta),
461                ),
462            ),
463            OperationSpec::AddRole {
464                relation_type,
465                role,
466            } => vec![schema_step(
467                define_role(relation_type, role),
468                Some(undefine_role_with_players(relation_type, role)),
469            )],
470            OperationSpec::RemoveRole {
471                relation_type,
472                role_name,
473            } => vec![schema_step(undefine_role(relation_type, role_name), None)],
474            OperationSpec::AddRolePlayer {
475                relation_type,
476                role_name,
477                player_type_name,
478            } => vec![schema_step(
479                define_role_player(relation_type, role_name, player_type_name),
480                Some(undefine_role_player(
481                    relation_type,
482                    role_name,
483                    player_type_name,
484                )),
485            )],
486            OperationSpec::RemoveRolePlayer {
487                relation_type,
488                role_name,
489                player_type_name,
490            } => vec![schema_step(
491                undefine_role_player(relation_type, role_name, player_type_name),
492                Some(define_role_player(
493                    relation_type,
494                    role_name,
495                    player_type_name,
496                )),
497            )],
498            copy @ OperationSpec::CopyAttribute { .. } => {
499                // Carried TypeQL (the frozen `CopyAttribute.to_typeql()` output)
500                // executes verbatim; the structured portable form synthesizes the
501                // identical template. `backfill.rs` composes its count queries
502                // from this `forward` text's match clause.
503                let (forward, reverse) = copy_attribute_typeql(copy)?;
504                vec![ExecutionStep {
505                    tx_type: TxType::Write,
506                    kind: StepKind::Backfill,
507                    operation_kind: OperationKind::CopyAttribute,
508                    forward,
509                    reverse,
510                }]
511            }
512            other @ OperationSpec::RenameAttribute { .. } => {
513                return Err(MigrationError::UnloweredOperation {
514                    kind: op_kind_name(other).to_string(),
515                });
516            }
517        };
518        if !migration_reversible {
519            for step in &mut op_steps {
520                step.reverse = None;
521            }
522        }
523        let operation_kind = OperationKind::from_spec(op);
524        for step in &mut op_steps {
525            step.operation_kind = operation_kind;
526        }
527        steps.append(&mut op_steps);
528    }
529    Ok(steps)
530}
531
532/// Lower an annotation-set change on `subject` into execution steps.
533///
534/// TypeDB 3.12 semantics (verified live): `define`/`undefine` blocks accept
535/// multiple statements per query, while `redefine` mutates exactly one schema
536/// element per query; parameterless annotations (`@key`, `@unique`,
537/// `@distinct`) can only be defined or undefined, never redefined. Removals
538/// therefore group into one `undefine` step, additions into one `define`
539/// step, and each value change becomes its own `redefine` step. Removals run
540/// first — adding `@key` while a conflicting explicit `@card` is still
541/// declared fails schema validation. Reverse steps restore the prior state
542/// with the mirrored verb.
543fn annotation_token_steps(subject: &str, diff: &AnnotationTokenDiff) -> Vec<ExecutionStep> {
544    let mut steps = Vec::new();
545    if !diff.removed.is_empty() {
546        let forward = typeql_block(
547            "undefine",
548            diff.removed
549                .iter()
550                .map(|token| format!("{} from {subject};", undefine_annotation_ref(token)))
551                .collect(),
552        );
553        let reverse = typeql_block(
554            "define",
555            diff.removed
556                .iter()
557                .map(|token| format!("{subject} {};", token.render()))
558                .collect(),
559        );
560        steps.push(schema_step(forward, Some(reverse)));
561    }
562    for (old_token, new_token) in &diff.changed {
563        steps.push(schema_step(
564            typeql_block(
565                "redefine",
566                vec![format!("{subject} {};", new_token.render())],
567            ),
568            Some(typeql_block(
569                "redefine",
570                vec![format!("{subject} {};", old_token.render())],
571            )),
572        ));
573    }
574    if !diff.added.is_empty() {
575        let forward = typeql_block(
576            "define",
577            diff.added
578                .iter()
579                .map(|token| format!("{subject} {};", token.render()))
580                .collect(),
581        );
582        let reverse = typeql_block(
583            "undefine",
584            diff.added
585                .iter()
586                .map(|token| format!("{} from {subject};", undefine_annotation_ref(token)))
587                .collect(),
588        );
589        steps.push(schema_step(forward, Some(reverse)));
590    }
591    steps
592}
593
594/// The `@...` reference used in `undefine <ref> from <subject>`.
595///
596/// `@meta` must use the keyed form `@meta("key")`; every other annotation
597/// (parameterless or parameterized) undefines by bare name.
598fn undefine_annotation_ref(token: &AnnotationToken) -> String {
599    if token.name == "meta"
600        && let Some(key) = token.meta_key()
601    {
602        return format!(
603            "@meta({})",
604            type_bridge_orm::_schema::annotations::escaped_string_literal(&key)
605        );
606    }
607    format!("@{}", token.name)
608}
609
610/// Build the `@doc`/`@meta` token list for one side of a type or role
611/// annotation change.
612fn doc_meta_tokens(
613    doc: Option<&str>,
614    meta: &std::collections::BTreeMap<String, String>,
615) -> Vec<AnnotationToken> {
616    let mut tokens = Vec::new();
617    if let Some(doc) = doc {
618        tokens.push(AnnotationToken::doc(doc));
619    }
620    for (key, value) in meta {
621        tokens.push(AnnotationToken::meta(key, value));
622    }
623    tokens
624}
625
626fn schema_step(forward: String, reverse: Option<String>) -> ExecutionStep {
627    ExecutionStep {
628        tx_type: TxType::Schema,
629        kind: StepKind::Schema,
630        operation_kind: OperationKind::RunTypeql,
631        forward,
632        reverse,
633    }
634}
635
636fn run_typeql_tx_type(forward: &str) -> TxType {
637    let first_statement = forward
638        .lines()
639        .map(str::trim)
640        .find(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("//"))
641        .unwrap_or_default()
642        .to_ascii_lowercase();
643    if first_statement.starts_with("define")
644        || first_statement.starts_with("undefine")
645        || first_statement.starts_with("redefine")
646    {
647        TxType::Schema
648    } else {
649        TxType::Write
650    }
651}
652
653fn schema_to_typeql(schema: &SchemaInfo) -> crate::Result<String> {
654    schema
655        .to_typeql()
656        .map_err(|e| MigrationError::SchemaGeneration {
657            message: e.to_string(),
658        })
659}
660
661fn define_attribute(attribute: &AttributeSchemaEntry) -> crate::Result<String> {
662    let mut schema = SchemaInfo::default();
663    schema
664        .attributes
665        .insert(attribute.attr_name.clone(), attribute.clone());
666    schema_to_typeql(&schema)
667}
668
669fn undefine_attribute(attr_name: &str) -> String {
670    format!("undefine\n{attr_name};")
671}
672
673fn define_entity(entity: &EntitySchemaEntry) -> crate::Result<String> {
674    let mut schema = SchemaInfo::default();
675    schema
676        .entities
677        .insert(entity.type_name.clone(), entity.clone());
678    schema_to_typeql(&schema)
679}
680
681fn undefine_entity(type_name: &str) -> String {
682    format!("undefine\n{type_name};")
683}
684
685fn define_relation(relation: &RelationSchemaEntry) -> crate::Result<String> {
686    let mut schema = SchemaInfo::default();
687    schema
688        .relations
689        .insert(relation.type_name.clone(), relation.clone());
690    schema_to_typeql(&schema)
691}
692
693fn undefine_relation(type_name: &str) -> String {
694    format!("undefine\n{type_name};")
695}
696
697fn undefine_relation_with_players(relation: &RelationSchemaEntry) -> String {
698    let mut statements = Vec::new();
699    for role in &relation.roles {
700        for player_type_name in &role.player_type_names {
701            statements.push(format!(
702                "plays {}:{} from {player_type_name};",
703                relation.type_name, role.role_name
704            ));
705        }
706    }
707    statements.push(format!("{};", relation.type_name));
708    typeql_block("undefine", statements)
709}
710
711fn define_ownership(owner_type: &str, attribute: &OwnedAttributeEntry) -> String {
712    let attr_ref = owned_attribute_type_ref(attribute);
713    let flags = annotation_suffix(&attribute.flags_string());
714    typeql_block(
715        "define",
716        vec![format!("{owner_type} owns {attr_ref}{flags};")],
717    )
718}
719
720fn undefine_ownership(owner_type: &str, attr_name: &str) -> String {
721    typeql_block(
722        "undefine",
723        vec![format!("owns {attr_name} from {owner_type};")],
724    )
725}
726
727fn owned_attribute_type_ref(attribute: &OwnedAttributeEntry) -> String {
728    if attribute.is_ordered {
729        format!("{}[]", attribute.attr_name)
730    } else {
731        attribute.attr_name.clone()
732    }
733}
734
735fn define_role(relation_type: &str, role: &RoleEntry) -> String {
736    let mut statements = vec![format!(
737        "{relation_type} relates {};",
738        role_definition(role)
739    )];
740    for player_type_name in &role.player_type_names {
741        statements.push(format!(
742            "{player_type_name} plays {relation_type}:{};",
743            role.role_name
744        ));
745    }
746    typeql_block("define", statements)
747}
748
749fn undefine_role(relation_type: &str, role_name: &str) -> String {
750    typeql_block(
751        "undefine",
752        vec![format!("relates {role_name} from {relation_type};")],
753    )
754}
755
756fn undefine_role_with_players(relation_type: &str, role: &RoleEntry) -> String {
757    let mut statements = Vec::new();
758    for player_type_name in &role.player_type_names {
759        statements.push(format!(
760            "plays {relation_type}:{} from {player_type_name};",
761            role.role_name
762        ));
763    }
764    statements.push(format!(
765        "relates {} from {relation_type};",
766        role_type_ref(role)
767    ));
768    typeql_block("undefine", statements)
769}
770
771fn define_role_player(relation_type: &str, role_name: &str, player_type_name: &str) -> String {
772    typeql_block(
773        "define",
774        vec![format!(
775            "{player_type_name} plays {relation_type}:{role_name};"
776        )],
777    )
778}
779
780fn undefine_role_player(relation_type: &str, role_name: &str, player_type_name: &str) -> String {
781    typeql_block(
782        "undefine",
783        vec![format!(
784            "plays {relation_type}:{role_name} from {player_type_name};"
785        )],
786    )
787}
788
789fn role_definition(role: &RoleEntry) -> String {
790    let mut definition = role_type_ref(role);
791    if let Some(ref parent_role) = role.overrides {
792        definition.push_str(&format!(" as {parent_role}"));
793    }
794    if role.is_abstract {
795        definition.push_str(" @abstract");
796    }
797    if role.distinct {
798        definition.push_str(" @distinct");
799    }
800    if let Some((min, max)) = role.cardinality {
801        definition.push(' ');
802        definition.push_str(&card_annotation(min, max));
803    }
804    definition
805}
806
807fn role_type_ref(role: &RoleEntry) -> String {
808    if role.ordered {
809        format!("{}[]", role.role_name)
810    } else {
811        role.role_name.clone()
812    }
813}
814
815fn card_annotation(min: u32, max: Option<u32>) -> String {
816    let max_str = max.map(|value| value.to_string()).unwrap_or_default();
817    format!("@card({min}..{max_str})")
818}
819
820fn annotation_suffix(annotations: &str) -> String {
821    let trimmed = annotations.trim();
822    if trimmed.is_empty() {
823        String::new()
824    } else {
825        format!(" {trimmed}")
826    }
827}
828
829fn typeql_block(keyword: &str, statements: Vec<String>) -> String {
830    format!("{keyword}\n{}", statements.join("\n"))
831}
832
833/// Return a stable string name for an [`OperationSpec`] variant.
834///
835/// Used only in error messages.
836fn op_kind_name(op: &OperationSpec) -> &'static str {
837    match op {
838        OperationSpec::RunTypeql { .. } => "RunTypeql",
839        OperationSpec::DefineSchema { .. } => "DefineSchema",
840        OperationSpec::AddAttribute { .. } => "AddAttribute",
841        OperationSpec::RemoveAttribute { .. } => "RemoveAttribute",
842        OperationSpec::AddEntity { .. } => "AddEntity",
843        OperationSpec::RemoveEntity { .. } => "RemoveEntity",
844        OperationSpec::AddRelation { .. } => "AddRelation",
845        OperationSpec::RemoveRelation { .. } => "RemoveRelation",
846        OperationSpec::AddOwnership { .. } => "AddOwnership",
847        OperationSpec::RemoveOwnership { .. } => "RemoveOwnership",
848        OperationSpec::ModifyOwnership { .. } => "ModifyOwnership",
849        OperationSpec::ModifyTypeAnnotations { .. } => "ModifyTypeAnnotations",
850        OperationSpec::ModifyRoleAnnotations { .. } => "ModifyRoleAnnotations",
851        OperationSpec::AddRole { .. } => "AddRole",
852        OperationSpec::RemoveRole { .. } => "RemoveRole",
853        OperationSpec::AddRolePlayer { .. } => "AddRolePlayer",
854        OperationSpec::RemoveRolePlayer { .. } => "RemoveRolePlayer",
855        OperationSpec::RenameAttribute { .. } => "RenameAttribute",
856        OperationSpec::CopyAttribute { .. } => "CopyAttribute",
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use std::collections::BTreeMap;
863
864    use super::*;
865    use type_bridge_orm::_entity::Annotation;
866    use type_bridge_orm::_schema::info::{
867        AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry,
868        RoleEntry, SchemaInfo,
869    };
870    use type_bridge_orm::ValueType;
871
872    use crate::graph::AppliedMigrationRecord;
873    use crate::spec::{MigrationDependencySpec, MigrationGraph, MigrationSpec, OperationSpec};
874
875    // ── helpers ──────────────────────────────────────────────────────────────
876
877    fn run_typeql(forward: &str, reverse: Option<&str>) -> OperationSpec {
878        OperationSpec::RunTypeql {
879            forward: forward.to_string(),
880            reverse: reverse.map(str::to_string),
881        }
882    }
883
884    fn define_schema_op() -> OperationSpec {
885        let mut schema = SchemaInfo::default();
886        schema.attributes.insert(
887            "name".to_string(),
888            AttributeSchemaEntry::new("name", ValueType::String),
889        );
890        schema.entities.insert(
891            "person".to_string(),
892            EntitySchemaEntry {
893                type_name: "person".to_string(),
894                is_abstract: false,
895                parent_type: None,
896                owned_attributes: vec![OwnedAttributeEntry {
897                    attr_name: "name".to_string(),
898                    value_type: ValueType::String,
899                    annotations: vec![Annotation::Key],
900                    is_ordered: false,
901                    doc: None,
902                    meta: Default::default(),
903                }],
904                plays_cardinalities: BTreeMap::new(),
905                doc: None,
906                meta: Default::default(),
907            },
908        );
909        OperationSpec::DefineSchema { schema }
910    }
911
912    fn owned_attr(
913        attr_name: &str,
914        value_type: ValueType,
915        annotations: Vec<Annotation>,
916    ) -> OwnedAttributeEntry {
917        OwnedAttributeEntry {
918            attr_name: attr_name.to_string(),
919            value_type,
920            annotations,
921            is_ordered: false,
922            doc: None,
923            meta: Default::default(),
924        }
925    }
926
927    fn entity_entry(type_name: &str) -> EntitySchemaEntry {
928        EntitySchemaEntry {
929            type_name: type_name.to_string(),
930            is_abstract: false,
931            parent_type: None,
932            owned_attributes: vec![owned_attr("name", ValueType::String, vec![Annotation::Key])],
933            plays_cardinalities: BTreeMap::new(),
934            doc: None,
935            meta: Default::default(),
936        }
937    }
938
939    fn relation_entry(type_name: &str) -> RelationSchemaEntry {
940        RelationSchemaEntry {
941            type_name: type_name.to_string(),
942            is_abstract: false,
943            parent_type: None,
944            owned_attributes: vec![],
945            roles: vec![RoleEntry {
946                role_name: "employee".to_string(),
947                player_type_names: vec!["person".to_string()],
948                cardinality: None,
949                overrides: None,
950                is_abstract: false,
951                ordered: false,
952                distinct: false,
953                doc: None,
954                meta: Default::default(),
955            }],
956            plays_cardinalities: BTreeMap::new(),
957            doc: None,
958            meta: Default::default(),
959        }
960    }
961
962    fn migration(name: &str, ops: Vec<OperationSpec>, deps: Vec<(&str, &str)>) -> MigrationSpec {
963        MigrationSpec {
964            app_label: "app".to_string(),
965            name: name.to_string(),
966            dependencies: deps
967                .into_iter()
968                .map(|(app, dep_name)| MigrationDependencySpec {
969                    app_label: app.to_string(),
970                    migration_name: dep_name.to_string(),
971                })
972                .collect(),
973            operations: ops,
974            checksum: Some(format!("{name}-csum")),
975            source_sha256: None,
976            reversible: true,
977        }
978    }
979
980    fn applied(name: &str) -> AppliedMigrationRecord {
981        AppliedMigrationRecord {
982            app_label: "app".to_string(),
983            name: name.to_string(),
984            checksum: format!("{name}-csum"),
985            applied_at: None,
986        }
987    }
988
989    fn graph(migrations: Vec<MigrationSpec>) -> MigrationGraph {
990        MigrationGraph { migrations }
991    }
992
993    // ── test: pending-only ordering (target=None) ────────────────────────────
994
995    #[test]
996    fn pending_only_all_pending_applies_in_order() {
997        let g = graph(vec![
998            migration(
999                "0001_initial",
1000                vec![run_typeql("define attribute a, value string;", None)],
1001                vec![],
1002            ),
1003            migration(
1004                "0002_add",
1005                vec![run_typeql(
1006                    "define attribute b, value string;",
1007                    Some("undefine attribute b;"),
1008                )],
1009                vec![("app", "0001_initial")],
1010            ),
1011        ]);
1012
1013        let result = plan(&g, &[], None).expect("plan should succeed");
1014
1015        assert_eq!(result.to_apply.len(), 2);
1016        assert_eq!(result.to_rollback.len(), 0);
1017        assert_eq!(result.to_apply[0].name, "0001_initial");
1018        assert_eq!(result.to_apply[1].name, "0002_add");
1019    }
1020
1021    #[test]
1022    fn pending_only_already_applied_excluded() {
1023        let g = graph(vec![
1024            migration(
1025                "0001_initial",
1026                vec![run_typeql("define attribute a, value string;", None)],
1027                vec![],
1028            ),
1029            migration(
1030                "0002_add",
1031                vec![run_typeql(
1032                    "define attribute b, value string;",
1033                    Some("undefine attribute b;"),
1034                )],
1035                vec![("app", "0001_initial")],
1036            ),
1037        ]);
1038
1039        let result = plan(&g, &[applied("0001_initial")], None).expect("plan should succeed");
1040
1041        assert_eq!(result.to_apply.len(), 1);
1042        assert_eq!(result.to_apply[0].name, "0002_add");
1043        assert_eq!(result.to_rollback.len(), 0);
1044    }
1045
1046    // ── test: target-based apply/rollback split ───────────────────────────────
1047
1048    #[test]
1049    fn target_applies_up_to_and_including_target() {
1050        let g = graph(vec![
1051            migration(
1052                "0001_initial",
1053                vec![run_typeql("define attribute a, value string;", None)],
1054                vec![],
1055            ),
1056            migration(
1057                "0002_add",
1058                vec![run_typeql(
1059                    "define attribute b, value string;",
1060                    Some("undefine attribute b;"),
1061                )],
1062                vec![("app", "0001_initial")],
1063            ),
1064            migration(
1065                "0003_more",
1066                vec![run_typeql(
1067                    "define attribute c, value string;",
1068                    Some("undefine attribute c;"),
1069                )],
1070                vec![("app", "0002_add")],
1071            ),
1072        ]);
1073
1074        // target = 0002_add; none applied yet.
1075        let result = plan(&g, &[], Some("0002_add")).expect("plan should succeed");
1076
1077        assert_eq!(result.to_apply.len(), 2);
1078        assert_eq!(result.to_apply[0].name, "0001_initial");
1079        assert_eq!(result.to_apply[1].name, "0002_add");
1080        assert_eq!(result.to_rollback.len(), 0);
1081    }
1082
1083    #[test]
1084    fn target_rolls_back_past_target_in_reverse_order() {
1085        let g = graph(vec![
1086            migration(
1087                "0001_initial",
1088                vec![run_typeql("define attribute a, value string;", None)],
1089                vec![],
1090            ),
1091            migration(
1092                "0002_add",
1093                vec![run_typeql(
1094                    "define attribute b, value string;",
1095                    Some("undefine attribute b;"),
1096                )],
1097                vec![("app", "0001_initial")],
1098            ),
1099            migration(
1100                "0003_more",
1101                vec![run_typeql(
1102                    "define attribute c, value string;",
1103                    Some("undefine attribute c;"),
1104                )],
1105                vec![("app", "0002_add")],
1106            ),
1107        ]);
1108
1109        // All three applied; target = 0001_initial → rollback 0002 and 0003.
1110        let result = plan(
1111            &g,
1112            &[
1113                applied("0001_initial"),
1114                applied("0002_add"),
1115                applied("0003_more"),
1116            ],
1117            Some("0001_initial"),
1118        )
1119        .expect("plan should succeed");
1120
1121        assert_eq!(result.to_apply.len(), 0);
1122        // rollback list must be in reverse order: 0003, then 0002
1123        assert_eq!(result.to_rollback.len(), 2);
1124        assert_eq!(result.to_rollback[0].name, "0003_more");
1125        assert_eq!(result.to_rollback[1].name, "0002_add");
1126    }
1127
1128    // ── test: rollback reverse ordering for steps ─────────────────────────────
1129
1130    #[test]
1131    fn rollback_execution_action_is_rollback() {
1132        let g = graph(vec![
1133            migration(
1134                "0001_initial",
1135                vec![run_typeql("define attribute a, value string;", None)],
1136                vec![],
1137            ),
1138            migration(
1139                "0002_add",
1140                vec![run_typeql(
1141                    "define attribute b, value string;",
1142                    Some("undefine attribute b;"),
1143                )],
1144                vec![("app", "0001_initial")],
1145            ),
1146        ]);
1147
1148        let result = plan(
1149            &g,
1150            &[applied("0001_initial"), applied("0002_add")],
1151            Some("0001_initial"),
1152        )
1153        .expect("plan should succeed");
1154
1155        assert_eq!(result.to_rollback[0].action, MigrationAction::Rollback);
1156    }
1157
1158    // ── test: DefineSchema carries non-empty TypeQL from the generator ─────────
1159
1160    #[test]
1161    fn define_schema_step_carries_typeql_from_generator() {
1162        let g = graph(vec![migration(
1163            "0001_initial",
1164            vec![define_schema_op()],
1165            vec![],
1166        )]);
1167
1168        let result = plan(&g, &[], None).expect("plan should succeed");
1169
1170        let exec = &result.to_apply[0];
1171        assert_eq!(exec.steps.len(), 1);
1172        let step = &exec.steps[0];
1173        // Forward must be non-empty TypeQL produced by SchemaInfo::to_typeql().
1174        assert!(
1175            !step.forward.is_empty(),
1176            "DefineSchema forward must be non-empty"
1177        );
1178        assert!(
1179            step.forward.contains("define"),
1180            "DefineSchema forward must contain 'define'"
1181        );
1182        // DefineSchema is non-reversible — no reverse.
1183        assert!(step.reverse.is_none());
1184    }
1185
1186    #[test]
1187    fn define_schema_step_tx_type_is_schema() {
1188        let g = graph(vec![migration(
1189            "0001_initial",
1190            vec![define_schema_op()],
1191            vec![],
1192        )]);
1193
1194        let result = plan(&g, &[], None).expect("plan should succeed");
1195        assert_eq!(result.to_apply[0].steps[0].tx_type, TxType::Schema);
1196    }
1197
1198    // ── test: per-step TxType is Schema for RunTypeql ─────────────────────────
1199
1200    #[test]
1201    fn run_typeql_step_tx_type_is_schema() {
1202        let g = graph(vec![migration(
1203            "0001_add",
1204            vec![run_typeql("define attribute a, value string;", None)],
1205            vec![],
1206        )]);
1207
1208        let result = plan(&g, &[], None).expect("plan should succeed");
1209        assert_eq!(result.to_apply[0].steps[0].tx_type, TxType::Schema);
1210    }
1211
1212    #[test]
1213    fn data_run_typeql_step_tx_type_is_write() {
1214        let g = graph(vec![migration(
1215            "0002_seed",
1216            vec![run_typeql(
1217                r#"match $a isa account, has account-id "acct-001";
1218insert $a has email "ops@example.com";"#,
1219                Some(
1220                    r#"match $a isa account, has email "ops@example.com";
1221delete $a has email "ops@example.com";"#,
1222                ),
1223            )],
1224            vec![],
1225        )]);
1226
1227        let result = plan(&g, &[], None).expect("plan should succeed");
1228        let step = &result.to_apply[0].steps[0];
1229        assert_eq!(step.tx_type, TxType::Write);
1230        assert_eq!(step.kind, StepKind::Write);
1231    }
1232
1233    // ── tests: typed OperationSpec variants lower in Rust ─────────────────────
1234
1235    #[test]
1236    fn typed_attribute_operations_lower_to_schema_steps() {
1237        let g = graph(vec![migration(
1238            "0001_attrs",
1239            vec![
1240                OperationSpec::AddAttribute {
1241                    attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1242                },
1243                OperationSpec::RemoveAttribute {
1244                    attr_name: "legacy-score".to_string(),
1245                },
1246            ],
1247            vec![],
1248        )]);
1249
1250        let result = plan(&g, &[], None).expect("plan should succeed");
1251        let steps = &result.to_apply[0].steps;
1252
1253        assert_eq!(steps.len(), 2);
1254        assert!(steps[0].forward.contains("attribute score, value integer;"));
1255        assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nscore;"));
1256        assert_eq!(steps[1].forward, "undefine\nlegacy-score;");
1257        assert!(steps[1].reverse.is_none());
1258        assert!(!result.to_apply[0].reversible);
1259    }
1260
1261    #[test]
1262    fn typed_entity_and_relation_operations_lower_to_schema_steps() {
1263        let g = graph(vec![migration(
1264            "0001_types",
1265            vec![
1266                OperationSpec::AddEntity {
1267                    entity: entity_entry("person"),
1268                },
1269                OperationSpec::AddRelation {
1270                    relation: relation_entry("employment"),
1271                },
1272            ],
1273            vec![],
1274        )]);
1275
1276        let result = plan(&g, &[], None).expect("plan should succeed");
1277        let steps = &result.to_apply[0].steps;
1278
1279        assert!(steps[0].forward.contains("entity person,"));
1280        assert!(steps[0].forward.contains("owns name @key;"));
1281        assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nperson;"));
1282        assert!(steps[1].forward.contains("relation employment,"));
1283        assert!(steps[1].forward.contains("relates employee;"));
1284        assert!(
1285            steps[1]
1286                .forward
1287                .contains("person plays employment:employee;")
1288        );
1289        assert!(
1290            steps[1]
1291                .reverse
1292                .as_deref()
1293                .unwrap()
1294                .contains("plays employment:employee from person;")
1295        );
1296        assert!(steps[1].reverse.as_deref().unwrap().contains("employment;"));
1297    }
1298
1299    #[test]
1300    fn add_entity_with_parent_outside_singleton_schema_lowers_without_panic() {
1301        // Lowering AddEntity builds a singleton SchemaInfo containing only the
1302        // child; the parent named by `sub` lives outside it. Planning must not
1303        // panic and the define step must keep the `sub` clause (#190).
1304        let mut child = entity_entry("person");
1305        child.parent_type = Some("animal".to_string());
1306        let g = graph(vec![migration(
1307            "0001_sub_entity",
1308            vec![OperationSpec::AddEntity { entity: child }],
1309            vec![],
1310        )]);
1311
1312        let result = plan(&g, &[], None).expect("plan should succeed");
1313        let steps = &result.to_apply[0].steps;
1314
1315        assert_eq!(steps.len(), 1);
1316        assert!(steps[0].forward.contains("entity person sub animal,"));
1317        assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nperson;"));
1318    }
1319
1320    #[test]
1321    fn typed_ownership_operations_lower_to_schema_steps() {
1322        let g = graph(vec![migration(
1323            "0001_ownership",
1324            vec![
1325                OperationSpec::AddOwnership {
1326                    owner_type: "person".to_string(),
1327                    attribute: owned_attr("email", ValueType::String, vec![Annotation::Key]),
1328                },
1329                OperationSpec::RemoveOwnership {
1330                    owner_type: "person".to_string(),
1331                    attr_name: "legacy-email".to_string(),
1332                },
1333                OperationSpec::ModifyOwnership {
1334                    owner_type: "person".to_string(),
1335                    attr_name: "nickname".to_string(),
1336                    old_annotations: "@card(0..1)".to_string(),
1337                    new_annotations: "@card(1..1)".to_string(),
1338                },
1339            ],
1340            vec![],
1341        )]);
1342
1343        let result = plan(&g, &[], None).expect("plan should succeed");
1344        let steps = &result.to_apply[0].steps;
1345
1346        assert_eq!(steps[0].forward, "define\nperson owns email @key;");
1347        assert_eq!(
1348            steps[0].reverse.as_deref(),
1349            Some("undefine\nowns email from person;")
1350        );
1351        assert_eq!(steps[1].forward, "undefine\nowns legacy-email from person;");
1352        assert!(steps[1].reverse.is_none());
1353        assert_eq!(
1354            steps[2].forward,
1355            "redefine\nperson owns nickname @card(1..1);"
1356        );
1357        assert_eq!(
1358            steps[2].reverse.as_deref(),
1359            Some("redefine\nperson owns nickname @card(0..1);")
1360        );
1361    }
1362
1363    #[test]
1364    fn modify_ownership_decomposes_parameterless_transitions() {
1365        // @key can never be redefined (REX28): swapping @card(0..1) for
1366        // @key must lower to an undefine step followed by a define step,
1367        // removals first (defining @key beside a conflicting explicit
1368        // @card fails schema validation).
1369        let g = graph(vec![migration(
1370            "0002_key",
1371            vec![OperationSpec::ModifyOwnership {
1372                owner_type: "person".to_string(),
1373                attr_name: "nickname".to_string(),
1374                old_annotations: "@card(0..1)".to_string(),
1375                new_annotations: "@key".to_string(),
1376            }],
1377            vec![],
1378        )]);
1379
1380        let result = plan(&g, &[], None).expect("plan should succeed");
1381        let steps = &result.to_apply[0].steps;
1382
1383        assert_eq!(steps.len(), 2);
1384        assert_eq!(
1385            steps[0].forward,
1386            "undefine\n@card from person owns nickname;"
1387        );
1388        assert_eq!(
1389            steps[0].reverse.as_deref(),
1390            Some("define\nperson owns nickname @card(0..1);")
1391        );
1392        assert_eq!(steps[1].forward, "define\nperson owns nickname @key;");
1393        assert_eq!(
1394            steps[1].reverse.as_deref(),
1395            Some("undefine\n@key from person owns nickname;")
1396        );
1397    }
1398
1399    #[test]
1400    fn modify_ownership_from_plain_defines_and_identical_sets_lower_to_nothing() {
1401        let g = graph(vec![migration(
1402            "0002_tighten",
1403            vec![
1404                OperationSpec::ModifyOwnership {
1405                    owner_type: "person".to_string(),
1406                    attr_name: "nickname".to_string(),
1407                    old_annotations: String::new(),
1408                    new_annotations: "@key".to_string(),
1409                },
1410                OperationSpec::ModifyOwnership {
1411                    owner_type: "person".to_string(),
1412                    attr_name: "email".to_string(),
1413                    old_annotations: "@unique".to_string(),
1414                    new_annotations: "@unique".to_string(),
1415                },
1416            ],
1417            vec![],
1418        )]);
1419
1420        let result = plan(&g, &[], None).expect("plan should succeed");
1421        let steps = &result.to_apply[0].steps;
1422
1423        // The no-op transition contributes zero steps.
1424        assert_eq!(steps.len(), 1);
1425        assert_eq!(steps[0].forward, "define\nperson owns nickname @key;");
1426        assert_eq!(
1427            steps[0].reverse.as_deref(),
1428            Some("undefine\n@key from person owns nickname;")
1429        );
1430    }
1431
1432    #[test]
1433    fn typed_role_operations_lower_to_schema_steps() {
1434        let role = RoleEntry {
1435            role_name: "reviewer".to_string(),
1436            player_type_names: vec!["person".to_string()],
1437            cardinality: Some((0, Some(2))),
1438            overrides: None,
1439            is_abstract: false,
1440            ordered: false,
1441            distinct: false,
1442            doc: None,
1443            meta: Default::default(),
1444        };
1445        let g = graph(vec![migration(
1446            "0001_roles",
1447            vec![
1448                OperationSpec::AddRole {
1449                    relation_type: "employment".to_string(),
1450                    role,
1451                },
1452                OperationSpec::RemoveRole {
1453                    relation_type: "employment".to_string(),
1454                    role_name: "legacy".to_string(),
1455                },
1456                OperationSpec::AddRolePlayer {
1457                    relation_type: "employment".to_string(),
1458                    role_name: "employee".to_string(),
1459                    player_type_name: "contractor".to_string(),
1460                },
1461                OperationSpec::RemoveRolePlayer {
1462                    relation_type: "employment".to_string(),
1463                    role_name: "employee".to_string(),
1464                    player_type_name: "company".to_string(),
1465                },
1466            ],
1467            vec![],
1468        )]);
1469
1470        let result = plan(&g, &[], None).expect("plan should succeed");
1471        let steps = &result.to_apply[0].steps;
1472
1473        assert_eq!(
1474            steps[0].forward,
1475            "define\nemployment relates reviewer @card(0..2);\nperson plays employment:reviewer;"
1476        );
1477        assert_eq!(
1478            steps[0].reverse.as_deref(),
1479            Some(
1480                "undefine\nplays employment:reviewer from person;\nrelates reviewer from employment;"
1481            )
1482        );
1483        assert_eq!(
1484            steps[1].forward,
1485            "undefine\nrelates legacy from employment;"
1486        );
1487        assert!(steps[1].reverse.is_none());
1488        assert_eq!(
1489            steps[2].forward,
1490            "define\ncontractor plays employment:employee;"
1491        );
1492        assert_eq!(
1493            steps[2].reverse.as_deref(),
1494            Some("undefine\nplays employment:employee from contractor;")
1495        );
1496        assert_eq!(
1497            steps[3].forward,
1498            "undefine\nplays employment:employee from company;"
1499        );
1500        assert_eq!(
1501            steps[3].reverse.as_deref(),
1502            Some("define\ncompany plays employment:employee;")
1503        );
1504    }
1505
1506    #[test]
1507    fn migration_reversible_flag_drops_typed_operation_reverses() {
1508        let mut spec = migration(
1509            "0001_non_reversible",
1510            vec![OperationSpec::AddAttribute {
1511                attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1512            }],
1513            vec![],
1514        );
1515        spec.reversible = false;
1516        let g = graph(vec![spec]);
1517
1518        let result = plan(&g, &[], None).expect("plan should succeed");
1519
1520        assert!(result.to_apply[0].steps[0].reverse.is_none());
1521        assert!(!result.to_apply[0].reversible);
1522    }
1523
1524    // ── test: intentionally unsupported operation returns Err ────────────────
1525
1526    #[test]
1527    fn unlowered_op_returns_err() {
1528        let g = graph(vec![migration(
1529            "0001_rename_attr",
1530            vec![OperationSpec::RenameAttribute {
1531                old_name: "old-score".to_string(),
1532                new_name: "new-score".to_string(),
1533                value_type: "string".to_string(),
1534            }],
1535            vec![],
1536        )]);
1537
1538        let err = plan(&g, &[], None).expect_err("should fail for unsupported op");
1539        match err {
1540            MigrationError::UnloweredOperation { kind } => {
1541                assert_eq!(kind, "RenameAttribute");
1542            }
1543            other => panic!("expected UnloweredOperation, got {other:?}"),
1544        }
1545    }
1546
1547    // ── test: validation failure short-circuits ────────────────────────────────
1548
1549    #[test]
1550    fn validation_failure_returns_planning_error() {
1551        // 0002 depends on 0001 which is not in the graph.
1552        let g = graph(vec![migration(
1553            "0002_next",
1554            vec![run_typeql("define attribute b, value string;", None)],
1555            vec![("app", "0001_initial")],
1556        )]);
1557
1558        let err = plan(&g, &[], None).expect_err("should fail on validation error");
1559        assert!(
1560            matches!(err, MigrationError::Planning { .. }),
1561            "expected Planning error, got {err:?}"
1562        );
1563    }
1564
1565    // ── test: checksum drift short-circuits ───────────────────────────────────
1566
1567    #[test]
1568    fn checksum_drift_returns_error() {
1569        let g = graph(vec![migration(
1570            "0001_initial",
1571            vec![run_typeql("define attribute a, value string;", None)],
1572            vec![],
1573        )]);
1574
1575        // Record "0001_initial" with a wrong checksum.
1576        let bad_applied = AppliedMigrationRecord {
1577            app_label: "app".to_string(),
1578            name: "0001_initial".to_string(),
1579            checksum: "wrong-checksum".to_string(),
1580            applied_at: None,
1581        };
1582
1583        let err = plan(&g, &[bad_applied], None).expect_err("should fail on drift");
1584        assert!(
1585            matches!(err, MigrationError::ChecksumDrift { .. }),
1586            "expected ChecksumDrift error, got {err:?}"
1587        );
1588    }
1589
1590    // ── test: reversible flag ─────────────────────────────────────────────────
1591
1592    #[test]
1593    fn migration_with_no_reverse_is_marked_not_reversible() {
1594        let g = graph(vec![migration(
1595            "0001_initial",
1596            // reverse is None
1597            vec![run_typeql("define attribute a, value string;", None)],
1598            vec![],
1599        )]);
1600
1601        let result = plan(&g, &[], None).expect("plan should succeed");
1602        assert!(!result.to_apply[0].reversible);
1603    }
1604
1605    #[test]
1606    fn migration_with_all_reverses_is_reversible() {
1607        let g = graph(vec![migration(
1608            "0001_add",
1609            vec![run_typeql(
1610                "define attribute a, value string;",
1611                Some("undefine attribute a;"),
1612            )],
1613            vec![],
1614        )]);
1615
1616        let result = plan(&g, &[], None).expect("plan should succeed");
1617        assert!(result.to_apply[0].reversible);
1618    }
1619
1620    #[test]
1621    fn define_schema_migration_is_not_reversible() {
1622        // DefineSchema never has a reverse.
1623        let g = graph(vec![migration(
1624            "0001_initial",
1625            vec![define_schema_op()],
1626            vec![],
1627        )]);
1628
1629        let result = plan(&g, &[], None).expect("plan should succeed");
1630        assert!(!result.to_apply[0].reversible);
1631    }
1632
1633    // ── test: target not found returns TargetNotFound ─────────────────────────
1634
1635    #[test]
1636    fn unknown_target_returns_target_not_found_error() {
1637        let g = graph(vec![migration(
1638            "0001_initial",
1639            vec![run_typeql("define attribute a, value string;", None)],
1640            vec![],
1641        )]);
1642
1643        let err = plan(&g, &[], Some("nonexistent_migration"))
1644            .expect_err("should fail for missing target");
1645        assert!(
1646            matches!(err, MigrationError::TargetNotFound { .. }),
1647            "expected TargetNotFound, got {err:?}"
1648        );
1649    }
1650
1651    // ── test: CopyAttribute lowers to Write-typed Backfill step ───────────────
1652
1653    #[test]
1654    fn copy_attribute_lowers_to_write_typed_backfill_step() {
1655        // The carried forward/reverse mirror `CopyAttribute.to_typeql()` /
1656        // `to_rollback_typeql()`; assemble_steps must pass them through verbatim
1657        // under a Write/Backfill step (no re-synthesis — invariant 2).
1658        let forward = "match\n  $x isa person, has old-name $v;\n  \
1659            not { $x has new-name $d; };\ninsert\n  $x has new-name == $v;";
1660        let reverse = "match $x isa person, has new-name $v;\ndelete $v of $x;";
1661        let g = graph(vec![migration(
1662            "0002_backfill",
1663            vec![OperationSpec::CopyAttribute {
1664                owner: None,
1665                source: None,
1666                dest: None,
1667                filter: None,
1668                forward: Some(forward.to_string()),
1669                reverse: Some(reverse.to_string()),
1670            }],
1671            vec![],
1672        )]);
1673
1674        let result = plan(&g, &[], None).expect("plan should succeed");
1675
1676        let exec = &result.to_apply[0];
1677        assert_eq!(exec.steps.len(), 1);
1678        let step = &exec.steps[0];
1679
1680        assert_eq!(
1681            step.tx_type,
1682            TxType::Write,
1683            "CopyAttribute step must use Write tx"
1684        );
1685        assert_eq!(
1686            step.kind,
1687            StepKind::Backfill,
1688            "CopyAttribute step kind must be Backfill"
1689        );
1690        // The carried strings are passed through unchanged.
1691        assert_eq!(step.forward, forward, "forward must be carried verbatim");
1692        assert_eq!(
1693            step.reverse.as_deref(),
1694            Some(reverse),
1695            "reverse must be carried verbatim"
1696        );
1697    }
1698
1699    #[test]
1700    fn structured_copy_attribute_lowers_to_the_same_backfill_step() {
1701        // The structured portable form synthesizes the exact TypeQL the
1702        // carried form would have contained.
1703        let g = graph(vec![migration(
1704            "0002_backfill",
1705            vec![OperationSpec::CopyAttribute {
1706                owner: Some("person".to_string()),
1707                source: Some("old-name".to_string()),
1708                dest: Some("new-name".to_string()),
1709                filter: None,
1710                forward: None,
1711                reverse: None,
1712            }],
1713            vec![],
1714        )]);
1715
1716        let result = plan(&g, &[], None).expect("plan should succeed");
1717
1718        let step = &result.to_apply[0].steps[0];
1719        assert_eq!(step.tx_type, TxType::Write);
1720        assert_eq!(step.kind, StepKind::Backfill);
1721        assert_eq!(
1722            step.forward,
1723            "match\n  $x isa person, has old-name $v;\n  \
1724             not { $x has new-name $d; };\ninsert\n  $x has new-name == $v;"
1725        );
1726        assert_eq!(
1727            step.reverse.as_deref(),
1728            Some("match $x isa person, has new-name $v;\ndelete $v of $x;")
1729        );
1730    }
1731
1732    #[test]
1733    fn step_kind_default_is_schema_for_serde_backcompat() {
1734        // Simulate a legacy JSON step without the `kind` field.
1735        let json =
1736            r#"{"tx_type":"Schema","forward":"define attribute a, value string;","reverse":null}"#;
1737        let step: ExecutionStep =
1738            serde_json::from_str(json).expect("should deserialize legacy step");
1739        assert_eq!(
1740            step.kind,
1741            StepKind::Schema,
1742            "missing `kind` field must default to Schema for backward compat"
1743        );
1744        assert_eq!(step.operation_kind, OperationKind::RunTypeql);
1745    }
1746
1747    // ── test: whole-relation removal normalization (#168) ───────────────────
1748
1749    /// The exact operation shape v1.5.5/v1.5.6 generators authored for a
1750    /// whole-relation deletion: granular unwind, then `RemoveRelation`.
1751    fn legacy_remove_relation_ops(relation: &str) -> Vec<OperationSpec> {
1752        vec![
1753            OperationSpec::RemoveRolePlayer {
1754                relation_type: relation.to_string(),
1755                role_name: "subject".to_string(),
1756                player_type_name: "person".to_string(),
1757            },
1758            OperationSpec::RemoveRole {
1759                relation_type: relation.to_string(),
1760                role_name: "subject".to_string(),
1761            },
1762            OperationSpec::RemoveRolePlayer {
1763                relation_type: relation.to_string(),
1764                role_name: "badge".to_string(),
1765                player_type_name: "temporary-badge".to_string(),
1766            },
1767            OperationSpec::RemoveRole {
1768                relation_type: relation.to_string(),
1769                role_name: "badge".to_string(),
1770            },
1771            OperationSpec::RemoveOwnership {
1772                owner_type: relation.to_string(),
1773                attr_name: "legacy-link-id".to_string(),
1774            },
1775            OperationSpec::RemoveRelation {
1776                type_name: relation.to_string(),
1777            },
1778        ]
1779    }
1780
1781    #[test]
1782    fn legacy_decomposed_relation_removal_normalizes_to_single_step() {
1783        let g = graph(vec![migration(
1784            "0005_remove_legacy_link",
1785            legacy_remove_relation_ops("legacy-link"),
1786            vec![],
1787        )]);
1788
1789        let result = plan(&g, &[], None).expect("plan should succeed");
1790
1791        let exec = &result.to_apply[0];
1792        assert_eq!(
1793            exec.steps.len(),
1794            1,
1795            "granular removals shadowed by RemoveRelation must be dropped"
1796        );
1797        assert_eq!(exec.steps[0].forward, "undefine\nlegacy-link;");
1798        assert_eq!(exec.steps[0].tx_type, TxType::Schema);
1799    }
1800
1801    #[test]
1802    fn surviving_relation_granular_removals_are_kept() {
1803        // No RemoveRelation for `employment`: granular ops must lower 1:1.
1804        let ops = vec![
1805            OperationSpec::RemoveRolePlayer {
1806                relation_type: "employment".to_string(),
1807                role_name: "employee".to_string(),
1808                player_type_name: "contractor".to_string(),
1809            },
1810            OperationSpec::RemoveRole {
1811                relation_type: "employment".to_string(),
1812                role_name: "reviewer".to_string(),
1813            },
1814            OperationSpec::RemoveOwnership {
1815                owner_type: "employment".to_string(),
1816                attr_name: "note".to_string(),
1817            },
1818        ];
1819        let g = graph(vec![migration("0002_trim_employment", ops, vec![])]);
1820
1821        let result = plan(&g, &[], None).expect("plan should succeed");
1822
1823        assert_eq!(result.to_apply[0].steps.len(), 3);
1824    }
1825
1826    #[test]
1827    fn normalization_is_scoped_to_the_removed_relation() {
1828        // One relation is removed wholesale while another one is trimmed in
1829        // the same migration; only ops scoped to the removed relation drop.
1830        let mut ops = legacy_remove_relation_ops("legacy-link");
1831        ops.push(OperationSpec::RemoveRolePlayer {
1832            relation_type: "employment".to_string(),
1833            role_name: "employee".to_string(),
1834            player_type_name: "contractor".to_string(),
1835        });
1836        ops.push(OperationSpec::RemoveOwnership {
1837            owner_type: "person".to_string(),
1838            attr_name: "nickname".to_string(),
1839        });
1840        let g = graph(vec![migration("0006_mixed_removals", ops, vec![])]);
1841
1842        let result = plan(&g, &[], None).expect("plan should succeed");
1843
1844        let forwards: Vec<&str> = result.to_apply[0]
1845            .steps
1846            .iter()
1847            .map(|s| s.forward.as_str())
1848            .collect();
1849        assert_eq!(
1850            forwards,
1851            vec![
1852                "undefine\nlegacy-link;",
1853                "undefine\nplays employment:employee from contractor;",
1854                "undefine\nowns nickname from person;",
1855            ]
1856        );
1857    }
1858
1859    #[test]
1860    fn modify_ownership_lowers_per_annotation_steps() {
1861        // Mixed add/update/remove including parameterless @key/@unique, which
1862        // can never be redefined (REX28) — they must go through define/undefine.
1863        let g = graph(vec![migration(
1864            "0001_annotations",
1865            vec![OperationSpec::ModifyOwnership {
1866                owner_type: "person".to_string(),
1867                attr_name: "name".to_string(),
1868                old_annotations: "@key @doc(\"old doc\") @meta(\"x\", \"1\")".to_string(),
1869                new_annotations: "@unique @doc(\"new doc\") @meta(\"y\", \"2\")".to_string(),
1870            }],
1871            vec![],
1872        )]);
1873
1874        let result = plan(&g, &[], None).expect("plan should succeed");
1875        let steps = &result.to_apply[0].steps;
1876        assert_eq!(steps.len(), 3);
1877
1878        // Removals run first: adding @unique while the conflicting @key is
1879        // still declared would fail schema validation.
1880        assert_eq!(
1881            steps[0].forward,
1882            "undefine\n@key from person owns name;\n@meta(\"x\") from person owns name;"
1883        );
1884        assert_eq!(
1885            steps[0].reverse.as_deref(),
1886            Some("define\nperson owns name @key;\nperson owns name @meta(\"x\", \"1\");")
1887        );
1888        assert_eq!(
1889            steps[1].forward,
1890            "redefine\nperson owns name @doc(\"new doc\");"
1891        );
1892        assert_eq!(
1893            steps[1].reverse.as_deref(),
1894            Some("redefine\nperson owns name @doc(\"old doc\");")
1895        );
1896        assert_eq!(
1897            steps[2].forward,
1898            "define\nperson owns name @meta(\"y\", \"2\");\nperson owns name @unique;"
1899        );
1900        assert_eq!(
1901            steps[2].reverse.as_deref(),
1902            Some("undefine\n@meta(\"y\") from person owns name;\n@unique from person owns name;")
1903        );
1904        // added order: "meta:y" < "unique" in identity order.
1905    }
1906
1907    #[test]
1908    fn modify_ownership_with_identical_annotations_lowers_to_no_steps() {
1909        let g = graph(vec![migration(
1910            "0001_noop",
1911            vec![OperationSpec::ModifyOwnership {
1912                owner_type: "person".to_string(),
1913                attr_name: "name".to_string(),
1914                old_annotations: "@key @doc(\"same\")".to_string(),
1915                new_annotations: "@key @doc(\"same\")".to_string(),
1916            }],
1917            vec![],
1918        )]);
1919
1920        let result = plan(&g, &[], None).expect("plan should succeed");
1921        assert!(result.to_apply[0].steps.is_empty());
1922    }
1923
1924    #[test]
1925    fn modify_type_annotations_lowers_add_update_remove() {
1926        let g = graph(vec![migration(
1927            "0001_type_annotations",
1928            vec![OperationSpec::ModifyTypeAnnotations {
1929                type_name: "person".to_string(),
1930                old_doc: Some("old type doc".to_string()),
1931                new_doc: Some("new type doc".to_string()),
1932                old_meta: BTreeMap::from([("gone".to_string(), "1".to_string())]),
1933                new_meta: BTreeMap::from([("added".to_string(), "2".to_string())]),
1934            }],
1935            vec![],
1936        )]);
1937
1938        let result = plan(&g, &[], None).expect("plan should succeed");
1939        let steps = &result.to_apply[0].steps;
1940        assert_eq!(steps.len(), 3);
1941        assert_eq!(steps[0].forward, "undefine\n@meta(\"gone\") from person;");
1942        assert_eq!(
1943            steps[0].reverse.as_deref(),
1944            Some("define\nperson @meta(\"gone\", \"1\");")
1945        );
1946        assert_eq!(steps[1].forward, "redefine\nperson @doc(\"new type doc\");");
1947        assert_eq!(
1948            steps[1].reverse.as_deref(),
1949            Some("redefine\nperson @doc(\"old type doc\");")
1950        );
1951        assert_eq!(steps[2].forward, "define\nperson @meta(\"added\", \"2\");");
1952        assert_eq!(
1953            steps[2].reverse.as_deref(),
1954            Some("undefine\n@meta(\"added\") from person;")
1955        );
1956    }
1957
1958    #[test]
1959    fn modify_role_annotations_lowers_on_relates_subject() {
1960        let g = graph(vec![migration(
1961            "0001_role_annotations",
1962            vec![OperationSpec::ModifyRoleAnnotations {
1963                relation_type: "employment".to_string(),
1964                role_name: "employee".to_string(),
1965                old_doc: None,
1966                new_doc: Some("The employed party.".to_string()),
1967                old_meta: BTreeMap::new(),
1968                new_meta: BTreeMap::new(),
1969            }],
1970            vec![],
1971        )]);
1972
1973        let result = plan(&g, &[], None).expect("plan should succeed");
1974        let steps = &result.to_apply[0].steps;
1975        assert_eq!(steps.len(), 1);
1976        assert_eq!(
1977            steps[0].forward,
1978            "define\nemployment relates employee @doc(\"The employed party.\");"
1979        );
1980        assert_eq!(
1981            steps[0].reverse.as_deref(),
1982            Some("undefine\n@doc from employment relates employee;")
1983        );
1984    }
1985}