cob: Add CobAction::produces_identifier and validation

Transactions will now fail/deny when adding multiple actions that
produce an identifier.
This commit is contained in:
Lorenz Leutgeb 2025-04-28 08:46:20 +02:00
parent e4d23fe56a
commit f30760d6bb
No known key found for this signature in database
5 changed files with 94 additions and 7 deletions

View File

@ -77,7 +77,11 @@ pub enum Action {
RevisionRedact { revision: RevisionId },
}
impl CobAction for Action {}
impl CobAction for Action {
fn produces_identifier(&self) -> bool {
matches!(self, Self::Revision { .. })
}
}
/// Error applying an operation onto a state.
#[derive(Error, Debug)]

View File

@ -935,7 +935,11 @@ pub enum Action {
},
}
impl CobAction for Action {}
impl CobAction for Action {
fn produces_identifier(&self) -> bool {
matches!(self, Self::Comment { .. })
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
@ -946,8 +950,8 @@ mod test {
use crate::cob::{store::CobWithType, ActorId, Reaction};
use crate::git::Oid;
use crate::issue::cache::Issues as _;
use crate::test;
use crate::test::arbitrary;
use crate::{assert_matches, test};
#[test]
fn test_concurrency() {
@ -1688,6 +1692,29 @@ mod test {
assert_eq!(issue.comments().count(), 1);
}
#[test]
fn test_invalid_tx_reference() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Cache::no_cache(&*repo).unwrap();
let issue = issues
.create(
"My first issue",
"Blah blah blah.",
&[],
&[],
[],
&node.signer,
)
.unwrap();
// Comments require references, so adding two of them to the same transaction errors.
let mut tx: Transaction<Issue, test::storage::git::Repository> =
Transaction::<Issue, _>::default();
tx.comment("First reply", *issue.id, vec![]).unwrap();
let err = tx.comment("Second reply", *issue.id, vec![]).unwrap_err();
assert_matches!(err, cob::store::Error::ClashingIdentifiers(_, _));
}
#[test]
fn test_invalid_cob() {
use crate::crypto::test::signer::MockSigner;

View File

@ -320,6 +320,16 @@ impl CobAction for Action {
_ => vec![],
}
}
fn produces_identifier(&self) -> bool {
matches!(
self,
Self::Revision { .. }
| Self::RevisionComment { .. }
| Self::Review { .. }
| Self::ReviewComment { .. }
)
}
}
/// Output of a merge.

View File

@ -22,6 +22,23 @@ pub trait CobAction: Debug {
fn parents(&self) -> Vec<git::Oid> {
Vec::new()
}
/// The outcome of some actions are to be referred later.
/// For example, one action may create a comment, followed by another
/// action that may create a reply to the comment, referring to it.
/// Since actions are stored as part of [`crate::cob::op::Op`],
/// and operations are the smallest identifiable units,
/// this may lead to ambiguity.
/// It would not be possible to to, say, address one particular comment out
/// of two, if the corresponding actions of creations were part of the
/// same operation.
/// To help avoid this, implementations signal whether specific actions
/// require "their own" identifier.
/// This allows checking for multiple such actions before creating an
/// operation.
fn produces_identifier(&self) -> bool {
false
}
}
/// A collaborative object. Can be materialized from an operation history.
@ -111,6 +128,8 @@ pub enum Error {
#[source]
err: git::raw::Error,
},
#[error("transaction already contains action {0} which produces an identifier, denying to add action {1} which also produces an identifier")]
ClashingIdentifiers(String, String),
}
/// Storage for collaborative objects of a specific type `T` in a single repository.
@ -340,6 +359,13 @@ pub struct Transaction<T: Cob + cob::Evaluate<R>, R> {
actions: Vec<T::Action>,
embeds: Vec<Embed<Uri>>,
// Internal state kept for validation of the transaction.
// If an action that produces an identifier is added to
// the transaction, then this will track its index,
// so that adding a second action that produces an identifier
// can fail with a useful error.
produces_identifier: Option<usize>,
repo: PhantomData<R>,
type_name: TypeName,
}
@ -349,6 +375,7 @@ impl<T: Cob + CobWithType + cob::Evaluate<R>, R> Default for Transaction<T, R> {
Self {
actions: Vec::new(),
embeds: Vec::new(),
produces_identifier: None,
repo: PhantomData,
type_name: T::type_name().clone(),
}
@ -363,6 +390,7 @@ where
Self {
actions,
embeds,
produces_identifier: None,
repo: PhantomData,
type_name,
}
@ -405,15 +433,29 @@ where
{
/// Add an action to this transaction.
pub fn push(&mut self, action: T::Action) -> Result<(), Error> {
if action.produces_identifier() {
if let Some(index) = self.produces_identifier {
return Err(Error::ClashingIdentifiers(
serde_json::to_string(&self.actions[index])?,
serde_json::to_string(&action)?,
));
} else {
self.produces_identifier = Some(self.actions.len())
}
}
self.actions.push(action);
Ok(())
}
/// Add actions to this transaction.
pub fn extend(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Result<(), Error> {
self.actions.extend(actions);
/// Note that we cannot implement [`std::iter::Extend`] because [`Self::push`]
/// validates the action being pushed, and therefore is falliable.
pub fn extend<I: IntoIterator<Item = T::Action>>(&mut self, actions: I) -> Result<(), Error> {
for action in actions {
self.push(action)?;
}
Ok(())
}

View File

@ -272,7 +272,11 @@ pub enum Action {
},
}
impl cob::store::CobAction for Action {}
impl cob::store::CobAction for Action {
fn produces_identifier(&self) -> bool {
matches!(self, Self::Comment { .. })
}
}
impl From<Action> for nonempty::NonEmpty<Action> {
fn from(action: Action) -> Self {