example_test.go

  1package dag_test
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"os"
  7	"time"
  8
  9	"github.com/MichaelMure/git-bug/entities/identity"
 10	"github.com/MichaelMure/git-bug/entity"
 11	"github.com/MichaelMure/git-bug/entity/dag"
 12	"github.com/MichaelMure/git-bug/repository"
 13)
 14
 15// Note: you can find explanations about the underlying data model here:
 16// https://github.com/MichaelMure/git-bug/blob/master/doc/model.md
 17
 18// This file explains how to define a replicated data structure, stored in and using git as a medium for
 19// synchronisation. To do this, we'll use the entity/dag package, which will do all the complex handling.
 20//
 21// The example we'll use here is a small shared configuration with two fields. One of them is special as
 22// it also defines who is allowed to change said configuration.
 23// Note: this example is voluntarily a bit complex with operation linking to identities and logic rules,
 24// to show that how something more complex than a toy would look like. That said, it's still a simplified
 25// example: in git-bug for example, more layers are added for caching, memory handling and to provide an
 26// easier to use API.
 27//
 28// Let's start by defining the document/structure we are going to share:
 29
 30// Snapshot is the compiled view of a ProjectConfig
 31type Snapshot struct {
 32	// Administrator is the set of users with the higher level of access
 33	Administrator map[entity.Identity]struct{}
 34	// SignatureRequired indicate that all git commit need to be signed
 35	SignatureRequired bool
 36}
 37
 38// HasAdministrator returns true if the given identity is included in the administrator.
 39func (snap *Snapshot) HasAdministrator(i entity.Identity) bool {
 40	for admin, _ := range snap.Administrator {
 41		if admin.Id() == i.Id() {
 42			return true
 43		}
 44	}
 45	return false
 46}
 47
 48// Now, we will not edit this configuration directly. Instead, we are going to apply "operations" on it.
 49// Those are the ones that will be stored and shared. Doing things that way allow merging concurrent editing
 50// and deal with conflict.
 51//
 52// Here, we will define three operations:
 53// - SetSignatureRequired is a simple operation that set or unset the SignatureRequired boolean
 54// - AddAdministrator is more complex and add a new administrator in the Administrator set
 55// - RemoveAdministrator is the counterpart the remove administrators
 56//
 57// Note: there is some amount of boilerplate for operations. In a real project, some of that can be
 58// factorized and simplified.
 59
 60// Operation is the operation interface acting on Snapshot
 61type Operation interface {
 62	dag.Operation
 63
 64	// Apply the operation to a Snapshot to create the final state
 65	Apply(snapshot *Snapshot)
 66}
 67
 68const (
 69	_ entity.OperationType = iota
 70	SetSignatureRequiredOp
 71	AddAdministratorOp
 72	RemoveAdministratorOp
 73)
 74
 75// SetSignatureRequired is an operation to set/unset if git signature are required.
 76type SetSignatureRequired struct {
 77	dag.OpBase
 78	Value bool `json:"value"`
 79}
 80
 81func NewSetSignatureRequired(author entity.Identity, value bool) *SetSignatureRequired {
 82	return &SetSignatureRequired{
 83		OpBase: dag.NewOpBase(SetSignatureRequiredOp, author, time.Now().Unix()),
 84		Value:  value,
 85	}
 86}
 87
 88func (ssr *SetSignatureRequired) Id() entity.Id {
 89	// the Id of the operation is the hash of the serialized data.
 90	return dag.IdOperation(ssr, &ssr.OpBase)
 91}
 92
 93func (ssr *SetSignatureRequired) Validate() error {
 94	return ssr.OpBase.Validate(ssr, SetSignatureRequiredOp)
 95}
 96
 97// Apply is the function that makes changes on the snapshot
 98func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) {
 99	// check that we are allowed to change the config
100	if _, ok := snapshot.Administrator[ssr.Author()]; !ok {
101		return
102	}
103	snapshot.SignatureRequired = ssr.Value
104}
105
106// AddAdministrator is an operation to add a new administrator in the set
107type AddAdministrator struct {
108	dag.OpBase
109	ToAdd []entity.Identity `json:"to_add"`
110}
111
112func NewAddAdministratorOp(author entity.Identity, toAdd ...entity.Identity) *AddAdministrator {
113	return &AddAdministrator{
114		OpBase: dag.NewOpBase(AddAdministratorOp, author, time.Now().Unix()),
115		ToAdd:  toAdd,
116	}
117}
118
119func (aa *AddAdministrator) Id() entity.Id {
120	// the Id of the operation is the hash of the serialized data.
121	return dag.IdOperation(aa, &aa.OpBase)
122}
123
124func (aa *AddAdministrator) Validate() error {
125	// Let's enforce an arbitrary rule
126	if len(aa.ToAdd) == 0 {
127		return fmt.Errorf("nothing to add")
128	}
129	return aa.OpBase.Validate(aa, AddAdministratorOp)
130}
131
132// Apply is the function that makes changes on the snapshot
133func (aa *AddAdministrator) Apply(snapshot *Snapshot) {
134	// check that we are allowed to change the config ... or if there is no admin yet
135	if !snapshot.HasAdministrator(aa.Author()) && len(snapshot.Administrator) != 0 {
136		return
137	}
138	for _, toAdd := range aa.ToAdd {
139		snapshot.Administrator[toAdd] = struct{}{}
140	}
141}
142
143// RemoveAdministrator is an operation to remove an administrator from the set
144type RemoveAdministrator struct {
145	dag.OpBase
146	ToRemove []entity.Identity `json:"to_remove"`
147}
148
149func NewRemoveAdministratorOp(author entity.Identity, toRemove ...entity.Identity) *RemoveAdministrator {
150	return &RemoveAdministrator{
151		OpBase:   dag.NewOpBase(RemoveAdministratorOp, author, time.Now().Unix()),
152		ToRemove: toRemove,
153	}
154}
155
156func (ra *RemoveAdministrator) Id() entity.Id {
157	// the Id of the operation is the hash of the serialized data.
158	return dag.IdOperation(ra, &ra.OpBase)
159}
160
161func (ra *RemoveAdministrator) Validate() error {
162	// Let's enforce some rules. If we return an error, this operation will be
163	// considered invalid and will not be included in our data.
164	if len(ra.ToRemove) == 0 {
165		return fmt.Errorf("nothing to remove")
166	}
167	return ra.OpBase.Validate(ra, RemoveAdministratorOp)
168}
169
170// Apply is the function that makes changes on the snapshot
171func (ra *RemoveAdministrator) Apply(snapshot *Snapshot) {
172	// check if we are allowed to make changes
173	if !snapshot.HasAdministrator(ra.Author()) {
174		return
175	}
176	// special rule: we can't end up with no administrator
177	stillSome := false
178	for admin, _ := range snapshot.Administrator {
179		if admin != ra.Author() {
180			stillSome = true
181			break
182		}
183	}
184	if !stillSome {
185		return
186	}
187	// apply
188	for _, toRemove := range ra.ToRemove {
189		delete(snapshot.Administrator, toRemove)
190	}
191}
192
193// Now, let's create the main object (the entity) we are going to manipulate: ProjectConfig.
194// This object wrap a dag.Entity, which makes it inherit some methods and provide all the complex
195// DAG handling. Additionally, ProjectConfig is the place where we can add functions specific for that type.
196
197type ProjectConfig struct {
198	// this is really all we need
199	*dag.Entity
200}
201
202func NewProjectConfig() *ProjectConfig {
203	return wrapper(dag.New(def))
204}
205
206func wrapper(e *dag.Entity) *ProjectConfig {
207	return &ProjectConfig{Entity: e}
208}
209
210// a Definition describes a few properties of the Entity, a sort of configuration to manipulate the
211// DAG of operations
212var def = dag.Definition{
213	Typename:             "project config",
214	Namespace:            "conf",
215	OperationUnmarshaler: operationUnmarshaler,
216	FormatVersion:        1,
217}
218
219// operationUnmarshaler is a function doing the de-serialization of the JSON data into our own
220// concrete Operations. If needed, we can use the resolver to connect to other entities.
221func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) {
222	var t struct {
223		OperationType entity.OperationType `json:"type"`
224	}
225
226	if err := json.Unmarshal(raw, &t); err != nil {
227		return nil, err
228	}
229
230	var op dag.Operation
231
232	switch t.OperationType {
233	case AddAdministratorOp:
234		op = &AddAdministrator{}
235	case RemoveAdministratorOp:
236		op = &RemoveAdministrator{}
237	case SetSignatureRequiredOp:
238		op = &SetSignatureRequired{}
239	default:
240		panic(fmt.Sprintf("unknown operation type %v", t.OperationType))
241	}
242
243	err := json.Unmarshal(raw, &op)
244	if err != nil {
245		return nil, err
246	}
247
248	switch op := op.(type) {
249	case *AddAdministrator:
250		// We need to resolve identities
251		for i, stub := range op.ToAdd {
252			iden, err := entity.Resolve[entity.Identity](resolvers, stub.Id())
253			if err != nil {
254				return nil, err
255			}
256			op.ToAdd[i] = iden
257		}
258	case *RemoveAdministrator:
259		// We need to resolve identities
260		for i, stub := range op.ToRemove {
261			iden, err := entity.Resolve[entity.Identity](resolvers, stub.Id())
262			if err != nil {
263				return nil, err
264			}
265			op.ToRemove[i] = iden
266		}
267	}
268
269	return op, nil
270}
271
272// Compile compute a view of the final state. This is what we would use to display the state
273// in a user interface.
274func (pc ProjectConfig) Compile() *Snapshot {
275	// Note: this would benefit from caching, but it's a simple example
276	snap := &Snapshot{
277		// default value
278		Administrator:     make(map[entity.Identity]struct{}),
279		SignatureRequired: false,
280	}
281	for _, op := range pc.Operations() {
282		op.(Operation).Apply(snap)
283	}
284	return snap
285}
286
287// Read is a helper to load a ProjectConfig from a Repository
288func Read(repo repository.ClockedRepo, id entity.Id) (*ProjectConfig, error) {
289	return dag.Read(def, wrapper, repo, simpleResolvers(repo), id)
290}
291
292func simpleResolvers(repo repository.ClockedRepo) entity.Resolvers {
293	// resolvers can look a bit complex or out of place here, but it's an important concept
294	// to allow caching and flexibility when constructing the final app.
295	return entity.Resolvers{
296		&identity.Identity{}: identity.NewSimpleResolver(repo),
297	}
298}
299
300func Example_entity() {
301	const gitBugNamespace = "git-bug"
302	// Note: this example ignore errors for readability
303	// Note: variable names get a little confusing as we are simulating both side in the same function
304
305	// Let's start by defining two git repository and connecting them as remote
306	repoRenePath, _ := os.MkdirTemp("", "")
307	repoIsaacPath, _ := os.MkdirTemp("", "")
308	repoRene, _ := repository.InitGoGitRepo(repoRenePath, gitBugNamespace)
309	defer repoRene.Close()
310	repoIsaac, _ := repository.InitGoGitRepo(repoIsaacPath, gitBugNamespace)
311	defer repoIsaac.Close()
312	_ = repoRene.AddRemote("origin", repoIsaacPath)
313	_ = repoIsaac.AddRemote("origin", repoRenePath)
314
315	// Now we need identities and to propagate them
316	rene, _ := identity.NewIdentity(repoRene, "René Descartes", "rene@descartes.fr")
317	isaac, _ := identity.NewIdentity(repoRene, "Isaac Newton", "isaac@newton.uk")
318	_ = rene.Commit(repoRene)
319	_ = isaac.Commit(repoRene)
320	_ = identity.Pull(repoIsaac, "origin")
321
322	// create a new entity
323	confRene := NewProjectConfig()
324
325	// add some operations
326	confRene.Append(NewAddAdministratorOp(rene, rene))
327	confRene.Append(NewAddAdministratorOp(rene, isaac))
328	confRene.Append(NewSetSignatureRequired(rene, true))
329
330	// Rene commits on its own repo
331	_ = confRene.Commit(repoRene)
332
333	// Isaac pull and read the config
334	_ = dag.Pull(def, wrapper, repoIsaac, simpleResolvers(repoIsaac), "origin", isaac)
335	confIsaac, _ := Read(repoIsaac, confRene.Id())
336
337	// Compile gives the current state of the config
338	snapshot := confIsaac.Compile()
339	for admin, _ := range snapshot.Administrator {
340		fmt.Println(admin.DisplayName())
341	}
342
343	// Isaac add more operations
344	confIsaac.Append(NewSetSignatureRequired(isaac, false))
345	reneFromIsaacRepo, _ := identity.ReadLocal(repoIsaac, rene.Id())
346	confIsaac.Append(NewRemoveAdministratorOp(isaac, reneFromIsaacRepo))
347	_ = confIsaac.Commit(repoIsaac)
348}