cli: Introduce `cob [create|update]`

This commit is contained in:
Lorenz Leutgeb 2024-05-22 16:44:31 +02:00
parent 547614a5bb
commit e4d23fe56a
No known key found for this signature in database
20 changed files with 1244 additions and 218 deletions

View File

@ -36,6 +36,9 @@ Make sure all tests are passing with:
$ cargo test --workspace
Some tests require `jq`. If `jq` is not detected, these tests will succeed
without effectively testing anything.
## Checking the docs
If you make documentation changes, you may want to check whether there are any

View File

@ -179,7 +179,7 @@
cargoExtraArgs = "-p ${name}";
doCheck = false;
nativeBuildInputs = with pkgs; [asciidoctor installShellFiles];
nativeBuildInputs = with pkgs; [asciidoctor installShellFiles jq];
postInstall = ''
for page in ${lib.escapeShellArgs pages}; do
asciidoctor -d manpage -b manpage $page

View File

@ -0,0 +1,32 @@
#! /usr/bin/env -S jq --from-file --sort-keys --compact-output
#
# Models a multiset as a JSON object.
#
# Takes an operation which contains actions, each in one of two shapes:
#
# Add x:
#
# { "+": $x }
#
# Remove x:
#
# { "-": $x }
#
# (where `$x` is a string).
#
# These actions are reduced to a multiset represented by an
# object. The key being the item to count (corresponding to `$x` above),
# and the value being the count itself (corresponding to how many times
# `$x` was added, minus how many times `$x` was removed).
#
# Errors if any unrecognizable action is encountered.
#
# For an example, see `rad-cob-multiset.md`.
reduce
.op.actions[]
as {"+": $p, "-": $m} (
.value;
.[$p // $m // ("invalid" | halt_error)] |= (
[. + (if $p then 1 else -1 end), 0] | max
)
)

View File

@ -0,0 +1,72 @@
This example demonstrates handling of arbitrary Collaborative Objects (COBs) by means of an external program.
The external program is called a "COB helper" (analogous to "remote helpers" that can be used to extend Git).
It consumes the current state of the COB and at least one operation (all in [JSON]) to be applied to it via files whose names are passed as arguments.
It returns the resulting COB by printing it to standard output in [JSON].
For the sake of example, consider a simplified shopping list, where every item on the list is associated with an integral quantity:
- 5 Bananas
- 3 Zucchini
- 1 Bar of Chocolate
One data structure that maps to this concept very well is a [multiset] (sometimes also called "bag").
So, let us introduce a new COB type with the name `com.example.multiset` that implements multisets, which we will then use to model our shopping list.
The COB we implement should allow two actions:
- The action `+`, which adds an item or, if already present, increases the associated quantity.
- The action `-`, which decreases the associated quantity of an item, if it is non-zero.
We model actions as objects in [JSON], and a sequence of actions in [JSON Lines].
An example sequence of actions looks as follows:
``` ./groceries.jsonl
{ "+": "jelly" }
{ "+": "peanut butter" }
{ "-": "jelly" }
{ "-": "jelly" }
{ "+": "salad" }
{ "+": "salad" }
```
Starting with an empty grocery list, the expected result after evaluating all actions is:
- 0 Jelly (this could be omitted)
- 1 Peanut Butter
- 2 Salad
We have a COB helper, named `rad-cob-multiset`, that implements evaluation of these actions using [jq].
It reads the current state of the grocery list and operations containing actions from files given as arguments and writes the resulting grocery list to standard output.
We do not invoke the program directly, but instead use `rad cob create`:
```
$ rad cob create --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --message "Create grocery shopping multiset" groceries.jsonl
9bba8e6f83ef56b11151ef6ad02cc4595f982aab
```
We can verify that the COB evaluated as expected:
```
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab
{"jelly":0,"peanut butter":1,"salad":2}
```
To apply actions to COBs that already exist, we can use `rad cob update`:
```
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab --message "Modify grocery shopping multiset" groceries.jsonl
d36aac77be13c1ca80edbfe7b7bf9b42c723f019
```
Again, we verify the result with `rad cob show`:
```
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab
{"jelly":0,"peanut butter":2,"salad":4}
```
[multisets]: https://wikipedia.org/wiki/Multiset
[JSON]: https://tools.ietf.org/html/std90
[JSON Lines]: https://jsonlines.org/
[jq]: https://github.com/jqlang/jq

View File

@ -0,0 +1,6 @@
Updating the repository identity via `rad cob update` is forbidden:
``` (fail)
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.id --object 0656c217f917c3e06234771e9ecae53aba5e173e --message "Danger" /dev/null
✗ Error: Update of collaborative objects of type xyz.radicle.id is not supported.
```

View File

