Release secrets-mcp 0.3.0: folder/type schema and MCP folder disambiguation
- Rename namespace/kind to folder/type on entries, audit_log, and history tables; add notes. Unique key is (user_id, folder, name). - Service layer and MCP tools support name-first lookup with optional folder when multiple entries share the same name. - secrets_delete dry_run uses the same disambiguation as real deletes. - Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and AGENTS.md. Made-with: Cursor
This commit is contained in:
@@ -8,17 +8,19 @@ use crate::db;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct RollbackResult {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub restored_version: i64,
|
||||
}
|
||||
|
||||
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
|
||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
to_version: Option<i64>,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
@@ -26,69 +28,122 @@ pub async fn run(
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryHistoryRow {
|
||||
entry_id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
version: i64,
|
||||
action: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
}
|
||||
|
||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||
if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||
AND user_id = $5 ORDER BY id DESC LIMIT 1",
|
||||
// Disambiguate: find the unique entry_id for (name, folder).
|
||||
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
|
||||
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(ver)
|
||||
.bind(f)
|
||||
.bind(uid)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(ver)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.bind(uid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
match ids.len() {
|
||||
0 => None,
|
||||
1 => Some(ids[0]),
|
||||
_ => {
|
||||
let folders: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT folder FROM entries_history \
|
||||
WHERE name = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
anyhow::bail!(
|
||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
AND user_id = $4 ORDER BY id DESC LIMIT 1",
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT entry_id FROM entries_history \
|
||||
WHERE name = $1 AND user_id IS NULL",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
match ids.len() {
|
||||
0 => None,
|
||||
1 => Some(ids[0]),
|
||||
_ => {
|
||||
let folders: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT folder FROM entries_history \
|
||||
WHERE name = $1 AND user_id IS NULL",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
anyhow::bail!(
|
||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
|
||||
|
||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, folder, type, version, action, tags, metadata \
|
||||
FROM entries_history \
|
||||
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(ver)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
|
||||
"SELECT entry_id, folder, type, version, action, tags, metadata \
|
||||
FROM entries_history \
|
||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(entry_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let snap = snap.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No history found for [{}/{}] {}{}.",
|
||||
namespace,
|
||||
kind,
|
||||
"No history found for '{}'{}.",
|
||||
name,
|
||||
to_version
|
||||
.map(|v| format!(" at version {}", v))
|
||||
@@ -130,43 +185,32 @@ pub async fn run(
|
||||
struct LiveEntry {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
#[allow(dead_code)]
|
||||
notes: String,
|
||||
}
|
||||
|
||||
// Query live entry with correct user_id scoping to avoid PK conflicts
|
||||
let live: Option<LiveEntry> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
// Lock the live entry if it exists (matched by entry_id for precision).
|
||||
let live: Option<LiveEntry> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let entry_id = if let Some(ref lr) = live {
|
||||
// Snapshot current state before overwriting
|
||||
let live_entry_id = if let Some(ref lr) = live {
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: lr.id,
|
||||
user_id,
|
||||
namespace,
|
||||
kind,
|
||||
folder: &lr.folder,
|
||||
entry_type: &lr.entry_type,
|
||||
name,
|
||||
version: lr.version,
|
||||
action: "rollback",
|
||||
@@ -209,7 +253,6 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// Update the existing row in-place to preserve its primary key and user_id
|
||||
sqlx::query(
|
||||
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
|
||||
updated_at = NOW() WHERE id = $3",
|
||||
@@ -222,16 +265,15 @@ pub async fn run(
|
||||
|
||||
lr.id
|
||||
} else {
|
||||
// No live entry — insert a fresh one with a new UUID
|
||||
if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(user_id, namespace, kind, name, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id",
|
||||
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
@@ -241,11 +283,11 @@ pub async fn run(
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO entries \
|
||||
(namespace, kind, name, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id",
|
||||
(folder, type, name, notes, tags, metadata, version, updated_at) \
|
||||
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(&snap.folder)
|
||||
.bind(&snap.entry_type)
|
||||
.bind(name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
@@ -256,7 +298,7 @@ pub async fn run(
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
|
||||
.bind(entry_id)
|
||||
.bind(live_entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -265,7 +307,7 @@ pub async fn run(
|
||||
continue;
|
||||
}
|
||||
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
|
||||
.bind(entry_id)
|
||||
.bind(live_entry_id)
|
||||
.bind(&f.field_name)
|
||||
.bind(&f.encrypted)
|
||||
.execute(&mut *tx)
|
||||
@@ -276,8 +318,8 @@ pub async fn run(
|
||||
&mut tx,
|
||||
user_id,
|
||||
"rollback",
|
||||
namespace,
|
||||
kind,
|
||||
&snap.folder,
|
||||
&snap.entry_type,
|
||||
name,
|
||||
serde_json::json!({
|
||||
"restored_version": snap.version,
|
||||
@@ -289,9 +331,9 @@ pub async fn run(
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(RollbackResult {
|
||||
namespace: namespace.to_string(),
|
||||
kind: kind.to_string(),
|
||||
name: name.to_string(),
|
||||
folder: snap.folder,
|
||||
entry_type: snap.entry_type,
|
||||
restored_version: snap.version,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user