feat: 持久化 Sync 收件箱并在拉取时保留离线冲突

This commit is contained in:
2026-09-08 15:38:10 +08:00
parent 2bc9ef84b9
commit 251d3bac7d
7 changed files with 705 additions and 17 deletions
+135
View File
@@ -259,6 +259,141 @@ impl SyncClient {
workspace.access(|ws| ws.sync_ack(&job, &revision))?;
Ok(true)
}
pub async fn pull_page(
&self,
workspace: &impl WorkspaceAccess,
binding: &Binding,
) -> Result<usize> {
if Url::parse(&binding.endpoint)
.ok()
.is_none_or(|url| url != self.endpoint)
{
return Err(SyncError::new("SYNC_BINDING_CHANGED"));
}
identifier(&binding.remote_vault)?;
workspace.access(|ws| {
while ws.sync_apply_pending(&binding.id)? {}
Ok(())
})?;
let (cursor, boundary) = workspace.access(|ws| {
ws.check_binding(&binding.id)?;
Ok((
ws.sync_binding()?
.ok_or_else(|| crate::workspace::HostError::new("SYNC_BINDING_CHANGED"))?
.cursor,
ws.sync_boundary(&binding.id)?,
))
})?;
let mut path = format!(
"sync/v1/vaults/{}/changes?cursor={cursor}&limit=100",
binding.remote_vault
);
if let Some(end) = boundary {
path.push_str(&format!("&boundary={end}"));
}
let page = self.json(Method::GET, &path, None).await?;
let end = page["boundary"]
.as_i64()
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
let items = page["items"]
.as_array()
.filter(|items| items.len() <= 100)
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
if items.is_empty() {
if end != cursor {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
return Ok(0);
}
workspace.access(|ws| ws.sync_set_boundary(&binding.id, end))?;
for (index, item) in items.iter().enumerate() {
let revision: crate::sync_inbox::RemoteRevision = serde_json::from_value(item.clone())
.map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))?;
revision.validate(binding)?;
if revision.sequence != cursor + index as i64 + 1 || revision.sequence > end {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
if revision.operation == "put" {
self.download(workspace, binding, &revision).await?;
}
workspace.access(|ws| {
ws.sync_stage(&binding.id, &revision)?;
ws.sync_apply_pending(&binding.id)?;
Ok(())
})?;
}
Ok(items.len())
}
async fn download(
&self,
workspace: &impl WorkspaceAccess,
binding: &Binding,
revision: &crate::sync_inbox::RemoteRevision,
) -> Result<()> {
let digest = revision
.hash
.as_deref()
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
let target = workspace.access(|ws| {
ws.check_binding(&binding.id)?;
ws.sync_spool(digest)
})?;
if target.exists() {
let bytes = std::fs::read(&target)?;
if bytes.len() as i64 == revision.size && crate::workspace::hash(&bytes) == digest {
return Ok(());
}
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
}
let url = self
.endpoint
.join(&format!(
"sync/v1/vaults/{}/objects/{digest}",
binding.remote_vault
))
.map_err(|_| SyncError::new("SYNC_PATH_INVALID"))?;
let mut response = self
.client
.get(url)
.bearer_auth(self.token.as_str())
.send()
.await
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?;
if !response.status().is_success() {
return Err(SyncError {
code: "SYNC_DOWNLOAD_FAILED".into(),
status: response.status().as_u16(),
retry_after: None,
});
}
let mut file = tempfile::NamedTempFile::new_in(
target
.parent()
.ok_or_else(|| SyncError::new("SYNC_SPOOL_FAILED"))?,
)?;
let mut hasher = Sha256::new();
let mut length = 0u64;
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?
{
length += chunk.len() as u64;
if length > revision.size as u64 {
return Err(SyncError::new("SYNC_OBJECT_CORRUPT"));
}
workspace.access(|ws| ws.check_binding(&binding.id))?;
std::io::Write::write_all(&mut file, &chunk)?;
hasher.update(&chunk);
}
if length != revision.size as u64 || format!("{:x}", hasher.finalize()) != digest {
return Err(SyncError::new("SYNC_OBJECT_CORRUPT"));
}
file.as_file().sync_all()?;
file.persist_noclobber(target)
.map_err(|_| SyncError::new("SYNC_SPOOL_FAILED"))?;
Ok(())
}
async fn upload(
&self,
workspace: &impl WorkspaceAccess,