Skip to content

Commit 0861e44

Browse files
authored
[arenabuddy] Add automatic match sync from server on login and startup (#318)
This pull request implements automatic match synchronization from the server to the local database. **Key Changes:** - **Post-login sync**: After successful authentication, the app now automatically syncs matches from the server in the background - **Initial sync on startup**: When the app launches with saved authentication, it performs an initial sync to fetch any new matches - **New sync module**: Added `backend/sync.rs` with `sync_matches()` function that compares server and local match lists, downloading missing matches with proper token refresh handling - **Match filtering**: Added filter to exclude matches with game_number ≤ 0 from the UI display - **Logging improvements**: Changed missing match details from error to debug level, and added comprehensive sync logging The sync process fetches the server's match list, compares against local matches by ID, and downloads any missing match data. Token refresh is handled automatically during sync operations.
1 parent 44db2c5 commit 0861e44

File tree

7 files changed

+220
-4
lines changed

7 files changed

+220
-4
lines changed

.sqlx/query-24fb9ee1dbc43a016bbbd2828600f4682ca81e22156533d2859eb469a4f924f7.json

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

arenabuddy/arenabuddy/src/app/pages.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::{
66
debug_logs::DebugLogs, draft_details::DraftDetails, drafts::Drafts, error_logs::ErrorLogs,
77
match_details::MatchDetails, matches::Matches, stats::Stats,
88
},
9-
backend::{BackgroundRuntime, SharedAuthState},
9+
backend::{BackgroundRuntime, Service, SharedAuthState},
1010
};
1111

1212
fn open_github() {
@@ -98,12 +98,15 @@ fn Layout() -> Element {
9898
});
9999

