diff --git a/.pgschemaignore b/.pgschemaignore new file mode 100644 index 0000000000..059350e198 --- /dev/null +++ b/.pgschemaignore @@ -0,0 +1,25 @@ +# pgschema reads this file from its working directory; every caller runs it +# from the repository root. +# +# The relay's partition manager creates dated `events` / `delivery_log` +# monthlies at runtime (see crates/buzz-db/src/store/partition/). They are not +# declared in schema/partitions/, so without this file re-applying pgschema to a +# database a relay has used drops them, and their rows, with +# `DROP TABLE IF EXISTS ... CASCADE`. Only the monthlies schema/partitions/ +# declares stay managed by pgschema; keep the `!` exemptions below in sync with +# them. +[tables] +patterns = [ + "events_p2*", + "!events_p2026_01", + "!events_p2026_02", + "!events_p2026_03", + "!events_p2026_04", + "!events_p2026_05", + "!events_p2026_06", + "delivery_log_p2*", + "!delivery_log_p2026_03", + "!delivery_log_p2026_04", + "!delivery_log_p2026_05", + "!delivery_log_p2026_06", +] diff --git a/AGENTS.md b/AGENTS.md index c2aba29217..665ac30312 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -613,7 +613,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803). 4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command. 5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly. 6. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing. -7. **`pgschema` omits seed DML and some storage parameters** — `schema/schema.sql` is an ordered `\i` manifest: `schema/tables/public/.sql` holds only that table's `CREATE TABLE` and `CREATE INDEX` statements (one table per file, the shape out-of-band schema tools read), and types, functions, partitions, triggers, and seed rows live in the sibling `schema/` directories. Fresh desired-state bootstraps use `./bin/pgschema apply`, which does not execute `INSERT` statements or preserve every table storage parameter from `schema/schema.sql`. Put each unsupported invariant in `scripts/reconcile-schema-after-pgschema.sql` as an idempotent convergence statement plus a live catalog or data assertion. Every `pgschema apply` caller must run that script. A string assertion against `schema.sql` alone does not prove the pgschema-created database has the intended state. +7. **`pgschema` omits seed DML and some storage parameters** — `schema/schema.sql` is an ordered `\i` manifest: `schema/tables/public/
.sql` holds only that table's `CREATE TABLE` and `CREATE INDEX` statements (one table per file, the shape out-of-band schema tools read), and types, functions, partitions, triggers, and seed rows live in the sibling `schema/` directories. Fresh desired-state bootstraps use `./bin/pgschema apply`, which does not execute `INSERT` statements or preserve every table storage parameter from `schema/schema.sql`. Put each unsupported invariant in `scripts/reconcile-schema-after-pgschema.sql` as an idempotent convergence statement plus a live catalog or data assertion. Every `pgschema apply` caller must run that script, and must run pgschema from the repository root so it reads `.pgschemaignore`; from any other directory a re-apply plans to drop the partitions the relay created at runtime. A string assertion against `schema.sql` alone does not prove the pgschema-created database has the intended state. --- diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index d194f0fa35..af87733252 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -236,6 +236,66 @@ mod postgres_tests { .unwrap_or_default() } + /// Runs `bin/pgschema ` against `db`, which doubles as the plan + /// database (`--plan-*`): that avoids the embedded-Postgres download and + /// matches start-relay-for-tests.sh. Runs from the repository root, like + /// every pgschema caller, so `.pgschemaignore` applies. + fn pgschema(conn: &PgConn, db: &str, command: &[&str]) -> std::process::Output { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let port = conn.port.to_string(); + std::process::Command::new(repo_root.join("bin/pgschema")) + .current_dir(&repo_root) + .args(command) + .args([ + "--file", + "schema/schema.sql", + "--host", + &conn.host, + "--port", + &port, + "--user", + &conn.user, + "--password", + &conn.password, + "--db", + db, + "--plan-host", + &conn.host, + "--plan-port", + &port, + "--plan-user", + &conn.user, + "--plan-password", + &conn.password, + "--plan-db", + db, + ]) + .output() + .expect("run bin/pgschema (hermit env required)") + } + + /// Dated monthlies `schema/partitions/` declares, e.g. `events_p2026_01`. + fn declared_monthlies() -> BTreeSet { + let dir = crate::test_support::desired_state_schema_dir().join("partitions"); + let mut names = BTreeSet::new(); + for entry in fs::read_dir(&dir).expect("read schema/partitions") { + let sql = fs::read_to_string(entry.expect("partition file").path()) + .expect("read partition file"); + names.extend( + sql.lines() + .filter_map(|line| line.strip_prefix("CREATE TABLE ")) + .filter_map(|rest| rest.split_whitespace().next()) + .filter(|name| { + name.rsplit_once("_p").is_some_and(|(_, month)| { + month.starts_with(|c: char| c.is_ascii_digit()) + }) + }) + .map(str::to_owned), + ); + } + names + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { ForeignKey, @@ -1517,6 +1577,32 @@ mod postgres_tests { ); } + /// `.pgschemaignore` hides dated monthlies from pgschema so a re-apply + /// cannot drop the ones the relay created at runtime. Each monthly + /// `schema/partitions/` declares must be exempt, or pgschema would stop + /// creating it on a fresh database. + #[test] + fn pgschemaignore_exempts_exactly_the_declared_monthlies() { + let ignore = + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.pgschemaignore")) + .expect("read .pgschemaignore"); + let exempt: BTreeSet = ignore + .lines() + .filter_map(|line| { + line.trim() + .trim_end_matches(',') + .strip_prefix("\"!")? + .strip_suffix('"') + }) + .map(str::to_owned) + .collect(); + assert_eq!( + exempt, + declared_monthlies(), + ".pgschemaignore `!` exemptions must match the monthlies schema/partitions/ declares" + ); + } + #[test] fn workflow_run_error_codes_are_additive_and_backfilled_without_parsing_diagnostics() { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); @@ -2703,42 +2789,8 @@ mod postgres_tests { .expect("create migrated probe database"); // Bootstrap the desired-state probe through the real pgschema binary, the - // same invocation the test-relay launchers use. The freshly-created probe - // db doubles as pgschema's plan database (--plan-*), which avoids the - // embedded-Postgres download and matches start-relay-for-tests.sh. - let pgschema = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bin/pgschema"); - let schema_file = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema/schema.sql"); - let port = conn.port.to_string(); - let apply = std::process::Command::new(&pgschema) - .args([ - "apply", - "--auto-approve", - "--file", - schema_file.to_str().expect("schema path utf-8"), - "--host", - &conn.host, - "--port", - &port, - "--user", - &conn.user, - "--password", - &conn.password, - "--db", - &desired_db, - "--plan-host", - &conn.host, - "--plan-port", - &port, - "--plan-user", - &conn.user, - "--plan-password", - &conn.password, - "--plan-db", - &desired_db, - ]) - .output() - .expect("run bin/pgschema apply (hermit env required)"); + // same invocation the test-relay launchers use. + let apply = pgschema(&conn, &desired_db, &["apply", "--auto-approve"]); assert!( apply.status.success(), "pgschema apply failed: {}\n{}", @@ -2790,6 +2842,134 @@ mod postgres_tests { } } + /// Re-applying `schema/schema.sql` to a database a relay has used plans + /// `DROP TABLE IF EXISTS ... CASCADE` for every monthly the partition + /// manager created at runtime, and their rows go with them. + /// `.pgschemaignore` keeps those monthlies out of pgschema's view, and + /// pgschema does not compare partition bounds, so the advanced catch-all + /// is left alone too. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn pgschema_reapply_keeps_runtime_partitions() { + use crate::store::partition::{ + maintain_partitions, PartitionMaintenancePolicy, PARTITION_MANAGER_MONTHS_AHEAD, + }; + use sqlx::AssertSqlSafe; + + let base_url = crate::test_support::database_url(); + let conn = parse_pg_url(&base_url); + let admin = PgPool::connect(&base_url) + .await + .expect("connect admin database"); + let (base_prefix, _) = base_url.rsplit_once('/').expect("database url has a path"); + let database = format!("buzz_pgschema_reapply_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {database}"))) + .execute(&admin) + .await + .expect("create probe database"); + + let apply = pgschema(&conn, &database, &["apply", "--auto-approve"]); + assert!( + apply.status.success(), + "pgschema apply failed: {}\n{}", + String::from_utf8_lossy(&apply.stdout), + String::from_utf8_lossy(&apply.stderr), + ); + + let pool = PgPool::connect(&format!("{base_prefix}/{database}")) + .await + .expect("connect probe database"); + // Every pgschema caller attaches the declared children this way. + sqlx::raw_sql(include_str!( + "../../../../scripts/reconcile-schema-after-pgschema.sql" + )) + .execute(&pool) + .await + .expect("reconcile schema after pgschema"); + async fn catch_all_bounds(pool: &PgPool) -> Vec<(String, Option)> { + sqlx::query_as( + "SELECT relname::text, pg_get_expr(relpartbound, oid) FROM pg_class \ + WHERE relname IN ('events_p_future', 'delivery_log_p_future') \ + ORDER BY relname", + ) + .fetch_all(pool) + .await + .expect("read catch-all bounds") + } + let declared_bounds = catch_all_bounds(&pool).await; + assert!( + declared_bounds.len() == 2 && declared_bounds.iter().all(|(_, bound)| bound.is_some()), + "reconcile did not attach both catch-alls: {declared_bounds:?}" + ); + + // The relay's default startup pass advances each catch-all into dated + // monthlies schema/partitions/ does not declare. + maintain_partitions( + &pool, + PARTITION_MANAGER_MONTHS_AHEAD, + PartitionMaintenancePolicy { + create_enabled: true, + advance_enabled: true, + }, + ) + .await + .expect("maintain partitions"); + let advanced_bounds = catch_all_bounds(&pool).await; + for (declared, advanced) in declared_bounds.iter().zip(&advanced_bounds) { + assert_ne!(declared, advanced, "catch-all did not advance"); + } + let children: Vec<(String, String)> = sqlx::query_as( + "SELECT p.relname::text, c.relname::text FROM pg_inherits i \ + JOIN pg_class c ON c.oid = i.inhrelid \ + JOIN pg_class p ON p.oid = i.inhparent \ + WHERE p.relname IN ('events', 'delivery_log')", + ) + .fetch_all(&pool) + .await + .expect("list partitions"); + pool.close().await; + let declared = declared_monthlies(); + let runtime: Vec<_> = children + .iter() + .filter(|(parent, child)| { + child.starts_with(&format!("{parent}_p2")) && !declared.contains(child) + }) + .collect(); + for parent in ["events", "delivery_log"] { + // The first month the declared catch-all served. + let first = format!("{parent}_p2026_07"); + assert!( + runtime.iter().any(|(_, child)| *child == first), + "maintenance did not advance into {first}: {children:?}" + ); + } + + let plan = pgschema(&conn, &database, &["plan", "--output-sql", "stdout"]); + let plan_sql = String::from_utf8_lossy(&plan.stdout); + assert!( + plan.status.success(), + "pgschema plan failed: {plan_sql}\n{}", + String::from_utf8_lossy(&plan.stderr), + ); + assert!( + !plan_sql.contains("DROP TABLE"), + "re-applying schema.sql would drop tables:\n{plan_sql}" + ); + for (_, child) in &runtime { + assert!( + !plan_sql.contains(child.as_str()), + "re-applying schema.sql would change runtime monthly {child}:\n{plan_sql}" + ); + } + + sqlx::query(AssertSqlSafe(format!( + "DROP DATABASE {database} WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop probe database"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn pre_0007_ambiguous_nip_rs_data_blocks_without_mutation_and_allows_retry() { diff --git a/schema/partitions/delivery_log.sql b/schema/partitions/delivery_log.sql index 9314b0b4b8..f37c086585 100644 --- a/schema/partitions/delivery_log.sql +++ b/schema/partitions/delivery_log.sql @@ -1,3 +1,7 @@ +-- The static layout migrations/0001 creates. The relay's partition manager +-- (crates/buzz-db/src/store/partition/) adds later monthlies at runtime; +-- .pgschemaignore hides those from pgschema and must exempt every dated +-- monthly declared here. CREATE TABLE delivery_log_p_past PARTITION OF delivery_log FOR VALUES FROM (MINVALUE) TO ('2026-03-01'); CREATE TABLE delivery_log_p2026_03 PARTITION OF delivery_log diff --git a/schema/partitions/events.sql b/schema/partitions/events.sql index ffb257390c..e5c9ef75d4 100644 --- a/schema/partitions/events.sql +++ b/schema/partitions/events.sql @@ -1,3 +1,7 @@ +-- The static layout migrations/0001 creates. The relay's partition manager +-- (crates/buzz-db/src/store/partition/) adds later monthlies at runtime; +-- .pgschemaignore hides those from pgschema and must exempt every dated +-- monthly declared here. CREATE TABLE events_p_past PARTITION OF events FOR VALUES FROM (MINVALUE) TO ('2026-01-01'); CREATE TABLE events_p2026_01 PARTITION OF events