feat(sync): round trip cloud snapshots

This commit is contained in:
2026-05-16 00:01:28 -04:00
parent 06e947f43e
commit 40ab6b4874
22 changed files with 694 additions and 69 deletions
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ed25519-dalek.workspace = true
ely_domain = { path = "../ely_domain" }
serde = { workspace = true }
serde_json = { workspace = true }
+62
View File
@@ -106,6 +106,19 @@ impl SyncApiClient {
read_json_response::<DeviceListResponse>(&endpoint, response)
}
/// `GET /api/sync/status` — return the worker-side cursor,
/// object, snapshot, and device summary for the authenticated
/// approved device.
pub fn sync_status(&self) -> Result<SyncStatusDocument, SyncClientError> {
let endpoint = self.endpoint("/api/sync/status");
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SyncStatusDocument>(&endpoint, response)
}
/// `POST /api/sync/snapshot` — push the full per-user state. The
/// worker enforces logical-clock monotonicity, so callers must
/// pass a value strictly greater than the last accepted snapshot.
@@ -162,6 +175,55 @@ pub struct SnapshotUploadDocument {
pub snapshot: crate::snapshot::SnapshotDocument,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncStatusDocument {
pub version: u32,
pub user_id: String,
pub device_id: String,
pub cursor: SyncCursorStatusDocument,
pub objects: Vec<SyncObjectStatusDocument>,
pub snapshots: SyncSnapshotStatusDocument,
pub devices: SyncDeviceStatusDocument,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncCursorStatusDocument {
pub latest_change_id: u64,
pub total_changes: u64,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncObjectStatusDocument {
pub object_type: String,
pub active_count: u64,
pub deleted_count: u64,
pub latest_logical_clock: u64,
pub latest_updated_at: u64,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncSnapshotStatusDocument {
pub total_snapshots: u64,
pub latest: Option<SyncLatestSnapshotDocument>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncLatestSnapshotDocument {
pub snapshot_id: String,
pub payload_hash: String,
pub logical_clock: u64,
pub device_id: String,
pub size_bytes: u64,
pub created_at: u64,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncDeviceStatusDocument {
pub approved_count: u64,
pub current_device_id: String,
pub current_device_approved: bool,
}
fn read_json_response<T: DeserializeOwned>(
endpoint: &str,
response: Result<ureq::Response, ureq::Error>,
+16 -4
View File
@@ -4,6 +4,7 @@ use std::{
path::Path,
};
use ed25519_dalek::SigningKey;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -51,10 +52,7 @@ impl DeviceIdentity {
pub fn generate(device_name: impl Into<String>, platform: impl Into<String>) -> Self {
let device_id = format!("ely-{}", Uuid::now_v7().simple());
// Placeholder public key — Ed25519 device-bound signing is a
// backend feature still in design. The worker validates the
// shape but does not currently challenge it.
let public_key = format!("ed25519:{}", Uuid::now_v7().simple());
let public_key = public_key_hex();
Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() }
}
@@ -102,6 +100,18 @@ fn io_err(error: io::Error) -> SyncClientError {
SyncClientError::TokenStorage(error.to_string())
}
fn public_key_hex() -> String {
let mut seed = [0_u8; 32];
seed[..16].copy_from_slice(Uuid::now_v7().as_bytes());
seed[16..].copy_from_slice(Uuid::now_v7().as_bytes());
let signing_key = SigningKey::from_bytes(&seed);
hex_string(&signing_key.verifying_key().to_bytes())
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[derive(Clone, Debug, Serialize)]
pub struct DeviceRegistration<'a> {
pub device_id: &'a str,
@@ -147,6 +157,8 @@ mod tests {
let path = dir.join("device.json");
let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?;
identity.validate()?;
assert_eq!(identity.public_key.len(), 64);
assert!(identity.public_key.as_bytes().iter().all(u8::is_ascii_hexdigit));
let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
assert_eq!(identity, again);
+6
View File
@@ -30,4 +30,10 @@ pub enum SyncClientError {
#[error("Snapshot base64 decode failed: {0}")]
SnapshotBase64(String),
#[error("Snapshot schema is invalid: {0}")]
SnapshotSchema(String),
#[error("Device {device_id} cannot sync with approval status {status}")]
DeviceApprovalStatus { device_id: String, status: String },
}
+3 -11
View File
@@ -10,19 +10,11 @@
//! - Bearer-token authenticated requests via `ureq`.
//! - Device registration (`POST /api/devices/register`) and listing
//! (`GET /api/devices`).
//! - Sync snapshot upload (`POST /api/sync/snapshot`) and download
//! - Sync status (`GET /api/sync/status`), snapshot upload
//! (`POST /api/sync/snapshot`), and snapshot download
//! (`GET /api/sync/snapshot?snapshot_id=…`).
//!
//! Intentionally omitted (kept for follow-up work, not papered over here):
//! - The full Better Auth handshake (email + OTP / OAuth). Callers obtain
//! the bearer token out-of-band and hand it to the client.
//! - First-device approval bootstrap. The Cloudflare API rejects sync from
//! an unapproved device; the user must approve a freshly-registered
//! device from another already-approved device (or via direct D1
//! operation), exactly as the backend enforces.
//! - Incremental change-log push/pull (`/api/sync/push` and `/api/sync/pull`).
//! The snapshot path is the simplest contract that round-trips the user's
//! entire state, so we start there.
pub mod auth;
pub mod client;
@@ -32,7 +24,7 @@ pub mod error;
pub mod snapshot;
pub use auth::{BearerToken, BearerTokenStore};
pub use client::{ApiClientConfig, SyncApiClient};
pub use client::{ApiClientConfig, SyncApiClient, SyncLatestSnapshotDocument, SyncStatusDocument};
pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration};
pub use email_otp::{send_email_otp, verify_email_otp};
pub use error::SyncClientError;
+4
View File
@@ -44,6 +44,10 @@ impl SnapshotPayload {
pub fn payload_hash(&self) -> &str {
&self.payload_hash
}
pub fn into_bytes(self) -> Vec<u8> {
self.bytes
}
}
#[derive(Clone, Debug, Serialize)]