Skip to content

Commit 2300248

Browse files
tlongwell-blocknpub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgytanpub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
authored
Add automatic database migrations (#988)
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
1 parent f871561 commit 2300248

15 files changed

Lines changed: 703 additions & 120 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ unacceptable behavior to **conduct@buzz-relay.org**.
4646
| Docker | 24+ | For Postgres, Redis, Typesense |
4747
| `just` | latest | Task runner — `cargo install just` |
4848
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
49-
| `pgschema` | latest | Schema tool — `just migrate` applies `schema/schema.sql` declaratively |
49+
| `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` |
5050

5151
This repo uses [Hermit](https://cashapp.github.io/hermit/) for toolchain
5252
pinning. Activate it once per shell session:

TESTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
270270
| `TYPESENSE_URL` | `http://localhost:8108` | |
271271
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
272272
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
273+
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
273274
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
274275
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
275276

crates/buzz-admin/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ enum Command {
3434
ListMembers,
3535
/// Generate a new Nostr keypair (for bootstrapping).
3636
GenerateKey,
37+
/// Run pending database migrations.
38+
Migrate,
3739
/// Emit kind:39000/39002 events for channels missing them.
3840
///
3941
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
@@ -59,6 +61,11 @@ async fn main() -> Result<()> {
5961
println!("Secret key: {}", keys.secret_key().display_secret());
6062
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
6163
}
64+
Command::Migrate => {
65+
let db = connect_db().await?;
66+
db.migrate().await?;
67+
println!("Database migrations complete.");
68+
}
6269
Command::AddMember { pubkey, role } => {
6370
let db = connect_db().await?;
6471
let pk_bytes = hex::decode(&pubkey)?;

crates/buzz-db/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ pub enum DbError {
99
#[error("database error: {0}")]
1010
Sqlx(#[from] sqlx::Error),
1111

12+
/// A SQLx migration error.
13+
#[error("migration error: {0}")]
14+
Migrate(#[from] sqlx::migrate::MigrateError),
15+
1216
/// Attempted to store an AUTH event (kind 22242), which is forbidden.
1317
#[error("AUTH events (kind 22242) must not be stored")]
1418
AuthEventRejected,

crates/buzz-db/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pub mod error;
2323
pub mod event;
2424
/// Home feed queries.
2525
pub mod feed;
26+
/// Embedded database migrations.
27+
pub mod migration;
2628
/// Monthly table partition management.
2729
pub mod partition;
2830
/// Reaction persistence.
@@ -196,6 +198,11 @@ impl Db {
196198
Self { pool }
197199
}
198200

201+
/// Run pending database migrations.
202+
pub async fn migrate(&self) -> Result<()> {
203+
migration::run_migrations(&self.pool).await
204+
}
205+
199206
/// Returns `true` if the database is reachable (used by readiness probes).
200207
pub async fn ping(&self) -> bool {
201208
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()

crates/buzz-db/src/migration.rs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
//! Embedded SQLx migrations for Buzz.
2+
//!
3+
//! Fresh deployments apply the checked-in SQL files under `migrations/`.
4+
//! Existing pre-SQLx deployments are baselined when core Buzz tables already
5+
//! exist but `_sqlx_migrations` does not, so startup will not try to replay the
6+
//! initial schema over a live database.
7+
8+
use sqlx::PgPool;
9+
10+
use crate::Result;
11+
12+
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
13+
14+
#[cfg(test)]
15+
static SCHEMA_SQL: &str = include_str!("../../../schema/schema.sql");
16+
17+
const BASELINE_MIGRATION_VERSIONS: &[i64] = &[1, 2];
18+
19+
/// Run all pending Buzz database migrations.
20+
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
21+
baseline_existing_database(pool).await?;
22+
MIGRATOR.run(pool).await?;
23+
Ok(())
24+
}
25+
26+
async fn baseline_existing_database(pool: &PgPool) -> Result<()> {
27+
if migrations_table_exists(pool).await? || !pre_sqlx_schema_exists(pool).await? {
28+
return Ok(());
29+
}
30+
31+
ensure_migrations_table(pool).await?;
32+
33+
for version in BASELINE_MIGRATION_VERSIONS {
34+
let migration = MIGRATOR
35+
.iter()
36+
.find(|migration| migration.version == *version)
37+
.expect("baseline migration version must exist in embedded migrator");
38+
39+
sqlx::query(
40+
r#"
41+
INSERT INTO _sqlx_migrations
42+
(version, description, success, checksum, execution_time)
43+
VALUES ($1, $2, TRUE, $3, 0)
44+
ON CONFLICT (version) DO NOTHING
45+
"#,
46+
)
47+
.bind(migration.version)
48+
.bind(&*migration.description)
49+
.bind(&*migration.checksum)
50+
.execute(pool)
51+
.await?;
52+
}
53+
54+
tracing::info!(
55+
versions = ?BASELINE_MIGRATION_VERSIONS,
56+
"Baselined existing Buzz database for SQLx migrations"
57+
);
58+
59+
Ok(())
60+
}
61+
62+
async fn migrations_table_exists(pool: &PgPool) -> Result<bool> {
63+
let exists = sqlx::query_scalar::<_, bool>(
64+
r#"
65+
SELECT EXISTS (
66+
SELECT 1
67+
FROM information_schema.tables
68+
WHERE table_schema = 'public'
69+
AND table_name = '_sqlx_migrations'
70+
)
71+
"#,
72+
)
73+
.fetch_one(pool)
74+
.await?;
75+
76+
Ok(exists)
77+
}
78+
79+
async fn pre_sqlx_schema_exists(pool: &PgPool) -> Result<bool> {
80+
let exists = sqlx::query_scalar::<_, bool>(
81+
r#"
82+
SELECT EXISTS (
83+
SELECT 1
84+
FROM information_schema.tables
85+
WHERE table_schema = 'public'
86+
AND table_name = 'events'
87+
) AND EXISTS (
88+
SELECT 1
89+
FROM information_schema.tables
90+
WHERE table_schema = 'public'
91+
AND table_name = 'channels'
92+
)
93+
"#,
94+
)
95+
.fetch_one(pool)
96+
.await?;
97+
98+
Ok(exists)
99+
}
100+
101+
async fn ensure_migrations_table(pool: &PgPool) -> Result<()> {
102+
sqlx::query(
103+
r#"
104+
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
105+
version BIGINT PRIMARY KEY,
106+
description TEXT NOT NULL,
107+
installed_on TIMESTAMPTZ NOT NULL DEFAULT now(),
108+
success BOOLEAN NOT NULL,
109+
checksum BYTEA NOT NULL,
110+
execution_time BIGINT NOT NULL
111+
)
112+
"#,
113+
)
114+
.execute(pool)
115+
.await?;
116+
117+
Ok(())
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
use sqlx::PgPool;
124+
125+
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
126+
127+
#[test]
128+
fn embedded_migrator_contains_initial_schema_and_d_tag_backfill() {
129+
let migrations: Vec<_> = MIGRATOR.iter().collect();
130+
131+
assert_eq!(migrations.len(), 2);
132+
assert_eq!(migrations[0].version, 1);
133+
assert_eq!(&*migrations[0].description, "initial schema");
134+
assert!(
135+
migrations[0].sql.as_str().contains("CREATE TABLE channels"),
136+
"initial schema migration should include Buzz core tables"
137+
);
138+
assert!(
139+
migrations[0]
140+
.sql
141+
.as_str()
142+
.contains("CREATE TABLE IF NOT EXISTS relay_members"),
143+
"initial schema migration should include relay_members"
144+
);
145+
146+
assert_eq!(migrations[1].version, 2);
147+
assert_eq!(&*migrations[1].description, "backfill d tag");
148+
assert!(
149+
migrations[1].sql.as_str().contains("UPDATE events"),
150+
"second migration should backfill existing event rows"
151+
);
152+
}
153+
154+
async fn connect_test_pool() -> PgPool {
155+
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
156+
.or_else(|_| std::env::var("DATABASE_URL"))
157+
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
158+
159+
PgPool::connect(&database_url)
160+
.await
161+
.expect("connect to test DB")
162+
}
163+
164+
async fn reset_public_schema(pool: &PgPool) {
165+
sqlx::query("DROP SCHEMA IF EXISTS public CASCADE")
166+
.execute(pool)
167+
.await
168+
.expect("drop public schema");
169+
sqlx::query("CREATE SCHEMA IF NOT EXISTS public")
170+
.execute(pool)
171+
.await
172+
.expect("create public schema");
173+
}
174+
175+
async fn applied_versions(pool: &PgPool) -> Vec<i64> {
176+
sqlx::query_scalar::<_, i64>(
177+
"SELECT version FROM _sqlx_migrations WHERE success ORDER BY version",
178+
)
179+
.fetch_all(pool)
180+
.await
181+
.expect("read applied migrations")
182+
}
183+
184+
#[tokio::test]
185+
#[ignore = "requires Postgres"]
186+
async fn run_migrations_applies_embedded_versions_on_fresh_database() {
187+
let pool = connect_test_pool().await;
188+
reset_public_schema(&pool).await;
189+
190+
run_migrations(&pool).await.expect("run migrations");
191+
192+
assert_eq!(applied_versions(&pool).await, vec![1, 2]);
193+
let events_exists = sqlx::query_scalar::<_, bool>(
194+
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'events')",
195+
)
196+
.fetch_one(&pool)
197+
.await
198+
.expect("check events table");
199+
assert!(events_exists);
200+
}
201+
202+
#[tokio::test]
203+
#[ignore = "requires Postgres"]
204+
async fn run_migrations_baselines_existing_schema_and_preserves_allowlist_backfill_path() {
205+
let pool = connect_test_pool().await;
206+
reset_public_schema(&pool).await;
207+
sqlx::raw_sql(SCHEMA_SQL)
208+
.execute(&pool)
209+
.await
210+
.expect("load pre-SQLx schema snapshot");
211+
sqlx::query(
212+
"INSERT INTO pubkey_allowlist (pubkey, added_at) VALUES (decode($1, 'hex'), now())",
213+
)
214+
.bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
215+
.execute(&pool)
216+
.await
217+
.expect("seed legacy allowlist row");
218+
219+
run_migrations(&pool).await.expect("baseline migrations");
220+
221+
assert_eq!(applied_versions(&pool).await, vec![1, 2]);
222+
let allowlist_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pubkey_allowlist")
223+
.fetch_one(&pool)
224+
.await
225+
.expect("count allowlist rows");
226+
assert_eq!(
227+
allowlist_count, 1,
228+
"baseline must not drop legacy allowlist rows before relay startup backfills them"
229+
);
230+
231+
let inserted = crate::relay_members::backfill_from_allowlist(&pool)
232+
.await
233+
.expect("backfill legacy allowlist rows");
234+
assert_eq!(inserted, 1);
235+
let relay_member_count = sqlx::query_scalar::<_, i64>(
236+
"SELECT COUNT(*) FROM relay_members WHERE pubkey = $1 AND role = 'member'",
237+
)
238+
.bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
239+
.fetch_one(&pool)
240+
.await
241+
.expect("count backfilled relay member");
242+
assert_eq!(relay_member_count, 1);
243+
}
244+
}

crates/buzz-relay/src/main.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ use buzz_relay::router::{build_health_router, build_router};
1616
use buzz_relay::state::AppState;
1717
use buzz_workflow::WorkflowEngine;
1818

19+
fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool {
20+
value.map(str::trim).is_some_and(|value| {
21+
matches!(
22+
value.to_ascii_lowercase().as_str(),
23+
"true" | "1" | "yes" | "on"
24+
)
25+
})
26+
}
27+
1928
#[tokio::main]
2029
async fn main() -> anyhow::Result<()> {
2130
// JSON-only structured logs — simple, machine-parseable, CAKE-compatible.
@@ -55,6 +64,18 @@ async fn main() -> anyhow::Result<()> {
5564
})?;
5665
info!("Postgres connected");
5766

67+
let auto_migrate =
68+
buzz_auto_migrate_enabled(std::env::var("BUZZ_AUTO_MIGRATE").ok().as_deref());
69+
if auto_migrate {
70+
db.migrate().await.map_err(|e| {
71+
error!("Failed to run database migrations: {e}");
72+
anyhow::anyhow!("Database migration failed: {e}")
73+
})?;
74+
info!("Database migrations complete");
75+
} else {
76+
info!("Skipping database migrations because BUZZ_AUTO_MIGRATE is not enabled");
77+
}
78+
5879
if let Err(e) = db.ensure_future_partitions(3).await {
5980
error!("Failed to ensure partitions: {e}");
6081
}
@@ -604,3 +625,22 @@ async fn shutdown_signal() {
604625
tokio::signal::ctrl_c().await.ok();
605626
}
606627
}
628+
#[cfg(test)]
629+
mod tests {
630+
use super::buzz_auto_migrate_enabled;
631+
632+
#[test]
633+
fn buzz_auto_migrate_is_opt_in() {
634+
assert!(!buzz_auto_migrate_enabled(None));
635+
assert!(!buzz_auto_migrate_enabled(Some("")));
636+
assert!(!buzz_auto_migrate_enabled(Some("false")));
637+
assert!(!buzz_auto_migrate_enabled(Some("0")));
638+
assert!(!buzz_auto_migrate_enabled(Some("no")));
639+
640+
assert!(buzz_auto_migrate_enabled(Some("true")));
641+
assert!(buzz_auto_migrate_enabled(Some("TRUE")));
642+
assert!(buzz_auto_migrate_enabled(Some(" 1 ")));
643+
assert!(buzz_auto_migrate_enabled(Some("yes")));
644+
assert!(buzz_auto_migrate_enabled(Some("on")));
645+
}
646+
}

deploy/compose/README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,10 @@ keypair.
3232
Typesense, and S3 secrets stable across restarts.
3333
- `RELAY_OWNER_PUBKEY` is intentionally not prefixed with `BUZZ_`; it must be a
3434
64-character hex Nostr pubkey when closed relay mode is enabled.
35-
- `BUZZ_AUTO_MIGRATE=true` requires an image that includes embedded SQLx
36-
migrations. Do not share this quick start for a fresh public install until PR
37-
#988 is merged and `ghcr.io/block/buzz:main` has been rebuilt from it. Before
38-
then, this bundle is only suitable for instances whose database schema has
39-
already been applied.
35+
- `BUZZ_AUTO_MIGRATE` is opt-in. Set `BUZZ_AUTO_MIGRATE=true` or run
36+
`buzz-admin migrate` before starting the relay when bootstrapping a fresh
37+
database. Auto-migration requires an image that includes embedded SQLx
38+
migrations.
4039
- The stack uses Postgres, Redis, Typesense, MinIO, and a git data volume because
4140
those are real Buzz dependencies today. Minimal mode can simplify this later.
4241

deploy/compose/compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ services:
1717
BUZZ_S3_SECRET_KEY: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY}
1818
BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media}
1919
BUZZ_GIT_REPO_PATH: /data/git
20-
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-true}
20+
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-false}
2121
BUZZ_GIT_CONFORMANCE_PROBE: ${BUZZ_GIT_CONFORMANCE_PROBE:-true}
2222
ports:
2323
- "${BUZZ_HTTP_PORT:-3000}:3000"

0 commit comments

Comments
 (0)