example_test.go

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