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,9 +8,10 @@ use crate::models::{EntryRow, SecretFieldRow};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeletedEntry {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -20,34 +21,29 @@ pub struct DeleteResult {
|
||||
}
|
||||
|
||||
pub struct DeleteParams<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: Option<&'a str>,
|
||||
/// If set, delete a single entry by name.
|
||||
pub name: Option<&'a str>,
|
||||
/// Folder filter for bulk delete.
|
||||
pub folder: Option<&'a str>,
|
||||
/// Type filter for bulk delete.
|
||||
pub entry_type: Option<&'a str>,
|
||||
pub dry_run: bool,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
|
||||
match params.name {
|
||||
Some(name) => {
|
||||
let kind = params
|
||||
.kind
|
||||
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
|
||||
delete_one(
|
||||
pool,
|
||||
params.namespace,
|
||||
kind,
|
||||
name,
|
||||
params.dry_run,
|
||||
params.user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await,
|
||||
None => {
|
||||
if params.folder.is_none() && params.entry_type.is_none() {
|
||||
anyhow::bail!(
|
||||
"Bulk delete requires at least one of: name, folder, or type filter."
|
||||
);
|
||||
}
|
||||
delete_bulk(
|
||||
pool,
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
params.dry_run,
|
||||
params.user_id,
|
||||
)
|
||||
@@ -58,93 +54,169 @@ pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult
|
||||
|
||||
async fn delete_one(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
dry_run: bool,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<DeleteResult> {
|
||||
if dry_run {
|
||||
let exists: bool = if let Some(uid) = user_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4)",
|
||||
// Dry-run uses the same disambiguation logic as actual delete:
|
||||
// - 0 matches → nothing to delete
|
||||
// - 1 match → show what would be deleted (with correct folder/type)
|
||||
// - 2+ matches → disambiguation error (same as non-dry-run)
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DryRunRow {
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
}
|
||||
|
||||
let rows: Vec<DryRunRow> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT folder, type FROM entries WHERE user_id = $1 AND folder = $2 AND name = $3",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT folder, type FROM entries WHERE user_id = $1 AND name = $2")
|
||||
.bind(uid)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT folder, type FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3)",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
sqlx::query_as("SELECT folder, type FROM entries WHERE user_id IS NULL AND name = $1")
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let deleted = if exists {
|
||||
vec![DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: kind.to_string(),
|
||||
name: name.to_string(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
return match rows.len() {
|
||||
0 => Ok(DeleteResult {
|
||||
deleted: vec![],
|
||||
dry_run: true,
|
||||
}),
|
||||
1 => {
|
||||
let row = rows.into_iter().next().unwrap();
|
||||
Ok(DeleteResult {
|
||||
deleted: vec![DeletedEntry {
|
||||
name: name.to_string(),
|
||||
folder: row.folder,
|
||||
entry_type: row.entry_type,
|
||||
}],
|
||||
dry_run: true,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
||||
anyhow::bail!(
|
||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
rows.len(),
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
};
|
||||
return Ok(DeleteResult {
|
||||
deleted,
|
||||
dry_run: true,
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryRow> = if let Some(uid) = user_id {
|
||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
||||
let rows: Vec<EntryRow> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
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",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_all(&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",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let Some(row) = row else {
|
||||
tx.rollback().await?;
|
||||
return Ok(DeleteResult {
|
||||
deleted: vec![],
|
||||
dry_run: false,
|
||||
});
|
||||
let row = match rows.len() {
|
||||
0 => {
|
||||
tx.rollback().await?;
|
||||
return Ok(DeleteResult {
|
||||
deleted: vec![],
|
||||
dry_run: false,
|
||||
});
|
||||
}
|
||||
1 => rows.into_iter().next().unwrap(),
|
||||
_ => {
|
||||
tx.rollback().await?;
|
||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
||||
anyhow::bail!(
|
||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
rows.len(),
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?;
|
||||
crate::audit::log_tx(&mut tx, user_id, "delete", namespace, kind, name, json!({})).await;
|
||||
let folder = row.folder.clone();
|
||||
let entry_type = row.entry_type.clone();
|
||||
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
user_id,
|
||||
"delete",
|
||||
&folder,
|
||||
&entry_type,
|
||||
name,
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(DeleteResult {
|
||||
deleted: vec![DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: kind.to_string(),
|
||||
name: name.to_string(),
|
||||
folder,
|
||||
entry_type,
|
||||
}],
|
||||
dry_run: false,
|
||||
})
|
||||
@@ -152,8 +224,8 @@ async fn delete_one(
|
||||
|
||||
async fn delete_bulk(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: Option<&str>,
|
||||
folder: Option<&str>,
|
||||
entry_type: Option<&str>,
|
||||
dry_run: bool,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<DeleteResult> {
|
||||
@@ -161,62 +233,57 @@ async fn delete_bulk(
|
||||
struct FullEntryRow {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
kind: String,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
metadata: serde_json::Value,
|
||||
tags: Vec<String>,
|
||||
notes: String,
|
||||
}
|
||||
|
||||
let rows: Vec<FullEntryRow> = match (user_id, kind) {
|
||||
(Some(uid), Some(k)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 AND kind = $3 ORDER BY name",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.bind(k)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
(Some(uid), None) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id = $1 AND namespace = $2 ORDER BY kind, name",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(namespace)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
(None, Some(k)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 ORDER BY name",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(k)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
(None, None) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, kind, name, metadata, tags FROM entries \
|
||||
WHERE user_id IS NULL AND namespace = $1 ORDER BY kind, name",
|
||||
)
|
||||
.bind(namespace)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut idx: i32 = 1;
|
||||
|
||||
if user_id.is_some() {
|
||||
conditions.push(format!("user_id = ${}", idx));
|
||||
idx += 1;
|
||||
} else {
|
||||
conditions.push("user_id IS NULL".to_string());
|
||||
}
|
||||
if folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if entry_type.is_some() {
|
||||
conditions.push(format!("type = ${}", idx));
|
||||
}
|
||||
|
||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||
FROM entries {where_clause} ORDER BY type, name"
|
||||
);
|
||||
|
||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
if let Some(t) = entry_type {
|
||||
q = q.bind(t);
|
||||
}
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
if dry_run {
|
||||
let deleted = rows
|
||||
.iter()
|
||||
.map(|r| DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: r.kind.clone(),
|
||||
name: r.name.clone(),
|
||||
folder: r.folder.clone(),
|
||||
entry_type: r.entry_type.clone(),
|
||||
})
|
||||
.collect();
|
||||
return Ok(DeleteResult {
|
||||
@@ -230,29 +297,37 @@ async fn delete_bulk(
|
||||
let entry_row = EntryRow {
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
folder: row.folder.clone(),
|
||||
entry_type: row.entry_type.clone(),
|
||||
tags: row.tags.clone(),
|
||||
metadata: row.metadata.clone(),
|
||||
notes: row.notes.clone(),
|
||||
};
|
||||
let mut tx = pool.begin().await?;
|
||||
snapshot_and_delete(
|
||||
&mut tx, namespace, &row.kind, &row.name, &entry_row, user_id,
|
||||
&mut tx,
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
&row.name,
|
||||
&entry_row,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
user_id,
|
||||
"delete",
|
||||
namespace,
|
||||
&row.kind,
|
||||
&row.folder,
|
||||
&row.entry_type,
|
||||
&row.name,
|
||||
json!({"bulk": true}),
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
deleted.push(DeletedEntry {
|
||||
namespace: namespace.to_string(),
|
||||
kind: row.kind.clone(),
|
||||
name: row.name.clone(),
|
||||
folder: row.folder.clone(),
|
||||
entry_type: row.entry_type.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,8 +339,8 @@ async fn delete_bulk(
|
||||
|
||||
async fn snapshot_and_delete(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
folder: &str,
|
||||
entry_type: &str,
|
||||
name: &str,
|
||||
row: &EntryRow,
|
||||
user_id: Option<Uuid>,
|
||||
@@ -275,8 +350,8 @@ async fn snapshot_and_delete(
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
user_id,
|
||||
namespace,
|
||||
kind,
|
||||
folder,
|
||||
entry_type,
|
||||
name,
|
||||
version: row.version,
|
||||
action: "delete",
|
||||
|
||||
Reference in New Issue
Block a user