feat: 预览并恢复首次 Sync 合并且不丢失笔记链接
This commit is contained in:
@@ -35,6 +35,11 @@ def _refresh():
|
||||
removed = set(old) - {entry['file_id'] for entry in current}
|
||||
# Content is verified before starting the projection transaction. No model/network IO inside.
|
||||
with transaction(conn):
|
||||
task_links = []
|
||||
for entry in current:
|
||||
for alias in entry.get('aliases', []):
|
||||
if alias in removed:
|
||||
task_links.extend((entry['file_id'], row['task_id']) for row in conn.execute('SELECT task_id FROM tasks WHERE note_id=?', [alias]))
|
||||
for file_id in removed:
|
||||
for block_id in repository.delete_note(file_id, conn=conn):
|
||||
conn.execute('DELETE FROM vec_blocks WHERE block_id=?', [block_id])
|
||||
@@ -47,6 +52,8 @@ def _refresh():
|
||||
conn.execute('DELETE FROM vec_blocks WHERE block_id=?', [block_id])
|
||||
conn.execute('UPDATE blocks SET embedding_local_only=? WHERE note_id=?', (int(parsed.embedding_local_only), parsed.note_id))
|
||||
conn.execute('INSERT OR REPLACE INTO host_projection VALUES (?,?,?)', (parsed.note_id, document['hash'], parsed.file_path))
|
||||
for file_id, task_id in task_links:
|
||||
conn.execute('UPDATE tasks SET note_id=? WHERE task_id=? AND note_id IS NULL', [file_id, task_id])
|
||||
if removed or changed:
|
||||
repository.set_index_meta({'workspace_vectors_pending': '1'}, conn=conn)
|
||||
finally:
|
||||
|
||||
@@ -74,3 +74,23 @@ def test_desktop_semantic_rebuild_preserves_host_file_id(tmp_path, monkeypatch):
|
||||
assert [record.note_id for record in repository.list_note_locations()] == ['stable-host-id']
|
||||
finally:
|
||||
host_bridge.vault_id.reset(token)
|
||||
|
||||
|
||||
def test_host_identity_adoption_keeps_existing_task_links(tmp_path, monkeypatch):
|
||||
settings = replace(get_settings(), environment='desktop', data_dir=tmp_path, db_path=tmp_path/'global.sqlite3')
|
||||
monkeypatch.setattr(db, 'get_settings', lambda: settings)
|
||||
monkeypatch.setattr('app.config.get_settings', lambda: settings)
|
||||
document = {'file_id': 'before-merge', 'path': 'same.md', 'hash': sha256(b'test').hexdigest(), 'content': 'test', 'created_at': 0, 'updated_at': 1}
|
||||
monkeypatch.setattr(desktop_notes, 'call', lambda method, **params: {'items': [document], 'total': 1} if method == 'list' else document)
|
||||
from app.services import task_service
|
||||
token = host_bridge.vault_id.set(str(uuid4()))
|
||||
try:
|
||||
task = task_service.create_task(title='Preserve link', note_id='before-merge')
|
||||
document['file_id'] = 'after-merge'
|
||||
document['aliases'] = ['before-merge']
|
||||
asyncio.run(desktop_projection.refresh())
|
||||
assert task_service.get_task(task.task_id).note_id == 'after-merge'
|
||||
assert repository.get_note_record('before-merge') is None
|
||||
assert repository.get_note_record('after-merge') is not None
|
||||
finally:
|
||||
host_bridge.vault_id.reset(token)
|
||||
|
||||
@@ -79,3 +79,14 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- 此测试尚未覆盖完整 S-02 的每个 pull 边界 kill 20 轮,也不代表四并发服务 RSS 或生产 MinIO 性能验收。外部 rename 的稳定身份识别和目录大规模扫描优化继续实施。
|
||||
|
||||
- 本增量 Rust desktop 全目标 46 项通过、1 个受父测试驱动的独立进程辅助入口标记 ignored(父测试实际运行并强杀该入口 10 次);Clippy `-D warnings` 通过。
|
||||
|
||||
|
||||
## 增量:非空初始合并与文件身份关联
|
||||
|
||||
- 同名独立创建的文件支持保留本地、采用远端、另存副本;先通过 journal 留存旧身份内容,再采用远端稳定身份,旧队列封存。三种选择及中途重开各 20 轮通过,真实双客户端也完成了同名创建后的收敛。
|
||||
- schema 8 保存固定远端初始快照。设置页展示上传/下载/相同/冲突预览,每页 100 条;服务器、账户、远端或本地 Vault 变化会清除预览。确认时复核摘要,发生变化返回 SYNC_PREVIEW_CHANGED。初始同步只应用各文件最新可见版本,不重放历史旧路径;历史 tombstone 仅初始化远端基线,不删除本地文件。
|
||||
- 初始文件全部落盘或持久保留冲突后,cursor 才一次推进到快照 boundary;任何未完成初始快照都禁止 push。逐文件提交前后重启各 20 轮通过,真实第三个 Workspace 完成相同内容身份采纳、本地独有上传、远端独有下载和冲突解决。
|
||||
- Host 保存身份别名,旧文件引用仍可解析;Core 每个 Vault 的投影在身份采纳时同事务迁移本地任务链接,避免外键删除把任务 note_id 清空。新增后端关联测试通过。
|
||||
- 目前快照最多处理 100000 条历史 revision,超限明确拒绝;大库快照/扫描性能、任务和配置逻辑同步、完整分类故障矩阵及签名/沙箱等其他生产门槛继续实施。
|
||||
|
||||
- 本增量后端全量 900 项、Rust desktop 全目标 49 项通过(另 1 个父测试使用的进程辅助入口);Sync 界面 3 项交互测试、两个 TypeScript 项目检查和 Clippy `-D warnings` 通过。
|
||||
|
||||
@@ -20,6 +20,7 @@ fn main() {
|
||||
"sync_vaults",
|
||||
"sync_create_vault",
|
||||
"sync_bind",
|
||||
"sync_preview",
|
||||
"sync_unbind",
|
||||
"sync_pause",
|
||||
"sync_status",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"allow-sync-status",
|
||||
"allow-sync-resolve",
|
||||
"allow-sync-logout",
|
||||
"allow-sync-run"
|
||||
"allow-sync-run",
|
||||
"allow-sync-preview"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-sync-preview"
|
||||
description = "Enables the sync_preview command without any pre-configured scope."
|
||||
commands.allow = ["sync_preview"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-sync-preview"
|
||||
description = "Denies the sync_preview command without any pre-configured scope."
|
||||
commands.deny = ["sync_preview"]
|
||||
@@ -15,6 +15,7 @@ pub mod sync_auth;
|
||||
pub mod sync_client;
|
||||
pub mod sync_discovery;
|
||||
pub mod sync_inbox;
|
||||
pub mod sync_initial;
|
||||
pub mod sync_resolution;
|
||||
pub mod sync_state;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -835,6 +835,7 @@ fn main() {
|
||||
sync_vaults,
|
||||
sync_create_vault,
|
||||
sync_bind,
|
||||
sync_preview,
|
||||
sync_unbind,
|
||||
sync_pause,
|
||||
sync_status,
|
||||
|
||||
@@ -222,6 +222,50 @@ impl SyncClient {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn snapshot(&self, remote_vault: &str) -> Result<crate::sync_initial::Snapshot> {
|
||||
identifier(remote_vault)?;
|
||||
let mut cursor = 0i64;
|
||||
let mut boundary = None;
|
||||
let mut heads = std::collections::BTreeMap::new();
|
||||
loop {
|
||||
let mut path =
|
||||
format!("sync/v1/vaults/{remote_vault}/changes?cursor={cursor}&limit=500");
|
||||
if let Some(end) = boundary {
|
||||
path.push_str(&format!("&boundary={end}"));
|
||||
}
|
||||
let page = self.json(Method::GET, &path, None).await?;
|
||||
let end = page["boundary"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
if end < cursor || boundary.is_some_and(|old| old != end) || end > 100000 {
|
||||
return Err(SyncError::new("SYNC_SNAPSHOT_LIMIT"));
|
||||
}
|
||||
boundary = Some(end);
|
||||
let items = page["items"]
|
||||
.as_array()
|
||||
.filter(|v| v.len() <= 500)
|
||||
.ok_or_else(|| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
if items.is_empty() && cursor != end {
|
||||
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
||||
}
|
||||
for item in items {
|
||||
let revision: crate::sync_inbox::RemoteRevision =
|
||||
serde_json::from_value(item.clone())
|
||||
.map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
if revision.sequence != cursor + 1 || revision.sequence > end {
|
||||
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
||||
}
|
||||
cursor = revision.sequence;
|
||||
heads.insert(revision.file_id.clone(), revision);
|
||||
}
|
||||
if cursor == end {
|
||||
return Ok(crate::sync_initial::Snapshot {
|
||||
boundary: end,
|
||||
items: heads.into_values().collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pub async fn verify_empty(&self, remote_vault: &str) -> Result<()> {
|
||||
identifier(remote_vault)?;
|
||||
let page = self
|
||||
@@ -248,6 +292,12 @@ impl SyncClient {
|
||||
return Err(SyncError::new("SYNC_BINDING_CHANGED"));
|
||||
}
|
||||
identifier(&binding.remote_vault)?;
|
||||
if workspace
|
||||
.access(|ws| ws.sync_initial_pending(&binding.id))?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let job = workspace.access(|ws| {
|
||||
ws.sync_resume_resolutions(&binding.id)?;
|
||||
ws.sync_capture(&binding.id)?;
|
||||
@@ -290,6 +340,20 @@ impl SyncClient {
|
||||
while ws.sync_apply_pending(&binding.id)? {}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(initial) = workspace.access(|ws| ws.sync_initial_pending(&binding.id))? {
|
||||
let count = initial.len().min(100);
|
||||
for revision in initial.iter().take(count) {
|
||||
if revision.operation == "put" {
|
||||
self.download(workspace, binding, revision).await?;
|
||||
}
|
||||
workspace.access(|ws| {
|
||||
ws.sync_stage(&binding.id, revision)?;
|
||||
ws.sync_apply_pending(&binding.id)?;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
return Ok(count);
|
||||
}
|
||||
let (cursor, boundary) = workspace.access(|ws| {
|
||||
ws.check_binding(&binding.id)?;
|
||||
Ok((
|
||||
|
||||
@@ -119,6 +119,37 @@ pub struct Bind {
|
||||
account: String,
|
||||
remote_vault: String,
|
||||
mode: String,
|
||||
#[serde(default)]
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn sync_preview(
|
||||
host: State<'_, Host>,
|
||||
request: Bind,
|
||||
) -> Result<notesagent_host::sync_initial::Preview, String> {
|
||||
let _guard = host.sync.gate.lock().await;
|
||||
let client = sync_auth::client(
|
||||
&host.credentials,
|
||||
&request.endpoint,
|
||||
&request.account,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.code)?;
|
||||
let snapshot = sync_auth::guarded(&host.credentials, client.snapshot(&request.remote_vault))
|
||||
.await
|
||||
.map_err(|e| e.code)?;
|
||||
with_workspace(&host, |ws| {
|
||||
if ws.vault_id != request.vault_id {
|
||||
return Err(notesagent_host::workspace::HostError::new("VAULT_CHANGED"));
|
||||
}
|
||||
ws.sync_preview(
|
||||
&request.endpoint,
|
||||
&request.remote_vault,
|
||||
&request.account,
|
||||
&snapshot,
|
||||
)
|
||||
})
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn sync_bind(host: State<'_, Host>, request: Bind) -> Result<Binding, String> {
|
||||
@@ -131,6 +162,24 @@ pub async fn sync_bind(host: State<'_, Host>, request: Bind) -> Result<Binding,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.code)?;
|
||||
if request.mode == "merge" {
|
||||
let snapshot =
|
||||
sync_auth::guarded(&host.credentials, client.snapshot(&request.remote_vault))
|
||||
.await
|
||||
.map_err(|e| e.code)?;
|
||||
return with_workspace(&host, |ws| {
|
||||
if ws.vault_id != request.vault_id {
|
||||
return Err(notesagent_host::workspace::HostError::new("VAULT_CHANGED"));
|
||||
}
|
||||
ws.sync_bind_initial(
|
||||
&request.endpoint,
|
||||
&request.remote_vault,
|
||||
&request.account,
|
||||
&snapshot,
|
||||
request.fingerprint.as_deref().unwrap_or(""),
|
||||
)
|
||||
});
|
||||
}
|
||||
if request.mode == "upload" {
|
||||
sync_auth::guarded(
|
||||
&host.credentials,
|
||||
|
||||
@@ -136,7 +136,13 @@ impl Workspace {
|
||||
.ok_or_else(|| HostError::new("SYNC_BINDING_CHANGED"))?;
|
||||
revision.validate(&active)?;
|
||||
self.resolve(&revision.path)?;
|
||||
if revision.sequence != active.cursor + 1
|
||||
let encoded =
|
||||
serde_json::to_string(revision).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
if let Some(initial) = self.initial_revision(binding, revision.sequence)? {
|
||||
if initial != encoded {
|
||||
return Err(HostError::new("SYNC_REVISION_CHANGED"));
|
||||
}
|
||||
} else if revision.sequence != active.cursor + 1
|
||||
|| self
|
||||
.sync_boundary(binding)?
|
||||
.is_none_or(|end| revision.sequence > end)
|
||||
@@ -177,18 +183,38 @@ impl Workspace {
|
||||
fn sync_finish(&mut self, binding: &str, revision: &RemoteRevision, state: &str) -> Result<()> {
|
||||
self.check_binding(binding)?;
|
||||
let tx = self.db.transaction()?;
|
||||
let changed = tx.execute(
|
||||
"UPDATE sync_bindings SET cursor=?2 WHERE id=?1 AND state='active' AND cursor=?3",
|
||||
params![binding, revision.sequence, revision.sequence - 1],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(HostError::new("SYNC_CURSOR_INVALID"));
|
||||
let initial: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT boundary FROM sync_initial WHERE binding=?1",
|
||||
[binding],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if initial.is_none() {
|
||||
let changed = tx.execute(
|
||||
"UPDATE sync_bindings SET cursor=?2 WHERE id=?1 AND state='active' AND cursor=?3",
|
||||
params![binding, revision.sequence, revision.sequence - 1],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(HostError::new("SYNC_CURSOR_INVALID"));
|
||||
}
|
||||
}
|
||||
tx.execute("INSERT INTO sync_heads VALUES (?1,?2,?3,?4,?5) ON CONFLICT(binding,file_id) DO UPDATE SET revision=excluded.revision,path=excluded.path,hash=excluded.hash WHERE sync_heads.revision<excluded.revision", params![binding,revision.file_id,revision.sequence,revision.path,revision.hash.as_deref().unwrap_or("")])?;
|
||||
tx.execute(
|
||||
"UPDATE sync_inbox SET state=?3 WHERE binding=?1 AND sequence=?2",
|
||||
params![binding, revision.sequence, state],
|
||||
)?;
|
||||
if let Some(boundary) = initial {
|
||||
let pending:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM sync_initial_items i WHERE i.binding=?1 AND NOT EXISTS(SELECT 1 FROM sync_inbox n WHERE n.binding=i.binding AND n.sequence=i.sequence AND n.state!='pending'))",[binding],|r|r.get(0))?;
|
||||
if !pending {
|
||||
tx.execute(
|
||||
"UPDATE sync_bindings SET cursor=?2 WHERE id=?1",
|
||||
params![binding, boundary],
|
||||
)?;
|
||||
tx.execute("DELETE FROM sync_initial WHERE binding=?1", [binding])?;
|
||||
tx.execute("DELETE FROM sync_initial_items WHERE binding=?1", [binding])?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM sync_windows WHERE binding=?1 AND boundary=?2",
|
||||
params![binding, revision.sequence],
|
||||
@@ -223,7 +249,7 @@ impl Workspace {
|
||||
remote
|
||||
],
|
||||
)?;
|
||||
tx.execute("UPDATE sync_jobs SET state='conflict' WHERE binding=?1 AND file_id=?2 AND state NOT IN ('acked','archived')", params![binding,revision.file_id])?;
|
||||
tx.execute("UPDATE sync_jobs SET state='conflict' WHERE binding=?1 AND (file_id=?2 OR path=?3) AND state NOT IN ('acked','archived')", params![binding,revision.file_id,local_path])?;
|
||||
tx.commit()?;
|
||||
self.sync_finish(binding, revision, "conflict")
|
||||
}
|
||||
@@ -255,6 +281,18 @@ impl Workspace {
|
||||
self.sync_finish(binding, &revision, "applied")?;
|
||||
return Ok(true);
|
||||
}
|
||||
let known: Option<i64> = self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?2",
|
||||
params![binding, revision.file_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if known.is_some_and(|head| head >= revision.sequence) {
|
||||
self.sync_finish(binding, &revision, "applied")?;
|
||||
return Ok(true);
|
||||
}
|
||||
let local_path = self.path_for_id(&revision.file_id).ok();
|
||||
let path = local_path.as_deref().unwrap_or(&revision.path);
|
||||
let local = self.resolve(path)?;
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Initial merge uses a confirmed fixed remote snapshot and never replays obsolete paths.
|
||||
use crate::{
|
||||
sync_inbox::RemoteRevision,
|
||||
sync_state::Binding,
|
||||
workspace::{hash, HostError, Result, Workspace},
|
||||
};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs};
|
||||
use uuid::Uuid;
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub boundary: i64,
|
||||
pub items: Vec<RemoteRevision>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct Preview {
|
||||
pub fingerprint: String,
|
||||
pub boundary: i64,
|
||||
pub items: Vec<PreviewItem>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct PreviewItem {
|
||||
pub path: String,
|
||||
pub action: String,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct Local {
|
||||
path: String,
|
||||
hash: String,
|
||||
size: usize,
|
||||
}
|
||||
impl Workspace {
|
||||
fn initial_local(&self) -> Result<Vec<Local>> {
|
||||
self.sync_paths()?
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let source = self.resolve(&path)?;
|
||||
if fs::metadata(&source)?.len() > 104857600 {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
let bytes = fs::read(source)?;
|
||||
Ok(Local {
|
||||
path,
|
||||
hash: hash(&bytes),
|
||||
size: bytes.len(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn sync_preview(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
remote: &str,
|
||||
account: &str,
|
||||
snapshot: &Snapshot,
|
||||
) -> Result<Preview> {
|
||||
if self.sync_binding()?.is_some() {
|
||||
return Err(HostError::new("SYNC_ALREADY_BOUND"));
|
||||
}
|
||||
let local = self.initial_local()?;
|
||||
let binding = Binding {
|
||||
id: String::new(),
|
||||
endpoint: endpoint.into(),
|
||||
remote_vault: remote.into(),
|
||||
account: account.into(),
|
||||
cursor: 0,
|
||||
};
|
||||
let mut paths = BTreeMap::new();
|
||||
for item in &snapshot.items {
|
||||
item.validate(&binding)?;
|
||||
self.resolve(&item.path)?;
|
||||
if item.sequence > snapshot.boundary {
|
||||
return Err(HostError::new("SYNC_RESPONSE_INVALID"));
|
||||
}
|
||||
if item.operation == "put" {
|
||||
paths.insert(item.path.clone(), "download");
|
||||
}
|
||||
}
|
||||
for item in &local {
|
||||
let remote = snapshot
|
||||
.items
|
||||
.iter()
|
||||
.find(|v| v.path == item.path && v.operation == "put");
|
||||
paths.insert(
|
||||
item.path.clone(),
|
||||
match remote {
|
||||
Some(r) if r.operation == "put" && r.hash.as_deref() == Some(&item.hash) => {
|
||||
"identical"
|
||||
}
|
||||
Some(_) => "conflict",
|
||||
None => "upload",
|
||||
},
|
||||
);
|
||||
}
|
||||
let fingerprint = hash(
|
||||
&serde_json::to_vec(&(&self.vault_id, endpoint, remote, account, &local, snapshot))
|
||||
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?,
|
||||
);
|
||||
Ok(Preview {
|
||||
fingerprint,
|
||||
boundary: snapshot.boundary,
|
||||
items: paths
|
||||
.into_iter()
|
||||
.map(|(path, action)| PreviewItem {
|
||||
path,
|
||||
action: action.into(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
pub fn sync_bind_initial(
|
||||
&mut self,
|
||||
endpoint: &str,
|
||||
remote: &str,
|
||||
account: &str,
|
||||
snapshot: &Snapshot,
|
||||
expected: &str,
|
||||
) -> Result<Binding> {
|
||||
if self
|
||||
.sync_preview(endpoint, remote, account, snapshot)?
|
||||
.fingerprint
|
||||
!= expected
|
||||
{
|
||||
return Err(HostError::new("SYNC_PREVIEW_CHANGED"));
|
||||
}
|
||||
let local = self.initial_local()?;
|
||||
let mut prepared = Vec::new();
|
||||
for item in local {
|
||||
let operation = Uuid::new_v4().to_string();
|
||||
let bytes = fs::read(self.resolve(&item.path)?)?;
|
||||
if hash(&bytes) != item.hash {
|
||||
return Err(HostError::new("SYNC_PREVIEW_CHANGED"));
|
||||
}
|
||||
self.store_payload(&operation, &bytes)?;
|
||||
let old = self.entry(&item.path)?;
|
||||
let remote = snapshot
|
||||
.items
|
||||
.iter()
|
||||
.find(|r| r.path == item.path && r.operation == "put");
|
||||
let file_id = remote
|
||||
.map(|r| r.file_id.clone())
|
||||
.or_else(|| old.as_ref().map(|v| v.file_id.clone()))
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
prepared.push((item, operation, old, file_id, remote));
|
||||
}
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let tx = self.db.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE outbox SET state='archived' WHERE state IN ('pending','queued')",
|
||||
[],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_bindings VALUES (?1,?2,?3,?4,'active',0)",
|
||||
params![id, endpoint, remote, account],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_initial VALUES (?1,?2)",
|
||||
params![id, snapshot.boundary],
|
||||
)?;
|
||||
for remote in &snapshot.items {
|
||||
if remote.operation == "put" {
|
||||
tx.execute(
|
||||
"INSERT INTO sync_initial_items VALUES (?1,?2,?3)",
|
||||
params![
|
||||
id,
|
||||
remote.sequence,
|
||||
serde_json::to_string(remote)
|
||||
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO sync_heads VALUES (?1,?2,?3,?4,'')",
|
||||
params![id, remote.file_id, remote.sequence, remote.path],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for (item, operation, old, file_id, remote) in prepared {
|
||||
if let Some(old) = old {
|
||||
if old.file_id != file_id {
|
||||
let occupied: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM files WHERE id=?1)",
|
||||
[&file_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if occupied {
|
||||
return Err(HostError::new("SYNC_IDENTITY_CONFLICT"));
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE files SET id=?1 WHERE id=?2",
|
||||
params![file_id, old.file_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE file_aliases SET file_id=?1 WHERE file_id=?2",
|
||||
params![file_id, old.file_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO file_aliases VALUES (?1,?2)",
|
||||
params![old.file_id, file_id],
|
||||
)?;
|
||||
tx.execute("DELETE FROM sync_observed WHERE file_id=?1", [&old.file_id])?;
|
||||
}
|
||||
}
|
||||
tx.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,deleted=0",params![file_id,item.path,item.hash])?;
|
||||
tx.execute("INSERT INTO sync_observed VALUES (?1,?2,?3,0) ON CONFLICT(file_id) DO UPDATE SET path=excluded.path,hash=excluded.hash,deleted=0",params![file_id,item.path,item.hash])?;
|
||||
if let Some(remote) = remote.filter(|r| r.hash.as_deref() == Some(&item.hash)) {
|
||||
tx.execute(
|
||||
"INSERT INTO sync_heads VALUES (?1,?2,?3,?4,?5)",
|
||||
params![id, file_id, remote.sequence, item.path, item.hash],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,hash,'put',X'','pending' FROM files WHERE id=?2",params![operation,file_id])?;
|
||||
}
|
||||
}
|
||||
if snapshot.items.iter().all(|item| item.operation == "delete") {
|
||||
tx.execute(
|
||||
"UPDATE sync_bindings SET cursor=?2 WHERE id=?1",
|
||||
params![id, snapshot.boundary],
|
||||
)?;
|
||||
tx.execute("DELETE FROM sync_initial WHERE binding=?1", [&id])?;
|
||||
}
|
||||
tx.commit()?;
|
||||
self.sync_binding()?
|
||||
.ok_or_else(|| HostError::new("DATABASE_ERROR"))
|
||||
}
|
||||
pub fn sync_initial_pending(&self, binding: &str) -> Result<Option<Vec<RemoteRevision>>> {
|
||||
self.check_binding(binding)?;
|
||||
let active: bool = self.db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sync_initial WHERE binding=?1)",
|
||||
[binding],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !active {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut statement=self.db.prepare("SELECT i.revision FROM sync_initial_items i WHERE binding=?1 AND NOT EXISTS(SELECT 1 FROM sync_inbox n WHERE n.binding=i.binding AND n.sequence=i.sequence AND n.state!='pending') ORDER BY sequence")?;
|
||||
let rows = statement
|
||||
.query_map([binding], |r| r.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(Some(
|
||||
rows.iter()
|
||||
.map(|v| {
|
||||
serde_json::from_str(v).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
))
|
||||
}
|
||||
pub(crate) fn initial_revision(&self, binding: &str, sequence: i64) -> Result<Option<String>> {
|
||||
Ok(self.db.query_row("SELECT revision FROM sync_initial_items WHERE binding=?1 AND sequence=?2 AND EXISTS(SELECT 1 FROM sync_initial WHERE binding=?1)",params![binding,sequence],|r|r.get(0)).optional()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn revision(sequence: i64, path: &str, content: &[u8]) -> RemoteRevision {
|
||||
RemoteRevision {
|
||||
vault_id: "remote".into(),
|
||||
sequence,
|
||||
file_id: Uuid::new_v4().to_string(),
|
||||
base_revision: 0,
|
||||
path: path.into(),
|
||||
operation: "put".into(),
|
||||
hash: Some(hash(content)),
|
||||
size: content.len() as i64,
|
||||
operation_id: Uuid::new_v4().to_string(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn initial_snapshot_cursor_waits_for_all_files_and_recovers_twenty_rounds() {
|
||||
for _ in 0..20 {
|
||||
for committed in [false, true] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
ws.write("same.md", "", b"same", "local").unwrap();
|
||||
ws.write("local.md", "", b"local", "local").unwrap();
|
||||
let same = revision(2, "same.md", b"same");
|
||||
let new = revision(4, "remote.md", b"remote");
|
||||
let mut deleted = revision(5, "old.md", b"");
|
||||
deleted.operation = "delete".into();
|
||||
deleted.hash = None;
|
||||
let snapshot = Snapshot {
|
||||
boundary: 5,
|
||||
items: vec![same.clone(), new.clone(), deleted],
|
||||
};
|
||||
let preview = ws
|
||||
.sync_preview("https://sync.example", "remote", "account", &snapshot)
|
||||
.unwrap();
|
||||
let binding = ws
|
||||
.sync_bind_initial(
|
||||
"https://sync.example",
|
||||
"remote",
|
||||
"account",
|
||||
&snapshot,
|
||||
&preview.fingerprint,
|
||||
)
|
||||
.unwrap();
|
||||
ws.sync_stage(&binding.id, &same).unwrap();
|
||||
ws.sync_apply_pending(&binding.id).unwrap();
|
||||
assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 0);
|
||||
ws.sync_store_bytes(b"remote").unwrap();
|
||||
ws.sync_stage(&binding.id, &new).unwrap();
|
||||
if committed {
|
||||
let operation: String = ws
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT operation_id FROM sync_inbox WHERE sequence=4",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
ws.write_with_identity(
|
||||
"remote.md",
|
||||
"",
|
||||
b"remote",
|
||||
"remote",
|
||||
&operation,
|
||||
Some(&new.file_id),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(ws);
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 0);
|
||||
ws.sync_apply_pending(&binding.id).unwrap();
|
||||
assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 5);
|
||||
assert!(ws.sync_initial_pending(&binding.id).unwrap().is_none());
|
||||
assert_eq!(ws.read("same.md").unwrap().entry.file_id, same.file_id);
|
||||
assert_eq!(ws.read("remote.md").unwrap().content, "remote");
|
||||
assert_eq!(ws.pending_count().unwrap(), 1);
|
||||
let deletes: i64 = ws
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT count(*) FROM outbox WHERE operation='delete'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(deletes, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stale_preview_is_rejected_without_binding_or_file_changes() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let snapshot = Snapshot {
|
||||
boundary: 0,
|
||||
items: vec![],
|
||||
};
|
||||
let preview = ws
|
||||
.sync_preview("https://sync.example", "remote", "account", &snapshot)
|
||||
.unwrap();
|
||||
ws.write("new.md", "", b"new", "local").unwrap();
|
||||
assert_eq!(
|
||||
ws.sync_bind_initial(
|
||||
"https://sync.example",
|
||||
"remote",
|
||||
"account",
|
||||
&snapshot,
|
||||
&preview.fingerprint
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code,
|
||||
"SYNC_PREVIEW_CHANGED"
|
||||
);
|
||||
assert!(ws.sync_binding().unwrap().is_none());
|
||||
assert_eq!(ws.read("new.md").unwrap().content, "new");
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,6 @@ impl Workspace {
|
||||
if current != expected {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
if !current.is_empty() && self.path_for_id(&revision.file_id).is_err() {
|
||||
return Err(HostError::new("SYNC_CONFLICT_REQUIRES_RENAME"));
|
||||
}
|
||||
if choice == "copy" {
|
||||
if current.is_empty() || self.resolve(destination)?.exists() {
|
||||
return Err(HostError::new("PATH_CONFLICT"));
|
||||
@@ -112,24 +109,41 @@ impl Workspace {
|
||||
.is_some_and(|value| value["state"] == "committed")
|
||||
{
|
||||
let source = self.resolve(&path)?;
|
||||
let current = if source.is_file() {
|
||||
let mut current = if source.is_file() {
|
||||
hash(&fs::read(source)?)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if current != expected {
|
||||
let retired = self.operation(&rename)?.is_some_and(|value| {
|
||||
value["state"] == "committed" && value["result"]["deleted"] == true
|
||||
});
|
||||
if current != if retired { "" } else { &expected } {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
if choice == "copy" {
|
||||
let content = fs::read(self.sync_spool(&expected)?)?;
|
||||
self.write_operation(&destination, "", &content, "local", ©)?;
|
||||
}
|
||||
if self
|
||||
.entry(&path)?
|
||||
.is_some_and(|entry| !entry.deleted && entry.file_id != revision.file_id)
|
||||
{
|
||||
self.mutate_with_origin("delete", &path, "", ¤t, &rename, "remote")?;
|
||||
current.clear();
|
||||
}
|
||||
if choice == "local" {
|
||||
if current.is_empty() {
|
||||
if expected.is_empty() {
|
||||
self.sync_delete_intent(&revision, &operation)?;
|
||||
} else {
|
||||
let content = fs::read(self.sync_spool(&expected)?)?;
|
||||
self.write_operation(&path, &expected, &content, "local", &operation)?;
|
||||
self.write_with_identity(
|
||||
&path,
|
||||
¤t,
|
||||
&content,
|
||||
"local",
|
||||
&operation,
|
||||
Some(&revision.file_id),
|
||||
)?;
|
||||
}
|
||||
} else if revision.operation == "delete" {
|
||||
if !current.is_empty() {
|
||||
@@ -164,7 +178,24 @@ impl Workspace {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let retired = self.operation(&rename)?.and_then(|value| {
|
||||
(value["state"] == "committed" && value["result"]["deleted"] == true)
|
||||
.then(|| value["result"]["file_id"].as_str().map(str::to_owned))
|
||||
.flatten()
|
||||
});
|
||||
let tx = self.db.transaction()?;
|
||||
if let Some(retired) = retired.filter(|id| id != &revision.file_id) {
|
||||
tx.execute(
|
||||
"UPDATE file_aliases SET file_id=?1 WHERE file_id=?2",
|
||||
params![revision.file_id, retired],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO file_aliases VALUES (?1,?2)",
|
||||
params![retired, revision.file_id],
|
||||
)?;
|
||||
tx.execute("UPDATE outbox SET state='archived' WHERE file_id=?1 AND state IN ('pending','queued')",[&retired])?;
|
||||
tx.execute("UPDATE sync_jobs SET state='archived' WHERE binding=?1 AND file_id=?2 AND state!='acked'",params![binding,retired])?;
|
||||
}
|
||||
tx.execute("UPDATE outbox SET state='archived' WHERE file_id=?1 AND state IN ('pending','queued') AND operation_id!=?2",params![revision.file_id,operation])?;
|
||||
tx.execute("UPDATE sync_jobs SET state='archived' WHERE binding=?1 AND file_id=?2 AND state!='acked' AND operation_id!=?3",params![binding,revision.file_id,operation])?;
|
||||
tx.execute(
|
||||
@@ -422,4 +453,70 @@ mod tests {
|
||||
assert_eq!(payload["operation"], "delete");
|
||||
assert_eq!(ws.pending_count().unwrap(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn same_path_independent_identities_resolve_and_recover_twenty_rounds() {
|
||||
for _ in 0..20 {
|
||||
for choice in ["local", "remote", "copy"] {
|
||||
for crash in [false, true] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let local = ws.write("a.md", "", b"local", "local").unwrap();
|
||||
let binding = ws
|
||||
.sync_bind_empty("https://sync.example", "remote-vault", "account")
|
||||
.unwrap();
|
||||
let remote = RemoteRevision {
|
||||
vault_id: "remote-vault".into(),
|
||||
sequence: 1,
|
||||
file_id: Uuid::new_v4().to_string(),
|
||||
base_revision: 0,
|
||||
path: "a.md".into(),
|
||||
operation: "put".into(),
|
||||
hash: Some(ws.sync_store_bytes(b"remote").unwrap()),
|
||||
size: 6,
|
||||
operation_id: Uuid::new_v4().to_string(),
|
||||
};
|
||||
receive(&mut ws, &binding.id, &remote);
|
||||
if crash {
|
||||
let operation = Uuid::new_v4().to_string();
|
||||
let rename = Uuid::new_v4().to_string();
|
||||
let copy = Uuid::new_v4().to_string();
|
||||
ws.db.execute("INSERT INTO sync_resolutions VALUES (?1,1,?2,?3,?4,?5,?6,?7,'pending')",params![binding.id,choice,if choice=="copy" {"copy.md"} else {""},local.hash,operation,rename,copy]).unwrap();
|
||||
if choice == "copy" {
|
||||
ws.write_operation("copy.md", "", b"local", "local", ©)
|
||||
.unwrap();
|
||||
}
|
||||
ws.mutate_with_origin("delete", "a.md", "", &local.hash, &rename, "remote")
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
ws = Workspace::open(root.path()).unwrap();
|
||||
ws.sync_resume_resolutions(&binding.id).unwrap();
|
||||
} else {
|
||||
ws.sync_resolve(
|
||||
&binding.id,
|
||||
1,
|
||||
choice,
|
||||
if choice == "copy" { "copy.md" } else { "" },
|
||||
&local.hash,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let actual = ws.read("a.md").unwrap();
|
||||
assert_eq!(actual.entry.file_id, remote.file_id);
|
||||
assert_eq!(
|
||||
actual.content,
|
||||
if choice == "local" { "local" } else { "remote" }
|
||||
);
|
||||
assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty());
|
||||
assert_eq!(ws.path_for_id(&local.file_id).unwrap(), "a.md");
|
||||
ws.sync_capture(&binding.id).unwrap();
|
||||
let job = ws.sync_next(&binding.id).unwrap();
|
||||
if choice == "remote" {
|
||||
assert!(job.is_none());
|
||||
} else {
|
||||
assert_ne!(job.unwrap().file_id, local.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ impl Workspace {
|
||||
}
|
||||
pub fn sync_next(&self, binding: &str) -> Result<Option<Job>> {
|
||||
self.check_binding(binding)?;
|
||||
let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND c.file_id=sync_jobs.file_id AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| {
|
||||
let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| {
|
||||
Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? })
|
||||
}).optional()?;
|
||||
if job
|
||||
|
||||
@@ -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 > 7 {
|
||||
if version > 8 {
|
||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..7).contains(&version) {
|
||||
if (1..8).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()])?;
|
||||
@@ -157,6 +157,9 @@ impl Workspace {
|
||||
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));
|
||||
CREATE TABLE IF NOT EXISTS file_aliases (alias TEXT PRIMARY KEY,file_id TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_initial (binding TEXT PRIMARY KEY,boundary INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_initial_items (binding TEXT NOT NULL,sequence INTEGER NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(binding,sequence));
|
||||
CREATE TABLE IF NOT EXISTS payloads (operation_id TEXT PRIMARY KEY,hash TEXT NOT NULL,size INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_observed (file_id TEXT PRIMARY KEY,path TEXT NOT NULL,hash TEXT NOT NULL,deleted INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
|
||||
@@ -175,7 +178,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("PRAGMA user_version=7; COMMIT;")?;
|
||||
db.execute_batch("PRAGMA user_version=8; COMMIT;")?;
|
||||
let vault_id: String = db
|
||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||
.optional()?
|
||||
@@ -331,10 +334,19 @@ impl Workspace {
|
||||
Ok(Document { entry, content })
|
||||
}
|
||||
|
||||
pub fn aliases_for_id(&self, file_id: &str) -> Result<Vec<String>> {
|
||||
let mut statement = self
|
||||
.db
|
||||
.prepare("SELECT alias FROM file_aliases WHERE file_id=?1 ORDER BY alias")?;
|
||||
let rows = statement
|
||||
.query_map([file_id], |r| r.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
pub fn path_for_id(&self, file_id: &str) -> Result<String> {
|
||||
self.db
|
||||
.query_row(
|
||||
"SELECT path FROM files WHERE id=?1 AND deleted=0",
|
||||
"SELECT path FROM files WHERE deleted=0 AND (id=?1 OR id=(SELECT file_id FROM file_aliases WHERE alias=?1)) ORDER BY id=?1 DESC LIMIT 1",
|
||||
[file_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
|
||||
@@ -64,9 +64,19 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
entries.retain(|e| !e.deleted && !e.is_folder);
|
||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
let total = entries.len();
|
||||
Ok(
|
||||
json!({"items":entries.into_iter().skip(p.offset).take(p.limit).collect::<Vec<_>>(),"total":total}),
|
||||
)
|
||||
let items = entries
|
||||
.into_iter()
|
||||
.skip(p.offset)
|
||||
.take(p.limit)
|
||||
.map(|entry| {
|
||||
let aliases = ws.aliases_for_id(&entry.file_id).map_err(|e| e.code)?;
|
||||
let mut value = serde_json::to_value(entry)
|
||||
.map_err(|_| "HOST_SERIALIZE_FAILED".to_owned())?;
|
||||
value["aliases"] = json!(aliases);
|
||||
Ok(value)
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
Ok(json!({"items":items,"total":total}))
|
||||
}
|
||||
"workspace.read" => {
|
||||
let p: Read = decode(params)?;
|
||||
|
||||
@@ -304,6 +304,125 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
}
|
||||
}
|
||||
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write("collision.md", "", b"created-a", "local")
|
||||
.unwrap();
|
||||
workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write("collision.md", "", b"created-b", "local")
|
||||
.unwrap();
|
||||
client.push_one(&workspace, &binding).await.unwrap();
|
||||
client_b.pull_page(&workspace_b, &binding_b).await.unwrap();
|
||||
{
|
||||
let mut ws = workspace_b.lock().unwrap();
|
||||
let conflict = ws.sync_conflicts(&binding_b.id).unwrap().remove(0);
|
||||
ws.sync_resolve(
|
||||
&binding_b.id,
|
||||
conflict["sequence"].as_i64().unwrap(),
|
||||
"local",
|
||||
"",
|
||||
conflict["current_hash"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
client_b.push_one(&workspace_b, &binding_b).await.unwrap();
|
||||
client.pull_page(&workspace, &binding).await.unwrap();
|
||||
client_b.pull_page(&workspace_b, &binding_b).await.unwrap();
|
||||
assert_eq!(
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.read("collision.md")
|
||||
.unwrap()
|
||||
.content,
|
||||
"created-b"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.read("collision.md")
|
||||
.unwrap()
|
||||
.entry
|
||||
.file_id,
|
||||
workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.read("collision.md")
|
||||
.unwrap()
|
||||
.entry
|
||||
.file_id
|
||||
);
|
||||
|
||||
// Initial merge reviews a fixed snapshot and preserves conflicting local content.
|
||||
let merge_root = tempfile::tempdir().unwrap();
|
||||
let merge_ws = Arc::new(Mutex::new(Workspace::open(merge_root.path()).unwrap()));
|
||||
let snapshot = client.snapshot(remote).await.unwrap();
|
||||
let merge_binding = {
|
||||
let mut ws = merge_ws.lock().unwrap();
|
||||
ws.write("note.md", "", b"next-a", "local").unwrap();
|
||||
ws.write("collision.md", "", b"merge-local", "local")
|
||||
.unwrap();
|
||||
ws.write("local-only.md", "", b"only-local", "local")
|
||||
.unwrap();
|
||||
let preview = ws
|
||||
.sync_preview(&endpoint, remote, "rust-fixture", &snapshot)
|
||||
.unwrap();
|
||||
assert!(preview
|
||||
.items
|
||||
.iter()
|
||||
.any(|v| v.path == "note.md" && v.action == "identical"));
|
||||
assert!(preview
|
||||
.items
|
||||
.iter()
|
||||
.any(|v| v.path == "collision.md" && v.action == "conflict"));
|
||||
ws.sync_bind_initial(
|
||||
&endpoint,
|
||||
remote,
|
||||
"rust-fixture",
|
||||
&snapshot,
|
||||
&preview.fingerprint,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(!client_b.push_one(&merge_ws, &merge_binding).await.unwrap());
|
||||
assert_eq!(
|
||||
client_b.pull_page(&merge_ws, &merge_binding).await.unwrap(),
|
||||
snapshot.items.len()
|
||||
);
|
||||
{
|
||||
let mut ws = merge_ws.lock().unwrap();
|
||||
assert_eq!(ws.read("collision.md").unwrap().content, "merge-local");
|
||||
assert_eq!(ws.read("copy.md").unwrap().content, "next-b");
|
||||
assert_eq!(
|
||||
ws.sync_binding().unwrap().unwrap().cursor,
|
||||
snapshot.boundary
|
||||
);
|
||||
let conflict = ws.sync_conflicts(&merge_binding.id).unwrap().remove(0);
|
||||
ws.sync_resolve(
|
||||
&merge_binding.id,
|
||||
conflict["sequence"].as_i64().unwrap(),
|
||||
"remote",
|
||||
"",
|
||||
conflict["current_hash"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(client_b.push_one(&merge_ws, &merge_binding).await.unwrap());
|
||||
assert!(!client_b.push_one(&merge_ws, &merge_binding).await.unwrap());
|
||||
client.pull_page(&workspace, &binding).await.unwrap();
|
||||
assert_eq!(
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.read("local-only.md")
|
||||
.unwrap()
|
||||
.content,
|
||||
"only-local"
|
||||
);
|
||||
// Kill the actual client process after each durable 10 MiB server offset,
|
||||
// before its response reaches the client. The next process must query offset.
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -35,3 +35,29 @@ it('cancels destructive choices and binds accepted conflict decisions to their s
|
||||
expect(wrapper.get('input[type=url]').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('requires a reviewed merge fingerprint and invalidates preview when the remote changes', async () => {
|
||||
vi.mocked(hostInvoke).mockImplementation(async command => {
|
||||
if (command === 'sync_status') return empty()
|
||||
if (command === 'sync_login') return { endpoint: 'https://test.example/', account: 'test' }
|
||||
if (command === 'sync_vaults') return { items: [{ id: 'one', name: 'One', sequence: 2 }, { id: 'two', name: 'Two', sequence: 3 }] }
|
||||
if (command === 'sync_preview') return { fingerprint: 'reviewed-snapshot', boundary: 2, items: [{ path: 'same.md', action: 'conflict' }] }
|
||||
return null
|
||||
})
|
||||
const wrapper = mount(SyncSettings); await flushPromises()
|
||||
await wrapper.get('input[type=url]').setValue('https://test.example/')
|
||||
await wrapper.get('input[autocomplete=username]').setValue('test')
|
||||
await wrapper.get('input[type=password]').setValue('fixture')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
await wrapper.get('select').setValue('one')
|
||||
await wrapper.findAll('button').find(button => button.text() === '预览合并')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('same.md')
|
||||
await wrapper.get('select').setValue('two')
|
||||
expect(wrapper.text()).not.toContain('same.md')
|
||||
await wrapper.get('select').setValue('one')
|
||||
await wrapper.findAll('button').find(button => button.text() === '预览合并')!.trigger('click'); await flushPromises()
|
||||
confirm.mockResolvedValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认合并并绑定')!.trigger('click'); await flushPromises()
|
||||
expect(hostInvoke).toHaveBeenCalledWith('sync_bind', { request: { vault_id: 'local', endpoint: 'https://test.example/', account: 'test', remote_vault: 'one', mode: 'merge', fingerprint: 'reviewed-snapshot' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch, computed } from 'vue'
|
||||
import { hostInvoke } from '@/services/platform/desktop'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
@@ -13,6 +13,18 @@ const status = ref<Status | null>(null)
|
||||
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
|
||||
const connected = ref(false), busy = ref(false), message = ref(''), remoteVaults = ref<RemoteVault[]>([]), selected = ref(''), newName = ref('')
|
||||
const copies = ref<Record<number, string>>({})
|
||||
interface Preview { fingerprint: string; boundary: number; items: Array<{ path: string; action: string }> }
|
||||
const preview = ref<Preview | null>(null), previewPage = ref(0)
|
||||
const previewItems = computed(() => preview.value?.items.slice(previewPage.value * 100, (previewPage.value + 1) * 100) ?? [])
|
||||
watch([endpoint, account, selected, () => status.value?.vault_id], () => { preview.value = null; previewPage.value = 0 })
|
||||
function previewMerge() { return act(async () => {
|
||||
preview.value = await hostInvoke<Preview>('sync_preview', { request: { vault_id: status.value!.vault_id, endpoint: endpoint.value, account: account.value, remote_vault: selected.value, mode: 'merge' } })
|
||||
}) }
|
||||
async function merge() {
|
||||
const plan = preview.value, vaultId = status.value?.vault_id, remote = selected.value
|
||||
if (!plan || !vaultId || !(await askConfirm(t('按预览合并并绑定?相同路径将使用远端文件身份,内容不同的文件保留为待解决冲突。', 'Merge and bind this preview? Matching paths adopt remote identities; differing contents are preserved as conflicts.')))) return
|
||||
await act(async () => { await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode: 'merge', fingerprint: plan.fingerprint } }); preview.value = null })
|
||||
}
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let mounted = true
|
||||
async function refresh() {
|
||||
@@ -73,8 +85,8 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
||||
<section class="panel settings-section sync-settings" aria-labelledby="sync-title">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<h2 id="sync-title">OpenNexus Sync</h2>
|
||||
<p>{{ t('同步当前 Vault 的文件。登录前请先解锁设备凭据保险库。', 'Sync files in the current vault. Unlock the device credential vault before signing in.') }}</p>
|
||||
<p class="subtle">{{ t('当前支持本地上传到空远端,或远端下载到空本地。两边都有文件时请先使用独立空库;合并预览仍在开发中。', 'Upload to an empty remote vault, or download into an empty local vault. Initial merge of two populated vaults is still under development.') }}</p>
|
||||
<p>{{ t('同步当前 Vault 的 Markdown 与常用附件。登录前请先解锁设备凭据保险库。', 'Sync Markdown and supported attachments in the current vault. Unlock the device credential vault before signing in.') }}</p>
|
||||
<p class="subtle">{{ t('两边都有文件时先预览合并。内容冲突会保留两端版本;任务和配置记录同步仍在开发中。', 'Preview a merge when both vaults contain files. Conflicts retain both versions; task and configuration record sync is still under development.') }}</p>
|
||||
<p v-if="message" class="error-banner" role="alert">{{ message }}</p>
|
||||
<form class="sync-form" @submit.prevent="login">
|
||||
<label>{{ t('服务器地址', 'Server URL') }}<input v-model="endpoint" required :disabled="!!status?.binding || busy" type="url" autocomplete="url" /></label>
|
||||
@@ -89,6 +101,12 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
||||
<label>{{ t('远端库', 'Remote vault') }}<select v-model="selected"><option value="">{{ t('请选择', 'Choose a vault') }}</option><option v-for="vault in remoteVaults" :key="vault.id" :value="vault.id">{{ vault.name }} · {{ vault.sequence }}</option></select></label>
|
||||
<div class="inline-actions"><input v-model="newName" :placeholder="t('新远端库名称', 'New vault name')" /><button :disabled="busy || !newName.trim()" @click="createVault">{{ t('创建远端库', 'Create vault') }}</button></div>
|
||||
<div class="inline-actions"><button :disabled="busy || !selected || !status" @click="bind('upload')">{{ t('上传到空远端', 'Upload to empty remote') }}</button><button :disabled="busy || !selected || !status" @click="bind('download')">{{ t('下载到空本地', 'Download into empty local') }}</button></div>
|
||||
<button :disabled="busy || !selected || !status" @click="previewMerge">{{ t('预览合并', 'Preview merge') }}</button>
|
||||
<div v-if="preview" class="sync-preview">
|
||||
<p>{{ t('预览文件数', 'Files in preview') }} {{ preview.items.length }}</p>
|
||||
<ul><li v-for="item in previewItems" :key="item.path">{{ item.path }} · {{ item.action }}</li></ul>
|
||||
<div class="inline-actions"><button :disabled="previewPage === 0" @click="previewPage--">{{ t('上一页', 'Previous') }}</button><button :disabled="(previewPage + 1) * 100 >= preview.items.length" @click="previewPage++">{{ t('下一页', 'Next') }}</button><button :disabled="busy" @click="merge">{{ t('确认合并并绑定', 'Confirm merge and bind') }}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="status?.binding" class="sync-bound">
|
||||
<p>{{ status.binding.endpoint }} · {{ status.binding.account }} · {{ status.binding.remote_vault }}</p>
|
||||
|
||||
Reference in New Issue
Block a user