use sqlx::{AssertSqlSafe, PgPool}; use crate::error::DurableError; use crate::tx::{self, PgTx}; /// Optimistic compare-and-swap on a caller-owned table. /// /// The table must expose `revision BIGINT NOT NULL` and a primary key column /// bound as `$1`. Feature packages own the surrounding schema; this helper only /// serializes the revision bump. Identifiers are restricted to lowercase SQL /// identifiers before interpolation. pub async fn cas_set_text( tx: &mut PgTx<'_>, table: &str, id_column: &str, id: &str, value_column: &str, value: &str, expected_revision: i64, ) -> Result { validate_ident(table)?; validate_ident(id_column)?; validate_ident(value_column)?; let sql = format!( "UPDATE {table} SET {value_column} = $2, revision = revision + 1 WHERE {id_column} = $1 AND revision = $3 RETURNING revision" ); let next: Option<(i64,)> = sqlx::query_as(AssertSqlSafe(sql)) .bind(id) .bind(value) .bind(expected_revision) .fetch_optional(tx.as_mut()) .await?; next.map(|(revision,)| revision) .ok_or(DurableError::CasConflict) } pub async fn cas_set_text_pool( pool: &PgPool, table: &str, id_column: &str, id: &str, value_column: &str, value: &str, expected_revision: i64, ) -> Result { let table = table.to_owned(); let id_column = id_column.to_owned(); let id = id.to_owned(); let value_column = value_column.to_owned(); let value = value.to_owned(); tx::with_transaction(pool, move |tx| { Box::pin(async move { cas_set_text( tx, &table, &id_column, &id, &value_column, &value, expected_revision, ) .await }) }) .await } fn validate_ident(value: &str) -> Result<(), DurableError> { if value.is_empty() || value.len() > 63 || !value.chars().all(|character| { character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' }) || value .chars() .next() .is_none_or(|character| !(character.is_ascii_lowercase() || character == '_')) { return Err(DurableError::Config(format!( "invalid SQL identifier '{value}'" ))); } Ok(()) }