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