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>
This commit is contained in:
tlongwell-block
2026-06-16 08:39:19 -04:00
committed by GitHub
co-authored by npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent f8715612af
commit 2300248d3b
15 changed files with 703 additions and 120 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ unacceptable behavior to **conduct@buzz-relay.org**.
| Docker | 24+ | For Postgres, Redis, Typesense |
| `just` | latest | Task runner — `cargo install just` |
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
| `pgschema` | latest | Schema tool — `just migrate` applies `schema/schema.sql` declaratively |
| `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` |
This repo uses [Hermit](https://cashapp.github.io/hermit/) for toolchain
pinning. Activate it once per shell session:
+1
View File
@@ -270,6 +270,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `TYPESENSE_URL` | `http://localhost:8108` | |
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
+7
View File
@@ -34,6 +34,8 @@ enum Command {
ListMembers,
/// Generate a new Nostr keypair (for bootstrapping).
GenerateKey,
/// Run pending database migrations.
Migrate,
/// Emit kind:39000/39002 events for channels missing them.
///
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
@@ -59,6 +61,11 @@ async fn main() -> Result<()> {
println!("Secret key: {}", keys.secret_key().display_secret());
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
}
Command::Migrate => {
let db = connect_db().await?;
db.migrate().await?;
println!("Database migrations complete.");
}
Command::AddMember { pubkey, role } => {
let db = connect_db().await?;
let pk_bytes = hex::decode(&pubkey)?;
+4
View File
@@ -9,6 +9,10 @@ pub enum DbError {
#[error("database error: {0}")]
Sqlx(#[from] sqlx::Error),
/// A SQLx migration error.
#[error("migration error: {0}")]
Migrate(#[from] sqlx::migrate::MigrateError),
/// Attempted to store an AUTH event (kind 22242), which is forbidden.
#[error("AUTH events (kind 22242) must not be stored")]
AuthEventRejected,
+7
View File
@@ -23,6 +23,8 @@ pub mod error;
pub mod event;
/// Home feed queries.
pub mod feed;
/// Embedded database migrations.
pub mod migration;
/// Monthly table partition management.
pub mod partition;
/// Reaction persistence.
@@ -196,6 +198,11 @@ impl Db {
Self { pool }
}
/// Run pending database migrations.
pub async fn migrate(&self) -> Result<()> {
migration::run_migrations(&self.pool).await
}
/// Returns `true` if the database is reachable (used by readiness probes).
pub async fn ping(&self) -> bool {
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()
+244
View File
@@ -0,0 +1,244 @@
//! Embedded SQLx migrations for Buzz.
//!
//! Fresh deployments apply the checked-in SQL files under `migrations/`.
//! Existing pre-SQLx deployments are baselined when core Buzz tables already
//! exist but `_sqlx_migrations` does not, so startup will not try to replay the
//! initial schema over a live database.
use sqlx::PgPool;
use crate::Result;
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
#[cfg(test)]
static SCHEMA_SQL: &str = include_str!("../../../schema/schema.sql");
const BASELINE_MIGRATION_VERSIONS: &[i64] = &[1, 2];
/// Run all pending Buzz database migrations.
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
baseline_existing_database(pool).await?;
MIGRATOR.run(pool).await?;
Ok(())
}
async fn baseline_existing_database(pool: &PgPool) -> Result<()> {
if migrations_table_exists(pool).await? || !pre_sqlx_schema_exists(pool).await? {
return Ok(());
}
ensure_migrations_table(pool).await?;
for version in BASELINE_MIGRATION_VERSIONS {
let migration = MIGRATOR
.iter()
.find(|migration| migration.version == *version)
.expect("baseline migration version must exist in embedded migrator");
sqlx::query(
r#"
INSERT INTO _sqlx_migrations
(version, description, success, checksum, execution_time)
VALUES ($1, $2, TRUE, $3, 0)
ON CONFLICT (version) DO NOTHING
"#,
)
.bind(migration.version)
.bind(&*migration.description)
.bind(&*migration.checksum)
.execute(pool)
.await?;
}
tracing::info!(
versions = ?BASELINE_MIGRATION_VERSIONS,
"Baselined existing Buzz database for SQLx migrations"
);
Ok(())
}
async fn migrations_table_exists(pool: &PgPool) -> Result<bool> {
let exists = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = '_sqlx_migrations'
)
"#,
)
.fetch_one(pool)
.await?;
Ok(exists)
}
async fn pre_sqlx_schema_exists(pool: &PgPool) -> Result<bool> {
let exists = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'events'
) AND EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'channels'
)
"#,
)
.fetch_one(pool)
.await?;
Ok(exists)
}
async fn ensure_migrations_table(pool: &PgPool) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
version BIGINT PRIMARY KEY,
description TEXT NOT NULL,
installed_on TIMESTAMPTZ NOT NULL DEFAULT now(),
success BOOLEAN NOT NULL,
checksum BYTEA NOT NULL,
execution_time BIGINT NOT NULL
)
"#,
)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::PgPool;
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
#[test]
fn embedded_migrator_contains_initial_schema_and_d_tag_backfill() {
let migrations: Vec<_> = MIGRATOR.iter().collect();
assert_eq!(migrations.len(), 2);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(
migrations[0].sql.as_str().contains("CREATE TABLE channels"),
"initial schema migration should include Buzz core tables"
);
assert!(
migrations[0]
.sql
.as_str()
.contains("CREATE TABLE IF NOT EXISTS relay_members"),
"initial schema migration should include relay_members"
);
assert_eq!(migrations[1].version, 2);
assert_eq!(&*migrations[1].description, "backfill d tag");
assert!(
migrations[1].sql.as_str().contains("UPDATE events"),
"second migration should backfill existing event rows"
);
}
async fn connect_test_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
PgPool::connect(&database_url)
.await
.expect("connect to test DB")
}
async fn reset_public_schema(pool: &PgPool) {
sqlx::query("DROP SCHEMA IF EXISTS public CASCADE")
.execute(pool)
.await
.expect("drop public schema");
sqlx::query("CREATE SCHEMA IF NOT EXISTS public")
.execute(pool)
.await
.expect("create public schema");
}
async fn applied_versions(pool: &PgPool) -> Vec<i64> {
sqlx::query_scalar::<_, i64>(
"SELECT version FROM _sqlx_migrations WHERE success ORDER BY version",
)
.fetch_all(pool)
.await
.expect("read applied migrations")
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn run_migrations_applies_embedded_versions_on_fresh_database() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
run_migrations(&pool).await.expect("run migrations");
assert_eq!(applied_versions(&pool).await, vec![1, 2]);
let events_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'events')",
)
.fetch_one(&pool)
.await
.expect("check events table");
assert!(events_exists);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn run_migrations_baselines_existing_schema_and_preserves_allowlist_backfill_path() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
sqlx::raw_sql(SCHEMA_SQL)
.execute(&pool)
.await
.expect("load pre-SQLx schema snapshot");
sqlx::query(
"INSERT INTO pubkey_allowlist (pubkey, added_at) VALUES (decode($1, 'hex'), now())",
)
.bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.execute(&pool)
.await
.expect("seed legacy allowlist row");
run_migrations(&pool).await.expect("baseline migrations");
assert_eq!(applied_versions(&pool).await, vec![1, 2]);
let allowlist_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pubkey_allowlist")
.fetch_one(&pool)
.await
.expect("count allowlist rows");
assert_eq!(
allowlist_count, 1,
"baseline must not drop legacy allowlist rows before relay startup backfills them"
);
let inserted = crate::relay_members::backfill_from_allowlist(&pool)
.await
.expect("backfill legacy allowlist rows");
assert_eq!(inserted, 1);
let relay_member_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM relay_members WHERE pubkey = $1 AND role = 'member'",
)
.bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.fetch_one(&pool)
.await
.expect("count backfilled relay member");
assert_eq!(relay_member_count, 1);
}
}
+40
View File
@@ -16,6 +16,15 @@ use buzz_relay::router::{build_health_router, build_router};
use buzz_relay::state::AppState;
use buzz_workflow::WorkflowEngine;
fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool {
value.map(str::trim).is_some_and(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "on"
)
})
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// JSON-only structured logs — simple, machine-parseable, CAKE-compatible.
@@ -55,6 +64,18 @@ async fn main() -> anyhow::Result<()> {
})?;
info!("Postgres connected");
let auto_migrate =
buzz_auto_migrate_enabled(std::env::var("BUZZ_AUTO_MIGRATE").ok().as_deref());
if auto_migrate {
db.migrate().await.map_err(|e| {
error!("Failed to run database migrations: {e}");
anyhow::anyhow!("Database migration failed: {e}")
})?;
info!("Database migrations complete");
} else {
info!("Skipping database migrations because BUZZ_AUTO_MIGRATE is not enabled");
}
if let Err(e) = db.ensure_future_partitions(3).await {
error!("Failed to ensure partitions: {e}");
}
@@ -604,3 +625,22 @@ async fn shutdown_signal() {
tokio::signal::ctrl_c().await.ok();
}
}
#[cfg(test)]
mod tests {
use super::buzz_auto_migrate_enabled;
#[test]
fn buzz_auto_migrate_is_opt_in() {
assert!(!buzz_auto_migrate_enabled(None));
assert!(!buzz_auto_migrate_enabled(Some("")));
assert!(!buzz_auto_migrate_enabled(Some("false")));
assert!(!buzz_auto_migrate_enabled(Some("0")));
assert!(!buzz_auto_migrate_enabled(Some("no")));
assert!(buzz_auto_migrate_enabled(Some("true")));
assert!(buzz_auto_migrate_enabled(Some("TRUE")));
assert!(buzz_auto_migrate_enabled(Some(" 1 ")));
assert!(buzz_auto_migrate_enabled(Some("yes")));
assert!(buzz_auto_migrate_enabled(Some("on")));
}
}
+4 -5
View File
@@ -32,11 +32,10 @@ keypair.
Typesense, and S3 secrets stable across restarts.
- `RELAY_OWNER_PUBKEY` is intentionally not prefixed with `BUZZ_`; it must be a
64-character hex Nostr pubkey when closed relay mode is enabled.
- `BUZZ_AUTO_MIGRATE=true` requires an image that includes embedded SQLx
migrations. Do not share this quick start for a fresh public install until PR
#988 is merged and `ghcr.io/block/buzz:main` has been rebuilt from it. Before
then, this bundle is only suitable for instances whose database schema has
already been applied.
- `BUZZ_AUTO_MIGRATE` is opt-in. Set `BUZZ_AUTO_MIGRATE=true` or run
`buzz-admin migrate` before starting the relay when bootstrapping a fresh
database. Auto-migration requires an image that includes embedded SQLx
migrations.
- The stack uses Postgres, Redis, Typesense, MinIO, and a git data volume because
those are real Buzz dependencies today. Minimal mode can simplify this later.
+1 -1
View File
@@ -17,7 +17,7 @@ services:
BUZZ_S3_SECRET_KEY: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY}
BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media}
BUZZ_GIT_REPO_PATH: /data/git
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-true}
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-false}
BUZZ_GIT_CONFORMANCE_PROBE: ${BUZZ_GIT_CONFORMANCE_PROBE:-true}
ports:
- "${BUZZ_HTTP_PORT:-3000}:3000"
+4 -8
View File
@@ -148,13 +148,9 @@ _ensure-services:
echo " timed out"
exit 1
# Apply database migrations if pgschema is available
# Apply database migrations if the dev database is running
_ensure-migrations: _ensure-services
#!/usr/bin/env bash
set -euo pipefail
if [[ -x bin/pgschema && -f schema/schema.sql ]]; then
bin/pgschema apply --file schema/schema.sql --auto-approve || true
fi
cargo run -p buzz-admin -- migrate
# Run clippy on the desktop Tauri Rust crate
desktop-tauri-clippy: _ensure-sidecar-stubs
@@ -388,9 +384,9 @@ mobile-dev:
# ─── Database ─────────────────────────────────────────────────────────────────
# Apply schema migrations via pgschema
# Apply database migrations
migrate: _ensure-services
./bin/pgschema apply --file schema/schema.sql --auto-approve
cargo run -p buzz-admin -- migrate
# ─── Utilities ────────────────────────────────────────────────────────────────
+356
View File
@@ -0,0 +1,356 @@
-- Buzz initial Postgres schema.
--
-- This migration is the source of truth for fresh database setup.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ── Custom types ──────────────────────────────────────────────────────────────
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
CREATE TYPE channel_visibility AS ENUM ('open', 'private');
CREATE TYPE member_role AS ENUM ('owner', 'admin', 'member', 'guest', 'bot');
CREATE TYPE workflow_status AS ENUM ('active', 'disabled', 'archived');
CREATE TYPE run_status AS ENUM ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled');
CREATE TYPE approval_status AS ENUM ('pending', 'granted', 'denied', 'expired');
CREATE TYPE delivery_method AS ENUM ('webhook', 'websocket');
CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted');
CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
-- ── Channels ──────────────────────────────────────────────────────────────────
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
channel_type channel_type NOT NULL DEFAULT 'stream',
visibility channel_visibility NOT NULL DEFAULT 'open',
description TEXT,
canvas TEXT,
created_by BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
nip29_group_id VARCHAR(255) UNIQUE,
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
max_members INT,
topic TEXT,
topic_set_by BYTEA,
topic_set_at TIMESTAMPTZ,
purpose TEXT,
purpose_set_by BYTEA,
purpose_set_at TIMESTAMPTZ,
participant_hash BYTEA,
ttl_seconds INT,
ttl_deadline TIMESTAMPTZ,
CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
);
CREATE INDEX idx_channels_type ON channels (channel_type);
CREATE INDEX idx_channels_visibility ON channels (visibility);
CREATE INDEX idx_channels_created_by ON channels (created_by);
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash);
CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline)
WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL;
-- ── Channel members ───────────────────────────────────────────────────────────
CREATE TABLE channel_members (
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
pubkey BYTEA NOT NULL,
role member_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invited_by BYTEA,
removed_at TIMESTAMPTZ,
removed_by BYTEA,
hidden_at TIMESTAMPTZ,
PRIMARY KEY (channel_id, pubkey)
);
CREATE INDEX idx_channel_members_pubkey ON channel_members (pubkey)
WHERE removed_at IS NULL;
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE users (
pubkey BYTEA PRIMARY KEY,
nip05_handle VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
avatar_url TEXT,
about TEXT,
agent_type VARCHAR(255),
capabilities JSONB,
okta_user_id VARCHAR(255) UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ,
metadata_event_id BYTEA,
agent_owner_pubkey BYTEA REFERENCES users(pubkey) ON DELETE SET NULL,
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32)
);
-- ── Events (partitioned by month on created_at) ──────────────────────────────
CREATE TABLE events (
id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
kind INT NOT NULL,
tags JSONB NOT NULL,
content TEXT NOT NULL,
sig BYTEA NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
channel_id UUID,
deleted_at TIMESTAMPTZ,
d_tag TEXT,
PRIMARY KEY (created_at, id)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_p_past PARTITION OF events
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
CREATE TABLE events_p2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_p2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_p2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE events_p2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_p2026_05 PARTITION OF events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE events_p2026_06 PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE events_p_future PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at);
CREATE INDEX idx_events_channel_created ON events (channel_id, created_at);
CREATE INDEX idx_events_kind_created ON events (kind, created_at);
CREATE INDEX idx_events_id ON events (id);
CREATE INDEX idx_events_deleted ON events (deleted_at);
CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at);
CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL;
-- ── Event mentions ────────────────────────────────────────────────────────────
CREATE TABLE event_mentions (
pubkey_hex VARCHAR(64) NOT NULL,
event_id BYTEA NOT NULL,
event_created_at TIMESTAMPTZ NOT NULL,
channel_id UUID,
event_kind INT,
PRIMARY KEY (pubkey_hex, event_id)
);
CREATE INDEX idx_event_mentions_pubkey_created ON event_mentions (pubkey_hex, event_created_at DESC);
CREATE INDEX idx_event_mentions_pubkey_kind_created ON event_mentions (pubkey_hex, event_kind, event_created_at DESC);
-- ── Subscriptions ─────────────────────────────────────────────────────────────
CREATE TABLE subscriptions (
id VARCHAR(255) PRIMARY KEY,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
filter_kinds JSONB,
filter_authors JSONB,
filter_channel_ids JSONB,
filter_since TIMESTAMPTZ,
filter_until TIMESTAMPTZ,
delivery_method delivery_method NOT NULL DEFAULT 'webhook',
delivery_url TEXT,
status subscription_status NOT NULL DEFAULT 'active',
pause_reason pause_reason,
delivered_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Delivery log (partitioned by month on delivered_at) ──────────────────────
CREATE TABLE delivery_log (
id BIGINT GENERATED ALWAYS AS IDENTITY,
subscription_id VARCHAR(255),
event_id BYTEA,
method delivery_method,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
success BOOLEAN,
http_status INT,
error_message TEXT,
attempt_number INT DEFAULT 1,
PRIMARY KEY (delivered_at, id)
) PARTITION BY RANGE (delivered_at);
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
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE delivery_log_p2026_04 PARTITION OF delivery_log
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE delivery_log_p2026_05 PARTITION OF delivery_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE delivery_log_p_future PARTITION OF delivery_log
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
-- ── Workflows ─────────────────────────────────────────────────────────────────
CREATE TABLE workflows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
channel_id UUID REFERENCES channels(id),
definition JSONB NOT NULL,
definition_hash BYTEA NOT NULL,
status workflow_status NOT NULL DEFAULT 'active',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled);
-- ── Workflow runs ─────────────────────────────────────────────────────────────
CREATE TABLE workflow_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
status run_status NOT NULL DEFAULT 'pending',
trigger_event_id BYTEA,
current_step INT NOT NULL DEFAULT 0,
execution_trace JSONB NOT NULL DEFAULT '[]',
trigger_context JSONB,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id);
CREATE INDEX idx_workflow_runs_status ON workflow_runs (status);
-- ── Workflow approvals ────────────────────────────────────────────────────────
CREATE TABLE workflow_approvals (
token BYTEA PRIMARY KEY,
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
step_id VARCHAR(64) NOT NULL,
step_index INT NOT NULL,
approver_spec TEXT NOT NULL,
status approval_status NOT NULL DEFAULT 'pending',
approver_pubkey BYTEA,
note TEXT,
granted_at TIMESTAMPTZ,
denied_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id);
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (run_id);
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status);
-- ── API tokens ────────────────────────────────────────────────────────────────
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL UNIQUE,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
name VARCHAR(255) NOT NULL,
scopes JSONB NOT NULL,
channel_ids JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by BYTEA,
created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32)
);
-- ── Rate limit violations ─────────────────────────────────────────────────────
CREATE TABLE rate_limit_violations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pubkey BYTEA,
violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
limit_type VARCHAR(64),
limit_value INT,
actual_value INT,
action_taken VARCHAR(64)
);
-- ── Thread metadata ───────────────────────────────────────────────────────────
CREATE TABLE thread_metadata (
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
channel_id UUID NOT NULL REFERENCES channels(id),
parent_event_id BYTEA,
parent_event_created_at TIMESTAMPTZ,
root_event_id BYTEA,
root_event_created_at TIMESTAMPTZ,
depth INT NOT NULL DEFAULT 0,
reply_count INT NOT NULL DEFAULT 0,
descendant_count INT NOT NULL DEFAULT 0,
last_reply_at TIMESTAMPTZ,
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (event_created_at, event_id)
);
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (parent_event_id);
CREATE INDEX idx_thread_metadata_root ON thread_metadata (root_event_id);
CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (channel_id, depth, event_created_at);
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (event_id);
-- ── Reactions ─────────────────────────────────────────────────────────────────
CREATE TABLE reactions (
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
emoji VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
reaction_event_id BYTEA,
PRIMARY KEY (event_created_at, event_id, pubkey, emoji)
);
CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at);
CREATE INDEX idx_reactions_pubkey ON reactions (pubkey);
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id);
-- ── Pubkey allowlist ──────────────────────────────────────────────────────────
CREATE TABLE pubkey_allowlist (
pubkey BYTEA PRIMARY KEY,
added_by BYTEA,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT
);
-- ── Relay members (NIP-43) ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS relay_members (
pubkey TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
added_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role);
-- ── Archived identities (NIP-IA) ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS archived_identities (
pubkey TEXT PRIMARY KEY,
consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')),
actor TEXT NOT NULL,
reason TEXT,
replaced_by TEXT,
request_event_id TEXT NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-51
View File
@@ -1,51 +0,0 @@
-- Migration 0001: relay_members
--
-- Introduces the relay-level membership table (NIP-43).
-- Replaces the old pubkey_allowlist with a richer model that tracks role
-- (owner / admin / member), who added the entry, and timestamps.
--
-- Idempotent: safe to run more than once.
-- ── 1. Create relay_members ───────────────────────────────────────────────────
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'relay_members'
) THEN
CREATE TABLE relay_members (
pubkey TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
added_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_relay_members_role ON relay_members(role);
END IF;
END $$;
-- ── 2. Migrate existing allowlist rows ────────────────────────────────────────
--
-- pubkey_allowlist stores pubkeys as BYTEA and timestamps as added_at.
-- Convert BYTEA to lowercase hex text via encode(..., 'hex').
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'pubkey_allowlist'
) THEN
INSERT INTO relay_members (pubkey, role, added_by, created_at)
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at
FROM pubkey_allowlist
ON CONFLICT (pubkey) DO NOTHING;
END IF;
END $$;
-- NOTE: pubkey_allowlist is intentionally NOT dropped here.
-- The old allowlist code still references it. Drop it in a future migration
-- once all references have been removed.
+17
View File
@@ -0,0 +1,17 @@
-- Backfill d_tag for existing NIP-33 range events (kind 3000039999).
-- Idempotent: only updates rows where d_tag is still NULL.
-- Includes soft-deleted rows so the column is fully populated.
-- Run once after adding the d_tag column to the events table.
--
-- Managed by sqlx migrations.
UPDATE events
SET d_tag = COALESCE(
(SELECT elem->>1
FROM jsonb_array_elements(tags) AS elem
WHERE elem->>0 = 'd'
LIMIT 1),
''
)
WHERE kind BETWEEN 30000 AND 39999
AND d_tag IS NULL;
+5 -3
View File
@@ -1,7 +1,9 @@
-- Buzz — Declarative Postgres schema (managed by pgschema)
-- Buzz Postgres schema reference.
--
-- This file represents the desired state of the database schema.
-- Use `pgschema apply --file schema/schema.sql` to bring the database up to date.
-- Runtime migrations live under migrations/ and are applied by sqlx. Keep this
-- file in sync as a human-readable snapshot of the desired schema.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ── Custom types ──────────────────────────────────────────────────────────────
+12 -51
View File
@@ -119,59 +119,20 @@ log "Starting services and waiting for health..."
# ---- Run migrations ---------------------------------------------------------
log "Running database migrations..."
PGSCHEMA="${REPO_ROOT}/bin/pgschema"
SCHEMA_FILE="${REPO_ROOT}/schema/schema.sql"
if [[ ! -f "${SCHEMA_FILE}" ]]; then
warn "No schema.sql found at ${SCHEMA_FILE}. Skipping."
else
if [[ -x "${PGSCHEMA}" ]]; then
# pgschema uses CREATE INDEX CONCURRENTLY for new indexes, which Postgres
# does not support on partitioned tables. Pre-create any such indexes here
# so pgschema sees them as already existing and skips the CONCURRENTLY path.
log "Pre-creating indexes on partitioned tables (if needed)..."
docker exec buzz-postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -q -c \
"CREATE INDEX IF NOT EXISTS idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL;" \
2>/dev/null || true
log "Using pgschema for migrations..."
attempts=0
max_attempts=10
pgschema_output="$(mktemp)"
trap 'rm -f "${pgschema_output}"' EXIT
until "${PGSCHEMA}" apply --file "${SCHEMA_FILE}" --auto-approve >"${pgschema_output}" 2>&1; do
attempts=$((attempts + 1))
if postgres_accepting_connections; then
error "pgschema failed even though Postgres is accepting connections"
cat "${pgschema_output}" >&2
exit 1
fi
if [[ ${attempts} -ge ${max_attempts} ]]; then
error "Failed to run migrations after ${max_attempts} attempts"
cat "${pgschema_output}" >&2
exit 1
fi
log "Postgres not ready for connections yet, retrying in 2s... (${attempts}/${max_attempts})"
sleep 2
done
success "Migrations applied via pgschema"
# Run data backfills (idempotent — safe to re-run).
BACKFILL_DIR="${REPO_ROOT}/scripts"
if [[ -f "${BACKFILL_DIR}/backfill-d-tag.sql" ]]; then
log "Running d_tag backfill for NIP-33 events..."
if psql "${DATABASE_URL}" -f "${BACKFILL_DIR}/backfill-d-tag.sql" 2>/dev/null; then
success "d_tag backfill complete"
else
warn "d_tag backfill failed (relay startup will retry automatically)"
fi
fi
else
error "pgschema not found at ${PGSCHEMA}. Run: ./bin/hermit install pgschema"
attempts=0
max_attempts=10
until postgres_accepting_connections; do
attempts=$((attempts + 1))
if [[ ${attempts} -ge ${max_attempts} ]]; then
error "Postgres did not accept connections after ${max_attempts} attempts"
exit 1
fi
fi
log "Postgres not ready for connections yet, retrying in 2s... (${attempts}/${max_attempts})"
sleep 2
done
"${REPO_ROOT}/bin/cargo" run -p buzz-admin -- migrate
success "Database migrations complete"
# ---- Install desktop dependencies -------------------------------------------