use vta_sdk::webvh::WebvhDidRecord;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordSnapshot {
did: String,
log_entry_count: u32,
updated_at: chrono::DateTime<chrono::Utc>,
server_id: String,
}
impl RecordSnapshot {
pub fn capture(record: &WebvhDidRecord) -> Self {
Self {
did: record.did.clone(),
log_entry_count: record.log_entry_count,
updated_at: record.updated_at,
server_id: record.server_id.clone(),
}
}
pub fn assert_unchanged(&self, current: &WebvhDidRecord) -> Result<(), RaceDetected> {
debug_assert_eq!(
self.did, current.did,
"RecordSnapshot::assert_unchanged comparing different DIDs — caller bug"
);
if self.log_entry_count != current.log_entry_count {
return Err(RaceDetected::LogEntryCountChanged {
did: self.did.clone(),
expected: self.log_entry_count,
current: current.log_entry_count,
});
}
if self.updated_at != current.updated_at {
return Err(RaceDetected::UpdatedAtChanged {
did: self.did.clone(),
expected: self.updated_at,
current: current.updated_at,
});
}
if self.server_id != current.server_id {
return Err(RaceDetected::ServerIdChanged {
did: self.did.clone(),
expected: self.server_id.clone(),
current: current.server_id.clone(),
});
}
Ok(())
}
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, thiserror::Error)]
pub enum RaceDetected {
#[error(
"DID `{did}` log_entry_count changed concurrently \
(expected {expected}, got {current}) — another caller appended a log entry"
)]
LogEntryCountChanged {
did: String,
expected: u32,
current: u32,
},
#[error(
"DID `{did}` was modified concurrently \
(record updated_at moved from {expected} to {current})"
)]
UpdatedAtChanged {
did: String,
expected: chrono::DateTime<chrono::Utc>,
current: chrono::DateTime<chrono::Utc>,
},
#[error(
"DID `{did}` server_id changed concurrently \
(`{expected}` → `{current}`) — another caller registered or moved this DID"
)]
ServerIdChanged {
did: String,
expected: String,
current: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn record(did: &str, count: u32, ts: i64, server: &str) -> WebvhDidRecord {
WebvhDidRecord {
did: did.into(),
server_id: server.into(),
mnemonic: "irrelevant".into(),
scid: "scid".into(),
context_id: "vta".into(),
portable: true,
log_entry_count: count,
pre_rotation_count: 0,
next_fragment_id: 0,
created_at: chrono::Utc::now(),
updated_at: chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0).unwrap(),
}
}
#[test]
fn unchanged_record_passes() {
let r = record("did:webvh:foo", 1, 1_000_000, "serverless");
let snap = RecordSnapshot::capture(&r);
snap.assert_unchanged(&r).expect("identity case must pass");
}
#[test]
fn log_entry_count_change_detected() {
let before = record("did:webvh:foo", 1, 1_000_000, "serverless");
let after = record("did:webvh:foo", 2, 1_000_000, "serverless");
let snap = RecordSnapshot::capture(&before);
let err = snap.assert_unchanged(&after).unwrap_err();
assert!(
matches!(
err,
RaceDetected::LogEntryCountChanged {
expected: 1,
current: 2,
..
}
),
"got {err:?}"
);
}
#[test]
fn updated_at_change_detected() {
let before = record("did:webvh:foo", 1, 1_000_000, "serverless");
let after = record("did:webvh:foo", 1, 1_000_001, "serverless");
let snap = RecordSnapshot::capture(&before);
let err = snap.assert_unchanged(&after).unwrap_err();
assert!(
matches!(err, RaceDetected::UpdatedAtChanged { .. }),
"got {err:?}"
);
}
#[test]
fn server_id_change_detected() {
let before = record("did:webvh:foo", 1, 1_000_000, "serverless");
let after = record("did:webvh:foo", 1, 1_000_000, "webvh-prod");
let snap = RecordSnapshot::capture(&before);
let err = snap.assert_unchanged(&after).unwrap_err();
assert!(
matches!(err, RaceDetected::ServerIdChanged { ref expected, ref current, .. }
if expected == "serverless" && current == "webvh-prod"),
"got {err:?}"
);
}
#[test]
fn unrelated_field_changes_do_not_trip_assertion() {
let before = record("did:webvh:foo", 1, 1_000_000, "serverless");
let mut after = before.clone();
after.mnemonic = "rotated-by-this-op".into();
after.next_fragment_id = 42;
after.pre_rotation_count = 3;
let snap = RecordSnapshot::capture(&before);
snap.assert_unchanged(&after)
.expect("only log_entry_count, updated_at, server_id are version-vector fields");
}
}