Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cob: Change APIs to take URIs for embeds
Merged did:key:z6MksFqX...wzpT opened 1 year ago

To facilitate edit actions, take URIs instead of the actual blobs. This means API callers don’t have to load all the blobs just for them to be re-hashed when an edit action is submitted.

There are some peculiarities when dealing with the Identity COB since the embed is the identity document itself. We handle that special case.

12 files changed +152 -144 034eb418 855327d3
modified radicle-cli/src/commands/patch/edit.rs
@@ -1,6 +1,6 @@
use super::*;

-
use radicle::cob::{self, patch, resolve_embed};
+
use radicle::cob::{self, patch};
use radicle::crypto;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@@ -19,12 +19,11 @@ pub fn run(
    let Ok(patch) = patches.get_mut(patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
-

    let (title, description) = term::patch::get_edit_message(message, &patch)?;

    match revision_id {
-
        Some(id) => edit_revision(patch, id, title, description, repository, &signer),
-
        None => edit_root(patch, title, description, repository, &signer),
+
        Some(id) => edit_revision(patch, id, title, description, &signer),
+
        None => edit_root(patch, title, description, &signer),
    }
}

@@ -32,7 +31,6 @@ fn edit_root<G>(
    mut patch: patch::PatchMut<'_, '_, Repository, cob::cache::StoreWriter>,
    title: String,
    description: String,
-
    repository: &Repository,
    signer: &G,
) -> anyhow::Result<()>
where
@@ -56,11 +54,7 @@ where

    let (root, _) = patch.root();
    let target = patch.target();
-
    let embeds = patch
-
        .embeds()
-
        .iter()
-
        .filter_map(|embed| resolve_embed(repository, embed.clone()))
-
        .collect::<Vec<_>>();
+
    let embeds = patch.embeds().to_owned();

    patch.transaction("Edit root", signer, |tx| {
        if let Some(t) = title {
@@ -80,17 +74,12 @@ fn edit_revision<G>(
    revision: patch::RevisionId,
    mut title: String,
    description: String,
-
    repository: &Repository,
    signer: &G,
) -> anyhow::Result<()>
where
    G: crypto::Signer,
{
-
    let embeds = patch
-
        .embeds()
-
        .iter()
-
        .filter_map(|embed| resolve_embed(repository, embed.clone()))
-
        .collect::<Vec<_>>();
+
    let embeds = patch.embeds().to_owned();
    let description = if description.is_empty() {
        title
    } else {
modified radicle-cob/src/backend/git/change.rs
@@ -341,7 +341,7 @@ fn write_commit(
fn write_manifest(
    repo: &git2::Repository,
    manifest: &store::Manifest,
-
    embeds: Vec<Embed>,
+
    embeds: Vec<Embed<Oid>>,
    contents: &NonEmpty<Vec<u8>>,
) -> Result<git2::Oid, git2::Error> {
    let mut root = repo.treebuilder(None)?;
@@ -372,10 +372,10 @@ fn write_manifest(
        let mut embeds_tree = repo.treebuilder(None)?;

        for embed in embeds {
-
            let oid = repo.blob(&embed.content)?;
+
            let oid = embed.content;
            let path = PathBuf::from(embed.name);

-
            embeds_tree.insert(path, oid, git2::FileMode::Blob.into())?;
+
            embeds_tree.insert(path, *oid, git2::FileMode::Blob.into())?;
        }
        let oid = embeds_tree.write()?;

modified radicle-cob/src/change/store.rs
@@ -45,7 +45,7 @@ pub struct Template<Id> {
    pub type_name: TypeName,
    pub tips: Vec<Id>,
    pub message: String,
-
    pub embeds: Vec<Embed>,
+
    pub embeds: Vec<Embed<Oid>>,
    pub contents: NonEmpty<Vec<u8>>,
}

@@ -191,6 +191,22 @@ pub struct Embed<T = Vec<u8>> {
    pub content: T,
}

+
impl<T: From<Oid>> Embed<T> {
+
    /// Create a new embed.
+
    pub fn store(
+
        name: impl ToString,
+
        content: &[u8],
+
        repo: &git2::Repository,
+
    ) -> Result<Self, git2::Error> {
+
        let oid = repo.blob(content)?;
+

+
        Ok(Self {
+
            name: name.to_string(),
+
            content: T::from(oid.into()),
+
        })
+
    }
+
}
+

impl Embed<Vec<u8>> {
    /// Get the object id of the embedded content.
    pub fn oid(&self) -> Oid {
modified radicle-cob/src/object/collaboration/create.rs
@@ -18,7 +18,7 @@ pub struct Create {
    /// The message to add when creating this object.
    pub message: String,
    /// Embedded content.
-
    pub embeds: Vec<Embed>,
+
    pub embeds: Vec<Embed<Oid>>,
    /// COB version.
    pub version: Version,
}
modified radicle-cob/src/object/collaboration/update.rs
@@ -34,7 +34,7 @@ pub struct Update {
    /// The message to add when updating this object.
    pub message: String,
    /// Embedded files.
-
    pub embeds: Vec<Embed>,
+
    pub embeds: Vec<Embed<Oid>>,
}

/// Update an existing [`CollaborativeObject`].
modified radicle/src/cob/common.rs
@@ -8,10 +8,8 @@ use base64::prelude::{Engine, BASE64_STANDARD};
use localtime::LocalTime;
use serde::{Deserialize, Serialize};

-
use crate::cob::Embed;
use crate::git::Oid;
use crate::prelude::{Did, PublicKey};
-
use crate::storage::ReadRepository;

/// Timestamp used for COB operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -348,24 +346,6 @@ impl TryFrom<&Uri> for DataUri {
    }
}

-
/// Resolve an embed with a URI to one with actual data.
-
pub fn resolve_embed(repo: &impl ReadRepository, embed: Embed<Uri>) -> Option<Embed<Vec<u8>>> {
-
    DataUri::try_from(&embed.content)
-
        .ok()
-
        .map(|content| Embed {
-
            name: embed.name.clone(),
-
            content: content.into(),
-
        })
-
        .or_else(|| {
-
            Oid::try_from(&embed.content).ok().and_then(|oid| {
-
                repo.blob(oid).ok().map(|blob| Embed {
-
                    name: embed.name,
-
                    content: blob.content().to_vec(),
-
                })
-
            })
-
        })
-
}
-

/// The result of an authorization check on an COB action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Authorization {
modified radicle/src/cob/identity.rs
@@ -3,7 +3,7 @@ use std::{fmt, ops::Deref, str::FromStr};

use crypto::{PublicKey, Signature};
use once_cell::sync::Lazy;
-
use radicle_cob::{ObjectId, TypeName};
+
use radicle_cob::{Embed, ObjectId, TypeName};
use radicle_crypto::{Signer, Verified};
use radicle_git_ext as git_ext;
use radicle_git_ext::Oid;
@@ -15,7 +15,7 @@ use crate::{
    cob::{
        op, store,
        store::{Cob, CobAction, Transaction},
-
        ActorId, Timestamp,
+
        ActorId, Timestamp, Uri,
    },
    identity::{
        doc::{Doc, DocError, RepoId},
@@ -187,10 +187,12 @@ impl Identity {
        signer: &G,
    ) -> Result<IdentityMut<'a, R>, cob::store::Error> {
        let mut store = cob::store::Store::open(store)?;
-
        let (id, identity) =
-
            Transaction::<Identity, _>::initial("Initialize identity", &mut store, signer, |tx| {
-
                tx.revision("Initial revision", "", doc, None, signer)
-
            })?;
+
        let (id, identity) = Transaction::<Identity, _>::initial(
+
            "Initialize identity",
+
            &mut store,
+
            signer,
+
            |tx, repo| tx.revision("Initial revision", "", doc, None, repo, signer),
+
        )?;

        Ok(IdentityMut {
            id,
@@ -825,22 +827,26 @@ impl<R: ReadRepository> store::Transaction<Identity, R> {
    pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> {
        self.push(Action::RevisionRedact { revision })
    }
+
}

+
impl<R: WriteRepository> store::Transaction<Identity, R> {
    pub fn revision<G: Signer>(
        &mut self,
        title: impl ToString,
        description: impl ToString,
        doc: &Doc<Verified>,
        parent: Option<RevisionId>,
+
        repo: &R,
        signer: &G,
    ) -> Result<(), store::Error> {
-
        let (blob, content, signature) = doc.sign(signer).map_err(store::Error::Identity)?;
+
        let (blob, bytes, signature) = doc.sign(signer).map_err(store::Error::Identity)?;
+
        // Store document blob in repository.
+
        let embed =
+
            Embed::<Uri>::store("radicle.json", &bytes, repo.raw()).map_err(store::Error::Git)?;
+
        debug_assert_eq!(embed.content, Uri::from(blob)); // Make sure we pre-computed the correct OID for the blob.

        // Identity document.
-
        self.embed([cob::Embed {
-
            name: String::from("radicle.json"),
-
            content,
-
        }])?;
+
        self.embed([embed])?;

        // Revision metadata.
        self.push(Action::Revision {
@@ -891,10 +897,10 @@ where
    ) -> Result<EntryId, Error>
    where
        G: Signer,
-
        F: FnOnce(&mut Transaction<Identity, R>) -> Result<(), store::Error>,
+
        F: FnOnce(&mut Transaction<Identity, R>, &R) -> Result<(), store::Error>,
    {
        let mut tx = Transaction::default();
-
        operations(&mut tx)?;
+
        operations(&mut tx, self.store.as_ref())?;

        let (doc, commit) = tx.commit(message, self.id, &mut self.store, signer)?;
        self.identity = doc;
@@ -912,8 +918,8 @@ where
        signer: &G,
    ) -> Result<RevisionId, Error> {
        let parent = self.current;
-
        let id = self.transaction("Propose revision", signer, |tx| {
-
            tx.revision(title, description, doc, Some(parent), signer)
+
        let id = self.transaction("Propose revision", signer, |tx, repo| {
+
            tx.revision(title, description, doc, Some(parent), repo, signer)
        })?;

        Ok(id)
@@ -929,7 +935,7 @@ where
        let revision = self.revision(revision).ok_or(Error::NotFound(id))?;
        let signature = revision.sign(signer)?;

-
        self.transaction("Accept revision", signer, |tx| tx.accept(id, signature))
+
        self.transaction("Accept revision", signer, |tx, _| tx.accept(id, signature))
    }

    /// Reject an active revision.
@@ -938,7 +944,7 @@ where
        revision: RevisionId,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Reject revision", signer, |tx| tx.reject(revision))
+
        self.transaction("Reject revision", signer, |tx, _| tx.reject(revision))
    }

    /// Redact a revision.
@@ -947,7 +953,7 @@ where
        revision: RevisionId,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Redact revision", signer, |tx| tx.redact(revision))
+
        self.transaction("Redact revision", signer, |tx, _| tx.redact(revision))
    }

    /// Edit an active revision's title or description.
@@ -958,7 +964,7 @@ where
        description: String,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Edit revision", signer, |tx| {
+
        self.transaction("Edit revision", signer, |tx, _| {
            tx.edit(revision, title, description)
        })
    }
modified radicle/src/cob/issue.rs
@@ -472,15 +472,13 @@ impl<R: ReadRepository> store::Transaction<Issue, R> {
        &mut self,
        id: CommentId,
        body: impl ToString,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::CommentEdit {
            id,
            body: body.to_string(),
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -506,15 +504,13 @@ impl<R: ReadRepository> store::Transaction<Issue, R> {
        &mut self,
        body: S,
        reply_to: CommentId,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::Comment {
            body: body.to_string(),
            reply_to: Some(reply_to),
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -545,16 +541,15 @@ impl<R: ReadRepository> store::Transaction<Issue, R> {
    fn thread<S: ToString>(
        &mut self,
        body: S,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
    ) -> Result<(), store::Error> {
        let embeds = embeds.into_iter().collect::<Vec<_>>();
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::Comment {
            body: body.to_string(),
            reply_to: None,
-
            embeds: hashed,
+
            embeds,
        })
    }
}
@@ -613,7 +608,7 @@ where
    pub fn edit_description<G: Signer>(
        &mut self,
        description: impl ToString,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        let (id, _) = self.root();
@@ -633,7 +628,7 @@ where
        &mut self,
        body: S,
        reply_to: CommentId,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Comment", signer, |tx| {
@@ -646,7 +641,7 @@ where
        &mut self,
        id: CommentId,
        body: S,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Edit comment", signer, |tx| {
@@ -777,7 +772,7 @@ where
        description: impl ToString,
        labels: &[Label],
        assignees: &[Did],
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        cache: &'g mut C,
        signer: &G,
    ) -> Result<IssueMut<'a, 'g, R, C>, Error>
@@ -785,7 +780,7 @@ where
        G: Signer,
        C: cob::cache::Update<Issue>,
    {
-
        let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
+
        let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx, _| {
            tx.thread(description, embeds)?;
            tx.edit(title)?;

@@ -1511,17 +1506,22 @@ mod test {
    fn test_embeds() {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Cache::no_cache(&*repo).unwrap();
+

+
        let content1 = repo.backend.blob(b"<html>Hello World!</html>").unwrap();
+
        let content2 = repo.backend.blob(b"<html>Hello Radicle!</html>").unwrap();
+
        let content3 = repo.backend.blob(b"body { color: red }").unwrap();
+

        let embed1 = Embed {
            name: String::from("example.html"),
-
            content: b"<html>Hello World!</html>".to_vec(),
+
            content: Uri::from(Oid::from(content1)),
        };
        let embed2 = Embed {
            name: String::from("style.css"),
-
            content: b"body { color: red }".to_vec(),
+
            content: Uri::from(Oid::from(content2)),
        };
        let embed3 = Embed {
            name: String::from("bin"),
-
            content: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+
            content: Uri::from(Oid::from(content3)),
        };
        let mut issue = issues
            .create(
@@ -1552,34 +1552,35 @@ mod test {
        let e2 = &c0.embeds()[1];
        let e3 = &c1.embeds()[0];

-
        let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
-
        let b2 = repo.blob(Oid::try_from(&e2.content).unwrap()).unwrap();
-
        let b3 = repo.blob(Oid::try_from(&e3.content).unwrap()).unwrap();
-

-
        assert_eq!(b1.content(), &embed1.content);
-
        assert_eq!(b2.content(), &embed2.content);
-
        assert_eq!(b3.content(), &embed3.content);
+
        let b1 = Oid::try_from(&e1.content).unwrap();
+
        let b2 = Oid::try_from(&e2.content).unwrap();
+
        let b3 = Oid::try_from(&e3.content).unwrap();

-
        assert_eq!(b1.is_binary(), false);
-
        assert_eq!(b2.is_binary(), false);
-
        assert_eq!(b3.is_binary(), true);
+
        assert_eq!(b1, Oid::try_from(&embed1.content).unwrap());
+
        assert_eq!(b2, Oid::try_from(&embed2.content).unwrap());
+
        assert_eq!(b3, Oid::try_from(&embed3.content).unwrap());
    }

    #[test]
    fn test_embeds_edit() {
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let mut issues = Cache::no_cache(&*repo).unwrap();
+

+
        let content1 = repo.backend.blob(b"<html>Hello World!</html>").unwrap();
+
        let content1_edited = repo.backend.blob(b"<html>Hello Radicle!</html>").unwrap();
+
        let content2 = repo.backend.blob(b"body { color: red }").unwrap();
+

        let embed1 = Embed {
            name: String::from("example.html"),
-
            content: b"<html>Hello World!</html>".to_vec(),
+
            content: Uri::from(Oid::from(content1)),
        };
        let embed1_edited = Embed {
-
            name: String::from("example.html"),
-
            content: b"<html>Hello Radicle!</html>".to_vec(),
+
            name: String::from("style.css"),
+
            content: Uri::from(Oid::from(content1_edited)),
        };
        let embed2 = Embed {
-
            name: String::from("style.css"),
-
            content: b"body { color: red }".to_vec(),
+
            name: String::from("bin"),
+
            content: Uri::from(Oid::from(content2)),
        };
        let mut issue = issues
            .create(
@@ -1603,10 +1604,10 @@ mod test {
        assert_eq!(c0.embeds().len(), 1);

        let e1 = &c0.embeds()[0];
-
        let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap();
+
        let b1 = Oid::try_from(&e1.content).unwrap();

-
        assert_eq!(e1.content, Uri::from(embed1_edited.oid()));
-
        assert_eq!(b1.content(), &embed1_edited.content);
+
        assert_eq!(e1.content, embed1_edited.content);
+
        assert_eq!(b1, Oid::try_from(&embed1_edited.content).unwrap());
    }

    #[test]
modified radicle/src/cob/issue/cache.rs
@@ -8,7 +8,7 @@ use crate::cob;
use crate::cob::cache;
use crate::cob::cache::{Remove, StoreReader, StoreWriter, Update};
use crate::cob::store;
-
use crate::cob::{Embed, Label, ObjectId, TypeName};
+
use crate::cob::{Embed, Label, ObjectId, TypeName, Uri};
use crate::crypto::Signer;
use crate::prelude::{Did, RepoId};
use crate::storage::{HasRepoId, ReadRepository, RepositoryError, SignRepository, WriteRepository};
@@ -79,7 +79,7 @@ impl<'a, R, C> Cache<super::Issues<'a, R>, C> {
        description: impl ToString,
        labels: &[Label],
        assignees: &[Did],
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<IssueMut<'a, 'g, R, C>, super::Error>
    where
modified radicle/src/cob/patch.rs
@@ -475,7 +475,7 @@ impl Patch {
    }

    /// Patch embeds.
-
    pub fn embeds(&self) -> &Vec<Embed<Uri>> {
+
    pub fn embeds(&self) -> &[Embed<Uri>] {
        let (_, r) = self.root();
        r.embeds()
    }
@@ -1410,7 +1410,7 @@ impl Revision {
        self.description.iter()
    }

-
    pub fn embeds(&self) -> &Vec<Embed<Uri>> {
+
    pub fn embeds(&self) -> &[Embed<Uri>] {
        &self.description.last().embeds
    }

@@ -1663,15 +1663,13 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        &mut self,
        revision: RevisionId,
        description: impl ToString,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::RevisionEdit {
            revision,
            description: description.to_string(),
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -1718,17 +1716,15 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        body: S,
        reply_to: Option<CommentId>,
        location: Option<CodeLocation>,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::RevisionComment {
            revision,
            body: body.to_string(),
            reply_to,
            location,
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -1738,16 +1734,14 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        revision: RevisionId,
        comment: CommentId,
        body: S,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::RevisionCommentEdit {
            revision,
            comment,
            body: body.to_string(),
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -1783,17 +1777,15 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        body: S,
        location: Option<CodeLocation>,
        reply_to: Option<CommentId>,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-
        self.embed(embeds)?;
-

+
        self.embed(embeds.clone())?;
        self.push(Action::ReviewComment {
            review,
            body: body.to_string(),
            location,
            reply_to,
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -1821,16 +1813,14 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
        review: ReviewId,
        comment: EntryId,
        body: S,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
    ) -> Result<(), store::Error> {
-
        let hashed = embeds.iter().map(|e| e.hashed()).collect();
-

-
        self.embed(embeds)?;
+
        self.embed(embeds.clone())?;
        self.push(Action::ReviewCommentEdit {
            review,
            comment,
            body: body.to_string(),
-
            embeds: hashed,
+
            embeds,
        })
    }

@@ -2011,7 +2001,7 @@ where
        &mut self,
        revision: RevisionId,
        description: String,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Edit revision", signer, |tx| {
@@ -2045,7 +2035,7 @@ where
        body: S,
        reply_to: Option<CommentId>,
        location: Option<CodeLocation>,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Comment", signer, |tx| {
@@ -2079,7 +2069,7 @@ where
        revision: RevisionId,
        comment: CommentId,
        body: S,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Edit comment", signer, |tx| {
@@ -2120,7 +2110,7 @@ where
        body: S,
        location: Option<CodeLocation>,
        reply_to: Option<CommentId>,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Review comment", signer, |tx| {
@@ -2140,7 +2130,7 @@ where
        review: ReviewId,
        comment: EntryId,
        body: S,
-
        embeds: impl IntoIterator<Item = Embed>,
+
        embeds: impl IntoIterator<Item = Embed<Uri>>,
        signer: &G,
    ) -> Result<EntryId, Error> {
        self.transaction("Edit review comment", signer, |tx| {
@@ -2567,7 +2557,7 @@ where
    where
        C: cob::cache::Update<Patch>,
    {
-
        let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx| {
+
        let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx, _| {
            tx.revision(description, base, oid)?;
            tx.edit(title, target)?;

modified radicle/src/cob/store.rs
@@ -9,7 +9,7 @@ use radicle_cob::CollaborativeObject;
use serde::{Deserialize, Serialize};

use crate::cob::op::Op;
-
use crate::cob::{Create, Embed, EntryId, ObjectId, TypeName, Update, Updated, Version};
+
use crate::cob::{Create, Embed, EntryId, ObjectId, TypeName, Update, Updated, Uri, Version};
use crate::git;
use crate::prelude::*;
use crate::storage::git as storage;
@@ -92,6 +92,10 @@ pub enum Error {
    NotFound(TypeName, ObjectId),
    #[error("signed refs: {0}")]
    SignRefs(#[from] storage::Error),
+
    #[error("invalid or unknown embed URI: {0}")]
+
    EmbedUri(Uri),
+
    #[error(transparent)]
+
    Git(git::raw::Error),
    #[error("failed to find reference '{name}': {err}")]
    RefLookup {
        name: git::RefString,
@@ -148,12 +152,21 @@ where
        object_id: ObjectId,
        message: &str,
        actions: impl Into<NonEmpty<T::Action>>,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
        signer: &G,
    ) -> Result<Updated<T>, Error> {
        let actions = actions.into();
        let related = actions.iter().flat_map(T::Action::parents).collect();
        let changes = actions.try_map(encoding::encode)?;
+
        let embeds = embeds
+
            .into_iter()
+
            .map(|e| {
+
                Ok::<_, Error>(Embed {
+
                    content: git::Oid::try_from(&e.content).map_err(Error::EmbedUri)?,
+
                    name: e.name.clone(),
+
                })
+
            })
+
            .collect::<Result<_, _>>()?;
        let updated = cob::update(
            self.repo,
            signer,
@@ -178,12 +191,21 @@ where
        &self,
        message: &str,
        actions: impl Into<NonEmpty<T::Action>>,
-
        embeds: Vec<Embed>,
+
        embeds: Vec<Embed<Uri>>,
        signer: &G,
    ) -> Result<(ObjectId, T), Error> {
        let actions = actions.into();
        let parents = actions.iter().flat_map(T::Action::parents).collect();
        let contents = actions.try_map(encoding::encode)?;
+
        let embeds = embeds
+
            .into_iter()
+
            .map(|e| {
+
                Ok::<_, Error>(Embed {
+
                    content: git::Oid::try_from(&e.content).map_err(Error::EmbedUri)?,
+
                    name: e.name.clone(),
+
                })
+
            })
+
            .collect::<Result<_, _>>()?;
        let cob = cob::create::<T, _, G>(
            self.repo,
            signer,
@@ -263,7 +285,7 @@ where
#[derive(Debug)]
pub struct Transaction<T: Cob + cob::Evaluate<R>, R> {
    actions: Vec<T::Action>,
-
    embeds: Vec<Embed>,
+
    embeds: Vec<Embed<Uri>>,
    repo: PhantomData<R>,
}

@@ -290,12 +312,12 @@ where
    ) -> Result<(ObjectId, T), Error>
    where
        G: Signer,
-
        F: FnOnce(&mut Self) -> Result<(), Error>,
+
        F: FnOnce(&mut Self, &R) -> Result<(), Error>,
        R: ReadRepository + SignRepository + cob::Store,
        T::Action: Serialize + Clone,
    {
        let mut tx = Transaction::default();
-
        operations(&mut tx)?;
+
        operations(&mut tx, store.as_ref())?;

        let actions = NonEmpty::from_vec(tx.actions)
            .expect("Transaction::initial: transaction must contain at least one action");
@@ -311,7 +333,7 @@ where
    }

    /// Embed media into the transaction.
-
    pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed>) -> Result<(), Error> {
+
    pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed<Uri>>) -> Result<(), Error> {
        self.embeds.extend(embeds);

        Ok(())
modified radicle/src/storage/git.rs
@@ -439,9 +439,13 @@ impl Repository {
        storage: &S,
        signer: &G,
    ) -> Result<(Self, git::Oid), RepositoryError> {
-
        let (doc_oid, _) = doc.encode()?;
+
        let (doc_oid, doc_bytes) = doc.encode()?;
        let id = RepoId::from(doc_oid);
        let repo = Self::create(paths::repository(storage, &id), id, storage.info())?;
+
        let oid = repo.backend.blob(&doc_bytes)?; // Store document blob in repository.
+

+
        debug_assert_eq!(oid, *doc_oid);
+

        let commit = doc.init(&repo, signer)?;

        Ok((repo, commit))