use chrono::{DateTime, Timelike, Utc};
use serde::Serialize;
use serde::de::DeserializeOwned;
use sqlx::postgres::PgRow;
use sqlx::{Decode, Row, Type};
use turnframe_core::ids::CaseRevision;
use turnframe_store::error::{StoreError, invalid_record};
use turnframe_store::events::EventCursor;
use crate::error::store_error;
pub(crate) fn label<T: Serialize>(value: &T) -> Result<String, StoreError> {
match serde_json::to_value(value).map_err(|_| StoreError::Serialization)? {
serde_json::Value::String(text) => Ok(text),
_ => Err(StoreError::Serialization),
}
}
pub(crate) fn from_label<T: DeserializeOwned>(text: &str) -> Result<T, StoreError> {
serde_json::from_value(serde_json::Value::String(text.to_owned()))
.map_err(|_| StoreError::Corrupt)
}
pub(crate) fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, StoreError> {
serde_json::to_value(value).map_err(|_| StoreError::Serialization)
}
pub(crate) fn from_json<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, StoreError> {
serde_json::from_value(value).map_err(|_| StoreError::Corrupt)
}
pub(crate) fn column<'r, T>(row: &'r PgRow, name: &str) -> Result<T, StoreError>
where
T: Decode<'r, sqlx::Postgres> + Type<sqlx::Postgres>,
{
row.try_get(name).map_err(|error| store_error(&error))
}
pub(crate) fn revision_to_sql(revision: CaseRevision) -> Result<i64, StoreError> {
i64::try_from(revision.value()).map_err(|_| invalid_record())
}
pub(crate) fn revision_from_sql(value: i64) -> Result<CaseRevision, StoreError> {
u64::try_from(value)
.map(CaseRevision)
.map_err(|_| StoreError::Corrupt)
}
pub(crate) fn attempts_to_sql(attempts: u32) -> Result<i32, StoreError> {
i32::try_from(attempts).map_err(|_| invalid_record())
}
pub(crate) fn attempts_from_sql(value: i32) -> Result<u32, StoreError> {
u32::try_from(value).map_err(|_| StoreError::Corrupt)
}
pub(crate) fn sequence_from_sql(value: i64) -> Result<u64, StoreError> {
u64::try_from(value).map_err(|_| StoreError::Corrupt)
}
pub(crate) fn count_from_sql(value: i64) -> Result<u64, StoreError> {
u64::try_from(value).map_err(|_| StoreError::Corrupt)
}
#[must_use]
pub(crate) fn limit_to_sql(limit: usize) -> i64 {
i64::try_from(limit).unwrap_or(i64::MAX)
}
#[must_use]
pub(crate) fn cursor_to_sql(cursor: EventCursor) -> i64 {
i64::try_from(cursor.value()).unwrap_or(i64::MAX)
}
#[must_use]
pub(crate) fn now() -> DateTime<Utc> {
truncate_to_micros(Utc::now())
}
#[must_use]
pub(crate) fn truncate_to_micros(at: DateTime<Utc>) -> DateTime<Utc> {
let micros = at.timestamp_subsec_micros();
at.with_nanosecond(micros.saturating_mul(1_000))
.unwrap_or(at)
}
#[cfg(test)]
mod tests {
use super::*;
use turnframe_core::event::OutboxStatus;
use turnframe_core::interaction::{InteractionKind, InteractionStatus};
use turnframe_core::replay::TurnPhase;
use turnframe_store::journal::CommandJournalStatus;
#[test]
fn interaction_status_labels_match_the_partial_index() {
for (status, expected) in [
(InteractionStatus::Active, "active"),
(InteractionStatus::Resolving, "resolving"),
(InteractionStatus::Resolved, "resolved"),
(InteractionStatus::Declined, "declined"),
(InteractionStatus::Dismissed, "dismissed"),
(InteractionStatus::Invalidated, "invalidated"),
(InteractionStatus::Expired, "expired"),
(InteractionStatus::Failed, "failed"),
] {
assert_eq!(label(&status).unwrap(), expected);
assert_eq!(from_label::<InteractionStatus>(expected).unwrap(), status);
}
}
#[test]
fn turn_phase_labels_match_the_recovery_index() {
for (phase, expected) in [
(TurnPhase::Received, "received"),
(TurnPhase::Interpreted, "interpreted"),
(TurnPhase::Reduced, "reduced"),
(TurnPhase::Executing, "executing"),
(TurnPhase::Committed, "committed"),
(TurnPhase::Composed, "composed"),
(TurnPhase::Delivered, "delivered"),
(TurnPhase::Failed, "failed"),
] {
assert_eq!(label(&phase).unwrap(), expected);
assert_eq!(from_label::<TurnPhase>(expected).unwrap(), phase);
}
}
#[test]
fn outbox_and_journal_labels_match_their_where_clauses() {
for (status, expected) in [
(OutboxStatus::Pending, "pending"),
(OutboxStatus::Dispatching, "dispatching"),
(OutboxStatus::OutcomeUnknown, "outcome_unknown"),
(OutboxStatus::Completed, "completed"),
(OutboxStatus::Failed, "failed"),
] {
assert_eq!(label(&status).unwrap(), expected);
}
for (status, expected) in [
(CommandJournalStatus::Pending, "pending"),
(
CommandJournalStatus::AwaitingConfirmation,
"awaiting_confirmation",
),
(CommandJournalStatus::Executing, "executing"),
(CommandJournalStatus::Committed, "committed"),
(CommandJournalStatus::Failed, "failed"),
(CommandJournalStatus::OutcomeUnknown, "outcome_unknown"),
] {
assert_eq!(label(&status).unwrap(), expected);
}
assert_eq!(
label(&InteractionKind::SingleSelect).unwrap(),
"single_select"
);
}
#[test]
fn an_unknown_label_is_corrupt_rather_than_a_silent_default() {
assert_eq!(
from_label::<InteractionStatus>("nonsense").unwrap_err(),
StoreError::Corrupt
);
}
#[test]
fn numbers_are_refused_rather_than_wrapped() {
assert_eq!(revision_to_sql(CaseRevision(7)).unwrap(), 7);
assert!(revision_to_sql(CaseRevision(u64::MAX)).is_err());
assert_eq!(revision_from_sql(7).unwrap(), CaseRevision(7));
assert_eq!(revision_from_sql(-1).unwrap_err(), StoreError::Corrupt);
assert_eq!(attempts_to_sql(3).unwrap(), 3);
assert_eq!(attempts_from_sql(3).unwrap(), 3);
assert_eq!(attempts_from_sql(-1).unwrap_err(), StoreError::Corrupt);
assert_eq!(sequence_from_sql(9).unwrap(), 9);
assert_eq!(count_from_sql(9).unwrap(), 9);
assert_eq!(limit_to_sql(10), 10);
assert_eq!(limit_to_sql(usize::MAX), i64::MAX);
assert_eq!(cursor_to_sql(EventCursor::after(4)), 4);
assert_eq!(cursor_to_sql(EventCursor(u64::MAX)), i64::MAX);
}
#[test]
fn instants_are_truncated_to_what_timestamptz_can_hold() {
let with_nanos = DateTime::<Utc>::UNIX_EPOCH
.with_nanosecond(123_456_789)
.unwrap();
assert_eq!(
truncate_to_micros(with_nanos).timestamp_subsec_nanos(),
123_456_000
);
assert_eq!(now().timestamp_subsec_nanos() % 1_000, 0);
}
#[test]
fn documents_round_trip_through_json() {
let value = serde_json::json!({ "b": 1, "a": [1, 2] });
let stored = to_json(&value).unwrap();
assert_eq!(from_json::<serde_json::Value>(stored).unwrap(), value);
}
}