use sha2::{Digest, Sha256};
use crate::__bypass::RawAccessExt as _;
use crate::context::DjogiContext;
use crate::error::{DbError, DjogiError};
pub(crate) const LEDGER_SELECT_COLS: &str = "SELECT version, description, checksum_up, checksum_down, execution_mode, \
status, execution_time_ms, out_of_order_flag, applied_steps_count, \
total_steps, partial_apply_note, run_id, snapshot_version, app_label, leaf_identity \
FROM djogi_schema_migrations";
impl TryFrom<&tokio_postgres::Row> for LedgerRow {
type Error = tokio_postgres::Error;
fn try_from(row: &tokio_postgres::Row) -> Result<Self, Self::Error> {
let version: String = row.try_get(0)?;
let description: String = row.try_get(1)?;
let checksum_up: String = row.try_get(2)?;
let checksum_down: Option<String> = row.try_get(3)?;
let execution_mode_s: String = row.try_get(4)?;
let status_s: String = row.try_get(5)?;
let execution_time_ms: i64 = row.try_get(6)?;
let out_of_order_flag: bool = row.try_get(7)?;
let applied_steps_count: i32 = row.try_get(8)?;
let total_steps: Option<i32> = row.try_get(9)?;
let partial_apply_note: Option<String> = row.try_get(10)?;
let run_id: i64 = row.try_get(11)?;
let snapshot_version: String = row.try_get(12)?;
let app_label: String = row.try_get(13)?;
let leaf_identity: Option<String> = row.try_get(14)?;
let execution_mode = match execution_mode_s.as_str() {
"transactional" => ExecutionMode::Transactional,
_ => ExecutionMode::NonTransactional,
};
let status = LedgerStatus::from_db_str(&status_s).unwrap_or(LedgerStatus::Failed);
Ok(LedgerRow {
version,
description,
checksum_up,
checksum_down,
execution_mode,
status,
execution_time_ms,
out_of_order_flag,
applied_steps_count,
total_steps,
partial_apply_note,
run_id,
snapshot_version,
app_label,
leaf_identity,
})
}
}
pub(crate) async fn load_full_row_by_version(
ctx: &mut DjogiContext,
version: &str,
) -> Result<Option<LedgerRow>, DjogiError> {
let sql = format!("{LEDGER_SELECT_COLS} WHERE version = $1");
let row_opt = ctx.query_opt(&sql, &[&version]).await?;
let Some(row) = row_opt else {
return Ok(None);
};
let ledger_row = LedgerRow::try_from(&row)?;
Ok(Some(ledger_row))
}
pub const LEDGER_TABLE_DDL: &str = r#"
CREATE TABLE IF NOT EXISTS djogi_schema_migrations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
version TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
checksum_up VARCHAR(68) NOT NULL,
checksum_down VARCHAR(68),
execution_mode TEXT NOT NULL DEFAULT 'transactional'
CHECK (execution_mode IN ('transactional', 'non_transactional')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN (
'pending', 'applied', 'baseline',
'faked', 'rolled_back', 'failed'
)),
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
applied_by TEXT NOT NULL DEFAULT current_user,
execution_time_ms BIGINT NOT NULL DEFAULT 0,
out_of_order_flag BOOLEAN NOT NULL DEFAULT FALSE,
applied_steps_count INTEGER NOT NULL DEFAULT 0,
total_steps INTEGER,
partial_apply_note TEXT,
run_id BIGINT NOT NULL,
snapshot_version TEXT NOT NULL,
app_label TEXT NOT NULL DEFAULT '',
leaf_identity TEXT
);
"#;
pub const SHA256_HEX_LEN: usize = 64;
pub const CHECKSUM_PREFIX: &str = "V1:";
pub const CHECKSUM_LEN: usize = 3 + SHA256_HEX_LEN;
pub(crate) const NON_TX_PROGRESS_CLAIM_PREFIX: &str = "non-tx progress claim:";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LedgerStatus {
Pending,
Applied,
Baseline,
Faked,
RolledBack,
Failed,
}
impl LedgerStatus {
pub const fn as_db_str(self) -> &'static str {
match self {
LedgerStatus::Pending => "pending",
LedgerStatus::Applied => "applied",
LedgerStatus::Baseline => "baseline",
LedgerStatus::Faked => "faked",
LedgerStatus::RolledBack => "rolled_back",
LedgerStatus::Failed => "failed",
}
}
pub fn from_db_str(s: &str) -> Option<Self> {
Some(match s {
"pending" => LedgerStatus::Pending,
"applied" => LedgerStatus::Applied,
"baseline" => LedgerStatus::Baseline,
"faked" => LedgerStatus::Faked,
"rolled_back" => LedgerStatus::RolledBack,
"failed" => LedgerStatus::Failed,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionMode {
Transactional,
NonTransactional,
}
impl ExecutionMode {
pub const fn as_db_str(self) -> &'static str {
match self {
ExecutionMode::Transactional => "transactional",
ExecutionMode::NonTransactional => "non_transactional",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LedgerRow {
pub version: String,
pub description: String,
pub checksum_up: String,
pub checksum_down: Option<String>,
pub execution_mode: ExecutionMode,
pub status: LedgerStatus,
pub execution_time_ms: i64,
pub out_of_order_flag: bool,
pub applied_steps_count: i32,
pub total_steps: Option<i32>,
pub partial_apply_note: Option<String>,
pub run_id: i64,
pub snapshot_version: String,
pub app_label: String,
pub leaf_identity: Option<String>,
}
pub(crate) fn format_non_tx_progress_claim(
prior_note: Option<&str>,
step_ordinal: i32,
total_steps: Option<i32>,
segment_index: usize,
statement_label: &str,
) -> String {
let mut note = match total_steps {
Some(total) => format!(
"{NON_TX_PROGRESS_CLAIM_PREFIX} step {step_ordinal} of {total} \
`{statement_label}` in segment {} is/was in flight; automatic resume \
is blocked until an operator reconciles whether that step committed",
segment_index + 1,
),
None => format!(
"{NON_TX_PROGRESS_CLAIM_PREFIX} step {step_ordinal} `{statement_label}` \
in segment {} is/was in flight; automatic resume is blocked until an \
operator reconciles whether that step committed",
segment_index + 1,
),
};
if let Some(prior) = prior_note.filter(|note| !note.is_empty()) {
note.push_str("\nprior note: ");
note.push_str(prior);
}
note
}
pub(crate) fn note_has_non_tx_progress_claim(note: Option<&str>) -> bool {
note.is_some_and(|value| value.starts_with(NON_TX_PROGRESS_CLAIM_PREFIX))
}
pub fn compute_checksum<I, S>(fragments: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut hasher = Sha256::new();
let mut first = true;
for frag in fragments {
if !first {
hasher.update(b"\n");
}
first = false;
hasher.update(frag.as_ref().as_bytes());
}
let digest = hasher.finalize();
let mut out = String::with_capacity(CHECKSUM_LEN);
out.push_str(CHECKSUM_PREFIX);
for b in digest {
out.push(hex_digit(b >> 4));
out.push(hex_digit(b & 0x0f));
}
debug_assert_eq!(out.len(), CHECKSUM_LEN);
out
}
fn hex_digit(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + (n - 10)) as char,
_ => unreachable!("hex_digit takes a 4-bit nibble"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChecksumFormatErrorKind {
WrongPrefix,
WrongLength { observed: usize },
NonLowercaseHex { offset: usize, byte: u8 },
}
impl std::fmt::Display for ChecksumFormatErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WrongPrefix => write!(
f,
"checksum string does not start with the `{CHECKSUM_PREFIX}` prefix",
),
Self::WrongLength { observed } => write!(
f,
"checksum string length is {observed} bytes; expected {CHECKSUM_LEN}",
),
Self::NonLowercaseHex { offset, byte } => write!(
f,
"checksum hex byte at offset {offset} is 0x{byte:02x}; expected lowercase ASCII hex",
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChecksumFormatError {
pub side: ChecksumSide,
pub value: String,
pub kind: ChecksumFormatErrorKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChecksumSide {
Expected,
Actual,
}
impl std::fmt::Display for ChecksumSide {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Expected => f.write_str("expected"),
Self::Actual => f.write_str("actual"),
}
}
}
impl std::fmt::Display for ChecksumFormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{side} checksum `{value}` is malformed: {kind}",
side = self.side,
value = self.value,
kind = self.kind,
)
}
}
impl std::error::Error for ChecksumFormatError {}
pub fn validate_checksum_format(s: &str) -> Result<(), ChecksumFormatErrorKind> {
let bytes = s.as_bytes();
if !s.starts_with(CHECKSUM_PREFIX) {
return Err(ChecksumFormatErrorKind::WrongPrefix);
}
if bytes.len() != CHECKSUM_LEN {
return Err(ChecksumFormatErrorKind::WrongLength {
observed: bytes.len(),
});
}
let prefix_len = CHECKSUM_PREFIX.len();
for (i, &b) in bytes[prefix_len..].iter().enumerate() {
let is_lower_hex = b.is_ascii_digit() || (b'a'..=b'f').contains(&b);
if !is_lower_hex {
return Err(ChecksumFormatErrorKind::NonLowercaseHex {
offset: prefix_len + i,
byte: b,
});
}
}
Ok(())
}
pub fn verify_checksum(version: &str, expected: &str, actual: &str) -> Result<(), VerifyError> {
if let Err(kind) = validate_checksum_format(expected) {
return Err(VerifyError::Format(ChecksumFormatError {
side: ChecksumSide::Expected,
value: expected.to_string(),
kind,
}));
}
if let Err(kind) = validate_checksum_format(actual) {
return Err(VerifyError::Format(ChecksumFormatError {
side: ChecksumSide::Actual,
value: actual.to_string(),
kind,
}));
}
if expected == actual {
Ok(())
} else {
Err(VerifyError::Mismatch(ChecksumMismatch {
version: version.to_string(),
expected: expected.to_string(),
actual: actual.to_string(),
}))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyError {
Format(ChecksumFormatError),
Mismatch(ChecksumMismatch),
}
impl std::fmt::Display for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VerifyError::Format(e) => write!(f, "{e}"),
VerifyError::Mismatch(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for VerifyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
VerifyError::Format(e) => Some(e),
VerifyError::Mismatch(e) => Some(e),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChecksumMismatch {
pub version: String,
pub expected: String,
pub actual: String,
}
impl std::fmt::Display for ChecksumMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"checksum mismatch on version `{ver}`: expected {exp}, computed {act}",
ver = self.version,
exp = self.expected,
act = self.actual,
)
}
}
impl std::error::Error for ChecksumMismatch {}
pub async fn bootstrap(ctx: &mut DjogiContext) -> Result<(), DjogiError> {
ctx.raw_ddl(LEDGER_TABLE_DDL).await?;
ctx.raw_ddl("ALTER TABLE djogi_schema_migrations ADD COLUMN IF NOT EXISTS leaf_identity TEXT")
.await?;
Ok(())
}
pub async fn insert_pending(ctx: &mut DjogiContext, row: &LedgerRow) -> Result<i64, DjogiError> {
let sql = "INSERT INTO djogi_schema_migrations \
(version, description, checksum_up, checksum_down, execution_mode, \
status, execution_time_ms, out_of_order_flag, applied_steps_count, \
total_steps, partial_apply_note, run_id, snapshot_version, app_label, leaf_identity) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) \
RETURNING id";
let exec_mode = row.execution_mode.as_db_str();
let status = row.status.as_db_str();
let row_db = ctx
.query_one(
sql,
&[
&row.version,
&row.description,
&row.checksum_up,
&row.checksum_down,
&exec_mode,
&status,
&row.execution_time_ms,
&row.out_of_order_flag,
&row.applied_steps_count,
&row.total_steps,
&row.partial_apply_note,
&row.run_id,
&row.snapshot_version,
&row.app_label,
&row.leaf_identity,
],
)
.await?;
Ok(row_db.try_get::<_, i64>("id")?)
}
pub async fn mark_applied(
ctx: &mut DjogiContext,
ledger_id: i64,
execution_time_ms: i64,
applied_steps_count: i32,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET status = 'applied', \
execution_time_ms = $2, \
applied_steps_count = $3, \
applied_at = now(), \
partial_apply_note = NULL \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, &execution_time_ms, &applied_steps_count])
.await?;
Ok(())
}
pub async fn mark_applied_keep_note(
ctx: &mut DjogiContext,
ledger_id: i64,
execution_time_ms: i64,
applied_steps_count: i32,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET status = 'applied', \
execution_time_ms = $2, \
applied_steps_count = $3, \
applied_at = now() \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, &execution_time_ms, &applied_steps_count])
.await?;
Ok(())
}
pub async fn mark_failed(
ctx: &mut DjogiContext,
ledger_id: i64,
note: &str,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET status = 'failed', \
partial_apply_note = $2 \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, ¬e]).await?;
Ok(())
}
pub async fn claim_non_tx_progress(
ctx: &mut DjogiContext,
ledger_id: i64,
note: &str,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET partial_apply_note = $2 \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, ¬e]).await?;
Ok(())
}
pub async fn update_progress(
ctx: &mut DjogiContext,
ledger_id: i64,
applied_steps_count: i32,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET applied_steps_count = $2 \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, &applied_steps_count])
.await?;
Ok(())
}
pub async fn ack_non_tx_progress(
ctx: &mut DjogiContext,
ledger_id: i64,
applied_steps_count: i32,
restored_note: Option<&str>,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET applied_steps_count = $2, \
partial_apply_note = $3 \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, &applied_steps_count, &restored_note])
.await?;
Ok(())
}
pub async fn mark_partial(
ctx: &mut DjogiContext,
ledger_id: i64,
applied_steps_count: i32,
note: &str,
) -> Result<(), DjogiError> {
let sql = "UPDATE djogi_schema_migrations \
SET status = 'failed', \
applied_steps_count = $2, \
partial_apply_note = $3 \
WHERE id = $1";
ctx.execute(sql, &[&ledger_id, &applied_steps_count, ¬e])
.await?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LedgerSummaryRow {
pub id: i64,
pub version: String,
pub description: String,
pub status: LedgerStatus,
pub execution_time_ms: i64,
pub applied_at_rfc3339: String,
pub applied_by: String,
pub run_id: i64,
pub partial_apply_note: Option<String>,
pub app_label: String,
pub out_of_order_flag: bool,
}
pub async fn select_all(ctx: &mut DjogiContext) -> Result<Vec<LedgerSummaryRow>, DjogiError> {
let sql = "SELECT \
id, version, description, status, execution_time_ms, \
to_char(applied_at AT TIME ZONE 'UTC', \
'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS applied_at_rfc3339, \
applied_by, run_id, partial_apply_note, app_label, \
out_of_order_flag \
FROM djogi_schema_migrations \
ORDER BY app_label ASC, applied_at ASC, id ASC";
let rows = ctx.query_all(sql, &[]).await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let status_str: String = row.try_get("status")?;
let status = LedgerStatus::from_db_str(&status_str).ok_or_else(|| {
DjogiError::Db(DbError::other(format!(
"unknown LedgerStatus value in djogi_schema_migrations: {status_str:?}"
)))
})?;
out.push(LedgerSummaryRow {
id: row.try_get("id")?,
version: row.try_get("version")?,
description: row.try_get("description")?,
status,
execution_time_ms: row.try_get("execution_time_ms")?,
applied_at_rfc3339: row.try_get("applied_at_rfc3339")?,
applied_by: row.try_get("applied_by")?,
run_id: row.try_get("run_id")?,
partial_apply_note: row.try_get("partial_apply_note")?,
app_label: row.try_get("app_label")?,
out_of_order_flag: row.try_get("out_of_order_flag")?,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checksum_has_v1_prefix_and_64_hex() {
let csum = compute_checksum(["CREATE TABLE foo ();"]);
assert!(csum.starts_with(CHECKSUM_PREFIX));
assert_eq!(csum.len(), CHECKSUM_LEN);
let hex = &csum[CHECKSUM_PREFIX.len()..];
assert_eq!(hex.len(), SHA256_HEX_LEN);
assert!(hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')));
}
#[test]
fn empty_input_still_hashes_deterministically() {
let csum = compute_checksum(std::iter::empty::<&str>());
assert_eq!(
csum,
"V1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn checksum_is_deterministic_across_calls() {
let a = compute_checksum(["a", "b", "c"]);
let b = compute_checksum(["a", "b", "c"]);
assert_eq!(a, b);
}
#[test]
fn checksum_changes_on_fragment_reorder() {
let a = compute_checksum(["a", "b"]);
let b = compute_checksum(["b", "a"]);
assert_ne!(a, b, "order must be load-bearing");
}
#[test]
fn checksum_separates_fragments_with_newline() {
let direct = {
let mut h = Sha256::new();
h.update(b"a\nb");
let d = h.finalize();
let mut s = String::from(CHECKSUM_PREFIX);
for b in d {
s.push(hex_digit(b >> 4));
s.push(hex_digit(b & 0x0f));
}
s
};
assert_eq!(compute_checksum(["a", "b"]), direct);
}
#[test]
fn verify_checksum_ok_on_match() {
let csum = compute_checksum(["x"]);
assert!(verify_checksum("V_test", &csum, &csum).is_ok());
}
#[test]
fn verify_checksum_err_on_mismatch_carries_payload() {
let a = compute_checksum(["x"]);
let b = compute_checksum(["y"]);
let err = verify_checksum("V_test", &a, &b).unwrap_err();
match err {
VerifyError::Mismatch(m) => {
assert_eq!(m.version, "V_test");
assert_eq!(m.expected, a);
assert_eq!(m.actual, b);
let msg = m.to_string();
assert!(msg.contains("V_test"));
assert!(msg.contains("checksum mismatch"));
}
other => panic!("expected Mismatch, got {other:?}"),
}
}
#[test]
fn validate_format_accepts_canonical_compute_checksum_output() {
let csum = compute_checksum(["foo"]);
validate_checksum_format(&csum).expect("canonical output is valid");
}
#[test]
fn validate_format_rejects_uppercase_v_prefix() {
let bad = "v1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(bad.len(), CHECKSUM_LEN);
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(err, ChecksumFormatErrorKind::WrongPrefix));
}
#[test]
fn validate_format_rejects_v2_prefix() {
let bad = "V2:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(bad.len(), CHECKSUM_LEN);
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(err, ChecksumFormatErrorKind::WrongPrefix));
}
#[test]
fn validate_format_rejects_no_prefix() {
let bad = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(err, ChecksumFormatErrorKind::WrongPrefix));
}
#[test]
fn validate_format_rejects_short_length() {
let bad = "V1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85"; assert_eq!(bad.len(), CHECKSUM_LEN - 1);
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(
err,
ChecksumFormatErrorKind::WrongLength { observed: 66 }
));
}
#[test]
fn validate_format_rejects_long_length() {
let bad = "V1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8550"; assert_eq!(bad.len(), CHECKSUM_LEN + 1);
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(
err,
ChecksumFormatErrorKind::WrongLength { observed: 68 }
));
}
#[test]
fn validate_format_rejects_uppercase_hex() {
let bad = "V1:ABC0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(bad.len(), CHECKSUM_LEN);
let err = validate_checksum_format(bad).unwrap_err();
match err {
ChecksumFormatErrorKind::NonLowercaseHex { offset, byte } => {
assert_eq!(offset, 3); assert_eq!(byte, b'A');
}
other => panic!("expected NonLowercaseHex, got {other:?}"),
}
}
#[test]
fn validate_format_rejects_non_hex_letters() {
let bad = "V1:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
assert_eq!(bad.len(), CHECKSUM_LEN);
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(
err,
ChecksumFormatErrorKind::NonLowercaseHex {
offset: 3,
byte: b'z'
}
));
}
#[test]
fn validate_format_rejects_uppercase_g() {
let bad = "V1:GGG0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(bad.len(), CHECKSUM_LEN);
let err = validate_checksum_format(bad).unwrap_err();
assert!(matches!(
err,
ChecksumFormatErrorKind::NonLowercaseHex {
offset: 3,
byte: b'G'
}
));
}
#[test]
fn verify_checksum_rejects_malformed_expected() {
let actual = compute_checksum(["x"]);
let err = verify_checksum("V_test", "V1:NOT_HEX", &actual).unwrap_err();
match err {
VerifyError::Format(f) => {
assert_eq!(f.side, ChecksumSide::Expected);
}
other => panic!("expected Format(Expected), got {other:?}"),
}
}
#[test]
fn verify_checksum_rejects_malformed_actual() {
let expected = compute_checksum(["x"]);
let bad_actual = "V1:ABC0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let err = verify_checksum("V_test", &expected, bad_actual).unwrap_err();
match err {
VerifyError::Format(f) => {
assert_eq!(f.side, ChecksumSide::Actual);
}
other => panic!("expected Format(Actual), got {other:?}"),
}
}
#[test]
fn verify_checksum_runs_format_check_before_equality() {
let bad = "V1:ABC0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let err = verify_checksum("V_test", bad, bad).unwrap_err();
assert!(matches!(err, VerifyError::Format(_)));
}
#[test]
fn ledger_status_round_trips_through_db_str() {
for s in [
LedgerStatus::Pending,
LedgerStatus::Applied,
LedgerStatus::Baseline,
LedgerStatus::Faked,
LedgerStatus::RolledBack,
LedgerStatus::Failed,
] {
assert_eq!(LedgerStatus::from_db_str(s.as_db_str()), Some(s));
}
}
#[test]
fn ledger_status_unknown_returns_none() {
assert_eq!(LedgerStatus::from_db_str("out_of_order"), None);
assert_eq!(LedgerStatus::from_db_str(""), None);
}
#[test]
fn execution_mode_db_strings_are_stable() {
assert_eq!(ExecutionMode::Transactional.as_db_str(), "transactional");
assert_eq!(
ExecutionMode::NonTransactional.as_db_str(),
"non_transactional"
);
}
#[test]
fn checksum_constants_are_consistent() {
assert_eq!(CHECKSUM_LEN, CHECKSUM_PREFIX.len() + SHA256_HEX_LEN);
assert_eq!(SHA256_HEX_LEN, 64);
}
#[test]
fn non_tx_progress_claim_note_round_trips_through_prefix_detector() {
let note = format_non_tx_progress_claim(
Some("out-of-order apply: peer V20260524__older"),
2,
Some(3),
1,
"AddIndex widgets_name_idx",
);
assert!(note_has_non_tx_progress_claim(Some(¬e)));
assert!(note.contains("step 2 of 3"));
assert!(note.contains("AddIndex widgets_name_idx"));
assert!(note.contains("prior note: out-of-order apply"));
}
#[test]
fn non_tx_progress_claim_detector_ignores_regular_partial_notes() {
assert!(!note_has_non_tx_progress_claim(None));
assert!(!note_has_non_tx_progress_claim(Some(
"non-tx step 2 of segment 1 failed: AddIndex widgets_name_idx"
)));
}
}