package dag_test import ( "encoding/json" "fmt" "os" "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/entity/dag" "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" ) // This file explains how to define a replicated data structure, stored and using git as a medium for // synchronisation. To do this, we'll use the entity/dag package, which will do all the complex handling. // // The example we'll use here is a small shared configuration with two fields. One of them is special as // it also defines who is allowed to change said configuration. Note: this example is voluntarily a bit // complex with operation linking to identities and logic rules, to show that how something more complex // than a toy would look like. That said, it's still a simplified example: in git-bug for example, more // layers are added for caching, memory handling and to provide an easier to use API. // // Let's start by defining the document/structure we are going to share: // Snapshot is the compiled view of a ProjectConfig type Snapshot struct { // Administrator is the set of users with the higher level of access Administrator map[identity.Interface]struct{} // SignatureRequired indicate that all git commit need to be signed SignatureRequired bool } // HasAdministrator returns true if the given identity is included in the administrator. func (snap *Snapshot) HasAdministrator(i identity.Interface) bool { for admin, _ := range snap.Administrator { if admin.Id() == i.Id() { return true } } return false } // Now, we will not edit this configuration directly. Instead, we are going to apply "operations" on it. // Those are the ones that will be stored and shared. Doing things that way allow merging concurrent editing // and deal with conflict. // // Here, we will define three operations: // - SetSignatureRequired is a simple operation that set or unset the SignatureRequired boolean // - AddAdministrator is more complex and add a new administrator in the Administrator set // - RemoveAdministrator is the counterpart the remove administrators // // Note: there is some amount of boilerplate for operations. In a real project, some of that can be // factorized and simplified. // Operation is the operation interface acting on Snapshot type Operation interface { dag.Operation // Apply the operation to a Snapshot to create the final state Apply(snapshot *Snapshot) } type OperationType int const ( _ OperationType = iota SetSignatureRequiredOp AddAdministratorOp RemoveAdministratorOp ) // SetSignatureRequired is an operation to set/unset if git signature are required. type SetSignatureRequired struct { author identity.Interface OperationType OperationType `json:"type"` Value bool `json:"value"` } func NewSetSignatureRequired(author identity.Interface, value bool) *SetSignatureRequired { return &SetSignatureRequired{author: author, OperationType: SetSignatureRequiredOp, Value: value} } func (ssr *SetSignatureRequired) Id() entity.Id { // the Id of the operation is the hash of the serialized data. // we could memorize the Id when deserializing, but that will do data, _ := json.Marshal(ssr) return entity.DeriveId(data) } func (ssr *SetSignatureRequired) Validate() error { if ssr.author == nil { return fmt.Errorf("author not set") } return ssr.author.Validate() } func (ssr *SetSignatureRequired) Author() identity.Interface { return ssr.author } // Apply is the function that makes changes on the snapshot func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) { // check that we are allowed to change the config if _, ok := snapshot.Administrator[ssr.author]; !ok { return } snapshot.SignatureRequired = ssr.Value } // AddAdministrator is an operation to add a new administrator in the set type AddAdministrator struct { author identity.Interface OperationType OperationType `json:"type"` ToAdd []identity.Interface `json:"to_add"` } // addAdministratorJson is a helper struct to deserialize identities with a concrete type. type addAdministratorJson struct { ToAdd []identity.IdentityStub `json:"to_add"` } func NewAddAdministratorOp(author identity.Interface, toAdd ...identity.Interface) *AddAdministrator { return &AddAdministrator{author: author, OperationType: AddAdministratorOp, ToAdd: toAdd} } func (aa *AddAdministrator) Id() entity.Id { // we could memorize the Id when deserializing, but that will do data, _ := json.Marshal(aa) return entity.DeriveId(data) } func (aa *AddAdministrator) Validate() error { // Let's enforce an arbitrary rule if len(aa.ToAdd) == 0 { return fmt.Errorf("nothing to add") } if aa.author == nil { return fmt.Errorf("author not set") } return aa.author.Validate() } func (aa *AddAdministrator) Author() identity.Interface { return aa.author } // Apply is the function that makes changes on the snapshot func (aa *AddAdministrator) Apply(snapshot *Snapshot) { // check that we are allowed to change the config ... or if there is no admin yet if !snapshot.HasAdministrator(aa.author) && len(snapshot.Administrator) != 0 { return } for _, toAdd := range aa.ToAdd { snapshot.Administrator[toAdd] = struct{}{} } } // RemoveAdministrator is an operation to remove an administrator from the set type RemoveAdministrator struct { author identity.Interface OperationType OperationType `json:"type"` ToRemove []identity.Interface `json:"to_remove"` } // removeAdministratorJson is a helper struct to deserialize identities with a concrete type. type removeAdministratorJson struct { ToRemove []identity.Interface `json:"to_remove"` } func NewRemoveAdministratorOp(author identity.Interface, toRemove ...identity.Interface) *RemoveAdministrator { return &RemoveAdministrator{author: author, OperationType: RemoveAdministratorOp, ToRemove: toRemove} } func (ra *RemoveAdministrator) Id() entity.Id { // the Id of the operation is the hash of the serialized data. // we could memorize the Id when deserializing, but that will do data, _ := json.Marshal(ra) return entity.DeriveId(data) } func (ra *RemoveAdministrator) Validate() error { // Let's enforce some rules. If we return an error, this operation will be // considered invalid and will not be included in our data. if len(ra.ToRemove) == 0 { return fmt.Errorf("nothing to remove") } if ra.author == nil { return fmt.Errorf("author not set") } return ra.author.Validate() } func (ra *RemoveAdministrator) Author() identity.Interface { return ra.author } // Apply is the function that makes changes on the snapshot func (ra *RemoveAdministrator) Apply(snapshot *Snapshot) { // check if we are allowed to make changes if !snapshot.HasAdministrator(ra.author) { return } // special rule: we can't end up with no administrator stillSome := false for admin, _ := range snapshot.Administrator { if admin != ra.author { stillSome = true break } } if !stillSome { return } // apply for _, toRemove := range ra.ToRemove { delete(snapshot.Administrator, toRemove) } } // Now, let's create the main object (the entity) we are going to manipulate: ProjectConfig. // This object wrap a dag.Entity, which makes it inherit some methods and provide all the complex // DAG handling. Additionally, ProjectConfig is the place where we can add functions specific for that type. type ProjectConfig struct { // this is really all we need *dag.Entity } func NewProjectConfig() *ProjectConfig { return &ProjectConfig{Entity: dag.New(def)} } // a Definition describes a few properties of the Entity, a sort of configuration to manipulate the // DAG of operations var def = dag.Definition{ Typename: "project config", Namespace: "conf", OperationUnmarshaler: operationUnmarshaller, FormatVersion: 1, } // operationUnmarshaller is a function doing the de-serialization of the JSON data into our own // concrete Operations. If needed, we can use the resolver to connect to other entities. func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) { var t struct { OperationType OperationType `json:"type"` } if err := json.Unmarshal(raw, &t); err != nil { return nil, err } var value interface{} switch t.OperationType { case AddAdministratorOp: value = &addAdministratorJson{} case RemoveAdministratorOp: value = &removeAdministratorJson{} case SetSignatureRequiredOp: value = &SetSignatureRequired{} default: panic(fmt.Sprintf("unknown operation type %v", t.OperationType)) } err := json.Unmarshal(raw, &value) if err != nil { return nil, err } var op Operation switch value := value.(type) { case *SetSignatureRequired: value.author = author op = value case *addAdministratorJson: // We need something less straightforward to deserialize and resolve identities aa := &AddAdministrator{ author: author, OperationType: AddAdministratorOp, ToAdd: make([]identity.Interface, len(value.ToAdd)), } for i, stub := range value.ToAdd { iden, err := resolver.ResolveIdentity(stub.Id()) if err != nil { return nil, err } aa.ToAdd[i] = iden } op = aa case *removeAdministratorJson: // We need something less straightforward to deserialize and resolve identities ra := &RemoveAdministrator{ author: author, OperationType: RemoveAdministratorOp, ToRemove: make([]identity.Interface, len(value.ToRemove)), } for i, stub := range value.ToRemove { iden, err := resolver.ResolveIdentity(stub.Id()) if err != nil { return nil, err } ra.ToRemove[i] = iden } op = ra default: panic(fmt.Sprintf("unknown operation type %T", value)) } return op, nil } // Compile compute a view of the final state. This is what we would use to display the state // in a user interface. func (pc ProjectConfig) Compile() *Snapshot { // Note: this would benefit from caching, but it's a simple example snap := &Snapshot{ // default value Administrator: make(map[identity.Interface]struct{}), SignatureRequired: false, } for _, op := range pc.Operations() { op.(Operation).Apply(snap) } return snap } // Read is a helper to load a ProjectConfig from a Repository func Read(repo repository.ClockedRepo, id entity.Id) (*ProjectConfig, error) { e, err := dag.Read(def, repo, identity.NewSimpleResolver(repo), id) if err != nil { return nil, err } return &ProjectConfig{Entity: e}, nil } func Example_entity() { const gitBugApplicationName = "git-bug" // Note: this example ignore errors for readability // Note: variable names get a little confusing as we are simulating both side in the same function // Let's start by defining two git repository and connecting them as remote repoRenePath, _ := os.MkdirTemp("", "") repoIsaacPath, _ := os.MkdirTemp("", "") repoRene, _ := repository.InitGoGitRepo(repoRenePath, gitBugApplicationName) repoIsaac, _ := repository.InitGoGitRepo(repoIsaacPath, gitBugApplicationName) _ = repoRene.AddRemote("origin", repoIsaacPath) _ = repoIsaac.AddRemote("origin", repoRenePath) // Now we need identities and to propagate them rene, _ := identity.NewIdentity(repoRene, "René Descartes", "rene@descartes.fr") isaac, _ := identity.NewIdentity(repoRene, "Isaac Newton", "isaac@newton.uk") _ = rene.Commit(repoRene) _ = isaac.Commit(repoRene) _ = identity.Pull(repoIsaac, "origin") // create a new entity confRene := NewProjectConfig() // add some operations confRene.Append(NewAddAdministratorOp(rene, rene)) confRene.Append(NewAddAdministratorOp(rene, isaac)) confRene.Append(NewSetSignatureRequired(rene, true)) // Rene commits on its own repo _ = confRene.Commit(repoRene) // Isaac pull and read the config _ = dag.Pull(def, repoIsaac, identity.NewSimpleResolver(repoIsaac), "origin", isaac) confIsaac, _ := Read(repoIsaac, confRene.Id()) // Compile gives the current state of the config snapshot := confIsaac.Compile() for admin, _ := range snapshot.Administrator { fmt.Println(admin.DisplayName()) } // Isaac add more operations confIsaac.Append(NewSetSignatureRequired(isaac, false)) reneFromIsaacRepo, _ := identity.ReadLocal(repoIsaac, rene.Id()) confIsaac.Append(NewRemoveAdministratorOp(isaac, reneFromIsaacRepo)) _ = confIsaac.Commit(repoIsaac) }