Add local file conflict metadata

This commit is contained in:
Eric Wendland 2026-05-18 03:57:26 +02:00
commit 20c88af800
13 changed files with 673 additions and 15 deletions

View file

@ -133,6 +133,22 @@ impl Store {
latest_tree_json TEXT,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS file_conflicts (
conflict_id TEXT PRIMARY KEY,
root_name TEXT NOT NULL,
resource_id TEXT NOT NULL,
path TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
base_tree_hash TEXT,
local_tree_hash TEXT,
remote_tree_hash TEXT,
detail TEXT NOT NULL,
resolution TEXT,
resolution_note TEXT,
created_at_ms INTEGER NOT NULL,
resolved_at_ms INTEGER
);
CREATE TABLE IF NOT EXISTS peer_cards (
peer_id TEXT PRIMARY KEY,
card_json TEXT NOT NULL,
@ -450,6 +466,78 @@ impl Store {
.map_err(StoreError::from)
}
pub fn upsert_file_conflict(&self, conflict: &StoredFileConflict) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO file_conflicts(
conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
params![
conflict.conflict_id,
conflict.root_name,
conflict.resource_id,
conflict.path,
conflict.kind,
conflict.status,
conflict.base_tree_hash,
conflict.local_tree_hash,
conflict.remote_tree_hash,
conflict.detail,
conflict.resolution,
conflict.resolution_note,
conflict.created_at_ms,
conflict.resolved_at_ms
],
)?;
Ok(())
}
pub fn get_file_conflict(
&self,
conflict_id: &str,
) -> Result<Option<StoredFileConflict>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
FROM file_conflicts WHERE conflict_id = ?1"#,
)?;
let mut rows = stmt.query(params![conflict_id])?;
if let Some(row) = rows.next()? {
Ok(Some(stored_file_conflict_from_row(row)?))
} else {
Ok(None)
}
}
pub fn list_file_conflicts(
&self,
root_name: Option<&str>,
) -> Result<Vec<StoredFileConflict>, StoreError> {
let sql = if root_name.is_some() {
r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
FROM file_conflicts WHERE root_name = ?1
ORDER BY status, created_at_ms, conflict_id"#
} else {
r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash,
local_tree_hash, remote_tree_hash, detail, resolution, resolution_note,
created_at_ms, resolved_at_ms
FROM file_conflicts
ORDER BY status, root_name, created_at_ms, conflict_id"#
};
let mut stmt = self.conn.prepare(sql)?;
let rows = if let Some(root_name) = root_name {
stmt.query_map(params![root_name], stored_file_conflict_from_row)?
} else {
stmt.query_map([], stored_file_conflict_from_row)?
};
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_resource_secret(&self, secret: &StoredResourceSecret) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO resource_secrets(
@ -857,6 +945,27 @@ fn stored_ssh_cert_request_from_row(
})
}
fn stored_file_conflict_from_row(
row: &rusqlite::Row<'_>,
) -> Result<StoredFileConflict, rusqlite::Error> {
Ok(StoredFileConflict {
conflict_id: row.get(0)?,
root_name: row.get(1)?,
resource_id: row.get(2)?,
path: row.get(3)?,
kind: row.get(4)?,
status: row.get(5)?,
base_tree_hash: row.get(6)?,
local_tree_hash: row.get(7)?,
remote_tree_hash: row.get(8)?,
detail: row.get(9)?,
resolution: row.get(10)?,
resolution_note: row.get(11)?,
created_at_ms: row.get(12)?,
resolved_at_ms: row.get(13)?,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResource {
pub resource_id: String,
@ -908,6 +1017,24 @@ pub struct StoredFileRoot {
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredFileConflict {
pub conflict_id: String,
pub root_name: String,
pub resource_id: String,
pub path: String,
pub kind: String,
pub status: String,
pub base_tree_hash: Option<String>,
pub local_tree_hash: Option<String>,
pub remote_tree_hash: Option<String>,
pub detail: String,
pub resolution: Option<String>,
pub resolution_note: Option<String>,
pub created_at_ms: i64,
pub resolved_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResourceSecret {
pub secret_id: String,
@ -1204,6 +1331,62 @@ mod tests {
assert_eq!(store.list_file_roots().expect("list roots"), vec![root]);
}
#[test]
fn file_conflicts_roundtrip_and_resolve() {
let store = Store::open_memory().expect("open");
let conflict = StoredFileConflict {
conflict_id: "file-conflict:1".to_owned(),
root_name: "notes".to_owned(),
resource_id: "resource:cas-tree:notes".to_owned(),
path: "notes/todo.md".to_owned(),
kind: "concurrent-edit".to_owned(),
status: "open".to_owned(),
base_tree_hash: None,
local_tree_hash: Some(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(),
),
remote_tree_hash: Some(
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".to_owned(),
),
detail: "local and remote edits touched the same path".to_owned(),
resolution: None,
resolution_note: None,
created_at_ms: 10,
resolved_at_ms: None,
};
store
.upsert_file_conflict(&conflict)
.expect("insert conflict");
assert_eq!(
store
.get_file_conflict("file-conflict:1")
.expect("get conflict"),
Some(conflict.clone())
);
assert_eq!(
store
.list_file_conflicts(Some("notes"))
.expect("list by root"),
vec![conflict.clone()]
);
let resolved = StoredFileConflict {
status: "resolved".to_owned(),
resolution: Some("keep-local".to_owned()),
resolution_note: Some("local file is authoritative".to_owned()),
resolved_at_ms: Some(20),
..conflict
};
store
.upsert_file_conflict(&resolved)
.expect("resolve conflict");
assert_eq!(
store.list_file_conflicts(None).expect("list all"),
vec![resolved]
);
}
#[test]
fn kv_store_and_entries_roundtrip() {
let store = Store::open_memory().expect("open");