@ -0,0 +1,178 @@
First off, we set up a patch.
```
$ git checkout -b changes
$ touch README.md
$ git add README.md
$ git commit --message "Add README, just for the fun"
[changes 03c02af] Add README, just for the fun
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 README.md
```
``` (stderr)
$ git push rad -o patch.message="Add README, just for the fun" HEAD:refs/patches
✓ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
* [new reference] HEAD -> refs/patches
```
```
$ touch LICENSE
$ git add LICENSE
$ git commit -v -m "Define the LICENSE"
[changes 8945f61] Define the LICENSE
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 LICENSE
```
``` (stderr)
$ git push -f -o patch.message="Add License"
✓ Patch 89f7afb updated to revision 5d78dd5376453e25df5988ec86951c99cb73742c
To compare against your previous revision 89f7afb, run:
git range-diff f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 03c02af4b12a593d17a06d38fae50a57fc3c339a 8945f6189adf027892c85ac57f7e9341049c2537
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
03c02af..8945f61 changes -> patches/89f7afb1511b976482b21f6b2f39aef7f4fb88a2
```
Let's look at the patch, to see what it looks like before editing it:
```
$ rad patch show 89f7afb
╭─────────────────────────────────────────────────────────────────────╮
│ Title Add README, just for the fun │
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
│ Author alice (you) │
│ Head 8945f6189adf027892c85ac57f7e9341049c2537 │
│ Branches changes │
│ Commits ahead 2, behind 0 │
│ Status open │
├─────────────────────────────────────────────────────────────────────┤
│ 8945f61 Define the LICENSE │
│ 03c02af Add README, just for the fun │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by alice (you) (03c02af) now │
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
╰─────────────────────────────────────────────────────────────────────╯
```
We can change the title and description of the patch itself by using a
multi-line message (using two `--message` options here):
```
$ rad patch edit 89f7afb --message "Add Metadata" --message "Add README & LICENSE" --no-announce
$ rad patch show 89f7afb
╭─────────────────────────────────────────────────────────────────────╮
│ Title Add Metadata │
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
│ Author alice (you) │
│ Head 8945f6189adf027892c85ac57f7e9341049c2537 │
│ Branches changes │
│ Commits ahead 2, behind 0 │
│ Status open │
│ │
│ Add README & LICENSE │
├─────────────────────────────────────────────────────────────────────┤
│ 8945f61 Define the LICENSE │
│ 03c02af Add README, just for the fun │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by alice (you) (03c02af) now │
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
╰─────────────────────────────────────────────────────────────────────╯
```
We prepare the file `revision-edit.json` which contains one action (thus one line) to be applied.
``` ./revision-edit.jsonl
{"type": "revision.edit", "description": "Add README and LICENSE", "revision": "89f7afb1511b976482b21f6b2f39aef7f4fb88a2"}
```
We now use `rad cob update` to edit the patch another time, rewriting the description.
The action itself is of type `revision.edit` and carries the parameters `revision`,
specifying the revision for which the description should be changed, and `description`,
specifying the new description.
```
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Edit patch" revision-edit.jsonl
79b816e92735c49b33d93a31890fdf040b36234c
$ rad patch show --verbose 89f7afb
╭─────────────────────────────────────────────────────────────────────╮
│ Title Add Metadata │
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
│ Author alice (you) │
│ Head 8945f6189adf027892c85ac57f7e9341049c2537 │
│ Base f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
│ Branches changes │
│ Commits ahead 2, behind 0 │
│ Status open │
│ │
│ Add README and LICENSE │
├─────────────────────────────────────────────────────────────────────┤
│ 8945f61 Define the LICENSE │
│ 03c02af Add README, just for the fun │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by alice (you) (03c02af) now │
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
╰─────────────────────────────────────────────────────────────────────╯
```
Notice that the patch now has the description `Add README and LICENSE`.
We may use `rad cob update` to create a new revision altogether, as well.
Let's create yet another commit, an empty one this time, and do that.
```
$ git commit --allow-empty --message="Dummy commit for a new revision"
[changes f1339dd] Dummy commit for a new revision
```
We prepare the file `revision-create.jsonl` which contains one action.
``` ./revision.jsonl
{"type": "revision", "description": "A new revision", "base": "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354", "oid": "f1339dd109e538c6b3a7fed3e72403e1b4db08c9"}
```
Attempting to create the new revision right away would fail:
``` (fail)
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Create new revision" revision.jsonl
✗ Error: store: update error: failed to read 'f1339dd109e538c6b3a7fed3e72403e1b4db08c9' from git odb
```
Since we are not using the remote helper `git-remote-rad` here, we need to push
the new commit to storage manually. See `fn patch_open` in `/radicle-remote-helper/src/push.rs`
for more details.
```
$ git push rad HEAD:tmp/heads/f1339dd109e538c6b3a7fed3e72403e1b4db08c9
$ git push rad :tmp/heads/f1339dd109e538c6b3a7fed3e72403e1b4db08c9
```
Now we can invoke `rad cob update`:
```
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Create new revision" revision.jsonl
2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c
$ rad patch show 89f7afb
╭─────────────────────────────────────────────────────────────────────╮
│ Title Add Metadata │
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
│ Author alice (you) │
│ Head f1339dd109e538c6b3a7fed3e72403e1b4db08c9 │
│ Branches changes │
│ Commits ahead 3, behind 0 │
│ Status open │
│ │
│ Add README and LICENSE │
├─────────────────────────────────────────────────────────────────────┤
│ f1339dd Dummy commit for a new revision │
│ 8945f61 Define the LICENSE │
│ 03c02af Add README, just for the fun │
├─────────────────────────────────────────────────────────────────────┤
│ ● opened by alice (you) (03c02af) now │
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
│ ↑ updated to 2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c (f1339dd) now │
╰─────────────────────────────────────────────────────────────────────╯
```

View File

