Skip to main content

cratefield_cli/
apply.rs

1//! `fz migrations apply` (issue #18): applies the harness's module
2//! migrations directly to a Postgres database — the native counterpart of
3//! `wrangler d1 migrations apply`. `--fleet` and `--plan` instead point
4//! it at a **control** database and run the boot-time reconciliation the
5//! native runtime runs (RECONCILIATION.md, issue #30) by hand.
6//!
7//! The two are different databases and different work, so neither is the
8//! other's default. An earlier cut of #30 made `--url` mean the control
9//! database always: against an ordinary venture database that
10//! reconciled a registry with no tenants in it, printed nothing, and
11//! **exited zero having applied no migrations** — including in
12//! ROLLBACK.md's recovery runbook, which runs this command to re-apply
13//! what is missing. Silence and success are the two things a migration
14//! step must never combine.
15//!
16//! Requires building `fz` with cratefield-cli's `postgres` feature so
17//! sqlx and tokio stay out of the default (wasm-safe) dependency graph.
18
19use cratefield_core::Harness;
20
21#[cfg(feature = "postgres")]
22/// Default per-boot fan-out to tenant databases (RECONCILIATION.md §2:
23/// "something small like 8"; #30 §10 keeps it a measured question).
24#[cfg(feature = "postgres")]
25const FLEET_PARALLELISM: usize = 8;
26
27/// Applies the harness's migrations to the database at `url`; with
28/// `fleet` or `plan`, reconciles the tenants registered in the control
29/// database at `url` instead.
30///
31/// # Errors
32///
33/// A human-readable message when the dialect is unsupported, the flags
34/// contradict each other, the binary lacks the `postgres` feature, the
35/// server is unreachable, or a migration fails. In strict mode any
36/// degraded tenant is one.
37pub fn apply(
38    harness: &Harness,
39    dialect: &str,
40    url: &str,
41    plan: bool,
42    fleet: bool,
43    tenant: Option<&str>,
44    strict: bool,
45) -> Result<(), String> {
46    if dialect != "postgres" {
47        return Err(format!(
48            "dialect {dialect:?} is not supported for apply (only postgres, issue #18)"
49        ));
50    }
51    if plan && fleet {
52        return Err(
53            "--plan and --fleet contradict each other: --plan applies nothing, --fleet \
54             applies to every registered tenant"
55                .to_owned(),
56        );
57    }
58    if tenant.is_some() && !plan {
59        return Err(
60            "--tenant only means something with --plan today: fleet apply is the runtime \
61             reconciler's job, and one tenant cannot be applied without its neighbours' \
62             registry being read anyway"
63                .to_owned(),
64        );
65    }
66    if strict && !fleet {
67        return Err(
68            "--strict says what to do about a degraded tenant, and only --fleet has \
69             tenants: a plain apply either applies its migrations or fails"
70                .to_owned(),
71        );
72    }
73    #[cfg(not(feature = "postgres"))]
74    {
75        let _ = (harness, url, plan, fleet, tenant, strict);
76        Err(
77            "this fz binary was built without cratefield-cli's `postgres` feature — \
78             rebuild the fz bin with `--features cratefield-cli/postgres` to apply \
79             migrations to Postgres (sqlite/D1 migrations run through \
80             `wrangler d1 migrations apply`)"
81                .to_owned(),
82        )
83    }
84    #[cfg(feature = "postgres")]
85    {
86        if plan {
87            apply_plan(harness, url, tenant)
88        } else if fleet {
89            apply_fleet(harness, url, strict)
90        } else {
91            apply_postgres(harness, url)
92        }
93    }
94}
95
96#[cfg(feature = "postgres")]
97fn runtime() -> Result<tokio::runtime::Runtime, String> {
98    tokio::runtime::Builder::new_current_thread()
99        .enable_all()
100        .build()
101        .map_err(|err| format!("cannot start the async runtime: {err}"))
102}
103
104/// The default, and what issue #18 named: this harness's migrations, on
105/// the database `--url` names, tracked in `harness_migrations`.
106#[cfg(feature = "postgres")]
107fn apply_postgres(harness: &Harness, url: &str) -> Result<(), String> {
108    runtime()?.block_on(async {
109        // The URL is never echoed: connection strings carry credentials.
110        let db = cratefield_adapter_postgres::Postgres::connect(url)
111            .await
112            .map_err(|err| format!("cannot connect (check --url): {err}"))?;
113        db.apply_harness_migrations(harness)
114            .await
115            .map_err(|err| format!("migrations failed: {err}"))
116    })
117}
118
119/// `--fleet`: `url` names the **control** database, and every
120/// non-degraded tenant in its registry is reconciled against its own
121/// database (RECONCILIATION.md §2).
122#[cfg(feature = "postgres")]
123fn apply_fleet(harness: &Harness, url: &str, strict: bool) -> Result<(), String> {
124    runtime()?.block_on(async {
125        let db = cratefield_adapter_postgres::Postgres::connect(url)
126            .await
127            .map_err(|err| {
128                format!("cannot connect to the control database (check --url): {err}")
129            })?;
130        let reports = db
131            .reconcile_fleet(harness, FLEET_PARALLELISM)
132            .await
133            .map_err(|err| format!("reconciliation failed: {err}"))?;
134        // An empty registry is the shape that made the old default a
135        // silent no-op. Say so rather than print nothing and exit zero.
136        if reports.is_empty() {
137            println!(
138                "no tenants registered in the control database — nothing to reconcile \
139                 (a venture database is applied without --fleet)"
140            );
141            return Ok(());
142        }
143        let degraded: Vec<&cratefield_adapter_postgres::TenantReport> = reports
144            .iter()
145            .filter(|report| report.status == cratefield_adapter_postgres::TenantStatus::Degraded)
146            .collect();
147        for report in &reports {
148            println!(
149                "tenant {}: {} applied, {} skipped, {}{}",
150                report.tenant,
151                report.applied,
152                report.skipped,
153                report.status,
154                report
155                    .error
156                    .as_deref()
157                    .map(|err| format!(" — {err}"))
158                    .unwrap_or_default()
159            );
160        }
161        if degraded.is_empty() {
162            Ok(())
163        } else if strict {
164            Err(format!(
165                "{} tenant(s) degraded ({}): boot would abort in strict mode",
166                degraded.len(),
167                degraded
168                    .iter()
169                    .map(|report| report.tenant.as_str())
170                    .collect::<Vec<_>>()
171                    .join(", ")
172            ))
173        } else {
174            println!(
175                "{} tenant(s) degraded; every other tenant serves (re-run with --strict \
176                 to make this a failure)",
177                degraded.len()
178            );
179            Ok(())
180        }
181    })
182}
183
184/// `--plan`: what would be applied, per tenant and per module, applied
185/// nowhere. Locks are taken so the answer is not a guess about a moving
186/// target (RECONCILIATION.md §8).
187#[cfg(feature = "postgres")]
188fn apply_plan(harness: &Harness, url: &str, tenant: Option<&str>) -> Result<(), String> {
189    runtime()?.block_on(async {
190        let control = cratefield_adapter_postgres::Postgres::connect(url)
191            .await
192            .map_err(|err| {
193                format!("cannot connect to the control database (check --url): {err}")
194            })?;
195        control
196            .bootstrap_registry()
197            .await
198            .map_err(|err| format!("cannot bootstrap the registry: {err}"))?;
199        let records = control
200            .tenants()
201            .await
202            .map_err(|err| format!("cannot read the tenant registry: {err}"))?;
203        let records: Vec<_> = match tenant {
204            Some(name) => {
205                let found: Vec<_> = records
206                    .iter()
207                    .filter(|record| record.tenant == name)
208                    .collect();
209                if found.is_empty() {
210                    return Err(format!("tenant {name:?} is not in the registry"));
211                }
212                found
213            }
214            None => records.iter().collect(),
215        };
216        if records.is_empty() {
217            println!("no tenants registered");
218            return Ok(());
219        }
220        for record in records {
221            match control.reconcile_plan(record, harness).await {
222                Ok(plan) => {
223                    if plan.modules.is_empty() {
224                        println!("tenant {}: nothing to apply", plan.tenant);
225                    } else {
226                        println!("tenant {}:", plan.tenant);
227                        for module in &plan.modules {
228                            println!("  {}: {}", module.module, module.migrations.join(", "));
229                        }
230                    }
231                }
232                Err(err) => println!("tenant {}: unreachable — {err}", record.tenant),
233            }
234        }
235        Ok(())
236    })
237}