100100
let bg_runtime = use_context::<BackgroundRuntime>();
101+
let service = use_context::<Service>();
101102
let on_login = {
102103
let auth_state = auth_state.clone();
103104
let bg_runtime = bg_runtime.clone();
105+
let service = service.clone();
104106
move |_| {
105107
let auth_state = auth_state.clone();
106108
let bg = bg_runtime.clone();
109+
let service = service.clone();
107110
spawn(async move {
108111
let grpc_url = crate::backend::paths::grpc_url();
109112
let client_id =
@@ -123,6 +126,16 @@ fn Layout() -> Element {
123126
let username = state.user.username.clone();
124127
*auth_state.lock().await = Some(state);
125128
login_status.set(Some(username));
129+
130+
// Sync matches from server after login
131+
let sync_db = service.db.clone();
132+
let sync_auth = auth_state.clone();
133+
bg.spawn(async move {
134+
match crate::backend::sync::sync_matches(&sync_db, &sync_auth).await {
135+
Ok(n) => tracingx::info!("Post-login sync complete: {n} new matches"),
136+
Err(e) => tracingx::error!("Post-login sync failed: {e}"),
137+
}
138+
});
126139
}
127140
Ok(Err(e)) => {
128141
tracingx::error!("Login failed: {e}");

arenabuddy/arenabuddy/src/backend/launch.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use dioxus::{
99
use start::AppMeta;
1010
use tracing_appender::rolling::{RollingFileAppender, Rotation};
1111
use tracingx::{
12-
EnvFilter, Layer, Level, SubscriberExt, SubscriberInitExt,
12+
EnvFilter, Layer, Level, SubscriberExt, SubscriberInitExt, error,
1313
fmt::{self, writer::MakeWriterExt},
1414
info,
1515
};
@@ -40,6 +40,16 @@ pub fn launch() -> Result<()> {
4040
if let Some(saved) = crate::backend::auth::load_auth() {
4141
info!("Restored auth session for {}", saved.user.username);
4242
*auth_state.blocking_lock() = Some(saved);
43+
44+
// Sync matches from server in background
45+
let sync_db = service.db.clone();
46+
let sync_auth = auth_state.clone();
47+
background.spawn(async move {
48+
match crate::backend::sync::sync_matches(&sync_db, &sync_auth).await {
49+
Ok(n) => info!("Initial sync complete: {n} new matches"),
50+
Err(e) => error!("Initial sync failed: {e}"),
51+
}
52+
});
4353
}
4454
let service2 = service.clone();
4555
let auth_state2 = auth_state.clone();

arenabuddy/arenabuddy/src/backend/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub(crate) mod ingest;
44
mod launch;
55
pub(crate) mod paths;
66
mod service;
7+
pub(crate) mod sync;
78

89
pub use auth::{SharedAuthState, new_shared_auth_state};
910
pub use launch::{BackgroundRuntime, launch};

arenabuddy/arenabuddy/src/backend/service.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ where
115115
Vec::default()
116116
})
117117
.iter()
118+
.filter(|mr| mr.game_number() > 0)
118119
.map(|mr| {
119120
GameResultDisplay::from_match_result(
120121
mr,
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
use std::collections::HashSet;
2+
3+
use arenabuddy_core::{
4+
models::{MTGAMatch, MatchData},
5+
services::match_service::{GetMatchDataRequest, ListMatchesRequest, match_service_client::MatchServiceClient},
6+
};
7+
use arenabuddy_data::{ArenabuddyRepository, MatchDB};
8+
use tracingx::{error, info};
9+
10+
use super::auth::{SharedAuthState, needs_refresh, refresh};
11+
12+
fn attach_token<T>(request: &mut tonic::Request<T>, token: &str) {
13+
let bearer = format!("Bearer {token}");
14+
if let Ok(value) = bearer.parse() {
15+
request.metadata_mut().insert("authorization", value);
16+
}
17+
}
18+
19+
async fn current_token(auth_state: &SharedAuthState, grpc_url: &str) -> Option<String> {
20+
let mut guard = auth_state.lock().await;
21+
let state = guard.as_ref()?;
22+
23+
if needs_refresh(state) {
24+
info!("Access token expiring soon, refreshing for sync");
25+
match refresh(grpc_url, state).await {
26+
Ok(new_state) => {
27+
let token = new_state.token.clone();
28+
*guard = Some(new_state);
29+
return Some(token);
30+
}
31+
Err(e) => {
32+
error!("Failed to refresh token for sync: {e}");
33+
}
34+
}
35+
}
36+
37+
Some(state.token.clone())
38+
}
39+
40+
/// Sync matches from the server into the local database.
41+
///
42+
/// Fetches the server's match list for the authenticated user, compares
43+
/// against local matches, and downloads any that are missing locally.
44+
///
45+
/// Returns the number of newly synced matches.
46+
///
47+
/// # Errors
48+
///
49+
/// Returns an error if the user is not authenticated, the gRPC connection
50+
/// fails, or the server returns an error from `ListMatches`.
51+
pub async fn sync_matches(
52+
db: &MatchDB,
53+
auth_state: &SharedAuthState,
54+
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
55+
let grpc_url = super::paths::grpc_url();
56+
57+
let token = current_token(auth_state, &grpc_url).await.ok_or("not authenticated")?;
58+
59+
let mut client = MatchServiceClient::connect(grpc_url).await?;
60+
61+
// Get server match list
62+
let mut request = tonic::Request::new(ListMatchesRequest {});
63+
attach_token(&mut request, &token);
64+
65+
let server_matches = client.list_matches(request).await?.into_inner().matches;
66+
info!("Server has {} matches for this user", server_matches.len());
67+
68+
// Get local match IDs
69+
let local_ids: HashSet<_> = db
70+
.list_matches(None)
71+
.await
72+
.map_err(|e| e.to_string())?
73+
.iter()
74+
.map(|m| m.id().to_owned())
75+
.collect();
76+
77+
// Find matches we're missing locally
78+
let missing: Vec<_> = server_matches
79+
.iter()
80+
.filter(|m| !local_ids.contains(m.id.as_str()))
81+
.collect();
82+
83+
if missing.is_empty() {
84+
info!("Local database is up to date");
85+
return Ok(0);
86+
}
87+
88+
info!("Syncing {} new matches from server", missing.len());
89+
90+
let mut synced = 0;
91+
for server_match in &missing {
92+
let mut request = tonic::Request::new(GetMatchDataRequest {
93+
match_id: server_match.id.clone(),
94+
});
95+
attach_token(&mut request, &token);
96+
97+
let response = match client.get_match_data(request).await {
98+
Ok(r) => r.into_inner(),
99+
Err(e) => {
100+
error!("Failed to fetch match {}: {e}", server_match.id);
101+
continue;
102+
}
103+
};
104+
105+
let Some(match_data_proto) = response.match_data else {
106+
error!("Server returned empty match_data for {}", server_match.id);
107+
continue;
108+
};
109+
110+
let match_data: MatchData = match (&match_data_proto).try_into() {
111+
Ok(data) => data,
112+
Err(e) => {
113+
error!("Failed to convert match {}: {e}", server_match.id);
114+
continue;
115+
}
116+
};
117+
118+
if let Err(e) = db
119+
.upsert_match_data(
120+
&match_data.mtga_match,
121+
&match_data.decks,
122+
&match_data.mulligans,
123+
&match_data.results,
124+
&match_data.opponent_deck.cards,
125+
&match_data.event_logs,
126+
None,
127+
)
128+
.await
129+
{
130+
error!("Failed to write match {} locally: {e}", server_match.id);
131+
continue;
132+
}
133+
134+
synced += 1;
135+
}
136+
137+
info!("Synced {synced}/{} matches from server", missing.len());
138+
Ok(synced)
139+
}

arenabuddy/data/src/db/postgres.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ impl ArenabuddyRepository for PostgresMatchDB {
614614
Some(MatchResult::new_match_result(row.id, row.winning_team_id)),
615615
))
616616
} else {
617-
error!("Error getting match details for match_id: {}", match_id);
617+
debug!("No match-level result found for match_id: {}", match_id);
618618
Ok((MTGAMatch::default(), None))
619619
}
620620
}
@@ -704,7 +704,7 @@ impl ArenabuddyRepository for PostgresMatchDB {
704704
async fn list_match_results(&self, match_id: &str) -> Result<Vec<MatchResult>> {
705705
let match_id = Uuid::parse_str(match_id)?;
706706
let results = sqlx::query!(
707-
"SELECT game_number, winning_team_id, result_scope FROM match_result WHERE match_id = $1 AND game_number > 0",
707+
"SELECT game_number, winning_team_id, result_scope FROM match_result WHERE match_id = $1",
708708
match_id
709709
)
710710
.fetch_all(&self.pool)

0 commit comments

Comments
 (0)