@ -1,21 +1,19 @@
use std::ffi::OsString;
use std::io;
use std::io::Write;
use std::path::PathBuf;
use std::str::FromStr;
use std::{fs, io};
use anyhow::{anyhow, bail};
use anyhow::anyhow;
use chrono::prelude::*;
use nonempty::NonEmpty;
use radicle::cob;
use radicle::cob::Op;
use radicle::identity::Identity;
use radicle::issue::cache::Issues;
use radicle::patch::cache::Patches;
use radicle::prelude::RepoId;
use radicle::cob::store::CobAction;
use radicle::prelude::*;
use radicle::storage::git;
use radicle::storage::ReadStorage;
use radicle::Profile;
use radicle_cob::object::collaboration::list;
use serde_json::json;
use crate::git::Rev;
@ -30,16 +28,28 @@ pub const HELP: Help = Help {
Usage
rad cob <command> [<option>...]
rad cob create --repo <rid> --type <typename> <filename> [<option>...]
rad cob list --repo <rid> --type <typename>
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob migrate [<option>...]
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob update --repo <rid> --type <typename> --object <oid> <filename>
[<option>...]
Commands
create Create a new COB of a given type given initial actions
list List all COBs of a given type (--object is not needed)
log Print a log of all raw operations on a COB
migrate Migrate the COB database to the latest version
update Add actions to a COB
show Print the state of COBs
Create, Update options
--embed-file <name> <path> Supply embed of given name via file at given path
--embed-hash <name> <oid> Supply embed of given name via object ID of blob
Log options
@ -55,8 +65,10 @@ Other options
"#,
};
#[derive(PartialEq)]
#[derive(Clone, Copy, PartialEq)]
enum OperationName {
Update,
Create,
List,
Log,
Migrate,
@ -64,20 +76,36 @@ enum OperationName {
}
enum Operation {
Create {
rid: RepoId,
type_name: FilteredTypeName,
message: String,
actions: PathBuf,
embeds: Vec<Embed>,
},
List {
repo: RepoId,
type_name: cob::TypeName,
rid: RepoId,
type_name: FilteredTypeName,
},
Log {
repo: RepoId,
rev: Rev,
type_name: cob::TypeName,
rid: RepoId,
type_name: FilteredTypeName,
oid: Rev,
format: Format,
},
Migrate,
Show {
repo: RepoId,
revs: Vec<Rev>,
type_name: cob::TypeName,
rid: RepoId,
type_name: FilteredTypeName,
oids: Vec<Rev>,
},
Update {
rid: RepoId,
type_name: FilteredTypeName,
oid: Rev,
message: String,
actions: PathBuf,
embeds: Vec<Embed>,
},
}
@ -88,132 +116,290 @@ enum Format {
pub struct Options {
op: Operation,
format: Format,
}
/// A precursor to [`cob::Embed`] used for parsing
/// that can be initialized without relying on a [`git::Repository`].
struct Embed {
name: String,
content: EmbedContent,
}
enum EmbedContent {
Path(PathBuf),
Hash(Rev),
}
/// A thin wrapper around [`cob::TypeName`] used for parsing.
/// Well known COB type names are captured as variants,
/// with [`FilteredTypeName::Other`] as an escape hatch for type names
/// that are not well known.
enum FilteredTypeName {
Issue,
Patch,
Identity,
Other(cob::TypeName),
}
impl From<cob::TypeName> for FilteredTypeName {
fn from(value: cob::TypeName) -> Self {
if value == *cob::issue::TYPENAME {
FilteredTypeName::Issue
} else if value == *cob::patch::TYPENAME {
FilteredTypeName::Patch
} else if value == *cob::identity::TYPENAME {
FilteredTypeName::Identity
} else {
FilteredTypeName::Other(value)
}
}
}
impl AsRef<cob::TypeName> for FilteredTypeName {
fn as_ref(&self) -> &cob::TypeName {
match self {
FilteredTypeName::Issue => &cob::issue::TYPENAME,
FilteredTypeName::Patch => &cob::patch::TYPENAME,
FilteredTypeName::Identity => &cob::identity::TYPENAME,
FilteredTypeName::Other(value) => value,
}
}
}
impl std::fmt::Display for FilteredTypeName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_ref().fmt(f)
}
}
impl Embed {
fn try_into_bytes(self, repo: &git::Repository) -> anyhow::Result<cob::Embed<cob::Uri>> {
Ok(match self.content {
EmbedContent::Hash(hash) => cob::Embed {
name: self.name,
content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
},
EmbedContent::Path(path) => {
cob::Embed::store(self.name, &std::fs::read(path)?, &repo.backend)?
}
})
}
}
impl Args for Options {
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
use lexopt::prelude::*;
use term::args::string;
use OperationName::*;
let mut parser = lexopt::Parser::from_args(args);
let mut op: Option<OperationName> = None;
let mut type_name: Option<cob::TypeName> = None;
let mut revs: Vec<Rev> = vec![];
let mut rid: Option<RepoId> = None;
let mut format: Option<Format> = None;
while let Some(arg) = parser.next()? {
match arg {
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"list" => op = Some(OperationName::List),
"log" => op = Some(OperationName::Log),
"migrate" => op = Some(OperationName::Migrate),
"show" => op = Some(OperationName::Show),
unknown => anyhow::bail!("unknown operation '{unknown}'"),
},
Long("type") | Short('t') => {
let v = parser.value()?;
let v = term::args::string(&v);
let v = cob::TypeName::from_str(&v)?;
type_name = Some(v);
}
Long("object") => {
let v = parser.value()?;
let v = term::args::string(&v);
revs.push(Rev::from(v));
}
Long("repo") => {
let v = parser.value()?;
let v = term::args::rid(&v)?;
rid = Some(v);
}
Long("help") | Short('h') => {
let op = match parser.next()? {
None | Some(Long("help") | Short('h')) => {
return Err(Error::Help.into());
}
Long("format")
if op == Some(OperationName::Log) || op == Some(OperationName::Show) =>
{
let v: String = term::args::string(&parser.value()?);
match v.as_ref() {
"pretty" if op == Some(OperationName::Log) => format = Some(Format::Pretty),
"json" => format = Some(Format::Json),
unknown => anyhow::bail!("unknown format '{unknown}'"),
Some(Value(val)) => match val.to_string_lossy().as_ref() {
"update" => Update,
"create" => Create,
"list" => List,
"log" => Log,
"migrate" => Migrate,
"show" => Show,
unknown => bail!("unknown operation '{unknown}'"),
},
Some(arg) => return Err(anyhow!(arg.unexpected())),
};
let mut type_name: Option<FilteredTypeName> = None;
let mut oids: Vec<Rev> = vec![];
let mut rid: Option<RepoId> = None;
let mut format: Format = Format::Pretty;
let mut message: Option<String> = None;
let mut embeds: Vec<Embed> = vec![];
let mut actions: Option<PathBuf> = None;
while let Some(arg) = parser.next()? {
match (&op, &arg) {
(_, Long("help") | Short('h')) => {
return Err(Error::Help.into());
}
(_, Long("repo") | Short('r')) => {
rid = Some(term::args::rid(&parser.value()?)?);
}
(_, Long("type") | Short('t')) => {
let v = string(&parser.value()?);
type_name = Some(FilteredTypeName::from(cob::TypeName::from_str(&v)?));
}
(Update | Log | Show, Long("object") | Short('o')) => {
let v = string(&parser.value()?);
oids.push(Rev::from(v));
}
(Update | Create, Long("message") | Short('m')) => {
message = Some(string(&parser.value()?));
}
(Log | Show | Update, Long("format")) => {
format = match (op, string(&parser.value()?).as_ref()) {
(Log, "pretty") => Format::Pretty,
(Log | Show | Update, "json") => Format::Json,
(_, unknown) => bail!("unknown format '{unknown}'"),
};
}
(Update | Create, Long("embed-file")) => {
let mut values = parser.values()?;
let name = values
.next()
.map(|s| term::args::string(&s))
.ok_or(anyhow!("expected name of embed"))?;
let content = EmbedContent::Path(PathBuf::from(
values
.next()
.ok_or(anyhow!("expected path to file to embed"))?,
));
embeds.push(Embed { name, content });
}
(Update | Create, Long("embed-hash")) => {
let mut values = parser.values()?;
let name = values
.next()
.map(|s| term::args::string(&s))
.ok_or(anyhow!("expected name of embed"))?;
let content = EmbedContent::Hash(Rev::from(term::args::string(
&values
.next()
.ok_or(anyhow!("expected hash of file to embed"))?,
)));
embeds.push(Embed { name, content });
}
(Update | Create, Value(val)) => {
actions = Some(PathBuf::from(term::args::string(val)));
}
_ => return Err(anyhow!(arg.unexpected())),
}
}
_ => return Err(anyhow::anyhow!(arg.unexpected())),
if op == OperationName::Migrate {
return Ok((
Options {
op: Operation::Migrate,
},
vec![],
));
}
}
let repo = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"));
let rid = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?;
let type_name =
type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"));
type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?;
let missing_oid = || anyhow!("an object id must be specified with `--object`");
let missing_message = || anyhow!("a message must be specified with `--message`");
Ok((
Options {
op: {
match op.ok_or_else(|| anyhow!("a command must be specified"))? {
OperationName::List => Operation::List {
repo: repo?,
type_name: type_name?,
},
OperationName::Log => Operation::Log {
repo: repo?,
rev: revs.pop().ok_or_else(|| {
anyhow!("an object id must be specified with `--object`")
op: match op {
Create => Operation::Create {
rid,
type_name,
message: message.ok_or_else(missing_message)?,
actions: actions.ok_or_else(|| {
anyhow!("a file containing initial actions must be specified")
})?,
type_name: type_name?,
embeds,
},
OperationName::Migrate => Operation::Migrate,
OperationName::Show => {
if revs.is_empty() {
anyhow::bail!("an object id must be specified with `--object`")
List => Operation::List { rid, type_name },
Log => Operation::Log {
rid,
type_name,
oid: oids.pop().ok_or_else(missing_oid)?,
format,
},
Migrate => Operation::Migrate,
Show => {
if oids.is_empty() {
return Err(missing_oid());
}
Operation::Show {
repo: repo?,
revs,
type_name: type_name?,
}
rid,
oids,
type_name,
}
}
Update => Operation::Update {
rid,
type_name,
oid: oids.pop().ok_or_else(missing_oid)?,
message: message.ok_or_else(missing_message)?,
actions: actions.ok_or_else(|| {
anyhow!("a file containing actions must be specified")
})?,
embeds,
},
},
format: format.unwrap_or(Format::Pretty),
},
vec![],
))
}
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<()> {
use cob::store::Store;
use FilteredTypeName::*;
use Operation::*;
let profile = ctx.profile()?;
let storage = &profile.storage;
match options.op {
Operation::List { repo, type_name } => {
let repo = storage.repository(repo)?;
let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &type_name)?;
for cob in cobs {
println!("{}", cob.id);
}
}
Operation::Log {
repo,
rev: oid,
match op {
Create {
rid,
type_name,
message,
embeds,
actions,
} => {
let repo = storage.repository(repo)?;
let oid = oid.resolve(&repo.backend)?;
let ops = cob::store::ops(&oid, &type_name, &repo)?;
let signer = &profile.signer()?;
let repo = storage.repository_mut(rid)?;
for op in ops.into_iter().rev() {
match options.format {
Format::Json => print_op_json(op)?,
Format::Pretty => print_op_pretty(op)?,
let reader = io::BufReader::new(fs::File::open(actions)?);
let embeds = embeds
.into_iter()
.map(|embed| embed.try_into_bytes(&repo))
.collect::<anyhow::Result<Vec<_>>>()?;
let oid = match type_name {
Patch => {
let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
let actions = read_jsonl_actions(reader)?;
let (oid, _) = store.create(&message, actions, embeds, signer)?;
oid
}
Issue => {
let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
let actions = read_jsonl_actions(reader)?;
let (oid, _) = store.create(&message, actions, embeds, signer)?;
oid
}
Identity => anyhow::bail!(
"Creation of collaborative objects of type {} is not supported.",
&type_name
),
Other(type_name) => {
let store: Store<cob::external::External, _> =
Store::open_for(&type_name, &repo)?;
let actions = read_jsonl_actions(reader)?;
let (oid, _) = store.create(&message, actions, embeds, signer)?;
oid
}
Operation::Migrate => {
};
println!("{}", oid);
}
Migrate => {
let mut db = profile.cobs_db_mut()?;
if db.check_version().is_ok() {
term::success!("Collaborative objects database is already up to date");
@ -224,71 +410,175 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
);
}
}
Operation::Show {
repo,
revs,
List { rid, type_name } => {
let repo = storage.repository(rid)?;
let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(&repo, type_name.as_ref())?;
for cob in cobs {
println!("{}", cob.id);
}
}
Log {
rid,
type_name,
oid,
format,
} => {
let repo = storage.repository(rid)?;
let oid = oid.resolve(&repo.backend)?;
let ops = cob::store::ops(&oid, type_name.as_ref(), &repo)?;
for op in ops.into_iter().rev() {
match format {
Format::Json => print_op_json(op)?,
Format::Pretty => print_op_pretty(op)?,
}
}
}
Show {
rid,
oids,
type_name,
} => {
let repo = storage.repository(repo)?;
if let Err(e) = show(revs, &repo, type_name, &profile) {
if let Some(err) = e.downcast_ref::<io::Error>() {
if err.kind() == io::ErrorKind::BrokenPipe {
let repo = storage.repository(rid)?;
if let Err(e) = show(oids, &repo, type_name, &profile) {
if let Some(err) = e.downcast_ref::<std::io::Error>() {
if err.kind() == std::io::ErrorKind::BrokenPipe {
return Ok(());
}
}
return Err(e);
}
}
}
Update {
rid,
type_name,
oid,
message,
actions,
embeds,
} => {
let signer = &profile.signer()?;
let repo = storage.repository_mut(rid)?;
let reader = io::BufReader::new(fs::File::open(actions)?);
let oid = &oid.resolve(&repo.backend)?;
let embeds = embeds
.into_iter()
.map(|embed| embed.try_into_bytes(&repo))
.collect::<anyhow::Result<Vec<_>>>()?;
let oid = match type_name {
Patch => {
let actions: Vec<cob::patch::Action> = read_jsonl(reader)?;
let mut patches = profile.patches_mut(&repo)?;
let mut patch = patches.get_mut(oid)?;
patch.transaction(&message, &profile.signer()?, |tx| {
tx.extend(actions)?;
tx.embed(embeds)?;
Ok(())
})?
}
Issue => {
let actions: Vec<cob::issue::Action> = read_jsonl(reader)?;
let mut issues = profile.issues_mut(&repo)?;
let mut issue = issues.get_mut(oid)?;
issue.transaction(&message, &profile.signer()?, |tx| {
tx.extend(actions)?;
tx.embed(embeds)?;
Ok(())
})?
}
Identity => anyhow::bail!(
"Update of collaborative objects of type {} is not supported.",
&type_name
),
Other(type_name) => {
use cob::external::{Action, External};
let actions: Vec<Action> = read_jsonl(reader)?;
let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
let (_, oid) = tx.commit(&message, *oid, &mut store, signer)?;
oid
}
};
println!("{}", oid);
}
}
Ok(())
}
fn show(
revs: Vec<Rev>,
oids: Vec<Rev>,
repo: &git::Repository,
type_name: cob::TypeName,
type_name: FilteredTypeName,
profile: &Profile,
) -> Result<(), anyhow::Error> {
use io::Write as _;
let mut stdout = std::io::stdout();
if type_name == cob::patch::TYPENAME.clone() {
let patches = term::cob::patches(profile, repo)?;
for oid in revs {
match type_name {
FilteredTypeName::Identity => {
use cob::identity;
for oid in oids {
let oid = &oid.resolve(&repo.backend)?;
let Some(patch) = patches.get(oid)? else {
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid));
};
serde_json::to_writer(&stdout, &patch)?;
stdout.write_all(b"\n")?;
}
} else if type_name == cob::issue::TYPENAME.clone() {
let issues = term::cob::issues(profile, repo)?;
for oid in revs {
let oid = &oid.resolve(&repo.backend)?;
let Some(issue) = issues.get(oid)? else {
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
};
serde_json::to_writer(&stdout, &issue)?;
stdout.write_all(b"\n")?;
}
} else if type_name == cob::identity::TYPENAME.clone() {
for oid in revs {
let oid = &oid.resolve(&repo.backend)?;
let Some(cob) = cob::get::<Identity, _>(repo, &type_name, oid)? else {
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid));
let Some(cob) = cob::get::<identity::Identity, _>(repo, type_name.as_ref(), oid)?
else {
bail!(cob::store::Error::NotFound(
type_name.as_ref().clone(),
*oid
));
};
serde_json::to_writer(&stdout, &cob.object)?;
stdout.write_all(b"\n")?;
}
} else {
anyhow::bail!("the type name '{type_name}' is unknown");
}
FilteredTypeName::Issue => {
use radicle::issue::cache::Issues as _;
let issues = term::cob::issues(profile, repo)?;
for oid in oids {
let oid = &oid.resolve(&repo.backend)?;
let Some(issue) = issues.get(oid)? else {
bail!(cob::store::Error::NotFound(
type_name.as_ref().clone(),
*oid
))
};
serde_json::to_writer(&stdout, &issue)?;
stdout.write_all(b"\n")?;
}
}
FilteredTypeName::Patch => {
use radicle::patch::cache::Patches as _;
let patches = term::cob::patches(profile, repo)?;
for oid in oids {
let oid = &oid.resolve(&repo.backend)?;
let Some(patch) = patches.get(oid)? else {
bail!(cob::store::Error::NotFound(
type_name.as_ref().clone(),
*oid
));
};
serde_json::to_writer(&stdout, &patch)?;
stdout.write_all(b"\n")?;
}
}
FilteredTypeName::Other(type_name) => {
let store =
cob::store::Store::<cob::external::External, _>::open_for(&type_name, repo)?;
for oid in oids {
let oid = &oid.resolve(&repo.backend)?;
let cob = store
.get(oid)?
.ok_or_else(|| anyhow!(cob::store::Error::NotFound(type_name.clone(), *oid)))?;
serde_json::to_writer(&stdout, &cob)?;
stdout.write_all(b"\n")?;
}
}
}
Ok(())
}
fn print_op_pretty(op: Op<Vec<u8>>) -> anyhow::Result<()> {
fn print_op_pretty(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
let time = DateTime::<Utc>::from(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(op.timestamp.as_secs()),
)
@ -317,9 +607,11 @@ fn print_op_pretty(op: Op<Vec<u8>>) -> anyhow::Result<()> {
Ok(())
}
fn print_op_json(op: Op<Vec<u8>>) -> anyhow::Result<()> {
fn print_op_json(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
let mut ser = json!(op);
ser.as_object_mut().unwrap().insert(
ser.as_object_mut()
.expect("ops must serialize to objects")
.insert(
"actions".to_string(),
json!(op
.actions
@ -332,3 +624,29 @@ fn print_op_json(op: Op<Vec<u8>>) -> anyhow::Result<()> {
term::print(ser);
Ok(())
}
/// Naive implementation for reading JSONL streams,
/// see <https://jsonlines.org/>.
fn read_jsonl<R, T>(reader: io::BufReader<R>) -> anyhow::Result<Vec<T>>
where
R: io::Read,
T: serde::de::DeserializeOwned,
{
use io::BufRead as _;
let mut result: Vec<T> = Vec::new();
for line in reader.lines() {
result.push(serde_json::from_str(&line?)?);
}
Ok(result)
}
/// Tiny utility to read a [`NonEmpty`] of COB actions.
/// This is used for `rad cob create` and `rad cob update`.
fn read_jsonl_actions<R, A>(reader: io::BufReader<R>) -> anyhow::Result<NonEmpty<A>>
where
R: io::Read,
A: CobAction + serde::de::DeserializeOwned,
{
NonEmpty::from_vec(read_jsonl(reader)?)
.ok_or_else(|| anyhow!("at least one action is required"))
}

View File

@ -158,6 +158,92 @@ fn rad_issue() {
test("examples/rad-issue.md", &working, Some(home), []).unwrap();
}
#[test]
fn rad_cob_update() {
let mut environment = Environment::new();
let profile = environment.profile(config::profile("alice"));
let working = environment.tmp().join("working");
let home = &profile.home;
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::create_dir_all(base).unwrap();
std::fs::create_dir_all(working.clone()).unwrap();
// Setup a test repository.
fixtures::repository(&working);
test("examples/rad-init.md", &working, Some(home), []).unwrap();
test("examples/rad-cob-update.md", &working, Some(home), []).unwrap();
}
#[test]
fn rad_cob_update_identity() {
let mut environment = Environment::new();
let profile = environment.profile(config::profile("alice"));
let working = environment.tmp().join("working");
let home = &profile.home;
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::create_dir_all(base).unwrap();
std::fs::create_dir_all(working.clone()).unwrap();
// Setup a test repository.
fixtures::repository(&working);
test("examples/rad-init.md", &working, Some(home), []).unwrap();
test(
"examples/rad-cob-update-identity.md",
&working,
Some(home),
[],
)
.unwrap();
}
#[test]
fn rad_cob_multiset() {
{
// `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
// We test whether `jq` is installed, and have this test succeed if it is not.
// Programmatic skipping of tests is not supported as of 2024-08.
let output = std::process::Command::new("/usr/bin/env")
.arg("jq")
.arg("-V")
.output()
.unwrap();
if !output.status.success() {
log::warn!(target: "test", "`jq` not found. Succeeding prematurely. {:?}", output);
return;
}
}
let mut environment = Environment::new();
let profile = environment.profile(config::profile("alice"));
let home = &profile.home;
let working = environment.tmp().join("working");
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::create_dir_all(base).unwrap();
std::fs::create_dir_all(working.clone()).unwrap();
// Copy over the script that implements the multiset COB.
std::fs::copy(
base.join("examples").join("rad-cob-multiset"),
working.join("rad-cob-multiset"),
)
.unwrap();
// Setup a test repository.
fixtures::repository(&working);
test("examples/rad-init.md", &working, Some(home), []).unwrap();
test("examples/rad-cob-multiset.md", &working, Some(home), []).unwrap();
}
#[test]
fn rad_cob_log() {
let mut environment = Environment::new();

View File

@ -342,7 +342,7 @@ fn update_or_remove<R, C, T>(
) -> Result<(), error::Cache>
where
R: cob::Store + ReadRepository,
T: cob::Evaluate<R> + cob::store::Cob,
T: cob::Evaluate<R> + cob::store::Cob + cob::store::CobWithType,
C: cob::cache::Update<T> + cob::cache::Remove<T>,
{
match store.get(&tid.id) {

View File

@ -1,6 +1,7 @@
#![warn(clippy::unwrap_used)]
pub mod cache;
pub mod common;
pub mod external;
pub mod identity;
pub mod issue;
pub mod job;

240
radicle/src/cob/external.rs Normal file
View File

@ -0,0 +1,240 @@
//! # External Collaborative Objects
//!
//! This module provides an interface for external helper programs to provide
//! the evaluation logic of Collaborative Objects (COBs).
//!
//! An external COB is one that relies on an external program (so called
//! "helper", is an executable file, for example a script, or a binary), that
//! implements the evaluation logic for that particular COB:
//! Whenever an operation is to be applied to an external COB, the helper is
//! invoked with the current state of the COB and the operation to be applied.
//! It then returns the new state of the COB, according to its internal logic.
//! This concept is borrowed from Git, which supports [remote helpers] and
//! [credential helpers].
//!
//! External COBs must be based on JSON, that is, the COB itself and associated
//! actions must serialize to and deserialize from JSON.
//! Further, the helper must be able to communicate to Radicle using
//! [JSON Lines] (see further details below).
//!
//! # Invocation
//!
//! The helper is invoked by Radicle without command line arguments.
//! In the future, more arguments might be added.
//!
//! # Helper Protocol
//!
//! Radicle and the helper communicate back and forth [JSON Lines] via standard
//! streams.
//!
//! 1. The helper must read and process at least one JSON Line (containing one
//! operation) from standard input, which represents the operation to be
//! applied to the COB, along with possible concurrent operations.
//! 2. The helper must write the new state of COB to standard output in a JSON
//! Line.
//! 3. The helper may read additional JSON Lines from standard input, these are
//! to be applied "on top of" the previous operations.
//! 4. The helper must reply with the state of the COB in a JSON Line after
//! processing each operation.
//! 5. The helper must exit with a status code of zero on success, and a
//! non-zero status code on failure.
//! 6. The helper may write to standard error for logging and debugging
//! purposes.
//!
//! # Syntax of Operations
//!
//! The operations sent from Radicle to the helper are of the following shape:
//!
//! ```json
//! {
//! "title": "Operation as sent to helper"
//! "type": "object",
//! "properties": {
//! "concurrent": {
//! "type": "array",
//! "items": {
//! "$ref": "#/definitions/radicle::cob::Op"
//! }
//! },
//! "value": {
//! "type": "object",
//! "properties": {
//! "prop": {
//! "$ref": "#/definitions/radicle::cob::external::External"
//! }
//! }
//! },
//! "op": {
//! "type": "object",
//! "properties": {
//! "prop": {
//! "$ref": "#/definitions/radicle::cob::Op"
//! }
//! }
//! }
//! },
//! }
//! ```
//!
//! [JSON Lines]: https://jsonlines.org/
//! [credential helpers]: https://git-scm.com/doc/credential-helpers
//! [remote helpers]: https://git-scm.com/docs/gitremote-helpers
use std::collections::HashMap;
use std::io::{Error as IoError, ErrorKind};
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use serde_json::{from_slice, to_writer, Error as JsonError, Map, Value};
use crate::cob::object::collaboration::Evaluate;
use crate::cob::op::{Op as CobOp, OpEncodingError};
use crate::cob::store::{Cob, CobAction};
use crate::git::Oid;
use crate::storage::ReadRepository;
/// This prefix is used to generate the name of the command,
/// which is executed by the helper to apply operations.
static COB_EXTERNAL_COMMAND_PREFIX: &str = "rad-cob-";
#[derive(PartialEq, Debug, Serialize, Deserialize)]
pub struct External(Value);
impl Default for External {
fn default() -> Self {
Self(Value::Object(Map::default()))
}
}
#[derive(Error, Debug)]
pub enum Error {
#[error("op decoding failed: {0}")]
Op(#[from] OpEncodingError),
#[error("serde_json: {0}")]
Serde(#[from] JsonError),
#[error("io: {0}")]
Io(#[from] IoError),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Action {
#[serde(default)]
parents: Vec<Oid>,
#[serde(flatten)]
map: HashMap<String, Value>,
}
impl CobAction for Action {
fn parents(&self) -> Vec<Oid> {
self.parents.clone()
}
}
impl From<Action> for nonempty::NonEmpty<Action> {
fn from(action: Action) -> Self {
Self::new(action)
}
}
pub type Op = CobOp<Action>;
impl External {
fn handle<R: ReadRepository>(
&mut self,
op: Op,
concurrent: Vec<Op>,
_repo: &R,
) -> Result<(), Error> {
let command_name = {
let prefix = String::from(COB_EXTERNAL_COMMAND_PREFIX);
let type_name = op.manifest.type_name.to_string();
let suffix = type_name
.rsplit_once('.')
.map(|(_, suffix)| suffix)
.unwrap_or(type_name.as_str());
prefix + suffix
};
let child = Command::new(command_name)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let Some(stdin) = &child.stdin else {
return Err(Error::Io(IoError::new(
ErrorKind::BrokenPipe,
"stdin not available",
)));
};
#[derive(Serialize)]
struct OpMessage {
value: Value,
op: Op,
concurrent: Vec<Op>,
}
to_writer(
stdin,
&OpMessage {
value: self.0.clone(),
op,
concurrent,
},
)?;
self.0 = from_slice(&child.wait_with_output()?.stdout)?;
Ok(())
}
}
impl Cob for External {
type Action = Action;
type Error = Error;
fn from_root<R: ReadRepository>(
op: super::Op<Self::Action>,
repo: &R,
) -> Result<Self, Self::Error> {
let mut root = Self::default();
root.handle(op, vec![], repo)?;
Ok(root)
}
fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a super::Entry>>(
&mut self,
op: super::Op<Self::Action>,
concurrent: I,
repo: &R,
) -> Result<(), <Self as Cob>::Error> {
let concurrent: Vec<Op> = concurrent
.into_iter()
.map(Op::try_from)
.collect::<Result<Vec<Op>, _>>()?;
self.handle(op, concurrent, repo)
}
}
impl<R: ReadRepository> Evaluate<R> for External {
type Error = Error;
fn init(entry: &radicle_cob::Entry, store: &R) -> Result<Self, Self::Error> {
Self::from_root(Op::try_from(entry)?, store)
}
fn apply<'a, I: Iterator<Item = (&'a radicle_git_ext::Oid, &'a radicle_cob::Entry)>>(
&mut self,
entry: &radicle_cob::Entry,
concurrent: I,
repo: &R,
) -> Result<(), Self::Error> {
let concurrent: Vec<Op> = concurrent
.map(|(_, e)| e)
.map(Op::try_from)
.collect::<Result<Vec<Op>, _>>()?;
self.handle(Op::try_from(entry)?, concurrent, repo)
}
}

View File

@ -155,6 +155,12 @@ pub struct Identity {
timeline: Vec<EntryId>,
}
impl cob::store::CobWithType for Identity {
fn type_name() -> &'static TypeName {
&TYPENAME
}
}
impl std::ops::Deref for Identity {
type Target = Revision;
@ -206,6 +212,8 @@ impl Identity {
object: &ObjectId,
repo: &R,
) -> Result<Identity, store::Error> {
use cob::store::CobWithType;
cob::get::<Self, _>(repo, Self::type_name(), object)
.map(|r| r.map(|cob| cob.object))?
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object))
@ -293,10 +301,6 @@ impl store::Cob for Identity {
type Action = Action;
type Error = ApplyError;
fn type_name() -> &'static TypeName {
&TYPENAME
}
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let mut actions = op.actions.into_iter();
let Some(Action::Revision {

View File

@ -142,13 +142,15 @@ pub struct Issue {
pub(super) thread: Thread,
}
impl store::Cob for Issue {
type Action = Action;
type Error = Error;
impl cob::store::CobWithType for Issue {
fn type_name() -> &'static TypeName {
&TYPENAME
}
}
impl store::Cob for Issue {
type Action = Action;
type Error = Error;
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
@ -941,7 +943,7 @@ mod test {
use pretty_assertions::assert_eq;
use super::*;
use crate::cob::{ActorId, Reaction};
use crate::cob::{store::CobWithType, ActorId, Reaction};
use crate::git::Oid;
use crate::issue::cache::Issues as _;
use crate::test;

View File

@ -24,6 +24,8 @@ use crate::git;
use crate::prelude::ReadRepository;
use crate::storage::{Oid, WriteRepository};
use super::store::CobWithType;
/// The name of this COB type. Note that this is a "beta" COB, which
/// means it's not meant for others to rely on yet, and we may change
/// it without warning.
@ -213,10 +215,6 @@ impl Cob for Job {
type Action = Action;
type Error = Error;
fn type_name() -> &'static TypeName {
&TYPENAME
}
fn from_root<R: ReadRepository>(op: Op, _repo: &R) -> Result<Self, Self::Error> {
let mut actions = op.actions.into_iter();
let Some(Action::Trigger { commit }) = actions.next() else {
@ -245,6 +243,12 @@ impl Cob for Job {
}
}
impl CobWithType for Job {
fn type_name() -> &'static TypeName {
&TYPENAME
}
}
impl<R: ReadRepository> cob::Evaluate<R> for Job {
type Error = Error;

View File

@ -91,7 +91,7 @@ pub enum Error {
/// This error indicates that the operations are not being applied
/// in causal order, which is a requirement for this CRDT.
///
/// For example, this can occur if an operation references anothern operation
/// For example, this can occur if an operation references another operation
/// that hasn't happened yet.
#[error("causal dependency {0:?} missing")]
Missing(EntryId),
@ -1156,13 +1156,15 @@ impl Patch {
}
}
impl store::Cob for Patch {
type Action = Action;
type Error = Error;
impl cob::store::CobWithType for Patch {
fn type_name() -> &'static TypeName {
&TYPENAME
}
}
impl store::Cob for Patch {
type Action = Action;
type Error = Error;
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;

View File

@ -31,9 +31,6 @@ pub trait Cob: Sized + PartialEq + Debug {
/// Error returned by `apply` function.
type Error: std::error::Error + Send + Sync + 'static;
/// The object type name.
fn type_name() -> &'static TypeName;
/// Initialize a collarorative object from a root operation.
fn from_root<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error>;
@ -50,7 +47,10 @@ pub trait Cob: Sized + PartialEq + Debug {
fn from_history<R: ReadRepository>(
history: &crate::cob::History,
repo: &R,
) -> Result<Self, test::HistoryError<Self>> {
) -> Result<Self, test::HistoryError<Self>>
where
Self: CobWithType,
{
test::from_history::<R, Self>(history, repo)
}
@ -73,6 +73,15 @@ pub trait Cob: Sized + PartialEq + Debug {
}
}
/// Implementations are statically associated with a particular
/// type name of a collaborative object.
///
/// In most cases, this trait should be used in tandem with [`Cob`].
pub trait CobWithType {
/// The type name of the collaborative object type which backs this implementation.
fn type_name() -> &'static TypeName;
}
/// Store error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
@ -109,6 +118,7 @@ pub struct Store<'a, T, R> {
identity: Option<git::Oid>,
repo: &'a R,
witness: PhantomData<T>,
type_name: &'a TypeName,
}
impl<T, R> AsRef<R> for Store<'_, T, R> {
@ -122,11 +132,12 @@ where
R: ReadRepository + cob::Store,
{
/// Open a new generic store.
pub fn open(repo: &'a R) -> Result<Self, Error> {
pub fn open_for(type_name: &'a TypeName, repo: &'a R) -> Result<Self, Error> {
Ok(Self {
repo,
identity: None,
witness: PhantomData,
type_name,
})
}
@ -136,10 +147,41 @@ where
repo: self.repo,
witness: self.witness,
identity: Some(identity),
type_name: self.type_name,
}
}
}
impl<'a, T, R> Store<'a, T, R>
where
R: ReadRepository + cob::Store,
T: CobWithType,
{
/// Open a new generic store.
pub fn open(repo: &'a R) -> Result<Self, Error> {
Ok(Self {
repo,
identity: None,
witness: PhantomData,
type_name: T::type_name(),
})
}
}
impl<T, R> Store<'_, T, R>
where
R: ReadRepository + cob::Store,
T: Cob + cob::Evaluate<R>,
{
pub fn transaction(
&self,
actions: Vec<T::Action>,
embeds: Vec<Embed<Uri>>,
) -> Transaction<T, R> {
Transaction::new(self.type_name.clone(), actions, embeds)
}
}
impl<T, R> Store<'_, T, R>
where
R: ReadRepository + SignRepository + cob::Store,
@ -149,6 +191,7 @@ where
/// Update an object.
pub fn update<G: Signer>(
&self,
type_name: &TypeName,
object_id: ObjectId,
message: &str,
actions: impl Into<NonEmpty<T::Action>>,
@ -175,7 +218,7 @@ where
signer.public_key(),
Update {
object_id,
type_name: T::type_name().clone(),
type_name: type_name.clone(),
message: message.to_owned(),
embeds,
changes,
@ -215,7 +258,7 @@ where
parents,
signer.public_key(),
Create {
type_name: T::type_name().clone(),
type_name: self.type_name.clone(),
version: Version::default(),
message: message.to_owned(),
embeds,
@ -225,7 +268,7 @@ where
// Nb. We can't sign our refs before the identity refs exist, which are created after
// the identity COB is created. Therefore we manually sign refs when creating identity
// COBs.
if T::type_name() != &*crate::cob::identity::TYPENAME {
if self.type_name != &*crate::cob::identity::TYPENAME {
self.repo
.sign_refs(signer)
.map_err(|e| Error::SignRefs(Box::new(e)))?;
@ -235,13 +278,13 @@ where
/// Remove an object.
pub fn remove<G: Signer>(&self, id: &ObjectId, signer: &G) -> Result<(), Error> {
let name = git::refs::storage::cob(signer.public_key(), T::type_name(), id);
let name = git::refs::storage::cob(signer.public_key(), self.type_name, id);
match self
.repo
.reference_oid(signer.public_key(), &name.strip_namespace())
{
Ok(_) => {
cob::remove(self.repo, signer.public_key(), T::type_name(), id)?;
cob::remove(self.repo, signer.public_key(), self.type_name, id)?;
self.repo
.sign_refs(signer)
.map_err(|e| Error::SignRefs(Box::new(e)))?;
@ -259,12 +302,12 @@ where
impl<'a, T, R> Store<'a, T, R>
where
R: ReadRepository + cob::Store,
T: cob::Evaluate<R> + Cob,
T: Cob + cob::Evaluate<R>,
T::Action: Serialize,
{
/// Get an object.
pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
cob::get::<T, _>(self.repo, T::type_name(), id)
cob::get::<T, _>(self.repo, self.type_name, id)
.map(|r| r.map(|cob| cob.object))
.map_err(Error::from)
}
@ -273,7 +316,7 @@ where
pub fn all(
&self,
) -> Result<impl ExactSizeIterator<Item = Result<(ObjectId, T), Error>> + 'a, Error> {
let raw = cob::list::<T, _>(self.repo, T::type_name())?;
let raw = cob::list::<T, _>(self.repo, self.type_name)?;
Ok(raw.into_iter().map(|o| Ok((*o.id(), o.object))))
}
@ -285,7 +328,7 @@ where
/// Return objects count.
pub fn count(&self) -> Result<usize, Error> {
let raw = cob::list::<T, _>(self.repo, T::type_name())?;
let raw = cob::list::<T, _>(self.repo, self.type_name)?;
Ok(raw.len())
}
@ -296,15 +339,18 @@ where
pub struct Transaction<T: Cob + cob::Evaluate<R>, R> {
actions: Vec<T::Action>,
embeds: Vec<Embed<Uri>>,
repo: PhantomData<R>,
type_name: TypeName,
}
impl<T: Cob + cob::Evaluate<R>, R> Default for Transaction<T, R> {
impl<T: Cob + CobWithType + cob::Evaluate<R>, R> Default for Transaction<T, R> {
fn default() -> Self {
Self {
actions: Vec::new(),
embeds: Vec::new(),
repo: PhantomData,
type_name: T::type_name().clone(),
}
}
}
@ -312,6 +358,20 @@ impl<T: Cob + cob::Evaluate<R>, R> Default for Transaction<T, R> {
impl<T, R> Transaction<T, R>
where
T: Cob + cob::Evaluate<R>,
{
pub fn new(type_name: TypeName, actions: Vec<T::Action>, embeds: Vec<Embed<Uri>>) -> Self {
Self {
actions,
embeds,
repo: PhantomData,
type_name,
}
}
}
impl<T, R> Transaction<T, R>
where
T: Cob + CobWithType + cob::Evaluate<R>,
{
/// Create a new transaction to be used as the initial set of operations for a COB.
pub fn initial<G, F, Tx>(
@ -337,14 +397,26 @@ where
store.create(message, actions, tx.embeds, signer)
}
}
/// Add an operation to this transaction.
impl<T, R> Transaction<T, R>
where
T: Cob + cob::Evaluate<R>,
{
/// Add an action to this transaction.
pub fn push(&mut self, action: T::Action) -> Result<(), Error> {
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);
Ok(())
}
/// Embed media into the transaction.
pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed<Uri>>) -> Result<(), Error> {
self.embeds.extend(embeds);
@ -372,7 +444,7 @@ where
head,
object: CollaborativeObject { object, .. },
..
} = store.update(id, msg, actions, self.embeds, signer)?;
} = store.update(&self.type_name, id, msg, actions, self.embeds, signer)?;
Ok((object, head))
}
@ -424,7 +496,7 @@ pub mod test {
/// Turn a history into a concrete type, by traversing the history and applying each operation
/// to the state, skipping branches that return errors.
pub fn from_history<R: ReadRepository, T: Cob>(
pub fn from_history<R: ReadRepository, T: Cob + CobWithType>(
history: &crate::cob::History,
repo: &R,
) -> Result<T, HistoryError<T>> {

View File

@ -22,7 +22,7 @@ use crate::profile::env;
use crate::storage::ReadRepository;
use crate::test::arbitrary;
use super::store::Cob;
use super::store::{Cob, CobWithType};
use super::thread;
/// Convenience type for building histories.
@ -57,6 +57,7 @@ impl HistoryBuilder<thread::Thread> {
impl<T: Cob> HistoryBuilder<T>
where
T: CobWithType,
T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
{
pub fn new<G: Signer>(actions: &[T::Action], time: Timestamp, signer: &G) -> HistoryBuilder<T> {
@ -133,12 +134,13 @@ impl<A> Deref for HistoryBuilder<A> {
}
/// Create a new test history.
pub fn history<T: Cob, G: Signer>(
pub fn history<T, G: Signer>(
actions: &[T::Action],
time: Timestamp,
signer: &G,
) -> HistoryBuilder<T>
where
T: Cob + CobWithType,
T::Action: Serialize + Eq + 'static,
{
HistoryBuilder::new(actions, time, signer)
@ -163,13 +165,14 @@ impl<G> Actor<G> {
impl<G: Signer> Actor<G> {
/// Create a new operation.
pub fn op_with<T: Cob>(
pub fn op_with<T>(
&mut self,
actions: impl IntoIterator<Item = T::Action>,
identity: Option<Oid>,
timestamp: Timestamp,
) -> Op<T::Action>
where
T: Cob + CobWithType,
T::Action: Clone + Serialize,
{
let actions = actions.into_iter().collect::<Vec<_>>();
@ -199,8 +202,9 @@ impl<G: Signer> Actor<G> {
}
/// Create a new operation.
pub fn op<T: Cob>(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Op<T::Action>
pub fn op<T>(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Op<T::Action>
where
T: Cob + CobWithType,
T::Action: Clone + Serialize,
{
let identity = arbitrary::oid();

View File

@ -299,6 +299,12 @@ impl<T> Default for Thread<T> {
}
}
impl<T> cob::store::CobWithType for Thread<T> {
fn type_name() -> &'static radicle_cob::TypeName {
&TYPENAME
}
}
impl<T> Thread<T> {
pub fn new(id: CommentId, comment: T) -> Self {
Self {
@ -403,10 +409,6 @@ impl cob::store::Cob for Thread {
type Action = Action;
type Error = Error;
fn type_name() -> &'static radicle_cob::TypeName {
&TYPENAME
}
fn from_root<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
let author = op.author;
let entry = op.id;

View File

@ -8,4 +8,4 @@ pub use did::Did;
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility};
pub use project::Project;
pub use crate::cob::identity::{Error, Identity, IdentityMut};
pub use crate::cob::identity::{Action, Error, Identity, IdentityMut, TYPENAME};

View File

@ -31,7 +31,7 @@ pub mod test;
pub mod version;
pub mod web;
pub use cob::{issue, patch};
pub use cob::{external, issue, patch};
pub use node::Node;
pub use profile::Profile;
pub use storage::git::Storage;