feat(workspace): full server + sync implementation, all tests green

- solitaire_server: Axum auth, sync push/pull, leaderboard, daily
  challenge, account deletion, JWT middleware, rate limiting via
  tower_governor, SQLite migrations, health endpoint
- solitaire_server: expose build_test_router (no rate limiting) so
  integration tests work without a peer IP in oneshot requests
- solitaire_sync: SyncPayload, merge logic, shared API types
- solitaire_data: SyncProvider trait, LocalOnlyProvider,
  SolitaireServerClient, auth_tokens keyring integration, blanket
  Box<dyn SyncProvider> impl
- solitaire_data/settings: derive Default on SyncBackend (clippy fix)
- .sqlx/: offline query cache so server compiles without a live DB
- sqlx: removed non-existent "offline" feature flag
- keyring v2: fixed Entry::new() returning Result<Entry>
- sqlx 0.8: all SQLite TEXT columns wrapped in Option<T>
- Integration tests: max_connections(1) on in-memory pool so all
  connections share the same schema

All 191 tests pass; cargo clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-26 23:32:56 +00:00
parent 13b428b81c
commit 34ba4dc6ed
55 changed files with 4372 additions and 270 deletions
+164
View File
@@ -0,0 +1,164 @@
//! Sync pull and push handlers.
//!
//! `GET /api/sync/pull` — return the server's stored payload for this user.
//! `POST /api/sync/push` — receive the client's payload, merge, store, return.
use axum::{extract::State, Json};
use chrono::Utc;
use sqlx::SqlitePool;
use solitaire_sync::{
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
};
use crate::{error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Database row helpers
// ---------------------------------------------------------------------------
struct SyncRow {
stats_json: Option<String>,
achievements_json: Option<String>,
progress_json: Option<String>,
}
/// Load the stored `SyncPayload` for `user_id` from the database.
/// Returns `None` if this user has not pushed any data yet.
async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRow>, AppError> {
let row = sqlx::query_as!(
SyncRow,
"SELECT stats_json, achievements_json, progress_json FROM sync_state WHERE user_id = ?",
user_id
)
.fetch_optional(pool)
.await?;
Ok(row)
}
/// Deserialize a stored `SyncRow` into a `SyncPayload`.
fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError> {
let stats_json = row.stats_json.as_deref()
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
let achievements_json = row.achievements_json.as_deref()
.ok_or_else(|| AppError::Internal("missing achievements_json".into()))?;
let progress_json = row.progress_json.as_deref()
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
let achievements: Vec<AchievementRecord> = serde_json::from_str(achievements_json)?;
let progress: PlayerProgress = serde_json::from_str(progress_json)?;
Ok(SyncPayload {
user_id: user_id
.parse()
.map_err(|_| AppError::Internal("stored user_id is not a valid UUID".into()))?,
stats,
achievements,
progress,
last_modified: Utc::now(),
})
}
/// Persist a `SyncPayload` for `user_id` using an upsert.
async fn store_payload(
pool: &SqlitePool,
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
let stats_json = serde_json::to_string(&payload.stats)?;
let achievements_json = serde_json::to_string(&payload.achievements)?;
let progress_json = serde_json::to_string(&payload.progress)?;
let now = Utc::now().to_rfc3339();
sqlx::query!(
r#"INSERT INTO sync_state (user_id, stats_json, achievements_json, progress_json, last_modified)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
stats_json = excluded.stats_json,
achievements_json = excluded.achievements_json,
progress_json = excluded.progress_json,
last_modified = excluded.last_modified"#,
user_id,
stats_json,
achievements_json,
progress_json,
now
)
.execute(pool)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/sync/pull` — return the server's stored payload for this user.
///
/// If the user has never pushed any data, returns a default payload.
pub async fn pull(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
) -> Result<Json<SyncResponse>, AppError> {
let stored_payload = match load_sync_row(&pool, &user.user_id).await? {
Some(row) => row_to_payload(&row, &user.user_id)?,
None => {
// First pull — no server data yet; return an empty default payload.
let uid = user
.user_id
.parse()
.map_err(|_| AppError::Internal("invalid user_id UUID".into()))?;
SyncPayload {
user_id: uid,
stats: StatsSnapshot::default(),
achievements: vec![],
progress: PlayerProgress::default(),
last_modified: Utc::now(),
}
}
};
Ok(Json(SyncResponse {
merged: stored_payload,
server_time: Utc::now(),
conflicts: vec![],
}))
}
/// `POST /api/sync/push` — merge the client's payload with the server's
/// stored payload, persist the result, and return it.
pub async fn push(
State(pool): State<SqlitePool>,
user: AuthenticatedUser,
Json(client_payload): Json<SyncPayload>,
) -> Result<Json<SyncResponse>, AppError> {
// Reject payloads that claim to belong to a different user.
if client_payload.user_id.to_string() != user.user_id {
return Err(AppError::BadRequest("user_id mismatch".into()));
}
let server_payload = match load_sync_row(&pool, &user.user_id).await? {
Some(row) => row_to_payload(&row, &user.user_id)?,
None => {
// First push — nothing to merge against; store directly.
store_payload(&pool, &user.user_id, &client_payload).await?;
return Ok(Json(SyncResponse {
merged: client_payload,
server_time: Utc::now(),
conflicts: vec![],
}));
}
};
let (merged, conflicts) = merge(&client_payload, &server_payload);
store_payload(&pool, &user.user_id, &merged).await?;
Ok(Json(SyncResponse {
merged,
server_time: Utc::now(),
conflicts,
}))
}