feat: 添加持久化 Rust Sync 上传队列与 HTTP 传输

This commit is contained in:
2026-09-08 15:25:49 +08:00
parent e9df45b7ca
commit 2bc9ef84b9
8 changed files with 851 additions and 7 deletions
+3
View File
@@ -8,5 +8,8 @@ pub mod request_lifecycle;
mod runtime_compat;
#[cfg(windows)]
pub mod session_lock;
#[cfg(feature = "desktop")]
pub mod sync_client;
pub mod sync_state;
pub mod workspace;
pub mod workspace_broker;
+365
View File
@@ -0,0 +1,365 @@
//! Bounded Sync v1 transport. No redirects, no token-bearing URLs, no implicit retries.
use crate::{
sync_state::{Binding, Job},
workspace::Workspace,
};
use reqwest::{Client, Method, Url};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
io::{Read, Seek, SeekFrom},
sync::{Arc, Mutex},
time::Duration,
};
use zeroize::Zeroizing;
#[derive(Debug)]
pub struct SyncError {
pub code: String,
pub status: u16,
pub retry_after: Option<u64>,
}
impl SyncError {
fn new(code: &str) -> Self {
Self {
code: code.into(),
status: 0,
retry_after: None,
}
}
}
type Result<T> = std::result::Result<T, SyncError>;
pub trait WorkspaceAccess: Send + Sync {
fn access<T>(
&self,
action: impl FnOnce(&mut Workspace) -> crate::workspace::Result<T>,
) -> Result<T>;
}
impl WorkspaceAccess for Arc<Mutex<Workspace>> {
fn access<T>(
&self,
action: impl FnOnce(&mut Workspace) -> crate::workspace::Result<T>,
) -> Result<T> {
action(&mut *self.lock().map_err(|_| SyncError::new("HOST_BUSY"))?).map_err(Into::into)
}
}
impl WorkspaceAccess for Arc<Mutex<Option<Workspace>>> {
fn access<T>(
&self,
action: impl FnOnce(&mut Workspace) -> crate::workspace::Result<T>,
) -> Result<T> {
action(
self.lock()
.map_err(|_| SyncError::new("HOST_BUSY"))?
.as_mut()
.ok_or_else(|| SyncError::new("WORKSPACE_NOT_OPEN"))?,
)
.map_err(Into::into)
}
}
impl From<crate::workspace::HostError> for SyncError {
fn from(value: crate::workspace::HostError) -> Self {
Self::new(&value.code)
}
}
impl From<std::io::Error> for SyncError {
fn from(_: std::io::Error) -> Self {
Self::new("SYNC_IO_FAILED")
}
}
/// Persist only via the Stronghold Sync scope, never as an IPC response.
#[derive(Serialize, Deserialize)]
pub struct Session {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
pub device_id: String,
}
impl Drop for Session {
fn drop(&mut self) {
use zeroize::Zeroize;
self.access_token.zeroize();
self.refresh_token.zeroize();
}
}
pub struct SyncClient {
endpoint: Url,
client: Client,
token: Zeroizing<String>,
}
impl SyncClient {
pub fn new(endpoint: &str, token: Zeroizing<String>, allow_test_http: bool) -> Result<Self> {
let mut url = Url::parse(endpoint).map_err(|_| SyncError::new("SYNC_ENDPOINT_INVALID"))?;
if (url.scheme() != "https" && !(allow_test_http && url.scheme() == "http"))
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| !matches!(url.path(), "" | "/")
{
return Err(SyncError::new("SYNC_ENDPOINT_INVALID"));
}
url.set_path("/");
let client = Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| SyncError::new("SYNC_CLIENT_FAILED"))?;
Ok(Self {
endpoint: url,
client,
token,
})
}
pub async fn json(&self, method: Method, path: &str, body: Option<Value>) -> Result<Value> {
self.send(method, path, body.map(|v| v.to_string().into_bytes()), true)
.await
}
async fn send(
&self,
method: Method,
path: &str,
body: Option<Vec<u8>>,
is_json: bool,
) -> Result<Value> {
if !path.starts_with("sync/v1/") || path.contains(['\\', '#']) || path.contains("..") {
return Err(SyncError::new("SYNC_PATH_INVALID"));
}
let url = self
.endpoint
.join(path)
.map_err(|_| SyncError::new("SYNC_PATH_INVALID"))?;
let mut request = self.client.request(method, url);
if !self.token.is_empty() {
request = request.bearer_auth(self.token.as_str());
}
if let Some(body) = body {
request = request
.header(
"Content-Type",
if is_json {
"application/json"
} else {
"application/octet-stream"
},
)
.body(body);
}
let mut response = request
.send()
.await
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?;
let status = response.status().as_u16();
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| SyncError::new("SYNC_NETWORK_ERROR"))?
{
if body.len() + chunk.len() > 4 * 1024 * 1024 {
return Err(SyncError::new("SYNC_RESPONSE_TOO_LARGE"));
}
body.extend_from_slice(&chunk);
}
let value: Value = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))?
};
if !(200..300).contains(&status) {
let code = value["error"]["code"]
.as_str()
.filter(|v| v.len() <= 80 && v.bytes().all(|b| b.is_ascii_uppercase() || b == b'_'))
.unwrap_or("SYNC_HTTP_ERROR");
return Err(SyncError {
code: code.into(),
status,
retry_after,
});
}
Ok(value)
}
pub async fn login(
&self,
username: &str,
password: Zeroizing<String>,
device_name: &str,
) -> Result<Session> {
let value = self.json(Method::POST, "sync/v1/auth/sessions", Some(json!({"username":username,"password":password.as_str(),"device_name":device_name}))).await?;
serde_json::from_value(value).map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))
}
pub async fn handshake(&self) -> Result<()> {
let result = self
.json(Method::GET, "sync/v1/handshake?protocol=1", None)
.await?;
if result["protocol"] != 1
|| result["chunk_size"] != 1048576
|| result["max_object_size"] != 104857600
{
return Err(SyncError::new("PROTOCOL_INCOMPATIBLE"));
}
Ok(())
}
pub async fn verify_empty(&self, remote_vault: &str) -> Result<()> {
identifier(remote_vault)?;
let page = self
.json(
Method::GET,
&format!("sync/v1/vaults/{remote_vault}/changes?limit=1"),
None,
)
.await?;
if page["boundary"] != 0 || page["items"].as_array().is_none_or(|v| !v.is_empty()) {
return Err(SyncError::new("SYNC_RECONCILIATION_REQUIRED"));
}
Ok(())
}
pub async fn push_one(
&self,
workspace: &impl WorkspaceAccess,
binding: &Binding,
) -> Result<bool> {
if Url::parse(&binding.endpoint)
.ok()
.is_none_or(|url| url != self.endpoint)
{
return Err(SyncError::new("SYNC_BINDING_CHANGED"));
}
identifier(&binding.remote_vault)?;
let job = workspace.access(|ws| {
ws.sync_capture(&binding.id)?;
ws.sync_next(&binding.id)
})?;
let Some(job) = job else {
return Ok(false);
};
if job.state == "conflict" {
return Err(SyncError::new("REVISION_CONFLICT"));
}
if job.operation == "put" && job.base_revision.is_none() {
self.upload(workspace, binding, &job).await?;
}
let payload = workspace.access(|ws| ws.sync_commit_payload(&job))?;
let revision = self
.json(
Method::POST,
&format!("sync/v1/vaults/{}/revisions", binding.remote_vault),
Some(payload),
)
.await?;
workspace.access(|ws| ws.sync_ack(&job, &revision))?;
Ok(true)
}
async fn upload(
&self,
workspace: &impl WorkspaceAccess,
binding: &Binding,
job: &Job,
) -> Result<()> {
let path = workspace.access(|ws| ws.sync_spool(&job.hash))?;
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() != job.size as u64 {
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
}
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 1048576];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
if format!("{:x}", hasher.finalize()) != job.hash {
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
}
let base = format!("sync/v1/vaults/{}/uploads", binding.remote_vault);
let mut upload_id = job.upload_id.clone();
let mut offset = 0;
if let Some(id) = &upload_id {
identifier(id)?;
match self.json(Method::GET, &format!("{base}/{id}"), None).await {
Ok(status) => {
offset = status["offset"]
.as_u64()
.filter(|v| *v <= job.size as u64)
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?
}
Err(error)
if matches!(error.code.as_str(), "UPLOAD_EXPIRED" | "UPLOAD_DAMAGED") =>
{
upload_id = None
}
Err(error) => return Err(error),
}
}
if upload_id.is_none() {
let response = self
.json(
Method::POST,
&base,
Some(json!({"content_hash":job.hash,"size":job.size})),
)
.await?;
if response["complete"] == true {
return Ok(());
}
upload_id = Some(
response["upload_id"]
.as_str()
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?
.into(),
);
workspace.access(|ws| ws.sync_upload(job, upload_id.as_deref()))?;
}
let id = upload_id.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
identifier(&id)?;
file.seek(SeekFrom::Start(offset))?;
while offset < job.size as u64 {
workspace.access(|ws| ws.check_binding(&binding.id))?;
let count = file.read(&mut buffer)?;
if count == 0 {
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
}
let value = self
.send(
Method::PUT,
&format!("{base}/{id}?offset={offset}"),
Some(buffer[..count].to_vec()),
false,
)
.await?;
if value["offset"].as_u64() != Some(offset + count as u64) {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
offset += count as u64;
}
let value = self
.json(Method::POST, &format!("{base}/{id}/complete"), None)
.await?;
if value["complete"] != true || value["content_hash"] != job.hash {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
Ok(())
}
}
fn identifier(value: &str) -> Result<()> {
if value.is_empty()
|| value.len() > 80
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Err(SyncError::new("SYNC_IDENTIFIER_INVALID"));
}
Ok(())
}
+283
View File
@@ -0,0 +1,283 @@
//! Durable queue state. Network code never invents a remote base from a local revision.
use crate::workspace::{hash, HostError, Result, Workspace};
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::{fs, io::Write, path::PathBuf};
use uuid::Uuid;
#[derive(Clone, Serialize, Deserialize)]
pub struct Binding {
pub id: String,
pub endpoint: String,
pub remote_vault: String,
pub account: String,
pub cursor: i64,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Job {
pub binding: String,
pub operation_id: String,
pub file_id: String,
pub path: String,
pub hash: String,
pub size: i64,
pub operation: String,
pub state: String,
pub base_revision: Option<i64>,
pub upload_id: Option<String>,
}
impl Workspace {
pub fn sync_binding(&self) -> Result<Option<Binding>> {
Ok(self.db.query_row("SELECT id,endpoint,remote_vault,account,cursor FROM sync_bindings WHERE state='active'", [], |r| {
Ok(Binding { id:r.get(0)?, endpoint:r.get(1)?, remote_vault:r.get(2)?, account:r.get(3)?, cursor:r.get(4)? })
}).optional()?)
}
pub(crate) fn check_binding(&self, binding: &str) -> Result<()> {
if self.sync_binding()?.is_none_or(|b| b.id != binding) {
return Err(HostError::new("SYNC_BINDING_CHANGED"));
}
Ok(())
}
/// Caller verifies an empty remote and obtains a reconciliation confirmation first.
pub fn sync_bind_empty(
&mut self,
endpoint: &str,
remote_vault: &str,
account: &str,
) -> Result<Binding> {
if self.sync_binding()?.is_some() {
return Err(HostError::new("SYNC_ALREADY_BOUND"));
}
let had_binding: bool =
self.db
.query_row("SELECT EXISTS(SELECT 1 FROM sync_bindings)", [], |r| {
r.get(0)
})?;
let entries = self.scan()?;
let id = Uuid::new_v4().to_string();
// Rebinding explicitly starts from the current snapshot, never an old account's queue.
if had_binding {
self.db.execute(
"UPDATE outbox SET state='archived' WHERE state IN ('pending','queued')",
[],
)?;
}
for entry in entries.into_iter().filter(|e| !e.is_folder && !e.deleted) {
let queued: bool = self.db.query_row(
"SELECT EXISTS(SELECT 1 FROM outbox WHERE file_id=?1 AND state='pending')",
[&entry.file_id],
|r| r.get(0),
)?;
if !queued {
let content = fs::read(self.resolve(&entry.path)?)?;
self.write(&entry.path, &entry.hash, &content, "local")?;
}
}
self.db.execute(
"INSERT INTO sync_bindings VALUES (?1,?2,?3,?4,'active',0)",
params![id, endpoint, remote_vault, account],
)?;
self.sync_capture(&id)?;
self.sync_binding()?
.ok_or_else(|| HostError::new("DATABASE_ERROR"))
}
pub fn sync_unbind(&mut self, binding: &str) -> Result<()> {
self.check_binding(binding)?;
let tx = self.db.transaction()?;
tx.execute(
"UPDATE sync_bindings SET state='archived' WHERE id=?1",
[binding],
)?;
tx.execute(
"UPDATE outbox SET state='archived' WHERE state IN ('pending','queued')",
[],
)?;
tx.commit()?;
Ok(())
}
pub fn sync_spool(&self, digest: &str) -> Result<PathBuf> {
if digest.len() != 64
|| !digest
.bytes()
.all(|v| v.is_ascii_hexdigit() && !v.is_ascii_uppercase())
{
return Err(HostError::new("SYNC_HASH_INVALID"));
}
self.resolve(&format!("attachments/{digest}"))?; // Enforce the platform's general path rules.
let root = self.root.join(".ainote/sync-spool");
if root.exists() {
let meta = fs::symlink_metadata(&root)?;
if !meta.is_dir() || meta.file_type().is_symlink() {
return Err(HostError::new("UNSAFE_PATH"));
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if meta.file_attributes() & 0x400 != 0 {
return Err(HostError::new("UNSAFE_PATH"));
}
}
}
fs::create_dir_all(&root)?;
Ok(root.join(digest))
}
pub fn sync_capture(&mut self, binding: &str) -> Result<()> {
self.check_binding(binding)?;
loop {
let pending = self.db.query_row("SELECT operation_id,file_id,path,hash,operation,content FROM outbox WHERE state='pending' ORDER BY rowid LIMIT 1", [], |r| {
Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Vec<u8>>(5)?))
}).optional()?;
let Some((operation_id, file_id, path, digest, operation, content)) = pending else {
break;
};
if operation == "put" {
if hash(&content) != digest {
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
}
let target = self.sync_spool(&digest)?;
if target.exists() {
if fs::symlink_metadata(&target)?.file_type().is_symlink()
|| hash(&fs::read(&target)?) != digest
{
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
}
} else {
let mut temp = tempfile::NamedTempFile::new_in(target.parent().unwrap())?;
temp.write_all(&content)?;
temp.as_file().sync_all()?;
temp.persist_noclobber(target)
.map_err(|_| HostError::new("SYNC_SPOOL_FAILED"))?;
}
}
let tx = self.db.transaction()?;
tx.execute("INSERT OR IGNORE INTO sync_jobs VALUES (?1,?2,?3,?4,?5,?6,?7,'pending',NULL,NULL,NULL,NULL)",
params![binding,operation_id,file_id,path,digest,content.len() as i64,operation])?;
tx.execute(
"UPDATE outbox SET state='queued',content=X'' WHERE operation_id=?1",
[&operation_id],
)?;
tx.commit()?;
}
Ok(())
}
pub fn sync_next(&self, binding: &str) -> Result<Option<Job>> {
self.check_binding(binding)?;
Ok(self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived') ORDER BY rowid LIMIT 1", [binding], |r| {
Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? })
}).optional()?)
}
pub fn sync_upload(&self, job: &Job, upload: Option<&str>) -> Result<()> {
self.check_binding(&job.binding)?;
self.db.execute("UPDATE sync_jobs SET state='uploading',upload_id=?3 WHERE binding=?1 AND operation_id=?2 AND base_revision IS NULL", params![job.binding,job.operation_id,upload])?;
Ok(())
}
pub fn sync_commit_payload(&self, job: &Job) -> Result<serde_json::Value> {
self.check_binding(&job.binding)?;
// The base is frozen exactly once. A response loss reuses the byte-equivalent payload.
self.db.execute("UPDATE sync_jobs SET state='committing',base_revision=COALESCE((SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?3),0) WHERE binding=?1 AND operation_id=?2 AND base_revision IS NULL",
params![job.binding,job.operation_id,job.file_id])?;
let base: i64 = self.db.query_row(
"SELECT base_revision FROM sync_jobs WHERE binding=?1 AND operation_id=?2",
params![job.binding, job.operation_id],
|r| r.get(0),
)?;
Ok(
serde_json::json!({"operation_id":job.operation_id,"file_id":job.file_id,"base_revision":base,"path":job.path,
"operation":job.operation,"content_hash":if job.operation=="put" {Some(&job.hash)} else {None},"size":job.size}),
)
}
pub fn sync_ack(&mut self, job: &Job, revision: &serde_json::Value) -> Result<()> {
self.check_binding(&job.binding)?;
let payload = self.sync_commit_payload(job)?;
for field in [
"operation_id",
"file_id",
"base_revision",
"path",
"operation",
"size",
] {
if revision[field] != payload[field] {
return Err(HostError::new("SYNC_RESPONSE_INVALID"));
}
}
if revision["hash"] != payload["content_hash"]
|| revision["vault_id"]
!= self
.sync_binding()?
.ok_or_else(|| HostError::new("SYNC_BINDING_CHANGED"))?
.remote_vault
{
return Err(HostError::new("SYNC_RESPONSE_INVALID"));
}
let sequence = revision["sequence"]
.as_i64()
.filter(|v| *v > payload["base_revision"].as_i64().unwrap_or(0))
.ok_or_else(|| HostError::new("SYNC_RESPONSE_INVALID"))?;
let tx = self.db.transaction()?;
tx.execute("INSERT INTO sync_heads VALUES (?1,?2,?3,?4,?5) ON CONFLICT(binding,file_id) DO UPDATE SET revision=excluded.revision,path=excluded.path,hash=excluded.hash WHERE sync_heads.revision<excluded.revision", params![job.binding,job.file_id,sequence,job.path,job.hash])?;
tx.execute("UPDATE sync_jobs SET state='acked',remote_revision=?3,error=NULL WHERE binding=?1 AND operation_id=?2", params![job.binding,job.operation_id,sequence])?;
tx.execute(
"UPDATE outbox SET state='acked' WHERE operation_id=?1",
[&job.operation_id],
)?;
tx.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn queue_uses_remote_bases_and_keeps_retry_payload_across_restart() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
let mut digest = String::new();
for index in 0..20 {
digest = ws
.write("a.md", &digest, index.to_string().as_bytes(), "local")
.unwrap()
.hash;
}
let binding = ws
.sync_bind_empty("https://sync.example", "fixture-vault", "fixture-user")
.unwrap();
for sequence in 1..=20 {
let job = ws.sync_next(&binding.id).unwrap().unwrap();
let payload = ws.sync_commit_payload(&job).unwrap();
assert_eq!(payload["base_revision"], sequence - 1);
drop(ws);
ws = Workspace::open(root.path()).unwrap();
assert_eq!(ws.sync_commit_payload(&job).unwrap(), payload);
let response = serde_json::json!({"operation_id":job.operation_id,"file_id":job.file_id,"base_revision":sequence-1,
"path":"a.md","operation":"put","hash":hash((sequence-1).to_string().as_bytes()),"size":(sequence-1).to_string().len(),
"sequence":sequence,"vault_id":"fixture-vault"});
ws.sync_ack(&job, &response).unwrap();
}
assert!(ws.sync_next(&binding.id).unwrap().is_none());
assert_eq!(ws.read("a.md").unwrap().content, "19");
}
#[test]
fn unbind_archives_old_work_and_stale_completion_is_rejected() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
ws.write("a.md", "", b"safe", "local").unwrap();
let first = ws
.sync_bind_empty("https://one.example", "remote-one", "account-one")
.unwrap();
let old = ws.sync_next(&first.id).unwrap().unwrap();
ws.sync_unbind(&first.id).unwrap();
let second = ws
.sync_bind_empty("https://two.example", "remote-two", "account-two")
.unwrap();
let new = ws.sync_next(&second.id).unwrap().unwrap();
assert_ne!(old.operation_id, new.operation_id);
assert_eq!(
ws.sync_commit_payload(&old).unwrap_err().code,
"SYNC_BINDING_CHANGED"
);
assert_eq!(ws.read("a.md").unwrap().content, "safe");
}
}
+11 -7
View File
@@ -18,7 +18,7 @@ pub struct HostError {
pub type Result<T> = std::result::Result<T, HostError>;
impl HostError {
fn new(code: &str) -> Self {
pub(crate) fn new(code: &str) -> Self {
Self {
code: code.into(),
message: code.into(),
@@ -100,7 +100,7 @@ pub fn portable_path_string(path: &Path) -> String {
pub struct Workspace {
pub root: PathBuf,
pub vault_id: String,
db: Connection,
pub(crate) db: Connection,
_lock: File,
}
@@ -135,12 +135,12 @@ impl Workspace {
let db = Connection::open(db_path)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 2 {
if version > 3 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
if version == 1 {
if (1..3).contains(&version) {
// Independent, complete SQLite backup before the schema ownership change.
let backup = managed.join(format!("host-schema1-{}.sqlite3", Uuid::new_v4()));
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
}
db.execute_batch("BEGIN IMMEDIATE;
@@ -150,7 +150,11 @@ impl Workspace {
CREATE TABLE IF NOT EXISTS file_ops (id TEXT PRIMARY KEY,kind TEXT NOT NULL,path TEXT NOT NULL,destination TEXT NOT NULL,hash TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
CREATE TABLE IF NOT EXISTS outbox (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,revision INTEGER NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,operation TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
CREATE TABLE IF NOT EXISTS operations (operation_id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT);
PRAGMA user_version=2; COMMIT;")?;
CREATE TABLE IF NOT EXISTS sync_bindings (id TEXT PRIMARY KEY,endpoint TEXT NOT NULL,remote_vault TEXT NOT NULL,account TEXT NOT NULL,state TEXT NOT NULL,cursor INTEGER NOT NULL DEFAULT 0);
CREATE UNIQUE INDEX IF NOT EXISTS sync_active ON sync_bindings(state) WHERE state='active';
CREATE TABLE IF NOT EXISTS sync_jobs (binding TEXT NOT NULL,operation_id TEXT NOT NULL,file_id TEXT NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,size INTEGER NOT NULL,operation TEXT NOT NULL,state TEXT NOT NULL,base_revision INTEGER,upload_id TEXT,remote_revision INTEGER,error TEXT,PRIMARY KEY(binding,operation_id));
CREATE TABLE IF NOT EXISTS sync_heads (binding TEXT NOT NULL,file_id TEXT NOT NULL,revision INTEGER NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(binding,file_id));
PRAGMA user_version=3; COMMIT;")?;
let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()?
@@ -533,7 +537,7 @@ impl Workspace {
pub fn pending_count(&self) -> Result<i64> {
Ok(self.db.query_row(
"SELECT COUNT(*) FROM outbox WHERE state='pending'",
"SELECT COUNT(*) FROM outbox WHERE state IN ('pending','queued')",
[],
|r| r.get(0),
)?)