systemprompt_cli/commands/infrastructure/db/
admin_squash.rs1use 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(®istry, 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(®istry, 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}