use std::collections::BTreeMap;
use std::path::Path;
use rusqlite::{params, Connection, OptionalExtension};
use wsb::peer::converge::{Baseline, Entry, EntryKind};
use wsb::peer::identity::{Fingerprint, PairedPeer};
use wsb::peer::model::{Desired, PeerId, RepositoryId, Service, Switch, SyncMode, WorkspaceId};
pub struct MachineStore {
connection: std::sync::Mutex<Connection>,
}
impl MachineStore {
pub fn open(machine_dir: &Path) -> Result<Self, String> {
let path = machine_dir.join("machine.db");
let connection = Connection::open(&path)
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
connection
.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA foreign_keys = ON;",
)
.map_err(|e| format!("cannot configure {}: {e}", path.display()))?;
let store = Self {
connection: std::sync::Mutex::new(connection),
};
store.migrate()?;
Ok(store)
}
fn with<T>(&self, body: impl FnOnce(&Connection) -> Result<T, rusqlite::Error>) -> Result<T, String> {
let connection = self
.connection
.lock()
.map_err(|_| "the machine database is unusable after a panic".to_string())?;
body(&connection).map_err(|e| format!("machine database: {e}"))
}
fn migrate(&self) -> Result<(), String> {
self.with(|c| {
c.execute_batch(
r#"
-- A machine this one has been introduced to. The public key is
-- the identity; everything else is presentation or a hint.
CREATE TABLE IF NOT EXISTS peers (
peer_id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
address TEXT NOT NULL,
paired_at INTEGER NOT NULL,
last_seen INTEGER
);
-- One workspace, on one peer: what the operator asked for.
-- Observed state is NOT here; it is recomputed every time.
CREATE TABLE IF NOT EXISTS relationships (
peer_id TEXT NOT NULL REFERENCES peers(peer_id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL,
desired TEXT NOT NULL,
enabled_at INTEGER NOT NULL,
PRIMARY KEY (peer_id, workspace_id)
);
-- The last state both sides acknowledged, per path. This is
-- the thing that makes convergence three-way rather than a
-- copy, and losing it silently turns every edit into a
-- conflict.
CREATE TABLE IF NOT EXISTS baselines (
peer_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
repository TEXT NOT NULL,
path TEXT NOT NULL,
kind TEXT NOT NULL,
hash TEXT NOT NULL,
size INTEGER NOT NULL,
executable INTEGER NOT NULL,
PRIMARY KEY (peer_id, workspace_id, repository, path)
);
-- A path both sides changed differently. Kept until a person
-- resolves it: both versions stay recoverable, and the
-- baseline for that path does not move.
CREATE TABLE IF NOT EXISTS conflicts (
peer_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
repository TEXT NOT NULL,
path TEXT NOT NULL,
here_hash TEXT,
there_hash TEXT,
base_hash TEXT,
detected_at INTEGER NOT NULL,
PRIMARY KEY (peer_id, workspace_id, repository, path)
);
-- Which repositories have had a baseline established at all.
--
-- An empty baseline and a never-seeded one look identical in
-- the table above — no rows — and they mean opposite things:
-- one says "both sides have agreed that this repository is
-- empty", the other says "nothing has been agreed yet". The
-- second must seed from the commit both sides share, and the
-- first must not.
CREATE TABLE IF NOT EXISTS baseline_seeded (
peer_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
repository TEXT NOT NULL,
seeded_at INTEGER NOT NULL,
PRIMARY KEY (peer_id, workspace_id, repository)
);
-- A conflict a person has settled, waiting to be carried.
--
-- A decision, not an edit: the next reconciliation turns it
-- into an ordinary transfer, which is what makes a resolution
-- go through the same epoch check, the same verification and
-- the same acknowledgement as every other change. Moving the
-- baseline here instead would change a shared fact on one
-- machine only, and the two would then refuse each other's
-- transfers for ever.
CREATE TABLE IF NOT EXISTS resolutions (
peer_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
repository TEXT NOT NULL,
path TEXT NOT NULL,
-- 'here' | 'there'
keep TEXT NOT NULL,
decided_at INTEGER NOT NULL,
PRIMARY KEY (peer_id, workspace_id, repository, path)
);
-- Which of the things two paired machines do for each other
-- are switched on with this one.
--
-- A row per service that has been decided; an absent row is
-- the default, which is on. Machine-wide rather than per
-- workspace, because "stop carrying files with the laptop" and
-- "stop answering the laptop about secrets" are decisions
-- about a MACHINE, and having to make each of them once per
-- workspace is how somebody ends up with the laptop still
-- syncing one of five.
CREATE TABLE IF NOT EXISTS peer_services (
peer_id TEXT NOT NULL REFERENCES peers(peer_id) ON DELETE CASCADE,
service TEXT NOT NULL,
switch TEXT NOT NULL,
changed_at INTEGER NOT NULL,
PRIMARY KEY (peer_id, service)
);
-- A difference somebody chose to leave alone.
--
-- Keyed by the pair of hashes it was dismissed AT, not by the
-- path: "I do not care about this file as it stands" is a
-- statement about two versions of it, and a machine that kept
-- hiding the path after either side changed would be hiding
-- work rather than noise. Either hash absent means that side
-- does not have the file, which is itself a state somebody can
-- dismiss.
CREATE TABLE IF NOT EXISTS dismissals (
peer_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
repository TEXT NOT NULL,
path TEXT NOT NULL,
here_hash TEXT,
there_hash TEXT,
dismissed_at INTEGER NOT NULL,
PRIMARY KEY (peer_id, workspace_id, repository, path)
);
-- Every worktree this machine has opened, so the page can
-- list them without walking the filesystem. Keyed by what this
-- machine calls it; `workspace_id` is what a peer calls it,
-- absent while it has nothing a peer could recognise.
CREATE TABLE IF NOT EXISTS worktrees (
worktree_id TEXT PRIMARY KEY NOT NULL,
project TEXT,
name TEXT NOT NULL,
workspace_id TEXT,
super_root TEXT NOT NULL,
root_commit TEXT,
last_seen INTEGER NOT NULL
);
-- Keyed by repository alone, which handed every worktree of a
-- project the first one's state. Its rows describe no worktree.
DROP TABLE IF EXISTS workspaces;
"#,
)
})?;
let dropped = self.with(|c| {
let mut dropped = 0;
for table in [
"relationships",
"baselines",
"conflicts",
"baseline_seeded",
"resolutions",
"dismissals",
] {
dropped += c.execute(
&format!("DELETE FROM {table} WHERE instr(workspace_id, '@') = 0"),
[],
)?;
}
Ok(dropped)
})?;
if dropped > 0 {
tracing::warn!(
rows = dropped,
"dropped continuity records that named a repository rather than a worktree; \
enable continuity again with `wsb peer sync <peer>` in each worktree"
);
}
let has_mode = self.with(|c| {
let mut statement = c.prepare("PRAGMA table_info(relationships)")?;
let mut rows = statement.query([])?;
let mut found = false;
while let Some(row) = rows.next()? {
if row.get::<_, String>(1)? == "mode" {
found = true;
}
}
Ok(found)
})?;
if !has_mode {
self.with(|c| {
c.execute_batch(
"ALTER TABLE relationships ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual'",
)
})?;
}
Ok(())
}
pub fn add_peer(&self, peer: &PairedPeer) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT INTO peers (peer_id, name, public_key, fingerprint, address, paired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(peer_id) DO UPDATE SET
name = excluded.name,
address = excluded.address",
params![
peer.id.as_str(),
peer.name,
peer.public_key,
peer.fingerprint.as_str(),
peer.address,
peer.paired_at as i64,
],
)?;
Ok(())
})?;
let stored = self.peer(&peer.id)?.ok_or_else(|| "the peer vanished".to_string())?;
if stored.public_key != peer.public_key {
return Err(format!(
"{} is already paired with a different identity ({}). Remove it first if \
this is deliberate.",
peer.id, stored.fingerprint
));
}
Ok(())
}
pub fn peer(&self, id: &PeerId) -> Result<Option<PairedPeer>, String> {
self.with(|c| {
c.query_row(
"SELECT peer_id, name, public_key, fingerprint, address, paired_at
FROM peers WHERE peer_id = ?1",
params![id.as_str()],
row_to_peer,
)
.optional()
})
}
pub fn peers(&self) -> Result<Vec<PairedPeer>, String> {
self.with(|c| {
let mut statement = c.prepare(
"SELECT peer_id, name, public_key, fingerprint, address, paired_at
FROM peers ORDER BY name, peer_id",
)?;
let rows = statement.query_map([], row_to_peer)?;
rows.collect()
})
}
pub fn remove_peer(&self, id: &PeerId) -> Result<bool, String> {
self.with(|c| {
c.execute("DELETE FROM baselines WHERE peer_id = ?1", params![id.as_str()])?;
c.execute(
"DELETE FROM baseline_seeded WHERE peer_id = ?1",
params![id.as_str()],
)?;
c.execute("DELETE FROM conflicts WHERE peer_id = ?1", params![id.as_str()])?;
c.execute("DELETE FROM resolutions WHERE peer_id = ?1", params![id.as_str()])?;
c.execute("DELETE FROM relationships WHERE peer_id = ?1", params![id.as_str()])?;
let removed = c.execute("DELETE FROM peers WHERE peer_id = ?1", params![id.as_str()])?;
Ok(removed > 0)
})
}
pub fn note_seen(&self, id: &PeerId, when: u64) -> Result<(), String> {
self.with(|c| {
c.execute(
"UPDATE peers SET last_seen = ?2 WHERE peer_id = ?1",
params![id.as_str(), when as i64],
)?;
Ok(())
})
}
pub fn last_seen(&self, id: &PeerId) -> Result<Option<u64>, String> {
self.with(|c| {
c.query_row(
"SELECT last_seen FROM peers WHERE peer_id = ?1",
params![id.as_str()],
|row| row.get::<_, Option<i64>>(0),
)
.optional()
.map(|value| value.flatten().map(|v| v as u64))
})
}
pub fn set_desired(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
desired: Desired,
when: u64,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT INTO relationships (peer_id, workspace_id, desired, enabled_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(peer_id, workspace_id) DO UPDATE SET desired = excluded.desired",
params![peer.as_str(), workspace.as_str(), desired.as_str(), when as i64],
)?;
Ok(())
})
}
pub fn services(&self, peer: &PeerId) -> Result<BTreeMap<Service, Switch>, String> {
let stored: Vec<(String, String)> = self.with(|c| {
let mut statement =
c.prepare("SELECT service, switch FROM peer_services WHERE peer_id = ?1")?;
let rows = statement.query_map(params![peer.as_str()], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
})?;
let mut services = BTreeMap::new();
for service in Service::BUILT_IN {
let default = Switch::default_for(&service);
services.insert(service, default);
}
for (service, switch) in stored {
let (Ok(service), Ok(switch)) = (Service::parse(&service), Switch::parse(&switch))
else {
continue;
};
services.insert(service, switch);
}
Ok(services)
}
pub fn service(&self, peer: &PeerId, service: Service) -> Result<Switch, String> {
Ok(self
.services(peer)?
.get(&service)
.copied()
.unwrap_or_else(|| Switch::default_for(&service)))
}
pub fn set_service(
&self,
peer: &PeerId,
service: Service,
switch: Switch,
when: u64,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT INTO peer_services (peer_id, service, switch, changed_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(peer_id, service) DO UPDATE SET
switch = excluded.switch,
changed_at = excluded.changed_at",
params![peer.as_str(), service.as_str(), switch.as_str(), when as i64],
)?;
Ok(())
})
}
pub fn mode(&self, peer: &PeerId, workspace: &WorkspaceId) -> Result<SyncMode, String> {
let stored: Option<String> = self.with(|c| {
c.query_row(
"SELECT mode FROM relationships WHERE peer_id = ?1 AND workspace_id = ?2",
params![peer.as_str(), workspace.as_str()],
|row| row.get(0),
)
.optional()
})?;
Ok(stored
.as_deref()
.and_then(|raw| SyncMode::parse(raw).ok())
.unwrap_or_default())
}
pub fn set_mode(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
mode: SyncMode,
when: u64,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT INTO relationships (peer_id, workspace_id, desired, enabled_at, mode)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(peer_id, workspace_id) DO UPDATE SET mode = excluded.mode",
params![
peer.as_str(),
workspace.as_str(),
Desired::Disabled.as_str(),
when as i64,
mode.as_str()
],
)?;
Ok(())
})
}
pub fn desired(&self, peer: &PeerId, workspace: &WorkspaceId) -> Result<Desired, String> {
let stored: Option<String> = self.with(|c| {
c.query_row(
"SELECT desired FROM relationships WHERE peer_id = ?1 AND workspace_id = ?2",
params![peer.as_str(), workspace.as_str()],
|row| row.get(0),
)
.optional()
})?;
Ok(match stored.as_deref() {
Some("enabled") => Desired::Enabled,
Some("paused") => Desired::Paused,
_ => Desired::Disabled,
})
}
pub fn relationships(&self) -> Result<Vec<(PeerId, WorkspaceId, Desired)>, String> {
let rows = self.with(|c| {
let mut statement = c.prepare(
"SELECT peer_id, workspace_id, desired FROM relationships
ORDER BY peer_id, workspace_id",
)?;
let rows = statement.query_map([], |row| {
let peer: String = row.get(0)?;
let workspace: String = row.get(1)?;
let desired: String = row.get(2)?;
Ok((peer, workspace, desired))
})?;
rows.collect::<Result<Vec<_>, _>>()
})?;
rows.into_iter()
.map(|(peer, workspace, desired)| {
let peer = PeerId::parse(&peer)
.map_err(|e| format!("machine database, relationships: {e}"))?;
let workspace = WorkspaceId::parse(&workspace)
.map_err(|e| format!("machine database, relationships: {e}"))?;
let desired = match desired.as_str() {
"enabled" => Desired::Enabled,
"paused" => Desired::Paused,
"disabled" => Desired::Disabled,
other => {
return Err(format!(
"machine database, relationship {peer}/{workspace}: '{other}' is not \
enabled, paused or disabled"
))
}
};
Ok((peer, workspace, desired))
})
.collect()
}
pub fn baseline(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
) -> Result<Baseline, String> {
self.with(|c| {
let mut statement = c.prepare(
"SELECT path, kind, hash, size, executable FROM baselines
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3",
)?;
let rows = statement.query_map(
params![peer.as_str(), workspace.as_str(), repository.as_str()],
|row| {
let path: String = row.get(0)?;
let kind: String = row.get(1)?;
Ok((
path,
Entry {
kind: if kind == "symlink" {
EntryKind::Symlink
} else {
EntryKind::File
},
hash: row.get(2)?,
size: row.get::<_, i64>(3)? as u64,
executable: row.get::<_, i64>(4)? != 0,
},
))
},
)?;
let mut baseline = Baseline::new();
for row in rows {
let (path, entry) = row?;
baseline.insert(path, entry);
}
Ok(baseline)
})
}
pub fn put_baseline(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
baseline: &Baseline,
) -> Result<(), String> {
self.with(|c| {
let transaction = c.unchecked_transaction()?;
transaction.execute(
"DELETE FROM baselines WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3",
params![peer.as_str(), workspace.as_str(), repository.as_str()],
)?;
{
let mut insert = transaction.prepare(
"INSERT INTO baselines
(peer_id, workspace_id, repository, path, kind, hash, size, executable)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for (path, entry) in baseline {
insert.execute(params![
peer.as_str(),
workspace.as_str(),
repository.as_str(),
path,
match entry.kind {
EntryKind::File => "file",
EntryKind::Symlink => "symlink",
},
entry.hash,
entry.size as i64,
i64::from(entry.executable),
])?;
}
}
transaction.commit()
})
}
pub fn is_seeded(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
) -> Result<bool, String> {
self.with(|c| {
c.query_row(
"SELECT 1 FROM baseline_seeded
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3",
params![peer.as_str(), workspace.as_str(), repository.as_str()],
|_| Ok(()),
)
.optional()
.map(|found| found.is_some())
})
}
pub fn mark_seeded(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
when: u64,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT OR REPLACE INTO baseline_seeded
(peer_id, workspace_id, repository, seeded_at) VALUES (?1, ?2, ?3, ?4)",
params![
peer.as_str(),
workspace.as_str(),
repository.as_str(),
when as i64
],
)?;
Ok(())
})
}
pub fn record_conflict(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
here: Option<&Entry>,
there: Option<&Entry>,
base: Option<&Entry>,
when: u64,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT INTO conflicts
(peer_id, workspace_id, repository, path, here_hash, there_hash, base_hash, detected_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(peer_id, workspace_id, repository, path) DO UPDATE SET
here_hash = excluded.here_hash,
there_hash = excluded.there_hash",
params![
peer.as_str(),
workspace.as_str(),
repository.as_str(),
path,
here.map(|e| e.hash.clone()),
there.map(|e| e.hash.clone()),
base.map(|e| e.hash.clone()),
when as i64,
],
)?;
Ok(())
})
}
pub fn conflicts(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
) -> Result<Vec<ConflictRow>, String> {
self.with(|c| {
let mut statement = c.prepare(
"SELECT repository, path, here_hash, there_hash, base_hash, detected_at
FROM conflicts WHERE peer_id = ?1 AND workspace_id = ?2
ORDER BY repository, path",
)?;
let rows = statement.query_map(params![peer.as_str(), workspace.as_str()], |row| {
Ok(ConflictRow {
repository: row.get(0)?,
path: row.get(1)?,
here_hash: row.get(2)?,
there_hash: row.get(3)?,
base_hash: row.get(4)?,
detected_at: row.get::<_, i64>(5)? as u64,
})
})?;
rows.collect()
})
}
pub fn conflict_counts(&self) -> Result<Vec<ConflictCount>, String> {
self.with(|c| {
let mut statement = c.prepare(
"SELECT workspace_id, repository, peer_id, COUNT(*)
FROM conflicts
GROUP BY workspace_id, repository, peer_id
ORDER BY workspace_id, repository, peer_id",
)?;
let rows = statement.query_map([], |row| {
Ok(ConflictCount {
workspace: row.get(0)?,
repository: row.get(1)?,
peer: row.get(2)?,
conflicts: row.get::<_, i64>(3)? as usize,
})
})?;
rows.collect()
})
}
pub fn clear_conflict(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"DELETE FROM conflicts
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3 AND path = ?4",
params![peer.as_str(), workspace.as_str(), repository.as_str(), path],
)?;
Ok(())
})
}
pub fn set_resolution(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
keep: &str,
when: u64,
) -> Result<(), String> {
if keep != "here" && keep != "there" {
return Err(format!("'{keep}' is not a side to keep"));
}
self.with(|c| {
c.execute(
"INSERT OR REPLACE INTO resolutions
(peer_id, workspace_id, repository, path, keep, decided_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
peer.as_str(),
workspace.as_str(),
repository.as_str(),
path,
keep,
when as i64
],
)?;
Ok(())
})
}
pub fn resolutions(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
) -> Result<BTreeMap<String, String>, String> {
self.with(|c| {
let mut statement = c.prepare(
"SELECT path, keep FROM resolutions
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3",
)?;
let rows = statement.query_map(
params![peer.as_str(), workspace.as_str(), repository.as_str()],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)?;
let mut out = BTreeMap::new();
for row in rows {
let (path, keep) = row?;
out.insert(path, keep);
}
Ok(out)
})
}
pub fn clear_resolution(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"DELETE FROM resolutions
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3 AND path = ?4",
params![peer.as_str(), workspace.as_str(), repository.as_str(), path],
)?;
Ok(())
})
}
pub fn dismiss(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
here_hash: Option<&str>,
there_hash: Option<&str>,
when: u64,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT OR REPLACE INTO dismissals
(peer_id, workspace_id, repository, path, here_hash, there_hash, dismissed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
peer.as_str(),
workspace.as_str(),
repository.as_str(),
path,
here_hash,
there_hash,
when as i64
],
)?;
Ok(())
})
}
pub fn dismissals(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
) -> Result<BTreeMap<String, (Option<String>, Option<String>)>, String> {
self.with(|c| {
let mut statement = c.prepare(
"SELECT path, here_hash, there_hash FROM dismissals
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3",
)?;
let rows = statement.query_map(
params![peer.as_str(), workspace.as_str(), repository.as_str()],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)?;
let mut out = BTreeMap::new();
for row in rows {
let (path, here, there) = row?;
out.insert(path, (here, there));
}
Ok(out)
})
}
pub fn clear_dismissal(
&self,
peer: &PeerId,
workspace: &WorkspaceId,
repository: &RepositoryId,
path: &str,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"DELETE FROM dismissals
WHERE peer_id = ?1 AND workspace_id = ?2 AND repository = ?3 AND path = ?4",
params![peer.as_str(), workspace.as_str(), repository.as_str(), path],
)?;
Ok(())
})
}
pub fn register_worktree(
&self,
worktree: &wsb::daemon::RegisteredWorktree,
) -> Result<(), String> {
self.with(|c| {
c.execute(
"INSERT INTO worktrees
(worktree_id, project, name, workspace_id, super_root, root_commit, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(worktree_id) DO UPDATE SET
project = excluded.project,
name = excluded.name,
workspace_id = excluded.workspace_id,
super_root = excluded.super_root,
root_commit = excluded.root_commit,
last_seen = excluded.last_seen",
params![
worktree.id.as_str(),
worktree.project,
worktree.name,
worktree.workspace.as_ref().map(WorkspaceId::as_str),
worktree.super_root.display().to_string(),
worktree.root_commit,
worktree.last_seen as i64,
],
)?;
Ok(())
})
}
pub fn forget_worktree(&self, worktree: &wsb::daemon::WorktreeId) -> Result<(), String> {
self.with(|c| {
c.execute(
"DELETE FROM worktrees WHERE worktree_id = ?1",
params![worktree.as_str()],
)?;
Ok(())
})
}
pub fn worktrees(&self) -> Result<Vec<wsb::daemon::RegisteredWorktree>, String> {
let rows = self.with(|c| {
let mut statement = c.prepare(
"SELECT worktree_id, project, name, workspace_id, super_root, root_commit, last_seen
FROM worktrees ORDER BY project, name, worktree_id",
)?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, i64>(6)? as u64,
))
})?;
rows.collect::<Result<Vec<_>, _>>()
})?;
rows.into_iter()
.map(|(id, project, name, workspace, super_root, root_commit, last_seen)| {
let id = wsb::daemon::WorktreeId::parse(&id)
.map_err(|e| format!("machine database, worktrees: {e}"))?;
let workspace = workspace
.map(|raw| WorkspaceId::parse(&raw))
.transpose()
.map_err(|e| format!("machine database, worktree {id}: {e}"))?;
let super_root = std::path::PathBuf::from(super_root);
Ok(wsb::daemon::RegisteredWorktree {
id,
project,
name,
workspace,
available: super_root.is_dir(),
super_root,
root_commit,
last_seen,
})
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictCount {
pub workspace: String,
pub repository: String,
pub peer: String,
pub conflicts: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictRow {
pub repository: String,
pub path: String,
pub here_hash: Option<String>,
pub there_hash: Option<String>,
pub base_hash: Option<String>,
pub detected_at: u64,
}
fn row_to_peer(row: &rusqlite::Row<'_>) -> Result<PairedPeer, rusqlite::Error> {
let id: String = row.get(0)?;
Ok(PairedPeer {
id: PeerId::parse(&id).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, e.into())
})?,
name: row.get(1)?,
public_key: row.get(2)?,
fingerprint: Fingerprint::from_stored(row.get::<_, String>(3)?),
address: row.get(4)?,
paired_at: row.get::<_, i64>(5)? as u64,
})
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;