- Workspace 1.1.4 → 1.1.5; SDK 1.5 → 1.6; dashboard 0.10.0 → 0.11.0. - CHANGELOG v1.1.5 entry; CLAUDE.md runtime-config table gains PICLOUD_FILES_ROOT + PICLOUD_FILES_MAX_FILE_SIZE_BYTES. - schema_snapshot test: drop #[ignore] + #[sqlx::test]; run against DATABASE_URL when set, skip cleanly when absent. Re-blessed golden picks up files / files_trigger_details / pubsub_trigger_details, the two widened CHECKs, and the pubsub partial index. - First CI workflow (.github/workflows/ci.yml): postgres:15 service + fmt + clippy + cargo test --workspace; separate dashboard check job. - Add files/pubsub admin-trigger reject-coverage tests (module + cross-app + bad-pattern), mirroring the v1.1.3 regression set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
9.8 KiB
Rust
273 lines
9.8 KiB
Rust
//! Schema snapshot guardrail.
|
|
//!
|
|
//! Boots a fresh Postgres (via `#[sqlx::test]`), applies every
|
|
//! migration in `migrations/` in order, then queries
|
|
//! `information_schema` for a deterministic dump of every table,
|
|
//! column, index, and constraint the migrations produce. Compares
|
|
//! against the checked-in `expected_schema.txt` golden file.
|
|
//!
|
|
//! **What this catches:**
|
|
//!
|
|
//! * Someone edits a committed migration instead of adding a new
|
|
//! one — the resulting schema diverges from the golden.
|
|
//! * Someone adds a migration but forgets to update the golden —
|
|
//! same divergence; the test reminds them.
|
|
//! * Two migrations drift apart (the snapshot is the source of
|
|
//! truth about what the schema *should* be after replay).
|
|
//!
|
|
//! **How to update the golden after an intentional migration:**
|
|
//!
|
|
//! ```sh
|
|
//! BLESS=1 DATABASE_URL=postgres://... \
|
|
//! cargo test -p picloud-manager-core --test schema_snapshot \
|
|
//! -- --include-ignored
|
|
//! ```
|
|
//!
|
|
//! Review the resulting diff in the same PR as the new migration.
|
|
//!
|
|
//! v1.1.5: this test is no longer `#[ignore]`'d. It runs whenever
|
|
//! `DATABASE_URL` is set (CI wires a `postgres:15` service) and **skips
|
|
//! cleanly** when it's absent, so plain `cargo test --workspace` stays
|
|
//! green on machines without Postgres. Unlike the previous
|
|
//! `#[sqlx::test]` form (which spun up an isolated throwaway database),
|
|
//! it now applies the migrations against the `DATABASE_URL` database
|
|
//! directly — migrations are forward-only and idempotent, and CI's
|
|
//! Postgres is fresh, so the structural dump is identical either way.
|
|
|
|
use std::fmt::Write as _;
|
|
use std::path::PathBuf;
|
|
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use sqlx::{PgPool, Row};
|
|
|
|
const SCHEMA: &str = "public";
|
|
|
|
const SNAPSHOT_PATH: &str = "tests/expected_schema.txt";
|
|
|
|
#[tokio::test]
|
|
async fn schema_after_replay_matches_snapshot() {
|
|
// Skip cleanly when DATABASE_URL is unset so `cargo test --workspace`
|
|
// stays green without Postgres. CI sets it (postgres:15 service).
|
|
let Ok(url) = std::env::var("DATABASE_URL") else {
|
|
eprintln!(
|
|
"schema_snapshot: DATABASE_URL unset — skipping. Set it (e.g. \
|
|
postgres://picloud:picloud@localhost:5432/picloud) to run this guardrail."
|
|
);
|
|
return;
|
|
};
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.connect(&url)
|
|
.await
|
|
.expect("connect to DATABASE_URL");
|
|
sqlx::migrate!("./migrations")
|
|
.run(&pool)
|
|
.await
|
|
.expect("apply migrations");
|
|
|
|
let actual = dump_schema(&pool).await;
|
|
|
|
let snapshot_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SNAPSHOT_PATH);
|
|
|
|
if std::env::var("BLESS").is_ok() {
|
|
std::fs::write(&snapshot_file, &actual).expect("failed to write snapshot");
|
|
eprintln!(
|
|
"BLESS=1 set: refreshed {} ({} bytes)",
|
|
snapshot_file.display(),
|
|
actual.len()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let expected = std::fs::read_to_string(&snapshot_file).unwrap_or_else(|_| {
|
|
panic!(
|
|
"missing snapshot file at {}; run with BLESS=1 to create it",
|
|
snapshot_file.display()
|
|
)
|
|
});
|
|
|
|
if actual != expected {
|
|
eprintln!(
|
|
"schema snapshot mismatch.\n\
|
|
To re-bless after an intentional migration:\n\
|
|
\n \
|
|
BLESS=1 DATABASE_URL=postgres://... \\\n \
|
|
cargo test -p picloud-manager-core --test schema_snapshot \\\n \
|
|
-- --include-ignored\n\
|
|
\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n"
|
|
);
|
|
panic!("schema diverged from {}", snapshot_file.display());
|
|
}
|
|
}
|
|
|
|
/// Build a deterministic representation of the `public` schema.
|
|
/// Sorted by every dimension so cosmetic differences (insertion
|
|
/// order, etc.) never cause spurious diffs.
|
|
async fn dump_schema(pool: &PgPool) -> String {
|
|
let mut out = String::new();
|
|
writeln!(
|
|
&mut out,
|
|
"# Schema snapshot — generated by schema_snapshot test."
|
|
)
|
|
.unwrap();
|
|
writeln!(
|
|
&mut out,
|
|
"# Do not edit by hand. Run with BLESS=1 to regenerate."
|
|
)
|
|
.unwrap();
|
|
writeln!(&mut out).unwrap();
|
|
|
|
dump_tables(pool, &mut out).await;
|
|
dump_indexes(pool, &mut out).await;
|
|
dump_constraints(pool, &mut out).await;
|
|
dump_migrations(pool, &mut out).await;
|
|
|
|
out
|
|
}
|
|
|
|
async fn dump_tables(pool: &PgPool, out: &mut String) {
|
|
let rows = sqlx::query(
|
|
"SELECT \
|
|
c.table_name, c.column_name, c.data_type, c.udt_name, \
|
|
c.is_nullable, c.column_default \
|
|
FROM information_schema.columns c \
|
|
JOIN information_schema.tables t \
|
|
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
|
|
WHERE c.table_schema = $1 AND t.table_type = 'BASE TABLE' \
|
|
AND c.table_name NOT LIKE '\\_sqlx%' \
|
|
ORDER BY c.table_name, c.ordinal_position",
|
|
)
|
|
.bind(SCHEMA)
|
|
.fetch_all(pool)
|
|
.await
|
|
.expect("failed to query information_schema.columns");
|
|
|
|
writeln!(out, "## tables").unwrap();
|
|
let mut current_table = String::new();
|
|
for row in rows {
|
|
let table: String = row.try_get("table_name").unwrap();
|
|
let column: String = row.try_get("column_name").unwrap();
|
|
let data_type: String = row.try_get("data_type").unwrap();
|
|
let udt: String = row.try_get("udt_name").unwrap();
|
|
let nullable: String = row.try_get("is_nullable").unwrap();
|
|
let default: Option<String> = row.try_get("column_default").unwrap();
|
|
|
|
if table != current_table {
|
|
writeln!(out, "\ntable: {table}").unwrap();
|
|
current_table = table;
|
|
}
|
|
// Use udt_name when present (catches uuid, jsonb, etc. which
|
|
// information_schema reports as "USER-DEFINED").
|
|
let kind = if data_type == "USER-DEFINED" {
|
|
udt
|
|
} else {
|
|
data_type
|
|
};
|
|
let null_str = if nullable == "YES" {
|
|
"NULL"
|
|
} else {
|
|
"NOT NULL"
|
|
};
|
|
let default_str = default.map(|d| format!(" default={d}")).unwrap_or_default();
|
|
writeln!(out, " {column}: {kind} {null_str}{default_str}").unwrap();
|
|
}
|
|
writeln!(out).unwrap();
|
|
}
|
|
|
|
async fn dump_indexes(pool: &PgPool, out: &mut String) {
|
|
// pg_indexes is the most portable source of truth for index DDL.
|
|
let rows = sqlx::query(
|
|
"SELECT tablename, indexname, indexdef \
|
|
FROM pg_indexes \
|
|
WHERE schemaname = $1 \
|
|
AND tablename NOT LIKE '\\_sqlx%' \
|
|
ORDER BY tablename, indexname",
|
|
)
|
|
.bind(SCHEMA)
|
|
.fetch_all(pool)
|
|
.await
|
|
.expect("failed to query pg_indexes");
|
|
|
|
writeln!(out, "## indexes").unwrap();
|
|
let mut current_table = String::new();
|
|
for row in rows {
|
|
let table: String = row.try_get("tablename").unwrap();
|
|
let name: String = row.try_get("indexname").unwrap();
|
|
let def: String = row.try_get("indexdef").unwrap();
|
|
if table != current_table {
|
|
writeln!(out, "\nindexes on {table}:").unwrap();
|
|
current_table = table;
|
|
}
|
|
// The CREATE INDEX prefix is uniform across all rows; strip it
|
|
// to keep the snapshot focused on what's distinctive.
|
|
let summary = def
|
|
.split_once(" ON ")
|
|
.map_or(def.as_str(), |(_, after)| after);
|
|
writeln!(out, " {name}: {summary}").unwrap();
|
|
}
|
|
writeln!(out).unwrap();
|
|
}
|
|
|
|
async fn dump_constraints(pool: &PgPool, out: &mut String) {
|
|
// CHECK / FK / UNIQUE / PRIMARY KEY definitions live across a few
|
|
// information_schema views. pg_constraint via pg_get_constraintdef
|
|
// gives the canonical text in one query.
|
|
let rows = sqlx::query(
|
|
"SELECT \
|
|
cl.relname AS table_name, \
|
|
con.conname AS constraint_name, \
|
|
con.contype::text AS contype, \
|
|
pg_get_constraintdef(con.oid) AS definition \
|
|
FROM pg_constraint con \
|
|
JOIN pg_class cl ON cl.oid = con.conrelid \
|
|
JOIN pg_namespace ns ON ns.oid = cl.relnamespace \
|
|
WHERE ns.nspname = $1 \
|
|
AND cl.relname NOT LIKE '\\_sqlx%' \
|
|
ORDER BY cl.relname, con.contype, con.conname",
|
|
)
|
|
.bind(SCHEMA)
|
|
.fetch_all(pool)
|
|
.await
|
|
.expect("failed to query pg_constraint");
|
|
|
|
writeln!(out, "## constraints").unwrap();
|
|
let mut current_table = String::new();
|
|
for row in rows {
|
|
let table: String = row.try_get("table_name").unwrap();
|
|
let name: String = row.try_get("constraint_name").unwrap();
|
|
let kind_raw: String = row.try_get("contype").unwrap();
|
|
let def: String = row.try_get("definition").unwrap();
|
|
let kind = match kind_raw.as_str() {
|
|
"p" => "PRIMARY KEY",
|
|
"f" => "FOREIGN KEY",
|
|
"u" => "UNIQUE",
|
|
"c" => "CHECK",
|
|
other => other,
|
|
};
|
|
if table != current_table {
|
|
writeln!(out, "\nconstraints on {table}:").unwrap();
|
|
current_table = table;
|
|
}
|
|
writeln!(out, " [{kind}] {name}: {def}").unwrap();
|
|
}
|
|
writeln!(out).unwrap();
|
|
}
|
|
|
|
async fn dump_migrations(pool: &PgPool, out: &mut String) {
|
|
// Record the migration versions that ran so the snapshot reflects
|
|
// the migration set used to build it. A schema that looks identical
|
|
// but was produced by a different migration sequence would still
|
|
// surface here.
|
|
let rows = sqlx::query("SELECT version, description FROM _sqlx_migrations ORDER BY version")
|
|
.fetch_all(pool)
|
|
.await
|
|
.expect("failed to query _sqlx_migrations");
|
|
|
|
writeln!(out, "## applied migrations").unwrap();
|
|
for row in rows {
|
|
let version: i64 = row.try_get("version").unwrap();
|
|
let description: String = row.try_get("description").unwrap();
|
|
writeln!(out, " {version:04}: {description}").unwrap();
|
|
}
|
|
}
|