From e788609de7dc20753d6584a68cc12b9997aae1a3 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 07:15:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E7=9A=84=E5=8F=AF=E9=80=89=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 10 ++ frontend/src-tauri/src/lib.rs | 2 + frontend/src-tauri/src/sync_scope.rs | 125 ++++++++++++++++++ frontend/src-tauri/src/workspace.rs | 7 +- 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 frontend/src-tauri/src/sync_scope.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 2dedada..51695a2 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -720,3 +720,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 真实 HTTP 双客户端测试扩充为人设、布局各 60 轮同改冲突,三种选择各 20 轮。副本分支额外要求两端副本正文/摘要一致,副本 file_id 在两端一致且不同于活动记录。最终执行结果另列。 - 生产代码完整 Rust 回归 139 通过、12 ignored,日志 .build/record-copy-full-rust.log;随后单独运行扩充后的 60 轮/类别 HTTP 用例通过,38.97 秒,日志 .build/record-copy-http-final.log。Clippy 首次发现新测试重开变量多余 mut,已移除,最终全目标 -D warnings 通过(.build/record-copy-clippy-final.log)。前端 529 项及 type-check 通过,日志 .build/record-copy-frontend.log、.build/record-copy-types.log。 - 上述结果证明本机双客户端同改的三种选择与文本副本,不代替完整 S-03 改对删/rename/历史恢复、独立硬件 UI 或发布验收。本轮未更改用户 Vault 数据,完整目标仍未完成。 + + +## 增量:可选逻辑数据同步范围的持久层 + +- 复核生产化规划第 154 行发现,人设与布局属于“用户选择后同步”,不能沿默认白名单直接同步。本轮先建立 sync_scope::OptionalScope 持久层,persona/layout 两项均默认 false;严格 DTO 拒绝未知类别。配置只存当前本机 Vault SQLite,不写 outbox、不作为逻辑记录上传。 +- Workspace 升级到 schema 11,新建 sync_optional_scope 表,原 schema 升级前仍走既有 SQLite 备份流程。新增测试从 schema 10 升级后验证 Vault/文件身份、正文与待提交操作保留,且无自动授权。 +- setter 在已有活动绑定时返回 SYNC_SCOPE_REBIND_REQUIRED,避免直接切换范围后跳过远端历史。计划的产品流程为解除绑定、调整选择、重新对账;scope 需纳入初始预览 fingerprint,重新绑定必须能重新获取先前排除的远端记录。 +- **当前仅完成持久层,尚未接入传输过滤与 UI,因此不能将 default=false 当作已经阻止人设/布局传输的证据。** 下一步必须先完成 sync_paths/发现删除、capture/已排队 job、初始预览、pull 下载/stage/apply 的一致范围检查;排除项只推进元数据游标,不下载对象、不写本地文件、不删除远端数据。不得在这些边界未接齐时宣称“可选同步完成”。 +- 持久选择、重开、另一 Vault 隔离、绑定后拒绝修改、解绑后修改及未知字段拒绝的最初定向测试通过(.build/sync-optional-scope.log);补充升级用例后的完整结果另列。 +- 最终 desktop 全目标 142 通过、12 ignored(库 127、Host 8、其余集成 7),包含 schema 10 升级新用例;全目标 Clippy -D warnings 通过。日志 .build/sync-scope-foundation-full.log、.build/sync-scope-foundation-clippy.log。此结果仅证明持久层与现有回归,不能证明范围过滤已生效,下一轮优先完成剩余接线。 diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index fdfe8b3..0627662 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -99,3 +99,5 @@ pub mod extension_instance; #[cfg(windows)] mod process_creation; + +pub mod sync_scope; diff --git a/frontend/src-tauri/src/sync_scope.rs b/frontend/src-tauri/src/sync_scope.rs new file mode 100644 index 0000000..3906836 --- /dev/null +++ b/frontend/src-tauri/src/sync_scope.rs @@ -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 { + 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::( + r#"{"persona":true,"layout":false,"credentials":true}"# + ) + .is_err()); + } +} diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index 16af59c..4fba7ef 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -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 > 10 { + if version > 11 { 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. let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4())); 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_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));")?; let has_origin: bool = db.query_row( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')", @@ -181,7 +182,7 @@ impl Workspace { 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=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 .query_row("SELECT id FROM identity", [], |r| r.get(0)) .optional()?