diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd4d3e10..7080914e9 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -47,7 +47,7 @@ use sqlx::{PgPool, QueryBuilder, Row}; use std::time::Duration; use uuid::Uuid; -use buzz_core::StoredEvent; +use buzz_core::{CommunityId, StoredEvent}; /// Extract p-tag mentions from an event and insert into the `event_mentions` table. /// @@ -162,6 +162,15 @@ impl Default for DbConfig { } } +/// Community host-map row returned by [`Db::lookup_community_by_host`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, +} + /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -216,6 +225,67 @@ impl Db { self.pool.begin().await.map_err(Into::into) } + /// Returns the community mapped to a normalized request host, if one exists. + /// + /// The caller owns host normalization and turns `None` into the fail-closed + /// request/connection error. buzz-db only reads the durable host map. + pub async fn lookup_community_by_host( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, host + FROM communities + WHERE host = $1 + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + }) + .transpose() + } + + /// Ensure a configured community host exists and return its row. + /// + /// This is the startup/config seeding path for N=1 deployments. Migrations + /// create the schema only; deployment-specific hosts are not hardcoded into + /// schema history. + pub async fn ensure_configured_community( + &self, + normalized_host: &str, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (host) DO UPDATE SET host = EXCLUDED.host + RETURNING id, host + "#, + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + } + /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. pub async fn insert_event( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index f4f3c9ab3..de902f636 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -131,6 +131,13 @@ mod tests { assert_eq!(migrations.len(), 3); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); + assert!( + migrations[0] + .sql + .as_str() + .contains("CREATE TABLE communities"), + "initial schema migration should include tenant host map" + ); assert!( migrations[0].sql.as_str().contains("CREATE TABLE channels"), "initial schema migration should include Buzz core tables" diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 93644cba2..39871079b 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -17,6 +17,17 @@ 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'); +-- ── Communities ─────────────────────────────────────────────────────────────── + +CREATE TABLE communities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host VARCHAR(255) NOT NULL UNIQUE, + signing_key BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_communities_host_not_empty CHECK (LENGTH(TRIM(host)) > 0), + CONSTRAINT chk_communities_host_normalized CHECK (host = LOWER(host)) +); + -- ── Channels ────────────────────────────────────────────────────────────────── CREATE TABLE channels ( diff --git a/schema/schema.sql b/schema/schema.sql index 2a839adf6..36b15295f 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -18,6 +18,17 @@ 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'); +-- ── Communities ─────────────────────────────────────────────────────────────── + +CREATE TABLE communities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host VARCHAR(255) NOT NULL UNIQUE, + signing_key BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_communities_host_not_empty CHECK (LENGTH(TRIM(host)) > 0), + CONSTRAINT chk_communities_host_normalized CHECK (host = LOWER(host)) +); + -- ── Channels ────────────────────────────────────────────────────────────────── CREATE TABLE channels (