cratefield_cli/tables.rs
1//! `fz tables diff` and `fz tables drift` — what a declaration change
2//! costs, and what a database differs from the declaration by.
3//!
4//! Both halves of this landed without a way to run one against the other:
5//! a manifest can declare `[tables]` (#354) and
6//! `cratefield_introspect::drift` can compare a declaration against a
7//! live catalog (#353). This is the command that makes the pair reachable
8//! by a person, which is the only form in which either is worth anything.
9//!
10//! Read-only, always. It opens the database, reads its catalog and prints;
11//! there is no `--fix`, because what a drift costs is the author's
12//! decision and three of the four answers are not "apply this".
13
14use std::path::Path;
15
16#[cfg(feature = "postgres")]
17use cratefield_introspect::{drift as catalog_drift, unseen};
18use cratefield_tables::Step;
19
20/// `fz tables diff`: what changed between two versions of a manifest's
21/// declaration, and what each change costs.
22///
23/// The counterpart of `drift`, and the reason both exist: this is the
24/// question before a deploy — *"I edited the declaration; can I ship
25/// it?"* — and drift is the question after one. Neither answers the
26/// other, and a database is only involved in the second.
27///
28/// Exits non-zero when anything changed, for the same reason `drift`
29/// does: the finding is the point, and a CI job comparing a branch's
30/// manifest against `main`'s needs something to branch on.
31///
32/// # Errors
33///
34/// A message when either manifest cannot be read or is not valid, and a
35/// summary line when the report was not empty.
36pub fn diff(previous: &Path, current: &Path) -> Result<(), String> {
37 match diff_report(previous, current)? {
38 0 => Ok(()),
39 count => Err(format!("{count} change(s) to the declaration (above)")),
40 }
41}
42
43/// Reports the differences and answers how many there were.
44///
45/// # Errors
46///
47/// A message when either manifest cannot be read or is not valid.
48pub fn diff_report(previous: &Path, current: &Path) -> Result<usize, String> {
49 let before = load(previous)?;
50 let after = load(current)?;
51 let changes = cratefield_tables::diff(&before.tables, &after.tables);
52 // The schema is not the whole declaration. Flipping one table from
53 // `owner` to `public-read` changes no column, so the schema diff is
54 // empty — and this command answered "No change" and exited zero for
55 // an edit that publishes every subject's private rows.
56 let declared = cratefield_manifest::declaration_diff(
57 &before.table_access,
58 &after.table_access,
59 &before.table_privacy,
60 &after.table_privacy,
61 );
62
63 if changes.is_empty() && declared.is_empty() {
64 println!(
65 "No change: both declare the same {} table(s), with the same access and privacy.",
66 after.tables.tables.len()
67 );
68 return Ok(0);
69 }
70 if !declared.is_empty() {
71 // First, and not under the expand/contract/rewrite heading: those
72 // three words are about what a change costs to *apply*, and these
73 // changes cost nothing to apply and can still be the most
74 // consequential line in the diff.
75 println!(
76 "{} change(s) to who may reach these tables and what they hold:\n",
77 declared.len()
78 );
79 for change in &declared {
80 println!(" {}", change.line());
81 }
82 let widened = declared
83 .iter()
84 .filter(|change| {
85 matches!(
86 change,
87 cratefield_manifest::DeclarationChange::Access {
88 direction: cratefield_manifest::Move::Widens,
89 ..
90 }
91 )
92 })
93 .count();
94 if widened > 0 {
95 println!("\n {widened} of them widen reach. Read those before shipping.");
96 }
97 println!();
98 }
99 if changes.is_empty() {
100 return Ok(declared.len());
101 }
102 println!("{} change(s) to the declaration:\n", changes.len());
103 for change in &changes {
104 println!(" [{}] {}", change.step, change.line());
105 }
106 println!();
107 for step in Step::ALL.iter().copied() {
108 let count = changes.iter().filter(|change| change.step == step).count();
109 if count > 0 {
110 println!(" {count} {step}");
111 }
112 }
113 // The vocabulary is `docs/ROLLBACK.md` section 5's, and a reader who
114 // has not met it needs the line saying what the three words cost.
115 println!(
116 "\n expand: additive, a rollback stays free\n contract: safe once nothing deployed reads it\n rewrite: cannot be applied forward-only to rows that already exist"
117 );
118 Ok(changes.len() + declared.len())
119}
120
121/// Reads and validates one manifest. Validated on purpose: comparing two
122/// declarations where one is not legal reports differences against
123/// something that could never be deployed.
124fn load(path: &Path) -> Result<cratefield_manifest::VentureManifest, String> {
125 let raw = std::fs::read_to_string(path)
126 .map_err(|err| format!("cannot read {}: {err}", path.display()))?;
127 let manifest = crate::build::parse(path, &raw)?;
128 manifest
129 .validate()
130 .map_err(|err| format!("{}: {err}", path.display()))?;
131 Ok(manifest)
132}
133
134/// `fz tables drift`: report, then fail when there was anything to
135/// report.
136///
137/// Drift is the finding, so it is also the exit code — a CI job asking
138/// "does this database still match the manifest?" needs an answer it can
139/// branch on rather than a paragraph it has to read.
140///
141/// # Errors
142///
143/// Anything [`drift_report`] cannot do, and a summary line when the
144/// report was not empty.
145pub fn drift(manifest_path: &Path, dialect: &str, url: &str) -> Result<(), String> {
146 match drift_report(manifest_path, dialect, url)? {
147 0 => Ok(()),
148 count => Err(format!(
149 "{count} difference(s) between the declaration and the database (above)"
150 )),
151 }
152}
153
154/// Reports drift between the manifest at `manifest_path` and the database
155/// `url` names, and answers **how many differences there were**.
156///
157/// The count is the point: a command that only printed would be a
158/// command no test could tell the two answers apart with, and no CI job
159/// could act on. The caller turns a non-zero count into a non-zero exit.
160///
161/// # Errors
162///
163/// A message for the operator when the manifest cannot be read, the
164/// dialect is unsupported, the database cannot be reached, or its catalog
165/// cannot be read.
166pub fn drift_report(manifest_path: &Path, dialect: &str, url: &str) -> Result<usize, String> {
167 // Same gate `fz migrations apply` carries, for the same reason: the
168 // Postgres adapter pulls sqlx and tokio, and the default `fz`
169 // dependency graph stays free of both.
170 #[cfg(not(feature = "postgres"))]
171 {
172 let _ = (manifest_path, url);
173 Err(format!(
174 "dialect {dialect:?} needs an `fz` built with the `postgres` feature (cargo install cratefield-cli --features postgres)"
175 ))
176 }
177 #[cfg(feature = "postgres")]
178 {
179 report(manifest_path, dialect, url)
180 }
181}
182
183#[cfg(feature = "postgres")]
184fn report(manifest_path: &Path, dialect: &str, url: &str) -> Result<usize, String> {
185 if dialect != "postgres" {
186 return Err(format!(
187 "dialect {dialect:?} cannot be reached from here (a D1 database is read through \
188 wrangler, not a connection string); `--dialect postgres --url ...` is the \
189 direct-connection flow, the same one `fz migrations apply` uses"
190 ));
191 }
192 let raw = std::fs::read_to_string(manifest_path)
193 .map_err(|err| format!("cannot read {}: {err}", manifest_path.display()))?;
194 let manifest = crate::build::parse(manifest_path, &raw)?;
195 manifest.validate().map_err(|err| err.to_string())?;
196
197 if manifest.tables.is_empty() {
198 println!("{} declares no tables of its own.", manifest_path.display());
199 return Ok(0);
200 }
201
202 let changes = pollster::block_on(async {
203 let db = cratefield_adapter_postgres::Postgres::connect(url)
204 .await
205 // The URL carries credentials; the adapter's own errors are
206 // already sanitised, and this adds nothing that is not.
207 .map_err(|err| format!("cannot reach the database: {err}"))?;
208 let report = catalog_drift(&db, &manifest.tables)
209 .await
210 .map_err(|err| format!("cannot read the catalog: {err}"));
211 db.close().await.ok();
212 report
213 })?;
214
215 if changes.is_empty() {
216 println!(
217 "No drift: the database matches all {} declared table(s).",
218 manifest.tables.tables.len()
219 );
220 } else {
221 println!(
222 "{} difference(s) between the declaration and the database:\n",
223 changes.len()
224 );
225 for change in &changes {
226 println!(" [{}] {}", change.step, change.line());
227 }
228 println!();
229 for step in Step::ALL.iter().copied() {
230 let count = changes.iter().filter(|change| change.step == step).count();
231 if count > 0 {
232 println!(" {count} {step}");
233 }
234 }
235 }
236 // Printed either way, because "no drift" is an answer about the
237 // things that were compared and a reader has no way to know which
238 // those were.
239 println!("\n{}", unseen());
240 Ok(changes.len())
241}