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
+81 -16
View File
@@ -135,10 +135,10 @@ 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 > 3 {
if version > 4 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
if (1..3).contains(&version) {
if (1..4).contains(&version) {
// Independent, complete SQLite backup before the schema ownership change.
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
@@ -154,7 +154,21 @@ impl Workspace {
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;")?;
CREATE TABLE IF NOT EXISTS sync_windows (binding TEXT PRIMARY KEY,boundary INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sync_inbox (binding TEXT NOT NULL,sequence INTEGER NOT NULL,revision TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));
CREATE TABLE IF NOT EXISTS sync_conflicts (binding TEXT NOT NULL,sequence INTEGER NOT NULL,file_id TEXT NOT NULL,local_path TEXT NOT NULL,local_hash TEXT NOT NULL,remote TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
let has_origin: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')",
[],
|r| r.get(0),
)?;
if !has_origin {
db.execute(
"ALTER TABLE file_ops ADD COLUMN origin TEXT NOT NULL DEFAULT 'local'",
[],
)?;
}
db.execute_batch("PRAGMA user_version=4; COMMIT;")?;
let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()?
@@ -359,6 +373,17 @@ impl Workspace {
content: &[u8],
origin: &str,
operation_id: &str,
) -> Result<Entry> {
self.write_with_identity(path, expected, content, origin, operation_id, None)
}
pub(crate) fn write_with_identity(
&mut self,
path: &str,
expected: &str,
content: &[u8],
origin: &str,
operation_id: &str,
identity: Option<&str>,
) -> Result<Entry> {
if Uuid::parse_str(operation_id).is_err() {
return Err(HostError::new("OPERATION_ID_INVALID"));
@@ -404,9 +429,18 @@ impl Workspace {
if current != expected {
return Err(HostError::new("REVISION_CONFLICT"));
}
let file_id = self
.entry(path)?
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
let previous = self.entry(path)?;
if identity.is_some_and(|id| previous.as_ref().is_some_and(|entry| entry.file_id != id)) {
return Err(HostError::new("PATH_CONFLICT"));
}
let file_id = previous.map_or_else(
|| {
identity
.map(str::to_owned)
.unwrap_or_else(|| Uuid::new_v4().to_string())
},
|entry| entry.file_id,
);
let tx = self.db.transaction()?;
tx.execute(
"INSERT INTO operations VALUES (?1,?2,'pending',NULL)",
@@ -577,6 +611,7 @@ impl Workspace {
destination,
expected,
&Uuid::new_v4().to_string(),
"local",
)
}
@@ -588,7 +623,19 @@ impl Workspace {
expected: &str,
operation_id: &str,
) -> Result<serde_json::Value> {
let id = self.prepare_file_op_with_id(kind, path, destination, expected, operation_id)?;
self.mutate_with_origin(kind, path, destination, expected, operation_id, "local")
}
pub(crate) fn mutate_with_origin(
&mut self,
kind: &str,
path: &str,
destination: &str,
expected: &str,
operation_id: &str,
origin: &str,
) -> Result<serde_json::Value> {
let id =
self.prepare_file_op_with_id(kind, path, destination, expected, operation_id, origin)?;
if self
.operation(&id)?
.is_some_and(|v| v["state"] == "committed")
@@ -609,12 +656,16 @@ impl Workspace {
destination: &str,
expected: &str,
id: &str,
origin: &str,
) -> Result<String> {
if !matches!(kind, "rename" | "delete") || Uuid::parse_str(id).is_err() {
if !matches!(origin, "local" | "remote")
|| !matches!(kind, "rename" | "delete")
|| Uuid::parse_str(id).is_err()
{
return Err(HostError::new("INVALID_OPERATION"));
}
let fingerprint = hash(
&serde_json::to_vec(&(kind, path, destination, expected))
&serde_json::to_vec(&(kind, path, destination, expected, origin))
.map_err(|_| HostError::new("INVALID_OPERATION"))?,
);
let previous: Option<String> = self
@@ -657,24 +708,34 @@ impl Workspace {
params![id, fingerprint],
)?;
tx.execute(
"INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending')",
params![id, kind, path, destination, expected, content],
"INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending',?7)",
params![id, kind, path, destination, expected, content, origin],
)?;
tx.commit()?;
Ok(id.to_owned())
}
fn apply_file_op(&mut self, id: &str) -> Result<()> {
let (kind, path, destination, expected, content): (
let (kind, path, destination, expected, content, origin): (
String,
String,
String,
String,
Vec<u8>,
String,
) = self.db.query_row(
"SELECT kind,path,destination,hash,content FROM file_ops WHERE id=?1",
"SELECT kind,path,destination,hash,content,origin FROM file_ops WHERE id=?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
))
},
)?;
let source = self.resolve(&path)?;
let previous = self
@@ -724,13 +785,17 @@ impl Workspace {
"UPDATE files SET path=?1,revision=revision+1 WHERE id=?2",
params![destination, previous.file_id],
)?;
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE id=?3", params![id,content,previous.file_id])?;
if origin == "local" {
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE id=?3", params![id,content,previous.file_id])?;
}
} else {
tx.execute(
"UPDATE files SET deleted=1,revision=revision+1 WHERE id=?1",
[&previous.file_id],
)?;
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,'','delete',X'','pending' FROM files WHERE id=?2", params![id,previous.file_id])?;
if origin == "local" {
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,'','delete',X'','pending' FROM files WHERE id=?2", params![id,previous.file_id])?;
}
}
tx.execute("DELETE FROM file_ops WHERE id=?1", [id])?;
let mut result = previous;