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}