commit d14e376c7699c622b8ca3ba4cf8891afe56d4242
parent da2cd32f815a2fb4ad6dbcc2992d44bb71b4983c
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 22:55:47 +0000
operations: persist idempotent durable requests
- store prior state expected revisions diagnostics and terminal receipts
- replay matching request identifiers without duplicating operations
- reject conflicting retries and out-of-order phase transitions
- retain completed receipts for deterministic response-loss recovery
Diffstat:
6 files changed, 568 insertions(+), 11 deletions(-)
diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs
@@ -33,8 +33,9 @@ pub use config::{
pub use nostr_client::SdkNostrClient;
pub use ports::{
AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,
- AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, DurableOperationKind,
- DurableOperationPhase, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome,
+ AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock,
+ DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt,
+ DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome,
NostrClient, OperationDiagnostic, OperationId, OperationJournal, OperationPriorState,
PendingAccountOperation, ProfileRefreshStatus, ProfileRepository,
};
diff --git a/core/crates/application/src/ports.rs b/core/crates/application/src/ports.rs
@@ -136,6 +136,90 @@ impl DurableOperationReceipt {
}
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DurableAccountOperation {
+ request_id: DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ phase: DurableOperationPhase,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ terminal: Option<DurableOperationReceipt>,
+}
+
+impl DurableAccountOperation {
+ #[allow(clippy::too_many_arguments)]
+ #[must_use]
+ pub const fn new(
+ request_id: DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ phase: DurableOperationPhase,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ terminal: Option<DurableOperationReceipt>,
+ ) -> Self {
+ Self {
+ request_id,
+ kind,
+ account,
+ expected_revision,
+ phase,
+ prior,
+ updated_at,
+ diagnostic,
+ terminal,
+ }
+ }
+
+ #[must_use]
+ pub const fn request_id(&self) -> &DurableRequestId {
+ &self.request_id
+ }
+ #[must_use]
+ pub const fn kind(&self) -> DurableOperationKind {
+ self.kind
+ }
+ #[must_use]
+ pub const fn account(&self) -> PublicKey {
+ self.account
+ }
+ #[must_use]
+ pub const fn expected_revision(&self) -> Option<u64> {
+ self.expected_revision
+ }
+ #[must_use]
+ pub const fn phase(&self) -> DurableOperationPhase {
+ self.phase
+ }
+ #[must_use]
+ pub const fn prior(&self) -> OperationPriorState {
+ self.prior
+ }
+ #[must_use]
+ pub const fn updated_at(&self) -> UnixTimestamp {
+ self.updated_at
+ }
+ #[must_use]
+ pub const fn diagnostic(&self) -> Option<OperationDiagnostic> {
+ self.diagnostic
+ }
+ #[must_use]
+ pub const fn terminal(&self) -> Option<&DurableOperationReceipt> {
+ self.terminal.as_ref()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum DurableOperationStart {
+ Started(DurableAccountOperation),
+ Existing(DurableAccountOperation),
+}
+
const fn invalid_request_id() -> SafeError {
SafeError::new(
SafeErrorCode::InvalidApplicationState,
@@ -187,6 +271,8 @@ pub enum OperationDiagnostic {
KeyringUnavailable,
CredentialMissing,
CompensationFailed,
+ Conflict,
+ Expired,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -439,6 +525,66 @@ pub trait OperationJournal: Send + Sync {
fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError>;
}
+pub trait DurableOperationRepository: Send + Sync {
+ /// Records one idempotent durable operation or returns the existing matching request.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict or storage error when the request cannot be recorded.
+ #[allow(clippy::too_many_arguments)]
+ fn begin_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationStart, SafeError>;
+ /// Loads one durable operation by its idempotency key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when the lookup cannot complete.
+ fn load_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ ) -> Result<Option<DurableAccountOperation>, SafeError>;
+ /// Advances one operation only from the caller's expected phase.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict or storage error when the transition cannot commit.
+ fn advance_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ next_phase: DurableOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<DurableAccountOperation, SafeError>;
+ /// Finalizes one operation and durably retains its recoverable receipt.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe conflict or storage error when finalization cannot commit.
+ fn finalize_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationReceipt, SafeError>;
+ /// Lists unfinished operations in deterministic request order.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage error when operations cannot be read.
+ fn list_unfinished_durable_operations(&self)
+ -> Result<Vec<DurableAccountOperation>, SafeError>;
+}
+
pub trait NostrClient: Send + Sync {
fn fetch_profile<'a>(
&'a self,
diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs
@@ -397,7 +397,7 @@ mod tests {
assert_eq!(property("baseline.id"), Some("studio-runtime-v5"));
assert_eq!(property("schema.version"), Some("5"));
- assert_eq!(CURRENT_SCHEMA_VERSION, 8);
+ assert_eq!(CURRENT_SCHEMA_VERSION, 9);
assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1"));
assert_eq!(property("ffi.snapshot.schema"), Some("1"));
assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha"));
diff --git a/core/crates/storage/migrations/V9__durable_operation_receipts.sql b/core/crates/storage/migrations/V9__durable_operation_receipts.sql
@@ -0,0 +1,11 @@
+ALTER TABLE durable_operations ADD COLUMN prior_binding_availability TEXT CHECK (
+ prior_binding_availability IS NULL OR prior_binding_availability IN (
+ 'available',
+ 'credential_missing',
+ 'store_unavailable'
+ )
+);
+
+ALTER TABLE durable_operations ADD COLUMN resulting_revision INTEGER CHECK (
+ resulting_revision IS NULL OR resulting_revision >= 0
+);
diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs
@@ -9,7 +9,7 @@ use radroots_studio_domain::{AccountIdentity, PublicKey, SafeError, SafeErrorCod
use refinery::embed_migrations;
use rusqlite::{Connection, OpenFlags};
-pub const CURRENT_SCHEMA_VERSION: u32 = 8;
+pub const CURRENT_SCHEMA_VERSION: u32 = 9;
mod migrations {
use super::embed_migrations;
@@ -383,7 +383,7 @@ mod tests {
}
let database = Database::open(&path).expect("migrated database");
- assert_eq!(database.schema_version().expect("version"), 8);
+ assert_eq!(database.schema_version().expect("version"), 9);
assert_eq!(database.list_accounts().expect("accounts").len(), 1);
assert_eq!(
database.load_selected_account().expect("selection"),
diff --git a/core/crates/storage/src/journal.rs b/core/crates/storage/src/journal.rs
@@ -1,12 +1,236 @@
use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, OperationDiagnostic, OperationId,
- OperationJournal, PendingAccountOperation,
+ AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind,
+ DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository,
+ DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic,
+ OperationId, OperationJournal, OperationPriorState, PendingAccountOperation,
};
-use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
-use rusqlite::{Row, params};
+use radroots_studio_domain::{
+ BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp,
+};
+use rusqlite::{OptionalExtension, Row, params};
use crate::Database;
+impl DurableOperationRepository for Database {
+ fn begin_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ kind: DurableOperationKind,
+ account: PublicKey,
+ expected_revision: Option<u64>,
+ prior: OperationPriorState,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationStart, SafeError> {
+ let encoded_expected_revision = expected_revision
+ .map(i64::try_from)
+ .transpose()
+ .map_err(|_| operation_conflict())?;
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ let inserted = transaction
+ .execute(
+ "INSERT OR IGNORE INTO durable_operations (request_id, operation_kind, \
+ account_public_key, binding_public_key, expected_revision, phase, \
+ prior_selected_public_key, updated_at, prior_binding_availability) \
+ VALUES (?1, ?2, ?3, ?3, ?4, 'intent_recorded', ?5, ?6, ?7)",
+ params![
+ request_id.as_str(),
+ encode_durable_kind(kind),
+ account.to_hex(),
+ encoded_expected_revision,
+ prior.selected_account().map(PublicKey::to_hex),
+ updated_at.as_seconds(),
+ prior
+ .binding_availability()
+ .map(encode_binding_availability),
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ let operation =
+ query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
+ if operation.kind() != kind
+ || operation.account() != account
+ || operation.expected_revision() != expected_revision
+ || operation.prior() != prior
+ {
+ return Err(operation_conflict());
+ }
+ transaction.commit().map_err(|_| storage_error())?;
+ Ok(if inserted == 1 {
+ DurableOperationStart::Started(operation)
+ } else {
+ DurableOperationStart::Existing(operation)
+ })
+ }
+
+ fn load_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ ) -> Result<Option<DurableAccountOperation>, SafeError> {
+ query_durable_operation(&self.connection(), request_id)
+ }
+
+ fn advance_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ next_phase: DurableOperationPhase,
+ updated_at: UnixTimestamp,
+ diagnostic: Option<OperationDiagnostic>,
+ ) -> Result<DurableAccountOperation, SafeError> {
+ let mut connection = self.connection();
+ let transaction = connection.transaction().map_err(|_| storage_error())?;
+ let rows = transaction
+ .execute(
+ "UPDATE durable_operations SET phase = ?3, updated_at = ?4, diagnostic_code = ?5 \
+ WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
+ params![
+ request_id.as_str(),
+ encode_durable_phase(expected_phase),
+ encode_durable_phase(next_phase),
+ updated_at.as_seconds(),
+ diagnostic.map(encode_diagnostic),
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ if rows != 1 {
+ return Err(operation_conflict());
+ }
+ let operation =
+ query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?;
+ transaction.commit().map_err(|_| storage_error())?;
+ Ok(operation)
+ }
+
+ fn finalize_durable_operation(
+ &self,
+ request_id: &DurableRequestId,
+ expected_phase: DurableOperationPhase,
+ outcome: DurableTerminalOutcome,
+ resulting_revision: Option<u64>,
+ updated_at: UnixTimestamp,
+ ) -> Result<DurableOperationReceipt, SafeError> {
+ if let Some(existing) = self.load_durable_operation(request_id)?
+ && let Some(receipt) = existing.terminal()
+ {
+ return if receipt.outcome() == outcome
+ && receipt.resulting_revision() == resulting_revision
+ {
+ Ok(receipt.clone())
+ } else {
+ Err(operation_conflict())
+ };
+ }
+ let resulting_revision = resulting_revision
+ .map(i64::try_from)
+ .transpose()
+ .map_err(|_| operation_conflict())?;
+ let rows = self
+ .connection()
+ .execute(
+ "UPDATE durable_operations SET phase = 'finalized', terminal_outcome = ?3, \
+ resulting_revision = ?4, updated_at = ?5 \
+ WHERE request_id = ?1 AND phase = ?2 AND terminal_outcome IS NULL",
+ params![
+ request_id.as_str(),
+ encode_durable_phase(expected_phase),
+ encode_terminal_outcome(outcome),
+ resulting_revision,
+ updated_at.as_seconds(),
+ ],
+ )
+ .map_err(|_| storage_error())?;
+ if rows != 1 {
+ return Err(operation_conflict());
+ }
+ self.load_durable_operation(request_id)?
+ .and_then(|operation| operation.terminal().cloned())
+ .ok_or_else(corrupt_storage_error)
+ }
+
+ fn list_unfinished_durable_operations(
+ &self,
+ ) -> Result<Vec<DurableAccountOperation>, SafeError> {
+ let connection = self.connection();
+ let mut statement = connection
+ .prepare(&format!(
+ "{DURABLE_OPERATION_SELECT} WHERE terminal_outcome IS NULL ORDER BY request_id ASC"
+ ))
+ .map_err(|_| storage_error())?;
+ let rows = statement
+ .query_map([], decode_durable_operation)
+ .map_err(|_| storage_error())?;
+ rows.map(|row| row.map_err(|_| corrupt_storage_error()))
+ .collect()
+ }
+}
+
+const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, account_public_key, \
+ expected_revision, phase, prior_selected_public_key, updated_at, diagnostic_code, \
+ terminal_outcome, prior_binding_availability, resulting_revision FROM durable_operations";
+
+fn query_durable_operation(
+ connection: &rusqlite::Connection,
+ request_id: &DurableRequestId,
+) -> Result<Option<DurableAccountOperation>, SafeError> {
+ connection
+ .query_row(
+ &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"),
+ [request_id.as_str()],
+ decode_durable_operation,
+ )
+ .optional()
+ .map_err(|_| corrupt_storage_error())
+}
+
+fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> {
+ let request_id =
+ DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?;
+ let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?;
+ let account =
+ PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?;
+ let expected_revision = row
+ .get::<_, Option<i64>>(3)?
+ .map(|value| u64::try_from(value).map_err(|_| invalid_column(3)))
+ .transpose()?;
+ let phase = decode_durable_phase(row.get::<_, String>(4)?.as_str())?;
+ let prior_selected = row
+ .get::<_, Option<String>>(5)?
+ .map(|value| PublicKey::from_hex(&value).map_err(|_| invalid_column(5)))
+ .transpose()?;
+ let updated_at = UnixTimestamp::from_seconds(row.get(6)?).ok_or_else(|| invalid_column(6))?;
+ let diagnostic = row
+ .get::<_, Option<String>>(7)?
+ .map(|value| decode_diagnostic(&value))
+ .transpose()?;
+ let outcome = row
+ .get::<_, Option<String>>(8)?
+ .map(|value| decode_terminal_outcome(&value))
+ .transpose()?;
+ let prior_availability = row
+ .get::<_, Option<String>>(9)?
+ .map(|value| decode_binding_availability(&value))
+ .transpose()?;
+ let resulting_revision = row
+ .get::<_, Option<i64>>(10)?
+ .map(|value| u64::try_from(value).map_err(|_| invalid_column(10)))
+ .transpose()?;
+ let terminal = outcome.map(|outcome| {
+ DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision)
+ });
+ Ok(DurableAccountOperation::new(
+ request_id,
+ kind,
+ account,
+ expected_revision,
+ phase,
+ OperationPriorState::new(prior_selected, prior_availability),
+ updated_at,
+ diagnostic,
+ terminal,
+ ))
+}
+
impl OperationJournal for Database {
fn begin_operation(
&self,
@@ -99,6 +323,86 @@ fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation>
))
}
+const fn encode_durable_kind(value: DurableOperationKind) -> &'static str {
+ match value {
+ DurableOperationKind::Create => "create",
+ DurableOperationKind::Import => "import",
+ DurableOperationKind::Repair => "repair",
+ DurableOperationKind::Remove => "remove",
+ }
+}
+
+fn decode_durable_kind(value: &str) -> rusqlite::Result<DurableOperationKind> {
+ match value {
+ "create" => Ok(DurableOperationKind::Create),
+ "import" => Ok(DurableOperationKind::Import),
+ "repair" => Ok(DurableOperationKind::Repair),
+ "remove" => Ok(DurableOperationKind::Remove),
+ _ => Err(invalid_column(1)),
+ }
+}
+
+const fn encode_durable_phase(value: DurableOperationPhase) -> &'static str {
+ match value {
+ DurableOperationPhase::IntentRecorded => "intent_recorded",
+ DurableOperationPhase::CredentialWritten => "credential_written",
+ DurableOperationPhase::MetadataCommitted => "metadata_committed",
+ DurableOperationPhase::SelectionCommitted => "selection_committed",
+ DurableOperationPhase::CompensationPending => "compensation_pending",
+ DurableOperationPhase::CredentialDeleted => "credential_deleted",
+ DurableOperationPhase::MetadataDeleted => "metadata_deleted",
+ DurableOperationPhase::Finalized => "finalized",
+ }
+}
+
+fn decode_durable_phase(value: &str) -> rusqlite::Result<DurableOperationPhase> {
+ match value {
+ "intent_recorded" => Ok(DurableOperationPhase::IntentRecorded),
+ "credential_written" => Ok(DurableOperationPhase::CredentialWritten),
+ "metadata_committed" => Ok(DurableOperationPhase::MetadataCommitted),
+ "selection_committed" => Ok(DurableOperationPhase::SelectionCommitted),
+ "compensation_pending" => Ok(DurableOperationPhase::CompensationPending),
+ "credential_deleted" => Ok(DurableOperationPhase::CredentialDeleted),
+ "metadata_deleted" => Ok(DurableOperationPhase::MetadataDeleted),
+ "finalized" => Ok(DurableOperationPhase::Finalized),
+ _ => Err(invalid_column(4)),
+ }
+}
+
+const fn encode_terminal_outcome(value: DurableTerminalOutcome) -> &'static str {
+ match value {
+ DurableTerminalOutcome::Completed => "completed",
+ DurableTerminalOutcome::Cancelled => "cancelled",
+ DurableTerminalOutcome::Failed => "failed",
+ }
+}
+
+fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutcome> {
+ match value {
+ "completed" => Ok(DurableTerminalOutcome::Completed),
+ "cancelled" => Ok(DurableTerminalOutcome::Cancelled),
+ "failed" => Ok(DurableTerminalOutcome::Failed),
+ _ => Err(invalid_column(8)),
+ }
+}
+
+const fn encode_binding_availability(value: BindingAvailability) -> &'static str {
+ match value {
+ BindingAvailability::Available => "available",
+ BindingAvailability::CredentialMissing => "credential_missing",
+ BindingAvailability::StoreUnavailable => "store_unavailable",
+ }
+}
+
+fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> {
+ match value {
+ "available" => Ok(BindingAvailability::Available),
+ "credential_missing" => Ok(BindingAvailability::CredentialMissing),
+ "store_unavailable" => Ok(BindingAvailability::StoreUnavailable),
+ _ => Err(invalid_column(9)),
+ }
+}
+
const fn encode_kind(value: AccountOperationKind) -> &'static str {
match value {
AccountOperationKind::Add => "add",
@@ -145,6 +449,8 @@ const fn encode_diagnostic(value: OperationDiagnostic) -> &'static str {
OperationDiagnostic::KeyringUnavailable => "keyring_unavailable",
OperationDiagnostic::CredentialMissing => "credential_missing",
OperationDiagnostic::CompensationFailed => "compensation_failed",
+ OperationDiagnostic::Conflict => "conflict",
+ OperationDiagnostic::Expired => "expired",
}
}
@@ -154,6 +460,8 @@ fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> {
"keyring_unavailable" => Ok(OperationDiagnostic::KeyringUnavailable),
"credential_missing" => Ok(OperationDiagnostic::CredentialMissing),
"compensation_failed" => Ok(OperationDiagnostic::CompensationFailed),
+ "conflict" => Ok(OperationDiagnostic::Conflict),
+ "expired" => Ok(OperationDiagnostic::Expired),
_ => Err(invalid_column(5)),
}
}
@@ -187,12 +495,21 @@ const fn operation_not_found() -> SafeError {
)
}
+const fn operation_conflict() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("The durable account operation conflicts with existing state."),
+ )
+}
+
#[cfg(test)]
mod tests {
use radroots_studio_application::{
- AccountOperationKind, AccountOperationPhase, OperationDiagnostic, OperationJournal,
+ AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase,
+ DurableOperationRepository, DurableOperationStart, DurableRequestId,
+ DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState,
};
- use radroots_studio_domain::{PublicKey, UnixTimestamp};
+ use radroots_studio_domain::{BindingAvailability, PublicKey, UnixTimestamp};
use crate::Database;
@@ -259,4 +576,86 @@ mod tests {
assert!(!schema.contains("secret"));
assert!(!schema.contains("payload"));
}
+
+ #[test]
+ fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() {
+ let database = Database::in_memory().expect("database");
+ let request = DurableRequestId::parse("import:test:1").expect("request");
+ let account = PublicKey::from_bytes([9; 32]);
+ let prior = OperationPriorState::new(
+ Some(PublicKey::from_bytes([8; 32])),
+ Some(BindingAvailability::CredentialMissing),
+ );
+ let started = database
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Repair,
+ account,
+ Some(4),
+ prior,
+ UnixTimestamp::from_seconds(10).expect("time"),
+ )
+ .expect("begin");
+ assert!(matches!(started, DurableOperationStart::Started(_)));
+ let replay = database
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Repair,
+ account,
+ Some(4),
+ prior,
+ UnixTimestamp::from_seconds(11).expect("time"),
+ )
+ .expect("replay");
+ assert!(matches!(replay, DurableOperationStart::Existing(_)));
+ assert!(
+ database
+ .begin_durable_operation(
+ &request,
+ DurableOperationKind::Remove,
+ account,
+ Some(4),
+ prior,
+ UnixTimestamp::from_seconds(11).expect("time"),
+ )
+ .is_err()
+ );
+ database
+ .advance_durable_operation(
+ &request,
+ DurableOperationPhase::IntentRecorded,
+ DurableOperationPhase::CredentialWritten,
+ UnixTimestamp::from_seconds(12).expect("time"),
+ None,
+ )
+ .expect("advance");
+ let receipt = database
+ .finalize_durable_operation(
+ &request,
+ DurableOperationPhase::CredentialWritten,
+ DurableTerminalOutcome::Completed,
+ Some(5),
+ UnixTimestamp::from_seconds(13).expect("time"),
+ )
+ .expect("finalize");
+ assert_eq!(receipt.resulting_revision(), Some(5));
+ assert_eq!(
+ database
+ .finalize_durable_operation(
+ &request,
+ DurableOperationPhase::CredentialWritten,
+ DurableTerminalOutcome::Completed,
+ Some(5),
+ UnixTimestamp::from_seconds(14).expect("time"),
+ )
+ .expect("receipt replay"),
+ receipt
+ );
+ assert!(
+ database
+ .list_unfinished_durable_operations()
+ .expect("unfinished")
+ .is_empty()
+ );
+ }
}