lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 226acf73a2610c2c5c1abb269935135d99218e4e
parent cc3aa7a9cc67674c1e7c249797ff6b1b2533b583
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:
Mcrates/studio_application/src/lib.rs | 5+++--
Mcrates/studio_application/src/ports.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_ffi/src/commands.rs | 2+-
Acrates/studio_storage/migrations/V9__durable_operation_receipts.sql | 11+++++++++++
Mcrates/studio_storage/src/db.rs | 4++--
Mcrates/studio_storage/src/journal.rs | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 568 insertions(+), 11 deletions(-)

diff --git a/crates/studio_application/src/lib.rs b/crates/studio_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/crates/studio_application/src/ports.rs b/crates/studio_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/crates/studio_ffi/src/commands.rs b/crates/studio_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/crates/studio_storage/migrations/V9__durable_operation_receipts.sql b/crates/studio_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/crates/studio_storage/src/db.rs b/crates/studio_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/crates/studio_storage/src/journal.rs b/crates/studio_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() + ); + } }