feat(sync): 持久化明确的可选记录范围选择
This commit is contained in:
@@ -99,3 +99,5 @@ pub mod extension_instance;
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod process_creation;
|
mod process_creation;
|
||||||
|
|
||||||
|
pub mod sync_scope;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Device-local choices for optional logical data; never exported as sync records.
|
||||||
|
use crate::workspace::{HostError, Result, Workspace};
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct OptionalScope {
|
||||||
|
pub persona: bool,
|
||||||
|
pub layout: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionalScope {
|
||||||
|
pub fn includes(&self, path: &str) -> bool {
|
||||||
|
match path {
|
||||||
|
"opennexus-records/v1/persona/default.json" => self.persona,
|
||||||
|
"opennexus-records/v1/layout/sidebars.json" => self.layout,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
pub fn sync_optional_scope(&self) -> Result<OptionalScope> {
|
||||||
|
Ok(self
|
||||||
|
.db
|
||||||
|
.query_row(
|
||||||
|
"SELECT persona,layout FROM sync_optional_scope WHERE id=1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(OptionalScope {
|
||||||
|
persona: row.get(0)?,
|
||||||
|
layout: row.get(1)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_set_optional_scope(&mut self, scope: OptionalScope) -> Result<()> {
|
||||||
|
if self.sync_binding()?.is_some() {
|
||||||
|
return Err(HostError::new("SYNC_SCOPE_REBIND_REQUIRED"));
|
||||||
|
}
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO sync_optional_scope VALUES (1,?1,?2) ON CONFLICT(id) DO UPDATE SET persona=excluded.persona,layout=excluded.layout",
|
||||||
|
rusqlite::params![scope.persona, scope.layout],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_ten_upgrade_preserves_vault_and_does_not_infer_consent() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let mut ws = Workspace::open(root.path()).unwrap();
|
||||||
|
let entry = ws.write("note.md", "", b"retained", "local").unwrap();
|
||||||
|
let vault = ws.vault_id.clone();
|
||||||
|
ws.db
|
||||||
|
.execute_batch("DROP TABLE sync_optional_scope; PRAGMA user_version=10;")
|
||||||
|
.unwrap();
|
||||||
|
drop(ws);
|
||||||
|
let mut ws = Workspace::open(root.path()).unwrap();
|
||||||
|
assert_eq!(ws.vault_id, vault);
|
||||||
|
assert_eq!(ws.read("note.md").unwrap().entry.file_id, entry.file_id);
|
||||||
|
assert_eq!(ws.read("note.md").unwrap().content, "retained");
|
||||||
|
assert_eq!(ws.pending_count().unwrap(), 1);
|
||||||
|
assert_eq!(ws.sync_optional_scope().unwrap(), OptionalScope::default());
|
||||||
|
assert_eq!(
|
||||||
|
ws.db
|
||||||
|
.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
|
||||||
|
.unwrap(),
|
||||||
|
11
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn optional_scope_is_off_by_default_local_durable_and_frozen_while_bound() {
|
||||||
|
let a = tempfile::tempdir().unwrap();
|
||||||
|
let b = tempfile::tempdir().unwrap();
|
||||||
|
let mut ws = Workspace::open(a.path()).unwrap();
|
||||||
|
let off = ws.sync_optional_scope().unwrap();
|
||||||
|
assert!(!off.includes("opennexus-records/v1/persona/default.json"));
|
||||||
|
assert!(!off.includes("opennexus-records/v1/layout/sidebars.json"));
|
||||||
|
assert!(off.includes("notes/example.md"));
|
||||||
|
let chosen = OptionalScope {
|
||||||
|
persona: true,
|
||||||
|
layout: false,
|
||||||
|
};
|
||||||
|
ws.sync_set_optional_scope(chosen).unwrap();
|
||||||
|
assert_eq!(ws.pending_count().unwrap(), 0);
|
||||||
|
drop(ws);
|
||||||
|
let mut ws = Workspace::open(a.path()).unwrap();
|
||||||
|
assert_eq!(ws.sync_optional_scope().unwrap(), chosen);
|
||||||
|
assert_eq!(
|
||||||
|
Workspace::open(b.path())
|
||||||
|
.unwrap()
|
||||||
|
.sync_optional_scope()
|
||||||
|
.unwrap(),
|
||||||
|
off
|
||||||
|
);
|
||||||
|
let binding = ws
|
||||||
|
.sync_bind_empty("https://sync.example", "remote", "account")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ws.sync_set_optional_scope(off).unwrap_err().code,
|
||||||
|
"SYNC_SCOPE_REBIND_REQUIRED"
|
||||||
|
);
|
||||||
|
assert_eq!(ws.sync_optional_scope().unwrap(), chosen);
|
||||||
|
ws.sync_unbind(&binding.id).unwrap();
|
||||||
|
ws.sync_set_optional_scope(off).unwrap();
|
||||||
|
assert_eq!(ws.sync_optional_scope().unwrap(), off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_rejects_unknown_fields_instead_of_authorizing_future_categories() {
|
||||||
|
assert!(serde_json::from_str::<OptionalScope>(
|
||||||
|
r#"{"persona":true,"layout":false,"credentials":true}"#
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,10 +136,10 @@ impl Workspace {
|
|||||||
let db = Connection::open(db_path)?;
|
let db = Connection::open(db_path)?;
|
||||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||||
if version > 10 {
|
if version > 11 {
|
||||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||||
}
|
}
|
||||||
if (1..10).contains(&version) {
|
if (1..11).contains(&version) {
|
||||||
// Independent, complete SQLite backup before the schema ownership change.
|
// Independent, complete SQLite backup before the schema ownership change.
|
||||||
let backup = managed.join(format!("host-schema{version}-{}.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("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
|
||||||
@@ -166,6 +166,7 @@ impl Workspace {
|
|||||||
CREATE TABLE IF NOT EXISTS sync_attempts (binding TEXT NOT NULL,operation_id TEXT NOT NULL,attempts INTEGER NOT NULL,outcome TEXT NOT NULL,error TEXT,PRIMARY KEY(binding,operation_id));
|
CREATE TABLE IF NOT EXISTS sync_attempts (binding TEXT NOT NULL,operation_id TEXT NOT NULL,attempts INTEGER NOT NULL,outcome TEXT NOT NULL,error TEXT,PRIMARY KEY(binding,operation_id));
|
||||||
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_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_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,PRIMARY KEY(binding,sequence));")?;
|
||||||
let has_origin: bool = db.query_row(
|
let has_origin: bool = db.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')",
|
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')",
|
||||||
@@ -181,7 +182,7 @@ impl Workspace {
|
|||||||
if version < 7 {
|
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("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=10; 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=11; COMMIT;")?;
|
||||||
let vault_id: String = db
|
let vault_id: String = db
|
||||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||||
.optional()?
|
.optional()?
|
||||||
|
|||||||
Reference in New Issue
Block a user