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:
@@ -159,18 +159,20 @@ pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)>
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct AddResult {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub tags: Vec<String>,
|
||||
pub meta_keys: Vec<String>,
|
||||
pub secret_keys: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct AddParams<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub name: &'a str,
|
||||
pub folder: &'a str,
|
||||
pub entry_type: &'a str,
|
||||
pub notes: &'a str,
|
||||
pub tags: &'a [String],
|
||||
pub meta_entries: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
@@ -186,25 +188,23 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch existing entry (user-scoped or global depending on user_id)
|
||||
// Fetch existing entry by (user_id, folder, name) — the natural unique key
|
||||
let existing: Option<EntryRow> = if let Some(uid) = params.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",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.folder)
|
||||
.bind(params.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",
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
||||
)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.folder)
|
||||
.bind(params.name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
@@ -216,8 +216,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: ex.id,
|
||||
user_id: params.user_id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
folder: params.folder,
|
||||
entry_type: params.entry_type,
|
||||
name: params.name,
|
||||
version: ex.version,
|
||||
action: "add",
|
||||
@@ -232,10 +232,13 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
|
||||
let entry_id: Uuid = if let Some(uid) = params.user_id {
|
||||
sqlx::query_scalar(
|
||||
r#"INSERT INTO entries (user_id, namespace, kind, name, tags, metadata, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
|
||||
ON CONFLICT (user_id, namespace, kind, name) WHERE user_id IS NOT NULL
|
||||
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW())
|
||||
ON CONFLICT (user_id, folder, name) WHERE user_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
folder = EXCLUDED.folder,
|
||||
type = EXCLUDED.type,
|
||||
notes = EXCLUDED.notes,
|
||||
tags = EXCLUDED.tags,
|
||||
metadata = EXCLUDED.metadata,
|
||||
version = entries.version + 1,
|
||||
@@ -243,28 +246,33 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
.bind(&metadata)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
r#"INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 1, NOW())
|
||||
ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL
|
||||
r#"INSERT INTO entries (folder, type, name, notes, tags, metadata, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
|
||||
ON CONFLICT (folder, name) WHERE user_id IS NULL
|
||||
DO UPDATE SET
|
||||
folder = EXCLUDED.folder,
|
||||
type = EXCLUDED.type,
|
||||
notes = EXCLUDED.notes,
|
||||
tags = EXCLUDED.tags,
|
||||
metadata = EXCLUDED.metadata,
|
||||
version = entries.version + 1,
|
||||
updated_at = NOW()
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
.bind(&metadata)
|
||||
.fetch_one(&mut *tx)
|
||||
@@ -282,8 +290,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
db::EntrySnapshotParams {
|
||||
entry_id,
|
||||
user_id: params.user_id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
folder: params.folder,
|
||||
entry_type: params.entry_type,
|
||||
name: params.name,
|
||||
version: new_entry_version,
|
||||
action: "create",
|
||||
@@ -348,8 +356,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
&mut tx,
|
||||
params.user_id,
|
||||
"add",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"tags": params.tags,
|
||||
@@ -362,9 +370,9 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(AddResult {
|
||||
namespace: params.namespace.to_string(),
|
||||
kind: params.kind.to_string(),
|
||||
name: params.name.to_string(),
|
||||
folder: params.folder.to_string(),
|
||||
entry_type: params.entry_type.to_string(),
|
||||
tags: params.tags.to_vec(),
|
||||
meta_keys,
|
||||
secret_keys,
|
||||
|
||||
@@ -8,7 +8,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<V
|
||||
let limit = limit.clamp(1, 200);
|
||||
|
||||
let rows = sqlx::query_as(
|
||||
"SELECT id, user_id, action, namespace, kind, name, detail, created_at \
|
||||
"SELECT id, user_id, action, folder, type, name, detail, created_at \
|
||||
FROM audit_log \
|
||||
WHERE user_id = $1 \
|
||||
ORDER BY created_at DESC, id DESC \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_env_map(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
folder: Option<&str>,
|
||||
entry_type: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
only_fields: &[String],
|
||||
@@ -21,7 +21,7 @@ pub async fn build_env_map(
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entries = fetch_entries(pool, namespace, kind, name, tags, None, user_id).await?;
|
||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
|
||||
|
||||
let mut combined: HashMap<String, String> = HashMap::new();
|
||||
|
||||
@@ -68,16 +68,8 @@ async fn build_entry_env_map(
|
||||
|
||||
// Resolve key_ref
|
||||
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
|
||||
let key_entries = fetch_entries(
|
||||
pool,
|
||||
Some(&entry.namespace),
|
||||
Some("key"),
|
||||
Some(key_ref),
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let key_entries =
|
||||
fetch_entries(pool, None, Some("key"), Some(key_ref), &[], None, None).await?;
|
||||
|
||||
if let Some(key_entry) = key_entries.first() {
|
||||
let key_ids = vec![key_entry.id];
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::models::{ExportData, ExportEntry, ExportFormat};
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
|
||||
pub struct ExportParams<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub folder: Option<&'a str>,
|
||||
pub entry_type: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
@@ -25,8 +25,8 @@ pub async fn export(
|
||||
) -> Result<ExportData> {
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
params.name,
|
||||
params.tags,
|
||||
params.query,
|
||||
@@ -62,9 +62,10 @@ pub async fn export(
|
||||
};
|
||||
|
||||
export_entries.push(ExportEntry {
|
||||
namespace: entry.namespace.clone(),
|
||||
kind: entry.kind.clone(),
|
||||
name: entry.name.clone(),
|
||||
folder: entry.folder.clone(),
|
||||
entry_type: entry.entry_type.clone(),
|
||||
notes: entry.notes.clone(),
|
||||
tags: entry.tags.clone(),
|
||||
metadata: entry.metadata.clone(),
|
||||
secrets,
|
||||
|
||||
@@ -5,31 +5,19 @@ use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
use crate::service::search::{fetch_secrets_for_entries, resolve_entry};
|
||||
|
||||
/// Decrypt a single named field from an entry.
|
||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
||||
pub async fn get_secret_field(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
field_name: &str,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Value> {
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
Some(namespace),
|
||||
Some(kind),
|
||||
Some(name),
|
||||
&[],
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
let entry = entries
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
|
||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
||||
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
@@ -44,27 +32,15 @@ pub async fn get_secret_field(
|
||||
}
|
||||
|
||||
/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value.
|
||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
||||
pub async fn get_all_secrets(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let entries = fetch_entries(
|
||||
pool,
|
||||
Some(namespace),
|
||||
Some(kind),
|
||||
Some(name),
|
||||
&[],
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
let entry = entries
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
|
||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
||||
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::service::search::resolve_entry;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub version: i64,
|
||||
@@ -10,11 +12,12 @@ pub struct HistoryEntry {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Return version history for the entry identified by `name`.
|
||||
/// `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>,
|
||||
limit: u32,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Vec<HistoryEntry>> {
|
||||
@@ -25,32 +28,16 @@ pub async fn run(
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = if let Some(uid) = user_id {
|
||||
sqlx::query_as(
|
||||
"SELECT version, action, created_at FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
|
||||
ORDER BY id DESC LIMIT $5",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(uid)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT version, action, created_at FROM entries_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
|
||||
ORDER BY id DESC LIMIT $4",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
||||
|
||||
let rows: Vec<Row> = sqlx::query_as(
|
||||
"SELECT version, action, created_at FROM entries_history \
|
||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2",
|
||||
)
|
||||
.bind(entry.id)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -64,12 +51,11 @@ pub async fn run(
|
||||
|
||||
pub async fn run_json(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
limit: u32,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Value> {
|
||||
let entries = run(pool, namespace, kind, name, limit, user_id).await?;
|
||||
let entries = run(pool, name, folder, limit, user_id).await?;
|
||||
Ok(serde_json::to_value(entries)?)
|
||||
}
|
||||
|
||||
@@ -47,10 +47,9 @@ pub async fn run(
|
||||
for entry in &data.entries {
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NOT DISTINCT FROM $4)",
|
||||
WHERE folder = $1 AND name = $2 AND user_id IS NOT DISTINCT FROM $3)",
|
||||
)
|
||||
.bind(&entry.namespace)
|
||||
.bind(&entry.kind)
|
||||
.bind(&entry.folder)
|
||||
.bind(&entry.name)
|
||||
.bind(params.user_id)
|
||||
.fetch_one(pool)
|
||||
@@ -59,9 +58,7 @@ pub async fn run(
|
||||
|
||||
if exists && !params.force {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Import aborted: conflict on [{}/{}/{}]",
|
||||
entry.namespace,
|
||||
entry.kind,
|
||||
"Import aborted: conflict on '{}'",
|
||||
entry.name
|
||||
));
|
||||
}
|
||||
@@ -81,9 +78,10 @@ pub async fn run(
|
||||
match add_run(
|
||||
pool,
|
||||
AddParams {
|
||||
namespace: &entry.namespace,
|
||||
kind: &entry.kind,
|
||||
name: &entry.name,
|
||||
folder: &entry.folder,
|
||||
entry_type: &entry.entry_type,
|
||||
notes: &entry.notes,
|
||||
tags: &entry.tags,
|
||||
meta_entries: &meta_entries,
|
||||
secret_entries: &secret_entries,
|
||||
@@ -98,8 +96,6 @@ pub async fn run(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
namespace = entry.namespace,
|
||||
kind = entry.kind,
|
||||
name = entry.name,
|
||||
error = %e,
|
||||
"failed to import entry"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::models::{Entry, SecretField};
|
||||
pub const FETCH_ALL_LIMIT: u32 = 100_000;
|
||||
|
||||
pub struct SearchParams<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub folder: Option<&'a str>,
|
||||
pub entry_type: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
@@ -44,16 +44,16 @@ pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult
|
||||
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
|
||||
pub async fn fetch_entries(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
folder: Option<&str>,
|
||||
entry_type: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Vec<Entry>> {
|
||||
let params = SearchParams {
|
||||
namespace,
|
||||
kind,
|
||||
folder,
|
||||
entry_type,
|
||||
name,
|
||||
tags,
|
||||
query,
|
||||
@@ -77,12 +77,12 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
conditions.push("user_id IS NULL".to_string());
|
||||
}
|
||||
|
||||
if a.namespace.is_some() {
|
||||
conditions.push(format!("namespace = ${}", idx));
|
||||
if a.folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if a.kind.is_some() {
|
||||
conditions.push(format!("kind = ${}", idx));
|
||||
if a.entry_type.is_some() {
|
||||
conditions.push(format!("type = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if a.name.is_some() {
|
||||
@@ -106,8 +106,9 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
}
|
||||
if a.query.is_some() {
|
||||
conditions.push(format!(
|
||||
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' \
|
||||
OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' \
|
||||
"(name ILIKE ${i} ESCAPE '\\' OR folder ILIKE ${i} ESCAPE '\\' \
|
||||
OR type ILIKE ${i} ESCAPE '\\' OR notes ILIKE ${i} ESCAPE '\\' \
|
||||
OR metadata::text ILIKE ${i} ESCAPE '\\' \
|
||||
OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
|
||||
i = idx
|
||||
));
|
||||
@@ -131,8 +132,8 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, user_id, \
|
||||
namespace, kind, name, tags, metadata, version, created_at, updated_at \
|
||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||
created_at, updated_at \
|
||||
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||
);
|
||||
|
||||
@@ -141,10 +142,10 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
if let Some(uid) = a.user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(v) = a.namespace {
|
||||
if let Some(v) = a.folder {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = a.kind {
|
||||
if let Some(v) = a.entry_type {
|
||||
q = q.bind(v);
|
||||
}
|
||||
if let Some(v) = a.name {
|
||||
@@ -207,15 +208,51 @@ pub async fn fetch_secrets_for_entries(
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
|
||||
/// Resolve exactly one entry by name, with optional folder for disambiguation.
|
||||
///
|
||||
/// - If `folder` is provided: exact `(folder, name)` match.
|
||||
/// - If `folder` is None and exactly one entry matches: returns it.
|
||||
/// - If `folder` is None and multiple entries match: returns an error listing
|
||||
/// the folders and asking the caller to specify one.
|
||||
pub async fn resolve_entry(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<crate::models::Entry> {
|
||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
|
||||
match entries.len() {
|
||||
0 => {
|
||||
if let Some(f) = folder {
|
||||
anyhow::bail!("Not found: '{}' in folder '{}'", name, f)
|
||||
} else {
|
||||
anyhow::bail!("Not found: '{}'", name)
|
||||
}
|
||||
}
|
||||
1 => Ok(entries.into_iter().next().unwrap()),
|
||||
_ => {
|
||||
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
|
||||
anyhow::bail!(
|
||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
||||
Specify 'folder' to disambiguate.",
|
||||
entries.len(),
|
||||
name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryRaw {
|
||||
id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
namespace: String,
|
||||
kind: String,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
notes: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
version: i64,
|
||||
@@ -228,9 +265,10 @@ impl From<EntryRaw> for Entry {
|
||||
Entry {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
namespace: r.namespace,
|
||||
kind: r.kind,
|
||||
folder: r.folder,
|
||||
entry_type: r.entry_type,
|
||||
name: r.name,
|
||||
notes: r.notes,
|
||||
tags: r.tags,
|
||||
metadata: r.metadata,
|
||||
version: r.version,
|
||||
|
||||
@@ -13,9 +13,10 @@ use crate::service::add::{
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UpdateResult {
|
||||
pub namespace: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub folder: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub add_tags: Vec<String>,
|
||||
pub remove_tags: Vec<String>,
|
||||
pub meta_keys: Vec<String>,
|
||||
@@ -25,9 +26,10 @@ pub struct UpdateResult {
|
||||
}
|
||||
|
||||
pub struct UpdateParams<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub name: &'a str,
|
||||
/// Optional folder for disambiguation when multiple entries share the same name.
|
||||
pub folder: Option<&'a str>,
|
||||
pub notes: Option<&'a str>,
|
||||
pub add_tags: &'a [String],
|
||||
pub remove_tags: &'a [String],
|
||||
pub meta_entries: &'a [String],
|
||||
@@ -44,45 +46,76 @@ pub async fn run(
|
||||
) -> Result<UpdateResult> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<EntryRow> = if let Some(uid) = params.user_id {
|
||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
||||
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
|
||||
if let Some(folder) = params.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(folder)
|
||||
.bind(params.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(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(folder) = params.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(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(folder)
|
||||
.bind(params.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(params.namespace)
|
||||
.bind(params.kind)
|
||||
.bind(params.name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
|
||||
let row = row.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Not found: [{}/{}] {}. Use `add` to create it first.",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
params.name
|
||||
)
|
||||
})?;
|
||||
let row = match rows.len() {
|
||||
0 => {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Not found: '{}'. Use `add` to create it first.",
|
||||
params.name
|
||||
)
|
||||
}
|
||||
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(),
|
||||
params.name,
|
||||
folders.join(", ")
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = db::snapshot_entry_history(
|
||||
&mut tx,
|
||||
db::EntrySnapshotParams {
|
||||
entry_id: row.id,
|
||||
user_id: params.user_id,
|
||||
namespace: params.namespace,
|
||||
kind: params.kind,
|
||||
folder: &row.folder,
|
||||
entry_type: &row.entry_type,
|
||||
name: params.name,
|
||||
version: row.version,
|
||||
action: "update",
|
||||
@@ -117,12 +150,16 @@ pub async fn run(
|
||||
}
|
||||
let metadata = Value::Object(meta_map);
|
||||
|
||||
let new_notes = params.notes.unwrap_or(&row.notes);
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $3 AND version = $4",
|
||||
"UPDATE entries SET tags = $1, metadata = $2, notes = $3, \
|
||||
version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $4 AND version = $5",
|
||||
)
|
||||
.bind(&tags)
|
||||
.bind(&metadata)
|
||||
.bind(new_notes)
|
||||
.bind(row.id)
|
||||
.bind(row.version)
|
||||
.execute(&mut *tx)
|
||||
@@ -131,9 +168,7 @@ pub async fn run(
|
||||
if result.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Concurrent modification detected for [{}/{}] {}. Please retry.",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
"Concurrent modification detected for '{}'. Please retry.",
|
||||
params.name
|
||||
);
|
||||
}
|
||||
@@ -243,8 +278,8 @@ pub async fn run(
|
||||
&mut tx,
|
||||
params.user_id,
|
||||
"update",
|
||||
params.namespace,
|
||||
params.kind,
|
||||
"",
|
||||
"",
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"add_tags": params.add_tags,
|
||||
@@ -260,9 +295,9 @@ pub async fn run(
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
namespace: params.namespace.to_string(),
|
||||
kind: params.kind.to_string(),
|
||||
name: params.name.to_string(),
|
||||
folder: row.folder.clone(),
|
||||
entry_type: row.entry_type.clone(),
|
||||
add_tags: params.add_tags.to_vec(),
|
||||
remove_tags: params.remove_tags.to_vec(),
|
||||
meta_keys,
|
||||
|
||||
Reference in New Issue
Block a user