fix(sync): 完成 S-03 冲突收敛

This commit is contained in:
2026-09-09 11:33:06 +08:00
parent 7040558b6b
commit 24938c9d78
8 changed files with 988 additions and 50 deletions
+76 -4
View File
@@ -136,10 +136,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 > 11 {
if version > 12 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
if (1..11).contains(&version) {
if (1..12).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()])?;
@@ -167,7 +167,7 @@ impl Workspace {
CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)));
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,retire_id TEXT NOT NULL,restore_id 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')",
[],
@@ -179,10 +179,32 @@ impl Workspace {
[],
)?;
}
let has_retire_id: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name='retire_id')",
[],
|r| r.get(0),
)?;
if !has_retire_id {
db.execute(
"ALTER TABLE sync_resolutions ADD COLUMN retire_id TEXT NOT NULL DEFAULT ''",
[],
)?;
}
let has_restore_id: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name='restore_id')",
[],
|r| r.get(0),
)?;
if !has_restore_id {
db.execute(
"ALTER TABLE sync_resolutions ADD COLUMN restore_id TEXT NOT NULL DEFAULT ''",
[],
)?;
}
if version < 7 {
db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?;
}
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=11; COMMIT;")?;
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=12; COMMIT;")?;
let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()?
@@ -1375,6 +1397,56 @@ mod tests {
);
}
#[test]
fn schema_eleven_upgrade_adds_durable_conflict_operation_ids() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
ws.db
.execute_batch(
"DROP TABLE sync_resolutions;
CREATE TABLE sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));
PRAGMA user_version=11;",
)
.unwrap();
drop(ws);
let ws = Workspace::open(dir.path()).unwrap();
assert_eq!(
ws.db
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.unwrap(),
12
);
for column in ["retire_id", "restore_id"] {
assert!(ws
.db
.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name=?1)",
[column],
|row| row.get::<_, bool>(0),
)
.unwrap());
}
let backup = fs::read_dir(dir.path().join(".ainote"))
.unwrap()
.filter_map(|entry| entry.ok())
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("host-schema11-")
})
.unwrap();
let old = Connection::open(backup.path()).unwrap();
assert!(!old
.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name='retire_id')",
[],
|row| row.get::<_, bool>(0),
)
.unwrap());
}
#[test]
fn unsafe_paths_external_change_and_remote_origin() {
let dir = tempfile::tempdir().unwrap();