reliar-store-postgres 0.7.0

PostgreSQL provider for the Reliar transactional outbox and inbox: migrations, migrate(), enqueue and the SKIP LOCKED claim.
Documentation
//! Hand-rolled error enum for the inbox side of the PostgreSQL provider (ADR 0008, inbox contract
//! §3).

use core::fmt;

use reliar_core::{Classify, FailureKind, MessageId};

use crate::error::classify_sqlstate;

/// A failure of a [`crate::PostgresInboxStore`] call (inbox contract §3). Deliberately smaller
/// than [`crate::PostgresOutboxError`]: the inbox has no `enqueue`/`acquire` analog, so it needs
/// no `Decode`/`UnknownMetadataVersion`/`DuplicateMessage` variant, and no `InvalidSchema` — every
/// schema value this type ever sees already passed the same identifier validation at `migrate()`
/// (the inbox shares the outbox's schema and its single `migrate()` entry point, inbox contract §1),
/// and `claim`/`complete` bind it only as `set_config` *data*, never interpolate it into DDL.
#[derive(Debug)]
#[non_exhaustive]
pub enum PostgresInboxError {
    /// The connected server's `server_version_num` is below [`crate::MIN_SERVER_VERSION_NUM`]
    /// (ADR 0041). Checked at [`crate::PostgresInboxStore::connect`], before the `search_path`
    /// verification below — the same ordering
    /// [`crate::PostgresOutboxError::UnsupportedServerVersion`] uses and for the same reason.
    /// **Permanent.**
    UnsupportedServerVersion {
        /// [`crate::MIN_SERVER_VERSION_NUM`], restated on the value.
        required: u32,
        /// The `server_version_num` this connection reported.
        detected: u32,
    },

    /// `inbox` resolved to the configured schema, but the relation itself is missing —
    /// `migrate()` has not been run. **Permanent.** Checked before
    /// [`Self::SchemaNotOnSearchPath`] below at [`crate::PostgresInboxStore::connect`], exactly as
    /// [`crate::PostgresOutboxError::NotMigrated`] is checked before
    /// `crate::PostgresOutboxError::SchemaNotOnSearchPath` — a missing relation reported as a
    /// `search_path` problem would send an operator chasing the wrong fix.
    NotMigrated {
        /// The configured schema.
        schema: String,
    },

    /// The unqualified name `inbox` does not resolve to the configured schema and the relation
    /// exists somewhere reachable — `search_path` puts a different schema first. Carries the
    /// configured schema and the observed `search_path`; the `ALTER ROLE` remedy is in the
    /// `Display` text. **Permanent.**
    SchemaNotOnSearchPath {
        /// The schema [`crate::PostgresInboxSettings::schema`] named.
        configured: String,
        /// The `search_path` Postgres reported at construction.
        observed: String,
    },

    /// [`crate::PostgresInboxStore`]'s `InboxStore::complete` matched zero rows — reachable only
    /// by misuse (completing without a preceding `Claimed` claim, after the claiming
    /// transaction aborted, or completing a row that has since gone dead — `complete`'s own
    /// guard is `completed_at IS NULL AND dead_at IS NULL`, ADR 0042 A.2.4). **Permanent** —
    /// retrying the same call changes nothing.
    NotClaimed {
        /// The scope the caller completed under.
        scope: String,
        /// The message id the caller tried to complete.
        message_id: MessageId,
    },

    /// `PostgresInboxSettings::validate` rejected the settings — currently only
    /// `max_attempts == 0` (ADR 0042 A.2.4). Checked at
    /// [`crate::PostgresInboxStore::connect`], before any query runs. **Permanent.**
    InvalidSettings {
        /// A payload-free description of what was rejected.
        message: String,
    },

    /// Any other `sqlx` failure, classified by SQLSTATE exactly as
    /// [`crate::PostgresOutboxError::Database`].
    Database {
        /// The underlying `sqlx` error.
        source: sqlx::Error,
    },
}

impl fmt::Display for PostgresInboxError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnsupportedServerVersion { required, detected } => write!(
                f,
                "PostgreSQL 18 or newer is required (server_version_num >= {required}); \
                 detected {detected} — there is no supported way to run Reliar below the floor"
            ),
            Self::NotMigrated { schema } => write!(
                f,
                "relation \"{schema}.inbox\" does not exist; call \
                 reliar_store_postgres::migrate(&pool, ..) before constructing the store"
            ),
            Self::SchemaNotOnSearchPath {
                configured,
                observed,
            } => write!(
                f,
                "inbox did not resolve to schema \"{configured}\" (observed search_path: \
                 \"{observed}\"); set search_path so \"{configured}\" comes first, e.g. \
                 ALTER ROLE <role> SET search_path = {configured}, public"
            ),
            Self::NotClaimed { scope, message_id } => write!(
                f,
                "no claimed inbox row for scope {scope:?}, message {message_id}; complete() may \
                 only follow a Claimed claim() in the same transaction, and never a dead one"
            ),
            Self::InvalidSettings { message } => {
                write!(f, "invalid PostgresInboxSettings: {message}")
            }
            Self::Database { source } => write!(f, "database error: {source}"),
        }
    }
}

impl std::error::Error for PostgresInboxError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Database { source } => Some(source),
            Self::UnsupportedServerVersion { .. }
            | Self::NotMigrated { .. }
            | Self::SchemaNotOnSearchPath { .. }
            | Self::NotClaimed { .. }
            | Self::InvalidSettings { .. } => None,
        }
    }
}

/// Per-variant classification table, exactly as [`crate::PostgresOutboxError`]'s: no blanket
/// "everything else is transient".
impl Classify for PostgresInboxError {
    fn kind(&self) -> FailureKind {
        match self {
            Self::UnsupportedServerVersion { .. }
            | Self::NotMigrated { .. }
            | Self::SchemaNotOnSearchPath { .. }
            | Self::NotClaimed { .. }
            | Self::InvalidSettings { .. } => FailureKind::Permanent,
            Self::Database { source } => classify_sqlstate(source),
        }
    }
}

impl From<sqlx::Error> for PostgresInboxError {
    fn from(source: sqlx::Error) -> Self {
        Self::Database { source }
    }
}