Skip to main content

systemprompt_cli/commands/infrastructure/db/
admin_squash.rs

1//! `db migrate-squash` subcommand.
2//!
3//! Collapses an extension's migrations `1..=through` into a single `000`
4//! baseline file, optionally rewriting the recorded migration rows with
5//! `--apply`. Baseline placement and writing are delegated to
6//! [`SquashBaselineService`]; this module emits the manual follow-up steps the
7//! operator must complete.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::path::Path;
13use std::sync::Arc;
14
15use anyhow::{Context, Result, anyhow};
16use systemprompt_database::services::DatabaseProvider;
17use systemprompt_database::{Database, MigrationService, SquashBaselineService, SquashPlan};
18use systemprompt_extension::ExtensionRegistry;
19use systemprompt_logging::CliService;
20use systemprompt_models::Config;
21use systemprompt_runtime::DatabaseContext;
22
23use crate::cli_settings::CliConfig;
24use crate::shared::{CommandOutput, render_result};
25
26use super::types::DbSquashOutput;
27
28pub(super) struct SquashArgs<'a> {
29    pub extension: &'a str,
30    pub through: u32,
31    pub apply: bool,
32}
33
34pub(super) async fn execute_squash(config: &CliConfig, args: SquashArgs<'_>) -> Result<()> {
35    let sys_config = Config::get()?;
36
37    let database = Arc::new(
38        Database::from_config_with_write(
39            &sys_config.database_type,
40            &sys_config.database_url,
41            sys_config.database_write_url.as_deref(),
42            &systemprompt_database::PoolConfig::default(),
43        )
44        .await
45        .context("Failed to connect to database")?,
46    );
47
48    let registry = ExtensionRegistry::discover()?;
49    run_squash(&registry, database.write(), config, &args).await
50}
51
52pub(super) async fn execute_squash_standalone(
53    db_ctx: &DatabaseContext,
54    config: &CliConfig,
55    args: SquashArgs<'_>,
56) -> Result<()> {
57    let registry = ExtensionRegistry::discover()?;
58    run_squash(&registry, db_ctx.db_pool().write(), config, &args).await
59}
60
61async fn run_squash(
62    registry: &ExtensionRegistry,
63    write_provider: &dyn DatabaseProvider,
64    config: &CliConfig,
65    args: &SquashArgs<'_>,
66) -> Result<()> {
67    let extension_id = args.extension;
68    let through = args.through;
69    let apply = args.apply;
70    let ext = registry
71        .get(extension_id)
72        .ok_or_else(|| anyhow!("Extension '{}' not found in registry", extension_id))?;
73
74    let migration_service = MigrationService::new(write_provider);
75    let plan: SquashPlan = migration_service
76        .squash_through(ext.as_ref(), through, apply)
77        .await
78        .map_err(|e| anyhow!("Squash failed: {}", e))?;
79
80    let cwd = std::env::current_dir().context("Failed to read current working directory")?;
81    let baseline_path = SquashBaselineService::baseline_target_path(&cwd, extension_id, through)?;
82    let baseline_path_written = if apply {
83        SquashBaselineService::write_baseline_file(&baseline_path, &plan.baseline_sql)?;
84        true
85    } else {
86        false
87    };
88
89    let follow_up = build_follow_up(&plan, &baseline_path, apply);
90    let message = if apply {
91        format!(
92            "Squash applied: extension '{ext_id}' migrations 1..={through} retired; baseline \
93             written to {path}",
94            ext_id = plan.extension_id,
95            through = plan.through,
96            path = baseline_path.display(),
97        )
98    } else {
99        format!(
100            "Dry-run: would squash extension '{ext_id}' migrations 1..={through} (re-run with \
101             --apply to commit; baseline target: {path})",
102            ext_id = plan.extension_id,
103            through = plan.through,
104            path = baseline_path.display(),
105        )
106    };
107
108    let output = DbSquashOutput {
109        extension_id: plan.extension_id.clone(),
110        through: plan.through,
111        baseline_name: plan.baseline_name.clone(),
112        baseline_checksum: plan.baseline_checksum.clone(),
113        source_versions: plan.source_versions.clone(),
114        already_applied_versions: plan.already_applied_versions.clone(),
115        baseline_path: baseline_path.display().to_string(),
116        baseline_path_written,
117        applied: plan.applied,
118        message: message.clone(),
119        follow_up: follow_up.clone(),
120    };
121
122    if config.is_json_output() {
123        let result = CommandOutput::card_value("Database Migration Squash", &output);
124        render_result(&result, config);
125    } else {
126        render_squash_text(&plan, &baseline_path, &follow_up, &message, apply);
127    }
128
129    Ok(())
130}
131
132fn render_squash_text(
133    plan: &SquashPlan,
134    baseline_path: &Path,
135    follow_up: &[String],
136    message: &str,
137    apply: bool,
138) {
139    if apply {
140        CliService::success(message);
141    } else {
142        CliService::warning(message);
143    }
144    CliService::info(&format!(
145        "  Source versions     : {:?}",
146        plan.source_versions
147    ));
148    CliService::info(&format!(
149        "  Already applied     : {:?}",
150        plan.already_applied_versions
151    ));
152    CliService::info(&format!("  Baseline name       : {}", plan.baseline_name));
153    CliService::info(&format!(
154        "  Baseline checksum   : {}",
155        plan.baseline_checksum
156    ));
157    CliService::info(&format!(
158        "  Baseline file       : {}",
159        baseline_path.display()
160    ));
161    CliService::info("");
162    CliService::info("Follow-up steps:");
163    for step in follow_up {
164        CliService::info(&format!("  - {step}"));
165    }
166    if !apply {
167        CliService::info("");
168        CliService::info(
169            "Dry-run only — no rows changed and no file written. Re-run with --apply.",
170        );
171    }
172}
173
174pub fn build_follow_up(plan: &SquashPlan, baseline_path: &Path, apply: bool) -> Vec<String> {
175    let mut steps = Vec::new();
176    if !apply {
177        steps.push(format!(
178            "Re-run with --apply to write {path} and rewrite extension_migrations rows.",
179            path = baseline_path.display()
180        ));
181    }
182    steps.push(format!(
183        "Delete the squashed source files for migrations {versions:?} from the extension crate.",
184        versions = plan.source_versions
185    ));
186    steps.push(format!(
187        "In the extension's `extension.rs`, replace the squashed `Migration::new(...)` entries \
188         with: Migration::new(0, \"{name}\", BASELINE_SQL) using `include_str!` of the new \
189         baseline file.",
190        name = plan.baseline_name
191    ));
192    steps.push(format!(
193        "Bump any newly-added migrations so their version is > {through}.",
194        through = plan.through
195    ));
196    steps
197}