Enforce unique MCP proposal identities
This commit is contained in:
@@ -62,6 +62,15 @@ impl DbConnection {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn insert_legacy_null_mcp_proposal_for_test(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute(
|
||||
"INSERT INTO mcp_proposals \
|
||||
(id, project_id, kind, status, entity_id, data, created_at, expires_at) \
|
||||
VALUES ('null-entity', 'p1', 'draft_post', 'pending', NULL, '{}', 3, 99)",
|
||||
)
|
||||
}
|
||||
|
||||
/// Filesystem database path for sibling surfaces that must open their own
|
||||
/// short-lived connection (gallery workers, preview servers, Lua hosts).
|
||||
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
|
||||
|
||||
@@ -36,7 +36,60 @@ mod tests {
|
||||
let applied = db
|
||||
.conn()
|
||||
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
|
||||
assert_eq!(applied, 8);
|
||||
assert_eq!(applied, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_proposal_upgrade_normalizes_entities_and_enforces_uniqueness() {
|
||||
use crate::db::queries::{mcp_proposal, project};
|
||||
use crate::model::{McpProposal, ProposalKind, ProposalStatus};
|
||||
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.conn().with_migrations(|conn| {
|
||||
for _ in 0..8 {
|
||||
conn.run_next_migration(MIGRATIONS).unwrap();
|
||||
}
|
||||
});
|
||||
project::insert_project(db.conn(), &project::make_test_project("p1", "blog")).unwrap();
|
||||
|
||||
let targeted = |id: &str, created_at| McpProposal {
|
||||
id: id.into(),
|
||||
project_id: "p1".into(),
|
||||
kind: ProposalKind::ProposePostMetadata,
|
||||
status: ProposalStatus::Pending,
|
||||
entity_id: "post-1".into(),
|
||||
data: "{}".into(),
|
||||
result: None,
|
||||
created_at,
|
||||
expires_at: 99,
|
||||
resolved_at: None,
|
||||
};
|
||||
mcp_proposal::insert_proposal(db.conn(), &targeted("older", 1)).unwrap();
|
||||
mcp_proposal::insert_proposal(db.conn(), &targeted("newer", 2)).unwrap();
|
||||
db.conn()
|
||||
.insert_legacy_null_mcp_proposal_for_test()
|
||||
.unwrap();
|
||||
|
||||
run_migrations(db.conn()).unwrap();
|
||||
|
||||
let proposals = mcp_proposal::list_proposals(db.conn(), "p1").unwrap();
|
||||
assert_eq!(proposals.len(), 2);
|
||||
assert!(proposals.iter().any(|proposal| proposal.id == "newer"));
|
||||
assert_eq!(
|
||||
proposals
|
||||
.iter()
|
||||
.find(|proposal| proposal.id == "null-entity")
|
||||
.unwrap()
|
||||
.entity_id,
|
||||
"null-entity"
|
||||
);
|
||||
assert!(matches!(
|
||||
mcp_proposal::insert_proposal(db.conn(), &targeted("duplicate", 4)),
|
||||
Err(diesel::result::Error::DatabaseError(
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
||||
_
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -48,6 +48,21 @@ pub fn list_pending_proposals(
|
||||
|
||||
pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
let expiring = mcp_proposals::table
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
||||
.filter(mcp_proposals::expires_at.le(now))
|
||||
.select(McpProposal::as_select())
|
||||
.load::<McpProposal>(connection)?;
|
||||
for proposal in &expiring {
|
||||
diesel::delete(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.ne(&proposal.id))
|
||||
.filter(mcp_proposals::kind.eq(proposal.kind))
|
||||
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Expired)),
|
||||
)
|
||||
.execute(connection)?;
|
||||
}
|
||||
diesel::update(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
||||
@@ -62,6 +77,23 @@ pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_status_collision(
|
||||
conn: &DbConnection,
|
||||
proposal: &McpProposal,
|
||||
status: ProposalStatus,
|
||||
) -> QueryResult<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::delete(
|
||||
mcp_proposals::table
|
||||
.filter(mcp_proposals::id.ne(&proposal.id))
|
||||
.filter(mcp_proposals::kind.eq(proposal.kind))
|
||||
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
|
||||
.filter(mcp_proposals::status.eq(status)),
|
||||
)
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn claim_pending(conn: &DbConnection, id: &str, now: i64) -> QueryResult<bool> {
|
||||
conn.with(|connection| {
|
||||
diesel::update(
|
||||
@@ -123,7 +155,7 @@ mod tests {
|
||||
project_id: "p1".into(),
|
||||
kind: ProposalKind::DraftPost,
|
||||
status: ProposalStatus::Pending,
|
||||
entity_id: None,
|
||||
entity_id: id.into(),
|
||||
data: "{}".into(),
|
||||
result: None,
|
||||
created_at: 1,
|
||||
|
||||
@@ -161,7 +161,7 @@ diesel::table! {
|
||||
project_id -> Text,
|
||||
kind -> Text,
|
||||
status -> Text,
|
||||
entity_id -> Nullable<Text>,
|
||||
entity_id -> Text,
|
||||
data -> Text,
|
||||
result -> Nullable<Text>,
|
||||
created_at -> BigInt,
|
||||
|
||||
@@ -98,7 +98,17 @@ pub fn list_pending_proposals(
|
||||
}
|
||||
|
||||
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
|
||||
let expired = proposal_q::expire_pending(conn, now_unix_ms())?;
|
||||
conn.begin_savepoint()?;
|
||||
let expired = match proposal_q::expire_pending(conn, now_unix_ms()) {
|
||||
Ok(expired) => {
|
||||
conn.release_savepoint()?;
|
||||
expired
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if expired > 0 {
|
||||
notify_proposals_changed();
|
||||
}
|
||||
@@ -114,19 +124,37 @@ pub(crate) fn create_proposal(
|
||||
) -> EngineResult<McpProposal> {
|
||||
expire_proposals(conn)?;
|
||||
let now = now_unix_ms();
|
||||
let entity_id = entity_id
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let proposal = McpProposal {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
kind,
|
||||
status: ProposalStatus::Pending,
|
||||
entity_id: entity_id.map(str::to_string),
|
||||
entity_id,
|
||||
data: serde_json::to_string(data)?,
|
||||
result: None,
|
||||
created_at: now,
|
||||
expires_at: now + PROPOSAL_TTL_MS,
|
||||
resolved_at: None,
|
||||
};
|
||||
proposal_q::insert_proposal(conn, &proposal)?;
|
||||
if let Err(error) = proposal_q::insert_proposal(conn, &proposal) {
|
||||
if matches!(
|
||||
error,
|
||||
diesel::result::Error::DatabaseError(
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
||||
_
|
||||
)
|
||||
) {
|
||||
return Err(EngineError::Conflict(format!(
|
||||
"proposal already pending for {} entity {}",
|
||||
proposal.kind.as_str(),
|
||||
proposal.entity_id
|
||||
)));
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
cli_sync::record_cli_event(
|
||||
conn,
|
||||
&DomainEvent::SettingsChanged {
|
||||
@@ -182,6 +210,7 @@ fn resolve_proposal(
|
||||
} else {
|
||||
ProposalStatus::Rejected
|
||||
};
|
||||
proposal_q::delete_status_collision(conn, &proposal, status)?;
|
||||
if !proposal_q::resolve_claimed(
|
||||
conn,
|
||||
proposal_id,
|
||||
|
||||
@@ -119,7 +119,7 @@ pub struct McpProposal {
|
||||
pub project_id: String,
|
||||
pub kind: ProposalKind,
|
||||
pub status: ProposalStatus,
|
||||
pub entity_id: Option<String>,
|
||||
pub entity_id: String,
|
||||
pub data: String,
|
||||
pub result: Option<String>,
|
||||
pub created_at: i64,
|
||||
|
||||
Reference in New Issue
Block a user