1use std::collections::BTreeSet;
21
22use crate::cli::commands::ObjectRef;
23use crate::cli::error::CliError;
24use crate::client::{Client, quote_identifier};
25use crate::config::Settings;
26use crate::project::SchemaQualifier;
27use crate::project::analysis::changeset::ChangeSet;
28use crate::project::analysis::deployment_snapshot;
29use crate::project::ast::Statement;
30use crate::project::ir::compiled::FullyQualifiedName;
31use crate::project::ir::object_id::ObjectId;
32use crate::project::resolve::normalize::NormalizingVisitor;
33use crate::{info, verbose};
34
35fn overlay_db_name(base_db: &str, profile: &str) -> String {
37 format!("{}__{}", base_db, profile)
38}
39
40async fn refuse_if_targets_production_cluster(
43 client: &Client,
44 cluster: &str,
45) -> Result<(), CliError> {
46 let production = client.deployments().list_production_clusters().await?;
47 if let Some(rec) = production.into_iter().find(|r| r.cluster_name == cluster) {
48 return Err(CliError::DevTargetsProductionCluster { cluster: rec });
49 }
50 Ok(())
51}
52
53pub async fn run(
63 settings: &Settings,
64 cluster: Option<String>,
65 down: bool,
66 dry_run: bool,
67) -> Result<(), CliError> {
68 let profile = settings.connection();
69 let profile_name = settings
71 .profile_name
72 .clone()
73 .expect("dev requires an active profile");
74 let project_name = settings
75 .directory
76 .file_name()
77 .and_then(|s| s.to_str())
78 .filter(|s| !s.is_empty())
79 .ok_or_else(|| CliError::InvalidProjectDirectory {
80 path: settings.directory.display().to_string(),
81 })?
82 .to_string();
83
84 let planned_project = super::compile::run(settings, true).await?;
85
86 let in_project_databases: BTreeSet<String> = planned_project
87 .databases
88 .iter()
89 .map(|db| db.name.clone())
90 .collect();
91
92 let client = Client::connect_with_profile(profile.clone())
93 .await
94 .map_err(CliError::Connection)?;
95
96 crate::cli::commands::setup::verify(&client, settings.emulator()).await?;
97 let role =
98 crate::cli::commands::setup::validate_connection(&client, settings.emulator()).await?;
99 crate::cli::commands::setup::require_developer(role)?;
100
101 if in_project_databases.is_empty() {
102 info!("Project has no databases — nothing to overlay.");
103 return Ok(());
104 }
105
106 let sample_overlay_db = overlay_db_name(
107 in_project_databases.iter().next().expect("non-empty"),
108 &profile_name,
109 );
110 crate::cli::commands::setup::require_createdb(&client, &profile.username, &sample_overlay_db)
111 .await?;
112
113 if down {
114 drop_phase(&client, &profile_name, &project_name, &in_project_databases).await?;
115 info!("Overlay removed.");
116 return Ok(());
117 }
118
119 let target_cluster = cluster.expect("cluster required unless --down");
121 refuse_if_targets_production_cluster(&client, &target_cluster).await?;
122
123 let new_snapshot = deployment_snapshot::build_snapshot_from_planned(&planned_project)?;
124 let production_snapshot = deployment_snapshot::load_from_database(&client, None).await?;
125
126 let change_set = if production_snapshot.objects.is_empty() {
128 verbose!("Full deployment: no production deployment found");
129 None
130 } else {
131 Some(ChangeSet::from_deployment_snapshot_comparison(
132 &production_snapshot,
133 &new_snapshot,
134 &planned_project,
135 &BTreeSet::new(),
136 ))
137 };
138
139 let all_objects = match change_set.as_ref() {
140 Some(cs) if cs.is_empty() => Vec::new(),
141 Some(cs) => {
142 verbose!("{}", cs);
143 planned_project.get_sorted_objects_filtered(&cs.objects_to_deploy)?
144 }
145 None => planned_project.get_sorted_objects()?,
146 };
147
148 let mut skipped = 0usize;
149 let overlay_objects: Vec<ObjectRef<'_>> = all_objects
150 .into_iter()
151 .filter(|(_, typed_obj)| match &typed_obj.stmt {
152 Statement::CreateView(_) | Statement::CreateMaterializedView(_) => true,
153 _ => {
154 skipped += 1;
155 false
156 }
157 })
158 .collect();
159 if skipped > 0 {
160 verbose!(
161 "skipped {} object(s) of unsupported type (tables/sources/sinks)",
162 skipped
163 );
164 }
165
166 let dirty_schemas: BTreeSet<SchemaQualifier> = overlay_objects
167 .iter()
168 .map(|(id, _)| {
169 SchemaQualifier::new(id.expect_database().to_string(), id.schema().to_string())
170 })
171 .collect();
172
173 print_plan(&dirty_schemas, &profile_name);
174
175 if dry_run {
176 return Ok(());
177 }
178
179 drop_phase(&client, &profile_name, &project_name, &in_project_databases).await?;
180
181 if dirty_schemas.is_empty() {
182 info!("Dev overlay ready (nothing to overlay).");
183 return Ok(());
184 }
185
186 create_phase(
187 &client,
188 &profile_name,
189 &project_name,
190 &in_project_databases,
191 &dirty_schemas,
192 &overlay_objects,
193 &target_cluster,
194 )
195 .await?;
196
197 info!("Dev overlay ready.");
198 Ok(())
199}
200
201fn print_plan(dirty_schemas: &BTreeSet<SchemaQualifier>, profile_name: &str) {
202 if dirty_schemas.is_empty() {
203 info!("Dirty set is empty — nothing to overlay.");
204 return;
205 }
206 info!("→ Dirty schemas:");
207 for qual in dirty_schemas {
208 info!(" {}.{}", qual.database, qual.schema);
209 }
210
211 let overlay_dbs: BTreeSet<String> = dirty_schemas
212 .iter()
213 .map(|q| overlay_db_name(&q.database, profile_name))
214 .collect();
215 info!("→ Overlay databases:");
216 for db in &overlay_dbs {
217 info!(" {}", db);
218 }
219}
220
221pub(crate) async fn drop_phase(
226 client: &Client,
227 profile_name: &str,
228 project_name: &str,
229 in_project_databases: &BTreeSet<String>,
230) -> Result<(), CliError> {
231 let overlays = client.dev_overlays();
232
233 let existing: BTreeSet<String> = overlays
234 .list_overlays(profile_name, project_name)
235 .await?
236 .into_iter()
237 .collect();
238 for db in &existing {
239 drop_database(client, db).await?;
240 }
241 overlays.delete_overlays(profile_name, project_name).await?;
242
243 for base_db in in_project_databases {
244 let overlay_db = overlay_db_name(base_db, profile_name);
245 if !existing.contains(&overlay_db) {
246 drop_database(client, &overlay_db).await?;
247 }
248 }
249
250 Ok(())
251}
252
253async fn drop_database(client: &Client, database: &str) -> Result<(), CliError> {
254 let sql = format!(
255 "DROP DATABASE IF EXISTS {} CASCADE",
256 quote_identifier(database),
257 );
258 client.execute(&sql, &[]).await?;
259 Ok(())
260}
261
262pub(crate) async fn create_phase(
270 client: &Client,
271 profile_name: &str,
272 project_name: &str,
273 in_project_databases: &BTreeSet<String>,
274 dirty_schemas: &BTreeSet<SchemaQualifier>,
275 overlay_objects: &[ObjectRef<'_>],
276 target_cluster: &str,
277) -> Result<(), CliError> {
278 let provisioning = client.provisioning();
279 let overlays = client.dev_overlays();
280
281 let mut created_overlay_dbs: BTreeSet<String> = BTreeSet::new();
282 for qualifier in dirty_schemas {
283 let overlay_db = overlay_db_name(&qualifier.database, profile_name);
284 if created_overlay_dbs.insert(overlay_db.clone()) {
285 provisioning.create_database(&overlay_db).await?;
286 overlays
287 .insert_overlay(profile_name, project_name, &overlay_db)
288 .await?;
289 }
290 }
291
292 for qualifier in dirty_schemas {
293 let overlay_db = overlay_db_name(&qualifier.database, profile_name);
294 provisioning
295 .create_schema(&overlay_db, &qualifier.schema)
296 .await?;
297 }
298
299 let overlay_object_ids: BTreeSet<ObjectId> = overlay_objects
300 .iter()
301 .map(|(id, _)| (*id).clone())
302 .collect();
303
304 for (object_id, typed_object) in overlay_objects {
305 let original_fqn: FullyQualifiedName = object_id.clone().into();
306 let mut visitor = NormalizingVisitor::overlay(
307 &original_fqn,
308 profile_name,
309 in_project_databases,
310 &overlay_object_ids,
311 target_cluster,
312 );
313
314 let stmt = typed_object
315 .stmt
316 .clone()
317 .normalize_name_with(&visitor, &original_fqn.to_item_name())
318 .normalize_dependencies_with(&mut visitor)
319 .normalize_cluster_with(&visitor);
320
321 client.execute(&stmt.to_string(), &[]).await?;
322
323 let mut indexes = typed_object.indexes.clone();
324 visitor.normalize_index_references(&mut indexes);
325 visitor.normalize_index_clusters(&mut indexes);
326 for index in &indexes {
327 client.execute(&index.to_string(), &[]).await?;
328 }
329 }
330
331 Ok(())
332}