Skip to content

Commit 0acb50b

Browse files
committed
[arenabuddy] add sync with server
1 parent 44db2c5 commit 0acb50b

File tree

6 files changed

+168
-4
lines changed

6 files changed

+168
-4
lines changed

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)