cob: Use hashes for operation ids
It's fairly easy for a user to (by mistake or intentionally) create two operations with the same OpId. This patch makes it much less likely, and ensures that if the OpId is equal, it's because the operations are identical.
This commit is contained in:
parent
5c0505e221
commit
06a92d52d9
|
|
@ -1807,6 +1807,7 @@ dependencies = [
|
||||||
"radicle-crypto",
|
"radicle-crypto",
|
||||||
"radicle-git-ext",
|
"radicle-git-ext",
|
||||||
"radicle-ssh",
|
"radicle-ssh",
|
||||||
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"siphasher",
|
"siphasher",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ delegates creating proposals concurrently.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
|
$ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
|
||||||
✓ Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱
|
✓ Identity proposal '16d2b7c47bb9615da1a72e67f66f0e1d345be2e3' created 🌱
|
||||||
title: Add Alice
|
title: Add Alice
|
||||||
description: Add Alice as a delegate
|
description: Add Alice as a delegate
|
||||||
status: ❲open❳
|
status: ❲open❳
|
||||||
|
|
@ -48,7 +48,7 @@ Quorum Reached
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm
|
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm
|
||||||
✓ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱
|
✓ Identity proposal '9615d03e4d98cf413994b6fdadf170747064c23d' created 🌱
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
status: ❲open❳
|
status: ❲open❳
|
||||||
|
|
@ -93,7 +93,7 @@ second proposal, then the identity would be out of date. So let's run
|
||||||
through that and see what happens.
|
through that and see what happens.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id accept 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id accept 16d2b7c47bb9615da1a72e67f66f0e1d345be2e3 --no-confirm
|
||||||
✓ Accepted proposal ✓
|
✓ Accepted proposal ✓
|
||||||
title: Add Alice
|
title: Add Alice
|
||||||
description: Add Alice as a delegate
|
description: Add Alice as a delegate
|
||||||
|
|
@ -137,7 +137,7 @@ Quorum Reached
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id commit 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id commit 16d2b7c47bb9615da1a72e67f66f0e1d345be2e3 --no-confirm
|
||||||
✓ Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱
|
✓ Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱
|
||||||
title: Add Alice
|
title: Add Alice
|
||||||
description: Add Alice as a delegate
|
description: Add Alice as a delegate
|
||||||
|
|
@ -183,7 +183,7 @@ Quorum Reached
|
||||||
Now, when we go to accept the second proposal:
|
Now, when we go to accept the second proposal:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id accept 9615d03e4d98cf413994b6fdadf170747064c23d --no-confirm
|
||||||
! Warning: Revision is out of date
|
! Warning: Revision is out of date
|
||||||
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
|
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
|
||||||
👉 Consider using 'rad id rebase' to update the proposal to the latest identity
|
👉 Consider using 'rad id rebase' to update the proposal to the latest identity
|
||||||
|
|
@ -238,19 +238,19 @@ Note that a warning was emitted:
|
||||||
If we attempt to commit this revision, the command will fail:
|
If we attempt to commit this revision, the command will fail:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id commit 9615d03e4d98cf413994b6fdadf170747064c23d --no-confirm
|
||||||
! Warning: Revision is out of date
|
! Warning: Revision is out of date
|
||||||
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
|
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
|
||||||
👉 Consider using 'rad id rebase' to update the proposal to the latest identity
|
👉 Consider using 'rad id rebase' to update the proposal to the latest identity
|
||||||
✗ Id failed: the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1'
|
✗ Id failed: the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision '6877dc63e001e7f7fcb285f5f530948b3d96b488'
|
||||||
```
|
```
|
||||||
|
|
||||||
So, let's fix this by running a rebase on the proposal's revision:
|
So, let's fix this by running a rebase on the proposal's revision:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id rebase c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id rebase 9615d03e4d98cf413994b6fdadf170747064c23d --no-confirm
|
||||||
✓ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱
|
✓ Identity proposal '9615d03e4d98cf413994b6fdadf170747064c23d' rebased 🌱
|
||||||
✓ Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4'
|
✓ Revision 'aaa890c3531f880c9901b162ab38016ceb559c9f'
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
status: ❲open❳
|
status: ❲open❳
|
||||||
|
|
@ -293,9 +293,9 @@ Quorum Reached
|
||||||
We can now update the proposal to have both keys in the delegates set:
|
We can now update the proposal to have both keys in the delegates set:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id update c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
|
$ rad id update 9615d03e4d98cf413994b6fdadf170747064c23d --rev aaa890c3531f880c9901b162ab38016ceb559c9f --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
|
||||||
✓ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱
|
✓ Identity proposal '9615d03e4d98cf413994b6fdadf170747064c23d' updated 🌱
|
||||||
✓ Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6'
|
✓ Revision '24ad4a6ce84b1ce4b8cc754494c23f1079020a14'
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
status: ❲open❳
|
status: ❲open❳
|
||||||
|
|
@ -338,10 +338,10 @@ Quorum Reached
|
||||||
Finally, we can accept and commit this proposal, creating the final
|
Finally, we can accept and commit this proposal, creating the final
|
||||||
state of our new Radicle identity:
|
state of our new Radicle identity:
|
||||||
|
|
||||||
$ rad id show c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --revisions
|
$ rad id show 9615d03e4d98cf413994b6fdadf170747064c23d --revisions
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm
|
$ rad id accept 9615d03e4d98cf413994b6fdadf170747064c23d --rev 24ad4a6ce84b1ce4b8cc754494c23f1079020a14 --no-confirm
|
||||||
✓ Accepted proposal ✓
|
✓ Accepted proposal ✓
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
|
|
@ -385,7 +385,7 @@ Quorum Reached
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm
|
$ rad id commit 9615d03e4d98cf413994b6fdadf170747064c23d --rev 24ad4a6ce84b1ce4b8cc754494c23f1079020a14 --no-confirm
|
||||||
✓ Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱
|
✓ Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Let's add Bob as a delegate using their DID
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
|
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
|
||||||
✓ Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱
|
✓ Identity proposal 'de4102c1b9b9b83683d7d9ca80c79ffebd62ac83' created 🌱
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
status: ❲open❳
|
status: ❲open❳
|
||||||
|
|
@ -89,7 +89,7 @@ Finally, we can see whether the `Quorum` was reached:
|
||||||
Let's see what happens when we reject the change:
|
Let's see what happens when we reject the change:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id reject 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id reject de4102c1b9b9b83683d7d9ca80c79ffebd62ac83 --no-confirm
|
||||||
✓ Rejected proposal 👎
|
✓ Rejected proposal 👎
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
|
|
@ -145,7 +145,7 @@ increased to `1`.
|
||||||
Instead, let's accept the proposal:
|
Instead, let's accept the proposal:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id accept 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id accept de4102c1b9b9b83683d7d9ca80c79ffebd62ac83 --no-confirm
|
||||||
✓ Accepted proposal ✓
|
✓ Accepted proposal ✓
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
|
|
@ -207,7 +207,7 @@ As well as that, the `Quorum` has now been reached:
|
||||||
At this point, we can commit the proposal and update the identity:
|
At this point, we can commit the proposal and update the identity:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id commit 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
|
$ rad id commit de4102c1b9b9b83683d7d9ca80c79ffebd62ac83 --no-confirm
|
||||||
✓ Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱
|
✓ Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱
|
||||||
title: Add Bob
|
title: Add Bob
|
||||||
description: Add Bob as a delegate
|
description: Add Bob as a delegate
|
||||||
|
|
@ -255,7 +255,7 @@ the `--threshold` option:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm
|
$ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm
|
||||||
✓ Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱
|
✓ Identity proposal '14a980c4061f06433ace03cf6b1e5eedba4f8cfc' created 🌱
|
||||||
title: Update threshold
|
title: Update threshold
|
||||||
description: Update to safer threshold
|
description: Update to safer threshold
|
||||||
status: ❲open❳
|
status: ❲open❳
|
||||||
|
|
@ -298,8 +298,8 @@ Quorum Reached
|
||||||
But we change our minds and decide to close the proposal instead:
|
But we change our minds and decide to close the proposal instead:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id close dc00640d3152ea5f1df59f39f2f5983d2ad21810 --no-confirm
|
$ rad id close 14a980c4061f06433ace03cf6b1e5eedba4f8cfc --no-confirm
|
||||||
✓ Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810'
|
✓ Closed identity proposal '14a980c4061f06433ace03cf6b1e5eedba4f8cfc'
|
||||||
title: Update threshold
|
title: Update threshold
|
||||||
description: Update to safer threshold
|
description: Update to safer threshold
|
||||||
status: ❲closed❳
|
status: ❲closed❳
|
||||||
|
|
@ -348,15 +348,15 @@ Radicle identity, then we can use the list command:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id list
|
$ rad id list
|
||||||
06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob" ❲committed❳
|
14a980c4061f06433ace03cf6b1e5eedba4f8cfc "Update threshold" ❲closed❳
|
||||||
dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold" ❲closed❳
|
de4102c1b9b9b83683d7d9ca80c79ffebd62ac83 "Add Bob" ❲committed❳
|
||||||
```
|
```
|
||||||
|
|
||||||
And if we want to view the latest state of any proposal we can use the
|
And if we want to view the latest state of any proposal we can use the
|
||||||
show command:
|
show command:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad id show dc00640d3152ea5f1df59f39f2f5983d2ad21810
|
$ rad id show 14a980c4061f06433ace03cf6b1e5eedba4f8cfc
|
||||||
title: Update threshold
|
title: Update threshold
|
||||||
description: Update to safer threshold
|
description: Update to safer threshold
|
||||||
status: ❲closed❳
|
status: ❲closed❳
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ The issue is now listed under our project.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad issue list
|
$ rad issue list
|
||||||
2b4650e3c66d568132034de0d02871a2fbf9c5b5 "flux capacitor underpowered"
|
e379d630f91a6082d3c7677467eb0d4875635d74 "flux capacitor underpowered"
|
||||||
```
|
```
|
||||||
|
|
||||||
Great! Now we've documented the issue for ourselves and others.
|
Great! Now we've documented the issue for ourselves and others.
|
||||||
|
|
@ -22,20 +22,20 @@ others to work on. This is to ensure work is not duplicated.
|
||||||
Let's assign ourselves to this one.
|
Let's assign ourselves to this one.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad assign 2b4650e3c66d568132034de0d02871a2fbf9c5b5 did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
$ rad assign e379d630f91a6082d3c7677467eb0d4875635d74 did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||||
```
|
```
|
||||||
|
|
||||||
It will now show in the list of issues assigned to us.
|
It will now show in the list of issues assigned to us.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad issue list --assigned
|
$ rad issue list --assigned
|
||||||
2b4650e3c66d568132034de0d02871a2fbf9c5b5 "flux capacitor underpowered" did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
e379d630f91a6082d3c7677467eb0d4875635d74 "flux capacitor underpowered" did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: this can always be undone with the `unassign` subcommand.
|
Note: this can always be undone with the `unassign` subcommand.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad unassign 2b4650e3c66d568132034de0d02871a2fbf9c5b5 did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
$ rad unassign e379d630f91a6082d3c7677467eb0d4875635d74 did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||||
```
|
```
|
||||||
|
|
||||||
Great, now we have communicated to the world about our car's defect.
|
Great, now we have communicated to the world about our car's defect.
|
||||||
|
|
@ -44,8 +44,8 @@ But wait! We've found an important detail about the car's power requirements.
|
||||||
It will help whoever works on a fix.
|
It will help whoever works on a fix.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad comment 2b4650e3c66d568132034de0d02871a2fbf9c5b5 --message 'The flux capacitor needs 1.21 Gigawatts'
|
$ rad comment e379d630f91a6082d3c7677467eb0d4875635d74 --message 'The flux capacitor needs 1.21 Gigawatts'
|
||||||
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/7
|
f1895792f7b1b56590aa21e34454bde74d04649a
|
||||||
$ rad comment 2b4650e3c66d568132034de0d02871a2fbf9c5b5 --reply-to z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/7 --message 'More power!'
|
$ rad comment e379d630f91a6082d3c7677467eb0d4875635d74 --reply-to f1895792f7b1b56590aa21e34454bde74d04649a --message 'More power!'
|
||||||
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/8
|
0bf5f874c57ac0a5cc010a9895dd0fec9edc4f3d
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ No description provided.
|
||||||
╰───────────────────────────────────
|
╰───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
✓ Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱
|
✓ Patch fd1df2db86867aa859541464fa334d0b22988ea7 created 🌱
|
||||||
```
|
```
|
||||||
|
|
||||||
It will now be listed as one of the project's open patches.
|
It will now be listed as one of the project's open patches.
|
||||||
|
|
@ -55,17 +55,17 @@ $ rad patch
|
||||||
|
|
||||||
❲YOU PROPOSED❳
|
❲YOU PROPOSED❳
|
||||||
|
|
||||||
define power requirements d4ef85f57a8 R0 3e674d1 (flux-capacitor-power) ahead 1, behind 0
|
define power requirements fd1df2db868 R0 3e674d1 (flux-capacitor-power) ahead 1, behind 0
|
||||||
└─ * opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [..]
|
└─ * opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [..]
|
||||||
└─ * patch id d4ef85f57a849bd845915d7a66a2192cd23811f6
|
└─ * patch id fd1df2db86867aa859541464fa334d0b22988ea7
|
||||||
|
|
||||||
❲OTHERS PROPOSED❳
|
❲OTHERS PROPOSED❳
|
||||||
|
|
||||||
Nothing to show.
|
Nothing to show.
|
||||||
|
|
||||||
$ rad patch show d4ef85f57a849bd845915d7a66a2192cd23811f6
|
$ rad patch show fd1df2db86867aa859541464fa334d0b22988ea7
|
||||||
|
|
||||||
patch d4ef85f57a849bd845915d7a66a2192cd23811f6
|
patch fd1df2db86867aa859541464fa334d0b22988ea7
|
||||||
|
|
||||||
╭─ define power requirements ───────
|
╭─ define power requirements ───────
|
||||||
|
|
||||||
|
|
@ -94,34 +94,34 @@ $ git commit --message "Add README, just for the fun"
|
||||||
[flux-capacitor-power 27857ec] Add README, just for the fun
|
[flux-capacitor-power 27857ec] Add README, just for the fun
|
||||||
1 file changed, 0 insertions(+), 0 deletions(-)
|
1 file changed, 0 insertions(+), 0 deletions(-)
|
||||||
create mode 100644 README.md
|
create mode 100644 README.md
|
||||||
$ rad patch update --message "Add README, just for the fun" --no-confirm d4ef85f57a849bd845915d7a66a2192cd23811f6
|
$ rad patch update --message "Add README, just for the fun" --no-confirm fd1df2db86867aa859541464fa334d0b22988ea7
|
||||||
|
|
||||||
🌱 Updating patch for heartwood
|
🌱 Updating patch for heartwood
|
||||||
|
|
||||||
✓ Pushing HEAD to storage...
|
✓ Pushing HEAD to storage...
|
||||||
✓ Analyzing remotes...
|
✓ Analyzing remotes...
|
||||||
|
|
||||||
d4ef85f57a8 R0 (3e674d1) -> R1 (27857ec)
|
fd1df2db868 R0 (3e674d1) -> R1 (27857ec)
|
||||||
1 commit(s) ahead, 0 commit(s) behind
|
1 commit(s) ahead, 0 commit(s) behind
|
||||||
|
|
||||||
|
|
||||||
✓ Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱
|
✓ Patch fd1df2db86867aa859541464fa334d0b22988ea7 updated 🌱
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
And lets leave a quick comment for our team:
|
And lets leave a quick comment for our team:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad comment d4ef85f57a849bd845915d7a66a2192cd23811f6 --message 'I cannot wait to get back to the 90s!'
|
$ rad comment fd1df2db86867aa859541464fa334d0b22988ea7 --message 'I cannot wait to get back to the 90s!'
|
||||||
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/5
|
84ef44764de73695cf30e6b284585d2c50d6d0e5
|
||||||
$ rad comment d4ef85f57a849bd845915d7a66a2192cd23811f6 --message 'I cannot wait to get back to the 90s!' --reply-to z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/5
|
$ rad comment fd1df2db86867aa859541464fa334d0b22988ea7 --message 'I cannot wait to get back to the 90s!' --reply-to 84ef44764de73695cf30e6b284585d2c50d6d0e5
|
||||||
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6
|
2fa3ac18d82ebdafe73484a15fa9823355c4664b
|
||||||
```
|
```
|
||||||
|
|
||||||
Now, let's checkout the patch that we just created:
|
Now, let's checkout the patch that we just created:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ rad patch checkout d4ef85f57a849bd845915d7a66a2192cd23811f6
|
$ rad patch checkout fd1df2db86867aa859541464fa334d0b22988ea7
|
||||||
✓ Performing patch checkout...
|
✓ Performing patch checkout...
|
||||||
✓ Switched to branch patch/d4ef85f57a8
|
✓ Switched to branch patch/fd1df2db868
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -468,12 +468,19 @@ fn select<'a>(
|
||||||
previous: &Identity<Oid>,
|
previous: &Identity<Oid>,
|
||||||
interactive: &Interactive,
|
interactive: &Interactive,
|
||||||
) -> anyhow::Result<(RevisionId, &'a identity::Revision)> {
|
) -> anyhow::Result<(RevisionId, &'a identity::Revision)> {
|
||||||
let (id, revision) = match id {
|
let (id, revision) = match (id, interactive) {
|
||||||
None => {
|
(None, Interactive::Yes) => {
|
||||||
let (id, revision) = term::proposal::revision_select(proposal).unwrap();
|
let (id, revision) = term::proposal::revision_select(proposal).unwrap();
|
||||||
(*id, revision)
|
(*id, revision)
|
||||||
}
|
}
|
||||||
Some(id) => {
|
(None, Interactive::No) => {
|
||||||
|
let (id, revision) = proposal
|
||||||
|
.revisions()
|
||||||
|
.next()
|
||||||
|
.ok_or(anyhow!("No revisions found!"))?;
|
||||||
|
(*id, revision)
|
||||||
|
}
|
||||||
|
(Some(id), _) => {
|
||||||
let revision = proposal
|
let revision = proposal
|
||||||
.revision(&id)
|
.revision(&id)
|
||||||
.context(format!("No revision found for {id}"))?
|
.context(format!("No revision found for {id}"))?
|
||||||
|
|
@ -494,13 +501,20 @@ fn commit_select<'a>(
|
||||||
previous: &'a Identity<Oid>,
|
previous: &'a Identity<Oid>,
|
||||||
interactive: &Interactive,
|
interactive: &Interactive,
|
||||||
) -> anyhow::Result<(RevisionId, &'a identity::Revision)> {
|
) -> anyhow::Result<(RevisionId, &'a identity::Revision)> {
|
||||||
let (id, revision) = match id {
|
let (id, revision) = match (id, interactive) {
|
||||||
None => {
|
(None, Interactive::Yes) => {
|
||||||
let (id, revision) =
|
let (id, revision) =
|
||||||
term::proposal::revision_commit_select(proposal, previous).unwrap();
|
term::proposal::revision_commit_select(proposal, previous).unwrap();
|
||||||
(*id, revision)
|
(*id, revision)
|
||||||
}
|
}
|
||||||
Some(id) => {
|
(None, Interactive::No) => {
|
||||||
|
let (id, revision) = proposal
|
||||||
|
.revisions()
|
||||||
|
.find(|(_, r)| r.is_quorum_reached(previous))
|
||||||
|
.ok_or(anyhow!("No revisions with quorum found"))?;
|
||||||
|
(*id, revision)
|
||||||
|
}
|
||||||
|
(Some(id), _) => {
|
||||||
let revision = proposal
|
let revision = proposal
|
||||||
.revision(&id)
|
.revision(&id)
|
||||||
.context(format!("No revision found for {id}"))?
|
.context(format!("No revision found for {id}"))?
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ use radicle_node::test::{
|
||||||
logger,
|
logger,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Seed used in tests.
|
||||||
|
const RAD_SEED: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||||
|
|
||||||
/// Run a CLI test file.
|
/// Run a CLI test file.
|
||||||
fn test<'a>(
|
fn test<'a>(
|
||||||
test: impl AsRef<Path>,
|
test: impl AsRef<Path>,
|
||||||
|
|
@ -40,6 +43,7 @@ fn test<'a>(
|
||||||
.env("GIT_COMMITTER_NAME", "radicle")
|
.env("GIT_COMMITTER_NAME", "radicle")
|
||||||
.env("RAD_HOME", home.to_string_lossy())
|
.env("RAD_HOME", home.to_string_lossy())
|
||||||
.env("RAD_PASSPHRASE", "radicle")
|
.env("RAD_PASSPHRASE", "radicle")
|
||||||
|
.env("RAD_SEED", RAD_SEED)
|
||||||
.env("TZ", "UTC")
|
.env("TZ", "UTC")
|
||||||
.env("LANG", "C")
|
.env("LANG", "C")
|
||||||
.env(radicle_cob::git::RAD_COMMIT_TIME, "1671125284")
|
.env(radicle_cob::git::RAD_COMMIT_TIME, "1671125284")
|
||||||
|
|
@ -54,16 +58,7 @@ fn test<'a>(
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rad_auth() {
|
fn rad_auth() {
|
||||||
test(
|
test("examples/rad-auth.md", Path::new("."), None, []).unwrap();
|
||||||
"examples/rad-auth.md",
|
|
||||||
Path::new("."),
|
|
||||||
None,
|
|
||||||
[(
|
|
||||||
"RAD_SEED",
|
|
||||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
||||||
)],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,16 @@ impl change::Storage for git2::Repository {
|
||||||
history_type,
|
history_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
let revision = write_manifest(self, &manifest, &contents)?;
|
let (revision, blob_oids) = write_manifest(self, &manifest, &contents)?;
|
||||||
let tree = self.find_tree(revision)?;
|
let tree = self.find_tree(revision)?;
|
||||||
|
let contents = NonEmpty::collect(blob_oids.into_iter().zip(contents).map(|(oid, op)| {
|
||||||
|
entry::EntryBlob {
|
||||||
|
oid: oid.into(),
|
||||||
|
data: op,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
// SAFETY: We know it's not empty because the original `contents` is `NonEmpty`.
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let signature = {
|
let signature = {
|
||||||
let sig = signer.sign(revision.as_bytes());
|
let sig = signer.sign(revision.as_bytes());
|
||||||
|
|
@ -205,10 +213,13 @@ fn load_contents(
|
||||||
entry.kind().and_then(|kind| match kind {
|
entry.kind().and_then(|kind| match kind {
|
||||||
git2::ObjectType::Blob => {
|
git2::ObjectType::Blob => {
|
||||||
let name = entry.name()?.parse::<i8>().ok()?;
|
let name = entry.name()?.parse::<i8>().ok()?;
|
||||||
let content = entry.to_object(repo).and_then(|object| {
|
let blob = entry
|
||||||
object.peel_to_blob().map(|blob| blob.content().to_owned())
|
.to_object(repo)
|
||||||
});
|
.and_then(|object| object.peel_to_blob())
|
||||||
Some(content.map(|c| (name, c)))
|
.map(entry::EntryBlob::from)
|
||||||
|
.map(|b| (name, b));
|
||||||
|
|
||||||
|
Some(blob)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
|
|
@ -279,8 +290,8 @@ where
|
||||||
fn write_manifest(
|
fn write_manifest(
|
||||||
repo: &git2::Repository,
|
repo: &git2::Repository,
|
||||||
manifest: &store::Manifest,
|
manifest: &store::Manifest,
|
||||||
contents: &entry::Contents,
|
contents: &NonEmpty<Vec<u8>>,
|
||||||
) -> Result<git2::Oid, git2::Error> {
|
) -> Result<(git2::Oid, Vec<git2::Oid>), git2::Error> {
|
||||||
let mut tb = repo.treebuilder(None)?;
|
let mut tb = repo.treebuilder(None)?;
|
||||||
// SAFETY: we're serializing to an in memory buffer so the only source of
|
// SAFETY: we're serializing to an in memory buffer so the only source of
|
||||||
// errors here is a programming error, which we can't recover from
|
// errors here is a programming error, which we can't recover from
|
||||||
|
|
@ -292,10 +303,13 @@ fn write_manifest(
|
||||||
git2::FileMode::Blob.into(),
|
git2::FileMode::Blob.into(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let mut blob_oids = Vec::new();
|
||||||
for (ix, op) in contents.iter().enumerate() {
|
for (ix, op) in contents.iter().enumerate() {
|
||||||
let change_blob = repo.blob(op.as_ref())?;
|
let oid = repo.blob(op.as_ref())?;
|
||||||
tb.insert(&ix.to_string(), change_blob, git2::FileMode::Blob.into())?;
|
tb.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?;
|
||||||
|
blob_oids.push(oid);
|
||||||
}
|
}
|
||||||
|
let tree_oid = tb.write()?;
|
||||||
|
|
||||||
tb.write()
|
Ok((tree_oid, blob_oids))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
use nonempty::NonEmpty;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -46,7 +47,7 @@ pub struct Template<Id> {
|
||||||
pub history_type: String,
|
pub history_type: String,
|
||||||
pub tips: Vec<Id>,
|
pub tips: Vec<Id>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub contents: Contents,
|
pub contents: NonEmpty<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ use git_ext::Oid;
|
||||||
use radicle_dag::Dag;
|
use radicle_dag::Dag;
|
||||||
|
|
||||||
use crate::history::entry::{EntryId, EntryWithClock};
|
use crate::history::entry::{EntryId, EntryWithClock};
|
||||||
use crate::history::Clock;
|
|
||||||
use crate::{change::Change, history, pruning_fold};
|
use crate::{change::Change, history, pruning_fold};
|
||||||
|
|
||||||
/// # Panics
|
/// # Panics
|
||||||
|
|
@ -43,9 +42,9 @@ pub fn evaluate(root: Oid, graph: &Dag<Oid, Change>, rng: fastrand::Rng) -> hist
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
let entry = &entries[&EntryId::from(*e)];
|
let entry = &entries[&EntryId::from(*e)];
|
||||||
let clock = entry.clock();
|
let clock = entry.range();
|
||||||
|
|
||||||
clock + entry.contents().len() as Clock - 1
|
*clock.end()
|
||||||
})
|
})
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or_default() // When there are no operations, the clock is zero.
|
.unwrap_or_default() // When there are no operations, the clock is zero.
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,27 @@ use radicle_crypto::PublicKey;
|
||||||
|
|
||||||
use crate::pruning_fold;
|
use crate::pruning_fold;
|
||||||
|
|
||||||
|
/// Blob under an entry.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct EntryBlob {
|
||||||
|
/// The OID of the blob.
|
||||||
|
pub oid: Oid,
|
||||||
|
/// The blob data.
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> From<git2::Blob<'r>> for EntryBlob {
|
||||||
|
fn from(blob: git2::Blob) -> Self {
|
||||||
|
Self {
|
||||||
|
oid: blob.id().into(),
|
||||||
|
data: blob.content().to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Entry contents.
|
/// Entry contents.
|
||||||
/// This is the change payload.
|
/// This is the change payload.
|
||||||
pub type Contents = NonEmpty<Vec<u8>>;
|
pub type Contents = NonEmpty<EntryBlob>;
|
||||||
|
|
||||||
/// Logical clock used to track causality in change graph.
|
/// Logical clock used to track causality in change graph.
|
||||||
pub type Clock = u64;
|
pub type Clock = u64;
|
||||||
|
|
@ -146,6 +164,19 @@ impl EntryWithClock {
|
||||||
pub fn clock(&self) -> Clock {
|
pub fn clock(&self) -> Clock {
|
||||||
self.clock
|
self.clock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the clock range.
|
||||||
|
pub fn range(&self) -> std::ops::RangeInclusive<Clock> {
|
||||||
|
self.clock..=(self.clock + self.contents.tail.len() as Clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterator over the changes, including the clock.
|
||||||
|
pub fn changes(&self) -> impl Iterator<Item = (Clock, &EntryBlob)> {
|
||||||
|
self.contents
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, blob)| (self.clock + ix as u64, blob))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl pruning_fold::GraphNode for EntryWithClock {
|
impl pruning_fold::GraphNode for EntryWithClock {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use std::collections::BTreeSet;
|
||||||
use git_ext::Oid;
|
use git_ext::Oid;
|
||||||
|
|
||||||
use crate::change::store::Manifest;
|
use crate::change::store::Manifest;
|
||||||
use crate::{change, identity::Identity, Contents, History, ObjectId, TypeName};
|
use crate::{change, identity::Identity, History, ObjectId, TypeName};
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
|
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
|
||||||
// Linking Exception. For full terms see the included LICENSE file.
|
// Linking Exception. For full terms see the included LICENSE file.
|
||||||
|
|
||||||
|
use nonempty::NonEmpty;
|
||||||
|
|
||||||
use crate::Store;
|
use crate::Store;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -12,7 +14,7 @@ pub struct Create {
|
||||||
/// The type of history that will be used for this object.
|
/// The type of history that will be used for this object.
|
||||||
pub history_type: String,
|
pub history_type: String,
|
||||||
/// The CRDT history to initialize this object with.
|
/// The CRDT history to initialize this object with.
|
||||||
pub contents: Contents,
|
pub contents: NonEmpty<Vec<u8>>,
|
||||||
/// The typename for this object.
|
/// The typename for this object.
|
||||||
pub typename: TypeName,
|
pub typename: TypeName,
|
||||||
/// The message to add when creating this object.
|
/// The message to add when creating this object.
|
||||||
|
|
@ -61,35 +63,30 @@ where
|
||||||
G: crypto::Signer,
|
G: crypto::Signer,
|
||||||
Resource: Identity,
|
Resource: Identity,
|
||||||
{
|
{
|
||||||
let Create {
|
let Create { ref typename, .. } = &args;
|
||||||
ref contents,
|
|
||||||
ref typename,
|
|
||||||
..
|
|
||||||
} = &args;
|
|
||||||
|
|
||||||
let init_change = storage
|
let init_change = storage
|
||||||
.store(resource.content_id(), signer, args.template())
|
.store(resource.content_id(), signer, args.template())
|
||||||
.map_err(error::Create::from)?;
|
.map_err(error::Create::from)?;
|
||||||
|
let object_id = init_change.id().into();
|
||||||
|
|
||||||
|
storage
|
||||||
|
.update(identifier, typename, &object_id, &init_change)
|
||||||
|
.map_err(|err| error::Create::Refs { err: Box::new(err) })?;
|
||||||
|
|
||||||
let history = History::new_from_root(
|
let history = History::new_from_root(
|
||||||
*init_change.id(),
|
*init_change.id(),
|
||||||
init_change.signature.key,
|
init_change.signature.key,
|
||||||
resource.content_id(),
|
resource.content_id(),
|
||||||
contents.clone(),
|
init_change.contents,
|
||||||
init_change.timestamp,
|
init_change.timestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
let object_id = init_change.id().into();
|
|
||||||
storage
|
|
||||||
.update(identifier, typename, &object_id, &init_change)
|
|
||||||
.map_err(|err| error::Create::Refs { err: Box::new(err) })?;
|
|
||||||
|
|
||||||
Ok(CollaborativeObject {
|
Ok(CollaborativeObject {
|
||||||
manifest: Manifest {
|
manifest: Manifest {
|
||||||
typename: args.typename,
|
typename: args.typename,
|
||||||
history_type: args.history_type,
|
history_type: args.history_type,
|
||||||
},
|
},
|
||||||
history,
|
history,
|
||||||
id: init_change.id().into(),
|
id: object_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
|
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
|
||||||
// Linking Exception. For full terms see the included LICENSE file.
|
// Linking Exception. For full terms see the included LICENSE file.
|
||||||
|
|
||||||
|
use nonempty::NonEmpty;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
change, change_graph::ChangeGraph, identity::Identity, CollaborativeObject, Contents, ObjectId,
|
change, change_graph::ChangeGraph, identity::Identity, CollaborativeObject, ObjectId, Store,
|
||||||
Store, TypeName,
|
TypeName,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::error;
|
use super::error;
|
||||||
|
|
@ -15,7 +17,7 @@ pub struct Update {
|
||||||
/// The type of history that will be used for this object.
|
/// The type of history that will be used for this object.
|
||||||
pub history_type: String,
|
pub history_type: String,
|
||||||
/// The CRDT changes to add to the object.
|
/// The CRDT changes to add to the object.
|
||||||
pub changes: Contents,
|
pub changes: NonEmpty<Vec<u8>>,
|
||||||
/// The object ID of the object to be updated.
|
/// The object ID of the object to be updated.
|
||||||
pub object_id: ObjectId,
|
pub object_id: ObjectId,
|
||||||
/// The typename of the object to be updated.
|
/// The typename of the object to be updated.
|
||||||
|
|
@ -76,21 +78,23 @@ where
|
||||||
change::Template {
|
change::Template {
|
||||||
tips: object.tips().iter().cloned().collect(),
|
tips: object.tips().iter().cloned().collect(),
|
||||||
history_type,
|
history_type,
|
||||||
contents: changes.clone(),
|
contents: changes,
|
||||||
typename: typename.clone(),
|
typename: typename.clone(),
|
||||||
message,
|
message,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
object.history.extend(
|
|
||||||
change.id,
|
|
||||||
change.signature.key,
|
|
||||||
change.resource,
|
|
||||||
changes,
|
|
||||||
change.timestamp,
|
|
||||||
);
|
|
||||||
storage
|
storage
|
||||||
.update(identifier, typename, &object_id, &change)
|
.update(identifier, typename, &object_id, &change)
|
||||||
.map_err(|err| error::Update::Refs { err: Box::new(err) })?;
|
.map_err(|err| error::Update::Refs { err: Box::new(err) })?;
|
||||||
|
|
||||||
|
object.history.extend(
|
||||||
|
change.id,
|
||||||
|
change.signature.key,
|
||||||
|
change.resource,
|
||||||
|
change.contents,
|
||||||
|
change.timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
Ok(object)
|
Ok(object)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ fn traverse_cobs() {
|
||||||
// traverse over the history and filter by changes that were only authorized by terry
|
// traverse over the history and filter by changes that were only authorized by terry
|
||||||
let contents = updated.history().traverse(Vec::new(), |mut acc, entry| {
|
let contents = updated.history().traverse(Vec::new(), |mut acc, entry| {
|
||||||
if entry.actor() == terry_signer.public_key() {
|
if entry.actor() == terry_signer.public_key() {
|
||||||
acc.push(entry.contents().head.to_vec());
|
acc.push(entry.contents().head.data.clone());
|
||||||
}
|
}
|
||||||
ControlFlow::Continue(acc)
|
ControlFlow::Continue(acc)
|
||||||
});
|
});
|
||||||
|
|
@ -210,7 +210,7 @@ fn traverse_cobs() {
|
||||||
|
|
||||||
// traverse over the history and filter by changes that were only authorized by neil
|
// traverse over the history and filter by changes that were only authorized by neil
|
||||||
let contents = updated.history().traverse(Vec::new(), |mut acc, entry| {
|
let contents = updated.history().traverse(Vec::new(), |mut acc, entry| {
|
||||||
acc.push(entry.contents().head.to_vec());
|
acc.push(entry.contents().head.data.clone());
|
||||||
ControlFlow::Continue(acc)
|
ControlFlow::Continue(acc)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -447,22 +447,34 @@ pub mod keypair {
|
||||||
/// Generate a new keypair using OS randomness.
|
/// Generate a new keypair using OS randomness.
|
||||||
pub fn generate() -> KeyPair {
|
pub fn generate() -> KeyPair {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
if let Ok(seed) = std::env::var("RAD_SEED") {
|
if let Some(seed) = env::seed() {
|
||||||
// Generate a keypair based on the given environment variable.
|
// Generate a keypair based on the given environment variable.
|
||||||
// This is useful for debugging and testing, since the
|
// This is useful for debugging and testing, since the
|
||||||
// public key can be known in advance.
|
// public key can be known in advance.
|
||||||
|
return KeyPair::from_seed(Seed::new(seed));
|
||||||
|
}
|
||||||
|
KeyPair::generate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod env {
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
/// Return the seed stored in the `RAD_SEED` environment variable, if any.
|
||||||
|
pub fn seed() -> Option<[u8; 32]> {
|
||||||
|
if let Ok(seed) = env::var("RAD_SEED") {
|
||||||
let seed = (0..seed.len())
|
let seed = (0..seed.len())
|
||||||
.step_by(2)
|
.step_by(2)
|
||||||
.map(|i| u8::from_str_radix(&seed[i..i + 2], 16))
|
.map(|i| u8::from_str_radix(&seed[i..i + 2], 16))
|
||||||
.collect::<Result<Vec<u8>, _>>()
|
.collect::<Result<Vec<u8>, _>>()
|
||||||
.expect("generate: invalid hexadecimal value set in `RAD_SEED`");
|
.expect("env::seed: invalid hexadecimal value set in `RAD_SEED`");
|
||||||
let seed: [u8; 32] = seed
|
let seed: [u8; 32] = seed
|
||||||
.try_into()
|
.try_into()
|
||||||
.expect("generate: invalid seed length set in `RAD_SEED`");
|
.expect("env::seed: invalid seed length set in `RAD_SEED`");
|
||||||
|
|
||||||
return KeyPair::from_seed(Seed::new(seed));
|
return Some(seed);
|
||||||
}
|
}
|
||||||
KeyPair::generate()
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -454,13 +454,18 @@ async fn issue_update_handler(
|
||||||
Path((project, issue_id)): Path<(Id, Oid)>,
|
Path((project, issue_id)): Path<(Id, Oid)>,
|
||||||
Json(action): Json<Action>,
|
Json(action): Json<Action>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let sessions = ctx.sessions.write().await;
|
ctx.sessions
|
||||||
sessions.get(&token).ok_or(Error::Auth("Unauthorized"))?;
|
.write()
|
||||||
|
.await
|
||||||
|
.get(&token)
|
||||||
|
.ok_or(Error::Auth("Unauthorized"))?;
|
||||||
|
|
||||||
let storage = &ctx.profile.storage;
|
let storage = &ctx.profile.storage;
|
||||||
let signer = ctx.profile.signer().unwrap();
|
let signer = ctx.profile.signer().unwrap();
|
||||||
let repo = storage.repository(project)?;
|
let repo = storage.repository(project)?;
|
||||||
let mut issues = Issues::open(ctx.profile.public_key, &repo)?;
|
let mut issues = Issues::open(ctx.profile.public_key, &repo)?;
|
||||||
let mut issue = issues.get_mut(&issue_id.into())?;
|
let mut issue = issues.get_mut(&issue_id.into())?;
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
Action::Assign { add, remove } => {
|
Action::Assign { add, remove } => {
|
||||||
issue.assign(add, &signer)?;
|
issue.assign(add, &signer)?;
|
||||||
|
|
@ -475,27 +480,24 @@ async fn issue_update_handler(
|
||||||
Action::Edit { title } => {
|
Action::Edit { title } => {
|
||||||
issue.edit(title, &signer)?;
|
issue.edit(title, &signer)?;
|
||||||
}
|
}
|
||||||
Action::Thread { action } => {
|
Action::Thread { action } => match action {
|
||||||
let mut actor = thread::Actor::new(ctx.profile.signer().unwrap());
|
thread::Action::Comment { body, reply_to } => {
|
||||||
match action {
|
if let Some(reply_to) = reply_to {
|
||||||
thread::Action::Comment { body, reply_to } => {
|
issue.comment(body, reply_to, &signer)?;
|
||||||
if let Some(reply_to) = reply_to {
|
} else {
|
||||||
issue.comment(body, reply_to, &signer)?;
|
issue.thread(body, &signer)?;
|
||||||
} else {
|
|
||||||
issue.thread(body, &signer)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread::Action::React { to, reaction, .. } => {
|
|
||||||
issue.react(to, reaction, &signer)?;
|
|
||||||
}
|
|
||||||
thread::Action::Edit { id, body } => {
|
|
||||||
actor.edit(id, &body);
|
|
||||||
}
|
|
||||||
thread::Action::Redact { id } => {
|
|
||||||
actor.redact(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
thread::Action::React { to, reaction, .. } => {
|
||||||
|
issue.react(to, reaction, &signer)?;
|
||||||
|
}
|
||||||
|
thread::Action::Edit { .. } => {
|
||||||
|
todo!();
|
||||||
|
}
|
||||||
|
thread::Action::Redact { .. } => {
|
||||||
|
todo!();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok::<_, Error>(Json(json!({ "success": true })))
|
Ok::<_, Error>(Json(json!({ "success": true })))
|
||||||
|
|
@ -565,7 +567,7 @@ mod routes {
|
||||||
|
|
||||||
use crate::test::{self, get, patch, post, HEAD, HEAD_1, ISSUE_ID, PATCH_ID};
|
use crate::test::{self, get, patch, post, HEAD, HEAD_1, ISSUE_ID, PATCH_ID};
|
||||||
|
|
||||||
const CREATED_ISSUE_ID: &str = "b56febfba1e7dd20f4aea43ca2fe9dcf1fd448ba";
|
const CREATED_ISSUE_ID: &str = "745052a1603000b9566445753d7e2fee1ff5041f";
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_projects_root() {
|
async fn test_projects_root() {
|
||||||
|
|
@ -1045,7 +1047,7 @@ mod routes {
|
||||||
"assignees": [],
|
"assignees": [],
|
||||||
"discussion": [
|
"discussion": [
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"id": "f0afe34f5bf4248df432f6b6a8818bcae360bbc2",
|
||||||
"author": {
|
"author": {
|
||||||
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
|
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
|
||||||
},
|
},
|
||||||
|
|
@ -1107,7 +1109,7 @@ mod routes {
|
||||||
"status": "open",
|
"status": "open",
|
||||||
},
|
},
|
||||||
"discussion": [{
|
"discussion": [{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"id": "f0afe34f5bf4248df432f6b6a8818bcae360bbc2",
|
||||||
"author": {
|
"author": {
|
||||||
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||||
},
|
},
|
||||||
|
|
@ -1168,7 +1170,7 @@ mod routes {
|
||||||
},
|
},
|
||||||
"discussion": [
|
"discussion": [
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"id": "f0afe34f5bf4248df432f6b6a8818bcae360bbc2",
|
||||||
"author": {
|
"author": {
|
||||||
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||||
},
|
},
|
||||||
|
|
@ -1178,7 +1180,7 @@ mod routes {
|
||||||
"replyTo": null,
|
"replyTo": null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/5",
|
"id": "f7da49e705f60c39265dbdd748d786a620bc8030",
|
||||||
"author": {
|
"author": {
|
||||||
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||||
},
|
},
|
||||||
|
|
@ -1204,7 +1206,7 @@ mod routes {
|
||||||
"action": {
|
"action": {
|
||||||
"type": "comment",
|
"type": "comment",
|
||||||
"body": "This is a reply to the first comment",
|
"body": "This is a reply to the first comment",
|
||||||
"replyTo": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"replyTo": "f0afe34f5bf4248df432f6b6a8818bcae360bbc2",
|
||||||
}}))
|
}}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let response = patch(
|
let response = patch(
|
||||||
|
|
@ -1238,7 +1240,7 @@ mod routes {
|
||||||
},
|
},
|
||||||
"discussion": [
|
"discussion": [
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"id": "f0afe34f5bf4248df432f6b6a8818bcae360bbc2",
|
||||||
"author": {
|
"author": {
|
||||||
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||||
},
|
},
|
||||||
|
|
@ -1248,14 +1250,14 @@ mod routes {
|
||||||
"replyTo": null,
|
"replyTo": null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/5",
|
"id": "ab98b5b794d02c23d29769a39fe8e0b74624f3d8",
|
||||||
"author": {
|
"author": {
|
||||||
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
"id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||||
},
|
},
|
||||||
"body": "This is a reply to the first comment",
|
"body": "This is a reply to the first comment",
|
||||||
"reactions": [],
|
"reactions": [],
|
||||||
"timestamp": 1673001014,
|
"timestamp": 1673001014,
|
||||||
"replyTo": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"replyTo": "f0afe34f5bf4248df432f6b6a8818bcae360bbc2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|
@ -1285,7 +1287,7 @@ mod routes {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"revisions": [
|
"revisions": [
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"id": "d6ba305f78e2fa1ebcc55d8c3be8806bbce25fb4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"reviews": [],
|
"reviews": [],
|
||||||
}
|
}
|
||||||
|
|
@ -1316,7 +1318,7 @@ mod routes {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"revisions": [
|
"revisions": [
|
||||||
{
|
{
|
||||||
"id": "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1",
|
"id": "d6ba305f78e2fa1ebcc55d8c3be8806bbce25fb4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"reviews": [],
|
"reviews": [],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ use crate::api::{auth, Context};
|
||||||
|
|
||||||
pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5";
|
pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5";
|
||||||
pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
|
pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
|
||||||
pub const PATCH_ID: &str = "4250f0117659ee4de9af99e699a63395cd6ffa1c";
|
pub const PATCH_ID: &str = "afb3063f8f0343fa31d2a0d55bac2a6f4a77125e";
|
||||||
pub const ISSUE_ID: &str = "8adca8aad2a2cb99b9847d20193930cde2042a57";
|
pub const ISSUE_ID: &str = "331569cd5e4dcc55104363ebce92c78b0e5d67d4";
|
||||||
|
|
||||||
const PASSWORD: &str = "radicle";
|
const PASSWORD: &str = "radicle";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
siphasher = { version = "0.3.10" }
|
siphasher = { version = "0.3.10" }
|
||||||
radicle-git-ext = { version = "0", features = ["serde"] }
|
radicle-git-ext = { version = "0", features = ["serde"] }
|
||||||
|
rand = { version = "0.8.5", default-features = false, features = ["std_rng"] }
|
||||||
sqlite = { version = "0.30.3", optional = true }
|
sqlite = { version = "0.30.3", optional = true }
|
||||||
tempfile = { version = "3.3.0" }
|
tempfile = { version = "3.3.0" }
|
||||||
thiserror = { version = "1" }
|
thiserror = { version = "1" }
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ pub mod test;
|
||||||
|
|
||||||
pub use cob::{create, get, list, remove, update};
|
pub use cob::{create, get, list, remove, update};
|
||||||
pub use cob::{
|
pub use cob::{
|
||||||
identity::Identity, object::collaboration::error, CollaborativeObject, Contents, Create, Entry,
|
history::entry::EntryBlob, identity::Identity, object::collaboration::error,
|
||||||
History, ObjectId, TypeName, Update,
|
CollaborativeObject, Contents, Create, Entry, History, ObjectId, TypeName, Update,
|
||||||
};
|
};
|
||||||
pub use common::*;
|
pub use common::*;
|
||||||
pub use op::{Actor, ActorId, Op, OpId};
|
pub use op::{ActorId, Op, OpId};
|
||||||
|
|
||||||
use radicle_cob as cob;
|
use radicle_cob as cob;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub use radicle_crdt::clock;
|
||||||
pub use radicle_crdt::clock::Physical as Timestamp;
|
pub use radicle_crdt::clock::Physical as Timestamp;
|
||||||
|
|
||||||
/// Author.
|
/// Author.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::{ops::Deref, str::FromStr};
|
||||||
use crypto::{PublicKey, Signature};
|
use crypto::{PublicKey, Signature};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use radicle_cob::{ObjectId, TypeName};
|
use radicle_cob::{ObjectId, TypeName};
|
||||||
use radicle_crdt::{clock, GMap, LWWMap, LWWReg, Max, Redactable, Semilattice};
|
use radicle_crdt::{clock, GMap, GSet, LWWMap, LWWReg, Max, Redactable, Semilattice};
|
||||||
use radicle_crypto::{Signer, Verified};
|
use radicle_crypto::{Signer, Verified};
|
||||||
use radicle_git_ext::Oid;
|
use radicle_git_ext::Oid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -144,6 +144,8 @@ pub struct Proposal {
|
||||||
state: LWWReg<Max<State>>,
|
state: LWWReg<Max<State>>,
|
||||||
/// List of revisions for this proposal.
|
/// List of revisions for this proposal.
|
||||||
revisions: GMap<RevisionId, Redactable<Revision>>,
|
revisions: GMap<RevisionId, Redactable<Revision>>,
|
||||||
|
/// Timeline of events.
|
||||||
|
timeline: GSet<(clock::Lamport, OpId)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
|
@ -169,6 +171,7 @@ impl Default for Proposal {
|
||||||
description: Max::from(String::default()).into(),
|
description: Max::from(String::default()).into(),
|
||||||
state: Max::from(State::default()).into(),
|
state: Max::from(State::default()).into(),
|
||||||
revisions: GMap::default(),
|
revisions: GMap::default(),
|
||||||
|
timeline: GSet::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -270,11 +273,12 @@ impl Proposal {
|
||||||
|
|
||||||
/// All the [`Revision`]s that have not been redacted.
|
/// All the [`Revision`]s that have not been redacted.
|
||||||
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
||||||
self.revisions
|
self.timeline.iter().filter_map(|(_, id)| {
|
||||||
.iter()
|
self.revisions
|
||||||
.filter_map(|(rid, r)| -> Option<(&RevisionId, &Revision)> {
|
.get(id)
|
||||||
r.get().map(|r| (rid, r))
|
.and_then(Redactable::get)
|
||||||
})
|
.map(|rev| (id, rev))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_by(&self, who: &Did) -> Option<(&RevisionId, &Revision)> {
|
pub fn latest_by(&self, who: &Did) -> Option<(&RevisionId, &Revision)> {
|
||||||
|
|
@ -302,10 +306,12 @@ impl store::FromHistory for Proposal {
|
||||||
|
|
||||||
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), Self::Error> {
|
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), Self::Error> {
|
||||||
for op in ops {
|
for op in ops {
|
||||||
let id = op.id();
|
let id = op.id;
|
||||||
let author = Author::new(op.author);
|
let author = Author::new(op.author);
|
||||||
let timestamp = op.timestamp;
|
let timestamp = op.timestamp;
|
||||||
|
|
||||||
|
self.timeline.insert((op.clock, id));
|
||||||
|
|
||||||
match op.action {
|
match op.action {
|
||||||
Action::Accept {
|
Action::Accept {
|
||||||
revision,
|
revision,
|
||||||
|
|
@ -335,14 +341,30 @@ impl store::FromHistory for Proposal {
|
||||||
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
||||||
None => return Err(ApplyError::Missing(revision)),
|
None => return Err(ApplyError::Missing(revision)),
|
||||||
},
|
},
|
||||||
Action::Revision { current, proposed } => self.revisions.insert(
|
Action::Revision { current, proposed } => {
|
||||||
id,
|
// Since revisions are keyed by content hash, we shouldn't re-insert a revision
|
||||||
Redactable::Present(Revision::new(author, current, proposed, timestamp)),
|
// if it already exists, otherwise this will be resolved via the `merge`
|
||||||
),
|
// operation of `Redactable`.
|
||||||
|
if self.revisions.contains_key(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.revisions.insert(
|
||||||
|
id,
|
||||||
|
Redactable::Present(Revision::new(author, current, proposed, timestamp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
|
Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
|
||||||
Some(Redactable::Present(revision)) => revision
|
Some(Redactable::Present(revision)) => {
|
||||||
.discussion
|
revision.discussion.apply([cob::Op::new(
|
||||||
.apply([cob::Op::new(action, op.author, op.timestamp, op.clock)])?,
|
op.id,
|
||||||
|
action,
|
||||||
|
op.nonce,
|
||||||
|
op.author,
|
||||||
|
op.timestamp,
|
||||||
|
op.clock,
|
||||||
|
)])?
|
||||||
|
}
|
||||||
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
||||||
None => return Err(ApplyError::Missing(revision)),
|
None => return Err(ApplyError::Missing(revision)),
|
||||||
},
|
},
|
||||||
|
|
@ -451,34 +473,50 @@ impl Revision {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl store::Transaction<Proposal> {
|
impl store::Transaction<Proposal> {
|
||||||
pub fn accept(&mut self, revision: RevisionId, signature: Signature) -> OpId {
|
pub fn accept(
|
||||||
|
&mut self,
|
||||||
|
revision: RevisionId,
|
||||||
|
signature: Signature,
|
||||||
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Accept {
|
self.push(Action::Accept {
|
||||||
revision,
|
revision,
|
||||||
signature,
|
signature,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reject(&mut self, revision: RevisionId) -> OpId {
|
pub fn reject(&mut self, revision: RevisionId) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Reject { revision })
|
self.push(Action::Reject { revision })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn edit(&mut self, title: impl ToString, description: impl ToString) -> OpId {
|
pub fn edit(
|
||||||
|
&mut self,
|
||||||
|
title: impl ToString,
|
||||||
|
description: impl ToString,
|
||||||
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Edit {
|
self.push(Action::Edit {
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redact(&mut self, revision: RevisionId) -> OpId {
|
pub fn redact(&mut self, revision: RevisionId) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Redact { revision })
|
self.push(Action::Redact { revision })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn revision(&mut self, current: Oid, proposed: Doc<Verified>) -> OpId {
|
pub fn revision(
|
||||||
|
&mut self,
|
||||||
|
current: Oid,
|
||||||
|
proposed: Doc<Verified>,
|
||||||
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Revision { current, proposed })
|
self.push(Action::Revision { current, proposed })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a proposal revision discussion.
|
/// Start a proposal revision discussion.
|
||||||
pub fn thread<S: ToString>(&mut self, revision: RevisionId, body: S) -> OpId {
|
pub fn thread<S: ToString>(
|
||||||
|
&mut self,
|
||||||
|
revision: RevisionId,
|
||||||
|
body: S,
|
||||||
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Thread {
|
self.push(Action::Thread {
|
||||||
revision,
|
revision,
|
||||||
action: thread::Action::Comment {
|
action: thread::Action::Comment {
|
||||||
|
|
@ -494,7 +532,7 @@ impl store::Transaction<Proposal> {
|
||||||
revision: RevisionId,
|
revision: RevisionId,
|
||||||
body: S,
|
body: S,
|
||||||
reply_to: thread::CommentId,
|
reply_to: thread::CommentId,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Thread {
|
self.push(Action::Thread {
|
||||||
revision,
|
revision,
|
||||||
action: thread::Action::Comment {
|
action: thread::Action::Comment {
|
||||||
|
|
@ -536,10 +574,10 @@ impl<'a, 'g> ProposalMut<'a, 'g> {
|
||||||
) -> Result<T, Error>
|
) -> Result<T, Error>
|
||||||
where
|
where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Transaction<Proposal>) -> T,
|
F: FnOnce(&mut Transaction<Proposal>) -> Result<T, store::Error>,
|
||||||
{
|
{
|
||||||
let mut tx = Transaction::new(*signer.public_key(), self.clock);
|
let mut tx = Transaction::new(*signer.public_key(), self.clock, self.store.rng());
|
||||||
let output = operations(&mut tx);
|
let output = operations(&mut tx)?;
|
||||||
let (ops, clock) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
let (ops, clock) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||||
|
|
||||||
self.proposal.apply(ops)?;
|
self.proposal.apply(ops)?;
|
||||||
|
|
@ -654,8 +692,10 @@ impl<'a> Proposals<'a> {
|
||||||
) -> Result<ProposalMut<'a, 'g>, Error> {
|
) -> Result<ProposalMut<'a, 'g>, Error> {
|
||||||
let (id, proposal, clock) =
|
let (id, proposal, clock) =
|
||||||
Transaction::initial("Create proposal", &mut self.raw, signer, |tx| {
|
Transaction::initial("Create proposal", &mut self.raw, signer, |tx| {
|
||||||
tx.revision(current.into(), proposed);
|
tx.revision(current.into(), proposed)?;
|
||||||
tx.edit(title, description);
|
tx.edit(title, description)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
// Just a sanity check that our clock is advancing as expected.
|
// Just a sanity check that our clock is advancing as expected.
|
||||||
debug_assert_eq!(clock.get(), 2);
|
debug_assert_eq!(clock.get(), 2);
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,14 @@ impl store::FromHistory for Issue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Thread { action } => {
|
Action::Thread { action } => {
|
||||||
self.thread
|
self.thread.apply([cob::Op::new(
|
||||||
.apply([cob::Op::new(action, op.author, op.timestamp, op.clock)])?;
|
op.id,
|
||||||
|
action,
|
||||||
|
op.nonce,
|
||||||
|
op.author,
|
||||||
|
op.timestamp,
|
||||||
|
op.clock,
|
||||||
|
)])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +205,7 @@ impl store::Transaction<Issue> {
|
||||||
&mut self,
|
&mut self,
|
||||||
add: impl IntoIterator<Item = ActorId>,
|
add: impl IntoIterator<Item = ActorId>,
|
||||||
remove: impl IntoIterator<Item = ActorId>,
|
remove: impl IntoIterator<Item = ActorId>,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
let add = add.into_iter().collect::<Vec<_>>();
|
let add = add.into_iter().collect::<Vec<_>>();
|
||||||
let remove = remove.into_iter().collect::<Vec<_>>();
|
let remove = remove.into_iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -207,19 +213,19 @@ impl store::Transaction<Issue> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the issue title.
|
/// Set the issue title.
|
||||||
pub fn edit(&mut self, title: impl ToString) -> OpId {
|
pub fn edit(&mut self, title: impl ToString) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Edit {
|
self.push(Action::Edit {
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lifecycle an issue.
|
/// Lifecycle an issue.
|
||||||
pub fn lifecycle(&mut self, state: State) -> OpId {
|
pub fn lifecycle(&mut self, state: State) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Lifecycle { state })
|
self.push(Action::Lifecycle { state })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the issue thread.
|
/// Create the issue thread.
|
||||||
pub fn thread<S: ToString>(&mut self, body: S) -> CommentId {
|
pub fn thread<S: ToString>(&mut self, body: S) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::from(thread::Action::Comment {
|
self.push(Action::from(thread::Action::Comment {
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
|
@ -227,7 +233,11 @@ impl store::Transaction<Issue> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comment on an issue.
|
/// Comment on an issue.
|
||||||
pub fn comment<S: ToString>(&mut self, body: S, reply_to: CommentId) -> CommentId {
|
pub fn comment<S: ToString>(
|
||||||
|
&mut self,
|
||||||
|
body: S,
|
||||||
|
reply_to: CommentId,
|
||||||
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::from(thread::Action::Comment {
|
self.push(Action::from(thread::Action::Comment {
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
reply_to: Some(reply_to),
|
reply_to: Some(reply_to),
|
||||||
|
|
@ -239,7 +249,7 @@ impl store::Transaction<Issue> {
|
||||||
&mut self,
|
&mut self,
|
||||||
add: impl IntoIterator<Item = Tag>,
|
add: impl IntoIterator<Item = Tag>,
|
||||||
remove: impl IntoIterator<Item = Tag>,
|
remove: impl IntoIterator<Item = Tag>,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
let add = add.into_iter().collect::<Vec<_>>();
|
let add = add.into_iter().collect::<Vec<_>>();
|
||||||
let remove = remove.into_iter().collect::<Vec<_>>();
|
let remove = remove.into_iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -247,7 +257,7 @@ impl store::Transaction<Issue> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// React to an issue comment.
|
/// React to an issue comment.
|
||||||
pub fn react(&mut self, to: CommentId, reaction: Reaction) -> OpId {
|
pub fn react(&mut self, to: CommentId, reaction: Reaction) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Thread {
|
self.push(Action::Thread {
|
||||||
action: thread::Action::React {
|
action: thread::Action::React {
|
||||||
to,
|
to,
|
||||||
|
|
@ -342,6 +352,7 @@ impl<'a, 'g> IssueMut<'a, 'g> {
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<OpId, Error> {
|
) -> Result<OpId, Error> {
|
||||||
self.transaction("Unassign", signer, |tx| tx.assign([], assignees))
|
self.transaction("Unassign", signer, |tx| tx.assign([], assignees))
|
||||||
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transaction<G, F, T>(
|
pub fn transaction<G, F, T>(
|
||||||
|
|
@ -352,10 +363,11 @@ impl<'a, 'g> IssueMut<'a, 'g> {
|
||||||
) -> Result<T, Error>
|
) -> Result<T, Error>
|
||||||
where
|
where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Transaction<Issue>) -> T,
|
F: FnOnce(&mut Transaction<Issue>) -> Result<T, store::Error>,
|
||||||
{
|
{
|
||||||
let mut tx = Transaction::new(*signer.public_key(), self.clock);
|
let rng = self.store.rng();
|
||||||
let output = operations(&mut tx);
|
let mut tx = Transaction::new(*signer.public_key(), self.clock, rng);
|
||||||
|
let output = operations(&mut tx)?;
|
||||||
let (ops, clock) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
let (ops, clock) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||||
|
|
||||||
self.issue.apply(ops)?;
|
self.issue.apply(ops)?;
|
||||||
|
|
@ -435,10 +447,12 @@ impl<'a> Issues<'a> {
|
||||||
) -> Result<IssueMut<'a, 'g>, Error> {
|
) -> Result<IssueMut<'a, 'g>, Error> {
|
||||||
let (id, issue, clock) =
|
let (id, issue, clock) =
|
||||||
Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
|
Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
|
||||||
tx.thread(description);
|
tx.thread(description)?;
|
||||||
tx.assign(assignees.to_owned(), []);
|
tx.assign(assignees.to_owned(), [])?;
|
||||||
tx.edit(title);
|
tx.edit(title)?;
|
||||||
tx.tag(tags.to_owned(), []);
|
tx.tag(tags.to_owned(), [])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
// Just a sanity check that our clock is advancing as expected.
|
// Just a sanity check that our clock is advancing as expected.
|
||||||
debug_assert_eq!(clock.get(), 4);
|
debug_assert_eq!(clock.get(), 4);
|
||||||
|
|
@ -700,7 +714,8 @@ mod test {
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let comment = OpId::initial(*signer.public_key());
|
let (comment, _) = issue.root();
|
||||||
|
let comment = *comment;
|
||||||
let reaction = Reaction::new('🥳').unwrap();
|
let reaction = Reaction::new('🥳').unwrap();
|
||||||
issue.react(comment, reaction, &signer).unwrap();
|
issue.react(comment, reaction, &signer).unwrap();
|
||||||
|
|
||||||
|
|
@ -717,12 +732,12 @@ mod test {
|
||||||
fn test_issue_reply() {
|
fn test_issue_reply() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let (_, signer, project) = test::setup::context(&tmp);
|
let (_, signer, project) = test::setup::context(&tmp);
|
||||||
let author = *signer.public_key();
|
|
||||||
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
let mut issues = Issues::open(*signer.public_key(), &project).unwrap();
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let root = OpId::root(author);
|
let (root, _) = issue.root();
|
||||||
|
let root = *root;
|
||||||
|
|
||||||
let c1 = issue.comment("Hi hi hi.", root, &signer).unwrap();
|
let c1 = issue.comment("Hi hi hi.", root, &signer).unwrap();
|
||||||
let c2 = issue.comment("Ha ha ha.", root, &signer).unwrap();
|
let c2 = issue.comment("Ha ha ha.", root, &signer).unwrap();
|
||||||
|
|
@ -792,7 +807,8 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// The root thread op id is always the same.
|
// The root thread op id is always the same.
|
||||||
let c0 = OpId::root(author);
|
let (c0, _) = issue.root();
|
||||||
|
let c0 = *c0;
|
||||||
|
|
||||||
issue.comment("Ho ho ho.", c0, &signer).unwrap();
|
issue.comment("Ho ho ho.", c0, &signer).unwrap();
|
||||||
issue.comment("Ha ha ha.", c0, &signer).unwrap();
|
issue.comment("Ha ha ha.", c0, &signer).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str;
|
use std::str;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
@ -10,37 +9,49 @@ use thiserror::Error;
|
||||||
use radicle_cob::history::EntryWithClock;
|
use radicle_cob::history::EntryWithClock;
|
||||||
use radicle_crdt::clock;
|
use radicle_crdt::clock;
|
||||||
use radicle_crdt::clock::Lamport;
|
use radicle_crdt::clock::Lamport;
|
||||||
use radicle_crypto::{PublicKey, Signer};
|
use radicle_crypto::PublicKey;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
/// Identifies an [`Op`] internally and within the change graph.
|
/// Identifies an [`Op`] internally and within the change graph.
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(into = "String", try_from = "String")]
|
#[serde(into = "String", try_from = "String")]
|
||||||
pub struct OpId(Lamport, ActorId);
|
pub struct OpId(git::Oid);
|
||||||
|
|
||||||
impl OpId {
|
impl OpId {
|
||||||
/// Create a new operation id.
|
/// Create a new operation id.
|
||||||
pub fn new(clock: Lamport, actor: ActorId) -> Self {
|
pub fn new(oid: git::Oid) -> Self {
|
||||||
Self(clock, actor)
|
Self(oid)
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the initial operation id for the given actor.
|
|
||||||
pub fn initial(actor: ActorId) -> Self {
|
|
||||||
Self(Lamport::initial(), actor)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn root(actor: ActorId) -> Self {
|
|
||||||
Self(Lamport::initial().tick(), actor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get operation id clock.
|
|
||||||
pub fn clock(&self) -> Lamport {
|
|
||||||
self.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for OpId {
|
impl fmt::Display for OpId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}/{}", self.1, self.0)
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OpId> for git::Oid {
|
||||||
|
fn from(value: OpId) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OpId> for git2::Oid {
|
||||||
|
fn from(value: OpId) -> Self {
|
||||||
|
value.0.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<git::Oid> for OpId {
|
||||||
|
fn from(value: git::Oid) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<git2::Oid> for OpId {
|
||||||
|
fn from(value: git2::Oid) -> Self {
|
||||||
|
Self(value.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,7 +64,7 @@ impl From<OpId> for String {
|
||||||
|
|
||||||
// Used by `serde::Deserialize`.
|
// Used by `serde::Deserialize`.
|
||||||
impl TryFrom<String> for OpId {
|
impl TryFrom<String> for OpId {
|
||||||
type Error = OpIdError;
|
type Error = git::raw::Error;
|
||||||
|
|
||||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
value.as_str().try_into()
|
value.as_str().try_into()
|
||||||
|
|
@ -70,7 +81,7 @@ pub enum OpIdError {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for OpId {
|
impl FromStr for OpId {
|
||||||
type Err = OpIdError;
|
type Err = git::raw::Error;
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
Self::try_from(s)
|
Self::try_from(s)
|
||||||
|
|
@ -78,26 +89,19 @@ impl FromStr for OpId {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&str> for OpId {
|
impl TryFrom<&str> for OpId {
|
||||||
type Error = OpIdError;
|
type Error = git::raw::Error;
|
||||||
|
|
||||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||||
if s.is_empty() {
|
git::Oid::try_from(s).map(Self)
|
||||||
return Err(OpIdError::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((actor_id, clock)) = s.split_once('/') else {
|
|
||||||
return Err(OpIdError::BadFormat);
|
|
||||||
};
|
|
||||||
Ok(Self(
|
|
||||||
Lamport::from_str(clock).map_err(|_| OpIdError::BadFormat)?,
|
|
||||||
ActorId::from_str(actor_id).map_err(|_| OpIdError::BadFormat)?,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The author of an [`Op`].
|
/// The author of an [`Op`].
|
||||||
pub type ActorId = PublicKey;
|
pub type ActorId = PublicKey;
|
||||||
|
|
||||||
|
/// Random number used to prevent op-id collisions.
|
||||||
|
pub type Nonce = u64;
|
||||||
|
|
||||||
/// Error decoding an operation from an entry.
|
/// Error decoding an operation from an entry.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum OpEncodingError {
|
pub enum OpEncodingError {
|
||||||
|
|
@ -107,14 +111,29 @@ pub enum OpEncodingError {
|
||||||
Git(#[from] git2::Error),
|
Git(#[from] git2::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The operation payload that is actually stored on disk as a git blob.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct OpBlob<A> {
|
||||||
|
/// The underlying action.
|
||||||
|
pub action: A,
|
||||||
|
/// A random number used to disambiguate otherwise identical ops (actions).
|
||||||
|
/// Note that since the timestamp and author are not stored at the individual op level,
|
||||||
|
/// but instead at the commit level; individual ops can trivially collide.
|
||||||
|
pub nonce: Nonce,
|
||||||
|
}
|
||||||
|
|
||||||
/// The `Op` is the operation that is applied onto a state to form a CRDT.
|
/// The `Op` is the operation that is applied onto a state to form a CRDT.
|
||||||
///
|
///
|
||||||
/// Everything that can be done in the system is represented by an `Op`.
|
/// Everything that can be done in the system is represented by an `Op`.
|
||||||
/// Operations are applied to an accumulator to yield a final state.
|
/// Operations are applied to an accumulator to yield a final state.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Op<A> {
|
pub struct Op<A> {
|
||||||
|
/// Operation id.
|
||||||
|
pub id: OpId,
|
||||||
/// The action carried out by this operation.
|
/// The action carried out by this operation.
|
||||||
pub action: A,
|
pub action: A,
|
||||||
|
/// The nonce from the [`OpBlob`].
|
||||||
|
pub nonce: Nonce,
|
||||||
/// The author of the operation.
|
/// The author of the operation.
|
||||||
pub author: ActorId,
|
pub author: ActorId,
|
||||||
/// Lamport clock.
|
/// Lamport clock.
|
||||||
|
|
@ -125,30 +144,38 @@ pub struct Op<A> {
|
||||||
|
|
||||||
impl<A: Eq> PartialOrd for Op<A> {
|
impl<A: Eq> PartialOrd for Op<A> {
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
self.id().partial_cmp(&other.id())
|
self.id.partial_cmp(&other.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: Eq> Ord for Op<A> {
|
impl<A: Eq> Ord for Op<A> {
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
self.id().cmp(&other.id())
|
self.id.cmp(&other.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: Serialize> Op<A> {
|
impl<A> Op<A> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
id: OpId,
|
||||||
action: A,
|
action: A,
|
||||||
|
nonce: Nonce,
|
||||||
author: ActorId,
|
author: ActorId,
|
||||||
timestamp: impl Into<clock::Physical>,
|
timestamp: impl Into<clock::Physical>,
|
||||||
clock: Lamport,
|
clock: Lamport,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
id,
|
||||||
action,
|
action,
|
||||||
|
nonce,
|
||||||
author,
|
author,
|
||||||
clock,
|
clock,
|
||||||
timestamp: timestamp.into(),
|
timestamp: timestamp.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> OpId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Ops<A>(pub NonEmpty<Op<A>>);
|
pub struct Ops<A>(pub NonEmpty<Op<A>>);
|
||||||
|
|
@ -160,109 +187,24 @@ where
|
||||||
type Error = OpEncodingError;
|
type Error = OpEncodingError;
|
||||||
|
|
||||||
fn try_from(entry: &'a EntryWithClock) -> Result<Self, Self::Error> {
|
fn try_from(entry: &'a EntryWithClock) -> Result<Self, Self::Error> {
|
||||||
let mut clock = entry.clock().into();
|
let ops = entry
|
||||||
|
.changes()
|
||||||
entry
|
.map(|(clock, blob)| {
|
||||||
.contents()
|
let OpBlob { action, nonce } = serde_json::from_slice(blob.data.as_slice())?;
|
||||||
.clone()
|
|
||||||
.try_map(|op| {
|
|
||||||
let action = serde_json::from_slice(&op)?;
|
|
||||||
let op = Op {
|
let op = Op {
|
||||||
|
id: blob.oid.into(),
|
||||||
action,
|
action,
|
||||||
|
nonce,
|
||||||
author: *entry.actor(),
|
author: *entry.actor(),
|
||||||
clock,
|
clock: clock.into(),
|
||||||
timestamp: entry.timestamp().into(),
|
timestamp: entry.timestamp().into(),
|
||||||
};
|
};
|
||||||
clock.tick();
|
Ok::<_, Self::Error>(op)
|
||||||
|
|
||||||
Ok(op)
|
|
||||||
})
|
})
|
||||||
.map(Self)
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
}
|
|
||||||
}
|
// SAFETY: Entry is guaranteed to have at least one operation.
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
impl<A> Op<A> {
|
Ok(Self(NonEmpty::from_vec(ops).unwrap()))
|
||||||
/// Get the op id.
|
|
||||||
/// This uniquely identifies each operation in the CRDT.
|
|
||||||
pub fn id(&self) -> OpId {
|
|
||||||
OpId(self.clock, self.author)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An object that can be used to create and sign operations.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Actor<G, A> {
|
|
||||||
pub signer: G,
|
|
||||||
pub clock: Lamport,
|
|
||||||
pub ops: BTreeMap<(Lamport, PublicKey), Op<A>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<G: Signer, A: Clone> Actor<G, A> {
|
|
||||||
pub fn new(signer: G) -> Self {
|
|
||||||
Self {
|
|
||||||
signer,
|
|
||||||
clock: Lamport::default(),
|
|
||||||
ops: BTreeMap::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn receive(&mut self, ops: impl IntoIterator<Item = Op<A>>) -> Lamport {
|
|
||||||
for op in ops {
|
|
||||||
let clock = op.clock;
|
|
||||||
|
|
||||||
self.ops.insert((clock, op.author), op);
|
|
||||||
self.clock.merge(clock);
|
|
||||||
}
|
|
||||||
self.clock
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset actor state to initial state.
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.ops.clear();
|
|
||||||
self.clock = Lamport::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returned an ordered list of events.
|
|
||||||
pub fn timeline(&self) -> impl Iterator<Item = &Op<A>> {
|
|
||||||
self.ops.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new operation.
|
|
||||||
pub fn op(&mut self, action: A) -> Op<A> {
|
|
||||||
let author = *self.signer.public_key();
|
|
||||||
let clock = self.clock.tick();
|
|
||||||
let timestamp = clock::Physical::now();
|
|
||||||
let op = Op {
|
|
||||||
action,
|
|
||||||
author,
|
|
||||||
clock,
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
self.ops.insert((self.clock, author), op.clone());
|
|
||||||
|
|
||||||
op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_opid_try_from_str() {
|
|
||||||
let s = "z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/12";
|
|
||||||
let id = OpId::try_from(s).expect("Op ID parses string");
|
|
||||||
assert_eq!(s, id.to_string(), "string conversion is consistent");
|
|
||||||
|
|
||||||
let s = "";
|
|
||||||
assert!(OpId::try_from(s).is_err(), "empty strings are invalid");
|
|
||||||
|
|
||||||
let s = "jlkjfksgi";
|
|
||||||
assert!(OpId::try_from(s).is_err(), "badly formatted string");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<OpId>(serde_json::to_string(&id).unwrap().as_str()).unwrap(),
|
|
||||||
id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use radicle_crdt::clock;
|
use radicle_crdt::clock;
|
||||||
use radicle_crdt::{GMap, LWWReg, LWWSet, Max, Redactable, Semilattice};
|
use radicle_crdt::{GMap, GSet, LWWReg, LWWSet, Lamport, Max, Redactable, Semilattice};
|
||||||
|
|
||||||
use crate::cob;
|
use crate::cob;
|
||||||
use crate::cob::common::{Author, Tag, Timestamp};
|
use crate::cob::common::{Author, Tag, Timestamp};
|
||||||
|
|
@ -134,6 +134,8 @@ pub struct Patch {
|
||||||
/// List of patch revisions. The initial changeset is part of the
|
/// List of patch revisions. The initial changeset is part of the
|
||||||
/// first revision.
|
/// first revision.
|
||||||
pub revisions: GMap<RevisionId, Redactable<Revision>>,
|
pub revisions: GMap<RevisionId, Redactable<Revision>>,
|
||||||
|
/// Timeline of operations.
|
||||||
|
pub timeline: GSet<(Lamport, OpId)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Semilattice for Patch {
|
impl Semilattice for Patch {
|
||||||
|
|
@ -156,6 +158,7 @@ impl Default for Patch {
|
||||||
target: Max::from(MergeTarget::default()).into(),
|
target: Max::from(MergeTarget::default()).into(),
|
||||||
tags: LWWSet::default(),
|
tags: LWWSet::default(),
|
||||||
revisions: GMap::default(),
|
revisions: GMap::default(),
|
||||||
|
timeline: GSet::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,11 +202,12 @@ impl Patch {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
||||||
self.revisions
|
self.timeline.iter().filter_map(|(_, id)| {
|
||||||
.iter()
|
self.revisions
|
||||||
.filter_map(|(rid, r)| -> Option<(&RevisionId, &Revision)> {
|
.get(id)
|
||||||
r.get().map(|r| (rid, r))
|
.and_then(Redactable::get)
|
||||||
})
|
.map(|rev| (id, rev))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn head(&self) -> &git::Oid {
|
pub fn head(&self) -> &git::Oid {
|
||||||
|
|
@ -244,10 +248,12 @@ impl store::FromHistory for Patch {
|
||||||
|
|
||||||
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), ApplyError> {
|
fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), ApplyError> {
|
||||||
for op in ops {
|
for op in ops {
|
||||||
let id = op.id();
|
let id = op.id;
|
||||||
let author = Author::new(op.author);
|
let author = Author::new(op.author);
|
||||||
let timestamp = op.timestamp;
|
let timestamp = op.timestamp;
|
||||||
|
|
||||||
|
self.timeline.insert((op.clock, id));
|
||||||
|
|
||||||
match op.action {
|
match op.action {
|
||||||
Action::Edit {
|
Action::Edit {
|
||||||
title,
|
title,
|
||||||
|
|
@ -271,6 +277,12 @@ impl store::FromHistory for Patch {
|
||||||
base,
|
base,
|
||||||
oid,
|
oid,
|
||||||
} => {
|
} => {
|
||||||
|
// Since revisions are keyed by content hash, we shouldn't re-insert a revision
|
||||||
|
// if it already exists, otherwise this will be resolved via the `merge`
|
||||||
|
// operation of `Redactable`.
|
||||||
|
if self.revisions.contains_key(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
self.revisions.insert(
|
self.revisions.insert(
|
||||||
id,
|
id,
|
||||||
Redactable::Present(Revision::new(
|
Redactable::Present(Revision::new(
|
||||||
|
|
@ -323,9 +335,9 @@ impl store::FromHistory for Patch {
|
||||||
// TODO(cloudhead): Make sure we can deal with redacted revisions which are added
|
// TODO(cloudhead): Make sure we can deal with redacted revisions which are added
|
||||||
// to out of order, like in the `Merge` case.
|
// to out of order, like in the `Merge` case.
|
||||||
if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
|
if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
|
||||||
revision
|
revision.discussion.apply([cob::Op::new(
|
||||||
.discussion
|
op.id, action, op.nonce, op.author, timestamp, op.clock,
|
||||||
.apply([cob::Op::new(action, op.author, timestamp, op.clock)])?;
|
)])?;
|
||||||
} else {
|
} else {
|
||||||
return Err(ApplyError::Missing(revision));
|
return Err(ApplyError::Missing(revision));
|
||||||
}
|
}
|
||||||
|
|
@ -549,7 +561,7 @@ impl store::Transaction<Patch> {
|
||||||
title: impl ToString,
|
title: impl ToString,
|
||||||
description: impl ToString,
|
description: impl ToString,
|
||||||
target: MergeTarget,
|
target: MergeTarget,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Edit {
|
self.push(Action::Edit {
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
|
|
@ -558,7 +570,11 @@ impl store::Transaction<Patch> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a patch revision discussion.
|
/// Start a patch revision discussion.
|
||||||
pub fn thread<S: ToString>(&mut self, revision: RevisionId, body: S) -> OpId {
|
pub fn thread<S: ToString>(
|
||||||
|
&mut self,
|
||||||
|
revision: RevisionId,
|
||||||
|
body: S,
|
||||||
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Thread {
|
self.push(Action::Thread {
|
||||||
revision,
|
revision,
|
||||||
action: thread::Action::Comment {
|
action: thread::Action::Comment {
|
||||||
|
|
@ -574,7 +590,7 @@ impl store::Transaction<Patch> {
|
||||||
revision: RevisionId,
|
revision: RevisionId,
|
||||||
body: S,
|
body: S,
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Thread {
|
self.push(Action::Thread {
|
||||||
revision,
|
revision,
|
||||||
action: thread::Action::Comment {
|
action: thread::Action::Comment {
|
||||||
|
|
@ -591,7 +607,7 @@ impl store::Transaction<Patch> {
|
||||||
verdict: Option<Verdict>,
|
verdict: Option<Verdict>,
|
||||||
comment: Option<String>,
|
comment: Option<String>,
|
||||||
inline: Vec<CodeComment>,
|
inline: Vec<CodeComment>,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Review {
|
self.push(Action::Review {
|
||||||
revision,
|
revision,
|
||||||
comment,
|
comment,
|
||||||
|
|
@ -601,7 +617,7 @@ impl store::Transaction<Patch> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge a patch revision.
|
/// Merge a patch revision.
|
||||||
pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> OpId {
|
pub fn merge(&mut self, revision: RevisionId, commit: git::Oid) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Merge { revision, commit })
|
self.push(Action::Merge { revision, commit })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,7 +627,7 @@ impl store::Transaction<Patch> {
|
||||||
description: impl ToString,
|
description: impl ToString,
|
||||||
base: impl Into<git::Oid>,
|
base: impl Into<git::Oid>,
|
||||||
oid: impl Into<git::Oid>,
|
oid: impl Into<git::Oid>,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
self.push(Action::Revision {
|
self.push(Action::Revision {
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
base: base.into(),
|
base: base.into(),
|
||||||
|
|
@ -624,7 +640,7 @@ impl store::Transaction<Patch> {
|
||||||
&mut self,
|
&mut self,
|
||||||
add: impl IntoIterator<Item = Tag>,
|
add: impl IntoIterator<Item = Tag>,
|
||||||
remove: impl IntoIterator<Item = Tag>,
|
remove: impl IntoIterator<Item = Tag>,
|
||||||
) -> OpId {
|
) -> Result<OpId, store::Error> {
|
||||||
let add = add.into_iter().collect::<Vec<_>>();
|
let add = add.into_iter().collect::<Vec<_>>();
|
||||||
let remove = remove.into_iter().collect::<Vec<_>>();
|
let remove = remove.into_iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -663,10 +679,10 @@ impl<'a, 'g> PatchMut<'a, 'g> {
|
||||||
) -> Result<T, Error>
|
) -> Result<T, Error>
|
||||||
where
|
where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Transaction<Patch>) -> T,
|
F: FnOnce(&mut Transaction<Patch>) -> Result<T, store::Error>,
|
||||||
{
|
{
|
||||||
let mut tx = Transaction::new(*signer.public_key(), self.clock);
|
let mut tx = Transaction::new(*signer.public_key(), self.clock, self.store.rng());
|
||||||
let output = operations(&mut tx);
|
let output = operations(&mut tx)?;
|
||||||
let (ops, clock) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
let (ops, clock) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||||
|
|
||||||
self.patch.apply(ops)?;
|
self.patch.apply(ops)?;
|
||||||
|
|
@ -803,9 +819,11 @@ impl<'a> Patches<'a> {
|
||||||
) -> Result<PatchMut<'a, 'g>, Error> {
|
) -> Result<PatchMut<'a, 'g>, Error> {
|
||||||
let (id, patch, clock) =
|
let (id, patch, clock) =
|
||||||
Transaction::initial("Create patch", &mut self.raw, signer, |tx| {
|
Transaction::initial("Create patch", &mut self.raw, signer, |tx| {
|
||||||
tx.revision(String::default(), base, oid);
|
tx.revision(String::default(), base, oid)?;
|
||||||
tx.edit(title, description, target);
|
tx.edit(title, description, target)?;
|
||||||
tx.tag(tags.to_owned(), []);
|
tx.tag(tags.to_owned(), [])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
// Just a sanity check that our clock is advancing as expected.
|
// Just a sanity check that our clock is advancing as expected.
|
||||||
debug_assert_eq!(clock.get(), 3);
|
debug_assert_eq!(clock.get(), 3);
|
||||||
|
|
@ -884,7 +902,7 @@ mod test {
|
||||||
use qcheck::{Arbitrary, TestResult};
|
use qcheck::{Arbitrary, TestResult};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cob::op::{Actor, ActorId};
|
use crate::cob::test::Actor;
|
||||||
use crate::crypto::test::signer::MockSigner;
|
use crate::crypto::test::signer::MockSigner;
|
||||||
use crate::test;
|
use crate::test;
|
||||||
|
|
||||||
|
|
@ -908,9 +926,13 @@ mod test {
|
||||||
|
|
||||||
impl<const N: usize> Arbitrary for Changes<N> {
|
impl<const N: usize> Arbitrary for Changes<N> {
|
||||||
fn arbitrary(g: &mut qcheck::Gen) -> Self {
|
fn arbitrary(g: &mut qcheck::Gen) -> Self {
|
||||||
type State = (clock::Lamport, Vec<OpId>, Vec<Tag>);
|
type State = (
|
||||||
|
Actor<MockSigner, Action>,
|
||||||
|
clock::Lamport,
|
||||||
|
Vec<OpId>,
|
||||||
|
Vec<Tag>,
|
||||||
|
);
|
||||||
|
|
||||||
let author = ActorId::from([0; 32]);
|
|
||||||
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
|
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
|
||||||
let oids = iter::repeat_with(|| {
|
let oids = iter::repeat_with(|| {
|
||||||
git::Oid::try_from(
|
git::Oid::try_from(
|
||||||
|
|
@ -924,35 +946,35 @@ mod test {
|
||||||
.take(16)
|
.take(16)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let gen = WeightedGenerator::<(clock::Lamport, Action), State>::new(rng.clone())
|
let gen = WeightedGenerator::<(clock::Lamport, Op), State>::new(rng.clone())
|
||||||
.variant(1, |(clock, _, _), rng| {
|
.variant(1, |(actor, clock, _, _), rng| {
|
||||||
Some((
|
Some((
|
||||||
clock.tick(),
|
clock.tick(),
|
||||||
Action::Edit {
|
actor.op(Action::Edit {
|
||||||
title: iter::repeat_with(|| rng.alphabetic()).take(8).collect(),
|
title: iter::repeat_with(|| rng.alphabetic()).take(8).collect(),
|
||||||
description: iter::repeat_with(|| rng.alphabetic()).take(16).collect(),
|
description: iter::repeat_with(|| rng.alphabetic()).take(16).collect(),
|
||||||
target: MergeTarget::Delegates,
|
target: MergeTarget::Delegates,
|
||||||
},
|
}),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.variant(1, |(clock, revisions, _), rng| {
|
.variant(1, |(actor, clock, revisions, _), rng| {
|
||||||
if revisions.is_empty() {
|
if revisions.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let revision = revisions[rng.usize(..revisions.len())];
|
let revision = revisions[rng.usize(..revisions.len())];
|
||||||
let commit = oids[rng.usize(..oids.len())];
|
let commit = oids[rng.usize(..oids.len())];
|
||||||
|
|
||||||
Some((clock.tick(), Action::Merge { revision, commit }))
|
Some((clock.tick(), actor.op(Action::Merge { revision, commit })))
|
||||||
})
|
})
|
||||||
.variant(1, |(clock, revisions, _), rng| {
|
.variant(1, |(actor, clock, revisions, _), rng| {
|
||||||
if revisions.is_empty() {
|
if revisions.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let revision = revisions[rng.usize(..revisions.len())];
|
let revision = revisions[rng.usize(..revisions.len())];
|
||||||
|
|
||||||
Some((clock.tick(), Action::Redact { revision }))
|
Some((clock.tick(), actor.op(Action::Redact { revision })))
|
||||||
})
|
})
|
||||||
.variant(1, |(clock, _, tags), rng| {
|
.variant(1, |(actor, clock, _, tags), rng| {
|
||||||
let add = iter::repeat_with(|| rng.alphabetic())
|
let add = iter::repeat_with(|| rng.alphabetic())
|
||||||
.take(rng.usize(0..=3))
|
.take(rng.usize(0..=3))
|
||||||
.map(|c| Tag::new(c).unwrap())
|
.map(|c| Tag::new(c).unwrap())
|
||||||
|
|
@ -965,32 +987,29 @@ mod test {
|
||||||
for tag in &add {
|
for tag in &add {
|
||||||
tags.push(tag.clone());
|
tags.push(tag.clone());
|
||||||
}
|
}
|
||||||
Some((clock.tick(), Action::Tag { add, remove }))
|
Some((clock.tick(), actor.op(Action::Tag { add, remove })))
|
||||||
})
|
})
|
||||||
.variant(1, |(clock, revisions, _), rng| {
|
.variant(1, |(actor, clock, revisions, _), rng| {
|
||||||
let oid = oids[rng.usize(..oids.len())];
|
let oid = oids[rng.usize(..oids.len())];
|
||||||
let base = oids[rng.usize(..oids.len())];
|
let base = oids[rng.usize(..oids.len())];
|
||||||
let description = iter::repeat_with(|| rng.alphabetic()).take(6).collect();
|
let description = iter::repeat_with(|| rng.alphabetic()).take(6).collect();
|
||||||
|
let op = actor.op(Action::Revision {
|
||||||
|
description,
|
||||||
|
base,
|
||||||
|
oid,
|
||||||
|
});
|
||||||
|
|
||||||
if rng.bool() {
|
if rng.bool() {
|
||||||
revisions.push(OpId::new(clock.tick(), author));
|
revisions.push(op.id);
|
||||||
}
|
}
|
||||||
Some((
|
Some((*clock, op))
|
||||||
*clock,
|
|
||||||
Action::Revision {
|
|
||||||
description,
|
|
||||||
base,
|
|
||||||
oid,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut changes = Vec::new();
|
let mut changes = Vec::new();
|
||||||
let mut permutations: [Vec<Op>; N] = array::from_fn(|_| Vec::new());
|
let mut permutations: [Vec<Op>; N] = array::from_fn(|_| Vec::new());
|
||||||
let timestamp = Timestamp::now() + rng.u64(..60);
|
|
||||||
|
|
||||||
for (clock, action) in gen.take(g.size()) {
|
for (_, op) in gen.take(g.size()) {
|
||||||
changes.push(Op::new(action, author, timestamp, clock));
|
changes.push(op);
|
||||||
}
|
}
|
||||||
|
|
||||||
for p in &mut permutations {
|
for p in &mut permutations {
|
||||||
|
|
@ -1231,7 +1250,7 @@ mod test {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_revision_redacted_reinsert() {
|
fn test_revision_redact_reinsert() {
|
||||||
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
||||||
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
|
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
|
||||||
let mut alice = Actor::<_, Action>::new(MockSigner::default());
|
let mut alice = Actor::<_, Action>::new(MockSigner::default());
|
||||||
|
|
@ -1251,6 +1270,30 @@ mod test {
|
||||||
assert_eq!(p1, p2);
|
assert_eq!(p1, p2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_revision_merge_reinsert() {
|
||||||
|
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
||||||
|
let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
|
||||||
|
let mut alice = Actor::<_, Action>::new(MockSigner::default());
|
||||||
|
let mut p1 = Patch::default();
|
||||||
|
let mut p2 = Patch::default();
|
||||||
|
|
||||||
|
let a1 = alice.op(Action::Revision {
|
||||||
|
description: String::new(),
|
||||||
|
base,
|
||||||
|
oid,
|
||||||
|
});
|
||||||
|
let a2 = alice.op(Action::Merge {
|
||||||
|
revision: a1.id(),
|
||||||
|
commit: oid,
|
||||||
|
});
|
||||||
|
|
||||||
|
p1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
|
||||||
|
p2.apply([a1.clone(), a1, a2]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(p1, p2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_patch_review_edit() {
|
fn test_patch_review_edit() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -1328,10 +1371,9 @@ mod test {
|
||||||
assert_eq!(patch.description(), Some("Blah blah blah."));
|
assert_eq!(patch.description(), Some("Blah blah blah."));
|
||||||
assert_eq!(patch.version(), 0);
|
assert_eq!(patch.version(), 0);
|
||||||
|
|
||||||
let r1 = patch
|
let _ = patch
|
||||||
.update("I've made changes.", base, rev1_oid, &signer)
|
.update("I've made changes.", base, rev1_oid, &signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(r1.clock().get(), 4);
|
|
||||||
|
|
||||||
let id = patch.id;
|
let id = patch.id;
|
||||||
let patch = patches.get(&id).unwrap().unwrap();
|
let patch = patches.get(&id).unwrap().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ use std::ops::ControlFlow;
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
use radicle_crdt::Lamport;
|
use radicle_crdt::Lamport;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{RngCore as _, SeedableRng};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cob;
|
use crate::cob;
|
||||||
use crate::cob::common::Author;
|
use crate::cob::common::Author;
|
||||||
use crate::cob::op::{Op, OpId, Ops};
|
use crate::cob::op::{Nonce, Op, OpId, Ops};
|
||||||
use crate::cob::CollaborativeObject;
|
use crate::cob::CollaborativeObject;
|
||||||
use crate::cob::{ActorId, Create, History, ObjectId, TypeName, Update};
|
use crate::cob::{ActorId, Create, History, ObjectId, TypeName, Update};
|
||||||
use crate::crypto::PublicKey;
|
use crate::crypto::PublicKey;
|
||||||
|
|
@ -27,7 +29,7 @@ pub const HISTORY_TYPE: &str = "radicle";
|
||||||
/// All collaborative objects implement this trait.
|
/// All collaborative objects implement this trait.
|
||||||
pub trait FromHistory: Sized + Default {
|
pub trait FromHistory: Sized + Default {
|
||||||
/// The underlying action composing each operation.
|
/// The underlying action composing each operation.
|
||||||
type Action: for<'de> Deserialize<'de>;
|
type Action: for<'de> Deserialize<'de> + Serialize;
|
||||||
/// Error returned by `apply` function.
|
/// Error returned by `apply` function.
|
||||||
type Error: std::error::Error;
|
type Error: std::error::Error;
|
||||||
|
|
||||||
|
|
@ -92,6 +94,7 @@ pub struct Store<'a, T> {
|
||||||
identity: Identity<git::Oid>,
|
identity: Identity<git::Oid>,
|
||||||
raw: &'a storage::Repository,
|
raw: &'a storage::Repository,
|
||||||
witness: PhantomData<T>,
|
witness: PhantomData<T>,
|
||||||
|
rng: StdRng,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, T> AsRef<storage::Repository> for Store<'a, T> {
|
impl<'a, T> AsRef<storage::Repository> for Store<'a, T> {
|
||||||
|
|
@ -103,6 +106,7 @@ impl<'a, T> AsRef<storage::Repository> for Store<'a, T> {
|
||||||
impl<'a, T> Store<'a, T> {
|
impl<'a, T> Store<'a, T> {
|
||||||
/// Open a new generic store.
|
/// Open a new generic store.
|
||||||
pub fn open(whoami: PublicKey, store: &'a storage::Repository) -> Result<Self, Error> {
|
pub fn open(whoami: PublicKey, store: &'a storage::Repository) -> Result<Self, Error> {
|
||||||
|
let rng = rng::std();
|
||||||
let identity = Identity::load(&whoami, store)?;
|
let identity = Identity::load(&whoami, store)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -110,6 +114,7 @@ impl<'a, T> Store<'a, T> {
|
||||||
whoami,
|
whoami,
|
||||||
raw: store,
|
raw: store,
|
||||||
witness: PhantomData,
|
witness: PhantomData,
|
||||||
|
rng,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +127,11 @@ impl<'a, T> Store<'a, T> {
|
||||||
pub fn public_key(&self) -> &PublicKey {
|
pub fn public_key(&self) -> &PublicKey {
|
||||||
&self.whoami
|
&self.whoami
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a new RNG from the existing one.
|
||||||
|
pub fn rng(&self) -> StdRng {
|
||||||
|
StdRng::from_rng(self.rng.clone()).expect("Store::rng: failed to derive RNG")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, T: FromHistory> Store<'a, T>
|
impl<'a, T: FromHistory> Store<'a, T>
|
||||||
|
|
@ -133,10 +143,10 @@ where
|
||||||
&self,
|
&self,
|
||||||
object_id: ObjectId,
|
object_id: ObjectId,
|
||||||
message: &str,
|
message: &str,
|
||||||
actions: impl Into<NonEmpty<T::Action>>,
|
actions: impl Into<NonEmpty<Vec<u8>>>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<CollaborativeObject, Error> {
|
) -> Result<CollaborativeObject, Error> {
|
||||||
let changes = actions.into().try_map(|e| encoding::encode(&e))?;
|
let changes = actions.into();
|
||||||
|
|
||||||
cob::update(
|
cob::update(
|
||||||
self.raw,
|
self.raw,
|
||||||
|
|
@ -158,10 +168,10 @@ where
|
||||||
pub fn create<G: Signer>(
|
pub fn create<G: Signer>(
|
||||||
&self,
|
&self,
|
||||||
message: &str,
|
message: &str,
|
||||||
actions: impl Into<NonEmpty<T::Action>>,
|
actions: impl Into<NonEmpty<Vec<u8>>>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<(ObjectId, T, Lamport), Error> {
|
) -> Result<(ObjectId, T, Lamport), Error> {
|
||||||
let contents = actions.into().try_map(|e| encoding::encode(&e))?;
|
let contents = actions.into();
|
||||||
let cob = cob::create(
|
let cob = cob::create(
|
||||||
self.raw,
|
self.raw,
|
||||||
signer,
|
signer,
|
||||||
|
|
@ -226,18 +236,20 @@ pub struct Transaction<T: FromHistory> {
|
||||||
actor: ActorId,
|
actor: ActorId,
|
||||||
start: Lamport,
|
start: Lamport,
|
||||||
clock: Lamport,
|
clock: Lamport,
|
||||||
actions: Vec<T::Action>,
|
rng: StdRng,
|
||||||
|
actions: Vec<(T::Action, OpId, Nonce, Vec<u8>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: FromHistory> Transaction<T> {
|
impl<T: FromHistory> Transaction<T> {
|
||||||
/// Create a new transaction.
|
/// Create a new transaction.
|
||||||
pub fn new(actor: ActorId, clock: Lamport) -> Self {
|
pub fn new(actor: ActorId, clock: Lamport, rng: StdRng) -> Self {
|
||||||
let start = clock;
|
let start = clock;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
actor,
|
actor,
|
||||||
start,
|
start,
|
||||||
clock,
|
clock,
|
||||||
|
rng,
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +263,7 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
) -> Result<(ObjectId, T, Lamport), Error>
|
) -> Result<(ObjectId, T, Lamport), Error>
|
||||||
where
|
where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Self),
|
F: FnOnce(&mut Self) -> Result<(), Error>,
|
||||||
T::Action: Serialize + Clone,
|
T::Action: Serialize + Clone,
|
||||||
{
|
{
|
||||||
let actor = *signer.public_key();
|
let actor = *signer.public_key();
|
||||||
|
|
@ -259,12 +271,14 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
actor,
|
actor,
|
||||||
start: Lamport::initial(),
|
start: Lamport::initial(),
|
||||||
clock: Lamport::initial(),
|
clock: Lamport::initial(),
|
||||||
|
rng: store.rng(),
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
};
|
};
|
||||||
operations(&mut tx);
|
operations(&mut tx)?;
|
||||||
|
|
||||||
let actions = NonEmpty::from_vec(tx.actions)
|
let actions = NonEmpty::from_vec(tx.actions)
|
||||||
.expect("Transaction::initial: transaction must contain at least one operation");
|
.expect("Transaction::initial: transaction must contain at least one operation")
|
||||||
|
.map(|(_, _, _, blob)| blob);
|
||||||
let (id, cob, clock) = store.create(message, actions, signer)?;
|
let (id, cob, clock) = store.create(message, actions, signer)?;
|
||||||
|
|
||||||
// The history clock should be in sync with the tx clock.
|
// The history clock should be in sync with the tx clock.
|
||||||
|
|
@ -274,9 +288,14 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an operation to this transaction.
|
/// Add an operation to this transaction.
|
||||||
pub fn push(&mut self, action: T::Action) -> cob::OpId {
|
pub fn push(&mut self, action: T::Action) -> Result<cob::OpId, Error> {
|
||||||
self.actions.push(action);
|
let nonce = self.rng.next_u64();
|
||||||
OpId::new(self.clock.tick(), self.actor)
|
let (id, blob) = encoding::encode(&action, nonce)?;
|
||||||
|
|
||||||
|
self.actions.push((action, id, nonce, blob));
|
||||||
|
self.clock.tick();
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commit transaction.
|
/// Commit transaction.
|
||||||
|
|
@ -294,7 +313,7 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
{
|
{
|
||||||
let actions = NonEmpty::from_vec(self.actions)
|
let actions = NonEmpty::from_vec(self.actions)
|
||||||
.expect("Transaction::commit: transaction must not be empty");
|
.expect("Transaction::commit: transaction must not be empty");
|
||||||
let cob = store.update(id, msg, actions.clone(), signer)?;
|
let cob = store.update(id, msg, actions.clone().map(|(_, _, _, blob)| blob), signer)?;
|
||||||
let author = self.actor;
|
let author = self.actor;
|
||||||
let timestamp = cob.history().timestamp().into();
|
let timestamp = cob.history().timestamp().into();
|
||||||
|
|
||||||
|
|
@ -305,7 +324,9 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
let mut clock = self.start;
|
let mut clock = self.start;
|
||||||
let ops = actions
|
let ops = actions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|action| cob::Op {
|
.map(|(action, id, nonce, _)| cob::Op {
|
||||||
|
id,
|
||||||
|
nonce,
|
||||||
action,
|
action,
|
||||||
author,
|
author,
|
||||||
clock: clock.tick(),
|
clock: clock.tick(),
|
||||||
|
|
@ -321,15 +342,43 @@ pub mod encoding {
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::canonical::formatter::CanonicalFormatter;
|
use crate::canonical::formatter::CanonicalFormatter;
|
||||||
|
use crate::cob::op::{Nonce, OpId};
|
||||||
|
|
||||||
/// Serialize the change into a byte string.
|
/// Serialize the change into a byte string.
|
||||||
pub fn encode<T: Serialize>(obj: &T) -> Result<Vec<u8>, serde_json::Error> {
|
pub fn encode<A: Serialize>(
|
||||||
|
action: &A,
|
||||||
|
nonce: Nonce,
|
||||||
|
) -> Result<(OpId, Vec<u8>), serde_json::Error> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut serializer =
|
let mut serializer =
|
||||||
serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new());
|
serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new());
|
||||||
|
|
||||||
obj.serialize(&mut serializer)?;
|
serde_json::json!({
|
||||||
|
"action": action,
|
||||||
|
"nonce": nonce,
|
||||||
|
})
|
||||||
|
.serialize(&mut serializer)?;
|
||||||
|
|
||||||
Ok(buf)
|
// SAFETY: This really shouldn't fail, since we're providing a valid object type.
|
||||||
|
let oid = git2::Oid::hash_object(git2::ObjectType::Blob, buf.as_slice())
|
||||||
|
.expect("encoding::encode: failed to get object hash for change")
|
||||||
|
.into();
|
||||||
|
|
||||||
|
Ok((oid, buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod rng {
|
||||||
|
use crate::env;
|
||||||
|
use rand::{rngs::StdRng, SeedableRng};
|
||||||
|
|
||||||
|
/// Get the "standard" CSPRNG, seeded from OS entropy.
|
||||||
|
/// The seed can be overwritten in debug mode with the `RAD_SEED` environment variable.
|
||||||
|
pub fn std() -> StdRng {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if let Some(seed) = env::seed() {
|
||||||
|
return StdRng::from_seed(seed);
|
||||||
|
}
|
||||||
|
StdRng::from_entropy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::ops::{ControlFlow, Deref};
|
use std::ops::{ControlFlow, Deref};
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::cob::common::clock;
|
||||||
use crate::cob::op::{Op, Ops};
|
use crate::cob::op::{Op, Ops};
|
||||||
use crate::cob::store::encoding;
|
use crate::cob::store::encoding;
|
||||||
use crate::cob::History;
|
use crate::cob::{EntryBlob, History};
|
||||||
|
use crate::crypto::{PublicKey, Signer};
|
||||||
use crate::git::Oid;
|
use crate::git::Oid;
|
||||||
use crate::test::arbitrary;
|
use crate::test::arbitrary;
|
||||||
|
|
||||||
|
|
@ -28,14 +30,17 @@ where
|
||||||
pub fn new(op: &Op<T::Action>) -> HistoryBuilder<T> {
|
pub fn new(op: &Op<T::Action>) -> HistoryBuilder<T> {
|
||||||
let entry = arbitrary::oid();
|
let entry = arbitrary::oid();
|
||||||
let resource = arbitrary::oid();
|
let resource = arbitrary::oid();
|
||||||
let contents = encoding::encode(&op.action).unwrap();
|
let (id, data) = encoding::encode(&op.action, op.nonce).unwrap();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
history: History::new_from_root(
|
history: History::new_from_root(
|
||||||
entry,
|
entry,
|
||||||
op.author,
|
op.author,
|
||||||
resource,
|
resource,
|
||||||
NonEmpty::new(contents),
|
NonEmpty::new(EntryBlob {
|
||||||
|
oid: id.into(),
|
||||||
|
data,
|
||||||
|
}),
|
||||||
op.timestamp.as_secs(),
|
op.timestamp.as_secs(),
|
||||||
),
|
),
|
||||||
resource,
|
resource,
|
||||||
|
|
@ -44,11 +49,16 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn append(&mut self, op: &Op<T::Action>) -> &mut Self {
|
pub fn append(&mut self, op: &Op<T::Action>) -> &mut Self {
|
||||||
|
let (id, data) = encoding::encode(&op.action, op.nonce).unwrap();
|
||||||
|
|
||||||
self.history.extend(
|
self.history.extend(
|
||||||
arbitrary::oid(),
|
arbitrary::oid(),
|
||||||
op.author,
|
op.author,
|
||||||
self.resource,
|
self.resource,
|
||||||
NonEmpty::new(encoding::encode(&op.action).unwrap()),
|
NonEmpty::new(EntryBlob {
|
||||||
|
oid: id.into(),
|
||||||
|
data,
|
||||||
|
}),
|
||||||
op.timestamp.as_secs(),
|
op.timestamp.as_secs(),
|
||||||
);
|
);
|
||||||
self
|
self
|
||||||
|
|
@ -95,3 +105,69 @@ where
|
||||||
{
|
{
|
||||||
HistoryBuilder::new(op)
|
HistoryBuilder::new(op)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An object that can be used to create and sign operations.
|
||||||
|
pub struct Actor<G, A> {
|
||||||
|
pub signer: G,
|
||||||
|
pub clock: clock::Lamport,
|
||||||
|
pub ops: BTreeMap<(clock::Lamport, PublicKey), Op<A>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G: Default, A> Default for Actor<G, A> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(G::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G, A> Actor<G, A> {
|
||||||
|
pub fn new(signer: G) -> Self {
|
||||||
|
Self {
|
||||||
|
signer,
|
||||||
|
clock: clock::Lamport::default(),
|
||||||
|
ops: BTreeMap::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G: Signer, A: Clone + Serialize> Actor<G, A> {
|
||||||
|
pub fn receive(&mut self, ops: impl IntoIterator<Item = Op<A>>) -> clock::Lamport {
|
||||||
|
for op in ops {
|
||||||
|
let clock = op.clock;
|
||||||
|
|
||||||
|
self.ops.insert((clock, op.author), op);
|
||||||
|
self.clock.merge(clock);
|
||||||
|
}
|
||||||
|
self.clock
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset actor state to initial state.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.ops.clear();
|
||||||
|
self.clock = clock::Lamport::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned an ordered list of events.
|
||||||
|
pub fn timeline(&self) -> impl Iterator<Item = &Op<A>> {
|
||||||
|
self.ops.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new operation.
|
||||||
|
pub fn op(&mut self, action: A) -> Op<A> {
|
||||||
|
let author = *self.signer.public_key();
|
||||||
|
let clock = self.clock.tick();
|
||||||
|
let timestamp = clock::Physical::now();
|
||||||
|
let nonce = fastrand::u64(..);
|
||||||
|
let (id, _) = encoding::encode(&action, nonce).unwrap();
|
||||||
|
let op = Op {
|
||||||
|
id,
|
||||||
|
action,
|
||||||
|
nonce,
|
||||||
|
author,
|
||||||
|
clock,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
self.ops.insert((self.clock, author), op.clone());
|
||||||
|
|
||||||
|
op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
@ -10,10 +9,9 @@ use thiserror::Error;
|
||||||
use crate::cob;
|
use crate::cob;
|
||||||
use crate::cob::common::{Reaction, Timestamp};
|
use crate::cob::common::{Reaction, Timestamp};
|
||||||
use crate::cob::{ActorId, Op, OpId};
|
use crate::cob::{ActorId, Op, OpId};
|
||||||
use crate::crypto::Signer;
|
|
||||||
|
|
||||||
use crdt::clock::Lamport;
|
use crdt::clock::Lamport;
|
||||||
use crdt::{GMap, LWWSet, Max, Redactable, Semilattice};
|
use crdt::{GMap, GSet, LWWSet, Max, Redactable, Semilattice};
|
||||||
|
|
||||||
/// Type name of a thread, as well as the domain for all thread operations.
|
/// Type name of a thread, as well as the domain for all thread operations.
|
||||||
/// Note that threads are not usually used standalone. They are embeded into other COBs.
|
/// Note that threads are not usually used standalone. They are embeded into other COBs.
|
||||||
|
|
@ -167,12 +165,15 @@ pub struct Thread {
|
||||||
comments: GMap<CommentId, Redactable<Comment>>,
|
comments: GMap<CommentId, Redactable<Comment>>,
|
||||||
/// Reactions to changes.
|
/// Reactions to changes.
|
||||||
reactions: GMap<CommentId, LWWSet<(ActorId, Reaction), Lamport>>,
|
reactions: GMap<CommentId, LWWSet<(ActorId, Reaction), Lamport>>,
|
||||||
|
/// Comment timeline.
|
||||||
|
timeline: GSet<(Lamport, OpId)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Semilattice for Thread {
|
impl Semilattice for Thread {
|
||||||
fn merge(&mut self, other: Self) {
|
fn merge(&mut self, other: Self) {
|
||||||
self.comments.merge(other.comments);
|
self.comments.merge(other.comments);
|
||||||
self.reactions.merge(other.reactions);
|
self.reactions.merge(other.reactions);
|
||||||
|
self.timeline.merge(other.timeline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,6 +182,7 @@ impl Thread {
|
||||||
Self {
|
Self {
|
||||||
comments: GMap::singleton(id, Redactable::Present(comment)),
|
comments: GMap::singleton(id, Redactable::Present(comment)),
|
||||||
reactions: GMap::default(),
|
reactions: GMap::default(),
|
||||||
|
timeline: GSet::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,6 +206,10 @@ impl Thread {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn root(&self) -> (&CommentId, &Comment) {
|
||||||
|
self.first().expect("Thread::root: thread is empty")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn first(&self) -> Option<(&CommentId, &Comment)> {
|
pub fn first(&self) -> Option<(&CommentId, &Comment)> {
|
||||||
self.comments().next()
|
self.comments().next()
|
||||||
}
|
}
|
||||||
|
|
@ -238,9 +244,12 @@ impl Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &Comment)> + '_ {
|
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &Comment)> + '_ {
|
||||||
self.comments
|
self.timeline.iter().filter_map(|(_, id)| {
|
||||||
.iter()
|
self.comments
|
||||||
.filter_map(|(id, comment)| comment.get().map(|comment| (id, comment)))
|
.get(id)
|
||||||
|
.and_then(Redactable::get)
|
||||||
|
.map(|comment| (id, comment))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,12 +263,20 @@ impl cob::store::FromHistory for Thread {
|
||||||
|
|
||||||
fn apply(&mut self, ops: impl IntoIterator<Item = Op<Action>>) -> Result<(), OpError> {
|
fn apply(&mut self, ops: impl IntoIterator<Item = Op<Action>>) -> Result<(), OpError> {
|
||||||
for op in ops.into_iter() {
|
for op in ops.into_iter() {
|
||||||
let id = op.id();
|
let id = op.id;
|
||||||
let author = op.author;
|
let author = op.author;
|
||||||
let timestamp = op.timestamp;
|
let timestamp = op.timestamp;
|
||||||
|
|
||||||
|
self.timeline.insert((op.clock, op.id));
|
||||||
|
|
||||||
match op.action {
|
match op.action {
|
||||||
Action::Comment { body, reply_to } => {
|
Action::Comment { body, reply_to } => {
|
||||||
|
// Since comments are keyed by content hash, we shouldn't re-insert a comment
|
||||||
|
// if it already exists, otherwise this will be resolved via the `merge`
|
||||||
|
// operation of `Redactable`.
|
||||||
|
if self.comments.contains_key(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
self.comments.insert(
|
self.comments.insert(
|
||||||
id,
|
id,
|
||||||
Redactable::Present(Comment::new(author, body, reply_to, timestamp)),
|
Redactable::Present(Comment::new(author, body, reply_to, timestamp)),
|
||||||
|
|
@ -296,73 +313,12 @@ impl cob::store::FromHistory for Thread {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An object that can be used to create and sign changes.
|
|
||||||
pub struct Actor<G> {
|
|
||||||
inner: cob::Actor<G, Action>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<G: Default + Signer> Default for Actor<G> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
inner: cob::Actor::new(G::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<G: Signer> Actor<G> {
|
|
||||||
pub fn new(signer: G) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: cob::Actor::new(signer),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new thread.
|
|
||||||
pub fn thread(&self) -> Thread {
|
|
||||||
Thread::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new comment.
|
|
||||||
pub fn comment(&mut self, body: &str, reply_to: Option<OpId>) -> Op<Action> {
|
|
||||||
self.op(Action::Comment {
|
|
||||||
body: String::from(body),
|
|
||||||
reply_to,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new redaction.
|
|
||||||
pub fn redact(&mut self, id: OpId) -> Op<Action> {
|
|
||||||
self.op(Action::Redact { id })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Edit a comment.
|
|
||||||
pub fn edit(&mut self, id: OpId, body: &str) -> Op<Action> {
|
|
||||||
self.op(Action::Edit {
|
|
||||||
id,
|
|
||||||
body: body.to_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<G> Deref for Actor<G> {
|
|
||||||
type Target = cob::Actor<G, Action>;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<G> DerefMut for Actor<G> {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::{array, iter};
|
use std::{array, iter};
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use qcheck::{Arbitrary, TestResult};
|
use qcheck::{Arbitrary, TestResult};
|
||||||
|
|
||||||
|
|
@ -373,6 +329,72 @@ mod tests {
|
||||||
use crate::cob::store::FromHistory;
|
use crate::cob::store::FromHistory;
|
||||||
use crate::cob::test;
|
use crate::cob::test;
|
||||||
use crate::crypto::test::signer::MockSigner;
|
use crate::crypto::test::signer::MockSigner;
|
||||||
|
use crate::crypto::Signer;
|
||||||
|
|
||||||
|
/// An object that can be used to create and sign changes.
|
||||||
|
pub struct Actor<G> {
|
||||||
|
inner: cob::test::Actor<G, Action>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G: Default + Signer> Default for Actor<G> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: cob::test::Actor::new(G::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G: Signer> Actor<G> {
|
||||||
|
pub fn new(signer: G) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: cob::test::Actor::new(signer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new comment.
|
||||||
|
pub fn comment(&mut self, body: &str, reply_to: Option<OpId>) -> Op<Action> {
|
||||||
|
self.op(Action::Comment {
|
||||||
|
body: String::from(body),
|
||||||
|
reply_to,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new redaction.
|
||||||
|
pub fn redact(&mut self, id: OpId) -> Op<Action> {
|
||||||
|
self.op(Action::Redact { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit a comment.
|
||||||
|
pub fn edit(&mut self, id: OpId, body: &str) -> Op<Action> {
|
||||||
|
self.op(Action::Edit {
|
||||||
|
id,
|
||||||
|
body: body.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// React to a comment.
|
||||||
|
pub fn react(&mut self, to: OpId, reaction: Reaction, active: bool) -> Op<Action> {
|
||||||
|
self.op(Action::React {
|
||||||
|
to,
|
||||||
|
reaction,
|
||||||
|
active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G> Deref for Actor<G> {
|
||||||
|
type Target = cob::test::Actor<G, Action>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<G> DerefMut for Actor<G> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Changes<const N: usize> {
|
struct Changes<const N: usize> {
|
||||||
|
|
@ -394,75 +416,63 @@ mod tests {
|
||||||
|
|
||||||
impl<const N: usize> Arbitrary for Changes<N> {
|
impl<const N: usize> Arbitrary for Changes<N> {
|
||||||
fn arbitrary(g: &mut qcheck::Gen) -> Self {
|
fn arbitrary(g: &mut qcheck::Gen) -> Self {
|
||||||
let author = ActorId::from([0; 32]);
|
|
||||||
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
|
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
|
||||||
let root = OpId::initial(author);
|
let gen = WeightedGenerator::<
|
||||||
let gen =
|
(Lamport, Op<Action>),
|
||||||
WeightedGenerator::<(Lamport, Action), (Lamport, BTreeSet<OpId>)>::new(rng.clone())
|
(Actor<MockSigner>, Lamport, BTreeSet<OpId>),
|
||||||
.variant(3, |(clock, comments), rng| {
|
>::new(rng.clone())
|
||||||
comments.insert(OpId::new(clock.tick(), author));
|
.variant(3, |(actor, clock, comments), rng| {
|
||||||
|
let comment = actor.comment(
|
||||||
|
iter::repeat_with(|| rng.alphabetic())
|
||||||
|
.take(4)
|
||||||
|
.collect::<String>()
|
||||||
|
.as_str(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
comments.insert(comment.id);
|
||||||
|
|
||||||
Some((
|
Some((*clock, comment))
|
||||||
*clock,
|
})
|
||||||
Action::Comment {
|
.variant(2, |(actor, clock, comments), rng| {
|
||||||
body: iter::repeat_with(|| rng.alphabetic()).take(16).collect(),
|
if comments.is_empty() {
|
||||||
reply_to: Some(root),
|
return None;
|
||||||
},
|
}
|
||||||
))
|
let id = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
||||||
})
|
let edit = actor.edit(
|
||||||
.variant(2, |(clock, comments), rng| {
|
id,
|
||||||
if comments.is_empty() {
|
iter::repeat_with(|| rng.alphabetic())
|
||||||
return None;
|
.take(4)
|
||||||
}
|
.collect::<String>()
|
||||||
let id = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
.as_str(),
|
||||||
|
);
|
||||||
|
|
||||||
Some((
|
Some((*clock, edit))
|
||||||
*clock,
|
})
|
||||||
Action::Edit {
|
.variant(2, |(actor, clock, comments), rng| {
|
||||||
id,
|
if comments.is_empty() {
|
||||||
body: iter::repeat_with(|| rng.alphabetic()).take(16).collect(),
|
return None;
|
||||||
},
|
}
|
||||||
))
|
let to = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
||||||
})
|
let react = actor.react(to, Reaction::new('✨').unwrap(), rng.bool());
|
||||||
.variant(2, |(clock, comments), rng| {
|
|
||||||
if comments.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let to = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
|
||||||
|
|
||||||
Some((
|
Some((clock.tick(), react))
|
||||||
clock.tick(),
|
})
|
||||||
Action::React {
|
.variant(2, |(actor, clock, comments), rng| {
|
||||||
to,
|
if comments.is_empty() {
|
||||||
reaction: Reaction::new('✨').unwrap(),
|
return None;
|
||||||
active: rng.bool(),
|
}
|
||||||
},
|
let id = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
||||||
))
|
comments.remove(&id);
|
||||||
})
|
let redact = actor.redact(id);
|
||||||
.variant(2, |(clock, comments), rng| {
|
|
||||||
if comments.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
|
||||||
comments.remove(&id);
|
|
||||||
|
|
||||||
Some((clock.tick(), Action::Redact { id }))
|
Some((clock.tick(), redact))
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut ops = vec![Op::new(
|
let mut ops = vec![Actor::<MockSigner>::default().comment("", None)];
|
||||||
Action::Comment {
|
|
||||||
body: String::default(),
|
|
||||||
reply_to: None,
|
|
||||||
},
|
|
||||||
author,
|
|
||||||
Timestamp::now(),
|
|
||||||
Lamport::initial(),
|
|
||||||
)];
|
|
||||||
let mut permutations: [Vec<Op<Action>>; N] = array::from_fn(|_| Vec::new());
|
let mut permutations: [Vec<Op<Action>>; N] = array::from_fn(|_| Vec::new());
|
||||||
|
|
||||||
for (clock, action) in gen.take(g.size()) {
|
for (_, op) in gen.take(g.size()) {
|
||||||
let timestamp = Timestamp::now() + rng.u64(..60);
|
ops.push(op);
|
||||||
ops.push(Op::new(action, author, timestamp, clock));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for p in &mut permutations {
|
for p in &mut permutations {
|
||||||
|
|
@ -474,19 +484,6 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod setup {
|
|
||||||
use super::*;
|
|
||||||
use crate::storage::git as storage;
|
|
||||||
use cob::store::Store;
|
|
||||||
|
|
||||||
pub fn store<'a, G: Signer>(
|
|
||||||
signer: &G,
|
|
||||||
repo: &'a storage::Repository,
|
|
||||||
) -> Store<'a, Thread> {
|
|
||||||
Store::<Thread>::open(*signer.public_key(), repo).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_redact_comment() {
|
fn test_redact_comment() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -538,36 +535,6 @@ mod tests {
|
||||||
assert_eq!(t1, t2);
|
assert_eq!(t1, t2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_storage() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let (_, signer, repository) = radicle::test::setup::context(&tmp);
|
|
||||||
let store = setup::store(&signer, &repository);
|
|
||||||
let mut alice = Actor::new(signer);
|
|
||||||
|
|
||||||
let a0 = alice.comment("Thread root", None);
|
|
||||||
let a1 = alice.comment("First comment", Some(a0.id()));
|
|
||||||
let a2 = alice.comment("Second comment", Some(a0.id()));
|
|
||||||
|
|
||||||
let mut expected = Thread::default();
|
|
||||||
expected
|
|
||||||
.apply([a0.clone(), a1.clone(), a2.clone()])
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (id, _, _) = store
|
|
||||||
.create("Thread created", a0.action, &alice.signer)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let actions = NonEmpty::from_vec(vec![a1.action, a2.action]).unwrap();
|
|
||||||
store
|
|
||||||
.update(id, "Thread updated", actions, &alice.signer)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (actual, _) = store.get(&id).unwrap().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(actual, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_timelines_basic() {
|
fn test_timelines_basic() {
|
||||||
let mut alice = Actor::<MockSigner>::default();
|
let mut alice = Actor::<MockSigner>::default();
|
||||||
|
|
@ -667,6 +634,84 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_comments() {
|
||||||
|
let mut alice = Actor::<MockSigner>::default();
|
||||||
|
let mut bob = Actor::<MockSigner>::default();
|
||||||
|
|
||||||
|
let a0 = alice.comment("Hello World!", None);
|
||||||
|
let b0 = bob.comment("Hello World!", None);
|
||||||
|
|
||||||
|
let mut a = test::history::<Thread>(&a0);
|
||||||
|
let mut b = a.clone();
|
||||||
|
|
||||||
|
b.append(&b0);
|
||||||
|
a.merge(b);
|
||||||
|
|
||||||
|
let (thread, _) = Thread::from_history(&a).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(thread.comments().count(), 2);
|
||||||
|
|
||||||
|
let (first_id, first) = thread.comments().nth(0).unwrap();
|
||||||
|
let (second_id, second) = thread.comments().nth(1).unwrap();
|
||||||
|
|
||||||
|
assert!(first_id != second_id); // The ids are not the same,
|
||||||
|
assert_eq!(first.edits, second.edits); // despite the content being the same.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_comments_same_author() {
|
||||||
|
let mut alice = Actor::<MockSigner>::default();
|
||||||
|
|
||||||
|
let a0 = alice.comment("Hello World!", None);
|
||||||
|
let a1 = alice.comment("Hello World!", None);
|
||||||
|
let a2 = alice.comment("Hello World!", None);
|
||||||
|
|
||||||
|
// These simulate two devices sharing the same key.
|
||||||
|
let mut h1 = test::history::<Thread>(&a0);
|
||||||
|
let mut h2 = h1.clone();
|
||||||
|
let mut h3 = h1.clone();
|
||||||
|
|
||||||
|
// Alice writes the same comment on both devices, not realizing what she has done.
|
||||||
|
h1.append(&a1);
|
||||||
|
h2.append(&a2);
|
||||||
|
|
||||||
|
// Eventually the histories are merged by a third party.
|
||||||
|
h3.merge(h1);
|
||||||
|
h3.merge(h2);
|
||||||
|
|
||||||
|
let (thread, _) = Thread::from_history(&h3).unwrap();
|
||||||
|
|
||||||
|
// The three comments, distinct yet identical in terms of content, are preserved.
|
||||||
|
assert_eq!(thread.comments().count(), 3);
|
||||||
|
|
||||||
|
let (first_id, first) = thread.comments().nth(0).unwrap();
|
||||||
|
let (second_id, second) = thread.comments().nth(1).unwrap();
|
||||||
|
let (third_id, third) = thread.comments().nth(2).unwrap();
|
||||||
|
|
||||||
|
// Their IDs are not the same.
|
||||||
|
assert!(first_id != second_id);
|
||||||
|
assert!(second_id != third_id);
|
||||||
|
// Their content are the same.
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert_eq!(second, third);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_comment_edit_reinsert() {
|
||||||
|
let mut alice = Actor::<MockSigner>::default();
|
||||||
|
let mut t1 = Thread::default();
|
||||||
|
let mut t2 = Thread::default();
|
||||||
|
|
||||||
|
let a1 = alice.comment("Hello.", None);
|
||||||
|
let a2 = alice.edit(a1.id(), "Hello World.");
|
||||||
|
|
||||||
|
t1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
|
||||||
|
t2.apply([a1.clone(), a1, a2]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(t1, t2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prop_invariants() {
|
fn prop_invariants() {
|
||||||
fn property(log: Changes<3>) -> TestResult {
|
fn property(log: Changes<3>) -> TestResult {
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,7 @@ pub mod prelude {
|
||||||
pub use profile::Profile;
|
pub use profile::Profile;
|
||||||
pub use storage::{BranchName, ReadRepository, ReadStorage, WriteRepository, WriteStorage};
|
pub use storage::{BranchName, ReadRepository, ReadStorage, WriteRepository, WriteStorage};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod env {
|
||||||
|
pub use crypto::env::*;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue