use sqlx::{PgExecutor, PgPool};
use crate::error::{validate_schema, OutboxError, Result};
use crate::record::OutboxRecord;
fn index_name(schema: &str, suffix: &str) -> String {
const MAX_IDENTIFIER_BYTES: usize = 63;
let mut name = format!("idx_{schema}_{suffix}");
name.truncate(MAX_IDENTIFIER_BYTES);
name
}
pub async fn migrate(pool: &PgPool, schema: &str) -> Result<()> {
validate_schema(schema)?;
sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {schema}")).execute(pool).await?;
sqlx::query(&format!(
r#"CREATE TABLE IF NOT EXISTS {schema}.outbox_events (
id uuid PRIMARY KEY,
event_type text NOT NULL,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
company_id uuid NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
correlation_id text,
causation_id text,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
)"#
))
.execute(pool)
.await?;
sqlx::query(&format!(
"ALTER TABLE {schema}.outbox_events ADD COLUMN IF NOT EXISTS company_id uuid"
))
.execute(pool)
.await?;
sqlx::query(&format!(
"ALTER TABLE {schema}.outbox_events ADD COLUMN IF NOT EXISTS failed_at timestamptz"
))
.execute(pool)
.await?;
sqlx::query(&format!(
"ALTER TABLE {schema}.outbox_events ADD COLUMN IF NOT EXISTS failure_reason text"
))
.execute(pool)
.await?;
sqlx::query(&format!(
"ALTER TABLE {schema}.outbox_events ADD COLUMN IF NOT EXISTS org_unit_id uuid"
))
.execute(pool)
.await?;
sqlx::query(&format!(
"UPDATE {schema}.outbox_events SET org_unit_id = company_id
WHERE org_unit_id IS NULL AND company_id IS NOT NULL"
))
.execute(pool)
.await?;
sqlx::query(&format!(
r#"CREATE OR REPLACE FUNCTION {schema}.outbox_events_org_unit_fill() RETURNS trigger AS $$
BEGIN
IF NEW.org_unit_id IS NULL THEN
NEW.org_unit_id := NULLIF(current_setting('app.acting_unit_id', true), '')::uuid;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql"#
))
.execute(pool)
.await?;
sqlx::query(&format!(
"DROP TRIGGER IF EXISTS outbox_events_org_unit_fill ON {schema}.outbox_events"
))
.execute(pool)
.await?;
sqlx::query(&format!(
"CREATE TRIGGER outbox_events_org_unit_fill
BEFORE INSERT ON {schema}.outbox_events
FOR EACH ROW EXECUTE FUNCTION {schema}.outbox_events_org_unit_fill()"
))
.execute(pool)
.await?;
let pending_idx = index_name(schema, "outbox_pending");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS {pending_idx}
ON {schema}.outbox_events (occurred_at) WHERE published_at IS NULL AND failed_at IS NULL"
))
.execute(pool)
.await?;
let legacy_idx = index_name(schema, "outbox_unpublished");
if legacy_idx == pending_idx {
return Err(OutboxError::InvalidSchema(format!(
"{schema}: too long to build distinct index names within Postgres's 63-byte limit"
)));
}
sqlx::query(&format!("DROP INDEX IF EXISTS {schema}.{legacy_idx}"))
.execute(pool)
.await?;
#[cfg(feature = "multi_tenant")]
{
let org_idx = index_name(schema, "outbox_org_unit_id");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS {org_idx} ON {schema}.outbox_events (org_unit_id)"
))
.execute(pool)
.await?;
sqlx::query(&format!("ALTER TABLE {schema}.outbox_events ENABLE ROW LEVEL SECURITY"))
.execute(pool).await?;
sqlx::query(&format!("ALTER TABLE {schema}.outbox_events FORCE ROW LEVEL SECURITY"))
.execute(pool).await?;
sqlx::raw_sql(&format!(
r#"DROP POLICY IF EXISTS outbox_events_org_unit_isolation ON {schema}.outbox_events;
CREATE POLICY outbox_events_org_unit_isolation ON {schema}.outbox_events
FOR ALL
USING (org_unit_id = ANY(string_to_array(
current_setting('app.scope_unit_ids', true), ',')::uuid[])
OR current_user = 'metaphor_relay')
WITH CHECK (org_unit_id = ANY(string_to_array(
current_setting('app.scope_unit_ids', true), ',')::uuid[])
OR current_user = 'metaphor_relay');
DROP POLICY IF EXISTS outbox_events_company_isolation ON {schema}.outbox_events;"#
))
.execute(pool)
.await?;
let company_idx = index_name(schema, "outbox_company_id");
sqlx::query(&format!("DROP INDEX IF EXISTS {schema}.{company_idx}"))
.execute(pool)
.await?;
}
sqlx::query(&format!(
r#"CREATE TABLE IF NOT EXISTS {schema}.inbox_consumed (
consumer text NOT NULL,
event_id uuid NOT NULL,
consumed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, event_id)
)"#
))
.execute(pool)
.await?;
Ok(())
}
pub async fn stage<'c, E>(executor: E, schema: &str, rec: &OutboxRecord) -> Result<bool>
where
E: PgExecutor<'c>,
{
validate_schema(schema)?;
let done = sqlx::query(&format!(
r#"INSERT INTO {schema}.outbox_events
(id, event_type, aggregate_type, aggregate_id, company_id, payload, occurred_at,
correlation_id, causation_id, version)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (id) DO NOTHING"#
))
.bind(rec.id)
.bind(&rec.event_type)
.bind(&rec.aggregate_type)
.bind(&rec.aggregate_id)
.bind(rec.company_id)
.bind(&rec.payload)
.bind(rec.occurred_at)
.bind(&rec.correlation_id)
.bind(&rec.causation_id)
.bind(rec.version)
.execute(executor)
.await?;
Ok(done.rows_affected() == 1)
}
pub async fn dead_count(pool: &PgPool, schema: &str) -> Result<i64> {
validate_schema(schema)?;
let n: i64 = sqlx::query_scalar(&format!(
"SELECT count(*) FROM {schema}.outbox_events WHERE failed_at IS NOT NULL"
))
.fetch_one(pool)
.await?;
Ok(n)
}
pub async fn pending_count(pool: &PgPool, schema: &str) -> Result<i64> {
validate_schema(schema)?;
let n: i64 = sqlx::query_scalar(&format!(
"SELECT count(*) FROM {schema}.outbox_events WHERE published_at IS NULL AND failed_at IS NULL"
))
.fetch_one(pool)
.await?;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::index_name;
#[test]
fn a_short_schema_keeps_its_full_index_name() {
assert_eq!(index_name("payment", "outbox_pending"), "idx_payment_outbox_pending");
}
#[test]
fn a_long_schema_is_truncated_the_way_postgres_truncates() {
let schema = "a".repeat(50);
let name = index_name(&schema, "outbox_pending");
assert_eq!(name.len(), 63, "the name is capped at the identifier limit");
assert_eq!(name, index_name(&schema, "outbox_pending"), "create and drop name the same index");
}
#[test]
fn a_schema_long_enough_to_collapse_both_suffixes_is_refused() {
let schema = "b".repeat(60);
assert_eq!(
index_name(&schema, "outbox_pending"),
index_name(&schema, "outbox_unpublished"),
"the two names collapse, which is what migrate refuses"
);
}
#[test]
fn realistic_schema_names_are_nowhere_near_the_limit() {
for schema in ["payment", "billing", "messaging", "tax", "selling"] {
assert!(index_name(schema, "outbox_unpublished").len() < 63);
assert_ne!(
index_name(schema, "outbox_pending"),
index_name(schema, "outbox_unpublished")
);
}
}
}