Skip to main content

mz_deploy/cli/commands/
stage.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Stage command - deploy to staging environment with renamed schemas and clusters.
11//!
12//! Runs the standard blue/green deployment pipeline that can later be promoted
13//! to production via [`super::promote`].
14
15use super::ObjectRef;
16use crate::cli::CliError;
17use crate::cli::executor::{self, DeploymentExecutor};
18use crate::cli::{git, progress};
19use crate::client::DeploymentMode;
20use crate::client::{
21    Client, ClusterConfig, ClusterOptions, DeploymentKind, PendingStatement, ReplacementMvRecord,
22};
23use crate::config::Settings;
24use crate::log;
25use crate::project::SchemaQualifier;
26use crate::project::analysis::changeset::ChangeSet;
27use crate::project::analysis::deployment_snapshot::{self, DeploymentSnapshot};
28use crate::project::analysis::deps::extract_external_indexes;
29use crate::project::ast::Statement;
30use crate::project::ir::compiled::{DatabaseObject, FullyQualifiedName};
31use crate::project::ir::graph::Project;
32use crate::project::ir::object_id::ObjectId;
33use crate::project::resolve::normalize::{self, NormalizingVisitor};
34use crate::verbose;
35use mz_ore::option::OptionExt;
36use mz_sql_parser::ast::Ident;
37use std::collections::BTreeSet;
38
39/// Reject a stage name long enough that appending the staging suffix
40/// `_<stage_name>` to a schema or cluster identifier would exceed the
41/// identifier length limit and panic during deploy.
42fn validate_stage_name(stage_name: &str) -> Result<(), CliError> {
43    // The suffix is appended to existing identifiers, so reserve headroom for
44    // the base name rather than letting the suffix consume the whole limit.
45    if stage_name.len() + 1 > Ident::MAX_LENGTH / 2 {
46        return Err(CliError::InvalidEnvironmentName {
47            name: stage_name.to_string(),
48        });
49    }
50    Ok(())
51}
52use std::fmt;
53use std::path::Path;
54use std::time::Instant;
55
56/// Planning output produced once and consumed by all stage execution phases.
57///
58/// Keeps stage deterministic by passing one analyzed view of objects/resources through
59/// validation, metadata recording, and resource creation.
60struct StageAnalysis<'a> {
61    objects: Vec<ObjectRef<'a>>,
62    sinks: Vec<ObjectRef<'a>>,
63    replacement_mvs: Vec<ObjectRef<'a>>,
64    schema_set: BTreeSet<SchemaQualifier>,
65    cluster_set: BTreeSet<String>,
66}
67
68/// Classification result for objects considered during staging.
69///
70/// Separates deploy-now objects from deferred/special-case categories that apply handles later.
71struct PartitionedObjects<'a> {
72    objects: Vec<ObjectRef<'a>>,
73    sinks: Vec<ObjectRef<'a>>,
74    replacement_mvs: Vec<ObjectRef<'a>>,
75    table_count: usize,
76}
77
78/// Summary returned after a successful stage run, used for terminal output
79/// and `--json`.
80#[derive(serde::Serialize)]
81struct StageResult {
82    deploy_id: String,
83    objects_deployed: usize,
84    #[serde(skip)]
85    duration: std::time::Duration,
86}
87
88impl fmt::Display for StageResult {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(
91            f,
92            "  \u{2713} Successfully deployed {} objects to '{}' staging environment ({:.1}s)",
93            self.objects_deployed,
94            self.deploy_id,
95            self.duration.as_secs_f64()
96        )
97    }
98}
99
100#[derive(serde::Serialize)]
101struct StagePlan {
102    deploy_id: String,
103    schemas: Vec<StagePlanSchema>,
104    clusters: Vec<StagePlanCluster>,
105    objects: Vec<StagePlanObject>,
106    sinks: Vec<StagePlanObject>,
107    replacement_mvs: Vec<StagePlanObject>,
108}
109
110#[derive(serde::Serialize)]
111struct StagePlanSchema {
112    database: String,
113    schema: String,
114    staging_schema: String,
115}
116
117#[derive(serde::Serialize)]
118struct StagePlanCluster {
119    production_cluster: String,
120    staging_cluster: String,
121}
122
123#[derive(serde::Serialize)]
124struct StagePlanObject {
125    database: String,
126    schema: String,
127    object: String,
128}
129
130impl fmt::Display for StagePlan {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        writeln!(f, "Stage plan for '{}':", self.deploy_id)?;
133
134        if !self.schemas.is_empty() {
135            writeln!(f, "\nSchemas ({}):", self.schemas.len())?;
136            for s in &self.schemas {
137                writeln!(
138                    f,
139                    "    {}.{} \u{2192} {}",
140                    s.database, s.schema, s.staging_schema
141                )?;
142            }
143        }
144
145        if !self.clusters.is_empty() {
146            writeln!(f, "\nClusters ({}):", self.clusters.len())?;
147            for c in &self.clusters {
148                writeln!(
149                    f,
150                    "    {} \u{2192} {}",
151                    c.production_cluster, c.staging_cluster
152                )?;
153            }
154        }
155
156        if !self.objects.is_empty() {
157            writeln!(f, "\nObjects ({}):", self.objects.len())?;
158            for o in &self.objects {
159                writeln!(f, "    {}.{}.{}", o.database, o.schema, o.object)?;
160            }
161        }
162
163        if !self.sinks.is_empty() {
164            writeln!(f, "\nSinks ({}):", self.sinks.len())?;
165            for s in &self.sinks {
166                writeln!(f, "    {}.{}.{}", s.database, s.schema, s.object)?;
167            }
168        }
169
170        if !self.replacement_mvs.is_empty() {
171            writeln!(f, "\nReplacement MVs ({}):", self.replacement_mvs.len())?;
172            for m in &self.replacement_mvs {
173                writeln!(f, "    {}.{}.{}", m.database, m.schema, m.object)?;
174            }
175        }
176
177        Ok(())
178    }
179}
180
181/// Deploy the project to a staging environment.
182///
183/// Creates renamed schemas and clusters alongside production, deploys the
184/// project onto them, and records metadata so a later `apply` can atomically
185/// swap staging into production.
186///
187/// # Arguments
188/// * `settings` - Resolved CLI settings (project directory, profile, etc.)
189/// * `stage_name` - Optional environment name; defaults to a git-derived or
190///   random identifier
191/// * `allow_dirty` - Allow deploying with uncommitted changes
192/// * `no_rollback` - Skip automatic rollback on failure (for debugging)
193/// * `dry_run` - Print SQL instead of executing it
194///
195/// # Returns
196/// `Ok(())` if the deployment succeeds.
197///
198/// # Errors
199/// Surfaces `CliError` variants from git checks, project compilation, and
200/// database execution.
201pub async fn run(
202    settings: &Settings,
203    stage_name: Option<&str>,
204    allow_dirty: bool,
205    no_rollback: bool,
206    dry_run: bool,
207    redeploy_schemas: &[String],
208    redeploy_all: bool,
209) -> Result<(), CliError> {
210    let profile = settings.connection();
211    let directory = &settings.directory;
212    let start_time = Instant::now();
213
214    if !allow_dirty && git::is_dirty(directory) {
215        return Err(CliError::GitDirty);
216    }
217
218    let stage_name = stage_name
219        .owned()
220        .or_else(|| git::get_git_commit(directory).map(|sha| sha.chars().take(7).collect()))
221        .unwrap_or_else(executor::generate_random_env_name);
222    validate_stage_name(&stage_name)?;
223
224    let planned_project = super::compile::run(settings, true).await?;
225    let staging_suffix = format!("_{}", stage_name);
226
227    let client = Client::connect_with_profile(profile.clone())
228        .await
229        .map_err(CliError::Connection)?;
230
231    crate::cli::commands::setup::verify(&client, settings.emulator()).await?;
232    let role =
233        crate::cli::commands::setup::validate_connection(&client, settings.emulator()).await?;
234    crate::cli::commands::setup::require_deployer(role)?;
235
236    let forced_dirty_schemas = resolve_redeploy_schemas(&planned_project, redeploy_schemas)?;
237
238    let Some(analysis) = analyze_project_changes(
239        &client,
240        &planned_project,
241        &stage_name,
242        forced_dirty_schemas,
243        redeploy_all,
244    )
245    .await?
246    else {
247        return Ok(());
248    };
249
250    validate_project_for_stage(
251        &client,
252        &planned_project,
253        directory,
254        &analysis.schema_set,
255        &analysis.cluster_set,
256    )
257    .await?;
258
259    if !dry_run {
260        // Metadata is written before any resources are created, so a failure
261        // partway through can leave deployment rows behind that block
262        // re-staging under the same name. Roll them back on failure, unless
263        // --no-rollback asks to preserve state for debugging. The rollback runs
264        // a suffix-matching CASCADE drop, so honoring the flag here also avoids
265        // dropping resources the operator asked to keep.
266        if let Err(e) = record_stage_metadata(
267            &client,
268            directory,
269            &stage_name,
270            &staging_suffix,
271            &analysis.objects,
272            &analysis.sinks,
273            &analysis.replacement_mvs,
274            &planned_project.replacement_schemas,
275        )
276        .await
277        {
278            if no_rollback {
279                progress::error("Deployment failed (skipping rollback due to --no-rollback flag)");
280            } else {
281                progress::error("Deployment failed, rolling back...");
282                rollback_staging_resources(&client, &stage_name).await;
283            }
284            return Err(e);
285        }
286    }
287
288    if dry_run {
289        let plan = StagePlan {
290            deploy_id: stage_name.to_string(),
291            schemas: analysis
292                .schema_set
293                .iter()
294                .map(|sq| StagePlanSchema {
295                    database: sq.database.clone(),
296                    schema: sq.schema.clone(),
297                    staging_schema: format!("{}{}", sq.schema, staging_suffix),
298                })
299                .collect(),
300            clusters: analysis
301                .cluster_set
302                .iter()
303                .map(|c| StagePlanCluster {
304                    production_cluster: c.clone(),
305                    staging_cluster: format!("{}{}", c, staging_suffix),
306                })
307                .collect(),
308            objects: analysis
309                .objects
310                .iter()
311                .map(|(id, _)| StagePlanObject {
312                    database: id.expect_database().to_string(),
313                    schema: id.schema().to_string(),
314                    object: id.object().to_string(),
315                })
316                .collect(),
317            sinks: analysis
318                .sinks
319                .iter()
320                .map(|(id, _)| StagePlanObject {
321                    database: id.expect_database().to_string(),
322                    schema: id.schema().to_string(),
323                    object: id.object().to_string(),
324                })
325                .collect(),
326            replacement_mvs: analysis
327                .replacement_mvs
328                .iter()
329                .map(|(id, _)| StagePlanObject {
330                    database: id.expect_database().to_string(),
331                    schema: id.schema().to_string(),
332                    object: id.object().to_string(),
333                })
334                .collect(),
335        };
336        log::output(&plan);
337        return Ok(());
338    }
339
340    let success_count = create_resources_with_rollback(
341        &client,
342        &stage_name,
343        &staging_suffix,
344        &analysis.schema_set,
345        &analysis.cluster_set,
346        &planned_project,
347        &analysis.objects,
348        &analysis.replacement_mvs,
349        no_rollback,
350        dry_run,
351    )
352    .await?;
353
354    let result = StageResult {
355        deploy_id: stage_name.to_string(),
356        objects_deployed: success_count,
357        duration: start_time.elapsed(),
358    };
359    log::output(&result);
360    log::print_deploy_id(&stage_name);
361    Ok(())
362}
363
364/// Parse one `--redeploy-schema` value, which must be fully qualified as
365/// `database.schema`. Reuses the SQL parser so reserved-word components quote
366/// correctly.
367fn parse_qualified_schema(raw: &str) -> Result<SchemaQualifier, CliError> {
368    let unqualified = || {
369        CliError::Message(format!(
370            "invalid --redeploy-schema '{}': expected a qualified 'database.schema' name",
371            raw
372        ))
373    };
374    let name = mz_sql_parser::parser::parse_item_name(raw).map_err(|_| unqualified())?;
375    let [database, schema] = name.0.as_slice() else {
376        return Err(unqualified());
377    };
378    Ok(SchemaQualifier::new(
379        database.as_str().to_string(),
380        schema.as_str().to_string(),
381    ))
382}
383
384/// Resolve `--redeploy-schema` values into `SchemaQualifier`s, validating each
385/// against the project's schemas.
386fn resolve_redeploy_schemas(
387    planned_project: &Project,
388    redeploy_schemas: &[String],
389) -> Result<BTreeSet<SchemaQualifier>, CliError> {
390    if redeploy_schemas.is_empty() {
391        return Ok(BTreeSet::new());
392    }
393
394    let objects: Vec<_> = planned_project.iter_objects().collect();
395    let project_schemas = SchemaQualifier::collect_from(&objects);
396
397    let mut resolved = BTreeSet::new();
398    for raw in redeploy_schemas {
399        let sq = parse_qualified_schema(raw)?;
400        if !project_schemas.contains(&sq) {
401            let available = project_schemas
402                .iter()
403                .map(|s| format!("{}.{}", s.database, s.schema))
404                .collect::<Vec<_>>()
405                .join(", ");
406            return Err(CliError::Message(format!(
407                "--redeploy-schema '{}.{}' is not a schema in this project; available: {}",
408                sq.database, sq.schema, available
409            )));
410        }
411        resolved.insert(sq);
412    }
413    Ok(resolved)
414}
415
416/// Produces the stage deployment plan by diffing against current production snapshot.
417///
418/// Handles incremental-vs-full mode, applies stage-specific object filtering,
419/// validates table dependencies, and returns resource sets required for execution.
420async fn analyze_project_changes<'a>(
421    client: &Client,
422    planned_project: &'a Project,
423    stage_name: &str,
424    forced_dirty_schemas: BTreeSet<SchemaQualifier>,
425    redeploy_all: bool,
426) -> Result<Option<StageAnalysis<'a>>, CliError> {
427    progress::stage_start("Analyzing project changes");
428    let analyze_start = Instant::now();
429
430    if client
431        .deployments()
432        .get_deployment_metadata(stage_name)
433        .await?
434        .is_some()
435    {
436        return Err(CliError::InvalidEnvironmentName {
437            name: format!("deployment '{}' already exists", stage_name),
438        });
439    }
440
441    let new_snapshot = deployment_snapshot::build_snapshot_from_planned(planned_project)?;
442    let production_snapshot = deployment_snapshot::load_from_database(client, None).await?;
443
444    let dirty_schemas = if redeploy_all {
445        new_snapshot.schemas.keys().cloned().collect()
446    } else {
447        forced_dirty_schemas
448    };
449
450    let change_set = if production_snapshot.objects.is_empty() {
451        None
452    } else {
453        Some(ChangeSet::from_deployment_snapshot_comparison(
454            &production_snapshot,
455            &new_snapshot,
456            planned_project,
457            &dirty_schemas,
458        ))
459    };
460
461    // Reject adding brand-new objects to a schema that already has production objects.
462    // During incremental deployment:
463    //
464    //   1. The changeset correctly classifies these as `new_replacement_objects`
465    //   2. But `new_replacement_objects` is never consumed by `partition_objects` —
466    //      only `changed_replacement_objects` feeds into it
467    //   3. The new MV ends up in the regular `objects` partition and deploys to the
468    //      staging schema (e.g. `core_v3`)
469    //   4. Metadata for the production schema (`core`) gets overwritten to
470    //      `DeploymentKind::Replacement` by the changed MVs
471    //   5. During promote, the staging schema is skipped from swap and dropped CASCADE
472    //      — the new MV is lost
473    //
474    // The proper long-term fix is to support `ALTER MATERIALIZED VIEW ... SET SCHEMA`.
475    // With that, new MVs could be deployed to the staging schema alongside changed MVs,
476    // then moved into the production schema during promote via `SET SCHEMA` instead of
477    // relying on schema swap. This would eliminate the need for mixed deployment kinds
478    // or special-casing in `partition_objects` — new objects simply deploy to staging
479    // and get relocated on promote, just like changed objects get swapped.
480    //
481    // A brand-new stable schema (no prior production objects) deploys fine via normal
482    // blue-green swap — only schemas with existing production objects are affected.
483    if let Some(ref cs) = change_set {
484        validate_no_new_objects_in_existing_stable_schemas(cs, &production_snapshot)?;
485    }
486
487    let objects = select_stage_objects(planned_project, change_set.as_ref())?;
488    if objects.is_empty() && change_set.as_ref().is_some_and(ChangeSet::is_empty) {
489        progress::success("No changes detected compared to production, skipping deployment");
490        return Ok(None);
491    }
492
493    let replacement_object_ids = change_set
494        .as_ref()
495        .map(|cs| cs.changed_replacement_objects.clone())
496        .unwrap_or_default();
497    let partitioned = partition_objects(objects, &replacement_object_ids);
498    log_partition_summary(&partitioned);
499
500    let object_ids: BTreeSet<_> = partitioned
501        .objects
502        .iter()
503        .map(|(id, _)| id.clone())
504        .collect();
505    client
506        .validation()
507        .validate_table_dependencies(planned_project, &object_ids)
508        .await?;
509
510    let (schema_set, cluster_set) =
511        collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
512
513    let analyze_duration = analyze_start.elapsed();
514    progress::stage_success(
515        &format!(
516            "Ready to deploy {} view(s)/materialized view(s)",
517            partitioned.objects.len()
518        ),
519        analyze_duration,
520    );
521
522    Ok(Some(StageAnalysis {
523        objects: partitioned.objects,
524        sinks: partitioned.sinks,
525        replacement_mvs: partitioned.replacement_mvs,
526        schema_set,
527        cluster_set,
528    }))
529}
530
531/// Chooses the initial object set for stage before stage-specific partitioning.
532///
533/// Incremental mode uses the change set; full mode uses all sorted project objects.
534fn select_stage_objects<'a>(
535    planned_project: &'a Project,
536    change_set: Option<&ChangeSet>,
537) -> Result<Vec<ObjectRef<'a>>, CliError> {
538    if let Some(cs) = change_set {
539        if cs.is_empty() {
540            return Ok(Vec::new());
541        }
542        verbose!("{}", cs);
543        Ok(planned_project.get_sorted_objects_filtered(&cs.objects_to_deploy)?)
544    } else {
545        verbose!("Full deployment: no production deployment found");
546        Ok(planned_project.get_sorted_objects()?)
547    }
548}
549
550/// Splits objects into stage execution categories.
551///
552/// Tables/sources are excluded, sinks are deferred to apply, and changed replacement MVs
553/// are tracked for special replacement handling.
554fn partition_objects<'a>(
555    objects: Vec<ObjectRef<'a>>,
556    replacement_object_ids: &BTreeSet<ObjectId>,
557) -> PartitionedObjects<'a> {
558    let mut kept = Vec::new();
559    let mut sinks = Vec::new();
560    let mut replacement_mvs = Vec::new();
561    let mut table_count = 0;
562
563    for (object_id, typed_obj) in objects {
564        match &typed_obj.stmt {
565            Statement::CreateTable(_)
566            | Statement::CreateTableFromSource(_)
567            | Statement::CreateSource(_)
568            | Statement::CreateSecret(_)
569            | Statement::CreateConnection(_) => {
570                table_count += 1;
571            }
572            Statement::CreateSink(_) => sinks.push((object_id, typed_obj)),
573            Statement::CreateMaterializedView(_) if replacement_object_ids.contains(&object_id) => {
574                replacement_mvs.push((object_id, typed_obj));
575            }
576            _ => kept.push((object_id, typed_obj)),
577        }
578    }
579
580    PartitionedObjects {
581        objects: kept,
582        sinks,
583        replacement_mvs,
584        table_count,
585    }
586}
587
588/// Reports the partitioning decisions visible to users in verbose mode.
589fn log_partition_summary(partitioned: &PartitionedObjects<'_>) {
590    if partitioned.table_count > 0 {
591        verbose!(
592            "Skipped {} table(s)/source(s) - use 'mz-deploy apply' for those",
593            partitioned.table_count
594        );
595    }
596    if !partitioned.sinks.is_empty() {
597        verbose!(
598            "Found {} sink(s) - will be created during apply after swap",
599            partitioned.sinks.len()
600        );
601    }
602    if !partitioned.replacement_mvs.is_empty() {
603        verbose!(
604            "Found {} replacement MV(s) - will use CREATE REPLACEMENT protocol",
605            partitioned.replacement_mvs.len()
606        );
607    }
608}
609
610/// Derives schema/cluster prerequisites for resource creation.
611///
612/// Builds schema and cluster sets solely from the objects being staged.
613/// Apply-managed objects (sources, tables, secrets, connections) are excluded
614/// by `partition_objects`, so their schemas and clusters are never staged.
615fn collect_stage_resources(
616    objects: &[ObjectRef<'_>],
617    replacement_mvs: &[ObjectRef<'_>],
618) -> (BTreeSet<SchemaQualifier>, BTreeSet<String>) {
619    let mut schema_set = BTreeSet::new();
620    let mut cluster_set = BTreeSet::new();
621
622    for (object_id, typed_obj) in objects.iter().chain(replacement_mvs.iter()) {
623        schema_set.insert(SchemaQualifier::new(
624            object_id.expect_database().to_string(),
625            object_id.schema().to_string(),
626        ));
627        cluster_set.extend(typed_obj.clusters());
628    }
629
630    (schema_set, cluster_set)
631}
632
633/// Runs all preflight database validations required before mutating deployment state.
634///
635/// This is intentionally isolated so stage fails before any metadata/resource writes.
636async fn validate_project_for_stage(
637    client: &Client,
638    planned_project: &Project,
639    directory: &Path,
640    schema_set: &BTreeSet<SchemaQualifier>,
641    cluster_set: &BTreeSet<String>,
642) -> Result<(), CliError> {
643    progress::stage_start("Validating project");
644    let validate_start = Instant::now();
645    client
646        .validation()
647        .validate_project(planned_project, directory)
648        .await?;
649    client
650        .validation()
651        .validate_cluster_isolation(planned_project)
652        .await?;
653    client
654        .validation()
655        .validate_privileges(planned_project)
656        .await?;
657    client
658        .validation()
659        .validate_schema_ownership(schema_set)
660        .await?;
661    client
662        .validation()
663        .validate_cluster_ownership(cluster_set)
664        .await?;
665    client
666        .validation()
667        .validate_sink_connections_exist(planned_project)
668        .await?;
669    let validate_duration = validate_start.elapsed();
670    progress::stage_success("All validations passed", validate_duration);
671    Ok(())
672}
673
674/// Persists stage deployment state and deferred apply actions.
675///
676/// Records object hashes plus schema deployment kinds, then stores sink/replacement
677/// records that the `apply` command consumes after swap.
678async fn record_stage_metadata(
679    client: &Client,
680    directory: &Path,
681    stage_name: &str,
682    staging_suffix: &str,
683    objects: &[ObjectRef<'_>],
684    sinks: &[ObjectRef<'_>],
685    replacement_mvs: &[ObjectRef<'_>],
686    replacement_schemas: &BTreeSet<SchemaQualifier>,
687) -> Result<(), CliError> {
688    progress::stage_start("Recording deployment metadata");
689    let metadata_start = Instant::now();
690    let metadata = executor::collect_deployment_metadata(client, directory).await;
691
692    let mut staging_snapshot = DeploymentSnapshot::default();
693
694    for (object_id, typed_obj) in objects {
695        let hash = deployment_snapshot::compute_typed_hash(typed_obj);
696        staging_snapshot.objects.insert(object_id.clone(), hash);
697        staging_snapshot.schemas.insert(
698            SchemaQualifier::new(
699                object_id.expect_database().to_string(),
700                object_id.schema().to_string(),
701            ),
702            DeploymentKind::Objects,
703        );
704    }
705
706    for (object_id, typed_obj) in sinks {
707        let hash = deployment_snapshot::compute_typed_hash(typed_obj);
708        staging_snapshot.objects.insert(object_id.clone(), hash);
709        staging_snapshot
710            .schemas
711            .entry(SchemaQualifier::new(
712                object_id.expect_database().to_string(),
713                object_id.schema().to_string(),
714            ))
715            .or_insert(DeploymentKind::Sinks);
716    }
717
718    for (object_id, typed_obj) in replacement_mvs {
719        let hash = deployment_snapshot::compute_typed_hash(typed_obj);
720        staging_snapshot.objects.insert(object_id.clone(), hash);
721        staging_snapshot.schemas.insert(
722            SchemaQualifier::new(
723                object_id.expect_database().to_string(),
724                object_id.schema().to_string(),
725            ),
726            DeploymentKind::Replacement,
727        );
728    }
729
730    // Ensure replacement schemas record the correct kind.
731    // During Objects→Replacement transitions, MVs go through the regular objects
732    // path (for blue-green swap), but the metadata must reflect the final kind
733    // so future deploys know to use CREATE REPLACEMENT.
734    for sq in replacement_schemas {
735        if staging_snapshot.schemas.contains_key(sq) {
736            staging_snapshot
737                .schemas
738                .insert(sq.clone(), DeploymentKind::Replacement);
739        }
740    }
741
742    deployment_snapshot::write_to_database(
743        client,
744        &staging_snapshot,
745        stage_name,
746        &metadata,
747        None,
748        DeploymentMode::Stage,
749    )
750    .await?;
751
752    if !sinks.is_empty() {
753        let pending_statements: Vec<PendingStatement> = sinks
754            .iter()
755            .enumerate()
756            .map(|(idx, (object_id, typed_obj))| {
757                let original_fqn: FullyQualifiedName = object_id.clone().into();
758                let mut visitor = NormalizingVisitor::fully_qualifying(&original_fqn);
759                let stmt = typed_obj
760                    .stmt
761                    .clone()
762                    .normalize_name_with(&visitor, &original_fqn.to_item_name())
763                    .normalize_dependencies_with(&mut visitor);
764                let hash = deployment_snapshot::compute_typed_hash(typed_obj);
765                #[allow(clippy::as_conversions)]
766                PendingStatement {
767                    deploy_id: stage_name.to_string(),
768                    sequence_num: idx as i32,
769                    database: object_id.expect_database().to_string(),
770                    schema: object_id.schema().to_string(),
771                    object: object_id.object().to_string(),
772                    object_hash: hash,
773                    statement_sql: stmt.to_string(),
774                    statement_kind: "sink".to_string(),
775                    executed_at: None,
776                }
777            })
778            .collect();
779
780        client
781            .deployments()
782            .insert_pending_statements(&pending_statements)
783            .await?;
784        verbose!(
785            "Stored {} pending sink statement(s)",
786            pending_statements.len()
787        );
788    }
789
790    if !replacement_mvs.is_empty() {
791        let records: Vec<ReplacementMvRecord> = replacement_mvs
792            .iter()
793            .map(|(object_id, _)| ReplacementMvRecord {
794                deploy_id: stage_name.to_string(),
795                target_database: object_id.expect_database().to_string(),
796                target_schema: object_id.schema().to_string(),
797                target_name: object_id.object().to_string(),
798                replacement_schema: format!("{}{}", object_id.schema(), staging_suffix),
799            })
800            .collect();
801        client
802            .deployments()
803            .insert_replacement_mvs(&records)
804            .await?;
805        verbose!("Stored {} replacement MV record(s)", records.len());
806    }
807
808    let metadata_duration = metadata_start.elapsed();
809    progress::stage_success("Deployment metadata recorded", metadata_duration);
810    Ok(())
811}
812
813/// Top-level orchestrator for the staging deployment pipeline.
814///
815/// Provisions all databases, schemas, clusters, and objects needed for a blue-green
816/// deployment. On failure, automatically rolls back every resource created during
817/// this invocation unless the `no_rollback` flag is set.
818#[allow(clippy::too_many_arguments)]
819async fn create_resources_with_rollback<'a>(
820    client: &Client,
821    stage_name: &str,
822    staging_suffix: &str,
823    schema_set: &BTreeSet<SchemaQualifier>,
824    cluster_set: &BTreeSet<String>,
825    planned_project: &'a Project,
826    objects: &'a [(ObjectId, &'a DatabaseObject)],
827    replacement_mvs: &'a [(ObjectId, &'a DatabaseObject)],
828    no_rollback: bool,
829    dry_run: bool,
830) -> Result<usize, CliError> {
831    let executor = DeploymentExecutor::with_dry_run(client, dry_run);
832
833    let result = async {
834        create_databases_and_schemas(&executor, planned_project, schema_set, staging_suffix)
835            .await?;
836        create_staging_clusters(&executor, client, stage_name, cluster_set, staging_suffix).await?;
837        deploy_objects_to_staging(
838            &executor,
839            objects,
840            replacement_mvs,
841            planned_project,
842            cluster_set,
843            staging_suffix,
844        )
845        .await
846    }
847    .await;
848
849    match result {
850        Ok(count) => Ok(count),
851        Err(e) if dry_run || no_rollback => {
852            if !dry_run {
853                progress::error("Deployment failed (skipping rollback due to --no-rollback flag)");
854            }
855            Err(e)
856        }
857        Err(e) => {
858            progress::error("Deployment failed, rolling back...");
859            let (schemas, clusters) = rollback_staging_resources(client, stage_name).await;
860
861            if schemas > 0 || clusters > 0 {
862                progress::success(&format!(
863                    "Rolled back: {} schema(s), {} cluster(s)",
864                    schemas, clusters
865                ));
866            }
867
868            Err(e)
869        }
870    }
871}
872
873/// Provision all database and schema infrastructure required for a staged deployment.
874///
875/// After this completes, both the suffixed staging schemas (where new objects will be
876/// created) and the production schemas (swap targets) are guaranteed to exist.
877async fn create_databases_and_schemas(
878    executor: &DeploymentExecutor<'_>,
879    planned_project: &Project,
880    schema_set: &BTreeSet<SchemaQualifier>,
881    staging_suffix: &str,
882) -> Result<(), CliError> {
883    // Create project databases that aren't in schema_set
884    // (schema_set databases will be created by prepare_databases_and_schemas)
885    let schema_set_dbs: BTreeSet<&str> = schema_set.iter().map(|sq| sq.database.as_str()).collect();
886    for db in &planned_project.databases {
887        if !schema_set_dbs.contains(db.name.as_str()) {
888            executor.ensure_database(&db.name).await?;
889            verbose!("  Ensured database {} exists", db.name);
890        }
891    }
892
893    // Create staging schemas + apply mod_statements
894    progress::stage_start("Creating staging schemas and applying setup statements");
895    let schema_start = Instant::now();
896    executor
897        .prepare_databases_and_schemas(planned_project, schema_set, Some(staging_suffix))
898        .await?;
899    let schema_duration = schema_start.elapsed();
900    progress::stage_success(
901        &format!(
902            "Created {} staging schema(s) with setup statements",
903            schema_set.len()
904        ),
905        schema_duration,
906    );
907
908    // Create production schemas for swap
909    if !executor.is_dry_run() {
910        for sq in schema_set {
911            executor.ensure_schema(&sq.database, &sq.schema).await?;
912            verbose!("  Ensured schema {}.{} exists", sq.database, sq.schema);
913        }
914    }
915
916    Ok(())
917}
918
919/// Provision staging clusters that mirror the size and configuration of their
920/// production counterparts.
921///
922/// Clusters that already exist are skipped. Cluster names are recorded for rollback
923/// tracking before any cluster is created, so partial failures can be cleaned up.
924async fn create_staging_clusters(
925    executor: &DeploymentExecutor<'_>,
926    client: &Client,
927    stage_name: &str,
928    cluster_set: &BTreeSet<String>,
929    staging_suffix: &str,
930) -> Result<(), CliError> {
931    // Write cluster mappings BEFORE creating clusters so abort can clean up on failure
932    let cluster_names: Vec<String> = cluster_set.iter().cloned().collect();
933    executor
934        .record_deployment_clusters(stage_name, &cluster_names)
935        .await?;
936
937    progress::stage_start("Creating staging clusters");
938    let cluster_start = Instant::now();
939    let mut created_clusters = 0;
940
941    // Batch check which staging clusters already exist (skip in dry-run mode)
942    let existing_staging_clusters = if !executor.is_dry_run() {
943        let staging_cluster_names: Vec<String> = cluster_set
944            .iter()
945            .map(|name| format!("{}{}", name, staging_suffix))
946            .collect();
947        client
948            .introspection()
949            .check_clusters_exist(&staging_cluster_names)
950            .await?
951    } else {
952        BTreeSet::new()
953    };
954
955    for prod_cluster in cluster_set {
956        let staging_cluster = format!("{}{}", prod_cluster, staging_suffix);
957
958        if executor.is_dry_run() {
959            // Config is unused in dry-run mode; provide a placeholder.
960            let placeholder = ClusterConfig::Managed {
961                options: ClusterOptions {
962                    size: String::new(),
963                    replication_factor: 1,
964                    auto_scaling_strategy: None,
965                },
966                grants: Vec::new(),
967            };
968            executor
969                .create_cluster(&staging_cluster, prod_cluster, &placeholder)
970                .await?;
971            created_clusters += 1;
972            continue;
973        }
974
975        // Check if staging cluster already exists using batch result
976        if existing_staging_clusters.contains(&staging_cluster) {
977            verbose!("  Cluster '{}' already exists, skipping", staging_cluster);
978            continue;
979        }
980
981        // Get production cluster configuration (handles both managed and unmanaged)
982        let config = client
983            .introspection()
984            .get_cluster_config(prod_cluster)
985            .await?;
986
987        let config = match config {
988            Some(config) => config,
989            None => {
990                return Err(CliError::ClusterNotFound {
991                    name: prod_cluster.clone(),
992                });
993            }
994        };
995
996        executor
997            .create_cluster(&staging_cluster, prod_cluster, &config)
998            .await?;
999        created_clusters += 1;
1000
1001        log_cluster_creation(&staging_cluster, prod_cluster, &config);
1002    }
1003
1004    let cluster_duration = cluster_start.elapsed();
1005    progress::stage_success(
1006        &format!("Created {} cluster(s)", created_clusters),
1007        cluster_duration,
1008    );
1009
1010    Ok(())
1011}
1012
1013/// Log verbose details about a newly created staging cluster.
1014fn log_cluster_creation(staging_cluster: &str, prod_cluster: &str, config: &ClusterConfig) {
1015    match config {
1016        ClusterConfig::Managed { options, grants } => {
1017            verbose!(
1018                "  Created managed cluster '{}' (size: {}, replication_factor: {}{}, {} grant(s), cloned from '{}')",
1019                staging_cluster,
1020                options.size,
1021                options.replication_factor,
1022                if options.auto_scaling_strategy.is_some() {
1023                    ", autoscaling policy copied"
1024                } else {
1025                    ""
1026                },
1027                grants.len(),
1028                prod_cluster
1029            );
1030        }
1031        ClusterConfig::Unmanaged { replicas, grants } => {
1032            verbose!(
1033                "  Created unmanaged cluster '{}' with {} replica(s), {} grant(s) (cloned from '{}')",
1034                staging_cluster,
1035                replicas.len(),
1036                grants.len(),
1037                prod_cluster
1038            );
1039            for replica in replicas {
1040                verbose!(
1041                    "    - {} (size: {}{})",
1042                    replica.name,
1043                    replica.size,
1044                    replica
1045                        .availability_zone
1046                        .as_ref()
1047                        .map(|az| format!(", az: {}", az))
1048                        .unwrap_or_default()
1049                );
1050            }
1051        }
1052    }
1053}
1054
1055/// Execute all object definitions (views, materialized views, indexes) into the
1056/// staging schemas.
1057///
1058/// Regular objects are created with suffixed names; replacement materialized views
1059/// are linked to their production targets via `CREATE REPLACEMENT MATERIALIZED VIEW
1060/// ... FOR`. Returns the total number of successfully deployed objects.
1061async fn deploy_objects_to_staging<'a>(
1062    executor: &DeploymentExecutor<'_>,
1063    objects: &'a [(ObjectId, &'a DatabaseObject)],
1064    replacement_mvs: &'a [(ObjectId, &'a DatabaseObject)],
1065    planned_project: &'a Project,
1066    cluster_set: &BTreeSet<String>,
1067    staging_suffix: &str,
1068) -> Result<usize, CliError> {
1069    progress::stage_start("Deploying objects to staging");
1070    let deploy_start = Instant::now();
1071
1072    // Collect ObjectIds from objects being deployed for the staging transformer
1073    // Include both regular objects and replacement MVs
1074    let objects_to_deploy_set: BTreeSet<_> = objects
1075        .iter()
1076        .chain(replacement_mvs.iter())
1077        .map(|(oid, _)| oid.clone())
1078        .collect();
1079
1080    // Deploy external indexes
1081    let mut external_indexes: Vec<_> = planned_project
1082        .iter_objects()
1083        .filter(|object| !objects_to_deploy_set.contains(&object.id))
1084        .flat_map(extract_external_indexes)
1085        .filter_map(|(cluster, index)| cluster_set.contains(&cluster.name).then_some(index))
1086        .collect();
1087
1088    // Transform cluster names in external indexes for staging
1089    normalize::transform_cluster_names_for_staging(&mut external_indexes, staging_suffix);
1090    for index in external_indexes {
1091        verbose!("Creating external index {}", index);
1092        executor.execute_sql(&index).await?;
1093    }
1094
1095    // Build the set of replacement object IDs from the replacement MVs slice.
1096    // Only these specific objects have their references left unsuffixed.
1097    let replacement_object_ids: BTreeSet<ObjectId> =
1098        replacement_mvs.iter().map(|(oid, _)| oid.clone()).collect();
1099
1100    let mut success_count = 0;
1101
1102    // Deploy regular objects
1103    for (idx, (object_id, typed_obj)) in objects.iter().enumerate() {
1104        verbose!(
1105            "Applying {}/{}: {}{} (to schema {}{})",
1106            idx + 1,
1107            objects.len(),
1108            object_id.object(),
1109            staging_suffix,
1110            object_id.schema(),
1111            staging_suffix
1112        );
1113
1114        deploy_single_object(
1115            executor,
1116            object_id,
1117            typed_obj,
1118            staging_suffix,
1119            planned_project,
1120            &objects_to_deploy_set,
1121            &replacement_object_ids,
1122            |stmt| stmt,
1123        )
1124        .await?;
1125        success_count += 1;
1126    }
1127
1128    // Deploy replacement MVs using CREATE REPLACEMENT MATERIALIZED VIEW ... FOR
1129    for (idx, (object_id, typed_obj)) in replacement_mvs.iter().enumerate() {
1130        verbose!(
1131            "Applying replacement MV {}/{}: {} FOR {}",
1132            idx + 1,
1133            replacement_mvs.len(),
1134            object_id.object(),
1135            object_id
1136        );
1137
1138        let production_target = object_id.to_unresolved_item_name();
1139        deploy_single_object(
1140            executor,
1141            object_id,
1142            typed_obj,
1143            staging_suffix,
1144            planned_project,
1145            &objects_to_deploy_set,
1146            &replacement_object_ids,
1147            |stmt| match stmt {
1148                Statement::CreateMaterializedView(mut mv) => {
1149                    mv.replacement_for =
1150                        Some(mz_sql_parser::ast::RawItemName::Name(production_target));
1151                    Statement::CreateMaterializedView(mv)
1152                }
1153                other => other,
1154            },
1155        )
1156        .await?;
1157        success_count += 1;
1158    }
1159
1160    let deploy_duration = deploy_start.elapsed();
1161    progress::stage_success(
1162        &format!("Deployed {} view(s)/materialized view(s)", success_count),
1163        deploy_duration,
1164    );
1165
1166    Ok(success_count)
1167}
1168
1169/// Rollback staging resources on deployment failure.
1170///
1171/// This function performs best-effort cleanup of staging resources created during
1172/// a failed deployment. It mirrors the abort command logic but uses a best-effort
1173/// approach where cleanup failures are logged rather than returning errors.
1174///
1175/// # Arguments
1176/// * `client` - Database client
1177/// * `environment` - Staging environment name
1178///
1179/// # Returns
1180/// Number of schemas and clusters that were cleaned up (for summary message)
1181async fn rollback_staging_resources(client: &Client, environment: &str) -> (usize, usize) {
1182    let staging_schemas = best_effort_fetch(
1183        client
1184            .introspection()
1185            .get_staging_schemas(environment)
1186            .await,
1187        "query staging schemas",
1188    );
1189    let staging_clusters = best_effort_fetch(
1190        client
1191            .introspection()
1192            .get_staging_clusters(environment)
1193            .await,
1194        "query staging clusters",
1195    );
1196
1197    let schema_count = staging_schemas.len();
1198    let cluster_count = staging_clusters.len();
1199
1200    if !staging_schemas.is_empty() {
1201        verbose!("Dropping staging schemas...");
1202        if let Err(e) = client
1203            .introspection()
1204            .drop_staging_schemas(&staging_schemas)
1205            .await
1206        {
1207            verbose!("Warning: Failed to drop some schemas: {}", e);
1208        } else {
1209            for sq in &staging_schemas {
1210                verbose!("  Dropped {}.{}", sq.database, sq.schema);
1211            }
1212        }
1213    }
1214
1215    if !staging_clusters.is_empty() {
1216        verbose!("Dropping staging clusters...");
1217        if let Err(e) = client
1218            .introspection()
1219            .drop_staging_clusters(&staging_clusters)
1220            .await
1221        {
1222            verbose!("Warning: Failed to drop some clusters: {}", e);
1223        } else {
1224            for cluster in &staging_clusters {
1225                verbose!("  Dropped {}", cluster);
1226            }
1227        }
1228    }
1229
1230    verbose!("Deleting deployment records...");
1231    best_effort_delete(
1232        client
1233            .deployments()
1234            .delete_deployment_clusters(environment)
1235            .await,
1236        "delete cluster records",
1237    );
1238    best_effort_delete(
1239        client
1240            .deployments()
1241            .delete_pending_statements(environment)
1242            .await,
1243        "delete pending statements",
1244    );
1245    best_effort_delete(
1246        client
1247            .deployments()
1248            .delete_replacement_mvs(environment)
1249            .await,
1250        "delete replacement MV records",
1251    );
1252    best_effort_delete(
1253        client.deployments().delete_deployment(environment).await,
1254        "delete deployment records",
1255    );
1256
1257    (schema_count, cluster_count)
1258}
1259
1260/// Best-effort fetch wrapper used by rollback.
1261///
1262/// Converts query failures into empty results so cleanup can continue and report
1263/// as much progress as possible instead of aborting midway.
1264fn best_effort_fetch<T, E: fmt::Display>(result: Result<Vec<T>, E>, action: &str) -> Vec<T> {
1265    match result {
1266        Ok(values) => values,
1267        Err(e) => {
1268            verbose!("Warning: Failed to {}: {}", action, e);
1269            vec![]
1270        }
1271    }
1272}
1273
1274/// Best-effort delete wrapper used by rollback metadata cleanup.
1275fn best_effort_delete<E: fmt::Display>(result: Result<(), E>, action: &str) {
1276    if let Err(e) = result {
1277        verbose!("Warning: Failed to {}: {}", action, e);
1278    }
1279}
1280
1281/// Deploy a single object to the staging environment.
1282///
1283/// Handles normalization, execution, and deployment of indexes/grants/comments.
1284/// The `transform` callback allows the caller to modify the normalized statement
1285/// before execution (e.g., to set `replacement_for` on replacement MVs).
1286///
1287/// `replacement_objects` is the set of specific object IDs being updated
1288/// in-place via replacement MVs. References to these objects are left
1289/// unsuffixed (pointing to production). During full deployment the set is
1290/// empty, so every reference is suffixed to point at the staging schemas.
1291async fn deploy_single_object(
1292    executor: &DeploymentExecutor<'_>,
1293    object_id: &ObjectId,
1294    typed_obj: &DatabaseObject,
1295    staging_suffix: &str,
1296    planned_project: &Project,
1297    objects_to_deploy_set: &BTreeSet<ObjectId>,
1298    replacement_objects: &BTreeSet<ObjectId>,
1299    transform: impl FnOnce(Statement) -> Statement,
1300) -> Result<(), CliError> {
1301    let original_fqn: FullyQualifiedName = object_id.clone().into();
1302
1303    let mut visitor = NormalizingVisitor::staging(
1304        &original_fqn,
1305        staging_suffix.to_string(),
1306        &planned_project.external_dependencies,
1307        Some(objects_to_deploy_set),
1308        replacement_objects,
1309    );
1310
1311    let stmt = typed_obj
1312        .stmt
1313        .clone()
1314        .normalize_name_with(&visitor, &original_fqn.to_item_name())
1315        .normalize_dependencies_with(&mut visitor)
1316        .normalize_cluster_with(&visitor);
1317
1318    let stmt = transform(stmt);
1319    executor.execute_sql(&stmt).await?;
1320
1321    // Deploy indexes, grants, and comments
1322    let mut indexes = typed_obj.indexes.clone();
1323    let mut grants = typed_obj.grants.clone();
1324    let mut comments = typed_obj.comments.clone();
1325
1326    visitor.normalize_index_references(&mut indexes);
1327    visitor.normalize_index_clusters(&mut indexes);
1328    visitor.normalize_grant_references(&mut grants);
1329    visitor.normalize_comment_references(&mut comments);
1330
1331    for index in &indexes {
1332        executor.execute_sql(index).await?;
1333    }
1334
1335    for grant in &grants {
1336        executor.execute_sql(grant).await?;
1337    }
1338
1339    for comment in &comments {
1340        executor.execute_sql(comment).await?;
1341    }
1342
1343    Ok(())
1344}
1345
1346/// Check that no new replacement objects are being added to schemas that already
1347/// have production objects.
1348fn validate_no_new_objects_in_existing_stable_schemas(
1349    change_set: &ChangeSet,
1350    production_snapshot: &DeploymentSnapshot,
1351) -> Result<(), CliError> {
1352    let blocked: Vec<_> = change_set
1353        .new_replacement_objects
1354        .iter()
1355        .filter(|obj| {
1356            !production_snapshot.objects.contains_key(obj)
1357                && production_snapshot
1358                    .objects
1359                    .keys()
1360                    .any(|prod| prod.database() == obj.database() && prod.schema() == obj.schema())
1361        })
1362        .collect();
1363
1364    if blocked.is_empty() {
1365        return Ok(());
1366    }
1367
1368    let first = blocked[0];
1369    Err(CliError::NewObjectInExistingStableSchema {
1370        database: first.expect_database().to_string(),
1371        schema: first.schema().to_string(),
1372        objects: blocked.iter().map(|o| o.object().to_string()).collect(),
1373    })
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378    use super::*;
1379    use crate::project::analysis::deployment_snapshot::build_snapshot_from_planned;
1380    use crate::project::ir::compiled;
1381    use crate::project::ir::object_id::ObjectId;
1382    use std::collections::{BTreeMap, BTreeSet};
1383
1384    #[mz_ore::test]
1385    fn parse_qualified_schema_requires_two_parts() {
1386        // Fully qualified parses to (database, schema).
1387        let sq = parse_qualified_schema("app.core").expect("qualified name parses");
1388        assert_eq!(
1389            sq,
1390            SchemaQualifier::new("app".to_string(), "core".to_string())
1391        );
1392
1393        // A reserved-word component is handled via the SQL parser.
1394        let sq = parse_qualified_schema("app.\"select\"").expect("quoted keyword parses");
1395        assert_eq!(
1396            sq,
1397            SchemaQualifier::new("app".to_string(), "select".to_string())
1398        );
1399
1400        // Unqualified (1-part) and over-qualified (3-part) are rejected.
1401        assert!(parse_qualified_schema("core").is_err());
1402        assert!(parse_qualified_schema("app.core.orders").is_err());
1403    }
1404
1405    /// Parse SQL strings into a compiled::DatabaseObject.
1406    ///
1407    /// The first CREATE statement becomes the main statement.
1408    /// Any CREATE INDEX statements become entries in the indexes vec.
1409    fn make_typed_object(sqls: &[&str]) -> DatabaseObject {
1410        let mut stmt = None;
1411        let mut indexes = Vec::new();
1412
1413        for sql in sqls {
1414            let parsed = mz_sql_parser::parser::parse_statements(sql).unwrap();
1415            for p in parsed {
1416                match p.ast {
1417                    mz_sql_parser::ast::Statement::CreateView(s) => {
1418                        stmt = Some(Statement::CreateView(s));
1419                    }
1420                    mz_sql_parser::ast::Statement::CreateMaterializedView(s) => {
1421                        stmt = Some(Statement::CreateMaterializedView(s));
1422                    }
1423                    mz_sql_parser::ast::Statement::CreateTable(s) => {
1424                        stmt = Some(Statement::CreateTable(s));
1425                    }
1426                    mz_sql_parser::ast::Statement::CreateSource(s) => {
1427                        stmt = Some(Statement::CreateSource(s));
1428                    }
1429                    mz_sql_parser::ast::Statement::CreateConnection(s) => {
1430                        stmt = Some(Statement::CreateConnection(s));
1431                    }
1432                    mz_sql_parser::ast::Statement::CreateSecret(s) => {
1433                        stmt = Some(Statement::CreateSecret(s));
1434                    }
1435                    mz_sql_parser::ast::Statement::CreateIndex(s) => {
1436                        indexes.push(s);
1437                    }
1438                    other => panic!("Unexpected statement type: {:?}", other),
1439                }
1440            }
1441        }
1442
1443        DatabaseObject {
1444            path: std::path::PathBuf::from("test.sql"),
1445            stmt: stmt.expect("Expected at least one CREATE statement"),
1446            indexes,
1447            grants: vec![],
1448            comments: vec![],
1449            tests: vec![],
1450        }
1451    }
1452
1453    /// Build a graph::Project from a list of (database, schema, object_name, typed_obj) tuples.
1454    fn make_planned_project(objects: Vec<(&str, &str, &str, DatabaseObject)>) -> Project {
1455        // Group into databases -> schemas -> objects
1456        let mut db_map: BTreeMap<String, BTreeMap<String, Vec<DatabaseObject>>> = BTreeMap::new();
1457
1458        for (database, schema, _name, typed_obj) in objects {
1459            db_map
1460                .entry(database.to_string())
1461                .or_default()
1462                .entry(schema.to_string())
1463                .or_default()
1464                .push(typed_obj);
1465        }
1466
1467        let databases: Vec<compiled::Database> = db_map
1468            .into_iter()
1469            .map(|(db_name, schemas)| compiled::Database {
1470                name: db_name,
1471                schemas: schemas
1472                    .into_iter()
1473                    .map(|(schema_name, objs)| compiled::Schema {
1474                        name: schema_name,
1475                        objects: objs,
1476                        mod_statements: None,
1477                    })
1478                    .collect(),
1479                mod_statements: None,
1480            })
1481            .collect();
1482
1483        let typed_project = compiled::Project {
1484            databases,
1485            replacement_schemas: BTreeSet::new(),
1486        };
1487
1488        Project::from(typed_project)
1489    }
1490
1491    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1492    #[mz_ore::test]
1493    fn test_full_deploy_view_not_indexed_mixed_types() {
1494        let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
1495        let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1496        let source_obj = make_typed_object(&[
1497            "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1498        ]);
1499        let conn_obj =
1500            make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1501        let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1502
1503        let objects: Vec<ObjectRef> = vec![
1504            (
1505                ObjectId::new("db".into(), "public".into(), "my_view".into()),
1506                &view_obj,
1507            ),
1508            (
1509                ObjectId::new("db".into(), "public".into(), "my_table".into()),
1510                &table_obj,
1511            ),
1512            (
1513                ObjectId::new("db".into(), "public".into(), "my_source".into()),
1514                &source_obj,
1515            ),
1516            (
1517                ObjectId::new("db".into(), "public".into(), "my_conn".into()),
1518                &conn_obj,
1519            ),
1520            (
1521                ObjectId::new("db".into(), "public".into(), "my_secret".into()),
1522                &secret_obj,
1523            ),
1524        ];
1525
1526        let replacement_ids = BTreeSet::new();
1527        let partitioned = partition_objects(objects, &replacement_ids);
1528
1529        // Only the view should be in staged objects
1530        assert_eq!(
1531            partitioned.objects.len(),
1532            1,
1533            "Only the view should be staged"
1534        );
1535        assert_eq!(partitioned.objects[0].0.object(), "my_view");
1536
1537        // Table, source, connection, secret should be counted as skipped
1538        assert_eq!(
1539            partitioned.table_count, 4,
1540            "Table, source, connection, and secret should all be skipped"
1541        );
1542
1543        // No sinks or replacement MVs
1544        assert!(partitioned.sinks.is_empty());
1545        assert!(partitioned.replacement_mvs.is_empty());
1546
1547        // Collect stage resources
1548        let (schema_set, cluster_set) =
1549            collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1550
1551        // Should have the view's schema
1552        assert_eq!(schema_set.len(), 1);
1553        assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1554
1555        // View has no cluster, so cluster_set should be empty
1556        assert!(
1557            cluster_set.is_empty(),
1558            "View without index should not require any clusters"
1559        );
1560    }
1561
1562    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1563    #[mz_ore::test]
1564    fn test_full_deploy_view_indexed_different_cluster() {
1565        let view_obj = make_typed_object(&[
1566            "CREATE VIEW my_view AS SELECT 1",
1567            "CREATE INDEX my_idx IN CLUSTER index_cluster ON my_view (column1)",
1568        ]);
1569        let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1570        let source_obj = make_typed_object(&[
1571            "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1572        ]);
1573        let conn_obj =
1574            make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1575        let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1576
1577        let objects: Vec<ObjectRef> = vec![
1578            (
1579                ObjectId::new("db".into(), "public".into(), "my_view".into()),
1580                &view_obj,
1581            ),
1582            (
1583                ObjectId::new("db".into(), "public".into(), "my_table".into()),
1584                &table_obj,
1585            ),
1586            (
1587                ObjectId::new("db".into(), "public".into(), "my_source".into()),
1588                &source_obj,
1589            ),
1590            (
1591                ObjectId::new("db".into(), "public".into(), "my_conn".into()),
1592                &conn_obj,
1593            ),
1594            (
1595                ObjectId::new("db".into(), "public".into(), "my_secret".into()),
1596                &secret_obj,
1597            ),
1598        ];
1599
1600        let replacement_ids = BTreeSet::new();
1601        let partitioned = partition_objects(objects, &replacement_ids);
1602
1603        // Only the view should be staged
1604        assert_eq!(partitioned.objects.len(), 1);
1605        assert_eq!(partitioned.objects[0].0.object(), "my_view");
1606        assert_eq!(partitioned.table_count, 4);
1607
1608        // Collect stage resources
1609        let (schema_set, cluster_set) =
1610            collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1611
1612        // Should have view's schema
1613        assert_eq!(schema_set.len(), 1);
1614        assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1615
1616        // Should stage index_cluster (from the view's index), NOT source_cluster
1617        assert_eq!(
1618            cluster_set.len(),
1619            1,
1620            "Should only have index_cluster, got: {:?}",
1621            cluster_set
1622        );
1623        assert!(
1624            cluster_set.contains("index_cluster"),
1625            "Should stage index_cluster from the view's index"
1626        );
1627        assert!(
1628            !cluster_set.contains("source_cluster"),
1629            "Should NOT stage source_cluster (source is not staged)"
1630        );
1631    }
1632
1633    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1634    #[mz_ore::test]
1635    fn test_incremental_deploy_view_updated_not_indexed() {
1636        // Build planned project with all object types
1637        let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
1638        let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1639        let source_obj = make_typed_object(&[
1640            "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1641        ]);
1642        let conn_obj =
1643            make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1644        let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1645
1646        let planned_project = make_planned_project(vec![
1647            ("db", "public", "my_view", view_obj),
1648            ("db", "storage", "my_table", table_obj),
1649            ("db", "storage", "my_source", source_obj),
1650            ("db", "storage", "my_conn", conn_obj),
1651            ("db", "storage", "my_secret", secret_obj),
1652        ]);
1653
1654        // Build new snapshot from planned project
1655        let new_snapshot = build_snapshot_from_planned(&planned_project).unwrap();
1656
1657        // Build old snapshot: same hashes for everything EXCEPT the view
1658        let mut old_snapshot = DeploymentSnapshot::default();
1659        for (object_id, hash) in &new_snapshot.objects {
1660            if object_id.object() == "my_view" {
1661                // Different hash to simulate the view having changed
1662                old_snapshot
1663                    .objects
1664                    .insert(object_id.clone(), "old_hash".to_string());
1665            } else {
1666                old_snapshot.objects.insert(object_id.clone(), hash.clone());
1667            }
1668        }
1669
1670        // Compute changeset
1671        let change_set = ChangeSet::from_deployment_snapshot_comparison(
1672            &old_snapshot,
1673            &new_snapshot,
1674            &planned_project,
1675            &BTreeSet::new(),
1676        );
1677
1678        // The view should be in objects_to_deploy
1679        assert!(
1680            change_set.objects_to_deploy.contains(&ObjectId::new(
1681                "db".into(),
1682                "public".into(),
1683                "my_view".into()
1684            )),
1685            "Changed view should be in objects_to_deploy"
1686        );
1687
1688        // Get filtered objects and partition
1689        let objects = planned_project
1690            .get_sorted_objects_filtered(&change_set.objects_to_deploy)
1691            .unwrap();
1692
1693        let partitioned = partition_objects(objects, &change_set.changed_replacement_objects);
1694
1695        // Only the view should be staged
1696        assert_eq!(
1697            partitioned.objects.len(),
1698            1,
1699            "Only the changed view should be staged, got: {:?}",
1700            partitioned
1701                .objects
1702                .iter()
1703                .map(|(id, _)| id.object())
1704                .collect::<Vec<_>>()
1705        );
1706        assert_eq!(partitioned.objects[0].0.object(), "my_view");
1707
1708        let (schema_set, cluster_set) =
1709            collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1710
1711        assert_eq!(schema_set.len(), 1);
1712        assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1713        assert!(
1714            cluster_set.is_empty(),
1715            "View without index should not require any clusters"
1716        );
1717    }
1718
1719    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1720    #[mz_ore::test]
1721    fn test_incremental_deploy_view_updated_indexed_different_cluster() {
1722        // Build planned project with indexed view and other object types
1723        let view_obj = make_typed_object(&[
1724            "CREATE VIEW my_view AS SELECT 1",
1725            "CREATE INDEX my_idx IN CLUSTER index_cluster ON my_view (column1)",
1726        ]);
1727        let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1728        let source_obj = make_typed_object(&[
1729            "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1730        ]);
1731        let conn_obj =
1732            make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1733        let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1734
1735        let planned_project = make_planned_project(vec![
1736            ("db", "public", "my_view", view_obj),
1737            ("db", "storage", "my_table", table_obj),
1738            ("db", "storage", "my_source", source_obj),
1739            ("db", "storage", "my_conn", conn_obj),
1740            ("db", "storage", "my_secret", secret_obj),
1741        ]);
1742
1743        // Build new snapshot from planned project
1744        let new_snapshot = build_snapshot_from_planned(&planned_project).unwrap();
1745
1746        // Build old snapshot: same hashes except the view
1747        let mut old_snapshot = DeploymentSnapshot::default();
1748        for (object_id, hash) in &new_snapshot.objects {
1749            if object_id.object() == "my_view" {
1750                old_snapshot
1751                    .objects
1752                    .insert(object_id.clone(), "old_hash".to_string());
1753            } else {
1754                old_snapshot.objects.insert(object_id.clone(), hash.clone());
1755            }
1756        }
1757
1758        // Compute changeset
1759        let change_set = ChangeSet::from_deployment_snapshot_comparison(
1760            &old_snapshot,
1761            &new_snapshot,
1762            &planned_project,
1763            &BTreeSet::new(),
1764        );
1765
1766        assert!(
1767            change_set.objects_to_deploy.contains(&ObjectId::new(
1768                "db".into(),
1769                "public".into(),
1770                "my_view".into()
1771            )),
1772            "Changed view should be in objects_to_deploy"
1773        );
1774
1775        // Get filtered objects and partition
1776        let objects = planned_project
1777            .get_sorted_objects_filtered(&change_set.objects_to_deploy)
1778            .unwrap();
1779
1780        let partitioned = partition_objects(objects, &change_set.changed_replacement_objects);
1781
1782        // Only the view should be staged
1783        assert_eq!(
1784            partitioned.objects.len(),
1785            1,
1786            "Only the changed view should be staged, got: {:?}",
1787            partitioned
1788                .objects
1789                .iter()
1790                .map(|(id, _)| id.object())
1791                .collect::<Vec<_>>()
1792        );
1793        assert_eq!(partitioned.objects[0].0.object(), "my_view");
1794
1795        let (schema_set, cluster_set) =
1796            collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1797
1798        assert_eq!(schema_set.len(), 1);
1799        assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1800
1801        // Should stage index_cluster only, NOT source_cluster
1802        assert_eq!(
1803            cluster_set.len(),
1804            1,
1805            "Should only have index_cluster, got: {:?}",
1806            cluster_set
1807        );
1808        assert!(
1809            cluster_set.contains("index_cluster"),
1810            "Should stage index_cluster from the view's index"
1811        );
1812        assert!(
1813            !cluster_set.contains("source_cluster"),
1814            "Should NOT stage source_cluster"
1815        );
1816    }
1817
1818    fn make_empty_change_set() -> ChangeSet {
1819        ChangeSet {
1820            changed_objects: BTreeSet::new(),
1821            dirty_schemas: BTreeSet::new(),
1822            dirty_clusters: BTreeSet::new(),
1823            objects_to_deploy: BTreeSet::new(),
1824            new_replacement_objects: BTreeSet::new(),
1825            changed_replacement_objects: BTreeSet::new(),
1826        }
1827    }
1828
1829    #[mz_ore::test]
1830    fn test_validate_no_new_replacement_objects_first_deploy() {
1831        let cs = make_empty_change_set();
1832        let snapshot = DeploymentSnapshot::default();
1833        assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1834    }
1835
1836    #[mz_ore::test]
1837    fn test_validate_new_replacement_objects_in_brand_new_schema() {
1838        let mut cs = make_empty_change_set();
1839        cs.new_replacement_objects.insert(ObjectId::new(
1840            "db".into(),
1841            "analytics".into(),
1842            "new_mv".into(),
1843        ));
1844
1845        // Production has objects in a *different* schema, not analytics
1846        let mut snapshot = DeploymentSnapshot::default();
1847        snapshot.objects.insert(
1848            ObjectId::new("db".into(), "public".into(), "existing_mv".into()),
1849            "hash1".into(),
1850        );
1851
1852        assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1853    }
1854
1855    #[mz_ore::test]
1856    fn test_validate_new_replacement_objects_in_existing_production_schema() {
1857        let mut cs = make_empty_change_set();
1858        cs.new_replacement_objects.insert(ObjectId::new(
1859            "db".into(),
1860            "analytics".into(),
1861            "new_mv".into(),
1862        ));
1863
1864        // Production already has objects in analytics
1865        let mut snapshot = DeploymentSnapshot::default();
1866        snapshot.objects.insert(
1867            ObjectId::new("db".into(), "analytics".into(), "existing_mv".into()),
1868            "hash1".into(),
1869        );
1870
1871        let result = validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot);
1872        assert!(result.is_err());
1873        match result.unwrap_err() {
1874            CliError::NewObjectInExistingStableSchema {
1875                database,
1876                schema,
1877                objects,
1878            } => {
1879                assert_eq!(database, "db");
1880                assert_eq!(schema, "analytics");
1881                assert_eq!(objects, vec!["new_mv"]);
1882            }
1883            other => panic!("Expected NewObjectInExistingStableSchema, got: {:?}", other),
1884        }
1885    }
1886
1887    #[mz_ore::test]
1888    fn test_validate_changed_replacement_objects_only() {
1889        let mut cs = make_empty_change_set();
1890        // Only changed objects, no new ones
1891        cs.changed_replacement_objects.insert(ObjectId::new(
1892            "db".into(),
1893            "analytics".into(),
1894            "changed_mv".into(),
1895        ));
1896
1897        let mut snapshot = DeploymentSnapshot::default();
1898        snapshot.objects.insert(
1899            ObjectId::new("db".into(), "analytics".into(), "changed_mv".into()),
1900            "hash1".into(),
1901        );
1902
1903        assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1904    }
1905
1906    #[mz_ore::test]
1907    fn test_validate_mixed_new_in_new_schema_changed_in_existing() {
1908        let mut cs = make_empty_change_set();
1909        // New object in a brand-new schema
1910        cs.new_replacement_objects.insert(ObjectId::new(
1911            "db".into(),
1912            "new_schema".into(),
1913            "new_mv".into(),
1914        ));
1915        // Changed object in an existing schema
1916        cs.changed_replacement_objects.insert(ObjectId::new(
1917            "db".into(),
1918            "existing_schema".into(),
1919            "changed_mv".into(),
1920        ));
1921
1922        // Production has objects only in existing_schema
1923        let mut snapshot = DeploymentSnapshot::default();
1924        snapshot.objects.insert(
1925            ObjectId::new("db".into(), "existing_schema".into(), "changed_mv".into()),
1926            "hash1".into(),
1927        );
1928
1929        // Should pass: the new object is in a schema with no production objects
1930        assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1931    }
1932
1933    #[mz_ore::test]
1934    fn test_validate_transitioning_objects_in_existing_schema_allowed() {
1935        let mut cs = make_empty_change_set();
1936        // Object transitioning from Objects→Replacement lands in new_replacement_objects
1937        cs.new_replacement_objects.insert(ObjectId::new(
1938            "db".into(),
1939            "analytics".into(),
1940            "existing_mv".into(),
1941        ));
1942
1943        // The same object already exists in production (it's transitioning, not new)
1944        let mut snapshot = DeploymentSnapshot::default();
1945        snapshot.objects.insert(
1946            ObjectId::new("db".into(), "analytics".into(), "existing_mv".into()),
1947            "hash1".into(),
1948        );
1949
1950        // Should pass: the object already exists in production, it's just changing schema kind
1951        assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1952    }
1953
1954    fn make_planned_project_with_replacement_schemas(
1955        objects: Vec<(&str, &str, &str, DatabaseObject)>,
1956        replacement_schemas: BTreeSet<SchemaQualifier>,
1957    ) -> Project {
1958        let mut db_map: BTreeMap<String, BTreeMap<String, Vec<DatabaseObject>>> = BTreeMap::new();
1959
1960        for (database, schema, _name, typed_obj) in objects {
1961            db_map
1962                .entry(database.to_string())
1963                .or_default()
1964                .entry(schema.to_string())
1965                .or_default()
1966                .push(typed_obj);
1967        }
1968
1969        let databases: Vec<compiled::Database> = db_map
1970            .into_iter()
1971            .map(|(db_name, schemas)| compiled::Database {
1972                name: db_name,
1973                schemas: schemas
1974                    .into_iter()
1975                    .map(|(schema_name, objs)| compiled::Schema {
1976                        name: schema_name,
1977                        objects: objs,
1978                        mod_statements: None,
1979                    })
1980                    .collect(),
1981                mod_statements: None,
1982            })
1983            .collect();
1984
1985        let typed_project = compiled::Project {
1986            databases,
1987            replacement_schemas,
1988        };
1989
1990        Project::from(typed_project)
1991    }
1992
1993    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1994    #[mz_ore::test]
1995    fn test_build_snapshot_replacement_schema_kind() {
1996        let mv_obj =
1997            make_typed_object(&["CREATE MATERIALIZED VIEW my_mv IN CLUSTER compute AS SELECT 1"]);
1998        let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
1999
2000        let mut replacement_schemas = BTreeSet::new();
2001        replacement_schemas.insert(SchemaQualifier::new("db".into(), "stable".into()));
2002
2003        let planned_project = make_planned_project_with_replacement_schemas(
2004            vec![
2005                ("db", "stable", "my_mv", mv_obj),
2006                ("db", "regular", "my_view", view_obj),
2007            ],
2008            replacement_schemas,
2009        );
2010
2011        let snapshot = build_snapshot_from_planned(&planned_project).unwrap();
2012
2013        // The stable schema should be Replacement
2014        assert_eq!(
2015            snapshot
2016                .schemas
2017                .get(&SchemaQualifier::new("db".into(), "stable".into())),
2018            Some(&DeploymentKind::Replacement),
2019            "Replacement schema should have Replacement kind in snapshot"
2020        );
2021
2022        // The regular schema should be Objects
2023        assert_eq!(
2024            snapshot
2025                .schemas
2026                .get(&SchemaQualifier::new("db".into(), "regular".into())),
2027            Some(&DeploymentKind::Objects),
2028            "Regular schema should have Objects kind in snapshot"
2029        );
2030    }
2031
2032    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2033    #[mz_ore::test]
2034    fn test_build_snapshot_no_replacement_schemas_all_objects() {
2035        let mv_obj =
2036            make_typed_object(&["CREATE MATERIALIZED VIEW my_mv IN CLUSTER compute AS SELECT 1"]);
2037        let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
2038
2039        let planned_project = make_planned_project(vec![
2040            ("db", "stable", "my_mv", mv_obj),
2041            ("db", "regular", "my_view", view_obj),
2042        ]);
2043
2044        let snapshot = build_snapshot_from_planned(&planned_project).unwrap();
2045
2046        // Both should be Objects when no replacement_schemas configured
2047        assert_eq!(
2048            snapshot
2049                .schemas
2050                .get(&SchemaQualifier::new("db".into(), "stable".into())),
2051            Some(&DeploymentKind::Objects),
2052        );
2053        assert_eq!(
2054            snapshot
2055                .schemas
2056                .get(&SchemaQualifier::new("db".into(), "regular".into())),
2057            Some(&DeploymentKind::Objects),
2058        );
2059    }
2060
2061    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2062    #[mz_ore::test]
2063    fn test_record_stage_metadata_transition_override() {
2064        // During an Objects→Replacement transition, MVs go through the regular
2065        // objects path (not replacement_mvs), but the metadata must still record
2066        // the schema as Replacement.
2067        let mv_obj =
2068            make_typed_object(&["CREATE MATERIALIZED VIEW my_mv IN CLUSTER compute AS SELECT 1"]);
2069
2070        // Objects path (transition — MV is NOT in replacement_mvs)
2071        let objects: Vec<ObjectRef> = vec![(
2072            ObjectId::new("db".into(), "stable".into(), "my_mv".into()),
2073            &mv_obj,
2074        )];
2075        let sinks: Vec<ObjectRef> = vec![];
2076        let replacement_mvs: Vec<ObjectRef> = vec![];
2077
2078        // The project declares "stable" as a replacement schema
2079        let mut replacement_schemas = BTreeSet::new();
2080        replacement_schemas.insert(SchemaQualifier::new("db".into(), "stable".into()));
2081
2082        // Simulate what record_stage_metadata does (without DB calls)
2083        let mut staging_snapshot = DeploymentSnapshot::default();
2084
2085        for (object_id, typed_obj) in &objects {
2086            let hash = deployment_snapshot::compute_typed_hash(typed_obj);
2087            staging_snapshot.objects.insert(object_id.clone(), hash);
2088            staging_snapshot.schemas.insert(
2089                SchemaQualifier::new(
2090                    object_id.expect_database().to_string(),
2091                    object_id.schema().to_string(),
2092                ),
2093                DeploymentKind::Objects,
2094            );
2095        }
2096
2097        for (object_id, typed_obj) in &sinks {
2098            let hash = deployment_snapshot::compute_typed_hash(typed_obj);
2099            staging_snapshot.objects.insert(object_id.clone(), hash);
2100            staging_snapshot
2101                .schemas
2102                .entry(SchemaQualifier::new(
2103                    object_id.expect_database().to_string(),
2104                    object_id.schema().to_string(),
2105                ))
2106                .or_insert(DeploymentKind::Sinks);
2107        }
2108
2109        for (object_id, typed_obj) in &replacement_mvs {
2110            let hash = deployment_snapshot::compute_typed_hash(typed_obj);
2111            staging_snapshot.objects.insert(object_id.clone(), hash);
2112            staging_snapshot.schemas.insert(
2113                SchemaQualifier::new(
2114                    object_id.expect_database().to_string(),
2115                    object_id.schema().to_string(),
2116                ),
2117                DeploymentKind::Replacement,
2118            );
2119        }
2120
2121        // Before the fix, the schema would remain Objects here.
2122        assert_eq!(
2123            staging_snapshot
2124                .schemas
2125                .get(&SchemaQualifier::new("db".into(), "stable".into())),
2126            Some(&DeploymentKind::Objects),
2127            "Before override, schema should be Objects (from regular objects path)"
2128        );
2129
2130        // Apply the replacement_schemas override (the fix)
2131        for sq in &replacement_schemas {
2132            if staging_snapshot.schemas.contains_key(sq) {
2133                staging_snapshot
2134                    .schemas
2135                    .insert(sq.clone(), DeploymentKind::Replacement);
2136            }
2137        }
2138
2139        // After the fix, the schema should be Replacement
2140        assert_eq!(
2141            staging_snapshot
2142                .schemas
2143                .get(&SchemaQualifier::new("db".into(), "stable".into())),
2144            Some(&DeploymentKind::Replacement),
2145            "After override, schema should be Replacement"
2146        );
2147    }
2148
2149    #[mz_ore::test]
2150    fn test_record_stage_metadata_override_only_applies_to_existing_schemas() {
2151        // The override should NOT create new schema entries — it only applies to
2152        // schemas that already have objects in the staging snapshot.
2153        let replacement_schemas =
2154            BTreeSet::from([SchemaQualifier::new("db".into(), "nonexistent".into())]);
2155
2156        let mut staging_snapshot = DeploymentSnapshot::default();
2157
2158        // Apply the replacement_schemas override
2159        for sq in &replacement_schemas {
2160            if staging_snapshot.schemas.contains_key(sq) {
2161                staging_snapshot
2162                    .schemas
2163                    .insert(sq.clone(), DeploymentKind::Replacement);
2164            }
2165        }
2166
2167        // Should NOT have created a new entry
2168        assert!(
2169            staging_snapshot.schemas.is_empty(),
2170            "Override should not create entries for schemas with no objects"
2171        );
2172    }
2173
2174    #[mz_ore::test]
2175    fn test_validate_stage_name_length() {
2176        assert!(validate_stage_name("prod").is_ok());
2177        assert!(validate_stage_name(&"a".repeat(Ident::MAX_LENGTH / 2 - 1)).is_ok());
2178        assert!(validate_stage_name(&"a".repeat(Ident::MAX_LENGTH)).is_err());
2179    }
2180}