//! 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_path_enabled(&self, path: &str) -> Result { Ok(crate::sync_discovery::allowed(path) && self.sync_optional_scope()?.includes(path)) } pub fn sync_optional_scope(&self) -> Result { 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 old_queued_optional_jobs_cannot_bypass_missing_consent() { let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); ws.sync_set_optional_scope(OptionalScope { persona: true, layout: false, }) .unwrap(); let data = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"local","system_prompt":"private","dialogue_pairs":[]}})).unwrap(); let path = "opennexus-records/v1/persona/default.json"; ws.write(path, "", &data, "local").unwrap(); let binding = ws .sync_bind_empty("https://sync.example", "remote", "account") .unwrap(); let job = ws.sync_next(&binding.id).unwrap().unwrap(); // Models a pre-scope database's pending job after schema migration. ws.db .execute("DELETE FROM sync_optional_scope", []) .unwrap(); assert_eq!( ws.sync_commit_payload(&job).unwrap_err().code, "SYNC_SCOPE_DISABLED" ); assert!(ws.sync_next(&binding.id).unwrap().is_none()); ws.write("note.md", "", b"still synchronized", "local") .unwrap(); ws.sync_capture(&binding.id).unwrap(); assert_eq!(ws.sync_next(&binding.id).unwrap().unwrap().path, "note.md"); assert_eq!(std::fs::read(root.path().join(path)).unwrap(), data); } #[test] fn excluded_records_neither_upload_nor_require_object_bytes_to_advance_cursor() { use crate::sync_inbox::RemoteRevision; let root = tempfile::tempdir().unwrap(); let mut ws = Workspace::open(root.path()).unwrap(); let path = "opennexus-records/v1/persona/default.json"; let local = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"Local only","system_prompt":"private","dialogue_pairs":[]}})).unwrap(); ws.write(path, "", &local, "local").unwrap(); let binding = ws .sync_bind_download("https://sync.example", "remote", "account") .unwrap(); ws.sync_capture(&binding.id).unwrap(); assert!(ws.sync_next(&binding.id).unwrap().is_none()); let revision = RemoteRevision { vault_id: "remote".into(), sequence: 1, file_id: uuid::Uuid::new_v4().to_string(), base_revision: 0, path: path.into(), operation: "put".into(), hash: Some("a".repeat(64)), size: 123, operation_id: uuid::Uuid::new_v4().to_string(), }; ws.sync_set_boundary(&binding.id, 1).unwrap(); ws.sync_stage(&binding.id, &revision).unwrap(); assert!(ws.sync_apply_pending(&binding.id).unwrap()); assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 1); assert_eq!(std::fs::read(root.path().join(path)).unwrap(), local); assert!(!ws .sync_spool(revision.hash.as_ref().unwrap()) .unwrap() .exists()); assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty()); ws.sync_unbind(&binding.id).unwrap(); let snapshot = crate::sync_initial::Snapshot { boundary: 1, items: vec![revision], }; let before = ws .sync_preview("https://sync.example", "remote", "account", &snapshot) .unwrap(); assert!(before.items.is_empty()); ws.sync_set_optional_scope(OptionalScope { persona: true, layout: false, }) .unwrap(); assert_eq!( ws.sync_bind_initial( "https://sync.example", "remote", "account", &snapshot, &before.fingerprint ) .err() .unwrap() .code, "SYNC_PREVIEW_CHANGED" ); let after = ws .sync_preview("https://sync.example", "remote", "account", &snapshot) .unwrap(); assert_eq!(after.items.len(), 1); assert_eq!(after.items[0].action, "conflict"); } #[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(), 12 ); } #[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::( r#"{"persona":true,"layout":false,"credentials":true}"# ) .is_err()); } }