app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

lib.rs (22480B)


      1 #![forbid(unsafe_code)]
      2 
      3 mod publish;
      4 
      5 pub use publish::{
      6     AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload,
      7     AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload,
      8     AppOrderRequestItemPayload, AppOrderRequestPublishPayload,
      9     AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload,
     10     AppPublishContext, AppPublishPayload, AppPublishPayloadJsonError, AppPublishValidationFailure,
     11     AppPublishValidationFailureSet, AppPublishWorkKind,
     12 };
     13 
     14 use radroots_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId};
     15 use serde::{Deserialize, Serialize};
     16 use thiserror::Error;
     17 
     18 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
     19 #[serde(
     20     tag = "aggregate_kind",
     21     content = "aggregate_id",
     22     rename_all = "snake_case"
     23 )]
     24 pub enum SyncAggregateRef {
     25     Farm(FarmId),
     26     FulfillmentWindow(FulfillmentWindowId),
     27     Product(ProductId),
     28     Order(OrderId),
     29 }
     30 
     31 impl SyncAggregateRef {
     32     pub const fn aggregate_kind(&self) -> &'static str {
     33         match self {
     34             Self::Farm(_) => "farm",
     35             Self::FulfillmentWindow(_) => "fulfillment_window",
     36             Self::Product(_) => "product",
     37             Self::Order(_) => "order",
     38         }
     39     }
     40 
     41     pub fn aggregate_id(&self) -> String {
     42         match self {
     43             Self::Farm(farm_id) => farm_id.to_string(),
     44             Self::FulfillmentWindow(fulfillment_window_id) => fulfillment_window_id.to_string(),
     45             Self::Product(product_id) => product_id.to_string(),
     46             Self::Order(order_id) => order_id.to_string(),
     47         }
     48     }
     49 }
     50 
     51 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
     52 #[serde(rename_all = "snake_case")]
     53 pub enum SyncTrigger {
     54     AppLaunch,
     55     ForegroundResume,
     56     #[default]
     57     ManualRefresh,
     58     LocalMutation,
     59 }
     60 
     61 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
     62 #[serde(rename_all = "snake_case")]
     63 pub enum SyncOperationKind {
     64     Upsert,
     65     Delete,
     66 }
     67 
     68 impl SyncOperationKind {
     69     pub const fn storage_key(self) -> &'static str {
     70         match self {
     71             Self::Upsert => "upsert",
     72             Self::Delete => "delete",
     73         }
     74     }
     75 }
     76 
     77 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
     78 #[serde(rename_all = "snake_case")]
     79 pub enum PendingSyncOperationState {
     80     Pending,
     81     InProgress,
     82     Succeeded,
     83     Failed,
     84     Blocked,
     85     Retryable,
     86 }
     87 
     88 impl PendingSyncOperationState {
     89     pub const fn storage_key(self) -> &'static str {
     90         match self {
     91             Self::Pending => "pending",
     92             Self::InProgress => "in_progress",
     93             Self::Succeeded => "succeeded",
     94             Self::Failed => "failed",
     95             Self::Blocked => "blocked",
     96             Self::Retryable => "retryable",
     97         }
     98     }
     99 
    100     pub const fn is_active(self) -> bool {
    101         !matches!(self, Self::Succeeded)
    102     }
    103 }
    104 
    105 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    106 pub struct PendingSyncOperation {
    107     pub operation_key: String,
    108     pub aggregate: SyncAggregateRef,
    109     pub operation: SyncOperationKind,
    110     pub payload_json: String,
    111     pub created_at: String,
    112     pub available_at: String,
    113     pub attempt_count: u32,
    114     pub state: PendingSyncOperationState,
    115     pub last_error_message: Option<String>,
    116 }
    117 
    118 impl PendingSyncOperation {
    119     pub fn new(
    120         aggregate: SyncAggregateRef,
    121         operation: SyncOperationKind,
    122         payload_json: impl Into<String>,
    123         created_at: impl Into<String>,
    124     ) -> Self {
    125         let operation_key = Self::deterministic_operation_key(&aggregate, operation);
    126         let created_at = created_at.into();
    127         Self {
    128             operation_key,
    129             aggregate,
    130             operation,
    131             payload_json: payload_json.into(),
    132             created_at: created_at.clone(),
    133             available_at: created_at,
    134             attempt_count: 0,
    135             state: PendingSyncOperationState::Pending,
    136             last_error_message: None,
    137         }
    138     }
    139 
    140     pub fn deterministic_operation_key(
    141         aggregate: &SyncAggregateRef,
    142         operation: SyncOperationKind,
    143     ) -> String {
    144         format!(
    145             "{}:{}:{}",
    146             aggregate.aggregate_kind(),
    147             aggregate.aggregate_id(),
    148             operation.storage_key()
    149         )
    150     }
    151 
    152     pub const fn is_retry(&self) -> bool {
    153         self.attempt_count > 0
    154     }
    155 }
    156 
    157 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    158 #[serde(rename_all = "snake_case")]
    159 pub enum SyncConflictKind {
    160     RevisionMismatch,
    161     RemoteDelete,
    162     RemoteValidationReject,
    163 }
    164 
    165 impl SyncConflictKind {
    166     pub const fn storage_key(self) -> &'static str {
    167         match self {
    168             Self::RevisionMismatch => "revision_mismatch",
    169             Self::RemoteDelete => "remote_delete",
    170             Self::RemoteValidationReject => "remote_validation_reject",
    171         }
    172     }
    173 }
    174 
    175 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    176 #[serde(rename_all = "snake_case")]
    177 pub enum SyncConflictSeverity {
    178     ReviewRequired,
    179     Blocking,
    180 }
    181 
    182 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    183 #[serde(rename_all = "snake_case")]
    184 pub enum SyncConflictResolutionStatus {
    185     Unresolved,
    186     AcceptedLocal,
    187     AcceptedRemote,
    188     Dismissed,
    189 }
    190 
    191 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    192 pub struct SyncConflict {
    193     pub aggregate: SyncAggregateRef,
    194     pub kind: SyncConflictKind,
    195     pub severity: SyncConflictSeverity,
    196     pub resolution: SyncConflictResolutionStatus,
    197     pub local_payload_json: String,
    198     pub remote_payload_json: Option<String>,
    199     pub detected_at: String,
    200     pub resolved_at: Option<String>,
    201 }
    202 
    203 impl SyncConflict {
    204     pub const fn is_unresolved(&self) -> bool {
    205         matches!(self.resolution, SyncConflictResolutionStatus::Unresolved)
    206     }
    207 }
    208 
    209 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    210 pub struct SyncConflictStatus {
    211     pub unresolved_count: usize,
    212     pub blocking_count: usize,
    213 }
    214 
    215 impl SyncConflictStatus {
    216     pub const fn clear() -> Self {
    217         Self {
    218             unresolved_count: 0,
    219             blocking_count: 0,
    220         }
    221     }
    222 
    223     pub fn from_conflicts(conflicts: &[SyncConflict]) -> Self {
    224         let unresolved_conflicts = conflicts.iter().filter(|conflict| conflict.is_unresolved());
    225         let unresolved_count = unresolved_conflicts.clone().count();
    226         let blocking_count = unresolved_conflicts
    227             .filter(|conflict| matches!(conflict.severity, SyncConflictSeverity::Blocking))
    228             .count();
    229 
    230         Self {
    231             unresolved_count,
    232             blocking_count,
    233         }
    234     }
    235 
    236     pub const fn is_clear(&self) -> bool {
    237         self.unresolved_count == 0
    238     }
    239 
    240     pub const fn requires_attention(&self) -> bool {
    241         self.unresolved_count > 0
    242     }
    243 
    244     pub const fn has_blocking_conflicts(&self) -> bool {
    245         self.blocking_count > 0
    246     }
    247 }
    248 
    249 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    250 #[serde(rename_all = "snake_case")]
    251 pub enum SyncCheckpointState {
    252     #[default]
    253     NeverSynced,
    254     Syncing,
    255     Current,
    256     Failed,
    257 }
    258 
    259 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    260 pub struct SyncCheckpointStatus {
    261     pub state: SyncCheckpointState,
    262     pub last_sync_started_at: Option<String>,
    263     pub last_sync_completed_at: Option<String>,
    264     pub last_remote_cursor: Option<String>,
    265     pub last_error_message: Option<String>,
    266 }
    267 
    268 impl Default for SyncCheckpointStatus {
    269     fn default() -> Self {
    270         Self::never_synced()
    271     }
    272 }
    273 
    274 impl SyncCheckpointStatus {
    275     pub const fn never_synced() -> Self {
    276         Self {
    277             state: SyncCheckpointState::NeverSynced,
    278             last_sync_started_at: None,
    279             last_sync_completed_at: None,
    280             last_remote_cursor: None,
    281             last_error_message: None,
    282         }
    283     }
    284 
    285     pub fn syncing(started_at: impl Into<String>, last_remote_cursor: Option<String>) -> Self {
    286         Self {
    287             state: SyncCheckpointState::Syncing,
    288             last_sync_started_at: Some(started_at.into()),
    289             last_sync_completed_at: None,
    290             last_remote_cursor,
    291             last_error_message: None,
    292         }
    293     }
    294 
    295     pub fn current(
    296         started_at: Option<String>,
    297         completed_at: impl Into<String>,
    298         last_remote_cursor: Option<String>,
    299     ) -> Self {
    300         Self {
    301             state: SyncCheckpointState::Current,
    302             last_sync_started_at: started_at,
    303             last_sync_completed_at: Some(completed_at.into()),
    304             last_remote_cursor,
    305             last_error_message: None,
    306         }
    307     }
    308 
    309     pub fn failed(
    310         started_at: Option<String>,
    311         completed_at: Option<String>,
    312         last_remote_cursor: Option<String>,
    313         message: impl Into<String>,
    314     ) -> Self {
    315         Self {
    316             state: SyncCheckpointState::Failed,
    317             last_sync_started_at: started_at,
    318             last_sync_completed_at: completed_at,
    319             last_remote_cursor,
    320             last_error_message: Some(message.into()),
    321         }
    322     }
    323 
    324     pub const fn is_failed(&self) -> bool {
    325         matches!(self.state, SyncCheckpointState::Failed)
    326     }
    327 
    328     pub const fn is_syncing(&self) -> bool {
    329         matches!(self.state, SyncCheckpointState::Syncing)
    330     }
    331 }
    332 
    333 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    334 #[serde(rename_all = "snake_case")]
    335 pub enum AppSyncRunStatus {
    336     #[default]
    337     Idle,
    338     Syncing,
    339     Succeeded,
    340     Conflicted,
    341     Failed,
    342 }
    343 
    344 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    345 pub struct AppSyncProjection {
    346     pub run_status: AppSyncRunStatus,
    347     pub checkpoint: SyncCheckpointStatus,
    348     pub conflict_status: SyncConflictStatus,
    349 }
    350 
    351 impl Default for AppSyncProjection {
    352     fn default() -> Self {
    353         Self {
    354             run_status: AppSyncRunStatus::Idle,
    355             checkpoint: SyncCheckpointStatus::never_synced(),
    356             conflict_status: SyncConflictStatus::clear(),
    357         }
    358     }
    359 }
    360 
    361 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    362 #[serde(rename_all = "snake_case")]
    363 pub enum AppRelayIngestFreshnessState {
    364     Fresh,
    365     #[default]
    366     Stale,
    367     Failed,
    368 }
    369 
    370 impl AppRelayIngestFreshnessState {
    371     pub const fn storage_key(self) -> &'static str {
    372         match self {
    373             Self::Fresh => "fresh",
    374             Self::Stale => "stale",
    375             Self::Failed => "failed",
    376         }
    377     }
    378 }
    379 
    380 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    381 #[serde(rename_all = "snake_case")]
    382 pub enum AppRelayIngestScopeStatus {
    383     Fresh,
    384     #[default]
    385     Stale,
    386     Partial,
    387     Failed,
    388 }
    389 
    390 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    391 pub struct AppRelayIngestRelayFreshness {
    392     pub relay_url: String,
    393     pub state: AppRelayIngestFreshnessState,
    394     pub cursor_since_unix_seconds: Option<i64>,
    395     pub last_event_created_at_unix_seconds: Option<i64>,
    396     pub last_fetch_started_at: Option<String>,
    397     pub last_fetch_completed_at: Option<String>,
    398     pub last_success_at: Option<String>,
    399     pub last_error_message: Option<String>,
    400 }
    401 
    402 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    403 pub struct AppRelayIngestScopeFreshness {
    404     pub scope_key: String,
    405     pub status: AppRelayIngestScopeStatus,
    406     pub relays: Vec<AppRelayIngestRelayFreshness>,
    407 }
    408 
    409 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    410 pub struct AppSyncRequest {
    411     pub trigger: SyncTrigger,
    412     pub checkpoint: SyncCheckpointStatus,
    413     pub pending_operations: Vec<PendingSyncOperation>,
    414     pub known_conflicts: Vec<SyncConflict>,
    415 }
    416 
    417 impl AppSyncRequest {
    418     pub fn conflict_status(&self) -> SyncConflictStatus {
    419         SyncConflictStatus::from_conflicts(&self.known_conflicts)
    420     }
    421 }
    422 
    423 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    424 pub struct AppSyncResult {
    425     pub run_status: AppSyncRunStatus,
    426     pub checkpoint: SyncCheckpointStatus,
    427     pub pushed_operation_count: usize,
    428     pub pulled_record_count: usize,
    429     pub conflicts: Vec<SyncConflict>,
    430     #[serde(default)]
    431     pub published_receipts: Vec<AppPublishedOperationReceipt>,
    432 }
    433 
    434 impl AppSyncResult {
    435     pub fn projection(&self) -> AppSyncProjection {
    436         AppSyncProjection {
    437             run_status: self.run_status,
    438             checkpoint: self.checkpoint.clone(),
    439             conflict_status: SyncConflictStatus::from_conflicts(&self.conflicts),
    440         }
    441     }
    442 }
    443 
    444 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    445 pub struct AppPublishedOperationReceipt {
    446     pub operation_key: String,
    447     pub source_account_id: String,
    448     pub source_local_event_id: Option<String>,
    449     #[serde(default)]
    450     pub listing_addr: Option<String>,
    451     pub event_id: String,
    452     pub event_kind: u32,
    453     pub event_pubkey: String,
    454     pub event_created_at: u32,
    455     pub event_tags_json: serde_json::Value,
    456     pub event_content: String,
    457     pub event_sig: String,
    458     pub raw_event_json: serde_json::Value,
    459     pub relay_set_fingerprint: String,
    460     pub relay_delivery_json: serde_json::Value,
    461 }
    462 
    463 #[derive(Clone, Debug, Eq, Error, PartialEq)]
    464 pub enum AppSyncTransportError {
    465     #[error("app sync transport is unavailable: {message}")]
    466     Unavailable { message: String },
    467     #[error("app sync transport failed: {message}")]
    468     Failed { message: String },
    469 }
    470 
    471 impl AppSyncTransportError {
    472     pub fn unavailable(message: impl Into<String>) -> Self {
    473         Self::Unavailable {
    474             message: message.into(),
    475         }
    476     }
    477 
    478     pub fn failed(message: impl Into<String>) -> Self {
    479         Self::Failed {
    480             message: message.into(),
    481         }
    482     }
    483 }
    484 
    485 pub trait AppSyncTransport {
    486     fn sync(&mut self, request: AppSyncRequest) -> Result<AppSyncResult, AppSyncTransportError>;
    487 
    488     fn supports_empty_sync_request(&self) -> bool {
    489         true
    490     }
    491 }
    492 
    493 #[derive(Clone, Debug)]
    494 pub struct RecordedAppSyncTransport {
    495     result: Result<AppSyncResult, AppSyncTransportError>,
    496     last_request: Option<AppSyncRequest>,
    497     call_count: usize,
    498 }
    499 
    500 impl RecordedAppSyncTransport {
    501     pub fn succeed(result: AppSyncResult) -> Self {
    502         Self {
    503             result: Ok(result),
    504             last_request: None,
    505             call_count: 0,
    506         }
    507     }
    508 
    509     pub fn fail(error: AppSyncTransportError) -> Self {
    510         Self {
    511             result: Err(error),
    512             last_request: None,
    513             call_count: 0,
    514         }
    515     }
    516 
    517     pub fn last_request(&self) -> Option<&AppSyncRequest> {
    518         self.last_request.as_ref()
    519     }
    520 
    521     pub const fn call_count(&self) -> usize {
    522         self.call_count
    523     }
    524 }
    525 
    526 impl AppSyncTransport for RecordedAppSyncTransport {
    527     fn sync(&mut self, request: AppSyncRequest) -> Result<AppSyncResult, AppSyncTransportError> {
    528         self.call_count += 1;
    529         self.last_request = Some(request);
    530         self.result.clone()
    531     }
    532 }
    533 
    534 #[cfg(test)]
    535 mod tests {
    536     use super::{
    537         AppSyncProjection, AppSyncRequest, AppSyncResult, AppSyncRunStatus, AppSyncTransport,
    538         AppSyncTransportError, PendingSyncOperation, RecordedAppSyncTransport, SyncAggregateRef,
    539         SyncCheckpointState, SyncCheckpointStatus, SyncConflict, SyncConflictKind,
    540         SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, SyncOperationKind,
    541         SyncTrigger,
    542     };
    543     use radroots_app_view::{FarmId, ProductId};
    544 
    545     #[test]
    546     fn default_projection_starts_idle_and_clear() {
    547         let projection = AppSyncProjection::default();
    548 
    549         assert_eq!(projection.run_status, AppSyncRunStatus::Idle);
    550         assert_eq!(
    551             projection.checkpoint.state,
    552             SyncCheckpointState::NeverSynced
    553         );
    554         assert!(projection.conflict_status.is_clear());
    555     }
    556 
    557     #[test]
    558     fn checkpoint_constructors_keep_sync_and_failure_state_explicit() {
    559         let syncing =
    560             SyncCheckpointStatus::syncing("2026-04-17T19:30:00Z", Some("cursor-1".to_owned()));
    561         let failed = SyncCheckpointStatus::failed(
    562             Some("2026-04-17T19:30:00Z".to_owned()),
    563             Some("2026-04-17T19:30:30Z".to_owned()),
    564             Some("cursor-1".to_owned()),
    565             "relay timeout",
    566         );
    567         let current = SyncCheckpointStatus::current(
    568             Some("2026-04-17T19:30:00Z".to_owned()),
    569             "2026-04-17T19:30:30Z",
    570             Some("cursor-2".to_owned()),
    571         );
    572 
    573         assert!(syncing.is_syncing());
    574         assert_eq!(syncing.last_sync_completed_at, None);
    575         assert_eq!(syncing.last_error_message, None);
    576 
    577         assert!(failed.is_failed());
    578         assert_eq!(failed.last_error_message.as_deref(), Some("relay timeout"));
    579 
    580         assert_eq!(current.state, SyncCheckpointState::Current);
    581         assert_eq!(current.last_remote_cursor.as_deref(), Some("cursor-2"));
    582         assert_eq!(current.last_error_message, None);
    583     }
    584 
    585     #[test]
    586     fn conflict_status_counts_only_unresolved_conflicts() {
    587         let conflicts = vec![
    588             SyncConflict {
    589                 aggregate: SyncAggregateRef::Product(ProductId::new()),
    590                 kind: SyncConflictKind::RevisionMismatch,
    591                 severity: SyncConflictSeverity::Blocking,
    592                 resolution: SyncConflictResolutionStatus::Unresolved,
    593                 local_payload_json: "{\"title\":\"carrots\"}".to_owned(),
    594                 remote_payload_json: Some("{\"title\":\"rainbow carrots\"}".to_owned()),
    595                 detected_at: "2026-04-17T19:31:00Z".to_owned(),
    596                 resolved_at: None,
    597             },
    598             SyncConflict {
    599                 aggregate: SyncAggregateRef::Farm(FarmId::new()),
    600                 kind: SyncConflictKind::RemoteValidationReject,
    601                 severity: SyncConflictSeverity::ReviewRequired,
    602                 resolution: SyncConflictResolutionStatus::AcceptedRemote,
    603                 local_payload_json: "{\"display_name\":\"Sunrise Farm\"}".to_owned(),
    604                 remote_payload_json: Some("{\"display_name\":\"Sunrise Farm LLC\"}".to_owned()),
    605                 detected_at: "2026-04-17T19:31:30Z".to_owned(),
    606                 resolved_at: Some("2026-04-17T19:32:00Z".to_owned()),
    607             },
    608         ];
    609 
    610         let status = SyncConflictStatus::from_conflicts(&conflicts);
    611 
    612         assert_eq!(status.unresolved_count, 1);
    613         assert_eq!(status.blocking_count, 1);
    614         assert!(status.requires_attention());
    615         assert!(status.has_blocking_conflicts());
    616     }
    617 
    618     #[test]
    619     fn request_and_result_surface_conflict_status_through_typed_contracts() {
    620         let mut pending_operation = PendingSyncOperation::new(
    621             SyncAggregateRef::Product(ProductId::new()),
    622             SyncOperationKind::Upsert,
    623             "{\"title\":\"greens\"}",
    624             "2026-04-17T19:32:00Z",
    625         );
    626         pending_operation.attempt_count = 1;
    627         let conflict = SyncConflict {
    628             aggregate: SyncAggregateRef::Product(ProductId::new()),
    629             kind: SyncConflictKind::RevisionMismatch,
    630             severity: SyncConflictSeverity::ReviewRequired,
    631             resolution: SyncConflictResolutionStatus::Unresolved,
    632             local_payload_json: "{\"stock_count\":4}".to_owned(),
    633             remote_payload_json: Some("{\"stock_count\":6}".to_owned()),
    634             detected_at: "2026-04-17T19:33:00Z".to_owned(),
    635             resolved_at: None,
    636         };
    637         let request = AppSyncRequest {
    638             trigger: SyncTrigger::LocalMutation,
    639             checkpoint: SyncCheckpointStatus::current(
    640                 Some("2026-04-17T19:30:00Z".to_owned()),
    641                 "2026-04-17T19:32:30Z",
    642                 Some("cursor-4".to_owned()),
    643             ),
    644             pending_operations: vec![pending_operation.clone()],
    645             known_conflicts: vec![conflict.clone()],
    646         };
    647         let result = AppSyncResult {
    648             run_status: AppSyncRunStatus::Conflicted,
    649             checkpoint: request.checkpoint.clone(),
    650             pushed_operation_count: 1,
    651             pulled_record_count: 3,
    652             conflicts: vec![conflict],
    653             published_receipts: Vec::new(),
    654         };
    655 
    656         assert_eq!(request.conflict_status().unresolved_count, 1);
    657         assert!(pending_operation.is_retry());
    658         assert_eq!(pending_operation.operation.storage_key(), "upsert");
    659 
    660         let projection = result.projection();
    661         assert_eq!(projection.run_status, AppSyncRunStatus::Conflicted);
    662         assert_eq!(
    663             projection.checkpoint.last_remote_cursor.as_deref(),
    664             Some("cursor-4")
    665         );
    666         assert_eq!(projection.conflict_status.unresolved_count, 1);
    667     }
    668 
    669     #[test]
    670     fn recorded_transport_is_mockable_and_records_requests() {
    671         let request = AppSyncRequest {
    672             trigger: SyncTrigger::ManualRefresh,
    673             checkpoint: SyncCheckpointStatus::never_synced(),
    674             pending_operations: vec![],
    675             known_conflicts: vec![],
    676         };
    677         let expected_result = AppSyncResult {
    678             run_status: AppSyncRunStatus::Succeeded,
    679             checkpoint: SyncCheckpointStatus::current(
    680                 Some("2026-04-17T19:34:00Z".to_owned()),
    681                 "2026-04-17T19:34:10Z",
    682                 Some("cursor-9".to_owned()),
    683             ),
    684             pushed_operation_count: 0,
    685             pulled_record_count: 2,
    686             conflicts: vec![],
    687             published_receipts: Vec::new(),
    688         };
    689         let mut transport = RecordedAppSyncTransport::succeed(expected_result.clone());
    690 
    691         let actual_result = transport
    692             .sync(request.clone())
    693             .expect("recorded transport should succeed");
    694 
    695         assert_eq!(actual_result, expected_result);
    696         assert_eq!(transport.last_request(), Some(&request));
    697         assert_eq!(transport.call_count(), 1);
    698     }
    699 
    700     #[test]
    701     fn recorded_transport_can_fail_without_a_live_backend() {
    702         let mut transport =
    703             RecordedAppSyncTransport::fail(AppSyncTransportError::unavailable("offline"));
    704 
    705         let error = transport
    706             .sync(AppSyncRequest {
    707                 trigger: SyncTrigger::AppLaunch,
    708                 checkpoint: SyncCheckpointStatus::never_synced(),
    709                 pending_operations: vec![],
    710                 known_conflicts: vec![],
    711             })
    712             .expect_err("recorded transport should fail");
    713 
    714         assert_eq!(error, AppSyncTransportError::unavailable("offline"));
    715         assert_eq!(transport.call_count(), 1);
    716     }
    717 }