remora/crates/remora-durable/src/cas.rs
ms2k 957d170150 Add PostgreSQL DurableStore substrate for WP-03
Introduce remora-durable with pool/migrations, a RemoraApiMutation-shaped
ledger, claim-fenced outbox projection, CAS helpers, and keyset cursors,
plus a kind Job acceptance suite against the hermetic fixture.
2026-07-16 03:56:44 +09:00

90 lines
2.4 KiB
Rust

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<i64, DurableError> {
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<i64, DurableError> {
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(())
}