Skip to main content

mongreldb_core/
migrate_mysql.rs

1//! MySQL migration path (spec section 14.1, Stage 5A).
2//!
3//! Maps MySQL wire/dialect concepts into the canonical MongrelDB request
4//! model. Does **not** duplicate the transaction engine: the adapter only
5//! translates auth, COM_QUERY, prepared statements, transactions, metadata,
6//! and kill into protocol requests.
7//!
8//! The migrate tool pipeline:
9//! introspect → map types → recommend partition keys → generate DDL →
10//! bounded copy → validate → binlog CDC catch-up → cutover → rollback window.
11//! Application-level dual-write without outbox/CDC is explicitly not
12//! recommended.
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18/// MySQL→Mongrel type mapping entry.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TypeMapping {
21    /// MySQL type name (e.g. `BIGINT UNSIGNED`).
22    pub mysql_type: String,
23    /// Mongrel type name (e.g. `UInt64`) or error marker.
24    pub mongrel_type: String,
25    /// Whether the mapping is lossy.
26    pub lossy: bool,
27    /// Notes for the operator.
28    pub notes: String,
29}
30
31/// Dialect compatibility matrix row.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct DialectFeature {
34    /// Feature key (e.g. `AUTO_INCREMENT`).
35    pub feature: String,
36    /// Support level.
37    pub support: DialectSupport,
38    /// Operator-facing detail.
39    pub detail: String,
40}
41
42/// How fully a MySQL dialect feature is supported.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum DialectSupport {
45    /// Fully supported with equivalent semantics.
46    Full,
47    /// Supported with documented differences.
48    Partial,
49    /// Unsupported; clear error returned.
50    Unsupported,
51}
52
53/// One table plan produced by introspection + mapping.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct MigrateTablePlan {
56    /// Source table name.
57    pub source_table: String,
58    /// Target table name.
59    pub target_table: String,
60    /// Column mappings (source → target type).
61    pub columns: Vec<TypeMapping>,
62    /// Recommended partition key columns (empty = single tablet).
63    pub recommended_partition_keys: Vec<String>,
64    /// Generated Mongrel DDL.
65    pub ddl: String,
66    /// Incompatible features flagged.
67    pub incompatibilities: Vec<String>,
68}
69
70/// Full migration plan (schema + copy + CDC + cutover).
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct MysqlMigratePlan {
73    /// Source DSN (redacted credentials for display).
74    pub source_display: String,
75    /// Target database id / path.
76    pub target: String,
77    /// Schema-only mode (no data copy).
78    pub schema_only: bool,
79    /// Per-table plans.
80    pub tables: Vec<MigrateTablePlan>,
81    /// Pipeline stages remaining.
82    pub stages: Vec<MigrateStage>,
83}
84
85/// Pipeline stage of the migrate tool.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87pub enum MigrateStage {
88    /// Introspect MySQL schema.
89    Introspect,
90    /// Map types and constraints.
91    MapTypes,
92    /// Recommend partition/colocation keys.
93    RecommendPartitioning,
94    /// Generate MongrelDB DDL.
95    GenerateDdl,
96    /// Copy data in bounded batches.
97    BoundedCopy,
98    /// Validate row counts and checksums.
99    Validate,
100    /// Binlog CDC catch-up.
101    CdcCatchUp,
102    /// Cut over after lag reaches zero.
103    Cutover,
104    /// Rollback window open.
105    RollbackWindow,
106}
107
108impl MigrateStage {
109    /// Full pipeline order.
110    pub const PIPELINE: [MigrateStage; 9] = [
111        MigrateStage::Introspect,
112        MigrateStage::MapTypes,
113        MigrateStage::RecommendPartitioning,
114        MigrateStage::GenerateDdl,
115        MigrateStage::BoundedCopy,
116        MigrateStage::Validate,
117        MigrateStage::CdcCatchUp,
118        MigrateStage::Cutover,
119        MigrateStage::RollbackWindow,
120    ];
121
122    /// Stable name.
123    pub fn name(self) -> &'static str {
124        match self {
125            Self::Introspect => "introspect",
126            Self::MapTypes => "map_types",
127            Self::RecommendPartitioning => "recommend_partitioning",
128            Self::GenerateDdl => "generate_ddl",
129            Self::BoundedCopy => "bounded_copy",
130            Self::Validate => "validate",
131            Self::CdcCatchUp => "cdc_catch_up",
132            Self::Cutover => "cutover",
133            Self::RollbackWindow => "rollback_window",
134        }
135    }
136}
137
138/// Built-in dialect compatibility matrix (spec §14.1 list).
139pub fn dialect_matrix() -> Vec<DialectFeature> {
140    vec![
141        feat(
142            "data_types",
143            DialectSupport::Partial,
144            "common numeric/string/json types map; spatial unsupported",
145        ),
146        feat(
147            "AUTO_INCREMENT",
148            DialectSupport::Full,
149            "maps to Mongrel sequences / auto-inc",
150        ),
151        feat(
152            "LAST_INSERT_ID",
153            DialectSupport::Partial,
154            "session-scoped; multi-row insert returns first",
155        ),
156        feat(
157            "ON DUPLICATE KEY UPDATE",
158            DialectSupport::Partial,
159            "maps to upsert when unique key present",
160        ),
161        feat(
162            "LIMIT syntax",
163            DialectSupport::Full,
164            "LIMIT/OFFSET supported",
165        ),
166        feat(
167            "boolean behavior",
168            DialectSupport::Partial,
169            "MySQL TINYINT(1) → Bool when declared",
170        ),
171        feat(
172            "date/time behavior",
173            DialectSupport::Partial,
174            "TIMESTAMP TZ handling differs; documented",
175        ),
176        feat("JSON", DialectSupport::Full, "JSON type + path extracts"),
177        feat(
178            "collations",
179            DialectSupport::Partial,
180            "utf8mb4_bin equivalent; locale collations limited",
181        ),
182        feat(
183            "isolation levels",
184            DialectSupport::Full,
185            "RC/RR/Serializable via SET TRANSACTION",
186        ),
187        feat(
188            "locks",
189            DialectSupport::Partial,
190            "FOR UPDATE planned; GET_LOCK unsupported",
191        ),
192        feat(
193            "information_schema",
194            DialectSupport::Partial,
195            "core tables exposed; full MySQL catalog not mirrored",
196        ),
197    ]
198}
199
200fn feat(feature: &str, support: DialectSupport, detail: &str) -> DialectFeature {
201    DialectFeature {
202        feature: feature.into(),
203        support,
204        detail: detail.into(),
205    }
206}
207
208/// Map a MySQL column type name to a Mongrel type (best-effort matrix).
209pub fn map_mysql_type(mysql_type: &str) -> TypeMapping {
210    let upper = mysql_type.trim().to_ascii_uppercase();
211    let (mongrel, lossy, notes) = match upper.as_str() {
212        "TINYINT" | "TINYINT(1)" | "BOOL" | "BOOLEAN" => ("Bool", false, ""),
213        "SMALLINT" => ("Int16", false, ""),
214        "INT" | "INTEGER" | "MEDIUMINT" => ("Int32", false, ""),
215        "BIGINT" => ("Int64", false, ""),
216        "BIGINT UNSIGNED" => ("UInt64", false, ""),
217        "FLOAT" => ("Float32", false, ""),
218        "DOUBLE" | "DOUBLE PRECISION" | "REAL" => ("Float64", false, ""),
219        t if t.starts_with("DECIMAL") || t.starts_with("NUMERIC") => {
220            ("Decimal", true, "precision/scale preserved when declared")
221        }
222        t if t.starts_with("VARCHAR")
223            || t.starts_with("CHAR")
224            || t == "TEXT"
225            || t == "TINYTEXT"
226            || t == "MEDIUMTEXT"
227            || t == "LONGTEXT" =>
228        {
229            ("Utf8", false, "")
230        }
231        t if t.starts_with("VARBINARY")
232            || t.starts_with("BINARY")
233            || t == "BLOB"
234            || t == "TINYBLOB"
235            || t == "MEDIUMBLOB"
236            || t == "LONGBLOB" =>
237        {
238            ("Bytes", false, "")
239        }
240        "JSON" => ("Json", false, ""),
241        "DATE" => ("Date", false, ""),
242        "DATETIME" | "TIMESTAMP" => ("Timestamp", true, "timezone semantics differ"),
243        "TIME" => ("Time", false, ""),
244        other => ("Unsupported", true, other),
245    };
246    TypeMapping {
247        mysql_type: mysql_type.into(),
248        mongrel_type: mongrel.into(),
249        lossy,
250        notes: notes.into(),
251    }
252}
253
254/// Build a migration plan from an introspected schema description.
255///
256/// `tables` maps table name → list of `(column_name, mysql_type)`.
257pub fn plan_mysql_migration(
258    source_display: impl Into<String>,
259    target: impl Into<String>,
260    schema_only: bool,
261    tables: &BTreeMap<String, Vec<(String, String)>>,
262) -> MysqlMigratePlan {
263    let mut table_plans = Vec::new();
264    for (name, cols) in tables {
265        let columns: Vec<TypeMapping> = cols.iter().map(|(_, ty)| map_mysql_type(ty)).collect();
266        let incompatibilities: Vec<String> = columns
267            .iter()
268            .filter(|c| c.mongrel_type == "Unsupported")
269            .map(|c| format!("column type {} unsupported", c.mysql_type))
270            .collect();
271        let col_ddl: Vec<String> = cols
272            .iter()
273            .zip(columns.iter())
274            .map(|((cname, _), mapping)| format!("  {} {}", cname, mapping.mongrel_type))
275            .collect();
276        let ddl = format!("CREATE TABLE {} (\n{}\n);", name, col_ddl.join(",\n"));
277        // Recommend first integer PK-like column as partition key when present.
278        let recommended_partition_keys = cols
279            .iter()
280            .find(|(_, ty)| {
281                let u = ty.to_ascii_uppercase();
282                u.contains("INT") && !u.contains("POINT")
283            })
284            .map(|(c, _)| vec![c.clone()])
285            .unwrap_or_default();
286        table_plans.push(MigrateTablePlan {
287            source_table: name.clone(),
288            target_table: name.clone(),
289            columns,
290            recommended_partition_keys,
291            ddl,
292            incompatibilities,
293        });
294    }
295    let stages = if schema_only {
296        MigrateStage::PIPELINE
297            .into_iter()
298            .filter(|s| {
299                !matches!(
300                    s,
301                    MigrateStage::BoundedCopy
302                        | MigrateStage::Validate
303                        | MigrateStage::CdcCatchUp
304                        | MigrateStage::Cutover
305                        | MigrateStage::RollbackWindow
306                )
307            })
308            .collect()
309    } else {
310        MigrateStage::PIPELINE.to_vec()
311    };
312    MysqlMigratePlan {
313        source_display: source_display.into(),
314        target: target.into(),
315        schema_only,
316        tables: table_plans,
317        stages,
318    }
319}
320
321/// Wire-adapter request kinds mapped into the canonical protocol model.
322///
323/// The adapter never implements its own transaction state machine.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub enum MysqlWireRequest {
326    /// COM_QUERY → ExecuteRequest SQL.
327    Query {
328        /// SQL text.
329        sql: String,
330    },
331    /// COM_STMT_PREPARE.
332    Prepare {
333        /// SQL text.
334        sql: String,
335    },
336    /// COM_STMT_EXECUTE.
337    ExecutePrepared {
338        /// Statement id.
339        statement_id: u32,
340        /// Bound parameter payloads (opaque).
341        params: Vec<Vec<u8>>,
342    },
343    /// BEGIN/COMMIT/ROLLBACK → session txn commands.
344    Transaction {
345        /// begin | commit | rollback.
346        verb: String,
347    },
348    /// COM_PROCESS_KILL / KILL QUERY.
349    Kill {
350        /// Target connection/query id.
351        target_id: u64,
352    },
353    /// Auth handshake result (SCRAM or mysql_native_password mapped).
354    Authenticate {
355        /// Username.
356        user: String,
357    },
358}
359
360/// Dual-write warning constant (spec §14.1).
361pub const DUAL_WRITE_WARNING: &str = "Do not recommend application-level dual writes without an \
362outbox/CDC design. Use MySQL binlog CDC for migration catch-up.";
363
364/// One source row as column name → string value (testable without a live MySQL).
365pub type SourceRow = BTreeMap<String, String>;
366
367/// Trait the migrate tool uses for source/target I/O. Production binds a
368/// MySQL client + Mongrel session; tests supply in-memory stores.
369pub trait MigrateIo {
370    /// Apply generated DDL on the target.
371    fn apply_ddl(&mut self, ddl: &str) -> Result<(), String>;
372    /// Copy up to `batch` rows starting at `offset` from `table`.
373    fn copy_batch(
374        &mut self,
375        table: &str,
376        offset: u64,
377        batch: u64,
378    ) -> Result<Vec<SourceRow>, String>;
379    /// Insert one row into the target table.
380    fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String>;
381    /// Count rows on source and target for validation.
382    fn count_rows(&self, table: &str) -> Result<(u64, u64), String>;
383    /// Row checksum (deterministic) for source and target.
384    fn checksum_rows(&self, table: &str) -> Result<(String, String), String>;
385    /// Apply one CDC event (insert/update/delete as row map + op).
386    fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String>;
387    /// Current CDC lag in events remaining (0 = caught up).
388    fn cdc_lag(&self) -> u64;
389}
390
391/// CDC operation kind from binlog.
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393pub enum CdcOp {
394    /// Insert.
395    Insert,
396    /// Update (row is post-image).
397    Update,
398    /// Delete.
399    Delete,
400}
401
402/// Result of running the migrate pipeline against a [`MigrateIo`].
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct MigrateRunReport {
405    /// Stages completed in order.
406    pub completed: Vec<MigrateStage>,
407    /// Rows copied per table.
408    pub rows_copied: BTreeMap<String, u64>,
409    /// Validation ok.
410    pub validated: bool,
411    /// CDC lag at cutover.
412    pub cdc_lag_at_cutover: u64,
413    /// Whether cutover was performed.
414    pub cut_over: bool,
415    /// Rollback window still open.
416    pub rollback_window_open: bool,
417}
418
419/// Default bounded copy batch size.
420pub const DEFAULT_COPY_BATCH: u64 = 1_000;
421
422/// Run the full migrate pipeline (or schema-only subset) against `io`.
423///
424/// Stages: map types already in `plan` → apply DDL → bounded copy → validate
425/// counts/checksums → CDC catch-up until lag 0 → cutover → open rollback window.
426/// Does **not** dual-write; CDC is the catch-up path (see [`DUAL_WRITE_WARNING`]).
427pub fn run_migrate_pipeline(
428    plan: &MysqlMigratePlan,
429    io: &mut dyn MigrateIo,
430    copy_batch: u64,
431    cdc_events: &[(String, CdcOp, SourceRow)],
432) -> Result<MigrateRunReport, String> {
433    let batch = copy_batch.max(1);
434    let mut completed = Vec::new();
435    let mut rows_copied: BTreeMap<String, u64> = BTreeMap::new();
436
437    // Introspect/Map/Recommend already done when building the plan.
438    completed.push(MigrateStage::Introspect);
439    completed.push(MigrateStage::MapTypes);
440    completed.push(MigrateStage::RecommendPartitioning);
441
442    for table in &plan.tables {
443        if !table.incompatibilities.is_empty() {
444            return Err(format!(
445                "table {} has incompatibilities: {:?}",
446                table.source_table, table.incompatibilities
447            ));
448        }
449        io.apply_ddl(&table.ddl)?;
450    }
451    completed.push(MigrateStage::GenerateDdl);
452
453    if plan.schema_only {
454        return Ok(MigrateRunReport {
455            completed,
456            rows_copied,
457            validated: true,
458            cdc_lag_at_cutover: 0,
459            cut_over: false,
460            rollback_window_open: false,
461        });
462    }
463
464    for table in &plan.tables {
465        let mut offset = 0u64;
466        let mut total = 0u64;
467        loop {
468            let rows = io.copy_batch(&table.source_table, offset, batch)?;
469            if rows.is_empty() {
470                break;
471            }
472            for row in &rows {
473                io.insert_row(&table.target_table, row)?;
474            }
475            let n = rows.len() as u64;
476            total += n;
477            offset += n;
478            if n < batch {
479                break;
480            }
481        }
482        rows_copied.insert(table.source_table.clone(), total);
483    }
484    completed.push(MigrateStage::BoundedCopy);
485
486    for table in &plan.tables {
487        let (src, dst) = io.count_rows(&table.source_table)?;
488        if src != dst {
489            return Err(format!(
490                "row count mismatch on {}: source={src} target={dst}",
491                table.source_table
492            ));
493        }
494        let (cs, cd) = io.checksum_rows(&table.source_table)?;
495        if cs != cd {
496            return Err(format!(
497                "checksum mismatch on {}: source={cs} target={cd}",
498                table.source_table
499            ));
500        }
501    }
502    completed.push(MigrateStage::Validate);
503
504    for (table, op, row) in cdc_events {
505        io.apply_cdc(table, *op, row)?;
506    }
507    // Drain remaining lag reported by the source.
508    let mut spins = 0u32;
509    while io.cdc_lag() > 0 {
510        spins += 1;
511        if spins > 10_000 {
512            return Err(format!("CDC lag did not reach zero (lag={})", io.cdc_lag()));
513        }
514    }
515    completed.push(MigrateStage::CdcCatchUp);
516
517    let lag = io.cdc_lag();
518    if lag != 0 {
519        return Err(format!("refusing cutover with cdc lag {lag}"));
520    }
521    completed.push(MigrateStage::Cutover);
522    completed.push(MigrateStage::RollbackWindow);
523
524    Ok(MigrateRunReport {
525        completed,
526        rows_copied,
527        validated: true,
528        cdc_lag_at_cutover: lag,
529        cut_over: true,
530        rollback_window_open: true,
531    })
532}
533
534/// In-memory migrate I/O for tests (source rows + target store).
535#[derive(Debug, Default)]
536pub struct MemoryMigrateIo {
537    /// Source table → rows.
538    pub source: BTreeMap<String, Vec<SourceRow>>,
539    /// Target table → rows.
540    pub target: BTreeMap<String, Vec<SourceRow>>,
541    /// Applied DDL statements.
542    pub ddl: Vec<String>,
543    /// Pending CDC lag counter (decremented by apply_cdc or set by tests).
544    pub lag: u64,
545}
546
547impl MigrateIo for MemoryMigrateIo {
548    fn apply_ddl(&mut self, ddl: &str) -> Result<(), String> {
549        self.ddl.push(ddl.to_owned());
550        Ok(())
551    }
552
553    fn copy_batch(
554        &mut self,
555        table: &str,
556        offset: u64,
557        batch: u64,
558    ) -> Result<Vec<SourceRow>, String> {
559        let rows = self.source.get(table).cloned().unwrap_or_default();
560        let start = offset as usize;
561        if start >= rows.len() {
562            return Ok(Vec::new());
563        }
564        let end = (start + batch as usize).min(rows.len());
565        Ok(rows[start..end].to_vec())
566    }
567
568    fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String> {
569        self.target
570            .entry(table.to_owned())
571            .or_default()
572            .push(row.clone());
573        Ok(())
574    }
575
576    fn count_rows(&self, table: &str) -> Result<(u64, u64), String> {
577        let s = self.source.get(table).map(|r| r.len() as u64).unwrap_or(0);
578        let t = self.target.get(table).map(|r| r.len() as u64).unwrap_or(0);
579        Ok((s, t))
580    }
581
582    fn checksum_rows(&self, table: &str) -> Result<(String, String), String> {
583        fn sum(rows: &[SourceRow]) -> String {
584            use sha2::{Digest, Sha256};
585            let mut h = Sha256::new();
586            for row in rows {
587                for (k, v) in row {
588                    h.update(k.as_bytes());
589                    h.update([0]);
590                    h.update(v.as_bytes());
591                    h.update([0]);
592                }
593                h.update([1]);
594            }
595            format!("{:x}", h.finalize())
596        }
597        let s = self.source.get(table).map(|r| sum(r)).unwrap_or_default();
598        let t = self.target.get(table).map(|r| sum(r)).unwrap_or_default();
599        Ok((s, t))
600    }
601
602    fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String> {
603        match op {
604            CdcOp::Insert | CdcOp::Update => {
605                // Upsert by first column if present.
606                let rows = self.target.entry(table.to_owned()).or_default();
607                if let Some(key) = row.keys().next().cloned() {
608                    if let Some(val) = row.get(&key) {
609                        if let Some(existing) = rows.iter_mut().find(|r| r.get(&key) == Some(val)) {
610                            *existing = row.clone();
611                        } else {
612                            rows.push(row.clone());
613                        }
614                    } else {
615                        rows.push(row.clone());
616                    }
617                } else {
618                    rows.push(row.clone());
619                }
620            }
621            CdcOp::Delete => {
622                if let Some((key, val)) = row.iter().next() {
623                    if let Some(rows) = self.target.get_mut(table) {
624                        rows.retain(|r| r.get(key) != Some(val));
625                    }
626                }
627            }
628        }
629        self.lag = self.lag.saturating_sub(1);
630        Ok(())
631    }
632
633    fn cdc_lag(&self) -> u64 {
634        self.lag
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn maps_common_types() {
644        assert_eq!(map_mysql_type("BIGINT").mongrel_type, "Int64");
645        assert_eq!(map_mysql_type("VARCHAR(255)").mongrel_type, "Utf8");
646        assert_eq!(map_mysql_type("JSON").mongrel_type, "Json");
647        assert!(map_mysql_type("GEOMETRY").mongrel_type == "Unsupported");
648    }
649
650    #[test]
651    fn plan_pipeline_includes_cdc_not_dual_write() {
652        let mut tables = BTreeMap::new();
653        tables.insert(
654            "orders".into(),
655            vec![
656                ("id".into(), "BIGINT".into()),
657                ("note".into(), "TEXT".into()),
658            ],
659        );
660        let plan = plan_mysql_migration("mysql://***@host/db", "mongrel://local", false, &tables);
661        assert!(plan.stages.contains(&MigrateStage::CdcCatchUp));
662        assert!(plan.stages.contains(&MigrateStage::BoundedCopy));
663        assert_eq!(plan.tables.len(), 1);
664        assert!(plan.tables[0].ddl.contains("CREATE TABLE orders"));
665        assert_eq!(
666            plan.tables[0].recommended_partition_keys,
667            vec!["id".to_string()]
668        );
669        assert!(DUAL_WRITE_WARNING.contains("binlog CDC"));
670    }
671
672    #[test]
673    fn run_pipeline_copy_validate_cdc_cutover() {
674        let mut tables = BTreeMap::new();
675        tables.insert(
676            "orders".into(),
677            vec![
678                ("id".into(), "BIGINT".into()),
679                ("note".into(), "TEXT".into()),
680            ],
681        );
682        let plan = plan_mysql_migration("src", "dst", false, &tables);
683        let mut io = MemoryMigrateIo::default();
684        for i in 0..5u64 {
685            let mut row = SourceRow::new();
686            row.insert("id".into(), i.to_string());
687            row.insert("note".into(), format!("n{i}"));
688            io.source.entry("orders".into()).or_default().push(row);
689        }
690        // One insert that arrived after snapshot start.
691        let mut cdc_row = SourceRow::new();
692        cdc_row.insert("id".into(), "99".into());
693        cdc_row.insert("note".into(), "late".into());
694        io.lag = 1;
695        let report = run_migrate_pipeline(
696            &plan,
697            &mut io,
698            2, // small batches force multi-batch copy
699            &[("orders".into(), CdcOp::Insert, cdc_row)],
700        )
701        .unwrap();
702        assert!(report.cut_over);
703        assert!(report.validated);
704        assert!(report.rollback_window_open);
705        assert_eq!(report.rows_copied.get("orders"), Some(&5));
706        assert!(report.completed.contains(&MigrateStage::BoundedCopy));
707        assert!(report.completed.contains(&MigrateStage::Validate));
708        assert!(report.completed.contains(&MigrateStage::CdcCatchUp));
709        assert!(report.completed.contains(&MigrateStage::Cutover));
710        // Target has 5 copied + 1 CDC.
711        assert_eq!(io.target.get("orders").map(|r| r.len()), Some(6));
712        assert!(!io.ddl.is_empty());
713    }
714
715    #[test]
716    fn dialect_matrix_covers_spec_list() {
717        let m = dialect_matrix();
718        for key in [
719            "AUTO_INCREMENT",
720            "ON DUPLICATE KEY UPDATE",
721            "JSON",
722            "isolation levels",
723            "information_schema",
724        ] {
725            assert!(m.iter().any(|f| f.feature == key), "missing {key}");
726        }
727    }
728
729    #[test]
730    fn schema_only_skips_data_stages() {
731        let plan = plan_mysql_migration("src", "dst", true, &BTreeMap::new());
732        assert!(!plan.stages.iter().any(|s| matches!(
733            s,
734            MigrateStage::BoundedCopy | MigrateStage::CdcCatchUp | MigrateStage::Cutover
735        )));
736    }
737}